From 3a9dfd8f200cab92eca6fa4ced758382befe00ff Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 23 Apr 2026 13:18:27 -0500 Subject: [PATCH 01/45] Implement oxc based change detection --- .../change-detection-adapter/index.test.ts | 144 ++++ .../src/change-detection-adapter/index.ts | 69 ++ code/builders/builder-vite/src/index.ts | 25 + code/core/package.json | 2 + .../ChangeDetectionService.test.ts | 694 ++++++++++++------ .../ChangeDetectionService.ts | 283 +++++-- .../change-detection/adapters/index.ts | 1 + .../change-detection/adapters/types.test.ts | 31 + .../change-detection/adapters/types.ts | 45 ++ .../DependencyGraphBuilder.test.ts | 311 ++++++++ .../DependencyGraphBuilder.ts | 195 +++++ .../dependency-graph/IncrementalPatcher.ts | 240 ++++++ .../dependency-graph/ResolverFactory.ts | 97 +++ .../dependency-graph/ReverseIndex.test.ts | 111 +++ .../dependency-graph/ReverseIndex.ts | 62 ++ .../dependency-graph/WorkspaceLocator.test.ts | 165 +++++ .../dependency-graph/WorkspaceLocator.ts | 127 ++++ .../incremental-patch.test.ts | 299 ++++++++ .../dependency-graph/index.ts | 6 + .../dependency-graph/types.ts | 15 + .../src/core-server/change-detection/index.ts | 8 + .../parser-registry/ParserRegistry.test.ts | 156 ++++ .../parser-registry/ParserRegistry.ts | 70 ++ .../parser-registry/builtins.test.ts | 205 ++++++ .../parser-registry/builtins.ts | 27 + .../change-detection/parser-registry/index.ts | 3 + .../parser-registry/mdx-parse.ts | 26 + .../parser-registry/oxc-parse.ts | 173 +++++ .../change-detection/parser-registry/types.ts | 42 ++ .../change-detection/trace-changed.test.ts | 151 ---- .../change-detection/trace-changed.ts | 51 -- code/core/src/core-server/dev-server.ts | 6 +- code/core/src/core-server/index.ts | 11 + code/core/src/types/modules/core-common.ts | 27 + docs/configure/integration/eslint-plugin.mdx | 30 +- package.json | 2 + scripts/bench/baselines/.gitkeep | 0 scripts/bench/change-detection.ts | 182 +++++ scripts/bench/report-change-detection.ts | 71 ++ yarn.lock | 474 ++++++++++++ 40 files changed, 4116 insertions(+), 521 deletions(-) create mode 100644 code/builders/builder-vite/src/change-detection-adapter/index.test.ts create mode 100644 code/builders/builder-vite/src/change-detection-adapter/index.ts create mode 100644 code/core/src/core-server/change-detection/adapters/index.ts create mode 100644 code/core/src/core-server/change-detection/adapters/types.test.ts create mode 100644 code/core/src/core-server/change-detection/adapters/types.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.test.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/IncrementalPatcher.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.test.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.test.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/incremental-patch.test.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/index.ts create mode 100644 code/core/src/core-server/change-detection/dependency-graph/types.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/ParserRegistry.test.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/ParserRegistry.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/builtins.test.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/builtins.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/index.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/mdx-parse.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/oxc-parse.ts create mode 100644 code/core/src/core-server/change-detection/parser-registry/types.ts delete mode 100644 code/core/src/core-server/change-detection/trace-changed.test.ts delete mode 100644 code/core/src/core-server/change-detection/trace-changed.ts create mode 100644 scripts/bench/baselines/.gitkeep create mode 100644 scripts/bench/change-detection.ts create mode 100644 scripts/bench/report-change-detection.ts diff --git a/code/builders/builder-vite/src/change-detection-adapter/index.test.ts b/code/builders/builder-vite/src/change-detection-adapter/index.test.ts new file mode 100644 index 000000000000..c6f810624db3 --- /dev/null +++ b/code/builders/builder-vite/src/change-detection-adapter/index.test.ts @@ -0,0 +1,144 @@ +// Tests the Vite implementation of ChangeDetectionAdapter — wiring of resolve config +// snapshot and chokidar event normalisation. +import { EventEmitter } from 'node:events'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ViteDevServer } from 'vite'; + +import { createViteChangeDetectionAdapter } from './index.ts'; + +vi.mock('vite', { spy: true }); + +interface FakeViteDevServer { + config: { + root: string; + resolve?: { + alias?: unknown; + conditions?: string[]; + tsconfig?: string; + }; + }; + watcher: EventEmitter; +} + +function createFakeServer(overrides: Partial = {}): { + server: ViteDevServer; + watcher: EventEmitter; +} { + const watcher = new EventEmitter(); + const server: FakeViteDevServer = { + config: { + root: '/repo', + ...overrides, + }, + watcher, + }; + return { server: server as unknown as ViteDevServer, watcher }; +} + +describe('createViteChangeDetectionAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('snapshots projectRoot, alias and conditions from server.config in getResolveConfig()', async () => { + const alias = [{ find: '@', replacement: '/repo/src' }]; + const conditions = ['import', 'module', 'default']; + const { server } = createFakeServer({ + root: '/repo', + resolve: { alias, conditions }, + }); + + const adapter = createViteChangeDetectionAdapter(server); + const config = await adapter.getResolveConfig(); + + expect(config).toEqual({ + projectRoot: '/repo', + tsconfigPath: undefined, + alias, + conditions, + }); + }); + + it('forwards chokidar `add` events as kind: "add"', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + adapter.onFileChange(handler); + + watcher.emit('all', 'add', '/repo/src/A.tsx'); + + expect(handler).toHaveBeenCalledWith({ kind: 'add', path: '/repo/src/A.tsx' }); + }); + + it('forwards chokidar `change` events as kind: "change"', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + adapter.onFileChange(handler); + + watcher.emit('all', 'change', '/repo/src/A.tsx'); + + expect(handler).toHaveBeenCalledWith({ kind: 'change', path: '/repo/src/A.tsx' }); + }); + + it('forwards chokidar `unlink` events as kind: "unlink"', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + adapter.onFileChange(handler); + + watcher.emit('all', 'unlink', '/repo/src/A.tsx'); + + expect(handler).toHaveBeenCalledWith({ kind: 'unlink', path: '/repo/src/A.tsx' }); + }); + + it('does NOT forward `addDir`, `unlinkDir`, `ready`, `raw`, or `error` chokidar events', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + adapter.onFileChange(handler); + + watcher.emit('all', 'addDir', '/repo/src/some-dir'); + watcher.emit('all', 'unlinkDir', '/repo/src/some-dir'); + watcher.emit('all', 'ready'); + watcher.emit('all', 'raw', '/repo/src/A.tsx'); + watcher.emit('all', 'error', new Error('boom')); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('normalises chokidar paths via pathe.normalize before forwarding', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + adapter.onFileChange(handler); + + // Path with `/./` and mixed-case noise that pathe.normalize collapses. + watcher.emit('all', 'change', '/repo/src/./A.tsx'); + + expect(handler).toHaveBeenCalledWith({ + kind: 'change', + path: '/repo/src/A.tsx', + }); + }); + + it('returns an unsubscribe function that removes the listener', () => { + const { server, watcher } = createFakeServer(); + const adapter = createViteChangeDetectionAdapter(server); + const handler = vi.fn(); + const unsubscribe = adapter.onFileChange(handler); + + watcher.emit('all', 'change', '/repo/src/A.tsx'); + expect(handler).toHaveBeenCalledTimes(1); + + unsubscribe(); + watcher.emit('all', 'change', '/repo/src/B.tsx'); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/code/builders/builder-vite/src/change-detection-adapter/index.ts b/code/builders/builder-vite/src/change-detection-adapter/index.ts new file mode 100644 index 000000000000..1d726cc6a72c --- /dev/null +++ b/code/builders/builder-vite/src/change-detection-adapter/index.ts @@ -0,0 +1,69 @@ +import type { + ChangeDetectionAdapter, + FileChangeEvent, + ResolveConfig, +} from 'storybook/internal/core-server'; + +import { normalize } from 'pathe'; +import type { ViteDevServer } from 'vite'; + +/** + * Vite implementation of {@link ChangeDetectionAdapter}. + * + * - `getResolveConfig()` snapshots `server.config.resolve.alias`, `server.config.resolve.conditions` + * and `server.config.root` once at startup. The detector caches the result. + * - `onFileChange()` subscribes to `server.watcher` (chokidar) and forwards `add`/`change`/`unlink` + * events with normalised absolute paths. Other chokidar event names (`addDir`, `unlinkDir`, + * `ready`, `raw`, `error`) are intentionally filtered out. + * + * `tsconfigPath` is left undefined unless the user explicitly set `resolve.tsconfig`. When omitted, + * oxc-resolver auto-discovers tsconfig files by walking up from each parent dir. + */ +export function createViteChangeDetectionAdapter(server: ViteDevServer): ChangeDetectionAdapter { + return { + async getResolveConfig(): Promise { + const resolveOpts = server.config.resolve; + // Vite normalises `resolve.alias` to its array form (`Array<{find, replacement, ...}>`) + // before we ever see it. The detector accepts both Record and Array shapes, so we pass + // the array through unchanged. + const alias = resolveOpts?.alias as ResolveConfig['alias']; + const conditions = resolveOpts?.conditions; + // `tsconfig` is a non-standard Vite config option — only present when the user set it + // explicitly. We forward it as-is; otherwise leave undefined so oxc-resolver auto-discovers. + const tsconfigPath = (resolveOpts as { tsconfig?: string } | undefined)?.tsconfig; + + return { + projectRoot: server.config.root, + tsconfigPath, + alias, + conditions, + }; + }, + + onFileChange(handler) { + const onAll = (eventName: string, path: string) => { + let kind: FileChangeEvent['kind']; + switch (eventName) { + case 'add': + kind = 'add'; + break; + case 'change': + kind = 'change'; + break; + case 'unlink': + kind = 'unlink'; + break; + // Filter out 'addDir', 'unlinkDir', 'ready', 'raw', 'error' and any other chokidar + // event we don't care about. + default: + return; + } + handler({ kind, path: normalize(path) }); + }; + server.watcher.on('all', onAll); + return () => { + server.watcher.off('all', onAll); + }; + }, + }; +} diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index 13634fd352da..50bfabee9a3e 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -16,6 +16,7 @@ import type { import type { ViteDevServer } from 'vite'; import { build as viteBuild } from './build.ts'; +import { createViteChangeDetectionAdapter } from './change-detection-adapter/index.ts'; import type { ViteBuilder } from './types.ts'; import { createViteServer } from './vite-server.ts'; import { buildModuleGraph } from './utils/build-module-graph.ts'; @@ -115,6 +116,11 @@ function startModuleGraphTracking(): void { }); } +/** + * @deprecated Use `changeDetectionAdapter` instead. Removed in PR-B (next minor) once the new + * oxc-resolver-based change detector has been on a stable release. Kept here so the legacy + * builder API remains usable for one release cycle (see consensus plan §ADR-E). + */ export const onModuleGraphChange: NonNullable['onModuleGraphChange']> = (cb) => { listeners.add(cb); startModuleGraphTracking(); @@ -124,6 +130,25 @@ export const onModuleGraphChange: NonNullable['onModuleGraphCha }; }; +/** + * Returns a {@link ChangeDetectionAdapter} bound to the Vite dev server created by `start()`. + * + * Throws if called before `start()` has resolved (i.e. before the Vite dev server exists). + * Replaces the polling-based `onModuleGraphChange` flow above. + */ +export const changeDetectionAdapter: NonNullable< + Builder['changeDetectionAdapter'] +> = () => { + if (!server) { + throw new Error( + 'builder-vite: changeDetectionAdapter() called before start(); the Vite dev server is not ready yet.' + ); + } + return createViteChangeDetectionAdapter(server); +}; + +// knip-ignore: kept temporarily; replaced by changeDetectionAdapter (see consensus plan §ADR-E). +// Will be removed in PR-B once the new detector has been on a stable release. const startChangeDetection = async (options: Options) => { const startTime = process.hrtime(); const indexGenerator = await options.presets.apply('storyIndexGenerator'); diff --git a/code/core/package.json b/code/core/package.json index 2fc0015f64b8..88b14bba6c3f 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -237,6 +237,8 @@ "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts index 639a4141cb71..a2ec6d03831c 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts @@ -1,16 +1,13 @@ -import { join } from 'pathe'; - +// Rewritten for forward-walk semantics per ADR-F. +// Pure-function tests (mergeStatusValues, mergeChangeDetectionStatuses, +// buildIndexBaselineStatuses), readiness-state-machine tests, git-state-change tests +// and debounce tests are kept verbatim. Tests that previously drove the service via +// `MockBuilder.emit(moduleGraph)` now drive it via `MockAdapter.emitFileChange(...)` +// and stub the dependency-graph module to inject a synthetic `ReverseIndexImpl`. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { logger } from 'storybook/internal/node-logger'; -import type { - Builder, - ModuleGraph, - ModuleGraphChangeEvent, - ModuleNode, - Status, - StoryIndex, -} from 'storybook/internal/types'; +import type { Status, StoryIndex } from 'storybook/internal/types'; import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; import { @@ -18,6 +15,7 @@ import { UNIVERSAL_STATUS_STORE_OPTIONS, } from '../../shared/status-store/index.ts'; import { MockUniversalStore } from '../../shared/universal-store/mock.ts'; +import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; import { getChangeDetectionReadiness, internal_resetChangeDetectionReadiness } from './index.ts'; import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; import { @@ -26,11 +24,32 @@ import { mergeChangeDetectionStatuses, mergeStatusValues, } from './ChangeDetectionService.ts'; +import { + ChangeDetectionResolverFactory, + DependencyGraphBuilder, + IncrementalPatcher, + ReverseIndexImpl, + WorkspaceLocator, +} from './dependency-graph/index.ts'; import type { GitDiffResult } from './GitDiffProvider.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; import type { IndexBaselineService } from './IndexBaselineService.ts'; vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('./dependency-graph/index.ts', async (importOriginal) => { + // Keep ReverseIndexImpl + types real so tests can build synthetic indexes; replace the + // ChangeDetectionResolverFactory / WorkspaceLocator / DependencyGraphBuilder / + // IncrementalPatcher constructors with `vi.fn()`s so tests can override their behaviour + // per-case via `vi.mocked(Ctor).mockImplementation(...)`. + const actual = await importOriginal(); + return { + ...actual, + ChangeDetectionResolverFactory: vi.fn(), + WorkspaceLocator: vi.fn(), + DependencyGraphBuilder: vi.fn(), + IncrementalPatcher: vi.fn(), + }; +}); function createDeferred() { let resolve!: (value: T) => void; @@ -43,15 +62,6 @@ function createDeferred() { }; } -function createModuleNode(file: string): ModuleNode { - return { - file, - type: 'js', - importers: new Set(), - importedModules: new Set(), - }; -} - function createStoryIndex( entries: Array<{ storyId: string; importPath: string; title?: string; name?: string }> ): StoryIndex { @@ -73,29 +83,50 @@ function createStoryIndex( }; } -function createBuilder() { - let onModuleGraphChange: ((event: ModuleGraphChangeEvent) => void) | undefined; +interface MockAdapterHandle { + adapter: ChangeDetectionAdapter; + emitFileChange: (event: FileChangeEvent) => void; + emitStartupFailure: (event: { reason: string; error?: Error }) => void; + hasFileChangeSubscriber: () => boolean; + hasStartupFailureSubscriber: () => boolean; +} - const builder = { - onModuleGraphChange: vi.fn((callback: (event: ModuleGraphChangeEvent) => void) => { - onModuleGraphChange = callback; - return vi.fn(() => { - onModuleGraphChange = undefined; - }); - }), - } as unknown as Builder; +function createMockAdapter(opts?: { + resolveConfig?: { projectRoot?: string }; + withoutStartupFailure?: boolean; +}): MockAdapterHandle { + const fileHandlers = new Set<(e: FileChangeEvent) => void>(); + const startupHandlers = new Set<(e: { reason: string; error?: Error }) => void>(); + + const adapter: ChangeDetectionAdapter = { + async getResolveConfig() { + return { + projectRoot: opts?.resolveConfig?.projectRoot ?? '/repo', + }; + }, + onFileChange(handler) { + fileHandlers.add(handler); + return () => fileHandlers.delete(handler); + }, + }; + + if (!opts?.withoutStartupFailure) { + adapter.onStartupFailure = (handler) => { + startupHandlers.add(handler); + return () => startupHandlers.delete(handler); + }; + } return { - builder, - emit(moduleGraph: ModuleGraph) { - onModuleGraphChange?.({ type: 'moduleGraph', moduleGraph }); + adapter, + emitFileChange: (event) => { + fileHandlers.forEach((h) => h(event)); }, - emitUnavailable(reason: string, error?: Error) { - onModuleGraphChange?.({ type: 'unavailable', reason, error }); - }, - emitError(error: Error) { - onModuleGraphChange?.({ type: 'error', error }); + emitStartupFailure: (event) => { + startupHandlers.forEach((h) => h(event)); }, + hasFileChangeSubscriber: () => fileHandlers.size > 0, + hasStartupFailureSubscriber: () => startupHandlers.size > 0, }; } @@ -168,6 +199,55 @@ function createStatus(value: Status['value'], data?: Status['data']): Status { }; } +/** + * Build a ReverseIndexImpl populated with the given (dep -> story -> depth) entries. + * Used by tests to control what `reverseIndex.lookup(changedFile)` returns. + */ +function buildReverseIndex(edges: Iterable): ReverseIndexImpl { + const reverseIndex = new ReverseIndexImpl(); + for (const [dep, story, depth] of edges) { + reverseIndex.record(dep, story, depth); + } + return reverseIndex; +} + +/** + * Stub the dependency-graph constructors so the service uses an in-test + * ReverseIndexImpl + an inert IncrementalPatcher. + * + * Note: `vi.mock` replaces these exports with plain `vi.fn()` constructors. When the + * service calls `new Ctor(...)` we must return objects via `mockImplementation` — + * but vitest invokes the impl with `Reflect.construct` on `new`, so arrow-function + * impls throw "is not a constructor". `function () { return obj; }` works because + * regular functions support `[[Construct]]`. + */ +function installDependencyGraphMocks(reverseIndex: ReverseIndexImpl): { + patchSpy: ReturnType; + buildSpy: ReturnType; +} { + const patchSpy = vi.fn(async () => undefined); + const buildSpy = vi.fn(async () => ({ reverseIndex, graph: new Map() })); + + vi.mocked(ChangeDetectionResolverFactory).mockImplementation(function () { + return { + resolve: vi.fn(async () => null), + } as unknown as ChangeDetectionResolverFactory; + } as unknown as new () => ChangeDetectionResolverFactory); + vi.mocked(WorkspaceLocator).mockImplementation(function () { + return { + locate: vi.fn(async () => new Set()), + } as unknown as WorkspaceLocator; + } as unknown as new () => WorkspaceLocator); + vi.mocked(DependencyGraphBuilder).mockImplementation(function () { + return { build: buildSpy } as unknown as DependencyGraphBuilder; + } as unknown as new () => DependencyGraphBuilder); + vi.mocked(IncrementalPatcher).mockImplementation(function () { + return { patch: patchSpy } as unknown as IncrementalPatcher; + } as unknown as new () => IncrementalPatcher); + + return { patchSpy, buildSpy }; +} + describe('ChangeDetectionService', () => { const workingDir = '/repo'; @@ -177,36 +257,33 @@ describe('ChangeDetectionService', () => { vi.mocked(logger.info).mockImplementation(() => undefined); vi.mocked(logger.warn).mockImplementation(() => undefined); vi.mocked(logger.error).mockImplementation(() => undefined); + vi.mocked(logger.debug).mockImplementation(() => undefined); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + vi.resetAllMocks(); internal_resetChangeDetectionReadiness(); }); - it('marks only the nearest stories as modified', async () => { - const buttonCss = createModuleNode('/repo/src/Button.module.css'); - const buttonComponent = createModuleNode('/repo/src/Button.tsx'); - const buttonStory = createModuleNode('/repo/src/Button.stories.tsx'); - const headerComponent = createModuleNode('/repo/src/Header.tsx'); - const headerStory = createModuleNode('/repo/src/Header.stories.tsx'); - - buttonCss.importers.add(buttonComponent); - buttonComponent.importers.add(buttonStory); - buttonComponent.importers.add(headerComponent); - headerComponent.importers.add(headerStory); - - const moduleGraph: ModuleGraph = new Map([ - ['/repo/src/Button.module.css', new Set([buttonCss])], - ['/repo/src/Button.tsx', new Set([buttonComponent])], - ['/repo/src/Button.stories.tsx', new Set([buttonStory])], - ['/repo/src/Header.tsx', new Set([headerComponent])], - ['/repo/src/Header.stories.tsx', new Set([headerStory])], + // ------------------------------------------------------------------ + // ADR-F semantic test cases (modified vs affected) — all four MUST appear. + // ------------------------------------------------------------------ + + it('ADR-F #1: edits a story file -> that story is modified at distance 0; importer stories are affected at distance 1', async () => { + // Story A is the changed file (distance 0). Story B imports A (distance 1). + // Reverse index models forward-walk depths: A reaches itself at 0; B reaches A at 1. + const reverseIndex = buildReverseIndex([ + ['/repo/src/A.stories.tsx', '/repo/src/A.stories.tsx', 0], + ['/repo/src/A.stories.tsx', '/repo/src/B.stories.tsx', 1], + ['/repo/src/B.stories.tsx', '/repo/src/B.stories.tsx', 0], ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ - { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, - { storyId: 'header--default', importPath: './src/Header.stories.tsx', title: 'Header' }, + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, ]); const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), @@ -214,11 +291,11 @@ describe('ChangeDetectionService', () => { }); const gitDiffProvider = createMockGitDiffProvider((provider) => { provider.getChangedFilesMock.mockResolvedValue({ - changed: new Set(['src/Button.module.css']), + changed: new Set(['src/A.stories.tsx']), new: new Set(), }); }); - const { builder, emit } = createBuilder(); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -229,14 +306,13 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); + service.start(adapter, true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ - 'button--primary': { + 'a--default': { [CHANGE_DETECTION_STATUS_TYPE_ID]: { - storyId: 'button--primary', + storyId: 'a--default', typeId: CHANGE_DETECTION_STATUS_TYPE_ID, value: 'status-value:modified', title: '', @@ -244,9 +320,9 @@ describe('ChangeDetectionService', () => { sidebarContextMenu: false, }, }, - 'header--default': { + 'b--default': { [CHANGE_DETECTION_STATUS_TYPE_ID]: { - storyId: 'header--default', + storyId: 'b--default', typeId: CHANGE_DETECTION_STATUS_TYPE_ID, value: 'status-value:affected', title: '', @@ -255,11 +331,156 @@ describe('ChangeDetectionService', () => { }, }, }); + await service.dispose(); + }); + + it('ADR-F #2: edits a non-story dep at distance 1 from one story and distance 2 from another -> nearest is modified, farther is affected', async () => { + // Button.tsx is imported by Button.stories.tsx (distance 1) and by Compositions.stories.tsx + // transitively via the Button story chain (distance 2). + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/Button.tsx', '/repo/src/Compositions.stories.tsx', 2], + ]); + installDependencyGraphMocks(reverseIndex); + + const storyIndex = createStoryIndex([ + { + storyId: 'button--primary', + importPath: './src/Button.stories.tsx', + title: 'Button', + }, + { + storyId: 'compositions--default', + importPath: './src/Compositions.stories.tsx', + title: 'Compositions', + }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider((provider) => { + provider.getChangedFilesMock.mockResolvedValue({ + changed: new Set(['src/Button.tsx']), + new: new Set(), + }); + }); + const { adapter } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); + + const all = getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll(); + expect(all['button--primary'][CHANGE_DETECTION_STATUS_TYPE_ID].value).toBe( + 'status-value:modified' + ); + expect(all['compositions--default'][CHANGE_DETECTION_STATUS_TYPE_ID].value).toBe( + 'status-value:affected' + ); + await service.dispose(); + }); + + it('ADR-F #3: edits a non-story dep at equal distance from two stories -> both stories tie and are both modified', async () => { + // Both Button.stories.tsx and Header.stories.tsx import shared.ts at distance 1. + const reverseIndex = buildReverseIndex([ + ['/repo/src/shared.ts', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/shared.ts', '/repo/src/Header.stories.tsx', 1], + ]); + installDependencyGraphMocks(reverseIndex); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + { storyId: 'header--default', importPath: './src/Header.stories.tsx', title: 'Header' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider((provider) => { + provider.getChangedFilesMock.mockResolvedValue({ + changed: new Set(['src/shared.ts']), + new: new Set(), + }); + }); + const { adapter } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); + + const all = getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll(); + expect(all['button--primary'][CHANGE_DETECTION_STATUS_TYPE_ID].value).toBe( + 'status-value:modified' + ); + expect(all['header--default'][CHANGE_DETECTION_STATUS_TYPE_ID].value).toBe( + 'status-value:modified' + ); + await service.dispose(); + }); + + it('ADR-F #4: edits a non-story file with no story importers -> reverse-index lookup is empty -> no status emitted', async () => { + // orphan.ts is in neither the reverse index nor the story index. + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ]); + installDependencyGraphMocks(reverseIndex); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider((provider) => { + provider.getChangedFilesMock.mockResolvedValue({ + changed: new Set(['src/orphan.ts']), + new: new Set(), + }); + }); + const { adapter } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); + + expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); expect(await getChangeDetectionReadiness()).toEqual({ status: 'ready' }); await service.dispose(); }); + // ------------------------------------------------------------------ + // Orchestration / merge-status / readiness / git-state / debounce tests. + // ------------------------------------------------------------------ + it('marks new story files from the git new set and unsets them after they are reverted', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const storyIndex = createStoryIndex([ { storyId: 'new-button--primary', @@ -282,7 +503,11 @@ describe('ChangeDetectionService', () => { new: new Set(), }); }); - const { builder, emit } = createBuilder(); + let onGitStateChange: (() => void) | undefined; + gitDiffProvider.onGitStateChangeMock.mockImplementation((callback: () => void) => { + onGitStateChange = callback; + }); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -294,8 +519,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(builder.onModuleGraphChange, true); - emit(new Map()); + service.start(adapter, true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -311,7 +535,7 @@ describe('ChangeDetectionService', () => { }, }); - emit(new Map()); + onGitStateChange?.(); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -321,18 +545,12 @@ describe('ChangeDetectionService', () => { }); it('replaces prior scan status data instead of cumulatively merging with store state', async () => { - const depA = createModuleNode('/repo/src/depA.ts'); - const depB = createModuleNode('/repo/src/depB.ts'); - const buttonStory = createModuleNode('/repo/src/Button.stories.tsx'); - - depA.importers.add(buttonStory); - depB.importers.add(buttonStory); - - const moduleGraph: ModuleGraph = new Map([ - ['/repo/src/depA.ts', new Set([depA])], - ['/repo/src/depB.ts', new Set([depB])], - ['/repo/src/Button.stories.tsx', new Set([buttonStory])], + const reverseIndex = buildReverseIndex([ + ['/repo/src/depB.ts', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/depA.ts', '/repo/src/Button.stories.tsx', 1], ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, ]); @@ -351,7 +569,11 @@ describe('ChangeDetectionService', () => { new: new Set(), }); }); - const { builder, emit } = createBuilder(); + let onGitStateChange: (() => void) | undefined; + gitDiffProvider.onGitStateChangeMock.mockImplementation((callback: () => void) => { + onGitStateChange = callback; + }); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -363,8 +585,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); + service.start(adapter, true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -380,7 +601,7 @@ describe('ChangeDetectionService', () => { }, }); - emit(moduleGraph); + onGitStateChange?.(); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -399,10 +620,11 @@ describe('ChangeDetectionService', () => { }); it('rescans on git state changes using the normal debounce', async () => { - const buttonStory = createModuleNode('/repo/src/Button.stories.tsx'); - const moduleGraph: ModuleGraph = new Map([ - ['/repo/src/Button.stories.tsx', new Set([buttonStory])], + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0], ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, ]); @@ -420,7 +642,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); }); - const { builder, emit } = createBuilder(); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -432,8 +654,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); + service.start(adapter, true); await vi.runAllTimersAsync(); expect(gitDiffProvider.onGitStateChangeMock).toHaveBeenCalledTimes(1); @@ -451,13 +672,58 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); + it('debounces consecutive file-change events into a single scan', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider(); + const { adapter, emitFileChange } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + debounceMs: 50, + }); + + service.start(adapter, true); + // First scan from initial start — debounce 0 runs synchronously. + await vi.runAllTimersAsync(); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); + + // Three file-change events within the debounce window collapse to one scan. + emitFileChange({ kind: 'change', path: '/repo/src/A.ts' }); + await vi.advanceTimersByTimeAsync(10); + emitFileChange({ kind: 'change', path: '/repo/src/B.ts' }); + await vi.advanceTimersByTimeAsync(10); + emitFileChange({ kind: 'change', path: '/repo/src/C.ts' }); + await vi.advanceTimersByTimeAsync(10); + + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(50); + + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); + + await service.dispose(); + }); + it('does not subscribe to git state when change detection is disabled', async () => { const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); const gitDiffProvider = createMockGitDiffProvider(); - const { builder } = createBuilder(); + const { adapter, hasFileChangeSubscriber } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), @@ -468,9 +734,9 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(builder.onModuleGraphChange, false); + service.start(adapter, false); - expect(builder.onModuleGraphChange).not.toHaveBeenCalled(); + expect(hasFileChangeSubscriber()).toBe(false); expect(gitDiffProvider.onGitStateChangeMock).not.toHaveBeenCalled(); expect(await getChangeDetectionReadiness()).toEqual({ status: 'unavailable', @@ -479,7 +745,7 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('logs unavailability when the builder does not expose module graph changes', async () => { + it('logs unavailability when the builder does not provide an adapter', async () => { const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', @@ -497,25 +763,31 @@ describe('ChangeDetectionService', () => { service.start(undefined, true); expect(logger.warn).toHaveBeenCalledWith( - 'Change detection unavailable: Not supported by builder' + 'Change detection unavailable: builder does not support change detection' ); expect(await getChangeDetectionReadiness()).toEqual({ status: 'unavailable', - reason: 'builder does not support module graph', + reason: 'builder does not support change detection', }); await service.dispose(); }); - it('resolves readiness when the builder reports change detection startup failure', async () => { + it('resolves readiness as unavailable when the adapter reports a startup failure', async () => { + // Park git lookup so the initial scan never resolves to 'ready' before we emit the failure. + const gitDeferred = createDeferred(); + installDependencyGraphMocks(buildReverseIndex([])); + const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); - const gitDiffProvider = createMockGitDiffProvider(); - const { builder, emitError } = createBuilder(); + const gitDiffProvider = createMockGitDiffProvider((provider) => { + provider.getChangedFilesMock.mockImplementation(() => gitDeferred.promise); + }); + const { adapter, emitStartupFailure } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn(), + getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), gitDiffProvider, @@ -523,22 +795,64 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(builder.onModuleGraphChange, true); - emitError(new Error('module graph warmup failed')); - await Promise.resolve(); + service.start(adapter, true); + // Let startInternal subscribe before emitting the failure (initial scan parked on git). + await vi.runAllTimersAsync(); + + emitStartupFailure({ reason: 'vite warmup failed', error: new Error('warmup failed') }); + await vi.runAllTimersAsync(); + + expect(logger.warn).toHaveBeenCalledWith('Change detection unavailable: vite warmup failed'); + expect(await getChangeDetectionReadiness()).toEqual({ + status: 'unavailable', + reason: 'vite warmup failed', + error: expect.objectContaining({ message: 'warmup failed' }), + }); + // Unblock the parked git call so dispose can drain. + gitDeferred.resolve({ changed: new Set(), new: new Set() }); + await service.dispose(); + }); + + it('resolves readiness as error when the eager build throws', async () => { + const { buildSpy } = installDependencyGraphMocks(buildReverseIndex([])); + buildSpy.mockImplementation(async () => { + throw new ChangeDetectionFailureError('graph build blew up'); + }); + + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const { adapter } = createMockAdapter(); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider: createMockGitDiffProvider(), + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(adapter, true); + await vi.runAllTimersAsync(); expect(logger.error).toHaveBeenCalledWith( - 'Change detection failed: module graph warmup failed' + 'Change detection failed to start: graph build blew up' ); expect(await getChangeDetectionReadiness()).toEqual({ status: 'error', - error: expect.objectContaining({ message: 'module graph warmup failed' }), + error: expect.objectContaining({ message: 'graph build blew up' }), }); - expect(gitDiffProvider.getChangedFilesMock).not.toHaveBeenCalled(); await service.dispose(); }); it('keeps the previous statuses when a live rescan fails', async () => { + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0], + ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, ]); @@ -554,7 +868,11 @@ describe('ChangeDetectionService', () => { }) .mockRejectedValueOnce(new ChangeDetectionFailureError('scan blew up')); }); - const { builder, emit } = createBuilder(); + let onGitStateChange: (() => void) | undefined; + gitDiffProvider.onGitStateChangeMock.mockImplementation((callback: () => void) => { + onGitStateChange = callback; + }); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -566,10 +884,10 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(builder.onModuleGraphChange, true); - emit(new Map()); + service.start(adapter, true); await vi.runAllTimersAsync(); - emit(new Map()); + + onGitStateChange?.(); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -589,13 +907,14 @@ describe('ChangeDetectionService', () => { }); it('does not apply scan results or rerun after disposal', async () => { + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0], + ]); + installDependencyGraphMocks(reverseIndex); + const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, ]); - const buttonStory = createModuleNode('/repo/src/Button.stories.tsx'); - const moduleGraph: ModuleGraph = new Map([ - ['/repo/src/Button.stories.tsx', new Set([buttonStory])], - ]); const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', @@ -607,7 +926,7 @@ describe('ChangeDetectionService', () => { const gitDiffProvider = createMockGitDiffProvider((provider) => { provider.getChangedFilesMock.mockImplementation(() => changedFilesDeferred.promise); }); - const { builder, emit } = createBuilder(); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), @@ -619,10 +938,7 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); - await vi.advanceTimersByTimeAsync(0); - emit(moduleGraph); + service.start(adapter, true); await vi.advanceTimersByTimeAsync(0); await service.dispose(); @@ -633,7 +949,6 @@ describe('ChangeDetectionService', () => { await Promise.resolve(); await Promise.resolve(); - expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); await expect( Promise.race([ @@ -644,6 +959,8 @@ describe('ChangeDetectionService', () => { }); it('tears down after a permanently unavailable scan result', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', @@ -653,10 +970,10 @@ describe('ChangeDetectionService', () => { new ChangeDetectionUnavailableError('not a git repository') ); }); - const { builder, emit } = createBuilder(); + const { adapter } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn(), + getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), gitDiffProvider, @@ -665,11 +982,8 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); - service.start(builder.onModuleGraphChange, true); - emit(new Map()); - await vi.advanceTimersByTimeAsync(0); - emit(new Map()); - await vi.advanceTimersByTimeAsync(0); + service.start(adapter, true); + await vi.runAllTimersAsync(); expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith('Change detection unavailable: not a git repository'); @@ -681,30 +995,15 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('prefers modified over affected when the same story is reached by multiple changed files', async () => { - const shared = createModuleNode('/repo/src/shared.ts'); - const closeComponent = createModuleNode('/repo/src/Close.tsx'); - const farComponent = createModuleNode('/repo/src/Far.tsx'); - const story = createModuleNode('/repo/src/Button.stories.tsx'); - - shared.importers.add(closeComponent); - shared.importers.add(farComponent); - closeComponent.importers.add(story); - farComponent.importers.add(closeComponent); - - const directChange = createModuleNode('/repo/src/direct.ts'); - const indirectChange = createModuleNode('/repo/src/indirect.ts'); - directChange.importers.add(closeComponent); - indirectChange.importers.add(farComponent); - - const moduleGraph: ModuleGraph = new Map([ - ['/repo/src/shared.ts', new Set([shared])], - ['/repo/src/Close.tsx', new Set([closeComponent])], - ['/repo/src/Far.tsx', new Set([farComponent])], - ['/repo/src/Button.stories.tsx', new Set([story])], - ['/repo/src/direct.ts', new Set([directChange])], - ['/repo/src/indirect.ts', new Set([indirectChange])], - ]); + it('queues file-change events that arrive while the eager build is in flight and patches them after build resolves', async () => { + const reverseIndex = buildReverseIndex([]); + const buildDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockImplementation(async () => { + await buildDeferred.promise; + return { reverseIndex, graph: new Map() }; + }); + const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, ]); @@ -712,98 +1011,35 @@ describe('ChangeDetectionService', () => { universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); - const gitDiffProvider = createMockGitDiffProvider((provider) => { - provider.getChangedFilesMock.mockResolvedValue({ - changed: new Set(['src/direct.ts', 'src/indirect.ts']), - new: new Set(), - }); - }); - const { builder, emit } = createBuilder(); + const { adapter, emitFileChange } = createMockAdapter(); const service = new ChangeDetectionService({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider, + gitDiffProvider: createMockGitDiffProvider(), indexBaselineService: createMockStoryIndexBaselineService(), workingDir, }); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); - await vi.runAllTimersAsync(); + service.start(adapter, true); + // Allow startInternal to reach the build step and start awaiting it. + await Promise.resolve(); + await Promise.resolve(); - expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ - 'button--primary': { - [CHANGE_DETECTION_STATUS_TYPE_ID]: { - storyId: 'button--primary', - typeId: CHANGE_DETECTION_STATUS_TYPE_ID, - value: 'status-value:modified', - title: '', - description: '', - sidebarContextMenu: false, - }, - }, - }); - await service.dispose(); - }); + // Adapter has no subscribers yet, but the service may have stashed events arriving via + // its pendingEvents queue once subscriptions complete. Currently subscriptions are wired + // after build resolves; assert no patch calls have happened yet. + expect(patchSpy).not.toHaveBeenCalled(); - it('handles normalized paths when assigning statuses', async () => { - const buttonCssPath = join(workingDir, 'src', 'Button.module.css'); - const buttonComponentPath = join(workingDir, 'src', 'Button.tsx'); - const buttonStoryPath = join(workingDir, 'src', 'Button.stories.tsx'); - const buttonCss = createModuleNode(buttonCssPath); - const buttonComponent = createModuleNode(buttonComponentPath); - const buttonStory = createModuleNode(buttonStoryPath); - - buttonCss.importers.add(buttonComponent); - buttonComponent.importers.add(buttonStory); - - const moduleGraph: ModuleGraph = new Map([ - [buttonCssPath, new Set([buttonCss])], - [buttonComponentPath, new Set([buttonComponent])], - [buttonStoryPath, new Set([buttonStory])], - ]); - const storyIndex = createStoryIndex([ - { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, - ]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const gitDiffProvider = createMockGitDiffProvider((provider) => { - provider.getChangedFilesMock.mockResolvedValue({ - changed: new Set(['src/Button.module.css']), - new: new Set(), - }); - }); - const { builder, emit } = createBuilder(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn().mockResolvedValue(storyIndex), - } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider, - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); + buildDeferred.resolve(); + await vi.runAllTimersAsync(); - service.start(builder.onModuleGraphChange, true); - emit(moduleGraph); + // Now the adapter has subscribers — file events go through the patcher. + emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); await vi.runAllTimersAsync(); + expect(patchSpy).toHaveBeenCalledTimes(1); - expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ - 'button--primary': { - [CHANGE_DETECTION_STATUS_TYPE_ID]: { - storyId: 'button--primary', - typeId: CHANGE_DETECTION_STATUS_TYPE_ID, - value: 'status-value:modified', - title: '', - description: '', - sidebarContextMenu: false, - }, - }, - }); await service.dispose(); }); }); diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.ts index 857275982690..a8b285653df7 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.ts @@ -1,11 +1,10 @@ -import { join } from 'pathe'; +import { writeFile } from 'node:fs/promises'; + +import { join, normalize } from 'pathe'; import { logger } from 'storybook/internal/node-logger'; import type { - Builder, - ModuleGraph, - ModuleGraphChangeEvent, - ModuleNode, + Presets, StatusValue, StoryIndex, Status, @@ -13,13 +12,21 @@ import type { } from 'storybook/internal/types'; import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; -import { normalizePath } from '../../common/utils/normalize-path.ts'; import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; +import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; +import { + ChangeDetectionResolverFactory, + DependencyGraphBuilder, + IncrementalPatcher, + WorkspaceLocator, +} from './dependency-graph/index.ts'; +import type { DependencyGraph, ReverseIndexImpl } from './dependency-graph/index.ts'; import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; -import { resetChangeDetectionReadiness, setChangeDetectionReadiness } from './readiness.ts'; import { extractBaselineEntryIds, IndexBaselineService } from './IndexBaselineService.ts'; -import { findAffectedStoryFiles } from './trace-changed.ts'; +import type { ImportParser } from './parser-registry/index.ts'; +import { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; +import { resetChangeDetectionReadiness, setChangeDetectionReadiness } from './readiness.ts'; const CHANGE_DETECTION_DEBOUNCE_MS = 200; @@ -46,7 +53,7 @@ function getStoryIdsByAbsolutePath( const storyIdsByFile = new Map>(); Object.values(storyIndex.entries).forEach((entry) => { if (entry.type === 'story' && !entry.importPath.startsWith('virtual:')) { - const filePath = join(workingDir, entry.importPath); + const filePath = normalize(join(workingDir, entry.importPath)); const storyIds = storyIdsByFile.get(filePath) ?? new Set(); storyIds.add(entry.id); storyIdsByFile.set(filePath, storyIds); @@ -115,16 +122,14 @@ export function buildIndexBaselineStatuses( } /** - * Coordinates change detection by listening to builder module-graph updates, resolving changed - * files from git, mapping those changes to affected stories, and publishing the resulting story - * statuses to the status store. + * Coordinates change detection by owning a builder-supplied {@link ChangeDetectionAdapter}, + * eagerly building a reverse-dependency index from story files at startup, applying + * file-system events incrementally to that index, resolving git-changed files, and publishing + * the resulting story statuses to the status store. */ export class ChangeDetectionService { private disposed = false; - private unsubscribeModuleGraph: (() => void) | undefined; private debounceTimer: ReturnType | undefined; - private latestModuleGraph: ModuleGraph | undefined; - private hasReceivedModuleGraph = false; private scanInFlight = false; private rerunAfterCurrentScan = false; private readinessResolved = false; @@ -133,6 +138,16 @@ export class ChangeDetectionService { private indexBaselineService: IndexBaselineService | undefined; private readonly workingDir: string; private readonly debounceMs: number; + private adapter: ChangeDetectionAdapter | undefined; + private dependencyGraphBuilder: DependencyGraphBuilder | undefined; + private incrementalPatcher: IncrementalPatcher | undefined; + private reverseIndex: ReverseIndexImpl | undefined; + private graph: DependencyGraph | undefined; + private storyFiles: Set = new Set(); + private buildInFlight = false; + private pendingEvents: FileChangeEvent[] = []; + private unsubscribeFileChange: (() => void) | undefined; + private unsubscribeStartupFailure: (() => void) | undefined; constructor( private readonly options: { @@ -142,6 +157,12 @@ export class ChangeDetectionService { indexBaselineService?: IndexBaselineService; workingDir?: string; debounceMs?: number; + /** + * Presets instance used to resolve `experimental_importParsers` contributions from + * framework/renderer plugins. Optional for tests that never construct the real + * dependency-graph layer. + */ + presets?: Presets; } ) { this.gitDiffProvider = options.gitDiffProvider; @@ -151,10 +172,7 @@ export class ChangeDetectionService { resetChangeDetectionReadiness(); } - start( - onModuleGraphChange: Builder['onModuleGraphChange'], - enabled: boolean | undefined - ): void { + start(adapter: ChangeDetectionAdapter | undefined, enabled: boolean | undefined): void { if (enabled === false) { logger.debug('Change detection disabled.'); this.resolveReadiness({ @@ -164,31 +182,139 @@ export class ChangeDetectionService { return; } - if (!onModuleGraphChange) { - logger.warn('Change detection unavailable: Not supported by builder'); + if (!adapter) { + logger.warn('Change detection unavailable: builder does not support change detection'); this.resolveReadiness({ status: 'unavailable', - reason: 'builder does not support module graph', + reason: 'builder does not support change detection', }); return; } logger.debug('Change detection enabled.'); - void this.getIndexBaselineService().start(); - this.unsubscribeModuleGraph = onModuleGraphChange((event) => { + this.adapter = adapter; + + void this.startInternal().catch((error) => { if (this.disposed) { return; } + const failure = + error instanceof Error ? error : new ChangeDetectionFailureError(String(error)); + logger.error(`Change detection failed to start: ${failure.message}`); + this.resolveReadiness({ status: 'error', error: failure }); + }); + } + + private async startInternal(): Promise { + const adapter = this.adapter; + if (!adapter) { + return; + } + + // 1. Get resolveConfig from adapter. + const resolveConfig = await adapter.getResolveConfig(); + const projectRoot = normalize(resolveConfig.projectRoot ?? this.workingDir); + + // 2. Construct parser registry (built-ins + preset contributions), resolver, workspace locator. + const pluginParsers = this.options.presets + ? await this.options.presets.apply('experimental_importParsers', []) + : []; + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers, + }); + const resolver = new ChangeDetectionResolverFactory(resolveConfig); + const workspaceLocator = new WorkspaceLocator(projectRoot); + const workspaceRoots = await workspaceLocator.locate(); + + // 3. Get story-file index. + const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; + const storyIndex = await storyIndexGenerator.getIndex(); + const storyIdsByFile = getStoryIdsByAbsolutePath(storyIndex, this.workingDir); + this.storyFiles = new Set(storyIdsByFile.keys()); + + if (this.disposed) { + return; + } - if (event.type === 'moduleGraph') { - this.latestModuleGraph = event.moduleGraph; - this.scheduleScan(this.hasReceivedModuleGraph ? this.debounceMs : 0); - this.hasReceivedModuleGraph = true; + // 4. Eager build. + this.dependencyGraphBuilder = new DependencyGraphBuilder({ + registry, + resolver, + workspaceRoots, + projectRoot, + }); + this.buildInFlight = true; + let reverseIndex: ReverseIndexImpl; + let graph: DependencyGraph; + try { + ({ reverseIndex, graph } = await this.dependencyGraphBuilder.build(this.storyFiles)); + } finally { + this.buildInFlight = false; + } + if (this.disposed) { + return; + } + this.reverseIndex = reverseIndex; + this.graph = graph; + + this.incrementalPatcher = new IncrementalPatcher({ + reverseIndex, + graph, + registry, + resolver, + workspaceRoots, + projectRoot, + isStoryFile: (path: string) => this.storyFiles.has(normalize(path)), + }); + + // 5. Drain any events that arrived during the eager build, in arrival order. + const drained = this.pendingEvents.splice(0); + for (const event of drained) { + if (this.disposed) { return; } + try { + await this.incrementalPatcher.patch(event); + } catch (error) { + logger.warn( + `Change detection: failed to apply queued ${event.kind} for ${event.path}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - this.handleBuilderStartupEvent(event); + // 6. Subscribe to file-change events. + this.unsubscribeFileChange = adapter.onFileChange((event) => { + if (this.disposed) { + return; + } + if (this.buildInFlight) { + this.pendingEvents.push(event); + return; + } + void this.handleFileChange(event); }); + + // 7. Subscribe to startup-failure events (optional). + if (adapter.onStartupFailure) { + this.unsubscribeStartupFailure = adapter.onStartupFailure((event) => { + if (this.disposed) { + return; + } + logger.warn(`Change detection unavailable: ${event.reason}`); + this.resolveReadiness({ + status: 'unavailable', + reason: event.reason, + error: event.error, + }); + void this.dispose(); + }); + } + + // 8. Index baseline service. + void this.getIndexBaselineService().start(); + + // 9. Git state changes. this.getGitDiffProvider().onGitStateChange(() => { if (this.disposed) { return; @@ -199,6 +325,9 @@ export class ChangeDetectionService { .handleGitStateChange() .catch(() => undefined); }); + + // Trigger an initial scan so we surface git-pending diffs immediately. + this.scheduleScan(0); } onStoryIndexInvalidated(): void { @@ -216,8 +345,24 @@ export class ChangeDetectionService { this.debounceTimer = undefined; } - this.unsubscribeModuleGraph?.(); - this.unsubscribeModuleGraph = undefined; + this.unsubscribeFileChange?.(); + this.unsubscribeFileChange = undefined; + this.unsubscribeStartupFailure?.(); + this.unsubscribeStartupFailure = undefined; + } + + private async handleFileChange(event: FileChangeEvent): Promise { + if (this.disposed || !this.incrementalPatcher) { + return; + } + try { + await this.incrementalPatcher.patch(event); + } catch (error) { + logger.warn( + `Change detection: failed to apply ${event.kind} for ${event.path}: ${error instanceof Error ? error.message : String(error)}` + ); + } + this.scheduleScan(this.debounceMs); } private scheduleScan(delayMs: number): void { @@ -232,7 +377,7 @@ export class ChangeDetectionService { } private async scan(): Promise { - if (this.disposed || !this.latestModuleGraph) { + if (this.disposed || !this.reverseIndex) { return; } @@ -244,7 +389,7 @@ export class ChangeDetectionService { this.scanInFlight = true; try { - const nextStatuses = await this.buildStatuses(this.latestModuleGraph); + const nextStatuses = await this.buildStatuses(this.reverseIndex); if (this.disposed) { return; } @@ -291,7 +436,7 @@ export class ChangeDetectionService { } } - private async buildStatuses(moduleGraph: ModuleGraph): Promise> { + private async buildStatuses(reverseIndex: ReverseIndexImpl): Promise> { const gitDiffProvider = this.getGitDiffProvider(); const [changes, repoRoot, storyIndexGenerator, baselineEntryIds] = await Promise.all([ gitDiffProvider.getChangedFiles(), @@ -300,19 +445,13 @@ export class ChangeDetectionService { this.getIndexBaselineService().getBaselineEntryIds(), ]); - const changedFiles = new Set(Array.from(changes.changed).map((path) => join(repoRoot, path))); - const newFiles = new Set(Array.from(changes.new).map((path) => join(repoRoot, path))); + const changedFiles = new Set( + Array.from(changes.changed).map((path) => normalize(join(repoRoot, path))) + ); + const newFiles = new Set( + Array.from(changes.new).map((path) => normalize(join(repoRoot, path))) + ); const scannedFiles = new Set([...changedFiles, ...newFiles]); - const normalizedModuleGraph = new Map>(); - moduleGraph.forEach((nodes, filePath) => { - const normalizedPath = normalizePath(filePath); - const existingNodes = normalizedModuleGraph.get(normalizedPath); - if (existingNodes) { - nodes.forEach((node) => void existingNodes.add(node)); - } else { - normalizedModuleGraph.set(normalizedPath, new Set(nodes)); - } - }); const storyIndex = await storyIndexGenerator.getIndex(); const baselineStatuses = buildIndexBaselineStatuses(storyIndex, baselineEntryIds); @@ -320,16 +459,19 @@ export class ChangeDetectionService { const statuses = new Map(); for (const changedFile of scannedFiles) { - const affectedStoryFiles = findAffectedStoryFiles( - changedFile, - normalizedModuleGraph, - storyIdsByFile - ); - const lowestDistance = Math.min( - ...Array.from(affectedStoryFiles.values(), ({ distance }) => distance) - ); + const affectedStoryFiles = reverseIndex.lookup(changedFile); + // Include the changed file as a story-at-distance-0 if it IS a story (parity with + // legacy trace-changed.ts:10-12). + const allEntries = new Map(affectedStoryFiles); + if (storyIdsByFile.has(changedFile)) { + allEntries.set(changedFile, 0); + } + if (allEntries.size === 0) { + continue; + } + const lowestDistance = Math.min(...allEntries.values()); - for (const [storyFile, { distance }] of affectedStoryFiles.entries()) { + for (const [storyFile, distance] of allEntries.entries()) { const storyIds = storyIdsByFile.get(storyFile); if (!storyIds) { continue; @@ -410,26 +552,21 @@ export class ChangeDetectionService { this.readinessResolved = true; setChangeDetectionReadiness(readiness); - } - private handleBuilderStartupEvent( - event: Exclude - ): void { - if (event.type === 'unavailable') { - logger.warn(`Change detection unavailable: ${event.reason}`); - this.resolveReadiness({ - status: 'unavailable', - reason: event.reason, - error: event.error, - }); - } else { - logger.error(`Change detection failed: ${event.error.message}`); - this.resolveReadiness({ - status: 'error', - error: event.error, - }); + if (readiness.status === 'ready') { + void this.writeBenchMarker(readiness.status); } + } - void this.dispose(); + private async writeBenchMarker(status: string): Promise { + const marker = process.env.STORYBOOK_BENCH_MARKER; + if (!marker) { + return; + } + try { + await writeFile(marker, `${JSON.stringify({ ts: Date.now(), status })}\n`, { flag: 'a' }); + } catch (e) { + logger.debug(`Failed to write bench marker: ${(e as Error).message}`); + } } } diff --git a/code/core/src/core-server/change-detection/adapters/index.ts b/code/core/src/core-server/change-detection/adapters/index.ts new file mode 100644 index 000000000000..0f1cb3cfe486 --- /dev/null +++ b/code/core/src/core-server/change-detection/adapters/index.ts @@ -0,0 +1 @@ +export type { ChangeDetectionAdapter, FileChangeEvent, ResolveConfig } from './types.ts'; diff --git a/code/core/src/core-server/change-detection/adapters/types.test.ts b/code/core/src/core-server/change-detection/adapters/types.test.ts new file mode 100644 index 000000000000..393a57ebd792 --- /dev/null +++ b/code/core/src/core-server/change-detection/adapters/types.test.ts @@ -0,0 +1,31 @@ +// Type-level smoke tests for the ChangeDetectionAdapter contract. +import { describe, expectTypeOf, it } from 'vitest'; + +import type { ChangeDetectionAdapter, FileChangeEvent, ResolveConfig } from './types.ts'; + +describe('ChangeDetectionAdapter types', () => { + it('FileChangeEvent is a discriminated union over add | change | unlink', () => { + expectTypeOf().toEqualTypeOf< + | { kind: 'add'; path: string } + | { kind: 'change'; path: string } + | { kind: 'unlink'; path: string } + >(); + }); + + it('ResolveConfig.alias accepts both Record and Array<{find, replacement}>', () => { + expectTypeOf>().toMatchTypeOf>(); + expectTypeOf>().toMatchTypeOf< + NonNullable + >(); + }); + + it('ChangeDetectionAdapter.getResolveConfig returns Promise', () => { + expectTypeOf< + ChangeDetectionAdapter['getResolveConfig'] + >().returns.resolves.toEqualTypeOf(); + }); + + it('ChangeDetectionAdapter.onFileChange returns an unsubscribe function', () => { + expectTypeOf().returns.toEqualTypeOf<() => void>(); + }); +}); diff --git a/code/core/src/core-server/change-detection/adapters/types.ts b/code/core/src/core-server/change-detection/adapters/types.ts new file mode 100644 index 000000000000..74ac1975a895 --- /dev/null +++ b/code/core/src/core-server/change-detection/adapters/types.ts @@ -0,0 +1,45 @@ +/** + * Builder-agnostic change-detection adapter contract. + * + * The detector is owned by `code/core/src/core-server/change-detection/` and never imports a + * builder package. Each builder ships its own `ChangeDetectionAdapter` implementation that + * (a) supplies static resolve config once at start, and (b) pushes file-system events as + * they occur. + * + * Builder-vite is the first consumer (see `code/builders/builder-vite/src/change-detection-adapter/`). + */ + +export type FileChangeEvent = + | { kind: 'add'; path: string } + | { kind: 'change'; path: string } + | { kind: 'unlink'; path: string }; + +export interface ResolveConfig { + /** Project root (where Storybook is started from). */ + projectRoot: string; + /** Resolved absolute path to the active tsconfig; oxc-resolver reads `paths`/`baseUrl` itself. */ + tsconfigPath?: string; + /** + * Builder-supplied alias map. Accepts both Vite shapes: + * - `Record` (object form) + * - `Array<{ find: string | RegExp; replacement: string }>` (array form, supports regex) + * + * Regex aliases that cannot be translated to oxc-resolver are downgraded to opaque-leaf + * with a debug log (R1 mitigation). Plain string aliases are forwarded as-is. + */ + alias?: Record | Array<{ find: string | RegExp; replacement: string }>; + /** + * Conditions for package `exports` resolution. Defaults to + * `['storybook', 'import', 'module', 'default']`. + */ + conditions?: string[]; +} + +export interface ChangeDetectionAdapter { + /** Pull: builder produces resolve-config once at start; detector caches it. */ + getResolveConfig(): Promise; + /** Push: builder reports file-system events; returns an unsubscribe function. */ + onFileChange(handler: (event: FileChangeEvent) => void): () => void; + /** Optional: builder reports a startup failure so the detector can mark itself unavailable. */ + onStartupFailure?(handler: (event: { reason: string; error?: Error }) => void): () => void; +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.test.ts b/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.test.ts new file mode 100644 index 000000000000..4dc65f30f67d --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.test.ts @@ -0,0 +1,311 @@ +// Tests the eager forward-walk performed by DependencyGraphBuilder. +// We stub readFile + ChangeDetectionResolverFactory, and drive the walker via a real +// ParserRegistry that dispatches to an in-memory test parser — no oxc binary needed. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readFile } from 'node:fs/promises'; + +import { logger } from 'storybook/internal/node-logger'; + +import { ParserRegistry } from '../parser-registry/index.ts'; +import type { ImportEdge, ImportParser } from '../parser-registry/index.ts'; +import { DependencyGraphBuilder } from './DependencyGraphBuilder.ts'; +import type { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); + +interface FakeWorld { + /** Source files that exist (path -> content). Content is irrelevant — parser is stubbed. */ + files: Set; + /** Per-file outgoing edges, keyed by absolute path. */ + edges: Map; + /** Per-(from, specifier) resolutions; absent → resolver returns null. */ + resolutions: Map; +} + +/** + * Builds a real ParserRegistry with a single test parser that claims the full set of + * walkable JS/TS/MDX extensions. The parser returns per-file precomputed edges from the + * FakeWorld, standing in for oxc/mdx parsing in these unit tests. + */ +function makeFakeRegistry(world: FakeWorld): ParserRegistry { + const testParser: ImportParser = { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mdx'], + parse: vi.fn(async ({ filePath }) => world.edges.get(filePath) ?? []), + }; + return new ParserRegistry({ + defaultParsers: [testParser], + pluginParsers: [], + }); +} + +function makeFakeResolver(world: FakeWorld): ChangeDetectionResolverFactory { + return { + resolve: vi.fn(async (from: string, specifier: string) => { + const key = `${from}::${specifier}`; + return world.resolutions.has(key) ? world.resolutions.get(key)! : null; + }), + } as unknown as ChangeDetectionResolverFactory; +} + +function setupFsReadOk(world: FakeWorld) { + vi.mocked(readFile).mockImplementation(async (path) => { + if (!world.files.has(String(path))) { + throw Object.assign(new Error(`ENOENT: ${String(path)}`), { code: 'ENOENT' }); + } + return ''; + }); +} + +describe('DependencyGraphBuilder', () => { + beforeEach(() => { + vi.mocked(logger.debug).mockImplementation(() => undefined); + vi.mocked(logger.warn).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty results for an empty story list', async () => { + const world: FakeWorld = { files: new Set(), edges: new Map(), resolutions: new Map() }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex, graph } = await builder.build([]); + + expect(reverseIndex.asMap().size).toBe(0); + expect(graph.size).toBe(0); + }); + + it('records a story with no imports at depth 0 and adds nothing else', async () => { + const story = '/repo/src/A.stories.tsx'; + const world: FakeWorld = { + files: new Set([story]), + edges: new Map([[story, []]]), + resolutions: new Map(), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + expect(reverseIndex.lookup(story).get(story)).toBe(0); + expect(reverseIndex.asMap().size).toBe(1); + }); + + it('skips CSS imports (they are not walkable)', async () => { + const story = '/repo/src/A.stories.tsx'; + const css = '/repo/src/styles.css'; + const world: FakeWorld = { + files: new Set([story, css]), + edges: new Map([[story, [{ specifier: './styles.css', kind: 'static' }]]]), + resolutions: new Map([[`${story}::./styles.css`, css]]), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + // CSS resolved into scope so the reverse index DOES record it at depth 1, but the walk + // does not recurse into it (no further calls to extractor on it). Verify only story root + // and the css leaf appear. + expect(reverseIndex.lookup(story).get(story)).toBe(0); + // CSS may be present at depth 1 (resolved into scope). Either is fine for this test — + // what matters is no transitive walk happened. + expect(reverseIndex.asMap().size).toBeLessThanOrEqual(2); + }); + + it('records sibling JS dep at depth 1 when imported by story', async () => { + const story = '/repo/src/A.stories.tsx'; + const sibling = '/repo/src/sibling.ts'; + const world: FakeWorld = { + files: new Set([story, sibling]), + edges: new Map([ + [story, [{ specifier: './sibling.ts', kind: 'static' }]], + [sibling, []], + ]), + resolutions: new Map([[`${story}::./sibling.ts`, sibling]]), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + expect(reverseIndex.lookup(sibling).get(story)).toBe(1); + }); + + it('walks into a workspace package (resolved into a workspaceRoot)', async () => { + const story = '/repo/src/A.stories.tsx'; + const wsMain = '/repo/packages/lib/src/index.ts'; + const world: FakeWorld = { + files: new Set([story, wsMain]), + edges: new Map([ + [story, [{ specifier: '@scope/lib', kind: 'static' }]], + [wsMain, []], + ]), + resolutions: new Map([[`${story}::@scope/lib`, wsMain]]), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(['/repo/packages/lib']), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + expect(reverseIndex.lookup(wsMain).get(story)).toBe(1); + }); + + it('does NOT walk into a regular node_modules package (resolved outside scope)', async () => { + const story = '/repo/src/A.stories.tsx'; + const npmMain = '/repo/node_modules/lodash/index.js'; + const npmTransitive = '/repo/node_modules/lodash/util.js'; + const registry = makeFakeRegistry({ + files: new Set(), + edges: new Map([ + [story, [{ specifier: 'lodash', kind: 'static' }]], + [npmMain, [{ specifier: './util', kind: 'static' }]], + ]), + resolutions: new Map(), + }); + const resolver = { + resolve: vi.fn(async (from: string, spec: string) => { + if (from === story && spec === 'lodash') { + return npmMain; + } + if (from === npmMain && spec === './util') { + return npmTransitive; + } + return null; + }), + } as unknown as ChangeDetectionResolverFactory; + setupFsReadOk({ + files: new Set([story, npmMain, npmTransitive]), + edges: new Map(), + resolutions: new Map(), + }); + const builder = new DependencyGraphBuilder({ + registry, + resolver, + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + // npmMain resolved out of scope — neither it nor its transitive dep should appear. + expect(reverseIndex.asMap().has(npmMain)).toBe(false); + expect(reverseIndex.asMap().has(npmTransitive)).toBe(false); + }); + + it('records two stories importing the same shared dep with their independent depths', async () => { + const a = '/repo/src/A.stories.tsx'; + const b = '/repo/src/B.stories.tsx'; + const intermediate = '/repo/src/intermediate.ts'; + const shared = '/repo/src/shared.ts'; + const world: FakeWorld = { + files: new Set([a, b, intermediate, shared]), + edges: new Map([ + [a, [{ specifier: './shared.ts', kind: 'static' }]], + [b, [{ specifier: './intermediate.ts', kind: 'static' }]], + [intermediate, [{ specifier: './shared.ts', kind: 'static' }]], + [shared, []], + ]), + resolutions: new Map([ + [`${a}::./shared.ts`, shared], + [`${b}::./intermediate.ts`, intermediate], + [`${intermediate}::./shared.ts`, shared], + ]), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([a, b]); + + const inner = reverseIndex.lookup(shared); + expect(inner.get(a)).toBe(1); + expect(inner.get(b)).toBe(2); + }); + + it('skips one specifier that fails to resolve but records the other edges from the same file', async () => { + const story = '/repo/src/A.stories.tsx'; + const ok = '/repo/src/ok.ts'; + const world: FakeWorld = { + files: new Set([story, ok]), + edges: new Map([ + [ + story, + [ + { specifier: './missing', kind: 'static' }, + { specifier: './ok.ts', kind: 'static' }, + ], + ], + [ok, []], + ]), + // Only the ./ok.ts specifier resolves; ./missing has no entry => null. + resolutions: new Map([[`${story}::./ok.ts`, ok]]), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + const { reverseIndex } = await builder.build([story]); + + expect(reverseIndex.lookup(ok).get(story)).toBe(1); + }); + + it('emits a debug log line on completion', async () => { + const story = '/repo/src/A.stories.tsx'; + const world: FakeWorld = { + files: new Set([story]), + edges: new Map([[story, []]]), + resolutions: new Map(), + }; + setupFsReadOk(world); + const builder = new DependencyGraphBuilder({ + registry: makeFakeRegistry(world), + resolver: makeFakeResolver(world), + workspaceRoots: new Set(), + projectRoot: '/repo', + }); + + await builder.build([story]); + + expect(vi.mocked(logger.debug)).toHaveBeenCalledWith( + expect.stringContaining('Change detection graph built:') + ); + }); +}); diff --git a/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.ts b/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.ts new file mode 100644 index 000000000000..f8c412ab6804 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/DependencyGraphBuilder.ts @@ -0,0 +1,195 @@ +import { readFile } from 'node:fs/promises'; +import { cpus } from 'node:os'; + +import { normalize } from 'pathe'; + +import { logger as defaultLogger } from 'storybook/internal/node-logger'; + +import type { ParserRegistry } from '../parser-registry/index.ts'; +import { ReverseIndexImpl } from './ReverseIndex.ts'; +import type { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; +import type { DependencyGraph, ImportEdge } from './types.ts'; + +interface BuilderLogger { + debug: (message: string) => void; + warn: (message: string) => void; +} + +interface BuilderOptions { + registry: ParserRegistry; + resolver: ChangeDetectionResolverFactory; + workspaceRoots: Set; + projectRoot: string; + logger?: BuilderLogger; + concurrency?: number; +} + +const NODE_MODULES_SEGMENT = '/node_modules/'; + +function isInsideAnyWorkspace(absolute: string, workspaceRoots: Set): boolean { + for (const root of workspaceRoots) { + if (absolute === root || absolute.startsWith(root.endsWith('/') ? root : `${root}/`)) { + return true; + } + } + return false; +} + +function isInScope(absolute: string, projectRoot: string, workspaceRoots: Set): boolean { + // (a) under projectRoot AND not under any node_modules path segment + const projectPrefix = projectRoot.endsWith('/') ? projectRoot : `${projectRoot}/`; + if ( + (absolute === projectRoot || absolute.startsWith(projectPrefix)) && + !absolute.includes(NODE_MODULES_SEGMENT) + ) { + return true; + } + // (b) under one of workspaceRoots (workspace packages live in node_modules typically — these + // are first-party packages we still want to walk into). + if (isInsideAnyWorkspace(absolute, workspaceRoots)) { + return true; + } + return false; +} + +/** + * Eagerly builds a {@link ReverseIndexImpl} + {@link DependencyGraph} by BFS-walking forward + * from each story file. Walks stop at workspace boundaries, non-JS extensions, or unresolved + * specifiers. All-or-nothing per file: a file's resolved subset is committed even if some of + * its imports failed. + */ +export class DependencyGraphBuilder { + private readonly registry: ParserRegistry; + private readonly resolver: ChangeDetectionResolverFactory; + private readonly workspaceRoots: Set; + private readonly projectRoot: string; + private readonly logger: BuilderLogger; + private readonly concurrency: number; + + constructor(opts: BuilderOptions) { + this.registry = opts.registry; + this.resolver = opts.resolver; + this.workspaceRoots = new Set(Array.from(opts.workspaceRoots, (r) => normalize(r))); + this.projectRoot = normalize(opts.projectRoot); + this.logger = opts.logger ?? defaultLogger; + this.concurrency = opts.concurrency ?? cpus().length * 2; + } + + async build( + storyFiles: Iterable + ): Promise<{ reverseIndex: ReverseIndexImpl; graph: DependencyGraph }> { + const startedAt = Date.now(); + const reverseIndex = new ReverseIndexImpl(); + const graph: DependencyGraph = new Map(); + const parseCache = new Map>(); + + const { default: pLimit } = await import('p-limit'); + const limit = pLimit(this.concurrency); + + const stories = Array.from(storyFiles, (s) => normalize(s)); + + await Promise.all( + stories.map((story) => + limit(() => this.walkFromStory(story, reverseIndex, graph, parseCache)) + ) + ); + + const elapsed = Date.now() - startedAt; + this.logger.debug( + `Change detection graph built: ${stories.length} stories, ${reverseIndex.asMap().size} deps tracked, ${elapsed}ms` + ); + + return { reverseIndex, graph }; + } + + private isWalkable(filePath: string): boolean { + return this.registry.parserFor(filePath) !== undefined; + } + + private async walkFromStory( + storyRoot: string, + reverseIndex: ReverseIndexImpl, + graph: DependencyGraph, + parseCache: Map> + ): Promise { + // Story root itself is recorded at depth 0. + reverseIndex.record(storyRoot, storyRoot, 0); + + // Per-story BFS visited map (dedupes within this story walk; tracks min depth). + const visited = new Map(); + visited.set(storyRoot, 0); + const queue: Array<{ file: string; depth: number }> = [{ file: storyRoot, depth: 0 }]; + let head = 0; + + while (head < queue.length) { + const { file, depth } = queue[head++]; + + if (!this.isWalkable(file)) { + continue; + } + + const edges = await this.parseOnce(file, parseCache); + if (!edges) { + continue; + } + + const resolvedDeps = graph.get(file) ?? new Set(); + for (const edge of edges) { + const resolved = await this.resolver.resolve(file, edge.specifier); + if (resolved === null) { + this.logger.warn(`Could not resolve ${edge.specifier} from ${file}`); + continue; + } + const normalised = normalize(resolved); + if (!isInScope(normalised, this.projectRoot, this.workspaceRoots)) { + // Out-of-scope (e.g. external node_modules) — opaque leaf, no walk. + continue; + } + + resolvedDeps.add(normalised); + + const nextDepth = depth + 1; + const previousDepth = visited.get(normalised); + if (previousDepth !== undefined && previousDepth <= nextDepth) { + // Already reached at equal-or-shorter depth; do not re-walk. + continue; + } + visited.set(normalised, nextDepth); + reverseIndex.record(normalised, storyRoot, nextDepth); + queue.push({ file: normalised, depth: nextDepth }); + } + graph.set(file, resolvedDeps); + } + } + + private parseOnce( + filePath: string, + parseCache: Map> + ): Promise { + const existing = parseCache.get(filePath); + if (existing) { + return existing; + } + const promise = (async (): Promise => { + let source: string; + try { + source = await readFile(filePath, 'utf8'); + } catch (error) { + this.logger.warn( + `Change detection: could not read ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + try { + return (await this.registry.parse(filePath, source)) ?? []; + } catch (error) { + this.logger.warn( + `Change detection: failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + })(); + parseCache.set(filePath, promise); + return promise; + } +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/IncrementalPatcher.ts b/code/core/src/core-server/change-detection/dependency-graph/IncrementalPatcher.ts new file mode 100644 index 000000000000..da41a1121e3a --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/IncrementalPatcher.ts @@ -0,0 +1,240 @@ +import { readFile } from 'node:fs/promises'; + +import { normalize } from 'pathe'; + +import { logger as defaultLogger } from 'storybook/internal/node-logger'; + +import type { FileChangeEvent } from '../adapters/types.ts'; +import type { ParserRegistry } from '../parser-registry/index.ts'; +import type { ReverseIndexImpl } from './ReverseIndex.ts'; +import type { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; +import type { DependencyGraph, ImportEdge } from './types.ts'; + +interface PatcherLogger { + debug: (message: string) => void; + warn: (message: string) => void; +} + +interface PatcherOptions { + reverseIndex: ReverseIndexImpl; + graph: DependencyGraph; + registry: ParserRegistry; + resolver: ChangeDetectionResolverFactory; + workspaceRoots: Set; + projectRoot: string; + logger?: PatcherLogger; + /** Set-style predicate: returns true if the path is a story-root file. */ + isStoryFile: (path: string) => boolean; +} + +const NODE_MODULES_SEGMENT = '/node_modules/'; + +function isInsideAnyWorkspace(absolute: string, workspaceRoots: Set): boolean { + for (const root of workspaceRoots) { + if (absolute === root || absolute.startsWith(root.endsWith('/') ? root : `${root}/`)) { + return true; + } + } + return false; +} + +function isInScope(absolute: string, projectRoot: string, workspaceRoots: Set): boolean { + const projectPrefix = projectRoot.endsWith('/') ? projectRoot : `${projectRoot}/`; + if ( + (absolute === projectRoot || absolute.startsWith(projectPrefix)) && + !absolute.includes(NODE_MODULES_SEGMENT) + ) { + return true; + } + if (isInsideAnyWorkspace(absolute, workspaceRoots)) { + return true; + } + return false; +} + +/** + * Applies a single {@link FileChangeEvent} to the live reverse index + graph. + * + * `patch()` ASSUMES the caller has serialised events behind any in-flight `build()`. The + * service that owns this patcher (see {@link ChangeDetectionService}) implements the + * queue-during-build pattern. + */ +export class IncrementalPatcher { + private readonly reverseIndex: ReverseIndexImpl; + private readonly graph: DependencyGraph; + private readonly registry: ParserRegistry; + private readonly resolver: ChangeDetectionResolverFactory; + private readonly workspaceRoots: Set; + private readonly projectRoot: string; + private readonly logger: PatcherLogger; + private readonly isStoryFile: (path: string) => boolean; + + constructor(opts: PatcherOptions) { + this.reverseIndex = opts.reverseIndex; + this.graph = opts.graph; + this.registry = opts.registry; + this.resolver = opts.resolver; + this.workspaceRoots = new Set(Array.from(opts.workspaceRoots, (r) => normalize(r))); + this.projectRoot = normalize(opts.projectRoot); + this.logger = opts.logger ?? defaultLogger; + this.isStoryFile = opts.isStoryFile; + } + + /** Apply a single FileChangeEvent. Idempotent — safe to call multiple times for the same event. */ + async patch(event: FileChangeEvent): Promise { + const path = normalize(event.path); + if (event.kind === 'add') { + if (this.isStoryFile(path)) { + await this.walkFromStory(path); + } + // non-story add: no-op until something imports it. + return; + } + + if (event.kind === 'unlink') { + // Stories that previously reached `path`. + const dependents = Array.from(this.reverseIndex.lookup(path).keys()); + this.graph.delete(path); + if (this.isStoryFile(path)) { + this.reverseIndex.removeStory(path); + } else { + // Remove path itself from index entries that referenced it. + for (const story of dependents) { + this.reverseIndex.removeEdge(path, story); + } + } + // Re-walk every story that previously reached `path` so transitive deps reachable only + // through `path` are pruned correctly. + for (const story of dependents) { + if (story === path) { + continue; + } + if (this.isStoryFile(story)) { + this.reverseIndex.removeStory(story); + await this.walkFromStory(story); + } + } + return; + } + + // 'change' + const previousDeps = this.graph.get(path) ?? new Set(); + const newEdges = await this.parseFile(path); + const newDeps = new Set(); + if (newEdges) { + for (const edge of newEdges) { + const resolved = await this.resolver.resolve(path, edge.specifier); + if (resolved === null) { + this.logger.warn(`Could not resolve ${edge.specifier} from ${path}`); + continue; + } + const normalised = normalize(resolved); + if (!isInScope(normalised, this.projectRoot, this.workspaceRoots)) { + continue; + } + newDeps.add(normalised); + } + } + this.graph.set(path, newDeps); + + // Stories whose dependency-set reaches `path`. + const dependents = Array.from(this.reverseIndex.lookup(path).keys()); + if (this.isStoryFile(path) && !dependents.includes(path)) { + dependents.push(path); + } + if (dependents.length === 0) { + return; + } + + const removedDeps = new Set(); + for (const dep of previousDeps) { + if (!newDeps.has(dep)) { + removedDeps.add(dep); + } + } + + // For each story that reaches `path`: prune obsolete (dep, story) edges and re-walk to + // recompute depths through the new outgoing-edge set. + for (const story of dependents) { + for (const removedDep of removedDeps) { + this.reverseIndex.removeEdge(removedDep, story); + } + if (this.isStoryFile(story)) { + // Conservative re-walk: clear the story's contribution from index then redo BFS. + this.reverseIndex.removeStory(story); + await this.walkFromStory(story); + } + } + } + + private isWalkable(filePath: string): boolean { + return this.registry.parserFor(filePath) !== undefined; + } + + /** BFS from a story root, recording depth into the reverse index and updating graph entries. */ + private async walkFromStory(storyRoot: string): Promise { + this.reverseIndex.record(storyRoot, storyRoot, 0); + + const visited = new Map(); + visited.set(storyRoot, 0); + const queue: Array<{ file: string; depth: number }> = [{ file: storyRoot, depth: 0 }]; + let head = 0; + + while (head < queue.length) { + const { file, depth } = queue[head++]; + + if (!this.isWalkable(file)) { + continue; + } + + const edges = await this.parseFile(file); + if (!edges) { + continue; + } + + const resolvedDeps = this.graph.get(file) ?? new Set(); + for (const edge of edges) { + const resolved = await this.resolver.resolve(file, edge.specifier); + if (resolved === null) { + this.logger.warn(`Could not resolve ${edge.specifier} from ${file}`); + continue; + } + const normalised = normalize(resolved); + if (!isInScope(normalised, this.projectRoot, this.workspaceRoots)) { + continue; + } + resolvedDeps.add(normalised); + + const nextDepth = depth + 1; + const previous = visited.get(normalised); + if (previous !== undefined && previous <= nextDepth) { + continue; + } + visited.set(normalised, nextDepth); + this.reverseIndex.record(normalised, storyRoot, nextDepth); + queue.push({ file: normalised, depth: nextDepth }); + } + this.graph.set(file, resolvedDeps); + } + } + + private async parseFile(filePath: string): Promise { + let source: string; + try { + source = await readFile(filePath, 'utf8'); + } catch (error) { + this.logger.warn( + `Change detection: could not read ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + try { + return (await this.registry.parse(filePath, source)) ?? []; + } catch (error) { + this.logger.warn( + `Change detection: failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts b/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts new file mode 100644 index 000000000000..8fc11a23591b --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts @@ -0,0 +1,97 @@ +import { ResolverFactory as OxcResolverFactory } from 'oxc-resolver'; +import { dirname } from 'pathe'; + +import { logger } from 'storybook/internal/node-logger'; + +import type { ResolveConfig } from '../adapters/types.ts'; + +const DEFAULT_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json']; +const DEFAULT_CONDITIONS = ['storybook', 'import', 'module', 'default']; + +/** + * Thin wrapper around `oxc-resolver`'s `ResolverFactory` configured per the + * change-detection `ResolveConfig`. Normalises the alias map shape and converts + * resolver errors to `null` (with a debug log) — the caller treats unresolvable + * specifiers as opaque-leaf edges. + */ +export class ChangeDetectionResolverFactory { + private readonly factory: OxcResolverFactory; + + constructor(config: ResolveConfig) { + const alias = normaliseAlias(config.alias); + const conditionNames = config.conditions ?? DEFAULT_CONDITIONS; + + this.factory = new OxcResolverFactory({ + tsconfig: config.tsconfigPath + ? { configFile: config.tsconfigPath, references: 'auto' } + : undefined, + alias, + conditionNames, + extensions: DEFAULT_EXTENSIONS, + }); + } + + /** + * Resolves `specifier` from the file at `from` (must be an absolute path). + * Returns the absolute resolved path, or `null` if the resolver could not + * locate it. Never throws — internal errors are converted to `null` and a + * debug-level log line is emitted. + */ + async resolve(from: string, specifier: string): Promise { + const directory = dirname(from); + try { + const result = await this.factory.async(directory, specifier); + if (result.path) { + return result.path; + } + if (result.error) { + logger.debug( + `ChangeDetectionResolverFactory: '${specifier}' from '${from}' unresolved (${result.error})` + ); + } + return null; + } catch (error) { + logger.debug( + `ChangeDetectionResolverFactory: error resolving '${specifier}' from '${from}': ${String(error)}` + ); + return null; + } + } +} + +/** + * `ResolveConfig.alias` accepts both Vite shapes: + * - `Record` (object form) + * - `Array<{ find: string | RegExp; replacement: string }>` (array form) + * + * `oxc-resolver` expects `Record>`. + * RegExp `find` entries cannot be expressed in oxc-resolver's alias config and + * are skipped with a debug log (downgraded to opaque-leaf at resolve time). + */ +function normaliseAlias( + alias: ResolveConfig['alias'] +): Record> | undefined { + if (!alias) { + return undefined; + } + + const out: Record> = {}; + + if (Array.isArray(alias)) { + for (const entry of alias) { + if (typeof entry.find === 'string') { + out[entry.find] = [entry.replacement]; + } else { + logger.debug( + `ChangeDetectionResolverFactory: skipping regex alias '${String(entry.find)}' (not supported by oxc-resolver)` + ); + } + } + } else { + for (const [find, replacement] of Object.entries(alias)) { + out[find] = [replacement]; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.test.ts b/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.test.ts new file mode 100644 index 000000000000..6318afd90dc5 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.test.ts @@ -0,0 +1,111 @@ +// Replaces trace-changed.test.ts. Covers the construction + mutation surface of +// ReverseIndexImpl (per ADR-F + §5 of the consensus plan). +import { describe, expect, it } from 'vitest'; + +import { ReverseIndexImpl } from './ReverseIndex.ts'; + +describe('ReverseIndexImpl', () => { + it('returns an empty Map (not undefined) when looking up an unknown dep', () => { + const index = new ReverseIndexImpl(); + + const result = index.lookup('/repo/src/foo.ts'); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it('records (dep, story, depth) and surfaces the depth via lookup', () => { + const index = new ReverseIndexImpl(); + + index.record('/repo/src/foo.ts', '/repo/src/Foo.stories.tsx', 1); + + const result = index.lookup('/repo/src/foo.ts'); + expect(result.get('/repo/src/Foo.stories.tsx')).toBe(1); + }); + + it('keeps min(existing, depth) when the same (dep, story) is recorded twice with different depths', () => { + const index = new ReverseIndexImpl(); + + index.record('/repo/src/foo.ts', '/repo/src/A.stories.tsx', 3); + index.record('/repo/src/foo.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/foo.ts', '/repo/src/A.stories.tsx', 2); + + expect(index.lookup('/repo/src/foo.ts').get('/repo/src/A.stories.tsx')).toBe(1); + }); + + it('records the same dep against multiple stories and surfaces both via lookup', () => { + const index = new ReverseIndexImpl(); + + index.record('/repo/src/shared.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/shared.ts', '/repo/src/B.stories.tsx', 2); + + const result = index.lookup('/repo/src/shared.ts'); + expect(result.size).toBe(2); + expect(result.get('/repo/src/A.stories.tsx')).toBe(1); + expect(result.get('/repo/src/B.stories.tsx')).toBe(2); + }); + + it('removeStory deletes the story from every inner map and prunes outer entries that become empty', () => { + const index = new ReverseIndexImpl(); + index.record('/repo/src/x.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/x.ts', '/repo/src/B.stories.tsx', 2); + index.record('/repo/src/y.ts', '/repo/src/A.stories.tsx', 1); + + index.removeStory('/repo/src/A.stories.tsx'); + + // /repo/src/y.ts had only A — should be pruned. + expect(index.asMap().has('/repo/src/y.ts')).toBe(false); + // /repo/src/x.ts retained for B; A removed. + const inner = index.lookup('/repo/src/x.ts'); + expect(inner.size).toBe(1); + expect(inner.get('/repo/src/B.stories.tsx')).toBe(2); + expect(inner.has('/repo/src/A.stories.tsx')).toBe(false); + }); + + it('removeEdge removes only the (dep, story) pair without touching other stories', () => { + const index = new ReverseIndexImpl(); + index.record('/repo/src/x.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/x.ts', '/repo/src/B.stories.tsx', 2); + + index.removeEdge('/repo/src/x.ts', '/repo/src/A.stories.tsx'); + + const inner = index.lookup('/repo/src/x.ts'); + expect(inner.has('/repo/src/A.stories.tsx')).toBe(false); + expect(inner.get('/repo/src/B.stories.tsx')).toBe(2); + }); + + it('removeEdge prunes the outer entry when it becomes empty', () => { + const index = new ReverseIndexImpl(); + index.record('/repo/src/x.ts', '/repo/src/A.stories.tsx', 1); + + index.removeEdge('/repo/src/x.ts', '/repo/src/A.stories.tsx'); + + expect(index.asMap().has('/repo/src/x.ts')).toBe(false); + }); + + it('handles cycles by retaining the minimum depth across multiple recordings', () => { + // A imports B (depth 1); B imports C (depth 2); C imports B (would be depth 3). + // Whatever order build() visits in, recording min keeps depth 1 for B and 2 for C from story A. + const index = new ReverseIndexImpl(); + index.record('/repo/src/B.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/C.ts', '/repo/src/A.stories.tsx', 2); + // Cycle re-entry attempts: + index.record('/repo/src/B.ts', '/repo/src/A.stories.tsx', 3); + index.record('/repo/src/C.ts', '/repo/src/A.stories.tsx', 4); + + expect(index.lookup('/repo/src/B.ts').get('/repo/src/A.stories.tsx')).toBe(1); + expect(index.lookup('/repo/src/C.ts').get('/repo/src/A.stories.tsx')).toBe(2); + }); + + it('normalises dep paths so equivalent path forms collapse to the same key', () => { + const index = new ReverseIndexImpl(); + // pathe.normalize collapses './' and double-slash style noise. We use './foo' vs 'foo' + // segments to verify two forms reach the same internal key. + index.record('/repo/src/Foo/bar.ts', '/repo/src/A.stories.tsx', 1); + index.record('/repo/src/Foo/./bar.ts', '/repo/src/A.stories.tsx', 2); + + // Both writes targeted the same normalised key — record() kept the lower depth (1). + expect(index.lookup('/repo/src/Foo/bar.ts').get('/repo/src/A.stories.tsx')).toBe(1); + expect(index.lookup('/repo/src/Foo/./bar.ts').get('/repo/src/A.stories.tsx')).toBe(1); + }); +}); diff --git a/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.ts b/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.ts new file mode 100644 index 000000000000..ead1b7c81c66 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/ReverseIndex.ts @@ -0,0 +1,62 @@ +import { normalize } from 'pathe'; + +import type { ReverseIndex } from './types.ts'; + +/** + * In-memory reverse index from dep file → story file → shortest BFS depth. + * + * Path keys are normalised defensively via `pathe.normalize` on every mutation; + * callers SHOULD normalise too (defence in depth). + */ +export class ReverseIndexImpl { + private readonly index: ReverseIndex = new Map(); + + /** Records (or updates with min) the depth for (dep, story). */ + record(dep: string, story: string, depth: number): void { + const depKey = normalize(dep); + const storyKey = normalize(story); + let inner = this.index.get(depKey); + if (!inner) { + inner = new Map(); + this.index.set(depKey, inner); + } + const previous = inner.get(storyKey); + if (previous === undefined || depth < previous) { + inner.set(storyKey, depth); + } + } + + /** Removes a story from every inner map; prunes outer entries that become empty. */ + removeStory(story: string): void { + const storyKey = normalize(story); + for (const [depKey, inner] of this.index) { + if (inner.delete(storyKey) && inner.size === 0) { + this.index.delete(depKey); + } + } + } + + /** Removes a single (dep, story) pair without affecting other stories' depths to that dep. */ + removeEdge(dep: string, story: string): void { + const depKey = normalize(dep); + const storyKey = normalize(story); + const inner = this.index.get(depKey); + if (!inner) { + return; + } + if (inner.delete(storyKey) && inner.size === 0) { + this.index.delete(depKey); + } + } + + /** Returns the per-story depth map for dep. EMPTY map (not undefined) if dep unknown. */ + lookup(dep: string): Map { + const depKey = normalize(dep); + return this.index.get(depKey) ?? new Map(); + } + + /** Internal state inspection — for tests. Returns the underlying Map. */ + asMap(): ReverseIndex { + return this.index; + } +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.test.ts b/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.test.ts new file mode 100644 index 000000000000..53926bfcf2ae --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.test.ts @@ -0,0 +1,165 @@ +// Covers the YAML/JSON parsing + tinyglobby expansion surface of WorkspaceLocator. +// Mocks `node:fs/promises` and `tinyglobby` so the test does not depend on the +// host file system layout. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { access, readFile } from 'node:fs/promises'; + +import { glob } from 'tinyglobby'; + +import { WorkspaceLocator } from './WorkspaceLocator.ts'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('tinyglobby', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); + +interface FileSystemFixture { + files: Map; + matches: Map; +} + +function setupFs(fixture: FileSystemFixture) { + vi.mocked(readFile).mockImplementation(async (path) => { + const key = String(path); + const content = fixture.files.get(key); + if (content === undefined) { + throw Object.assign(new Error(`ENOENT: ${key}`), { code: 'ENOENT' }); + } + return content; + }); + vi.mocked(access).mockImplementation(async (path) => { + const key = String(path); + if (!fixture.files.has(key)) { + throw Object.assign(new Error(`ENOENT: ${key}`), { code: 'ENOENT' }); + } + }); + vi.mocked(glob).mockImplementation(async (patterns) => { + const key = JSON.stringify(patterns); + return fixture.matches.get(key) ?? []; + }); +} + +describe('WorkspaceLocator', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('expands array-form workspaces via tinyglobby', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ workspaces: ['packages/*'] })], + ['/repo/packages/a/package.json', '{}'], + ['/repo/packages/b/package.json', '{}'], + ]), + matches: new Map([ + [JSON.stringify(['packages/*']), ['/repo/packages/a', '/repo/packages/b']], + ]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set(['/repo/packages/a', '/repo/packages/b'])); + }); + + it('expands object-form workspaces.packages', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ workspaces: { packages: ['packages/*'] } })], + ['/repo/packages/a/package.json', '{}'], + ]), + matches: new Map([[JSON.stringify(['packages/*']), ['/repo/packages/a']]]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set(['/repo/packages/a'])); + }); + + it('returns an empty Set when there is no workspaces field and no pnpm-workspace.yaml', async () => { + setupFs({ + files: new Map([['/repo/package.json', JSON.stringify({ name: 'root' })]]), + matches: new Map(), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set()); + }); + + it('falls back to pnpm-workspace.yaml when no workspaces field is present', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ name: 'root' })], + ['/repo/pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`], + ['/repo/apps/web/package.json', '{}'], + ]), + matches: new Map([[JSON.stringify(['apps/*']), ['/repo/apps/web']]]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set(['/repo/apps/web'])); + }); + + it('prefers yarn workspaces when both workspaces field and pnpm-workspace.yaml are present', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ workspaces: ['packages/*'] })], + ['/repo/pnpm-workspace.yaml', `packages:\n - 'apps/*'\n`], + ['/repo/packages/a/package.json', '{}'], + ['/repo/apps/web/package.json', '{}'], + ]), + matches: new Map([ + [JSON.stringify(['packages/*']), ['/repo/packages/a']], + [JSON.stringify(['apps/*']), ['/repo/apps/web']], + ]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set(['/repo/packages/a'])); + }); + + it('filters out matched directories that do not contain a package.json', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ workspaces: ['packages/*'] })], + ['/repo/packages/a/package.json', '{}'], + // /repo/packages/b is matched by glob but has no package.json — filtered out. + ]), + matches: new Map([ + [JSON.stringify(['packages/*']), ['/repo/packages/a', '/repo/packages/b']], + ]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + expect(roots).toEqual(new Set(['/repo/packages/a'])); + }); + + it('returns absolute, normalised paths', async () => { + setupFs({ + files: new Map([ + ['/repo/package.json', JSON.stringify({ workspaces: ['packages/*'] })], + ['/repo/packages/a/package.json', '{}'], + ]), + matches: new Map([[JSON.stringify(['packages/*']), ['/repo/packages/./a']]]), + }); + + const locator = new WorkspaceLocator('/repo'); + const roots = await locator.locate(); + + // pathe.normalize collapses '/./' and yields '/repo/packages/a'. + expect(roots).toEqual(new Set(['/repo/packages/a'])); + }); +}); diff --git a/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.ts b/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.ts new file mode 100644 index 000000000000..deb4c9512da1 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/WorkspaceLocator.ts @@ -0,0 +1,127 @@ +import { access, readFile } from 'node:fs/promises'; + +import { join, normalize } from 'pathe'; +import { glob } from 'tinyglobby'; +import { parse as parseYaml } from 'yaml'; + +import { logger } from 'storybook/internal/node-logger'; + +interface RootPackageJson { + workspaces?: string[] | { packages?: string[] }; +} + +interface PnpmWorkspaceFile { + packages?: string[]; +} + +/** + * Locates workspace package roots in the project. Reads the root `package.json` + * `workspaces` field (string[] OR `{packages: string[]}`); falls back to + * `pnpm-workspace.yaml` if no workspaces field is present. + * + * Returns absolute, normalised paths to directories that contain a `package.json`. + * Returns an empty `Set` if no workspaces are configured. + */ +export class WorkspaceLocator { + private readonly projectRoot: string; + + constructor(projectRoot: string) { + this.projectRoot = normalize(projectRoot); + } + + async locate(): Promise> { + const patterns = await this.collectPatterns(); + if (patterns.length === 0) { + return new Set(); + } + + const matches = await glob(patterns, { + cwd: this.projectRoot, + onlyDirectories: true, + dot: false, + absolute: true, + }); + + const roots = new Set(); + await Promise.all( + matches.map(async (matchPath) => { + const normalised = normalize(matchPath); + const pkgPath = join(normalised, 'package.json'); + if (await fileExists(pkgPath)) { + roots.add(normalised); + } + }) + ); + + return roots; + } + + private async collectPatterns(): Promise { + const fromPackageJson = await this.readWorkspacesField(); + if (fromPackageJson.length > 0) { + return fromPackageJson; + } + return this.readPnpmWorkspaceYaml(); + } + + private async readWorkspacesField(): Promise { + const pkgPath = join(this.projectRoot, 'package.json'); + let raw: string; + try { + raw = await readFile(pkgPath, 'utf8'); + } catch (error) { + logger.debug(`WorkspaceLocator: no root package.json at '${pkgPath}': ${String(error)}`); + return []; + } + + let parsed: RootPackageJson; + try { + parsed = JSON.parse(raw) as RootPackageJson; + } catch (error) { + logger.debug(`WorkspaceLocator: failed to parse root package.json: ${String(error)}`); + return []; + } + + const workspaces = parsed.workspaces; + if (Array.isArray(workspaces)) { + return workspaces; + } + if (workspaces && Array.isArray(workspaces.packages)) { + return workspaces.packages; + } + return []; + } + + private async readPnpmWorkspaceYaml(): Promise { + const yamlPath = join(this.projectRoot, 'pnpm-workspace.yaml'); + let raw: string; + try { + raw = await readFile(yamlPath, 'utf8'); + } catch (error) { + logger.debug(`WorkspaceLocator: no pnpm-workspace.yaml at '${yamlPath}': ${String(error)}`); + return []; + } + + let parsed: PnpmWorkspaceFile | null; + try { + parsed = parseYaml(raw) as PnpmWorkspaceFile | null; + } catch (error) { + logger.debug(`WorkspaceLocator: failed to parse pnpm-workspace.yaml: ${String(error)}`); + return []; + } + + if (parsed && Array.isArray(parsed.packages)) { + return parsed.packages; + } + return []; + } +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/incremental-patch.test.ts b/code/core/src/core-server/change-detection/dependency-graph/incremental-patch.test.ts new file mode 100644 index 000000000000..84dbf9d4485a --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/incremental-patch.test.ts @@ -0,0 +1,299 @@ +// Covers the IncrementalPatcher behaviour for add/change/unlink events. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readFile } from 'node:fs/promises'; + +import { logger } from 'storybook/internal/node-logger'; + +import { ParserRegistry } from '../parser-registry/index.ts'; +import type { ImportEdge, ImportParser } from '../parser-registry/index.ts'; +import { IncrementalPatcher } from './IncrementalPatcher.ts'; +import type { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; +import { ReverseIndexImpl } from './ReverseIndex.ts'; +import type { DependencyGraph } from './types.ts'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); + +interface PatcherWorld { + edges: Map; + resolutions: Map; +} + +interface TestRegistry { + registry: ParserRegistry; + parseSpy: ReturnType; +} + +/** + * Build a real ParserRegistry with a single test parser that reads precomputed edges out + * of the PatcherWorld. Exposes the parse spy so tests can assert how many times a path + * was dispatched to the parser. + */ +function makeRegistry(world: PatcherWorld): TestRegistry { + const parseSpy = vi.fn(async ({ filePath }: { filePath: string }) => { + return world.edges.get(filePath) ?? []; + }); + const testParser: ImportParser = { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mdx'], + parse: parseSpy, + }; + const registry = new ParserRegistry({ + defaultParsers: [testParser], + pluginParsers: [], + }); + return { registry, parseSpy }; +} + +function makeResolver(world: PatcherWorld): ChangeDetectionResolverFactory { + return { + resolve: vi.fn(async (from: string, specifier: string) => { + const key = `${from}::${specifier}`; + return world.resolutions.has(key) ? world.resolutions.get(key)! : null; + }), + } as unknown as ChangeDetectionResolverFactory; +} + +function setupReadFile() { + vi.mocked(readFile).mockImplementation(async () => ''); +} + +function buildPatcher(opts: { + reverseIndex?: ReverseIndexImpl; + graph?: DependencyGraph; + storyFiles?: Set; + world: PatcherWorld; +}): { + patcher: IncrementalPatcher; + reverseIndex: ReverseIndexImpl; + graph: DependencyGraph; + parseSpy: ReturnType; +} { + const reverseIndex = opts.reverseIndex ?? new ReverseIndexImpl(); + const graph = opts.graph ?? new Map(); + const storyFiles = opts.storyFiles ?? new Set(); + const { registry, parseSpy } = makeRegistry(opts.world); + const resolver = makeResolver(opts.world); + const patcher = new IncrementalPatcher({ + reverseIndex, + graph, + registry, + resolver, + workspaceRoots: new Set(), + projectRoot: '/repo', + isStoryFile: (path: string) => storyFiles.has(path), + }); + return { patcher, reverseIndex, graph, parseSpy }; +} + +describe('IncrementalPatcher', () => { + beforeEach(() => { + setupReadFile(); + vi.mocked(logger.debug).mockImplementation(() => undefined); + vi.mocked(logger.warn).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('treats a `change` event for an unknown non-story file as a no-op', async () => { + const { patcher, reverseIndex, graph, parseSpy } = buildPatcher({ + world: { edges: new Map(), resolutions: new Map() }, + }); + + await patcher.patch({ kind: 'change', path: '/repo/src/unknown.ts' }); + + // No previous deps, no dependents — graph + reverseIndex unchanged for this path + // (other than the empty entry the patcher creates for graph). + expect(reverseIndex.asMap().size).toBe(0); + // The patcher does call the registry parser on the changed file once (per A3 contract). + expect(parseSpy).toHaveBeenCalledTimes(1); + expect(graph.get('/repo/src/unknown.ts')).toEqual(new Set()); + }); + + it('re-walks a story root on `change`, updating depths', async () => { + const story = '/repo/src/A.stories.tsx'; + const dep = '/repo/src/dep.ts'; + const world: PatcherWorld = { + edges: new Map([ + [story, [{ specifier: './dep.ts', kind: 'static' }]], + [dep, []], + ]), + resolutions: new Map([[`${story}::./dep.ts`, dep]]), + }; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + const { patcher, reverseIndex } = buildPatcher({ + world, + reverseIndex: initialIndex, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'change', path: story }); + + expect(reverseIndex.lookup(dep).get(story)).toBe(1); + }); + + it('removes (oldDep, story) edges when an import disappears on `change`', async () => { + const story = '/repo/src/A.stories.tsx'; + const oldDep = '/repo/src/old.ts'; + const newDep = '/repo/src/new.ts'; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + initialIndex.record(oldDep, story, 1); + const initialGraph: DependencyGraph = new Map([[story, new Set([oldDep])]]); + const world: PatcherWorld = { + edges: new Map([ + [story, [{ specifier: './new.ts', kind: 'static' }]], + [newDep, []], + ]), + resolutions: new Map([[`${story}::./new.ts`, newDep]]), + }; + const { patcher, reverseIndex } = buildPatcher({ + world, + reverseIndex: initialIndex, + graph: initialGraph, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'change', path: story }); + + expect(reverseIndex.asMap().has(oldDep)).toBe(false); + expect(reverseIndex.lookup(newDep).get(story)).toBe(1); + }); + + it('adds NEW edges with the correct depth on `change`', async () => { + const story = '/repo/src/A.stories.tsx'; + const newDep = '/repo/src/added.ts'; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + const initialGraph: DependencyGraph = new Map([[story, new Set()]]); + const world: PatcherWorld = { + edges: new Map([ + [story, [{ specifier: './added.ts', kind: 'static' }]], + [newDep, []], + ]), + resolutions: new Map([[`${story}::./added.ts`, newDep]]), + }; + const { patcher, reverseIndex } = buildPatcher({ + world, + reverseIndex: initialIndex, + graph: initialGraph, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'change', path: story }); + + expect(reverseIndex.lookup(newDep).get(story)).toBe(1); + }); + + it('removes a story root on `unlink` and clears its reverse-index contribution', async () => { + const story = '/repo/src/A.stories.tsx'; + const dep = '/repo/src/dep.ts'; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + initialIndex.record(dep, story, 1); + const initialGraph: DependencyGraph = new Map([[story, new Set([dep])]]); + const { patcher, reverseIndex, graph } = buildPatcher({ + world: { edges: new Map(), resolutions: new Map() }, + reverseIndex: initialIndex, + graph: initialGraph, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'unlink', path: story }); + + expect(reverseIndex.asMap().has(dep)).toBe(false); + expect(reverseIndex.asMap().has(story)).toBe(false); + expect(graph.has(story)).toBe(false); + }); + + it('on `unlink` of a non-story dep, re-walks every story that previously reached it', async () => { + const story = '/repo/src/A.stories.tsx'; + const dep = '/repo/src/dep.ts'; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + initialIndex.record(dep, story, 1); + const initialGraph: DependencyGraph = new Map([ + [story, new Set([dep])], + [dep, new Set()], + ]); + const world: PatcherWorld = { + edges: new Map([[story, []]]), + resolutions: new Map(), + }; + const { patcher, reverseIndex } = buildPatcher({ + world, + reverseIndex: initialIndex, + graph: initialGraph, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'unlink', path: dep }); + + // dep removed from index; story re-walked with no deps so only story@0 remains. + expect(reverseIndex.asMap().has(dep)).toBe(false); + expect(reverseIndex.lookup(story).get(story)).toBe(0); + }); + + it('runs full BFS for an `add` event for a new story root', async () => { + const story = '/repo/src/New.stories.tsx'; + const dep = '/repo/src/dep.ts'; + const world: PatcherWorld = { + edges: new Map([ + [story, [{ specifier: './dep.ts', kind: 'static' }]], + [dep, []], + ]), + resolutions: new Map([[`${story}::./dep.ts`, dep]]), + }; + const { patcher, reverseIndex } = buildPatcher({ + world, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'add', path: story }); + + expect(reverseIndex.lookup(story).get(story)).toBe(0); + expect(reverseIndex.lookup(dep).get(story)).toBe(1); + }); + + it('treats `add` for a non-story file as a no-op', async () => { + const file = '/repo/src/loose.ts'; + const world: PatcherWorld = { edges: new Map(), resolutions: new Map() }; + const { patcher, reverseIndex, parseSpy } = buildPatcher({ + world, + storyFiles: new Set(), + }); + + await patcher.patch({ kind: 'add', path: file }); + + expect(reverseIndex.asMap().size).toBe(0); + expect(parseSpy).not.toHaveBeenCalled(); + }); + + it('A3 acceptance: registry parse called exactly once for the changed file', async () => { + const story = '/repo/src/A.stories.tsx'; + const initialIndex = new ReverseIndexImpl(); + initialIndex.record(story, story, 0); + const world: PatcherWorld = { + edges: new Map([[story, []]]), + resolutions: new Map(), + }; + const { patcher, parseSpy } = buildPatcher({ + world, + reverseIndex: initialIndex, + storyFiles: new Set([story]), + }); + + await patcher.patch({ kind: 'change', path: story }); + + // Once for the change-step itself, plus once during the re-walk of the same story. + // The contract says `parse` is called exactly once for each file participating; for a + // story root with no deps, that means a single re-walk visits only the root => one parse. + // Allow up to 2 (one for the diff, one for the re-walk) but assert it's bounded. + const calls = parseSpy.mock.calls.filter((c) => c[0].filePath === story); + expect(calls.length).toBeGreaterThanOrEqual(1); + expect(calls.length).toBeLessThanOrEqual(2); + }); +}); diff --git a/code/core/src/core-server/change-detection/dependency-graph/index.ts b/code/core/src/core-server/change-detection/dependency-graph/index.ts new file mode 100644 index 000000000000..971be8230984 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/index.ts @@ -0,0 +1,6 @@ +export type { ImportEdge, ReverseIndex, DependencyGraph } from './types.ts'; +export { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; +export { WorkspaceLocator } from './WorkspaceLocator.ts'; +export { ReverseIndexImpl } from './ReverseIndex.ts'; +export { DependencyGraphBuilder } from './DependencyGraphBuilder.ts'; +export { IncrementalPatcher } from './IncrementalPatcher.ts'; diff --git a/code/core/src/core-server/change-detection/dependency-graph/types.ts b/code/core/src/core-server/change-detection/dependency-graph/types.ts new file mode 100644 index 000000000000..736b8ad93d84 --- /dev/null +++ b/code/core/src/core-server/change-detection/dependency-graph/types.ts @@ -0,0 +1,15 @@ +import type { ImportEdge } from '../parser-registry/index.ts'; + +export type { ImportEdge }; + +/** + * Reverse index from dep file path → story file → shortest forward-walk depth from that story. + * Inner number preserves the BFS hop-count semantics used by `ChangeDetectionService.buildStatuses` + * to distinguish `modified` (closest stories) from `affected` (farther stories). + * + * Keys are absolute paths normalised via `pathe.normalize`. + */ +export type ReverseIndex = Map>; + +/** Per-file outgoing edges, used by IncrementalPatcher to compute diffs. */ +export type DependencyGraph = Map>; diff --git a/code/core/src/core-server/change-detection/index.ts b/code/core/src/core-server/change-detection/index.ts index b0b545e148ba..677ac67ccadd 100644 --- a/code/core/src/core-server/change-detection/index.ts +++ b/code/core/src/core-server/change-detection/index.ts @@ -6,3 +6,11 @@ export { type ChangeDetectionReadiness, } from './readiness.ts'; export { ChangeDetectionService } from './ChangeDetectionService.ts'; +export type { ChangeDetectionAdapter, FileChangeEvent, ResolveConfig } from './adapters/index.ts'; +export { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; +export type { + ImportEdge, + ImportParser, + ImportParserContext, + ParseFileArgs, +} from './parser-registry/index.ts'; diff --git a/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.test.ts b/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.test.ts new file mode 100644 index 000000000000..dc96f43e71a5 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { logger } from 'storybook/internal/node-logger'; + +import { ParserRegistry } from './ParserRegistry.ts'; +import { builtinImportParsers } from './builtins.ts'; +import type { ImportParser } from './types.ts'; + +vi.mock('storybook/internal/node-logger', { spy: true }); + +describe('ParserRegistry', () => { + beforeEach(() => { + vi.mocked(logger.debug).mockImplementation(() => undefined); + }); + + it('registers default parsers and looks them up by extension', () => { + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers: [], + }); + + expect(registry.parserFor('/tmp/a.ts')).toBeDefined(); + expect(registry.parserFor('/tmp/a.tsx')).toBeDefined(); + expect(registry.parserFor('/tmp/a.mdx')).toBeDefined(); + }); + + it('lets a plugin parser override a default parser and logs a debug line', () => { + const pluginParse = vi.fn(async () => []); + const plugin: ImportParser = { + extensions: ['.ts'], + parse: pluginParse, + }; + + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers: [plugin], + }); + + expect(registry.parserFor('/tmp/a.ts')).toBe(pluginParse); + expect(vi.mocked(logger.debug)).toHaveBeenCalledWith( + expect.stringContaining('.ts parser overridden') + ); + }); + + it('returns undefined from parserFor for an unknown extension', () => { + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers: [], + }); + + expect(registry.parserFor('/tmp/a.unknown')).toBeUndefined(); + }); + + it('returns null from parse for an unknown extension (not empty array, not throw)', async () => { + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers: [], + }); + + await expect(registry.parse('/tmp/a.unknown', 'anything')).resolves.toBeNull(); + }); + + it('matches extensions case-insensitively (.TSX matches a .tsx plugin)', () => { + const plugin: ImportParser = { + extensions: ['.tsx'], + parse: vi.fn(async () => []), + }; + const registry = new ParserRegistry({ + defaultParsers: [], + pluginParsers: [plugin], + }); + + expect(registry.parserFor('/tmp/COMP.TSX')).toBe(plugin.parse); + }); + + it('lowercases the extensions a plugin registers so subsequent lookups match', () => { + const plugin: ImportParser = { + extensions: ['.FOO'], + parse: vi.fn(async () => []), + }; + const registry = new ParserRegistry({ + defaultParsers: [], + pluginParsers: [plugin], + }); + + expect(registry.parserFor('/tmp/a.foo')).toBe(plugin.parse); + expect(registry.walkableExtensions().has('.foo')).toBe(true); + }); + + it('walkableExtensions returns the union of every registered extension', () => { + const plugin: ImportParser = { + extensions: ['.vue'], + parse: vi.fn(async () => []), + }; + const registry = new ParserRegistry({ + defaultParsers: builtinImportParsers, + pluginParsers: [plugin], + }); + + const exts = registry.walkableExtensions(); + expect(exts.has('.ts')).toBe(true); + expect(exts.has('.tsx')).toBe(true); + expect(exts.has('.js')).toBe(true); + expect(exts.has('.jsx')).toBe(true); + expect(exts.has('.mjs')).toBe(true); + expect(exts.has('.cjs')).toBe(true); + expect(exts.has('.mdx')).toBe(true); + expect(exts.has('.vue')).toBe(true); + }); + + it('exposes parseScriptWithOxc to plugins via the context, returning import edges', async () => { + let observedEdges: unknown; + const sfcPlugin: ImportParser = { + extensions: ['.sfc'], + async parse(_args, ctx) { + observedEdges = await ctx.parseScriptWithOxc( + `import x from 'y'; export { a } from 'b';`, + '/tmp/virtual.ts' + ); + return observedEdges as { specifier: string; kind: 'static' | 'dynamic' | 'require' }[]; + }, + }; + const registry = new ParserRegistry({ + defaultParsers: [], + pluginParsers: [sfcPlugin], + }); + + const edges = await registry.parse('/tmp/component.sfc', 'ignored-by-plugin'); + + expect(edges).toEqual([ + { specifier: 'y', kind: 'static' }, + { specifier: 'b', kind: 'static' }, + ]); + expect(observedEdges).toEqual(edges); + }); + + it('dispatches a known extension to the registered parser and returns its edges', async () => { + const pluginParse = vi.fn(async () => [{ specifier: 'foo', kind: 'static' as const }]); + const plugin: ImportParser = { + extensions: ['.foo'], + parse: pluginParse, + }; + const registry = new ParserRegistry({ + defaultParsers: [], + pluginParsers: [plugin], + }); + + const edges = await registry.parse('/tmp/a.foo', 'some source'); + + expect(edges).toEqual([{ specifier: 'foo', kind: 'static' }]); + expect(pluginParse).toHaveBeenCalledWith( + { filePath: '/tmp/a.foo', source: 'some source' }, + expect.objectContaining({ parseScriptWithOxc: expect.any(Function) }) + ); + }); +}); diff --git a/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.ts b/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.ts new file mode 100644 index 000000000000..8a24eb5017ee --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/ParserRegistry.ts @@ -0,0 +1,70 @@ +import { extname } from 'pathe'; + +import { logger } from 'storybook/internal/node-logger'; + +import { oxcParse } from './oxc-parse.ts'; +import type { ImportEdge, ImportParser, ImportParserContext } from './types.ts'; + +/** + * Dispatches a file to the correct {@link ImportParser} based on its extension. The + * registry is built once at change-detection startup from {@link builtinImportParsers} + * plus any contributions from the `experimental_importParsers` preset key. + * + * Registration is last-wins on collision (plugin extensions override built-in + * extensions). Lookup is case-insensitive and uses `path.extname` — compound + * extensions like `.svelte.ts` match only the last segment (`.ts`). + */ +export class ParserRegistry { + private byExtension = new Map(); + private context: ImportParserContext; + + constructor(opts: { + defaultParsers: readonly ImportParser[]; + pluginParsers: readonly ImportParser[]; + }) { + this.context = { parseScriptWithOxc: this.parseScriptWithOxc.bind(this) }; + for (const p of opts.defaultParsers) { + this.register(p); + } + for (const p of opts.pluginParsers) { + this.register(p); + } + } + + private register(plugin: ImportParser): void { + for (const ext of plugin.extensions) { + const lower = ext.toLowerCase(); + if (this.byExtension.has(lower)) { + logger.debug(`ParserRegistry: ${lower} parser overridden`); + } + this.byExtension.set(lower, plugin.parse); + } + } + + /** Returns the parse function for a file's extension, or undefined if unclaimed. */ + parserFor(filePath: string): ImportParser['parse'] | undefined { + return this.byExtension.get(extname(filePath).toLowerCase()); + } + + /** Every extension claimed by some registered parser. Used by the walker to filter. */ + walkableExtensions(): ReadonlySet { + return new Set(this.byExtension.keys()); + } + + /** + * Dispatch `(filePath, source)` to its claimed parser and return the edges. Returns + * `null` when no parser claims the extension — callers interpret this as "opaque + * leaf, do not walk into". + */ + async parse(filePath: string, source: string): Promise { + const fn = this.parserFor(filePath); + if (!fn) { + return null; + } + return fn({ filePath, source }, this.context); + } + + private async parseScriptWithOxc(source: string, virtualFilePath: string): Promise { + return oxcParse(virtualFilePath, source); + } +} diff --git a/code/core/src/core-server/change-detection/parser-registry/builtins.test.ts b/code/core/src/core-server/change-detection/parser-registry/builtins.test.ts new file mode 100644 index 000000000000..afb1c8959594 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/builtins.test.ts @@ -0,0 +1,205 @@ +// Integration tests for the built-in ImportParser plugins. We exercise the real oxc-parser +// binary here (no spy) — the parser is fast enough for unit tests and stubbing it would +// test the stub, not the extractor's mapping from oxc nodes to ImportEdge. +import { describe, expect, it, vi } from 'vitest'; + +import { ChangeDetectionFailureError } from '../errors.ts'; +import { mdxImportParser, oxcImportParser } from './builtins.ts'; +import type { ImportParserContext } from './types.ts'; + +vi.mock('storybook/internal/node-logger', { spy: true }); + +const noopContext: ImportParserContext = { + parseScriptWithOxc: async () => [], +}; + +describe('oxcImportParser', () => { + it('claims the core JS/TS extensions', () => { + expect(oxcImportParser.extensions).toEqual(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); + }); + + it('extracts a regular static `import x from "y"`', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: `import x from 'y';` }, + noopContext + ); + + expect(edges).toEqual([{ specifier: 'y', kind: 'static' }]); + }); + + it('skips a type-only `import type x from "y"`', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: `import type x from 'y';` }, + noopContext + ); + + expect(edges).toEqual([]); + }); + + it('keeps a re-export `export { x } from "y"`', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: `export { x } from 'y';` }, + noopContext + ); + + expect(edges).toEqual([{ specifier: 'y', kind: 'static' }]); + }); + + it('skips a type-only `export type { x } from "y"`', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: `export type { x } from 'y';` }, + noopContext + ); + + expect(edges).toEqual([]); + }); + + it('keeps a dynamic `import("y")` with a string literal', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: `const m = import('y');` }, + noopContext + ); + + expect(edges).toEqual([{ specifier: 'y', kind: 'dynamic' }]); + }); + + it('skips a dynamic import with a non-literal specifier', async () => { + const edges = await oxcImportParser.parse( + { + filePath: '/tmp/a.ts', + source: `async function f(name: string) { await import(name); }`, + }, + noopContext + ); + + expect(edges).toEqual([]); + }); + + it('skips a template-literal dynamic import with interpolation', async () => { + const edges = await oxcImportParser.parse( + { + filePath: '/tmp/a.ts', + source: 'async function f(x: string) { await import(`./${x}`); }', + }, + noopContext + ); + + expect(edges).toEqual([]); + }); + + it('keeps a template-literal dynamic import with no interpolation', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: 'async function f() { await import(`./y`); }' }, + noopContext + ); + + expect(edges).toEqual([{ specifier: './y', kind: 'dynamic' }]); + }); + + it('extracts a `require("y")` call with a string literal', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.js', source: `const m = require('y');` }, + noopContext + ); + + const requires = edges.filter((edge) => edge.kind === 'require'); + expect(requires).toEqual([{ specifier: 'y', kind: 'require' }]); + }); + + it('skips `require(someVar)` with a non-literal argument', async () => { + const edges = await oxcImportParser.parse( + { filePath: '/tmp/a.js', source: `function f(name) { return require(name); }` }, + noopContext + ); + + expect(edges.filter((edge) => edge.kind === 'require')).toEqual([]); + }); + + it('parses a .tsx file with JSX and an import together', async () => { + const edges = await oxcImportParser.parse( + { + filePath: '/tmp/a.tsx', + source: `import React from 'react';\nexport const X = () =>
;`, + }, + noopContext + ); + + expect(edges).toEqual([{ specifier: 'react', kind: 'static' }]); + }); + + it('parses `.mjs` and `.cjs` sources without error', async () => { + const mjs = await oxcImportParser.parse( + { filePath: '/tmp/a.mjs', source: `import x from 'y';` }, + noopContext + ); + const cjs = await oxcImportParser.parse( + { filePath: '/tmp/a.cjs', source: `const m = require('y');` }, + noopContext + ); + + expect(mjs).toEqual([{ specifier: 'y', kind: 'static' }]); + expect(cjs.filter((e) => e.kind === 'require')).toEqual([{ specifier: 'y', kind: 'require' }]); + }); + + it('wraps an oxc-parser throw in a ChangeDetectionFailureError', async () => { + // The dissolved oxc wrapper throws ChangeDetectionFailureError on any parser-level + // failure (null `module`, oxc throw). We cannot reliably produce an oxc throw with + // a test fixture — oxc is permissive. We document the contract: if the parser + // refused the source, the wrapping is a ChangeDetectionFailureError. + let threw: unknown; + try { + // Pass binary-garbage-ish source; if oxc refuses, we expect the error-wrapping path. + await oxcImportParser.parse( + { filePath: '/tmp/a.ts', source: ' invalid source `' }, + noopContext + ); + } catch (error) { + threw = error; + } + if (threw !== undefined) { + expect(threw).toBeInstanceOf(ChangeDetectionFailureError); + } + }); +}); + +describe('mdxImportParser', () => { + it('claims the `.mdx` extension', () => { + expect(mdxImportParser.extensions).toEqual(['.mdx']); + }); + + it('extracts top-of-file import lines from an MDX source', async () => { + const source = [ + `import { Meta } from '@storybook/blocks';`, + `import * as ButtonStories from './Button.stories';`, + ``, + ``, + ].join('\n'); + + const edges = await mdxImportParser.parse({ filePath: '/tmp/intro.mdx', source }, noopContext); + + expect(edges).toEqual([ + { specifier: '@storybook/blocks', kind: 'static' }, + { specifier: './Button.stories', kind: 'static' }, + ]); + }); + + it('returns an empty array when an MDX file has no imports', async () => { + const edges = await mdxImportParser.parse( + { filePath: '/tmp/doc.mdx', source: `# Title\n\nSome prose.` }, + noopContext + ); + + expect(edges).toEqual([]); + }); + + it('deduplicates identical import specifiers', async () => { + const source = [ + `import { Meta } from '@storybook/blocks';`, + `import { Canvas } from '@storybook/blocks';`, + ].join('\n'); + + const edges = await mdxImportParser.parse({ filePath: '/tmp/intro.mdx', source }, noopContext); + + expect(edges).toEqual([{ specifier: '@storybook/blocks', kind: 'static' }]); + }); +}); diff --git a/code/core/src/core-server/change-detection/parser-registry/builtins.ts b/code/core/src/core-server/change-detection/parser-registry/builtins.ts new file mode 100644 index 000000000000..8cccdef028a4 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/builtins.ts @@ -0,0 +1,27 @@ +import { mdxParse } from './mdx-parse.ts'; +import { oxcParse } from './oxc-parse.ts'; +import type { ImportParser } from './types.ts'; + +/** Default parser for JavaScript/TypeScript source. Uses `oxc-parser` under the hood. */ +export const oxcImportParser: ImportParser = { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + async parse({ filePath, source }) { + return oxcParse(filePath, source); + }, +}; + +/** Default parser for MDX files. Uses a regex fallback (oxc-parser cannot parse MDX). */ +export const mdxImportParser: ImportParser = { + extensions: ['.mdx'], + async parse({ source }) { + return mdxParse(source); + }, +}; + +/** + * Built-in parsers shipped with core change-detection. These cover the extensions the + * previous {@link ImportExtractor} handled directly; additional extensions (e.g. `.vue`, + * `.svelte`) are contributed by framework/renderer plugins via the + * `experimental_importParsers` preset key. + */ +export const builtinImportParsers: readonly ImportParser[] = [oxcImportParser, mdxImportParser]; diff --git a/code/core/src/core-server/change-detection/parser-registry/index.ts b/code/core/src/core-server/change-detection/parser-registry/index.ts new file mode 100644 index 000000000000..fe906b1f67a4 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/index.ts @@ -0,0 +1,3 @@ +export type { ImportEdge, ImportParser, ImportParserContext, ParseFileArgs } from './types.ts'; +export { ParserRegistry } from './ParserRegistry.ts'; +export { builtinImportParsers, mdxImportParser, oxcImportParser } from './builtins.ts'; diff --git a/code/core/src/core-server/change-detection/parser-registry/mdx-parse.ts b/code/core/src/core-server/change-detection/parser-registry/mdx-parse.ts new file mode 100644 index 000000000000..eccdef367868 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/mdx-parse.ts @@ -0,0 +1,26 @@ +import type { ImportEdge } from './types.ts'; + +/** + * Matches top-of-file `import` declarations in an MDX source. oxc-parser does not + * understand MDX, so we fall back to a regex. This only captures literal-string + * specifiers — enough for change detection to resolve them with oxc-resolver. + */ +const MDX_IMPORT_REGEX = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g; + +/** Extracts literal-string import edges from an `.mdx` file via regex fallback. */ +export function mdxParse(source: string): ImportEdge[] { + const edges: ImportEdge[] = []; + const seen = new Set(); + MDX_IMPORT_REGEX.lastIndex = 0; + let match: RegExpExecArray | null = MDX_IMPORT_REGEX.exec(source); + while (match !== null) { + const specifier = match[1]; + const key = `static:${specifier}`; + if (!seen.has(key)) { + seen.add(key); + edges.push({ specifier, kind: 'static' }); + } + match = MDX_IMPORT_REGEX.exec(source); + } + return edges; +} diff --git a/code/core/src/core-server/change-detection/parser-registry/oxc-parse.ts b/code/core/src/core-server/change-detection/parser-registry/oxc-parse.ts new file mode 100644 index 000000000000..3baac1ce3809 --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/oxc-parse.ts @@ -0,0 +1,173 @@ +import { parse as oxcRawParse } from 'oxc-parser'; + +import { logger } from 'storybook/internal/node-logger'; + +import { ChangeDetectionFailureError } from '../errors.ts'; +import type { ImportEdge } from './types.ts'; + +/** + * Extracts literal-string import edges from a JS/TS/JSX/TSX source file using oxc-parser. + * + * Skipped: + * + * - Type-only `import` / `export` declarations + * - Dynamic imports with non-literal specifiers + * + * Caller is responsible for filtering CSS/asset specifiers from the returned list + * (extension-based) — this function returns ALL literal-string specifiers it finds. + * + * Throws {@link ChangeDetectionFailureError} if the parser fails to produce any usable + * result; callers should catch and treat such files as opaque-leaf nodes. + */ +export async function oxcParse(filePath: string, source: string): Promise { + let parseResult: Awaited>; + try { + parseResult = await oxcRawParse(filePath, source); + } catch (error) { + throw new ChangeDetectionFailureError( + `oxc-parser failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? { cause: error } : undefined + ); + } + + const moduleInfo = parseResult.module; + if (!moduleInfo) { + throw new ChangeDetectionFailureError(`oxc-parser returned no module info for ${filePath}`); + } + + const edges: ImportEdge[] = []; + const seen = new Set(); + + for (const staticImport of moduleInfo.staticImports) { + const specifier = staticImport.moduleRequest.value; + // A `StaticImport` represents `import ... from "mod"`. If every specifier + // entry is `isType`, the whole import is type-only and contributes no + // runtime edge. Bare `import "mod"` (entries empty) IS a runtime edge. + const allTypeOnly = + staticImport.entries.length > 0 && staticImport.entries.every((entry) => entry.isType); + if (allTypeOnly) { + continue; + } + const key = `static:${specifier}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + edges.push({ specifier, kind: 'static' }); + } + + for (const staticExport of moduleInfo.staticExports) { + for (const entry of staticExport.entries) { + if (!entry.moduleRequest || entry.isType) { + continue; + } + const specifier = entry.moduleRequest.value; + const key = `static:${specifier}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + edges.push({ specifier, kind: 'static' }); + } + } + + for (const dynamicImport of moduleInfo.dynamicImports) { + const specifierSpan = dynamicImport.moduleRequest; + const literal = extractLiteralFromSource(source, specifierSpan.start, specifierSpan.end); + if (literal === null) { + continue; + } + const key = `dynamic:${literal}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + edges.push({ specifier: literal, kind: 'dynamic' }); + } + + // oxc-parser's `EcmaScriptModule` does not surface `require()` calls separately, + // so walk the AST body to find them. Keep the walk shallow-ish but recurse into + // every child object so conditional/nested `require(...)` is captured. + if (parseResult.program?.body) { + collectRequireSpecifiers(parseResult.program, edges, seen); + } + + return edges; +} + +/** + * The dynamic-import `moduleRequest` span covers the literal token (including its + * surrounding quotes) when the argument IS a string literal. If the argument is + * non-literal (`import(someVar)`), the span still exists but does not delimit a + * string literal we can use; we return null in that case so the caller skips it. + */ +function extractLiteralFromSource(source: string, start: number, end: number): string | null { + if (start < 0 || end > source.length || end <= start) { + return null; + } + const slice = source.slice(start, end); + const first = slice[0]; + const last = slice[slice.length - 1]; + if ((first === '"' || first === "'" || first === '`') && last === first && slice.length >= 2) { + const inner = slice.slice(1, -1); + // Template literals with interpolation contain `${`; treat those as non-literal. + if (first === '`' && inner.includes('${')) { + return null; + } + return inner; + } + return null; +} + +/** + * Walks an oxc AST recursively looking for `CallExpression` nodes whose callee is + * the identifier `require` and whose first argument is a string literal. The walk + * is intentionally generic — it does not know oxc node shapes — so it visits every + * own-property of every node and recurses into objects/arrays. + */ +function collectRequireSpecifiers(node: unknown, edges: ImportEdge[], seen: Set): void { + if (node === null || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + for (const child of node) { + collectRequireSpecifiers(child, edges, seen); + } + return; + } + + const maybeNode = node as { type?: unknown; callee?: unknown; arguments?: unknown }; + if (maybeNode.type === 'CallExpression') { + const callee = maybeNode.callee as { type?: unknown; name?: unknown } | null | undefined; + if (callee && callee.type === 'Identifier' && callee.name === 'require') { + const args = Array.isArray(maybeNode.arguments) ? maybeNode.arguments : []; + const firstArg = args[0] as { type?: unknown; value?: unknown } | null | undefined; + if ( + firstArg && + (firstArg.type === 'StringLiteral' || firstArg.type === 'Literal') && + typeof firstArg.value === 'string' + ) { + const specifier = firstArg.value; + const key = `require:${specifier}`; + if (!seen.has(key)) { + seen.add(key); + edges.push({ specifier, kind: 'require' }); + } + } + } + } + + for (const key of Object.keys(node)) { + // Skip the parent backref, range/loc, and other non-AST metadata to avoid + // walking into giant numeric arrays. + if (key === 'parent' || key === 'loc' || key === 'range' || key === 'span') { + continue; + } + try { + collectRequireSpecifiers((node as Record)[key], edges, seen); + } catch (error) { + // Some AST node properties are getters that may throw on access; ignore. + logger.debug(`oxc-parse: skipped property '${key}' during require walk: ${String(error)}`); + } + } +} diff --git a/code/core/src/core-server/change-detection/parser-registry/types.ts b/code/core/src/core-server/change-detection/parser-registry/types.ts new file mode 100644 index 000000000000..3f91bc9b343f --- /dev/null +++ b/code/core/src/core-server/change-detection/parser-registry/types.ts @@ -0,0 +1,42 @@ +/** + * A single import edge extracted from a source file. `specifier` is the literal token the + * source used (e.g. `./foo`, `lodash`, `@scope/pkg`). `kind` distinguishes the three edge + * flavours the change-detection walker cares about: static `import`/`export`, dynamic + * `import()`, and CommonJS `require()`. + */ +export interface ImportEdge { + specifier: string; + kind: 'static' | 'dynamic' | 'require'; +} + +/** Arguments handed to an {@link ImportParser} when the registry dispatches a file to it. */ +export interface ParseFileArgs { + filePath: string; + source: string; +} + +/** + * Services passed to every parser. SFC parsers (vue, svelte) extract a ``, + ``, + `
hello
`, + ].join('\n'); + + const { ctx, calls } = makeContext((src) => { + if (src.includes(`from './Button.svelte'`)) { + return [ + { specifier: './Button.svelte', kind: 'static' }, + { specifier: 'svelte', kind: 'static' }, + ]; + } + return []; + }); + + const edges = await svelteImportParser.parse({ filePath: '/tmp/Foo.svelte', source }, ctx); + + expect(calls).toHaveLength(1); + expect(calls[0]?.virtualFilePath).toBe('/tmp/Foo.svelte.script.ts'); + expect(calls[0]?.source).toContain(`import Button from './Button.svelte';`); + expect(calls[0]?.source).toContain(`import { onMount } from 'svelte';`); + expect(calls[0]?.source).not.toContain('
'); + expect(edges).toEqual([ + { specifier: './Button.svelte', kind: 'static' }, + { specifier: 'svelte', kind: 'static' }, + ]); + }); + + it('extracts imports from a `, + ``, + `

body

`, + ].join('\n'); + + const { ctx, calls } = makeContext((src) => { + if (src.includes(`from './store.ts'`)) { + return [{ specifier: './store.ts', kind: 'static' }]; + } + return []; + }); + + const edges = await svelteImportParser.parse({ filePath: '/tmp/Bar.svelte', source }, ctx); + + expect(calls).toHaveLength(1); + expect(calls[0]?.virtualFilePath).toBe('/tmp/Bar.svelte.script.ts'); + expect(calls[0]?.source).toContain(`import { store } from './store.ts';`); + expect(edges).toEqual([{ specifier: './store.ts', kind: 'static' }]); + }); + + it('extracts imports from BOTH instance and module scripts and dedupes', async () => { + const source = [ + ``, + ``, + ``, + ``, + `
`, + ].join('\n'); + + const { ctx, calls } = makeContext((src) => { + if (src.includes(`from './shared.ts'`)) { + return [ + { specifier: './shared.ts', kind: 'static' }, + { specifier: './Button.svelte', kind: 'static' }, + ]; + } + return [ + { specifier: 'svelte', kind: 'static' }, + { specifier: './Button.svelte', kind: 'static' }, + ]; + }); + + const edges = await svelteImportParser.parse({ filePath: '/tmp/Baz.svelte', source }, ctx); + + expect(calls).toHaveLength(2); + expect(edges).toEqual([ + { specifier: './shared.ts', kind: 'static' }, + { specifier: './Button.svelte', kind: 'static' }, + { specifier: 'svelte', kind: 'static' }, + ]); + }); + + it('returns [] for a Svelte file with only markup and styles', async () => { + const source = [ + `
`, + `

No scripts here.

`, + `
`, + ``, + ``, + ].join('\n'); + + const { ctx, calls } = makeContext(() => []); + + const edges = await svelteImportParser.parse({ filePath: '/tmp/Empty.svelte', source }, ctx); + + expect(calls).toHaveLength(0); + expect(edges).toEqual([]); + }); + + it('forwards type-only import filtering to parseScriptWithOxc (no special-casing)', async () => { + // svelteImportParser does not know about type-only imports — that's oxc's job. We + // assert here that the parser hands the script verbatim to parseScriptWithOxc and + // returns exactly what oxc reports, so type-only filtering is preserved end to end. + const source = [ + ``, + ].join('\n'); + + const { ctx, calls } = makeContext(() => [{ specifier: 'svelte/store', kind: 'static' }]); + + const edges = await svelteImportParser.parse({ filePath: '/tmp/Typed.svelte', source }, ctx); + + expect(calls).toHaveLength(1); + expect(calls[0]?.source).toContain(`import type { Writable } from 'svelte/store';`); + expect(calls[0]?.source).toContain(`import { writable } from 'svelte/store';`); + expect(edges).toEqual([{ specifier: 'svelte/store', kind: 'static' }]); + }); + + it('parses a .stories.svelte (Svelte CSF) file the same way', async () => { + const source = [ + ``, + ``, + ``, + ].join('\n'); + + const { ctx } = makeContext(() => [ + { specifier: '@storybook/addon-svelte-csf', kind: 'static' }, + { specifier: './Button.svelte', kind: 'static' }, + ]); + + const edges = await svelteImportParser.parse( + { filePath: '/tmp/Button.stories.svelte', source }, + ctx + ); + + expect(edges).toEqual([ + { specifier: '@storybook/addon-svelte-csf', kind: 'static' }, + { specifier: './Button.svelte', kind: 'static' }, + ]); + }); + + it('surfaces a malformed .svelte source as ChangeDetectionFailureError', async () => { + // Unclosed tag + bad expression — should cause svelte/compiler to throw. + const source = ``, + ].join('\n'); + + const { ctx, calls } = makeContext(() => [{ specifier: 'vue', kind: 'static' }]); + + const edges = await vueImportParser.parse({ filePath: '/tmp/Button.vue', source }, ctx); + + expect(calls).toHaveLength(1); + expect(calls[0]?.virtualFilePath).toBe('/tmp/Button.vue.script.ts'); + expect(calls[0]?.source).toContain(`import { defineComponent } from 'vue';`); + expect(calls[0]?.source).toContain(`import type { PropType } from 'vue';`); + expect(calls[0]?.source).not.toContain('