From b6984e6fc9c61eb57e8db182e1daabc14786cb60 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 01:50:40 +0900 Subject: [PATCH 01/50] fix(bun-plugin): emit the stylesheet in Bun.build and compile alias and StyleX imports The generated stylesheet always resolved to an empty module, so Bun.build dropped every style, and the pre-parse gate only accepted import declarations of @devup-ui/react, so files using the alias packages, StyleX, subpaths or re-exports ran the runtime stubs. DevupUI() now serves the complete sheet to the CSS loader after defer(), and the gate reads the real imports with Bun.Transpiler.scanImports. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_plugin_build.json | 7 ++ packages/bun-plugin/README.md | 26 ++++- .../bun-plugin/__regression__/css-emit.bun.ts | 91 +++++++++++++++ packages/bun-plugin/src/plugin.ts | 109 +++++++++++++----- packages/bun-plugin/src/register.ts | 2 +- 5 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 .changepacks/changepack_log_bun_plugin_build.json diff --git a/.changepacks/changepack_log_bun_plugin_build.json b/.changepacks/changepack_log_bun_plugin_build.json new file mode 100644 index 00000000..a461c80b --- /dev/null +++ b/.changepacks/changepack_log_bun_plugin_build.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Bun plugin works with Bun.build: `DevupUI()` (from `@devup-ui/bun-plugin/register`) serves the generated stylesheet to the bundler's CSS loader once every other module is loaded, so the build emits a CSS output with the styles of the whole bundle instead of an empty module; class names are short there and readable under the runtime (`debug` option). Files importing the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), `@stylexjs/stylex`, or a subpath or re-export of these or `@devup-ui/react`, are compiled instead of skipped", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index c2385521..289863c3 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -40,9 +40,29 @@ Add the zero-config entry to Bun's preload list: preload = ["@devup-ui/bun-plugin"] ``` -## Custom Shorthands - -To configure custom shorthands, preload a local module instead of the +## Bundling with `Bun.build` + +```ts +import { DevupUI } from '@devup-ui/bun-plugin/register' + +await Bun.build({ + entrypoints: ['./src/index.tsx'], + outdir: './dist', + plugins: [DevupUI()], +}) +``` + +The build emits the stylesheet as a CSS output holding the styles of every +module in the bundle. Imports of the packages Devup UI takes the place of +(`@emotion/react`, `@emotion/styled`, `styled-components`, +`@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too. + +Class names are short in `Bun.build` and readable under the runtime; pass +`debug` to choose. + +## Custom Shorthands + +To configure custom shorthands, preload a local module instead of the zero-config entry: ```toml diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts index 7637396b..7a39652c 100644 --- a/packages/bun-plugin/__regression__/css-emit.bun.ts +++ b/packages/bun-plugin/__regression__/css-emit.bun.ts @@ -69,6 +69,97 @@ await import(cssPath) }, ) +const registerEntry = resolve(import.meta.dir, '..', 'dist', 'register.mjs') + +function run(cwd: string, script: string) { + writeFileSync(join(cwd, 'bunfig.toml'), '') + writeFileSync(join(cwd, 'check.ts'), script) + const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) +} + +it('bundles the styles of every module into the Bun.build stylesheet', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + const widths = Array.from({ length: 8 }, (_, i) => 301 + i) + for (const width of widths) { + writeFileSync( + join(cwd, `fixture-${width}.ts`), + `import { css } from '@devup-ui/react' +export const cls = css({ width: '${width}px' }) +`, + ) + } + writeFileSync( + join(cwd, 'entry.ts'), + widths + .map((width) => `export { cls as c${width} } from './fixture-${width}'`) + .join('\n'), + ) + run( + cwd, + `import { expect } from 'bun:test' +import { DevupUI } from ${JSON.stringify(registerEntry.replaceAll('\\', '/'))} + +const result = await Bun.build({ + entrypoints: ['./entry.ts'], + outdir: './out', + plugins: [DevupUI()], +}) +expect(result.success).toBe(true) +const stylesheets = result.outputs.filter((output) => output.path.endsWith('.css')) +expect(stylesheets).toHaveLength(1) +const css = (await stylesheets[0].text()).replace(/\\s+/g, '') +for (const width of ${JSON.stringify(widths)}) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + +it('compiles the packages Devup UI takes the place of, and StyleX', () => { + const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) + try { + writeFileSync( + join(cwd, 'emotion.ts'), + `import { css } from '@emotion/react' +export const cls = css({ width: '201px' }) +`, + ) + writeFileSync( + join(cwd, 'stylex.ts'), + `import * as stylex from '@stylexjs/stylex' +export const styles = stylex.create({ box: { width: '203px' } }) +`, + ) + run( + cwd, + `import { readFileSync } from 'node:fs' +import { expect } from 'bun:test' + +await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}) +const emotion = await import('./emotion.ts') +const stylex = await import('./stylex.ts') +expect(emotion.cls).toBeTruthy() +expect(stylex.styles).toBeTruthy() +const css = readFileSync('./df/devup-ui/devup-ui.css', 'utf-8') +for (const width of [201, 203]) expect(css).toContain('width:' + width + 'px') +`, + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + it('emits vanilla-extract styles through the WASM engine', () => { const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-')) try { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 52591c9e..5fc4b01c 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -14,13 +14,12 @@ import { codeExtract, getCss, getThemeInterface, - hasDevupUI, registerShorthands, registerTheme, setDebug, setModuleResolver, } from '@devup-ui/wasm' -import { plugin } from 'bun' +import { type BunPlugin, plugin, type PluginBuilder } from 'bun' import { cssDirName, cssNamespace, resolveCssId } from './css-id' @@ -30,11 +29,25 @@ const distDir = 'df' const cssDir = resolve(distDir, cssDirName) const singleCss = true const importAliases = mergeImportAliases() +// The packages whose imports the extractor compiles: Devup UI, the packages it +// takes the place of, and StyleX +const compiledPackages = [ + libPackage, + '@stylexjs/stylex', + ...Object.keys(importAliases), +] export interface DevupUIBunPluginOptions { shorthands?: CustomShorthands + /** + * Readable class names. Defaults to `true` under the Bun runtime (tests) and + * `false` in `Bun.build`. + */ + debug?: boolean } +type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' + async function writeDataFiles() { let theme = {} try { @@ -71,16 +84,31 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { await writeDataFiles() } -// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a -// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the -// script/data loaders). The injected import exists so bundlers pick the -// stylesheet up, so under the Bun runtime it resolves to an empty module. -function loadCssModule() { - return { contents: '', loader: 'js' as const } +const scanners = new Map() + +/** Whether `contents` imports a package the extractor compiles */ +function importsCompiledPackage(contents: string, loader: SourceLoader) { + let scanner = scanners.get(loader) + if (!scanner) { + scanner = new Bun.Transpiler({ loader }) + scanners.set(loader, scanner) + } + try { + return scanner + .scanImports(contents) + .some(({ path }) => + compiledPackages.some( + (name) => path === name || path.startsWith(`${name}/`), + ), + ) + } catch { + // Bun reports the syntax error when it loads the untouched source + return false + } } -async function loadSourceFile(filePath: string) { - const loader: 'tsx' | 'ts' | 'jsx' | 'js' = filePath.endsWith('.tsx') +async function loadSourceFile(filePath: string, bundling: boolean) { + const loader: SourceLoader = filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') ? 'ts' @@ -89,7 +117,7 @@ async function loadSourceFile(filePath: string) { : 'js' const contents = await Bun.file(filePath).text() - if (hasDevupUI(filePath, contents, libPackage)) { + if (importsCompiledPackage(contents, loader)) { const code = codeExtract( filePath, contents, @@ -100,29 +128,29 @@ async function loadSourceFile(filePath: string) { false, importAliases, ) - // singleCss stores every extracted style in the base sheet. Finish the - // write before returning the injected import; synchronous writes also keep + // Under the runtime the stylesheet is read from disk. singleCss stores + // every extracted style in the base sheet; synchronous writes keep // concurrent source loads from overwriting a newer sheet with an older one. - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') + if (!bundling) + writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') return { contents: code.code, loader } } return { contents, loader } } -// Registers the Bun plugin. Returns the promise produced by `plugin()` (its -// `setup` is async), so callers MUST `await` it. Bun's preload mechanism waits -// for an awaited module evaluation to settle; awaiting this guarantees the -// `onLoad` hook is installed before any source file is loaded. Without the -// await, preload-driven `bun test` users race the async setup and load sources -// against the @devup-ui/react runtime stubs (throwing "Cannot run on the -// runtime"). -function register(options: DevupUIBunPluginOptions = {}) { - return plugin({ +/** + * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the + * Bun runtime ({@link register}). + */ +function DevupUI(options: DevupUIBunPluginOptions = {}) { + return { name: 'devup-ui', - async setup(build) { + async setup(build: PluginBuilder) { + // `Bun.build` hands its config to plugins; the runtime has none + const bundling = build.config !== undefined await initialize(options) - setDebug(true) + setDebug(options.debug ?? !bundling) // Resolve devup-ui CSS files onto a path-free virtual id, so nothing // derived from this checkout's cwd can be baked into Bun's shared, @@ -132,9 +160,17 @@ function register(options: DevupUIBunPluginOptions = {}) { ({ path, importer }) => resolveCssId(path, importer, distDir), ) - // Serve the virtual stylesheet resolved above - build.onLoad({ filter: /.*/, namespace: cssNamespace }, () => - loadCssModule(), + // The bundler takes the stylesheet once every other module is loaded, + // so it holds the styles of all of them. The Bun runtime has no CSS + // loader (`onLoad` only accepts the script/data loaders), so there the + // injected import resolves to an empty module. + build.onLoad( + { filter: /.*/, namespace: cssNamespace }, + async ({ defer }) => { + if (!bundling) return { contents: '', loader: 'js' } + await defer() + return { contents: getCss(null, false), loader: 'css' } + }, ) // Load source files from packages directory (file namespace) @@ -142,10 +178,21 @@ function register(options: DevupUIBunPluginOptions = {}) { { filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, }, - ({ path }) => loadSourceFile(path), + ({ path }) => loadSourceFile(path, bundling), ) }, - }) + } satisfies BunPlugin +} + +// Registers the Bun runtime plugin. Returns the promise produced by `plugin()` +// (its `setup` is async), so callers MUST `await` it. Bun's preload mechanism +// waits for an awaited module evaluation to settle; awaiting this guarantees +// the `onLoad` hook is installed before any source file is loaded. Without the +// await, preload-driven `bun test` users race the async setup and load sources +// against the @devup-ui/react runtime stubs (throwing "Cannot run on the +// runtime"). +function register(options: DevupUIBunPluginOptions = {}) { + return plugin(DevupUI(options)) } -export { plugin, register } +export { DevupUI, plugin, register } diff --git a/packages/bun-plugin/src/register.ts b/packages/bun-plugin/src/register.ts index eb32ee74..3f4240ff 100644 --- a/packages/bun-plugin/src/register.ts +++ b/packages/bun-plugin/src/register.ts @@ -1,2 +1,2 @@ export type { DevupUIBunPluginOptions } from './plugin' -export { register } from './plugin' +export { DevupUI, register } from './plugin' From 71a59846aeaf0d8f297f3db687a8bddc07da0ffa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 02:44:05 +0900 Subject: [PATCH 02/50] fix(webpack-plugin,rsbuild-plugin): keep every extracted style in the built stylesheets A stylesheet module is built on its first import, and the shared base is read from disk past the plugin, so styles extracted later were missing from webpack and Rsbuild output; Rsbuild also served a module-wide string kept only when it grew. The files the entries reach are now extracted before the build, the base on disk is kept current, and a compilation whose stylesheets changed is run once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet. Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_css_snapshot.json | 9 + .../plugin-utils/src/import-graph.test.ts | 51 +++++ packages/plugin-utils/src/import-graph.ts | 37 ++++ packages/plugin-utils/src/index.ts | 2 + .../src/__tests__/checkout-isolation.test.ts | 2 + .../src/__tests__/plugin.test.ts | 194 +++++++++++++++++- packages/rsbuild-plugin/src/plugin.ts | 143 +++++++++++-- .../src/__tests__/css-loader.test.ts | 22 ++ .../src/__tests__/plugin.test.ts | 178 +++++++++++++--- packages/webpack-plugin/src/css-loader.ts | 10 +- packages/webpack-plugin/src/plugin.ts | 120 ++++++++--- packages/webpack-plugin/src/served-css.ts | 18 ++ 12 files changed, 709 insertions(+), 77 deletions(-) create mode 100644 .changepacks/changepack_log_css_snapshot.json create mode 100644 packages/webpack-plugin/src/served-css.ts diff --git a/.changepacks/changepack_log_css_snapshot.json b/.changepacks/changepack_log_css_snapshot.json new file mode 100644 index 00000000..a1c52eee --- /dev/null +++ b/.changepacks/changepack_log_css_snapshot.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Webpack and Rsbuild builds hold every style in their stylesheets: a stylesheet module is built on its first import, so styles extracted later (the global styles of files webpack or Rsbuild reached late, often outside `src`) were missing from the output. The files the entries reach under `src` are now extracted before the build, the shared base on disk is kept current, and when a stylesheet still changed during the build it is compiled once more, writing only the complete result. Rsbuild serves each stylesheet from the current sheet instead of a module-wide string kept only when it grew, which left edits that shortened the CSS and later builds in the same process stale. plugin-utils adds computeReachableFiles", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index cc15f234..d99a4fca 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -17,11 +17,62 @@ import { computeCompiledFiles, computeFileReach, computeFileRoutes, + computeReachableFiles, createModuleResolver, planAtomHoist, runImportGraphCli, } from './import-graph' +describe('computeReachableFiles', () => { + let tempRoot: string + let srcDir: string + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'devup-ui-reachable-files-')) + srcDir = join(tempRoot, 'src') + }) + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + function writeFixture(path: string, code: string): void { + const filePath = join(tempRoot, path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, code) + } + + it('follows static and dynamic imports from the entries only', () => { + writeFixture('src/index.tsx', "import './a'\nimport('./lazy')\n") + writeFixture('src/a.tsx', "import './b'\n") + writeFixture('src/b.tsx', 'export const b = 1\n') + writeFixture('src/lazy.tsx', "import './b'\n") + writeFixture('src/unused.stories.tsx', "import './a'\n") + + expect( + computeReachableFiles({ srcDir, entries: [join(srcDir, 'index')] }), + ).toEqual( + ['a.tsx', 'b.tsx', 'index.tsx', 'lazy.tsx'].map((file) => + join(srcDir, file), + ), + ) + }) + + it('reaches nothing from entries outside the source directory', () => { + writeFixture('src/a.tsx', 'export const a = 1\n') + writeFixture('other/entry.tsx', "import '../src/a'\n") + const graph = buildStaticImportGraph(srcDir) + + expect( + computeReachableFiles({ + srcDir, + entries: [join(tempRoot, 'other/entry.tsx'), join(srcDir, 'missing')], + graph, + }), + ).toEqual([]) + }) +}) + describe('buildCanonicalMap', () => { let tempRoot: string let cwd: string diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aa55a9bc..b221bbc1 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -336,6 +336,43 @@ export function computeCompiledFiles( return [...compiled].map((file) => toPosixRelative(cwd, file)).sort() } +export interface ComputeReachableFilesOptions { + srcDir: string + tsconfigPath?: string + /** The bundler's entry modules, as absolute paths with or without extension. */ + entries: string[] + /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ + graph?: StaticImportGraph +} + +/** + * The source files under `srcDir` a bundler compiles from `entries`: their + * closure over static and dynamic `import()` edges, as absolute paths in the + * graph's order. Extracting them before bundling fills the shared stylesheet + * without the styles of files no entry imports. + */ +export function computeReachableFiles( + opts: ComputeReachableFilesOptions, +): string[] { + const { files, fileSet, staticImports, dynamicImports } = + opts.graph ?? buildStaticImportGraph(opts.srcDir, opts.tsconfigPath) + const queue = opts.entries + .map((entry) => resolveFile(resolve(entry))) + .filter( + (entry): entry is string => entry !== undefined && fileSet.has(entry), + ) + const reached = new Set() + for (let index = 0; index < queue.length; index += 1) { + const file = queue[index] + if (reached.has(file)) continue + reached.add(file) + for (const imports of [staticImports, dynamicImports]) { + for (const target of imports.get(file) ?? []) queue.push(target) + } + } + return files.filter((file) => reached.has(file)) +} + export interface ComputeFileReachOptions { srcDir: string tsconfigPath?: string diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b66b07ab..385a2dc2 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -9,6 +9,8 @@ export { type ComputeFileReachOptions, computeFileRoutes, type ComputeFileRoutesOptions, + computeReachableFiles, + type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, listSourceFiles, diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2..7c61a459 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -69,6 +69,8 @@ async function extractedCssDirIn(checkout: string) { await plugin.setup({ transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd..1a4393a9 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -42,6 +42,9 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), + onBeforeBuild: mock(), + context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -261,15 +264,17 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) + const getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('file css') expect( transform.mock.calls[0][1]({ - code: ` - .devup-ui-1 { - color: red; - } - `, + code: '/* placeholder */', + resourcePath: resolve('df', 'devup-ui', 'devup-ui-1.css'), + environment: { name: 'web' }, }), - ).toBe('') + ).toBe('file css') + // A file's stylesheet imports the shared base + expect(getCssSpy).toHaveBeenCalledWith(1, true) + getCssSpy.mockRestore() }) it('should transform code', async () => { const plugin = DevupUI() @@ -291,11 +296,6 @@ describe('DevupUIRsbuildPlugin', () => { expect.any(Function), ) - expect( - transform.mock.calls[0][1]({ - code: ``, - }), - ).toBe('') codeExtractSpy.mockReturnValue( createCodeExtractResult({ code: '
', @@ -607,12 +607,14 @@ const App = () => `, const servedChunk = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui-3.css'), + environment: { name: 'web' }, }) expect(servedChunk).toBe('CSS_FOR_3') expect(getCssSpy).toHaveBeenCalledWith(3, false) const servedBase = transform.mock.calls[0][1]({ code: '', resourcePath: resolve('df', 'devup-ui', 'devup-ui.css'), + environment: { name: 'web' }, }) expect(servedBase).toBe('CSS_FOR_null') expect(getCssSpy).toHaveBeenCalledWith(null, false) @@ -710,4 +712,174 @@ const App = () => `, expect((cfgArr.tools.rspack as unknown[]).length).toBe(2) }) }) + + describe('stylesheets built too early', () => { + let getCssSpy: ReturnType + let readFileSyncSpy: ReturnType + let computeReachableFilesSpy: ReturnType + + afterEach(() => { + getCssSpy.mockRestore() + readFileSyncSpy.mockRestore() + computeReachableFilesSpy.mockRestore() + existsSyncSpy.mockReturnValue(false) + }) + + async function setup(options: Parameters[0] = {}) { + getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('before') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('source') + computeReachableFilesSpy = spyOn( + pluginUtils, + 'computeReachableFiles', + ).mockReturnValue([resolve('src', 'App.tsx')]) + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const transform = mock() + const onBeforeBuild = mock() + const modifyRspackConfig = mock() + await DevupUI(options).setup( + createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), + ) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config, { + environment: { name: 'web' }, + }) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compiler = { + watchMode: false, + rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, + hooks: { + run: { tap: tap('run') }, + thisCompilation: { tap: tap('start') }, + }, + } + config.plugins![0]!.apply(compiler) + const compilation = { + assets: { 'index.js': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const serve = (resourcePath: string) => + transform.mock.calls[0][1]({ + resourcePath, + environment: { name: 'web' }, + }) + return { onBeforeBuild, compiler, compilation, taps, serve } + } + + it('extracts the files the entries reach before building', async () => { + const { onBeforeBuild } = await setup({ atomHoist: undefined }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ + environments: { + web: { + entry: { + a: './src/a.tsx', + b: ['./src/b.tsx'], + c: { import: './src/c.tsx' }, + d: { import: ['./src/d.tsx'] }, + }, + }, + }, + }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: ['a', 'b', 'c', 'd'].map((name) => + resolve(process.cwd(), `./src/${name}.tsx`), + ), + }) + expect(codeExtractSpy).toHaveBeenCalledWith( + resolve('src', 'App.tsx'), + 'source', + '@devup-ui/react', + expect.stringMatching(/^\.\//), + false, + false, + true, + expect.anything(), + ) + + // an extraction error is reported by the transform of that file + codeExtractSpy.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + onBeforeBuild.mock.calls[0][0]({ environments: {} }), + ).not.toThrow() + }) + + it('extracts under posix names in atom mode', async () => { + const { onBeforeBuild } = await setup({ atomHoist: 2 }) + codeExtractSpy.mockClear() + onBeforeBuild.mock.calls[0][0]({ environments: {} }) + expect(codeExtractSpy.mock.calls[0]![0]).toBe( + resolve('src', 'App.tsx').replaceAll('\\', '/'), + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', async () => { + const { compiler, compilation, taps, serve } = await setup() + writeFileSyncSpy.mockClear() + taps.start!(compilation) + // the shared base is written first, as the CSS loaders read it from disk + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'before', + 'utf-8', + ) + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('index.js') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui-1.css'), + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + resolve('df', 'devup-ui', 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + taps.run!() + expect(taps.needAdditionalPass!()).toBe(true) + + // the dev server rebuilds through the files the transforms write + compiler.watchMode = true + taps.start!(compilation) + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBe(false) + }) + + it('keeps the pass when every stylesheet is current', async () => { + const { compilation, taps, serve } = await setup() + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('before') + writeFileSyncSpy.mockClear() + taps.start!(compilation) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + serve(resolve('df', 'devup-ui', 'devup-ui-1.css')) + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBe(false) + }) + }) }) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 990a4cd2..47d4f2f3 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,10 +1,11 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, @@ -30,7 +31,9 @@ import { setModuleResolver, setPrefix, } from '@devup-ui/wasm' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildPlugin, Rspack } from '@rsbuild/core' + +const PLUGIN_NAME = 'devup-ui-rsbuild-plugin' export interface DevupUIRsbuildPluginOptions { package: string @@ -65,8 +68,6 @@ export interface DevupUIRsbuildPluginOptions { importAliases?: ImportAliases } -let globalCss = '' - async function writeDataFiles( options: Omit< DevupUIRsbuildPluginOptions, @@ -103,6 +104,16 @@ async function writeDataFiles( ]) } +/** + * Write `css` to the stylesheet file at `path` unless it holds it already. The + * CSS loaders read the shared base from disk when a file's stylesheet imports + * it, so it must hold every style extracted so far. + */ +function writeChanged(path: string, css: string) { + if (!existsSync(path) || readFileSync(path, 'utf-8') !== css) + writeFileSync(path, css, 'utf-8') +} + export const DevupUI = ({ include = [], package: libPackage = '@devup-ui/react', @@ -121,7 +132,7 @@ export const DevupUI = ({ const importAliases = mergeImportAliases(userImportAliases) return { - name: 'devup-ui-rsbuild-plugin', + name: PLUGIN_NAME, async setup(api) { setDebug(debug) if (prefix) { @@ -190,23 +201,123 @@ export const DevupUI = ({ } } + // Extract the source files under `src` that the entries reach, in path + // order, the same way the transform does, so that a stylesheet built on + // its first import already holds the styles of every one. Best-effort: + // a stylesheet still missing styles is rebuilt by another pass. + api.onBeforeBuild(({ environments }) => { + try { + const root = api.context.rootPath + const entries = Object.values(environments).flatMap(({ entry }) => + Object.values(entry).flatMap((value) => + (typeof value === 'object' && !Array.isArray(value) + ? [value.import].flat() + : [value].flat() + ).map((request) => resolve(root, request)), + ), + ) + for (const file of computeReachableFiles({ + srcDir: resolve(root, 'src'), + tsconfigPath: resolve(root, 'tsconfig.json'), + entries, + })) { + let extractCssDir = relative(dirname(file), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + codeExtract( + atomMode ? file.replaceAll('\\', '/') : file, + readFileSync(file, 'utf-8'), + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ) + } + } catch { + // The transform reports the error of the file it cannot extract + } + }) + + const servedCss = new Map>() + const stylesheet = (resourcePath: string) => + // A file's stylesheet imports the shared base, except in atom mode, + // where the entry code imports the base itself so that hoisted atoms + // load once (the injected splitChunks cacheGroup, see + // modifyRsbuildConfig, emits the base once) + getCss(getFileNumByFilename(basename(resourcePath)), !atomMode) + api.transform( { test: cssDir, }, - ({ resourcePath }) => { - // Non-atom: keep the existing single-string behavior (no regression). - if (!atomMode) return globalCss - // Atom mode: serve the route-specific chunk and have it @import the - // shared base (devup-ui.css) so hoisted atoms load ONCE and are not - // inlined per chunk. The base file itself imports nothing. - // Route chunk and base are SEPARATE modules (the transformed entry - // code imports both via import_main_css); the injected splitChunks - // cacheGroup (see modifyRsbuildConfig) emits the base once. - return getCss(getFileNumByFilename(basename(resourcePath)), false) + ({ resourcePath, environment }) => { + const css = stylesheet(resourcePath) + servedCss.get(environment.name)?.set(resourcePath, css) + return css }, ) + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. The dev server rebuilds it + // through the stylesheet files the transforms write instead. + api.modifyRspackConfig((config, { environment }) => { + config.plugins ??= [] + config.plugins.push({ + apply(compiler: Rspack.Compiler) { + let passes = 0 + compiler.hooks.run.tap(PLUGIN_NAME, () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const served = new Map() + servedCss.set(environment.name, served) + const basePath = join(cssDir, 'devup-ui.css') + const base = stylesheet(basePath) + writeChanged(basePath, base) + let stale: string[] = [] + compilation.hooks.finishModules.tap(PLUGIN_NAME, () => { + if (compiler.watchMode) return + const changed = new Set( + [...served] + .filter(([path, css]) => stylesheet(path) !== css) + .map(([path]) => path), + ) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin + if (stylesheet(basePath) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: PLUGIN_NAME, + stage: + compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap(PLUGIN_NAME, () => { + if (stale.length === 0 || passes > 0) return false + passes += 1 + for (const path of stale) + writeFileSync(path, stylesheet(path), 'utf-8') + return true + }) + }) + }, + }) + }) + api.modifyRsbuildConfig((config) => { const theme = getDefaultTheme() if (theme) { @@ -283,7 +394,6 @@ export const DevupUI = ({ : resourcePath const { code: retCode, - css = '', map, cssFile, updatedBaseStyle, @@ -312,7 +422,6 @@ export const DevupUI = ({ } if (cssFile) { - if (globalCss.length < css.length) globalCss = css promises.push( writeFile( join(cssDir, basename(cssFile)), diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 5e5f80fe..cff74169 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -12,6 +12,7 @@ import { } from 'bun:test' import devupUICssLoader from '../css-loader' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType type CssLoaderThis = ThisParameterType @@ -34,12 +35,15 @@ function createCodeExtractResult( function createCssLoaderContext( resourcePath: string, callback: ReturnType, + compilation?: object, ): CssLoaderThis { return { callback, + cacheable: mock(), addContextDependency: mock(), resourcePath, getOptions: () => ({ watch: resourcePath.includes('devup-ui') }), + _compilation: compilation, } as unknown as CssLoaderThis } @@ -108,4 +112,22 @@ describe('devupUICssLoader', () => { expect(getCssSpy).toBeCalledTimes(1) }) + + it('records the stylesheet it serves in its compilation, uncached', () => { + const callback = mock() + const compilation = {} + getCssSpy.mockReturnValue('base css') + const context = createCssLoaderContext( + '/df/devup-ui/devup-ui.css', + callback, + compilation, + ) + devupUICssLoader.bind(context)(Buffer.from(''), '') + + expect(context.cacheable).toHaveBeenCalledWith(false) + expect(servedCss(compilation)).toEqual( + new Map([['/df/devup-ui/devup-ui.css', 'base css']]), + ) + expect(servedCss(compilation)).toBe(servedCss(compilation)) + }) }) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead3..524e755b 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -17,19 +17,28 @@ import { import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +import { servedCss } from '../served-css' type CodeExtractResult = ReturnType interface MockCompiler { options: { module: { rules: unknown[] } plugins: unknown[] + entry?: unknown + context?: string + } + watchMode?: boolean + webpack: { + DefinePlugin: ReturnType + Compilation: { PROCESS_ASSETS_STAGE_REPORT: number } } - webpack: { DefinePlugin: ReturnType } hooks: { watchRun: { tapPromise: ReturnType } beforeRun: { tapPromise: ReturnType } done: { tapPromise: ReturnType } afterCompile: { tap: ReturnType } + run: { tap: ReturnType } + thisCompilation: { tap: ReturnType } } } @@ -136,6 +145,7 @@ function createCompiler(): MockCompiler { }, webpack: { DefinePlugin: mock(), + Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 }, }, hooks: { watchRun: { @@ -150,6 +160,12 @@ function createCompiler(): MockCompiler { afterCompile: { tap: mock(), }, + run: { + tap: mock(), + }, + thisCompilation: { + tap: mock(), + }, }, } } @@ -459,7 +475,7 @@ describe('devupUIWebpackPlugin', () => { let importCanonicalMapSpy: ReturnType let importFileRoutesSpy: ReturnType let setAtomHoistSpy: ReturnType - let listSourceFilesSpy: ReturnType + let computeReachableFilesSpy: ReturnType beforeEach(() => { buildCanonicalMapSpy = spyOn( @@ -478,9 +494,9 @@ describe('devupUIWebpackPlugin', () => { ) setAtomHoistSpy = spyOn(wasm, 'setAtomHoist').mockReturnValue(undefined) // Default: no source files so pre-warm is a no-op unless a test opts in. - listSourceFilesSpy = spyOn( + computeReachableFilesSpy = spyOn( pluginUtils, - 'listSourceFiles', + 'computeReachableFiles', ).mockReturnValue([]) }) @@ -490,7 +506,7 @@ describe('devupUIWebpackPlugin', () => { importCanonicalMapSpy.mockRestore() importFileRoutesSpy.mockRestore() setAtomHoistSpy.mockRestore() - listSourceFilesSpy.mockRestore() + computeReachableFilesSpy.mockRestore() }) it('runs single-importer collapse even when atomHoist is unset (always-on), without hoisting', () => { @@ -511,15 +527,15 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('pre-warms the extractor over all source files in build mode when collapse is active', () => { - // Without pre-warm, webpack builds each shared devup-ui-N.css ONCE at - // first import — before later bucket members are extracted — so their - // atoms are dropped. Pre-warming the sheet makes getCss(N) complete from - // the first css-loader call. + it('pre-warms the extractor over the files the entries reach in build mode', () => { + // Without pre-warm, webpack builds each shared stylesheet ONCE at first + // import — before later files are extracted — so their styles are + // dropped. Pre-warming the sheet makes getCss complete from the first + // css-loader call. buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) @@ -532,8 +548,21 @@ describe('devupUIWebpackPlugin', () => { package: '@devup-ui/react', singleCss: true, }) - plugin.apply(asCompiler(createCompiler())) - expect(listSourceFilesSpy).toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = { + main: { import: ['./src/parent', './src/other'] }, + worker: {}, + } + compiler.options.context = '/project' + plugin.apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith({ + srcDir: resolve(process.cwd(), 'src'), + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -570,22 +599,21 @@ describe('devupUIWebpackPlugin', () => { ) }) - it('skips pre-warm when the canonical map is empty (no collapse, no race)', () => { + it('pre-warms without collapse, from no entries when they are a function', () => { buildCanonicalMapSpy.mockReturnValue({}) - listSourceFilesSpy.mockReturnValue([ - resolve(process.cwd(), 'src', 'a.tsx'), - ]) - readFileSyncSpy.mockReturnValue('source') - const plugin = new DevupUIWebpackPlugin({}) - plugin.apply(asCompiler(createCompiler())) - expect(codeExtractSpy).not.toHaveBeenCalled() + const compiler = createCompiler() + compiler.options.entry = () => ({}) + new DevupUIWebpackPlugin({}).apply(asCompiler(compiler)) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ entries: [] }), + ) }) it('skips pre-warm in watch mode (race only affects one-shot builds)', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -598,7 +626,7 @@ describe('devupUIWebpackPlugin', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) - listSourceFilesSpy.mockReturnValue([ + computeReachableFilesSpy.mockReturnValue([ resolve(process.cwd(), 'src', 'parent.tsx'), ]) readFileSyncSpy.mockReturnValue('source') @@ -682,4 +710,108 @@ describe('devupUIWebpackPlugin', () => { expect(compiler.options.module.rules.length).toBeGreaterThan(0) }) }) + + describe('another pass for stylesheets built too early', () => { + function compile(watch = false) { + const compiler = createCompiler() + const plugin = new DevupUIWebpackPlugin({ watch }) + plugin.apply(asCompiler(compiler)) + const taps: Record unknown> = {} + const tap = + (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { + taps[hook] = fn + } + const compilation = { + assets: { 'main.js': {}, 'main.css': {} }, + deleteAsset: mock(), + hooks: { + finishModules: { tap: tap('finishModules') }, + processAssets: { tap: tap('processAssets') }, + needAdditionalPass: { tap: tap('needAdditionalPass') }, + }, + } + const run = compiler.hooks.run.tap.mock.calls[0]?.[1] as () => void + const start = () => { + ;( + compiler.hooks.thisCompilation.tap.mock.calls[0]![1] as ( + compilation: object, + ) => void + )(compilation) + } + return { compiler, plugin, compilation, taps, run, start } + } + + it('taps nothing in watch mode', () => { + const { compiler } = compile(true) + expect(compiler.hooks.run.tap).not.toHaveBeenCalled() + expect(compiler.hooks.thisCompilation.tap).not.toHaveBeenCalled() + }) + + it('writes the shared base to disk when it does not hold it yet', () => { + getCssSpy.mockReturnValue('base') + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('base') + const { plugin, start } = compile() + writeFileSyncSpy.mockClear() + start() + expect(writeFileSyncSpy).not.toHaveBeenCalled() + + readFileSyncSpy.mockReturnValue('older') + start() + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'base', + 'utf-8', + ) + }) + + it('compiles once more, writing no file, when a stylesheet changed', () => { + getCssSpy.mockReturnValue('before') + const { compiler, compilation, plugin, taps, run, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'before') + getCssSpy.mockReturnValue('after') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.js') + expect(compilation.deleteAsset).toHaveBeenCalledWith('main.css') + writeFileSyncSpy.mockClear() + expect(taps.needAdditionalPass!()).toBe(true) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + '/df/devup-ui/devup-ui-1.css', + 'after', + 'utf-8', + ) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(plugin.options.cssDir, 'devup-ui.css'), + 'after', + 'utf-8', + ) + + // one more pass per run at most + compilation.deleteAsset.mockClear() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + run() + expect(taps.needAdditionalPass!()).toBe(true) + + // a watching compiler rebuilds through the files the loaders write + compiler.watchMode = true + start() + taps.finishModules!() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + + it('keeps the pass when every stylesheet is current', () => { + getCssSpy.mockReturnValue('same') + const { compilation, taps, start } = compile() + start() + servedCss(compilation).set('/df/devup-ui/devup-ui-1.css', 'same') + taps.finishModules!() + taps.processAssets!() + expect(compilation.deleteAsset).not.toHaveBeenCalled() + expect(taps.needAdditionalPass!()).toBeUndefined() + }) + }) }) diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index bc634407..450d9e67 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,8 +2,14 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { servedCss } from './served-css' + const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { - const fileNum = getFileNumByFilename(this.resourcePath) - this.callback(null, getCss(fileNum, true), map, meta) + // The stylesheet comes from every extracted module, not from this file + this.cacheable(false) + const css = getCss(getFileNumByFilename(this.resourcePath), true) + if (this._compilation) + servedCss(this._compilation).set(this.resourcePath, css) + this.callback(null, css, map, meta) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a..83b2c226 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,13 +6,14 @@ import { dirname, join, relative, resolve } from 'node:path' import { buildCanonicalMap, computeFileReach, + computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, @@ -37,6 +38,8 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { servedCss } from './served-css' + export interface DevupUIWebpackPluginOptions { package: string cssDir: string @@ -144,14 +147,16 @@ export class DevupUIWebpackPlugin { } /** - * Extract every source file under `src` into the shared WASM sheet so that a - * later `getCss(fileNum)` call returns the COMPLETE bucket (all collapsed - * members), not just the first member webpack happened to build. Mirrors the - * loader's `codeExtract` call (same filename keying + options) so re-extraction - * during compilation is idempotent. Best-effort: extraction errors are - * swallowed so a single bad file never breaks the build. + * Extract the source files under `src` that `entries` reach into the shared + * WASM sheet, in path order, so that a stylesheet built on its first import + * holds the styles of every one (all collapsed members of a bucket, and the + * shared base), not just those of the modules webpack happened to build + * first. Mirrors the loader's `codeExtract` call (same filename keying + + * options) so re-extraction during compilation is idempotent. Best-effort: + * extraction errors are swallowed so a single bad file never breaks the + * build, and a stylesheet still missing styles is rebuilt by another pass. */ - private prewarmExtractor() { + private prewarmExtractor(entries: string[]) { try { const cwd = process.cwd() // The same resolver as the loader's, so imported constants and @@ -161,8 +166,11 @@ export class DevupUIWebpackPlugin { toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - const srcDir = resolve(cwd, 'src') - for (const file of listSourceFiles(srcDir)) { + for (const file of computeReachableFiles({ + srcDir: resolve(cwd, 'src'), + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + })) { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -234,12 +242,11 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. - let canonicalMap: Record = {} try { const srcDir = resolve(process.cwd(), 'src') const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') const cwd = process.cwd() - canonicalMap = buildCanonicalMap({ + const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, @@ -269,19 +276,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } - // Pre-warm the extractor so the css-loader serves COMPLETE bucket CSS. + // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // - // Under collapse, several source files share ONE devup-ui-N.css (the - // importer's bucket). The css-loader serves `getCss(N, true)`, but webpack - // builds that shared .css module ONCE — at the FIRST import resolution, - // before the bucket's other members have been extracted — so their atoms - // would be dropped. Turbopack avoids this via its idle coordinator; webpack - // has no such re-serve, so we extract every source file up front (single - // shared WASM instance) to populate the bucket fully BEFORE any css-loader - // runs. Re-extraction by the per-file loader is then idempotent (set-based - // atom dedup). Only needed for one-shot builds when collapse is active. - if (!this.options.watch && Object.keys(canonicalMap).length > 0) { - this.prewarmExtractor() + // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared + // base (global styles of every file) and, under collapse, a bucket's + // devup-ui-N.css shared by several source files. Files extracted after that + // would be missing, so the files the entries reach are extracted up front + // (single shared WASM instance). Re-extraction by the per-file loader is + // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets + // through the files the loaders write instead. + if (!this.options.watch) { + const { entry, context = process.cwd() } = compiler.options + this.prewarmExtractor( + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.map((request) => resolve(context, request)), + ), + ) } if (this.options.watch) { @@ -309,6 +321,66 @@ export class DevupUIWebpackPlugin { }), ) if (!this.options.watch) { + // A stylesheet module is built on its first import, which can come before + // the modules whose styles it holds are extracted. When one was, compile + // once more: every module is extracted by then. Watch mode rebuilds it + // through the stylesheet files the loaders write instead. + let passes = 0 + compiler.hooks.run.tap('DevupUIWebpackPlugin', () => { + passes = 0 + }) + compiler.hooks.thisCompilation.tap( + 'DevupUIWebpackPlugin', + (compilation) => { + const basePath = join(this.options.cssDir, 'devup-ui.css') + const base = getCss(null, true) + // A file's stylesheet `@import`s the shared base, which the CSS + // loaders read from disk without passing through this plugin, so it + // must hold every style extracted so far + if (!existsSync(basePath) || readFileSync(basePath, 'utf-8') !== base) + writeFileSync(basePath, base, 'utf-8') + let stale: string[] = [] + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => { + if (compiler.watchMode) return + const changed = new Set( + [...servedCss(compilation)] + .filter( + ([path, css]) => + getCss(getFileNumByFilename(path), true) !== css, + ) + .map(([path]) => path), + ) + if (getCss(null, true) !== base) changed.add(basePath) + stale = [...changed] + }) + // The next pass writes the build; this one writes none of its files + compilation.hooks.processAssets.tap( + { + name: 'DevupUIWebpackPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (stale.length === 0 || passes > 0) return + for (const name of Object.keys(compilation.assets)) + compilation.deleteAsset(name) + }, + ) + compilation.hooks.needAdditionalPass.tap( + 'DevupUIWebpackPlugin', + () => { + if (stale.length === 0 || passes > 0) return undefined + passes += 1 + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + return true + }, + ) + }, + ) compiler.hooks.done.tapPromise('DevupUIWebpackPlugin', async (stats) => { if (!stats.hasErrors()) { // write css file diff --git a/packages/webpack-plugin/src/served-css.ts b/packages/webpack-plugin/src/served-css.ts new file mode 100644 index 00000000..c04ca03f --- /dev/null +++ b/packages/webpack-plugin/src/served-css.ts @@ -0,0 +1,18 @@ +declare global { + var __devupUiServedCss: WeakMap> | undefined +} + +/** + * The stylesheet each devup-ui CSS module of `compilation` was built with, by + * resource path. The plugin and its loaders load as separate modules, so the + * record lives on `globalThis`. + */ +export function servedCss(compilation: object): Map { + globalThis.__devupUiServedCss ??= new WeakMap() + let served = globalThis.__devupUiServedCss.get(compilation) + if (!served) { + served = new Map() + globalThis.__devupUiServedCss.set(compilation, served) + } + return served +} From 383ca0543b32ade26dc963ba8fdd8a85ab62c082 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 21:43:01 +0900 Subject: [PATCH 03/50] fix(next-plugin): isolate coordinator port file and explain unreachable coordinators --- ...gepack_log_coordinator_port_isolation.json | 7 + .../src/__tests__/coordinator-port.test.ts | 92 ++++++++++++ .../src/__tests__/css-loader.test.ts | 5 +- .../next-plugin/src/__tests__/loader.test.ts | 6 +- packages/next-plugin/src/coordinator-port.ts | 133 ++++++++++++++++++ packages/next-plugin/src/coordinator.ts | 11 +- packages/next-plugin/src/css-loader.ts | 22 ++- packages/next-plugin/src/loader.ts | 22 ++- packages/next-plugin/src/plugin.ts | 14 +- 9 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 .changepacks/changepack_log_coordinator_port_isolation.json create mode 100644 packages/next-plugin/src/__tests__/coordinator-port.test.ts create mode 100644 packages/next-plugin/src/coordinator-port.ts diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 00000000..667a7527 --- /dev/null +++ b/.changepacks/changepack_log_coordinator_port_isolation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch" + }, + "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable", + "date": "2026-10-02T12:41:12.3693972Z" +} diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts new file mode 100644 index 00000000..b934cf06 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + formatPortFile, + isConnectionError, + parsePortFile, + removeOwnPortFile, + removeStalePortFile, + resolveCoordinatorPortFile, + unreachableCoordinatorError, +} from '../coordinator-port' + +const dirs: string[] = [] +function tmp(): string { + const dir = mkdtempSync(join(tmpdir(), 'devup-port-')) + dirs.push(dir) + return dir +} +// A pid that cannot be alive: far above any OS limit. +const DEAD_PID = 2 ** 31 - 2 +// The parent process is alive and is not this one. +const LIVE_FOREIGN_PID = process.ppid + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('coordinator port file', () => { + it('keeps the port on the first line so older readers still parse it', () => { + const text = formatPortFile(4321, 99) + expect(Number.parseInt(text, 10)).toBe(4321) + expect(parsePortFile(text)).toEqual({ port: 4321, pid: 99 }) + }) + + it('reads a bare port written by an older release', () => { + expect(parsePortFile('4321\n')).toEqual({ port: 4321, pid: undefined }) + }) + + it('uses the shared name unless another live process owns it', () => { + const dir = tmp() + const shared = join(dir, 'coordinator.port') + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, DEAD_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, process.pid)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, LIVE_FOREIGN_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe( + join(dir, `coordinator.${process.pid}.port`), + ) + }) + + it('never deletes a live foreign owner file, but clears a dead one', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(1, LIVE_FOREIGN_PID)) + removeStalePortFile(file) + removeOwnPortFile(file) + expect(Bun.file(file).size).toBeGreaterThan(0) + + writeFileSync(file, formatPortFile(1, DEAD_PID)) + removeStalePortFile(file) + expect(Bun.file(file).size).toBe(0) + }) + + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(4321, DEAD_PID)) + const message = unreachableCoordinatorError( + file, + Object.assign(new Error('connect ETIMEDOUT 127.0.0.1:4321'), { + code: 'ETIMEDOUT', + }), + ).message + expect(message).toContain('127.0.0.1:4321') + expect(message).toContain(`owner pid ${DEAD_PID} (not running)`) + expect(message).toContain('restart the dev server or build') + }) + + it('recognises connection failures, including aggregated ones', () => { + expect(isConnectionError({ code: 'ECONNREFUSED' })).toBe(true) + expect(isConnectionError({ errors: [{ code: 'ETIMEDOUT' }] })).toBe(true) + expect(isConnectionError(new Error('boom'))).toBe(false) + }) +}) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f718..af08e5a6 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -358,7 +358,10 @@ describe('devupUICssLoader', () => { } as unknown as CssLoaderThis)(Buffer.from(''), '', '') }) - expect(error.message).toBe('Coordinator port file not found') + expect(error.message).toContain( + 'Coordinator port file not found: nonexistent.port', + ) + expect(error.message).toContain('Restart the dev server or build') }) it('should error when coordinator returns non-200 status', async () => { diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index c7f33b49..7f885888 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -850,7 +850,11 @@ describe('devupUILoader', () => { // Retries 20 times × 50ms = 1s max, then calls back with error await waitFor(() => { expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator port file not found'), + expect.objectContaining({ + message: expect.stringContaining( + 'Coordinator port file not found: nonexistent.port', + ), + }), ) }, 3000) diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts new file mode 100644 index 00000000..1335a4c3 --- /dev/null +++ b/packages/next-plugin/src/coordinator-port.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * What a coordinator writes to its port file: the port on the first line (all + * an older reader looks at, via `parseInt`) and the owning pid on the second. + */ +export interface CoordinatorPortInfo { + port: number + /** Process that owns the coordinator, when the file says. */ + pid?: number +} + +export function formatPortFile(port: number, pid = process.pid): string { + return `${port}\n${pid}` +} + +export function parsePortFile(text: string): CoordinatorPortInfo { + const [portLine = '', pidLine = ''] = text.trim().split(/\r?\n/) + const port = Number.parseInt(portLine.trim(), 10) + const pid = Number.parseInt(pidLine.trim(), 10) + return { port, pid: Number.isInteger(pid) && pid > 0 ? pid : undefined } +} + +/** Whether a process exists. EPERM means it exists but is not ours. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function readOwner(portFile: string): CoordinatorPortInfo | undefined { + try { + return parsePortFile(readFileSync(portFile, 'utf-8')) + } catch { + return undefined + } +} + +/** A live coordinator owned by some other process, if the file names one. */ +export function foreignLiveOwner(portFile: string): number | undefined { + const pid = readOwner(portFile)?.pid + return pid !== undefined && pid !== process.pid && isProcessAlive(pid) + ? pid + : undefined +} + +/** + * The port file this process should use in `distDir`. + * + * `next dev` and `next build` in one directory used to share one file: the + * second one deleted the first one's port and overwrote it, and the first's + * loaders then talked to a dead port or the wrong coordinator. The shared name + * is kept when nobody else holds it (so a cache keyed on loader options stays + * stable in the common case); when a different live process owns it, this + * process gets its own `coordinator..port`. + */ +export function resolveCoordinatorPortFile(distDir: string): string { + const shared = join(distDir, 'coordinator.port') + return foreignLiveOwner(shared) === undefined + ? shared + : join(distDir, `coordinator.${process.pid}.port`) +} + +/** Remove the file unless a different live process owns it. */ +export function removeStalePortFile(portFile: string): void { + if (foreignLiveOwner(portFile) !== undefined) return + try { + unlinkSync(portFile) + } catch { + // Nothing to remove (first run). + } +} + +/** Remove the file only if this process wrote it. */ +export function removeOwnPortFile(portFile: string): void { + const owner = readOwner(portFile) + if (owner?.pid !== undefined && owner.pid !== process.pid) return + try { + unlinkSync(portFile) + } catch { + // Already gone. + } +} + +const CONNECTION_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'EHOSTUNREACH', +]) + +export function isConnectionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== undefined && CONNECTION_CODES.has(code)) return true + // An AggregateError from `localhost` resolution carries its codes inside. + const inner = (error as { errors?: unknown[] } | undefined)?.errors + return Array.isArray(inner) && inner.some(isConnectionError) +} + +/** The port file is missing: say whose it should be and how to recover. */ +export function missingPortFileError(portFile: string): Error { + return new Error( + `Coordinator port file not found: ${portFile}. ` + + 'The devup-ui coordinator for this build is not running (or exited). ' + + 'Restart the dev server or build; if another `next dev`/`next build` ' + + 'uses the same distDir, stop it or give this one its own distDir.', + ) +} + +/** A coordinator that was running is not answering. */ +export function unreachableCoordinatorError( + portFile: string, + cause: unknown, +): Error { + const info = existsSync(portFile) ? readOwner(portFile) : undefined + const owner = + info?.pid === undefined + ? 'unknown owner' + : `owner pid ${info.pid} (${isProcessAlive(info.pid) ? 'running' : 'not running'})` + const detail = cause instanceof Error ? cause.message : String(cause) + const next = + info?.pid !== undefined && !isProcessAlive(info.pid) + ? 'The coordinator process has exited: restart the dev server or build.' + : 'Another process may have replaced the coordinator: restart the dev server or build, and avoid running `next dev` and `next build` in one distDir at the same time.' + return new Error( + `Coordinator unreachable at 127.0.0.1:${info?.port ?? '?'} (${owner}, port file ${portFile}): ${detail}. ${next}`, + ) +} diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf5347..830c6520 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -1,9 +1,10 @@ -import { unlinkSync, writeFile, writeFileSync } from 'node:fs' +import { writeFile, writeFileSync } from 'node:fs' import { createServer, type IncomingMessage, type Server } from 'node:http' import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { formatPortFile, removeOwnPortFile } from './coordinator-port' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' @@ -668,7 +669,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.listen(0, '127.0.0.1', () => { const addr = coordinatorServer.address() if (addr && typeof addr !== 'string') { - writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') + writeFileSync(coordinatorPortFile, formatPortFile(addr.port), 'utf-8') } }) @@ -682,11 +683,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.close() if (server === coordinatorServer) { server = null - try { - unlinkSync(coordinatorPortFile) - } catch { - // ignore if already deleted - } + removeOwnPortFile(coordinatorPortFile) } }, } diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index a404c277..9dec9da2 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -4,6 +4,12 @@ import { Agent, request } from 'node:http' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUICssLoaderOptions { @@ -26,7 +32,7 @@ const keepAliveAgent = new Agent({ keepAlive: true }) function readCoordinatorPort(portFile: string): number { if (cachedPort !== null) return cachedPort - cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) + cachedPort = parsePortFile(readFileSync(portFile, 'utf-8')).port return cachedPort } @@ -96,7 +102,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = setTimeout(() => tryFetch(retries - 1), 50) return } - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -107,7 +113,17 @@ const devupUICssLoader: RawLoaderDefinitionFunction = importMainCss, !watch, (err, css) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // The port may belong to a coordinator that has since been + // replaced; forget it so the next call re-reads the file. + cachedPort = null + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, css) }, ) diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index de9f1515..8699814e 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -5,6 +5,12 @@ import { basename, dirname, join, relative } from 'node:path' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUILoaderOptions { @@ -43,7 +49,7 @@ function readCoordinatorPort(portFile: string): number { const cachedPort = cachedPorts.get(portFile) if (cachedPort !== undefined) return cachedPort - const port = Number.parseInt(readFileSync(portFile, 'utf-8').trim(), 10) + const port = parsePortFile(readFileSync(portFile, 'utf-8')).port cachedPorts.set(portFile, port) return port } @@ -146,7 +152,7 @@ const devupUILoader: RawLoaderDefinitionFunction = return } // Port file never appeared — fall through to error - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -164,7 +170,17 @@ const devupUILoader: RawLoaderDefinitionFunction = resourcePath: this.resourcePath, }) coordinatorExtract(port, body, (err, content, sourceMap) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // Forget a port that no longer answers so the next file + // re-reads the port file instead of repeating the timeout. + cachedPorts.delete(coordinatorPortFile) + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, content, sourceMap as Parameters[2]) }) } catch (error) { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692e..6ef697ac 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -29,6 +29,10 @@ import { startCoordinator, takeExtractOutput, } from './coordinator' +import { + removeStalePortFile, + resolveCoordinatorPortFile, +} from './coordinator-port' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' @@ -331,7 +335,7 @@ export function DevupUI( // disable turbo parallel const excludeRegex = createNodeModulesExcludeRegex(include, '.mdx.[tj]sx?$') - const coordinatorPortFile = join(distDir, 'coordinator.port') + const coordinatorPortFile = resolveCoordinatorPortFile(distDir) // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one // importer merge into that importer's bucket, so their identical atoms share @@ -523,11 +527,9 @@ export function DevupUI( // Delete stale port file from previous session so loaders don't connect // to a dead coordinator port. The new coordinator writes a fresh port file // once it starts listening. - try { - unlinkSync(coordinatorPortFile) - } catch { - // Port file doesn't exist (first run) — safe to ignore - } + // A live coordinator owned by another process is left alone (see + // resolveCoordinatorPortFile). + removeStalePortFile(coordinatorPortFile) const coordinator = startCoordinator({ wasm, From 1d6a14a4b1c614f20fcbc61b0f55d167150db424 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 21:16:46 +0900 Subject: [PATCH 04/50] feat(plugins): number files and name classes independently of processing order Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_deterministic_numbering.json | 13 ++ bindings/devup-ui-wasm/src/lib.rs | 111 ++++++++++++- classMapFile | 1 + cssDir/devup-ui-1.css | 1 + cssDir/devup-ui.css | 1 + cssFile/cssFile | 1 + cssFile/devup-ui.css | 1 + fileMapFile | 1 + foo/cssFile | 1 + libs/css/src/file_map.rs | 34 ++++ libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 32 +++- libs/extractor/src/module_loader.rs | 15 ++ libs/extractor/src/tailwind.rs | 4 +- libs/extractor/src/vanilla_extract.rs | 3 + libs/sheet/src/lib.rs | 44 ++++- packages/bun-plugin/src/plugin.ts | 24 +++ .../__tests__/deterministic-numbering.test.ts | 144 ++++++++++++++++ .../next-plugin/src/__tests__/loader.test.ts | 61 +++++++ .../next-plugin/src/__tests__/plugin.test.ts | 26 +++ packages/next-plugin/src/loader.ts | 19 ++- packages/next-plugin/src/plugin.ts | 25 ++- .../src/__tests__/numbering.test.ts | 85 ++++++++++ .../src/__tests__/state-writer.test.ts | 106 ++++++++++++ packages/plugin-utils/src/build-session.ts | 23 +++ packages/plugin-utils/src/import-graph.ts | 13 +- packages/plugin-utils/src/index.ts | 12 ++ packages/plugin-utils/src/numbering.ts | 66 ++++++++ packages/plugin-utils/src/state-writer.ts | 70 ++++++++ .../src/__tests__/plugin.test.ts | 48 +++++- packages/rsbuild-plugin/src/plugin.ts | 35 +++- .../vite-plugin/src/__tests__/plugin.test.ts | 157 ++++++++++-------- packages/vite-plugin/src/plugin.ts | 103 +++++------- .../src/__tests__/plugin.test.ts | 36 ++++ packages/webpack-plugin/src/loader.ts | 19 ++- packages/webpack-plugin/src/plugin.ts | 30 +++- sheetFile | 1 + 37 files changed, 1188 insertions(+), 180 deletions(-) create mode 100644 .changepacks/changepack_log_deterministic_numbering.json create mode 100644 classMapFile create mode 100644 cssDir/devup-ui-1.css create mode 100644 cssDir/devup-ui.css create mode 100644 cssFile/cssFile create mode 100644 cssFile/devup-ui.css create mode 100644 fileMapFile create mode 100644 foo/cssFile create mode 100644 packages/next-plugin/src/__tests__/deterministic-numbering.test.ts create mode 100644 packages/plugin-utils/src/__tests__/numbering.test.ts create mode 100644 packages/plugin-utils/src/__tests__/state-writer.test.ts create mode 100644 packages/plugin-utils/src/build-session.ts create mode 100644 packages/plugin-utils/src/numbering.ts create mode 100644 packages/plugin-utils/src/state-writer.ts create mode 100644 sheetFile diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 00000000..27507cb8 --- /dev/null +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. Vite removes the CSS the client already emits from the server bundle's forwarding set whichever environment finishes first. File lists are ordered by code point rather than the locale", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f..d6175ba3 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -243,7 +243,14 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(serde_json::to_string).map_err(|e| e.to_string()) + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importClassMap")] @@ -261,7 +268,11 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(serde_json::to_string).map_err(|e| e.to_string()) + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importFileMap")] @@ -284,7 +295,11 @@ pub fn import_canonical_map_internal(map: HashMap) { /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(serde_json::to_string).map_err(|e| e.to_string()) + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) } #[wasm_bindgen(js_name = "importCanonicalMap")] @@ -300,6 +315,34 @@ pub fn export_canonical_map() -> Result { export_canonical_map_internal().map_err(js_error) } +/// Number every file in `files` now, in path order, keeping numbers files +/// already hold, so class prefixes depend on the paths and not on the order +/// workers reach files in. +#[wasm_bindgen(js_name = "seedFileMap")] +pub fn seed_file_map(files: Vec) { + css::file_map::seed_file_numbers(&files); +} + +/// Forget everything one build left in the engine. +/// +/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting +/// and the module resolver, so the next build starts from its own options +/// alone. Theme, shorthands and debug mode are set by every build, and stay. +pub fn reset_build_state_internal() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + MODULE_RESOLVER.with_borrow_mut(|current| *current = None); +} + +#[wasm_bindgen(js_name = "resetBuildState")] +pub fn reset_build_state() { + reset_build_state_internal(); +} /// Set the atom-level hoist threshold. /// /// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct @@ -1551,7 +1594,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1929,6 +1972,60 @@ mod tests { assert!(json_str.starts_with('{') || json_str.starts_with("[]")); } + #[test] + #[serial] + fn test_exported_maps_are_canonical_json() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::class_map::set_class_map(HashMap::from([ + ( + "b.tsx".to_string(), + HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]), + ), + ("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])), + ])); + assert_eq!( + export_class_map_internal().unwrap(), + r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"# + ); + seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]); + assert_eq!( + export_file_map_internal().unwrap(), + r#"{"a.tsx":0,"b.tsx":1}"# + ); + import_canonical_map_internal(HashMap::from([ + ("y".to_string(), "b".to_string()), + ("x".to_string(), "a".to_string()), + ])); + assert_eq!( + export_canonical_map_internal().unwrap(), + r#"{"x":"a","y":"b"}"# + ); + reset_build_state_internal(); + assert_eq!(export_class_map_internal().unwrap(), "{}"); + assert_eq!(export_file_map_internal().unwrap(), "{}"); + assert_eq!(export_canonical_map_internal().unwrap(), "{}"); + assert_eq!(get_prefix(), None); + } + + #[test] + #[serial] + fn test_numbers_do_not_depend_on_the_order_files_are_seen() { + let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"]; + let mut maps = Vec::new(); + for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] { + reset_build_state_internal(); + seed_file_map(files.iter().map(ToString::to_string).collect()); + for index in order { + let _ = css::file_map::get_file_num_by_filename(files[index]); + } + maps.push(export_file_map_internal().unwrap()); + } + assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#); + assert_eq!(maps[0], maps[1]); + assert_eq!(maps[0], maps[2]); + } #[test] #[serial] fn test_code_extract_internal_success() { @@ -1951,7 +2048,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +2072,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2097,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/classMapFile b/classMapFile new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/classMapFile @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cssDir/devup-ui-1.css b/cssDir/devup-ui-1.css new file mode 100644 index 00000000..c848720f --- /dev/null +++ b/cssDir/devup-ui-1.css @@ -0,0 +1 @@ +/* write-error.tsx 0 */ \ No newline at end of file diff --git a/cssDir/devup-ui.css b/cssDir/devup-ui.css new file mode 100644 index 00000000..788cdbf9 --- /dev/null +++ b/cssDir/devup-ui.css @@ -0,0 +1 @@ +base-css \ No newline at end of file diff --git a/cssFile/cssFile b/cssFile/cssFile new file mode 100644 index 00000000..493ec68a --- /dev/null +++ b/cssFile/cssFile @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/cssFile/devup-ui.css b/cssFile/devup-ui.css new file mode 100644 index 00000000..493ec68a --- /dev/null +++ b/cssFile/devup-ui.css @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/fileMapFile b/fileMapFile new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/fileMapFile @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/foo/cssFile b/foo/cssFile new file mode 100644 index 00000000..493ec68a --- /dev/null +++ b/foo/cssFile @@ -0,0 +1 @@ +css \ No newline at end of file diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a9..258b2558 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { }) } +/// Give every file in `files` a number now, in path order. +/// +/// Numbers files already hold are kept. The numbers files get then depend on the paths alone, +/// not on which file a worker reaches first; files that appear later (in +/// development) are numbered after the existing ones. +pub fn seed_file_numbers(files: &[String]) { + let mut sorted: Vec<&String> = files.iter().collect(); + sorted.sort_unstable(); + sorted.dedup(); + for file in sorted { + let _ = get_file_num_by_filename(file); + } +} #[must_use] pub fn get_filename_by_file_num(file_num: usize) -> String { with_file_map(|map| { @@ -196,6 +209,27 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_is_independent_of_order() { + reset_file_map(); + seed_file_numbers(&[ + "b.tsx".to_string(), + "a.tsx".to_string(), + "b.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + seed_file_numbers(&[ + "c.tsx".to_string(), + "0.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("0.tsx"), 2); + assert_eq!(get_file_num_by_filename("c.tsx"), 3); + reset_file_map(); + } #[test] #[serial] fn test_set_and_get_file_map() { diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb02..3b2346ae 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36..4d126e12 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -13631,6 +13631,30 @@ globalCss({ assert!(!output.code.contains("css("), "{}", output.code); } + #[test] + #[serial] + fn test_stylesheets_read_nothing_that_differs_between_builds() { + for (read, fails) in [ + ("Math.random()", true), + ("Date.now()", true), + ("new Date()", true), + ("Date()", true), + ("new Date(0).getTime()", false), + ("Date.UTC(2020, 0, 1)", false), + ] { + reset_class_map(); + reset_file_map(); + let result = extract( + "when.css.ts", + &format!( + "import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});" + ), + ExtractOption::default(), + ); + assert_eq!(result.is_err(), fails, "{read}"); + } + } + #[test] #[serial] fn test_vanilla_extract_execution_fallback() { @@ -18778,7 +18802,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 4082ff39..f64390a8 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -21,6 +21,21 @@ pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; /// A console whose calls do nothing, as what a module logs while it runs /// changes no value +/// Run before a stylesheet: what differs between builds fails the evaluation, +/// so a stylesheet never gives different rules for the same source. `new +/// Date(0)` and other fixed dates stay. +pub(crate) const DETERMINISM: &str = r#"(() => { + const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); }; + Math.random = refuse("Math.random"); + const RealDate = Date; + globalThis.Date = new Proxy(RealDate, { + construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget), + apply: refuse("Date()"), + get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver), + }); +})(); +"#; + pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; /// Loop iterations an evaluation may run before it fails instead of hanging diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f517..70643aa7 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 9e736b09..169996fd 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -276,6 +276,9 @@ pub fn execute_stylesheet( context .eval(Source::from_bytes(crate::module_loader::CONSOLE)) .map_err(|e| format!("JS execution error: {e}"))?; + context + .eval(Source::from_bytes(crate::module_loader::DETERMINISM)) + .map_err(|e| format!("JS execution error: {e}"))?; context .eval(Source::from_bytes(run.as_bytes())) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8..fb99cef6 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -502,7 +502,11 @@ impl StyleSheet { Some(filename) }; let bucket_scope = if single_css { None } else { Some(filename) }; - for style in styles { + // Names are handed out in the order styles are first seen, so the + // set is walked in a fixed order, not the hash order. + let mut ordered: Vec<&ExtractStyleValue> = styles.iter().collect(); + ordered.sort_unstable(); + for style in ordered { match style { // A conditional `typography` preset: its class is the atom, and the // preset's declarations are emitted under the atom's selector. @@ -2909,6 +2913,44 @@ mod tests { assert_debug_snapshot!(sheet.create_css(None, true).split("*/").nth(1).unwrap()); } + #[test] + #[serial] + fn test_class_names_do_not_depend_on_the_order_styles_are_inserted() { + use extractor::extract_style::extract_static_style::ExtractStaticStyle; + use extractor::extract_style::extract_style_value::ExtractStyleValue; + + let values: Vec = (0..40) + .map(|index| { + ExtractStyleValue::Static(ExtractStaticStyle::new( + ["color", "margin", "padding", "width"][index % 4], + &format!("{index}px"), + 0, + None, + )) + }) + .collect(); + let mut outputs = Vec::new(); + for reverse in [false, true] { + css::class_map::reset_class_map(); + let mut styles = FxHashSet::default(); + if reverse { + values.iter().rev().for_each(|value| { + styles.insert(value.clone()); + }); + } else { + values.iter().for_each(|value| { + styles.insert(value.clone()); + }); + } + let mut sheet = StyleSheet::default(); + sheet.update_styles(&styles, "index.tsx", true); + outputs.push(( + sheet.create_css(None, true), + css::class_map::get_class_map(), + )); + } + assert_eq!(outputs[0], outputs[1]); + } #[test] #[serial] fn test_update_styles_with_typography() { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 5fc4b01c..7ae81ab9 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -3,12 +3,15 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, + collectNumberedFiles, createCompatTypes, createModuleResolver, createThemeInterfaceArgs, type CustomShorthands, loadDevupConfig, mergeImportAliases, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -16,8 +19,11 @@ import { getThemeInterface, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setDebug, setModuleResolver, + setPrefix, } from '@devup-ui/wasm' import { type BunPlugin, plugin, type PluginBuilder } from 'bun' @@ -73,7 +79,21 @@ async function writeDataFiles() { async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) + setPrefix(null) setModuleResolver(createModuleResolver()) + // Number every source file in path order, so class prefixes do not depend + // on the order Bun loads files in + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve('src')], + toId: (path) => path, + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -149,6 +169,10 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { async setup(build: PluginBuilder) { // `Bun.build` hands its config to plugins; the runtime has none const bundling = build.config !== undefined + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + build.onEnd?.(endBuild) await initialize(options) setDebug(options.debug ?? !bundling) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts new file mode 100644 index 00000000..e0afcf1f --- /dev/null +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -0,0 +1,144 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { collectNumberedFiles, seedFileNumbers } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' + +const sources: Record = { + 'src/a.tsx': `import { Box } from '@devup-ui/react'\nexport const A = () => `, + 'src/b.tsx': `import { Box } from '@devup-ui/react'\nexport const B = () => `, + 'src/nested/c.tsx': `import { Box } from '@devup-ui/react'\nexport const C = () => `, + 'src/nested/d.tsx': `import { Box, Flex } from '@devup-ui/react'\nexport const D = () => `, + 'node_modules/@acme/ui/e.tsx': `import { Box } from '@devup-ui/react'\nexport const E = () => `, +} + +/** How each plugin names the files it extracts */ +const schemes: Record (path: string) => string> = { + 'vite (absolute, posix)': () => (path) => path.replaceAll('\\', '/'), + 'webpack and next (relative to cwd)': (root) => (path) => + relative(root, path).replaceAll('\\', '/'), + 'rsbuild and bun (absolute, as found)': () => (path) => path, +} + +describe('class and file numbers do not depend on the order files are seen', () => { + let root: string + const original = process.cwd() + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-order-')) + for (const [path, code] of Object.entries(sources)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + process.chdir(root) + }) + + afterAll(() => { + wasm.resetBuildState() + process.chdir(original) + rmSync(root, { recursive: true, force: true }) + }) + + function build( + scheme: (path: string) => string, + order: string[], + parallel: boolean, + ) { + wasm.resetBuildState() + seedFileNumbers( + wasm, + collectNumberedFiles({ + roots: [join(root, 'src')], + include: ['@acme/ui'], + cwd: root, + toId: scheme, + }), + ) + const extract = (file: string) => { + const output = wasm.codeExtractWithoutSourceMap( + scheme(join(root, file)), + readFileSync(join(root, file), 'utf-8'), + '@devup-ui/react', + '../css', + false, + false, + true, + {}, + ) + return { file, code: output.code, cssFile: output.cssFile } + } + const outputs = parallel + ? Promise.all(order.map(async (file) => extract(file))) + : Promise.resolve(order.map(extract)) + return outputs.then((results) => { + const byFile = Object.fromEntries( + results + .sort((a, b) => a.file.localeCompare(b.file)) + .map((r) => [r.file, r]), + ) + const fileMap = JSON.parse(wasm.exportFileMap()) as Record + const sheets = Object.values(fileMap) + .sort((a, b) => a - b) + .map((number) => wasm.getCss(number, false)) + return { + byFile, + fileMap: wasm.exportFileMap(), + classMap: wasm.exportClassMap(), + sheets, + base: wasm.getCss(null, false), + } + }) + } + + const files = Object.keys(sources) + const orders = [ + files, + [...files].reverse(), + [files[2], files[4], files[0], files[3], files[1]], + ] + + it.each(Object.entries(schemes))( + 'gives the same output for any order, one at a time or at once: %s', + async (_name, makeScheme) => { + const scheme = makeScheme(root) + const results = [] + for (const order of orders) { + results.push(await build(scheme, order, false)) + results.push(await build(scheme, order, true)) + } + for (const result of results.slice(1)) { + expect(result.fileMap).toBe(results[0].fileMap) + expect(result.byFile).toEqual(results[0].byFile) + expect(result.sheets).toEqual(results[0].sheets) + expect(result.base).toBe(results[0].base) + } + const numbers = Object.values( + JSON.parse(results[0].fileMap) as Record, + ).sort((a, b) => a - b) + expect(numbers).toEqual([0, 1, 2, 3, 4]) + }, + ) + + it('numbers the files in path order, and files that appear later after them', () => { + wasm.resetBuildState() + seedFileNumbers(wasm, ['b.tsx', 'a.tsx']) + seedFileNumbers(wasm, ['0.tsx', 'a.tsx', 'c.tsx']) + expect(JSON.parse(wasm.exportFileMap())).toEqual({ + 'a.tsx': 0, + 'b.tsx': 1, + '0.tsx': 2, + 'c.tsx': 3, + }) + expect(wasm.exportFileMap()).toBe( + '{"0.tsx":2,"a.tsx":0,"b.tsx":1,"c.tsx":3}', + ) + }) +}) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index cab12c69..3ac31da7 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -603,6 +603,67 @@ describe('devupUILoader', () => { requestSpy.mockRestore() }) + it('should report a coordinator that no longer answers', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12346') + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, _callback?: any) => { + const fakeReq = { + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'error') { + setTimeout( + () => + handler( + Object.assign(new Error('refused'), { + code: 'ECONNREFUSED', + }), + ), + 0, + ) + } + return fakeReq + }), + write: mock(), + end: mock(), + } + return asClientRequest(fakeReq) + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(asLoaderContext(t))( + Buffer.from('source code'), + 'src/App.tsx', + ) + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('coordinator.port'), + }), + ) + }) + + requestSpy.mockRestore() + }) it('should handle coordinator non-200 response', async () => { existsSyncSpy.mockReturnValue(true) readFileSyncSpy.mockReturnValue('12345') diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7..dce4cd7b 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -784,6 +784,32 @@ export const box = style({ color: 'red' })` DevupUI({}, { prefix: 'my-prefix' }) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + it('numbers the files the scan finds, and survives a failing scan', () => { + process.env.TURBOPACK = '1' + const collectSpy = spyOn(importGraphModule, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.roots).toEqual( + ['src', 'app', 'pages'].map((dir) => resolve(dir)), + ) + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx'] + }) + DevupUI({}, { include: ['@acme/ui'] }) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + include: ['@acme/ui'], + }) + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + DevupUI({}, {}) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) it('should import previous session state on restart', () => { process.env.TURBOPACK = '1' existsSyncSpy diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index 2270af5a..4fd488d0 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative, resolve } from 'node:path' +import { createStateWriter } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { @@ -13,6 +14,10 @@ import { } from './coordinator-port' import { loadWasm } from './wasm' +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) + export interface DevupUILoaderOptions { package: string cssDir: string @@ -282,19 +287,23 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle && watch) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile && watch) { // don't write file when build promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${this.resourcePath} ${Date.now()} */`, ), - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } Promise.all(promises).then( diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index d083d4b6..9a710002 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -11,6 +11,7 @@ import { deserialize, serialize } from 'node:v8' import { buildCanonicalMap, buildStaticImportGraph, + collectNumberedFiles, computeCompiledFiles, computeFileRoutes, createCompatTypes, @@ -20,6 +21,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type StaticImportGraph, } from '@devup-ui/plugin-utils' import { type NextConfig } from 'next' @@ -233,9 +235,7 @@ export function DevupUI( registerShorthands(shorthands ?? {}) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) writeFileSync( join(distDir, 'compat.d.ts'), @@ -250,7 +250,24 @@ export function DevupUI( importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) } catch { - // No previous session state (first run) or corrupt files — start fresh + // No previous session state (first run) or corrupt files, start fresh + } + // Number every file the build can extract in path order, so class prefixes + // do not depend on the order modules reach a loader. Numbers restored above + // stay; files that appear later get the numbers after them. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap: wasm.seedFileMap }, + collectNumberedFiles({ + roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), + include, + cwd, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. } const devupConfig = loadDevupConfigSync(devupFile) diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts new file mode 100644 index 00000000..8a284b47 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' + +import { collectNumberedFiles, seedFileNumbers } from '../numbering' + +describe('collectNumberedFiles', () => { + let root: string + + const write = (path: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), 'export {}') + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) + write('src/b.tsx') + write('src/a.tsx') + write('src/nested/c.ts') + write('src/readme.md') + write('node_modules/@acme/ui/Button.tsx') + write('node_modules/@acme/ui/node_modules/dep/skipped.tsx') + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('lists the project source and the included packages in path order', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'missing')], + include: ['@acme/ui', 'not-installed'], + cwd: root, + }) + expect( + files.map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ).toEqual( + [...files] + .sort() + .map((file) => file.slice(file.indexOf('devup-ui-numbering-'))), + ) + expect(files.filter((file) => file.includes('/src/'))).toHaveLength(3) + expect(files.some((file) => file.endsWith('/@acme/ui/Button.tsx'))).toBe( + true, + ) + expect(files.some((file) => file.includes('skipped'))).toBe(false) + }) + + it('does not depend on the order of the roots', () => { + const first = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], + cwd: root, + }) + const second = collectNumberedFiles({ + roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')], + cwd: root, + }) + expect(first).toEqual(second) + }) + + it('names files the way the plugin extracts them', () => { + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + expect(files).toEqual([ + 'id:/src/a.tsx', + 'id:/src/b.tsx', + 'id:/src/nested/c.ts', + ]) + }) +}) + +describe('seedFileNumbers', () => { + it('seeds files, and nothing when there are none', () => { + const seedFileMap = mock() + seedFileNumbers({ seedFileMap }, []) + expect(seedFileMap).not.toHaveBeenCalled() + seedFileNumbers({ seedFileMap }, ['a']) + expect(seedFileMap).toHaveBeenCalledWith(['a']) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 00000000..661189cf --- /dev/null +++ b/packages/plugin-utils/src/__tests__/state-writer.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { beginBuild } from '../build-session' +import { createStateWriter, writeFileAtomically } from '../state-writer' + +describe('createStateWriter', () => { + it('leaves the newest snapshot, whichever write would finish last', async () => { + const written: string[] = [] + const release: Array<() => void> = [] + const writer = createStateWriter( + (_path, content) => + new Promise((done) => { + release.push(() => { + written.push(content) + done() + }) + }), + ) + const first = writer.write('state', 'one') + const second = writer.write('state', 'two') + const third = writer.write('state', 'three', 'utf-8') + release[0]() + await first + await new Promise((resolve) => setTimeout(resolve, 0)) + release[1]() + await Promise.all([second, third]) + // the second snapshot was overtaken before it started + expect(written).toEqual(['one', 'three']) + expect(release).toHaveLength(2) + }) + + it('keeps writing after a write fails, and keeps paths apart', async () => { + const calls: string[] = [] + const writer = createStateWriter(async (path, content) => { + calls.push(`${path}=${content}`) + if (content === 'bad') throw new Error('disk full') + }) + const bad = writer.write('a', 'bad') + const good = writer.write('a', 'good') + await expect(bad).rejects.toThrow('disk full') + await good + await writer.write('b', 'other') + expect(calls).toEqual(['a=bad', 'a=good', 'b=other']) + }) +}) + +describe('writeFileAtomically', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devup-ui-writer-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('replaces the file whole and leaves no temporary file', async () => { + const path = join(dir, 'deep', 'state.json') + await writeFileAtomically(path, '1') + await writeFileAtomically(path, '22') + expect(readFileSync(path, 'utf-8')).toBe('22') + expect(readdirSync(join(dir, 'deep'))).toEqual(['state.json']) + }) + + it('is what the writer uses unless told otherwise', async () => { + const path = join(dir, 'state.json') + await createStateWriter().write(path, 'first') + await createStateWriter().write(path, 'second', 'utf-8') + expect(readFileSync(path, 'utf-8')).toBe('second') + }) + + it('cleans up when the file cannot be replaced', async () => { + const path = join(dir, 'target') + await writeFileAtomically(join(path, 'inner'), 'x') + await expect(writeFileAtomically(path, 'y')).rejects.toBeDefined() + expect(readdirSync(dir)).toEqual(['target']) + }) +}) + +describe('beginBuild', () => { + it('resets the engine only when no other build is running', () => { + let resets = 0 + const engine = { + resetBuildState: () => { + resets += 1 + }, + } + const first = beginBuild(engine) + expect(resets).toBeLessThanOrEqual(1) + const before = resets + const second = beginBuild(engine) + expect(resets).toBe(before) + second() + second() + first() + beginBuild({})() + const third = beginBuild(engine) + expect(resets).toBe(before + 1) + third() + }) +}) diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 00000000..bd16e68f --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,23 @@ +export interface ResettableEngine { + resetBuildState(): void +} + +let running = 0 + +/** + * Start a build in this process. The engine is shared by every plugin instance + * in the process, so what an earlier build left in it (prefix, atom hoisting, + * routes, buckets, names, numbers, styles) would otherwise leak into this one: + * it is reset unless another build is still running, whose state it would + * wipe. Returns the function that ends the build. + */ +export function beginBuild(engine: Partial): () => void { + if (running === 0) engine.resetBuildState?.() + running += 1 + let ended = false + return () => { + if (ended) return + ended = true + running -= 1 + } +} diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index b221bbc1..1602513f 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -257,7 +257,7 @@ export function computeFileRoutes( const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -426,7 +426,7 @@ export function computeFileReach( ) } entries = [...new Set(entries)].sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const toKey = makeToKey(cwd, opts.keyBy ?? 'cwd-relative') @@ -599,6 +599,11 @@ function getStaticClosure( return closure } +/** Order that is the same on every machine, unlike localeCompare. */ +export function compareCodePoints(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + /** * Enumerate every extractable source file under `srcDir`, sorted by POSIX path * (deterministic order). Skips `node_modules`, test/spec files, and non-JS/TS @@ -612,7 +617,7 @@ export function listSourceFiles(srcDir: string): string[] { function visit(dir: string): void { if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -630,7 +635,7 @@ export function listSourceFiles(srcDir: string): string[] { visit(srcDir) return files.sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) } diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 385a2dc2..f33dbc98 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,4 @@ +export { beginBuild, type ResettableEngine } from './build-session' export { type AtomHoistPlan, buildCanonicalMap, @@ -19,6 +20,12 @@ export { type StaticImportGraph, } from './import-graph' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + type FileNumbering, + seedFileNumbers, +} from './numbering' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -27,6 +34,11 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export type { CustomShorthands, DevupConfig, diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 00000000..b7d95f87 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,66 @@ +import { existsSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' + +export interface CollectNumberedFilesOptions { + /** Directories holding the project's own source files */ + roots: string[] + /** Packages whose source the build extracts too (the `include` option) */ + include?: string[] + cwd?: string + /** The name the plugin extracts a file under, given its absolute path */ + toId?: (path: string) => string +} + +/** Where an included package lives, found the way the bundler finds it. */ +function findPackageDir(cwd: string, name: string): string | undefined { + let dir = resolve(cwd) + for (;;) { + const candidate = join(dir, 'node_modules', name) + if (existsSync(candidate)) return realpathSync(candidate) + const parent = resolve(dir, '..') + if (parent === dir) return undefined + dir = parent + } +} + +/** + * Every file the build can extract, as the names the plugin extracts them + * under: the project's source, then the source of each included package. The + * list is the same on every run for the same files, and the engine numbers it + * in path order, so file numbers (and the class prefixes they make) do not + * depend on which file a bundler worker reaches first. + */ +export function collectNumberedFiles({ + roots, + include = [], + cwd = process.cwd(), + toId = (path) => path.replaceAll('\\', '/'), +}: CollectNumberedFilesOptions): string[] { + const directories = [ + ...roots, + ...include + .map((name) => findPackageDir(cwd, name)) + .filter((dir): dir is string => dir !== undefined), + ] + return [ + ...new Set(directories.flatMap((dir) => listSourceFiles(dir).map(toId))), + ].sort() +} + +export interface FileNumbering { + seedFileMap(files: string[]): void +} + +/** + * Number `files` in path order. Files numbered before keep their numbers, so + * running it again as files appear (in development) numbers only the new ones, + * after the existing ones. + */ +export function seedFileNumbers( + engine: FileNumbering, + files: Parameters[0], +): void { + if (files.length > 0) engine.seedFileMap(files) +} diff --git a/packages/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 00000000..45a21ad4 --- /dev/null +++ b/packages/plugin-utils/src/state-writer.ts @@ -0,0 +1,70 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +let temporaryFiles = 0 + +/** Write `content` so a reader sees the old file or the new one, never half. */ +export async function writeFileAtomically( + path: string, + content: string, +): Promise { + const temporary = `${path}.${process.pid}.${++temporaryFiles}.tmp` + await mkdir(dirname(path), { recursive: true }) + try { + await writeFile(temporary, content, 'utf-8') + await rename(temporary, path) + } catch (error) { + await rm(temporary, { force: true }) + throw error + } +} + +export type WriteOne = ( + path: string, + content: string, + encoding?: 'utf-8', +) => Promise + +export interface StateWriter { + /** + * Write `content` to `path` after every earlier write to it. A write that a + * newer one for the same path overtakes before it starts is dropped, so the + * file never ends up holding an older snapshot than one already handed in. + */ + write(path: string, content: string, encoding?: 'utf-8'): Promise +} + +/** + * Persist engine snapshots from concurrent transforms. Each call hands in the + * state as it was when the call was made, and the last call made decides what + * the file holds, whichever write the operating system finishes last. + */ +export function createStateWriter( + writeOne: WriteOne = (path, content) => writeFileAtomically(path, content), +): StateWriter { + const revisions = new Map() + const tails = new Map>() + return { + write(path, content, encoding) { + const revision = (revisions.get(path) ?? 0) + 1 + revisions.set(path, revision) + const start = async () => { + if (revisions.get(path) !== revision) return + await (encoding + ? writeOne(path, content, encoding) + : writeOne(path, content)) + } + const previous = tails.get(path) + // With nothing in flight the write starts at once + const run = previous + ? previous.catch(() => undefined).then(start) + : start() + tails.set(path, run) + const done = () => { + if (tails.get(path) === run) tails.delete(path) + } + run.then(done, done) + return run + }, + } +} diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 1a4393a9..b674ede7 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -367,13 +367,6 @@ const App = () => `, map: undefined, }) - if (options.updatedBaseStyle) { - expect(writeFileSpy).toHaveBeenCalledWith( - resolve('df', 'devup-ui', 'devup-ui.css'), - expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), - 'utf-8', - ) - } expect(writeFileSpy).toHaveBeenCalledWith( resolve('df', 'devup-ui', 'devup-ui.css'), expect.stringMatching(/\/\* src\/App\.tsx \d+ \*\//), @@ -485,6 +478,47 @@ const App = () => `, expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) + describe('deterministic file numbering', () => { + it.each([ + ['relative', false], + ['posix', true], + ])('numbers the files the scan finds (%s ids)', async (_name, atomMode) => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const closeBuild = mock() + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId('C:\\p\\a.tsx')).toBe( + atomMode ? 'C:/p/a.tsx' : 'C:\\p\\a.tsx', + ) + return ['/p/a.tsx'] + }) + await DevupUI({ + include: ['@acme/ui'], + atomHoist: atomMode ? 2 : undefined, + }).setup(createSetupContext({ onCloseBuild: closeBuild })) + expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + closeBuild.mock.calls[0][0]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + await DevupUI().setup(createSetupContext()) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + } + }) + + it('sets the prefix every time, even without one', async () => { + setPrefixSpy.mockClear() + await DevupUI().setup(createSetupContext()) + expect(setPrefixSpy).toHaveBeenCalledWith(null) + }) + }) describe('atomHoist pre-pass', () => { let buildCanonicalMapSpy: ReturnType let computeFileReachSpy: ReturnType diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 47d4f2f3..8f8f8239 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -3,12 +3,15 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, getFileNumByFilename, @@ -16,6 +19,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -26,6 +30,8 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -130,14 +136,19 @@ export const DevupUI = ({ }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { name: PLUGIN_NAME, async setup(api) { + // A build starts from its own options, not from what an earlier build + // in this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + api.onCloseBuild?.(endBuild) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -169,6 +180,20 @@ export const DevupUI = ({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), }), ) + try { + // Number every file the build can extract in path order, so class + // prefixes do not depend on the order modules reach the transform + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(process.cwd(), 'src')], + include, + toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } if (atomMode) { try { const root = process.cwd() @@ -413,7 +438,7 @@ export const DevupUI = ({ if (updatedBaseStyle) { // update base style promises.push( - writeFile( + stateWriter.write( join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8', @@ -423,7 +448,7 @@ export const DevupUI = ({ if (cssFile) { promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), `/* ${resourcePath} ${Date.now()} */`, 'utf-8', diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c910f40..f7f6bee6 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -214,6 +214,7 @@ describe('devupUIVitePlugin', () => { expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -434,20 +435,17 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + let collectSpy: ReturnType + let seedFileMapSpy: ReturnType beforeEach(() => { - listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') - importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) - exportFileMapSpy = spyOn(wasm, 'exportFileMap').mockReturnValue('{}') + collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + seedFileMapSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) }) afterEach(() => { - listSourceFilesSpy.mockRestore() - importFileMapSpy.mockRestore() - exportFileMapSpy.mockRestore() + collectSpy.mockRestore() + seedFileMapSpy.mockRestore() }) function onlyDirs(...dirs: string[]) { @@ -455,90 +453,54 @@ describe('devupUIVitePlugin', () => { existsSyncSpy.mockImplementation((path: string) => wanted.has(path)) } - it('numbers files by sorted path, not by transform arrival order', async () => { - onlyDirs('src') - // returned out of order on purpose: arrival order must not leak through - listSourceFilesSpy.mockReturnValue([ - '/p/src/z.tsx', - '/p/src/a.tsx', - '/p/src/m.tsx', - ]) - - await createPlugin({}).configResolved({ root: '/p' }) + it('numbers the files the scan finds, in the order it returns them', async () => { + onlyDirs('src', 'app') + collectSpy.mockReturnValue(['/p/app/page.tsx', '/p/src/b.tsx']) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', }) - }) - - it('normalizes windows separators to match vite module ids', async () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['C:\\p\\src\\a.tsx']) - await createPlugin({}).configResolved({ root: '/p' }) - - expect(importFileMapSpy).toHaveBeenCalledWith({ 'C:/p/src/a.tsx': 0 }) + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + include: ['@acme/ui'], + cwd: '/p', + }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - // A framework plugin resolves the config once per environment. Re-seeding - // drops the numbers already given to files outside src/ and app/, and since - // the sheet does not reset with the map, the next such file reuses a live - // number and its atoms overwrite the previous owner's. - it('leaves an already-populated map alone on a second configResolved', async () => { + // A framework plugin resolves the config once per environment. Seeding + // keeps the numbers files hold, so a second pass numbers only new files. + it('seeds again on a second configResolved', async () => { onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) - - await createPlugin({}).configResolved({ root: '/p' }) - - expect(importFileMapSpy).not.toHaveBeenCalled() - }) - - it('scans app/ for App Router projects and dedupes across roots', async () => { - onlyDirs('src', 'app') - listSourceFilesSpy.mockImplementation((dir: string) => - dir === resolve('/p', 'app') - ? ['/p/app/page.tsx', '/p/shared.tsx'] - : ['/p/src/b.tsx', '/p/shared.tsx'], - ) + collectSpy.mockReturnValue(['/p/src/a.tsx']) + const plugin = createPlugin({}) - await createPlugin({}).configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, - }) + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) }) - it.each([ - ['no conventional source dir exists', () => onlyDirs()], - [ - 'the source dir is empty', - () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue([]) - }, - ], - ])('leaves numbering alone when %s', async (_name, setup) => { - setup() + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() }) it('keeps building when the scan fails', async () => { onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { + collectSpy.mockImplementation(() => { throw new Error('scan boom') }) await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() + expect(seedFileMapSpy).not.toHaveBeenCalled() }) }) @@ -653,6 +615,55 @@ describe('devupUIVitePlugin', () => { ) }) + it('does not forward server css the client emits, whichever environment finishes first', async () => { + const plugin = createPlugin({}) + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base sheet' : 'file sheet', + ) + const serverBundle = { + 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + 'entry.js': { + name: 'entry', + viteMetadata: { + importedCss: new Set(['file.css', 'server-only.css']), + }, + }, + } + const clientBundle = { + 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + } + + await plugin.generateBundle.call( + { environment: { name: 'client', config: { consumer: 'client' } } }, + {}, + clientBundle, + ) + await plugin.generateBundle.call( + { environment: { name: 'rsc', config: { consumer: 'server' } } }, + {}, + serverBundle, + ) + + expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( + new Set(['server-only.css']), + ) + }) + + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } + }) it('ignores no-write analysis bundles when tracking server css', async () => { const plugin = createPlugin({}) const serverBundle = { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 9d0b7761..ea28ed9f 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,31 +3,34 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, getFileNumByFilename, type ImportAliases, - listSourceFiles, loadDevupConfig, mergeImportAliases, planAtomHoist, + seedFileNumbers, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, getCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -63,45 +66,6 @@ function resolveSourceDirs(root: string): string[] { ) } -/** - * Assigns each source file its devup file number up front, ordered by path. - * - * The engine otherwise hands numbers out on first sight, and the bundler - * transforms in parallel, so two identical builds produce different per-file - * class prefixes and therefore different CSS *and* JS asset hashes. Seeding - * from a sorted scan makes the numbering a pure function of the file paths. - * Every source file now holds a slot, where before only the ones that emitted - * styles consumed a number. Prefix length is a step function of the highest - * number handed out (1 char up to 26, 2 up to 1025, 3 beyond), so this is free - * until a project passes 1026 files under the scanned roots, at which point - * prefixes that used to be 2 chars become 3. - * - * Vite reports module ids as absolute POSIX-style paths even on Windows, so the - * scanned paths are normalized to match the keys `codeExtract` will look up. - * - * `importFileMap` REPLACES the engine's map, and a framework plugin resolves the - * config once per environment, so seeding unconditionally would wipe the numbers - * already handed to files outside `sourceDirs`: a monorepo sibling, or anything - * reached through `include`. The style sheet does not reset with the map, so the - * next such file reuses a live number and its atoms overwrite the previous - * owner's. Seeding only into an empty map keeps numbering deterministic on the - * first pass and stable for every later one. - */ -function seedFileMap(sourceDirs: string[]): void { - if (Object.keys(JSON.parse(exportFileMap())).length > 0) return - const sorted = [ - ...new Set( - sourceDirs - .flatMap((dir) => listSourceFiles(dir)) - .map((file) => file.replaceAll('\\', '/')), - ), - ].sort() - if (sorted.length === 0) return - const fileMap: Record = {} - for (const [index, file] of sorted.entries()) fileMap[file] = index - importFileMap(fileMap) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -269,23 +233,29 @@ export function DevupUI({ atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { + // A build starts from its own options: whatever an earlier build in this + // process left in the engine (prefix, hoisting, routes, buckets, numbers, + // styles) is gone unless another build is still running. + const endBuild = beginBuild({ resetBuildState }) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() let serverBundleToForward: Record | undefined + const clientCssFiles = new Set() let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) - return writeFile(join(cssDir, fileName), css, 'utf-8') + return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } return { name: 'devup-ui', @@ -305,7 +275,18 @@ export function DevupUI({ ) const sourceDirs = resolveSourceDirs(projectRoot) try { - seedFileMap(sourceDirs) + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: sourceDirs, + include, + cwd: projectRoot, + }), + ) } catch { // Best-effort; on failure numbering falls back to arrival order. } @@ -411,6 +392,9 @@ export function DevupUI({ apply() { return true }, + closeBundle() { + endBuild() + }, async watchChange(id) { if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { @@ -596,23 +580,26 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - if (environment.config.consumer === 'client' && serverBundleToForward) { - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. - for (const output of Object.values(serverBundleToForward)) { - for (const file of cssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } - } + // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC + // bundle into the client bundle. Files the client already emitted are + // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' + // imports and client metadata intact, but remove overlaps from the RSC + // forwarding set before its later generateBundle hook reads it. Each + // environment records what it finished, and whichever finishes second + // removes the overlap, so the result does not depend on their order. + if (environment.config.consumer === 'client') { + for (const file of cssFiles) clientCssFiles.add(file) } else if (environment.config.consumer === 'server') { serverBundleToForward = bundle as unknown as Record< string, ViteOutputWithMetadata > } + for (const output of Object.values(serverBundleToForward ?? {})) { + for (const file of clientCssFiles) { + output.viteMetadata?.importedCss?.delete(file) + } + } }, } } diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 524e755b..f0568339 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -39,6 +39,7 @@ interface MockCompiler { afterCompile: { tap: ReturnType } run: { tap: ReturnType } thisCompilation: { tap: ReturnType } + shutdown: { tap: ReturnType } } } @@ -166,6 +167,9 @@ function createCompiler(): MockCompiler { thisCompilation: { tap: mock(), }, + shutdown: { + tap: mock(), + }, }, } } @@ -173,6 +177,38 @@ function createCompiler(): MockCompiler { describe('devupUIWebpackPlugin', () => { console.error = mock() + describe('deterministic file numbering', () => { + it('numbers the files the scan finds and ends the build at shutdown', () => { + const collectSpy = spyOn(pluginUtils, 'collectNumberedFiles') + const seedSpy = spyOn(wasm, 'seedFileMap').mockReturnValue(undefined) + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) + try { + collectSpy.mockImplementation((options: any) => { + expect(options.toId(resolve('src', 'a.tsx'))).toBe('src/a.tsx') + return ['src/a.tsx', 'src/b.tsx'] + }) + const compiler = createCompiler() + new DevupUIWebpackPlugin({ include: ['@acme/ui'] }).apply( + asCompiler(compiler), + ) + expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) + expect(collectSpy.mock.calls[0][0]).toMatchObject({ + roots: [resolve('src')], + include: ['@acme/ui'], + }) + compiler.hooks.shutdown.tap.mock.calls[0][1]() + compiler.hooks.shutdown.tap.mock.calls[0][1]() + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) + new DevupUIWebpackPlugin({}).apply(asCompiler(createCompiler())) + } finally { + collectSpy.mockRestore() + seedSpy.mockRestore() + resetSpy.mockRestore() + } + }) + }) it('should apply default options', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e75..0c88b873 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,7 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver } from '@devup-ui/plugin-utils' +import { createModuleResolver, createStateWriter } from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -34,6 +34,9 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) let moduleResolver: ReturnType | undefined /** Resolve imports to the cwd-relative ids this loader extracts files under */ @@ -101,23 +104,27 @@ const devupUILoader: RawLoaderDefinitionFunction = if (updatedBaseStyle) { // update base style promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } if (cssFile) { const content = `${this.resourcePath} ${Date.now()}` // should be reset css promises.push( - writeFile( + stateWriter.write( join(cssDir, basename(cssFile)), watch ? `/* ${content} */` : css, ), ) if (watch) { promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), ) } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 83b2c226..711a85eb 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -4,7 +4,9 @@ import { createRequire } from 'node:module' import { dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, @@ -17,6 +19,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -31,6 +34,8 @@ import { importSheet, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -195,10 +200,12 @@ export class DevupUIWebpackPlugin { } apply(compiler: Compiler) { + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) - } + setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -226,6 +233,23 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } this.writeDataFiles() // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader diff --git a/sheetFile b/sheetFile new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/sheetFile @@ -0,0 +1 @@ +{} \ No newline at end of file From 1fc57442d426211e50546f1647ea7e43b57c205c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 21:38:15 +0900 Subject: [PATCH 05/50] chore: remove files test runs left behind Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- classMapFile | 1 - cssDir/devup-ui-1.css | 1 - cssDir/devup-ui.css | 1 - cssFile/cssFile | 1 - cssFile/devup-ui.css | 1 - fileMapFile | 1 - foo/cssFile | 1 - sheetFile | 1 - 8 files changed, 8 deletions(-) delete mode 100644 classMapFile delete mode 100644 cssDir/devup-ui-1.css delete mode 100644 cssDir/devup-ui.css delete mode 100644 cssFile/cssFile delete mode 100644 cssFile/devup-ui.css delete mode 100644 fileMapFile delete mode 100644 foo/cssFile delete mode 100644 sheetFile diff --git a/classMapFile b/classMapFile deleted file mode 100644 index 9e26dfee..00000000 --- a/classMapFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/cssDir/devup-ui-1.css b/cssDir/devup-ui-1.css deleted file mode 100644 index c848720f..00000000 --- a/cssDir/devup-ui-1.css +++ /dev/null @@ -1 +0,0 @@ -/* write-error.tsx 0 */ \ No newline at end of file diff --git a/cssDir/devup-ui.css b/cssDir/devup-ui.css deleted file mode 100644 index 788cdbf9..00000000 --- a/cssDir/devup-ui.css +++ /dev/null @@ -1 +0,0 @@ -base-css \ No newline at end of file diff --git a/cssFile/cssFile b/cssFile/cssFile deleted file mode 100644 index 493ec68a..00000000 --- a/cssFile/cssFile +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/cssFile/devup-ui.css b/cssFile/devup-ui.css deleted file mode 100644 index 493ec68a..00000000 --- a/cssFile/devup-ui.css +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/fileMapFile b/fileMapFile deleted file mode 100644 index 9e26dfee..00000000 --- a/fileMapFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/foo/cssFile b/foo/cssFile deleted file mode 100644 index 493ec68a..00000000 --- a/foo/cssFile +++ /dev/null @@ -1 +0,0 @@ -css \ No newline at end of file diff --git a/sheetFile b/sheetFile deleted file mode 100644 index 9e26dfee..00000000 --- a/sheetFile +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file From ffd7dc7941fbc3bfa8663b886f2c80235535aa0d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:27:43 +0900 Subject: [PATCH 06/50] fix(plugins): number buckets and extracted files only, leave vite css forwarding alone Refs #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_deterministic_numbering.json | 2 +- bindings/devup-ui-wasm/src/lib.rs | 2 +- libs/css/src/file_map.rs | 26 +++++++- packages/bun-plugin/src/plugin.ts | 1 + .../__tests__/deterministic-numbering.test.ts | 1 + packages/next-plugin/src/plugin.ts | 36 +++++------ .../src/__tests__/numbering.test.ts | 24 +++++++- packages/plugin-utils/src/index.ts | 1 + packages/plugin-utils/src/numbering.ts | 30 +++++++++- packages/rsbuild-plugin/src/plugin.ts | 30 +++++----- .../vite-plugin/src/__tests__/plugin.test.ts | 38 ++---------- packages/vite-plugin/src/plugin.ts | 60 +++++++++---------- packages/webpack-plugin/src/plugin.ts | 36 +++++------ 13 files changed, 166 insertions(+), 121 deletions(-) diff --git a/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json index 27507cb8..053739d3 100644 --- a/.changepacks/changepack_log_deterministic_numbering.json +++ b/.changepacks/changepack_log_deterministic_numbering.json @@ -8,6 +8,6 @@ "packages/next-plugin/package.json": "Patch", "packages/bun-plugin/package.json": "Patch" }, - "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. Vite removes the CSS the client already emits from the server bundle's forwarding set whichever environment finishes first. File lists are ordered by code point rather than the locale", + "note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale", "date": "2026-10-01T00:00:00.000Z" } \ No newline at end of file diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index d6175ba3..685af49a 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -2002,7 +2002,7 @@ mod tests { export_canonical_map_internal().unwrap(), r#"{"x":"a","y":"b"}"# ); - reset_build_state_internal(); + reset_build_state(); assert_eq!(export_class_map_internal().unwrap(), "{}"); assert_eq!(export_file_map_internal().unwrap(), "{}"); assert_eq!(export_canonical_map_internal().unwrap(), "{}"); diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 258b2558..f0a20a93 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -95,15 +95,15 @@ pub fn get_file_num_by_filename(filename: &str) -> usize { /// Give every file in `files` a number now, in path order. /// -/// Numbers files already hold are kept. The numbers files get then depend on the paths alone, +/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone, /// not on which file a worker reaches first; files that appear later (in /// development) are numbered after the existing ones. pub fn seed_file_numbers(files: &[String]) { - let mut sorted: Vec<&String> = files.iter().collect(); + let mut sorted: Vec = files.iter().map(|file| canonical(file)).collect(); sorted.sort_unstable(); sorted.dedup(); for file in sorted { - let _ = get_file_num_by_filename(file); + let _ = get_file_num_by_filename(&file); } } #[must_use] @@ -209,6 +209,26 @@ mod tests { use super::*; + #[test] + #[serial] + fn test_seed_file_numbers_numbers_buckets_not_their_members() { + reset_file_map(); + set_canonical_map(std::collections::HashMap::from([( + "x.tsx".to_string(), + "a.tsx".to_string(), + )])); + seed_file_numbers(&[ + "x.tsx".to_string(), + "b.tsx".to_string(), + "a.tsx".to_string(), + ]); + assert_eq!(get_file_num_by_filename("a.tsx"), 0); + assert_eq!(get_file_num_by_filename("b.tsx"), 1); + assert_eq!(get_file_map().len(), 2); + reset_canonical_map(); + reset_file_map(); + } + #[test] #[serial] fn test_seed_file_numbers_is_independent_of_order() { diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 7ae81ab9..91287479 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -88,6 +88,7 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { { seedFileMap }, collectNumberedFiles({ roots: [resolve('src')], + needles: compiledPackages, toId: (path) => path, }), ) diff --git a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts index e0afcf1f..b18a334c 100644 --- a/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -58,6 +58,7 @@ describe('class and file numbers do not depend on the order files are seen', () collectNumberedFiles({ roots: [join(root, 'src')], include: ['@acme/ui'], + needles: ['@devup-ui/react'], cwd: root, toId: scheme, }), diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 9a710002..542c00e7 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -18,6 +18,7 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type DevupUIBasePluginOptions, + extractedNeedles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, @@ -252,23 +253,6 @@ export function DevupUI( } catch { // No previous session state (first run) or corrupt files, start fresh } - // Number every file the build can extract in path order, so class prefixes - // do not depend on the order modules reach a loader. Numbers restored above - // stay; files that appear later get the numbers after them. - try { - const cwd = process.cwd() - seedFileNumbers( - { seedFileMap: wasm.seedFileMap }, - collectNumberedFiles({ - roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), - include, - cwd, - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } const devupConfig = loadDevupConfigSync(devupFile) @@ -379,6 +363,24 @@ export function DevupUI( }) } + // Number every file the build can extract in path order, so class prefixes + // do not depend on the order modules reach a loader. Numbers restored above + // stay; files that appear later get the numbers after them. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap: wasm.seedFileMap }, + collectNumberedFiles({ + roots: ['src', 'app', 'pages'].map((dir) => resolve(cwd, dir)), + include, + cwd, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Turbopack can request a CSS module before it has scheduled every source // loader. Waiting for a quiet window is not a compilation-complete signal: // a CSS request can itself hold up the next extraction wave. In one-shot diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts index 8a284b47..e64ad571 100644 --- a/packages/plugin-utils/src/__tests__/numbering.test.ts +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -4,7 +4,11 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' -import { collectNumberedFiles, seedFileNumbers } from '../numbering' +import { + collectNumberedFiles, + extractedNeedles, + seedFileNumbers, +} from '../numbering' describe('collectNumberedFiles', () => { let root: string @@ -48,6 +52,24 @@ describe('collectNumberedFiles', () => { expect(files.some((file) => file.includes('skipped'))).toBe(false) }) + it('numbers only the files that mention what the build extracts', () => { + writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + cwd: root, + needles: extractedNeedles('@devup-ui/react', { + '@emotion/styled': 'styled', + }), + toId: (path) => path.slice(root.length).replaceAll('\\', '/'), + }) + expect(files).toEqual(['/src/a.tsx']) + expect(extractedNeedles('p', { a: 1, b: 2 })).toEqual([ + 'p', + '@stylexjs/stylex', + 'a', + 'b', + ]) + }) it('does not depend on the order of the roots', () => { const first = collectNumberedFiles({ roots: [join(root, 'src'), join(root, 'node_modules/@acme/ui')], diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index f33dbc98..ccbb2386 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -23,6 +23,7 @@ export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { collectNumberedFiles, type CollectNumberedFilesOptions, + extractedNeedles, type FileNumbering, seedFileNumbers, } from './numbering' diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index b7d95f87..213bbf9c 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { listSourceFiles } from './import-graph' @@ -9,10 +9,21 @@ export interface CollectNumberedFilesOptions { /** Packages whose source the build extracts too (the `include` option) */ include?: string[] cwd?: string + /** + * Only files whose text contains one of these are numbered, so files the + * build never extracts do not take numbers (and lengthen class prefixes) + */ + needles?: string[] /** The name the plugin extracts a file under, given its absolute path */ toId?: (path: string) => string } +/** Whether the text of the file contains one of the needles */ +function usesAny(file: string, needles: string[]): boolean { + const text = readFileSync(file, 'utf-8') + return needles.some((needle) => text.includes(needle)) +} + /** Where an included package lives, found the way the bundler finds it. */ function findPackageDir(cwd: string, name: string): string | undefined { let dir = resolve(cwd) @@ -36,6 +47,7 @@ export function collectNumberedFiles({ roots, include = [], cwd = process.cwd(), + needles, toId = (path) => path.replaceAll('\\', '/'), }: CollectNumberedFilesOptions): string[] { const directories = [ @@ -45,7 +57,13 @@ export function collectNumberedFiles({ .filter((dir): dir is string => dir !== undefined), ] return [ - ...new Set(directories.flatMap((dir) => listSourceFiles(dir).map(toId))), + ...new Set( + directories.flatMap((dir) => + listSourceFiles(dir) + .filter((file) => !needles || usesAny(file, needles)) + .map(toId), + ), + ), ].sort() } @@ -64,3 +82,11 @@ export function seedFileNumbers( ): void { if (files.length > 0) engine.seedFileMap(files) } + +/** What a file mentions when the build extracts it: the package, StyleX, and the packages Devup UI takes the place of */ +export function extractedNeedles( + libPackage: string, + importAliases: object, +): string[] { + return [libPackage, '@stylexjs/stylex', ...Object.keys(importAliases)] +} diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 8f8f8239..ad2793ec 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, @@ -180,20 +181,6 @@ export const DevupUI = ({ toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), }), ) - try { - // Number every file the build can extract in path order, so class - // prefixes do not depend on the order modules reach the transform - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(process.cwd(), 'src')], - include, - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } if (atomMode) { try { const root = process.cwd() @@ -226,6 +213,21 @@ export const DevupUI = ({ } } + try { + // Number every file the build can extract in path order, so class + // prefixes do not depend on the order modules reach the transform + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(process.cwd(), 'src')], + include, + needles: extractedNeedles(libPackage, importAliases), + toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Extract the source files under `src` that the entries reach, in path // order, the same way the transform does, so that a stylesheet built on // its first import already holds the styles of every one. Best-effort: diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index f7f6bee6..26027dce 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -465,6 +465,10 @@ describe('devupUIVitePlugin', () => { roots: [resolve('/p', 'src'), resolve('/p', 'app')], include: ['@acme/ui'], cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) expect(seedFileMapSpy).toHaveBeenCalledWith([ '/p/app/page.tsx', @@ -615,40 +619,6 @@ describe('devupUIVitePlugin', () => { ) }) - it('does not forward server css the client emits, whichever environment finishes first', async () => { - const plugin = createPlugin({}) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base sheet' : 'file sheet', - ) - const serverBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['file.css', 'server-only.css']), - }, - }, - } - const clientBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - } - - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['server-only.css']), - ) - }) - it('starts a build from its own options and ends it at closeBundle', async () => { const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) try { diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index ea28ed9f..cb10d6fe 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -13,6 +13,7 @@ import { createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfig, @@ -243,7 +244,6 @@ export function DevupUI({ const importAliases = mergeImportAliases(userImportAliases) const cssMap = new Map() let serverBundleToForward: Record | undefined - const clientCssFiles = new Set() let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that @@ -274,22 +274,6 @@ export function DevupUI({ }), ) const sourceDirs = resolveSourceDirs(projectRoot) - try { - // Numbers come from the sorted paths of every file the build can - // extract (source and included packages), not from arrival order. - // Files numbered before keep their numbers, so a later pass in the - // dev server only numbers new files after the existing ones. - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: sourceDirs, - include, - cwd: projectRoot, - }), - ) - } catch { - // Best-effort; on failure numbering falls back to arrival order. - } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') await writeFile( @@ -363,6 +347,23 @@ export function DevupUI({ // Best-effort; on failure atom hoisting stays off (identity). } } + try { + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: sourceDirs, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch { + // Best-effort; on failure numbering falls back to arrival order. + } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { const theme = getDefaultTheme() @@ -580,26 +581,23 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. Each - // environment records what it finished, and whichever finishes second - // removes the overlap, so the result does not depend on their order. - if (environment.config.consumer === 'client') { - for (const file of cssFiles) clientCssFiles.add(file) + if (environment.config.consumer === 'client' && serverBundleToForward) { + // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC + // bundle into the client bundle. Files the client already emitted are + // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' + // imports and client metadata intact, but remove overlaps from the RSC + // forwarding set before its later generateBundle hook reads it. + for (const output of Object.values(serverBundleToForward)) { + for (const file of cssFiles) { + output.viteMetadata?.importedCss?.delete(file) + } + } } else if (environment.config.consumer === 'server') { serverBundleToForward = bundle as unknown as Record< string, ViteOutputWithMetadata > } - for (const output of Object.values(serverBundleToForward ?? {})) { - for (const file of clientCssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } - } }, } } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 711a85eb..0c5fd308 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, loadDevupConfigSync, @@ -233,23 +234,6 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } - // Number every file the build can extract in path order, so class - // prefixes do not depend on which file a worker reaches first. Numbers - // already handed out (a restored map in watch mode) stay. - try { - const cwd = process.cwd() - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(cwd, 'src')], - include: this.options.include, - cwd, - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } this.writeDataFiles() // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader @@ -300,6 +284,24 @@ export class DevupUIWebpackPlugin { // hoisting stays off. } + // Number every file the build can extract in path order, so class + // prefixes do not depend on which file a worker reaches first. Numbers + // already handed out (a restored map in watch mode) stay. + try { + const cwd = process.cwd() + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: [resolve(cwd, 'src')], + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch { + // Best-effort; numbering falls back to arrival order. + } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared From 187261f8c54336542c0df07f95d9fec34b50a1c9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 17:40:32 +0900 Subject: [PATCH 07/50] fix(plugin-utils): report config failures and inheritance cycles Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/config-errors.test.ts | 100 +++++++++++++++++ .../src/__tests__/load-config.test.ts | 5 +- packages/plugin-utils/src/load-config.ts | 104 ++++++++++++++---- 3 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/config-errors.test.ts diff --git a/packages/plugin-utils/src/__tests__/config-errors.test.ts b/packages/plugin-utils/src/__tests__/config-errors.test.ts new file mode 100644 index 00000000..622aa2c1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/config-errors.test.ts @@ -0,0 +1,100 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + ConfigLoadError, + loadDevupConfig, + loadDevupConfigSync, +} from '../load-config' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'config-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('located bounded config errors', () => { + it.each(['{', 'null', '[]', '{"extends":"base.json"}', '{"extends":[1]}'])( + 'rejects invalid config %s in both loaders', + async (content) => { + const file = join(root, 'devup.json') + writeFileSync(file, content) + expect(() => loadDevupConfigSync(file)).toThrow(ConfigLoadError) + await expect(loadDevupConfig(file)).rejects.toThrow(file) + }, + ) + it('reports a missing parent rather than silently clearing its theme', async () => { + const file = join(root, 'devup.json') + const missing = join(root, 'missing.json') + writeFileSync(file, JSON.stringify({ extends: ['./missing.json'] })) + expect(() => loadDevupConfigSync(file)).toThrow(missing) + await expect(loadDevupConfig(file)).rejects.toThrow(missing) + }) + it('reports directory read errors with the file and original cause', async () => { + try { + loadDevupConfigSync(root) + throw new Error('Expected a read failure') + } catch (error) { + expect(error).toBeInstanceOf(ConfigLoadError) + if (!(error instanceof ConfigLoadError)) throw error + expect(error.file).toBe(root) + expect(error.cause).toBeInstanceOf(Error) + } + await expect(loadDevupConfig(root)).rejects.toThrow(root) + }) + it('detects canonical inheritance cycles in both loaders', async () => { + const file = join(root, 'a.json') + writeFileSync(file, JSON.stringify({ extends: ['./b.json'] })) + writeFileSync( + join(root, 'b.json'), + JSON.stringify({ extends: ['./nested/../a.json'] }), + ) + mkdirSync(join(root, 'nested')) + expect(() => loadDevupConfigSync(file)).toThrow('a.json ->') + await expect(loadDevupConfig(file)).rejects.toThrow('a.json ->') + }) + it('allows repeated parents across separate branches of a diamond', async () => { + writeFileSync( + join(root, 'base.json'), + JSON.stringify({ theme: { colors: { default: { text: 'red' } } } }), + ) + writeFileSync( + join(root, 'left.json'), + JSON.stringify({ extends: ['./base.json'] }), + ) + writeFileSync( + join(root, 'right.json'), + JSON.stringify({ extends: ['./base.json'] }), + ) + const file = join(root, 'devup.json') + writeFileSync( + file, + JSON.stringify({ extends: ['./left.json', './right.json'] }), + ) + const expected = { theme: { colors: { default: { text: 'red' } } } } + expect(loadDevupConfigSync(file)).toEqual(expected) + expect(await loadDevupConfig(file)).toEqual(expected) + }) + it('returns an empty config for a missing top file in both loaders', async () => { + const file = join(root, 'missing.json') + expect(loadDevupConfigSync(file)).toEqual({}) + expect(await loadDevupConfig(file)).toEqual({}) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/load-config.test.ts b/packages/plugin-utils/src/__tests__/load-config.test.ts index bc3510ba..8f5c1fdf 100644 --- a/packages/plugin-utils/src/__tests__/load-config.test.ts +++ b/packages/plugin-utils/src/__tests__/load-config.test.ts @@ -91,12 +91,11 @@ describe('loadDevupConfigSync', () => { }) }) - it('should return empty object for invalid JSON', () => { + it('throws a located error for invalid JSON', () => { const configPath = join(testDir, 'invalid.json') writeFileSync(configPath, 'not valid json {{{') - const result = loadDevupConfigSync(configPath) - expect(result).toEqual({}) + expect(() => loadDevupConfigSync(configPath)).toThrow(configPath) }) it('should resolve extends', () => { diff --git a/packages/plugin-utils/src/load-config.ts b/packages/plugin-utils/src/load-config.ts index a30c977a..7564e34f 100644 --- a/packages/plugin-utils/src/load-config.ts +++ b/packages/plugin-utils/src/load-config.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -48,12 +48,48 @@ export function deepMerge(base: T, override: U): T { /** * Parse JSON content safely */ +export class ConfigLoadError extends Error { + constructor( + readonly file: string, + cause: unknown, + ) { + super( + `${file}:1:1: Cannot load configuration: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'ConfigLoadError' + } +} + +function isDevupConfig(value: unknown): value is DevupConfig { + return ( + isPlainObject(value) && + (value.extends === undefined || + (Array.isArray(value.extends) && + value.extends.every((path) => typeof path === 'string'))) + ) +} + function parseConfig(content: string): DevupConfig { - try { - return JSON.parse(content) as DevupConfig - } catch { - return {} + const config: unknown = JSON.parse(content) + if (!isDevupConfig(config)) + throw new TypeError( + 'Expected a config object with an array of extends paths', + ) + return config +} + +function configStack(configPath: string, stack: readonly string[]): string[] { + const canonical = realpathSync(configPath) + if (stack.includes(canonical) || stack.length >= 128) { + throw new ConfigLoadError( + configPath, + new Error( + `Configuration inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ), + ) } + return [...stack, canonical] } /** @@ -83,25 +119,39 @@ function mergeExtendedConfigs( * @param configPath - Path to the devup.json file * @returns Resolved configuration with all extends merged */ -export function loadDevupConfigSync(configPath: string): DevupConfig { - if (!existsSync(configPath)) { - return {} +export function loadDevupConfigSync( + configPath: string, + stack: readonly string[] = [], +): DevupConfig { + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(readFileSync(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = readFileSync(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) return mergeExtendedConfigs( config, config.extends.map((extendPath) => - loadDevupConfigSync(resolve(configDir, extendPath)), + loadDevupConfigSync(resolve(configDir, extendPath), nextStack), ), ) } @@ -115,25 +165,39 @@ export function loadDevupConfigSync(configPath: string): DevupConfig { */ export async function loadDevupConfig( configPath: string, + stack: readonly string[] = [], ): Promise { - if (!existsSync(configPath)) { - return {} + const file = resolve(configPath) + let config: DevupConfig + let nextStack: string[] + try { + nextStack = configStack(file, stack) + config = parseConfig(await readFile(file, 'utf-8')) + } catch (cause) { + if ( + stack.length === 0 && + cause instanceof Error && + 'code' in cause && + cause.code === 'ENOENT' + ) + return {} + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) } - const content = await readFile(configPath, 'utf-8') - const config = parseConfig(content) - // If no extends, return the config as-is if (!config.extends || config.extends.length === 0) { return config } - const configDir = dirname(configPath) + const configDir = dirname(file) // Load extends sequentially to preserve merge order const extendedConfigs: DevupConfig[] = [] for (const extendPath of config.extends) { - extendedConfigs.push(await loadDevupConfig(resolve(configDir, extendPath))) + extendedConfigs.push( + await loadDevupConfig(resolve(configDir, extendPath), nextStack), + ) } return mergeExtendedConfigs(config, extendedConfigs) From a2dcd454d97ed3983fbda03e725b9bf8d3c2a4fb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 17:47:22 +0900 Subject: [PATCH 08/50] fix(plugin-utils): match literal packages and normalize project roots Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/shared-paths.test.ts | 130 ++++++++++++++++++ packages/plugin-utils/src/shared.ts | 64 ++++++++- 2 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/shared-paths.test.ts diff --git a/packages/plugin-utils/src/__tests__/shared-paths.test.ts b/packages/plugin-utils/src/__tests__/shared-paths.test.ts new file mode 100644 index 00000000..0d19b9b7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-paths.test.ts @@ -0,0 +1,130 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterAll, describe, expect, it } from 'bun:test' + +import { + createNodeModulesExcludeRegex, + GRAPH_SOURCE_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, +} from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +mkdirSync(parent, { recursive: true }) +const root = realpathSync(mkdtempSync(join(parent, 'paths-'))) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +describe('project roots', () => { + it('resolves default outputs against the project when cwd differs', () => { + const paths = resolveProjectPaths(root) + expect(paths).toEqual({ + devupFile: join(root, 'devup.json'), + distDir: join(root, 'df'), + cssDir: join(root, 'df/devup-ui'), + }) + }) + it('resolves explicit paths and derives CSS from custom dist when omitted', () => { + expect( + resolveProjectPaths(root, { + devupFile: 'config/theme.json', + distDir: 'generated', + cssDir: 'styles', + }), + ).toEqual({ + devupFile: join(root, 'config/theme.json'), + distDir: join(root, 'generated'), + cssDir: join(root, 'styles'), + }) + expect(resolveProjectPaths(root, { distDir: 'generated' }).cssDir).toBe( + join(root, 'generated/devup-ui'), + ) + }) + it('discovers both src and app when present', () => { + mkdirSync(join(root, 'src')) + mkdirSync(join(root, 'app')) + expect(resolveSourceDirs(root)).toEqual([ + join(root, 'src'), + join(root, 'app'), + ]) + }) + it('retains explicit roots without requiring them to exist yet', () => { + expect( + resolveSourceDirs(root, ['custom', 'custom', resolve(root, 'more')]), + ).toEqual([join(root, 'custom'), join(root, 'more')]) + expect(resolveSourceDirs(root, 'custom')).toEqual([join(root, 'custom')]) + expect(resolveSourceDirs(join(root, 'empty'))).toEqual([]) + }) +}) + +describe('source filters', () => { + it.each(SOURCE_EXTENSIONS)( + 'accepts modern source extension %s', + (extension) => { + expect(SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + expect(POST_COMPILED_MDX_RE.test(`file.mdx${extension}`)).toBe(true) + expect(GRAPH_SOURCE_FILE_RE.test(`file${extension}`)).toBe(true) + }, + ) + it('discovers MDX without sending raw markdown through the source transform', () => { + expect(GRAPH_SOURCE_FILE_RE.test('page.mdx')).toBe(true) + expect(SOURCE_FILE_RE.test('page.mdx')).toBe(false) + expect(POST_COMPILED_MDX_RE.test('page.mdx')).toBe(false) + expect(GRAPH_SOURCE_FILE_RE.test('page.css')).toBe(false) + }) +}) + +describe('literal package boundaries', () => { + it.each(['foo.bar', 'foo+bar', '@scope/foo.bar'])( + 'includes literal package %s only', + (name) => { + const filter = createNodeModulesExcludeRegex([name]) + expect(filter.test(`node_modules/${name}/index.js`)).toBe(false) + expect( + filter.test( + `C:\\project\\node_modules\\${name.replaceAll('/', '\\')}\\index.js`, + ), + ).toBe(false) + expect(filter.test(`node_modules/${name}extra/index.js`)).toBe(true) + }, + ) + it('does not mistake scoped, substring or regex lookalike packages for includes', () => { + const filter = createNodeModulesExcludeRegex(['pkg', 'foo.bar']) + expect(filter.test('node_modules/@scope/pkg/index.js')).toBe(true) + expect(filter.test('node_modules/other-pkg/index.js')).toBe(true) + expect(filter.test('node_modules/fooXbar/index.js')).toBe(true) + expect(filter.test('not_node_modules/pkg/index.js')).toBe(false) + expect(filter.test('node_modules/@devup-ui-extra/react/index.js')).toBe( + true, + ) + }) + it('uses the innermost node_modules under pnpm or nested dependencies', () => { + const filter = createNodeModulesExcludeRegex(['pkg', '@scope/pkg']) + expect( + filter.test('node_modules/.pnpm/pkg@1/node_modules/pkg/index.js'), + ).toBe(false) + expect( + filter.test( + 'node_modules/.pnpm/@scope+pkg@1/node_modules/@scope/pkg/index.js', + ), + ).toBe(false) + expect(filter.test('node_modules/pkg/node_modules/other/index.js')).toBe( + true, + ) + expect( + filter.test( + 'node_modules/other/node_modules/@devup-editor/react/index.js', + ), + ).toBe(false) + }) +}) diff --git a/packages/plugin-utils/src/shared.ts b/packages/plugin-utils/src/shared.ts index 9d5710be..bd6f620c 100644 --- a/packages/plugin-utils/src/shared.ts +++ b/packages/plugin-utils/src/shared.ts @@ -1,5 +1,56 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + import type { CustomShorthands, ImportAliases } from './types' +export const SOURCE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', +] as const +export const SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx)$/i +export const MDX_FILE_RE = /\.mdx$/i +export const POST_COMPILED_MDX_RE = /\.mdx\.(?:[mc]?[jt]s|[jt]sx)$/i +export const GRAPH_SOURCE_FILE_RE = /\.(?:[mc]?[jt]s|[jt]sx|mdx)$/i + +export function resolveProjectPaths( + root: string, + options: { + readonly devupFile?: string + readonly distDir?: string + readonly cssDir?: string + } = {}, +): { devupFile: string; distDir: string; cssDir: string } { + const distDir = resolve(root, options.distDir ?? 'df') + return { + devupFile: resolve(root, options.devupFile ?? 'devup.json'), + distDir, + cssDir: options.cssDir + ? resolve(root, options.cssDir) + : resolve(distDir, 'devup-ui'), + } +} + +export function resolveSourceDirs( + root: string, + configured?: string | string[], +): string[] { + const dirs = + configured === undefined + ? ['src', 'app'] + : typeof configured === 'string' + ? [configured] + : configured + return [...new Set(dirs.map((dir) => resolve(root, dir)))].filter( + (dir) => configured !== undefined || existsSync(dir), + ) +} + /** * Extract file number from a devup-ui CSS filename. * @@ -51,12 +102,19 @@ export function getFileNumByFilename(filename: string): number | null { * @returns A RegExp for use in bundler exclude/condition rules */ export function createNodeModulesExcludeRegex( - include: string[], + include: readonly string[], extraExcludes?: string, ): RegExp { - const base = `node_modules(?!.*(${['@devup-ui', '@devup-editor', ...include] + const separator = '[\\/\\\\]' + const names = include.map((name) => + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('/', separator), + ) + const allowed = ['@devup-ui', '@devup-editor'] + .map((scope) => `${scope}${separator}[^\\/\\\\]+`) + .concat(names) .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$))` + // Only the innermost node_modules owns the package (including pnpm stores). + const base = `(?:^|${separator})node_modules${separator}(?!.*${separator}node_modules${separator})(?!(?:${allowed})(?:${separator}|$))` return new RegExp(extraExcludes ? `(${base})|(${extraExcludes})` : base) } From 0e5d7302d30504f46219934465010fab4e4d94f8 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 17:53:52 +0900 Subject: [PATCH 09/50] fix(plugin-utils): resolve inherited aliases and complete source graphs Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/graph-exclude.test.ts | 82 ++++ .../src/__tests__/graph-modern.test.ts | 336 +++++++++++++++ .../src/__tests__/tsconfig.test.ts | 227 ++++++++++ .../plugin-utils/src/import-graph.test.ts | 21 +- packages/plugin-utils/src/import-graph.ts | 390 ++++++++++-------- packages/plugin-utils/src/tsconfig.ts | 199 +++++++++ 6 files changed, 1079 insertions(+), 176 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/graph-exclude.test.ts create mode 100644 packages/plugin-utils/src/__tests__/graph-modern.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig.test.ts create mode 100644 packages/plugin-utils/src/tsconfig.ts diff --git a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts new file mode 100644 index 00000000..5008d5b3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts @@ -0,0 +1,82 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, listSourceFiles } from '../import-graph' +import { + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + POST_COMPILED_MDX_RE, + SOURCE_FILE_RE, +} from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'mdx-shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'exclude-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('skips named directories at every depth without reintroducing imported files', () => { + const fixtures = { + 'src/z.ts': + "import './generated/a'\nimport('./nested/generated/b')\nimport './node_modules/local/c'\nimport './nested/A.MDX'", + 'src/generated/a.ts': 'a', + 'src/nested/generated/b.ts': 'b', + 'src/node_modules/local/c.ts': 'c', + 'src/nested/A.MDX': "import '../value.CTS'\n\n# Heading", + 'src/value.CTS': 'value', + 'src/nested/ignored.TEST.TS': 'test', + 'app/generated/d.ts': 'd', + 'app/a.mjs': 'a', + } + for (const [path, code] of Object.entries(fixtures)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + const graph = buildStaticImportGraph(['src', 'app'], undefined, { + cwd: root, + exclude: ['generated'], + }) + expect(graph.files).toEqual( + ['app/a.mjs', 'src/nested/A.MDX', 'src/value.CTS', 'src/z.ts'].map((path) => + join(root, path), + ), + ) + expect(graph.staticImports.get(join(root, 'src/nested/A.MDX'))).toEqual( + new Set([join(root, 'src/value.CTS')]), + ) + expect(listSourceFiles(join(root, 'src'), ['generated'])).toEqual( + graph.files.slice(1), + ) +}) + +it.each(['.mts', '.cts', '.mjs', '.cjs', '.tsx', '.jsx'])( + 'matches uppercase modern %s extensions without treating compiled MDX as raw MDX', + (extension) => { + expect(SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe(true) + expect(GRAPH_SOURCE_FILE_RE.test(`file${extension.toUpperCase()}`)).toBe( + true, + ) + expect( + POST_COMPILED_MDX_RE.test(`file.MDX${extension.toUpperCase()}`), + ).toBe(true) + expect(MDX_FILE_RE.test(`file.MDX${extension}`)).toBe(false) + expect(MDX_FILE_RE.test('file.MDX')).toBe(true) + }, +) diff --git a/packages/plugin-utils/src/__tests__/graph-modern.test.ts b/packages/plugin-utils/src/__tests__/graph-modern.test.ts new file mode 100644 index 00000000..adc7d97b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts @@ -0,0 +1,336 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + buildCanonicalMap, + buildStaticImportGraph, + computeCompiledFiles, + computeFileReach, + computeFileRoutes, + computeReachableFiles, + createModuleResolver, +} from '../import-graph' +import { SOURCE_EXTENSIONS } from '../shared' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'graph-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function fixture(path: string, source: string): string { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, source) + return file +} + +describe('multi-root source graph', () => { + it('resolves dotted specifiers and directories without replacing real asset extensions', () => { + const entry = fixture( + 'src/main.ts', + "import './a.generated'\nimport './b.config'\nimport './folder.with.dot'\nimport './c.css'", + ) + const generated = fixture( + 'src/a.generated.ts', + 'export const generated = 1', + ) + const config = fixture('src/b.config.tsx', 'export const config = 1') + const index = fixture( + 'src/folder.with.dot/index.ts', + 'export const index = 1', + ) + const css = fixture('src/c.css', 'body{color:red}') + fixture('src/c.css.ts', 'export const competing = 1') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('./a.generated', entry)?.path).toBe(generated) + expect(resolver('./b.config', entry)?.path).toBe(config) + expect(resolver('./folder.with.dot', entry)?.path).toBe(index) + expect(resolver('./c.css', entry)?.path).toBe(css) + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + }) + expect(graph.staticImports.get(entry)).toEqual( + new Set([generated, config, index]), + ) + expect(graph.files).not.toContain(css) + expect( + computeReachableFiles({ + srcDir: join(root, 'src'), + graph, + entries: [join(root, 'src/a.generated')], + }), + ).toEqual([generated]) + }) + + it('follows only eligible package closures when an entry uses the project root layout', () => { + const entry = fixture('entry.ts', "import 'included'\nimport 'excluded'") + fixture( + 'node_modules/included/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const included = fixture( + 'node_modules/included/index.js', + "import './leaf.cjs'", + ) + const leaf = fixture( + 'node_modules/included/leaf.cjs', + 'export const value = 1', + ) + fixture( + 'node_modules/excluded/package.json', + JSON.stringify({ main: 'index.js' }), + ) + fixture('node_modules/excluded/index.js', 'export const value = 2') + const graph = buildStaticImportGraph(root, undefined, { + cwd: root, + include: ['included'], + }) + expect(graph.files).toEqual([entry, included, leaf].sort()) + expect(graph.staticImports.get(entry)).toEqual(new Set([included])) + expect(graph.externalImports?.get(entry)).toEqual(new Set(['excluded'])) + }) + + it('shares one graph across reach, route, compiled and collapse consumers', () => { + const entry = fixture( + 'app/page.mts', + "import '../src/value.cts'\nimport('included')", + ) + const value = fixture('src/value.cts', 'export const value = 1') + fixture( + 'node_modules/included/package.json', + JSON.stringify({ + exports: { browser: './browser.mjs', import: './server.js' }, + }), + ) + const library = fixture( + 'node_modules/included/browser.mjs', + "export { value } from './leaf.cjs'\nimport('other')", + ) + const leaf = fixture( + 'node_modules/included/leaf.cjs', + 'export const value = 2', + ) + fixture('node_modules/included/unreachable.ts', 'unused') + fixture('node_modules/included/server.js', 'server') + fixture( + 'node_modules/other/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const other = fixture('node_modules/other/index.js', 'other') + const srcDir = ['src', 'app'] + const graph = buildStaticImportGraph(srcDir, undefined, { + cwd: root, + include: ['included'], + conditions: ['browser', 'import'], + }) + expect(graph.files).toEqual([entry, library, leaf, value].sort()) + expect(graph.externalImports?.get(library)).toEqual(new Set(['other'])) + expect(graph.fileSet.has(other)).toBe(false) + expect(computeReachableFiles({ srcDir, graph, entries: [entry] })).toEqual( + graph.files, + ) + const opts = { cwd: root, srcDir, graph } + expect(buildCanonicalMap(opts)['src/value.cts']).toBe('app/page.mts') + expect( + computeFileReach({ ...opts, entries: ['app/page.mts'] })['src/value.cts'], + ).toEqual([0]) + expect(computeFileRoutes(opts)['src/value.cts']).toEqual([0]) + expect(computeCompiledFiles(opts)).toEqual( + graph.files + .map((file) => file.slice(root.length + 1).replaceAll('\\', '/')) + .sort(), + ) + }) + it('follows reachable static included dependencies without enumerating their package', () => { + const entry = fixture('src/main.ts', "import '@scope/pkg'") + fixture( + 'node_modules/@scope/pkg/package.json', + JSON.stringify({ main: 'index.ts' }), + ) + const library = fixture( + 'node_modules/@scope/pkg/index.ts', + "import './nested/a'\nimport('included-two')", + ) + const nested = fixture( + 'node_modules/@scope/pkg/nested/a.ts', + 'export const a = 1', + ) + fixture( + 'node_modules/included-two/package.json', + JSON.stringify({ main: 'index.ts' }), + ) + const second = fixture( + 'node_modules/included-two/index.ts', + 'export const second = 1', + ) + fixture('node_modules/@scope/pkg/orphan.ts', 'orphan') + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + include: ['@scope/pkg', 'included-two'], + }) + expect( + computeReachableFiles({ + srcDir: join(root, 'src'), + graph, + entries: [entry], + }), + ).toEqual([library, nested, second, entry].sort()) + expect( + buildCanonicalMap({ cwd: root, srcDir: join(root, 'src'), graph })[ + 'node_modules/@scope/pkg/nested/a.ts' + ], + ).toBe('src/main.ts') + }) + it.each(SOURCE_EXTENSIONS)( + 'discovers and resolves %s modules and index files', + (extension) => { + const entry = fixture( + `src/main${extension}`, + "import './value'\nimport './folder'", + ) + const value = fixture(`src/value${extension}`, 'export const value = 1') + const index = fixture( + `src/folder/index${extension}`, + 'export const index = 1', + ) + const graph = buildStaticImportGraph(join(root, 'src')) + expect(graph.staticImports.get(entry)).toEqual(new Set([value, index])) + const resolver = createModuleResolver({ cwd: root }) + expect(resolver(`./value${extension}`, entry)?.path).toBe(value) + }, + ) +}) + +describe('MDX ESM discovery', () => { + it('discovers real ESM blocks while ignoring markdown, code fences, indented code and comments', () => { + const entry = fixture( + 'app/page.mdx', + [ + '# Heading', + '', + "Prose about import './fake'", + '', + '```tsx', + "import './fake'", + '```', + '', + '~~~js', + "import './fake'", + '~~~', + '', + " import './fake'", + '', + '', + '', + '', + '', + 'import {', + ' value', + "} from '../src/value.mts'", + '', + "export { other } from '../src/other.cts'", + '', + "export const load = () => import('../src/lazy.cjs')", + '', + '
Content
', + ].join('\n'), + ) + const value = fixture('src/value.mts', 'export const value = 1') + const other = fixture('src/other.cts', 'export const other = 1') + const lazy = fixture('src/lazy.cjs', 'export const lazy = 1') + fixture('app/fake.ts', 'fake') + const srcDir = [join(root, 'app'), join(root, 'src')] + const graph = buildStaticImportGraph(srcDir, undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([value, other])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([lazy])) + expect(computeCompiledFiles({ srcDir, cwd: root, graph })).toEqual([ + 'app/page.mdx', + 'src/lazy.cjs', + 'src/other.cts', + 'src/value.mts', + ]) + }) +}) + +describe('conditional package resolution', () => { + it('propagates invalid module path I/O errors instead of reporting an unresolved import', () => { + fixture('src/main.ts', 'export const main = 1') + expect(() => + createModuleResolver({ cwd: root })('./\0.ts', 'src/main.ts'), + ).toThrow() + }) + it('honors active conditions in declaration order rather than condition input order', () => { + fixture( + 'node_modules/pkg/package.json', + JSON.stringify({ + exports: { + browser: './browser.js', + import: './import.js', + default: './default.js', + }, + }), + ) + fixture('node_modules/pkg/browser.js', 'browser') + fixture('node_modules/pkg/import.js', 'import') + fixture('node_modules/pkg/default.js', 'default') + expect( + createModuleResolver({ cwd: root, conditions: ['import', 'browser'] })( + 'pkg', + 'src/main.ts', + )?.code, + ).toBe('browser') + expect( + createModuleResolver({ cwd: root, conditions: [] })('pkg', 'src/main.ts') + ?.code, + ).toBe('default') + }) + it('honors early default branches and null exclusions', () => { + fixture( + 'node_modules/pkg/package.json', + JSON.stringify({ + exports: { + '.': { default: './default.js', browser: './browser.js' }, + './blocked': { browser: null, default: './default.js' }, + './empty': [], + }, + }), + ) + fixture('node_modules/pkg/default.js', 'default') + const resolver = createModuleResolver({ + cwd: root, + conditions: ['browser'], + }) + expect(resolver('pkg', 'src/main.ts')?.code).toBe('default') + expect(resolver('pkg/blocked', 'src/main.ts')).toBeUndefined() + expect(resolver('pkg/empty', 'src/main.ts')).toBeUndefined() + }) + it.each(['{', 'null', '[]'])( + 'propagates invalid manifest %s with location and cause', + (content) => { + const file = fixture('node_modules/pkg/package.json', content) + expect(() => + createModuleResolver({ cwd: root })('pkg', 'src/main.ts'), + ).toThrow(file) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig.test.ts b/packages/plugin-utils/src/__tests__/tsconfig.test.ts new file mode 100644 index 00000000..9fd01d14 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig.test.ts @@ -0,0 +1,227 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { readPathAliases } from '../tsconfig' + +const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared', +) +let root: string +beforeEach(() => { + mkdirSync(parent, { recursive: true }) + root = realpathSync(mkdtempSync(join(parent, 'tsconfig-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function fixture(path: string, value: unknown): string { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)) + return file +} + +describe('tsconfig inheritance', () => { + it('resolves inherited baseUrl and paths relative to their defining config', () => { + fixture('config/base.json', { + compilerOptions: { baseUrl: '../', paths: { '@base/*': ['src/*'] } }, + }) + fixture('tsconfig.json', { extends: './config/base.json' }) + const value = fixture('src/value.mts', 'export const value = 1') + const entry = fixture('src/main.ts', "import '@base/value'") + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', entry)?.path).toBe(value) + const graph = buildStaticImportGraph( + join(root, 'src'), + join(root, 'tsconfig.json'), + { cwd: root }, + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([value])) + expect(resolver('src/value', entry)?.path).toBe(value) + }) + it('uses paths origin without baseUrl and replaces rather than deep-merging paths', () => { + fixture('config/base.json', { + compilerOptions: { paths: { '@base/*': ['values/*'] } }, + }) + const value = fixture('config/values/value.cts', 'export const value = 1') + fixture('tsconfig.json', { extends: './config/base' }) + expect( + createModuleResolver({ cwd: root })('@base/value', 'main.ts')?.path, + ).toBe(value) + fixture('tsconfig.json', { + extends: './config/base', + compilerOptions: { paths: { '@child': ['child'] } }, + }) + const child = fixture('child.ts', 'child') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@base/value', 'main.ts')).toBeUndefined() + expect(resolver('@child', 'main.ts')?.path).toBe(child) + expect(resolver('@child-extra', 'main.ts')).toBeUndefined() + }) + it('applies an overridden baseUrl to inherited paths and merges array parents left to right', () => { + fixture('first.json', { + compilerOptions: { baseUrl: './old', paths: { '@x': ['value'] } }, + }) + fixture('second.json', { compilerOptions: { paths: { '@y': ['value'] } } }) + fixture('tsconfig.json', { + extends: ['./first.json', './second.json'], + compilerOptions: { baseUrl: './new' }, + }) + const value = fixture('new/value.ts', 'new') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@y', 'main.ts')?.path).toBe(value) + expect(resolver('@x', 'main.ts')).toBeUndefined() + }) + it('honors exact aliases and longest wildcard prefixes without fallback to shorter aliases', () => { + fixture('tsconfig.json', { + compilerOptions: { + paths: { + '@/*': ['fallback/*'], + '@/specific/*': ['missing/*'], + '@/exact': ['value'], + '@end/*/tail': ['value'], + }, + }, + }) + fixture('fallback/specific/item.ts', 'fallback') + const value = fixture('value.ts', 'value') + const resolver = createModuleResolver({ cwd: root }) + expect(resolver('@/specific/item', 'main.ts')).toBeUndefined() + expect(resolver('@/exact', 'main.ts')?.path).toBe(value) + expect(resolver('@/exact-more', 'main.ts')).toBeUndefined() + expect(resolver('@end/tail', 'main.ts')).toBeUndefined() + expect(resolver('@end/a/tail', 'main.ts')?.path).toBe(value) + }) + it('parses comments and trailing commas without damaging strings', () => { + fixture( + 'tsconfig.json', + '{/* comment */"compilerOptions": {"paths": {"@x": ["src/value",], // trailing\n},}, "text": "quote\\"value,}",}', + ) + const value = fixture('src/value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@x', 'main.ts')?.path).toBe( + value, + ) + }) + it('inherits package configs, explicit package subpaths and package tsconfig fields', () => { + fixture('node_modules/config/package.json', {}) + fixture('node_modules/config/tsconfig.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('tsconfig.json', { extends: 'config/tsconfig.json' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + fixture('node_modules/custom/package.json', { + tsconfig: 'configs/base.json', + }) + fixture('node_modules/custom/configs/base.json', { + compilerOptions: { paths: { '@custom': ['value'] } }, + }) + const custom = fixture('node_modules/custom/configs/value.ts', 'custom') + fixture('tsconfig.json', { extends: 'custom' }) + expect( + createModuleResolver({ cwd: root })('@custom', 'main.ts')?.path, + ).toBe(custom) + }) + it('uses package tsconfig instead of JavaScript main when both are present', () => { + fixture('node_modules/config/package.json', { + main: 'index.js', + tsconfig: 'base.json', + }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/base.json', { + compilerOptions: { paths: { '@pkg': ['value'] } }, + }) + const value = fixture('node_modules/config/value.ts', 'value') + fixture('tsconfig.json', { extends: 'config' }) + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) + it('defaults a package with JavaScript main to tsconfig.json', () => { + fixture('node_modules/config/package.json', { main: 'index.js' }) + fixture('node_modules/config/index.js', 'module.exports = {}') + fixture('node_modules/config/tsconfig.json', {}) + fixture('tsconfig.json', { extends: 'config' }) + expect(readPathAliases(join(root, 'tsconfig.json')).aliases).toEqual([]) + }) + it('allows diamond inheritance without mistaking repeated parents for a cycle', () => { + fixture('base.json', { compilerOptions: { paths: { '@pkg': ['value'] } } }) + fixture('left.json', { extends: './base' }) + fixture('right.json', { extends: './base' }) + fixture('tsconfig.json', { extends: ['./left', './right'] }) + const value = fixture('value.ts', 'value') + expect(createModuleResolver({ cwd: root })('@pkg', 'main.ts')?.path).toBe( + value, + ) + }) +}) + +describe('tsconfig load errors', () => { + it('reports the missing default config after a package has an unresolved main entry', () => { + fixture('node_modules/incomplete/package.json', { main: 'missing.js' }) + fixture('tsconfig.json', { extends: 'incomplete' }) + expect(() => readPathAliases(join(root, 'tsconfig.json'))).toThrow( + 'tsconfig.json', + ) + }) + + it.each([ + '{', + 'null', + '{"extends":1}', + '{"extends":[1]}', + '{"compilerOptions":1}', + '{"compilerOptions":{"baseUrl":1}}', + '{"compilerOptions":{"paths":[]}}', + '{"compilerOptions":{"paths":{"@x":[1]}}}', + '{/* unterminated', + ])('rejects invalid config %s with its location', (content) => { + const file = fixture('tsconfig.json', content) + expect(() => createModuleResolver({ cwd: root })).toThrow(file) + }) + it.each(['./missing', 'not-installed'])( + 'reports missing inherited config %s', + (parent) => { + fixture('tsconfig.json', { extends: parent }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }, + ) + it('reports canonical config cycles instead of recursing indefinitely', () => { + fixture('tsconfig.json', { extends: './base' }) + fixture('base.json', { extends: './tsconfig' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'inheritance cycle', + ) + }) + it('reports an inherited package with no tsconfig rather than using an empty config', () => { + fixture('node_modules/private/package.json', { + exports: { './allowed': './base.json' }, + }) + fixture('node_modules/private/base.json', {}) + fixture('tsconfig.json', { extends: 'private' }) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot load configuration', + ) + }) +}) diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index d99a4fca..c92e2256 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -22,6 +22,7 @@ import { planAtomHoist, runImportGraphCli, } from './import-graph' +import { ConfigLoadError } from './load-config' describe('computeReachableFiles', () => { let tempRoot: string @@ -603,18 +604,25 @@ describe('buildCanonicalMap', () => { ).toEqual({}) }) - it('ignores a malformed tsconfig (JSON parse error)', () => { + it('reports a located error with cause when tsconfig is malformed', () => { writeFixture('tsconfig.json', '{ this is not json') writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - expect( + let caught: unknown + try { buildCanonicalMap({ cwd, srcDir, tsconfigPath: join(cwd, 'tsconfig.json'), - }), - ).toEqual({ 'src/b.tsx': 'src/a.tsx' }) + }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ConfigLoadError) + if (!(caught instanceof ConfigLoadError)) throw caught + expect(caught.message).toContain(`${join(cwd, 'tsconfig.json')}:1:1:`) + expect(caught.cause).toBeInstanceOf(SyntaxError) }) it('prefers the longest-prefix alias when multiple tsconfig paths overlap', () => { @@ -1393,7 +1401,10 @@ describe('createModuleResolver', () => { expect(resolveModule(join(root, 'src/tokens'), 'src/App.tsx')?.path).toBe( posix('src/tokens.ts'), ) - expect(resolveModule('./style.css', 'src/App.tsx')).toBeUndefined() + expect(resolveModule('./style.css', 'src/App.tsx')).toEqual({ + path: posix('src/style.css'), + code: 'body {}', + }) expect(resolveModule('./missing.ts', 'src/App.tsx')).toBeUndefined() expect(resolveModule('not-installed', 'src/App.tsx')).toBeUndefined() }) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 1602513f..3fa40b55 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -7,14 +7,16 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' + +import { ConfigLoadError } from './load-config' import { - dirname, - extname, - isAbsolute, - join, - relative, - resolve, -} from 'node:path' + createNodeModulesExcludeRegex, + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + SOURCE_EXTENSIONS, +} from './shared' +import { type PathAlias, readPathAliases } from './tsconfig' /** * How map keys (and bucket-root values) are stringified. @@ -33,7 +35,7 @@ function makeToKey(cwd: string, keyBy: GraphKeyMode): (file: string) => string { } export interface BuildCanonicalMapOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string hoistV?: number @@ -55,12 +57,6 @@ interface OxcParser { ) => unknown } -interface PathAlias { - prefix: string - suffix: string - targets: string[] -} - interface ResolveContext { aliases: PathAlias[] aliasBaseDir: string @@ -68,12 +64,12 @@ interface ResolveContext { srcDir: string } -const jsExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] -const jsFileRegex = /\.(?:tsx?|jsx?|mjs)$/ -const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/ +const jsExtensions: readonly string[] = [...SOURCE_EXTENSIONS, '.mdx'] +const jsFileRegex = GRAPH_SOURCE_FILE_RE +const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/i const routeFileRegex = - /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(tsx|ts|jsx|js)$/ -const leafRouteFileRegex = /(^|\/)page\.(tsx|ts|jsx|js)$/ + /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(?:[mc]?[jt]s|[jt]sx|mdx)$/ +const leafRouteFileRegex = /(^|\/)page\.(?:[mc]?[jt]s|[jt]sx|mdx)$/ let cachedOxcParser: false | OxcParser | undefined @@ -115,19 +111,25 @@ export interface StaticImportGraph { * the result, so the file I/O and parsing cost is paid a single time. */ export function buildStaticImportGraph( - srcDir: string, + srcDir: string | string[], tsconfigPath?: string, + options: StaticImportGraphOptions = {}, ): StaticImportGraph { - const resolvedSrcDir = resolve(srcDir) - const files = listSourceFiles(resolvedSrcDir) + const cwd = resolve(options.cwd ?? process.cwd()) + const roots = (typeof srcDir === 'string' ? [srcDir] : srcDir).map((dir) => + resolve(cwd, dir), + ) + const files = [ + ...new Set(roots.flatMap((root) => listSourceFiles(root, options.exclude))), + ].sort(compareCodePoints) const fileSet = new Set(files) - const aliases = readPathAliases(tsconfigPath) - const context: ResolveContext = { - aliases: aliases.aliases, - aliasBaseDir: aliases.baseDir, - files: fileSet, - srcDir: resolvedSrcDir, - } + const resolver = createModuleResolver({ + cwd, + tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), + conditions: options.conditions, + }) + const excluded = createNodeModulesExcludeRegex(options.include ?? []) + const includedRoots = new Set() const staticImporters = new Map>() const staticImports = new Map>() const dynamicImports = new Map>() @@ -141,10 +143,49 @@ export function buildStaticImportGraph( externalImports.set(file, new Set()) } - for (const file of files) { + for (let index = 0; index < files.length; index += 1) { + const file = files[index] const imports = parseImports(file, readFileSync(file, 'utf-8')) for (const importRef of imports) { - const target = resolveImport(importRef.specifier, file, context) + const resolved = resolver(importRef.specifier, file)?.path + if ( + resolved && + roots.some( + (root) => + isInsideDir(root, resolved) && + relative(root, dirname(resolved)) + .split(/[\\/]/) + .some((part) => options.exclude?.includes(part)), + ) + ) + continue + if ( + resolved && + !importRef.specifier.startsWith('.') && + !isAbsolute(importRef.specifier) + ) { + const parts = importRef.specifier.split('/') + const name = parts + .slice(0, importRef.specifier.startsWith('@') ? 2 : 1) + .join('/') + if (!excluded.test(`node_modules/${name}/`)) { + const dir = findPackage(dirname(file), name) + if (dir) includedRoots.add(realpathSync(dir)) + } + } + const target = + resolved && + jsFileRegex.test(resolved) && + (fileSet.has(resolved) || + roots.some( + (root) => + isInsideDir(root, resolved) && + !relative(root, resolved).split(/[\\/]/).includes('node_modules'), + ) || + (!excluded.test(resolved) && + [...includedRoots].some((root) => isInsideDir(root, resolved)))) + ? resolved + : undefined if (!target) { if ( !importRef.specifier.startsWith('.') && @@ -155,6 +196,14 @@ export function buildStaticImportGraph( } continue } + if (!fileSet.has(target)) { + fileSet.add(target) + files.push(target) + staticImporters.set(target, new Set()) + staticImports.set(target, new Set()) + dynamicImports.set(target, new Set()) + externalImports.set(target, new Set()) + } if (importRef.kind === 'dynamic') { dynamicTargets.add(target) dynamicImports.get(file)?.add(target) @@ -166,7 +215,7 @@ export function buildStaticImportGraph( } return { - files, + files: files.sort(compareCodePoints), fileSet, staticImports, staticImporters, @@ -176,13 +225,33 @@ export function buildStaticImportGraph( } } +export interface StaticImportGraphOptions { + readonly cwd?: string + readonly include?: readonly string[] + readonly conditions?: readonly string[] + /** Directory names skipped anywhere below source roots. */ + readonly exclude?: readonly string[] +} + +function graphRoot(srcDir: string | string[], cwd: string): string { + if (typeof srcDir === 'string') return resolve(cwd, srcDir) + let root = resolve(cwd, srcDir[0] ?? '.') + for (const dir of srcDir) { + const absolute = resolve(cwd, dir) + while (!isInsideDir(root, absolute) && dirname(root) !== root) + root = dirname(root) + } + return root +} + export function buildCanonicalMap( opts: BuildCanonicalMapOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const globalFiles = getRouteReachableGlobalFiles( files, @@ -227,7 +296,7 @@ export function buildCanonicalMap( } export interface ComputeFileRoutesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -250,9 +319,10 @@ export function computeFileRoutes( opts: ComputeFileRoutesOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const leafRoutes = files .filter((file) => leafRouteFileRegex.test(toPosixRelative(srcDir, file))) @@ -279,7 +349,7 @@ export function computeFileRoutes( } export interface ComputeCompiledFilesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** pre-built graph from `buildStaticImportGraph` to skip the file scan. */ @@ -308,9 +378,10 @@ export function computeCompiledFiles( opts: ComputeCompiledFilesOptions, ): string[] { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, staticImports, dynamicImports } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) const allImports = new Map>() for (const file of files) { @@ -337,7 +408,7 @@ export function computeCompiledFiles( } export interface ComputeReachableFilesOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string /** The bundler's entry modules, as absolute paths with or without extension. */ entries: string[] @@ -374,7 +445,7 @@ export function computeReachableFiles( } export interface ComputeFileReachOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** @@ -409,9 +480,10 @@ export function computeFileReach( opts: ComputeFileReachOptions, ): Record { const cwd = resolve(opts.cwd) - const srcDir = resolve(opts.srcDir) + const srcDir = graphRoot(opts.srcDir, cwd) const { files, fileSet, staticImports, staticImporters, dynamicTargets } = - opts.graph ?? buildStaticImportGraph(srcDir, opts.tsconfigPath) + opts.graph ?? + buildStaticImportGraph(opts.srcDir, opts.tsconfigPath, { cwd }) let entries: string[] if (opts.entries && opts.entries.length > 0) { @@ -611,7 +683,10 @@ export function compareCodePoints(a: string, b: string): number { * pre-warm the extractor over exactly the file set the canonical map was built * from. Returns absolute paths. */ -export function listSourceFiles(srcDir: string): string[] { +export function listSourceFiles( + srcDir: string, + exclude: readonly string[] = [], +): string[] { const files: string[] = [] function visit(dir: string): void { @@ -622,7 +697,8 @@ export function listSourceFiles(srcDir: string): string[] { for (const entry of entries) { const entryPath = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name === 'node_modules') continue + if (entry.name === 'node_modules' || exclude.includes(entry.name)) + continue visit(entryPath) continue } @@ -640,11 +716,44 @@ export function listSourceFiles(srcDir: string): string[] { } function parseImports(filename: string, source: string): ImportReference[] { + if (MDX_FILE_RE.test(filename)) return scanImports(mdxEsmSource(source)) const astImports = parseImportsWithOxc(filename, source) if (astImports) return astImports return scanImports(source) } +function mdxEsmSource(source: string): string { + const blocks: string[] = [] + let fence = '' + let esm = false + let comment = false + for (const line of source.split(/\r?\n/)) { + if (line.includes('')) comment = false + esm = false + continue + } + const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1] + if (marker) { + if (!fence) fence = marker + else if (marker[0] === fence[0] && marker.length >= fence.length) + fence = '' + esm = false + continue + } + if (fence) continue + if (!line.trim()) { + esm = false + blocks.push('') + continue + } + if (/^(?:import\s+(?!\()|export\s+)/.test(line)) esm = true + if (esm) blocks.push(line) + } + return blocks.join('\n') +} + function parseImportsWithOxc( filename: string, source: string, @@ -906,83 +1015,6 @@ function stripComments(source: string): string { return result } -function readPathAliases(tsconfigPath: string | undefined): { - aliases: PathAlias[] - baseDir: string -} { - if (!tsconfigPath || !existsSync(tsconfigPath)) { - return { aliases: [], baseDir: process.cwd() } - } - - const configPath = resolve(tsconfigPath) - const configDir = dirname(configPath) - try { - const config = JSON.parse( - stripTrailingCommas(stripComments(readFileSync(configPath, 'utf-8'))), - ) - if (!isRecord(config) || !isRecord(config.compilerOptions)) { - return { aliases: [], baseDir: configDir } - } - - const baseUrl = - typeof config.compilerOptions.baseUrl === 'string' - ? config.compilerOptions.baseUrl - : '.' - const paths = config.compilerOptions.paths - if (!isRecord(paths)) { - return { aliases: [], baseDir: resolve(configDir, baseUrl) } - } - - const aliases: PathAlias[] = [] - for (const [alias, targetList] of Object.entries(paths)) { - if (!Array.isArray(targetList)) continue - const starIndex = alias.indexOf('*') - aliases.push({ - prefix: starIndex === -1 ? alias : alias.slice(0, starIndex), - suffix: starIndex === -1 ? '' : alias.slice(starIndex + 1), - targets: targetList.filter( - (target): target is string => typeof target === 'string', - ), - }) - } - - aliases.sort((a, b) => b.prefix.length - a.prefix.length) - return { aliases, baseDir: resolve(configDir, baseUrl) } - } catch { - return { aliases: [], baseDir: configDir } - } -} - -function stripTrailingCommas(json: string): string { - return json.replace(/,\s*([}\]])/g, '$1') -} - -function resolveImport( - specifier: string, - importer: string, - context: ResolveContext, -): string | undefined { - const candidateBases: string[] = [] - - if (specifier.startsWith('.')) { - candidateBases.push(resolve(dirname(importer), specifier)) - } else if (specifier.startsWith('/')) { - candidateBases.push(resolve(specifier)) - } else { - candidateBases.push(...resolveAliasCandidates(specifier, context)) - } - - for (const candidateBase of candidateBases) { - const resolvedFile = resolveFile(candidateBase) - if (!resolvedFile) continue - if (!context.files.has(resolvedFile)) continue - if (!isInsideDir(context.srcDir, resolvedFile)) continue - return resolvedFile - } - - return undefined -} - /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { path: string @@ -992,6 +1024,7 @@ export interface ResolvedModule { export interface CreateModuleResolverOptions { cwd?: string tsconfigPath?: string + conditions?: readonly string[] /** * The name the plugin extracts a file under, given its absolute path. A * resolved module is extracted under this name, so it has to be the one the @@ -1009,61 +1042,56 @@ export function createModuleResolver({ cwd = process.cwd(), tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, + conditions = ['import', 'module', 'require', 'node'], }: CreateModuleResolverOptions = {}): ( specifier: string, importer: string, ) => ResolvedModule | undefined { - const { aliases, baseDir } = readPathAliases(tsconfigPath) + const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) return (specifier, importer) => { const from = resolve(cwd, importer) const path = specifier.startsWith('.') - ? resolveSourceFile(resolve(dirname(from), specifier)) + ? resolveFile(resolve(dirname(from), specifier)) : isAbsolute(specifier) - ? resolveSourceFile(specifier) + ? resolveFile(specifier) : (resolveAliasCandidates(specifier, { aliases, aliasBaseDir: baseDir, }) - .map(resolveSourceFile) - .find(Boolean) ?? resolvePackage(specifier, from)) + .map(resolveFile) + .find(Boolean) ?? + (baseUrl ? resolveFile(resolve(baseUrl, specifier)) : undefined) ?? + resolvePackage(specifier, from, conditions)) return path ? { path: toId(path), code: readFileSync(path, 'utf-8') } : undefined } } -const sourceExtensions = [...jsExtensions, '.cjs'] - -/** `resolveFile`, also completing a name like `theme.css` to `theme.css.ts`. */ -function resolveSourceFile(base: string): string | undefined { - const candidates = sourceExtensions.includes(extname(base)) - ? [base] - : [ - ...jsExtensions.map((extension) => `${base}${extension}`), - ...jsExtensions.map((extension) => join(base, `index${extension}`)), - ] - const found = candidates.find(isFile) - return found && resolve(found) -} - -/** Conditions of package `exports`, in the order a bundler prefers them */ -const packageConditions = ['import', 'module', 'default', 'require', 'node'] - function resolvePackage( specifier: string, importer: string, + conditions: readonly string[], ): string | undefined { const parts = specifier.split('/') const nameLength = specifier.startsWith('@') ? 2 : 1 const name = parts.slice(0, nameLength).join('/') const packageDir = findPackage(dirname(importer), name) if (!packageDir) return undefined + const manifestFile = join(packageDir, 'package.json') + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(manifestFile, 'utf-8')) + if (!isRecord(manifest) || Array.isArray(manifest)) + throw new TypeError('Expected a package manifest object') + } catch (cause) { + throw new ConfigLoadError(manifestFile, cause) + } const found = resolvePackageEntry( packageDir, - JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf-8'), - ) as Record, + manifest, ['.', ...parts.slice(nameLength)].join('/'), + conditions, ) return found && realpathSync(found) } @@ -1078,30 +1106,35 @@ function resolvePackageEntry( dir: string, manifest: Record, subpath: string, + conditions: readonly string[], ): string | undefined { if (manifest.exports !== undefined) { - const target = exportsTarget(manifest.exports, subpath) - return target === undefined + const target = exportsTarget(manifest.exports, subpath, conditions) + return typeof target !== 'string' ? undefined - : resolveSourceFile(join(dir, target)) + : resolveFile(join(dir, target)) } - if (subpath !== '.') return resolveSourceFile(join(dir, subpath)) + if (subpath !== '.') return resolveFile(join(dir, subpath)) const main = [manifest.module, manifest.main].find( (entry): entry is string => typeof entry === 'string', ) - return resolveSourceFile(join(dir, main ?? 'index')) + return resolveFile(join(dir, main ?? 'index')) } -function exportsTarget(exports: unknown, subpath: string): string | undefined { +function exportsTarget( + exports: unknown, + subpath: string, + conditions: readonly string[], +): string | null | undefined { const subpaths = isRecord(exports) && !Array.isArray(exports) ? Object.keys(exports).filter((key) => key.startsWith('.')) : [] if (subpaths.length === 0) { - return subpath === '.' ? conditionTarget(exports) : undefined + return subpath === '.' ? conditionTarget(exports, conditions) : undefined } const map = exports as Record - if (subpath in map) return conditionTarget(map[subpath]) + if (subpath in map) return conditionTarget(map[subpath], conditions) for (const key of subpaths) { const [prefix, suffix = ''] = key.split('*') if ( @@ -1114,22 +1147,32 @@ function exportsTarget(exports: unknown, subpath: string): string | undefined { prefix.length, subpath.length - suffix.length, ) - return conditionTarget(map[key])?.replaceAll('*', matched) + return conditionTarget(map[key], conditions)?.replaceAll('*', matched) } } return undefined } -function conditionTarget(value: unknown): string | undefined { +function conditionTarget( + value: unknown, + conditions: readonly string[], +): string | null | undefined { if (typeof value === 'string') return value + if (value === null) return null if (Array.isArray(value)) { - return value.map(conditionTarget).find(Boolean) + for (const entry of value) { + const target = conditionTarget(entry, conditions) + if (target !== undefined) return target + } + return undefined } if (!isRecord(value)) return undefined - return packageConditions - .filter((condition) => condition in value) - .map((condition) => conditionTarget(value[condition])) - .find(Boolean) + for (const [condition, branch] of Object.entries(value)) { + if (condition !== 'default' && !conditions.includes(condition)) continue + const target = conditionTarget(branch, conditions) + if (target !== undefined) return target + } + return undefined } function resolveAliasCandidates( specifier: string, @@ -1139,7 +1182,9 @@ function resolveAliasCandidates( for (const alias of context.aliases) { if ( !specifier.startsWith(alias.prefix) || - !specifier.endsWith(alias.suffix) + !specifier.endsWith(alias.suffix) || + (alias.exact && specifier !== alias.prefix) || + specifier.length < alias.prefix.length + alias.suffix.length ) { continue } @@ -1152,16 +1197,13 @@ function resolveAliasCandidates( resolve(context.aliasBaseDir, target.replace('*', matched)), ) } + break } return candidates } function resolveFile(candidateBase: string): string | undefined { - const ext = extname(candidateBase) - if (ext) { - if (!jsExtensions.includes(ext)) return undefined - return isFile(candidateBase) ? resolve(candidateBase) : undefined - } + if (isFile(candidateBase)) return resolve(candidateBase) for (const jsExtension of jsExtensions) { const candidate = `${candidateBase}${jsExtension}` @@ -1178,8 +1220,14 @@ function resolveFile(candidateBase: string): string | undefined { function isFile(path: string): boolean { try { return statSync(path).isFile() - } catch { - return false + } catch (cause) { + if ( + cause instanceof Error && + 'code' in cause && + (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') + ) + return false + throw cause } } diff --git a/packages/plugin-utils/src/tsconfig.ts b/packages/plugin-utils/src/tsconfig.ts new file mode 100644 index 00000000..1fdc6e4c --- /dev/null +++ b/packages/plugin-utils/src/tsconfig.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { ConfigLoadError } from './load-config' + +export interface PathAlias { + readonly prefix: string + readonly suffix: string + readonly exact: boolean + readonly targets: string[] +} + +interface AliasConfig { + readonly baseUrl?: string + readonly paths?: Record + readonly pathsOrigin?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseJsonc(source: string): unknown { + let text = '' + let quoted = false + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + const next = source[index + 1] + if (quoted) { + text += char + if (char === '\\') text += source[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + text += char + } else if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1 + text += '\n' + } else if (char === '/' && next === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new SyntaxError('Unterminated JSON comment') + text += ' ' + index = end + 1 + } else text += char + } + let json = '' + quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + json += char + if (char === '\\') json += text[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + json += char + } else if (char !== ',' || !/^\s*[}\]]/.test(text.slice(index + 1))) + json += char + } + return JSON.parse(json) +} + +function resolveParent(specifier: string, file: string): string { + if (specifier.startsWith('.') || isAbsolute(specifier)) { + const candidate = resolve(dirname(file), specifier) + return existsSync(candidate) ? candidate : `${candidate}.json` + } + const require = createRequire(file) + try { + const resolved = require.resolve(specifier) + if (resolved.endsWith('.json')) return resolved + const manifestPath = require.resolve(`${specifier}/package.json`) + const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) + return join( + dirname(manifestPath), + isRecord(manifest) && typeof manifest.tsconfig === 'string' + ? manifest.tsconfig + : 'tsconfig.json', + ) + } catch (cause) { + if ( + !(cause instanceof Error) || + !('code' in cause) || + cause.code !== 'MODULE_NOT_FOUND' + ) + throw cause + try { + return require.resolve(`${specifier}/tsconfig.json`) + } catch (fallback) { + if ( + !(fallback instanceof Error) || + !('code' in fallback) || + fallback.code !== 'MODULE_NOT_FOUND' + ) + throw fallback + const manifestPath = require.resolve(`${specifier}/package.json`) + const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) + const configName = + isRecord(manifest) && typeof manifest.tsconfig === 'string' + ? manifest.tsconfig + : 'tsconfig.json' + return join(dirname(manifestPath), configName) + } + } +} + +function loadAliases(file: string, stack: readonly string[]): AliasConfig { + try { + const canonical = realpathSync(file) + if (stack.includes(canonical) || stack.length >= 128) + throw new Error( + `Tsconfig inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, + ) + const config = parseJsonc(readFileSync(file, 'utf-8')) + if (!isRecord(config)) throw new TypeError('Expected a tsconfig object') + const parents = + config.extends === undefined + ? [] + : typeof config.extends === 'string' + ? [config.extends] + : config.extends + if ( + !Array.isArray(parents) || + !parents.every((parent) => typeof parent === 'string') + ) + throw new TypeError('Expected extends to be a path or array of paths') + let inherited: AliasConfig = {} + for (const parent of parents) + inherited = { + ...inherited, + ...loadAliases(resolveParent(parent, file), [...stack, canonical]), + } + if (config.compilerOptions === undefined) return inherited + if (!isRecord(config.compilerOptions)) + throw new TypeError('Expected compilerOptions to be an object') + const options = config.compilerOptions + const current: { + baseUrl?: string + paths?: Record + pathsOrigin?: string + } = {} + if (options.baseUrl !== undefined) { + if (typeof options.baseUrl !== 'string') + throw new TypeError('Expected baseUrl to be a path') + current.baseUrl = resolve(dirname(file), options.baseUrl) + } + if (options.paths !== undefined) { + if (!isRecord(options.paths)) + throw new TypeError('Expected paths to be an object') + current.paths = {} + current.pathsOrigin = dirname(file) + for (const [key, targets] of Object.entries(options.paths)) { + if ( + !Array.isArray(targets) || + !targets.every( + (target): target is string => typeof target === 'string', + ) + ) + throw new TypeError(`Expected string targets for paths.${key}`) + current.paths[key] = targets + } + } + return { ...inherited, ...current } + } catch (cause) { + if (cause instanceof ConfigLoadError) throw cause + throw new ConfigLoadError(file, cause) + } +} + +export function readPathAliases(tsconfigPath?: string): { + aliases: PathAlias[] + baseDir: string + baseUrl?: string +} { + if (!tsconfigPath || !existsSync(tsconfigPath)) + return { aliases: [], baseDir: process.cwd() } + const file = resolve(tsconfigPath) + const config = loadAliases(file, []) + const baseDir = config.baseUrl ?? config.pathsOrigin ?? dirname(file) + const aliases = Object.entries(config.paths ?? {}).map(([alias, targets]) => { + const star = alias.indexOf('*') + return { + prefix: star === -1 ? alias : alias.slice(0, star), + suffix: star === -1 ? '' : alias.slice(star + 1), + exact: star === -1, + targets: targets.map((target) => resolve(baseDir, target)), + } + }) + aliases.sort( + (a, b) => + Number(b.exact) - Number(a.exact) || b.prefix.length - a.prefix.length, + ) + return { + aliases, + baseDir: config.baseUrl ?? dirname(file), + baseUrl: config.baseUrl, + } +} From 349f4cecdc59dd0ad74eaa406879e9a9ea79bb42 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:00:31 +0900 Subject: [PATCH 10/50] fix(plugin-utils): remap compiled MDX diagnostics through source maps Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/mdx-errors.test.ts | 246 ++++++++++++++++++ packages/plugin-utils/src/mdx-errors.ts | 243 +++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 packages/plugin-utils/src/__tests__/mdx-errors.test.ts create mode 100644 packages/plugin-utils/src/mdx-errors.ts diff --git a/packages/plugin-utils/src/__tests__/mdx-errors.test.ts b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts new file mode 100644 index 00000000..3ba25cb4 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'bun:test' + +import { remapMdxError } from '../index' + +const filename = 'C:\\docs\\page.mdx' +const map = { + version: 3, + sources: ['page.mdx', 'other.mdx'], + names: ['Box'], + mappings: ';EAIEA,KCEG;ADFH', +} + +describe('MDX error locations', () => { + it.each([map, JSON.stringify(map)])( + 'maps multiline Windows locations with real VLQ deltas', + (input) => { + const error = new Error( + `${filename}:2:3: first\n${filename}:2:9: second\n${filename}:3:1: third\nother.ts:2:3: unchanged\nnote ${filename}:2:3: unchanged`, + ) + const result = remapMdxError(error, filename, input) + expect(result.message).toBe( + 'page.mdx:5:3: first\nother.mdx:7:6: second\npage.mdx:5:3: third\nother.ts:2:3: unchanged\nnote C:\\docs\\page.mdx:2:3: unchanged', + ) + expect(result.cause).toBe(error) + }, + ) + it.each([undefined, null])( + 'labels compiled coordinates immediately after the column when no map exists', + (input) => { + expect( + remapMdxError( + `${filename}:2:3: failure\n${filename}:4:5 details`, + filename, + input, + ).message, + ).toBe( + `${filename}:2:3 (in compiled MDX): failure\n${filename}:4:5 (in compiled MDX) details`, + ) + }, + ) + it('does not change a message without a matching location', () => { + expect(remapMdxError(`${filename}:not a location`, filename).message).toBe( + `${filename}:not a location`, + ) + }) + it('labels gaps, before-first segments, unmapped segments and missing lines', () => { + const input = { + version: 3, + sources: ['page.mdx'], + mappings: ';EAAA,E;AAAA;;', + } + const text = [1, 2, 4, 8] + .map((line) => `${filename}:${line}:1: missing`) + .concat(`${filename}:2:5: gap`) + .join('\n') + expect(remapMdxError(text, filename, input).message).toBe( + text + .replace(/:(\d+):1:/g, ':$1:1 (in compiled MDX):') + .replace(':2:5:', ':2:5 (in compiled MDX):'), + ) + }) + it.each([ + ['', 'page.mdx', 'page.mdx'], + ['/docs', 'page.mdx', '/docs/page.mdx'], + ['C:\\docs', 'page.mdx', 'C:\\docs\\page.mdx'], + [ + 'https://example.com/docs/', + '../page.mdx', + 'https://example.com/page.mdx', + ], + ['/docs', '/absolute.mdx', '/absolute.mdx'], + ['/docs', 'C:\\absolute.mdx', 'C:\\absolute.mdx'], + ['/docs', 'webpack:///page.mdx', 'webpack:///page.mdx'], + ])('resolves sourceRoot %s and source %s', (sourceRoot, source, expected) => { + expect( + remapMdxError(`${filename}:1:1`, filename, { + version: 3, + mappings: 'AAAA', + sources: [source], + sourceRoot, + }).message, + ).toBe(`${expected}:1:1`) + }) + it('decodes multi-digit VLQ fields and chooses the preceding mapping without inventing column offsets', () => { + expect( + remapMdxError(`${filename}:1:30`, filename, { + version: 3, + sources: ['page.mdx'], + mappings: 'gBAgBgB', + }).message, + ).toBe('page.mdx:17:17') + }) + it('tracks name deltas across segments and preserves the original error cause chain', () => { + const cause = new TypeError('original') + const error = new Error(`${filename}:1:3`, { cause }) + const result = remapMdxError(error, filename, { + version: 3, + sources: ['page.mdx'], + names: ['a', 'b'], + mappings: 'AAAAC,EAAAD', + }) + expect(result.message).toBe('page.mdx:1:1') + expect(result.cause).toBe(error) + expect(error.cause).toBe(cause) + }) + it.each([ + '{', + 'null', + '[]', + '{}', + { version: 2, sections: [] }, + { ...map, version: 2 }, + { ...map, sources: [null] }, + { ...map, names: [1] }, + { ...map, sourceRoot: 1 }, + ...[ + '?', + 'g', + 'ggggggggggggA', + '////////////A', + 'AA', + 'AAA', + 'AAAAAA', + ',', + 'D', + 'EAAA,D', + 'ACAA', + 'ADAA', + 'AADA', + 'AAAD', + 'AAAAC', + 'AAAAD', + ].map((mappings) => ({ ...map, sources: ['page.mdx'], mappings })), + ])( + 'diagnoses invalid or unsupported maps with location and cause: %j', + (input) => { + let caught: unknown + try { + remapMdxError('failure', filename, input) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Error) + if (!(caught instanceof Error)) throw caught + expect(caught.message).toStartWith( + `${filename}:1:1: Invalid MDX source map:`, + ) + expect(caught.cause).toBeInstanceOf(Error) + }, + ) + it.each(['0:1', '1:0', '9007199254740992:1', '1:9007199254740992'])( + 'diagnoses invalid extractor coordinates %s', + (location) => { + expect(() => remapMdxError(`${filename}:${location}`, filename)).toThrow( + `${filename}:1:1:`, + ) + }, + ) + + it('remaps indexed maps across offsets, gaps and nested sections', () => { + const input = { + version: 3, + sections: [ + { + offset: { line: 0, column: 2 }, + map: { version: 3, sources: ['first.mdx'], mappings: 'AAAA;AACE' }, + }, + { + offset: { line: 1, column: 4 }, + map: { + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ['second.mdx'], + mappings: 'AAGE;AACA', + }, + }, + ], + }, + }, + { offset: { line: 3, column: 0 }, map: { version: 3, sections: [] } }, + ], + } + expect( + remapMdxError( + `${filename}:1:3: first\n${filename}:2:5: second\n${filename}:3:1: nested\n${filename}:4:1: gap`, + filename, + JSON.stringify(input), + ).message, + ).toBe( + `first.mdx:1:1: first\nsecond.mdx:4:3: second\nsecond.mdx:5:3: nested\n${filename}:4:1 (in compiled MDX): gap`, + ) + }) + + it('accepts an empty indexed map and labels the unmapped compiled position', () => { + expect( + remapMdxError(`${filename}:1:1: missing`, filename, { + version: 3, + sections: [], + }).message, + ).toBe(`${filename}:1:1 (in compiled MDX): missing`) + }) + + it.each([ + { version: 3, sections: null }, + { version: 3, sections: [null] }, + { version: 3, sections: [{ offset: { line: -1, column: 0 } }] }, + { version: 3, sections: [{ offset: { line: '0', column: 0 } }] }, + { version: 3, sections: [{ offset: { line: 0, column: -1 } }] }, + { version: 3, sections: [{ offset: { line: 0, column: '0' } }] }, + { version: 3, sections: [{ offset: { line: 0, column: 0.5 } }] }, + { + version: 3, + sections: [0, 0].map(() => ({ + offset: { line: 0, column: 0 }, + map: { version: 3, sections: [] }, + })), + }, + { + version: 3, + sections: [1, 0].map((line) => ({ + offset: { line, column: 0 }, + map: { version: 3, sections: [] }, + })), + }, + { + version: 3, + sections: [ + { + offset: { line: 0, column: Number.MAX_SAFE_INTEGER }, + map: { version: 3, sources: ['page.mdx'], mappings: 'CAAA' }, + }, + ], + }, + ])( + 'diagnoses invalid indexed maps with the original filename: %j', + (input) => { + expect(() => remapMdxError(`${filename}:1:1`, filename, input)).toThrow( + `${filename}:1:1: Invalid MDX source map:`, + ) + }, + ) +}) diff --git a/packages/plugin-utils/src/mdx-errors.ts b/packages/plugin-utils/src/mdx-errors.ts new file mode 100644 index 00000000..54f4d1e6 --- /dev/null +++ b/packages/plugin-utils/src/mdx-errors.ts @@ -0,0 +1,243 @@ +import { posix, win32 } from 'node:path' + +type SourceMap = { + readonly mappings: string + readonly sources: readonly string[] + readonly names: readonly string[] + readonly sourceRoot: string +} +type Segment = { + readonly generated: number + readonly original?: { + readonly source: string + readonly line: number + readonly column: number + } +} +const BASE64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +class MdxSourceMapError extends Error { + constructor(filename: string, cause: unknown) { + super( + `${filename}:1:1: Invalid MDX source map: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'MdxSourceMapError' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function strings(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ) +} + +function parseMap(input: unknown): SourceMap { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value)) throw new TypeError('Expected a source-map object') + if ( + value.version !== 3 || + typeof value.mappings !== 'string' || + !strings(value.sources) || + (value.names !== undefined && !strings(value.names)) || + (value.sourceRoot !== undefined && typeof value.sourceRoot !== 'string') + ) { + throw new TypeError( + 'Expected source-map v3 mappings, sources, names and sourceRoot', + ) + } + return { + mappings: value.mappings, + sources: value.sources, + names: value.names ?? [], + sourceRoot: value.sourceRoot ?? '', + } +} + +function decode(segment: string): number[] { + const values: number[] = [] + let value = 0 + let multiplier = 1 + for (const char of segment) { + const digit = BASE64.indexOf(char) + if (digit < 0) throw new TypeError('Invalid base64 VLQ digit') + value += (digit % 32) * multiplier + if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) + throw new RangeError('VLQ exceeds safe integer bounds') + if (digit >= 32) { + multiplier *= 32 + } else { + values.push((value % 2 === 1 ? -1 : 1) * Math.floor(value / 2)) + value = 0 + multiplier = 1 + } + } + if (multiplier !== 1) throw new TypeError('Unterminated base64 VLQ') + if (![1, 4, 5].includes(values.length)) + throw new TypeError('Expected 1, 4 or 5 VLQ fields') + return values +} + +function sourcePath(root: string, source: string): string { + if ( + !root || + posix.isAbsolute(source) || + win32.isAbsolute(source) || + /^[a-z][\w+.-]*:/i.test(source) + ) + return source + if (/^[a-z][\w+.-]*:\/\//i.test(root)) + return new URL(source, `${root.replace(/\/$/, '')}/`).href + return root.includes('\\') + ? win32.join(root, source) + : posix.join(root, source) +} + +function decodeMap(map: SourceMap): Segment[][] { + let source = 0 + let line = 0 + let column = 0 + let name = 0 + return map.mappings.split(';').map((encodedLine) => { + let generated = 0 + if (!encodedLine) return [] + return encodedLine.split(',').map((encoded) => { + const fields = decode(encoded) + const [delta, sourceDelta, lineDelta, columnDelta, nameDelta] = fields + const previous = generated + generated += delta + if (!Number.isSafeInteger(generated) || generated < previous) + throw new RangeError( + 'Generated columns must be nonnegative and ordered', + ) + if (fields.length === 1) return { generated } + source += sourceDelta + line += lineDelta + column += columnDelta + if ( + ![source, line, column].every( + (value) => Number.isSafeInteger(value) && value >= 0, + ) || + source >= map.sources.length + ) { + throw new RangeError('Original source position is out of bounds') + } + if (nameDelta !== undefined) { + name += nameDelta + if (!Number.isSafeInteger(name) || name < 0 || name >= map.names.length) + throw new RangeError('Name index is out of bounds') + } + return { + generated, + original: { + source: sourcePath(map.sourceRoot, map.sources[source]), + line: line + 1, + column: column + 1, + }, + } + }) + }) +} + +function decodeInput(input: unknown): Segment[][] { + const value: unknown = typeof input === 'string' ? JSON.parse(input) : input + if (!isRecord(value) || !('sections' in value)) + return decodeMap(parseMap(value)) + if (value.version !== 3 || !Array.isArray(value.sections)) + throw new TypeError('Expected source-map v3 sections') + const rows: Segment[][] = [] + let previousLine = -1 + let previousColumn = -1 + for (const section of value.sections) { + if (!isRecord(section) || !isRecord(section.offset)) + throw new TypeError('Expected a section offset and map') + const { line, column } = section.offset + if ( + typeof line !== 'number' || + typeof column !== 'number' || + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 0 || + column < 0 || + line < previousLine || + (line === previousLine && column <= previousColumn) + ) { + throw new RangeError('Section offsets must be nonnegative and ordered') + } + previousLine = line + previousColumn = column + rows.length = line + 1 + rows[line] = (rows[line] ?? []).filter( + (segment) => segment.generated < column, + ) + rows[line].push({ generated: column }) + decodeInput(section.map).forEach((segments, index) => { + const targetLine = line + index + const shifted = segments.map((segment) => ({ + ...segment, + generated: segment.generated + (index === 0 ? column : 0), + })) + if (shifted.some((segment) => !Number.isSafeInteger(segment.generated))) + throw new RangeError('Section generated column is out of bounds') + if (index === 0) rows[targetLine].push(...shifted) + else rows[targetLine] = shifted + }) + } + return rows +} + +/** Remap one-based extractor locations using zero-based source-map columns. */ +export function remapMdxError( + error: unknown, + filename: string, + inputSourceMap?: unknown, +): Error { + let lines: Segment[][] = [] + if (inputSourceMap !== undefined && inputSourceMap !== null) { + try { + lines = decodeInput(inputSourceMap) + } catch (cause) { + throw new MdxSourceMapError(filename, cause) + } + } + const message = error instanceof Error ? error.message : String(error) + const remapped = message + .split('\n') + .map((text) => { + if (!text.startsWith(`${filename}:`)) return text + const location = /^(\d+):(\d+)(?=:|\s|$)/.exec( + text.slice(filename.length + 1), + ) + if (!location) return text + const line = Number(location[1]) + const column = Number(location[2]) + if ( + !Number.isSafeInteger(line) || + !Number.isSafeInteger(column) || + line < 1 || + column < 1 + ) { + throw new MdxSourceMapError( + filename, + new RangeError('Error location is out of bounds'), + ) + } + let selected: Segment | undefined + for (const segment of lines[line - 1] ?? []) { + if (segment.generated > column - 1) break + selected = segment + } + const position = selected?.original + const prefix = position + ? `${position.source}:${position.line}:${position.column}` + : `${filename}:${location[0]} (in compiled MDX)` + return prefix + text.slice(filename.length + 1 + location[0].length) + }) + .join('\n') + return new Error(remapped, { cause: error }) +} From 7f99ae88bb4d82e7e52017f169b79a908845d63f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:06:59 +0900 Subject: [PATCH 11/50] feat(plugin-utils): export shared plugin integration helpers Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/plugin-utils/src/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index ccbb2386..aff1efae 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -18,8 +18,15 @@ export { planAtomHoist, type ResolvedModule, type StaticImportGraph, + type StaticImportGraphOptions, } from './import-graph' -export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + ConfigLoadError, + deepMerge, + loadDevupConfig, + loadDevupConfigSync, +} from './load-config' +export { remapMdxError } from './mdx-errors' export { collectNumberedFiles, type CollectNumberedFilesOptions, @@ -34,6 +41,13 @@ export { type DevupThemeInterfaceNames, type DevupUIBasePluginOptions, getFileNumByFilename, + GRAPH_SOURCE_FILE_RE, + MDX_FILE_RE, + POST_COMPILED_MDX_RE, + resolveProjectPaths, + resolveSourceDirs, + SOURCE_EXTENSIONS, + SOURCE_FILE_RE, } from './shared' export { createStateWriter, From c85701e016a573baefdfaf5d855e852760e75875 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:15:21 +0900 Subject: [PATCH 12/50] fix(extractor): parse compiled MDX with real source identities Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_plugin_core_mdx.json | 7 + libs/extractor/src/build_time_values.rs | 4 +- libs/extractor/src/import_alias_visit.rs | 6 +- libs/extractor/src/lib.rs | 218 +++++++++++++++++- 4 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 .changepacks/changepack_log_plugin_core_mdx.json diff --git a/.changepacks/changepack_log_plugin_core_mdx.json b/.changepacks/changepack_log_plugin_core_mdx.json new file mode 100644 index 00000000..da179669 --- /dev/null +++ b/.changepacks/changepack_log_plugin_core_mdx.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Extract compiled MDX with its real source filename, including JSX-preserving alias imports and exact static computations. Raw or invalid MDX and unsupported extraction extensions now produce located build errors instead of untransformed styling placeholders.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e..95cca8f8 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -15,7 +15,7 @@ use oxc_ast::ast::{ use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{GetSpan, SourceType, Span}; +use oxc_span::{GetSpan, Span}; use oxc_syntax::operator::LogicalOperator; use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; @@ -131,7 +131,7 @@ const UNCERTAIN_MEMBERS: [&str; 9] = [ ]; fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> { - let source_type = SourceType::from_path(filename).ok()?; + let source_type = crate::parser_source_type(filename).ok()?; let parsed = Parser::new(allocator, code, source_type).parse(); (!parsed.fatal_error).then_some(parsed.program) } diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f42..3ee21790 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -22,7 +22,7 @@ use oxc_ast_visit::{ walk::{walk_call_expression, walk_jsx_opening_element}, }; use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::GetSpan; use std::borrow::Cow; use std::collections::HashMap; @@ -256,7 +256,7 @@ pub fn transform_import_aliases_with_edits<'a>( } let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_default(); + let source_type = crate::parser_source_type(filename).unwrap_or_default(); // Parse the code let parser_ret = Parser::new(&allocator, code, source_type).parse(); @@ -566,7 +566,7 @@ mod tests { use super::*; use insta::assert_snapshot; use oxc_ast::builder::AstBuilder; - use oxc_span::SPAN; + use oxc_span::{SPAN, SourceType}; fn emotion_alias() -> HashMap { let mut aliases = HashMap::new(); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4d126e12..f1345083 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -274,6 +274,26 @@ pub fn extract_with_modules( extract_with_source_map(filename, code, option, source_map, Some(resolver)) } +/// Compiled Markdown is JavaScript with optional JSX, never TypeScript. +fn parser_source_type(filename: &str) -> Result { + if is_compiled_mdx(filename) { + return Ok(SourceType::jsx()); + } + SourceType::from_path(filename).map_err(|error| { + format!("{filename}:1:1: source parser cannot use `{filename}` at build time: {error}") + }) +} + +/// Whether a compiler supplied JavaScript for a Markdown source filename. +fn is_compiled_mdx(filename: &str) -> bool { + std::path::Path::new(filename) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("mdx") || extension.eq_ignore_ascii_case("md") + }) +} + fn extract_with_source_map( filename: &str, code: &str, @@ -307,8 +327,32 @@ fn extract_source( // Step 2: Check if code contains the target package (after transformation) let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let compiled_mdx = is_compiled_mdx(filename); if !has_relevant_import { + // Raw Markdown must not pass through merely because it has no styling import. + if compiled_mdx { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, parser_source_type(filename)?).parse(); + if let Some(error) = parsed.diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(located_errors( + filename, + code, + &[], + vec![( + offset, + format!( + "source parser cannot use raw or invalid MDX at build time: {error}" + ), + )], + ) + .into()); + } + } // skip if not using package return Ok(ExtractOutput { styles: FxHashSet::default(), @@ -396,7 +440,7 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(filename)?; + let source_type = parser_source_type(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -412,8 +456,25 @@ fn extract_source( let ParserReturn { mut program, // AST fatal_error, // Parser encountered an error it couldn't recover from + diagnostics, .. } = Parser::new(&allocator, code_to_parse, source_type).parse(); + if compiled_mdx && let Some(error) = diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(located_errors( + filename, + code, + &[alias_edits.as_slice()], + vec![( + offset, + format!("source parser cannot use raw or invalid MDX at build time: {error}"), + )], + ) + .into()); + } if fatal_error { return Err("Parser panicked".into()); } @@ -641,7 +702,7 @@ fn extract_class_map_from_code( option: &ExtractOption, style_names: &FxHashSet, ) -> Result, Box> { - let source_type = SourceType::from_path(filename)?; + let source_type = parser_source_type(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, option); let css_files = vec![css_file]; let allocator = Allocator::default(); @@ -707,7 +768,7 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { return false; } - let source_type = match SourceType::from_path(filename) { + let source_type = match parser_source_type(filename) { Ok(st) => st, Err(_) => return false, }; @@ -716,10 +777,11 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { let ParserReturn { program, fatal_error, + diagnostics, .. } = Parser::new(&allocator, code, source_type).parse(); - if fatal_error { + if fatal_error || (is_compiled_mdx(filename) && diagnostics.has_errors()) { return false; } @@ -749,6 +811,152 @@ mod tests { use rstest::rstest; use serial_test::serial; + #[rstest] + #[case("page.js")] + #[case("page.jsx")] + #[case("page.mjs")] + #[case("page.cjs")] + #[case("page.ts")] + #[case("page.tsx")] + #[case("page.mts")] + #[case("page.cts")] + fn mdx_source_type_keeps_other_extensions(#[case] filename: &str) { + assert_eq!( + parser_source_type(filename), + SourceType::from_path(filename).map_err(|error| error.to_string()) + ); + } + + #[rstest] + #[case("page.mdx")] + #[case("page.md")] + #[serial] + fn mdx_compiler_jsx_calls_extract_with_real_filename(#[case] filename: &str) { + reset_class_map(); + reset_file_map(); + let source = "import {jsx as _jsx} from 'react/jsx-runtime';\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return _jsx(Box, {bg: 'tomato', children: 'hello'}); }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }"; + let output = extract(filename, source, ExtractOption::default()).unwrap(); + assert_ne!(output.styles.len(), 0); + assert!(output.code.contains("className")); + assert!(!output.code.contains("_jsx(Box")); + let map: serde_json::Value = serde_json::from_str(&output.map.unwrap()).unwrap(); + assert_eq!(map["sources"], serde_json::json!([filename])); + } + + #[rstest] + #[case("page.mdx")] + #[case("page.md")] + #[serial] + fn mdx_compiler_preserved_jsx_extracts(#[case] filename: &str) { + reset_class_map(); + reset_file_map(); + let source = "/*@jsxRuntime automatic*/\n/*@jsxImportSource react*/\nimport {Box} from '@devup-ui/react';\nfunction _createMdxContent(props) { return hello; }\nexport default function MDXContent(props = {}) { return _createMdxContent(props); }"; + let output = + extract_without_source_map(filename, source, ExtractOption::default()).unwrap(); + assert_ne!(output.styles.len(), 0); + assert!(output.code.contains("className")); + assert!(!output.code.contains("")] + #[case("page.md", "import {Box} from '@devup-ui/react';\n# Heading")] + #[case( + "page.mdx", + "import {Box} from '@devup-ui/react';\nconst color: string = 'red';\nconst view = ;" + )] + #[serial] + fn mdx_raw_or_typescript_input_fails_with_location( + #[case] filename: &str, + #[case] source: &str, + ) { + reset_class_map(); + reset_file_map(); + let error = extract(filename, source, ExtractOption::default()) + .err() + .unwrap() + .to_string(); + assert!(error.starts_with(&format!("{filename}:")), "{error}"); + assert!(error.contains("at build time"), "{error}"); + assert!(!has_devup_ui(filename, source, "@devup-ui/react")); + } + + #[test] + #[serial] + fn mdx_compiled_without_devup_keeps_source() { + let source = "export default function MDXContent() { return

Hello

; }"; + let output = extract("page.mdx", source, ExtractOption::default()).unwrap(); + assert_eq!(output.code, source); + assert_eq!(output.styles.len(), 0); + assert_eq!(output.css_file, None); + } + + #[test] + #[serial] + fn mdx_relevant_import_gate_accepts_compiled_jsx() { + let source = "import {Box} from '@devup-ui/react'; export default () => ;"; + assert!(has_devup_ui("page.mdx", source, "@devup-ui/react")); + } + + #[test] + #[serial] + fn mdx_generated_class_map_uses_jsx_source_type() { + reset_class_map(); + reset_file_map(); + let names = FxHashSet::from_iter(["card".to_string()]); + let map = extract_class_map_from_code( + "page.mdx", + "import {css} from '@devup-ui/react'; export const card = css({bg: 'red'});", + &ExtractOption::default(), + &names, + ) + .unwrap(); + assert!(map.contains_key("card")); + } + + #[test] + #[serial] + fn mdx_unknown_extension_error_is_located() { + let source = "import {Box} from '@devup-ui/react'; const view = ;"; + let error = extract("page.unknown", source, ExtractOption::default()) + .err() + .unwrap() + .to_string(); + assert!(error.starts_with("page.unknown:1:1:"), "{error}"); + } + #[derive(Debug)] #[allow(dead_code)] struct ToBTreeSet { @@ -4779,7 +4987,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string() - .starts_with("Unknown file extension") + .starts_with("test.wrong:1:1: source parser cannot use `test.wrong` at build time: Unknown file extension") ); reset_class_map(); From 13b033bf539797eeb35e82b12347ee753df3783b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:22:57 +0900 Subject: [PATCH 13/50] fix(wasm): skip source maps and surface located resolver failures Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bindings/devup-ui-wasm/README.md | 124 +++++----- .../devup-ui-wasm/boundary-regressions.mjs | 224 ++++++++++++++++++ bindings/devup-ui-wasm/src/lib.rs | 140 ++++++++++- 3 files changed, 412 insertions(+), 76 deletions(-) create mode 100644 bindings/devup-ui-wasm/boundary-regressions.mjs diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 613206dd..07ace89a 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -1,85 +1,75 @@ -
+# @devup-ui/wasm -

wasm-pack-template

+Build-time CSS extraction bindings used by the Devup UI bundler plugins. -A template for kick starting a Rust and WebAssembly project using wasm-pack. +## Extraction boundaries -

- Build Status -

+`codeExtract` generates a source map. `codeExtractWithoutSourceMap` skips map +generation, including when a module resolver is installed; its `map` getter is +`undefined`. Both functions return transformed source and watched dependencies. -

- Tutorial - | - Chat -

+`setModuleResolver` accepts a synchronous `(specifier, importer)` callback. +Return `{ path: string, code: string }` for a resolved module. `null` and +`undefined` mean ordinary unresolved imports. Thrown exceptions, property-getter +and reflection failures, and missing/non-string fields instead fail extraction: -Built with 🦀🕸 by The Rust and WebAssembly Working Group - -
- -## About - -[**📚 Read this template tutorial! 📚**][template-docs] - -This template is designed for compiling Rust libraries into WebAssembly and -publishing the resulting package to NPM. - -Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other -templates and usages of `wasm-pack`. +```text +/src/App.tsx:1:1: module resolver cannot use `./tokens` at build time: original cause +``` -[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html -[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html +The importer includes transitive importers. The resolver contract does not +provide an import span, so this boundary reports `1:1` rather than guessing a +position from source text. The first resolver fault takes precedence over the +extractor's unresolved-module fallback and is checked **before** sheet updates. +The previous stylesheet contribution therefore survives a failed re-extraction. +Class/file numbering performed inside extraction is not rolled back. -## 🚴 Usage +## Panic diagnostics -### 🐑 Use `cargo generate` to Clone this Template +WASM initialization installs this binding's own `std::panic::set_hook`, with no +additional dependency or feature flag. It reports Rust's panic payload and +`file:line:column` location to `console.error` before the trap. A failing host +console does not replace the original panic. This is diagnostic reporting, not +panic recovery: with `panic=abort`, a panic still traps and the instance should +be discarded. Normal resolver/configuration failures use fallible JS errors. -[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate) +Owned binding code does not unwrap user input or explicitly panic. Sheet mutex +poisoning is recovered, and no resolver callback is called while holding a sheet +lock or a thread-local resolver borrow. Panics originating in downstream crates +remain outside this binding's recovery contract. -``` -cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project -cd my-project -``` +## Verification -### 🛠️ Build with `wasm-pack build` +From the repository root: -``` -wasm-pack build +```sh +cargo test -p devup-ui-wasm boundary_ -- --nocapture ``` -### 🔬 Test in Headless Browsers with `wasm-pack test` +After rebuilding `pkg` with this package's `bun run build`, run from the +repository root: -``` -wasm-pack test --headless --firefox +```sh +node bindings/devup-ui-wasm/boundary-regressions.mjs ``` -### 🎁 Publish to NPM with `wasm-pack publish` - -``` -wasm-pack publish +This standalone runner loads the real generated JS and WASM, outside Bun's +coverage mocks. It exercises both extraction exports, map skipping with and +without resolution, unresolved null/undefined, valid resolved dependencies, +callback exceptions, getters, proxy reflection, malformed fields and hostile +exception objects. Every fault case verifies the previous sheet is unchanged. +Native tests cover every branch of the internal fault-precedence helper. + +The same JS runner also uses the installed `@mdx-js/mdx` compiler, resolved through +`@mdx-js/loader` in `apps/landing` (no added dependency). Both default `_jsx` +output and `jsx: true` output are extracted under real `.mdx` and `.md` filenames. +It checks CSS, the relevance gate, source-map filename identity and rejection of +raw Markdown. Compiled MDX is parsed as JSX JavaScript, not TSX. Its source-map +coordinates refer to compiler output; bundler integrations must compose input +maps to recover Markdown positions, or identify diagnostics as compiled MDX. + +Focused native MDX verification: + +```sh +cargo test -p extractor mdx_ -- --nocapture ``` - -## 🔋 Batteries Included - -- [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating - between WebAssembly and JavaScript. -- [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook) - for logging panic messages to the developer console. -- `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you - -## License - -Licensed under either of - -- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) -- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) - -at your option. - -### Contribution - -Unless you explicitly state otherwise, any contribution intentionally -submitted for inclusion in the work by you, as defined in the Apache-2.0 -license, shall be dual licensed as above, without any additional terms or -conditions. diff --git a/bindings/devup-ui-wasm/boundary-regressions.mjs b/bindings/devup-ui-wasm/boundary-regressions.mjs new file mode 100644 index 00000000..ac30a81a --- /dev/null +++ b/bindings/devup-ui-wasm/boundary-regressions.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const wasm = require('./pkg/index.js') +const filename = '/src/boundary.tsx' +const source = `import { Box } from '@devup-ui/react'; +import { PRIMARY } from './tokens'; +export const view = ;` +const extract = (method, code = source) => + wasm[method](filename, code, '@devup-ui/react', 'df', true, false, false, {}) + +const loaderPath = require.resolve('@mdx-js/loader', { + paths: [fileURLToPath(new URL('../../apps/landing/', import.meta.url))], +}) +const compilerPath = createRequire(loaderPath).resolve('@mdx-js/mdx') +const { compile } = await import(pathToFileURL(compilerPath).href) +const markdown = `import { Box } from '@devup-ui/react' + +# Heading + +Hello +` +for (const jsx of [false, true]) { + const compiled = String( + await compile({ path: '/src/document.mdx', value: markdown }, { jsx }), + ) + for (const extension of ['mdx', 'md']) { + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + const path = `/src/document.${extension}` + assert.equal(wasm.hasDevupUI(path, compiled, '@devup-ui/react'), true) + const output = wasm[method]( + path, + compiled, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.match(output.code, /className/) + assert.doesNotMatch(output.code, /(?:_jsx\(\s*Box\b| + wasm.codeExtract( + path, + markdown, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.startsWith(`${path}:`)) + assert.match(error.message, /:\d+:\d+:/) + return true + }, + ) +} +console.info( + 'Real MDX compiler regressions passed (default JSX calls and jsx:true).', +) + +for (const jsx of [false, true]) { + const markdown = + "import { css } from '@emotion/react'\n\nexport const cls = css({ color: 'red' })\n\n
\n" + const compiled = String( + await compile({ path: '/src/alias.mdx', value: markdown }, { jsx }), + ) + wasm.resetBuildState() + const output = wasm.codeExtract( + '/src/alias.mdx', + compiled, + '@devup-ui/react', + 'df', + true, + false, + false, + { '@emotion/react': null }, + ) + assert.doesNotMatch(output.code, /@emotion\/react/) + assert.match(wasm.getCss(undefined, false), /color:red/) + output.free() +} + +for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + const direct = extract( + method, + "import { Box } from '@devup-ui/react'; export const view = ;", + ) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(direct.map, undefined) + else assert.equal(typeof direct.map, 'string') + direct.free() + for (const result of [null, undefined]) { + wasm.resetBuildState() + wasm.setModuleResolver(() => result) + const output = extract(method) + assert.match(output.code, /--/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(output.map, undefined) + else assert.equal(typeof output.map, 'string') + output.free() + } + + wasm.resetBuildState() + wasm.setModuleResolver(() => ({ + path: '/src/tokens.ts', + code: "export const PRIMARY = 'blue';", + })) + const resolved = extract(method) + assert.deepEqual(resolved.dependencies, ['/src/tokens.ts']) + assert.doesNotMatch(resolved.code, /--/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(resolved.map, undefined) + resolved.free() + + const faults = [ + [ + () => { + throw new Error('callback failure') + }, + /callback failure/, + ], + [ + () => { + throw 'string failure' + }, + /string failure/, + ], + [ + () => ({ + get path() { + throw new Error('path getter failure') + }, + }), + /path getter failure/, + ], + [ + () => ({ + path: '/src/tokens.ts', + get code() { + throw new Error('code getter failure') + }, + }), + /code getter failure/, + ], + [ + () => + new Proxy( + {}, + { + get() { + throw new Error('reflection failure') + }, + }, + ), + /reflection failure/, + ], + [() => 7, /Reflect|reflect|object/i], + [() => ({}), /path.*must be a string/], + [() => ({ path: 7, code: '' }), /path.*must be a string/], + [() => ({ path: '/src/tokens.ts', code: null }), /code.*must be a string/], + [() => ({ path: '/src/tokens.ts', code: 7 }), /code.*must be a string/], + [ + () => { + throw new Proxy( + {}, + { + get() { + throw new Error('hostile exception') + }, + }, + ) + }, + /unprintable JavaScript exception/, + ], + ] + for (const [resolver, cause] of faults) { + wasm.resetBuildState() + const initial = extract( + method, + "import { Box } from '@devup-ui/react'; export const view = ;", + ) + initial.free() + const before = wasm.exportSheet() + wasm.setModuleResolver(resolver) + assert.throws( + () => extract(method), + (error) => { + assert.ok(error instanceof Error) + assert.match( + error.message, + /^\/src\/boundary\.tsx:1:1: module resolver cannot use `\.\/tokens` at build time: /, + ) + assert.match(error.message, cause) + return true + }, + ) + assert.equal(wasm.exportSheet(), before) + } +} +wasm.resetBuildState() +console.info( + 'WASM boundary regressions passed (real generated JS/WASM, no mocks).', +) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 685af49a..c52b0f9e 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -53,6 +53,52 @@ fn js_error(message: impl Display) -> JsValue { js_sys::Error::new(&message.to_string()).into() } +#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))] +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = error, catch)] + fn report_panic(message: &str) -> Result<(), JsValue>; +} + +#[cfg(not(tarpaulin_include))] +fn resolver_cause(value: &JsValue) -> String { + if let Some(message) = value.as_string() { + return message; + } + if let Ok(message) = js_sys::Reflect::get(value, &"message".into()) + && let Some(message) = message.as_string() + { + return message; + } + match js_sys::JSON::stringify(value) { + Ok(text) => text.as_string().unwrap_or_else(|| "undefined".to_string()), + Err(_) => "".to_string(), + } +} + +/// Report the panic payload and Rust file:line:column before an aborting WASM trap. +#[cfg(all(target_arch = "wasm32", not(tarpaulin_include)))] +#[wasm_bindgen(start)] +pub fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + // A broken host console must not replace the original panic with a JS exception. + match report_panic(&info.to_string()) { + Ok(()) | Err(_) => {} + } + })); +} + +/// Prefer a resolver failure over extraction's unresolved-module fallback. +fn checked_extraction( + extracted: Result, + fault: Option<&RefCell>>, +) -> Result { + if let Some(message) = fault.and_then(|fault| fault.borrow_mut().take()) { + return Err(message); + } + extracted.map_err(|error| error.to_string()) +} + #[wasm_bindgen] pub struct Output { code: String, @@ -400,6 +446,7 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, ) } @@ -425,6 +472,7 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, ) } @@ -453,6 +501,7 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, ) } @@ -468,6 +517,7 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_fault: Option<&RefCell>>, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -488,7 +538,7 @@ fn code_extract_internal_impl( (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), }; - match extracted { + match checked_extraction(extracted, resolver_fault) { Ok(output) => Ok(Output::new( output.code, output.styles, @@ -499,7 +549,7 @@ fn code_extract_internal_impl( import_main_css_in_css, output.dependencies, )), - Err(error) => Err(error.to_string()), + Err(error) => Err(error), } } @@ -518,19 +568,28 @@ fn call_module_resolver( resolver: &js_sys::Function, specifier: &str, importer: &str, -) -> Option { +) -> Result, String> { let module = resolver .call2(&JsValue::NULL, &specifier.into(), &importer.into()) - .ok()?; + .map_err(|error| resolver_cause(&error))?; + if module.is_null() || module.is_undefined() { + return Ok(None); + } let field = |name: &str| { js_sys::Reflect::get(&module, &name.into()) - .ok()? + .map_err(|error| { + format!( + "reading resolver field `{name}`: {}", + resolver_cause(&error) + ) + })? .as_string() + .ok_or_else(|| format!("resolver field `{name}` must be a string")) }; - Some(ResolvedModule { + Ok(Some(ResolvedModule { path: field("path")?, code: field("code")?, - }) + })) } /// Extract with the resolver set by `setModuleResolver`, if any @@ -548,8 +607,24 @@ fn code_extract_js( source_map: SourceMapMode, ) -> Result { let import_aliases = import_aliases_from_js(import_aliases)?; + let fault = std::rc::Rc::new(RefCell::new(None)); + let resolver_fault = std::rc::Rc::clone(&fault); let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) + move |specifier: &str, importer: &str| { + if resolver_fault.borrow().is_some() { + return None; + } + match call_module_resolver(&resolver, specifier, importer) { + Ok(module) => module, + Err(cause) => { + // ModuleResolver carries no source span, including for transitive imports. + *resolver_fault.borrow_mut() = Some(format!( + "{importer}:1:1: module resolver cannot use `{specifier}` at build time: {cause}" + )); + None + } + } + } }); code_extract_internal_impl( filename, @@ -564,6 +639,7 @@ fn code_extract_js( resolver .as_ref() .map(|resolver| resolver as &ModuleResolver), + Some(&fault), ) .map_err(js_error) } @@ -634,7 +710,7 @@ pub fn code_extract_without_source_map( import_main_css_in_code, import_main_css_in_css, import_aliases, - SourceMapMode::Generate, + SourceMapMode::Skip, ) } @@ -732,6 +808,52 @@ mod tests { ct } + #[rstest] + #[case(Ok(42), false, None, Ok(42))] + #[case(Err("extract failed"), false, None, Err("extract failed"))] + #[case(Ok(42), true, None, Ok(42))] + #[case(Ok(42), true, Some("resolver failed"), Err("resolver failed"))] + #[case( + Err("extract failed"), + true, + Some("resolver failed"), + Err("resolver failed") + )] + fn boundary_fault_takes_precedence_when_resolver_failed( + #[case] extracted: Result, + #[case] has_resolver: bool, + #[case] message: Option<&str>, + #[case] expected: Result, + ) { + let fault = has_resolver.then(|| RefCell::new(message.map(str::to_string))); + let result = checked_extraction(extracted, fault.as_ref()); + assert_eq!(result, expected.map_err(str::to_string)); + } + + #[test] + #[serial] + fn boundary_sheet_stays_unchanged_when_resolver_failed() { + reset_build_state_internal(); + let before = export_sheet_internal().unwrap(); + let fault = RefCell::new(Some("resolver failed".to_string())); + let result = code_extract_internal_impl( + "boundary.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Skip, + None, + Some(&fault), + ); + assert_eq!(result.err(), Some("resolver failed".to_string())); + assert_eq!(export_sheet_internal().unwrap(), before); + reset_build_state_internal(); + } + #[test] #[serial] fn atom_hoist_splits_global_and_private() { From 4ca51f98df34ffd58afe3711938c0de1dc07eede Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:31:00 +0900 Subject: [PATCH 14/50] fix(vite-plugin): align roots and compiled MDX without changing RSC forwarding Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/core-regression.test.ts | 236 +++++++++ .../vite-plugin/src/__tests__/plugin.test.ts | 162 ++++-- packages/vite-plugin/src/plugin.ts | 469 +++++++++++------- 3 files changed, 644 insertions(+), 223 deletions(-) create mode 100644 packages/vite-plugin/src/__tests__/core-regression.test.ts diff --git a/packages/vite-plugin/src/__tests__/core-regression.test.ts b/packages/vite-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 00000000..219291b6 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,236 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +let root: string +let server: ViteDevServer | undefined +const temp = process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir() + +beforeEach(async () => { + wasm.resetBuildState() + root = await realpath(await mkdtemp(join(temp, 'vite-'))) + await writeFile(join(root, 'tsconfig.json'), '{}') +}) + +afterEach(async () => { + await server?.close() + server = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'extracts modern .%s modules under the actual Vite root', + async (extension) => { + await mkdir(join(root, 'src')) + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const color = css({bg:'red'});", + ) + server = await createServer({ + root, + tsconfig: join(root, 'tsconfig.json'), + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + plugins: [DevupUI({ singleCss: true })], + }) + + const result = await server.transformRequest(`/src/main.${extension}`) + + expect(result?.code).not.toContain('css({') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') + }, +) + +it('clears declarations when a real theme file becomes empty', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + plugins: [plugin], + }) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + await plugin.watchChange(join(root, 'devup.json')) + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it('preserves the prior theme on a malformed watched config', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { preserved: 'red' } } } }), + ) + const [plugin] = DevupUI() + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + plugins: [plugin], + }) + const prior = await readFile(join(root, 'df/theme.d.ts'), 'utf-8') + await writeFile(join(root, 'devup.json'), '{') + const diagnostic = spyOn(console, 'error').mockImplementation(() => {}) + try { + await plugin.watchChange(join(root, 'devup.json')) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toBe(prior) + expect(diagnostic.mock.calls[0]?.[0]).toContain(join(root, 'devup.json')) + expect(wasm.getCss(null, false)).toContain('--preserved') + } finally { + diagnostic.mockRestore() + } +}) + +it('rejects a malformed setup config rather than clearing it silently', async () => { + await writeFile(join(root, 'devup.json'), '{') + + await expect( + createServer({ root, configFile: false, plugins: [DevupUI()] }), + ).rejects.toThrow(join(root, 'devup.json')) +}) + +it.each(['client', 'ssr'])( + 'covers all roots and the included closure for environment %s', + async (consumer) => { + for (const dir of ['src', 'app', 'custom', 'node_modules/foo.bar/nested']) + await mkdir(join(root, dir), { recursive: true }) + await writeFile( + join(root, 'node_modules/foo.bar/package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + import: './import.mjs', + }, + }), + ) + await writeFile( + join(root, 'node_modules/foo.bar/browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/import.mjs'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'node_modules/foo.bar/node.mjs'), + "export const color = 'blue'", + ) + await writeFile( + join(root, 'src/style.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/main.ts'), "import '../src/style.mts'") + await writeFile(join(root, 'custom/main.cts'), "import '../src/style.mts'") + server = await createServer({ + root, + configFile: false, + tsconfig: join(root, 'tsconfig.json'), + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + resolve: { conditions: ['browser', 'development|production'] }, + ssr: { resolve: { conditions: ['node', 'development|production'] } }, + build: { + ssr: consumer === 'ssr', + rollupOptions: { + input: { app: 'app/main.ts', custom: 'custom/main.cts' }, + }, + }, + plugins: [ + DevupUI({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + const result = + consumer === 'ssr' + ? await server.environments.ssr.transformRequest('/src/style.mts') + : await server.transformRequest('/src/style.mts') + + expect(result?.code).not.toContain('css({') + expect(wasm.getCss(null, false)).toContain( + consumer === 'ssr' ? 'blue' : 'red', + ) + expect(wasm.getCss(null, false)).not.toContain( + consumer === 'ssr' ? 'red' : 'blue', + ) + }, +) + +it('extracts MDX only after the installed MDX compiler has compiled raw markdown', async () => { + const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const loader = landing.resolve('@mdx-js/loader') + const mdx = await import( + pathToFileURL(createRequire(loader).resolve('@mdx-js/mdx')).href + ) + await writeFile( + join(root, 'page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Title\n\n', + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + resolve: { + alias: { 'react/jsx-runtime': landing.resolve('react/jsx-runtime') }, + }, + server: { middlewareMode: true, hmr: false }, + plugins: [ + DevupUI({ singleCss: true }), + { + name: 'actual-mdx-compiler', + enforce: 'pre', + async transform(source, id) { + if (!id.endsWith('.mdx')) return + const output = await mdx.compile({ value: source, path: id }) + return { code: String(output), map: null } + }, + }, + ], + }) + + const result = await server.transformRequest('/page.mdx') + + expect(result?.code).not.toContain('bg: "red"') + expect(result?.code).toContain('devup-ui.css') + expect(wasm.getCss(null, false)).toContain('red') +}) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c21cf5c..59050e98 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,7 +14,14 @@ import { spyOn, } from 'bun:test' -import { DevupUI } from '../plugin' +import { DevupUI as createDevupUI } from '../plugin' + +const pluginInstances = new Set[0]>() +function DevupUI(options?: Parameters[0]) { + const plugin = createDevupUI(options) + pluginInstances.add(plugin[0]) + return plugin +} type CodeExtractResult = ReturnType interface ConfigHookMeta { @@ -88,6 +95,12 @@ interface ViteTestPlugin { load: (id: string) => string | undefined transform: ( this: { + getCombinedSourcemap?: () => { + version: number + sources: string[] + names: string[] + mappings: string + } environment?: { name: string config: { @@ -154,7 +167,10 @@ function createPlugins( } function createPlugin(options?: Parameters[0]): ViteTestPlugin { - return createPlugins(options)[0] + const plugin = createPlugins(options)[0] as unknown as ViteTestPlugin & { + transform: { handler: ViteTestPlugin['transform'] } + } + return { ...plugin, transform: plugin.transform.handler } } const ROLLUP_META: ConfigHookMeta = { @@ -194,6 +210,8 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let loadConfigSpy: ReturnType +let graphSpy: ReturnType beforeEach(() => { existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) @@ -214,9 +232,25 @@ beforeEach(() => { registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) setPrefixSpy = spyOn(wasm, 'setPrefix').mockReturnValue(undefined) + loadConfigSpy = spyOn(pluginUtils, 'loadDevupConfig').mockImplementation( + async (file: string) => { + if (!fs.existsSync(file)) return {} + return JSON.parse(await fsPromises.readFile(file, 'utf-8')) + }, + ) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const plugin of pluginInstances) plugin.closeBundle() + pluginInstances.clear() existsSyncSpy.mockRestore() mkdirSpy.mockRestore() readFileSpy.mockRestore() @@ -229,10 +263,43 @@ afterEach(() => { registerThemeSpy.mockRestore() setDebugSpy.mockRestore() setPrefixSpy.mockRestore() + loadConfigSpy.mockRestore() + graphSpy.mockRestore() }) describe('devupUIVitePlugin', () => { console.error = mock() + it('remaps compiled MDX extraction failures using the compiler map', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const plugin = DevupUI()[2] as unknown as Pick + + await expect( + plugin.transform.call( + { + getCombinedSourcemap: () => ({ + version: 3, + sources: ['authored.mdx'], + names: [], + mappings: 'AAGE', + }), + }, + 'compiled', + 'page.mdx', + ), + ).rejects.toThrow('authored.mdx:4:3') + }) + + it('propagates non-MDX extraction failures without remapping', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('source failure') + }) + + await expect(createPlugin().transform('code', 'page.ts')).rejects.toThrow( + 'source failure', + ) + }) it('should apply default options', () => { const plugin = createPlugin({}) @@ -529,9 +596,21 @@ describe('devupUIVitePlugin', () => { throw new Error('scan boom') }) - await createPlugin({}).configResolved({ root: '/p' }) - - expect(seedFileMapSpy).not.toHaveBeenCalled() + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const plugin = createPlugin({}) + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledTimes(1) + expect(warning.mock.calls[0]?.[1]).toMatchObject({ + phase: 'seed', + root: '/p', + cause: expect.any(Error), + }) + } finally { + warning.mockRestore() + } }) describe('source collection before seeding', () => { @@ -784,21 +863,23 @@ describe('devupUIVitePlugin', () => { ) }) - it('starts a build from its own options and ends it at closeBundle', async () => { - const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue(undefined) - try { - const first = createPlugin({}) - resetSpy.mockClear() - createPlugin({}) - expect(resetSpy).not.toHaveBeenCalled() - first.closeBundle() - first.closeBundle() - createPlugin({}) - expect(resetSpy).not.toHaveBeenCalled() - } finally { - resetSpy.mockRestore() - } - }) + it('starts a build from its own options and ends it at closeBundle', async () => { + const resetSpy = spyOn(wasm, 'resetBuildState').mockReturnValue( + undefined, + ) + try { + const first = createPlugin({}) + resetSpy.mockClear() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + first.closeBundle() + first.closeBundle() + createPlugin({}) + expect(resetSpy).not.toHaveBeenCalled() + } finally { + resetSpy.mockRestore() + } + }) it.each([ { label: 'per-file css', @@ -1184,8 +1265,8 @@ describe('devupUIVitePlugin', () => { getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -1196,7 +1277,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({ singleCss: options.singleCss }) await plugin.configResolved() if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -1207,7 +1288,7 @@ describe('devupUIVitePlugin', () => { 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1227,7 +1308,7 @@ describe('devupUIVitePlugin', () => { } }) - it('should reset data files when load error', async () => { + it('propagates config load errors without clearing the registered theme', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockReturnValue(true) @@ -1235,10 +1316,10 @@ describe('devupUIVitePlugin', () => { throw new Error('error') }) const plugin = createPlugin({}) - await plugin.configResolved() - expect(registerThemeSpy).toHaveBeenCalledWith({}) + await expect(plugin.configResolved()).rejects.toThrow('error') + expect(registerThemeSpy).not.toHaveBeenCalled() expect(writeFileSpy).toHaveBeenCalledWith( - join('df', '.gitignore'), + resolve('df', '.gitignore'), '*', 'utf-8', ) @@ -1252,7 +1333,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({}) await plugin.watchChange('devup.json') expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1281,7 +1362,7 @@ describe('devupUIVitePlugin', () => { }) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1313,7 +1394,7 @@ describe('devupUIVitePlugin', () => { expect(result).toBeUndefined() expect(writeFileSpy).not.toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), expect.any(String), 'utf-8', ) @@ -1344,7 +1425,7 @@ describe('devupUIVitePlugin', () => { ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1434,7 +1515,10 @@ describe('devupUIVitePlugin', () => { }) const plugin = createPlugin({}) await plugin.watchChange('devup.json') - expect(console.error).toHaveBeenCalledWith(expect.any(Error)) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(resolve('devup.json')), + expect.any(Error), + ) }) it('should load', () => { @@ -1559,7 +1643,7 @@ describe('devupUIVitePlugin', () => { { const plugin = createPlugin({ - cssDir: '', + cssDir: '.', }) expect(plugin.resolveId('devup-ui.css')).toEqual(expect.any(String)) } @@ -1806,7 +1890,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', dir) }), + expect.objectContaining({ srcDir: [resolve('/p', dir)] }), ) }) @@ -1820,7 +1904,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', 'src') }), + expect.objectContaining({ srcDir: [] }), ) }) @@ -1872,11 +1956,13 @@ describe('devupUIVitePlugin atom hoisting', () => { expect(setAtomHoistSpy).toHaveBeenCalledWith(2) }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom setup with root and cause', async () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) + await expect( + runConfigResolved({ atomHoist: 2 }, { root: '/p' }), + ).rejects.toThrow(/atom graph setup failed.*boom/) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index b1d06282..cc42c187 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { beginBuild, buildCanonicalMap, + buildStaticImportGraph, collectNumberedFiles, computeFileReach, createCompatTypes, @@ -15,11 +16,17 @@ import { type CustomShorthands, extractedNeedles, getFileNumByFilename, + GRAPH_SOURCE_FILE_RE, type ImportAliases, loadDevupConfig, + MDX_FILE_RE, mergeImportAliases, planAtomHoist, + remapMdxError, + resolveProjectPaths, + resolveSourceDirs, seedFileNumbers, + SOURCE_FILE_RE, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -41,7 +48,6 @@ import type { EnvironmentModuleNode, ModuleNode, Plugin, - PluginOption, ResolvedConfig, UserConfig, } from 'vite' @@ -55,20 +61,6 @@ import type { */ const DEVUP_CSS_FILE_RE = /^devup-ui(-\d+)?\.css$/ -const SOURCE_DIR_CANDIDATES = ['src', 'app'] - -/** - * Source roots to scan, or an empty list when none of the conventional layouts - * are present. Scanning the project root instead would sweep in config files - * and other never-transformed modules, so callers skip the scan entirely rather - * than guess. - */ -function resolveSourceDirs(root: string): string[] { - return SOURCE_DIR_CANDIDATES.map((dir) => resolve(root, dir)).filter((dir) => - existsSync(dir), - ) -} - /** * Names each devup CSS module after its own file, so every module is emitted * once and shared by all of its importers. @@ -239,6 +231,7 @@ export interface DevupUIPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -258,26 +251,15 @@ export interface DevupUIPluginOptions { async function writeDataFiles( options: Omit, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') // Sequential: writing into cssDir concurrently with its own mkdir loses the // race on a cold start (no `df/`) and fails the build with ENOENT. if (!existsSync(options.cssDir)) { @@ -292,16 +274,17 @@ export function DevupUI({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, extractCss = true, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, -}: Partial = {}): PluginOption { +}: Partial = {}) { // A build starts from its own options: whatever an earlier build in this // process left in the engine (prefix, hoisting, routes, buckets, numbers, // styles) is gone unless another build is still running. @@ -310,6 +293,20 @@ export function DevupUI({ setDebug(debug) setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) + const excludeModules = createNodeModulesExcludeRegex(include) + const pathOptions = { devupFile, distDir, cssDir: configuredCssDir } + let projectRoot = process.cwd() + let cssDir = configuredCssDir ?? join(distDir, 'devup-ui') + let pathsResolved = false + function resolveFallbackPaths() { + if (!pathsResolved) { + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + } + } const cssMap = new Map() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling @@ -317,6 +314,22 @@ export function DevupUI({ let restoreForwardedCss: ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false + let isProduction = false + let seedWarningEmitted = false + const resolvers = new Map>() + function moduleResolver(conditions: readonly string[]) { + const key = JSON.stringify([projectRoot, conditions]) + let resolver = resolvers.get(key) + if (!resolver) { + resolver = createModuleResolver({ + cwd: projectRoot, + conditions, + toId: (path) => path.replaceAll('\\', '/'), + }) + resolvers.set(key, resolver) + } + return resolver + } // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. @@ -329,7 +342,68 @@ export function DevupUI({ writtenCss.set(fileName, css) return stateWriter.write(join(cssDir, fileName), css, 'utf-8') } - const plugin: Plugin = { + const sourceTransform = { + async transform(code, id) { + if (!extractCss) return + resolveFallbackPaths() + const fileName = id.split('?')[0] + if (excludeModules.test(fileName)) return + const environmentConditions = this.environment?.config.resolve.conditions + if (environmentConditions) { + setModuleResolver( + moduleResolver( + ['import', ...environmentConditions].map((condition) => + condition === 'development|production' + ? isProduction + ? 'production' + : 'development' + : condition, + ), + ), + ) + } + let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') + if (!rel.startsWith('./')) rel = `./${rel}` + const { + code: extractedCode, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + fileName, + code, + libPackage, + rel, + singleCss, + true, + false, + importAliases, + ) + } catch (error) { + if (MDX_FILE_RE.test(fileName)) + throw remapMdxError(error, fileName, this.getCombinedSourcemap()) + throw error + } + })() + for (const dependency of dependencies) this.addWatchFile(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) + promises.push(writeCssFile('devup-ui.css', getCss(null, false))) + if (cssFile) { + const fileNum = getFileNumByFilename(cssFile) + const prevCss = cssMap.get(fileNum) + if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) + if (css) promises.push(writeCssFile(basename(cssFile), css)) + } + await Promise.all(promises) + return { code: extractedCode, map } + }, + } satisfies Pick + const plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes @@ -337,105 +411,157 @@ export function DevupUI({ sharedDuringBuild: true, async configResolved(config) { resolvedConfig = config - isServe = config?.command === 'serve' - const projectRoot = config?.root ?? process.cwd() - // Vite ids are POSIX absolute paths - setModuleResolver( - createModuleResolver({ - cwd: projectRoot, - toId: (path) => path.replaceAll('\\', '/'), - }), - ) - const sourceDirs = resolveSourceDirs(projectRoot) - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) - - // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any - // transform so atoms receive global (shared) class names. Composes with - // single-importer collapse: both are keyed by the canonical bucket. Vite - // passes the ABSOLUTE module id to codeExtract, so the graph maps use - // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. - const atomMode = - atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - if (atomMode) { + try { + isServe = config?.command === 'serve' + isProduction = config?.isProduction ?? false + projectRoot = config?.root ?? process.cwd() + ;({ devupFile, distDir, cssDir } = resolveProjectPaths( + projectRoot, + pathOptions, + )) + pathsResolved = true + const conditions = [ + 'import', + ...(config?.build?.ssr + ? (config.ssr?.resolve?.conditions ?? [ + 'module', + 'node', + 'development|production', + ]) + : (config?.resolve?.conditions ?? [ + 'module', + 'browser', + 'development|production', + ])), + ].map((condition) => + condition === 'development|production' + ? config?.isProduction + ? 'production' + : 'development' + : condition, + ) + // Vite ids are POSIX absolute paths + setModuleResolver(moduleResolver(conditions)) + const sourceDirs = resolveSourceDirs(projectRoot, configuredSourceDirs) + const input = + config?.build?.rolldownOptions?.input ?? + config?.build?.rollupOptions?.input ?? + (config?.build?.lib && config.build.lib.entry) + const rawEntries = + typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : input && typeof input === 'object' + ? Object.values(input) + : [] + const entries = rawEntries + .filter((entry): entry is string => typeof entry === 'string') + .filter((entry) => GRAPH_SOURCE_FILE_RE.test(entry)) + .map((entry) => resolve(projectRoot, entry)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((entry) => dirname(entry)), + ]), + ] + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any + // transform so atoms receive global (shared) class names. Composes with + // single-importer collapse: both are keyed by the canonical bucket. Vite + // passes the ABSOLUTE module id to codeExtract, so the graph maps use + // absolute keys (keyBy: 'absolute') to match the engine's bucket keys. + const atomMode = + atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 + if (atomMode) { + try { + const root = projectRoot + // App Router projects keep their sources in `app/`, so a hardcoded + // `src/` made the whole pre-pass a silent no-op for them. + const srcDir = roots + const tsconfigPath = resolve(root, 'tsconfig.json') + const graph = buildStaticImportGraph(roots, tsconfigPath, { + cwd: root, + include, + conditions, + }) + + const canonicalMap = buildCanonicalMap({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + + const fileReach = computeFileReach({ + srcDir, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length > 0 ? entries : undefined, + }) + const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) + if (plan) { + importFileRoutes(plan.reachByBucket) + setAtomHoist(plan.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } catch (cause) { + throw new Error( + `[devup-ui] atom graph setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } try { - const root = projectRoot - // App Router projects keep their sources in `app/`, so a hardcoded - // `src/` made the whole pre-pass a silent no-op for them. - const srcDir = sourceDirs[0] ?? resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - // C: prefer the bundler's real JS entries; fall back to the heuristic - // (files with no importer) when input is html-only / unavailable. - const input = config.build?.rollupOptions?.input - const rawEntries = - typeof input === 'string' - ? [input] - : Array.isArray(input) - ? input - : input && typeof input === 'object' - ? Object.values(input) - : [] - const entries = rawEntries - .filter((e): e is string => typeof e === 'string') - .filter((e) => /\.(tsx|ts|jsx|js|mjs)$/i.test(e)) - .map((e) => resolve(root, e)) - - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - entries: entries.length > 0 ? entries : undefined, - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + // Numbers come from the sorted paths of every file the build can + // extract (source and included packages), not from arrival order. + // Files numbered before keep their numbers, so a later pass in the + // dev server only numbers new files after the existing ones. + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + include, + cwd: projectRoot, + needles: extractedNeedles(libPackage, importAliases), + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: projectRoot, cause }, ) } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). } - } - try { - // Numbers come from the sorted paths of every file the build can - // extract (source and included packages), not from arrival order. - // Files numbered before keep their numbers, so a later pass in the - // dev server only numbers new files after the existing ones. - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: sourceDirs, - include, - cwd: projectRoot, - needles: extractedNeedles(libPackage, importAliases), - }), + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, ) - } catch { - // Best-effort; on failure numbering falls back to arrival order. } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { @@ -466,10 +592,11 @@ export function DevupUI({ apply() { return true }, - closeBundle() { + closeBundle(this: void) { endBuild() }, - async watchChange(id) { + async watchChange(this: void, id) { + resolveFallbackPaths() if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { await writeDataFiles({ @@ -480,13 +607,14 @@ export function DevupUI({ singleCss, }) } catch (error) { - console.error(error) + console.error(`[devup-ui] theme update failed at ${devupFile}`, error) } } }, // Runs once per environment. Vite 6+ ignores `handleHotUpdate` on a plugin // that defines this hook, so the devup.json reload lives here as well. async hotUpdate({ file, modules, timestamp }) { + resolveFallbackPaths() const { environment } = this if (environment.config.consumer === 'server') { // A module runner cannot apply CSS, so Vite answers a sheet change @@ -525,6 +653,7 @@ export function DevupUI({ }, // Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists. async handleHotUpdate({ file, server, modules, timestamp }) { + resolveFallbackPaths() if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) { return } @@ -550,6 +679,7 @@ export function DevupUI({ return [] }, resolveId(id, importer) { + resolveFallbackPaths() const fileName = basename(id).split('?')[0] if ( DEVUP_CSS_FILE_RE.test(fileName) && @@ -580,56 +710,11 @@ export function DevupUI({ } }, enforce: 'pre', - async transform(code, id) { - if (!extractCss) return - - const fileName = id.split('?')[0] - if (!/\.(tsx|ts|js|mjs|jsx)$/i.test(fileName)) return - if (createNodeModulesExcludeRegex(include).test(fileName)) { - return - } - - let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') - if (!rel.startsWith('./')) rel = `./${rel}` - - const { - code: extractedCode, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - // import main css in code - } = codeExtract( - fileName, - code, - libPackage, - rel, - singleCss, - true, - false, - importAliases, - ) - for (const dependency of dependencies) this.addWatchFile(dependency) - const promises: Promise[] = [] - - if (updatedBaseStyle) { - // update base style - promises.push(writeCssFile('devup-ui.css', getCss(null, false))) - } - - if (cssFile) { - const fileNum = getFileNumByFilename(cssFile) - const prevCss = cssMap.get(fileNum) - if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css) - // `css` is only set when this transform added styles to the sheet. - if (css) promises.push(writeCssFile(basename(cssFile), css)) - } - await Promise.all(promises) - return { - code: extractedCode, - map, - } + transform: { + async handler(code, id) { + if (!SOURCE_FILE_RE.test(id.split('?')[0])) return + return sourceTransform.transform.call(this, code, id) + }, }, async generateBundle(_options, bundle) { if (!extractCss) return @@ -666,7 +751,7 @@ export function DevupUI({ } } }, - } + } satisfies Plugin const restorePlugin: Plugin = { name: 'devup-ui:restore-forwarded-css', sharedDuringBuild: true, @@ -679,5 +764,19 @@ export function DevupUI({ }, }, } - return [plugin, restorePlugin] + const mdxPlugin = { + name: 'devup-ui:mdx', + enforce: 'post', + sharedDuringBuild: true, + async transform(code, id) { + if (!MDX_FILE_RE.test(id.split('?')[0])) return + return sourceTransform.transform.call(this, code, id) + }, + } satisfies Plugin + const plugins: [typeof plugin, typeof restorePlugin, typeof mdxPlugin] = [ + plugin, + restorePlugin, + mdxPlugin, + ] + return plugins } From bdbdc9102387a13c2cc5676791f7371bd4a8d3b0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:44:15 +0900 Subject: [PATCH 15/50] fix(webpack-plugin): reuse rooted graphs and bundler conditions Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/core-regression.test.ts | 208 ++++++++++++++ .../src/__tests__/plugin.test.ts | 91 +++--- packages/webpack-plugin/src/plugin.ts | 262 ++++++++++++------ 3 files changed, 433 insertions(+), 128 deletions(-) create mode 100644 packages/webpack-plugin/src/__tests__/core-regression.test.ts diff --git a/packages/webpack-plugin/src/__tests__/core-regression.test.ts b/packages/webpack-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 00000000..da6d90b4 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,208 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import * as pluginUtils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' +import type { Compiler, Configuration } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = landing( + 'next/dist/compiled/webpack/webpack', +) +function webpack(config: Configuration) { + return bundled.webpack({ + ...config, + optimization: { ...config.optimization, minimize: false }, + }) +} + +let root: string +let compiler: Compiler | undefined + +beforeEach(async () => { + wasm.resetBuildState() + root = await mkdtemp( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'webpack-'), + ) + await mkdir(join(root, 'src')) +}) + +afterEach(async () => { + if (compiler) + await new Promise((done, reject) => + compiler?.close((error) => (error ? reject(error) : done())), + ) + compiler = undefined + await rm(root, { recursive: true, force: true }) +}) + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'prewarms modern .%s files relative to compiler.context', + async (extension) => { + await writeFile( + join(root, 'src', `main.${extension}`), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'});", + ) + const plugin = new DevupUIWebpackPlugin({ singleCss: true }) + + compiler = webpack({ + context: root, + mode: 'production', + entry: `./src/main.${extension}`, + plugins: [plugin], + }) + + expect(plugin.options.distDir).toBe(join(root, 'df')) + expect(wasm.getCss(null, false)).toContain('red') + const rules = compiler.options.module.rules + expect(rules).toContainEqual( + expect.objectContaining({ + test: pluginUtils.SOURCE_FILE_RE, + enforce: 'pre', + }), + ) + expect(rules).toContainEqual( + expect.objectContaining({ enforce: 'post', test: /\.mdx$/i }), + ) + }, +) + +it('overwrites empty declarations using the actual compiler context', async () => { + await writeFile( + join(root, 'devup.json'), + JSON.stringify({ theme: { colors: { default: { removed: 'red' } } } }), + ) + const plugin = new DevupUIWebpackPlugin() + compiler = webpack({ context: root, plugins: [plugin] }) + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( + 'removed', + ) + await writeFile(join(root, 'devup.json'), '{}') + + plugin.writeDataFiles() + + expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).not.toContain( + 'removed', + ) +}) + +it.each(['web', 'node'])( + 'reuses one graph covering all roots and the included closure for target %s', + async (target) => { + await mkdir(join(root, 'app')) + await mkdir(join(root, 'custom')) + const library = join(root, 'node_modules', 'foo.bar') + await mkdir(join(library, 'nested'), { recursive: true }) + await writeFile( + join(library, 'package.json'), + JSON.stringify({ + name: 'foo.bar', + exports: { + browser: './browser.mjs', + node: './node.mjs', + default: './fallback.js', + }, + }), + ) + await writeFile( + join(library, 'browser.mjs'), + "export {color} from './nested/colors.mts'", + ) + await writeFile( + join(library, 'nested/colors.mts'), + "export const color = 'red'", + ) + await writeFile(join(library, 'node.mjs'), "export const color = 'blue'") + await writeFile( + join(library, 'fallback.js'), + "export const color = 'green'", + ) + await writeFile( + join(root, 'src/main.mts'), + "import {color} from 'foo.bar'; import {css} from '@devup-ui/react'; export const style = css({bg:color})", + ) + await writeFile(join(root, 'app/page.tsx'), "import '../src/main.mts'") + await writeFile(join(root, 'custom/entry.cts'), "import '../src/main.mts'") + const originalGraph = pluginUtils.buildStaticImportGraph + const captured: { graph?: pluginUtils.StaticImportGraph } = {} + const graph = spyOn( + pluginUtils, + 'buildStaticImportGraph', + ).mockImplementation((...args) => { + captured.graph = originalGraph(...args) + return captured.graph + }) + const canonical = spyOn(pluginUtils, 'buildCanonicalMap') + const reach = spyOn(pluginUtils, 'computeFileReach') + const prewarm = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + target, + entry: { app: './app/page.tsx', custom: './custom/entry.cts' }, + plugins: [ + new DevupUIWebpackPlugin({ + sourceDirs: ['src', 'app', 'custom'], + include: ['foo.bar'], + atomHoist: 2, + singleCss: true, + }), + ], + }) + + expect(graph).toHaveBeenCalledTimes(1) + const shared = captured.graph + expect(shared?.files).toContain( + join(library, target === 'web' ? 'nested/colors.mts' : 'node.mjs'), + ) + expect(shared?.files).not.toContain( + join(library, target === 'web' ? 'node.mjs' : 'nested/colors.mts'), + ) + expect(canonical.mock.calls[0]?.[0].graph).toBe(shared) + expect(reach.mock.calls[0]?.[0].graph).toBe(shared) + expect(prewarm.mock.calls[0]?.[0].graph).toBe(shared) + expect(wasm.getCss(null, false)).toContain( + target === 'web' ? 'red' : 'blue', + ) + } finally { + graph.mockRestore() + canonical.mockRestore() + reach.mockRestore() + prewarm.mockRestore() + } + }, +) + +it('rejects malformed config with its root and original cause', async () => { + await writeFile(join(root, 'devup.json'), '{') + + expect(() => + webpack({ context: root, plugins: [new DevupUIWebpackPlugin()] }), + ).toThrow(join(root, 'devup.json')) +}) + +it('does not parse raw MDX during prewarm', async () => { + await writeFile( + join(root, 'src/page.mdx'), + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + ) + const extract = spyOn(wasm, 'codeExtract') + try { + compiler = webpack({ + context: root, + entry: './src/page.mdx', + plugins: [new DevupUIWebpackPlugin()], + }) + + expect(extract).not.toHaveBeenCalled() + } finally { + extract.mockRestore() + } +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index f0568339..e24d0d87 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -86,6 +86,8 @@ let mkdirSpy: ReturnType let readFileSpy: ReturnType let statSpy: ReturnType let writeFileSpy: ReturnType +let graphSpy: ReturnType +const closeBuilds = new Set<() => void>() beforeEach(() => { codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation( @@ -112,9 +114,19 @@ beforeEach(() => { readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') statSpy = spyOn(fsPromises, 'stat').mockResolvedValue(createStats(0)) writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + graphSpy = spyOn(pluginUtils, 'buildStaticImportGraph').mockReturnValue({ + files: [], + fileSet: new Set(), + staticImports: new Map(), + staticImporters: new Map(), + dynamicImports: new Map(), + dynamicTargets: new Set(), + }) }) afterEach(() => { + for (const closeBuild of closeBuilds) closeBuild() + closeBuilds.clear() codeExtractSpy.mockRestore() getCssSpy.mockRestore() getDefaultThemeSpy.mockRestore() @@ -134,6 +146,7 @@ afterEach(() => { readFileSpy.mockRestore() statSpy.mockRestore() writeFileSpy.mockRestore() + graphSpy.mockRestore() }) function createCompiler(): MockCompiler { @@ -168,7 +181,9 @@ function createCompiler(): MockCompiler { tap: mock(), }, shutdown: { - tap: mock(), + tap: mock((_name: string, closeBuild: () => void) => { + closeBuilds.add(closeBuild) + }), }, }, } @@ -193,7 +208,7 @@ describe('devupUIWebpackPlugin', () => { ) expect(seedSpy).toHaveBeenCalledWith(['src/a.tsx', 'src/b.tsx']) expect(collectSpy.mock.calls[0][0]).toMatchObject({ - roots: [resolve('src')], + roots: [], include: ['@acme/ui'], }) compiler.hooks.shutdown.tap.mock.calls[0][1]() @@ -213,7 +228,7 @@ describe('devupUIWebpackPlugin', () => { expect(new DevupUIWebpackPlugin({}).options).toEqual({ include: [], package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), + cssDir: join('df', 'devup-ui'), devupFile: 'devup.json', distDir: 'df', watch: false, @@ -269,25 +284,20 @@ describe('devupUIWebpackPlugin', () => { 'CustomShadows', 'DevupTheme', ) - if (_options.getThemeInterface) - expect(writeFileSyncSpy).toHaveBeenCalledWith( - join(options.distDir, 'theme.d.ts'), - _options.getThemeInterface, - { - encoding: 'utf-8', - }, - ) - else expect(writeFileSyncSpy).toHaveBeenCalledTimes(options.watch ? 1 : 0) + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(options.distDir, 'theme.d.ts'), + _options.getThemeInterface, + { + encoding: 'utf-8', + }, + ) if (options.watch) expect(writeFileSyncSpy).toHaveBeenCalledWith( join(options.cssDir, 'devup-ui.css'), _options.getCss, ) - else - expect(writeFileSyncSpy).toHaveBeenCalledTimes( - _options.getThemeInterface ? 1 : 0, - ) + else expect(writeFileSyncSpy).toHaveBeenCalledTimes(1) }) }) @@ -322,10 +332,10 @@ describe('devupUIWebpackPlugin', () => { const compiler = createCompiler() await plugin.apply(asCompiler(compiler)) - expect(compiler.options.module.rules.length).toBe(2) + expect(compiler.options.module.rules.length).toBe(3) expect(compiler.options.module.rules[0].exclude).toEqual( - options.include.output, + pluginUtils.createNodeModulesExcludeRegex(options.include.input), ) }) @@ -347,7 +357,8 @@ describe('devupUIWebpackPlugin', () => { watch: true, }) const compiler = createCompiler() - readFileSyncSpy.mockImplementation(() => { + readFileSyncSpy.mockImplementation((file: string) => { + if (file.endsWith('tsconfig.json')) return '{}' throw new Error('error') }) statSpy.mockReturnValue(createStats(1)) @@ -591,14 +602,16 @@ describe('devupUIWebpackPlugin', () => { } compiler.options.context = '/project' plugin.apply(asCompiler(compiler)) - expect(computeReachableFilesSpy).toHaveBeenCalledWith({ - srcDir: resolve(process.cwd(), 'src'), - tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), - entries: [ - resolve('/project', './src/parent'), - resolve('/project', './src/other'), - ], - }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve('/project', 'tsconfig.json'), + entries: [ + resolve('/project', './src/parent'), + resolve('/project', './src/other'), + ], + }), + ) // The loader's resolver is registered before the first extraction, so // imported constants inline as they do in the loader expect(setModuleResolverSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -609,12 +622,12 @@ describe('devupUIWebpackPlugin', () => { importer: string, ) => { path: string } | undefined expect(resolveModule('./plugin.test', import.meta.path)?.path).toBe( - relative(process.cwd(), import.meta.path).replaceAll('\\', '/'), + relative('/project', import.meta.path).replaceAll('\\', '/'), ) setModuleResolverSpy.mockRestore() expect(codeExtractSpy).toHaveBeenCalledTimes(2) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/parent.tsx', + relative('/project', resolve('src/parent.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -624,7 +637,7 @@ describe('devupUIWebpackPlugin', () => { expect.anything(), ) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/child.tsx', + relative('/project', resolve('src/child.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -658,7 +671,7 @@ describe('devupUIWebpackPlugin', () => { expect(codeExtractSpy).not.toHaveBeenCalled() }) - it('swallows pre-warm errors (extraction failure does not break apply)', () => { + it('fails with file and cause when pre-warm extraction fails', () => { buildCanonicalMapSpy.mockReturnValue({ 'src/child.tsx': 'src/parent.tsx', }) @@ -670,8 +683,9 @@ describe('devupUIWebpackPlugin', () => { throw new Error('extract boom') }) const plugin = new DevupUIWebpackPlugin({}) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /prewarm failed.*parent.tsx.*extract boom/, + ) expect(codeExtractSpy).toHaveBeenCalled() }) @@ -719,13 +733,14 @@ describe('devupUIWebpackPlugin', () => { expect(importFileRoutesSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', () => { + it('fails with root and cause when graph setup fails', () => { buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) const plugin = new DevupUIWebpackPlugin({ atomHoist: 2 }) - // apply must still complete without throwing - plugin.apply(asCompiler(createCompiler())) + expect(() => plugin.apply(asCompiler(createCompiler()))).toThrow( + /graph setup failed.*boom/, + ) expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -786,7 +801,9 @@ describe('devupUIWebpackPlugin', () => { it('writes the shared base to disk when it does not hold it yet', () => { getCssSpy.mockReturnValue('base') existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue('base') + readFileSyncSpy.mockImplementation((file: string) => + file.endsWith('tsconfig.json') ? '{}' : 'base', + ) const { plugin, start } = compile() writeFileSyncSpy.mockClear() start() diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 0c5fd308..3e4c8909 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,6 +6,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { beginBuild, buildCanonicalMap, + buildStaticImportGraph, collectNumberedFiles, computeFileReach, computeReachableFiles, @@ -20,7 +21,11 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + resolveProjectPaths, + resolveSourceDirs, seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -57,6 +62,7 @@ export interface DevupUIWebpackPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] /** * Atom-level route-aware hoisting threshold. * @@ -88,23 +94,33 @@ export class DevupUIWebpackPlugin { classMapFile: string fileMapFile: string private importAliases: WasmImportAliases + private excludeModules: RegExp + private seedWarningEmitted = false + private pathOptions: { + readonly devupFile: string + readonly distDir: string + readonly cssDir: string + } constructor({ package: libPackage = '@devup-ui/react', devupFile = 'devup.json', distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir = join(distDir, 'devup-ui'), watch = false, debug = false, include = [], singleCss = false, prefix, shorthands, + sourceDirs, atomHoist, importAliases: userImportAliases, }: Partial = {}) { registerShorthands(shorthands ?? {}) this.importAliases = mergeImportAliases(userImportAliases) + this.excludeModules = createNodeModulesExcludeRegex(include) + this.pathOptions = { devupFile, distDir, cssDir } this.options = { package: libPackage, @@ -117,6 +133,7 @@ export class DevupUIWebpackPlugin { singleCss, prefix, atomHoist, + sourceDirs, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -125,24 +142,17 @@ export class DevupUIWebpackPlugin { } writeDataFiles() { - try { - const config = loadDevupConfigSync(this.options.devupFile) - const theme = config.theme ?? {} + const config = loadDevupConfigSync(this.options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(this.options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(this.options.package), + ) - if (interfaceCode) { - writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { - encoding: 'utf-8', - }) - } - } catch (error) { - console.error(error) - registerTheme({}) - } + writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { + encoding: 'utf-8', + }) if (!existsSync(this.options.cssDir)) mkdirSync(this.options.cssDir, { recursive: true }) if (this.options.watch) @@ -152,31 +162,20 @@ export class DevupUIWebpackPlugin { ) } - /** - * Extract the source files under `src` that `entries` reach into the shared - * WASM sheet, in path order, so that a stylesheet built on its first import - * holds the styles of every one (all collapsed members of a bucket, and the - * shared base), not just those of the modules webpack happened to build - * first. Mirrors the loader's `codeExtract` call (same filename keying + - * options) so re-extraction during compilation is idempotent. Best-effort: - * extraction errors are swallowed so a single bad file never breaks the - * build, and a stylesheet still missing styles is rebuilt by another pass. - */ - private prewarmExtractor(entries: string[]) { - try { - const cwd = process.cwd() - // The same resolver as the loader's, so imported constants and - // stylesheets extract to the classes the loader emits - setModuleResolver( - createModuleResolver({ - toId: (path) => relative(cwd, path).replaceAll('\\', '/'), - }), - ) - for (const file of computeReachableFiles({ - srcDir: resolve(cwd, 'src'), - tsconfigPath: resolve(cwd, 'tsconfig.json'), - entries, - })) { + private prewarmExtractor(options: { + graph: StaticImportGraph + entries: string[] + cwd: string + }) { + const { graph, entries, cwd } = options + for (const file of computeReachableFiles({ + srcDir: graph.files, + tsconfigPath: resolve(cwd, 'tsconfig.json'), + entries, + graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { const relativePath = relative(cwd, file).replaceAll('\\', '/') let relCssDir = relative(dirname(file), this.options.cssDir).replaceAll( '\\', @@ -193,18 +192,77 @@ export class DevupUIWebpackPlugin { true, this.importAliases, ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${cwd}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - } catch { - // Best-effort warm-up; on failure the css-loader still serves whatever - // atoms were extracted, matching pre-fix behavior. } } apply(compiler: Compiler) { + if (compiler.hooks.afterEnvironment) { + compiler.hooks.afterEnvironment.tap('DevupUIWebpackPlugin', () => { + this.setupCompiler(compiler) + }) + } else { + this.setupCompiler(compiler) + } + } + + private setupCompiler(compiler: Compiler) { + const cwd = compiler.context ?? compiler.options.context ?? process.cwd() + const paths = resolveProjectPaths(cwd, this.pathOptions) + this.options = { ...this.options, ...paths } + this.sheetFile = join(paths.distDir, 'sheet.json') + this.classMapFile = join(paths.distDir, 'classMap.json') + this.fileMapFile = join(paths.distDir, 'fileMap.json') + const sourceDirs = resolveSourceDirs(cwd, this.options.sourceDirs) + const resolveOptions = compiler.options.resolve + const baseConditions = resolveOptions?.conditionNames ?? [ + 'webpack', + compiler.options.mode === 'development' ? 'development' : 'production', + 'browser', + ] + const conditions = ( + resolveOptions?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = compiler.options.entry + const entries = + typeof entry === 'function' + ? [] + : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => + requests.map((request) => resolve(cwd, request)), + ) + const roots = [ + ...new Set([...sourceDirs, ...entries.map((file) => dirname(file))]), + ] // A build starts from its own options, not from what an earlier build in // this process left in the engine const endBuild = beginBuild({ resetBuildState }) compiler.hooks.shutdown?.tap('DevupUIWebpackPlugin', endBuild) + try { + setModuleResolver( + createModuleResolver({ + cwd, + conditions, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] resolver setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } setDebug(this.options.debug) setPrefix(this.options.prefix ?? null) const existsDevup = existsSync(this.options.devupFile) @@ -234,7 +292,15 @@ export class DevupUIWebpackPlugin { importFileMap({}) } } - this.writeDataFiles() + try { + this.writeDataFiles() + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] theme setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader // runs codeExtract (apply() body is synchronous, loaders run during @@ -250,15 +316,21 @@ export class DevupUIWebpackPlugin { // merge into that importer's bucket, deduplicating their identical atoms. // The canonical map is built + imported unconditionally; only atom HOISTING // composes on top when `atomHoist` is set. Mirrors next-plugin's pre-pass. + let graph: StaticImportGraph try { - const srcDir = resolve(process.cwd(), 'src') - const tsconfigPath = resolve(process.cwd(), 'tsconfig.json') - const cwd = process.cwd() + const srcDir = roots + const tsconfigPath = resolve(cwd, 'tsconfig.json') + graph = buildStaticImportGraph(roots, tsconfigPath, { + cwd, + include: this.options.include, + conditions, + }) const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, }) importCanonicalMap(canonicalMap) @@ -268,6 +340,8 @@ export class DevupUIWebpackPlugin { tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, + entries: entries.length ? entries : undefined, }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { @@ -279,28 +353,36 @@ export class DevupUIWebpackPlugin { ) } } - } catch { - // Best-effort; on failure canonical() is the identity (no merge) and atom - // hoisting stays off. + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } // Number every file the build can extract in path order, so class // prefixes do not depend on which file a worker reaches first. Numbers // already handed out (a restored map in watch mode) stay. try { - const cwd = process.cwd() seedFileNumbers( { seedFileMap }, collectNumberedFiles({ - roots: [resolve(cwd, 'src')], + roots, include: this.options.include, cwd, needles: extractedNeedles(this.options.package, this.importAliases), toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) - } catch { - // Best-effort; numbering falls back to arrival order. + } catch (cause) { + if (!this.seedWarningEmitted) { + this.seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root: cwd, cause }, + ) + } } // Pre-warm the extractor so the css-loader serves COMPLETE CSS. // @@ -312,14 +394,12 @@ export class DevupUIWebpackPlugin { // then idempotent (set-based atom dedup). Watch mode rebuilds stylesheets // through the files the loaders write instead. if (!this.options.watch) { - const { entry, context = process.cwd() } = compiler.options - this.prewarmExtractor( - typeof entry === 'function' - ? [] - : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => - requests.map((request) => resolve(context, request)), - ), - ) + try { + this.prewarmExtractor({ graph, entries, cwd }) + } catch (cause) { + endBuild() + throw cause + } } if (this.options.watch) { @@ -341,11 +421,11 @@ export class DevupUIWebpackPlugin { compilation.fileDependencies.add(resolve(this.options.devupFile)) }) - compiler.options.plugins.push( - new compiler.webpack.DefinePlugin({ - 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), - }), - ) + const definePlugin = new compiler.webpack.DefinePlugin({ + 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()), + }) + if (compiler.hooks.afterEnvironment) definePlugin.apply(compiler) + else compiler.options.plugins.push(definePlugin) if (!this.options.watch) { // A stylesheet module is built on its first import, which can come before // the modules whose styles it holds are extracted. When one was, compile @@ -419,32 +499,32 @@ export class DevupUIWebpackPlugin { }) } + const exclude = this.excludeModules + const sourceLoader = { + loader: createRequire(import.meta.url).resolve( + '@devup-ui/webpack-plugin/loader', + ), + options: { + package: this.options.package, + cssDir: this.options.cssDir, + sheetFile: this.sheetFile, + classMapFile: this.classMapFile, + fileMapFile: this.fileMapFile, + watch: this.options.watch, + singleCss: this.options.singleCss, + importAliases: this.importAliases, + rootDir: cwd, + conditions, + }, + } compiler.options.module.rules.push( { - test: /\.(tsx|ts|js|mjs|jsx)$/, - exclude: createNodeModulesExcludeRegex( - this.options.include, - '.mdx.[tj]sx?$', - ), + test: SOURCE_FILE_RE, + exclude, enforce: 'pre', - use: [ - { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/loader', - ), - options: { - package: this.options.package, - cssDir: this.options.cssDir, - sheetFile: this.sheetFile, - classMapFile: this.classMapFile, - fileMapFile: this.fileMapFile, - watch: this.options.watch, - singleCss: this.options.singleCss, - importAliases: this.importAliases, - }, - }, - ], + use: [sourceLoader], }, + { test: /\.mdx$/i, exclude, enforce: 'post', use: [sourceLoader] }, { test: this.options.cssDir, enforce: 'pre', From f4674eb0c682ed7b44309a32252bb7fd2155eebb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:49:51 +0900 Subject: [PATCH 16/50] fix(webpack-plugin): isolate loader resolution and map MDX errors Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/loader.test.ts | 121 ++++++++++++++++++ packages/webpack-plugin/src/loader.ts | 51 ++++++-- 2 files changed, 161 insertions(+), 11 deletions(-) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 8697cb09..0aedad58 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -1,7 +1,12 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as fsPromises from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createModuleResolver } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, @@ -125,6 +130,122 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it('keys resolver reuse by the explicit project root and active conditions', async () => { + const root = mkdtempSync( + join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'loader-'), + ) + for (const project of ['first', 'second']) { + const library = join(root, project, 'node_modules', 'conditional') + mkdirSync(library, { recursive: true }) + writeFileSync( + join(library, 'package.json'), + JSON.stringify({ + exports: { browser: './browser.js', node: './node.js' }, + }), + ) + writeFileSync(join(library, 'browser.js'), project + '-browser') + writeFileSync(join(library, 'node.js'), project + '-node') + } + const selected: (string | undefined)[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('conditional', 'src/main.ts')?.code) + }, + ) + try { + for (const [project, condition] of [ + ['first', 'browser'], + ['first', 'node'], + ['second', 'node'], + ]) { + const rootDir = join(root, project) + await new Promise((done) => { + const context = createLoaderContext( + { + rootDir, + conditions: [condition], + cssDir: join(rootDir, 'df/devup-ui'), + }, + () => done(), + join(rootDir, 'src/main.ts'), + ) + devupUILoader.bind(context)(Buffer.from('code')) + }) + } + expect(selected).toEqual(['first-browser', 'first-node', 'second-node']) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('extracts actual compiled MDX while retaining the original resource filename', async () => { + const landing = createRequire( + nodePath.resolve( + import.meta.dir, + '../../../../apps/landing/package.json', + ), + ) + const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href + ) + const rootDir = process.cwd() + const filename = nodePath.resolve(rootDir, 'src/page.mdx') + const compiled = await mdx.compile({ + value: + 'import {Box} from \'@devup-ui/react\'\n\n# Heading\n\n', + path: filename, + }) + codeExtractSpy.mockRestore() + wasm.resetBuildState() + const callback = mock() + const context = createLoaderContext( + { + package: '@devup-ui/react', + cssDir: nodePath.resolve('df/devup-ui'), + rootDir, + singleCss: true, + }, + callback, + filename, + ) + + await new Promise((done) => { + callback.mockImplementation(() => done()) + devupUILoader.bind(context)(Buffer.from(String(compiled))) + }) + + expect(callback.mock.calls[0][0]).toBeNull() + expect(callback.mock.calls[0][1]).not.toContain('bg: "red"') + expect(callback.mock.calls[0][1]).toContain('devup-ui.css') + }) + + it.each([ + undefined, + { version: 3, sources: ['authored.mdx'], names: [], mappings: 'AAGE' }, + ])( + 'locates MDX errors with the supplied compiler map %j', + async (inputMap) => { + const callback = mock() + const rootDir = nodePath.resolve('/project') + const filename = nodePath.join(rootDir, 'page.mdx') + codeExtractSpy.mockImplementation(() => { + throw new Error('page.mdx:1:1: cannot extract') + }) + const context = createLoaderContext( + { rootDir, cssDir: nodePath.join(rootDir, 'df/devup-ui') }, + callback, + filename, + ) + + devupUILoader.bind(context)(Buffer.from('compiled'), inputMap) + + expect(callback.mock.calls[0][0].message).toContain( + inputMap ? 'authored.mdx:4:3' : 'page.mdx:1:1 (in compiled MDX)', + ) + }, + ) it('resolves imports to cwd-relative ids and depends on the modules read', async () => { const setModuleResolverSpy = spyOn( wasm, diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 0c88b873..1c915743 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,11 @@ import { writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' -import { createModuleResolver, createStateWriter } from '@devup-ui/plugin-utils' +import { + createModuleResolver, + createStateWriter, + remapMdxError, +} from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -21,6 +25,8 @@ export interface DevupUILoaderOptions { watch: boolean singleCss: boolean importAliases?: Record + rootDir?: string + conditions?: readonly string[] } function toLoaderError(error: unknown): Error { @@ -37,18 +43,31 @@ function parseSourceMap(sourceMap: string | undefined): string | null { const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) -let moduleResolver: ReturnType | undefined +const moduleResolvers = new Map< + string, + ReturnType +>() /** Resolve imports to the cwd-relative ids this loader extracts files under */ -function setCwdModuleResolver(): void { - moduleResolver ??= createModuleResolver({ - toId: (path) => relative(process.cwd(), path).replaceAll('\\', '/'), - }) +function setCwdModuleResolver( + rootDir: string, + conditions: readonly string[], +): void { + const key = JSON.stringify([rootDir, conditions]) + let moduleResolver = moduleResolvers.get(key) + if (!moduleResolver) { + moduleResolver = createModuleResolver({ + cwd: rootDir, + conditions, + toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), + }) + moduleResolvers.set(key, moduleResolver) + } setModuleResolver(moduleResolver) } const devupUILoader: RawLoaderDefinitionFunction = - function (source) { + function (source, inputSourceMap) { const { watch, package: libPackage, @@ -58,6 +77,8 @@ const devupUILoader: RawLoaderDefinitionFunction = fileMapFile, singleCss, importAliases = {}, + rootDir = process.cwd(), + conditions = ['import', 'module', 'node'], } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -75,10 +96,10 @@ const devupUILoader: RawLoaderDefinitionFunction = // FILE_ROUTES keys (built with forward slashes by plugin-utils). Without // this, single-importer collapse and atom hoisting silently no-op on // Windows. No-op on POSIX. - const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') + const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver() + setCwdModuleResolver(rootDir, conditions) const { code, css = '', @@ -97,7 +118,7 @@ const devupUILoader: RawLoaderDefinitionFunction = importAliases, ) for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) + this.addDependency(resolve(rootDir, dependency)) } const sourceMap = parseSourceMap(map) const promises: Promise[] = [] @@ -133,7 +154,15 @@ const devupUILoader: RawLoaderDefinitionFunction = (error) => callback(toLoaderError(error)), ) } catch (error) { - callback(toLoaderError(error)) + callback( + /\.mdx$/i.test(id) + ? remapMdxError( + error, + relative(rootDir, id).replaceAll('\\', '/'), + inputSourceMap, + ) + : toLoaderError(error), + ) } return } From d2a0aabdb16ebe6b3acbfe3766845a0a28ab8b88 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 18:57:04 +0900 Subject: [PATCH 17/50] fix(rsbuild-plugin): complete rooted setup and shared CSS splitting Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/checkout-isolation.test.ts | 4 + .../src/__tests__/core-integration.test.ts | 290 ++++++++++ .../src/__tests__/plugin.test.ts | 95 ++-- packages/rsbuild-plugin/src/plugin.ts | 499 +++++++++++------- 4 files changed, 658 insertions(+), 230 deletions(-) create mode 100644 packages/rsbuild-plugin/src/__tests__/core-integration.test.ts diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index 7c61a459..9f89b1ce 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -29,6 +29,7 @@ const source = `import { Box } from '@devup-ui/react' const App = () => ` let codeExtractSpy: ReturnType +const closeCallbacks: (() => void)[] = [] beforeAll(() => { spyOn(fs, 'existsSync').mockReturnValue(true) @@ -52,6 +53,7 @@ beforeAll(() => { }) afterAll(() => { + for (const close of closeCallbacks) close() mock.restore() }) @@ -67,6 +69,8 @@ async function extractedCssDirIn(checkout: string) { cssDir: join(checkout, 'df', 'devup-ui'), }) await plugin.setup({ + context: { rootPath: checkout }, + onCloseBuild: mock((close) => closeCallbacks.push(close)), transform, modifyRsbuildConfig: mock(), modifyRspackConfig: mock(), diff --git a/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts new file mode 100644 index 00000000..4d686ffd --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts @@ -0,0 +1,290 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { afterEach, expect, it, mock, spyOn } from 'bun:test' + +import { DevupUI } from '../plugin' + +const base = process.env.DEVUP_PLUGIN_TEST_TMP ?? realpathSync(tmpdir()) +const roots: string[] = [] +const closes: (() => void)[] = [] +afterEach(() => { + for (const close of closes.splice(0)) close() + mock.restore() + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) + wasm.resetBuildState() + wasm.setDebug(true) +}) + +function project() { + mkdirSync(base, { recursive: true }) + const root = realpathSync(mkdtempSync(join(base, 'rsbuild-'))) + roots.push(root) + return root +} +function file(root: string, name: string, contents: string) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path +} +const box = (color: string) => + `import { Box } from '@devup-ui/react'; export default ` + +async function setup( + root: string, + options: Parameters[0] = {}, +) { + const transform = mock() + const modify = mock() + const configure = mock() + const before = mock() + await DevupUI(options).setup({ + context: { rootPath: root }, + transform, + modifyRspackConfig: modify, + modifyRsbuildConfig: configure, + onBeforeBuild: before, + onCloseBuild: mock((close) => closes.push(close)), + }) + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + const apply = (normalized: object) => { + modify.mock.calls[0][0](config, { + environment: { name: 'web', config: { output: { target: 'web' } } }, + }) + config.plugins?.at(-1)?.apply({ + options: normalized, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + } + return { transform, configure, before, apply } +} + +it('writes only beneath the Rsbuild root and clears declarations when the theme is removed', async () => { + const root = project() + file( + root, + 'devup.json', + JSON.stringify({ theme: { colors: { default: { primary: 'red' } } } }), + ) + await setup(root, { extractCss: false, distDir: 'generated' }) + expect(readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8')).toContain( + 'primary', + ) + file(root, 'devup.json', '{}') + await setup(root, { extractCss: false, distDir: 'generated' }) + expect( + readFileSync(join(root, 'generated/theme.d.ts'), 'utf-8'), + ).not.toContain('primary') + expect( + readFileSync(join(root, 'generated/devup-ui/devup-ui.css'), 'utf-8'), + ).not.toContain('--primary') +}) + +it.each(['{', JSON.stringify({ extends: ['./base.json'] })])( + 'fails initial setup for malformed or cyclic real config: %s', + async (contents) => { + const root = project() + file(root, 'devup.json', contents) + file(root, 'base.json', JSON.stringify({ extends: ['./devup.json'] })) + await expect(setup(root)).rejects.toThrow(join(root, 'devup.json')) + }, +) + +it('shares one conditional graph across canonical mapping, reach and reachable prewarm', async () => { + const root = project() + file( + root, + 'src/first.tsx', + `import Page from '../app/second'; import Widget from '../custom/widget'; import value from 'fixture'; export default [Page, Widget, value]`, + ) + file(root, 'app/second.tsx', box('tan')) + file(root, 'custom/widget.tsx', box('blue')) + file(root, 'custom/dead.tsx', 'this is invalid and unreachable') + file( + root, + 'node_modules/fixture/package.json', + JSON.stringify({ + name: 'fixture', + exports: { + browser: './browser.mjs', + node: './node.cjs', + default: './fallback.js', + }, + }), + ) + const browser = file( + root, + 'node_modules/fixture/browser.mjs', + `import { css } from '@devup-ui/react'; export default css({ color: 'green' })`, + ) + file(root, 'node_modules/fixture/node.cjs', `throw new Error('wrong branch')`) + const graph = spyOn(utils, 'buildStaticImportGraph') + const canonical = spyOn(utils, 'buildCanonicalMap') + const reach = spyOn(utils, 'computeFileReach') + const reachable = spyOn(utils, 'computeReachableFiles') + const extract = spyOn(wasm, 'codeExtract') + const api = await setup(root, { + atomHoist: 2, + sourceDirs: 'custom', + include: ['fixture'], + }) + api.apply({ + entry: { + first: { import: ['./src/first.tsx'] }, + second: { import: ['./app/second.tsx'] }, + }, + resolve: { + conditionNames: ['browser'], + byDependency: { esm: { conditionNames: ['import', '...'] } }, + }, + }) + const built = graph.mock.results[0]?.value + expect(graph).toHaveBeenCalledTimes(1) + expect(canonical.mock.calls[0]?.[0].graph).toBe(built) + expect(reach.mock.calls[0]?.[0].graph).toBe(built) + expect(reachable.mock.calls[0]?.[0].graph).toBe(built) + expect(extract.mock.calls.map((call) => call[0])).toContain( + browser.replaceAll('\\', '/'), + ) + expect(extract.mock.calls.map((call) => call[0])).not.toContain( + join(root, 'custom/dead.tsx').replaceAll('\\', '/'), + ) + expect(wasm.getCss(null, false)).toContain('green') +}) + +it.each(['mts', 'cts', 'cjs', 'mjs'])( + 'transforms real modern .%s files', + async (extension) => { + const root = project() + const path = file( + root, + `src/style.${extension}`, + `import { css } from '@devup-ui/react'; export const style = css({ color: 'red' })`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + const result = await api.transform.mock.calls[1][1]({ + code: readFileSync(path, 'utf-8'), + resourcePath: path, + addDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain('css(') + const sheet = result.code.match(/devup-ui(?:-\d+)?\.css/)?.[0] + expect( + wasm.getCss(utils.getFileNumByFilename(sheet ?? ''), true), + ).toContain('red') + }, +) + +it('extracts compiled MDX in the post-loader without prewarming raw markdown', async () => { + const root = project() + const path = file( + root, + 'app/page.mdx', + `# Markdown\n\nimport { Box } from '@devup-ui/react'\n\n`, + ) + const api = await setup(root) + api.apply({ + entry: { main: { import: [path] } }, + resolve: { conditionNames: ['browser'] }, + }) + expect(wasm.getCss(null, false)).not.toContain('red') + expect(api.transform.mock.calls[2][0]).toMatchObject({ order: 'post' }) + const result = await api.transform.mock.calls[2][1]({ + code: box('red'), + resourcePath: path, + addDependency: mock(), + environment: { name: 'web' }, + }) + expect(result.code).not.toContain(' { + const root = project() + const api = await setup(root) + spyOn(wasm, 'codeExtract').mockImplementation(() => { + throw new Error(`${join(root, 'page.mdx')}:3:4: broken`) + }) + await expect( + api.transform.mock.calls[2][1]({ + code: '', + resourcePath: join(root, 'page.mdx'), + addDependency: mock(), + }), + ).rejects.toThrow('in compiled MDX') + await expect( + api.transform.mock.calls[1][1]({ + code: '', + resourcePath: join(root, 'page.tsx'), + addDependency: mock(), + }), + ).rejects.toThrow('broken') +}) + +it('fails prewarm with its real source filename and root', async () => { + const root = project() + const path = file( + root, + 'src/style.ts', + `import { css } from '@devup-ui/react'; export const style = css({ color: Math.random() })`, + ) + const api = await setup(root) + expect(() => api.apply({ entry: { main: { import: [path] } } })).toThrow( + `prewarm failed at ${path}`, + ) +}) + +it('warns once for optional seed failure while preserving requested graph guarantees', async () => { + const root = project() + const api = await setup(root) + spyOn(utils, 'collectNumberedFiles').mockImplementation(() => { + throw new Error('seed failed') + }) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + api.apply({ entry: {} }) + api.apply({ entry: {} }) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ phase: 'seed', root }) +}) + +it('fails rather than guessing when normalized entries are dynamic', async () => { + const root = project() + const api = await setup(root) + expect(() => api.apply({ entry: () => ({}) })).toThrow( + 'dynamic Rspack entries', + ) +}) + +it('preserves user cache groups when shared splitting is composed', async () => { + const root = project() + const api = await setup(root, { atomHoist: 2 }) + const config: { tools?: { rspack?: (config: object) => void } } = {} + api.configure.mock.calls[0][0](config) + const vendor = { name: 'vendor' } + const rspack = { optimization: { splitChunks: { cacheGroups: { vendor } } } } + config.tools?.rspack?.(rspack) + expect(rspack.optimization.splitChunks.cacheGroups).toMatchObject({ + vendor, + devupUiShared: { enforce: true }, + }) +}) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index b674ede7..aa7b0954 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -20,6 +20,10 @@ import { DevupUI } from '../plugin' type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] +const closeCallbacks: (() => void)[] = [] +afterEach(() => { + for (const close of closeCallbacks.splice(0)) close() +}) function createCodeExtractResult( overrides: Partial = {}, @@ -42,8 +46,16 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), - modifyRspackConfig: mock(), + modifyRspackConfig: mock((callback) => { + const config: { plugins?: { apply(compiler: unknown): void }[] } = {} + callback(config, { environment: { name: 'web' } }) + config.plugins?.[0]?.apply({ + options: {}, + hooks: { run: { tap: mock() }, thisCompilation: { tap: mock() } }, + }) + }), onBeforeBuild: mock(), + onCloseBuild: mock((close) => closeCallbacks.push(close)), context: { rootPath: process.cwd() }, renderChunk: mock(), generateBundle: mock(), @@ -67,8 +79,12 @@ let getThemeInterfaceSpy: ReturnType let registerThemeSpy: ReturnType let setDebugSpy: ReturnType let setPrefixSpy: ReturnType +let realpathSpy: ReturnType beforeAll(() => { + realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((path) => + resolve(String(path)), + ) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) @@ -83,6 +99,7 @@ beforeAll(() => { }) afterAll(() => { + realpathSpy.mockRestore() existsSyncSpy.mockRestore() writeFileSyncSpy.mockRestore() mkdirSpy.mockRestore() @@ -154,7 +171,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -174,7 +191,7 @@ describe('DevupUIRsbuildPlugin', () => { readFileSpy.mockResolvedValueOnce(JSON.stringify({})) getThemeInterfaceSpy.mockReturnValue('') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -192,11 +209,9 @@ describe('DevupUIRsbuildPlugin', () => { }) it('should error when write data files', async () => { - const originalConsoleError = console.error - console.error = mock() readFileSpy.mockRejectedValueOnce('error') existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return true + if (path === resolve('devup.json')) return true return false }) const plugin = DevupUI() @@ -204,14 +219,14 @@ describe('DevupUIRsbuildPlugin', () => { expect(plugin.setup).toBeDefined() const transform = mock() const modifyRsbuildConfig = mock() - await plugin.setup( - createSetupContext({ - transform, - modifyRsbuildConfig, - }), - ) - expect(console.error).toHaveBeenCalledWith('error') - console.error = originalConsoleError + await expect( + plugin.setup( + createSetupContext({ + transform, + modifyRsbuildConfig, + }), + ), + ).rejects.toThrow('error') }) it('should not register css transform', async () => { @@ -259,7 +274,7 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -291,7 +306,7 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -344,7 +359,7 @@ const App = () => `, expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -401,8 +416,8 @@ const App = () => `, getThemeInterfaceSpy.mockReturnValue('interface code') getDefaultThemeSpy.mockReturnValue(options.getDefaultTheme) existsSyncSpy.mockImplementation((path: string) => { - if (path === 'devup.json') return options.existsDevupFile - if (path === 'df') return options.existsDistDir + if (path === resolve('devup.json')) return options.existsDevupFile + if (path === resolve('df')) return options.existsDistDir if (path === resolve('df', 'devup-ui')) return options.existsCssDir if (path === join('df', 'sheet.json')) return options.existsSheetFile if (path === join('df', 'classMap.json')) @@ -413,7 +428,7 @@ const App = () => `, const plugin = DevupUI({ singleCss: options.singleCss }) await plugin.setup(createSetupContext()) if (options.existsDevupFile) { - expect(readFileSpy).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(readFileSpy).toHaveBeenCalledWith(resolve('devup.json'), 'utf-8') expect(registerThemeSpy).toHaveBeenCalledWith({}) expect(getThemeInterfaceSpy).toHaveBeenCalledWith( '@devup-ui/react', @@ -424,7 +439,7 @@ const App = () => `, 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -495,6 +510,7 @@ const App = () => `, }) await DevupUI({ include: ['@acme/ui'], + sourceDirs: ['src'], atomHoist: atomMode ? 2 : undefined, }).setup(createSetupContext({ onCloseBuild: closeBuild })) expect(seedSpy).toHaveBeenCalledWith(['/p/a.tsx']) @@ -612,14 +628,19 @@ const App = () => `, expect(setAtomHoistSpy).not.toHaveBeenCalled() }) - it('swallows pre-pass errors (atom hoisting stays off)', async () => { + it('fails requested atom planning when the graph fails', async () => { spies() buildCanonicalMapSpy.mockImplementation(() => { throw new Error('boom') }) - await DevupUI({ atomHoist: 2 }).setup( - createSetupContext({ transform: mock(), modifyRsbuildConfig: mock() }), - ) + await expect( + DevupUI({ atomHoist: 2 }).setup( + createSetupContext({ + transform: mock(), + modifyRsbuildConfig: mock(), + }), + ), + ).rejects.toThrow('boom') expect(setAtomHoistSpy).not.toHaveBeenCalled() }) @@ -711,7 +732,12 @@ const App = () => `, // splitChunks missing/false -> no cacheGroup added, no throw const rspackCfg2 = {} as { optimization?: { splitChunks?: unknown } } inject(rspackCfg2) - expect(rspackCfg2.optimization?.splitChunks).toBeUndefined() + expect(rspackCfg2.optimization?.splitChunks).toMatchObject({ + cacheGroups: { devupUiShared: { type: 'css/mini-extract' } }, + }) + expect(() => inject({ optimization: { splitChunks: false } })).toThrow( + 'splitChunks: false', + ) }) it('composes the cacheGroup with existing tools.rspack (function then array)', async () => { @@ -783,6 +809,7 @@ const App = () => `, taps[hook] = fn } const compiler = { + options: {}, watchMode: false, rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, hooks: { @@ -823,13 +850,13 @@ const App = () => `, }, }, }) - expect(computeReachableFilesSpy).toHaveBeenCalledWith({ - srcDir: resolve(process.cwd(), 'src'), - tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), - entries: ['a', 'b', 'c', 'd'].map((name) => - resolve(process.cwd(), `./src/${name}.tsx`), - ), - }) + expect(computeReachableFilesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + srcDir: [], + tsconfigPath: resolve(process.cwd(), 'tsconfig.json'), + entries: [], + }), + ) expect(codeExtractSpy).toHaveBeenCalledWith( resolve('src', 'App.tsx'), 'source', @@ -847,7 +874,7 @@ const App = () => `, }) expect(() => onBeforeBuild.mock.calls[0][0]({ environments: {} }), - ).not.toThrow() + ).toThrow('prewarm failed') }) it('extracts under posix names in atom mode', async () => { diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index ad2793ec..ce317548 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { beginBuild, buildCanonicalMap, + buildStaticImportGraph, collectNumberedFiles, computeFileReach, computeReachableFiles, @@ -16,11 +17,17 @@ import { type CustomShorthands, extractedNeedles, getFileNumByFilename, + GRAPH_SOURCE_FILE_RE, type ImportAliases, loadDevupConfig, mergeImportAliases, planAtomHoist, + remapMdxError, + resolveProjectPaths, + resolveSourceDirs, seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -53,6 +60,7 @@ export interface DevupUIRsbuildPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for it * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -81,34 +89,19 @@ async function writeDataFiles( 'extractCss' | 'debug' | 'include' >, ) { - try { - const config = await loadDevupConfig(options.devupFile) - const theme = config.theme ?? {} + const config = await loadDevupConfig(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - await writeFile( - join(options.distDir, 'theme.d.ts'), - interfaceCode, - 'utf-8', - ) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - await Promise.all([ - !existsSync(options.cssDir) - ? mkdir(options.cssDir, { recursive: true }) - : Promise.resolve(), - !options.singleCss - ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) - : Promise.resolve(), - ]) + await writeFile(join(options.distDir, 'theme.d.ts'), interfaceCode, 'utf-8') + if (!existsSync(options.cssDir)) + await mkdir(options.cssDir, { recursive: true }) + if (!options.singleCss) + await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) } /** @@ -126,17 +119,20 @@ export const DevupUI = ({ package: libPackage = '@devup-ui/react', extractCss = true, distDir = 'df', - cssDir = resolve(distDir, 'devup-ui'), + cssDir: configuredCssDir, devupFile = 'devup.json', debug = false, singleCss = false, prefix, shorthands, + sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const excludeModules = createNodeModulesExcludeRegex(include) + let seedWarningEmitted = false const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) @@ -144,6 +140,16 @@ export const DevupUI = ({ return { name: PLUGIN_NAME, async setup(api) { + const root = api.context.rootPath + const { + cssDir, + distDir: outputDir, + devupFile: configFile, + } = resolveProjectPaths(root, { + distDir, + devupFile, + cssDir: configuredCssDir, + }) // A build starts from its own options, not from what an earlier build // in this process left in the engine const endBuild = beginBuild({ resetBuildState }) @@ -151,21 +157,29 @@ export const DevupUI = ({ setDebug(debug) setPrefix(prefix ?? null) - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) + try { + if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true }) + await writeFile(join(outputDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(outputDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) - await writeDataFiles({ - package: libPackage, - cssDir, - devupFile, - distDir, - singleCss, - }) + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile: configFile, + distDir: outputDir, + singleCss, + }) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } if (!extractCss) return // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any @@ -176,78 +190,33 @@ export const DevupUI = ({ // POSIX-normalized to match. const atomMode = atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - setModuleResolver( - createModuleResolver({ - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - if (atomMode) { - try { - const root = process.cwd() - const srcDir = resolve(root, 'src') - const tsconfigPath = resolve(root, 'tsconfig.json') - const canonicalMap = buildCanonicalMap({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - importCanonicalMap(canonicalMap) - const fileReach = computeFileReach({ - srcDir, - tsconfigPath, - cwd: root, - keyBy: 'absolute', - }) - const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) - if (plan) { - importFileRoutes(plan.reachByBucket) - setAtomHoist(plan.threshold) - } else { - console.info( - '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', - ) - } - } catch { - // Best-effort; on failure atom hoisting stays off (identity). + const toId = (path: string) => + atomMode ? path.replaceAll('\\', '/') : path + const sourceDirs = [ + ...new Set([ + ...resolveSourceDirs(root, configuredSourceDirs), + ...resolveSourceDirs(root), + ]), + ] + const plans = new Map< + string, + { + graph: StaticImportGraph + roots: string[] + entries: string[] + resolver: ReturnType } - } - - try { - // Number every file the build can extract in path order, so class - // prefixes do not depend on the order modules reach the transform - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: [resolve(process.cwd(), 'src')], - include, - needles: extractedNeedles(libPackage, importAliases), - toId: (path) => (atomMode ? path.replaceAll('\\', '/') : path), - }), - ) - } catch { - // Best-effort; numbering falls back to arrival order. - } - // Extract the source files under `src` that the entries reach, in path - // order, the same way the transform does, so that a stylesheet built on - // its first import already holds the styles of every one. Best-effort: - // a stylesheet still missing styles is rebuilt by another pass. - api.onBeforeBuild(({ environments }) => { - try { - const root = api.context.rootPath - const entries = Object.values(environments).flatMap(({ entry }) => - Object.values(entry).flatMap((value) => - (typeof value === 'object' && !Array.isArray(value) - ? [value.import].flat() - : [value].flat() - ).map((request) => resolve(root, request)), - ), - ) - for (const file of computeReachableFiles({ - srcDir: resolve(root, 'src'), - tsconfigPath: resolve(root, 'tsconfig.json'), - entries, - })) { + >() + const prewarm = (plan: NonNullable>) => { + setModuleResolver(plan.resolver) + for (const file of computeReachableFiles({ + srcDir: plan.roots, + tsconfigPath: resolve(root, 'tsconfig.json'), + entries: plan.entries, + graph: plan.graph, + })) { + if (!SOURCE_FILE_RE.test(file)) continue + try { let extractCssDir = relative(dirname(file), cssDir).replaceAll( '\\', '/', @@ -255,7 +224,7 @@ export const DevupUI = ({ if (!extractCssDir.startsWith('./')) extractCssDir = `./${extractCssDir}` codeExtract( - atomMode ? file.replaceAll('\\', '/') : file, + toId(file), readFileSync(file, 'utf-8'), libPackage, extractCssDir, @@ -264,10 +233,16 @@ export const DevupUI = ({ !atomMode, importAliases, ) + } catch (cause) { + throw new Error( + `[devup-ui] prewarm failed at ${file} (root ${root}): ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - } catch { - // The transform reports the error of the file it cannot extract } + } + api.onBeforeBuild(() => { + for (const plan of plans.values()) prewarm(plan) }) const servedCss = new Map>() @@ -294,9 +269,123 @@ export const DevupUI = ({ // once more: every module is extracted by then. The dev server rebuilds it // through the stylesheet files the transforms write instead. api.modifyRspackConfig((config, { environment }) => { + const initialize = (normalized: Rspack.Compiler['options']) => { + const baseConditions = normalized.resolve?.conditionNames ?? [ + 'webpack', + normalized.mode === 'development' ? 'development' : 'production', + environment.config?.output?.target === 'node' ? 'node' : 'browser', + ] + const conditions = ( + normalized.resolve?.byDependency?.esm?.conditionNames ?? [ + 'import', + 'module', + '...', + ] + ).flatMap((condition) => + condition === '...' ? baseConditions : [condition], + ) + const entry = normalized.entry + if (typeof entry === 'function') + throw new Error( + `[devup-ui] graph setup failed at ${root}: dynamic Rspack entries are not available for prewarm`, + ) + const rawEntries = + typeof entry === 'string' + ? [entry] + : Array.isArray(entry) + ? entry + : Object.values(entry ?? {}).flatMap((value) => + typeof value === 'string' + ? [value] + : Array.isArray(value) + ? value + : (value.import ?? []), + ) + const entries = rawEntries + .filter((file) => GRAPH_SOURCE_FILE_RE.test(file)) + .map((file) => resolve(root, file)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((file) => dirname(file)), + ]), + ] + const tsconfigPath = resolve(root, 'tsconfig.json') + try { + const resolver = createModuleResolver({ + cwd: root, + conditions, + toId, + }) + setModuleResolver(resolver) + const graph = buildStaticImportGraph(roots, tsconfigPath, { + cwd: root, + include, + conditions, + exclude: [basename(outputDir), basename(cssDir)], + }) + const plan = { graph, roots, entries, resolver } + plans.set(environment.name, plan) + if (atomMode) { + const canonicalMap = buildCanonicalMap({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + }) + importCanonicalMap(canonicalMap) + const reach = computeFileReach({ + srcDir: roots, + tsconfigPath, + cwd: root, + keyBy: 'absolute', + graph, + entries: entries.length ? entries : undefined, + }) + const hoist = planAtomHoist(canonicalMap, reach, atomHoist) + if (hoist) { + importFileRoutes(hoist.reachByBucket) + setAtomHoist(hoist.threshold) + } else { + console.info( + '[devup-ui] atomHoist is set but fewer than 2 routes were detected; atom hoisting is a no-op (single-entry/SPA).', + ) + } + } + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + include, + cwd: root, + needles: extractedNeedles(libPackage, importAliases), + toId, + }), + ) + } catch (cause) { + if (!seedWarningEmitted) { + seedWarningEmitted = true + console.warn( + '[devup-ui] deterministic file seeding failed; class IDs now depend on module arrival order', + { phase: 'seed', root, cause }, + ) + } + } + prewarm(plan) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] graph setup failed at ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + } + } config.plugins ??= [] config.plugins.push({ apply(compiler: Rspack.Compiler) { + initialize(compiler.options) let passes = 0 compiler.hooks.run.tap(PLUGIN_NAME, () => { passes = 0 @@ -369,16 +458,20 @@ export const DevupUI = ({ } }) => { rspackConfig.optimization ??= {} - const sc = rspackConfig.optimization.splitChunks - if (sc && typeof sc === 'object') { - sc.cacheGroups ??= {} - sc.cacheGroups['devupUiShared'] = { - type: 'css/mini-extract', - name: 'devup-ui-shared', - test: /[\\/]devup-ui\.css$/, - chunks: 'all', - enforce: true, - } + const splitChunks = rspackConfig.optimization.splitChunks + if (splitChunks === false) + throw new Error( + `[devup-ui] atomHoist requires splitChunks at ${root}; splitChunks: false disables shared CSS`, + ) + const sc = splitChunks ?? {} + rspackConfig.optimization.splitChunks = sc + sc.cacheGroups ??= {} + sc.cacheGroups['devupUiShared'] = { + type: 'css/mini-extract', + name: 'devup-ui-shared', + test: /[\\/]devup-ui\.css$/, + chunks: 'all', + enforce: true, } } config.tools.rspack = Array.isArray(prev) @@ -390,80 +483,94 @@ export const DevupUI = ({ return config }) - api.transform( - { - test: /\.(tsx|ts|js|mjs|jsx)$/, - }, - async ({ code, resourcePath, addDependency }) => { - if (createNodeModulesExcludeRegex(include).test(resourcePath)) - return code - // The stylesheet import is emitted relative to the importing file, as - // in the next/webpack/vite loaders. An absolute cssDir would bake this - // checkout's path into the emitted module, so byte-identical sources - // in two checkouts (git worktrees, a CI matrix, sibling clones) would - // produce different output and any content-addressed or relocated - // build cache would serve the wrong checkout's stylesheet. - // - // Atom mode additionally mirrors vite: the entry CODE imports the - // shared base (import_main_css_in_code=true) so rspack emits - // devup-ui.css once and links it from every entry (hoisted atoms - // shared, not inlined), and the extraction filename is - // POSIX-normalized to match the absolute-keyed canonical map / - // FILE_ROUTES. - let extractCssDir = relative( - dirname(resourcePath), - cssDir, - ).replaceAll('\\', '/') - if (!extractCssDir.startsWith('./')) - extractCssDir = `./${extractCssDir}` - const extractName = atomMode - ? resourcePath.replaceAll('\\', '/') - : resourcePath - const { - code: retCode, - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( - extractName, - code, - libPackage, - extractCssDir, - singleCss, - atomMode, - !atomMode, - importAliases, - ) - for (const dependency of dependencies) addDependency(dependency) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - stateWriter.write( - join(cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ), + const extract: Parameters[1] = async ({ + code, + resourcePath, + addDependency, + environment, + }) => { + if (excludeModules.test(resourcePath)) return code + const plan = plans.get(environment?.name) + if (plan) setModuleResolver(plan.resolver) + // The stylesheet import is emitted relative to the importing file, as + // in the next/webpack/vite loaders. An absolute cssDir would bake this + // checkout's path into the emitted module, so byte-identical sources + // in two checkouts (git worktrees, a CI matrix, sibling clones) would + // produce different output and any content-addressed or relocated + // build cache would serve the wrong checkout's stylesheet. + // + // Atom mode additionally mirrors vite: the entry CODE imports the + // shared base (import_main_css_in_code=true) so rspack emits + // devup-ui.css once and links it from every entry (hoisted atoms + // shared, not inlined), and the extraction filename is + // POSIX-normalized to match the absolute-keyed canonical map / + // FILE_ROUTES. + let extractCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + const extractName = atomMode + ? resourcePath.replaceAll('\\', '/') + : resourcePath + const { + code: retCode, + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return codeExtract( + extractName, + code, + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, ) + } catch (error) { + if (/\.mdx$/i.test(resourcePath)) + throw remapMdxError(error, resourcePath) + throw error } + })() + for (const dependency of dependencies) addDependency(dependency) + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style + promises.push( + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), + ) + } - if (cssFile) { - promises.push( - stateWriter.write( - join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, - 'utf-8', - ), - ) - } - await Promise.all(promises) - return { - code: retCode, - map, - } - }, - ) + if (cssFile) { + promises.push( + stateWriter.write( + join(cssDir, basename(cssFile)), + `/* ${resourcePath} ${Date.now()} */`, + 'utf-8', + ), + ) + } + await Promise.all(promises) + return { + code: retCode, + map, + } + } + api.transform({ test: SOURCE_FILE_RE }, extract) + // Rsbuild's order: post installs an enforce: post Rspack loader. Its + // transform context does not expose the incoming map, so located errors + // are explicitly labelled as compiled MDX rather than claiming raw lines. + api.transform({ test: /\.mdx$/i, order: 'post' }, extract) }, } } From 2755f020042c86dc102f16abd5cb21e68d17951e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:05:09 +0900 Subject: [PATCH 18/50] fix(bun-plugin): preserve source dependency edges and module loaders Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../bun-plugin/src/__tests__/source.test.ts | 105 ++++++++++++++++++ packages/bun-plugin/src/source.ts | 69 ++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 packages/bun-plugin/src/__tests__/source.test.ts create mode 100644 packages/bun-plugin/src/source.ts diff --git a/packages/bun-plugin/src/__tests__/source.test.ts b/packages/bun-plugin/src/__tests__/source.test.ts new file mode 100644 index 00000000..ef0c8a3c --- /dev/null +++ b/packages/bun-plugin/src/__tests__/source.test.ts @@ -0,0 +1,105 @@ +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { + importsCompiledPackage, + mentionsCompiledPackage, + preserveDependencies, + runtimeSourceFilter, + sourceLoader, +} from '../source' + +it.each(['ts', 'mts', 'cts', 'tsx', 'js', 'mjs', 'cjs', 'jsx'])( + 'chooses the Bun loader when the source extension is %s', + (extension) => { + const filename = `source.${extension.toUpperCase()}` + const expected = extension.endsWith('tsx') + ? 'tsx' + : extension.endsWith('ts') + ? 'ts' + : extension === 'jsx' + ? 'jsx' + : 'js' + const loader = sourceLoader(filename) + expect(loader).toBe(expected) + }, +) + +it.each(['@devup-ui/react', '@emotion/react/jsx-runtime', '@stylexjs/stylex'])( + 'recognizes a real import when its package is %s', + (name) => { + const source = `import * as styles from '${name}'; export { styles }` + const found = importsCompiledPackage(source, 'js') + expect(found).toBe(true) + }, +) + +it.each(['js', 'jsx', 'ts', 'tsx'] as const)( + 'ignores text-only package mentions when using %s', + (loader) => { + const source = '// @devup-ui/react\nexport const value = 1' + const found = importsCompiledPackage(source, loader) + expect(found).toBe(false) + }, +) + +it('leaves syntax errors to Bun when the scanner rejects source', () => { + const found = importsCompiledPackage('import {', 'ts') + expect(found).toBe(false) +}) + +it('propagates unexpected scanner failures rather than treating them as syntax errors', () => { + const failure = Symbol('scanner failure') + const scanner = spyOn( + Bun.Transpiler.prototype, + 'scanImports', + ).mockImplementation(() => { + throw failure + }) + let received: unknown + try { + importsCompiledPackage('export const value = 1', 'ts') + } catch (cause) { + if (cause !== failure) throw cause + received = cause + } finally { + scanner.mockRestore() + } + expect(received).toBe(failure) +}) + +it('distinguishes styling MDX when a compiler is missing', () => { + const results = ['# hello', "import { Box } from '@devup-ui/react'"].map( + mentionsCompiledPackage, + ) + expect(results).toEqual([false, true]) +}) + +it('preserves unique dependency edges when extraction erased the imports', () => { + const filename = join(process.cwd(), 'src', 'entry.ts') + const dependency = join(process.cwd(), 'tokens.ts') + const code = preserveDependencies('export const cls = "a"', filename, [ + dependency, + dependency, + join(process.cwd(), 'src', 'local.ts'), + ]) + expect( + new Bun.Transpiler({ loader: 'ts' }) + .scanImports(code) + .map(({ path }) => path), + ).toEqual(['../tokens.ts', './local.ts']) +}) + +it('retains source when no dependencies were extracted', () => { + const code = preserveDependencies('export const value = 1', 'entry.ts', []) + expect(code).toBe('export const value = 1') +}) + +it('claims only candidate files when building a runtime filter', () => { + const filter = runtimeSourceFilter(['C:\\source\\foo.bar.ts']) + expect(filter.test('C:\\source\\foo.bar.ts')).toBe(true) + expect(filter.test('C:/source/foo.bar.ts')).toBe(true) + expect(filter.test('C:/source/fooXbar.ts')).toBe(false) + expect(runtimeSourceFilter([]).test('anything.ts')).toBe(false) +}) diff --git a/packages/bun-plugin/src/source.ts b/packages/bun-plugin/src/source.ts new file mode 100644 index 00000000..caf35e7a --- /dev/null +++ b/packages/bun-plugin/src/source.ts @@ -0,0 +1,69 @@ +import { dirname, relative } from 'node:path' + +import { mergeImportAliases } from '@devup-ui/plugin-utils' + +export type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' +export const importAliases = mergeImportAliases() +export const libPackage = '@devup-ui/react' +export const compiledPackages = [ + libPackage, + '@stylexjs/stylex', + ...Object.keys(importAliases), +] +const scanners = new Map() + +export function sourceLoader(filename: string): SourceLoader { + if (/\.tsx$/i.test(filename)) return 'tsx' + if (/\.[mc]?ts$/i.test(filename)) return 'ts' + if (/\.jsx$/i.test(filename)) return 'jsx' + return 'js' +} + +export function mentionsCompiledPackage(contents: string): boolean { + return compiledPackages.some((name) => contents.includes(name)) +} + +/** The scanner avoids extracting package names that only appear in comments. */ +export function importsCompiledPackage( + contents: string, + loader: SourceLoader, +): boolean { + let scanner = scanners.get(loader) + if (!scanner) { + scanner = new Bun.Transpiler({ loader }) + scanners.set(loader, scanner) + } + try { + return scanner + .scanImports(contents) + .some(({ path }) => + compiledPackages.some( + (name) => path === name || path.startsWith(`${name}/`), + ), + ) + } catch (cause) { + // Leave invalid source to Bun's own located syntax diagnostic. + if (cause instanceof Error) return false + throw cause + } +} + +/** Keep erased build-time imports in Bun's module graph, including watch mode. */ +export function preserveDependencies( + code: string, + filename: string, + dependencies: readonly string[], +): string { + const imports = [...new Set(dependencies)].map((dependency) => { + const path = relative(dirname(filename), dependency).replaceAll('\\', '/') + return `import ${JSON.stringify(path.startsWith('.') ? path : `./${path}`)};` + }) + return [...imports, code].join('\n') +} + +export function runtimeSourceFilter(files: readonly string[]): RegExp { + const pattern = files.length + ? `^(?:${files.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('\\\\', '[\\\\/]')).join('|')})$` + : '$^' + return new RegExp(pattern) +} From 951d8581c4b71f709774e189f9bb9bf5e5ab3e88 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:13:42 +0900 Subject: [PATCH 19/50] fix(bun-plugin): compile project MDX before extraction Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/bun-plugin/src/__tests__/mdx.test.ts | 78 +++++++++++++++++++ packages/bun-plugin/src/mdx.ts | 61 +++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/bun-plugin/src/__tests__/mdx.test.ts create mode 100644 packages/bun-plugin/src/mdx.ts diff --git a/packages/bun-plugin/src/__tests__/mdx.test.ts b/packages/bun-plugin/src/__tests__/mdx.test.ts new file mode 100644 index 00000000..9da06fe8 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/mdx.test.ts @@ -0,0 +1,78 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compileMdx } from '../mdx' + +const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun') +mkdirSync(scratch, { recursive: true }) + +it('compiles original MDX when the project has the real compiler', async () => { + const root = mkdtempSync(join(scratch, 'mdx-')) + try { + const host = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const compilerPath = createRequire(host.resolve('@mdx-js/loader')).resolve( + '@mdx-js/mdx', + ) + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + const compiled = await compileMdx( + root, + join(root, 'page.mdx'), + '# title\n\n
', + ) + expect(compiled?.value).toContain('
') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 20000) + +it.each(['unrelated', 'styling', 'invalid compiler'])( + 'handles compiler boundaries when the project has %s MDX', + async (scenario) => { + const root = mkdtempSync(join(scratch, 'mdx-')) + try { + const pkg = join(root, 'node_modules/@mdx-js/mdx') + mkdirSync(pkg, { recursive: true }) + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ + type: 'module', + exports: + scenario === 'invalid compiler' ? './index.js' : './missing.js', + }), + ) + writeFileSync(join(pkg, 'index.js'), 'export const unrelated = true') + const action = () => + compileMdx( + root, + join(root, 'page.mdx'), + scenario === 'styling' + ? "import { Box } from '@devup-ui/react'" + : '# title', + ) + if (scenario === 'unrelated') expect(await action()).toBeUndefined() + else + await expect(action()).rejects.toThrow( + scenario === 'styling' ? 'page.mdx:1:1' : 'compile()', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, +) diff --git a/packages/bun-plugin/src/mdx.ts b/packages/bun-plugin/src/mdx.ts new file mode 100644 index 00000000..da3d6ba5 --- /dev/null +++ b/packages/bun-plugin/src/mdx.ts @@ -0,0 +1,61 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { mentionsCompiledPackage } from './source' + +type CompiledMdx = { readonly value: string; readonly map?: unknown } +type Compiler = { + readonly compile: ( + input: { readonly value: string; readonly path: string }, + options: { readonly jsx: true }, + ) => Promise +} + +export class MdxCompilerError extends Error { + constructor(filename: string, cause: unknown) { + super( + `${filename}:1:1: MDX using Devup UI requires @mdx-js/mdx; install @mdx-js/mdx in the project`, + { cause }, + ) + this.name = 'MdxCompilerError' + } +} + +function isCompiler(value: unknown): value is Compiler { + return ( + typeof value === 'object' && + value !== null && + 'compile' in value && + typeof value.compile === 'function' + ) +} + +export async function compileMdx( + root: string, + filename: string, + contents: string, +): Promise { + const require = createRequire(join(root, 'package.json')) + let compilerPath: string + try { + compilerPath = require.resolve('@mdx-js/mdx') + } catch (cause) { + if ( + !(cause instanceof Error) || + !('code' in cause) || + cause.code !== 'MODULE_NOT_FOUND' + ) + throw cause + if (mentionsCompiledPackage(contents)) + throw new MdxCompilerError(filename, cause) + return undefined + } + // Host resolution is deliberate: the optional project compiler is not bundled. + const compiler: unknown = await import(pathToFileURL(compilerPath).href) + if (!isCompiler(compiler)) + throw new TypeError( + `${filename}:1:1: @mdx-js/mdx does not export compile()`, + ) + return compiler.compile({ value: contents, path: filename }, { jsx: true }) +} From 3e6d1bcec16d9f5772a92e8922830d34d0940c82 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:21:30 +0900 Subject: [PATCH 20/50] fix(bun-plugin): align runtime roots conditions and debug defaults Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bun.preload.ts | 3 + bunfig.toml | 2 +- packages/bun-plugin/README.md | 34 ++- .../__regression__/conditional.bun.ts | 186 +++++++++++++ .../bun-plugin/__regression__/modern.bun.ts | 211 +++++++++++++++ .../bun-plugin/__regression__/watch.bun.ts | 76 ++++++ packages/bun-plugin/src/plugin.ts | 247 +++++++++++------- 7 files changed, 666 insertions(+), 93 deletions(-) create mode 100644 bun.preload.ts create mode 100644 packages/bun-plugin/__regression__/conditional.bun.ts create mode 100644 packages/bun-plugin/__regression__/modern.bun.ts create mode 100644 packages/bun-plugin/__regression__/watch.bun.ts diff --git a/bun.preload.ts b/bun.preload.ts new file mode 100644 index 00000000..4d90997b --- /dev/null +++ b/bun.preload.ts @@ -0,0 +1,3 @@ +import { register } from './packages/bun-plugin/src/register' + +await register({ debug: true }) diff --git a/bunfig.toml b/bunfig.toml index 1241b184..7b8f91a1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,6 @@ [test] root = "packages" -preload = ["./packages/bun-plugin/src/index.ts", "./bun.setup.ts", "bun-test-env-dom"] +preload = ["./bun.preload.ts", "./bun.setup.ts", "bun-test-env-dom"] coverage = true coverageReporter = ["text", "lcov"] coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 } diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index 289863c3..8563cdbf 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -57,8 +57,38 @@ module in the bundle. Imports of the packages Devup UI takes the place of (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`) and of `@stylexjs/stylex` are compiled too. -Class names are short in `Bun.build` and readable under the runtime; pass -`debug` to choose. +Class names are short by default in both `Bun.build` and the runtime. Pass +`debug: true` for readable names (including snapshot tests). + +The plugin transforms loaded `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, +`.mjs`, and `.cjs` modules, including libraries that import Devup UI, its +compatibility aliases, or StyleX. Unlike the other bundler plugins, Bun does +not require an `include` list when bundling loaded uncompiled libraries. +Under the runtime, use `include: ['your-library']` for uncompiled dependencies; +`@devup-ui/components` is included automatically. Runtime candidates are +discovered at setup so ordinary token/CommonJS modules retain Bun's native +watch/loading behavior; restart Bun after adding a new styling module or +changing `include`. Precompiled libraries must still publish their extracted +CSS for consumers to import. + +Raw `.mdx` files are compiled asynchronously using the project's optional +`@mdx-js/mdx` installation before extraction. Install that compiler in the +project when authoring Devup UI/compatibility/StyleX code in MDX. Without a +compiler, unrelated MDX is left to another plugin; styling MDX fails with a +located installation diagnostic. Extractor errors use the compiler's source +map when available, otherwise their locations are labeled `in compiled MDX`. + +`root` defaults to `Bun.build`'s root, or the runtime's working directory. +Relative `devupFile` (default `devup.json`), `distDir` (default `df`) and +`sourceDirs` resolve against that root. Default numbering roots are the existing +`src` and `app` directories; use `sourceDirs` for other layouts. Malformed or +cyclic theme configuration stops initialization with a file-located error. +Optional file-number seeding failures emit one warning including the cause. + +Build-time imported modules remain explicit side-effect imports in Bun's graph +so runtime watch mode can reload their importers. `Bun.build` takes CSS only +after extraction completes. Runtime imports synchronously publish changed CSS +to disk; identical revisions skip writes, without delaying read-after-import. ## Custom Shorthands diff --git a/packages/bun-plugin/__regression__/conditional.bun.ts b/packages/bun-plugin/__regression__/conditional.bun.ts new file mode 100644 index 00000000..77e135f4 --- /dev/null +++ b/packages/bun-plugin/__regression__/conditional.bun.ts @@ -0,0 +1,186 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', +) +const wasm = resolve( + import.meta.dir, + '../../../bindings/devup-ui-wasm/pkg/index.js', +).replaceAll('\\', '/') +const scratch = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'bun-conditions', +) +mkdirSync(scratch, { recursive: true }) + +function run(script: string) { + const root = realpathSync.native(mkdtempSync(join(scratch, 'conditional-'))) + try { + // Given: declaration order makes accidentally enabling require observable. + const branches = { + require: '811px', + custom: '821px', + browser: '823px', + bun: '827px', + node: '829px', + import: '839px', + module: '853px', + default: '857px', + } + const exports = Object.fromEntries( + Object.keys(branches).map((name) => [name, `./${name}.js`]), + ) + const files = { + 'bunfig.toml': '', + 'package.json': '{"type":"module"}', + 'wrong.js': "export const width = '863px'", + 'node_modules/conditional/package.json': JSON.stringify({ + type: 'module', + exports: { + '.': exports, + './node-first': { + node: './node.js', + bun: './bun.js', + default: './default.js', + }, + './import-only': { + require: './require.js', + import: './import.js', + default: './default.js', + }, + './module-first': { + module: './module.js', + import: './import.js', + default: './default.js', + }, + './fallback': { default: './default.js' }, + }, + }), + ...Object.fromEntries( + Object.entries(branches).map(([name, width]) => [ + `node_modules/conditional/${name}.js`, + `export const width = '${width}'`, + ]), + ), + } + for (const [name, contents] of Object.entries(files)) { + mkdirSync(resolve(root, name, '..'), { recursive: true }) + writeFileSync(join(root, name), contents) + } + writeFileSync( + join(root, 'check.ts'), + `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)}; import { setModuleResolver } from ${JSON.stringify(wasm)};\n${script}`, + ) + const result = Bun.spawnSync([process.execPath, 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect({ + exitCode: result.exitCode, + output: result.stdout.toString() + result.stderr.toString(), + }).toEqual({ exitCode: 0, output: '' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +const builds = [ + { config: {}, specifier: 'conditional', width: '823px' }, + { config: { target: 'browser' }, specifier: 'conditional', width: '823px' }, + { config: { target: 'bun' }, specifier: 'conditional', width: '827px' }, + { config: { target: 'node' }, specifier: 'conditional', width: '829px' }, + { + config: { conditions: ['custom'] }, + specifier: 'conditional', + width: '821px', + }, + { + config: { target: 'node', conditions: 'custom' }, + specifier: 'conditional', + width: '821px', + }, + { + config: { target: 'bun' }, + specifier: 'conditional/node-first', + width: '829px', + }, + { config: {}, specifier: 'conditional/import-only', width: '839px' }, + { config: {}, specifier: 'conditional/module-first', width: '853px' }, + { + config: { conditions: [] }, + specifier: 'conditional/module-first', + width: '853px', + }, + { + config: { conditions: ['custom'] }, + specifier: 'conditional/module-first', + width: '853px', + }, + { config: {}, specifier: 'conditional/fallback', width: '857px' }, +] as const + +it.each([...builds])( + 'matches real Bun.build export selection for $specifier with $config', + ({ config, specifier, width }) => { + run(` + const config = ${JSON.stringify(config)}; + await Bun.write('oracle.ts', "export { width } from '${specifier}'"); + const oracle = await Bun.build({ ...config, entrypoints: ['./oracle.ts'] }); + expect(oracle.success, String(oracle.logs)).toBe(true); + const js = oracle.outputs.find(output => output.path.endsWith('.js')); + const actual = await import('data:text/javascript;base64,' + Buffer.from(await js.text()).toString('base64')); + expect(actual.width).toBe('${width}'); + // Vanilla Extract evaluates imports through its CommonJS evaluator, but + // these requests originated as ESM imports, not source require() calls. + await Bun.write('styled.ts', "import { style } from '@vanilla-extract/css'; import { width } from '${specifier}'; export const cls = style({ width });"); + // When: a later engine user replaces the shared resolver after setup. + const result = await Bun.build({ ...config, entrypoints: ['./styled.ts'], plugins: [DevupUI(), { name: 'other-engine', setup() { setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" })); } }] }); + expect(result.success, String(result.logs)).toBe(true); + // Then: extracted compile-time CSS agrees with the actual bundler branch. + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain(actual.width); + expect(await css.text()).not.toContain('863px'); + `) + }, +) + +it.each([ + 'conditional', + 'conditional/node-first', + 'conditional/import-only', + 'conditional/module-first', +])( + 'matches native runtime imports after a foreign resolver override for %s', + (specifier) => { + run(` + // Given: Bun runtime has bun/node conditions; the fixture also has browser. + const actual = await import('${specifier}'); + expect(require('conditional/import-only').width).toBe('811px'); + await Bun.write('styled.ts', "import { css } from '@devup-ui/react'; import { width } from '${specifier}'; export const cls = css({ width });"); + await register(); + setModuleResolver(() => ({ path: process.cwd() + '/wrong.js', code: "export const width = '863px'" })); + // When: the styling module is imported through the real runtime plugin. + const { cls } = await import('./styled.ts'); + // Then: synchronous disk CSS agrees with native resolution, not the override. + expect(cls).toBeTruthy(); + const css = await Bun.file('df/devup-ui/devup-ui.css').text(); + expect(css).toContain(actual.width); + expect(css).not.toContain('863px'); + `) + }, +) diff --git a/packages/bun-plugin/__regression__/modern.bun.ts b/packages/bun-plugin/__regression__/modern.bun.ts new file mode 100644 index 00000000..4d6f65f7 --- /dev/null +++ b/packages/bun-plugin/__regression__/modern.bun.ts @@ -0,0 +1,211 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const entry = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', +) +const scratch = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core', 'bun') +mkdirSync(scratch, { recursive: true }) + +function run( + script: string, + files: Readonly>, + compiler = false, +) { + const root = realpathSync.native(mkdtempSync(join(scratch, 'modern-'))) + try { + writeFileSync(join(root, 'bunfig.toml'), '') + if (compiler) { + const host = createRequire( + resolve(import.meta.dir, '../../../apps/landing/package.json'), + ) + const compilerPath = createRequire( + host.resolve('@mdx-js/loader'), + ).resolve('@mdx-js/mdx') + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + } + for (const [name, contents] of Object.entries(files)) { + mkdirSync(resolve(root, name, '..'), { recursive: true }) + writeFileSync(join(root, name), contents) + } + writeFileSync( + join(root, 'check.ts'), + `import { expect } from 'bun:test'; import { DevupUI, register } from ${JSON.stringify(entry)};\n${script}`, + ) + const result = Bun.spawnSync([process.execPath, 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + expect( + result.exitCode, + result.stdout.toString() + result.stderr.toString(), + ).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs'])( + 'extracts real runtime and bundled source when its extension is %s', + (extension) => { + run( + ` + await register(); + const { cls } = await import('./local.${extension}'); + expect(cls).toBeTruthy(); + const disk = await Bun.file('df/devup-ui/devup-ui.css').text(); + expect(disk).toContain('width:719px'); + expect(cls).not.toContain('width'); + const result = await Bun.build({ entrypoints: ['./local.${extension}'], plugins: [DevupUI()] }); + expect(result.success, String(result.logs)).toBe(true); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('719px'); + `, + { + [`local.${extension}`]: + "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })", + }, + ) + }, +) + +it('uses project paths when invoked from a parent directory', () => { + run( + ` + const result = await Bun.build({ root: './project', entrypoints: ['./project/app/page.ts'], plugins: [DevupUI({ distDir: 'generated' })] }); + expect(result.success, String(result.logs)).toBe(true); + expect(await Bun.file('project/generated/theme.d.ts').exists()).toBe(true); + expect(await Bun.file('generated/theme.d.ts').exists()).toBe(false); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('#abcdef'); + `, + { + 'project/devup.json': + '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}', + 'project/app/page.ts': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '731px' })", + }, + ) +}) + +it.each(['{', '{"extends":["./devup.json"]}'])( + 'fails initialization with a located diagnostic when configuration is invalid: %s', + (config) => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./entry.ts'], plugins: [DevupUI()] }); } catch (cause) { failure = String(cause); } + expect(failure).toContain('devup.json'); + `, + { 'devup.json': config, 'entry.ts': 'export const value = 1' }, + ) + }, +) + +it('reports styling MDX without a compiler instead of extracting raw syntax', () => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./page.mdx'], plugins: [DevupUI()] }); } catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); } + expect(failure).toContain('page.mdx:1:1'); + expect(failure).toContain('@mdx-js/mdx'); + `, + { + 'node_modules/@mdx-js/mdx/package.json': '{"exports":"./missing.js"}', + 'page.mdx': + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + }, + ) +}) + +it('compiles real MDX before extraction using the project compiler', () => { + run( + ` + const result = await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] }); + expect(result.success, String(result.logs)).toBe(true); + const css = result.outputs.find(output => output.path.endsWith('.css')); + expect(await css.text()).toContain('743px'); + const js = result.outputs.find(output => output.path.endsWith('.js')); + const code = await js.text(); + expect(code).toContain('heading'); + expect(code).not.toContain('@devup-ui/react'); + `, + { + 'page.mdx': + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + }, + true, + ) +}) + +it('labels compiled MDX locations when extraction fails without a compiler source map', () => { + run( + ` + let failure; + try { await Bun.build({ entrypoints: ['./page.mdx'], external: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'], plugins: [DevupUI()] }); } + catch (cause) { failure = cause instanceof AggregateError ? cause.errors.map(String).join('\\n') : String(cause); } + expect(failure).toContain('page.mdx:'); + expect(failure).toContain('(in compiled MDX)'); + `, + { + 'page.mdx': + "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading", + }, + true, + ) +}) + +it('restores explicitly configured readable names when another engine user changes debug', () => { + const wasm = resolve( + import.meta.dir, + '../../../bindings/devup-ui-wasm/pkg/index.js', + ).replaceAll('\\', '/') + run( + ` + await register({ debug: true }); + const { setDebug } = await import(${JSON.stringify(wasm)}); + setDebug(false); + const { cls } = await import('./local.ts'); + expect(cls).toContain('width'); + `, + { + 'local.ts': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '761px' })", + }, + ) +}) + +it('transforms an included uncompiled runtime library', () => { + run( + ` + await register({ include: ['library'] }); + const { cls } = await import('library'); + expect(cls).toBeTruthy(); + expect(await Bun.file('df/devup-ui/devup-ui.css').text()).toContain('width:769px'); + `, + { + 'node_modules/library/package.json': + '{"type":"module","exports":"./index.mjs"}', + 'node_modules/library/index.mjs': + "import { css } from '@devup-ui/react'; export const cls = css({ width: '769px' })", + }, + ) +}) diff --git a/packages/bun-plugin/__regression__/watch.bun.ts b/packages/bun-plugin/__regression__/watch.bun.ts new file mode 100644 index 00000000..d3a4fb4e --- /dev/null +++ b/packages/bun-plugin/__regression__/watch.bun.ts @@ -0,0 +1,76 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it('reextracts an importer when only its build-time token changes under normal Bun watch caching', async () => { + const scratch = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'bun', + ) + mkdirSync(scratch, { recursive: true }) + const root = realpathSync.native(mkdtempSync(join(scratch, 'watch-'))) + const plugin = resolve(import.meta.dir, '../src/register.ts').replaceAll( + '\\', + '/', + ) + writeFileSync(join(root, 'bunfig.toml'), '') + writeFileSync(join(root, 'tokens.ts'), "export const width = '751px'") + writeFileSync( + join(root, 'style.css.ts'), + "import { style } from '@devup-ui/react'; import { width } from './tokens'; export const cls = style({ width })", + ) + writeFileSync( + join(root, 'check.ts'), + `import { register } from ${JSON.stringify(plugin)}; +await register({ debug: true }); +const { cls } = await import('./style.css.ts'); +console.log('WATCH_RESULT:' + JSON.stringify({ cls, css: await Bun.file('df/devup-ui/devup-ui.css').text() }));`, + ) + const child = Bun.spawn([process.execPath, '--watch', 'check.ts'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe', + }) + const errors = new Response(child.stderr).text() + const timer = setTimeout(() => child.kill(), 15000) + const results: string[] = [] + try { + let pending = '' + for await (const chunk of child.stdout) { + pending += new TextDecoder().decode(chunk) + const lines = pending.split('\n') + pending = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('WATCH_RESULT:')) continue + results.push(line) + if (results.length === 1) + writeFileSync(join(root, 'tokens.ts'), "export const width = '757px'") + } + if (results.length >= 2) break + } + child.kill() + expect(results, (await errors) + results.join('\n')).toHaveLength(2) + expect(results[0]).toContain('width:751px') + expect(results[1]).toContain('width:757px') + expect(results[0]).toMatch(/"cls":"[^"]*751px/) + expect(results[1]).toMatch(/"cls":"[^"]*757px/) + expect(results[1]).not.toContain('width:751px') + expect(results[1]).not.toBe(results[0]) + } finally { + clearTimeout(timer) + child.kill() + await child.exited + rmSync(root, { recursive: true, force: true }) + } +}, 20000) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 91287479..7ec53a6d 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,6 +1,6 @@ -import { existsSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join, relative, resolve } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' import { beginBuild, @@ -9,8 +9,13 @@ import { createModuleResolver, createThemeInterfaceArgs, type CustomShorthands, + GRAPH_SOURCE_FILE_RE, + listSourceFiles, loadDevupConfig, - mergeImportAliases, + MDX_FILE_RE, + remapMdxError, + resolveProjectPaths, + resolveSourceDirs, seedFileNumbers, } from '@devup-ui/plugin-utils' import { @@ -27,42 +32,43 @@ import { } from '@devup-ui/wasm' import { type BunPlugin, plugin, type PluginBuilder } from 'bun' -import { cssDirName, cssNamespace, resolveCssId } from './css-id' +import { cssNamespace, resolveCssId } from './css-id' +import { compileMdx } from './mdx' +import { + compiledPackages, + importAliases, + importsCompiledPackage, + libPackage, + mentionsCompiledPackage, + preserveDependencies, + runtimeSourceFilter, + sourceLoader, +} from './source' -const libPackage = '@devup-ui/react' -const devupFile = 'devup.json' -const distDir = 'df' -const cssDir = resolve(distDir, cssDirName) const singleCss = true -const importAliases = mergeImportAliases() -// The packages whose imports the extractor compiles: Devup UI, the packages it -// takes the place of, and StyleX -const compiledPackages = [ - libPackage, - '@stylexjs/stylex', - ...Object.keys(importAliases), -] export interface DevupUIBunPluginOptions { shorthands?: CustomShorthands - /** - * Readable class names. Defaults to `true` under the Bun runtime (tests) and - * `false` in `Bun.build`. - */ + /** Resolved against Bun.build's root, or cwd in the runtime. */ + root?: string + devupFile?: string + distDir?: string + sourceDirs?: string | string[] + /** Uncompiled dependencies to transform under the runtime. */ + include?: string[] + /** Readable class names. Defaults to false in both runtime and builds. */ debug?: boolean } -type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' +type Project = ReturnType & { + readonly root: string + readonly debug: boolean + readonly resolver: ReturnType +} -async function writeDataFiles() { - let theme = {} - try { - const config = await loadDevupConfig(devupFile) - theme = config.theme ?? {} - } catch { - // Error reading devup.json, use empty theme - } - registerTheme(theme) +async function writeDataFiles({ devupFile, distDir, cssDir }: Project) { + const config = await loadDevupConfig(devupFile) + registerTheme(config.theme ?? {}) // Generate theme interface after registration (always write, even if empty) await writeFile( @@ -77,23 +83,32 @@ async function writeDataFiles() { await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') } -async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { - registerShorthands(shorthands ?? {}) +async function initialize(project: Project, options: DevupUIBunPluginOptions) { + const { root, distDir } = project + registerShorthands(options.shorthands ?? {}) setPrefix(null) - setModuleResolver(createModuleResolver()) + setModuleResolver(project.resolver) // Number every source file in path order, so class prefixes do not depend // on the order Bun loads files in try { seedFileNumbers( { seedFileMap }, collectNumberedFiles({ - roots: [resolve('src')], + roots: resolveSourceDirs(root, options.sourceDirs), + cwd: root, + include: options.include, needles: compiledPackages, toId: (path) => path, }), ) - } catch { - // Best-effort; numbering falls back to arrival order. + } catch (cause) { + if (!(cause instanceof Error)) throw cause + console.warn('[devup-ui] File numbering fallback', { + root, + phase: 'seed', + impact: 'arrival-order file IDs', + cause, + }) } if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -102,59 +117,40 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { createCompatTypes(importAliases), 'utf-8', ) - await writeDataFiles() -} - -const scanners = new Map() - -/** Whether `contents` imports a package the extractor compiles */ -function importsCompiledPackage(contents: string, loader: SourceLoader) { - let scanner = scanners.get(loader) - if (!scanner) { - scanner = new Bun.Transpiler({ loader }) - scanners.set(loader, scanner) - } - try { - return scanner - .scanImports(contents) - .some(({ path }) => - compiledPackages.some( - (name) => path === name || path.startsWith(`${name}/`), - ), - ) - } catch { - // Bun reports the syntax error when it loads the untouched source - return false - } + await writeDataFiles(project) } -async function loadSourceFile(filePath: string, bundling: boolean) { - const loader: SourceLoader = filePath.endsWith('.tsx') - ? 'tsx' - : filePath.endsWith('.ts') - ? 'ts' - : filePath.endsWith('.jsx') - ? 'jsx' - : 'js' - const contents = await Bun.file(filePath).text() +async function loadSourceFile(filePath: string, project: Project) { + const original = await Bun.file(filePath).text() + const mdx = MDX_FILE_RE.test(filePath) + ? await compileMdx(project.root, filePath, original) + : undefined + if (MDX_FILE_RE.test(filePath) && !mdx) return undefined + const loader = mdx ? 'jsx' : sourceLoader(filePath) + const contents = mdx?.value ?? original if (importsCompiledPackage(contents, loader)) { - const code = codeExtract( - filePath, - contents, - libPackage, - relative(dirname(filePath), cssDir).replaceAll('\\', '/'), - singleCss, - true, - false, - importAliases, - ) - // Under the runtime the stylesheet is read from disk. singleCss stores - // every extracted style in the base sheet; synchronous writes keep - // concurrent source loads from overwriting a newer sheet with an older one. - if (!bundling) - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') - return { contents: code.code, loader } + setDebug(project.debug) + setModuleResolver(project.resolver) + try { + const code = codeExtract( + filePath, + contents, + libPackage, + relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), + singleCss, + true, + false, + importAliases, + ) + return { + contents: preserveDependencies(code.code, filePath, code.dependencies), + loader, + } + } catch (cause) { + if (mdx) throw remapMdxError(cause, filePath, mdx.map) + throw cause + } } return { contents, loader } } @@ -170,19 +166,74 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { async setup(build: PluginBuilder) { // `Bun.build` hands its config to plugins; the runtime has none const bundling = build.config !== undefined + const root = resolve(options.root ?? build.config?.root ?? process.cwd()) + const targetConditions = { + browser: ['browser'], + bun: ['bun', 'node'], + node: ['node'], + } as const + const customConditions = + typeof build.config?.conditions === 'string' + ? [build.config.conditions] + : (build.config?.conditions ?? []) + const project: Project = { + ...resolveProjectPaths(root, options), + root, + debug: options.debug ?? false, + resolver: createModuleResolver({ + cwd: root, + // Extracted ESM imports stay import requests even in the vanilla + // CommonJS evaluator. The callback does not expose a request kind. + conditions: [ + ...targetConditions[ + build.config?.target ?? (bundling ? 'browser' : 'bun') + ], + 'import', + ...(bundling ? ['module'] : []), + ...customConditions, + ], + }), + } // A build starts from its own options, not from what an earlier build in // this process left in the engine const endBuild = beginBuild({ resetBuildState }) build.onEnd?.(endBuild) - await initialize(options) - setDebug(options.debug ?? !bundling) + await initialize(project, options) + setDebug(project.debug) + let writtenCss = getCss(null, false) + // Native token modules must keep Bun's filesystem watch/CJS loading path. + const sourceFilter = bundling + ? GRAPH_SOURCE_FILE_RE + : new RegExp( + `${ + runtimeSourceFilter([ + ...listSourceFiles(root, [ + 'target', + 'dist', + basename(project.distDir), + '.git', + 'coverage', + ]).filter((file) => + mentionsCompiledPackage(readFileSync(file, 'utf-8')), + ), + ...collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + cwd: root, + include: ['@devup-ui/components', ...(options.include ?? [])], + needles: compiledPackages, + toId: (path) => path, + }), + ]).source + }|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + ) // Resolve devup-ui CSS files onto a path-free virtual id, so nothing // derived from this checkout's cwd can be baked into Bun's shared, // content-keyed transpiler cache. See ./css-id. build.onResolve( { filter: /devup-ui(-\d+)?\.css$/ }, - ({ path, importer }) => resolveCssId(path, importer, distDir), + ({ path, importer }) => + resolveCssId(path, importer, basename(project.distDir)), ) // The bundler takes the stylesheet once every other module is loaded, @@ -201,9 +252,25 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { // Load source files from packages directory (file namespace) build.onLoad( { - filter: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, + filter: sourceFilter, + }, + ({ path }) => { + return loadSourceFile(path, project).then((result) => { + if (!bundling && result) { + const css = getCss(null, false) + // Read-after-import is synchronous; only identical revisions coalesce. + if (css !== writtenCss) { + writeFileSync( + join(project.cssDir, 'devup-ui.css'), + css, + 'utf-8', + ) + writtenCss = css + } + } + return result + }) }, - ({ path }) => loadSourceFile(path, bundling), ) }, } satisfies BunPlugin From 362476c860007d649a9441b840e62e526ec7b499 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:27:27 +0900 Subject: [PATCH 21/50] test(next-plugin): align shared regex and canonical config mocks Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../next-plugin/src/__tests__/plugin.test.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index dce4cd7b..cc59a768 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -415,13 +415,9 @@ describe('DevupUINextPlugin', () => { ], condition: { not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + path: importGraphModule.createNodeModulesExcludeRegex( + [], + '.mdx.[tj]sx?$', ), }, }, @@ -467,13 +463,9 @@ describe('DevupUINextPlugin', () => { '*.{tsx,ts,jsx,js,mjs}': { condition: { not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + path: importGraphModule.createNodeModulesExcludeRegex( + [], + '.mdx.[tj]sx?$', ), }, }, @@ -528,7 +520,15 @@ describe('DevupUINextPlugin', () => { readFileSyncSpy.mockReturnValue(JSON.stringify({ theme: 'theme' })) mkdirSyncSpy.mockReturnValue('') writeFileSyncSpy.mockReturnValue(undefined) - const ret = DevupUI({}) + const realpathSpy = spyOn(fs, 'realpathSync').mockImplementation((path) => + resolve(String(path)), + ) + let ret: ReturnType + try { + ret = DevupUI({}) + } finally { + realpathSpy.mockRestore() + } expect(ret).toEqual({ turbopack: { @@ -560,13 +560,9 @@ describe('DevupUINextPlugin', () => { '*.{tsx,ts,jsx,js,mjs}': { condition: { not: { - path: new RegExp( - `(node_modules(?!.*(${['@devup-ui', '@devup-editor'] - .join('|') - .replaceAll( - '/', - '[\\/\\\\_]', - )})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, + path: importGraphModule.createNodeModulesExcludeRegex( + [], + '.mdx.[tj]sx?$', ), }, }, From d2ee4ccf1420215ba082a02b71bbb91e73d63444 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:33:29 +0900 Subject: [PATCH 22/50] docs(plugins): explain compiled and consumer-extracted library contracts Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/rsbuild-plugin/README.md | 43 +++++++++++++++++++++++++++++++ packages/vite-plugin/README.md | 24 +++++++++++++++++ packages/webpack-plugin/README.md | 19 ++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 404fc299..a3a5b724 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -177,6 +177,49 @@ const box = const box = ``` +## Publishing And Consuming Libraries + +With the default `extractCss: true`, a library build extracts its styles. Publish +the transformed JavaScript together with its emitted CSS and expose a stylesheet +that consumers import. The published code no longer calls compile-time Devup UI +placeholders. + +Use `extractCss: false` only for an intentionally uncompiled library. This is the +contract demonstrated by `apps/vite-lib`: **every** consumer needs a Devup UI build +plugin configured with the library's exact package name: + +```ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +The consuming build transforms the library and emits its CSS. Without that +configuration, the uncompiled placeholders throw `Cannot run on the runtime`; +the library must not be treated as standalone browser JavaScript. Both publishing +modes remain free of a styling runtime. + +## Project Roots And Extraction + +Relative `devupFile`, `distDir`, and `cssDir` options resolve against Rsbuild's +`root`, not the shell's working directory. The graph includes existing `src` +and `app` directories, normalized Rspack entries, and `sourceDirs` (a directory +or array of directories). Included packages follow the same effective Rspack +ES-module export conditions as extraction. + +Extraction supports `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and +`.cjs`. Configure an MDX compiler for `.mdx`; Devup UI runs as a post-loader on +its compiled JavaScript, never on raw Markdown. Rsbuild's transform API does not +expose the incoming source map, so extraction errors label compiled MDX locations +with `(in compiled MDX)`. + +Malformed or cyclic configuration and graph/prewarm failures stop the build with +the root and underlying cause. Empty themes overwrite generated declarations. +Optional file-number seeding failures warn once that IDs may depend on module +arrival order. `atomHoist` composes its shared CSS cache group with existing +groups and creates `splitChunks` when absent; `splitChunks: false` is an error +when atom hoisting is requested. + ## Custom Shorthands ```ts diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a943c028..e53cda7d 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -177,6 +177,30 @@ const box = const box = ``` +## Publishing Component Libraries + +Choose one of these build-time contracts. Neither mode permits Devup UI's +compile-time component placeholders to run in a consuming application. + +1. Precompiled library: keep `extractCss: true` (the default), publish the + transformed JavaScript and emitted CSS, and expose a stylesheet that consumers + import. Consumers do not need to transform the already compiled library. +2. Uncompiled library: use `DevupUI({ extractCss: false })` intentionally, as in + `apps/vite-lib`. Every consuming application must install a Devup UI build + plugin and include the library's exact package name so its modules are + transformed during the application's build. + +```ts +// The consuming application's vite.config.ts +export default defineConfig({ + plugins: [DevupUI({ include: ['@acme/components'] })], +}) +``` + +An uncompiled library is not standalone browser JavaScript. Using it without +the consumer transformation runs placeholders and throws +`Cannot run on the runtime`. Do not publish it as precompiled output. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 343ead56..42009afc 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -177,6 +177,25 @@ const box = const box = ``` +## Consuming Component Libraries + +A precompiled Devup UI library ships transformed JavaScript and emitted CSS. +Import its published stylesheet; its compile-time components have already been +removed, so the consumer does not need to transform that library again. + +A library intentionally published without extraction (for example with Vite's +`DevupUI({ extractCss: false })`, as in `apps/vite-lib`) requires a Devup UI build +plugin in **every** consuming application and its exact package name in `include`: + +```ts +new DevupUIWebpackPlugin({ include: ['@acme/components'] }) +``` + +The consumer compiles the library and emits its CSS at build time. Uncompiled +output is not standalone browser JavaScript: without this consumer setup, the +component placeholders throw `Cannot run on the runtime`. No styling runtime is +added by either supported publishing mode. + ## Custom Shorthands ```ts From f631df97f91f4f09dbb10d8def83f26e065790a1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 19:38:52 +0900 Subject: [PATCH 23/50] chore: record patch releases for plugin core fixes Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_plugin_core.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changepacks/changepack_log_plugin_core.json diff --git a/.changepacks/changepack_log_plugin_core.json b/.changepacks/changepack_log_plugin_core.json new file mode 100644 index 00000000..e74f874a --- /dev/null +++ b/.changepacks/changepack_log_plugin_core.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Use bundler project roots and active export conditions for build-time module resolution, resolve inherited tsconfig paths and dotted module specifiers, transform modern modules and compiled MDX, match included packages literally, and refresh empty theme declarations. Skip unrequested source maps, surface resolver faults before stylesheet mutation, and report WASM panic locations. Malformed or cyclic configuration, resolver failures, requested graph/prewarm failures and disabled Rsbuild shared splitting now stop builds with context; optional numbering fallback warns about its deterministic impact. Bun defaults to compact names and retains imported build-time dependency watch edges.", + "date": "2026-10-01T00:00:00.000Z" +} From e4bb45882a41375baac4e13a8cde45a03e5c5ca3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 22:00:17 +0900 Subject: [PATCH 24/50] fix(plugin-utils): opt in to compiled MDX graph discovery Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/graph-exclude.test.ts | 1 + .../src/__tests__/graph-modern.test.ts | 82 ++++++++++++++++++- packages/plugin-utils/src/import-graph.ts | 7 +- packages/rsbuild-plugin/src/plugin.ts | 1 + packages/vite-plugin/src/plugin.ts | 1 + 5 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts index 5008d5b3..453b560c 100644 --- a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts +++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts @@ -50,6 +50,7 @@ it('skips named directories at every depth without reintroducing imported files' writeFileSync(join(root, path), code) } const graph = buildStaticImportGraph(['src', 'app'], undefined, { + includeMdx: true, cwd: root, exclude: ['generated'], }) diff --git a/packages/plugin-utils/src/__tests__/graph-modern.test.ts b/packages/plugin-utils/src/__tests__/graph-modern.test.ts index adc7d97b..709f0c2a 100644 --- a/packages/plugin-utils/src/__tests__/graph-modern.test.ts +++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts @@ -220,6 +220,83 @@ describe('multi-root source graph', () => { }) describe('MDX ESM discovery', () => { + it.each(['src', '.'])( + 'excludes raw MDX roots and direct edges by default under %s', + (layout) => { + const entry = fixture( + 'src/main.ts', + "import './page.mdx'\nimport('./lazy.mdx')", + ) + const page = fixture( + 'src/page.mdx', + "import '../outside/provider.js'\n\n# Raw markdown", + ) + fixture('src/lazy.mdx', '# Lazy markdown') + fixture('outside/provider.js', 'export const value = 1') + const graph = buildStaticImportGraph(join(root, layout), undefined, { + cwd: root, + }) + expect(graph.files.filter((file) => file.endsWith('.mdx'))).toEqual([]) + expect(graph.staticImports.get(entry)).toEqual(new Set()) + expect(graph.dynamicImports.get(entry)).toEqual(new Set()) + expect( + createModuleResolver({ cwd: root })('./page.mdx', entry)?.path, + ).toBe(page) + }, + ) + + it.each([false, true])( + 'follows included JS and MDX provider closures only with MDX opt-in %s', + (includeMdx) => { + const entry = fixture( + 'src/main.ts', + "import 'included'\nimport 'mdx-entry'", + ) + fixture( + 'node_modules/included/package.json', + JSON.stringify({ main: 'index.js' }), + ) + const library = fixture( + 'node_modules/included/index.js', + "import './page.mdx'", + ) + const page = fixture( + 'node_modules/included/page.mdx', + "import './provider.js'\n\n# Page", + ) + const provider = fixture( + 'node_modules/included/provider.js', + 'export const value = 1', + ) + fixture( + 'node_modules/mdx-entry/package.json', + JSON.stringify({ main: 'index.mdx' }), + ) + const mdxEntry = fixture( + 'node_modules/mdx-entry/index.mdx', + "import './provider.js'\n\n# Entry", + ) + const secondProvider = fixture( + 'node_modules/mdx-entry/provider.js', + 'export const value = 2', + ) + const graph = buildStaticImportGraph(join(root, 'src'), undefined, { + cwd: root, + include: ['included', 'mdx-entry'], + includeMdx, + }) + expect(graph.files).toEqual( + (includeMdx + ? [entry, library, page, provider, mdxEntry, secondProvider] + : [entry, library] + ).sort(), + ) + expect(graph.staticImports.get(library)).toEqual( + new Set(includeMdx ? [page] : []), + ) + }, + ) + it('discovers real ESM blocks while ignoring markdown, code fences, indented code and comments', () => { const entry = fixture( 'app/page.mdx', @@ -260,7 +337,10 @@ describe('MDX ESM discovery', () => { const lazy = fixture('src/lazy.cjs', 'export const lazy = 1') fixture('app/fake.ts', 'fake') const srcDir = [join(root, 'app'), join(root, 'src')] - const graph = buildStaticImportGraph(srcDir, undefined, { cwd: root }) + const graph = buildStaticImportGraph(srcDir, undefined, { + cwd: root, + includeMdx: true, + }) expect(graph.staticImports.get(entry)).toEqual(new Set([value, other])) expect(graph.dynamicImports.get(entry)).toEqual(new Set([lazy])) expect(computeCompiledFiles({ srcDir, cwd: root, graph })).toEqual([ diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 3fa40b55..ddc0adc7 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -121,7 +121,9 @@ export function buildStaticImportGraph( ) const files = [ ...new Set(roots.flatMap((root) => listSourceFiles(root, options.exclude))), - ].sort(compareCodePoints) + ] + .filter((file) => options.includeMdx || !MDX_FILE_RE.test(file)) + .sort(compareCodePoints) const fileSet = new Set(files) const resolver = createModuleResolver({ cwd, @@ -176,6 +178,7 @@ export function buildStaticImportGraph( const target = resolved && jsFileRegex.test(resolved) && + (options.includeMdx || !MDX_FILE_RE.test(resolved)) && (fileSet.has(resolved) || roots.some( (root) => @@ -226,6 +229,8 @@ export function buildStaticImportGraph( } export interface StaticImportGraphOptions { + /** Include MDX only when the caller compiles it before extraction. */ + readonly includeMdx?: boolean readonly cwd?: string readonly include?: readonly string[] readonly conditions?: readonly string[] diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index ce317548..e751f6a4 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -319,6 +319,7 @@ export const DevupUI = ({ }) setModuleResolver(resolver) const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: true, cwd: root, include, conditions, diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index cc42c187..7ffecf3c 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -495,6 +495,7 @@ export function DevupUI({ const srcDir = roots const tsconfigPath = resolve(root, 'tsconfig.json') const graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: true, cwd: root, include, conditions, From c1cfdbb4e20220905ccbde65e05843e96282589a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 22:09:10 +0900 Subject: [PATCH 25/50] fix(webpack-plugin): normalize loader entry resources before prewarm Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/core-regression.test.ts | 53 +++++++++++++++++++ packages/webpack-plugin/src/plugin.ts | 15 +++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/webpack-plugin/src/__tests__/core-regression.test.ts b/packages/webpack-plugin/src/__tests__/core-regression.test.ts index da6d90b4..a831d0c1 100644 --- a/packages/webpack-plugin/src/__tests__/core-regression.test.ts +++ b/packages/webpack-plugin/src/__tests__/core-regression.test.ts @@ -206,3 +206,56 @@ it('does not parse raw MDX during prewarm', async () => { extract.mockRestore() } }) + +it.each([ + ['giant Next pitch', `next-app-loader?page=${'segment/'.repeat(5000)}!`], + ['virtual scheme', 'virtual:next-entry'], + ['virtual ID', '\0next-entry'], + ['empty resource', 'loader?pitch=true!?resourceQueryOnly'], +])('skips virtual or pitch-only entry %s', async (_name, entry) => { + const reachable = spyOn(pluginUtils, 'computeReachableFiles') + try { + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin()], + }) + expect(reachable.mock.calls[0]?.[0].entries).toEqual([]) + } finally { + reachable.mockRestore() + } +}) + +it.each([ + './src/main.ts', + './src/main.ts?resource=true', + 'first-loader?value=one!second-loader?value=two!./src/main.ts?resource=true', +])('prewarms the real resource of entry %s', async (entry) => { + await writeFile( + join(root, 'src/main.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'red'})", + ) + await writeFile( + join(root, 'src/unreachable.ts'), + "import {css} from '@devup-ui/react'; export const style = css({bg:'blue'})", + ) + compiler = webpack({ + context: root, + mode: 'production', + entry, + plugins: [new DevupUIWebpackPlugin({ singleCss: true })], + }) + expect(wasm.getCss(null, false)).toContain('red') + expect(wasm.getCss(null, false)).not.toContain('blue') +}) + +it('propagates invalid real entry resource I/O instead of skipping it', () => { + expect(() => + webpack({ + context: root, + entry: 'loader!./src/\0.ts?resource=true', + plugins: [new DevupUIWebpackPlugin()], + }), + ).toThrow() +}) diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 3e4c8909..2acbca13 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -239,7 +239,19 @@ export class DevupUIWebpackPlugin { typeof entry === 'function' ? [] : Object.values(entry ?? {}).flatMap(({ import: requests = [] }) => - requests.map((request) => resolve(cwd, request)), + requests.flatMap((request) => { + const resource = request + .slice(request.lastIndexOf('!') + 1) + .split('?')[0] + if ( + !resource || + resource.startsWith('\0') || + (/^[a-z][a-z\d+.-]*:/i.test(resource) && + !/^[a-z]:[\\/]/i.test(resource)) + ) + return [] + return [resolve(cwd, resource)] + }), ) const roots = [ ...new Set([...sourceDirs, ...entries.map((file) => dirname(file))]), @@ -321,6 +333,7 @@ export class DevupUIWebpackPlugin { const srcDir = roots const tsconfigPath = resolve(cwd, 'tsconfig.json') graph = buildStaticImportGraph(roots, tsconfigPath, { + includeMdx: true, cwd, include: this.options.include, conditions, From e81d7f3c325aae5f4b3ac404b5003ea934b2d87b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 22:18:12 +0900 Subject: [PATCH 26/50] test(vite-plugin): isolate watched config diagnostics Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/core-regression.test.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/vite-plugin/src/__tests__/core-regression.test.ts b/packages/vite-plugin/src/__tests__/core-regression.test.ts index 219291b6..8210cffc 100644 --- a/packages/vite-plugin/src/__tests__/core-regression.test.ts +++ b/packages/vite-plugin/src/__tests__/core-regression.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { ConfigLoadError } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' import { createServer, type ViteDevServer } from 'vite' @@ -68,9 +69,10 @@ it('clears declarations when a real theme file becomes empty', async () => { root, configFile: false, optimizeDeps: { noDiscovery: true }, - server: { middlewareMode: true, hmr: false }, + server: { middlewareMode: true, hmr: false, watch: null }, plugins: [plugin], }) + await server.watcher.close() expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toContain( 'removed', ) @@ -93,17 +95,35 @@ it('preserves the prior theme on a malformed watched config', async () => { root, configFile: false, optimizeDeps: { noDiscovery: true }, - server: { middlewareMode: true, hmr: false }, + server: { middlewareMode: true, hmr: false, watch: null }, plugins: [plugin], }) + await server.watcher.close() const prior = await readFile(join(root, 'df/theme.d.ts'), 'utf-8') + const priorCss = wasm.getCss(null, false) + expect(server.watcher.getWatched()).toEqual({}) await writeFile(join(root, 'devup.json'), '{') const diagnostic = spyOn(console, 'error').mockImplementation(() => {}) try { + console.error('[unrelated] config diagnostic from another server') await plugin.watchChange(join(root, 'devup.json')) expect(await readFile(join(root, 'df/theme.d.ts'), 'utf-8')).toBe(prior) - expect(diagnostic.mock.calls[0]?.[0]).toContain(join(root, 'devup.json')) - expect(wasm.getCss(null, false)).toContain('--preserved') + const targeted = diagnostic.mock.calls.find( + ([message]) => + message === + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + expect(targeted?.[0]).toBe( + `[devup-ui] theme update failed at ${join(root, 'devup.json')}`, + ) + const error = targeted?.[1] + expect(error).toBeInstanceOf(ConfigLoadError) + if (!(error instanceof ConfigLoadError)) + throw new TypeError('Expected config load diagnostic') + expect(error.file).toBe(join(root, 'devup.json')) + expect(error.cause).toBeInstanceOf(SyntaxError) + expect(wasm.getCss(null, false)).toBe(priorCss) + expect(priorCss).toContain('--preserved') } finally { diagnostic.mockRestore() } From fd269ce48b866a4741e4801b780710f49f6c908c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 22:26:39 +0900 Subject: [PATCH 27/50] chore: record plugin CI recovery patch releases Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_plugin_core_ci.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changepacks/changepack_log_plugin_core_ci.json diff --git a/.changepacks/changepack_log_plugin_core_ci.json b/.changepacks/changepack_log_plugin_core_ci.json new file mode 100644 index 00000000..398fa575 --- /dev/null +++ b/.changepacks/changepack_log_plugin_core_ci.json @@ -0,0 +1,10 @@ +{ + "changes": { + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch" + }, + "note": "Keep raw MDX out of static import graphs unless the caller compiles MDX before extraction, preserving Next production prewarm behavior while MDX-aware plugins opt in. Normalize Webpack inline-loader entry resources before filesystem lookup and skip virtual or pitch-only entries without swallowing real I/O failures. Isolate watched-config regression diagnostics while preserving the prior theme on invalid configuration. No new build-error category is introduced.", + "date": "2026-10-01T00:00:00.000Z" +} From a16a46944e6a8f6344c83aa2ff9809831c4612cb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 01:22:55 +0900 Subject: [PATCH 28/50] fix(plugin-utils): number MDX only for compiler-owning callers Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/bun-plugin/src/plugin.ts | 2 + .../src/__tests__/numbering.test.ts | 93 +++++++++++++++++-- packages/plugin-utils/src/numbering.ts | 5 + packages/rsbuild-plugin/src/plugin.ts | 1 + packages/vite-plugin/src/plugin.ts | 1 + packages/webpack-plugin/src/plugin.ts | 1 + 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 7ec53a6d..c6437c46 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -95,6 +95,7 @@ async function initialize(project: Project, options: DevupUIBunPluginOptions) { { seedFileMap }, collectNumberedFiles({ roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: true, cwd: root, include: options.include, needles: compiledPackages, @@ -218,6 +219,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { ), ...collectNumberedFiles({ roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: true, cwd: root, include: ['@devup-ui/components', ...(options.include ?? [])], needles: compiledPackages, diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts index e64ad571..74047191 100644 --- a/packages/plugin-utils/src/__tests__/numbering.test.ts +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -1,9 +1,18 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as fs from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' +import { listSourceFiles } from '../import-graph' import { collectNumberedFiles, extractedNeedles, @@ -14,12 +23,12 @@ describe('collectNumberedFiles', () => { let root: string const write = (path: string) => { - mkdirSync(dirname(join(root, path)), { recursive: true }) - writeFileSync(join(root, path), 'export {}') + fs.mkdirSync(dirname(join(root, path)), { recursive: true }) + fs.writeFileSync(join(root, path), 'export {}') } beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) + root = fs.mkdtempSync(join(tmpdir(), 'devup-ui-numbering-')) write('src/b.tsx') write('src/a.tsx') write('src/nested/c.ts') @@ -29,7 +38,7 @@ describe('collectNumberedFiles', () => { }) afterEach(() => { - rmSync(root, { recursive: true, force: true }) + fs.rmSync(root, { recursive: true, force: true }) }) it('lists the project source and the included packages in path order', () => { @@ -53,7 +62,7 @@ describe('collectNumberedFiles', () => { }) it('numbers only the files that mention what the build extracts', () => { - writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") + fs.writeFileSync(join(root, 'src/a.tsx'), "import '@devup-ui/react'") const files = collectNumberedFiles({ roots: [join(root, 'src')], cwd: root, @@ -82,6 +91,76 @@ describe('collectNumberedFiles', () => { expect(first).toEqual(second) }) + it.each([undefined, false, true])( + 'numbers project and included-package MDX only when includeMdx is true (%s)', + (includeMdx) => { + // Given source and package candidates with matching extraction needles. + write('src/docs/page.mdx') + write('node_modules/@acme/ui/Guide.mdx') + for (const path of [ + 'src/a.tsx', + 'src/docs/page.mdx', + 'node_modules/@acme/ui/Button.tsx', + 'node_modules/@acme/ui/Guide.mdx', + ]) { + fs.writeFileSync( + join(root, path), + "import '@devup-ui/react'\n# Raw MDX", + ) + } + + // When numbering uses the plugin's deterministic extraction IDs. + const files = collectNumberedFiles({ + roots: [join(root, 'src'), join(root, 'src/docs')], + include: ['@acme/ui'], + cwd: root, + needles: ['@devup-ui/react'], + ...(includeMdx === undefined ? {} : { includeMdx }), + toId: (path) => `id:${path.slice(root.length).replaceAll('\\', '/')}`, + }) + + // Then only explicit MDX extraction admits the raw MDX candidates. + expect(files).toEqual( + includeMdx + ? [ + 'id:/node_modules/@acme/ui/Button.tsx', + 'id:/node_modules/@acme/ui/Guide.mdx', + 'id:/src/a.tsx', + 'id:/src/docs/page.mdx', + ] + : ['id:/node_modules/@acme/ui/Button.tsx', 'id:/src/a.tsx'], + ) + }, + ) + + it('excludes raw MDX before reading source for extraction needles', () => { + // Given uncompiled MDX that remains visible to the standalone scanner. + write('src/page.mdx') + fs.writeFileSync( + join(root, 'src/page.mdx'), + "import '@devup-ui/react'\n# <", + ) + expect(listSourceFiles(join(root, 'src'))).toContain( + join(root, 'src/page.mdx'), + ) + const read = spyOn(fs, 'readFileSync') + try { + // When a caller uses the default non-MDX extraction set. + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + needles: ['@devup-ui/react'], + }) + + // Then no MDX source read occurs and no MDX ID is numbered. + expect(files).toEqual([]) + expect( + read.mock.calls.some(([path]) => path === join(root, 'src/page.mdx')), + ).toBe(false) + } finally { + read.mockRestore() + } + }) + it('names files the way the plugin extracts them', () => { const files = collectNumberedFiles({ roots: [join(root, 'src')], diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index 213bbf9c..acb10d0e 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -2,12 +2,15 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { listSourceFiles } from './import-graph' +import { MDX_FILE_RE } from './shared' export interface CollectNumberedFilesOptions { /** Directories holding the project's own source files */ roots: string[] /** Packages whose source the build extracts too (the `include` option) */ include?: string[] + /** Number MDX only when the caller extracts compiled MDX. Defaults to false. */ + includeMdx?: boolean cwd?: string /** * Only files whose text contains one of these are numbered, so files the @@ -46,6 +49,7 @@ function findPackageDir(cwd: string, name: string): string | undefined { export function collectNumberedFiles({ roots, include = [], + includeMdx = false, cwd = process.cwd(), needles, toId = (path) => path.replaceAll('\\', '/'), @@ -60,6 +64,7 @@ export function collectNumberedFiles({ ...new Set( directories.flatMap((dir) => listSourceFiles(dir) + .filter((file) => includeMdx || !MDX_FILE_RE.test(file)) .filter((file) => !needles || usesAny(file, needles)) .map(toId), ), diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index e751f6a4..9c347b58 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -359,6 +359,7 @@ export const DevupUI = ({ { seedFileMap }, collectNumberedFiles({ roots, + includeMdx: true, include, cwd: root, needles: extractedNeedles(libPackage, importAliases), diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 7ffecf3c..050e666d 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -543,6 +543,7 @@ export function DevupUI({ { seedFileMap }, collectNumberedFiles({ roots, + includeMdx: true, include, cwd: projectRoot, needles: extractedNeedles(libPackage, importAliases), diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 2acbca13..fd321d63 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -382,6 +382,7 @@ export class DevupUIWebpackPlugin { { seedFileMap }, collectNumberedFiles({ roots, + includeMdx: true, include: this.options.include, cwd, needles: extractedNeedles(this.options.package, this.importAliases), From 6615744aac5dccb5adaaead8256aa424e2d20081 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 01:33:48 +0900 Subject: [PATCH 29/50] fix(vite-plugin): prepare complete library CSS before optimization Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__regression__/aggregate-cycle.bun.ts | 45 ++++ .../src/__tests__/aggregate-build.test.ts | 244 +++++++++++++++++ .../src/__tests__/aggregate-css.test.ts | 176 ++++++++++++ .../src/__tests__/aggregate-errors.test.ts | 240 +++++++++++++++++ .../src/__tests__/aggregate-fixture.ts | 47 ++++ .../__tests__/aggregate-split-asset.test.ts | 77 ++++++ .../src/__tests__/aggregate-unknown.test.ts | 71 +++++ .../vite-plugin/src/__tests__/plugin.test.ts | 2 + packages/vite-plugin/src/aggregate-css.ts | 252 ++++++++++++++++++ packages/vite-plugin/src/aggregate-imports.ts | 47 ++++ packages/vite-plugin/src/plugin.ts | 60 ++++- 11 files changed, 1251 insertions(+), 10 deletions(-) create mode 100644 packages/vite-plugin/__regression__/aggregate-cycle.bun.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-build.test.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-css.test.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-errors.test.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-fixture.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts create mode 100644 packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts create mode 100644 packages/vite-plugin/src/aggregate-css.ts create mode 100644 packages/vite-plugin/src/aggregate-imports.ts diff --git a/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts new file mode 100644 index 00000000..b172089f --- /dev/null +++ b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../src/plugin' + +it('rejects a real public source-load/generated-CSS circular wait with a located alternative', async () => { + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-load-cycle-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const sheet = `${root}/df/devup-ui/devup-ui.css` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI(), + { + name: 'source-awaiting-generated-css', + enforce: 'post', + async transform(_source, id) { + if (id === entry) await this.load({ id: sheet }) + }, + }, + ], + build: { write: false, lib: { entry, formats: ['es'] } }, + }) + await expect(pending).rejects.toThrow( + `${entry}:1:1: [devup-ui] aggregate CSS asset`, + ) + await expect(pending).rejects.toThrow('preparation cycle') + await expect(pending).rejects.toThrow('separate imported asset') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 40000) diff --git a/packages/vite-plugin/src/__tests__/aggregate-build.test.ts b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts new file mode 100644 index 00000000..1ac53ceb --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const mdx = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + for (const configuration of ['library', 'split-library', 'aggregate-app']) { + it(`completes ${configuration} CSS when singleCss=${singleCss} and ${delayed} transforms last`, async () => { + // Given: real inputs, shared sheets, JS/CSS cycles, late compiler edges. + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-aggregate-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"aggregate-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; export const lazy=()=>import('./dynamic.js'); globalThis.fixtureLazy=lazy; import 'virtual:aggregate'; export {default as Page} from './page.mdx'; import './blue.css';", + 'other.js': + "export {second} from './second.js'; export {first} from './first.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; import './cycle-a.js'; export {staticStyle} from './style.css.ts'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'style.css.ts': + "import {css} from '@devup-ui/react'; import {color} from './constants.ts'; export const staticStyle=css({background:color});", + 'constants.ts': "export const color='purple';", + 'dynamic.js': + "import {css} from '@devup-ui/react'; export const dynamic=css({background:'orange'});", + 'injected.js': + "import {css} from '@devup-ui/react'; export const injected=css({background:'yellow'});", + 'plugin-entry.js': + "import {css} from '@devup-ui/react'; export const emitted=css({background:'pink'});", + 'cycle-a.js': "import './cycle-b.js'; export const a='a';", + 'cycle-b.js': "import './cycle-a.js'; export const b='b';", + 'page.mdx': + 'import {Box} from \'@devup-ui/react\'\n\n# Compiled\n\n', + 'unreachable.js': + "import {globalCss} from '@devup-ui/react'; globalCss({body:{color:'magenta'}});", + 'red.css': '@import "./local.css"; @layer b{.x{color:red}}', + 'local.css': + '@import "./cycle.css"; .asset{background:url(./image.svg?no-inline)}', + 'cycle.css': '@import "./local.css"; .local{display:block}', + 'blue.css': '@layer b{.x{color:blue}}', + 'image.svg': + '', + } + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(root, name), source) + } + const transformed = new Set() + const snapshots: string[] = [] + const plugins = DevupUI({ singleCss }) + const originalLoad = plugins[0].load + plugins[0].load = async function (id) { + const result = await originalLoad.call(this, id) + if (/devup-ui(?:-\d+)?\.css$/.test(id)) { + snapshots.push(String(result)) + expect(transformed.has(`${root}/second.js`)).toBe(true) + expect(transformed.has(`${root}/injected.js`)).toBe(true) + expect(transformed.has(`${root}/page.mdx`)).toBe(true) + } + return result + } + try { + // When: the actual optimizer consumes the ordinary generated imports. + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { + alias: { + 'react/jsx-runtime': landing.resolve('react/jsx-runtime'), + }, + }, + plugins: [ + { + name: 'aggregate-delay-and-mdx', + enforce: 'pre', + async transform(source, id) { + if (id.endsWith(`/${delayed}.js`)) { + await new Promise((done) => setTimeout(done, 60)) + } + if (id.endsWith('.mdx')) { + return { + code: String( + await mdx.compile({ value: source, path: id }), + ), + map: null, + } + } + }, + }, + plugins, + { + name: 'aggregate-injected-closure', + enforce: 'post', + buildStart() { + if (configuration !== 'split-library') { + this.emitFile({ + type: 'chunk', + id: join(root, 'plugin-entry.js'), + name: 'plugin-entry', + }) + } + }, + resolveId(id) { + if (id === 'virtual:aggregate') return '\0aggregate-companion' + }, + async load(id) { + if (id === '\0aggregate-companion') { + await new Promise((done) => setTimeout(done, 90)) + return 'export const companion=1;' + } + }, + transform(source, id) { + transformed.add(id) + if (id === '\0aggregate-companion') { + return `${source}\nimport ${JSON.stringify(join(root, 'injected.js'))};` + } + }, + }, + ], + build: { + write: false, + minify: false, + cssMinify: 'lightningcss', + cssCodeSplit: configuration === 'split-library', + lib: + configuration === 'aggregate-app' + ? false + : { + entry: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + formats: ['es'], + }, + rolldownOptions: { + input: { + entry: join(root, 'entry.js'), + other: join(root, 'other.js'), + }, + external: ['react/jsx-runtime'], + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + // Then: complete atoms/base sheet, blue cascade, native CSS processing. + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) + throw new Error('Build returned a watcher') + return bundle.output + }, + ) + const assets = output.filter((item) => item.type === 'asset') + const css = assets + .filter((item) => item.fileName.endsWith('.css')) + .map((item) => String(item.source)) + .join('\n') + expect(snapshots.length).toBeGreaterThan(0) + expect(snapshots.join('\n')).toContain('body{color:blue}') + expect(css).toMatch(/body[^}]*color:#00f/) + for (const color of ['purple', 'orange', '#ff0', '#0ff']) { + expect(css).toContain(color) + } + if (configuration !== 'split-library') expect(css).toContain('pink') + if (configuration !== 'split-library') + expect(css).not.toContain('green') + expect(css).not.toContain('magenta') + expect(css).toMatch(/(?:\.x|body,\.x)\{color:#00f\}/) + const byName = new Map(output.map((item) => [item.fileName, item])) + const entryCss: string[] = [] + const visited = new Set() + function visit(file: string) { + if (visited.has(file)) return + visited.add(file) + const item = byName.get(file) + if (item?.type !== 'chunk') return + for (const dependency of item.imports) visit(dependency) + const metadata = + 'viteMetadata' in item ? item.viteMetadata : undefined + if ( + metadata && + typeof metadata === 'object' && + 'importedCss' in metadata && + metadata.importedCss instanceof Set + ) { + for (const assetName of metadata.importedCss) { + const asset = byName.get(assetName) + if (asset?.type === 'asset') entryCss.push(String(asset.source)) + } + } + } + const entry = output.find( + (item) => + item.type === 'chunk' && item.isEntry && item.name === 'entry', + ) + if (!entry) throw new Error('Entry output missing') + visit(entry.fileName) + if (configuration === 'split-library') { + const winners = [ + ...entryCss.join('\n').matchAll(/([^{}]+)\{color:([^;}]+)/g), + ].filter((match) => match[1].split(',').includes('.x')) + expect(winners.at(-1)?.[2]).toBe('#00f') + } + expect(css).toContain('.local{display:block}') + expect(css).not.toContain('@import') + const image = assets.find((item) => + /custom\/image-[\w-]+\.svg$/.test(item.fileName), + ) + if (!image) throw new Error('Hashed URL asset missing') + expect(css).toContain(basename(image.fileName)) + expect(css).not.toContain('?no-inline') + expect( + assets + .filter((item) => item.fileName.endsWith('.css')) + .every((item) => /^custom\/.+-[\w-]+\.css$/.test(item.fileName)), + ).toBe(true) + expect(transformed.has(`${root}/unreachable.js`)).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 15000) + } + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-css.test.ts b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts new file mode 100644 index 00000000..edbe29bb --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from 'bun:test' + +import { createAggregateCssPreparation } from '../aggregate-css' +import { DevupUI } from '../plugin' +import { fixture, module } from './aggregate-fixture' + +it('shares one complete preparation when concurrent sheets use multiple cyclic entries', async () => { + // Given: two roots, a static cycle, dynamic and extensionless virtual edges. + const f = fixture({ first: 'entry', second: 'other' }) + f.modules.set( + 'entry', + module( + "import 'cycle'; export const lazy=()=>import('dynamic')", + ['cycle'], + ['dynamic'], + ), + ) + f.modules.set( + 'cycle', + module("import 'entry'; import 'sheet.css'", ['entry', 'sheet.css']), + ) + f.modules.set('other', module("import 'virtual'", ['virtual'])) + f.modules.set('dynamic', module()) + f.modules.set('virtual', module("import 'injected'", ['injected'])) + f.modules.set('injected', module()) + // When: every first snapshot requests the same environment barrier. + const first = f.preparation.prepare(f.environment, f.context, 'aggregate.css') + const second = f.preparation.prepare(f.environment, f.context, 'numbered.css') + await Promise.all([first, second]) + // Then: exactly the reachable non-CSS closure is ready, without a cycle wait. + expect(first).toBe(second) + expect([...f.loaded].sort()).toEqual([ + 'cycle', + 'dynamic', + 'entry', + 'injected', + 'other', + 'virtual', + ]) +}) + +it('isolates and resets barriers when builds reuse environment objects', async () => { + const f = fixture(['entry']) + const secondEnvironment = {} + f.modules.set('other', module()) + f.preparation.start(secondEnvironment, ['other'], { cssCodeSplit: false }) + const first = f.preparation.prepare(f.environment, f.context, 'first.css') + const second = f.preparation.prepare( + secondEnvironment, + f.context, + 'second.css', + ) + await Promise.all([first, second]) + expect(first).not.toBe(second) + expect([...f.loaded].sort()).toEqual(['entry', 'other']) + f.preparation.start(f.environment, 'other', { cssCodeSplit: false }) + const rebuilt = f.preparation.prepare(f.environment, f.context, 'rebuilt.css') + expect(rebuilt).not.toBe(first) + await rebuilt +}) + +it('leaves ordinary application builds untouched when aggregation is disabled', () => { + const f = fixture() + f.preparation.start(f.environment, 'entry', { cssCodeSplit: true }) + expect( + f.preparation.prepare(f.environment, f.context, 'sheet.css'), + ).toBeUndefined() + expect(f.loaded.size).toBe(0) +}) + +it('uses public external resolution when cached module information lacks isExternal', async () => { + const f = fixture() + f.modules.set( + 'entry', + module("import 'external'; import 'rollup-external'; import 'virtual';", [ + 'external', + 'rollup-external', + 'virtual', + ]), + ) + f.cached.set('external', { ...module(), code: null }) + f.cached.set('rollup-external', { ...module(), isExternal: true }) + f.cached.set('virtual', { ...module(), code: null }) + f.modules.set('virtual', module()) + f.context.resolve = async (id) => + id === 'virtual' ? null : { id, external: id === 'external' } + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'virtual']) +}) + +it('honors public canonical IDs when resolution redirects a dependency', async () => { + const f = fixture() + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.modules.set('canonical', module()) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id, + external: false, + }) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'canonical']) +}) + +it('accepts literal and empty-template dynamic imports after transformation', async () => { + const f = fixture() + f.modules.set( + 'entry', + module( + "export const a=()=>import('literal'); export const b=()=>import(`template`)", + [], + ['literal', 'template'], + ), + ) + f.modules.set('literal', module()) + f.modules.set('template', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'literal', 'template']) +}) + +it('keeps aggregation out of dev and disabled-extraction build hooks', () => { + const [plugin] = DevupUI({ extractCss: false }) + Reflect.apply(plugin.buildStart, {}, [{ input: 'entry' }]) + expect( + plugin.config.call(undefined, { build: { lib: { entry: 'entry' } } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('records fallback build inputs without preloading in buildStart', () => { + const [plugin] = DevupUI() + const f = fixture() + Reflect.apply(plugin.buildStart, f.context, [{ input: 'entry' }]) + expect(f.loaded.size).toBe(0) + expect( + plugin.config.call(undefined, { build: { cssCodeSplit: false } }).build, + ).toBeUndefined() + plugin.closeBundle() +}) + +it('fails located when a source transform awaits the generated CSS barrier it blocks', async () => { + const expirations: (() => void)[] = [] + const f = fixture() + const preparation = createAggregateCssPreparation((expire) => { + expirations.push(expire) + return () => {} + }) + preparation.start(f.environment, 'entry', { cssCodeSplit: false }) + const entered = Promise.withResolvers() + f.context.load = async () => { + entered.resolve() + await preparation.prepare(f.environment, f.context, 'aggregate.css') + return module() + } + const pending = preparation.prepare(f.environment, f.context, 'aggregate.css') + await entered.promise + for (const expire of expirations) expire() + await expect(pending).rejects.toThrow( + 'entry:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) + await expect(pending).rejects.toThrow('preparation cycle') +}) + +it('prepares actual plugin-defined entry modules but never cached orphan modules', async () => { + const f = fixture() + f.cached.set('plugin-entry', { ...module(), isEntry: true }) + f.cached.set('orphan', module()) + f.modules.set('plugin-entry', module()) + f.modules.set('orphan', module()) + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + expect([...f.loaded]).toEqual(['entry', 'plugin-entry']) + expect(() => + f.preparation.observe(f.environment, 'plugin-entry'), + ).not.toThrow() + expect(() => f.preparation.observe(f.environment, 'late')).toThrow( + 'late:1:1: [devup-ui] aggregate CSS asset aggregate.css', + ) +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts new file mode 100644 index 00000000..c73d7647 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-errors.test.ts @@ -0,0 +1,240 @@ +import { expect, it } from 'bun:test' + +import { AggregateCssError } from '../aggregate-css' +import { fixture, module } from './aggregate-fixture' + +const failures: readonly { + readonly name: string + readonly setup: (f: ReturnType) => void + readonly file: string + readonly reason: string +}[] = [ + { + name: 'empty inputs', + setup(f) { + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + }, + file: 'aggregate.css', + reason: 'input list is empty', + }, + { + name: 'unresolved entry', + setup(f) { + f.context.resolve = async () => null + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'external entry', + setup(f) { + f.context.resolve = async (id) => ({ id, external: 'absolute' }) + }, + file: 'entry', + reason: 'entry could not be resolved', + }, + { + name: 'unresolved dependency', + setup(f) { + f.modules.set('entry', module("import 'missing'", ['missing'])) + f.context.resolve = async (id) => + id === 'entry' ? { id, external: false } : null + }, + file: 'entry', + reason: 'resolution refused missing', + }, + { + name: 'cyclic canonical resolution', + setup(f) { + f.modules.set('entry', module("import 'alias'", ['alias'])) + f.context.resolve = async (id) => ({ + id: id === 'alias' ? 'canonical' : id === 'canonical' ? 'alias' : id, + external: false, + }) + }, + file: 'entry', + reason: 'ID cycle', + }, + { + name: 'null load', + setup(f) { + f.modules.delete('entry') + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete code', + setup(f) { + f.modules.set('entry', { ...module(), code: null }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete static edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'importedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'incomplete dynamic edges', + setup(f) { + Object.defineProperty(f.modules.get('entry'), 'dynamicallyImportedIds', { + value: null, + }) + }, + file: 'entry', + reason: 'null or incomplete', + }, + { + name: 'unresolved import graph', + setup(f) { + f.modules.set('entry', module("export * from 'missing'")) + }, + file: 'entry', + reason: 'unresolved import graph', + }, + { + name: 'computed import', + setup(f) { + f.modules.set( + 'entry', + module("const target='late';\nexport const lazy=()=>import(target)"), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'computed template import', + setup(f) { + f.modules.set( + 'entry', + module('export const lazy=(name)=>import(`./${name}.js`)'), + ) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'missing import source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression' }], + }) + }, + file: 'entry', + reason: 'incomplete dynamic import', + }, + { + name: 'unknown dynamic source', + setup(f) { + f.context.parse = () => ({ + type: 'Program', + body: [{ type: 'ImportExpression', source: { type: 'Identifier' } }], + }) + }, + file: 'entry', + reason: 'computed dynamic import', + }, + { + name: 'null parser', + setup(f) { + f.context.parse = () => null + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'incomplete parser', + setup(f) { + f.context.parse = () => ({}) + }, + file: 'entry', + reason: 'incomplete module information', + }, + { + name: 'invalid parser root', + setup(f) { + f.context.parse = () => ({ type: 'Literal' }) + }, + file: 'entry', + reason: 'incomplete module information', + }, + ...[new Error('refusal'), 'non-error refusal'].flatMap((cause) => [ + { + name: `load rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.load = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public load refused', + }, + { + name: `resolve rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.resolve = async () => { + throw cause + } + }, + file: 'entry', + reason: 'public resolution refused', + }, + { + name: `parse rejection ${String(cause)}`, + setup(f: ReturnType) { + f.context.parse = () => { + throw cause + } + }, + file: 'entry', + reason: 'public parser refused', + }, + ]), +] + +for (const failure of failures) { + it(`fails closed with a located asset/config alternative when ${failure.name}`, async () => { + // Given: a public seam refusing a complete transformed closure. + const f = fixture() + failure.setup(f) + // When: a generated snapshot attempts preparation. + try { + await f.preparation.prepare(f.environment, f.context, 'aggregate.css') + throw new Error('Expected aggregate preparation failure') + } catch (error) { + // Then: no silent partial success; responsible file and alternatives survive. + expect(error).toBeInstanceOf(AggregateCssError) + if (!(error instanceof AggregateCssError)) throw error + expect(error.file).toBe(failure.file) + expect(error.asset).toBe('aggregate.css') + expect(error.message).toContain(failure.reason) + expect(error.message).toContain('build.cssCodeSplit=true') + expect(error.message).toContain('separate imported asset') + if (failure.name === 'computed import') { + expect(error.line).toBe(2) + expect(error.column).toBe(23) + } + } + }) +} + +it('locates an unsafe standalone asset even when a plugin supplied all entry roots', () => { + const f = fixture() + f.preparation.start(f.environment, undefined, { cssCodeSplit: false }) + f.preparation.checkAsset(f.environment, { fileName: 'image.svg' }) + expect(() => + f.preparation.checkAsset(f.environment, { + name: 'devup-ui.css', + fileName: 'custom/devup-ui-hash.css', + }), + ).toThrow('custom/devup-ui-hash.css:1:1: [devup-ui] aggregate CSS asset') +}) diff --git a/packages/vite-plugin/src/__tests__/aggregate-fixture.ts b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts new file mode 100644 index 00000000..69cc7a93 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-fixture.ts @@ -0,0 +1,47 @@ +import { parseAst } from 'vite' + +import { + createAggregateCssPreparation, + type CssClosureContext, +} from '../aggregate-css' + +type Module = NonNullable> + +export function module( + code = '', + importedIds: readonly string[] = [], + dynamicallyImportedIds: readonly string[] = [], +): Module { + return { code, importedIds, dynamicallyImportedIds } +} + +export function fixture( + entries: + string | readonly string[] | Record | undefined = 'entry', +) { + const environment = {} + const preparation = createAggregateCssPreparation() + const modules = new Map([['entry', module()]]) + const cached = new Map() + const loaded = new Set() + const context: CssClosureContext = { + getModuleIds() { + return cached.keys() + }, + getModuleInfo(id) { + return cached.get(id) ?? null + }, + async resolve(id) { + return { id, external: false } + }, + async load({ id }) { + loaded.add(id) + return modules.get(id) ?? null + }, + parse(code) { + return parseAst(code) + }, + } + preparation.start(environment, entries, { lib: { entry: 'entry' } }) + return { environment, preparation, context, modules, cached, loaded } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts new file mode 100644 index 00000000..73ceadb7 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-split-asset.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +for (const singleCss of [false, true]) { + for (const delayed of ['first', 'second']) { + it(`rejects unsafe native standalone CSS when singleCss=${singleCss} and ${delayed} is delayed`, async () => { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-split-asset-'))) + .replaceAll('\\', '/') + const files = { + 'package.json': '{"name":"split-fixture","type":"module"}', + 'entry.js': + "import './red.css'; export {first} from './first.js'; export {second} from './second.js'; import './blue.css';", + 'other.js': + "export {first} from './first.js'; export {second} from './second.js';", + 'first.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({'.x':{color:'green'}}); export const first=css({color:'red'});", + 'second.js': + "import {css,globalCss} from '@devup-ui/react'; globalCss({body:{color:'blue'}}); export const second=css({color:'blue'});", + 'emitted.js': + "import {css} from '@devup-ui/react'; export const third=css({background:'pink'});", + 'red.css': '@layer b{.x{color:red}}', + 'blue.css': '@layer b{.x{color:blue}}', + } + for (const [name, source] of Object.entries(files)) + writeFileSync(join(root, name), source) + try { + const pending = build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ singleCss }), + { + name: 'actual-plugin-entry', + buildStart() { + this.emitFile({ type: 'chunk', id: `${root}/emitted.js` }) + }, + async transform(_code, id) { + if (id.endsWith(`/${delayed}.js`)) + await new Promise((done) => setTimeout(done, 60)) + }, + }, + ], + build: { + write: false, + cssCodeSplit: true, + cssMinify: 'lightningcss', + lib: { + entry: { entry: `${root}/entry.js`, other: `${root}/other.js` }, + formats: ['es'], + }, + rolldownOptions: { + output: { assetFileNames: 'custom/[name]-[hash][extname]' }, + }, + }, + }) + await expect(pending).rejects.toThrow( + /aggregate CSS asset custom\/devup-ui-[\w-]+\.css/, + ) + await expect(pending).rejects.toThrow( + 'build.cssCodeSplit=false for this library', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + } +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts new file mode 100644 index 00000000..05ffa610 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build } from 'vite' + +import { DevupUI } from '../plugin' + +function unknownFixture() { + resetBuildState() + const root = realpathSync + .native(mkdtempSync(join(tmpdir(), 'devup-unknown-'))) + .replaceAll('\\', '/') + const entry = `${root}/entry.js` + const unknown = `${root}/unknown.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'}); export {lazy} from './unknown.js';", + ) + writeFileSync( + unknown, + 'export const lazy=(target)=>import(/* @vite-ignore */ target);', + ) + return { root, entry, unknown } +} + +it('rejects an unknowable dynamic closure with the responsible source and asset alternative', async () => { + const { root, entry, unknown } = unknownFixture() + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { write: false, lib: { entry, formats: ['es'] } }, + }), + ).rejects.toThrow(`${unknown}:1:29: [devup-ui] aggregate CSS asset`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('builds the same module when the documented nonaggregating application alternative is used', async () => { + const { root, entry } = unknownFixture() + try { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + write: false, + lib: false, + cssCodeSplit: true, + rolldownOptions: { input: entry }, + }, + }) + const output = (Array.isArray(result) ? result : [result]).flatMap( + (bundle) => { + if (!('output' in bundle)) throw new Error('Build returned a watcher') + return bundle.output + }, + ) + expect(output.some((item) => item.fileName.endsWith('.css'))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 59050e98..1c1551e0 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -307,6 +307,7 @@ describe('devupUIVitePlugin', () => { name: 'devup-ui', sharedDuringBuild: true, closeBundle: expect.any(Function), + buildStart: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -557,6 +558,7 @@ describe('devupUIVitePlugin', () => { expect(collectSpy).toHaveBeenCalledWith({ roots: [resolve('/p', 'src'), resolve('/p', 'app')], + includeMdx: true, include: ['@acme/ui'], cwd: '/p', needles: expect.arrayContaining([ diff --git a/packages/vite-plugin/src/aggregate-css.ts b/packages/vite-plugin/src/aggregate-css.ts new file mode 100644 index 00000000..311b8da7 --- /dev/null +++ b/packages/vite-plugin/src/aggregate-css.ts @@ -0,0 +1,252 @@ +import type { BuildOptions } from 'vite' + +import { inspectImports } from './aggregate-imports' + +interface ClosureModule { + readonly code: string | null + readonly importedIds: readonly string[] + readonly dynamicallyImportedIds: readonly string[] + readonly isExternal?: boolean + readonly isEntry?: boolean +} + +export interface CssClosureContext { + getModuleIds(): IterableIterator + getModuleInfo(id: string): ClosureModule | null + load(options: { + id: string + resolveDependencies: boolean + }): Promise + resolve( + id: string, + importer?: string, + ): Promise<{ + readonly id: string + readonly external: boolean | string + } | null> + parse(code: string): unknown +} + +interface Preparation { + readonly entries: readonly string[] + promise?: Promise + completed?: { readonly ids: ReadonlySet; readonly asset: string } +} + +export class AggregateCssError extends Error { + readonly file: string + readonly line: number + readonly column: number + + constructor( + location: + | string + | { + readonly file: string + readonly line: number + readonly column: number + }, + readonly asset: string, + reason: string, + ) { + const { file, line, column } = + typeof location === 'string' + ? { file: location, line: 1, column: 1 } + : location + super( + `${file}:${line}:${column}: [devup-ui] aggregate CSS asset ${asset} cannot be completed at build time: ${reason}; use an application build with build.lib=false and build.cssCodeSplit=true, or precompile the responsible module and publish its CSS as a separate imported asset`, + ) + this.file = file + this.line = line + this.column = column + this.name = 'AggregateCssError' + } +} + +// Vite processes these requests as stylesheets, not JS graph vertices. Awaiting +// their loads would wait on this very barrier (including CSS @import cycles). +const CSS_REQUEST = + /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i + +export function createAggregateCssPreparation( + deadline: (expire: () => void) => () => void = (expire) => { + const timer = setTimeout(expire, 30_000) + return () => clearTimeout(timer) + }, +) { + const environments = new WeakMap() + return { + start( + environment: object, + input: string | readonly string[] | Record | undefined, + build: Pick, + ) { + environments.delete(environment) + if (!build.lib && build.cssCodeSplit !== false) return + const entries = + typeof input === 'string' ? [input] : input ? Object.values(input) : [] + environments.set(environment, { entries }) + }, + prepare(environment: object, context: CssClosureContext, asset: string) { + const state = environments.get(environment) + if (!state) return + state.promise ??= (async () => { + const visited = new Set() + const entries = new Set(state.entries) + for (const id of context.getModuleIds()) { + if (context.getModuleInfo(id)?.isEntry) entries.add(id) + } + async function resolveModule(id: string, importer?: string) { + try { + return await context.resolve(id, importer) + } catch (cause) { + throw new AggregateCssError( + importer ?? id, + asset, + `public resolution refused ${id}: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + } + async function walk(id: string, importer: string): Promise { + if (visited.has(id)) return + visited.add(id) + const known = context.getModuleInfo(id) + if (known?.isExternal) return + if (!known || known.code === null) { + const resolved = await resolveModule(id, importer) + if (!resolved && !known) + throw new AggregateCssError( + importer, + asset, + `public resolution refused ${id}`, + ) + if (resolved?.external) return + if (resolved && resolved.id !== id) { + if (visited.has(resolved.id)) + throw new AggregateCssError( + importer, + asset, + `public resolution formed an ID cycle at ${id}`, + ) + return walk(resolved.id, importer) + } + } + if (CSS_REQUEST.test(id)) return + let code = '' + function fail(reason: string, position?: number): never { + const lines = code.slice(0, position).split('\n') + const location = + position === undefined + ? id + : { + file: id, + line: lines.length, + column: (lines.at(-1)?.length ?? 0) + 1, + } + throw new AggregateCssError(location, asset, reason) + } + let info: ClosureModule | null + try { + info = await new Promise( + (resolve, reject) => { + const cancel = deadline(() => + reject( + new AggregateCssError( + id, + asset, + 'public load did not settle within 30 seconds; a source transform awaiting generated CSS creates a preparation cycle', + ), + ), + ) + Promise.resolve() + .then(() => context.load({ id, resolveDependencies: false })) + .then(resolve, reject) + .finally(cancel) + }, + ) + } catch (cause) { + fail( + `public load refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if ( + !info || + typeof info.code !== 'string' || + !Array.isArray(info.importedIds) || + !Array.isArray(info.dynamicallyImportedIds) + ) { + fail( + 'public load returned null or incomplete transformed module information', + ) + } + const dependencies = [ + ...info.importedIds, + ...info.dynamicallyImportedIds, + ] + code = info.code + let hasImports: boolean + try { + const ast = context.parse(code) + if ( + !ast || + typeof ast !== 'object' || + !('type' in ast) || + ast.type !== 'Program' + ) + fail('public parser returned incomplete module information') + hasImports = inspectImports(ast, fail) + } catch (cause) { + if (cause instanceof AggregateCssError) throw cause + fail( + `public parser refused the transformed module: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + } + if (hasImports && dependencies.length === 0) + fail('public load returned an unresolved import graph') + for (const dependency of dependencies) await walk(dependency, id) + } + if (entries.size === 0) + throw new AggregateCssError( + asset, + asset, + 'the public build input list is empty', + ) + for (const entry of entries) { + const resolved = await resolveModule(entry) + if (!resolved || resolved.external) + throw new AggregateCssError( + entry, + asset, + 'the public build entry could not be resolved to an internal module', + ) + await walk(resolved.id, entry) + } + state.completed = { ids: visited, asset } + })() + return state.promise + }, + observe(environment: object, id: string) { + const completed = environments.get(environment)?.completed + if (completed && !completed.ids.has(id)) { + throw new AggregateCssError( + id, + completed.asset, + 'a plugin introduced a module after the aggregate closure completed; expose its entry/imports before the first generated CSS load', + ) + } + }, + checkAsset( + environment: object, + asset: { readonly name?: string; readonly fileName: string }, + ) { + const state = environments.get(environment) + if (state && /^devup-ui(-\d+)?\.css$/.test(asset.name ?? '')) { + throw new AggregateCssError( + state.entries[0] ?? asset.fileName, + asset.fileName, + 'the native split library emitted a standalone generated stylesheet that the shared-sheet output contract rewrites after optimization; use build.cssCodeSplit=false for this library', + ) + } + }, + } +} diff --git a/packages/vite-plugin/src/aggregate-imports.ts b/packages/vite-plugin/src/aggregate-imports.ts new file mode 100644 index 00000000..562eccfb --- /dev/null +++ b/packages/vite-plugin/src/aggregate-imports.ts @@ -0,0 +1,47 @@ +export function inspectImports( + ast: unknown, + fail: (reason: string, position?: number) => never, +): boolean { + if (!ast || typeof ast !== 'object') return false + if (Array.isArray(ast)) + return ast.map((node) => inspectImports(node, fail)).some(Boolean) + let imports = false + if ('type' in ast && ast.type === 'ImportExpression') { + const source = 'source' in ast ? ast.source : undefined + if (!source || typeof source !== 'object' || !('type' in source)) { + fail('public parser returned an incomplete dynamic import') + } + if ( + !( + source.type === 'Literal' && + 'value' in source && + typeof source.value === 'string' + ) && + !( + source.type === 'TemplateLiteral' && + 'expressions' in source && + Array.isArray(source.expressions) && + source.expressions.length === 0 + ) + ) { + fail( + 'computed dynamic import has no knowable resolved closure; replace it with literal imports or import.meta.glob', + 'start' in ast && typeof ast.start === 'number' ? ast.start : undefined, + ) + } + imports = true + } + if ( + 'type' in ast && + (ast.type === 'ImportDeclaration' || + ast.type === 'ExportAllDeclaration' || + ast.type === 'ExportNamedDeclaration') && + 'source' in ast && + ast.source + ) + imports = true + for (const value of Object.values(ast)) { + if (inspectImports(value, fail)) imports = true + } + return imports +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 050e666d..bece9d3c 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -52,6 +52,8 @@ import type { UserConfig, } from 'vite' +import { createAggregateCssPreparation } from './aggregate-css' + /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... * @@ -308,6 +310,7 @@ export function DevupUI({ } } const cssMap = new Map() + const aggregateCss = createAggregateCssPreparation() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling // plugin once @vitejs/plugin-rsc has forwarded. @@ -348,6 +351,7 @@ export function DevupUI({ resolveFallbackPaths() const fileName = id.split('?')[0] if (excludeModules.test(fileName)) return + aggregateCss.observe(this.environment ?? plugin, id) const environmentConditions = this.environment?.config.resolve.conditions if (environmentConditions) { setModuleResolver( @@ -586,7 +590,11 @@ export function DevupUI({ noExternal: [...include, /@devup-ui/, /@devup-editor/], }, } - if (extractCss) { + if ( + extractCss && + !userConfig?.build?.lib && + userConfig?.build?.cssCodeSplit !== false + ) { ret.build = createCssChunkBuildOptions(this?.meta, userConfig) } return ret @@ -597,6 +605,14 @@ export function DevupUI({ closeBundle(this: void) { endBuild() }, + buildStart(options) { + if (isServe || !extractCss) return + aggregateCss.start( + this.environment ?? plugin, + options.input, + this.environment?.config.build ?? resolvedConfig?.build ?? {}, + ) + }, async watchChange(this: void, id) { resolveFallbackPaths() if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { @@ -705,10 +721,18 @@ export function DevupUI({ load(id) { const fileName = basename(id).split('?')[0] if (DEVUP_CSS_FILE_RE.test(fileName)) { - const fileNum = getFileNumByFilename(fileName) - const css = getCss(fileNum, false) - cssMap.set(fileNum, css) - return css + const snapshot = () => { + const fileNum = getFileNumByFilename(fileName) + const css = getCss(fileNum, false) + cssMap.set(fileNum, css) + return css + } + const preparation = aggregateCss.prepare( + this.environment ?? plugin, + this, + id, + ) + return preparation ? preparation.then(snapshot) : snapshot() } }, enforce: 'pre', @@ -775,10 +799,26 @@ export function DevupUI({ return sourceTransform.transform.call(this, code, id) }, } satisfies Plugin - const plugins: [typeof plugin, typeof restorePlugin, typeof mdxPlugin] = [ - plugin, - restorePlugin, - mdxPlugin, - ] + const aggregateGuard = { + name: 'devup-ui:aggregate-css-guard', + sharedDuringBuild: true, + apply: 'build', + generateBundle: { + order: 'post', + handler(_options, bundle) { + for (const asset of Object.values(bundle)) { + if (asset.type === 'asset') { + aggregateCss.checkAsset(this.environment ?? plugin, asset) + } + } + }, + }, + } satisfies Plugin + const plugins: [ + typeof plugin, + typeof restorePlugin, + typeof mdxPlugin, + typeof aggregateGuard, + ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard] return plugins } From 394a04993252748511efd6b917b071cf7d4193a4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 01:44:07 +0900 Subject: [PATCH 30/50] test(bun-plugin): cover internal setup and source callbacks completely Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bunfig.toml | 5 +- .../src/__tests__/callback-fixture.test.ts | 103 +++++ .../src/__tests__/plugin-boundaries.test.ts | 226 +++++++++++ .../bun-plugin/src/__tests__/plugin.test.ts | 360 +++++++++--------- packages/bun-plugin/src/devup-plugin.ts | 286 ++++++++++++++ packages/bun-plugin/src/plugin.ts | 289 +------------- 6 files changed, 801 insertions(+), 468 deletions(-) create mode 100644 packages/bun-plugin/src/__tests__/callback-fixture.test.ts create mode 100644 packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts create mode 100644 packages/bun-plugin/src/devup-plugin.ts diff --git a/bunfig.toml b/bunfig.toml index 7b8f91a1..67ea0904 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,7 +5,4 @@ coverage = true coverageReporter = ["text", "lcov"] coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 } coverageSkipTestFiles = true -# pkg/dist are generated artifacts. bun-plugin/src/plugin.ts registers a Bun -# plugin at import time; testing its private hooks would require exporting -# internals that are not part of the public API. -coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**", "**/bun-plugin/src/plugin.ts"] +coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] diff --git a/packages/bun-plugin/src/__tests__/callback-fixture.test.ts b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts new file mode 100644 index 00000000..17072d91 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts @@ -0,0 +1,103 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { loadDevupConfig } from '@devup-ui/plugin-utils' +import { + registerShorthands, + registerTheme, + resetBuildState, + setDebug, + setPrefix, +} from '@devup-ui/wasm' +import type { + OnEndCallback, + OnLoadCallback, + OnResolveCallback, + PluginConstraints, +} from 'bun' + +import { DevupUI } from '../devup-plugin' + +type Builder = Parameters['setup']>[0] + +export function fixture() { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-bun-callback-')), + ) + const loads: { constraints: PluginConstraints; callback: OnLoadCallback }[] = + [] + const resolves: { + constraints: PluginConstraints + callback: OnResolveCallback + }[] = [] + const ends: OnEndCallback[] = [] + const builder = { + onLoad(constraints, callback) { + loads.push({ constraints, callback }) + return this + }, + onResolve(constraints, callback) { + resolves.push({ constraints, callback }) + return this + }, + onEnd(callback) { + ends.push(callback) + return this + }, + } satisfies Builder + return { + root, + builder, + loads, + resolves, + write(name: string, contents: string) { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + return path + }, + async setup( + options: Parameters[0] = {}, + config?: Builder['config'], + ) { + await DevupUI({ root, ...options }).setup({ + ...builder, + ...(config ? { config } : {}), + }) + }, + async load(path: string, namespace = 'file', defer = async () => {}) { + const hook = loads.find( + ({ constraints }) => + (constraints.namespace ?? 'file') === namespace && + constraints.filter.test(path), + ) + if (!hook) throw new Error(`No load hook for ${namespace}:${path}`) + return hook.callback({ path, namespace, loader: 'tsx', defer }) + }, + async cleanup() { + for (const end of ends) + await end({ outputs: [], logs: [], success: true }) + resetBuildState() + registerShorthands({}) + setPrefix(null) + registerTheme( + (await loadDevupConfig(join(process.cwd(), 'devup.json'))).theme ?? {}, + ) + setDebug(true) + rmSync(root, { recursive: true, force: true }) + }, + } +} + +export function sourceContents(result: Awaited>) { + if (!result || !('contents' in result) || typeof result.contents !== 'string') + throw new TypeError('Expected source contents') + return result.contents +} diff --git a/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts new file mode 100644 index 00000000..c87da8ca --- /dev/null +++ b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts @@ -0,0 +1,226 @@ +import { symlinkSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { fixture, sourceContents } from './callback-fixture.test' + +it('warns once and still extracts real files when optional numbering fails', async () => { + const f = fixture() + const path = f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '751px' })", + ) + const cause = new Error('optional seed failure') + const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => { + throw cause + }) + const warning = spyOn(console, 'warn').mockImplementation(() => {}) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + expect(warning).toHaveBeenCalledTimes(1) + expect(warning.mock.calls[0]?.[1]).toEqual({ + root: f.root, + phase: 'seed', + impact: 'arrival-order file IDs', + cause, + }) + expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('751px') + } finally { + seed.mockRestore() + warning.mockRestore() + await f.cleanup() + } +}) + +it('propagates non-Error numbering faults rather than converting them to warnings', async () => { + const f = fixture() + f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '757px' })", + ) + const cause = { code: 'seed-fault' } + const seed = spyOn(wasm, 'seedFileMap').mockImplementationOnce(() => { + throw cause + }) + try { + await expect(f.setup()).rejects.toBe(cause) + } finally { + seed.mockRestore() + await f.cleanup() + } +}) + +it.each(['unrelated', 'styling'])( + 'handles %s MDX without a project compiler', + async (scenario) => { + const f = fixture() + f.write( + 'node_modules/@mdx-js/mdx/package.json', + '{"exports":"./missing.js"}', + ) + const path = f.write( + 'page.mdx', + scenario === 'styling' + ? "import { Box } from '@devup-ui/react'\n\n" + : '# title', + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + if (scenario === 'styling') + await expect(f.load(path)).rejects.toThrow('page.mdx:1:1') + else expect(await f.load(path)).toBeUndefined() + } finally { + await f.cleanup() + } + }, +) + +it.each(['plain', 'styling', 'invalid'])( + 'compiles real %s MDX with the project compiler before scanning/extraction', + async (scenario) => { + const f = fixture() + const host = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), + ) + const compiler = createRequire(host.resolve('@mdx-js/loader')).resolve( + '@mdx-js/mdx', + ) + f.write('node_modules/@mdx-js/placeholder', '') + symlinkSync( + dirname(compiler), + join(f.root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + const path = f.write( + 'src/page.mdx', + { + plain: '# heading\n\n
', + styling: + "import { Box } from '@devup-ui/react'\n\n# heading\n\n", + invalid: + "import { css } from '@devup-ui/react'\n\nexport const cls = css({ width: unknownWidth })\n\n# heading", + }[scenario], + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + if (scenario === 'invalid') + await expect(f.load(path)).rejects.toThrow('(in compiled MDX)') + else { + const result = await f.load(path) + expect(result?.loader).toBe('jsx') + expect(sourceContents(result)).toContain('heading') + if (scenario === 'styling') { + expect(sourceContents(result)).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('761px') + } + } + } finally { + await f.cleanup() + } + }, + 20000, +) + +it('uses compiler source maps for located extraction errors', async () => { + const f = fixture() + f.write( + 'node_modules/@mdx-js/mdx/package.json', + '{"type":"module","exports":"./index.js"}', + ) + f.write( + 'node_modules/@mdx-js/mdx/index.js', + `export async function compile(input) { + return { value: "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })", map: { version: 3, sources: [input.path], names: [], mappings: 'AAIA' } } + }`, + ) + const path = f.write('page.mdx', '# title') + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + await expect(f.load(path)).rejects.toThrow(`${path}:5:1`) + } finally { + await f.cleanup() + } +}) + +it.each([ + { target: 'browser', conditions: ['custom'], width: '773px' }, + { target: 'node', conditions: 'custom', width: '773px' }, + { target: 'node', conditions: [], width: '787px' }, + { target: 'bun', width: '797px' }, +] as const)( + 'selects compile-time conditional exports using $target and $conditions', + async (config) => { + const f = fixture() + f.write( + 'node_modules/conditional/package.json', + JSON.stringify({ + type: 'module', + exports: { + custom: './custom.js', + bun: './bun.js', + node: './node.js', + browser: './browser.js', + }, + }), + ) + for (const [branch, width] of Object.entries({ + custom: '773px', + node: '787px', + bun: '797px', + browser: '809px', + })) + f.write( + `node_modules/conditional/${branch}.js`, + `export const width = '${width}'`, + ) + const path = f.write( + 'style.ts', + "import { css } from '@devup-ui/react'; import { width } from 'conditional'; export const cls = css({ width })", + ) + try { + await f.setup( + {}, + { + entrypoints: [path], + plugins: [], + target: config.target, + ...(config.conditions !== undefined + ? { + conditions: + typeof config.conditions === 'string' + ? config.conditions + : [...config.conditions], + } + : {}), + }, + ) + await f.load(path) + expect(wasm.getCss(null, false)).toContain(config.width) + } finally { + await f.cleanup() + } + }, +) + +it('includes configured libraries in the native-path runtime filter', async () => { + const f = fixture() + f.write( + 'node_modules/library/package.json', + '{"type":"module","exports":"./index.mjs"}', + ) + const path = f.write( + 'node_modules/library/index.mjs', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '811px' })", + ) + try { + await f.setup({ include: ['library'] }) + expect(sourceContents(await f.load(path))).not.toContain('@devup-ui/react') + expect(wasm.getCss(null, false)).toContain('811px') + } finally { + await f.cleanup() + } +}) diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index e93d6741..5e33170c 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -1,199 +1,201 @@ -import * as fs from 'node:fs' -import * as fsPromises from 'node:fs/promises' +import { readFileSync, statSync, utimesSync } from 'node:fs' import { join } from 'node:path' -import * as wasm from '@devup-ui/wasm' -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' +import { getCss, setDebug, setModuleResolver } from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' -let getDefaultThemeSpy: ReturnType -let existsSyncSpy: ReturnType -let readFileSpy: ReturnType -let writeFileSpy: ReturnType -let mkdirSpy: ReturnType -let registerThemeSpy: ReturnType -let getThemeInterfaceSpy: ReturnType -let getCssSpy: ReturnType -let consoleErrorSpy: ReturnType -let setDebugSpy: ReturnType -let hasDevupUISpy: ReturnType -let codeExtractSpy: ReturnType +import { cssNamespace } from '../css-id' +import { fixture, sourceContents } from './callback-fixture.test' -type CodeExtractResult = ReturnType - -function createCodeExtractResult(): CodeExtractResult { - return { - code: 'code', - css: '', - cssFile: null, - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult -} - -beforeEach(() => { - getDefaultThemeSpy = spyOn(wasm, 'getDefaultTheme').mockReturnValue('default') - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') - writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) - mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) - registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) - getThemeInterfaceSpy = spyOn(wasm, 'getThemeInterface').mockReturnValue('') - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css') - consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) - setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) - hasDevupUISpy = spyOn(wasm, 'hasDevupUI').mockReturnValue(false) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( - createCodeExtractResult(), +it('writes theme declarations and CSS when setup creates project directories', async () => { + const f = fixture() + f.write( + 'devup.json', + '{"theme":{"colors":{"default":{"primary":"#abcdef"}}}}', ) + try { + await f.setup({ shorthands: { insetX: ['left', 'right'] } }) + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain( + 'primary', + ) + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).toContain( + 'insetX', + ) + expect(readFileSync(join(f.root, 'df/compat.d.ts'), 'utf8')).toContain( + '@devup-ui/react/compat/emotion', + ) + expect(readFileSync(join(f.root, 'df/.gitignore'), 'utf8')).toBe('*') + expect( + readFileSync( + join(f.root, 'df/devup-ui/devup-ui.css'), + 'utf8', + ).toLowerCase(), + ).toContain('#abcdef') + } finally { + await f.cleanup() + } }) -afterEach(() => { - getDefaultThemeSpy.mockRestore() - existsSyncSpy.mockRestore() - readFileSpy.mockRestore() - writeFileSpy.mockRestore() - mkdirSpy.mockRestore() - registerThemeSpy.mockRestore() - getThemeInterfaceSpy.mockRestore() - getCssSpy.mockRestore() - consoleErrorSpy.mockRestore() - setDebugSpy.mockRestore() - hasDevupUISpy.mockRestore() - codeExtractSpy.mockRestore() +it('overwrites stale declarations when configuration has no theme and directories exist', async () => { + const f = fixture() + f.write('devup.json', '{}') + f.write('df/theme.d.ts', 'stale-theme') + f.write('df/devup-ui/devup-ui.css', 'stale-css') + try { + await f.setup() + expect(readFileSync(join(f.root, 'df/theme.d.ts'), 'utf8')).not.toContain( + 'stale-theme', + ) + expect( + readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8'), + ).not.toContain('stale-css') + } finally { + await f.cleanup() + } }) -describe('getDevupDefine', () => { - it('should return define object with theme', async () => { - getDefaultThemeSpy.mockReturnValue('dark') - expect(getDefaultThemeSpy()).toBe('dark') - }) - - it('should return empty object when no theme', async () => { - getDefaultThemeSpy.mockReturnValue(undefined) - expect(getDefaultThemeSpy()).toBe(undefined) - }) +it('publishes changed CSS synchronously but leaves identical revisions untouched', async () => { + const f = fixture() + const path = f.write( + 'src/style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '719px' })", + ) + const tokens = f.write('src/tokens.ts', "export const width = '727px'") + const plain = f.write('src/plain.ts', 'export const value = 1') + try { + await f.setup() + const result = await f.load(path) + expect(sourceContents(result)).not.toContain('@devup-ui/react') + const cssPath = join(f.root, 'df/devup-ui/devup-ui.css') + expect(readFileSync(cssPath, 'utf8')).toContain('width:719px') + utimesSync(cssPath, 1, 1) + const before = statSync(cssPath).mtimeMs + await f.load(path) + expect(statSync(cssPath).mtimeMs).toBe(before) + expect( + f.loads + .find((h) => !h.constraints.namespace) + ?.constraints.filter.test(tokens), + ).toBe(false) + expect( + f.loads + .find((h) => !h.constraints.namespace) + ?.constraints.filter.test(plain), + ).toBe(false) + expect( + await f.load('devup-ui.css', cssNamespace, () => { + throw new Error('Runtime must not defer') + }), + ).toEqual({ contents: '', loader: 'js' }) + } finally { + await f.cleanup() + } }) -describe('writeDataFiles behavior', () => { - it('should register theme from devup.json when it exists', async () => { - readFileSpy.mockResolvedValue('{"theme": {"colors": {"primary": "#000"}}}') - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') - - // Simulate writeDataFiles behavior without exporting private plugin helpers. - const content = '{"theme": {"colors": {"primary": "#000"}}}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) - - expect(registerThemeSpy).toHaveBeenCalledWith({ - colors: { primary: '#000' }, - }) - }) - - it('should write theme.d.ts when interfaceCode is returned', async () => { - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') +it('preserves compile-time dependency imports and restores debug/resolver before extraction', async () => { + const f = fixture() + f.write('src/tokens.ts', "export const width = '733px'") + const path = f.write( + 'src/style.ts', + "import { style } from '@vanilla-extract/css'; import { width } from './tokens'; export const cls = style({ width })", + ) + try { + await f.setup({ debug: true }) + setDebug(false) + setModuleResolver(() => ({ + path: join(f.root, 'wrong.ts'), + code: "export const width = '999px'", + })) + const code = sourceContents(await f.load(path)) + expect(code).toContain('733px') + expect(code).toContain('tokens.ts') + expect(getCss(null, false)).toContain('width:733px') + expect(getCss(null, false)).not.toContain('999px') + } finally { + await f.cleanup() + } +}) - const interfaceCode = getThemeInterfaceSpy( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'CustomLength', - 'CustomShadows', - 'DevupTheme', +it('defers bundled CSS until source callbacks complete and does not publish it to disk', async () => { + const f = fixture() + const path = f.write( + 'style.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: '739px' })", + ) + try { + await f.setup({}, { entrypoints: [path], root: f.root, plugins: [] }) + const initial = readFileSync( + join(f.root, 'df/devup-ui/devup-ui.css'), + 'utf8', ) - - if (interfaceCode) { - await writeFileSpy(join('df', 'theme.d.ts'), interfaceCode, 'utf-8') - } - - expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), - 'interface CustomColors {}', - 'utf-8', + const defer = spyOn( + { + async run() { + await f.load(path) + }, + }, + 'run', ) - }) - - it('should register empty theme when devup.json does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the missing config branch. - const content = undefined - if (!content) { - registerThemeSpy({}) - } - - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should handle error and register empty theme on catch', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - readFileSpy.mockRejectedValue(new Error('Read error')) - - try { - await readFileSpy('devup.json', 'utf-8') - } catch (error) { - consoleErrorSpy(error) - registerThemeSpy({}) - } - - expect(consoleErrorSpy).toHaveBeenCalled() - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should handle JSON without theme key', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - - const content = '{"otherKey": "value"}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) - - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should create css directory when it does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the directory creation branch without exposing internals. - if (!existsSyncSpy('df/devup-ui')) { - await mkdirSpy('df/devup-ui', { recursive: true }) - } - - expect(mkdirSpy).toHaveBeenCalledWith('df/devup-ui', { recursive: true }) - }) + const css = await f.load('devup-ui.css', cssNamespace, defer) + expect(defer).toHaveBeenCalledTimes(1) + expect(sourceContents(css)).toContain('739px') + expect(readFileSync(join(f.root, 'df/devup-ui/devup-ui.css'), 'utf8')).toBe( + initial, + ) + const resolve = f.resolves[0] + if (!resolve) throw new Error('Expected CSS resolver') + expect( + await resolve.callback({ + path: './df/devup-ui/devup-ui.css', + importer: path, + namespace: 'file', + resolveDir: f.root, + kind: 'import-statement', + }), + ).toEqual({ path: 'devup-ui.css', namespace: cssNamespace }) + } finally { + await f.cleanup() + } }) -describe('plugin preload coverage', () => { - // The plugin is preloaded via bunfig.toml, which covers the main execution paths - // These tests verify the plugin's behavior through its side effects - - it('should have called setDebug during initialization', () => { - // Plugin calls setDebug(true) in setup - // This is covered by the preload - expect(setDebugSpy).toBeDefined() - }) - - it('should have registered theme during initialization', () => { - // Plugin calls registerTheme during writeDataFiles - // This is covered by the preload - expect(registerThemeSpy).toBeDefined() - }) +it('passes ordinary modules through the bundler source callback', async () => { + const f = fixture() + const path = f.write('plain.mts', 'export const value = 1') + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + expect(await f.load(path)).toEqual({ + contents: 'export const value = 1', + loader: 'ts', + }) + } finally { + await f.cleanup() + } +}) - it('should resolve devup-ui css path', async () => { - // This import triggers the onResolve callback for CSS files +it.each(['{', '{"extends":["./devup.json"]}'])( + 'rejects malformed configuration %s instead of silently registering an empty theme', + async (config) => { + const f = fixture() + f.write('devup.json', config) try { - await import('df/devup-ui/devup-ui.css') - } catch { - // File may not exist, but the resolver still runs + await expect(f.setup()).rejects.toThrow(join(f.root, 'devup.json')) + } finally { + await f.cleanup() } - expect(true).toBe(true) - }) + }, +) + +it('propagates source read and extraction errors', async () => { + const f = fixture() + const path = f.write( + 'bad.ts', + "import { css } from '@devup-ui/react'; export const cls = css({ width: unknownWidth })", + ) + try { + await f.setup({}, { entrypoints: [path], plugins: [] }) + await expect(f.load(join(f.root, 'missing.ts'))).rejects.toThrow('ENOENT') + await expect(f.load(path)).rejects.toThrow('bad.ts:') + } finally { + await f.cleanup() + } }) diff --git a/packages/bun-plugin/src/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts new file mode 100644 index 00000000..984a7494 --- /dev/null +++ b/packages/bun-plugin/src/devup-plugin.ts @@ -0,0 +1,286 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { basename, dirname, join, relative, resolve } from 'node:path' + +import { + beginBuild, + collectNumberedFiles, + createCompatTypes, + createModuleResolver, + createThemeInterfaceArgs, + type CustomShorthands, + GRAPH_SOURCE_FILE_RE, + listSourceFiles, + loadDevupConfig, + MDX_FILE_RE, + remapMdxError, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, +} from '@devup-ui/plugin-utils' +import { + codeExtract, + getCss, + getThemeInterface, + registerShorthands, + registerTheme, + resetBuildState, + seedFileMap, + setDebug, + setModuleResolver, + setPrefix, +} from '@devup-ui/wasm' +import { type BunPlugin, type PluginBuilder } from 'bun' + +import { cssNamespace, resolveCssId } from './css-id' +import { compileMdx } from './mdx' +import { + compiledPackages, + importAliases, + importsCompiledPackage, + libPackage, + mentionsCompiledPackage, + preserveDependencies, + runtimeSourceFilter, + sourceLoader, +} from './source' + +const singleCss = true + +type PluginHooks = { + readonly config?: PluginBuilder['config'] + readonly onEnd?: (...args: Parameters) => void + readonly onLoad: (...args: Parameters) => void + readonly onResolve: (...args: Parameters) => void +} + +export interface DevupUIBunPluginOptions { + shorthands?: CustomShorthands + /** Resolved against Bun.build's root, or cwd in the runtime. */ + root?: string + devupFile?: string + distDir?: string + sourceDirs?: string | string[] + /** Uncompiled dependencies to transform under the runtime. */ + include?: string[] + /** Readable class names. Defaults to false in both runtime and builds. */ + debug?: boolean +} + +type Project = ReturnType & { + readonly root: string + readonly debug: boolean + readonly resolver: ReturnType +} + +async function writeDataFiles({ devupFile, distDir, cssDir }: Project) { + const config = await loadDevupConfig(devupFile) + registerTheme(config.theme ?? {}) + + // Generate theme interface after registration (always write, even if empty) + await writeFile( + join(distDir, 'theme.d.ts'), + getThemeInterface(...createThemeInterfaceArgs(libPackage)), + 'utf-8', + ) + + if (!existsSync(cssDir)) { + await mkdir(cssDir, { recursive: true }) + } + await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') +} + +async function initialize(project: Project, options: DevupUIBunPluginOptions) { + const { root, distDir } = project + registerShorthands(options.shorthands ?? {}) + setPrefix(null) + setModuleResolver(project.resolver) + // Number every source file in path order, so class prefixes do not depend + // on the order Bun loads files in + try { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: true, + cwd: root, + include: options.include, + needles: compiledPackages, + toId: (path) => path, + }), + ) + } catch (cause) { + if (!(cause instanceof Error)) throw cause + console.warn('[devup-ui] File numbering fallback', { + root, + phase: 'seed', + impact: 'arrival-order file IDs', + cause, + }) + } + if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) + await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') + await writeFile( + join(distDir, 'compat.d.ts'), + createCompatTypes(importAliases), + 'utf-8', + ) + await writeDataFiles(project) +} + +async function loadSourceFile(filePath: string, project: Project) { + const original = await Bun.file(filePath).text() + const mdx = MDX_FILE_RE.test(filePath) + ? await compileMdx(project.root, filePath, original) + : undefined + if (MDX_FILE_RE.test(filePath) && !mdx) return undefined + const loader = mdx ? 'jsx' : sourceLoader(filePath) + const contents = mdx?.value ?? original + + if (importsCompiledPackage(contents, loader)) { + setDebug(project.debug) + setModuleResolver(project.resolver) + try { + const code = codeExtract( + filePath, + contents, + libPackage, + relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), + singleCss, + true, + false, + importAliases, + ) + return { + contents: preserveDependencies(code.code, filePath, code.dependencies), + loader, + } + } catch (cause) { + if (mdx) throw remapMdxError(cause, filePath, mdx.map) + throw cause + } + } + return { contents, loader } +} + +/** + * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the + * Bun runtime ({@link register}). + */ +export function DevupUI(options: DevupUIBunPluginOptions = {}) { + return { + name: 'devup-ui', + + async setup(build: PluginHooks) { + // `Bun.build` hands its config to plugins; the runtime has none + const bundling = build.config !== undefined + const root = resolve(options.root ?? build.config?.root ?? process.cwd()) + const targetConditions = { + browser: ['browser'], + bun: ['bun', 'node'], + node: ['node'], + } as const + const customConditions = + typeof build.config?.conditions === 'string' + ? [build.config.conditions] + : (build.config?.conditions ?? []) + const project: Project = { + ...resolveProjectPaths(root, options), + root, + debug: options.debug ?? false, + resolver: createModuleResolver({ + cwd: root, + // Extracted ESM imports stay import requests even in the vanilla + // CommonJS evaluator. The callback does not expose a request kind. + conditions: [ + ...targetConditions[ + build.config?.target ?? (bundling ? 'browser' : 'bun') + ], + 'import', + ...(bundling ? ['module'] : []), + ...customConditions, + ], + }), + } + // A build starts from its own options, not from what an earlier build in + // this process left in the engine + const endBuild = beginBuild({ resetBuildState }) + build.onEnd?.(endBuild) + await initialize(project, options) + setDebug(project.debug) + let writtenCss = getCss(null, false) + // Native token modules must keep Bun's filesystem watch/CJS loading path. + const sourceFilter = bundling + ? GRAPH_SOURCE_FILE_RE + : new RegExp( + `${ + runtimeSourceFilter([ + ...listSourceFiles(root, [ + 'target', + 'dist', + basename(project.distDir), + '.git', + 'coverage', + ]).filter((file) => + mentionsCompiledPackage(readFileSync(file, 'utf-8')), + ), + ...collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: true, + cwd: root, + include: ['@devup-ui/components', ...(options.include ?? [])], + needles: compiledPackages, + toId: (path) => path, + }), + ]).source + }|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + ) + + // Resolve devup-ui CSS files onto a path-free virtual id, so nothing + // derived from this checkout's cwd can be baked into Bun's shared, + // content-keyed transpiler cache. See ./css-id. + build.onResolve( + { filter: /devup-ui(-\d+)?\.css$/ }, + ({ path, importer }) => + resolveCssId(path, importer, basename(project.distDir)), + ) + + // The bundler takes the stylesheet once every other module is loaded, + // so it holds the styles of all of them. The Bun runtime has no CSS + // loader (`onLoad` only accepts the script/data loaders), so there the + // injected import resolves to an empty module. + build.onLoad( + { filter: /.*/, namespace: cssNamespace }, + async ({ defer }) => { + if (!bundling) return { contents: '', loader: 'js' } + await defer() + return { contents: getCss(null, false), loader: 'css' } + }, + ) + + // Load source files from packages directory (file namespace) + build.onLoad( + { + filter: sourceFilter, + }, + ({ path }) => { + return loadSourceFile(path, project).then((result) => { + if (!bundling && result) { + const css = getCss(null, false) + // Read-after-import is synchronous; only identical revisions coalesce. + if (css !== writtenCss) { + writeFileSync( + join(project.cssDir, 'devup-ui.css'), + css, + 'utf-8', + ) + writtenCss = css + } + } + return result + }) + }, + ) + }, + } satisfies BunPlugin +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index c6437c46..dec7ace5 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,292 +1,11 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative, resolve } from 'node:path' +import { plugin } from 'bun' -import { - beginBuild, - collectNumberedFiles, - createCompatTypes, - createModuleResolver, - createThemeInterfaceArgs, - type CustomShorthands, - GRAPH_SOURCE_FILE_RE, - listSourceFiles, - loadDevupConfig, - MDX_FILE_RE, - remapMdxError, - resolveProjectPaths, - resolveSourceDirs, - seedFileNumbers, -} from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getThemeInterface, - registerShorthands, - registerTheme, - resetBuildState, - seedFileMap, - setDebug, - setModuleResolver, - setPrefix, -} from '@devup-ui/wasm' -import { type BunPlugin, plugin, type PluginBuilder } from 'bun' +import { DevupUI, type DevupUIBunPluginOptions } from './devup-plugin' -import { cssNamespace, resolveCssId } from './css-id' -import { compileMdx } from './mdx' -import { - compiledPackages, - importAliases, - importsCompiledPackage, - libPackage, - mentionsCompiledPackage, - preserveDependencies, - runtimeSourceFilter, - sourceLoader, -} from './source' - -const singleCss = true - -export interface DevupUIBunPluginOptions { - shorthands?: CustomShorthands - /** Resolved against Bun.build's root, or cwd in the runtime. */ - root?: string - devupFile?: string - distDir?: string - sourceDirs?: string | string[] - /** Uncompiled dependencies to transform under the runtime. */ - include?: string[] - /** Readable class names. Defaults to false in both runtime and builds. */ - debug?: boolean -} - -type Project = ReturnType & { - readonly root: string - readonly debug: boolean - readonly resolver: ReturnType -} - -async function writeDataFiles({ devupFile, distDir, cssDir }: Project) { - const config = await loadDevupConfig(devupFile) - registerTheme(config.theme ?? {}) - - // Generate theme interface after registration (always write, even if empty) - await writeFile( - join(distDir, 'theme.d.ts'), - getThemeInterface(...createThemeInterfaceArgs(libPackage)), - 'utf-8', - ) - - if (!existsSync(cssDir)) { - await mkdir(cssDir, { recursive: true }) - } - await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') -} - -async function initialize(project: Project, options: DevupUIBunPluginOptions) { - const { root, distDir } = project - registerShorthands(options.shorthands ?? {}) - setPrefix(null) - setModuleResolver(project.resolver) - // Number every source file in path order, so class prefixes do not depend - // on the order Bun loads files in - try { - seedFileNumbers( - { seedFileMap }, - collectNumberedFiles({ - roots: resolveSourceDirs(root, options.sourceDirs), - includeMdx: true, - cwd: root, - include: options.include, - needles: compiledPackages, - toId: (path) => path, - }), - ) - } catch (cause) { - if (!(cause instanceof Error)) throw cause - console.warn('[devup-ui] File numbering fallback', { - root, - phase: 'seed', - impact: 'arrival-order file IDs', - cause, - }) - } - if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) - await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') - await writeFile( - join(distDir, 'compat.d.ts'), - createCompatTypes(importAliases), - 'utf-8', - ) - await writeDataFiles(project) -} - -async function loadSourceFile(filePath: string, project: Project) { - const original = await Bun.file(filePath).text() - const mdx = MDX_FILE_RE.test(filePath) - ? await compileMdx(project.root, filePath, original) - : undefined - if (MDX_FILE_RE.test(filePath) && !mdx) return undefined - const loader = mdx ? 'jsx' : sourceLoader(filePath) - const contents = mdx?.value ?? original - - if (importsCompiledPackage(contents, loader)) { - setDebug(project.debug) - setModuleResolver(project.resolver) - try { - const code = codeExtract( - filePath, - contents, - libPackage, - relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), - singleCss, - true, - false, - importAliases, - ) - return { - contents: preserveDependencies(code.code, filePath, code.dependencies), - loader, - } - } catch (cause) { - if (mdx) throw remapMdxError(cause, filePath, mdx.map) - throw cause - } - } - return { contents, loader } -} - -/** - * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the - * Bun runtime ({@link register}). - */ -function DevupUI(options: DevupUIBunPluginOptions = {}) { - return { - name: 'devup-ui', - - async setup(build: PluginBuilder) { - // `Bun.build` hands its config to plugins; the runtime has none - const bundling = build.config !== undefined - const root = resolve(options.root ?? build.config?.root ?? process.cwd()) - const targetConditions = { - browser: ['browser'], - bun: ['bun', 'node'], - node: ['node'], - } as const - const customConditions = - typeof build.config?.conditions === 'string' - ? [build.config.conditions] - : (build.config?.conditions ?? []) - const project: Project = { - ...resolveProjectPaths(root, options), - root, - debug: options.debug ?? false, - resolver: createModuleResolver({ - cwd: root, - // Extracted ESM imports stay import requests even in the vanilla - // CommonJS evaluator. The callback does not expose a request kind. - conditions: [ - ...targetConditions[ - build.config?.target ?? (bundling ? 'browser' : 'bun') - ], - 'import', - ...(bundling ? ['module'] : []), - ...customConditions, - ], - }), - } - // A build starts from its own options, not from what an earlier build in - // this process left in the engine - const endBuild = beginBuild({ resetBuildState }) - build.onEnd?.(endBuild) - await initialize(project, options) - setDebug(project.debug) - let writtenCss = getCss(null, false) - // Native token modules must keep Bun's filesystem watch/CJS loading path. - const sourceFilter = bundling - ? GRAPH_SOURCE_FILE_RE - : new RegExp( - `${ - runtimeSourceFilter([ - ...listSourceFiles(root, [ - 'target', - 'dist', - basename(project.distDir), - '.git', - 'coverage', - ]).filter((file) => - mentionsCompiledPackage(readFileSync(file, 'utf-8')), - ), - ...collectNumberedFiles({ - roots: resolveSourceDirs(root, options.sourceDirs), - includeMdx: true, - cwd: root, - include: ['@devup-ui/components', ...(options.include ?? [])], - needles: compiledPackages, - toId: (path) => path, - }), - ]).source - }|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, - ) - - // Resolve devup-ui CSS files onto a path-free virtual id, so nothing - // derived from this checkout's cwd can be baked into Bun's shared, - // content-keyed transpiler cache. See ./css-id. - build.onResolve( - { filter: /devup-ui(-\d+)?\.css$/ }, - ({ path, importer }) => - resolveCssId(path, importer, basename(project.distDir)), - ) - - // The bundler takes the stylesheet once every other module is loaded, - // so it holds the styles of all of them. The Bun runtime has no CSS - // loader (`onLoad` only accepts the script/data loaders), so there the - // injected import resolves to an empty module. - build.onLoad( - { filter: /.*/, namespace: cssNamespace }, - async ({ defer }) => { - if (!bundling) return { contents: '', loader: 'js' } - await defer() - return { contents: getCss(null, false), loader: 'css' } - }, - ) - - // Load source files from packages directory (file namespace) - build.onLoad( - { - filter: sourceFilter, - }, - ({ path }) => { - return loadSourceFile(path, project).then((result) => { - if (!bundling && result) { - const css = getCss(null, false) - // Read-after-import is synchronous; only identical revisions coalesce. - if (css !== writtenCss) { - writeFileSync( - join(project.cssDir, 'devup-ui.css'), - css, - 'utf-8', - ) - writtenCss = css - } - } - return result - }) - }, - ) - }, - } satisfies BunPlugin -} - -// Registers the Bun runtime plugin. Returns the promise produced by `plugin()` -// (its `setup` is async), so callers MUST `await` it. Bun's preload mechanism -// waits for an awaited module evaluation to settle; awaiting this guarantees -// the `onLoad` hook is installed before any source file is loaded. Without the -// await, preload-driven `bun test` users race the async setup and load sources -// against the @devup-ui/react runtime stubs (throwing "Cannot run on the -// runtime"). +// Await async setup before runtime imports, including Bun preload evaluation. function register(options: DevupUIBunPluginOptions = {}) { return plugin(DevupUI(options)) } +export type { DevupUIBunPluginOptions } from './devup-plugin' export { DevupUI, plugin, register } From abaf1b539ce15c2c392e652d799cdbbc752a2c3a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 01:54:00 +0900 Subject: [PATCH 31/50] docs(vite-plugin): describe complete aggregate CSS and located limits Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/vite-plugin/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index e53cda7d..a9fe1dac 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -201,6 +201,30 @@ An uncompiled library is not standalone browser JavaScript. Using it without the consumer transformation runs placeholders and throws `Cannot run on the runtime`. Do not publish it as precompiled output. +### Aggregated Library CSS + +Library builds and applications using `build.cssCodeSplit: false` prepare the +actual resolved build inputs and their static/dynamic dependencies before any +generated stylesheet is loaded. MDX still runs after the configured compiler. +Unreachable source files contribute no CSS, and complete styles pass through +Vite's normal CSS processing and optimizer in their original import positions. +Ordinary split application builds and development behavior are unchanged. + +If a public bundler hook cannot expose a complete transformed graph, the build +fails with the responsible source/stylesheet and a supported alternative instead +of emitting incomplete CSS. Remaining computed imports must use literal imports +or `import.meta.glob`; plugins must expose additional inputs before the first +generated stylesheet loads. A source transform that awaits generated CSS can +form a circular wait, so unsettled public loads fail after 30 seconds; an +exceptionally slow healthy transform can also reach that limit. + +Some split libraries with plugin-emitted entries produce a standalone generated +stylesheet that cannot retain optimizer output under the shared-sheet contract. +These builds fail with the final asset name. Use `build.cssCodeSplit: false` for +that library, or publish separately imported, precompiled CSS. The real-build +matrix is qualified with Vite 8.3.1 / Rolldown 1.2.10; no private adapter or +bundler version pin is required. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target From d05fac11786dff516e3bce40d0ea81075174f58b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 05:16:19 +0900 Subject: [PATCH 32/50] feat(plugin-utils): prepare graph sources with shared selection aliases and exclusions Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/bun-plugin/src/devup-plugin.ts | 18 +- .../src/__tests__/directory-exclusion.test.ts | 233 +++++++++ .../src/__tests__/graph-exclude.test.ts | 6 +- .../src/__tests__/import-mask.test.ts | 55 +++ .../src/__tests__/module-alias.test.ts | 221 +++++++++ .../src/__tests__/numbering.test.ts | 6 +- .../src/__tests__/prepared-compiler.test.ts | 221 +++++++++ .../__tests__/prepared-diagnostics.test.ts | 17 + .../src/__tests__/prepared-graph-fixture.ts | 39 ++ .../src/__tests__/prepared-graph.test.ts | 208 ++++++++ .../src/__tests__/shared-api.test.ts | 170 +++++++ .../plugin-utils/src/directory-exclusion.ts | 30 ++ packages/plugin-utils/src/import-graph.ts | 449 ++++++++++++------ packages/plugin-utils/src/import-mask.ts | 178 +++++++ packages/plugin-utils/src/index.ts | 5 + packages/plugin-utils/src/module-alias.ts | 33 ++ packages/plugin-utils/src/numbering.ts | 9 +- .../plugin-utils/src/prepared-diagnostics.ts | 26 + packages/plugin-utils/src/source-selection.ts | 27 ++ .../src/__tests__/aggregate-unknown.test.ts | 69 ++- .../vite-plugin/src/__tests__/plugin.test.ts | 12 +- 21 files changed, 1862 insertions(+), 170 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/directory-exclusion.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-mask.test.ts create mode 100644 packages/plugin-utils/src/__tests__/module-alias.test.ts create mode 100644 packages/plugin-utils/src/__tests__/prepared-compiler.test.ts create mode 100644 packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts create mode 100644 packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts create mode 100644 packages/plugin-utils/src/__tests__/prepared-graph.test.ts create mode 100644 packages/plugin-utils/src/__tests__/shared-api.test.ts create mode 100644 packages/plugin-utils/src/directory-exclusion.ts create mode 100644 packages/plugin-utils/src/import-mask.ts create mode 100644 packages/plugin-utils/src/module-alias.ts create mode 100644 packages/plugin-utils/src/prepared-diagnostics.ts create mode 100644 packages/plugin-utils/src/source-selection.ts diff --git a/packages/bun-plugin/src/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts index 984a7494..b8a3263e 100644 --- a/packages/bun-plugin/src/devup-plugin.ts +++ b/packages/bun-plugin/src/devup-plugin.ts @@ -215,13 +215,17 @@ export function DevupUI(options: DevupUIBunPluginOptions = {}) { : new RegExp( `${ runtimeSourceFilter([ - ...listSourceFiles(root, [ - 'target', - 'dist', - basename(project.distDir), - '.git', - 'coverage', - ]).filter((file) => + ...listSourceFiles( + root, + [ + 'target', + 'dist', + basename(project.distDir), + '.git', + 'coverage', + ], + { includeMdx: true }, + ).filter((file) => mentionsCompiledPackage(readFileSync(file, 'utf-8')), ), ...collectNumberedFiles({ diff --git a/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts new file mode 100644 index 00000000..604a38c3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/directory-exclusion.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' + +import { createDirectoryExclusion } from '../directory-exclusion' +import { buildStaticImportGraph, listSourceFiles } from '../import-graph' +import { collectNumberedFiles } from '../numbering' + +let root: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + fs.mkdirSync(parent, { recursive: true }) + root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'exclude-'))) +}) +afterEach(() => fs.rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, source) + return target +} + +it('matches bare names at every depth and absolute directories only within their boundary', () => { + const match = createDirectoryExclusion( + ['generated', '/project/build/next'], + 'linux', + ) + expect(match('/project/src/nested/generated')).toBe(true) + expect(match('/project/build/next/deep')).toBe(true) + expect(match('/project/other/next')).toBe(false) + expect(match('/project/build/next-other')).toBe(false) + expect(match('/project/Generated')).toBe(false) +}) + +it('normalizes Windows separators, drive case and directory case but retains boundaries', () => { + const match = createDirectoryExclusion( + ['Generated', 'C:/Project/build/Next/'], + 'win32', + ) + expect(match('c:\\PROJECT\\BUILD\\next\\deep')).toBe(true) + expect(match('D:/project/deep/GENERATED')).toBe(true) + expect(match('C:/Project/other/Next')).toBe(false) + expect(match('c:/project/build/next-other')).toBe(false) + expect(createDirectoryExclusion(['C:/'], 'win32')('c:/project')).toBe(true) +}) + +it('keeps a same-basename source folder while never reading, parsing, preparing or numbering excluded source', async () => { + const entry = file( + 'src/main.ts', + "import '../build/next/poison'\nimport './next/value'\nimport './nested/generated/poison'\nimport 'provider'\nimport 'included'", + ) + const kept = file('src/next/value.ts') + const poison = file('build/next/poison.ts', "import 'invalid-manifest'") + file('src/nested/generated/poison.ts', "import 'invalid-manifest'") + file('node_modules/invalid-manifest/package.json', '{') + file('node_modules/included/package.json', '{"main":"index.js"}') + file('node_modules/included/index.js', "import 'invalid-manifest'") + const excluded = [ + dirname(poison), + 'generated', + join(root, 'node_modules/included'), + ] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, thisArg, args) { + if ( + typeof args[0] === 'string' && + /poison|included[/\\]index/.test(args[0]) + ) + throw new Error('Excluded source read') + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + const options = { + cwd: root, + exclude: excluded, + alias: { provider: poison }, + include: ['included'], + } + const raw = buildStaticImportGraph('src', undefined, options) + const visits: string[] = [] + const prepared = await buildStaticImportGraph('src', undefined, { + ...options, + prepareSource: (filename) => { + visits.push(filename) + return undefined + }, + }) + expect(raw.files).toEqual([entry, kept].sort()) + expect(prepared.files).toEqual(raw.files) + expect(visits.sort()).toEqual(raw.files) + expect( + collectNumberedFiles({ + roots: [join(root, 'src'), dirname(poison)], + include: ['included'], + cwd: root, + exclude: excluded, + needles: ['export'], + toId: (path) => path, + }), + ).toEqual([kept]) + expect(listSourceFiles(dirname(poison), excluded)).toEqual([]) + } finally { + read.mockRestore() + } +}) + +it('uses native Windows absolute-path normalization in a real scanner fixture', () => { + const excluded = file('build/next/poison.ts') + const kept = file('src/next/value.ts') + const path = dirname(excluded) + const exclude = + process.platform === 'win32' + ? path.toUpperCase().replaceAll('\\', '/') + : path + expect(listSourceFiles(root, [exclude])).toEqual([kept]) +}) + +it.each([ + 'package', + 'package-alias', + 'package-subpath', + 'package-subpath-alias', + 'absolute', + 'absolute-alias', +])( + 'never reads a poisoned excluded manifest or source when resolving %s', + async (kind) => { + // Given an included package whose excluded manifest cannot be parsed. + const manifest = file('node_modules/excluded/package.json', '{') + const subpath = kind.includes('subpath') + const poison = file( + `node_modules/excluded/${subpath ? 'blocked/' : ''}index.ts`, + "import 'invalid'", + ) + const request = kind.startsWith('absolute') + ? poison.replaceAll('\\', '/') + : `excluded${subpath ? '/blocked/index' : ''}` + const aliased = kind.endsWith('alias') + const entry = file( + 'src/main.ts', + `import '${aliased ? 'provider' : request}'`, + ) + const excluded = subpath ? dirname(poison) : dirname(manifest) + const alias: Readonly> = aliased + ? { provider: request } + : {} + const options = { + cwd: root, + include: ['excluded'], + exclude: [excluded], + alias, + } + const raw = buildStaticImportGraph(['src', excluded], undefined, options) + const match = createDirectoryExclusion([excluded]) + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, thisArg, args) { + if (typeof args[0] === 'string' && match(dirname(args[0]))) + throw new Error('Excluded manifest/source read') + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + // When raw/prepared graphs and numbering follow the same excluded edge/root. + const visits: string[] = [] + const prepared = await buildStaticImportGraph( + ['src', excluded], + undefined, + { + ...options, + prepareSource: (filename) => { + visits.push(filename) + return undefined + }, + }, + ) + const numbered = collectNumberedFiles({ + ...options, + roots: [join(root, 'src'), excluded], + needles: ['import'], + toId: (path) => path, + }) + // Then no excluded source is visited, prewarmed or needle-read. + expect(raw.files).toEqual([entry]) + expect(prepared.files).toEqual([entry]) + expect(visits).toEqual([entry]) + expect(raw.externalImports?.get(entry)).toEqual(new Set()) + expect(prepared.externalImports?.get(entry)).toEqual(new Set()) + expect(numbered).toEqual([entry]) + } finally { + read.mockRestore() + } + }, +) + +it.each(['paths', 'baseUrl'])( + 'keeps excluded %s candidates terminal rather than prewarming a package fallback', + (kind) => { + const poison = file('blocked/index.ts') + const entry = file('src/main.ts', "import 'blocked'") + file('node_modules/blocked/package.json', '{') + file( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: + kind === 'paths' + ? { paths: { blocked: ['./blocked/index.ts'] } } + : { baseUrl: '.' }, + }), + ) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + include: ['blocked'], + exclude: [dirname(poison)], + }) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) diff --git a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts index 453b560c..329095f7 100644 --- a/packages/plugin-utils/src/__tests__/graph-exclude.test.ts +++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts @@ -62,9 +62,9 @@ it('skips named directories at every depth without reintroducing imported files' expect(graph.staticImports.get(join(root, 'src/nested/A.MDX'))).toEqual( new Set([join(root, 'src/value.CTS')]), ) - expect(listSourceFiles(join(root, 'src'), ['generated'])).toEqual( - graph.files.slice(1), - ) + expect( + listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: true }), + ).toEqual(graph.files.slice(1)) }) it.each(['.mts', '.cts', '.mjs', '.cjs', '.tsx', '.jsx'])( diff --git a/packages/plugin-utils/src/__tests__/import-mask.test.ts b/packages/plugin-utils/src/__tests__/import-mask.test.ts new file mode 100644 index 00000000..a74082d7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-mask.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'bun:test' + +import { maskImportText } from '../import-mask' + +it('masks escaped strings, nested templates and comments without consuming subsequent code', () => { + const code = [ + String.raw`const quote = "escaped \" import './fake'";`, + 'const value = `outer ${ {a: "}", b: `nested ${1}`} /* comment */ // comment', + '} import fake`;', + '/* multiline\n import fake */', + '// import fake', + "import './real'", + ].join('\n') + const masked = maskImportText(code, false) + expect(masked).not.toContain('fake') + expect(masked).toContain('import') + expect(masked.length).toBe(code.length) +}) + +it('masks regex quotes, escaped slashes and character classes', () => { + const code = String.raw`const value = /[/'"]import '\/fake'/gi; return /import "fake"/; import './real'` + expect(maskImportText(code, false)).not.toContain('fake') +}) + +it('masks JSX fragments, nesting, attributes and self-closing tags but keeps JavaScript expressions', () => { + const code = + 'const value = <>
import fake { {value: import("real")} }tail
; import "after"' + const masked = maskImportText(code, true) + expect(masked).not.toContain('fake') + expect(masked.match(/\bimport\b/g)).toHaveLength(2) +}) + +it('keeps TypeScript assertions and division as code rather than JSX or regex', () => { + const code = + 'const result = value / 2; const object = {x: 1}; import "real"' + expect(maskImportText(code, false)).toContain('value / 2') +}) + +it('handles JSX attribute expressions', () => { + const masked = maskImportText( + 'const node =
import fake
;', + true, + ) + expect(masked).toContain('import') + expect(masked).not.toContain('fake') +}) + +it('masks regex literals following control parentheses', () => { + expect( + maskImportText( + 'if (true) /import "fake"/.test(value); import "real"', + false, + ), + ).not.toContain('fake') +}) diff --git a/packages/plugin-utils/src/__tests__/module-alias.test.ts b/packages/plugin-utils/src/__tests__/module-alias.test.ts new file mode 100644 index 00000000..96412539 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/module-alias.test.ts @@ -0,0 +1,221 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' + +const installedRequire = createRequire( + resolve( + 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json', + ), +) +const enhancedResolve = installedRequire('enhanced-resolve') +let root: string +let entry: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + fs.mkdirSync(parent, { recursive: true }) + root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'alias-'))) + entry = file('src/main.ts') +}) +afterEach(() => fs.rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, source) + return target +} + +function installed( + request: string, + alias: Readonly>, + conditions: readonly string[] = ['import', 'browser'], +): string | undefined { + const resolver = enhancedResolve.ResolverFactory.createResolver({ + fileSystem: fs, + useSyncFileSystemCalls: true, + extensions: ['.js'], + alias, + conditionNames: conditions, + }) + try { + return resolver.resolveSync({}, dirname(entry), request) || undefined + } catch (cause) { + if (cause instanceof Error && /Can't resolve/.test(cause.message)) + return undefined + throw cause + } +} + +it('matches installed resolver declaration order, exact keys and subpath boundaries', () => { + const broad = file('broad/index.js') + const broadSub = file('broad/feature.js') + const specific = file('specific.js') + const dir = dirname(broad) + const cases = [ + { + request: '@x/feature', + alias: { '@x': dir, '@x/feature': specific }, + expected: broadSub, + }, + { + request: '@x/feature', + alias: { '@x/feature': specific, '@x': dir }, + expected: specific, + }, + { + request: '@x', + alias: { '@x$': specific, '@x': dir }, + expected: specific, + }, + { + request: '@x/feature', + alias: { '@x$': specific, '@x': dir }, + expected: broadSub, + }, + { request: '@xyz', alias: { '@x': dir }, expected: undefined }, + { request: '@x', alias: { '@x': dir }, expected: broad }, + ] + for (const { request, alias, expected } of cases) { + expect(installed(request, alias)).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(expected) + } +}) + +it.each([ + { conditions: ['browser', 'import'] }, + { conditions: ['import'] }, + { conditions: [] }, +])( + 'matches package exports conditions after alias rewrite (%j)', + ({ conditions }) => { + file( + 'node_modules/target/package.json', + JSON.stringify({ + exports: { + '.': { + browser: './browser.js', + import: './import.js', + default: './default.js', + }, + './feature': './feature.js', + }, + }), + ) + const browser = file('node_modules/target/browser.js') + const imported = file('node_modules/target/import.js') + const fallback = file('node_modules/target/default.js') + const feature = file('node_modules/target/feature.js') + const alias = { '@x': 'target' } + const expected = conditions.includes('browser') + ? browser + : conditions.includes('import') + ? imported + : fallback + expect(installed('@x', alias, conditions)).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias, conditions })('@x', entry)?.path, + ).toBe(expected) + expect(installed('@x/feature', alias, conditions)).toBe(feature) + expect( + createModuleResolver({ cwd: root, alias, conditions })( + '@x/feature', + entry, + )?.path, + ).toBe(feature) + }, +) + +it('matches missing-target terminal failure even when the original and a later alias exist', () => { + file('node_modules/original/package.json', '{"main":"index.js"}') + file('node_modules/original/index.js') + const later = file('later.js') + const alias = { original: join(root, 'missing'), original$: later } + expect(installed('original', alias)).toBeUndefined() + expect( + createModuleResolver({ cwd: root, alias })('original', entry), + ).toBeUndefined() +}) + +it('matches finite chains and self-alias skips', () => { + const target = file('target.js') + const alias = { '@a': '@a', '@a/feature': '@b', '@b': target } + expect(installed('@a/feature', alias)).toBe(target) + expect( + createModuleResolver({ cwd: root, alias })('@a/feature', entry)?.path, + ).toBe(target) +}) + +it('fails alias cycles at the real importer location', () => { + const alias = { '@a': '@b', '@b': '@a' } + expect(() => installed('@a', alias)).toThrow() + expect(() => createModuleResolver({ cwd: root, alias })('@a', entry)).toThrow( + `${entry}:1:1`, + ) +}) + +it('matches installed self-target subpath guards without an unbounded rewrite', () => { + file( + 'node_modules/pkg/package.json', + '{"exports":{"./feature":"./feature.js"}}', + ) + const feature = file('node_modules/pkg/feature.js') + const alias = { pkg: 'pkg/feature' } + expect(installed('pkg', alias)).toBe(feature) + expect(createModuleResolver({ cwd: root, alias })('pkg', entry)?.path).toBe( + feature, + ) +}) + +it('follows the actual aliased included-package closure rather than the alias name', () => { + fs.writeFileSync(entry, "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const provider = file( + 'node_modules/actual/index.js', + "import './nested/value.generated'", + ) + const leaf = file('node_modules/actual/nested/value.generated.mjs') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: 'actual' }, + include: ['actual'], + }) + expect(graph.files).toEqual([entry, provider, leaf].sort()) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: 'actual' }, + }).files, + ).toEqual([entry]) +}) + +it('follows an absolute alias into the actual included package and honors its exclusions', () => { + fs.writeFileSync(entry, "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const provider = file( + 'node_modules/actual/index.js', + "import './nested/leaf'", + ) + const leaf = file('node_modules/actual/nested/leaf.mjs') + const options = { cwd: root, alias: { provider }, include: ['actual'] } + expect(buildStaticImportGraph('src', undefined, options).files).toEqual( + [entry, provider, leaf].sort(), + ) + expect( + buildStaticImportGraph('src', undefined, { + ...options, + exclude: [dirname(leaf)], + }).files, + ).toEqual([entry, provider].sort()) +}) diff --git a/packages/plugin-utils/src/__tests__/numbering.test.ts b/packages/plugin-utils/src/__tests__/numbering.test.ts index 74047191..86d7d09d 100644 --- a/packages/plugin-utils/src/__tests__/numbering.test.ts +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -140,9 +140,9 @@ describe('collectNumberedFiles', () => { join(root, 'src/page.mdx'), "import '@devup-ui/react'\n# <", ) - expect(listSourceFiles(join(root, 'src'))).toContain( - join(root, 'src/page.mdx'), - ) + expect( + listSourceFiles(join(root, 'src'), [], { includeMdx: true }), + ).toContain(join(root, 'src/page.mdx')) const read = spyOn(fs, 'readFileSync') try { // When a caller uses the default non-MDX extraction set. diff --git a/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts new file mode 100644 index 00000000..3b9e66b7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts @@ -0,0 +1,221 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'scans real compiled MDX rather than fenced examples (async %s)', + async (asyncHook) => { + const markdown = [ + "import './real.js'", + '', + '# Heading', + '', + '```js', + "import './missing'", + "import { Box } from 'fake-package'", + "export {Box} from './missing'", + "require('./missing')", + "import('./missing')", + '```', + ].join('\n') + const entry = file('src/page.mdx', markdown) + file('node_modules/fake-package/package.json', '{') + const real = file('src/real.js') + const compiled = await compile({ value: markdown, path: entry }) + const hook = (filename: string) => + filename === entry + ? { code: String(compiled), map: compiled.map } + : undefined + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: asyncHook ? async (filename) => hook(filename) : hook, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set()) + }, +) + +it('rejects compiler errors with the actual Markdown filename and original cause', async () => { + const entry = file('src/page.mdx', '<') + let compilerCause: unknown + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: async (filename) => { + try { + return String(await compile({ value: '<', path: filename })) + } catch (cause) { + compilerCause = cause + throw cause + } + }, + }) + const error = await promise.catch((cause: unknown) => cause) + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) throw new Error('Expected compiler error') + expect(error.message).toContain(entry) + expect(error.cause).toBe(compilerCause) +}) + +it('selects and compiles a project-defined .mdown extension in scanner, graph and numbering', async () => { + const markdown = + "import './leaf'\n\n# Custom Markdown\n\n```js\nimport './missing'\n```" + const entry = file('src/page.mdown', markdown) + const leaf = file('src/leaf.mdown', '# Leaf') + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: async (filename) => { + return String( + await compile( + { value: filename === entry ? markdown : '# Leaf', path: filename }, + { format: 'mdx' }, + ), + ) + }, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.mdown', + 'src/page.mdown', + ]) +}) + +it('follows a real compiler-injected project-root provider and prepares included dependencies once', async () => { + const entry = file('src/page.mdx', '# Heading') + const provider = file('mdx-components.js', "import 'aliased-library'") + const remark = file('remark-extra.js') + const rehype = file('rehype-extra.js') + file('node_modules/actual/package.json', '{"main":"index.js"}') + const library = file('node_modules/actual/index.js', "import './leaf.config'") + const leaf = file('node_modules/actual/leaf.config.mjs') + const visits: string[] = [] + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + include: ['actual'], + alias: { + provider, + 'aliased-library': 'actual', + 'remark-extra': remark, + 'rehype-extra': rehype, + }, + prepareSource: async (filename) => { + visits.push(filename) + if (filename !== entry) return undefined + const inject = + (specifier: string) => () => (tree: { children: unknown[] }) => { + tree.children.unshift({ + type: 'mdxjsEsm', + value: `import '${specifier}'`, + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ImportDeclaration', + specifiers: [], + source: { type: 'Literal', value: specifier }, + }, + ], + }, + }, + }) + } + const compiled = await compile( + { value: '# Heading', path: filename }, + { + providerImportSource: 'provider', + SourceMapGenerator, + remarkPlugins: [inject('remark-extra')], + rehypePlugins: [inject('rehype-extra')], + }, + ) + return { code: String(compiled), map: compiled.map } + }, + }) + expect(graph.files).toEqual( + [entry, provider, library, leaf, remark, rehype].sort(), + ) + expect(visits.sort()).toEqual(graph.files) +}) + +it('remaps available-parser diagnostic positions through a real MDX compiler map', async () => { + const entry = file('src/page.mdx', '# Heading') + const compiled = await compile( + { value: '# Heading', path: entry }, + { SourceMapGenerator }, + ) + expect(compiled.map).toBeDefined() + const positions: { + line: number + column: number + originalLine: number + originalColumn: number + }[] = [] + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping: ( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + }) => void, + ) => void + }) => { + consumer.eachMapping((mapping) => { + if (mapping.originalLine) + positions.push({ + line: mapping.generatedLine, + column: mapping.generatedColumn + 1, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn + 1, + }) + }) + }, + ) + const position = positions[0] + if (!position) throw new Error('Real compiler map has no original mappings') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'available parser fixture', + line: position.line, + column: position.column, + }, + ], + }), + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }), + ).rejects.toThrow( + `${entry}:${position.originalLine}:${position.originalColumn}`, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts new file mode 100644 index 00000000..26a7fcaf --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-diagnostics.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from 'bun:test' + +import { preparedDiagnostics } from '../prepared-diagnostics' + +it('formats label-less diagnostic fixtures and unknown diagnostic values', () => { + expect( + preparedDiagnostics('file.mdx', '', ['failure', { message: 'empty' }]), + ).toBe('file.mdx:1:1: failure\nfile.mdx:1:1: empty') +}) + +it('uses the first compiled position when a diagnostic label has no offset', () => { + expect( + preparedDiagnostics('file.mdx', '', [ + { message: 'label', labels: [null, {}] }, + ]), + ).toBe('file.mdx:1:1: label') +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts new file mode 100644 index 00000000..33504a0f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph-fixture.ts @@ -0,0 +1,39 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach } from 'bun:test' + +import { __setOxcParserForTest } from '../import-graph' + +export function createPreparedFixture(setRoot: (root: string) => void) { + let root: string + beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'prepared-'))) + setRoot(root) + }) + afterEach(() => { + __setOxcParserForTest(undefined) + rmSync(root, { recursive: true, force: true }) + }) + return (path: string, source = 'export {}'): string => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target + } +} diff --git a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts new file mode 100644 index 00000000..ed5fb918 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -0,0 +1,208 @@ +import { expect, it } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + computeCompiledFiles, + createModuleResolver, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('reads raw source only for undefined, not for an empty prepared string', async () => { + const entry = file('src/page.ts', "import './real'") + const real = file('src/real.ts') + const raw = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => undefined, + }) + const empty = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => '', + }) + expect(raw.staticImports.get(entry)).toEqual(new Set([real])) + expect(empty.staticImports.get(entry)).toEqual(new Set()) +}) + +it.each([false, true])( + 'wraps located preparer throws/rejections (async %s)', + async (asyncHook) => { + const entry = file('src/main.ts') + const cause = Object.assign(new Error('Compiler failed'), { + line: 3, + column: 4, + }) + const hook = () => { + throw cause + } + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: asyncHook ? async () => hook() : hook, + }) + expect(promise).toBeInstanceOf(Promise) + const error = await promise.catch((failure: unknown) => failure) + if (!(error instanceof Error)) throw new Error('Expected error') + expect(error.message).toBe( + `${entry}:3:4 (in compiled output): Graph source preparation failed: Compiler failed`, + ) + expect(error.cause).toBe(cause) + }, +) + +it('turns asynchronous setup faults into Promise rejections', async () => { + const config = file('tsconfig.json', '{') + const promise = buildStaticImportGraph('src', config, { + cwd: root, + prepareSource: () => '', + }) + expect(promise).toBeInstanceOf(Promise) + await expect(promise).rejects.toThrow(config) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA;AACA' }, +])('reports every available parser diagnostic with map %j', async (map) => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { message: 'first', line: 1, column: 1 }, + { message: 'second', line: 2, column: 1 }, + ], + }), + }) + const promise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'compiled', map }), + }) + const error = await promise.catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain( + map ? 'original.mdx:1:1' : `${entry}:1:1 (in compiled output)`, + ) + expect(error.message).toContain( + map ? 'original.mdx:2:1' : `${entry}:2:1 (in compiled output)`, + ) + if (map) expect(error.message).not.toContain('(in compiled output)') +}) + +it('does not fall back to raw reads or lexical scanning after a prepared parser throws', async () => { + const entry = file('src/page.mdx', "import './raw'") + const cause = new Error(`${entry}:2:3: parse failed`) + __setOxcParserForTest({ + parseSync: () => { + throw cause + }, + }) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => "import './compiled'", + }), + ).rejects.toThrow(`${entry}:2:3 (in compiled output)`) +}) + +it('uses the available AST result for prepared source', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + __setOxcParserForTest({ + parseSync: () => ({ + type: 'ImportDeclaration', + source: { value: './leaf' }, + }), + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => 'compiled', + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) +}) + +it('reads the documented available-parser program getter for prepared source', async () => { + const entry = file('src/page.mdx') + const leaf = file('src/leaf.ts') + const result = Object.create({ + get program() { + return { type: 'ImportDeclaration', source: { value: './leaf' } } + }, + get errors() { + return [] + }, + }) + __setOxcParserForTest({ parseSync: () => result }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => '', + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) +}) + +it('reports every documented available-parser diagnostic label and remaps each position', async () => { + const entry = file('src/page.mdx') + __setOxcParserForTest({ + parseSync: () => ({ + errors: [ + { + message: 'labels', + labels: [ + { start: 0, end: 1, message: 'first' }, + { start: 2, end: 3, message: null }, + ], + }, + ], + }), + }) + const map = { + version: 3, + sources: ['original.mdx'], + names: [], + mappings: 'AAAA;AACA', + } + const error = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => ({ code: 'a\nb', map }), + }).catch((cause: unknown) => cause) + if (!(error instanceof Error)) throw new Error('Expected diagnostics') + expect(error.message).toContain('original.mdx:1:1') + expect(error.message).toContain('original.mdx:2:1') + expect(error.message).not.toContain(`${entry}:`) + expect(error.message).not.toContain('(in compiled output)') +}) + +it('selects Markdown routes and extensionless imports only with exact opt-in', () => { + const entry = file('src/page.md', "import './leaf'\n\n# Heading") + const leaf = file('src/leaf.md', '# Leaf') + file('src/other.mdx') + expect(createModuleResolver({ cwd: root })('./leaf.md', entry)?.path).toBe( + leaf, + ) + expect(createModuleResolver({ cwd: root })('./leaf', entry)).toBeUndefined() + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.md'], + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(computeCompiledFiles({ cwd: root, srcDir: 'src', graph })).toEqual([ + 'src/leaf.md', + 'src/page.md', + ]) +}) + +it('preserves legacy public resolver extensionless MDX while the graph remains default-off', () => { + const entry = file('src/main.ts', "import './page'") + const page = file('src/page.mdx', '# Heading') + expect(createModuleResolver({ cwd: root })('./page', entry)?.path).toBe(page) + expect(buildStaticImportGraph('src', undefined, { cwd: root }).files).toEqual( + [entry], + ) +}) diff --git a/packages/plugin-utils/src/__tests__/shared-api.test.ts b/packages/plugin-utils/src/__tests__/shared-api.test.ts new file mode 100644 index 00000000..7b4174d4 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-api.test.ts @@ -0,0 +1,170 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { + buildStaticImportGraph, + createModuleResolver, + listSourceFiles, +} from '../import-graph' +import { collectNumberedFiles } from '../numbering' + +let root: string +beforeEach(() => { + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'source-'))) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) +function file(path: string, source = 'export {}'): string { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target +} + +it('keeps Markdown off by default and selects exact extensions in scanner and numbering', () => { + const js = file('src/main.ts') + const md = file('src/page.md') + file('src/page.mdx') + const custom = file('src/page.mdown') + file('src/generated/skip.md') + expect(listSourceFiles(join(root, 'src'))).toEqual([js]) + expect( + listSourceFiles(join(root, 'src'), ['generated'], { includeMdx: ['.md'] }), + ).toEqual([js, md]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + exclude: ['generated'], + includeMdx: ['.md'], + toId: (path) => path, + }), + ).toEqual([js, md]) + expect( + listSourceFiles(join(root, 'src'), [], { includeMdx: ['.mdown'] }), + ).toEqual([js, custom]) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + includeMdx: ['.mdown'], + toId: (path) => path, + }), + ).toEqual([js, custom]) +}) + +it('returns a Promise for a synchronous preparer and follows a project-root provider once', async () => { + const entry = file('src/page.mdx', '# Raw markdown') + const provider = file('mdx-components.js', "import './value.generated'") + const leaf = file('value.generated.mjs') + const visits: string[] = [] + const graphPromise = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + alias: { provider }, + prepareSource: (filename: string) => { + visits.push(filename) + return filename === entry ? "import 'provider'" : undefined + }, + }) + expect(graphPromise).toBeInstanceOf(Promise) + const graph = await graphPromise + expect(graph.files).toEqual([entry, provider, leaf].sort()) + expect(visits.sort()).toEqual(graph.files) +}) + +it('does not discover import examples inside strings, regex or JSX text', () => { + const entry = file( + 'src/main.tsx', + [ + 'const example = "import \'./fake\'";', + 'const poison = "import \'fake-package\'";', + String.raw`const pattern = /"import '\.\/fake'"/;`, + "const template = `before ${`nested import './fake'`} after`;", + 'const element =
import \'./fake\' {import("./real")}
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + file('node_modules/fake-package/package.json', '{') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('does not leak template content after regex braces or nested escaped templates', () => { + const entry = file( + 'src/main.ts', + [ + 'const example = tag`prefix ${ /}/.test("x") ? `nested import "./fake"` : "" } tail import "./fake"`;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + expect( + buildStaticImportGraph('src', undefined, { cwd: root }).staticImports.get( + entry, + ), + ).toEqual(new Set([real])) +}) + +it('masks JSX text after arrow and conditional expression boundaries', () => { + const entry = file( + 'src/main.tsx', + [ + 'const View = () =>
import "./fake" {import("./real")}
;', + 'const branch = flag ?
import "./fake"
: ;', + 'const optional = flag &&
import "./fake"
;', + "import './real'", + ].join('\n'), + ) + const real = file('src/real.ts') + file('src/fake.ts') + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) +}) + +it('rewrites aliases before tsconfig and never falls through a matched missing target', () => { + const entry = file('src/main.ts') + const target = file('actual/index.js') + file( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { paths: { name: ['./fallback.js'] } } }), + ) + file('fallback.js') + const resolver = createModuleResolver({ + cwd: root, + alias: { name$: target, missing: join(root, 'absent') }, + }) + expect(resolver('name', entry)?.path).toBe(target) + file('node_modules/missing/package.json', '{"main":"index.js"}') + file('node_modules/missing/index.js') + expect(resolver('missing', entry)).toBeUndefined() +}) + +it('does not widen local graph roots merely because an empty alias table exists', () => { + const entry = file('src/main.ts', "import '../outside'") + file('outside.js') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: {}, + }) + expect(graph.files).toEqual([entry]) +}) diff --git a/packages/plugin-utils/src/directory-exclusion.ts b/packages/plugin-utils/src/directory-exclusion.ts new file mode 100644 index 00000000..9b80c24d --- /dev/null +++ b/packages/plugin-utils/src/directory-exclusion.ts @@ -0,0 +1,30 @@ +import { posix, win32 } from 'node:path' + +export function createDirectoryExclusion( + exclude: readonly string[] = [], + platform: string = process.platform, +): (directory: string) => boolean { + const windows = platform === 'win32' + const path = windows ? win32 : posix + const normalize = (value: string) => { + const normalized = path.normalize( + windows ? value.replaceAll('/', '\\') : value, + ) + const trimmed = normalized.replace(windows ? /\\+$/ : /\/+$/, '') + return windows ? trimmed.toLowerCase() : trimmed + } + const names = new Set( + exclude.filter((entry) => !path.isAbsolute(entry)).map(normalize), + ) + const absolute = exclude + .filter((entry) => path.isAbsolute(entry)) + .map(normalize) + return (directory) => { + const candidate = normalize(directory) + if (candidate.split(windows ? '\\' : '/').some((part) => names.has(part))) + return true + return absolute.some( + (root) => candidate === root || candidate.startsWith(root + path.sep), + ) + } +} diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index ddc0adc7..d4c05c30 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -9,13 +9,23 @@ import { import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { createDirectoryExclusion } from './directory-exclusion' +import { maskImportText } from './import-mask' import { ConfigLoadError } from './load-config' +import { remapMdxError } from './mdx-errors' +import { rewriteModuleAlias } from './module-alias' +import { preparedDiagnostics } from './prepared-diagnostics' import { createNodeModulesExcludeRegex, - GRAPH_SOURCE_FILE_RE, - MDX_FILE_RE, SOURCE_EXTENSIONS, + SOURCE_FILE_RE, } from './shared' +import { + isSelectedSource, + type MdxSelection, + sourceExtensions, + type SourceSelectionOptions, +} from './source-selection' import { type PathAlias, readPathAliases } from './tsconfig' /** @@ -64,12 +74,11 @@ interface ResolveContext { srcDir: string } -const jsExtensions: readonly string[] = [...SOURCE_EXTENSIONS, '.mdx'] -const jsFileRegex = GRAPH_SOURCE_FILE_RE +const jsExtensions: readonly string[] = SOURCE_EXTENSIONS const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/i const routeFileRegex = - /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.(?:[mc]?[jt]s|[jt]sx|mdx)$/ -const leafRouteFileRegex = /(^|\/)page\.(?:[mc]?[jt]s|[jt]sx|mdx)$/ + /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.[^./]+$/ +const leafRouteFileRegex = /(^|\/)page\.[^./]+$/ let cachedOxcParser: false | OxcParser | undefined @@ -110,28 +119,115 @@ export interface StaticImportGraph { * and return the full static import graph. All three graph consumers accept * the result, so the file I/O and parsing cost is paid a single time. */ +export function buildStaticImportGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise +export function buildStaticImportGraph( + srcDir: string | string[], + tsconfigPath?: string, + options?: SyncGraphOptions, +): StaticImportGraph export function buildStaticImportGraph( srcDir: string | string[], tsconfigPath?: string, - options: StaticImportGraphOptions = {}, -): StaticImportGraph { + options: SyncGraphOptions | PreparedGraphOptions = {}, +): StaticImportGraph | Promise { + if (options.prepareSource) + return drivePreparedGraph(srcDir, tsconfigPath, options) + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) step = traversal.next(undefined) + return step.value +} + +export type PreparedSource = + string | { readonly code: string; readonly map?: unknown } | undefined +export type PrepareSource = ( + filename: string, +) => PreparedSource | Promise +export interface SyncGraphOptions extends StaticImportGraphOptions { + readonly prepareSource?: never +} +export interface PreparedGraphOptions extends StaticImportGraphOptions { + readonly prepareSource: PrepareSource +} + +class GraphPreparationError extends Error { + constructor(filename: string, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause) + const location = + cause instanceof Error && + 'line' in cause && + typeof cause.line === 'number' + ? `${cause.line}:${'column' in cause && typeof cause.column === 'number' ? cause.column : 1}` + : '1:1' + super( + `${filename}:${location}: Graph source preparation failed: ${message}`, + { cause }, + ) + this.name = 'GraphPreparationError' + } +} + +async function drivePreparedGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: PreparedGraphOptions, +): Promise { + const traversal = traverseGraph(srcDir, tsconfigPath, options) + let step = traversal.next() + while (!step.done) { + const filename = step.value + let prepared: PreparedSource + try { + prepared = await options.prepareSource(filename) + } catch (cause) { + const located = new GraphPreparationError(filename, cause) + const remapped = remapMdxError(located, filename) + throw new Error( + remapped.message.replaceAll( + '(in compiled MDX)', + '(in compiled output)', + ), + { cause }, + ) + } + step = traversal.next(prepared) + } + return step.value +} + +function* traverseGraph( + srcDir: string | string[], + tsconfigPath: string | undefined, + options: StaticImportGraphOptions, +): Generator { const cwd = resolve(options.cwd ?? process.cwd()) const roots = (typeof srcDir === 'string' ? [srcDir] : srcDir).map((dir) => resolve(cwd, dir), ) const files = [ - ...new Set(roots.flatMap((root) => listSourceFiles(root, options.exclude))), - ] - .filter((file) => options.includeMdx || !MDX_FILE_RE.test(file)) - .sort(compareCodePoints) + ...new Set( + roots.flatMap((root) => listSourceFiles(root, options.exclude, options)), + ), + ].sort(compareCodePoints) const fileSet = new Set(files) - const resolver = createModuleResolver({ - cwd, - tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), - conditions: options.conditions, - }) + const excludedDirectory = createDirectoryExclusion(options.exclude) + const resolver = createModulePathResolver( + { + cwd, + tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), + conditions: options.conditions, + alias: options.alias, + includeMdx: options.includeMdx, + }, + excludedDirectory, + ) const excluded = createNodeModulesExcludeRegex(options.include ?? []) const includedRoots = new Set() + const localAliasRoots = new Set() const staticImporters = new Map>() const staticImports = new Map>() const dynamicImports = new Map>() @@ -147,44 +243,65 @@ export function buildStaticImportGraph( for (let index = 0; index < files.length; index += 1) { const file = files[index] - const imports = parseImports(file, readFileSync(file, 'utf-8')) + const prepared = yield file + const code = typeof prepared === 'string' ? prepared : prepared?.code + const imports = + code === undefined + ? parseImports(file, readFileSync(file, 'utf-8')) + : parsePreparedImports( + file, + code, + typeof prepared === 'object' ? prepared.map : undefined, + ) for (const importRef of imports) { - const resolved = resolver(importRef.specifier, file)?.path - if ( - resolved && - roots.some( - (root) => - isInsideDir(root, resolved) && - relative(root, dirname(resolved)) - .split(/[\\/]/) - .some((part) => options.exclude?.includes(part)), - ) + const resolved = resolver(importRef.specifier, file) + if (resolved === false) continue + if (resolved && excludedDirectory(dirname(resolved))) continue + const rewritten = rewriteModuleAlias( + importRef.specifier, + options.alias ?? {}, + file, ) - continue + const local = + resolved !== undefined && + isInsideDir(cwd, resolved) && + !relative(cwd, resolved).split(/[\\/]/).includes('node_modules') + if (resolved && local && rewritten !== importRef.specifier) + localAliasRoots.add(dirname(resolved)) if ( resolved && !importRef.specifier.startsWith('.') && !isAbsolute(importRef.specifier) ) { - const parts = importRef.specifier.split('/') - const name = parts - .slice(0, importRef.specifier.startsWith('@') ? 2 : 1) - .join('/') + const request = rewritten + const parts = request.split('/') + const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') if (!excluded.test(`node_modules/${name}/`)) { const dir = findPackage(dirname(file), name) if (dir) includedRoots.add(realpathSync(dir)) } + if (isAbsolute(request) && !local && !excluded.test(resolved)) { + let directory = dirname(resolved) + while ( + dirname(directory) !== directory && + !isFile(join(directory, 'package.json')) + ) + directory = dirname(directory) + if (isFile(join(directory, 'package.json'))) + includedRoots.add(realpathSync(directory)) + } } const target = resolved && - jsFileRegex.test(resolved) && - (options.includeMdx || !MDX_FILE_RE.test(resolved)) && + isSelectedSource(resolved, options.includeMdx) && (fileSet.has(resolved) || roots.some( (root) => isInsideDir(root, resolved) && !relative(root, resolved).split(/[\\/]/).includes('node_modules'), ) || + (local && + [...localAliasRoots].some((root) => isInsideDir(root, resolved))) || (!excluded.test(resolved) && [...includedRoots].some((root) => isInsideDir(root, resolved)))) ? resolved @@ -230,11 +347,12 @@ export function buildStaticImportGraph( export interface StaticImportGraphOptions { /** Include MDX only when the caller compiles it before extraction. */ - readonly includeMdx?: boolean + readonly includeMdx?: MdxSelection + readonly alias?: Readonly> readonly cwd?: string readonly include?: readonly string[] readonly conditions?: readonly string[] - /** Directory names skipped anywhere below source roots. */ + /** Bare directory names at any depth, or absolute directories and descendants. */ readonly exclude?: readonly string[] } @@ -691,10 +809,13 @@ export function compareCodePoints(a: string, b: string): number { export function listSourceFiles( srcDir: string, exclude: readonly string[] = [], + options: SourceSelectionOptions = {}, ): string[] { const files: string[] = [] + const excludedDirectory = createDirectoryExclusion(exclude) function visit(dir: string): void { + if (excludedDirectory(resolve(dir))) return if (!existsSync(dir)) return const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => compareCodePoints(a.name, b.name), @@ -702,13 +823,12 @@ export function listSourceFiles( for (const entry of entries) { const entryPath = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name === 'node_modules' || exclude.includes(entry.name)) - continue + if (entry.name === 'node_modules') continue visit(entryPath) continue } if (!entry.isFile()) continue - if (!jsFileRegex.test(entry.name)) continue + if (!isSelectedSource(entry.name, options.includeMdx)) continue if (testFileRegex.test(entry.name)) continue files.push(resolve(entryPath)) } @@ -721,10 +841,42 @@ export function listSourceFiles( } function parseImports(filename: string, source: string): ImportReference[] { - if (MDX_FILE_RE.test(filename)) return scanImports(mdxEsmSource(source)) + if (!SOURCE_FILE_RE.test(filename)) + return scanImports(mdxEsmSource(source), false) const astImports = parseImportsWithOxc(filename, source) if (astImports) return astImports - return scanImports(source) + return scanImports(source, /\.[jt]sx$/i.test(filename)) +} + +function parsePreparedImports( + filename: string, + source: string, + map: unknown, +): ImportReference[] { + const parser = getOxcParser() + if (!parser) return scanImports(source, true) + try { + const ast = parser.parseSync(filename, source, { + sourceType: 'module', + lang: 'jsx', + }) + if (isRecord(ast) && Array.isArray(ast.errors) && ast.errors.length) { + throw new Error(preparedDiagnostics(filename, source, ast.errors)) + } + const imports: ImportReference[] = [] + collectAstImports(isRecord(ast) ? (ast.program ?? ast) : ast, imports) + return imports + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + const located = message.startsWith(`${filename}:`) + ? cause + : new GraphPreparationError(filename, cause) + const remapped = remapMdxError(located, filename, map) + throw new Error( + remapped.message.replaceAll('(in compiled MDX)', '(in compiled output)'), + { cause }, + ) + } } function mdxEsmSource(source: string): string { @@ -911,9 +1063,9 @@ function isAllInlineTypeSpecifiers(clause: string | undefined): boolean { ) } -function scanImports(source: string): ImportReference[] { +function scanImports(source: string, jsx: boolean): ImportReference[] { const imports: ImportReference[] = [] - const code = stripComments(source) + const code = maskImportText(source, jsx) // The leading `(type\s+)?` is CAPTURED (not skipped) so we can drop type-only // statements: `import type ... from` / `export type ... from` are erased by // the bundler and produce NO runtime module — counting them as static graph @@ -932,94 +1084,34 @@ function scanImports(source: string): ImportReference[] { for (const match of code.matchAll(staticImportRegex)) { if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) + const offset = + (match.index ?? 0) + match[0].lastIndexOf(match[3]) - match[4].length + imports.push({ + kind: 'static', + specifier: source.slice(offset, offset + match[4].length), + }) } for (const match of code.matchAll(exportFromRegex)) { if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) + const offset = + (match.index ?? 0) + match[0].lastIndexOf(match[3]) - match[4].length + imports.push({ + kind: 'static', + specifier: source.slice(offset, offset + match[4].length), + }) } for (const match of code.matchAll(dynamicImportRegex)) { - imports.push({ kind: 'dynamic', specifier: match[2] }) + const offset = + (match.index ?? 0) + match[0].lastIndexOf(match[1]) - match[2].length + imports.push({ + kind: 'dynamic', + specifier: source.slice(offset, offset + match[2].length), + }) } return imports } -function stripComments(source: string): string { - let result = '' - let index = 0 - let quote: false | '"' | "'" | '`' = false - - while (index < source.length) { - const char = source[index] - const next = source[index + 1] - - if (quote) { - // Template-literal CONTENTS are blanked (delimiters and newlines kept): - // embedded code snippets (docs sites, codegen templates) would otherwise - // look like real import statements to the scanners below and create - // phantom graph edges for files the bundler never loads. Contents of - // '/" strings are preserved — import specifiers themselves are read from - // those literals by the scan regexes. - if (quote === '`') { - if (char === '\\') { - result += ' ' - index += 2 - continue - } - result += char === '`' || char === '\n' ? char : ' ' - if (char === '`') quote = false - index += 1 - continue - } - result += char - if (char === '\\') { - result += next ?? '' - index += 2 - continue - } - if (char === quote) quote = false - index += 1 - continue - } - - if (char === '"' || char === "'" || char === '`') { - quote = char - result += char - index += 1 - continue - } - - if (char === '/' && next === '/') { - while (index < source.length && source[index] !== '\n') { - result += ' ' - index += 1 - } - continue - } - - if (char === '/' && next === '*') { - result += ' ' - index += 2 - while ( - index < source.length && - !(source[index] === '*' && source[index + 1] === '/') - ) { - result += source[index] === '\n' ? '\n' : ' ' - index += 1 - } - result += ' ' - index += 2 - continue - } - - result += char - index += 1 - } - - return result -} - /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { path: string @@ -1027,6 +1119,8 @@ export interface ResolvedModule { } export interface CreateModuleResolverOptions { + readonly alias?: Readonly> + readonly includeMdx?: MdxSelection cwd?: string tsconfigPath?: string conditions?: readonly string[] @@ -1044,45 +1138,88 @@ export interface CreateModuleResolverOptions { * `module`, `main`), reading each module. */ export function createModuleResolver({ - cwd = process.cwd(), - tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, - conditions = ['import', 'module', 'require', 'node'], + ...options }: CreateModuleResolverOptions = {}): ( specifier: string, importer: string, ) => ResolvedModule | undefined { + const resolver = createModulePathResolver({ + ...options, + includeMdx: options.includeMdx ?? true, + }) + return (specifier, importer) => { + const path = resolver(specifier, importer) + return path + ? { path: toId(path), code: readFileSync(path, 'utf-8') } + : undefined + } +} + +function createModulePathResolver( + { + cwd = process.cwd(), + tsconfigPath = join(cwd, 'tsconfig.json'), + conditions = ['import', 'module', 'require', 'node'], + alias = {}, + includeMdx, + }: CreateModuleResolverOptions = {}, + excludedDirectory = createDirectoryExclusion(), +): (specifier: string, importer: string) => string | false | undefined { const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) + const extensions = sourceExtensions(includeMdx) + const fileResolver = (path: string) => + excludedDirectory(dirname(path)) || excludedDirectory(path) + ? false + : resolveFile(path, extensions) return (specifier, importer) => { const from = resolve(cwd, importer) - const path = specifier.startsWith('.') - ? resolveFile(resolve(dirname(from), specifier)) - : isAbsolute(specifier) - ? resolveFile(specifier) - : (resolveAliasCandidates(specifier, { + const request = rewriteModuleAlias(specifier, alias, from) + const path = request.startsWith('.') + ? fileResolver(resolve(dirname(from), request)) + : isAbsolute(request) + ? fileResolver(request) + : (resolveAliasCandidates(request, { aliases, aliasBaseDir: baseDir, }) - .map(resolveFile) - .find(Boolean) ?? - (baseUrl ? resolveFile(resolve(baseUrl, specifier)) : undefined) ?? - resolvePackage(specifier, from, conditions)) + .map(fileResolver) + .find((candidate) => candidate !== undefined) ?? + (baseUrl ? fileResolver(resolve(baseUrl, request)) : undefined) ?? + resolvePackage(request, from, { + conditions, + fileResolver, + excludedDirectory, + })) return path - ? { path: toId(path), code: readFileSync(path, 'utf-8') } - : undefined } } function resolvePackage( specifier: string, importer: string, - conditions: readonly string[], -): string | undefined { + options: { + readonly conditions: readonly string[] + readonly fileResolver: (path: string) => string | false | undefined + readonly excludedDirectory: (directory: string) => boolean + }, +): string | false | undefined { const parts = specifier.split('/') const nameLength = specifier.startsWith('@') ? 2 : 1 const name = parts.slice(0, nameLength).join('/') - const packageDir = findPackage(dirname(importer), name) + const packageDir = findPackage( + dirname(importer), + name, + options.excludedDirectory, + ) + if (packageDir === false) return false if (!packageDir) return undefined + const target = join(packageDir, ...parts.slice(nameLength)) + if ( + options.excludedDirectory(dirname(target)) || + options.excludedDirectory(target) + ) + return false const manifestFile = join(packageDir, 'package.json') let manifest: unknown try { @@ -1096,34 +1233,43 @@ function resolvePackage( packageDir, manifest, ['.', ...parts.slice(nameLength)].join('/'), - conditions, + options.conditions, + options.fileResolver, ) - return found && realpathSync(found) + return found ? realpathSync(found) : found } -function findPackage(dir: string, name: string): string | undefined { +function findPackage( + dir: string, + name: string, + excludedDirectory?: (directory: string) => boolean, +): string | false | undefined { const packageDir = join(dir, 'node_modules', name) + if (excludedDirectory?.(packageDir)) return false if (isFile(join(packageDir, 'package.json'))) return packageDir const parent = dirname(dir) - return parent === dir ? undefined : findPackage(parent, name) + return parent === dir + ? undefined + : findPackage(parent, name, excludedDirectory) } function resolvePackageEntry( dir: string, manifest: Record, subpath: string, conditions: readonly string[], -): string | undefined { + fileResolver: (path: string) => string | false | undefined, +): string | false | undefined { if (manifest.exports !== undefined) { const target = exportsTarget(manifest.exports, subpath, conditions) return typeof target !== 'string' ? undefined - : resolveFile(join(dir, target)) + : fileResolver(join(dir, target)) } - if (subpath !== '.') return resolveFile(join(dir, subpath)) + if (subpath !== '.') return fileResolver(join(dir, subpath)) const main = [manifest.module, manifest.main].find( (entry): entry is string => typeof entry === 'string', ) - return resolveFile(join(dir, main ?? 'index')) + return fileResolver(join(dir, main ?? 'index')) } function exportsTarget( @@ -1207,14 +1353,17 @@ function resolveAliasCandidates( return candidates } -function resolveFile(candidateBase: string): string | undefined { +function resolveFile( + candidateBase: string, + extensions: readonly string[] = jsExtensions, +): string | undefined { if (isFile(candidateBase)) return resolve(candidateBase) - for (const jsExtension of jsExtensions) { + for (const jsExtension of extensions) { const candidate = `${candidateBase}${jsExtension}` if (isFile(candidate)) return resolve(candidate) } - for (const jsExtension of jsExtensions) { + for (const jsExtension of extensions) { const candidate = join(candidateBase, `index${jsExtension}`) if (isFile(candidate)) return resolve(candidate) } diff --git a/packages/plugin-utils/src/import-mask.ts b/packages/plugin-utils/src/import-mask.ts new file mode 100644 index 00000000..fc6c344c --- /dev/null +++ b/packages/plugin-utils/src/import-mask.ts @@ -0,0 +1,178 @@ +/** Lexical masking only: the bundler, not this scanner, validates syntax. */ +export function maskImportText(source: string, jsx: boolean): string { + const output = source.split('') + let index = 0 + let previous = '' + let jsxDepth = 0 + let text = false + let tag = false + let tagClosing = false + const parentheses: boolean[] = [] + const expressionTags: boolean[] = [] + const expressions: number[] = [] + let braces = 0 + function blank(start: number, end: number): void { + for (let offset = start; offset < end; offset += 1) { + if (output[offset] !== '\n' && output[offset] !== '\r') + output[offset] = ' ' + } + } + function quoted(quote: string): void { + const start = index++ + while (index < source.length) { + const char = source[index++] + if (char === '\\') index += 1 + else if (char === quote) break + else if (quote === '`' && char === '$' && source[index] === '{') { + index += 1 + templateExpression() + } + } + blank(start + (quote === '`' ? 0 : 1), index - (quote === '`' ? 0 : 1)) + previous = 'value' + } + function templateExpression(): void { + let depth = 1 + previous = '' + while (index < source.length && depth) { + const char = source[index] + if (char === '"' || char === "'" || char === '`') quoted(char) + else if (char === '/' && source[index + 1] === '*') comment(false) + else if (char === '/' && source[index + 1] === '/') comment(true) + else if (char === '/' && /^(?:|=|\(|,|:|\{|return)$/.test(previous)) + regexLiteral() + else { + if (char === '{') depth += 1 + if (char === '}') depth -= 1 + if (!/\s/.test(char)) previous = char + index += 1 + } + } + } + function comment(line: boolean): void { + const start = index + index += 2 + while ( + index < source.length && + (line + ? source[index] !== '\n' + : !(source[index] === '*' && source[index + 1] === '/')) + ) + index += 1 + if (!line) index += 2 + blank(start, Math.min(index, source.length)) + } + function regexLiteral(): void { + const start = index++ + let bracket = false + while (index < source.length) { + const part = source[index++] + if (part === '\\') index += 1 + else if (part === '[') bracket = true + else if (part === ']') bracket = false + else if (part === '/' && !bracket) break + } + while (/[a-z]/i.test(source[index] ?? '') && index < source.length) + index += 1 + blank(start, index) + previous = 'value' + } + while (index < source.length) { + const char = source[index] + const next = source[index + 1] + if (tag) { + if (char === '"' || char === "'") { + quoted(char) + continue + } + if (char === '{') { + expressions.push(braces) + expressionTags.push(true) + braces += 1 + tag = false + index += 1 + previous = '{' + continue + } + if (char === '>') { + jsxDepth += tagClosing ? -1 : source[index - 1] === '/' ? 0 : 1 + tag = false + text = jsxDepth > 0 + previous = 'value' + } + blank(index, index + 1) + index += 1 + continue + } + if (text && char !== '<' && char !== '{') { + blank(index, index + 1) + index += 1 + continue + } + if (char === '/' && (next === '/' || next === '*')) { + comment(next === '/') + continue + } + if (char === '"' || char === "'" || char === '`') { + quoted(char) + continue + } + if (char === '/' && !/^(?:value|[\w$]+|\)|\])$/.test(previous)) { + regexLiteral() + continue + } + if ( + jsx && + char === '<' && + (text || /^(?:|=|\(|\[|,|:|\?|&|\||return|=>)$/.test(previous)) && + /[A-Za-z/>]/.test(next ?? '') + ) { + tag = true + tagClosing = next === '/' + text = false + blank(index, index + 1) + index += 1 + continue + } + if (text && char === '{') { + expressions.push(braces) + expressionTags.push(false) + braces += 1 + text = false + previous = '{' + index += 1 + continue + } + if (char === '{') braces += 1 + if (char === '}') { + braces -= 1 + if (expressions.at(-1) === braces) { + expressions.pop() + tag = expressionTags.pop() === true + text = !tag && jsxDepth > 0 + } + } + if (/[A-Za-z_$]/.test(char)) { + const start = index++ + while (/[\w$]/.test(source[index] ?? '') && index < source.length) + index += 1 + const word = source.slice(start, index) + previous = + /^(?:return|throw|case|delete|void|typeof|new|yield|await)$/.test(word) + ? '' + : word + continue + } + if (char === '(') + parentheses.push(/^(?:if|while|for|with|switch|catch)$/.test(previous)) + if (!/\s/.test(char)) + previous = + char === ')' && parentheses.pop() + ? '' + : char === '>' && previous === '=' + ? '=>' + : char + index += 1 + } + return output.join('') +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index aff1efae..c4f0147f 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -16,9 +16,13 @@ export { type CreateModuleResolverOptions, listSourceFiles, planAtomHoist, + type PreparedGraphOptions, + type PreparedSource, + type PrepareSource, type ResolvedModule, type StaticImportGraph, type StaticImportGraphOptions, + type SyncGraphOptions, } from './import-graph' export { ConfigLoadError, @@ -49,6 +53,7 @@ export { SOURCE_EXTENSIONS, SOURCE_FILE_RE, } from './shared' +export type { MdxSelection, SourceSelectionOptions } from './source-selection' export { createStateWriter, type StateWriter, diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts new file mode 100644 index 00000000..947d4343 --- /dev/null +++ b/packages/plugin-utils/src/module-alias.ts @@ -0,0 +1,33 @@ +export class ModuleAliasError extends Error { + constructor(importer: string, specifier: string) { + super(`${importer}:1:1: Module alias cycle cannot resolve ${specifier}`) + this.name = 'ModuleAliasError' + } +} + +/** First rewriting match wins; self aliases are not rewriting matches. */ +export function rewriteModuleAlias( + specifier: string, + alias: Readonly>, + importer: string, +): string { + const visited = new Set() + let request = specifier + while (!visited.has(request)) { + visited.add(request) + let rewritten = request + for (const [key, target] of Object.entries(alias)) { + const exact = key.endsWith('$') + const name = exact ? key.slice(0, -1) : key + if (request !== name && (exact || !request.startsWith(`${name}/`))) + continue + if (request === target || request.startsWith(`${target}/`)) continue + const candidate = target + request.slice(name.length) + rewritten = candidate + break + } + if (rewritten === request) return request + request = rewritten + } + throw new ModuleAliasError(importer, specifier) +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index acb10d0e..28384cbd 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { listSourceFiles } from './import-graph' -import { MDX_FILE_RE } from './shared' +import type { MdxSelection } from './source-selection' export interface CollectNumberedFilesOptions { /** Directories holding the project's own source files */ @@ -10,7 +10,8 @@ export interface CollectNumberedFilesOptions { /** Packages whose source the build extracts too (the `include` option) */ include?: string[] /** Number MDX only when the caller extracts compiled MDX. Defaults to false. */ - includeMdx?: boolean + includeMdx?: MdxSelection + readonly exclude?: readonly string[] cwd?: string /** * Only files whose text contains one of these are numbered, so files the @@ -50,6 +51,7 @@ export function collectNumberedFiles({ roots, include = [], includeMdx = false, + exclude, cwd = process.cwd(), needles, toId = (path) => path.replaceAll('\\', '/'), @@ -63,8 +65,7 @@ export function collectNumberedFiles({ return [ ...new Set( directories.flatMap((dir) => - listSourceFiles(dir) - .filter((file) => includeMdx || !MDX_FILE_RE.test(file)) + listSourceFiles(dir, exclude, { includeMdx }) .filter((file) => !needles || usesAny(file, needles)) .map(toId), ), diff --git a/packages/plugin-utils/src/prepared-diagnostics.ts b/packages/plugin-utils/src/prepared-diagnostics.ts new file mode 100644 index 00000000..1a0929b2 --- /dev/null +++ b/packages/plugin-utils/src/prepared-diagnostics.ts @@ -0,0 +1,26 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function preparedDiagnostics( + filename: string, + source: string, + errors: readonly unknown[], +): string { + return errors + .flatMap((error) => { + if (!isRecord(error)) return `${filename}:1:1: ${String(error)}` + const labels = Array.isArray(error.labels) + ? error.labels.filter(isRecord) + : [] + if (labels.length) { + return labels.map((label) => { + const offset = typeof label.start === 'number' ? label.start : 0 + const lines = source.slice(0, offset).split('\n') + return `${filename}:${lines.length}:${(lines.at(-1)?.length ?? 0) + 1}: ${String(error.message)}${typeof label.message === 'string' ? `: ${label.message}` : ''}` + }) + } + return `${filename}:${typeof error.line === 'number' ? error.line : 1}:${typeof error.column === 'number' ? error.column : 1}: ${String(error.message)}` + }) + .join('\n') +} diff --git a/packages/plugin-utils/src/source-selection.ts b/packages/plugin-utils/src/source-selection.ts new file mode 100644 index 00000000..1466006a --- /dev/null +++ b/packages/plugin-utils/src/source-selection.ts @@ -0,0 +1,27 @@ +import { extname } from 'node:path' + +import { SOURCE_EXTENSIONS, SOURCE_FILE_RE } from './shared' + +export type MdxSelection = boolean | readonly string[] +export interface SourceSelectionOptions { + readonly includeMdx?: MdxSelection +} + +export function sourceExtensions(selection?: MdxSelection): readonly string[] { + return [ + ...SOURCE_EXTENSIONS, + ...(selection === true ? ['.mdx'] : selection || []).map((extension) => + extension.toLowerCase(), + ), + ] +} + +export function isSelectedSource( + filename: string, + selection?: MdxSelection, +): boolean { + return ( + SOURCE_FILE_RE.test(filename) || + sourceExtensions(selection).includes(extname(filename).toLowerCase()) + ) +} diff --git a/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts index 05ffa610..195ed088 100644 --- a/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts +++ b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts @@ -1,7 +1,14 @@ -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { buildStaticImportGraph } from '@devup-ui/plugin-utils' import { resetBuildState } from '@devup-ui/wasm' import { expect, it } from 'bun:test' import { build } from 'vite' @@ -69,3 +76,63 @@ it('builds the same module when the documented nonaggregating application altern rmSync(root, { recursive: true, force: true }) } }) + +it('rejects a real late plugin-emitted module absent from the static source graph', async () => { + resetBuildState() + const parent = join( + tmpdir(), + 'opencode', + 'workers', + 'w20-plugins-core', + 'shared-api', + ) + mkdirSync(parent, { recursive: true }) + const root = realpathSync + .native(mkdtempSync(join(parent, 'devup-late-'))) + .replaceAll('\\', '/') + mkdirSync(`${root}/src`) + const entry = `${root}/src/entry.js` + const late = `${root}/late.js` + writeFileSync( + entry, + "import {css} from '@devup-ui/react'; export const style=css({color:'red'});", + ) + writeFileSync( + late, + "import {css} from '@devup-ui/react'; export const late=css({color:'blue'});", + ) + const graph = buildStaticImportGraph(`${root}/src`, undefined, { cwd: root }) + expect( + graph.fileSet.has(late.replaceAll('/', '\\')) || graph.fileSet.has(late), + ).toBe(false) + let emitted = false + try { + await expect( + build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + DevupUI({ sourceDirs: ['src'] }), + { + name: 'late-module-after-css-barrier', + transform(_code, id) { + if (!emitted && /devup-ui.*\.css(?:$|\?)/.test(id)) { + emitted = true + this.emitFile({ type: 'chunk', id: late, name: 'late' }) + } + }, + }, + ], + build: { + write: false, + cssCodeSplit: false, + lib: { entry, formats: ['es'] }, + }, + }), + ).rejects.toThrow(`${late}:1:1: [devup-ui] aggregate CSS asset`) + expect(emitted).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 1c1551e0..2d5959e8 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -690,8 +690,16 @@ describe('devupUIVitePlugin', () => { await createPlugin({}).configResolved({ root: '/p' }) - expect(listSourceFilesSpy).toHaveBeenCalledWith(resolve('/p', 'src')) - expect(listSourceFilesSpy).toHaveBeenCalledWith(resolve('/p', 'app')) + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'src'), + undefined, + { includeMdx: true }, + ) + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'app'), + undefined, + { includeMdx: true }, + ) expect(seedFileMapSpy).toHaveBeenCalledWith([ '/p/app/page.tsx', '/p/shared.tsx', From 9a4fef700246616050417ebd86849326fab06911 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 05:25:30 +0900 Subject: [PATCH 33/50] docs(plugin-utils): define prepared graph and resolver option contracts Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/plugin-utils/README.md | 124 ++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/plugin-utils/README.md diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md new file mode 100644 index 00000000..c36b536d --- /dev/null +++ b/packages/plugin-utils/README.md @@ -0,0 +1,124 @@ +# @devup-ui/plugin-utils + +Shared build-time utilities for Devup UI plugins. These utilities do not execute +modules or add styling JavaScript to application output. + +## Source selection and exclusions + +`listSourceFiles`, `buildStaticImportGraph`, and `collectNumberedFiles` all default +to JavaScript/TypeScript only (`.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, +`.cjs`). Markdown is **off by default everywhere**. + +Their `includeMdx` option has type `boolean | readonly string[]`: + +- Omitted, `false`, or `[]`: no extra extensions. +- `true`: exactly `.mdx`, not `.md`. +- A list: exactly the supplied literal extensions, such as `['.md', '.mdx']` + or `['.mdown']`. Use normalized extension strings with their leading dot; + matching is case-insensitive. No extension patterns or regular expressions. + +```ts +import { + buildStaticImportGraph, + collectNumberedFiles, + listSourceFiles, +} from '@devup-ui/plugin-utils' + +const js = listSourceFiles('/project/src', ['generated']) +const docs = listSourceFiles('/project/src', ['generated'], { + includeMdx: ['.md', '.mdx', '.mdown'], +}) +const numbered = collectNumberedFiles({ + roots: ['/project/src'], + includeMdx: ['.mdown'], + exclude: ['/project/build/next', 'generated'], + needles: ['@devup-ui/react'], +}) +``` + +The two-argument scanner call retains its directory-exclusion argument; use the +third options argument to opt into Markdown. All three APIs use one exclusion +matcher: a bare directory name matches at any depth; an absolute directory path +matches only that directory and its descendants, not same-basename folders +elsewhere. Windows matching normalizes separators, drive letters and path case; +POSIX matching preserves case. Excluded roots and imported targets are skipped +before reading source, preparation or numbering needle checks. `node_modules` +and test/spec files remain absent from directory enumeration. + +Opt-in extensions also participate in extensionless graph resolution and route +recognition. Explicit existing file paths still resolve with +`createModuleResolver` without opting into their extension. Numbering forwards +selection and exclusions to the scanner rather than reading then filtering. +The public module resolver retains its legacy extensionless `.mdx` probing; +the graph's path-only resolver probes extra extensions only when selected. + +## Synchronous and prepared-source graphs + +Without a `prepareSource` field, `buildStaticImportGraph` remains synchronous and +returns `StaticImportGraph`. Existing synchronous callers do not receive a +`StaticImportGraph | Promise` union. + +```ts +const graph = buildStaticImportGraph('src', undefined, { cwd: '/project' }) + +const prepared = await buildStaticImportGraph('src', undefined, { + cwd: '/project', + includeMdx: ['.mdx', '.mdown'], + alias: { 'mdx-provider$': '/project/mdx-components.js' }, + prepareSource: async (filename) => { + if (!filename.endsWith('.mdx') && !filename.endsWith('.mdown')) return undefined + const compiled = await compileProjectMarkdown(filename) + return { code: String(compiled.value), map: compiled.map } + }, +}) +``` + +Supplying **any** preparer, including a synchronous callback, returns +`Promise`. The exported `PrepareSource` receives each visited +real absolute filename once, including discovered providers and eligible +included-package dependencies. It returns a string, `{ code: string, map?: +unknown }`, `undefined`, or a Promise of those values. Only `undefined` reads raw +source; `''` is valid prepared code. Relative imports, keys and diagnostics keep +the actual filename. Prepared JS/JSX bypasses the raw Markdown ESM-block filter, +so compiler-injected provider/remark/rehype imports can be discovered. Configure +the project compiler to interpret custom extensions as MDX where required. + +The graph is an **import scanner, not a full syntax validator**. The caller's +compiler and bundler own validation of the same compiled output. No parser +dependency is installed by this package. If optional `oxc-parser` is available, +its prepared-source exceptions/diagnostics fail rather than falling back to raw +source or lexical scanning. Compiler failures retain the real filename and +original cause; setup/hook failures reject the Promise. Parser positions are +remapped using `remapMdxError` when a map is supplied; unmapped positions are +explicitly labeled `in compiled output`, not presented as Markdown coordinates. + +The dependency-free lexer masks ordinary strings, template contents, comments, +regular expressions and JSX text, while preserving import literal arguments and +JSX expression imports. It retains existing type-only elision. It is not a +general JavaScript parser: computed imports, CommonJS `require` discovery and +template-interpolation import discovery are not a new guarantee. The legacy +optional AST/fallback difference for template-interpolation imports is unchanged. +Raw opted-in Markdown scans only ESM blocks, not code fences or prose. + +## Resolver aliases + +Both `createModuleResolver` and graph options accept an optional readonly +`alias: Record`. Supported aliases are string targets only: +absolute file/directory paths or package requests. They run before tsconfig +paths, baseUrl and package resolution. + +- `name$` matches only `name`; `name` matches `name` and `name/subpath`, not + `name-other`. +- Declaration order matters: the first **rewriting** match wins. Put a specific + key before an overlapping broad key. +- A failed rewritten target never falls through to the original request or a + later alias. Ordinary unresolved targets return `undefined`. +- Finite chains resolve; self-aliases are skipped; cycles produce an + importer-located failure. +- Package exports and active conditions are applied after rewriting. Graph + package eligibility uses the actual target package, not the alias name. +- Project-local aliased providers outside configured source roots can be + followed; excluded directories and excluded external packages remain excluded. + +This subset is tested against installed enhanced-resolve 5.25.1 with real files. +Alias `false`, array targets and wildcard keys are not supported by this API. From e46ae772325d41ad470113913832e17cbaa11ad6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 07:05:41 +0900 Subject: [PATCH 34/50] feat(plugin-utils): prepare synchronous resolver modules and remap errors Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/graph-modern.test.ts | 6 +- .../src/__tests__/prepared-graph.test.ts | 8 +- .../__tests__/prepared-resolver-wasm.test.ts | 206 ++++++++++++++++++ .../src/__tests__/prepared-resolver.test.ts | 177 +++++++++++++++ packages/plugin-utils/src/import-graph.ts | 29 ++- packages/plugin-utils/src/index.ts | 1 + .../plugin-utils/src/prepared-resolver.ts | 128 +++++++++++ 7 files changed, 539 insertions(+), 16 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts create mode 100644 packages/plugin-utils/src/__tests__/prepared-resolver.test.ts create mode 100644 packages/plugin-utils/src/prepared-resolver.ts diff --git a/packages/plugin-utils/src/__tests__/graph-modern.test.ts b/packages/plugin-utils/src/__tests__/graph-modern.test.ts index 709f0c2a..ed9c8ac4 100644 --- a/packages/plugin-utils/src/__tests__/graph-modern.test.ts +++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts @@ -239,9 +239,9 @@ describe('MDX ESM discovery', () => { expect(graph.files.filter((file) => file.endsWith('.mdx'))).toEqual([]) expect(graph.staticImports.get(entry)).toEqual(new Set()) expect(graph.dynamicImports.get(entry)).toEqual(new Set()) - expect( - createModuleResolver({ cwd: root })('./page.mdx', entry)?.path, - ).toBe(page) + expect(() => + createModuleResolver({ cwd: root })('./page.mdx', entry), + ).toThrow(page) }, ) diff --git a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts index ed5fb918..a953181d 100644 --- a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -183,7 +183,7 @@ it('selects Markdown routes and extensionless imports only with exact opt-in', ( const entry = file('src/page.md', "import './leaf'\n\n# Heading") const leaf = file('src/leaf.md', '# Leaf') file('src/other.mdx') - expect(createModuleResolver({ cwd: root })('./leaf.md', entry)?.path).toBe( + expect(() => createModuleResolver({ cwd: root })('./leaf.md', entry)).toThrow( leaf, ) expect(createModuleResolver({ cwd: root })('./leaf', entry)).toBeUndefined() @@ -198,10 +198,12 @@ it('selects Markdown routes and extensionless imports only with exact opt-in', ( ]) }) -it('preserves legacy public resolver extensionless MDX while the graph remains default-off', () => { +it('rejects unprepared legacy extensionless MDX while the graph remains default-off', () => { const entry = file('src/main.ts', "import './page'") const page = file('src/page.mdx', '# Heading') - expect(createModuleResolver({ cwd: root })('./page', entry)?.path).toBe(page) + expect(() => createModuleResolver({ cwd: root })('./page', entry)).toThrow( + page, + ) expect(buildStaticImportGraph('src', undefined, { cwd: root }).files).toEqual( [entry], ) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts new file mode 100644 index 00000000..7deb8489 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver-wasm.test.ts @@ -0,0 +1,206 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +const { SourceMapGenerator, SourceMapConsumer } = compilerRequire('source-map') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + wasm.registerTheme({}) + wasm.setDebug(debug) +}) + +function extract(filename: string, source: string) { + const output = wasm.codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + return { code: output.code, css: wasm.getCss(undefined, false) } + } finally { + output.free() + } +} + +it.each(['absolute', 'relative'])( + 'uses actual compiled MDX constants like TS constants (%s ids)', + async (mode) => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + file('src/plain.ts', "export const PRIMARY = 'tomato'") + const entry = file( + 'src/main.tsx', + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const cache = new Map([ + [module, { code: String(compiled), map: compiled.map }], + ]) + const prepareSource = (filename: string) => cache.get(filename) + const toId = (filename: string) => + (mode === 'absolute' ? filename : relative(root, filename)).replaceAll( + '\\', + '/', + ) + const resolver = createModuleResolver({ cwd: root, prepareSource, toId }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdx', toId(entry))?.code).toBe(String(compiled)) + wasm.setModuleResolver(resolver) + const prepared = extract( + toId(entry), + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + wasm.resetBuildState() + wasm.setModuleResolver(resolver) + const ordinary = extract( + toId(entry), + "import {PRIMARY} from './plain'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + expect(prepared.css).toBe(ordinary.css) + expect(prepared.css).toContain('color:tomato') + expect(prepared.code).not.toContain('--') + }, +) + +it.each(['raw', 'promise'])( + 'rejects %s imported Markdown through the actual WASM callback', + (mode) => { + const module = file( + 'src/value.mdx', + "export const PRIMARY = 'tomato'\n\n# Heading", + ) + const entry = file('src/main.tsx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => + mode === 'promise' ? Promise.resolve('') : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {PRIMARY} from './value.mdx'; import {Box} from '@devup-ui/react'; export const view = ;", + ) + } catch (cause) { + const error = resolver.remapError(cause) + expect(error.message).toContain(`${entry}:1:1`) + expect(error.message).toContain(module) + expect(error.cause).toBe(cause) + return + } + throw new Error('Expected raw/preparation rejection') + }, +) + +it('leaves the real WASM importing-site error untouched after prepared-module evaluation', async () => { + const markdown = "export const bad = () => 'red'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const entry = file('src/main.tsx') + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => + filename === module + ? { code: String(compiled), map: compiled.map } + : undefined, + }) + wasm.setModuleResolver(resolver) + try { + extract( + entry, + "import {bad} from './value.mdx'; import {css} from '@devup-ui/react'; export const style = css({color:bad});", + ) + } catch (cause) { + const error = resolver.remapError(cause) + const original = cause instanceof Error ? cause.message : String(cause) + expect(error.message).toBe(original) + expect(error.cause).toBe(cause) + expect(resolver.remapError(error)).toBe(error) + expect(error.message).toContain(`${entry}:1:`) + return + } + throw new Error('Expected imported extraction failure') +}) + +it('remaps synthetic extractor-format diagnostics through a real MDX compiler map', async () => { + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const module = file('src/value.mdx', markdown) + const compiled = await compile( + { value: markdown, path: module }, + { SourceMapGenerator }, + ) + const resolver = createModuleResolver({ + cwd: root, + toId: () => 'src/value.mdx', + prepareSource: () => ({ code: String(compiled), map: compiled.map }), + }) + resolver(module, 'main.ts') + await SourceMapConsumer.with( + compiled.map, + null, + (consumer: { + eachMapping( + callback: (mapping: { + generatedLine: number + generatedColumn: number + originalLine: number + originalColumn: number + source: string + }) => void, + ): void + }) => { + const positions: string[] = [] + consumer.eachMapping((mapping) => { + if (mapping.originalLine) { + const error = resolver.remapError( + `src/value.mdx:${mapping.generatedLine}:${mapping.generatedColumn + 1}: diagnostic`, + ) + expect(error.message).toBe( + `${module}:${mapping.originalLine}:${mapping.originalColumn + 1}: diagnostic`, + ) + positions.push(error.message) + } + }) + expect(positions.length).toBeGreaterThan(0) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts new file mode 100644 index 00000000..6d246e25 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-resolver.test.ts @@ -0,0 +1,177 @@ +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each(['.mdx', '.md', '.mdown'])( + 'rejects unprepared %s at the importer', + (extension) => { + const module = file(`src/value${extension}`, '# Raw') + const resolver = createModuleResolver({ cwd: root, includeMdx: ['.mdown'] }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + `${resolve(root, 'src/main.tsx')}:1:1`, + ) + expect(() => resolver(module, 'src/main.tsx')).toThrow(module) + }, +) + +it.each([ + Promise.resolve(''), + { + code: '', + then() { + throw new Error('must not invoke') + }, + }, + Object.assign(() => {}, { code: '', then() {} }), +])('rejects a thenable without awaiting or invoking it', (value) => { + const module = file('src/value.mdx') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => value, + }) + expect(() => resolver(module, 'src/main.tsx')).toThrow( + 'prepare Markdown before extraction', + ) +}) + +it('preserves a hook exception and the real aliased module before toId', () => { + const module = file('src/value.mdx') + const cause = new Error('compiler failed') + const resolver = createModuleResolver({ + cwd: root, + alias: { value: module }, + toId: () => { + throw new Error('too early') + }, + prepareSource: (filename) => { + expect(filename).toBe(module) + throw cause + }, + }) + try { + resolver('value', 'src/main.tsx') + } catch (error) { + if (!(error instanceof Error)) throw error + expect(error.cause).toBe(cause) + expect(error.message).toContain(module) + return + } + throw new Error('Expected preparation failure') +}) + +it('accepts empty preparation and reads ordinary undefined sources', () => { + const module = file('src/value.mdx', '# Raw') + const ordinary = file('src/plain.ts', 'export const value = 1') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: (filename) => (filename === module ? '' : undefined), + }) + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + expect(resolver(ordinary, 'main.ts')?.code).toBe('export const value = 1') + expect(resolver('./missing', 'main.ts')).toBeUndefined() +}) + +it('remaps every exact id occurrence once, leaving other filenames alone', () => { + const module = file('src/value.mdx') + const other = file('src/other.mdx') + const id = 'src/[value]+(x).mdx' + const map = { version: 3, sources: [id], mappings: 'AAKA', names: [] } + const resolver = createModuleResolver({ + cwd: root, + toId: (filename) => (filename === module ? id : 'other.mdx'), + prepareSource: (filename) => ({ + code: '', + map: filename === module ? map : undefined, + }), + }) + resolver(module, 'main.ts') + resolver(other, 'main.ts') + const original = new Error( + `${id}:1:1: bad\nsee ${id}:1:1 and other.mdx:2:3: bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + const error = resolver.remapError(original) + expect(error.message).toBe( + `${id}:6:1: bad\nsee ${id}:6:1 and ${other}:2:3 (in compiled output): bad\nprefix-${id}:1:1: untouched\nother.ts:1:1: untouched`, + ) + expect(error.cause).toBe(original) + expect(resolver.remapError(error)).toBe(error) + expect(createModuleResolver({ cwd: root }).remapError(original).message).toBe( + original.message, + ) +}) + +it('replaces maps for the same id and deletes a no-longer-prepared generation', () => { + const module = file('src/value.ts') + let generation: { code: string; map?: unknown } | undefined = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAKA' }, + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => generation, + }) + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:6:1: bad`, + ) + generation = { + code: '', + map: { version: 3, sources: [module], mappings: 'AAQA' }, + } + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:9:1: bad`, + ) + generation = undefined + resolver(module, 'main.ts') + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('discards stale Markdown mappings when preparation disappears', () => { + const module = file('src/value.mdx') + let prepared: string | undefined = '' + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + resolver(module, 'main.ts') + prepared = undefined + expect(() => resolver(module, 'main.ts')).toThrow(module) + expect(resolver.remapError(`${module}:1:1: bad`).message).toBe( + `${module}:1:1: bad`, + ) +}) + +it('keeps exact Windows ids, uncovered positions and non-Error hook causes', () => { + const module = file('src/value.mdx') + const id = 'C:\\folder\\[value]+.mdx' + const resolver = createModuleResolver({ + cwd: root, + toId: () => id, + prepareSource: () => ({ + code: '', + map: { version: 3, sources: [module], mappings: 'AAAA' }, + }), + }) + resolver(module, 'main.ts') + expect( + resolver.remapError(`${id}:2:1: bad\nlong-${id}:1:1: other`).message, + ).toBe(`${module}:2:1 (in compiled output): bad\nlong-${id}:1:1: other`) + const failed = createModuleResolver({ + cwd: root, + prepareSource: () => { + throw { toString: () => 'compiler not ready' } + }, + }) + expect(() => failed(module, 'main.ts')).toThrow('compiler not ready') +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index d4c05c30..214875ec 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -15,6 +15,10 @@ import { ConfigLoadError } from './load-config' import { remapMdxError } from './mdx-errors' import { rewriteModuleAlias } from './module-alias' import { preparedDiagnostics } from './prepared-diagnostics' +import { + createPreparedResolver, + type ModuleResolver, +} from './prepared-resolver' import { createNodeModulesExcludeRegex, SOURCE_EXTENSIONS, @@ -1119,6 +1123,7 @@ export interface ResolvedModule { } export interface CreateModuleResolverOptions { + readonly prepareSource?: PrepareSource readonly alias?: Readonly> readonly includeMdx?: MdxSelection cwd?: string @@ -1140,20 +1145,24 @@ export interface CreateModuleResolverOptions { export function createModuleResolver({ toId = (path) => path, ...options -}: CreateModuleResolverOptions = {}): ( - specifier: string, - importer: string, -) => ResolvedModule | undefined { +}: CreateModuleResolverOptions = {}): ModuleResolver { const resolver = createModulePathResolver({ ...options, includeMdx: options.includeMdx ?? true, }) - return (specifier, importer) => { - const path = resolver(specifier, importer) - return path - ? { path: toId(path), code: readFileSync(path, 'utf-8') } - : undefined - } + const prepared = createPreparedResolver(options) + return Object.assign( + (specifier: string, importer: string) => { + const path = resolver(specifier, importer) + if (!path) return undefined + const source = prepared.read( + path, + resolve(options.cwd ?? process.cwd(), importer), + ) + return prepared.resolved(toId(path), path, source) + }, + { remapError: prepared.remapError }, + ) } function createModulePathResolver( diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index c4f0147f..5b47f9a5 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -38,6 +38,7 @@ export { type FileNumbering, seedFileNumbers, } from './numbering' +export type { ModuleResolver } from './prepared-resolver' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts new file mode 100644 index 00000000..50d3de1f --- /dev/null +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs' +import { extname } from 'node:path' + +import type { + PreparedSource, + PrepareSource, + ResolvedModule, +} from './import-graph' +import { remapMdxError } from './mdx-errors' +import type { MdxSelection } from './source-selection' + +export interface ModuleResolver { + (specifier: string, importer: string): ResolvedModule | undefined + remapError(error: unknown): Error +} + +class ModulePreparationError extends Error { + constructor(importer: string, filename: string, cause: unknown) { + super( + `${importer}:1:1: module source preparation cannot use \`${filename}\` at build time: ${cause instanceof Error ? cause.message : String(cause)}; prepare Markdown before extraction using a synchronous cache lookup, or move the imported value into JS/TS`, + { cause }, + ) + this.name = 'ModulePreparationError' + } +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) +} + +/** The mutable store belongs to one resolver, not to the WASM singleton. */ +export function createPreparedResolver(options: { + readonly prepareSource?: PrepareSource + readonly includeMdx?: MdxSelection +}) { + const generations = new Map< + string, + { readonly filename: string; readonly code: string; readonly map?: unknown } + >() + const idsByFilename = new Map() + const remappedErrors = new WeakSet() + const markdownExtensions = new Set([ + '.md', + '.mdx', + ...(typeof options.includeMdx === 'object' + ? options.includeMdx.map((extension) => extension.toLowerCase()) + : []), + ]) + return { + read(filename: string, importer: string): PreparedSource { + const previousId = idsByFilename.get(filename) + if (previousId !== undefined) generations.delete(previousId) + idsByFilename.delete(filename) + try { + const prepared = options.prepareSource?.(filename) + if (isThenable(prepared)) + throw new TypeError( + 'Source is not ready: the synchronous WASM resolver received a thenable', + ) + if ( + prepared === undefined && + markdownExtensions.has(extname(filename).toLowerCase()) + ) + throw new TypeError('Markdown source has no prepared JavaScript') + return prepared + } catch (cause) { + throw new ModulePreparationError(importer, filename, cause) + } + }, + resolved( + id: string, + filename: string, + prepared: PreparedSource, + ): ResolvedModule { + if (prepared === undefined) { + generations.delete(id) + return { path: id, code: readFileSync(filename, 'utf-8') } + } + const source = + typeof prepared === 'string' ? { code: prepared } : prepared + generations.set(id, { filename, ...source }) + idsByFilename.set(filename, id) + return { path: id, code: source.code } + }, + remapError(error: unknown): Error { + if (error instanceof Error && remappedErrors.has(error)) return error + const message = error instanceof Error ? error.message : String(error) + const replacements = [...generations] + .flatMap(([id, source]) => { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + `(^|[\\s"'\x60])${escaped}:(\\d+):(\\d+)(?=:|\\s|$)`, + 'g', + ) + return [...message.matchAll(pattern)].map((match) => ({ + start: match.index + match[1].length, + end: match.index + match[0].length, + text: remapMdxError( + `${source.filename}:${match[2]}:${match[3]}`, + source.filename, + source.map, + ).message.replace('(in compiled MDX)', '(in compiled output)'), + })) + }) + .sort((a, b) => a.start - b.start) + const mapped = replacements.reduce( + (state, replacement) => ({ + text: + state.text + + message.slice(state.end, replacement.start) + + replacement.text, + end: replacement.end, + }), + { text: '', end: 0 }, + ) + const result = new Error(mapped.text + message.slice(mapped.end), { + cause: error, + }) + remappedErrors.add(result) + return result + }, + } +} From 059972ceed9bc154f7a4d84834ee6e12163f9d4d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 07:13:25 +0900 Subject: [PATCH 35/50] docs(plugin-utils): explain prepared resolver mapping and importing-site proof Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/plugin-utils/README.md | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md index c36b536d..bbedc6ff 100644 --- a/packages/plugin-utils/README.md +++ b/packages/plugin-utils/README.md @@ -47,7 +47,8 @@ and test/spec files remain absent from directory enumeration. Opt-in extensions also participate in extensionless graph resolution and route recognition. Explicit existing file paths still resolve with -`createModuleResolver` without opting into their extension. Numbering forwards +`createModuleResolver` without opting into their extension, but Markdown must be +prepared before its source can be returned. Numbering forwards selection and exclusions to the scanner rather than reading then filtering. The public module resolver retains its legacy extensionless `.mdx` probing; the graph's path-only resolver probes extra extensions only when selected. @@ -100,6 +101,57 @@ template-interpolation import discovery are not a new guarantee. The legacy optional AST/fallback difference for template-interpolation imports is unchanged. Raw opted-in Markdown scans only ESM blocks, not code fences or prose. +## Synchronous prepared module resolver + +`createModuleResolver({ prepareSource })` retains its synchronous callable shape +for `setModuleResolver` and also exposes `resolver.remapError(error)`. The hook +receives the real absolute filename after native/alias resolution and before +`toId`; it returns the same string, `{ code, map? }`, or `undefined` values as the +graph hook. Empty code is valid. Ordinary JS/TS with `undefined` still reads raw +source. The resolver never awaits: **any thenable is rejected immediately**. +Async compilers must finish preparation into a caller-owned cache first, then +pass the same synchronous lookup hook to both graph and resolver. + +With or without a hook, `.mdx`, `.md`, and explicit `includeMdx` extensions cannot +reach WASM without prepared JavaScript. Missing preparation, thenables and hook +exceptions fail at the actual importer `:1:1` (the callback has no import span), +name the actual module and preserve the cause. Move imported values into JS/TS +when Markdown compilation is not available; this API does not add MDX support +to plugins that do not compile it. + +Callers supplying `prepareSource` **must route every error thrown by +`codeExtract` through `resolver.remapError`**, not just callback errors: + +```ts +const prepareSource = (filename: string) => compiledCache.get(filename) +const resolver = createModuleResolver({ cwd, toId, prepareSource }) +setModuleResolver(resolver) +try { + return codeExtract(filename, source, /* existing extraction arguments */) +} catch (error) { + throw resolver.remapError(error) +} +``` + +Mappings are resolver-instance-local, keyed by the exact id returned to WASM. +Each resolved generation replaces its prior code/map; ordinary raw JS/TS removes +its stale map. Keep the resolver for its build/compiler context, replace cache +entries when recompiling, and discard it when that context ends. Vite and Bun +create build-context resolvers; Webpack creates compiler/loader-context resolvers. +Only Next's prepared-source integration on #752 supplies this hook and wraps +extraction errors; this utility change adds no wrappers to other plugins. + +Every exact prepared-id location in an error is remapped through `remapMdxError`; +other file locations are untouched. Missing/uncovered mappings explicitly say +`in compiled output` of the real file. Returned errors retain the original as +`cause`; passing the returned error to the same resolver again is idempotent. +The resolver cannot recover imported-file coordinates absent from the extractor +error: that mapping boundary belongs to #757. With #755 alone, the real imported +invalid-value case reports the importing TSX source position, which stays +untouched. Contract tests use synthetic extractor-format diagnostics and real +compiler maps; the imported-module end-to-end mapping check runs after #755 and +#757 are both on main, without merging either automatically. + ## Resolver aliases Both `createModuleResolver` and graph options accept an optional readonly From 9cc58a2369d38fd596b0ca25fce38ee2ee9e8260 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 08:01:22 +0900 Subject: [PATCH 36/50] feat(plugin-utils): resolve ordered alias candidate arrays Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/alias-array-fixture.ts | 47 +++++ .../src/__tests__/alias-array-package.test.ts | 178 ++++++++++++++++++ .../__tests__/alias-array-prepared.test.ts | 72 +++++++ .../src/__tests__/alias-array.test.ts | 128 +++++++++++++ .../src/__tests__/module-alias.test.ts | 6 +- .../src/__tests__/shared-api.test.ts | 4 +- packages/plugin-utils/src/import-graph.ts | 69 ++++--- packages/plugin-utils/src/index.ts | 1 + packages/plugin-utils/src/module-alias.ts | 97 ++++++++-- packages/plugin-utils/src/types.ts | 3 + 10 files changed, 558 insertions(+), 47 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/alias-array-fixture.ts create mode 100644 packages/plugin-utils/src/__tests__/alias-array-package.test.ts create mode 100644 packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts create mode 100644 packages/plugin-utils/src/__tests__/alias-array.test.ts diff --git a/packages/plugin-utils/src/__tests__/alias-array-fixture.ts b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts new file mode 100644 index 00000000..2c3e0bf1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts @@ -0,0 +1,47 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import type { CreateModuleResolverOptions } from '../import-graph' + +const installedRequire = createRequire( + resolve( + 'node_modules/.bun/enhanced-resolve@5.25.1/node_modules/enhanced-resolve/package.json', + ), +) +const enhancedResolve = installedRequire('enhanced-resolve') + +export function aliasFixture() { + const parent = join(tmpdir(), 'opencode', 'workers', 'w20-plugins-core') + fs.mkdirSync(parent, { recursive: true }) + const root = fs.realpathSync.native( + fs.mkdtempSync(join(parent, 'alias-array-')), + ) + const file = (name: string, code = 'export {}') => { + const target = join(root, name) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, code) + return target + } + const entry = file('src/main.ts') + return { + root, + entry, + file, + dispose: () => fs.rmSync(root, { recursive: true, force: true }), + installed: ( + request: string, + options: CreateModuleResolverOptions, + ): string | false => { + const resolver = enhancedResolve.ResolverFactory.createResolver({ + fileSystem: fs, + useSyncFileSystemCalls: true, + extensions: ['.js', '.ts'], + alias: options.alias, + conditionNames: options.conditions ?? ['import', 'browser'], + }) + return resolver.resolveSync({}, dirname(entry), request) + }, + } +} diff --git a/packages/plugin-utils/src/__tests__/alias-array-package.test.ts b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts new file mode 100644 index 00000000..6450aa73 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts @@ -0,0 +1,178 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([ + { conditions: ['browser', 'import'] }, + { conditions: ['import'] }, + { conditions: [] }, +])( + 'resolves active conditions before selecting the package (%j)', + ({ conditions }: { readonly conditions: readonly string[] }) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file('node_modules/missing-main/package.json', '{"main":"absent.js"}') + file( + 'node_modules/actual/package.json', + '{"exports":{"browser":"./browser.js","import":"./import.js","default":"./default.js"}}', + ) + const browser = file('node_modules/actual/browser.js', "import './leaf'") + const imported = file('node_modules/actual/import.js', "import './leaf'") + const fallback = file('node_modules/actual/default.js', "import './leaf'") + const leaf = file('node_modules/actual/leaf.js') + const alias = { provider: ['missing-main', 'actual'] } + const expected = conditions.includes('browser') + ? browser + : conditions.includes('import') + ? imported + : fallback + expect(installed('provider', { alias, conditions })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias, conditions })('provider', entry), + ).toEqual({ path: expected, code: "import './leaf'" }) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions, + include: ['actual'], + }).files, + ).toEqual([entry, expected, leaf].sort()) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias, conditions }) + .files, + ).toEqual([entry]) + }, +) + +it.each(['blocked', 'condition', 'poison'])( + 'preserves fatal %s package errors instead of trying a later candidate', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = + kind === 'blocked' + ? '{"exports":{"./other":"./index.js"}}' + : kind === 'condition' + ? '{"exports":{"browser":"./absent.js","default":"./index.js"}}' + : '{' + const manifestFile = file('node_modules/first/package.json', manifest) + file('node_modules/first/index.js') + const alias = { provider: ['first', file('later.js')] } + expect(() => installed('provider', { alias })).toThrow() + const expected = + kind === 'poison' + ? manifestFile + : `${entry}:1:1: Module alias package first` + expect(() => + createModuleResolver({ cwd: root, alias, conditions: ['browser'] })( + 'provider', + entry, + ), + ).toThrow(expected) + expect(() => + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions: ['browser'], + }), + ).toThrow(expected) + }, +) + +it('keeps cycles fatal even with a resolving later candidate', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const alias = { provider: ['nested', file('later.js')], nested: 'provider' } + expect(() => installed('provider', { alias })).toThrow() + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(`${entry}:1:1: Module alias cycle`) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${entry}:1:1: Module alias cycle`) +}) + +it('skips self candidates while retaining ordered overlapping keys', () => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = { + provider: ['provider', chosen], + provider$: [file('later.js')], + } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) +}) + +it('follows a selected absolute package candidate and its closure', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file('node_modules/actual/package.json', '{"main":"index.js"}') + const chosen = file('node_modules/actual/index.js', "import './leaf'") + const leaf = file('node_modules/actual/leaf.js') + const alias = { provider: [join(root, 'missing'), chosen] } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['actual'], + }).files, + ).toEqual([entry, chosen, leaf].sort()) +}) + +it.each([false, true])( + 'skips poisoned excluded candidates without external prewarming (allExcluded=%s)', + (allExcluded) => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = file('node_modules/poison/package.json', '{') + const chosen = file('local/provider.js', "import './leaf'") + const leaf = file('local/leaf.js') + const alias = { provider: ['poison', chosen] } + const exclude = allExcluded + ? [dirname(poison), dirname(chosen)] + : [dirname(poison)] + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude, + }) + expect(graph.files).toEqual( + (allExcluded ? [entry] : [entry, chosen, leaf]).sort(), + ) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) + +it('keeps a mixed excluded and missing candidate list terminal', () => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = file('node_modules/poison/package.json', '{') + const alias = { provider: ['poison', join(root, 'missing')] } + expect(() => + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude: [dirname(poison)], + }), + ).toThrow(`${entry}:1:1: Module alias provider`) +}) diff --git a/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts new file mode 100644 index 00000000..2a70a7f9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-prepared.test.ts @@ -0,0 +1,72 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +let fixture: ReturnType +let debug: boolean +beforeEach(() => { + fixture = aliasFixture() + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + wasm.registerTheme({}) + wasm.setDebug(debug) + fixture.dispose() +}) + +it('prepares only the chosen real Markdown filename and extracts its imported constant', async () => { + const { root, entry, file } = fixture + const markdown = "export const PRIMARY = 'tomato'\n\n# Heading" + const chosen = file('provider.mdx', markdown) + const compiled = String(await compile({ value: markdown, path: chosen })) + const alias = { provider: [join(root, 'missing.mdx'), chosen] } + const calls: string[] = [] + const prepareSource = (filename: string) => { + calls.push(filename) + return filename === chosen ? compiled : undefined + } + const resolver = createModuleResolver({ cwd: root, alias, prepareSource }) + expect(resolver('provider', entry)).toEqual({ path: chosen, code: compiled }) + expect(calls).toEqual([chosen]) + const source = + "import {PRIMARY} from 'provider'; import {Box} from '@devup-ui/react'; export const view = ;" + file('src/main.ts', source) + calls.length = 0 + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + includeMdx: true, + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([chosen])) + expect(calls).toEqual([entry, chosen]) + wasm.setModuleResolver(resolver) + const output = wasm.codeExtract( + entry.replace(/\.ts$/, '.tsx'), + source, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + expect(wasm.getCss(undefined, false)).toContain('color:tomato') + expect(output.code).not.toContain('--') + } finally { + output.free() + } +}) diff --git a/packages/plugin-utils/src/__tests__/alias-array.test.ts b/packages/plugin-utils/src/__tests__/alias-array.test.ts new file mode 100644 index 00000000..abf1b6d1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array.test.ts @@ -0,0 +1,128 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([false, true])( + 'selects the first fully resolving candidate when first exists=%s', + (firstExists) => { + const { root, entry, file, installed } = fixture + const first = join(root, 'first/index.js') + if (firstExists) file('first/index.js', "export const value = 'first'") + const second = file('second/index.js', "export const value = 'second'") + file('src/main.ts', "import 'provider'") + const alias = { provider: [dirname(first), dirname(second)] } + const expected = firstExists ? first : second + const resolved = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + expect(resolved).toEqual({ + path: expected, + code: firstExists + ? "export const value = 'first'" + : "export const value = 'second'", + }) + expect(installed('provider', { alias })).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it.each(['provider', 'provider/feature'])( + 'applies exact and subpath arrays when request is %s', + (request) => { + const { root, entry, file, installed } = fixture + const exact = file('exact.js') + const feature = file('second/feature.js') + file('src/main.ts', `import '${request}'`) + const alias = { + provider$: [join(root, 'missing'), exact], + provider: [join(root, 'absent'), dirname(feature)], + } + const expected = request === 'provider' ? exact : feature + expect(installed(request, { alias })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it('reports all candidates at the real importer without original or lateral fallback', () => { + const { root, entry, file, installed } = fixture + file('node_modules/original/package.json', '{"main":"index.js"}') + file('node_modules/original/index.js') + const later = file('later.js') + file('src/main.ts', "import 'original'") + const candidates = [join(root, 'missing'), join(root, 'absent')] + const alias = { original: candidates, original$: [later] } + expect(() => installed('original', { alias })).toThrow() + const message = `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify(candidates)}` + expect(() => + createModuleResolver({ cwd: root, alias })('original', entry), + ).toThrow(message) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(message) +}) + +it('fails an empty matched candidate list without falling through', () => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const alias = { provider: [], provider$: file('later.js') } + const message = `${entry}:1:1: Module alias provider cannot resolve candidates []` + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(message) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(message) +}) + +it('tries another candidate after a complete nested alias miss', () => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = { provider: ['nested', chosen], nested: join(root, 'missing') } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) +}) + +it('does not match an exact array key against a package subpath', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider/feature'") + file( + 'node_modules/provider/package.json', + '{"exports":{"./feature":"./feature.js"}}', + ) + const chosen = file('node_modules/provider/feature.js') + const alias = { provider$: [file('wrong.js')] } + expect(installed('provider/feature', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider/feature', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['provider'], + }).files, + ).toEqual([entry, chosen].sort()) +}) diff --git a/packages/plugin-utils/src/__tests__/module-alias.test.ts b/packages/plugin-utils/src/__tests__/module-alias.test.ts index 96412539..7c7a50d2 100644 --- a/packages/plugin-utils/src/__tests__/module-alias.test.ts +++ b/packages/plugin-utils/src/__tests__/module-alias.test.ts @@ -143,9 +143,11 @@ it('matches missing-target terminal failure even when the original and a later a const later = file('later.js') const alias = { original: join(root, 'missing'), original$: later } expect(installed('original', alias)).toBeUndefined() - expect( + expect(() => createModuleResolver({ cwd: root, alias })('original', entry), - ).toBeUndefined() + ).toThrow( + `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify([alias.original])}`, + ) }) it('matches finite chains and self-alias skips', () => { diff --git a/packages/plugin-utils/src/__tests__/shared-api.test.ts b/packages/plugin-utils/src/__tests__/shared-api.test.ts index 7b4174d4..bf39861c 100644 --- a/packages/plugin-utils/src/__tests__/shared-api.test.ts +++ b/packages/plugin-utils/src/__tests__/shared-api.test.ts @@ -156,7 +156,9 @@ it('rewrites aliases before tsconfig and never falls through a matched missing t expect(resolver('name', entry)?.path).toBe(target) file('node_modules/missing/package.json', '{"main":"index.js"}') file('node_modules/missing/index.js') - expect(resolver('missing', entry)).toBeUndefined() + expect(() => resolver('missing', entry)).toThrow( + `${entry}:1:1: Module alias missing cannot resolve candidates ${JSON.stringify([join(root, 'absent')])}`, + ) }) it('does not widen local graph roots merely because an empty alias table exists', () => { diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 214875ec..11413463 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -13,7 +13,11 @@ import { createDirectoryExclusion } from './directory-exclusion' import { maskImportText } from './import-mask' import { ConfigLoadError } from './load-config' import { remapMdxError } from './mdx-errors' -import { rewriteModuleAlias } from './module-alias' +import { + type AliasResolution, + ModuleAliasPackageError, + resolveModuleAlias, +} from './module-alias' import { preparedDiagnostics } from './prepared-diagnostics' import { createPreparedResolver, @@ -31,6 +35,7 @@ import { type SourceSelectionOptions, } from './source-selection' import { type PathAlias, readPathAliases } from './tsconfig' +import type { ModuleAliases } from './types' /** * How map keys (and bucket-root values) are stringified. @@ -258,8 +263,9 @@ function* traverseGraph( typeof prepared === 'object' ? prepared.map : undefined, ) for (const importRef of imports) { - const resolved = resolver(importRef.specifier, file) - if (resolved === false) continue + const resolution = resolver(importRef.specifier, file) + if (resolution === false) continue + const resolved = resolution?.path if (resolved && excludedDirectory(dirname(resolved))) continue const rewritten = rewriteModuleAlias( importRef.specifier, @@ -352,7 +358,7 @@ function* traverseGraph( export interface StaticImportGraphOptions { /** Include MDX only when the caller compiles it before extraction. */ readonly includeMdx?: MdxSelection - readonly alias?: Readonly> + readonly alias?: ModuleAliases readonly cwd?: string readonly include?: readonly string[] readonly conditions?: readonly string[] @@ -1049,6 +1055,7 @@ function isImportCallee(node: unknown): boolean { // TypeScript import elision (the Next.js/SWC default) removes the whole // statement, so no runtime module is ever produced. Counting such an edge as // static merges a phantom member into a bucket the bundler never compiles — + readonly aliased: boolean // the next-plugin coordinator then waits for a file that can never arrive. // A mixed clause (`{ type A, b }`) still imports the module for `b` and is // kept. A default/namespace clause is always a value import and is kept. @@ -1083,6 +1090,8 @@ function scanImports(source: string, jsx: boolean): ImportReference[] { const staticImportRegex = /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm const exportFromRegex = + if (found === undefined && options.aliased && manifest.exports !== undefined) + throw new ModuleAliasPackageError(importer, specifier) /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm @@ -1124,7 +1133,7 @@ export interface ResolvedModule { export interface CreateModuleResolverOptions { readonly prepareSource?: PrepareSource - readonly alias?: Readonly> + readonly alias?: ModuleAliases readonly includeMdx?: MdxSelection cwd?: string tsconfigPath?: string @@ -1153,8 +1162,9 @@ export function createModuleResolver({ const prepared = createPreparedResolver(options) return Object.assign( (specifier: string, importer: string) => { - const path = resolver(specifier, importer) - if (!path) return undefined + const resolution = resolver(specifier, importer) + if (!resolution) return undefined + const { path } = resolution const source = prepared.read( path, resolve(options.cwd ?? process.cwd(), importer), @@ -1174,7 +1184,10 @@ function createModulePathResolver( includeMdx, }: CreateModuleResolverOptions = {}, excludedDirectory = createDirectoryExclusion(), -): (specifier: string, importer: string) => string | false | undefined { +): ( + specifier: string, + importer: string, +) => AliasResolution | false | undefined { const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) const extensions = sourceExtensions(includeMdx) const fileResolver = (path: string) => @@ -1183,24 +1196,28 @@ function createModulePathResolver( : resolveFile(path, extensions) return (specifier, importer) => { const from = resolve(cwd, importer) - const request = rewriteModuleAlias(specifier, alias, from) - const path = request.startsWith('.') - ? fileResolver(resolve(dirname(from), request)) - : isAbsolute(request) - ? fileResolver(request) - : (resolveAliasCandidates(request, { - aliases, - aliasBaseDir: baseDir, - }) - .map(fileResolver) - .find((candidate) => candidate !== undefined) ?? - (baseUrl ? fileResolver(resolve(baseUrl, request)) : undefined) ?? - resolvePackage(request, from, { - conditions, - fileResolver, - excludedDirectory, - })) - return path + return resolveModuleAlias(specifier, { + alias, + importer: from, + resolveRequest: (request, aliased) => + request.startsWith('.') + ? fileResolver(resolve(dirname(from), request)) + : isAbsolute(request) + ? fileResolver(request) + : (resolveAliasCandidates(request, { + aliases, + aliasBaseDir: baseDir, + }) + .map(fileResolver) + .find((candidate) => candidate !== undefined) ?? + (baseUrl ? fileResolver(resolve(baseUrl, request)) : undefined) ?? + resolvePackage(request, from, { + conditions, + fileResolver, + excludedDirectory, + aliased, + })), + }) } } diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 5b47f9a5..9ac74bb7 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -65,6 +65,7 @@ export type { DevupConfig, DevupTheme, ImportAliases, + ModuleAliases, ThemeColors, ThemeTypography, Typography, diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts index 947d4343..1bcf2ff5 100644 --- a/packages/plugin-utils/src/module-alias.ts +++ b/packages/plugin-utils/src/module-alias.ts @@ -1,3 +1,5 @@ +import type { ModuleAliases } from './types' + export class ModuleAliasError extends Error { constructor(importer: string, specifier: string) { super(`${importer}:1:1: Module alias cycle cannot resolve ${specifier}`) @@ -5,29 +7,88 @@ export class ModuleAliasError extends Error { } } -/** First rewriting match wins; self aliases are not rewriting matches. */ -export function rewriteModuleAlias( +export class ModuleAliasCandidatesError extends Error { + readonly name = 'ModuleAliasCandidatesError' + constructor( + readonly importer: string, + readonly key: string, + readonly candidates: readonly string[], + ) { + super( + `${importer}:1:1: Module alias ${key} cannot resolve candidates ${JSON.stringify(candidates)}`, + ) + } +} + +export class ModuleAliasPackageError extends Error { + readonly name = 'ModuleAliasPackageError' + constructor( + readonly importer: string, + readonly request: string, + ) { + super( + `${importer}:1:1: Module alias package ${request} has no resolving export under the active conditions`, + ) + } +} + +export interface AliasResolution { + readonly path: string + readonly request: string +} + +/** Resolve every rewriting candidate completely before selecting its request. */ +export function resolveModuleAlias( specifier: string, - alias: Readonly>, - importer: string, -): string { - const visited = new Set() - let request = specifier - while (!visited.has(request)) { - visited.add(request) - let rewritten = request - for (const [key, target] of Object.entries(alias)) { + context: { + readonly alias: ModuleAliases + readonly importer: string + readonly resolveRequest: ( + request: string, + aliased: boolean, + ) => string | false | undefined + }, +): AliasResolution | false | undefined { + let failure: + { readonly key: string; readonly candidates: readonly string[] } | undefined + function visit( + request: string, + visited: ReadonlySet, + aliased: boolean, + ): AliasResolution | false | undefined { + if (visited.has(request)) + throw new ModuleAliasError(context.importer, specifier) + const next = new Set(visited).add(request) + for (const [key, value] of Object.entries(context.alias)) { const exact = key.endsWith('$') const name = exact ? key.slice(0, -1) : key if (request !== name && (exact || !request.startsWith(`${name}/`))) continue - if (request === target || request.startsWith(`${target}/`)) continue - const candidate = target + request.slice(name.length) - rewritten = candidate - break + const targets = typeof value === 'string' ? [value] : value + const candidates = targets + .filter( + (target) => request !== target && !request.startsWith(`${target}/`), + ) + .map((target) => target + request.slice(name.length)) + if (targets.length && !candidates.length) continue + failure ??= { key, candidates } + let missing = !candidates.length + for (const candidate of candidates) { + const resolved = visit(candidate, next, true) + if (resolved) return resolved + if (resolved === undefined) missing = true + } + return missing ? undefined : false } - if (rewritten === request) return request - request = rewritten + const path = context.resolveRequest(request, aliased) + return typeof path === 'string' ? { path, request } : path } - throw new ModuleAliasError(importer, specifier) + const resolved = visit(specifier, new Set(), false) + if (resolved === undefined && failure) + throw new ModuleAliasCandidatesError( + context.importer, + failure.key, + failure.candidates, + ) + return resolved } diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2..03691792 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -63,6 +63,9 @@ export interface DevupTheme { /** Custom style prop aliases mapped to one or more CSS property names. */ export type CustomShorthands = Record +/** Ordered native module alias candidates; false/ignore aliases are unsupported. */ +export type ModuleAliases = Readonly> + /** * Devup configuration file structure (devup.json) */ From 21b1a1266f1f68ad1e4c0da4ba55d79f4d88be59 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 08:08:19 +0900 Subject: [PATCH 37/50] docs(plugin-utils): specify ordered alias candidates and terminal errors Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/plugin-utils/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md index bbedc6ff..b8eb689c 100644 --- a/packages/plugin-utils/README.md +++ b/packages/plugin-utils/README.md @@ -155,7 +155,7 @@ compiler maps; the imported-module end-to-end mapping check runs after #755 and ## Resolver aliases Both `createModuleResolver` and graph options accept an optional readonly -`alias: Record`. Supported aliases are string targets only: +`alias: ModuleAliases` (`Readonly>`). Targets are strings or ordered string candidate arrays: absolute file/directory paths or package requests. They run before tsconfig paths, baseUrl and package resolution. @@ -163,14 +163,22 @@ paths, baseUrl and package resolution. `name-other`. - Declaration order matters: the first **rewriting** match wins. Put a specific key before an overlapping broad key. -- A failed rewritten target never falls through to the original request or a - later alias. Ordinary unresolved targets return `undefined`. +- Each candidate undergoes full native resolution in order; the first resolving + candidate wins. A failed matched key never falls through to the original request + or a later key. All unresolved candidates (including an empty array) produce an + importer-located error naming the key and every rewritten candidate tried. + Unaliased unresolved requests still return `undefined`. - Finite chains resolve; self-aliases are skipped; cycles produce an importer-located failure. - Package exports and active conditions are applied after rewriting. Graph package eligibility uses the actual target package, not the alias name. +- Export/active-target failures, configuration/I/O errors and cycles are fatal, + not ordinary misses that advance to the next candidate. Excluded candidates + are skipped before I/O; an entirely excluded list stays excluded, not external. - Project-local aliased providers outside configured source roots can be followed; excluded directories and excluded external packages remain excluded. This subset is tested against installed enhanced-resolve 5.25.1 with real files. -Alias `false`, array targets and wildcard keys are not supported by this API. +Alias `false` (ignore) and wildcard keys are unsupported; `false` is rejected by +the public type. Empty arrays intentionally fail rather than enhanced-resolve's +empty-array fallthrough behavior. From 03d8e84510fbe4d7d9d629e9b4a1dbcbea70625a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 08:29:43 +0900 Subject: [PATCH 38/50] fix(plugin-utils): place alias integration in the committed resolver Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/plugin-utils/src/import-graph.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 11413463..be3ee313 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -267,11 +267,7 @@ function* traverseGraph( if (resolution === false) continue const resolved = resolution?.path if (resolved && excludedDirectory(dirname(resolved))) continue - const rewritten = rewriteModuleAlias( - importRef.specifier, - options.alias ?? {}, - file, - ) + const rewritten = resolution?.request ?? importRef.specifier const local = resolved !== undefined && isInsideDir(cwd, resolved) && @@ -1055,7 +1051,6 @@ function isImportCallee(node: unknown): boolean { // TypeScript import elision (the Next.js/SWC default) removes the whole // statement, so no runtime module is ever produced. Counting such an edge as // static merges a phantom member into a bucket the bundler never compiles — - readonly aliased: boolean // the next-plugin coordinator then waits for a file that can never arrive. // A mixed clause (`{ type A, b }`) still imports the module for `b` and is // kept. A default/namespace clause is always a value import and is kept. @@ -1090,8 +1085,6 @@ function scanImports(source: string, jsx: boolean): ImportReference[] { const staticImportRegex = /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm const exportFromRegex = - if (found === undefined && options.aliased && manifest.exports !== undefined) - throw new ModuleAliasPackageError(importer, specifier) /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm @@ -1228,6 +1221,7 @@ function resolvePackage( readonly conditions: readonly string[] readonly fileResolver: (path: string) => string | false | undefined readonly excludedDirectory: (directory: string) => boolean + readonly aliased: boolean }, ): string | false | undefined { const parts = specifier.split('/') @@ -1262,6 +1256,8 @@ function resolvePackage( options.conditions, options.fileResolver, ) + if (found === undefined && options.aliased && manifest.exports !== undefined) + throw new ModuleAliasPackageError(importer, specifier) return found ? realpathSync(found) : found } From e8abf15275848af888b5a88e972ec6be5d9d9305 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 13:09:11 +0900 Subject: [PATCH 39/50] fix(plugin-utils): make import edges parser independent Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__tests__/import-scanner-boundary.test.ts | 55 +++++ .../import-scanner-jsx-boundary.test.ts | 18 ++ .../src/__tests__/import-scanner-jsx.test.ts | 148 ++++++++++++ .../src/__tests__/import-scanner.test.ts | 147 ++++++++++++ .../src/__tests__/import-type-fixtures.ts | 108 +++++++++ .../src/__tests__/import-type-graph.test.ts | 90 +++++++ .../src/__tests__/import-type.test.ts | 18 ++ .../src/__tests__/jsx-boundary-fixtures.ts | 79 +++++++ .../src/__tests__/jsx-boundary-graph.test.ts | 63 +++++ .../src/__tests__/nested-jsx-graph.test.ts | 79 +++++++ .../src/__tests__/oracle-scanner-fixtures.ts | 122 ++++++++++ .../__tests__/oracle-scanner-graph.test.ts | 117 ++++++++++ .../src/__tests__/oracle-scanner.test.ts | 20 ++ .../src/__tests__/parser-corpus.test.ts | 183 +++++++++++++++ .../src/__tests__/parser-independent.test.ts | 72 ++++++ .../src/__tests__/prepared-graph.test.ts | 16 +- .../plugin-utils/src/import-graph.test.ts | 99 ++++---- packages/plugin-utils/src/import-graph.ts | 220 +++--------------- packages/plugin-utils/src/import-mask.ts | 189 ++++++++++----- packages/plugin-utils/src/import-scanner.ts | 128 ++++++++++ packages/plugin-utils/src/import-type-mask.ts | 205 ++++++++++++++++ 21 files changed, 1860 insertions(+), 316 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-scanner.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-type-fixtures.ts create mode 100644 packages/plugin-utils/src/__tests__/import-type-graph.test.ts create mode 100644 packages/plugin-utils/src/__tests__/import-type.test.ts create mode 100644 packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts create mode 100644 packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts create mode 100644 packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts create mode 100644 packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts create mode 100644 packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts create mode 100644 packages/plugin-utils/src/__tests__/oracle-scanner.test.ts create mode 100644 packages/plugin-utils/src/__tests__/parser-corpus.test.ts create mode 100644 packages/plugin-utils/src/__tests__/parser-independent.test.ts create mode 100644 packages/plugin-utils/src/import-scanner.ts create mode 100644 packages/plugin-utils/src/import-type-mask.ts diff --git a/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts new file mode 100644 index 00000000..99ffec47 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each( + ['function f() {}', 'class C {}'].flatMap((body) => + [ + 'const x = 1\n', + 'async // marker\n', + 'const x = call()\n', + 'const x = []\n', + ].map((prefix) => ({ + code: `${prefix}${body} /import("phantom")/.test(x); import("./real");`, + })), + ), +)('masks regex after an ASI declaration in $code', ({ code }) => { + // Given: valid syntax, checked without evaluating the fixture. + new Bun.Transpiler({ loader: 'js' }).transformSync(code) + // When: scan through the real lexical boundary. + const actual = scanImports(code, false) + // Then: regex text adds no phantom dependency. + expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }]) +}) + +it.each( + ['function f() {}', 'class C {}'].flatMap((body) => + [ + ['const value =\n', ';'], + ['const value = (\n', ');'], + ['const value = [\n', '];'], + ['const value = { field:\n', '};'], + ['const value = ok ?\n', ': 0;'], + ['call(0,\n', ');'], + ['function outer() { return ', '; }'], + ['function outer() { throw ', '; }'], + ['const value = void\n', ';'], + ['const value = typeof\n', ';'], + ['const value = delete\n', ';'], + ['const value = new\n', ';'], + ['const value = !\n', ';'], + ['const value = ~\n', ';'], + ['const value = +\n', ';'], + ['const value = -\n', ';'], + ].map(([prefix, suffix]) => ({ + code: `${prefix}${body} / import("./real")${suffix}`, + })), + ), +)('keeps literal imports in expression division in $code', ({ code }) => { + // Given: each expression prefix is real syntax, not a token-only example. + new Bun.Transpiler({ loader: 'js' }).transformSync(code) + // When: scan without executing the expression or resolving its import. + const actual = scanImports(code, false) + // Then: division retains the literal import dependency. + expect(actual).toEqual([{ kind: 'dynamic', specifier: './real' }]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts new file mode 100644 index 00000000..6d68cd7b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx-boundary.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' + +it.each([...jsxBoundaryFixtures])( + 'keeps independent edges when scanning $code', + ({ code, jsx, edges }) => { + // Given: syntax-checked source, never evaluated or used to derive expectations. + new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code) + // When: scan the original source. + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + // Then: only the fixture's independently named edges survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts new file mode 100644 index 00000000..e78ae791 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner-jsx.test.ts @@ -0,0 +1,148 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each([ + { + code: "const jsx =
{import('phantom') && import('./real')}
;", + edges: ['dynamic:./real'], + }, + { + code: "const jsx =
{(import('phantom')) && import('./real')}
;", + edges: ['dynamic:./real'], + }, + { + code: "const jsx =
{require('phantom') && require('./required')}
;", + edges: ['static:./required'], + }, + { + code: "const jsx =
{(require('phantom')) && require('./required')}
;", + edges: ['static:./required'], + }, + { + code: "const jsx = import('phantom')
}>require('phantom'){import('./real')};", + edges: ['dynamic:./real'], + }, + { + code: "const jsx = require('phantom')}>import('phantom'){require('./required')};", + edges: ['static:./required'], + }, + { + code: "const jsx = && require('./required'))} label=\"import('phantom')\">require('phantom'){import('./real')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ok ? import('phantom') : import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ok ? import('./real') : require('phantom')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{({node: import('phantom'), leaf: import('./real')})}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{[import('phantom'), import('./real'), require('./required')]}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{call(require('phantom'), import('./real'), require('./required'))}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{<>import('phantom') && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{ && import('./real')}require('phantom'){require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{{import('phantom') && import('./real')}require('phantom') && require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = import('phantom')}>require('phantom'){import('./real')}}>import('phantom'){require('./required')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = } next={import('./real')} label='require("phantom")'/>; require('./required');", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx = import('phantom'), leaf: import('./real')}}>require('phantom'){require('./required')};", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{(() => { const node = import('phantom'); return import('./real') })()}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{(() => require('phantom'))() && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{`${import('phantom') && import('./real')} require('phantom')`}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const jsx =
{!import('phantom') && import('./real')}{require('./required')}
;", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "async function f() { return
{await import('phantom') && import('./real')}{require('./required')}
}", + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "function* f() { return
{(yield require('phantom')) && import('./real')}{require('./required')}
}", + edges: ['dynamic:./real', 'static:./required'], + }, +])( + 'keeps only real nested JSX edges when scanning $code', + ({ code, edges }) => { + // Given: valid TSX, checked without evaluating its imports or expressions. + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + // When: scan the original source, not the compiler output. + const actual = scanImports(code, true) + .map((edge) => `${edge.kind}:${edge.specifier}`) + .sort() + // Then: fixture-derived dependencies exclude every text/quoted phantom. + expect(actual).toEqual([...edges]) + }, +) + +it.each([ + '~', + '+', + '-', + 'void ', + 'typeof ', + 'delete ', + '0 + ', + '0 - ', + '0 * ', + '0 / ', + '0 % ', + '0 ** ', + '0 ^ ', + '0 & ', + '0 | ', + '0 << ', + '0 > ', + '0 < ', + '0 in ', + '0 instanceof ', +])('restores parent code when JSX follows expression prefix %s', (prefix) => { + // Given: valid unary/binary operand syntax surrounding nested JSX. + const code = `const jsx =
{${prefix}import('phantom') && import('./real')}{require('./required')}
;` + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + // When: scan the original operand expression. + const actual = scanImports(code, true) + // Then: expression operators neither leak text nor hide following dependencies. + expect(actual).toEqual([ + { kind: 'dynamic', specifier: './real' }, + { kind: 'static', specifier: './required' }, + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-scanner.test.ts b/packages/plugin-utils/src/__tests__/import-scanner.test.ts new file mode 100644 index 00000000..dab92e61 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-scanner.test.ts @@ -0,0 +1,147 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' + +it.each([ + { + code: 'export const result = `${import("./leaf")}`', + edges: ['dynamic:./leaf'], + }, + { + code: 'export const result = `${require("./leaf")}`', + edges: ['static:./leaf'], + }, + { + code: 'const s = `text ${ {a: `nested ${import("./a")}`, b: require("./b")} } tail import("fake")`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const s = tag`escaped \\` \\${import("fake")} ${ /[`"}]/.test("}") ? import("./a") : require("./b") } tail`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const s = `${ (() => { /* } ` */ // } `\n return import("./a") })() }`', + edges: ['dynamic:./a'], + }, + { + code: 'const s = `${
import("fake"){import("./a")}
}`', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const a = 10 / import("./a"); const b = /[`"\']import("fake")/.test(a); const c = require("./b") / 2;', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'const x = {a: 1} / import("./a"); x++ / require("./b"); --x / import("./c");', + edges: ['dynamic:./a', 'dynamic:./c', 'static:./b'], + }, + { + code: 'if (x) { y() } /import("fake")/.test(x); while (x) /import("fake")/.test(x); import("./a")', + edges: ['dynamic:./a'], + }, + { + code: 'const x = "import(\\"fake\\")"; /* require("fake") */ // import("fake")\n const y = <>
require("fake")
{require("./b")};', + edges: ['static:./b'], + }, + { + code: 'export*from"./a"; export{x}from"./b"; export * as ns from "./c"; import"./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: 'import /*x*/ {x} /*y*/ from "./a"; require /*x*/ ( /*y*/ "./b" /*z*/ ); import("./c", {with:{type:"json"}});', + edges: ['dynamic:./c', 'static:./a', 'static:./b'], + }, + { + code: 'import(name); require(name); import("./a" + name); require("./b" + name); import(`./c`); object.require("fake"); object?.import("fake");', + edges: [], + }, + { + code: 'import(`${"fake"}`); require(`text ${"fake"}`); import(`text ${import("./a")}`);', + edges: ['dynamic:./a'], + }, + { + code: 'import type {A} from "fake"; export type*from"fake"; import{type A,type B,}from"fake"; export{type A}from"fake"; import {type A,b} from "./a"; import Default,{type A}from"./b"; import type from "./c"; import{type as renamed}from"./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: String.raw`import "./\x61"; export{x}from'./\u0062'; require('./\u{63}'); import("./quo\"te"); import('./quo\'te'); import('./back\\slash');`, + edges: [ + 'dynamic:./quo"te', + "dynamic:./quo'te", + 'dynamic:./back\\slash', + 'static:./a', + 'static:./b', + 'static:./c', + ].sort(), + }, + { + code: 'const s = `${ "}\\"" + /[}\\/`]/gi.test(x) + import("./a") } after`;', + edges: ['dynamic:./a'], + }, + { + code: 'const s = `${ x in /import("fake")/ ? import("./a") : "" }`;', + edges: ['dynamic:./a'], + }, + { + code: 'import from from "./a"; import {from} from "./b"; export {from as x} from "./c";', + edges: ['static:./a', 'static:./b', 'static:./c'], + }, + { + code: 'import type, {value} from "./a"; import type, * as values from "./b"; import {"string-name" as name} from "./c"; export {name as "string-name"} from "./d";', + edges: ['static:./a', 'static:./b', 'static:./c', 'static:./d'], + }, + { + code: 'const fn = function() {} / import("./a"); const C = class {} / require("./b");', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'try {} finally {} /import("fake")/.test(x); class C {} /require("fake")/.test(x); function f() {} /import("fake")/.test(x); import("./a");', + edges: ['dynamic:./a'], + }, + { + code: 'async function f() {} /import("fake")/.test(x); const fn = async function() {} / import("./a"); const g = function(a={x:1}, b=()=>{}) { return function() {} / require("./b") };', + edges: ['dynamic:./a', 'static:./b'], + }, + { + code: 'export async /* marker */ function f() {} /import("phantom")/.test(value); import("./real");', + edges: ['dynamic:./real'], + }, + { + code: 'const fn = async /*marker*/ function() {} / import("./real")', + edges: ['dynamic:./real'], + }, + { + code: 'async/*marker*//*next*/function f() {} /import("phantom")/.test(value); import("./real");', + edges: ['dynamic:./real'], + }, + { + code: 'const value = async /*marker*/ / import("./real") / 2; /*next*/ function f() {}', + edges: ['dynamic:./real'], + }, +])('discovers only fixture-derived edges in $code', ({ code, edges }) => { + // Given: independent expected edge sets, including text that resembles imports. + // When: scan without executing source or consulting a parser. + const actual = scanImports(code, true) + .map((edge) => `${edge.kind}:${edge.specifier}`) + .sort() + // Then: only literal dependencies in code are edges. + expect(actual).toEqual([...edges]) +}) + +it('preserves TypeScript angle assertions without interpreting them as JSX', () => { + const code = 'const value = input / 2; import("./leaf")' + expect(scanImports(code, false)).toEqual([ + { kind: 'dynamic', specifier: './leaf' }, + ]) +}) + +it('decodes escaped control characters and line continuations without evaluation', () => { + const code = String.raw`require('a\n\r\t\b\f\v\0\z'); import('a\ +b'); import('a\ +b');` + expect(scanImports(code, false)).toEqual([ + { kind: 'static', specifier: 'a\n\r\t\b\f\v\0z' }, + { kind: 'dynamic', specifier: 'ab' }, + { kind: 'dynamic', specifier: 'ab' }, + ]) +}) diff --git a/packages/plugin-utils/src/__tests__/import-type-fixtures.ts b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts new file mode 100644 index 00000000..34be6b3e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type-fixtures.ts @@ -0,0 +1,108 @@ +export const importTypeFixtures: readonly { + readonly code: string + readonly jsx: boolean + readonly edges: readonly string[] +}[] = [ + ...[ + "type X = typeof import('./types');", + "type X = import('./types').T;", + "export type X = typeof import('./types');", + "export type X = import('./types').T;", + "type X = {value: import('./types').T; loader: typeof import('./types')};", + "type X = T extends import('./types').T ? import('./types').U : never;", + "type X = import('./types').T | import('./other-types').T;", + "type X = (value: import('./types').T) => import('./types').U;", + "type X = `prefix${import('./types').T}`;", + "interface X {value: import('./types').T; loader: typeof import('./types')}", + "type X = typeof /* marker */ import /* next */ ('./types');", + "type X = typeof import('./types')\n", + ].map((prefix) => ({ + code: `${prefix}\nconst x = typeof import('./real'); require('./required');`, + jsx: false, + edges: ['dynamic:./real', 'static:./required'], + })), + ...[ + "const x: import('./types').T = import('./real');", + "const x: typeof import('./types') = typeof import('./real');", + "let x: Promise = import('./real');", + "const x: T extends U ? import('./types').T : never = import('./real');", + "const f = (x: () => import('./types').T = () => import('./real')) => import('./body');", + "const x = import('./real') as {fn: () => import('./types').T};", + "const x = import('./real') as (() => import('./types').T);", + "const x = f(import('./real'));", + "function f(x = import('./real')) {return import('./body')}", + "const f = (x = import('./real')) => import('./body');", + "const x = import('./real');", + "const x = a < import('./real');", + "class C extends mixin({value: import('./real')}) {f() {return import('./body')}}", + "type X = typeof import('./types')\nimport('./real');", + "type X = import('./types').T\nimport('./real');", + "function f(x: import('./types').T = import('./real')) {return import('./body')}", + "function f(x: import('./types').T = import('./real')): Promise {return import('./body')}", + "const f = (x: import('./types').T = import('./real')): Promise => import('./body');", + "function f({x}: import('./types').T = import('./real')) {return import('./body')}", + "class C {x: import('./types').T = import('./real'); f(x: import('./types').T) {return import('./body')}}", + "const x = import('./real') as import('./types').T;", + "const x = import('./real') satisfies import('./types').T;", + "const x = {type: typeof import('./real'), value: import('./body')};", + "const f = (x = typeof import('./real')) => import('./body');", + "const x = typeof import('./real');", + "const x = ok ? f() : typeof import('./real');", + "const x = ok ? f(import('./body')) : import('./real');", + "const x = ok ? f() : ((x = import('./real')) => x);", + "const as = (x) => x; const x = as(import('./real'));", + "const satisfies = (x) => x; const x = satisfies(import('./real'));", + "const x = {as: typeof import('./real')};", + "obj.class; const x = {value: import('./real')};", + "const x = {interface: import('./real'), const: import('./body')};", + "const x = import('./real') as import('./types').T && import('./body');", + "const x = import('./real') as number ? import('./body') : null;", + "const x = ok ? (f()) : import('./real');", + "const x = ok ? (value) : typeof import('./real');", + "const x = ok ? ((f(g()))) : import('./real');", + "const x = ok ? (f(import('./body'))) : import('./real');", + "const x = ok ? (value) : {loader: import('./real')};", + "const x = ok ? (value) : (() => import('./real'));", + "const x = ok ? (value) : (x) => import('./real');", + "const x = ok ? (value) : other ? (f()) : import('./real');", + "const x = ok ? (value) : typeof (import('./real'));", + "const x = ok ? (value) : (x = import('./real')): import('./types').T => x;", + "const x = ok ? f() : (x = import('./real')): import('./types').T => x;", + "const x = ok ? (value) : (): {loader: import('./types').T} => ({loader: import('./real')});", + "const x = {async f(): import('./types').T {return import('./real')}};", + "class C {async f(): import('./types').T {return import('./real')}}", + "const x = {async f(x: import('./types').T = import('./real')): Promise {return import('./body')}};", + "class C {static async f(x: import('./types').T = import('./real')): Promise {return import('./body')}}", + "const f = async (x: import('./types').T = import('./real')): Promise => import('./body');", + "async function f(x: import('./types').T = import('./real')): Promise {return import('./body')}", + "function f(): import('./types').T; function f() {return import('./real')}", + ].map((code) => ({ + code, + jsx: false, + edges: code.includes('f(import') + ? ['dynamic:./body', 'dynamic:./real'] + : code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + { + code: "type X = import('./types').T", + jsx: false, + edges: [], + }, + { + code: "type X = import('./types').T; const x = typeof import('./types');", + jsx: false, + edges: ['dynamic:./types'], + }, + { + code: "const x: import('./types').T =
{typeof import('./real')}import('phantom')
;", + jsx: true, + edges: ['dynamic:./real'], + }, + ...[ + "const x =
{ok ? (f()) : import('./real')}import('phantom')
;", + "const x =
{ok ? (value) : typeof import('./real')}
;", + "const x =
{ok ? ((f(g()))) : import('./real')}
;", + ].map((code) => ({ code, jsx: true, edges: ['dynamic:./real'] })), +] diff --git a/packages/plugin-utils/src/__tests__/import-type-graph.test.ts b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts new file mode 100644 index 00000000..504bf497 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type-graph.test.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { importTypeFixtures } from './import-type-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + importTypeFixtures + .filter(({ edges }) => !edges.includes('dynamic:./types')) + .flatMap((fixture) => + [false, true].map((prepared) => ({ ...fixture, prepared })), + ), +)( + 'ignores poisoned type modules with prepared=$prepared when scanning $code', + async ({ code, jsx, edges, prepared }) => { + // Given: real runtime leaves and a malformed manifest for type-only requests. + const source = code + .replaceAll('./types', 'phantom') + .replaceAll('./other-types', 'phantom') + const compiled = new Bun.Transpiler({ + loader: jsx ? 'tsx' : 'ts', + }).transformSync(source) + const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, source) + for (const name of ['real', 'required', 'body']) file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + prepareSource: (filename: string) => + prepared && filename === entry ? compiled : undefined, + } + // When: traverse the actual filesystem with diagnostics disabled and injected. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: only fixture-derived runtime edges load; no type/AST phantom is resolved. + expect(injected).toEqual(disabled) + const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic'] + for (const kind of kinds) { + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual( + new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ), + ) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('reaches the poisoned manifest when typeof import is genuinely runtime code', () => { + // Given: the same poisoned dependency, in both a type and a runtime expression. + file( + 'src/main.ts', + "type X = typeof import('phantom'); const x = typeof import('phantom');", + ) + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: runtime dependency resolution must still reach the malformed manifest. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/import-type.test.ts b/packages/plugin-utils/src/__tests__/import-type.test.ts new file mode 100644 index 00000000..2a16b4d4 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/import-type.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { importTypeFixtures } from './import-type-fixtures' + +it.each([...importTypeFixtures])( + 'elides only type imports when scanning $code', + ({ code, jsx, edges }) => { + // Given: syntax-valid source and independently named runtime dependencies. + new Bun.Transpiler({ loader: jsx ? 'tsx' : 'ts' }).transformSync(code) + // When: scan original source, never compiler output or evaluated modules. + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + // Then: types disappear while runtime typeof-import and initializers survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts new file mode 100644 index 00000000..c9b5ccfc --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts @@ -0,0 +1,79 @@ +export const jsxBoundaryFixtures: readonly { + readonly code: string + readonly jsx: boolean + readonly edges: readonly string[] +}[] = [ + ...[ + 'ab', + 'a<>b', + 'a>>>b', + 'a>>=b', + 'a>>>=b', + '1 <(x)', + 'f>(x)', + 'new Map()', + 'a[0] ({ + code: `const x = ${expression}; import('./real'); require('./required');`, + jsx: true, + edges: ['dynamic:./real', 'static:./required'], + })), + ...[ + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '>(x: T)=>x', + '(x: T): T=>x', + '(x = import("./inside"))=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + '(x: T)=>x', + ].map((expression) => ({ + code: `const fn = ${expression}; import('./real'); require('./required');`, + jsx: true, + edges: expression.includes('./inside') + ? ['dynamic:./inside', 'dynamic:./real', 'static:./required'] + : ['dynamic:./real', 'static:./required'], + })), + { + code: "const x = value; type X = Map>; import('./real'); require('./required');", + jsx: false, + edges: ['dynamic:./real', 'static:./required'], + }, + ...[ + { prefix: 'const x = ', suffix: ';' }, + { prefix: 'function f(){return ', suffix: '}' }, + { prefix: 'const x = (', suffix: ');' }, + { prefix: 'const x = ok ? ', suffix: ' : null;' }, + { prefix: 'const x = ok ? null : ', suffix: ';' }, + { prefix: 'const x = ok && ', suffix: ';' }, + { prefix: 'const x = ok || ', suffix: ';' }, + { prefix: 'const x = {node: ', suffix: '};' }, + { prefix: 'const x = [', suffix: '];' }, + { prefix: 'const x = () => ', suffix: ';' }, + { prefix: 'const x = 0 << ', suffix: ';' }, + { prefix: 'const x = 0 <= ', suffix: ';' }, + { prefix: 'const x = 0 === ', suffix: ';' }, + { prefix: 'const x = ok ?? ', suffix: ';' }, + { prefix: 'const x = 0 ** ', suffix: ';' }, + ].map(({ prefix, suffix }) => ({ + code: `${prefix}require('phantom'){require('./child')}${suffix} import('./real');`, + jsx: true, + edges: ['dynamic:./attribute', 'static:./child', 'dynamic:./real'], + })), + { + code: "const jsx = import('phantom'){import('./real')}; require('./required');", + jsx: true, + edges: ['dynamic:./real', 'static:./required'], + }, +] diff --git a/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts new file mode 100644 index 00000000..0848bde9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-boundary-graph.test.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([...jsxBoundaryFixtures])( + 'preserves expected filesystem graph edges when scanning $code', + async ({ code, jsx, edges }) => { + // Given: real leaves and a poisoned phantom dependency under a unique fixture root. + const entry = file(`src/main.${jsx ? 'tsx' : 'ts'}`, code) + for (const name of ['real', 'required', 'inside', 'attribute', 'child']) + file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportDeclaration', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + prepareSource: (filename: string) => + jsx && filename === entry ? code : undefined, + } + // When: traverse the same prepared filesystem graph with diagnostics disabled and injected. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: each expected edge resolves independently, with no AST reads or phantom resolution. + expect(injected).toEqual(disabled) + const kinds: readonly ('static' | 'dynamic')[] = ['static', 'dynamic'] + for (const kind of kinds) { + const expected = new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ) + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual(expected) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) diff --git a/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts new file mode 100644 index 00000000..f2d37040 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/nested-jsx-graph.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + [ + "const jsx =
{import('phantom') && import('./real')}{require('phantom') && require('./required')}
;", + "const jsx =
{(import('phantom')) && import('./real')}{(require('phantom')) && require('./required')}
;", + "const jsx = import('phantom')} next={require('phantom')}>require('phantom'){import('./real')}{require('./required')};", + ].flatMap((code) => + [false, true].flatMap((enabled) => + [false, true].map((prepared) => ({ code, enabled, prepared })), + ), + ), +)( + 'resolves only real nested JSX files with diagnostics=$enabled prepared=$prepared in $code', + async ({ code, enabled, prepared }) => { + // Given: independently created targets and a manifest that fails if resolved. + new Bun.Transpiler({ loader: 'tsx' }).transformSync(code) + const entry = file('src/main.tsx', code) + const real = file('src/real.ts', 'export const real = 1') + const required = file('src/required.ts', 'export const required = 2') + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const loaded: string[] = [] + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: traverse the real filesystem, never executing a fixture module. + const graph = prepared + ? await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: (filename: string) => { + loaded.push(filename) + return readFileSync(filename, 'utf8') + }, + }) + : buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: real edges load real files; no textual/AST phantom reaches resolution. + expect(graph.dynamicImports.get(entry)).toEqual(new Set([real])) + expect(graph.staticImports.get(entry)).toEqual(new Set([required])) + expect(graph.staticImporters.get(required)).toEqual(new Set([entry])) + expect(graph.dynamicTargets).toEqual(new Set([real])) + expect(graph.files).toEqual([entry, real, required].sort()) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + expect(loaded.sort()).toEqual( + prepared ? [entry, real, required].sort() : [], + ) + expect(parseSync).toHaveBeenCalledTimes(enabled ? 3 : 0) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('rejects the poisoned manifest when an actual literal edge requests phantom', () => { + // Given: the same poison is reachable by a real dependency, not excluded. + file('src/main.tsx', "import('phantom')") + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: resolution of that real edge must fail. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts new file mode 100644 index 00000000..bcfb9e53 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts @@ -0,0 +1,122 @@ +export const oracleScannerFixtures: readonly { + readonly code: string + readonly loader: 'js' | 'jsx' | 'ts' | 'tsx' + readonly edges: readonly string[] +}[] = [ + ...[ + "const x = a < import('./real') > (b);", + "const x = a < import('./real') > b;", + "const x = a < import('./real') > (import('./body'));", + "const x = a < import('./real') > (b) / 2;", + "const x = a < import('./real') > (b); const node =
import('phantom'){require('./required')}
;", + ].map((code) => ({ + code, + loader: code.includes('
') ? ('jsx' as const) : ('js' as const), + edges: code.includes('./required') + ? ['dynamic:./real', 'static:./required'] + : code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + ...[ + "type X = string\nfoo(import('./real'));", + "type X = string\nvoid import('./real');", + "type X = string\n(() => import('./real'))();", + "type X = import('./types').T\nfoo(import('./real'));", + "type X = {x: import('./types').T}\n(() => import('./real'))();", + "type X = string /* newline\n */ foo(import('./real'));", + "type X =\n import('./types').T\n | import('./types').U\nfoo(import('./real'));", + "type X = T\n & import('./types').T\nvoid import('./real');", + "type X = import('./types').T\n['x']\nfoo(import('./real'));", + "type X = T extends\n import('./types').T\n ? import('./types').U\n : import('./types').V\nfoo(import('./real'));", + "type X = (x: import('./types').T)\n => import('./types').U\nfoo(import('./real'));", + "type X = \n(x: T) => import('./types').U\nfoo(import('./real'));", + "type X = typeof import('./types')\nfoo(import('./real'));", + "type X = keyof\n import('./types').T\nvoid import('./real');", + "type X = new\n(x: import('./types').T) => import('./types').U\nfoo(import('./real'));", + "let x: import('./types').T\nfoo(import('./real'));", + "const x = value as import('./types').T\nvoid import('./real');", + "const x = foo! as import('./types').T; import('./real');", + "const x = foo! satisfies import('./types').T; import('./real');", + "const x = import('./real')! as import('./types').T;", + "const x = import('./real')! satisfies import('./types').T;", + "const x = !import('./real') as import('./types').T;", + "const x = 'text' as import('./types').T; import('./real');", + "const x = {} satisfies import('./types').T; import('./real');", + "const x = foo! / import('./real');", + "const x = !import('./real');", + "const x = import('./real') as import('./types').T;", + "declare function f(): import('./types').T; import('./real');", + "declare function f(): import('./types').T; import('./real');", + "declare function f>(x: import('./types').T): import('./types').T; import('./real');", + "declare function f(): import('./types').T\nimport('./real');", + "function f(): import('./types').T; function f() {return import('./real')}", + "function f(x: import('./types').T = import('./real')): import('./types').T {return import('./body')}", + "const f = (x = import('./real')): import('./types').T => import('./body');", + "const x = f(import('./real'));", + "const x = ok ? (f()) : import('./real');", + "const x = a < import('./real') > b;", + "const x = import('./real');", + "const x = value / import('./real');", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: code.includes('./body') + ? ['dynamic:./real', 'dynamic:./body'] + : ['dynamic:./real'], + })), + ...[ + "const x = foo! as import('./types').T;", + "const x = foo! satisfies import('./types').T;", + "declare function f(): import('./types').T;", + "declare function f>(): import('./types').T;", + "declare function f(): import('./types').T", + "const x = a < import('./types') > (b);", + "import { type as } from './types';", + "export { type as } from './types';", + "import { type as as as } from './types';", + "export { type as as as } from './types';", + "import { type as as renamed } from './types';", + "export { type as as renamed } from './types';", + "import {type A, type as,} from './types';", + "export {type as as 'renamed'} from './types';", + ].map((code) => ({ code, loader: 'ts' as const, edges: [] })), + ...[ + "async function f(){for await(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for /*marker*/ await /*next*/ (const x of xs) /require('phantom')/.test(x); import('./real');}", + "async function f(){for //marker\n await (const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of xs) import('./real');}", + "async function f(){for await(const x of xs) {import('./real');} /import('phantom')/.test(xs);}", + "async function f(){const x = await value / import('./real');}", + "async function f(){const x = await (value) / import('./real');}", + "async function f(){for(const x of xs) /import('phantom')/.test(x); import('./real');}", + "async function f(){for await(const x of import('./real')) /import('phantom')/.test(x);}", + "async function f(){for await(const x of xs) constCall(import('./real'));}", + ].map((code) => ({ code, loader: 'js' as const, edges: ['dynamic:./real'] })), + ...[ + "import {type as renamed} from './required';", + "export {type as renamed} from './required';", + "import {type as as} from './required';", + "export {type as as} from './required';", + "import {type} from './required';", + "import {from} from './required';", + "import {'type' as renamed} from './required';", + "export {type as 'as'} from './required';", + "import {type A, type as renamed} from './required';", + "export {type as, value} from './required';", + ].map((code) => ({ + code, + loader: 'ts' as const, + edges: ['static:./required'], + })), + { + code: "const f = (x = import('./real')) =>
import('phantom'){require('./required')}
;", + loader: 'tsx', + edges: ['dynamic:./real', 'static:./required'], + }, + { + code: "const x = `import('phantom') ${
import('phantom'){import('./real')}
}`; import(name); require('./' + name);", + loader: 'jsx', + edges: ['dynamic:./real'], + }, +] diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts new file mode 100644 index 00000000..65b1f624 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner-graph.test.ts @@ -0,0 +1,117 @@ +import { join } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each( + oracleScannerFixtures.flatMap((fixture) => + [false, true].map((prepared) => ({ ...fixture, prepared })), + ), +)( + 'resolves independent real paths without poisoned false edges when $loader prepared=$prepared: $code', + async ({ code, loader, edges, prepared }) => { + // Given: real source-language filenames, live leaves, and poisoned phantom manifests. + const source = code.replaceAll('./types', 'phantom') + const entry = file(`src/main.${loader}`, source) + for (const name of ['real', 'required', 'body']) file(`src/${name}.ts`) + file('node_modules/phantom/package.json', '{') + const programRead = mock(() => ({ + type: 'ImportExpression', + source: { value: 'phantom' }, + })) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + // When: scan raw or same-language prepared source with diagnostics disabled/injected. + const options = { cwd: root } + const preparedOptions = { + ...options, + prepareSource: (filename: string) => + filename === entry ? source : undefined, + } + __setOxcParserForTest(false) + const disabled = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = prepared + ? await buildStaticImportGraph('src', undefined, preparedOptions) + : buildStaticImportGraph('src', undefined, options) + // Then: full graph parity, independent expected paths, and zero AST authority. + expect(injected).toEqual(disabled) + for (const kind of ['static', 'dynamic'] as const) { + expect( + { static: injected.staticImports, dynamic: injected.dynamicImports }[ + kind + ].get(entry), + ).toEqual( + new Set( + edges + .filter((edge) => edge.startsWith(`${kind}:`)) + .map((edge) => + join(root, 'src', `${edge.slice(kind.length + 3)}.ts`), + ), + ), + ) + } + expect(injected.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() + }, +) + +it('keeps JavaScript comparison operands under a real compiled Markdown filename', async () => { + // Given: compiled JSX under the original custom extension, never a renamed shim. + const entry = file('src/page.mdown', '# source') + const real = file('src/real.ts') + file('node_modules/phantom/package.json', '{') + const code = + "const x = a < import('./real') > (b); const node =
import('phantom')
;" + const programRead = mock(() => ({})) + const parseSync = mock(() => ({ + errors: [], + get program() { + return programRead() + }, + })) + const options = { + cwd: root, + includeMdx: ['.mdown'], + prepareSource: (filename: string) => + filename === entry ? code : undefined, + } + // When: traverse compiled JS/JSX with both optional diagnostic modes. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph('src', undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph('src', undefined, options) + // Then: the real path and dynamic operand survive; quoted JSX text stays masked. + expect(injected).toEqual(disabled) + expect(injected.dynamicImports.get(entry)).toEqual(new Set([real])) + expect(injected.staticImports.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(injected.files.length) + expect(programRead).not.toHaveBeenCalled() +}) + +it('reaches a poisoned manifest when an ambiguous type-named binding is a value', () => { + // Given: the value spelling adjacent to the type-only as countercases. + file('src/main.ts', "import {type as renamed} from 'phantom';") + file('node_modules/phantom/package.json', '{') + __setOxcParserForTest(false) + // When/Then: a genuine value dependency still reaches manifest validation. + expect(() => buildStaticImportGraph('src', undefined, { cwd: root })).toThrow( + ConfigLoadError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts new file mode 100644 index 00000000..4eacba67 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/oracle-scanner.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it.each([...oracleScannerFixtures])( + 'preserves language-sensitive runtime edges when scanning $loader: $code', + ({ code, loader, edges }) => { + // Given: syntax-only compilation and independently specified dependencies. + new Bun.Transpiler({ loader }).transformSync(code) + // When: scan the original source in its actual language, without executing it. + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + // Then: lexical/type-only imports disappear and runtime operands survive. + expect(actual).toEqual([...edges]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/parser-corpus.test.ts b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts new file mode 100644 index 00000000..abdc1884 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts @@ -0,0 +1,183 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { extname, join, relative, resolve } from 'node:path' + +import { expect, it, mock } from 'bun:test' + +import { + __setOxcParserForTest, + buildStaticImportGraph, + listSourceFiles, +} from '../import-graph' +import { scanImports } from '../import-scanner' +import { importTypeFixtures } from './import-type-fixtures' +import { jsxBoundaryFixtures } from './jsx-boundary-fixtures' +import { oracleScannerFixtures } from './oracle-scanner-fixtures' + +it('keeps all package, landing and real compiled docs edges parser-independent', async () => { + // Given: actual repository sources and the landing compiler/provider configuration. + const cwd = resolve(import.meta.dir, '../../../..') + const projectRequire = createRequire(join(cwd, 'apps/landing/package.json')) + const compilerRequire = createRequire( + projectRequire.resolve('@mdx-js/loader'), + ) + const { + compile, + }: { + readonly compile: ( + input: { readonly path: string; readonly value: string | undefined }, + options: { readonly providerImportSource: string }, + ) => Promise<{ readonly value: string }> + } = compilerRequire('@mdx-js/mdx') + const roots = readdirSync(join(cwd, 'packages'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(cwd, 'packages', entry.name, 'src')) + const landing = join(cwd, 'apps/landing/src') + roots.push(landing) + const exclude = [ + '__tests__', + '__regression__', + '__fixtures__', + 'dist', + 'pkg', + 'df', + 'generated', + ] + const selection = { includeMdx: ['.mdx'] } + const files = [ + ...new Set( + roots.flatMap((root) => listSourceFiles(root, exclude, selection)), + ), + ].sort() + const code = new Map( + files.map((filename) => [filename, readFileSync(filename, 'utf8')]), + ) + const docs = files.filter((filename) => /\.mdx?$/.test(filename)) + for (const filename of docs) { + const compiled = await compile( + { path: filename, value: code.get(filename) }, + { providerImportSource: '@/mdx-components' }, + ) + code.set(filename, String(compiled)) + } + const options = { + cwd, + exclude, + ...selection, + prepareSource: (filename: string) => + /\.mdx?$/.test(filename) ? code.get(filename) : undefined, + } + const programRead = mock(() => ({ + type: 'ImportDeclaration', + source: { value: 'corpus-phantom' }, + })) + const parseSync = mock((filename: string, source: string) => { + expect(code.get(filename)).toBe(source) + return { + errors: [], + get program() { + return programRead() + }, + } + }) + try { + // When: run the real graph/resolution path both without and with optional diagnostics. + __setOxcParserForTest(false) + const disabled = await buildStaticImportGraph(roots, undefined, options) + __setOxcParserForTest({ parseSync }) + const injected = await buildStaticImportGraph(roots, undefined, options) + // Then: compare every edge, not just aggregate counts; prove the optional path ran. + expect(injected).toEqual(disabled) + expect(injected.files).toEqual(files) + expect(parseSync.mock.calls.map(([filename]) => filename).sort()).toEqual( + files, + ) + expect(programRead).not.toHaveBeenCalled() + const packages = files.filter((filename) => !filename.startsWith(landing)) + const app = files.filter( + (filename) => filename.startsWith(landing) && !docs.includes(filename), + ) + expect(packages.length).toBeGreaterThan(0) + expect(app.length).toBeGreaterThan(0) + expect(docs.length).toBeGreaterThan(0) + for (const filename of docs) { + expect(disabled.externalImports?.get(filename)).toContain( + 'react/jsx-runtime', + ) + expect(disabled.externalImports?.get(filename)).toContain( + '@/mdx-components', + ) + } + const entries = files.map((filename) => ({ + file: relative(cwd, filename).replaceAll('\\', '/'), + extension: extname(filename), + static: [...(disabled.staticImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + dynamic: [...(disabled.dynamicImports.get(filename) ?? [])] + .map((target) => relative(cwd, target).replaceAll('\\', '/')) + .sort(), + external: [...(disabled.externalImports?.get(filename) ?? [])].sort(), + })) + const artifact = { + roots: roots.map((root) => relative(cwd, root).replaceAll('\\', '/')), + exclude, + filter: + 'JS/TS source extensions plus .mdx; test/spec files and node_modules omitted', + compiler: + '@mdx-js/mdx via landing @mdx-js/loader; providerImportSource=@/mdx-components; no remark/rehype plugins', + counts: { + packageSources: packages.length, + landingSources: app.length, + compiledDocs: docs.length, + total: files.length, + diagnosticCalls: parseSync.mock.calls.length, + programReads: programRead.mock.calls.length, + staticEdges: entries.reduce( + (sum, entry) => sum + entry.static.length, + 0, + ), + dynamicEdges: entries.reduce( + (sum, entry) => sum + entry.dynamic.length, + 0, + ), + externalEdges: entries.reduce( + (sum, entry) => sum + entry.external.length, + 0, + ), + }, + parity: true, + targeted: jsxBoundaryFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + typeTargeted: importTypeFixtures.map(({ code, jsx, edges }) => { + const actual = scanImports(code, jsx).map( + ({ kind, specifier }) => `${kind}:${specifier}`, + ) + expect(actual).toEqual([...edges]) + return { code, jsx, expected: edges, actual } + }), + oracleTargeted: oracleScannerFixtures.map(({ code, loader, edges }) => { + const actual = scanImports( + code, + loader.endsWith('x'), + loader.startsWith('t'), + ).map(({ kind, specifier }) => `${kind}:${specifier}`) + expect(actual).toEqual([...edges]) + return { code, loader, expected: edges, actual } + }), + entries, + } + if (process.env.DEVUP_SCANNER_CORPUS_ARTIFACT) + writeFileSync( + process.env.DEVUP_SCANNER_CORPUS_ARTIFACT, + JSON.stringify(artifact, null, 2), + ) + } finally { + __setOxcParserForTest(undefined) + } +}, 30000) diff --git a/packages/plugin-utils/src/__tests__/parser-independent.test.ts b/packages/plugin-utils/src/__tests__/parser-independent.test.ts new file mode 100644 index 00000000..6a601b96 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/parser-independent.test.ts @@ -0,0 +1,72 @@ +import { expect, it, mock } from 'bun:test' + +import { __setOxcParserForTest, buildStaticImportGraph } from '../import-graph' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([false, true])( + 'discovers the real template interpolation import with parser enabled %s', + (enabled) => { + // Given: valid source and a real leaf; the injected AST describes the prior divergence. + const entry = file( + 'src/main.ts', + "export const result = `${import('./leaf')}`", + ) + const leaf = file('src/leaf.ts') + const parseSync = mock(() => ({ + program: { type: 'ImportExpression', source: { value: './leaf' } }, + errors: [], + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: build the real filesystem graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: interpolation code loads the leaf regardless of parser presence. + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + if (enabled) expect(parseSync).toHaveBeenCalledTimes(2) + }, +) + +it.each([false, true])( + 'ignores contradictory AST edges with optional diagnostics %s', + (enabled) => { + // Given: literal code edges and poisoned textual/AST dependencies. + const entry = file( + 'src/main.tsx', + [ + 'export * from "./static"; export {value} from "./reexport"; import "./side-effect";', + 'const value = `text import("phantom") ${import("./dynamic")} ${require("./required")}`;', + 'const nested = `${ `inner ${import("./nested")}` }`;', + 'const regex = /[`"\']import("phantom")/; const division = 1 / import("./division");', + 'const jsx =
import("phantom"){require("./attribute")}
;', + '/* import("phantom") */ import(name); require(name); import("phantom" + name);', + ].join('\n'), + ) + const staticTargets = [ + 'static', + 'reexport', + 'side-effect', + 'required', + 'attribute', + ].map((name) => file(`src/${name}.ts`)) + const dynamicTargets = ['dynamic', 'nested', 'division'].map((name) => + file(`src/${name}.ts`), + ) + file('node_modules/phantom/package.json', '{') + const parseSync = mock(() => ({ + errors: [], + program: { type: 'ImportExpression', source: { value: 'phantom' } }, + })) + __setOxcParserForTest(enabled ? { parseSync } : false) + // When: resolve the fixture through the actual graph path. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then: expected paths come only from the fixture's real literal dependencies. + expect(graph.staticImports.get(entry)).toEqual(new Set(staticTargets)) + expect(graph.dynamicImports.get(entry)).toEqual(new Set(dynamicTargets)) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + expect(parseSync).toHaveBeenCalledTimes(enabled ? graph.files.length : 0) + }, +) diff --git a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts index a953181d..4ba04545 100644 --- a/packages/plugin-utils/src/__tests__/prepared-graph.test.ts +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -109,29 +109,32 @@ it('does not fall back to raw reads or lexical scanning after a prepared parser ).rejects.toThrow(`${entry}:2:3 (in compiled output)`) }) -it('uses the available AST result for prepared source', async () => { +it('uses real prepared source rather than contradictory AST edges', async () => { const entry = file('src/page.mdx') const leaf = file('src/leaf.ts') + const phantom = file('src/phantom.ts') __setOxcParserForTest({ parseSync: () => ({ type: 'ImportDeclaration', - source: { value: './leaf' }, + source: { value: './phantom' }, }), }) const graph = await buildStaticImportGraph('src', undefined, { cwd: root, includeMdx: true, - prepareSource: () => 'compiled', + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, }) expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.staticImporters.get(phantom)).toEqual(new Set()) }) -it('reads the documented available-parser program getter for prepared source', async () => { +it('reads diagnostic getters without consulting the available-parser program getter', async () => { const entry = file('src/page.mdx') const leaf = file('src/leaf.ts') const result = Object.create({ get program() { - return { type: 'ImportDeclaration', source: { value: './leaf' } } + throw new Error('AST must not decide edges') }, get errors() { return [] @@ -141,7 +144,8 @@ it('reads the documented available-parser program getter for prepared source', a const graph = await buildStaticImportGraph('src', undefined, { cwd: root, includeMdx: true, - prepareSource: () => '', + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, }) expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) }) diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index c92e2256..885f501a 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -1074,11 +1074,7 @@ describe('planAtomHoist', () => { }) }) -// The oxc AST path is the fast parser used when `oxc-parser` is installed in -// the host project. It is absent in this repo, so we inject a fake parser to -// exercise the AST walk (module state is shared across test files, so this is -// reset after each test back to the regex fallback). -describe('oxc AST parsing path', () => { +describe('optional oxc diagnostic path', () => { let tempRoot: string let cwd: string let srcDir: string @@ -1101,115 +1097,91 @@ describe('oxc AST parsing path', () => { writeFileSync(filePath, code) } - it('collects every import/export node kind from the AST (type-only excluded)', () => { - const circular: Record = { type: 'SelfRef' } - circular.self = circular // self-reference -> exercises the `seen` guard + it('ignores every AST edge kind and derives canonical members from real source', () => { const richProgram = { type: 'Program', body: [ - // value import -> static edge (getStringLiteralValue via `.value`) { type: 'ImportDeclaration', importKind: 'value', source: { value: './val' }, }, - // `import type` -> skipped (importKind 'type') { type: 'ImportDeclaration', importKind: 'type', source: { value: './t1' }, }, - // value re-export -> static edge { type: 'ExportNamedDeclaration', exportKind: 'value', source: { value: './exp' }, }, - // `export type` -> skipped (exportKind 'type') { type: 'ExportNamedDeclaration', exportKind: 'type', source: { value: './t2' }, }, - // export-all -> static edge { type: 'ExportAllDeclaration', source: { value: './all' } }, - // dynamic import expression with `.source` { type: 'ImportExpression', source: { value: './dyn1' } }, - // dynamic import expression falling back to `.argument` { type: 'ImportExpression', argument: { value: './dyn2' } }, - // import() call via callee.type === 'Import', specifier via `.raw` { type: 'CallExpression', callee: { type: 'Import' }, arguments: [{ raw: "'./dyn3'" }], }, - // import() call via callee.name === 'import' { type: 'CallExpression', callee: { name: 'import' }, arguments: [{ value: './dyn4' }], }, - // import() with non-array arguments -> first arg undefined -> no push { type: 'CallExpression', callee: { type: 'Import' }, arguments: 'not-an-array', }, - // non-import call (isImportCallee false via name) -> falls through { type: 'CallExpression', callee: { name: 'other' }, arguments: [] }, - // non-record callee -> isImportCallee returns false { type: 'CallExpression', callee: null, arguments: [] }, - // source literal with neither `.value` nor `.raw` -> no push { type: 'ImportDeclaration', importKind: 'value', source: { kind: 'no-literal' }, }, - circular, 'primitive-child', 7, null, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program: richProgram } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'parsed by the fake oxc parser, content ignored') - writeFixture( - 'src/val.tsx', - 'parsed by the fake oxc parser, content ignored', - ) - - // Proof the AST path ran: `a` statically imports `./val` -> val collapses - // into a. The regex fallback would parse the literal content -> no imports. + writeFixture('src/a.tsx', "import './real'") + writeFixture('src/val.tsx', 'export {}') + writeFixture('src/real.tsx', 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/val.tsx': 'src/a.tsx', + 'src/real.tsx': 'src/a.tsx', }) }) - it('skips AST import/export nodes whose specifiers are all inline-type', () => { + it('elides source type-only imports even when AST import kinds disagree', () => { const program = { type: 'Program', body: [ - // all-inline-type import -> erased by the bundler -> no edge { type: 'ImportDeclaration', importKind: 'value', - specifiers: [{ type: 'ImportSpecifier', importKind: 'type' }], + specifiers: [{ type: 'ImportSpecifier', importKind: 'value' }], source: { value: './phantom' }, }, - // all-inline-type re-export -> erased -> no edge { type: 'ExportNamedDeclaration', exportKind: 'value', - specifiers: [{ type: 'ExportSpecifier', exportKind: 'type' }], + specifiers: [{ type: 'ExportSpecifier', exportKind: 'value' }], source: { value: './phantom2' }, }, - // mixed inline types -> module still imported -> edge kept { type: 'ImportDeclaration', importKind: 'value', @@ -1219,7 +1191,6 @@ describe('oxc AST parsing path', () => { ], source: { value: './mixed' }, }, - // default specifier (no importKind) alongside an inline type -> kept { type: 'ImportDeclaration', importKind: 'value', @@ -1229,38 +1200,47 @@ describe('oxc AST parsing path', () => { ], source: { value: './withdefault' }, }, - // non-record specifier entry -> not type-only -> kept { type: 'ImportDeclaration', importKind: 'value', specifiers: ['bogus'], source: { value: './bogus' }, }, - // empty specifier list (`import {} from`) -> kept (side-effect import) { type: 'ImportDeclaration', importKind: 'value', - specifiers: [] as unknown[], + specifiers: [], source: { value: './empty' }, }, - ] as unknown[], + ], } __setOxcParserForTest({ parseSync: (filename: string) => filename.endsWith('a.tsx') ? { program } - : { program: { type: 'Program', body: [] as unknown[] } }, + : { program: { type: 'Program', body: [] } }, }) - writeFixture('src/a.tsx', 'fake parser input') - writeFixture('src/phantom.tsx', 'fake parser input') - writeFixture('src/phantom2.tsx', 'fake parser input') - writeFixture('src/mixed.tsx', 'fake parser input') - writeFixture('src/withdefault.tsx', 'fake parser input') - writeFixture('src/bogus.tsx', 'fake parser input') - writeFixture('src/empty.tsx', 'fake parser input') - - // phantom/phantom2 gain no importer (edges dropped) -> roots, not members. + writeFixture( + 'src/a.tsx', + [ + "import { type A } from './phantom'", + "export { type B } from './phantom2'", + "import { type A, b } from './mixed'", + "import Default, { type A } from './withdefault'", + "import { value } from './bogus'", + "import {} from './empty'", + ].join('\n'), + ) + for (const name of [ + 'phantom', + 'phantom2', + 'mixed', + 'withdefault', + 'bogus', + 'empty', + ]) + writeFixture(`src/${name}.tsx`, 'export {}') expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ 'src/bogus.tsx': 'src/a.tsx', 'src/empty.tsx': 'src/a.tsx', @@ -1269,20 +1249,19 @@ describe('oxc AST parsing path', () => { }) }) - it('falls back to the regex scan when the oxc parser throws', () => { + it('reports an optional parser fault rather than silently changing edge authority', () => { __setOxcParserForTest({ parseSync: () => { - throw new Error('boom') + throw Object.assign(new Error('boom'), { line: 2, column: 3 }) }, }) writeFixture('src/a.tsx', "import './b'\n") writeFixture('src/b.tsx', 'export const b = 1\n') - // parseSync throws -> parseImportsWithOxc returns undefined -> scanImports. - expect(buildCanonicalMap({ cwd, srcDir })).toEqual({ - 'src/b.tsx': 'src/a.tsx', - }) + expect(() => buildCanonicalMap({ cwd, srcDir })).toThrow( + `${join(srcDir, 'a.tsx')}:2:3`, + ) }) }) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index be3ee313..be2bb585 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -10,7 +10,7 @@ import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { createDirectoryExclusion } from './directory-exclusion' -import { maskImportText } from './import-mask' +import { type ImportReference, scanImports } from './import-scanner' import { ConfigLoadError } from './load-config' import { remapMdxError } from './mdx-errors' import { @@ -63,11 +63,6 @@ export interface BuildCanonicalMapOptions { graph?: StaticImportGraph } -interface ImportReference { - kind: 'static' | 'dynamic' - specifier: string -} - interface OxcParser { parseSync: ( filename: string, @@ -848,41 +843,53 @@ export function listSourceFiles( function parseImports(filename: string, source: string): ImportReference[] { if (!SOURCE_FILE_RE.test(filename)) - return scanImports(mdxEsmSource(source), false) - const astImports = parseImportsWithOxc(filename, source) - if (astImports) return astImports - return scanImports(source, /\.[jt]sx$/i.test(filename)) + return scanImports(mdxEsmSource(source), false, false) + return parseSourceImports(filename, source) } function parsePreparedImports( filename: string, source: string, map: unknown, +): ImportReference[] { + return parseSourceImports(filename, source, { map }) +} + +function parseSourceImports( + filename: string, + source: string, + prepared?: { readonly map: unknown }, ): ImportReference[] { const parser = getOxcParser() - if (!parser) return scanImports(source, true) try { - const ast = parser.parseSync(filename, source, { - sourceType: 'module', - lang: 'jsx', - }) + const ast = parser?.parseSync( + filename, + source, + prepared && !/\.[mc]?tsx?$/i.test(filename) + ? { sourceType: 'module', lang: 'jsx' } + : { sourceType: 'module' }, + ) if (isRecord(ast) && Array.isArray(ast.errors) && ast.errors.length) { throw new Error(preparedDiagnostics(filename, source, ast.errors)) } - const imports: ImportReference[] = [] - collectAstImports(isRecord(ast) ? (ast.program ?? ast) : ast, imports) - return imports } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause) const located = message.startsWith(`${filename}:`) ? cause : new GraphPreparationError(filename, cause) - const remapped = remapMdxError(located, filename, map) + if (!prepared) throw located + const remapped = remapMdxError(located, filename, prepared.map) throw new Error( remapped.message.replaceAll('(in compiled MDX)', '(in compiled output)'), { cause }, ) } + return scanImports( + source, + /\.[jt]sx$/i.test(filename) || + (prepared !== undefined && !/\.[mc]?ts$/i.test(filename)), + /\.[mc]?tsx?$/i.test(filename), + ) } function mdxEsmSource(source: string): string { @@ -917,23 +924,6 @@ function mdxEsmSource(source: string): string { return blocks.join('\n') } -function parseImportsWithOxc( - filename: string, - source: string, -): ImportReference[] | undefined { - const parser = getOxcParser() - if (!parser) return undefined - - try { - const ast = parser.parseSync(filename, source, { sourceType: 'module' }) - const imports: ImportReference[] = [] - collectAstImports(ast, imports) - return imports - } catch { - return undefined - } -} - function getOxcParser(): OxcParser | undefined { if (cachedOxcParser !== undefined) { return cachedOxcParser || undefined @@ -953,10 +943,10 @@ function getOxcParser(): OxcParser | undefined { /** * @internal test-only: force the cached oxc parser. oxc-parser is an optional - * peer that is absent in this repo, so the AST path is otherwise unreachable + * peer that is absent in this repo, so the diagnostic path is otherwise unreachable * from tests; module state is shared across test files (no per-file reset), so * `mock.module` cannot toggle it deterministically. Pass `undefined` to clear - * the cache and re-detect (back to the regex fallback). + * the cache and re-detect. The scanner remains the sole edge authority. */ export function __setOxcParserForTest( parser: OxcParser | false | undefined, @@ -964,160 +954,6 @@ export function __setOxcParserForTest( cachedOxcParser = parser } -function collectAstImports( - node: unknown, - imports: ImportReference[], - seen = new WeakSet(), -): void { - if (!isRecord(node)) return - if (seen.has(node)) return - seen.add(node) - - const type = typeof node.type === 'string' ? node.type : undefined - if ( - type === 'ImportDeclaration' || - type === 'ExportNamedDeclaration' || - type === 'ExportAllDeclaration' - ) { - // `import type`/`export type ... from` carry importKind/exportKind 'type'. - // They are erased at build time (no runtime module), so they must NOT - // become static graph edges — see the regex fallback in `scanImports`. - // The same applies when every specifier is inline-type - // (`import { type A } from` / `export { type A } from`). - if ( - node.importKind !== 'type' && - node.exportKind !== 'type' && - !hasOnlyInlineTypeSpecifiers(node) - ) { - addAstImport(imports, 'static', node.source) - } - } else if (type === 'ImportExpression') { - addAstImport(imports, 'dynamic', node.source ?? node.argument) - } else if (type === 'CallExpression' && isImportCallee(node.callee)) { - const firstArgument = Array.isArray(node.arguments) - ? node.arguments[0] - : undefined - addAstImport(imports, 'dynamic', firstArgument) - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - collectAstImports(child, imports, seen) - } - continue - } - collectAstImports(value, imports, seen) - } -} - -// AST counterpart of `isAllInlineTypeSpecifiers`: an import/re-export whose -// specifiers are ALL inline-type is erased by the bundler (no runtime module), -// so it must not become a static graph edge. Default/namespace specifiers -// carry no `type` kind, so their presence keeps the edge. -function hasOnlyInlineTypeSpecifiers(node: Record): boolean { - const specifiers = node.specifiers - if (!Array.isArray(specifiers) || specifiers.length === 0) return false - return specifiers.every( - (specifier) => - isRecord(specifier) && - (specifier.importKind === 'type' || specifier.exportKind === 'type'), - ) -} - -function addAstImport( - imports: ImportReference[], - kind: ImportReference['kind'], - node: unknown, -): void { - const specifier = getStringLiteralValue(node) - if (specifier) imports.push({ kind, specifier }) -} - -function getStringLiteralValue(node: unknown): string | undefined { - if (!isRecord(node)) return undefined - if (typeof node.value === 'string') return node.value - if (typeof node.raw === 'string') return node.raw.slice(1, -1) - return undefined -} - -function isImportCallee(node: unknown): boolean { - if (!isRecord(node)) return false - return node.type === 'Import' || node.name === 'import' -} - -// A brace clause whose specifiers are ALL inline-type (`{ type A, type B }`) -// is erased by the bundler exactly like a statement-level `import type`: -// TypeScript import elision (the Next.js/SWC default) removes the whole -// statement, so no runtime module is ever produced. Counting such an edge as -// static merges a phantom member into a bucket the bundler never compiles — -// the next-plugin coordinator then waits for a file that can never arrive. -// A mixed clause (`{ type A, b }`) still imports the module for `b` and is -// kept. A default/namespace clause is always a value import and is kept. -function isAllInlineTypeSpecifiers(clause: string | undefined): boolean { - if (!clause) return false - const trimmed = clause.trim() - if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false - const specifiers = trimmed - .slice(1, -1) - .split(',') - .map((specifier) => specifier.trim()) - .filter((specifier) => specifier.length > 0) - return ( - specifiers.length > 0 && - specifiers.every((specifier) => /^type\s/.test(specifier)) - ) -} - -function scanImports(source: string, jsx: boolean): ImportReference[] { - const imports: ImportReference[] = [] - const code = maskImportText(source, jsx) - // The leading `(type\s+)?` is CAPTURED (not skipped) so we can drop type-only - // statements: `import type ... from` / `export type ... from` are erased by - // the bundler and produce NO runtime module — counting them as static graph - // edges merges phantom members into a bucket that the bundler never compiles, - // which is exactly what forced the coordinator's wall-clock fail-open to fire. - // The clause between the keyword and `from` is captured too, so all-inline- - // type specifier lists (`import { type A } from` / `export { type A } from`) - // — which the bundler also erases — are dropped via - // `isAllInlineTypeSpecifiers`. Mixed lists (`import { type A, b } from`) - // keep importing the module for `b`, so they are kept. - const staticImportRegex = - /\bimport\s+(type\s+)?(?:([^'"`]*?)\s+from\s*)?(['"])([^'"]+)\3/gm - const exportFromRegex = - /\bexport\s+(type\s+)?(\*[^'"`]*?|\{[^}]*\})\s+from\s*(['"])([^'"]+)\3/gm - const dynamicImportRegex = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/gm - - for (const match of code.matchAll(staticImportRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - const offset = - (match.index ?? 0) + match[0].lastIndexOf(match[3]) - match[4].length - imports.push({ - kind: 'static', - specifier: source.slice(offset, offset + match[4].length), - }) - } - for (const match of code.matchAll(exportFromRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - const offset = - (match.index ?? 0) + match[0].lastIndexOf(match[3]) - match[4].length - imports.push({ - kind: 'static', - specifier: source.slice(offset, offset + match[4].length), - }) - } - for (const match of code.matchAll(dynamicImportRegex)) { - const offset = - (match.index ?? 0) + match[0].lastIndexOf(match[1]) - match[2].length - imports.push({ - kind: 'dynamic', - specifier: source.slice(offset, offset + match[2].length), - }) - } - - return imports -} - /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { path: string diff --git a/packages/plugin-utils/src/import-mask.ts b/packages/plugin-utils/src/import-mask.ts index fc6c344c..dedb2802 100644 --- a/packages/plugin-utils/src/import-mask.ts +++ b/packages/plugin-utils/src/import-mask.ts @@ -1,5 +1,23 @@ +import { maskImportTypes } from './import-type-mask' + +const typeParameterPrefix = + /^<(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:const(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+)?[A-Za-z_$][\w$]*(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))*(?:,|=|extends\b(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\r\n]*(?:\r?\n|$))+[^\s=>/])/ + +function startsExpression(previous: string): boolean { + return ( + !/^(?:value|[\w$]+|\)|\]|\+\+|--|\.|\?\.)$/.test(previous) || + /^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|else|do)$/.test( + previous, + ) + ) +} + /** Lexical masking only: the bundler, not this scanner, validates syntax. */ -export function maskImportText(source: string, jsx: boolean): string { +export function maskImportText( + source: string, + jsx: boolean, + typescript = true, +): string { const output = source.split('') let index = 0 let previous = '' @@ -8,11 +26,28 @@ export function maskImportText(source: string, jsx: boolean): string { let tag = false let tagClosing = false const parentheses: boolean[] = [] - const expressionTags: boolean[] = [] - const expressions: number[] = [] + const expressions: { + readonly braces: number + readonly depth: number + readonly tag: boolean + readonly tagClosing: boolean + readonly text: boolean + }[] = [] + const templates: number[] = [] + const blocks = new Set() + const bodies: { + readonly braces: number + readonly parentheses: number + readonly statement: boolean + }[] = [] + let templateText = false let braces = 0 function blank(start: number, end: number): void { - for (let offset = start; offset < end; offset += 1) { + for ( + let offset = start; + offset < Math.min(end, source.length); + offset += 1 + ) { if (output[offset] !== '\n' && output[offset] !== '\r') output[offset] = ' ' } @@ -23,32 +58,10 @@ export function maskImportText(source: string, jsx: boolean): string { const char = source[index++] if (char === '\\') index += 1 else if (char === quote) break - else if (quote === '`' && char === '$' && source[index] === '{') { - index += 1 - templateExpression() - } } - blank(start + (quote === '`' ? 0 : 1), index - (quote === '`' ? 0 : 1)) + blank(start + 1, index - 1) previous = 'value' } - function templateExpression(): void { - let depth = 1 - previous = '' - while (index < source.length && depth) { - const char = source[index] - if (char === '"' || char === "'" || char === '`') quoted(char) - else if (char === '/' && source[index + 1] === '*') comment(false) - else if (char === '/' && source[index + 1] === '/') comment(true) - else if (char === '/' && /^(?:|=|\(|,|:|\{|return)$/.test(previous)) - regexLiteral() - else { - if (char === '{') depth += 1 - if (char === '}') depth -= 1 - if (!/\s/.test(char)) previous = char - index += 1 - } - } - } function comment(line: boolean): void { const start = index index += 2 @@ -80,24 +93,43 @@ export function maskImportText(source: string, jsx: boolean): string { while (index < source.length) { const char = source[index] const next = source[index + 1] + if (templateText) { + blank(index, index + 1) + index += 1 + if (char === '\\') { + blank(index, index + 1) + index += 1 + } else if (char === '`') { + output[index - 1] = '`' + templates.pop() + templateText = false + previous = 'value' + } else if (char === '$' && next === '{') { + blank(index, index + 1) + index += 1 + braces += 1 + templateText = false + previous = '(' + } + continue + } + if ((tag || text) && char === '{') { + expressions.push({ braces, depth: jsxDepth, tag, tagClosing, text }) + braces += 1 + tag = text = false + index += 1 + previous = '{' + continue + } if (tag) { if (char === '"' || char === "'") { quoted(char) continue } - if (char === '{') { - expressions.push(braces) - expressionTags.push(true) - braces += 1 - tag = false - index += 1 - previous = '{' - continue - } if (char === '>') { jsxDepth += tagClosing ? -1 : source[index - 1] === '/' ? 0 : 1 tag = false - text = jsxDepth > 0 + text = jsxDepth > (expressions.at(-1)?.depth ?? 0) previous = 'value' } blank(index, index + 1) @@ -113,18 +145,36 @@ export function maskImportText(source: string, jsx: boolean): string { comment(next === '/') continue } - if (char === '"' || char === "'" || char === '`') { + if (char === '`') { + templates.push(braces) + templateText = true + index += 1 + continue + } + if (char === '"' || char === "'") { quoted(char) continue } - if (char === '/' && !/^(?:value|[\w$]+|\)|\])$/.test(previous)) { + if (char === '/' && startsExpression(previous)) { regexLiteral() continue } + const operator = text + ? undefined + : /^(?:>>>=|>>>|>>=|<<=|===|!==|\*\*=|&&=|\|\|=|\?\?=|=>|<<|>>|<=|>=|==|!=|&&|\|\||\?\?|\*\*|\+\+|--|\?\.|[+*/%&|^!-]=)/.exec( + source.slice(index), + )?.[0] + if (operator) { + previous = operator + index += operator.length + continue + } if ( jsx && char === '<' && - (text || /^(?:|=|\(|\[|,|:|\?|&|\||return|=>)$/.test(previous)) && + (text || + (startsExpression(previous) && + !(typescript && typeParameterPrefix.test(source.slice(index))))) && /[A-Za-z/>]/.test(next ?? '') ) { tag = true @@ -134,33 +184,55 @@ export function maskImportText(source: string, jsx: boolean): string { index += 1 continue } - if (text && char === '{') { - expressions.push(braces) - expressionTags.push(false) + if (char === '{') { + const body = bodies.at(-1) + if (body?.braces === braces && body.parentheses === parentheses.length) { + bodies.pop() + if (body.statement) blocks.add(braces) + } else if (/^(?:|;|\)|=>|try|finally|else|do)$/.test(previous)) + blocks.add(braces) braces += 1 - text = false - previous = '{' - index += 1 - continue } - if (char === '{') braces += 1 if (char === '}') { braces -= 1 - if (expressions.at(-1) === braces) { + if (templates.at(-1) === braces) { + templateText = true + blank(index, index + 1) + } + const expression = expressions.at(-1) + if (expression?.braces === braces) { expressions.pop() - tag = expressionTags.pop() === true - text = !tag && jsxDepth > 0 + tag = expression.tag + tagClosing = expression.tagClosing + text = expression.text } + previous = blocks.delete(braces) ? '' : 'value' + index += 1 + continue } if (/[A-Za-z_$]/.test(char)) { const start = index++ while (/[\w$]/.test(source[index] ?? '') && index < source.length) index += 1 const word = source.slice(start, index) - previous = - /^(?:return|throw|case|delete|void|typeof|new|yield|await)$/.test(word) - ? '' - : word + if ( + word === 'async' && + /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)+function\b/.test( + source.slice(index), + ) + ) + continue + if (word === 'function' || word === 'class') + bodies.push({ + braces, + parentheses: parentheses.length, + statement: + /^(?:|;|[\w$]+|\)|\]|\+\+|--)$/.test(previous) && + !/^(?:return|throw|case|delete|void|typeof|new|yield|await|in|instanceof|of|extends)$/.test( + previous, + ), + }) + previous = word === 'await' && previous === 'for' ? 'for' : word continue } if (char === '(') @@ -169,10 +241,11 @@ export function maskImportText(source: string, jsx: boolean): string { previous = char === ')' && parentheses.pop() ? '' - : char === '>' && previous === '=' - ? '=>' + : char === '!' && typescript && !startsExpression(previous) + ? 'value' : char index += 1 } - return output.join('') + const masked = output.join('') + return typescript ? maskImportTypes(masked) : masked } diff --git a/packages/plugin-utils/src/import-scanner.ts b/packages/plugin-utils/src/import-scanner.ts new file mode 100644 index 00000000..0672a1ef --- /dev/null +++ b/packages/plugin-utils/src/import-scanner.ts @@ -0,0 +1,128 @@ +import { maskImportText } from './import-mask' + +export interface ImportReference { + readonly kind: 'static' | 'dynamic' + readonly specifier: string +} + +function literalValue(raw: string): string { + return raw + .slice(1, -1) + .replace( + /\\(?:u\{([\da-f]+)\}|u([\da-f]{4})|x([\da-f]{2})|\r\n|[\s\S])/gi, + ( + escape: string, + point: string | undefined, + unicode: string | undefined, + hex: string | undefined, + ) => { + const digits = point ?? unicode ?? hex + if (digits) return String.fromCodePoint(Number.parseInt(digits, 16)) + const char = escape.slice(1) + const escaped: Readonly> = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + '0': '\0', + } + return /[\r\n]/.test(char) ? '' : (escaped[char] ?? char) + }, + ) +} + +function allInlineTypes(clause: readonly string[]): boolean { + if (clause[0] !== '{' || clause.at(-1) !== '}') return false + const specifiers = clause + .slice(1, -1) + .join(' ') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + return ( + specifiers.length > 0 && + specifiers.every((value) => { + const names = value.split(/\s+/) + return ( + names[0] === 'type' && + names.length > 1 && + (names[1] !== 'as' || + names.length === 2 || + (names[2] === 'as' && names.length > 3)) + ) + }) + ) +} + +/** Internal scanner seam; not part of the package export map. */ +export function scanImports( + source: string, + jsx: boolean, + typescript = true, +): ImportReference[] { + const tokens = [ + ...maskImportText(source, jsx, typescript).matchAll( + /(['"])[^'"]*\1|[\w$]+|[^\s]/g, + ), + ].map((match) => ({ + value: match[0], + literal: match[1] + ? literalValue(source.slice(match.index, match.index + match[0].length)) + : undefined, + })) + const imports: ImportReference[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const word = tokens[index].value + if ( + !['import', 'export', 'require'].includes(word) || + tokens[index - 1]?.value === '.' + ) + continue + const next = tokens[index + 1] + if (next?.value === '(' && word !== 'export') { + const argument = tokens[index + 2]?.literal + const end = tokens[index + 3]?.value + if (argument && (end === ')' || (word === 'import' && end === ','))) + imports.push({ + kind: word === 'require' ? 'static' : 'dynamic', + specifier: argument, + }) + continue + } + if (word === 'require') continue + if (word === 'import' && next?.literal) { + imports.push({ kind: 'static', specifier: next.literal }) + continue + } + const clause: string[] = [] + let bindings = 0 + for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { + const token = tokens[cursor] + if ( + token.value === 'from' && + bindings === 0 && + tokens[cursor + 1]?.literal !== undefined + ) { + const specifier = tokens[cursor + 1]?.literal + if ( + specifier && + !(clause[0] === 'type' && clause.length > 1 && clause[1] !== ',') && + !allInlineTypes(clause) + ) + imports.push({ kind: 'static', specifier }) + break + } + if ( + !/^(?:[\w$]+|[*{},])$/.test(token.value) && + token.literal === undefined + ) + break + if (token.value === '{') bindings += 1 + if (token.value === '}') bindings -= 1 + clause.push(token.value) + } + } + return imports +} diff --git a/packages/plugin-utils/src/import-type-mask.ts b/packages/plugin-utils/src/import-type-mask.ts new file mode 100644 index 00000000..58e5c1f1 --- /dev/null +++ b/packages/plugin-utils/src/import-type-mask.ts @@ -0,0 +1,205 @@ +/** Receives already-masked code; only type-context import keywords are blanked. */ +export function maskImportTypes(source: string): string { + const tokens = [ + ...source.matchAll( + /(['"])[^'"]*\1|=>|\?\.|===|!==|==|!=|&&|\|\||\?\?|[\w$]+|[^\s]/g, + ), + ] + const output = source.split('') + const pairs = new Map() + const stack: number[] = [] + const questions = new Map() + const ternaryColons = new Set() + const parameters = new Set() + const parameterEnds = new Set() + const classes = new Set() + const bindings = new Set() + const variables = new Set() + const value = (index: number): string => tokens[index]?.[0] ?? '' + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + if (/^[({[]$/.test(word)) stack.push(index) + else if (/^[)}\]]$/.test(word)) { + const opening = stack.pop() + if (opening !== undefined) pairs.set(opening, index) + } + const scope = stack.at(-1) ?? -1 + if (word === '?' && value(index + 1) !== ':') + questions.set(scope, (questions.get(scope) ?? 0) + 1) + if (word === ':' && (questions.get(scope) ?? 0) > 0) { + ternaryColons.add(index) + questions.set(scope, (questions.get(scope) ?? 0) - 1) + } + } + function typeEnd(start: number, stops: RegExp): number { + const closing: string[] = [] + let functionType = /^(?:\(|<)$/.test(value(start)) + let conditional = false + const delimiters: Readonly> = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', + } + for (let cursor = start; cursor < tokens.length; cursor += 1) { + const word = value(cursor) + if (closing.length === 0) { + if ( + cursor > start && + /[\r\n]/.test( + source.slice(tokens[cursor - 1]?.index, tokens[cursor]?.index), + ) && + /^(?:[\w$]+|\)|\]|\}|>|`|(['"])[^'"]*\1)$/.test(value(cursor - 1)) && + !/^(?:typeof|keyof|extends|infer|import|new|abstract|is|in)$/.test( + value(cursor - 1), + ) && + ((/^[A-Za-z_$][\w$]*$/.test(word) && + !/^(?:extends|is|in)$/.test(word)) || + (word === '(' && !(functionType && value(cursor - 1) === '>'))) + ) + return cursor + if (word === 'extends') conditional = true + if (word === '=>' && functionType && value(cursor - 1) === ')') { + functionType = false + continue + } + if ( + (stops.test(word) && !(word === '?' && conditional)) || + /^(?:const|let|var|export|function|class|interface)$/.test(word) + ) + return cursor + } + if ( + word === '{' && + closing.length === 0 && + cursor > start && + !/^(?:[|&]|=>)$/.test(value(cursor - 1)) + ) + return cursor + const delimiter = delimiters[word] + if (delimiter) closing.push(delimiter) + else if (word === closing.at(-1)) closing.pop() + else if (/^[)}\]>]$/.test(word) && closing.length === 0) return cursor + } + return tokens.length + } + for (const [opening, closing] of pairs) { + if (value(opening) !== '(') continue + let prefix = opening - 1 + if (value(prefix) === '>') { + let depth = 1 + while (prefix > 0 && depth > 0) { + prefix -= 1 + if (value(prefix) === '>') depth += 1 + if (value(prefix) === '<') depth -= 1 + } + prefix -= 1 + } + const declaration = + value(prefix) === 'function' || + value(prefix - 1) === 'function' || + (value(prefix - 1) === '*' && value(prefix - 2) === 'function') + // A colon after grouping may belong to a ternary, not a return type. + // Require the annotation to end at an arrow/body, or a function signature. + const annotationEnd = + value(closing + 1) === ':' && !ternaryColons.has(closing + 1) + ? value(typeEnd(closing + 2, /^(?:=>|;|=|,|\?)$/)) + : '' + if ( + (/^(?:=>|\{)$/.test(value(closing + 1)) || + /^(?:=>|\{)$/.test(annotationEnd) || + (value(closing + 1) === ':' && + declaration && + !ternaryColons.has(closing + 1))) && + !/^(?:if|while|for|with|switch|catch|import|require)$/.test( + value(opening - 1), + ) + ) { + parameters.add(opening) + parameterEnds.add(closing) + } + } + function mask(start: number, end: number): void { + for (const token of tokens.slice(start, end)) { + if (token[0] !== 'import') continue + for (let offset = token.index; offset < token.index + 6; offset += 1) + output[offset] = ' ' + } + } + for (let index = 0; index < tokens.length; index += 1) { + const word = value(index) + const scope = stack.at(-1) ?? -1 + if (word === '<') { + const end = typeEnd(index + 1, /^(?:>|;)$/) + const operand = /^[A-Za-z_$][\w$]*$/.test(value(index - 1)) + const prefix = + /^(?:=|\(|,|return|=>)$/.test(value(index - 1)) && + (value(index + 1) === 'import' || + /^(?:const|extends|,|=)$/.test(value(index + 2))) + if ( + value(end) === '>' && + (prefix || (operand && /^(?:\(|\{|;|,|\))$/.test(value(end + 1)))) + ) { + mask(index + 1, end) + index = end + continue + } + } + if (word === 'type' && /^[A-Za-z_$][\w$]*$/.test(value(index + 1))) { + const equals = typeEnd(index + 2, /^(?:=|;)$/) + if (value(equals) === '=') { + const end = typeEnd(equals + 1, /^;$/) + mask(equals + 1, end) + index = end - 1 + continue + } + } + if ( + /^(?:interface|class)$/.test(word) && + value(index - 1) !== '.' && + /^(?:[A-Za-z_$][\w$]*|\{)$/.test(value(index + 1)) + ) { + let body = index + 1 + while (body < tokens.length && !/^(?:\{|;)$/.test(value(body))) + body = (pairs.get(body) ?? body) + 1 + const end = pairs.get(body) + if (word === 'interface' && end !== undefined) { + mask(body, end) + index = end + continue + } + if (word === 'class') classes.add(body) + } + if ( + /^(?:const|let|var)$/.test(word) && + /^(?:[A-Za-z_$][\w$]*|\{|\[)$/.test(value(index + 1)) + ) { + variables.add(scope) + bindings.add(scope) + } + if ( + (word === ':' && (bindings.has(scope) || parameterEnds.has(index - 1))) || + (/^(?:as|satisfies)$/.test(word) && + /^(?:[\w$]+|[)\]}!]|(['"])[^'"]*\1|`)$/.test(value(index - 1)) && + !/^(?:=|:)$/.test(value(index + 1))) + ) { + const end = typeEnd(index + 1, /^(?:=|,|;|=>|\+|-|&&|\|\||\?\?|\?)$/) + mask(index + 1, end) + index = end - 1 + continue + } + if (word === '=') bindings.delete(scope) + if (word === ',' && (parameters.has(scope) || variables.has(scope))) + bindings.add(scope) + if (word === ';') { + variables.delete(scope) + if (classes.has(scope)) bindings.add(scope) + else bindings.delete(scope) + } + if (/^[({[]$/.test(word)) { + stack.push(index) + if (parameters.has(index) || classes.has(index)) bindings.add(index) + } else if (/^[)}\]]$/.test(word)) stack.pop() + } + return output.join('') +} From 179fa6d33f920e231dc74a808d6186384a7acfb3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 00:50:49 +0900 Subject: [PATCH 40/50] fix(wasm): preserve source types for compiled MDX modules Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../devup-ui-wasm/source-type-regressions.mjs | 246 ++++++++++++++++++ bindings/devup-ui-wasm/src/lib.rs | 84 ++++-- .../devup-ui-wasm/src/source_type_tests.rs | 100 +++++++ libs/extractor/src/build_time_values.rs | 21 +- libs/extractor/src/import_alias_visit.rs | 11 +- libs/extractor/src/imported_constants.rs | 40 ++- .../src/imported_source_type_tests.rs | 18 ++ libs/extractor/src/lib.rs | 71 ++++- libs/extractor/src/module_loader.rs | 22 +- libs/extractor/src/source_type.rs | 132 ++++++++++ libs/extractor/src/source_type_tests.rs | 142 ++++++++++ libs/extractor/src/vanilla_extract.rs | 70 +++-- .../src/__tests__/source-type.test.ts | 185 +++++++++++++ packages/plugin-utils/src/import-graph.ts | 32 ++- packages/plugin-utils/src/index.ts | 1 + .../plugin-utils/src/prepared-resolver.ts | 18 +- packages/plugin-utils/src/prepared-source.ts | 29 +++ 17 files changed, 1140 insertions(+), 82 deletions(-) create mode 100644 bindings/devup-ui-wasm/source-type-regressions.mjs create mode 100644 bindings/devup-ui-wasm/src/source_type_tests.rs create mode 100644 libs/extractor/src/imported_source_type_tests.rs create mode 100644 libs/extractor/src/source_type.rs create mode 100644 libs/extractor/src/source_type_tests.rs create mode 100644 packages/plugin-utils/src/__tests__/source-type.test.ts create mode 100644 packages/plugin-utils/src/prepared-source.ts diff --git a/bindings/devup-ui-wasm/source-type-regressions.mjs b/bindings/devup-ui-wasm/source-type-regressions.mjs new file mode 100644 index 00000000..a017a580 --- /dev/null +++ b/bindings/devup-ui-wasm/source-type-regressions.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' + +const require = createRequire( + new URL('../../apps/landing/package.json', import.meta.url), +) +const compiler = createRequire(require.resolve('@mdx-js/loader')).resolve( + '@mdx-js/mdx', +) +const { compile } = await import(pathToFileURL(compiler).href) +const wasm = createRequire(import.meta.url)('./pkg/index.js') +const methods = ['codeExtract', 'codeExtractWithoutSourceMap'] +let passed = 0 + +function check(name, action) { + wasm.resetBuildState() + action() + passed += 1 + console.info(`PASS ${name}`) +} + +function extract(method, filename, code, sourceType, aliases = {}) { + return wasm[method]( + filename, + code, + '@devup-ui/react', + 'df', + true, + false, + false, + aliases, + sourceType, + ) +} + +const markdown = + 'import { Box } from "@devup-ui/react"\n\n\n' +const aliasMarkdown = + 'import { css } from "@emotion/react"\n\nexport const cls = css({color: "blue"})\n\n
\n' +for (const jsx of [false, true]) { + const code = String( + await compile( + { path: '/src/page.mdown', value: markdown }, + { jsx, format: 'mdx' }, + ), + ) + const alias = String( + await compile( + { path: '/src/alias.mdown', value: aliasMarkdown }, + { jsx, format: 'mdx' }, + ), + ) + for (const method of methods) { + for (const extension of ['md', 'mdx', 'mdown', 'ts']) { + const filename = `/src/page.${extension}` + check(`${method}:${jsx}:${extension}`, () => { + const output = extract( + method, + filename, + code, + ['mdown', 'ts'].includes(extension) ? 'compiled-mdx' : undefined, + ) + try { + assert.match(wasm.getCss(undefined, false), /background:tomato/) + assert.doesNotMatch(output.code, /(?: { + const output = extract( + method, + '/src/alias.mdown', + alias, + 'compiled-mdx', + { '@emotion/react': null }, + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + for (const reexport of [false, true]) { + check(`${method}:${jsx}:imported:${reexport}`, () => { + wasm.setModuleResolver((specifier) => + specifier === './entry' && reexport + ? { + path: '/src/reexport.js', + code: "export {PRIMARY} from './tokens';", + } + : { + path: '/src/tokens.mdown', + code: `${code}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + }, + ) + const output = extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./entry"; export const view = ;', + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + assert.doesNotMatch(wasm.getCss(undefined, false), /var\(--/) + assert.ok(output.dependencies.includes('/src/tokens.mdown')) + } finally { + output.free() + } + }) + } + } +} +for (const method of methods) { + const stylesheetModule = String( + await compile( + { path: '/src/value.mdown', value: '# Heading' }, + { jsx: true, format: 'mdx' }, + ), + ) + check(`${method}:stylesheet-import`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/value.mdown', + code: `${stylesheetModule}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + })) + const output = extract( + method, + '/src/theme.css.ts', + 'import {style} from "@vanilla-extract/css"; import {PRIMARY} from "./value"; export const cls = style({color: PRIMARY});', + undefined, + { '@vanilla-extract/css': null }, + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + for (const value of ['invalid', '', 7, {}, true]) { + check(`${method}:invalid-root:${JSON.stringify(value)}`, () => { + const previous = extract( + method, + '/src/plain.js', + 'import {css} from "@devup-ui/react"; export const cls = css({color: "green"});', + ) + previous.free() + const sheet = wasm.exportSheet() + assert.throws( + () => + extract(method, '/src/plain.js', 'export const answer = 42;', value), + /\/src\/plain\.js:1:1:.*source.*type/i, + ) + assert.equal(wasm.exportSheet(), sheet) + }) + } + check(`${method}:unknown-fastpath`, () => { + assert.throws( + () => extract(method, '/src/plain.mdown', 'export const answer = 42;'), + /\/src\/plain\.mdown:1:1:.*Unknown file extension/, + ) + }) + for (const value of ['invalid', 7]) { + check(`${method}:invalid-child:${value}`, () => { + const initial = extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; export const view = ;', + ) + initial.free() + const sheet = wasm.exportSheet() + wasm.setModuleResolver(() => ({ + path: '/src/token.js', + code: "export const PRIMARY = 'blue';", + sourceType: value, + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/App\.tsx:1:1:.*source.*type/i, + ) + assert.equal(wasm.exportSheet(), sheet) + }) + } + check(`${method}:getter`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.js', + code: "export const PRIMARY = 'blue';", + get sourceType() { + throw new Error('sourceType getter fault') + }, + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/App\.tsx:1:1:.*sourceType getter fault/, + ) + }) + check(`${method}:root-mode-not-inherited`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.ts', + code: "export enum Colors { PRIMARY = 'blue' }", + })) + const output = extract( + method, + '/src/App.mdown', + 'import {Box} from "@devup-ui/react"; import {Colors} from "./token"; export const view = ;', + 'compiled-mdx', + ) + try { + assert.match(wasm.getCss(undefined, false), /color:blue/) + } finally { + output.free() + } + }) + check(`${method}:unknown-child`, () => { + wasm.setModuleResolver(() => ({ + path: '/src/token.custom', + code: "export const PRIMARY = 'blue';", + })) + assert.throws( + () => + extract( + method, + '/src/App.tsx', + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./token"; export const view = ;', + ), + /\/src\/token\.custom:1:1:.*Unknown file extension/, + ) + }) +} +wasm.resetBuildState() +console.info(JSON.stringify({ passed })) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index c52b0f9e..94e0d216 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -2,11 +2,10 @@ use css::class_map::{set_class_map, with_class_map}; use css::file_map::{ canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, }; +#[cfg(test)] +use extractor::extract; use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ - ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, -}; +use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, has_devup_ui}; use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::cell::RefCell; @@ -14,6 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +#[cfg(test)] +mod source_type_tests; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -434,6 +435,7 @@ pub fn code_extract_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, ) -> Result { code_extract_internal_impl( filename, @@ -447,6 +449,7 @@ pub fn code_extract_internal( SourceMapMode::Generate, None, None, + source_type, ) } @@ -460,6 +463,7 @@ pub fn code_extract_without_source_map_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, ) -> Result { code_extract_internal_impl( filename, @@ -473,6 +477,7 @@ pub fn code_extract_without_source_map_internal( SourceMapMode::Skip, None, None, + source_type, ) } @@ -488,6 +493,7 @@ pub fn code_extract_with_modules_internal( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: HashMap, + source_type: Option, resolver: &ModuleResolver, ) -> Result { code_extract_internal_impl( @@ -502,6 +508,7 @@ pub fn code_extract_with_modules_internal( SourceMapMode::Generate, Some(resolver), None, + source_type, ) } @@ -518,6 +525,7 @@ fn code_extract_internal_impl( source_map: SourceMapMode, resolver: Option<&ModuleResolver>, resolver_fault: Option<&RefCell>>, + source_type: Option, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -526,17 +534,14 @@ fn code_extract_internal_impl( import_main_css: import_main_css_in_code, import_aliases, }; - let extracted = match (resolver, source_map) { - (Some(resolver), mode) => extract_with_modules( - filename, - code, - option, - matches!(mode, SourceMapMode::Generate), - resolver, - ), - (None, SourceMapMode::Generate) => extract(filename, code, option), - (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), - }; + let extracted = extractor::extract_with_source_type( + filename, + code, + option, + matches!(source_map, SourceMapMode::Generate), + resolver, + source_type, + ); match checked_extraction(extracted, resolver_fault) { Ok(output) => Ok(Output::new( @@ -589,6 +594,14 @@ fn call_module_resolver( Ok(Some(ResolvedModule { path: field("path")?, code: field("code")?, + source_type: source_type_from_js( + js_sys::Reflect::get(&module, &"sourceType".into()).map_err(|error| { + format!( + "reading resolver field `sourceType`: {}", + resolver_cause(&error) + ) + })?, + )?, })) } @@ -605,7 +618,10 @@ fn code_extract_js( import_main_css_in_css: bool, import_aliases: JsValue, source_map: SourceMapMode, + source_type: JsValue, ) -> Result { + let source_type = source_type_from_js(source_type) + .map_err(|error| js_error(format!("{filename}:1:1: {error}")))?; let import_aliases = import_aliases_from_js(import_aliases)?; let fault = std::rc::Rc::new(RefCell::new(None)); let resolver_fault = std::rc::Rc::clone(&fault); @@ -640,10 +656,22 @@ fn code_extract_js( .as_ref() .map(|resolver| resolver as &ModuleResolver), Some(&fault), + source_type, ) .map_err(js_error) } +#[cfg(not(tarpaulin_include))] +fn source_type_from_js(value: JsValue) -> Result, String> { + if value.is_undefined() || value.is_null() { + return extractor::parse_source_type(None); + } + let value = value.as_string().ok_or_else(|| { + "source type cannot use a non-string at build time: expected `compiled-mdx`".to_string() + })?; + extractor::parse_source_type(Some(&value)) +} + #[cfg(not(tarpaulin_include))] fn import_aliases_from_js( import_aliases: JsValue, @@ -663,7 +691,7 @@ fn import_aliases_from_js( } #[cfg(not(tarpaulin_include))] -#[wasm_bindgen(js_name = "codeExtract")] +#[wasm_bindgen(js_name = "codeExtract", skip_typescript)] #[allow(clippy::too_many_arguments)] pub fn code_extract( filename: &str, @@ -674,6 +702,7 @@ pub fn code_extract( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: JsValue, + source_type: JsValue, ) -> Result { code_extract_js( filename, @@ -685,11 +714,12 @@ pub fn code_extract( import_main_css_in_css, import_aliases, SourceMapMode::Generate, + source_type, ) } #[cfg(not(tarpaulin_include))] -#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap")] +#[wasm_bindgen(js_name = "codeExtractWithoutSourceMap", skip_typescript)] #[allow(clippy::too_many_arguments)] pub fn code_extract_without_source_map( filename: &str, @@ -700,6 +730,7 @@ pub fn code_extract_without_source_map( import_main_css_in_code: bool, import_main_css_in_css: bool, import_aliases: JsValue, + source_type: JsValue, ) -> Result { code_extract_js( filename, @@ -711,9 +742,16 @@ pub fn code_extract_without_source_map( import_main_css_in_css, import_aliases, SourceMapMode::Skip, + source_type, ) } +#[wasm_bindgen(typescript_custom_section)] +const EXTRACTION_TYPES: &str = r#" +export function codeExtract(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output; +export function codeExtractWithoutSourceMap(filename: string, code: string, package: string, css_dir: string, single_css: boolean, import_main_css_in_code: boolean, import_main_css_in_css: boolean, import_aliases: Record, sourceType?: 'compiled-mdx'): Output; +"#; + /// Internal function to register theme (testable without `JsValue`) pub fn register_theme_internal(theme: sheet::theme::Theme) { with_style_sheet_mut(|sheet| sheet.set_theme(theme)); @@ -848,6 +886,7 @@ mod tests { SourceMapMode::Skip, None, Some(&fault), + None, ); assert_eq!(result.err(), Some("resolver failed".to_string())); assert_eq!(export_sheet_internal().unwrap(), before); @@ -891,6 +930,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); code_extract_internal( @@ -902,6 +942,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); @@ -1036,6 +1077,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1086,6 +1128,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1188,6 +1231,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1349,6 +1393,7 @@ mod tests { } let resolver = |specifier: &str, _: &str| { (specifier == "./tokens").then(|| ResolvedModule { + source_type: None, path: "/src/tokens.ts".to_string(), code: "export const PRIMARY = 'red'".to_string(), }) @@ -1363,6 +1408,7 @@ mod tests { false, false, HashMap::new(), + None, &resolver, ) .unwrap(); @@ -1874,6 +1920,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -2166,6 +2213,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -2190,6 +2238,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -2215,6 +2264,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_err()); diff --git a/bindings/devup-ui-wasm/src/source_type_tests.rs b/bindings/devup-ui-wasm/src/source_type_tests.rs new file mode 100644 index 00000000..93e5fd58 --- /dev/null +++ b/bindings/devup-ui-wasm/src/source_type_tests.rs @@ -0,0 +1,100 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn source_type_extracts_compiled_mdx_when_native_variants_use_real_filename() -> Result<(), String> +{ + reset_build_state_internal(); + let code = "import {Box} from '@devup-ui/react'; export const view = ;"; + let mode = Some(extractor::ExtractSourceType::CompiledMdx); + let mapped = code_extract_internal( + "page.mdown", + code, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + )?; + assert!(mapped.map().is_some_and(|map| map.contains("page.mdown"))); + let unmapped = code_extract_without_source_map_internal( + "page.mdown", + code, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + )?; + assert_eq!(unmapped.map(), None); + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "tokens.mdown".to_string(), + code: "export function view() {return
;} export const PRIMARY = 'blue';" + .to_string(), + source_type: mode, + }) + }; + let imported = code_extract_with_modules_internal( + "page.mdown", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + mode, + &resolver, + )?; + assert_eq!(imported.dependencies(), ["tokens.mdown"]); + assert!(!with_style_sheet(|sheet| sheet.create_css(None, false)).contains("var(--")); + reset_build_state_internal(); + Ok(()) +} + +#[test] +#[serial] +fn source_type_preserves_sheet_when_unknown_child_validation_fails() -> Result<(), String> { + reset_build_state_internal(); + code_extract_internal( + "page.tsx", + "import {Box} from '@devup-ui/react'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + None, + )?; + let before = export_sheet_internal()?; + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "tokens.custom".to_string(), + code: "export const PRIMARY = 'red';".to_string(), + source_type: None, + }) + }; + let result = code_extract_with_modules_internal( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + None, + &resolver, + ); + assert!(result.is_err_and(|error| error.starts_with("tokens.custom:1:1:"))); + assert_eq!(export_sheet_internal()?, before); + reset_build_state_internal(); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 95cca8f8..db8d5be5 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -62,9 +62,10 @@ pub(crate) fn has_build_time_values( filename, &option.package, &option.import_aliases, + crate::parser_source_type(filename).unwrap_or_default(), ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &code, None) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -130,8 +131,13 @@ const UNCERTAIN_MEMBERS: [&str; 9] = [ "toString", ]; -fn parse<'a>(allocator: &'a Allocator, filename: &str, code: &'a str) -> Option> { - let source_type = crate::parser_source_type(filename).ok()?; +fn parse<'a>( + allocator: &'a Allocator, + filename: &str, + code: &'a str, + mode: Option, +) -> Option> { + let source_type = crate::source_type::parser_type(filename, mode).ok()?; let parsed = Parser::new(allocator, code, source_type).parse(); (!parsed.fatal_error).then_some(parsed.program) } @@ -916,12 +922,13 @@ pub(crate) fn evaluate( option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, + mode: Option, ) -> Option<( String, Vec, BTreeSet, )> { - let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; + let (mut values, dependencies) = compute(code, filename, option, resolver, unknown, mode)?; values.sort_unstable_by_key(|(span, _)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); @@ -968,13 +975,14 @@ fn compute( option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, + mode: Option, ) -> Option<(Vec, BTreeSet)> { use std::fmt::Write; use boa_engine::{Context, JsObject, Source}; let allocator = Allocator::default(); - let program = parse(&allocator, filename, code)?; + let program = parse(&allocator, filename, code, mode)?; let is_style = |source: &str| { source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) }; @@ -1059,7 +1067,8 @@ fn compute( .collect(); let _ = writeln!(module, "[{}];", names.join(", ")); - let script = crate::vanilla_extract::strip_typescript(&module, filename); + let script = + crate::vanilla_extract::strip_typescript_with_type(&module, filename, mode).ok()?; let mut context = Context::default(); context .runtime_limits_mut() diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 3ee21790..e8fb47d2 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -226,7 +226,14 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits( + code, + filename, + package, + import_aliases, + crate::parser_source_type(filename).unwrap_or_default(), + ) + .0 } /// A replacement of `code[start..end]` by text of `length` bytes @@ -249,6 +256,7 @@ pub fn transform_import_aliases_with_edits<'a>( filename: &str, package: &str, import_aliases: &HashMap, + source_type: oxc_span::SourceType, ) -> (Cow<'a, str>, Vec) { // Quick check: if no aliases match, return original code if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { @@ -256,7 +264,6 @@ pub fn transform_import_aliases_with_edits<'a>( } let allocator = Allocator::default(); - let source_type = crate::parser_source_type(filename).unwrap_or_default(); // Parse the code let parser_ret = Parser::new(&allocator, code, source_type).parse(); diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1de..f887a7ef 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,7 +15,10 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::SPAN; +#[cfg(test)] +#[path = "imported_source_type_tests.rs"] +mod source_type_tests; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; @@ -479,7 +482,11 @@ pub(crate) fn inline_constants<'a>( } (scoping, reads_math) }; - inlined.dependencies = modules.exports.into_keys().collect(); + inlined.dependencies = modules + .exports + .into_keys() + .map(|(path, _, _)| path) + .collect(); if !symbols.is_empty() || reads_math { Inline { ast_builder, @@ -554,7 +561,12 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { /// The modules read for the values of imports pub(crate) fn dependencies(&self) -> BTreeSet { - self.modules.borrow().exports.keys().cloned().collect() + self.modules + .borrow() + .exports + .keys() + .map(|(path, _, _)| path.clone()) + .collect() } pub(crate) fn is_changed(&self, name: &str) -> bool { @@ -715,9 +727,9 @@ pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { /// The constant exports of the modules read, by path struct Modules<'r> { - resolver: Option<&'r ModuleResolver>, + resolver: Option<&'r ModuleResolver<'r>>, option: &'r ExtractOption, - exports: FxHashMap>>, + exports: FxHashMap>>, loading: Vec, } @@ -728,22 +740,30 @@ impl Modules<'_> { importer: &str, ) -> Option>> { let module = (self.resolver?)(specifier, importer)?; - if let Some(exports) = self.exports.get(&module.path) { + let key = (module.path.clone(), module.code.clone(), module.source_type); + if let Some(exports) = self.exports.get(&key) { return Some(exports.clone()); } if self.loading.contains(&module.path) { return None; } self.loading.push(module.path.clone()); - let exports = Rc::new(self.read(&module.path, &module.code)); + let exports = Rc::new(self.read(&module.path, &module.code, module.source_type)); self.loading.pop(); - self.exports.insert(module.path, exports.clone()); + self.exports.insert(key, exports.clone()); Some(exports) } - fn read(&mut self, path: &str, code: &str) -> FxHashMap { + fn read( + &mut self, + path: &str, + code: &str, + mode: Option, + ) -> FxHashMap { let allocator = Allocator::default(); - let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts()); + let Ok(source_type) = crate::source_type::parser_type(path, mode) else { + return FxHashMap::default(); + }; let program = Parser::new(&allocator, code, source_type).parse().program; let mut scope = ModuleScope::new(path, &program, Some(code)); let mut exports = FxHashMap::default(); diff --git a/libs/extractor/src/imported_source_type_tests.rs b/libs/extractor/src/imported_source_type_tests.rs new file mode 100644 index 00000000..9aace6f8 --- /dev/null +++ b/libs/extractor/src/imported_source_type_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn source_type_returns_no_constants_when_an_unvalidated_internal_path_is_unknown() { + let option = ExtractOption::default(); + let mut modules = Modules { + resolver: None, + option: &option, + exports: FxHashMap::default(), + loading: Vec::new(), + }; + assert_eq!( + modules + .read("token.custom", "export const PRIMARY = 'red';", None) + .len(), + 0 + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index f1345083..ab702ecf 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -219,11 +219,17 @@ pub struct ExtractOutput { pub struct ResolvedModule { pub path: String, pub code: String, + pub source_type: Option, } +mod source_type; +pub use source_type::{ExtractSourceType, parse_source_type}; +#[cfg(test)] +mod source_type_tests; + /// Resolves `(specifier, importer)` the way the bundler does; `None` when it /// cannot -pub type ModuleResolver = dyn Fn(&str, &str) -> Option; +pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a; #[derive(Clone)] pub struct ExtractOption { @@ -301,7 +307,43 @@ fn extract_with_source_map( source_map: bool, resolver: Option<&ModuleResolver>, ) -> Result> { - extract_source(filename, code, None, option, source_map, resolver) + extract_with_source_type(filename, code, option, source_map, resolver, None) +} + +/// Extract compiler output under its real filename with an explicit language. +pub fn extract_with_source_type( + filename: &str, + code: &str, + option: ExtractOption, + source_map: bool, + resolver: Option<&ModuleResolver>, + source_type: Option, +) -> Result> { + let fault = std::cell::RefCell::new(None); + let validated = |specifier: &str, importer: &str| { + if fault.borrow().is_some() { + return None; + } + let module = resolver?(specifier, importer)?; + if let Err(error) = source_type::validate_module(&module) { + *fault.borrow_mut() = Some(error); + return None; + } + Some(module) + }; + let output = extract_source( + filename, + code, + None, + option, + source_map, + resolver.map(|_| &validated as &ModuleResolver), + source_type, + ); + match fault.into_inner() { + Some(error) => Err(error.into()), + None => output, + } } /// `evaluated` is the source `code` was computed from, with the layers of @@ -313,7 +355,9 @@ fn extract_source( option: ExtractOption, source_map: bool, resolver: Option<&ModuleResolver>, + source_mode: Option, ) -> Result> { + let source_type = source_type::parser_type(filename, source_mode)?; // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` @@ -322,18 +366,19 @@ fn extract_source( filename, &option.package, &option.import_aliases, + source_type, ); // Step 2: Check if code contains the target package (after transformation) let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); - let compiled_mdx = is_compiled_mdx(filename); + let compiled_mdx = source_mode.is_some() || is_compiled_mdx(filename); if !has_relevant_import { // Raw Markdown must not pass through merely because it has no styling import. if compiled_mdx { let allocator = Allocator::default(); - let parsed = Parser::new(&allocator, code, parser_source_type(filename)?).parse(); + let parsed = Parser::new(&allocator, code, source_type).parse(); if let Some(error) = parsed.diagnostics.errors().next() { let offset = error .labels @@ -370,7 +415,13 @@ fn extract_source( // otherwise the untouched `transformed_code` is parsed directly (no copy). let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_stylesheet_with_type( + &transformed_code, + filename, + &option, + resolver, + source_mode, + ) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -388,6 +439,7 @@ fn extract_source( ), &option, &referenced, + source_mode, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -440,7 +492,6 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = parser_source_type(filename)?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -517,6 +568,7 @@ fn extract_source( &option, resolver, &inlined.unknown, + source_mode, ) { let mut output = extract_source( @@ -526,6 +578,7 @@ fn extract_source( option, source_map, resolver, + source_mode, )?; let mut files: std::collections::BTreeSet = output.dependencies.into_iter().collect(); @@ -701,8 +754,9 @@ fn extract_class_map_from_code( partial_code: &str, option: &ExtractOption, style_names: &FxHashSet, + source_mode: Option, ) -> Result, Box> { - let source_type = parser_source_type(filename)?; + let source_type = source_type::parser_type(filename, source_mode)?; let (bucket, global, css_file) = resolve_css_target(filename, option); let css_files = vec![css_file]; let allocator = Allocator::default(); @@ -941,6 +995,7 @@ mod tests { "import {css} from '@devup-ui/react'; export const card = css({bg: 'red'});", &ExtractOption::default(), &names, + None, ) .unwrap(); assert!(map.contains_key("card")); @@ -14587,6 +14642,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + None, ) .unwrap(); @@ -18328,6 +18384,7 @@ export const d = style([cond && base]);", .any(|extension| format!("{path}{extension}") == *file) }) .map(|(file, code)| ResolvedModule { + source_type: None, path: (*file).to_string(), code: (*code).to_string(), }) diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index f64390a8..58900fae 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -87,11 +87,11 @@ fn is_evaluating(filename: &str) -> bool { /// Loads what a stylesheet imports, each module once, as JavaScript that /// defines the module's exports object ahead of the stylesheet pub(crate) struct ModuleLoader<'r> { - resolver: Option<&'r ModuleResolver>, + resolver: Option<&'r ModuleResolver<'r>>, option: &'r ExtractOption, /// Definitions of the loaded modules, each after the ones it uses definitions: Vec, - loaded: FxHashMap, + loaded: FxHashMap, /// `(path, name)` of the modules being defined, outermost first loading: Vec<(String, String)>, /// Names of modules imported before they finished evaluating, whose @@ -138,11 +138,12 @@ impl<'r> ModuleLoader<'r> { .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; let module = resolver(specifier, importer) .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + let key = (module.path.clone(), module.code.clone(), module.source_type); let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { self.keep_import(specifier); } - if let Some(name) = self.loaded.get(&module.path) { + if let Some(name) = self.loaded.get(&key) { return Ok(name.clone()); } if let Some((_, name)) = self.loading.iter().find(|(path, _)| *path == module.path) { @@ -165,15 +166,15 @@ impl<'r> ModuleLoader<'r> { // The stylesheet importing it is evaluated on its own, so it never // starts here self.pending.insert(name.clone()); - self.loaded.insert(module.path, name.clone()); + self.loaded.insert(key, name.clone()); return Ok(name); } self.dependencies.insert(module.path.clone()); self.loading.push((module.path.clone(), name.clone())); let defined = self.define(&name, module, stylesheet, resolver); - let (path, _) = self.loading.pop().unwrap_or_default(); + self.loading.pop(); defined?; - self.loaded.insert(path, name.clone()); + self.loaded.insert(key, name.clone()); Ok(name) } @@ -187,12 +188,13 @@ impl<'r> ModuleLoader<'r> { let code = if stylesheet { // Extracted the way the bundler extracts it, so the names it // exports are the ones its own CSS uses - let output = crate::extract_with_source_map( + let output = crate::extract_with_source_type( &module.path, &module.code, self.option.clone(), false, Some(resolver), + module.source_type, ) .map_err(|error| error.to_string())?; self.dependencies.extend(output.dependencies); @@ -200,7 +202,11 @@ impl<'r> ModuleLoader<'r> { } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let script = crate::vanilla_extract::strip_typescript_with_type( + &code, + &module.path, + module.source_type, + )?; let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module diff --git a/libs/extractor/src/source_type.rs b/libs/extractor/src/source_type.rs new file mode 100644 index 00000000..fb1e90d2 --- /dev/null +++ b/libs/extractor/src/source_type.rs @@ -0,0 +1,132 @@ +use oxc_span::SourceType; + +/// The language of compiler output, independent of its original filename. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ExtractSourceType { + /// JavaScript with optional JSX, never TypeScript or raw Markdown. + CompiledMdx, +} + +pub(crate) type ModuleCacheKey = (String, String, Option); +pub(crate) type PreparedFileKey = (String, Option); + +/// Parse a supplied source-type literal without accepting unknown modes. +pub fn parse_source_type(value: Option<&str>) -> Result, String> { + match value { + None => Ok(None), + Some("compiled-mdx") => Ok(Some(ExtractSourceType::CompiledMdx)), + Some(value) => Err(format!( + "source type cannot use `{value}` at build time: expected `compiled-mdx`" + )), + } +} + +pub(crate) fn parser_type( + filename: &str, + mode: Option, +) -> Result { + match mode { + Some(ExtractSourceType::CompiledMdx) => Ok(SourceType::jsx()), + None => crate::parser_source_type(filename), + } +} + +pub(crate) fn validate_module(module: &crate::ResolvedModule) -> Result<(), String> { + let source_type = parser_type(&module.path, module.source_type)?; + if module.source_type.is_some() || crate::is_compiled_mdx(&module.path) { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, &module.code, source_type).parse(); + if let Some(error) = parsed.diagnostics.errors().next() { + let offset = error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset); + return Err(crate::located_errors( + &module.path, + &module.code, + &[], + vec![( + offset, + format!("source parser cannot use raw or invalid MDX at build time: {error}"), + )], + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_modes_when_optional_literal_is_supplied() { + assert_eq!(parse_source_type(None), Ok(None)); + assert_eq!( + parse_source_type(Some("compiled-mdx")), + Ok(Some(ExtractSourceType::CompiledMdx)) + ); + for value in ["", "tsx", "invalid"] { + assert!( + parse_source_type(Some(value)) + .is_err_and(|error| error.contains("expected `compiled-mdx`")) + ); + } + } + + #[test] + fn overrides_language_when_real_extension_is_custom_or_typescript() { + for filename in ["page.mdown", "page.ts"] { + assert_eq!( + parser_type(filename, Some(ExtractSourceType::CompiledMdx)), + Ok(SourceType::jsx()) + ); + } + assert_eq!( + parser_type("page.ts", None), + SourceType::from_path("page.ts").map_err(|error| error.to_string()) + ); + assert!( + parser_type("page.mdown", None) + .is_err_and(|error| error.starts_with("page.mdown:1:1:")) + ); + } + + #[test] + fn retains_exports_when_strip_cache_changes_language() -> Result<(), String> { + let code = "export function view() { return
; } export const PRIMARY = 'red';"; + let filename = "mode-cache.ts"; + let ordinary = crate::vanilla_extract::strip_typescript_with_type(code, filename, None)?; + let compiled = crate::vanilla_extract::strip_typescript_with_type( + code, + filename, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(compiled.contains("PRIMARY")); + assert_ne!(compiled, ordinary); + assert_eq!( + crate::vanilla_extract::strip_typescript_with_type( + code, + filename, + Some(ExtractSourceType::CompiledMdx) + )?, + compiled + ); + assert!( + crate::vanilla_extract::strip_typescript_with_type(code, "bad.custom", None).is_err() + ); + Ok(()) + } + + #[test] + fn rejects_markdown_when_resolved_module_claims_compiled_output() { + let module = crate::ResolvedModule { + path: "page.mdown".to_string(), + code: "# Heading".to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + }; + assert!(validate_module(&module).is_err_and( + |error| error.starts_with("page.mdown:1:") && error.contains("raw or invalid MDX") + )); + } +} diff --git a/libs/extractor/src/source_type_tests.rs b/libs/extractor/src/source_type_tests.rs new file mode 100644 index 00000000..ec247303 --- /dev/null +++ b/libs/extractor/src/source_type_tests.rs @@ -0,0 +1,142 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn extracts_real_filename_when_compiled_mode_is_supplied() -> Result<(), Box> { + let code = "import {Box} from '@devup-ui/react'; export const view = ;"; + let output = extract_with_source_type( + "page.mdown", + code, + ExtractOption::default(), + true, + None, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(output.map.is_some_and(|map| map.contains("page.mdown"))); + assert!(!output.code.contains(" Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "token.ts".to_string(), + code: "export enum Colors { PRIMARY = 'red' }".to_string(), + source_type: None, + }) + }; + let output = extract_with_source_type( + "page.mdown", + "import {Box} from '@devup-ui/react'; import {Colors} from './token'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["token.ts"]); + Ok(()) +} + +#[test] +#[serial] +fn rejects_child_extension_when_constants_would_fall_back() { + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "token.custom".to_string(), + code: "export const PRIMARY = 'red';".to_string(), + source_type: None, + }) + }; + let result = extract_with_source_type( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './token'; import {OTHER} from './other'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + None, + ); + assert!(result.is_err_and(|error| error.to_string().starts_with("token.custom:1:1:"))); +} + +#[test] +#[serial] +fn evaluates_stylesheet_when_imported_compiled_jsx_has_trailing_constants() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "token.mdown".to_string(), + code: "export function view() { return
; } export const PRIMARY = 'blue';" + .to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + }) + }; + let output = extract_with_source_type( + "theme.css.ts", + "import {css} from '@devup-ui/react'; import {PRIMARY} from './token'; export const cls = css({color: PRIMARY});", + ExtractOption::default(), + false, + Some(&resolver), + None, + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["token.mdown"]); + Ok(()) +} + +#[test] +#[serial] +fn evaluates_generated_values_when_root_keeps_custom_filename() -> Result<(), Box> { + let output = extract_with_source_type( + "page.mdown", + "import {css} from '@devup-ui/react'; const twice = (x) => x * 2; export const cls = css({width: twice(10)});", + ExtractOption::default(), + false, + None, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(!output.code.contains("css(")); + assert!(!output.code.contains("--")); + Ok(()) +} + +#[test] +#[serial] +fn reads_commonjs_exports_when_compiled_jsx_precedes_assignment() -> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "tokens.mdown".to_string(), + code: "function view() { return
; } exports.PRIMARY = 'blue';".to_string(), + source_type: Some(ExtractSourceType::CompiledMdx), + }) + }; + let output = extract_with_source_type( + "page.tsx", + "import {Box} from '@devup-ui/react'; import {PRIMARY} from './tokens'; export const view = ;", + ExtractOption::default(), + false, + Some(&resolver), + None, + )?; + assert!(!output.code.contains("--")); + assert_eq!(output.dependencies, ["tokens.mdown"]); + Ok(()) +} + +#[test] +#[serial] +fn generates_class_names_when_explicit_mode_keeps_custom_filename() -> Result<(), Box> { + let names = FxHashSet::from_iter(["card".to_string()]); + let classes = extract_class_map_from_code( + "page.mdown", + "import {css} from '@devup-ui/react'; export const card = css({color:'blue'});", + &ExtractOption::default(), + &names, + Some(ExtractSourceType::CompiledMdx), + )?; + assert!(classes.contains_key("card")); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 169996fd..482e3dba 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -233,16 +233,29 @@ fn execute_vanilla_extract( /// Execute vanilla-extract style file and collect styles, loading what it /// imports through `resolver` +#[cfg(test)] pub fn execute_stylesheet( code: &str, filename: &str, option: &crate::ExtractOption, resolver: Option<&crate::ModuleResolver>, ) -> Result<(CollectedStyles, StylesheetImports), String> { + execute_stylesheet_with_type(code, filename, option, resolver, None) +} + +pub(crate) fn execute_stylesheet_with_type( + code: &str, + filename: &str, + option: &crate::ExtractOption, + resolver: Option<&crate::ModuleResolver>, + mode: Option, +) -> Result<(CollectedStyles, StylesheetImports), String> { + let source_type = crate::source_type::parser_type(filename, mode)?; + let cache_key = (filename.to_string(), mode); let _evaluating = Evaluating::enter(filename); let mut loader = ModuleLoader::new(resolver, option); let script = module_script( - &strip_typescript(code, filename), + &strip_typescript_with_type(code, filename, mode)?, filename, &mut loader, true, @@ -256,7 +269,7 @@ pub fn execute_stylesheet( let imported = crate::module_loader::evaluating_import(); if imported && let Some(collected) = IMPORTED_RUNS.with_borrow(|runs| { - runs.get(filename) + runs.get(&cache_key) .filter(|(num, source, text, _)| *num == file_num && source == code && *text == run) .map(|(.., collected)| collected.clone()) }) @@ -287,14 +300,14 @@ pub fn execute_stylesheet( let mut collected = std::mem::take(&mut collector.borrow_mut().styles); name_entries( &mut collected, - &top_level_bindings(code), + &top_level_bindings_with_type(code, source_type), &mut context, file_num, ); if imported { IMPORTED_RUNS.with_borrow_mut(|runs| { runs.insert( - filename.to_string(), + cache_key, (file_num, code.to_string(), run, collected.clone()), ); }); @@ -302,14 +315,18 @@ pub fn execute_stylesheet( Ok((collected, imports)) } +type ImportedRuns = + FxHashMap; +type StrippedSources = FxHashMap; + thread_local! { /// What running each stylesheet other evaluations import last collected, /// with its file number, source and the script run: every module importing /// it reuses one run of the same script - static IMPORTED_RUNS: RefCell> = + static IMPORTED_RUNS: RefCell = RefCell::default(); /// The TypeScript each file last had stripped, with its source - static STRIPPED: RefCell> = RefCell::default(); + static STRIPPED: RefCell = RefCell::default(); } /// A name a top-level variable declaration of the stylesheet binds @@ -320,11 +337,9 @@ struct Binding { init: Option, } -fn top_level_bindings(code: &str) -> Vec { +fn top_level_bindings_with_type(code: &str, source_type: SourceType) -> Vec { let allocator = Allocator::default(); - let program = Parser::new(&allocator, code, SourceType::ts()) - .parse() - .program; + let program = Parser::new(&allocator, code, source_type).parse().program; let mut bindings = Vec::new(); for statement in &program.body { let (declaration, exported) = match statement { @@ -560,25 +575,35 @@ fn replace_placeholders( /// Convert TypeScript to JavaScript using Oxc Transformer /// `code` without its TypeScript, stripped once for as long as the file holds /// it: the modules evaluations import are stripped once, not per evaluation +#[cfg(test)] pub(crate) fn strip_typescript(code: &str, filename: &str) -> String { + strip_typescript_with_type(code, filename, None).unwrap_or_default() +} + +pub(crate) fn strip_typescript_with_type( + code: &str, + filename: &str, + mode: Option, +) -> Result { + let source_type = crate::source_type::parser_type(filename, mode)?; + let key = (filename.to_string(), mode); if let Some(stripped) = STRIPPED.with_borrow(|stripped| { stripped - .get(filename) + .get(&key) .filter(|(source, _)| source == code) .map(|(_, stripped)| stripped.clone()) }) { - return stripped; + return Ok(stripped); } - let stripped = strip(code, filename); + let stripped = strip(code, source_type); STRIPPED.with_borrow_mut(|entries| { - entries.insert(filename.to_string(), (code.to_string(), stripped.clone())); + entries.insert(key, (code.to_string(), stripped.clone())); }); - stripped + Ok(stripped) } -fn strip(code: &str, filename: &str) -> String { +fn strip(code: &str, source_type: SourceType) -> String { let allocator = Allocator::default(); - let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); let mut program = Parser::new(&allocator, code, source_type).parse().program; let scoping = SemanticBuilder::new() .with_enum_eval(true) @@ -1474,6 +1499,7 @@ mod tests { == specifier.trim_start_matches('.').trim_end_matches(".ts") }) .map(|(path, code)| crate::ResolvedModule { + source_type: None, path: (*path).to_string(), code: (*code).to_string(), }) @@ -1517,11 +1543,11 @@ mod tests { cycle_error( "import { b } from './b'\nexport const x = b", &[ - ("/b", "import { c } from './c'\nexport const b = c"), - ("/c", "import { b } from './b'\nexport const c = b"), + ("/b.ts", "import { c } from './c'\nexport const b = c"), + ("/c.ts", "import { b } from './b'\nexport const c = b"), ] ) - .contains("Cannot access 'b' of '/b'") + .contains("Cannot access 'b' of '/b.ts'") ); assert!( cycle_error( @@ -1542,11 +1568,11 @@ mod tests { "import { style } from '@devup-ui/react'\nimport { both, reexported } from './b'\nimport { d, withA } from './d.css'\nexport const x = style([d, { color: both, background: reexported }])\nexport const y = typeof withA", &[ ( - "/b", + "/b.ts", "import { c, lazy } from './c'\nexport const b = 'blue'\nexport const both = c()\nexport { lazy as reexported }" ), ( - "/c", + "/c.ts", "import bDefault, { b } from './b'\nimport * as all from './b'\nexport const c = () => b + all.b.length\nexport const lazyDefault = () => bDefault\nexport const lazy = 'red'\nexport { b as again }" ), ("/a.css.ts", ""), diff --git a/packages/plugin-utils/src/__tests__/source-type.test.ts b/packages/plugin-utils/src/__tests__/source-type.test.ts new file mode 100644 index 00000000..f98f349a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/source-type.test.ts @@ -0,0 +1,185 @@ +import { createRequire } from 'node:module' +import { relative, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + __setOxcParserForTest, + buildStaticImportGraph, + createModuleResolver, + type PreparedSource, +} from '../import-graph' +import { PreparedSourceTypeError, readPreparedSource } from '../prepared-source' +import { createPreparedFixture } from './prepared-graph-fixture' + +const projectRequire = createRequire(resolve('apps/landing/package.json')) +const compilerRequire = createRequire(projectRequire.resolve('@mdx-js/loader')) +const { compile } = compilerRequire('@mdx-js/mdx') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) +}) +afterEach(() => { + wasm.resetBuildState() + wasm.setDebug(debug) + __setOxcParserForTest(undefined) +}) + +it.each([false, true])( + 'extracts compiled custom-extension exports after JSX (%s)', + async (jsx) => { + const markdown = + 'import {Box} from "@devup-ui/react"\n\n' + const module = file('src/value.mdown', markdown) + const entry = file('src/main.tsx', 'import {PRIMARY} from "./value.mdown";') + const compiled = await compile( + { path: module, value: markdown }, + { format: 'mdx', jsx }, + ) + const source: PreparedSource = { + code: `${compiled}\nexport const PRIMARY = 'blue';`, + sourceType: 'compiled-mdx', + map: compiled.map, + } + const prepareSource = (filename: string) => + filename === module ? source : undefined + const toId = (filename: string) => + relative(root, filename).replaceAll('\\', '/') + const resolver = createModuleResolver({ + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + toId, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: ['.mdown'], + prepareSource, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([module])) + expect(resolver('./value.mdown', toId(entry))?.sourceType).toBe( + 'compiled-mdx', + ) + wasm.setModuleResolver(resolver) + const output = wasm.codeExtractWithoutSourceMap( + toId(entry), + 'import {Box} from "@devup-ui/react"; import {PRIMARY} from "./value.mdown"; export const view = ;', + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + expect(wasm.getCss(undefined, false)).toContain('color:blue') + expect(output.code).not.toContain('--') + expect(output.dependencies).toEqual([toId(module)]) + } finally { + output.free() + } + }, +) + +it('uses JS JSX scanner and diagnostics when explicit mode overrides a TS filename', async () => { + const entry = file('src/page.ts') + const real = file('src/real.js') + file('node_modules/phantom/package.json', '{') + const modes: unknown[] = [] + __setOxcParserForTest({ + parseSync: (_filename, _code, options) => { + modes.push(options?.lang) + return {} + }, + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: (filename) => + filename === entry + ? { + code: 'const view =
import "phantom"
; import "./real.js";', + sourceType: 'compiled-mdx', + } + : undefined, + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([real])) + expect(modes).toContain('jsx') +}) + +it.each(['invalid', 7, null, true, {}])( + 'rejects invalid hook mode before scanner fallback (%p)', + async (value) => { + const entry = file('src/page.ts', '') + const prepared = { code: '' } + Reflect.set(prepared, 'sourceType', value) + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow(`${entry}:1:1:`) + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') + }, +) + +it('preserves getter cause when preparation cannot read optional mode', async () => { + const entry = file('src/page.ts', '') + const cause = new Error('sourceType getter fault') + const prepared = { + code: '', + get sourceType(): 'compiled-mdx' { + throw cause + }, + } + await expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => prepared, + }), + ).rejects.toThrow('sourceType getter fault') + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => prepared, + }) + expect(() => resolver(entry, 'main.ts')).toThrow('main.ts:1:1:') +}) + +it('replaces mode when prepared generations become ordinary source', () => { + const module = file('src/value.ts', 'export const PRIMARY = "red";') + let source: PreparedSource = { + code: 'export const PRIMARY = "blue";', + sourceType: 'compiled-mdx', + } + const resolver = createModuleResolver({ + cwd: root, + prepareSource: () => source, + }) + expect(resolver(module, 'main.ts')?.sourceType).toBe('compiled-mdx') + source = { code: '' } + expect(resolver(module, 'main.ts')).toEqual({ path: module, code: '' }) + source = undefined + expect(resolver(module, 'main.ts')).toEqual({ + path: module, + code: 'export const PRIMARY = "red";', + }) +}) + +it('retains empty/string/undefined preparation contracts when mode is omitted', () => { + expect(readPreparedSource(undefined)).toBeUndefined() + expect(readPreparedSource('')).toBe('') + expect(readPreparedSource({ code: '' })).toEqual({ code: '', map: undefined }) + expect(new PreparedSourceTypeError('getter fault').message).toContain( + 'getter fault', + ) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index be2bb585..5d5b17a3 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -23,6 +23,7 @@ import { createPreparedResolver, type ModuleResolver, } from './prepared-resolver' +import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' import { createNodeModulesExcludeRegex, SOURCE_EXTENSIONS, @@ -147,7 +148,13 @@ export function buildStaticImportGraph( } export type PreparedSource = - string | { readonly code: string; readonly map?: unknown } | undefined + | string + | { + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + | undefined export type PrepareSource = ( filename: string, ) => PreparedSource | Promise @@ -163,6 +170,7 @@ class GraphPreparationError extends Error { const message = cause instanceof Error ? cause.message : String(cause) const location = cause instanceof Error && + !(cause instanceof PreparedSourceTypeError) && 'line' in cause && typeof cause.line === 'number' ? `${cause.line}:${'column' in cause && typeof cause.column === 'number' ? cause.column : 1}` @@ -186,9 +194,10 @@ async function drivePreparedGraph( const filename = step.value let prepared: PreparedSource try { - prepared = await options.prepareSource(filename) + prepared = readPreparedSource(await options.prepareSource(filename)) } catch (cause) { const located = new GraphPreparationError(filename, cause) + if (cause instanceof PreparedSourceTypeError) throw located const remapped = remapMdxError(located, filename) throw new Error( remapped.message.replaceAll( @@ -256,6 +265,7 @@ function* traverseGraph( file, code, typeof prepared === 'object' ? prepared.map : undefined, + typeof prepared === 'object' ? prepared.sourceType : undefined, ) for (const importRef of imports) { const resolution = resolver(importRef.specifier, file) @@ -851,21 +861,27 @@ function parsePreparedImports( filename: string, source: string, map: unknown, + sourceType?: import('./prepared-source').SourceType, ): ImportReference[] { - return parseSourceImports(filename, source, { map }) + return parseSourceImports(filename, source, { map, sourceType }) } function parseSourceImports( filename: string, source: string, - prepared?: { readonly map: unknown }, + prepared?: { + readonly map: unknown + readonly sourceType?: import('./prepared-source').SourceType + }, ): ImportReference[] { const parser = getOxcParser() try { const ast = parser?.parseSync( filename, source, - prepared && !/\.[mc]?tsx?$/i.test(filename) + prepared && + (prepared.sourceType === 'compiled-mdx' || + !/\.[mc]?tsx?$/i.test(filename)) ? { sourceType: 'module', lang: 'jsx' } : { sourceType: 'module' }, ) @@ -886,9 +902,10 @@ function parseSourceImports( } return scanImports( source, - /\.[jt]sx$/i.test(filename) || + prepared?.sourceType === 'compiled-mdx' || + /\.[jt]sx$/i.test(filename) || (prepared !== undefined && !/\.[mc]?ts$/i.test(filename)), - /\.[mc]?tsx?$/i.test(filename), + prepared?.sourceType !== 'compiled-mdx' && /\.[mc]?tsx?$/i.test(filename), ) } @@ -958,6 +975,7 @@ export function __setOxcParserForTest( export interface ResolvedModule { path: string code: string + readonly sourceType?: import('./prepared-source').SourceType } export interface CreateModuleResolverOptions { diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 9ac74bb7..a259c390 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -39,6 +39,7 @@ export { seedFileNumbers, } from './numbering' export type { ModuleResolver } from './prepared-resolver' +export type { SourceType } from './prepared-source' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts index 50d3de1f..bc8d53e5 100644 --- a/packages/plugin-utils/src/prepared-resolver.ts +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -7,6 +7,7 @@ import type { ResolvedModule, } from './import-graph' import { remapMdxError } from './mdx-errors' +import { readPreparedSource } from './prepared-source' import type { MdxSelection } from './source-selection' export interface ModuleResolver { @@ -40,7 +41,12 @@ export function createPreparedResolver(options: { }) { const generations = new Map< string, - { readonly filename: string; readonly code: string; readonly map?: unknown } + { + readonly filename: string + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } >() const idsByFilename = new Map() const remappedErrors = new WeakSet() @@ -67,7 +73,7 @@ export function createPreparedResolver(options: { markdownExtensions.has(extname(filename).toLowerCase()) ) throw new TypeError('Markdown source has no prepared JavaScript') - return prepared + return readPreparedSource(prepared) } catch (cause) { throw new ModulePreparationError(importer, filename, cause) } @@ -85,7 +91,13 @@ export function createPreparedResolver(options: { typeof prepared === 'string' ? { code: prepared } : prepared generations.set(id, { filename, ...source }) idsByFilename.set(filename, id) - return { path: id, code: source.code } + return { + path: id, + code: source.code, + ...(source.sourceType === undefined + ? {} + : { sourceType: source.sourceType }), + } }, remapError(error: unknown): Error { if (error instanceof Error && remappedErrors.has(error)) return error diff --git a/packages/plugin-utils/src/prepared-source.ts b/packages/plugin-utils/src/prepared-source.ts new file mode 100644 index 00000000..d8f840da --- /dev/null +++ b/packages/plugin-utils/src/prepared-source.ts @@ -0,0 +1,29 @@ +import type { PreparedSource } from './import-graph' + +export type SourceType = 'compiled-mdx' + +export class PreparedSourceTypeError extends TypeError { + constructor(cause: unknown) { + super( + `source type cannot use the prepared source at build time: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) + this.name = 'PreparedSourceTypeError' + } +} + +export function readPreparedSource(prepared: PreparedSource): PreparedSource { + if (typeof prepared !== 'object' || prepared === null) return prepared + try { + const sourceType: unknown = prepared.sourceType + if (sourceType !== undefined && sourceType !== 'compiled-mdx') + throw new TypeError('expected `compiled-mdx`') + return { + code: prepared.code, + map: prepared.map, + ...(sourceType === undefined ? {} : { sourceType }), + } + } catch (cause) { + throw new PreparedSourceTypeError(cause) + } +} From a6a094dd089567a8da4c73c9fff0d7589566580b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 05:31:57 +0900 Subject: [PATCH 41/50] fix(plugins): guard definitely used untransformed MDX exports Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/callback-fixture.test.ts | 11 +- .../src/__tests__/mdx-ownership.test.ts | 89 ++++++++ .../src/__tests__/plugin-boundaries.test.ts | 4 +- packages/bun-plugin/src/devup-plugin.ts | 118 +++++------ packages/bun-plugin/src/mdx-ownership.ts | 67 ++++++ packages/bun-plugin/src/mdx.ts | 7 +- packages/bun-plugin/src/source-load.ts | 77 +++++++ .../src/__tests__/compiled-guard.test.ts | 187 +++++++++++++++++ packages/plugin-utils/src/compiled-guard.ts | 142 +++++++++++++ packages/plugin-utils/src/dependency-guard.ts | 127 ++++++++++++ packages/plugin-utils/src/guard-facts.ts | 157 ++++++++++++++ packages/plugin-utils/src/guard-nodes.ts | 153 ++++++++++++++ packages/plugin-utils/src/guard-origins.ts | 60 ++++++ packages/plugin-utils/src/index.ts | 25 ++- packages/plugin-utils/src/mdx-selection.ts | 46 +++++ .../src/__tests__/mdx-extensions.test.ts | 123 +++++++++++ .../src/__tests__/plugin.test.ts | 6 +- packages/rsbuild-plugin/src/plugin.ts | 55 ++++- .../src/__tests__/guard-facts.test.ts | 135 +++++++++++++ .../src/__tests__/mdx-extensions.test.ts | 191 ++++++++++++++++++ .../vite-plugin/src/__tests__/plugin.test.ts | 6 +- packages/vite-plugin/src/compiled-guard.ts | 140 +++++++++++++ packages/vite-plugin/src/plugin.ts | 32 ++- .../src/__tests__/mdx-build-fixture.ts | 147 ++++++++++++++ .../src/__tests__/mdx-extensions.test.ts | 83 ++++++++ .../src/__tests__/plugin.test.ts | 6 +- packages/webpack-plugin/src/compiled-guard.ts | 189 +++++++++++++++++ packages/webpack-plugin/src/loader.ts | 14 +- packages/webpack-plugin/src/plugin.ts | 33 ++- 29 files changed, 2325 insertions(+), 105 deletions(-) create mode 100644 packages/bun-plugin/src/__tests__/mdx-ownership.test.ts create mode 100644 packages/bun-plugin/src/mdx-ownership.ts create mode 100644 packages/bun-plugin/src/source-load.ts create mode 100644 packages/plugin-utils/src/__tests__/compiled-guard.test.ts create mode 100644 packages/plugin-utils/src/compiled-guard.ts create mode 100644 packages/plugin-utils/src/dependency-guard.ts create mode 100644 packages/plugin-utils/src/guard-facts.ts create mode 100644 packages/plugin-utils/src/guard-nodes.ts create mode 100644 packages/plugin-utils/src/guard-origins.ts create mode 100644 packages/plugin-utils/src/mdx-selection.ts create mode 100644 packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts create mode 100644 packages/vite-plugin/src/__tests__/guard-facts.test.ts create mode 100644 packages/vite-plugin/src/__tests__/mdx-extensions.test.ts create mode 100644 packages/vite-plugin/src/compiled-guard.ts create mode 100644 packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts create mode 100644 packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts create mode 100644 packages/webpack-plugin/src/compiled-guard.ts diff --git a/packages/bun-plugin/src/__tests__/callback-fixture.test.ts b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts index 17072d91..b03b3557 100644 --- a/packages/bun-plugin/src/__tests__/callback-fixture.test.ts +++ b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts @@ -38,6 +38,7 @@ export function fixture() { callback: OnResolveCallback }[] = [] const ends: OnEndCallback[] = [] + let success = true const builder = { onLoad(constraints, callback) { loads.push({ constraints, callback }) @@ -79,11 +80,15 @@ export function fixture() { constraints.filter.test(path), ) if (!hook) throw new Error(`No load hook for ${namespace}:${path}`) - return hook.callback({ path, namespace, loader: 'tsx', defer }) + try { + return await hook.callback({ path, namespace, loader: 'tsx', defer }) + } catch (error) { + success = false + throw error + } }, async cleanup() { - for (const end of ends) - await end({ outputs: [], logs: [], success: true }) + for (const end of ends) await end({ outputs: [], logs: [], success }) resetBuildState() registerShorthands({}) setPrefix(null) diff --git a/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts new file mode 100644 index 00000000..7db45337 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts @@ -0,0 +1,89 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +import { getCss } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' + +import { DevupUI } from '../devup-plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) + +it.each(['.mdx', '.mdown'])( + 'compiles owned %s using the project compiler', + async (extension) => { + const root = mkdtempSync(join(tmpdir(), 'devup-owned-bun-')) + try { + const path = join(root, `page${extension}`) + const compilerPath = createRequire( + landing.resolve('@mdx-js/loader'), + ).resolve('@mdx-js/mdx') + mkdirSync(join(root, 'node_modules/@mdx-js'), { recursive: true }) + symlinkSync( + dirname(compilerPath), + join(root, 'node_modules/@mdx-js/mdx'), + 'junction', + ) + writeFileSync( + path, + 'import {Box} from "@devup-ui/react"\n\n', + ) + const result = await Bun.build({ + entrypoints: [path], + root, + plugins: [DevupUI({ root, mdxExtensions: ['.mdx', '.mdown'] })], + external: ['react', 'react/jsx-runtime'], + }) + expect(result.success).toBe(true) + expect(getCss(null, false)).toContain('red') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000, +) + +it.each(['entry', 'import'])( + 'rejects a stolen owned %s without stylesheet imports', + async (kind) => { + const root = mkdtempSync(join(tmpdir(), 'devup-stolen-bun-')) + try { + const mdx = join(root, 'page.mdown') + const entry = join(root, 'entry.ts') + writeFileSync(mdx, '# Plain') + writeFileSync( + entry, + 'import value from "./page.mdown"; console.log(value)', + ) + const action = Bun.build({ + entrypoints: [kind === 'entry' ? mdx : entry], + root, + plugins: [ + { + name: 'stealer', + setup(builder) { + builder.onLoad({ filter: /\.mdown$/ }, () => ({ + contents: 'export default 1', + loader: 'js', + })) + }, + }, + DevupUI({ root, mdxExtensions: ['.mdown'] }), + ], + }) + await expect(action).rejects.toThrow('let Devup compile') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000, +) diff --git a/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts index c87da8ca..a85ad5d7 100644 --- a/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts @@ -70,9 +70,7 @@ it.each(['unrelated', 'styling'])( ) try { await f.setup({}, { entrypoints: [path], plugins: [] }) - if (scenario === 'styling') - await expect(f.load(path)).rejects.toThrow('page.mdx:1:1') - else expect(await f.load(path)).toBeUndefined() + await expect(f.load(path)).rejects.toThrow('page.mdx:1:1') } finally { await f.cleanup() } diff --git a/packages/bun-plugin/src/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts index b8a3263e..02dd6248 100644 --- a/packages/bun-plugin/src/devup-plugin.ts +++ b/packages/bun-plugin/src/devup-plugin.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative, resolve } from 'node:path' +import { basename, join, resolve } from 'node:path' import { beginBuild, @@ -9,17 +9,18 @@ import { createModuleResolver, createThemeInterfaceArgs, type CustomShorthands, - GRAPH_SOURCE_FILE_RE, + isMdxSource, listSourceFiles, loadDevupConfig, - MDX_FILE_RE, - remapMdxError, + mdxSourceFilter, + normalizeMdxExtensions, + type PreparedSource, resolveProjectPaths, resolveSourceDirs, seedFileNumbers, + selectedSourceFilter, } from '@devup-ui/plugin-utils' import { - codeExtract, getCss, getThemeInterface, registerShorthands, @@ -33,19 +34,15 @@ import { import { type BunPlugin, type PluginBuilder } from 'bun' import { cssNamespace, resolveCssId } from './css-id' -import { compileMdx } from './mdx' +import { createMdxOwnership } from './mdx-ownership' import { compiledPackages, importAliases, - importsCompiledPackage, libPackage, mentionsCompiledPackage, - preserveDependencies, runtimeSourceFilter, - sourceLoader, } from './source' - -const singleCss = true +import { loadSourceFile, type SourceProject } from './source-load' type PluginHooks = { readonly config?: PluginBuilder['config'] @@ -65,13 +62,10 @@ export interface DevupUIBunPluginOptions { include?: string[] /** Readable class names. Defaults to false in both runtime and builds. */ debug?: boolean + mdxExtensions?: readonly string[] } -type Project = ReturnType & { - readonly root: string - readonly debug: boolean - readonly resolver: ReturnType -} +type Project = ReturnType & SourceProject async function writeDataFiles({ devupFile, distDir, cssDir }: Project) { const config = await loadDevupConfig(devupFile) @@ -102,7 +96,7 @@ async function initialize(project: Project, options: DevupUIBunPluginOptions) { { seedFileMap }, collectNumberedFiles({ roots: resolveSourceDirs(root, options.sourceDirs), - includeMdx: true, + includeMdx: project.mdxExtensions, cwd: root, include: options.include, needles: compiledPackages, @@ -128,41 +122,6 @@ async function initialize(project: Project, options: DevupUIBunPluginOptions) { await writeDataFiles(project) } -async function loadSourceFile(filePath: string, project: Project) { - const original = await Bun.file(filePath).text() - const mdx = MDX_FILE_RE.test(filePath) - ? await compileMdx(project.root, filePath, original) - : undefined - if (MDX_FILE_RE.test(filePath) && !mdx) return undefined - const loader = mdx ? 'jsx' : sourceLoader(filePath) - const contents = mdx?.value ?? original - - if (importsCompiledPackage(contents, loader)) { - setDebug(project.debug) - setModuleResolver(project.resolver) - try { - const code = codeExtract( - filePath, - contents, - libPackage, - relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), - singleCss, - true, - false, - importAliases, - ) - return { - contents: preserveDependencies(code.code, filePath, code.dependencies), - loader, - } - } catch (cause) { - if (mdx) throw remapMdxError(cause, filePath, mdx.map) - throw cause - } - } - return { contents, loader } -} - /** * The Devup UI plugin, for `Bun.build` (`plugins: [DevupUI()]`) as well as the * Bun runtime ({@link register}). @@ -184,34 +143,54 @@ export function DevupUI(options: DevupUIBunPluginOptions = {}) { typeof build.config?.conditions === 'string' ? [build.config.conditions] : (build.config?.conditions ?? []) + const mdxExtensions = normalizeMdxExtensions(options.mdxExtensions) + const conditions = [ + ...targetConditions[ + build.config?.target ?? (bundling ? 'browser' : 'bun') + ], + 'import', + ...(bundling ? ['module'] : []), + ...customConditions, + ] + const prepared = new Map() + const ownership = createMdxOwnership({ + root, + extensions: mdxExtensions, + conditions, + entries: build.config?.entrypoints ?? [], + }) const project: Project = { ...resolveProjectPaths(root, options), root, debug: options.debug ?? false, + mdxExtensions, + prepared, + ownership, resolver: createModuleResolver({ cwd: root, // Extracted ESM imports stay import requests even in the vanilla // CommonJS evaluator. The callback does not expose a request kind. - conditions: [ - ...targetConditions[ - build.config?.target ?? (bundling ? 'browser' : 'bun') - ], - 'import', - ...(bundling ? ['module'] : []), - ...customConditions, - ], + conditions, + includeMdx: mdxExtensions, + prepareSource: (filename) => prepared.get(filename), }), } // A build starts from its own options, not from what an earlier build in // this process left in the engine const endBuild = beginBuild({ resetBuildState }) - build.onEnd?.(endBuild) + build.onEnd?.((result) => { + try { + if (result.success) ownership.validate() + } finally { + endBuild() + } + }) await initialize(project, options) setDebug(project.debug) let writtenCss = getCss(null, false) // Native token modules must keep Bun's filesystem watch/CJS loading path. const sourceFilter = bundling - ? GRAPH_SOURCE_FILE_RE + ? selectedSourceFilter(mdxExtensions) : new RegExp( `${ runtimeSourceFilter([ @@ -224,20 +203,23 @@ export function DevupUI(options: DevupUIBunPluginOptions = {}) { '.git', 'coverage', ], - { includeMdx: true }, - ).filter((file) => - mentionsCompiledPackage(readFileSync(file, 'utf-8')), + { includeMdx: mdxExtensions }, + ).filter( + (file) => + isMdxSource(file, mdxExtensions) || + mentionsCompiledPackage(readFileSync(file, 'utf-8')), ), ...collectNumberedFiles({ roots: resolveSourceDirs(root, options.sourceDirs), - includeMdx: true, + includeMdx: mdxExtensions, cwd: root, include: ['@devup-ui/components', ...(options.include ?? [])], needles: compiledPackages, toId: (path) => path, }), ]).source - }|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + }|${mdxSourceFilter(mdxExtensions).source}|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + 'i', ) // Resolve devup-ui CSS files onto a path-free virtual id, so nothing @@ -258,6 +240,7 @@ export function DevupUI(options: DevupUIBunPluginOptions = {}) { async ({ defer }) => { if (!bundling) return { contents: '', loader: 'js' } await defer() + ownership.validate() return { contents: getCss(null, false), loader: 'css' } }, ) @@ -269,6 +252,7 @@ export function DevupUI(options: DevupUIBunPluginOptions = {}) { }, ({ path }) => { return loadSourceFile(path, project).then((result) => { + if (isMdxSource(path, mdxExtensions)) ownership.loaded(path) if (!bundling && result) { const css = getCss(null, false) // Read-after-import is synchronous; only identical revisions coalesce. diff --git a/packages/bun-plugin/src/mdx-ownership.ts b/packages/bun-plugin/src/mdx-ownership.ts new file mode 100644 index 00000000..472dd66a --- /dev/null +++ b/packages/bun-plugin/src/mdx-ownership.ts @@ -0,0 +1,67 @@ +import { extname, resolve } from 'node:path' + +import { + createModuleResolver, + isMdxSource, + scanImports, +} from '@devup-ui/plugin-utils' + +function key(filename: string): string { + const path = resolve(filename).replaceAll('\\', '/') + return process.platform === 'win32' ? path.toLowerCase() : path +} + +export class MdxOwnershipError extends Error { + readonly name = 'MdxOwnershipError' + constructor( + readonly importer: string, + readonly target: string, + ) { + super( + `${importer}:1:1: owned MDX module '${target}' under extension '${extname(target)}' was loaded outside Devup UI; let Devup compile its extension, or remove that extension from mdxExtensions`, + ) + } +} + +export function createMdxOwnership(options: { + readonly root: string + readonly extensions: readonly string[] + readonly conditions: readonly string[] + readonly entries: readonly string[] +}) { + const resolver = createModuleResolver({ + cwd: options.root, + conditions: options.conditions, + includeMdx: options.extensions, + prepareSource: () => '', + }) + const reached = new Map< + string, + { readonly importer: string; readonly target: string } + >() + const successful = new Set() + function reach(target: string, importer: string) { + if (isMdxSource(target, options.extensions) && !reached.has(key(target))) + reached.set(key(target), { target, importer }) + } + for (const entry of options.entries) { + const target = resolve(options.root, entry) + reach(target, target) + } + return { + observe(filename: string, contents: string, typescript: boolean) { + for (const reference of scanImports(contents, true, typescript)) { + const target = resolver(reference.specifier, filename) + if (target) reach(target.path, filename) + } + }, + loaded(filename: string) { + successful.add(key(filename)) + }, + validate() { + for (const [path, value] of reached) + if (!successful.has(path)) + throw new MdxOwnershipError(value.importer, value.target) + }, + } +} diff --git a/packages/bun-plugin/src/mdx.ts b/packages/bun-plugin/src/mdx.ts index da3d6ba5..015f643e 100644 --- a/packages/bun-plugin/src/mdx.ts +++ b/packages/bun-plugin/src/mdx.ts @@ -8,7 +8,7 @@ type CompiledMdx = { readonly value: string; readonly map?: unknown } type Compiler = { readonly compile: ( input: { readonly value: string; readonly path: string }, - options: { readonly jsx: true }, + options: { readonly jsx: true; readonly format: 'mdx' }, ) => Promise } @@ -57,5 +57,8 @@ export async function compileMdx( throw new TypeError( `${filename}:1:1: @mdx-js/mdx does not export compile()`, ) - return compiler.compile({ value: contents, path: filename }, { jsx: true }) + return compiler.compile( + { value: contents, path: filename }, + { jsx: true, format: 'mdx' }, + ) } diff --git a/packages/bun-plugin/src/source-load.ts b/packages/bun-plugin/src/source-load.ts new file mode 100644 index 00000000..7773121c --- /dev/null +++ b/packages/bun-plugin/src/source-load.ts @@ -0,0 +1,77 @@ +import { dirname, relative } from 'node:path' + +import { + type createModuleResolver, + isMdxSource, + type PreparedSource, + remapMdxError, +} from '@devup-ui/plugin-utils' +import { codeExtract, setDebug, setModuleResolver } from '@devup-ui/wasm' + +import { compileMdx, MdxCompilerError } from './mdx' +import type { createMdxOwnership } from './mdx-ownership' +import { + importAliases, + importsCompiledPackage, + libPackage, + preserveDependencies, + sourceLoader, +} from './source' + +export interface SourceProject { + readonly root: string + readonly cssDir: string + readonly debug: boolean + readonly resolver: ReturnType + readonly mdxExtensions: readonly string[] + readonly prepared: Map + readonly ownership: ReturnType +} + +export async function loadSourceFile(filePath: string, project: SourceProject) { + const original = await Bun.file(filePath).text() + const selectedMdx = isMdxSource(filePath, project.mdxExtensions) + const mdx = selectedMdx + ? await compileMdx(project.root, filePath, original) + : undefined + if (selectedMdx && !mdx) throw new MdxCompilerError(filePath, undefined) + const loader = mdx ? 'jsx' : sourceLoader(filePath) + const contents = mdx?.value ?? original + if (mdx) + project.prepared.set(filePath, { + code: contents, + map: mdx.map, + sourceType: 'compiled-mdx', + }) + project.ownership.observe( + filePath, + contents, + !mdx && ['ts', 'tsx'].includes(loader), + ) + if (importsCompiledPackage(contents, loader)) { + setDebug(project.debug) + setModuleResolver(project.resolver) + try { + const code = codeExtract( + filePath, + contents, + libPackage, + relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), + true, + true, + false, + importAliases, + ...(mdx ? (['compiled-mdx'] as const) : ([] as const)), + ) + return { + contents: preserveDependencies(code.code, filePath, code.dependencies), + loader, + } + } catch (cause) { + const error = project.resolver.remapError(cause) + if (mdx) throw remapMdxError(error, filePath, mdx.map) + throw error + } + } + return { contents, loader } +} diff --git a/packages/plugin-utils/src/__tests__/compiled-guard.test.ts b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts new file mode 100644 index 00000000..822453a7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/compiled-guard.test.ts @@ -0,0 +1,187 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from '../compiled-guard' +import { createDependencyGuard } from '../dependency-guard' +import { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from '../mdx-selection' + +it('normalizes literal selection and preserves exact source filtering', () => { + const extensions = normalizeMdxExtensions(['.MDOWN', '.mdx', '.mdown']) + expect(extensions).toEqual(['.mdown', '.mdx']) + expect(isMdxSource('page.MDOWN?query', extensions)).toBe(true) + expect(mdxSourceFilter(extensions).test('page.md')).toBe(false) + expect(selectedSourceFilter(extensions).test('page.mts')).toBe(true) + expect(mdxSourceFilter([]).test('page.mdx')).toBe(false) + expect(normalizeMdxExtensions()).toEqual(['.mdx']) + expect(() => normalizeMdxExtensions(['*.mdx'])).toThrow('mdxExtensions:1:1') +}) + +it('offers alias disabling only for an active compile-time compatibility alias', () => { + const reference = { + request: '@devup-ui/react', + ids: ['Box'], + line: 1, + column: 1, + } + expect( + new UntransformedSourceError('/page.mdown', reference).message, + ).not.toContain('importAliases') + expect( + new UntransformedSourceError( + '/page.mdown', + { ...reference, request: '@emotion/styled' }, + true, + ).message, + ).toContain('importAliases["@emotion/styled"]=false') +}) + +it('classifies only actual public surfaces instead of names or directory membership', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-surface-')) + mkdirSync(join(root, 'dist/compat'), { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: '@devup-ui/react', + exports: { + '.': { types: './dist/index.d.ts', import: './dist/index.js' }, + './compat': './dist/compat/index.js', + './stylex': './dist/stylex.cjs', + './types': {}, + }, + }), + ) + try { + const classify = createCompileTimeClassifier('@devup-ui/react') + expect(classify(join(root, 'dist/index.js'), ['Box'])).toBe(true) + expect(classify(join(root, 'dist/index.js'), ['getTheme'])).toBe(false) + expect(classify(join(root, 'dist/index.js'), ['stylex', 'create'])).toBe( + true, + ) + expect(classify(join(root, 'dist/index.js'), ['stylex'])).toBe(false) + expect(classify(join(root, 'dist/compat/index.js'), ['Global'])).toBe(true) + expect( + classify(join(root, 'dist/compat/index.js'), ['ThemeProvider']), + ).toBe(false) + expect(classify(join(root, 'dist/stylex.cjs'), ['props'])).toBe(true) + expect(classify(join(root, 'dist/ordinary.js'), ['Box'])).toBe(false) + expect(classify(join(tmpdir(), 'Box.js'), ['Box'])).toBe(false) + mkdirSync(join(root, 'stylex')) + writeFileSync( + join(root, 'stylex/package.json'), + JSON.stringify({ + name: '@stylexjs/stylex', + exports: { '.': './index.js' }, + }), + ) + expect(classify(join(root, 'stylex/index.js'), ['default', 'create'])).toBe( + true, + ) + expect(classify(join(root, 'stylex/index.js'), ['default'])).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it.each([ + ['@emotion/styled', ['default'], { '@emotion/styled': 'styled' }, true], + ['@emotion/react', ['CacheProvider'], { '@emotion/react': null }, false], + [ + 'styled-components', + ['ThemeProvider'], + { 'styled-components': 'styled' }, + false, + ], + ['@vanilla-extract/css', ['style'], { '@vanilla-extract/css': null }, true], + ['custom', ['Box'], { custom: null }, true], + ['custom', [], { custom: 'styled' }, false], + ['custom', ['default'], { custom: null }, false], + ['disabled', ['css'], {}, false], + ['@stylexjs/stylex', ['default', 'create'], {}, false], +])( + 'uses extractor-compatible alias mapping for %s/%s', + (request, ids, aliases, expected) => { + expect( + isCompileTimeAlias({ request, ids, line: 1, column: 1 }, aliases), + ).toBe(expected) + }, +) + +it('guards used dependency IDs without guarding unused imports, opaque namespaces or ordinary JS', () => { + const check = createDependencyGuard({ + package: '@devup-ui/react', + mdxExtensions: ['.mdx'], + importAliases: { alias: 'styled' }, + }) + const dependency = { + type: 'esm import specifier', + request: 'alias', + ids: ['default'], + loc: { start: { line: 3, column: 4 } }, + } + const modules = [ + { + type: 'javascript/auto', + resource: '/page.mdown', + dependencies: [ + dependency, + { ...dependency, ids: [] }, + { ...dependency, type: 'esm import' }, + null, + ], + }, + { + type: 'javascript/auto', + resource: '/node_modules/skipped.js', + dependencies: [dependency], + }, + { + type: 'asset/source', + resource: '/asset.blob', + dependencies: [dependency], + }, + { type: 'javascript/auto', dependencies: [dependency] }, + ] + const errors = check(modules, { graph: {}, target: () => undefined }) + expect(errors).toHaveLength(1) + expect(errors[0]?.message).toContain('/page.mdown:3:5:') +}) + +it('consumes public getIds and require-member names but ignores untyped or opaque IDs', () => { + const check = createDependencyGuard({ + package: '@devup-ui/react', + mdxExtensions: [], + importAliases: { alias: null }, + }) + const module = { + type: 'javascript/auto', + resource: '/page.unknown', + dependencies: [ + { + type: 'harmony import specifier', + request: 'alias', + getIds: () => ['css'], + }, + { type: 'cjs full require', request: 'alias', names: ['css'] }, + { type: 'cjs full require', request: 'alias', names: [123] }, + ], + } + expect( + check([module], { + graph: {}, + target: () => undefined, + forwarded: () => false, + }), + ).toHaveLength(2) +}) diff --git a/packages/plugin-utils/src/compiled-guard.ts b/packages/plugin-utils/src/compiled-guard.ts new file mode 100644 index 00000000..641de0f7 --- /dev/null +++ b/packages/plugin-utils/src/compiled-guard.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, extname, join, resolve } from 'node:path' + +import type { WasmImportAliases } from './types' + +const rootExports = new Set([ + 'Box', + 'Button', + 'Center', + 'Flex', + 'Grid', + 'Image', + 'Input', + 'Text', + 'VStack', + 'css', + 'globalCss', + 'keyframes', + 'styled', +]) +const compatExports = new Set(['Global', 'createGlobalStyle']) +const stylexExports = new Set([ + 'create', + 'props', + 'attrs', + 'keyframes', + 'firstThatWorks', + 'include', + 'defineVars', + 'createTheme', + 'createThemeContract', + 'defineConsts', + 'positionTry', + 'viewTransitionClass', + 'types', +]) +const compatPackages = new Set([ + '@emotion/react', + '@emotion/styled', + 'styled-components', + '@vanilla-extract/css', +]) + +export interface CompiledReference { + readonly request: string + readonly ids: readonly string[] + readonly line: number + readonly column: number +} + +export class UntransformedSourceError extends Error { + readonly name = 'UntransformedSourceError' + constructor( + readonly filename: string, + readonly reference: CompiledReference, + alias = false, + ) { + const extension = extname(filename.split('?')[0]) + super( + `${filename}:${reference.line}:${reference.column}: (in compiled output) untransformed module cannot use import '${reference.request}' (${reference.ids.join('.')}) at build time under extension '${extension}'; add '${extension}' to mdxExtensions and configure its MDX compiler${alias ? `, or set importAliases[${JSON.stringify(reference.request)}]=false for an intended runtime alias` : ''}`, + ) + } +} + +/** Same known compatibility targets as the extractor; unknown exports stay runtime. */ +export function isCompileTimeAlias( + reference: CompiledReference, + aliases: WasmImportAliases, +): boolean { + const { request, ids } = reference + if (!Object.hasOwn(aliases, request) || !ids.length) return false + const name = ids[0] === 'default' ? aliases[request] : ids[0] + if (!name) return false + if ( + request === '@vanilla-extract/css' && + ['style', 'globalStyle'].includes(name) + ) + return true + return ( + ['css', 'keyframes', 'styled', 'Global', 'createGlobalStyle'].includes( + name, + ) || + (!compatPackages.has(request) && rootExports.has(name)) + ) +} + +function targets(value: unknown): string[] { + if (typeof value === 'string') return /\.[cm]?js$/.test(value) ? [value] : [] + if (!value || typeof value !== 'object') return [] + return Object.values(value).flatMap(targets) +} + +/** Identify an actual public export surface, never a basename/package-directory guess. */ +export function createCompileTimeClassifier(libPackage: string) { + const cache = new Map() + return (filename: string, ids: readonly string[]): boolean => { + const file = resolve(filename.split('?')[0]) + let surface = cache.get(file) + if (!cache.has(file)) { + let directory = dirname(file) + while (true) { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const pkg: unknown = JSON.parse(readFileSync(manifest, 'utf-8')) + if ( + pkg && + typeof pkg === 'object' && + 'name' in pkg && + (pkg.name === libPackage || pkg.name === '@stylexjs/stylex') && + 'exports' in pkg && + pkg.exports && + typeof pkg.exports === 'object' + ) { + for (const [entry, value] of Object.entries(pkg.exports)) { + if ( + ['.', './compat', './stylex'].includes(entry) && + targets(value).some( + (target) => resolve(directory, target) === file, + ) + ) + surface = + pkg.name === '@stylexjs/stylex' ? 'native-stylex' : entry + } + break + } + } + const parent = dirname(directory) + if (parent === directory) break + directory = parent + } + cache.set(file, surface) + } + if (surface === 'native-stylex') + return stylexExports.has(ids[0] === 'default' ? ids[1] : ids[0]) + if (surface === '.') + return ids[0] === 'stylex' + ? stylexExports.has(ids[1]) + : rootExports.has(ids[0]) + if (surface === './compat') return compatExports.has(ids[0]) + return surface === './stylex' && stylexExports.has(ids[0]) + } +} diff --git a/packages/plugin-utils/src/dependency-guard.ts b/packages/plugin-utils/src/dependency-guard.ts new file mode 100644 index 00000000..c527fe06 --- /dev/null +++ b/packages/plugin-utils/src/dependency-guard.ts @@ -0,0 +1,127 @@ +import { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +import { isSelectedSource } from './source-selection' +import type { WasmImportAliases } from './types' + +interface UsedDependency { + readonly request: string + readonly type: string + readonly ids?: unknown + readonly names?: unknown + readonly getIds?: (graph: unknown) => unknown + readonly loc?: unknown +} + +function isDependency(value: unknown): value is UsedDependency { + return ( + !!value && + typeof value === 'object' && + 'request' in value && + typeof value.request === 'string' && + 'type' in value && + typeof value.type === 'string' + ) +} + +function stringIds(value: unknown): readonly string[] { + return Array.isArray(value) && + value.every((id: unknown) => typeof id === 'string') + ? value + : [] +} + +export interface GuardModule { + readonly resource?: string + readonly type: string + readonly dependencies: readonly unknown[] +} + +export interface DependencyGuardOptions { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases +} + +export function createDependencyGuard(options: DependencyGuardOptions) { + const classify = createCompileTimeClassifier(options.package) + return ( + modules: Iterable, + context: { + readonly graph: unknown + readonly target: (dependency: unknown) => string | undefined + readonly forwarded?: ( + dependency: unknown, + ids: readonly string[], + ) => boolean + }, + ): UntransformedSourceError[] => { + const errors: UntransformedSourceError[] = [] + for (const module of modules) { + if ( + !module.resource || + !module.type.startsWith('javascript') || + isSelectedSource(module.resource.split('?')[0], options.mdxExtensions) + ) + continue + for (const dependency of module.dependencies) { + if ( + !isDependency(dependency) || + ![ + 'harmony import specifier', + 'esm import specifier', + 'cjs full require', + 'cjs require', + ].includes(dependency.type) + ) + continue + const ids = stringIds( + typeof dependency.getIds === 'function' + ? dependency.getIds(context.graph) + : (dependency.ids ?? dependency.names), + ) + if (!ids.length) continue + const loc = dependency.loc + const start = + loc && typeof loc === 'object' && 'start' in loc + ? loc.start + : undefined + const reference: CompiledReference = { + request: dependency.request, + ids, + line: + start && + typeof start === 'object' && + 'line' in start && + typeof start.line === 'number' + ? start.line + : 1, + column: + start && + typeof start === 'object' && + 'column' in start && + typeof start.column === 'number' + ? start.column + 1 + : 1, + } + const target = context.target(dependency) + if ( + isCompileTimeAlias(reference, options.importAliases) || + (target && classify(target, ids)) || + context.forwarded?.(dependency, ids) + ) + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + } + } + return errors + } +} diff --git a/packages/plugin-utils/src/guard-facts.ts b/packages/plugin-utils/src/guard-facts.ts new file mode 100644 index 00000000..2c5b741a --- /dev/null +++ b/packages/plugin-utils/src/guard-facts.ts @@ -0,0 +1,157 @@ +import { + bindingScope, + createScopes, + field, + type GuardNode, + guardNode, + type GuardOrigin, + nodeName, + patternNames, +} from './guard-nodes' +import { origin, patternExpressions } from './guard-origins' + +export interface GuardFacts { + readonly references: readonly (GuardOrigin & { readonly pos: number })[] + readonly exports: ReadonlyMap + readonly stars: readonly string[] +} + +export function compiledFacts(ast: unknown): GuardFacts { + const root = guardNode(ast) + const references: (GuardOrigin & { readonly pos: number })[] = [] + const exports = new Map() + const stars: string[] = [] + if (!root) return { references, exports, stars } + const { scopes, moduleScope } = createScopes(root) + for (const statement of root.fields.get('body') ?? []) { + if (statement.kind !== 'ImportDeclaration') continue + const request = nodeName(field(statement, 'source')) + for (const specifier of statement.fields.get('specifiers') ?? []) { + const local = field(specifier, 'local') + if (!local) continue + const ids = + specifier.kind === 'ImportNamespaceSpecifier' + ? [] + : [ + specifier.kind === 'ImportDefaultSpecifier' + ? 'default' + : nodeName(field(specifier, 'imported')), + ] + moduleScope.bindings.set(local.name, { request, ids }) + } + } + function record(value: GuardOrigin | undefined, pos: number) { + if (value?.ids.length) references.push({ ...value, pos }) + } + function visit(node: GuardNode, key = '') { + const scope = scopes.get(node) ?? moduleScope + if (node.kind === 'VariableDeclaration' && node.value === 'const') { + for (const declaration of node.fields.get('declarations') ?? []) { + const id = field(declaration, 'id') + const value = origin(field(declaration, 'init'), scope) + if (id?.kind === 'Identifier' && value) + bindingScope(scope, id.name)?.bindings.set(id.name, value) + } + } + if (node.kind === 'ImportDeclaration') return + if (node.kind === 'ExportAllDeclaration') { + const request = nodeName(field(node, 'source')) + const exported = nodeName(field(node, 'exported')) + if (exported) exports.set(exported, { request, ids: [] }) + else stars.push(request) + return + } + if (node.kind === 'ExportNamedDeclaration') { + const request = nodeName(field(node, 'source')) + for (const specifier of node.fields.get('specifiers') ?? []) { + const name = nodeName(field(specifier, 'exported')) + const local = field(specifier, 'local') + exports.set( + name, + request ? { request, ids: [nodeName(local)] } : origin(local, scope), + ) + } + const declaration = field(node, 'declaration') + if (declaration) { + const ids = + declaration.kind === 'VariableDeclaration' + ? (declaration.fields.get('declarations') ?? []).flatMap((item) => { + const pattern = field(item, 'id') + return pattern ? patternNames(pattern) : [] + }) + : [nodeName(field(declaration, 'id'))] + for (const name of ids) exports.set(name, undefined) + } + } + if (node.kind === 'ExportDefaultDeclaration') + exports.set('default', origin(field(node, 'declaration'), scope)) + if (['VariableDeclarator', 'AssignmentExpression'].includes(node.kind)) { + const pattern = field( + node, + node.kind === 'VariableDeclarator' ? 'id' : 'left', + ) + const value = origin( + field(node, node.kind === 'VariableDeclarator' ? 'init' : 'right'), + scope, + ) + if (pattern?.kind === 'ObjectPattern' && value) { + for (const property of pattern.fields.get('properties') ?? []) { + const propertyKey = field(property, 'key') + if ( + property.kind !== 'Property' || + (property.computed && propertyKey?.kind !== 'Literal') + ) + continue + record( + { + ...value, + ids: [...value.ids, nodeName(propertyKey)], + }, + property.pos, + ) + } + } + } + if (node.kind === 'MemberExpression') { + record(origin(node, scope), node.pos) + const object = field(node, 'object') + if (object && !origin(object, scope)) visit(object, 'object') + const property = field(node, 'property') + if (node.computed && property?.kind !== 'Literal' && property) + visit(property, 'property') + return + } + if (node.kind === 'Identifier') { + if ( + [ + 'id', + 'params', + 'param', + 'imported', + 'exported', + 'local', + 'key', + 'label', + ].includes(key) + ) + return + record(origin(node, scope), node.pos) + return + } + if ( + [ + 'ObjectPattern', + 'ArrayPattern', + 'AssignmentPattern', + 'RestElement', + ].includes(node.kind) + ) { + for (const expression of patternExpressions(node)) visit(expression) + return + } + for (const [name, children] of node.fields) + for (const child of children) visit(child, name) + } + visit(root) + return { references, exports, stars } +} diff --git a/packages/plugin-utils/src/guard-nodes.ts b/packages/plugin-utils/src/guard-nodes.ts new file mode 100644 index 00000000..d8f88dc2 --- /dev/null +++ b/packages/plugin-utils/src/guard-nodes.ts @@ -0,0 +1,153 @@ +export interface GuardNode { + readonly kind: string + readonly name: string + readonly value: string + readonly pos: number + readonly fields: ReadonlyMap + readonly computed: boolean +} + +export function guardNode(value: unknown): GuardNode | undefined { + if ( + !value || + typeof value !== 'object' || + !('type' in value) || + typeof value.type !== 'string' + ) + return + const fields = new Map() + for (const [key, child] of Object.entries(value)) { + const children = (Array.isArray(child) ? child : [child]).flatMap( + (item: unknown) => { + const node = guardNode(item) + return node ? [node] : [] + }, + ) + if (children.length) fields.set(key, children) + } + return { + kind: value.type, + name: 'name' in value && typeof value.name === 'string' ? value.name : '', + value: + 'value' in value && typeof value.value === 'string' + ? value.value + : 'kind' in value && typeof value.kind === 'string' + ? value.kind + : '', + pos: 'start' in value && typeof value.start === 'number' ? value.start : 0, + computed: 'computed' in value && value.computed === true, + fields, + } +} + +export function field(node: GuardNode, key: string): GuardNode | undefined { + return node.fields.get(key)?.[0] +} + +export function nodeName(node: GuardNode | undefined): string { + return node?.name || node?.value || '' +} + +export interface GuardOrigin { + readonly request: string + readonly ids: readonly string[] + readonly kind?: 'require-call' +} + +export interface GuardScope { + readonly parent?: GuardScope + readonly functionScope: boolean + readonly bindings: Map +} + +export function binding( + scope: GuardScope, + name: string, +): GuardOrigin | undefined { + return bindingScope(scope, name)?.bindings.get(name) +} + +export function bindingScope( + scope: GuardScope, + name: string, +): GuardScope | undefined { + return scope.bindings.has(name) + ? scope + : scope.parent && bindingScope(scope.parent, name) +} + +export function patternNames(node: GuardNode): string[] { + if (node.kind === 'Identifier') return [node.name] + if (node.kind === 'Property') { + const value = field(node, 'value') + return value ? patternNames(value) : [] + } + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + return left ? patternNames(left) : [] + } + return [...node.fields.values()].flat().flatMap(patternNames) +} + +export function createScopes(root: GuardNode) { + const scopes = new Map() + const moduleScope: GuardScope = { functionScope: true, bindings: new Map() } + function visit(node: GuardNode, parent: GuardScope) { + const functionScope = + /^(?:Program|FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/.test( + node.kind, + ) + const classScope = ['ClassDeclaration', 'ClassExpression'].includes( + node.kind, + ) + const scope = + node === root + ? moduleScope + : functionScope || + classScope || + [ + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForOfStatement', + 'ForInStatement', + 'StaticBlock', + 'SwitchStatement', + ].includes(node.kind) + ? { + parent, + functionScope, + bindings: new Map(), + } + : parent + scopes.set(node, scope) + const id = field(node, 'id') + if (id && ['FunctionDeclaration', 'ClassDeclaration'].includes(node.kind)) + parent.bindings.set(id.name, undefined) + if (functionScope || classScope) { + if (id) scope.bindings.set(id.name, undefined) + for (const parameter of node.fields.get('params') ?? []) + for (const name of patternNames(parameter)) + scope.bindings.set(name, undefined) + } + const param = field(node, 'param') + if (param) + for (const name of patternNames(param)) + scope.bindings.set(name, undefined) + if (node.kind === 'VariableDeclaration') { + let target = scope + if (node.value === 'var') + while (!target.functionScope && target.parent) target = target.parent + for (const declaration of node.fields.get('declarations') ?? []) { + const pattern = field(declaration, 'id') + if (pattern) + for (const name of patternNames(pattern)) + target.bindings.set(name, undefined) + } + } + for (const children of node.fields.values()) + for (const child of children) visit(child, scope) + } + visit(root, moduleScope) + return { scopes, moduleScope } +} diff --git a/packages/plugin-utils/src/guard-origins.ts b/packages/plugin-utils/src/guard-origins.ts new file mode 100644 index 00000000..c4bb269c --- /dev/null +++ b/packages/plugin-utils/src/guard-origins.ts @@ -0,0 +1,60 @@ +import { + binding, + bindingScope, + field, + type GuardNode, + type GuardOrigin, + type GuardScope, + nodeName, +} from './guard-nodes' + +export function origin( + node: GuardNode | undefined, + scope: GuardScope, +): GuardOrigin | undefined { + if (!node) return + if (node.kind === 'Identifier') return binding(scope, node.name) + if (node.kind === 'CallExpression') { + const callee = field(node, 'callee') + const args = node.fields.get('arguments') ?? [] + const request = args[0] + if ( + callee?.kind === 'Identifier' && + callee.name === 'require' && + !bindingScope(scope, 'require') && + args.length === 1 && + request?.kind === 'Literal' && + request.value + ) + return { request: request.value, ids: [], kind: 'require-call' } + } + if (node.kind === 'MemberExpression') { + const object = origin(field(node, 'object'), scope) + const property = field(node, 'property') + if (object && property && (!node.computed || property.kind === 'Literal')) + return { ...object, ids: [...object.ids, nodeName(property)] } + } +} + +/** Binding patterns are not reads; their defaults and computed keys are. */ +export function patternExpressions(node: GuardNode): readonly GuardNode[] { + if (node.kind === 'AssignmentPattern') { + const left = field(node, 'left') + const right = field(node, 'right') + return [ + ...(left ? patternExpressions(left) : []), + ...(right ? [right] : []), + ] + } + if (node.kind === 'Property') { + const key = field(node, 'key') + const value = field(node, 'value') + return [ + ...(node.computed && key ? [key] : []), + ...(value ? patternExpressions(value) : []), + ] + } + if (['ObjectPattern', 'ArrayPattern', 'RestElement'].includes(node.kind)) + return [...node.fields.values()].flat().flatMap(patternExpressions) + return [] +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index a259c390..f6289bb9 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,4 +1,16 @@ export { beginBuild, type ResettableEngine } from './build-session' +export { + type CompiledReference, + createCompileTimeClassifier, + isCompileTimeAlias, + UntransformedSourceError, +} from './compiled-guard' +export { + createDependencyGuard, + type DependencyGuardOptions, + type GuardModule, +} from './dependency-guard' +export { compiledFacts, type GuardFacts } from './guard-facts' export { type AtomHoistPlan, buildCanonicalMap, @@ -24,6 +36,7 @@ export { type StaticImportGraphOptions, type SyncGraphOptions, } from './import-graph' +export { type ImportReference, scanImports } from './import-scanner' export { ConfigLoadError, deepMerge, @@ -31,6 +44,12 @@ export { loadDevupConfigSync, } from './load-config' export { remapMdxError } from './mdx-errors' +export { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from './mdx-selection' export { collectNumberedFiles, type CollectNumberedFilesOptions, @@ -55,7 +74,11 @@ export { SOURCE_EXTENSIONS, SOURCE_FILE_RE, } from './shared' -export type { MdxSelection, SourceSelectionOptions } from './source-selection' +export { + isSelectedSource, + type MdxSelection, + type SourceSelectionOptions, +} from './source-selection' export { createStateWriter, type StateWriter, diff --git a/packages/plugin-utils/src/mdx-selection.ts b/packages/plugin-utils/src/mdx-selection.ts new file mode 100644 index 00000000..06d9829c --- /dev/null +++ b/packages/plugin-utils/src/mdx-selection.ts @@ -0,0 +1,46 @@ +import { extname } from 'node:path' + +import { sourceExtensions } from './source-selection' + +export class MdxExtensionError extends Error { + readonly name = 'MdxExtensionError' + constructor(readonly extension: string) { + super( + `mdxExtensions:1:1: invalid extension ${JSON.stringify(extension)}; use leading-dot literal extensions, for example '.mdown'`, + ) + } +} + +export function normalizeMdxExtensions( + extensions: readonly string[] = ['.mdx'], +): readonly string[] { + return [ + ...new Set( + extensions.map((extension) => { + if (!/^\.[a-z\d][a-z\d_-]*$/i.test(extension)) + throw new MdxExtensionError(extension) + return extension.toLowerCase() + }), + ), + ] +} + +export function isMdxSource( + filename: string, + extensions: readonly string[], +): boolean { + return extensions.includes(extname(filename.split('?')[0]).toLowerCase()) +} + +export function mdxSourceFilter(extensions: readonly string[]): RegExp { + return extensions.length + ? new RegExp( + `\\.${extensions.length === 1 ? extensions[0].slice(1) : `(?:${extensions.map((extension) => extension.slice(1)).join('|')})`}$`, + 'i', + ) + : /$^/ +} + +export function selectedSourceFilter(extensions: readonly string[]): RegExp { + return mdxSourceFilter(sourceExtensions(extensions)) +} diff --git a/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 00000000..fec4f0d7 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,123 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, +} from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const cjsIds: unknown[] = [] + try { + const builder = await createRsbuild({ + cwd: fixture.root, + rsbuildConfig: { + mode: 'development', + plugins: [ + DevupUI({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + source: { entry: { main: fixture.filename } }, + resolve: { alias: fixture.alias }, + output: { + target: 'node', + distPath: { root: join(fixture.root, 'out') }, + minify: false, + }, + tools: { + rspack: { + module: { rules: [fixture.rule] }, + plugins: [ + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'PublicCjsIds', + (compilation) => { + compilation.hooks.finishModules.tap( + 'PublicCjsIds', + (modules) => { + for (const module of modules) { + if (module.resource !== fixture.filename) continue + for (const dependency of module.dependencies) + if (dependency.type.startsWith('cjs')) + cjsIds.push(dependency.ids) + } + }, + ) + }, + ) + }, + }, + ], + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + }, + }, + }, + }) + const result = await builder.build() + try { + return { css: wasm.getCss(null, false), cjsIds } + } finally { + await result.close() + } + } finally { + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.css).toContain('red') + expect(custom.css).toBe(expected.css) +}) + +it.each(guardCases)( + 'uses Rspack definitely-used IDs to guard %s', + async (name, source, errors) => { + const action = bundle(source) + if (errors && name !== 'namespace destructure') + await expect(action).rejects.toThrow() + else expect((await action).css).not.toContain('{') + }, +) + +it.each(cjsGuardCases)( + 'allows require uses with opaque public Rspack IDs when %s', + async (name, source) => { + const result = await bundle(source) + + expect(result.cjsIds).toEqual( + name === 'require shadowed' || name === 'dynamic opaque' + ? [] + : [undefined], + ) + expect(result.css).not.toContain('{') + }, +) + +it('allows opaque destructuring while definite member IDs remain guarded', async () => { + // Rspack gives these destructuring uses the same empty export-ID list. + for (const name of ['css', 'getTheme']) { + const source = `import * as DU from "@devup-ui/react"\n\nexport const {${name}: value} = DU\n\n# Plain` + expect((await bundle(source)).css).not.toContain('{') + } + await expect( + bundle( + 'import * as DU from "@devup-ui/react"\n\nexport const value = DU.css({color:"red"})\n\n# Plain', + ), + ).rejects.toThrow('Rspack build failed') +}) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index aa7b0954..71f3de4e 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -805,8 +805,10 @@ const App = () => `, }) const taps: Record unknown> = {} const tap = - (hook: string) => (_: unknown, fn: (...args: unknown[]) => unknown) => { - taps[hook] = fn + (hook: string) => + (name: unknown, fn: (...args: unknown[]) => unknown) => { + taps[name === 'DevupUICompiledSourceGuard' ? `${hook}:guard` : hook] = + fn } const compiler = { options: {}, diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 9c347b58..9e400492 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -10,6 +10,7 @@ import { computeFileReach, computeReachableFiles, createCompatTypes, + createDependencyGuard, createModuleResolver, createNodeModulesExcludeRegex, createStateWriter, @@ -17,10 +18,13 @@ import { type CustomShorthands, extractedNeedles, getFileNumByFilename, - GRAPH_SOURCE_FILE_RE, type ImportAliases, + isMdxSource, + isSelectedSource, loadDevupConfig, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, remapMdxError, resolveProjectPaths, @@ -61,6 +65,7 @@ export interface DevupUIRsbuildPluginOptions { prefix?: string shorthands?: CustomShorthands sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for it * to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -128,9 +133,11 @@ export const DevupUI = ({ sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions: configuredMdxExtensions, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) const importAliases = mergeImportAliases(userImportAliases) + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) const excludeModules = createNodeModulesExcludeRegex(include) let seedWarningEmitted = false const stateWriter = createStateWriter((path, content, encoding) => @@ -246,6 +253,11 @@ export const DevupUI = ({ }) const servedCss = new Map>() + const checkCompiled = createDependencyGuard({ + package: libPackage, + mdxExtensions, + importAliases, + }) const stylesheet = (resourcePath: string) => // A file's stylesheet imports the shared base, except in atom mode, // where the entry code imports the base itself so that hoisted atoms @@ -302,7 +314,7 @@ export const DevupUI = ({ : (value.import ?? []), ) const entries = rawEntries - .filter((file) => GRAPH_SOURCE_FILE_RE.test(file)) + .filter((file) => isSelectedSource(file, mdxExtensions)) .map((file) => resolve(root, file)) const roots = [ ...new Set([ @@ -314,12 +326,13 @@ export const DevupUI = ({ try { const resolver = createModuleResolver({ cwd: root, + includeMdx: mdxExtensions, conditions, toId, }) setModuleResolver(resolver) const graph = buildStaticImportGraph(roots, tsconfigPath, { - includeMdx: true, + includeMdx: mdxExtensions, cwd: root, include, conditions, @@ -359,7 +372,7 @@ export const DevupUI = ({ { seedFileMap }, collectNumberedFiles({ roots, - includeMdx: true, + includeMdx: mdxExtensions, include, cwd: root, needles: extractedNeedles(libPackage, importAliases), @@ -411,6 +424,30 @@ export const DevupUI = ({ if (stylesheet(basePath) !== base) changed.add(basePath) stale = [...changed] }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + compilation.errors.push( + ...checkCompiled(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = + actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + }), + ) + }, + ) // The next pass writes the build; this one writes none of its files compilation.hooks.processAssets.tap( { @@ -533,9 +570,12 @@ export const DevupUI = ({ atomMode, !atomMode, importAliases, + ...(isMdxSource(resourcePath, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) } catch (error) { - if (/\.mdx$/i.test(resourcePath)) + if (isMdxSource(resourcePath, mdxExtensions)) throw remapMdxError(error, resourcePath) throw error } @@ -572,7 +612,10 @@ export const DevupUI = ({ // Rsbuild's order: post installs an enforce: post Rspack loader. Its // transform context does not expose the incoming map, so located errors // are explicitly labelled as compiled MDX rather than claiming raw lines. - api.transform({ test: /\.mdx$/i, order: 'post' }, extract) + api.transform( + { test: mdxSourceFilter(mdxExtensions), order: 'post' }, + extract, + ) }, } } diff --git a/packages/vite-plugin/src/__tests__/guard-facts.test.ts b/packages/vite-plugin/src/__tests__/guard-facts.test.ts new file mode 100644 index 00000000..597acae0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/guard-facts.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from 'bun:test' +import { parseAst } from 'vite' + +import { compiledFacts } from '../../../plugin-utils/src/guard-facts' + +it.each([ + [ + 'member object', + 'import {css} from "library"; export const n=css({color:"red"}).length', + ], + [ + 'parameter default', + 'import {css} from "library"; export function f({x=css({color:"red"})}={}){return x}', + ], + [ + 'array default', + 'import {css} from "library"; const [x=css({color:"red"})]=[]', + ], + [ + 'pattern default', + 'import {css} from "library"; const {x=css({color:"red"})}={}', + ], + [ + 'computed destructure', + 'import * as DU from "library"; const {["css"]:style}=DU', + ], + ['direct require', 'export const n=require("library").css({color:"red"})'], + [ + 'assigned require', + 'const DU=require("library"); export const n=DU.css({color:"red"})', + ], + [ + 'require destructure', + 'const {css:style}=require("library"); export const n=style({color:"red"})', + ], +] as const)('records definite css when %s executes', (_name, source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect( + facts.references.map(({ request, ids }) => ({ request, ids })), + ).toContainEqual({ request: 'library', ids: ['css'] }) +}) + +it.each([ + 'const DU=require("library"); export const value=DU', + 'export const value=require("library")', + 'export const value=import("library")', + 'export function f(require){return require("library").css()}', + 'const require=other; export const value=require("library").css()', + 'export const value=require(name).css()', + 'import * as DU from "library"; export function f({css}={}){return css()}', +] as const)( + 'keeps opaque or shadowed source unclassified when %s', + (source) => { + const ast = parseAst(source) + + const facts = compiledFacts(ast) + + expect(facts.references).toEqual([]) + }, +) + +it('resolves static namespace references while preserving lexical shadowing and opaque uses', () => { + const ast = parseAst(`import * as DU from 'library'; + import {Box as B, getTheme} from 'library'; + export function shadow(DU, {Box:B}) { return [DU.css(), B]; } + function hoisted() { if (false) { var B; } return B; } + const C = class DU { method() { return DU.css(); } static { const B = 1; } }; + const escaped = DU; + const {css: style} = DU; + let picked; ({keyframes: picked} = DU); + const read = [DU.getTheme, DU['css'], B, getTheme]; + let name; const computed = DU[name]; + export default read;`) + const facts = compiledFacts(ast) + expect(facts.references.map((reference) => reference.ids)).toEqual([ + ['css'], + ['keyframes'], + ['getTheme'], + ['css'], + ['Box'], + ['getTheme'], + ]) +}) + +it('retains actual forwarding names and local-export precedence for a bundler to resolve', () => { + const facts = compiledFacts( + parseAst(`import styled from 'alias'; + import {Box as B} from 'library'; + export {styled as Named, B as Widget}; + export {css as cls} from 'library'; + export * from 'star'; + export * as Namespace from 'library'; + export const local = 1, [first, ...rest] = []; + export class LocalClass {} + export default styled;`), + ) + expect([...facts.exports]).toEqual([ + ['Named', { request: 'alias', ids: ['default'] }], + ['Widget', { request: 'library', ids: ['Box'] }], + ['cls', { request: 'library', ids: ['css'] }], + ['Namespace', { request: 'library', ids: [] }], + ['local', undefined], + ['first', undefined], + ['rest', undefined], + ['LocalClass', undefined], + ['default', { request: 'alias', ids: ['default'] }], + ]) + expect(facts.stars).toEqual(['star']) +}) + +it('visits parameter defaults, catches, loop scopes and function expressions without guessing binding names', () => { + const ast = parseAst(`import {css} from 'library'; + const local = function self({color: c = 1}, ...args) { return self; }; + try { throw 1; } catch ({message: css}) { css(); } + for (let css of []) { css(); } + for (let css in {}) { css(); } + for (let css = 0; css < 1; css++) {} + switch (1) { case 1: { let css = 0; } } + export const result = css({color:'red'});`) + expect( + compiledFacts(ast).references.map((reference) => reference.ids), + ).toEqual([['css']]) +}) + +it('ignores data and empty public AST values rather than treating strings as imports', () => { + expect(compiledFacts(undefined).references).toEqual([]) + expect( + compiledFacts( + parseAst(`export default "import {Box} from '@devup-ui/react'"`), + ).references, + ).toEqual([]) +}) diff --git a/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 00000000..3a50e49c --- /dev/null +++ b/packages/vite-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { getCss, resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { build, type Plugin } from 'vite' + +import { DevupUI } from '../plugin' + +const landing = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const compiler: { + compile( + input: { readonly value: string; readonly path: string }, + options: { readonly format: 'mdx' }, + ): Promise<{ readonly value: string }> +} = await import( + pathToFileURL( + createRequire(landing.resolve('@mdx-js/loader')).resolve('@mdx-js/mdx'), + ).href +) +const devup = landing.resolve('@devup-ui/react') + +async function bundle( + source: string, + options: { + extension?: string + selected?: boolean + compilerFirst?: boolean + } = {}, +) { + resetBuildState() + const root = mkdtempSync(join(tmpdir(), 'devup-mdx-vite-')) + const extension = options.extension ?? '.mdown' + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'ordinary.js'), + 'export function Box(){return "div"}', + ) + const mdx: Plugin = { + name: 'project-mdx', + async transform(code, id) { + if (id === filename.replaceAll('\\', '/')) + return { + code: ( + await compiler.compile({ value: code, path: id }, { format: 'mdx' }) + ).value, + map: null, + } + }, + } + const plugins = DevupUI({ + singleCss: true, + mdxExtensions: options.selected ? ['.mdx', '.mdown'] : undefined, + }) + try { + await build({ + root, + configFile: false, + logLevel: 'silent', + resolve: { alias: { '@devup-ui/react': devup } }, + plugins: options.compilerFirst ? [mdx, plugins] : [plugins, mdx], + build: { + write: false, + minify: false, + rolldownOptions: { + input: filename, + external: ['react', 'react/jsx-runtime'], + }, + }, + }) + return getCss(null, false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each([false, true])( + 'extracts custom MDX with equal CSS when compilerFirst=%s', + async (compilerFirst) => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const expected = await bundle(source, { + extension: '.mdx', + selected: true, + compilerFirst, + }) + expect(expected).toContain('red') + expect(await bundle(source, { selected: true, compilerFirst })).toBe( + expected, + ) + }, +) + +it.each([ + ['used', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'member', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'forwarded Widget', + 'import {Widget} from "./barrel.js"\n\n', + true, + ], + ['ordinary Box', 'import {Box} from "./ordinary.js"\n\n', false], + [ + 'member executable', + 'import {css} from "@devup-ui/react"\n\nexport const n=css({color:"red"}).length', + true, + ], + [ + 'parameter executable', + 'import {css} from "@devup-ui/react"\n\nexport function f({x=css({color:"red"})}={}){return x}', + true, + ], + [ + 'pattern executable', + 'import {css} from "@devup-ui/react"\n\nexport const {x=css({color:"red"})}={}', + true, + ], + [ + 'computed destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {["css"]:style}=DU', + true, + ], + [ + 'require member', + 'export const style=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const style=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:style}=require("@devup-ui/react")\n\nexport const n=style({color:"red"})', + true, + ], + [ + 'require runtime', + 'export const theme=require("@devup-ui/react").getTheme', + false, + ], + [ + 'require opaque', + 'export const namespace=require("@devup-ui/react")', + false, + ], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const namespace=import("@devup-ui/react")', false], +])( + 'guards only definitely used compiled exports when %s', + async (_name, source, errors) => { + const action = bundle(source) + if (errors) await expect(action).rejects.toThrow('mdxExtensions') + else expect(await action).not.toContain('{') + }, +) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2d5959e8..c1cf7b1e 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -558,7 +558,7 @@ describe('devupUIVitePlugin', () => { expect(collectSpy).toHaveBeenCalledWith({ roots: [resolve('/p', 'src'), resolve('/p', 'app')], - includeMdx: true, + includeMdx: ['.mdx'], include: ['@acme/ui'], cwd: '/p', needles: expect.arrayContaining([ @@ -693,12 +693,12 @@ describe('devupUIVitePlugin', () => { expect(listSourceFilesSpy).toHaveBeenCalledWith( resolve('/p', 'src'), undefined, - { includeMdx: true }, + { includeMdx: ['.mdx'] }, ) expect(listSourceFilesSpy).toHaveBeenCalledWith( resolve('/p', 'app'), undefined, - { includeMdx: true }, + { includeMdx: ['.mdx'] }, ) expect(seedFileMapSpy).toHaveBeenCalledWith([ '/p/app/page.tsx', diff --git a/packages/vite-plugin/src/compiled-guard.ts b/packages/vite-plugin/src/compiled-guard.ts new file mode 100644 index 00000000..d2cdfc4c --- /dev/null +++ b/packages/vite-plugin/src/compiled-guard.ts @@ -0,0 +1,140 @@ +import { + compiledFacts, + createCompileTimeClassifier, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, + type WasmImportAliases, +} from '@devup-ui/plugin-utils' +import type { Plugin } from 'vite' + +export function createCompiledGuard(options: { + readonly package: string + readonly mdxExtensions: readonly string[] + readonly importAliases: WasmImportAliases + readonly extractCss: boolean +}): Plugin { + type Module = { + readonly facts: GuardFacts + readonly code: string + readonly targets: ReadonlyMap + } + const environments = new Map>() + const classify = createCompileTimeClassifier(options.package) + const targetKey = (ref: { + readonly request: string + readonly kind?: 'require-call' + }) => JSON.stringify([ref.request, ref.kind]) + function environmentModules(environment: object) { + let modules = environments.get(environment) + if (!modules) { + modules = new Map() + environments.set(environment, modules) + } + return modules + } + const plugin: Plugin = { + name: 'devup-ui:compiled-source-guard', + sharedDuringBuild: true, + apply: 'build', + buildStart() { + environmentModules(this.environment ?? plugin).clear() + }, + async moduleParsed(info) { + if (!options.extractCss || !info.code) return + const facts = compiledFacts(this.parse(info.code)) + const requests = new Map< + string, + { readonly request: string; readonly kind?: 'require-call' } + >( + [ + ...facts.references, + ...[...facts.exports.values()].flatMap((ref) => (ref ? [ref] : [])), + ...facts.stars.map((request) => ({ request })), + ].map((ref) => [targetKey(ref), ref]), + ) + const targets = new Map() + for (const [key, ref] of requests) { + const target = await this.resolve(ref.request, info.id, { + kind: ref.kind ?? 'import-statement', + }) + if (target && info.importedIds.includes(target.id)) + targets.set(key, target.id) + } + environmentModules(this.environment ?? plugin).set(info.id, { + facts, + code: info.code, + targets, + }) + }, + buildEnd(error) { + if (error || !options.extractCss) return + const modules = environmentModules(this.environment ?? plugin) + function origins( + id: string, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + const key = JSON.stringify([id, ids]) + if (seen.has(key)) return new Map() + if (classify(id, ids)) return new Map([[key, true]]) + const module = modules.get(id) + if (!module || !ids.length) return new Map() + const next = new Set([...seen, key]) + const direct = module.facts.exports.get(ids[0]) + if (module.facts.exports.has(ids[0])) { + const target = direct && module.targets.get(targetKey(direct)) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + return new Map( + (ids[0] === 'default' ? [] : module.facts.stars).flatMap( + (request) => { + const target = module.targets.get(targetKey({ request })) + return target ? [...origins(target, ids, next)] : [] + }, + ), + ) + } + for (const [id, module] of modules) { + if ( + id.startsWith('\0') || + isSelectedSource(id.split('?')[0], options.mdxExtensions) + ) + continue + for (const ref of module.facts.references) { + const target = module.targets.get(targetKey(ref)) + const targets = target + ? origins(target, ref.ids) + : new Map() + if ( + !isCompileTimeAlias( + { ...ref, line: 1, column: 1 }, + options.importAliases, + ) && + (targets.size !== 1 || ![...targets.values()][0]) + ) + continue + const prefix = module.code.slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + this.error({ + id, + pos: ref.pos, + message: new UntransformedSourceError( + id, + reference, + isCompileTimeAlias(reference, options.importAliases), + ).message, + }) + } + } + }, + } + return plugin +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index bece9d3c..adb72582 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -16,11 +16,12 @@ import { type CustomShorthands, extractedNeedles, getFileNumByFilename, - GRAPH_SOURCE_FILE_RE, type ImportAliases, + isMdxSource, + isSelectedSource, loadDevupConfig, - MDX_FILE_RE, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, remapMdxError, resolveProjectPaths, @@ -53,6 +54,7 @@ import type { } from 'vite' import { createAggregateCssPreparation } from './aggregate-css' +import { createCompiledGuard } from './compiled-guard' /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... @@ -234,6 +236,7 @@ export interface DevupUIPluginOptions { prefix?: string shorthands?: CustomShorthands sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold (min routes sharing an atom for * it to hoist into the shared devup-ui.css; clamped to >= 2; omit to disable). @@ -286,7 +289,9 @@ export function DevupUI({ sourceDirs: configuredSourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions: configuredMdxExtensions, }: Partial = {}) { + const mdxExtensions = normalizeMdxExtensions(configuredMdxExtensions) // A build starts from its own options: whatever an earlier build in this // process left in the engine (prefix, hoisting, routes, buckets, numbers, // styles) is gone unless another build is still running. @@ -326,6 +331,7 @@ export function DevupUI({ if (!resolver) { resolver = createModuleResolver({ cwd: projectRoot, + includeMdx: mdxExtensions, conditions, toId: (path) => path.replaceAll('\\', '/'), }) @@ -386,9 +392,12 @@ export function DevupUI({ true, false, importAliases, + ...(isMdxSource(fileName, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) } catch (error) { - if (MDX_FILE_RE.test(fileName)) + if (isMdxSource(fileName, mdxExtensions)) throw remapMdxError(error, fileName, this.getCombinedSourcemap()) throw error } @@ -461,7 +470,7 @@ export function DevupUI({ : [] const entries = rawEntries .filter((entry): entry is string => typeof entry === 'string') - .filter((entry) => GRAPH_SOURCE_FILE_RE.test(entry)) + .filter((entry) => isSelectedSource(entry, mdxExtensions)) .map((entry) => resolve(projectRoot, entry)) const roots = [ ...new Set([ @@ -499,7 +508,7 @@ export function DevupUI({ const srcDir = roots const tsconfigPath = resolve(root, 'tsconfig.json') const graph = buildStaticImportGraph(roots, tsconfigPath, { - includeMdx: true, + includeMdx: mdxExtensions, cwd: root, include, conditions, @@ -547,7 +556,7 @@ export function DevupUI({ { seedFileMap }, collectNumberedFiles({ roots, - includeMdx: true, + includeMdx: mdxExtensions, include, cwd: projectRoot, needles: extractedNeedles(libPackage, importAliases), @@ -795,7 +804,7 @@ export function DevupUI({ enforce: 'post', sharedDuringBuild: true, async transform(code, id) { - if (!MDX_FILE_RE.test(id.split('?')[0])) return + if (!isMdxSource(id, mdxExtensions)) return return sourceTransform.transform.call(this, code, id) }, } satisfies Plugin @@ -814,11 +823,18 @@ export function DevupUI({ }, }, } satisfies Plugin + const compiledGuard = createCompiledGuard({ + package: libPackage, + mdxExtensions, + importAliases, + extractCss, + }) const plugins: [ typeof plugin, typeof restorePlugin, typeof mdxPlugin, typeof aggregateGuard, - ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard] + typeof compiledGuard, + ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard, compiledGuard] return plugins } diff --git a/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts new file mode 100644 index 00000000..898869ac --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-build-fixture.ts @@ -0,0 +1,147 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export const mdxLanding = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) + +export const guardCases = [ + ['used Box', 'import {Box} from "@devup-ui/react"\n\n', true], + ['unused Box', 'import {Box} from "@devup-ui/react"\n\n# Plain', false], + [ + 'runtime helper', + 'import {getTheme} from "@devup-ui/react"\n\nexport const theme = getTheme\n\n# Plain', + false, + ], + [ + 'namespace css', + 'import * as DU from "@devup-ui/react"\n\nexport const style = DU.css({color:"red"})\n\n# Plain', + true, + ], + [ + 'namespace destructure', + 'import * as DU from "@devup-ui/react"\n\nexport const {css} = DU\n\n# Plain', + true, + ], + [ + 'opaque namespace', + 'import * as DU from "@devup-ui/react"\n\nexport const namespace = DU\n\n# Plain', + false, + ], + [ + 'active alias', + 'import styled from "@emotion/styled"\n\nexport const component = styled("div")\n\n# Plain', + true, + ], + [ + 'runtime alias', + 'import {ThemeProvider} from "styled-components"\n\n', + false, + ], + [ + 'unmapped alias', + 'import {CacheProvider} from "@emotion/react"\n\n', + false, + ], + [ + 'ordinary alias Box', + 'import {Box} from "ordinary-provider"\n\n', + false, + ], + ['actual alias Box', 'import {Box} from "opaque-provider"\n\n', true], + [ + 'shadowed namespace', + 'import * as DU from "@devup-ui/react"\n\nexport function use(DU){return DU.css()}\n\n# Plain', + false, + ], +] as const + +export const cjsGuardCases = [ + [ + 'require member', + 'export const value=require("@devup-ui/react").css({color:"red"})', + true, + ], + [ + 'require namespace', + 'export const DU=require("@devup-ui/react")\n\nexport const value=DU.css({color:"red"})', + true, + ], + [ + 'require destructure', + 'export const {css:value}=require("@devup-ui/react")', + true, + ], + [ + 'require runtime', + 'export const value=require("@devup-ui/react").getTheme', + false, + ], + ['require opaque', 'export const value=require("@devup-ui/react")', false], + [ + 'require shadowed', + 'export function f(require){return require("@devup-ui/react").css()}', + false, + ], + ['dynamic opaque', 'export const value=import("@devup-ui/react")', false], +] as const + +export function mdxBuildFixture(source: string, extension = '.mdown') { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-mdx-build-')), + ) + const filename = join(root, `page${extension}`) + writeFileSync(filename, source) + writeFileSync( + join(root, 'ordinary.js'), + 'export const Box = "div"; export const CacheProvider = "div"; export const ThemeProvider = "div"; export default () => "div"', + ) + writeFileSync(join(root, 'barrel.js'), 'export * from "./forward.js"') + writeFileSync( + join(root, 'forward.js'), + 'export {Box as Widget} from "@devup-ui/react"', + ) + mkdirSync(join(root, 'node_modules/skipped'), { recursive: true }) + writeFileSync( + join(root, 'node_modules/skipped/package.json'), + '{"name":"skipped","main":"index.js"}', + ) + writeFileSync( + join(root, 'node_modules/skipped/index.js'), + 'import {Box} from "@devup-ui/react"; export const component = Box', + ) + const devup = mdxLanding.resolve('@devup-ui/react') + const alias = { + '@devup-ui/react': devup, + 'opaque-provider': devup, + 'ordinary-provider': join(root, 'ordinary.js'), + '@emotion/styled': join(root, 'ordinary.js'), + '@emotion/react': join(root, 'ordinary.js'), + 'styled-components': join(root, 'ordinary.js'), + } + const rule = { + test: /\.(?:mdown|mdx)$/i, + type: 'javascript/auto', + use: [ + { + loader: mdxLanding.resolve('@mdx-js/loader'), + options: { format: 'mdx', development: false }, + }, + ], + } + return { + root, + filename, + alias, + rule, + cleanup: () => rmSync(root, { recursive: true, force: true }), + } +} diff --git a/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 00000000..425e6c98 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mdx-extensions.test.ts @@ -0,0 +1,83 @@ +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' +import { + cjsGuardCases, + guardCases, + mdxBuildFixture, + mdxLanding, +} from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +async function bundle(source: string, selected = false, extension = '.mdown') { + wasm.resetBuildState() + const fixture = mdxBuildFixture(source, extension) + const compiler = bundled.webpack({ + context: fixture.root, + mode: 'development', + entry: fixture.filename, + output: { path: `${fixture.root}/out` }, + resolve: { alias: fixture.alias }, + externals: { + react: 'commonjs react', + 'react/jsx-runtime': 'commonjs react/jsx-runtime', + }, + experiments: { css: true }, + optimization: { minimize: false }, + module: { rules: [fixture.rule, { test: /\.css$/, type: 'css' }] }, + plugins: [ + new DevupUIWebpackPlugin({ + singleCss: true, + mdxExtensions: selected ? ['.mdx', '.mdown'] : undefined, + }), + ], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + return { + errors: stats.toJson({ all: false, errors: true }).errors ?? [], + css: wasm.getCss(null, false), + } + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + fixture.cleanup() + } +} + +it('extracts custom MDX with the same isolated CSS as .mdx', async () => { + const source = 'import {Box} from "@devup-ui/react"\n\n' + const standard = await bundle(source, true, '.mdx') + const custom = await bundle(source, true) + expect(custom.errors).toEqual([]) + expect(custom.css).toContain('red') + expect(custom.css).toBe(standard.css) +}) + +it.each([...guardCases, ...cjsGuardCases])( + 'uses final dependencies to guard %s', + async (_name, source, errors) => { + const result = await bundle(source) + expect(result.errors.length > 0).toBe(errors) + if (errors) expect(result.errors[0]?.message).toContain('mdxExtensions') + }, +) + +it('follows an actual forwarded Widget export to Box', async () => { + const result = await bundle( + 'import {Widget} from "./barrel.js"\n\n', + ) + expect(result.errors[0]?.message).toContain('mdxExtensions') +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index e24d0d87..2ac6b4c9 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -792,10 +792,12 @@ describe('devupUIWebpackPlugin', () => { return { compiler, plugin, compilation, taps, run, start } } - it('taps nothing in watch mode', () => { + it('does not request another CSS pass in watch mode', () => { const { compiler } = compile(true) expect(compiler.hooks.run.tap).not.toHaveBeenCalled() - expect(compiler.hooks.thisCompilation.tap).not.toHaveBeenCalled() + expect( + compiler.hooks.thisCompilation.tap.mock.calls.map(([name]) => name), + ).not.toContain('DevupUIWebpackPlugin') }) it('writes the shared base to disk when it does not hold it yet', () => { diff --git a/packages/webpack-plugin/src/compiled-guard.ts b/packages/webpack-plugin/src/compiled-guard.ts new file mode 100644 index 00000000..4e1ca767 --- /dev/null +++ b/packages/webpack-plugin/src/compiled-guard.ts @@ -0,0 +1,189 @@ +import { + compiledFacts, + createCompileTimeClassifier, + createDependencyGuard, + type DependencyGuardOptions, + type GuardFacts, + isCompileTimeAlias, + isSelectedSource, + UntransformedSourceError, +} from '@devup-ui/plugin-utils' +import type { Compiler, Dependency, Module } from 'webpack' + +interface ScriptParser { + readonly state: { readonly module: Module } + readonly hooks: { + readonly program: { + tap( + options: { readonly name: string; readonly stage: number }, + callback: (ast: unknown) => void, + ): void + } + } +} + +function isScriptParser(value: object): value is ScriptParser { + const hooks = 'hooks' in value ? value.hooks : undefined + const program = + hooks && typeof hooks === 'object' && 'program' in hooks + ? hooks.program + : undefined + return ( + !!program && + typeof program === 'object' && + 'tap' in program && + typeof program.tap === 'function' && + 'state' in value + ) +} + +export function registerCompiledGuard( + compiler: Compiler, + options: DependencyGuardOptions, +) { + const check = createDependencyGuard(options) + const classify = createCompileTimeClassifier(options.package) + compiler.hooks.thisCompilation.tap( + 'DevupUICompiledSourceGuard', + (compilation, { normalModuleFactory }) => { + const facts = new Map() + for (const type of [ + 'javascript/auto', + 'javascript/esm', + 'javascript/dynamic', + ]) + normalModuleFactory.hooks.parser + .for(type) + .tap('DevupUICompiledSourceGuard', (parser) => { + if (!isScriptParser(parser)) return + parser.hooks.program.tap( + { name: 'DevupUICompiledSourceGuard', stage: -10000 }, + (ast) => { + facts.set(parser.state.module, compiledFacts(ast)) + }, + ) + }) + compilation.hooks.finishModules.tap( + 'DevupUICompiledSourceGuard', + (modules) => { + const dependencies = new Map() + for (const module of modules) + for (const dependency of module.dependencies) + dependencies.set(dependency, dependency) + const errors = check(modules, { + graph: compilation.moduleGraph, + target(dependency) { + const actual = dependencies.get(dependency) + const module = actual && compilation.moduleGraph.getModule(actual) + return module && + 'resource' in module && + typeof module.resource === 'string' + ? module.resource + : undefined + }, + forwarded(dependency, ids) { + const actual = dependencies.get(dependency) + let module = actual && compilation.moduleGraph.getModule(actual) + let names = ids + const seen = new Set() + while (module && names.length && !seen.has(module)) { + seen.add(module) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, names) + ) + return true + const target = compilation.moduleGraph + .getExportsInfo(module) + .getExportInfo(names[0]) + .getTarget(compilation.moduleGraph) + module = target?.module + names = target?.export + ? [...target.export, ...names.slice(1)] + : [] + } + return false + }, + }) + function resolved(module: Module, request: string) { + const dependency = module.dependencies.find( + (item) => 'request' in item && item.request === request, + ) + return dependency + ? compilation.moduleGraph.getModule(dependency) + : undefined + } + function origins( + module: Module, + ids: readonly string[], + seen: ReadonlySet = new Set(), + ): Map { + if (seen.has(module)) return new Map() + const key = JSON.stringify([module.identifier(), ids]) + if ( + 'resource' in module && + typeof module.resource === 'string' && + classify(module.resource, ids) + ) + return new Map([[key, true]]) + const symbols = facts.get(module) + if (!symbols || !ids.length) return new Map() + const next = new Set([...seen, module]) + const direct = symbols.exports.get(ids[0]) + if (symbols.exports.has(ids[0])) { + const target = direct && resolved(module, direct.request) + return target && direct + ? origins(target, [...direct.ids, ...ids.slice(1)], next) + : new Map([[key, false]]) + } + if (ids[0] === 'default') return new Map() + return new Map( + symbols.stars.flatMap((request) => { + const target = resolved(module, request) + return target ? [...origins(target, ids, next)] : [] + }), + ) + } + for (const module of modules) { + if ( + !('resource' in module) || + typeof module.resource !== 'string' || + isSelectedSource(module.resource, options.mdxExtensions) || + errors.some((error) => error.filename === module.resource) + ) + continue + for (const ref of facts.get(module)?.references ?? []) { + const target = resolved(module, ref.request) + const targets = target + ? origins(target, ref.ids) + : new Map() + const prefix = ( + module.originalSource()?.source().toString() ?? '' + ).slice(0, ref.pos) + const reference = { + ...ref, + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n'), + } + if ( + isCompileTimeAlias(reference, options.importAliases) || + (targets.size === 1 && [...targets.values()][0]) + ) { + errors.push( + new UntransformedSourceError( + module.resource, + reference, + isCompileTimeAlias(reference, options.importAliases), + ), + ) + break + } + } + } + compilation.errors.push(...errors) + }, + ) + }, + ) +} diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 1c915743..b0c67677 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { createModuleResolver, createStateWriter, + isMdxSource, remapMdxError, } from '@devup-ui/plugin-utils' import { @@ -27,6 +28,7 @@ export interface DevupUILoaderOptions { importAliases?: Record rootDir?: string conditions?: readonly string[] + mdxExtensions?: readonly string[] } function toLoaderError(error: unknown): Error { @@ -52,12 +54,14 @@ const moduleResolvers = new Map< function setCwdModuleResolver( rootDir: string, conditions: readonly string[], + mdxExtensions: readonly string[], ): void { - const key = JSON.stringify([rootDir, conditions]) + const key = JSON.stringify([rootDir, conditions, mdxExtensions]) let moduleResolver = moduleResolvers.get(key) if (!moduleResolver) { moduleResolver = createModuleResolver({ cwd: rootDir, + includeMdx: mdxExtensions, conditions, toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), }) @@ -79,6 +83,7 @@ const devupUILoader: RawLoaderDefinitionFunction = importAliases = {}, rootDir = process.cwd(), conditions = ['import', 'module', 'node'], + mdxExtensions = ['.mdx'], } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -99,7 +104,7 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver(rootDir, conditions) + setCwdModuleResolver(rootDir, conditions, mdxExtensions) const { code, css = '', @@ -116,6 +121,9 @@ const devupUILoader: RawLoaderDefinitionFunction = false, true, importAliases, + ...(isMdxSource(id, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) for (const dependency of dependencies) { this.addDependency(resolve(rootDir, dependency)) @@ -155,7 +163,7 @@ const devupUILoader: RawLoaderDefinitionFunction = ) } catch (error) { callback( - /\.mdx$/i.test(id) + isMdxSource(id, mdxExtensions) ? remapMdxError( error, relative(rootDir, id).replaceAll('\\', '/'), diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index fd321d63..d6717a90 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, extname, join, relative, resolve } from 'node:path' import { beginBuild, @@ -18,8 +18,11 @@ import { extractedNeedles, getFileNumByFilename, type ImportAliases, + isSelectedSource, loadDevupConfigSync, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, resolveProjectPaths, resolveSourceDirs, @@ -49,6 +52,7 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { registerCompiledGuard } from './compiled-guard' import { servedCss } from './served-css' export interface DevupUIWebpackPluginOptions { @@ -63,6 +67,7 @@ export interface DevupUIWebpackPluginOptions { prefix?: string shorthands?: CustomShorthands sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold. * @@ -95,6 +100,7 @@ export class DevupUIWebpackPlugin { fileMapFile: string private importAliases: WasmImportAliases private excludeModules: RegExp + private readonly mdxExtensions: readonly string[] private seedWarningEmitted = false private pathOptions: { readonly devupFile: string @@ -116,7 +122,9 @@ export class DevupUIWebpackPlugin { sourceDirs, atomHoist, importAliases: userImportAliases, + mdxExtensions, }: Partial = {}) { + this.mdxExtensions = normalizeMdxExtensions(mdxExtensions) registerShorthands(shorthands ?? {}) this.importAliases = mergeImportAliases(userImportAliases) this.excludeModules = createNodeModulesExcludeRegex(include) @@ -250,7 +258,10 @@ export class DevupUIWebpackPlugin { !/^[a-z]:[\\/]/i.test(resource)) ) return [] - return [resolve(cwd, resource)] + return isSelectedSource(resource, this.mdxExtensions) || + !extname(resource) + ? [resolve(cwd, resource)] + : [] }), ) const roots = [ @@ -264,6 +275,7 @@ export class DevupUIWebpackPlugin { setModuleResolver( createModuleResolver({ cwd, + includeMdx: this.mdxExtensions, conditions, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), @@ -333,7 +345,7 @@ export class DevupUIWebpackPlugin { const srcDir = roots const tsconfigPath = resolve(cwd, 'tsconfig.json') graph = buildStaticImportGraph(roots, tsconfigPath, { - includeMdx: true, + includeMdx: this.mdxExtensions, cwd, include: this.options.include, conditions, @@ -382,7 +394,7 @@ export class DevupUIWebpackPlugin { { seedFileMap }, collectNumberedFiles({ roots, - includeMdx: true, + includeMdx: this.mdxExtensions, include: this.options.include, cwd, needles: extractedNeedles(this.options.package, this.importAliases), @@ -529,6 +541,7 @@ export class DevupUIWebpackPlugin { importAliases: this.importAliases, rootDir: cwd, conditions, + mdxExtensions: this.mdxExtensions, }, } compiler.options.module.rules.push( @@ -538,7 +551,12 @@ export class DevupUIWebpackPlugin { enforce: 'pre', use: [sourceLoader], }, - { test: /\.mdx$/i, exclude, enforce: 'post', use: [sourceLoader] }, + { + test: mdxSourceFilter(this.mdxExtensions), + exclude, + enforce: 'post', + use: [sourceLoader], + }, { test: this.options.cssDir, enforce: 'pre', @@ -554,5 +572,10 @@ export class DevupUIWebpackPlugin { ], }, ) + registerCompiledGuard(compiler, { + package: this.options.package, + mdxExtensions: this.mdxExtensions, + importAliases: this.importAliases, + }) } } From 28dc9a11130a30ec0d44a56e98d621c9d2cd8ea9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 09:14:42 +0900 Subject: [PATCH 42/50] fix(wasm): preserve explicit ignored alias module semantics Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../ignored-alias-regressions.mjs | 235 ++++++++++++++++ .../ignored-semantics-regressions.mjs | 97 +++++++ bindings/devup-ui-wasm/src/lib.rs | 37 ++- .../devup-ui-wasm/src/source_type_tests.rs | 8 +- libs/extractor/src/ignored_loader_tests.rs | 68 +++++ libs/extractor/src/ignored_module_tests.rs | 188 +++++++++++++ libs/extractor/src/imported_constants.rs | 107 ++++++-- .../src/imported_constants/exports.rs | 34 +++ libs/extractor/src/lib.rs | 28 +- libs/extractor/src/module_loader.rs | 91 +++++- libs/extractor/src/source_type_tests.rs | 16 +- libs/extractor/src/vanilla_extract.rs | 10 +- packages/bun-plugin/src/mdx-ownership.ts | 2 +- .../src/__tests__/ignored-alias.test.ts | 258 ++++++++++++++++++ packages/plugin-utils/src/import-graph.ts | 16 +- packages/plugin-utils/src/index.ts | 2 + packages/plugin-utils/src/module-alias.ts | 22 +- .../plugin-utils/src/prepared-resolver.ts | 3 +- packages/plugin-utils/src/types.ts | 6 +- 19 files changed, 1148 insertions(+), 80 deletions(-) create mode 100644 bindings/devup-ui-wasm/ignored-alias-regressions.mjs create mode 100644 bindings/devup-ui-wasm/ignored-semantics-regressions.mjs create mode 100644 libs/extractor/src/ignored_loader_tests.rs create mode 100644 libs/extractor/src/ignored_module_tests.rs create mode 100644 libs/extractor/src/imported_constants/exports.rs create mode 100644 packages/plugin-utils/src/__tests__/ignored-alias.test.ts diff --git a/bindings/devup-ui-wasm/ignored-alias-regressions.mjs b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs new file mode 100644 index 00000000..76b50fa4 --- /dev/null +++ b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createModuleResolver } from '../../packages/plugin-utils/dist/index.mjs' +import { verifyIgnoredSemantics } from './ignored-semantics-regressions.mjs' + +const wasm = createRequire(import.meta.url)('./pkg/index.js') +const filename = '/src/ignored.tsx' +const root = mkdtempSync(join(tmpdir(), 'devup-ignored-')) +const file = (name, code) => { + const path = join(root, name) + writeFileSync(path, code) + return path +} +const barrel = file( + 'barrel.js', + "export {default as def, missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';", +) +const cjs = file( + 'cjs.js', + "const ns = require('empty'); const {missing} = require('empty'); exports.value = missing; exports.other = ns.absent;", +) +const whole = file('whole.js', "module.exports = require('empty');") +const real = file('real.js', 'module.exports = {};') +const shared = createModuleResolver({ + cwd: root, + alias: { empty: false, barrel, cjs, whole, real }, +}) +try { + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + verifyIgnoredSemantics(wasm, method, shared) + wasm.resetBuildState() + wasm.setModuleResolver(() => ({ ignored: true })) + const output = wasm[method]( + filename, + "import { Box } from '@devup-ui/react'; import { missing } from 'empty'; export const view = ", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.deepEqual(output.dependencies, []) + assert.doesNotMatch(output.code, /--/) + assert.doesNotMatch(wasm.getCss(undefined, false), /color:/) + output.free() + for (const binding of [ + "import * as ns from 'empty'; const value = ns.missing;", + "import def from 'empty'; const value = def.missing;", + "import {value} from 'barrel';", + "import {ns} from 'barrel'; const value = ns.missing;", + "import {value} from 'cjs';", + "import {other as value} from 'cjs';", + "import def from 'whole'; const value = def.missing;", + ]) { + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const resolved = wasm[method]( + filename, + `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.doesNotMatch(resolved.code, /--/) + assert.doesNotMatch(wasm.getCss(undefined, false), /color:/) + if (method === 'codeExtractWithoutSourceMap') + assert.equal(resolved.map, undefined) + else assert.equal(typeof resolved.map, 'string') + resolved.free() + } + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const evaluated = wasm[method]( + '/src/ignored.css.ts', + "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; import {ns as indirect, def as indirectDefault, value} from 'barrel'; function color() { if (missing !== undefined || value !== undefined || Object.keys(ns).length || Object.keys(def).length || Object.keys(indirect).length || Object.keys(indirectDefault).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.match(wasm.getCss(undefined, false), /color:red/) + assert.deepEqual(evaluated.dependencies, [barrel]) + evaluated.free() + + wasm.resetBuildState() + const ignored = { + ignored: true, + get path() { + throw new Error('must not read path') + }, + get code() { + throw new Error('must not read code') + }, + get sourceType() { + throw new Error('must not read sourceType') + }, + } + wasm.setModuleResolver(() => ignored) + const bypassed = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + assert.deepEqual(bypassed.dependencies, []) + bypassed.free() + + for (const result of [ + true, + false, + 1, + 'ignored', + { ignored: false }, + { ignored: null }, + { ignored: 1 }, + { ignored: 'true' }, + { + get ignored() { + throw new Error('ignored getter first') + }, + }, + new Proxy( + {}, + { + get() { + throw new Error('ignored proxy first') + }, + }, + ), + ]) { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + const initial = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + initial.free() + const sheet = wasm.exportSheet() + let calls = 0 + wasm.setModuleResolver(() => { + calls += 1 + return result + }) + assert.throws( + () => + wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; import {other} from 'later'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + /module resolver cannot use `empty` at build time/, + ) + assert.equal(calls, 1) + assert.equal(wasm.exportSheet(), sheet) + } + + wasm.resetBuildState() + wasm.setModuleResolver(shared) + const previous = wasm[method]( + filename, + "import {Box} from '@devup-ui/react'; export const view = ;", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + previous.free() + const sheet = wasm.exportSheet() + assert.throws( + () => + wasm[method]( + filename, + "import {css} from '@devup-ui/react'; import def from 'empty'; def.color = 'red'; export const cls = css(def);", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ), + /changed/, + ) + assert.equal(wasm.exportSheet(), sheet) + } + mkdirSync(join(root, 'src')) + const request = 'private-next-instrumentation-client-user' + const next = createModuleResolver({ + cwd: root, + alias: { + [request]: [ + join(root, 'src/instrumentation-client'), + join(root, 'instrumentation-client'), + 'private-next-empty-module', + ], + 'private-next-empty-module': false, + }, + }) + assert.deepEqual(next(request, join(root, 'entry.js')), { ignored: true }) + const instrumentation = file( + 'instrumentation-client.js', + "export const value = 'blue';", + ) + assert.equal(next(request, join(root, 'entry.js')).path, instrumentation) +} finally { + wasm.setModuleResolver(undefined) + rmSync(root, { recursive: true, force: true }) +} +console.info('Ignored alias actual WASM regressions passed.') diff --git a/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs new file mode 100644 index 00000000..62e89c7b --- /dev/null +++ b/bindings/devup-ui-wasm/ignored-semantics-regressions.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' + +export function verifyIgnoredSemantics(wasm, method, resolver) { + const extract = (filename, code) => { + wasm.resetBuildState() + wasm.setModuleResolver(resolver) + const output = wasm[method]( + filename, + code, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + const result = { + code: output.code, + css: wasm.getCss(undefined, false), + dependencies: output.dependencies, + } + output.free() + return result + } + for (const key of [ + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', + ]) { + const named = extract( + '/src/named.css.ts', + `import {style} from '@devup-ui/react'; import {${key} as value} from 'empty'; export const cls = style({color: typeof value === 'undefined' ? 'red' : 'blue'});`, + ) + assert.match(named.css, /color:red/, `${method}: named ${key}`) + assert.doesNotMatch(named.css, /color:blue/) + assert.deepEqual(named.dependencies, []) + } + for (const binding of [ + "import {missing as value} from 'empty';", + "import def from 'empty'; const value = def.missing;", + "import * as ns from 'empty'; const value = ns.missing;", + "import {value} from 'barrel';", + "import def from 'whole'; const value = def.missing;", + 'const record = {}; const value = record.missing;', + ]) { + const shadowed = extract( + '/src/shadowed.tsx', + `import {Box} from '@devup-ui/react'; ${binding} export const View = (undefined, read) => ;`, + ) + const argument = /read\(([^)]+)\)/.exec(shadowed.code)?.[1] + assert(argument, shadowed.code) + const selected = new Function( + 'undefined', + 'read', + 'value', + `return read(${argument});`, + )('blue', (value) => value ?? 'red', undefined) + assert.equal(selected, 'red', `${method}: ${binding}`) + } + for (const binding of [ + "import def from 'real';", + 'const def = {};', + "import def from 'empty';", + "import * as def from 'empty';", + "import def from 'whole';", + ]) { + for (const [key, expected] of [ + ['constructor', 'Object'], + ['toString', 'Object.prototype.toString'], + ]) { + const real = extract( + '/src/real.tsx', + `import {Box} from '@devup-ui/react'; ${binding} export const view = ;`, + ) + const condition = /className=\{([^?]+)\?/.exec(real.code)?.[1] + assert(condition, real.code) + assert.equal( + new Function('def', `return ${condition};`)({}), + true, + `${method}: ${binding} ${key}`, + ) + } + } + for (const binding of [ + "import def from 'empty';", + "import * as def from 'empty';", + ]) { + const mutated = extract( + '/src/mutated.tsx', + `import {Box} from '@devup-ui/react'; ${binding} def.color = 'blue'; export const view = ;`, + ) + assert.match(mutated.code, /def\.color/) + assert.match(mutated.css, /color:var\(/) + assert.deepEqual(mutated.dependencies, []) + } +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 94e0d216..4c819f74 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -5,7 +5,9 @@ use css::file_map::{ #[cfg(test)] use extractor::extract; use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, has_devup_ui}; +use extractor::{ + ExtractOption, ImportAlias, ModuleResolution, ModuleResolver, ResolvedModule, has_devup_ui, +}; use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::cell::RefCell; @@ -573,13 +575,30 @@ fn call_module_resolver( resolver: &js_sys::Function, specifier: &str, importer: &str, -) -> Result, String> { +) -> Result, String> { let module = resolver .call2(&JsValue::NULL, &specifier.into(), &importer.into()) .map_err(|error| resolver_cause(&error))?; if module.is_null() || module.is_undefined() { return Ok(None); } + if !module.is_object() { + return Err("resolver result must be an object".to_string()); + } + let ignored = js_sys::Reflect::get(&module, &"ignored".into()).map_err(|error| { + format!( + "reading resolver field `ignored`: {}", + resolver_cause(&error) + ) + })?; + if !ignored.is_undefined() { + return match ignored.as_bool() { + Some(true) => Ok(Some(ModuleResolution::Ignored)), + Some(false) | None => { + Err("resolver field `ignored` must be true or undefined".to_string()) + } + }; + } let field = |name: &str| { js_sys::Reflect::get(&module, &name.into()) .map_err(|error| { @@ -591,7 +610,7 @@ fn call_module_resolver( .as_string() .ok_or_else(|| format!("resolver field `{name}` must be a string")) }; - Ok(Some(ResolvedModule { + Ok(Some(ModuleResolution::Resolved(ResolvedModule { path: field("path")?, code: field("code")?, source_type: source_type_from_js( @@ -602,7 +621,7 @@ fn call_module_resolver( ) })?, )?, - })) + }))) } /// Extract with the resolver set by `setModuleResolver`, if any @@ -1392,10 +1411,12 @@ mod tests { *sheet = StyleSheet::default(); } let resolver = |specifier: &str, _: &str| { - (specifier == "./tokens").then(|| ResolvedModule { - source_type: None, - path: "/src/tokens.ts".to_string(), - code: "export const PRIMARY = 'red'".to_string(), + (specifier == "./tokens").then(|| { + ModuleResolution::Resolved(ResolvedModule { + source_type: None, + path: "/src/tokens.ts".to_string(), + code: "export const PRIMARY = 'red'".to_string(), + }) }) }; { diff --git a/bindings/devup-ui-wasm/src/source_type_tests.rs b/bindings/devup-ui-wasm/src/source_type_tests.rs index 93e5fd58..d38d3a7b 100644 --- a/bindings/devup-ui-wasm/src/source_type_tests.rs +++ b/bindings/devup-ui-wasm/src/source_type_tests.rs @@ -33,12 +33,12 @@ fn source_type_extracts_compiled_mdx_when_native_variants_use_real_filename() -> )?; assert_eq!(unmapped.map(), None); let resolver = |_: &str, _: &str| { - Some(ResolvedModule { + Some(ModuleResolution::Resolved(ResolvedModule { path: "tokens.mdown".to_string(), code: "export function view() {return
;} export const PRIMARY = 'blue';" .to_string(), source_type: mode, - }) + })) }; let imported = code_extract_with_modules_internal( "page.mdown", @@ -75,11 +75,11 @@ fn source_type_preserves_sheet_when_unknown_child_validation_fails() -> Result<( )?; let before = export_sheet_internal()?; let resolver = |_: &str, _: &str| { - Some(ResolvedModule { + Some(ModuleResolution::Resolved(ResolvedModule { path: "tokens.custom".to_string(), code: "export const PRIMARY = 'red';".to_string(), source_type: None, - }) + })) }; let result = code_extract_with_modules_internal( "page.tsx", diff --git a/libs/extractor/src/ignored_loader_tests.rs b/libs/extractor/src/ignored_loader_tests.rs new file mode 100644 index 00000000..89303f3e --- /dev/null +++ b/libs/extractor/src/ignored_loader_tests.rs @@ -0,0 +1,68 @@ +use super::*; + +#[test] +fn ignored_loader_when_imported_or_reexported_has_empty_namespace() -> Result<(), String> { + let resolver = |specifier: &str, _: &str| { + match specifier { + "empty" => Some(ModuleResolution::Ignored), + "barrel" => Some(ModuleResolution::Resolved(crate::ResolvedModule { + path: "/src/barrel.js".to_string(), + code: "export {default as def, missing as value, __exports__ as hidden} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(), + source_type: None, + })), + "cjs" => Some(ModuleResolution::Resolved(crate::ResolvedModule { + path: "/src/cjs.js".to_string(), + code: "const first = require('empty'); const second = require('empty'); const {missing} = require('empty'); module.exports = {ok: first === second && Object.keys(first).length === 0 && first.missing === undefined && missing === undefined};".to_string(), + source_type: None, + })), + _ => None, + } + }; + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let script = module_script( + "import def, {missing, __exports__ as hidden} from 'empty'; import * as ns from 'empty'; import * as barrel from 'barrel'; import cjs from 'cjs'; const ok = missing === undefined && hidden === undefined && Object.keys(def).length === 0 && Object.keys(ns).length === 0 && barrel.value === undefined && barrel.hidden === undefined && Object.keys(barrel.ns).length === 0 && Object.keys(barrel.def).length === 0 && Object.keys(barrel).length === 4 && cjs.ok; ok;", + "/src/entry.js", + &mut loader, + true, + )?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "{}{}", + loader.prelude(), + script.body + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true)); + assert_eq!( + loader.dependencies, + BTreeSet::from(["/src/barrel.js".to_string(), "/src/cjs.js".to_string()]) + ); + assert_eq!(loader.kept_imports, Vec::::new()); + Ok(()) +} + +#[test] +fn ignored_named_when_key_is_inherited_is_undefined() -> Result<(), String> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let option = ExtractOption::default(); + for key in ["constructor", "toString", "__proto__", "hasOwnProperty"] { + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let script = module_script( + &format!("import {{ {key} as value }} from 'empty'; typeof value === 'undefined';"), + "/src/entry.js", + &mut loader, + true, + )?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "{}{}", + loader.prelude(), + script.body + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true), "{key}"); + assert_eq!(loader.dependencies, BTreeSet::new()); + } + Ok(()) +} diff --git a/libs/extractor/src/ignored_module_tests.rs b/libs/extractor/src/ignored_module_tests.rs new file mode 100644 index 00000000..26bfb8c3 --- /dev/null +++ b/libs/extractor/src/ignored_module_tests.rs @@ -0,0 +1,188 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn ignored_named_values_when_resolved_have_no_dynamic_style_or_dependency() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + for binding in [ + "import { missing as value } from 'empty';", + "import * as ns from 'empty'; const value = ns.missing;", + "import def from 'empty'; const value = def.missing;", + ] { + let output = extract_with_modules( + "/src/ignored.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, Vec::::new()); + assert!(!output.code.contains("--"), "{}", output.code); + } + Ok(()) +} + +#[test] +#[serial] +fn ignored_stylesheet_bindings_when_evaluated_are_empty_commonjs() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored.css.ts", + "import {style} from '@devup-ui/react'; import def, {missing} from 'empty'; import * as ns from 'empty'; function color() { if (missing !== undefined || Object.keys(ns).length || Object.keys(def).length) throw new Error('bad interop'); return 'red'; } export const cls = style({color: color()});", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, Vec::::new()); + assert!(!output.code.contains("style(")); + assert!(!output.code.contains("--")); + Ok(()) +} + +#[test] +#[serial] +fn ignored_constants_when_required_or_reexported_remain_known() -> Result<(), Box> { + let resolver = |specifier: &str, _: &str| { + match specifier { + "empty" => Some(ModuleResolution::Ignored), + "barrel" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/barrel.js".to_string(), + code: "export {missing as value} from 'empty'; export * as ns from 'empty'; export * from 'empty';".to_string(), + source_type: None, + })), + "cjs" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/cjs.js".to_string(), + code: "module.exports = require('empty');".to_string(), + source_type: None, + })), + "cjsProps" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/cjsProps.js".to_string(), + code: "const {missing} = require('empty'); exports.value = missing; exports.other = require('empty').absent;".to_string(), + source_type: None, + })), + _ => None, + } + }; + for (binding, dependencies) in [ + ("import {value} from 'barrel';", vec!["/src/barrel.js"]), + ( + "import {ns} from 'barrel'; const value = ns.missing;", + vec!["/src/barrel.js"], + ), + ( + "import def from 'cjs'; const value = def.missing;", + vec!["/src/cjs.js"], + ), + ("import {value} from 'cjsProps';", vec!["/src/cjsProps.js"]), + ( + "import {other as value} from 'cjsProps';", + vec!["/src/cjsProps.js"], + ), + ] { + let output = extract_with_modules( + "/src/entry.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.dependencies, dependencies); + assert!(!output.code.contains("--"), "{}", output.code); + } + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_mutated_is_not_inlined_as_empty() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + for binding in ["import def from 'empty';", "import * as def from 'empty';"] { + let output = extract_with_modules( + "/src/mutated.tsx", + &format!( + "import {{Box}} from '@devup-ui/react'; {binding} def.color = 'blue'; export const view = ;" + ), + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("--")); + assert_eq!(output.dependencies, Vec::::new()); + } + Ok(()) +} + +#[test] +#[serial] +fn real_commonjs_when_empty_keeps_inherited_reads() -> Result<(), Box> { + let resolver = |_: &str, _: &str| { + Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/tokens.js".to_string(), + code: "module.exports = {};".to_string(), + source_type: None, + })) + }; + let output = extract_with_modules( + "/src/real.tsx", + "import {Box} from '@devup-ui/react'; import def from 'tokens'; export const view = ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("def.constructor"), "{}", output.code); + assert_eq!(output.dependencies, vec!["/src/tokens.js"]); + Ok(()) +} + +#[test] +#[serial] +fn ignored_missing_when_undefined_is_shadowed_keeps_primitive_argument() +-> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/shadowed.tsx", + "import {Box} from '@devup-ui/react'; import {missing} from 'empty'; export const View = (undefined, read) => ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("read(void 0)"), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + +#[test] +#[serial] +fn unresolved_require_when_dynamic_or_shadowed_is_not_an_ignored_constant() +-> Result<(), Box> { + for code in [ + "module.exports = require(name);", + "module.exports = require('missing');", + "const require = () => ({}); module.exports = require('empty');", + ] { + let resolver = |specifier: &str, _: &str| match specifier { + "tokens" => Some(ModuleResolution::Resolved(ResolvedModule { + path: "/src/tokens.js".to_string(), + code: code.to_string(), + source_type: None, + })), + "empty" => Some(ModuleResolution::Ignored), + _ => None, + }; + let output = extract_with_modules( + "/src/unresolved.tsx", + "import {Box} from '@devup-ui/react'; import value from 'tokens'; export const view = ;", + ExtractOption::default(), + false, + &resolver, + )?; + assert!(output.code.contains("--")); + } + Ok(()) +} diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index f887a7ef..a0501cee 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -25,7 +25,10 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ModuleResolution, ModuleResolver}; + +mod exports; +use exports::Exports; #[derive(Clone, Debug)] enum Constant { @@ -41,6 +44,8 @@ enum Constant { Object(Rc>), /// An object literal every property of which is known, in source order Record(Rc>), + /// An ignored module's empty `CommonJS` object, with ordinary prototype reads + IgnoredObject, Array(Rc>), /// `StyleX` custom properties by key, which read as their `var()` Vars(Rc>), @@ -56,7 +61,10 @@ enum Constant { impl Constant { /// Whether code can change what it holds const fn is_mutable(&self) -> bool { - matches!(self, Self::Object(_) | Self::Record(_) | Self::Array(_)) + matches!( + self, + Self::Object(_) | Self::Record(_) | Self::IgnoredObject | Self::Array(_) + ) } /// Whether this value or one it holds is a function @@ -77,7 +85,8 @@ impl Constant { Self::Number(number) => Some(crate::utils::js_number_string(*number)), Self::Null => Some("null".to_string()), Self::Bool(value) => Some(value.to_string()), - Self::Undefined => Some("undefined".to_string()), + Self::Undefined => Some("(void 0)".to_string()), + Self::IgnoredObject => Some("({})".to_string()), Self::Record(entries) => { let mut properties = Vec::with_capacity(entries.len()); for (key, value) in entries.iter() { @@ -734,15 +743,14 @@ struct Modules<'r> { } impl Modules<'_> { - fn exports( - &mut self, - specifier: &str, - importer: &str, - ) -> Option>> { - let module = (self.resolver?)(specifier, importer)?; + fn exports(&mut self, specifier: &str, importer: &str) -> Option { + let module = match (self.resolver?)(specifier, importer)? { + ModuleResolution::Ignored => return Some(Exports::Ignored), + ModuleResolution::Resolved(module) => module, + }; let key = (module.path.clone(), module.code.clone(), module.source_type); if let Some(exports) = self.exports.get(&key) { - return Some(exports.clone()); + return Some(Exports::Partial(exports.clone())); } if self.loading.contains(&module.path) { return None; @@ -751,7 +759,7 @@ impl Modules<'_> { let exports = Rc::new(self.read(&module.path, &module.code, module.source_type)); self.loading.pop(); self.exports.insert(key, exports.clone()); - Some(exports) + Some(Exports::Partial(exports)) } fn read( @@ -805,11 +813,8 @@ impl Modules<'_> { Statement::ExportFromDeclaration(export) => { if let Some(from) = self.exports(&export.source.value, path) { for specifier in &export.specifiers { - if let Some(constant) = from.get(specifier.local.name().as_str()) { - exports.insert( - specifier.exported.name().to_string(), - constant.clone(), - ); + if let Some(constant) = from.named(specifier.local.name().as_str()) { + exports.insert(specifier.exported.name().to_string(), constant); } } } @@ -818,10 +823,10 @@ impl Modules<'_> { if let Some(from) = self.exports(&export.source.value, path) { match &export.exported { Some(exported) => { - exports.insert(exported.name().to_string(), Constant::Object(from)); + exports.insert(exported.name().to_string(), from.namespace()); } None => { - for (name, constant) in from.iter() { + for (name, constant) in from.entries() { if name != "default" { exports .entry(name.clone()) @@ -1092,8 +1097,9 @@ impl<'p, 'a> ModuleScope<'p, 'a> { self.uses = Some(uses.clone()); uses }; - let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace))); let value = self.lookup_raw(modules, name); + let namespace = matches!(self.imports.get(name), Some((_, Imported::Namespace))) + && !matches!(value, Some(Constant::IgnoredObject)); let mut change = None; for found in uses.get(name).into_iter().flatten() { change = match found { @@ -1271,8 +1277,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let (source, imported) = self.imports.get(name)?; let exports = modules.exports(source, self.path)?; match imported { - Imported::Named(export) => exports.get(export).cloned(), - Imported::Namespace => Some(Constant::Object(exports)), + Imported::Named(export) => exports.named(export), + Imported::Namespace => Some(exports.namespace()), } } @@ -1476,6 +1482,18 @@ impl<'p, 'a> ModuleScope<'p, 'a> { member.property.name.as_str(), ), Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(callee) + if callee.name == "require" && !self.binds("require") => + { + let [Argument::StringLiteral(source)] = call.arguments.as_slice() else { + return None; + }; + Some( + modules + .exports(source.value.as_str(), self.path)? + .namespace(), + ) + } Expression::StaticMemberExpression(callee) if self.is_global_math(&callee.object) => { @@ -1529,12 +1547,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { (key, value) } ObjectPropertyKind::SpreadProperty(spread) => { - if let Some(Constant::Record(spread)) = self.evaluate(modules, &spread.argument) - { - for (key, value) in spread.iter() { - set_entry(&mut entries, key.clone(), value.clone()); + match self.evaluate(modules, &spread.argument) { + Some(Constant::IgnoredObject) => continue, + Some(Constant::Record(spread)) => { + for (key, value) in spread.iter() { + set_entry(&mut entries, key.clone(), value.clone()); + } + continue; } - continue; + _ => {} } (None, None) } @@ -1590,6 +1611,25 @@ fn member_of(object: &Constant, key: &str) -> Option { .iter() .find(|(name, _)| name == key) .map(|(_, value)| value.clone()), + Constant::IgnoredObject + if !matches!( + key, + "constructor" + | "toString" + | "toLocaleString" + | "valueOf" + | "hasOwnProperty" + | "isPrototypeOf" + | "propertyIsEnumerable" + | "__proto__" + | "__defineGetter__" + | "__defineSetter__" + | "__lookupGetter__" + | "__lookupSetter__" + ) => + { + Some(Constant::Undefined) + } Constant::Array(values) => key .parse::() .ok() @@ -1874,6 +1914,17 @@ impl<'a> Inline<'_, 'a> { )), Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Undefined => Some(Expression::new_unary_expression( + SPAN, + oxc_syntax::operator::UnaryOperator::Void, + Expression::new_numeric_literal(SPAN, 0.0, None, NumberBase::Decimal, builder), + builder, + )), + Constant::IgnoredObject if self.objects => Some(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + )), Constant::Record(entries) if self.objects => { let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); for (key, value) in entries.iter() { @@ -1961,7 +2012,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { - self.visit_expression(&mut call.callee); + if !matches!(self.constant(&call.callee), Some(Constant::Undefined)) { + self.visit_expression(&mut call.callee); + } let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); self.reading_styles(styles, |inline| { diff --git a/libs/extractor/src/imported_constants/exports.rs b/libs/extractor/src/imported_constants/exports.rs new file mode 100644 index 00000000..12aa7677 --- /dev/null +++ b/libs/extractor/src/imported_constants/exports.rs @@ -0,0 +1,34 @@ +use super::{Constant, FxHashMap, Rc}; + +pub(super) enum Exports { + Partial(Rc>), + Ignored, +} + +impl Exports { + pub(super) fn named(&self, name: &str) -> Option { + match self { + Self::Partial(exports) => exports.get(name).cloned(), + Self::Ignored => Some(if name == "default" { + Constant::IgnoredObject + } else { + Constant::Undefined + }), + } + } + + pub(super) fn namespace(self) -> Constant { + match self { + Self::Partial(exports) => Constant::Object(exports), + Self::Ignored => Constant::IgnoredObject, + } + } + + pub(super) fn entries(&self) -> impl Iterator { + let entries = match self { + Self::Partial(exports) => Some(exports), + Self::Ignored => None, + }; + entries.into_iter().flat_map(|exports| exports.iter()) + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ab702ecf..ee5ce117 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -222,14 +222,22 @@ pub struct ResolvedModule { pub source_type: Option, } +/// A native file resolution or a successful alias ignore without source. +pub enum ModuleResolution { + Resolved(ResolvedModule), + Ignored, +} + mod source_type; pub use source_type::{ExtractSourceType, parse_source_type}; #[cfg(test)] +mod ignored_module_tests; +#[cfg(test)] mod source_type_tests; /// Resolves `(specifier, importer)` the way the bundler does; `None` when it /// cannot -pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a; +pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a; #[derive(Clone)] pub struct ExtractOption { @@ -325,7 +333,11 @@ pub fn extract_with_source_type( return None; } let module = resolver?(specifier, importer)?; - if let Err(error) = source_type::validate_module(&module) { + let validation = match &module { + ModuleResolution::Resolved(module) => source_type::validate_module(module), + ModuleResolution::Ignored => Ok(()), + }; + if let Err(error) = validation { *fault.borrow_mut() = Some(error); return None; } @@ -18370,7 +18382,7 @@ export const d = style([cond && base]);", fn memory_resolver( files: &'static [(&'static str, &'static str)], - ) -> impl Fn(&str, &str) -> Option { + ) -> impl Fn(&str, &str) -> Option { move |specifier, importer| { let directory = importer .rsplit_once('/') @@ -18383,10 +18395,12 @@ export const d = style([cond && base]);", .iter() .any(|extension| format!("{path}{extension}") == *file) }) - .map(|(file, code)| ResolvedModule { - source_type: None, - path: (*file).to_string(), - code: (*code).to_string(), + .map(|(file, code)| { + ModuleResolution::Resolved(ResolvedModule { + source_type: None, + path: (*file).to_string(), + code: (*code).to_string(), + }) }) } } diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index 58900fae..de274e40 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -14,7 +14,10 @@ use oxc_span::{GetSpan, SourceType}; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; -use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; +use crate::{ExtractOption, ModuleResolution, ModuleResolver, utils::is_vanilla_extract_file}; +#[cfg(test)] +#[path = "ignored_loader_tests.rs"] +mod ignored_loader_tests; /// The object the package's API is bound to while a stylesheet runs pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; @@ -92,6 +95,9 @@ pub(crate) struct ModuleLoader<'r> { /// Definitions of the loaded modules, each after the ones it uses definitions: Vec, loaded: FxHashMap, + ignored: FxHashMap<(String, String), String>, + ignored_bindings: FxHashSet, + helper_emitted: bool, /// `(path, name)` of the modules being defined, outermost first loading: Vec<(String, String)>, /// Names of modules imported before they finished evaluating, whose @@ -112,6 +118,9 @@ impl<'r> ModuleLoader<'r> { option, definitions: Vec::new(), loaded: FxHashMap::default(), + ignored: FxHashMap::default(), + ignored_bindings: FxHashSet::default(), + helper_emitted: false, loading: Vec::new(), pending: FxHashSet::default(), next_module: 0, @@ -136,8 +145,25 @@ impl<'r> ModuleLoader<'r> { let resolver = self .resolver .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; - let module = resolver(specifier, importer) - .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + let module = match resolver(specifier, importer) + .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))? + { + ModuleResolution::Resolved(module) => module, + ModuleResolution::Ignored => { + let key = (importer.to_string(), specifier.to_string()); + if let Some(name) = self.ignored.get(&key) { + return Ok(name.clone()); + } + let name = format!("__module_{}__", self.next_module); + self.next_module += 1; + self.definitions.push(format!( + "const {name}$ = {{}};\nconst {name} = {{ default: {name}$, __exports__: {name}$ }};\n" + )); + self.ignored.insert(key, name.clone()); + self.ignored_bindings.insert(name.clone()); + return Ok(name); + } + }; let key = (module.path.clone(), module.code.clone(), module.source_type); let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { @@ -155,8 +181,9 @@ impl<'r> ModuleLoader<'r> { self.next_module += 1; // Created before the modules it imports, so a cycle among them can // reach it; reading it before it starts evaluating is an error - if self.definitions.is_empty() { + if !self.helper_emitted { self.definitions.push(MODULE_HELPER.to_string()); + self.helper_emitted = true; } self.definitions.push(format!( "const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n", @@ -243,6 +270,28 @@ impl<'r> ModuleLoader<'r> { } } +impl ModuleLoader<'_> { + fn named(&self, module: &str, name: &str) -> String { + if name != "default" && self.ignored_bindings.contains(module) { + return "void 0".to_string(); + } + let object = if name == "default" { + module.to_string() + } else { + self.namespace(module) + }; + format!("{object}[{name:?}]") + } + + fn namespace(&self, module: &str) -> String { + if self.ignored_bindings.contains(module) { + format!("{module}.__exports__") + } else { + module.to_string() + } + } +} + /// A module turned into a script: imports bound to the loaded modules, export /// keywords dropped pub(crate) struct ModuleScript { @@ -389,14 +438,32 @@ pub(crate) fn module_script( let mut named = Vec::new(); for specifier in import.specifiers.iter().flatten() { match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => named.push( - format!("{:?}: {}", specifier.imported.name(), specifier.local.name), - ), + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if loader.ignored_bindings.contains(module) { + let _ = writeln!( + body, + "const {} = {};", + specifier.local.name, + loader.named(module, specifier.imported.name().as_str()) + ); + } else { + named.push(format!( + "{:?}: {}", + specifier.imported.name(), + specifier.local.name + )); + } + } ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { named.push(format!("\"default\": {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - let _ = writeln!(body, "const {} = {module};", specifier.local.name); + let _ = writeln!( + body, + "const {} = {};", + specifier.local.name, + loader.namespace(module) + ); } } } @@ -425,7 +492,7 @@ pub(crate) fn module_script( for specifier in &export.specifiers { exports.push(( export_name(&specifier.exported), - format!("{module}[{:?}]", export_name(&specifier.local)), + loader.named(&module, &export_name(&specifier.local)), )); } } @@ -451,8 +518,10 @@ pub(crate) fn module_script( Statement::ExportAllDeclaration(export) => { let module = loader.load(export.source.value.as_str(), filename, entry)?; match &export.exported { - Some(exported) => exports.push((export_name(exported), module)), - None => spreads.push(module), + Some(exported) => { + exports.push((export_name(exported), loader.namespace(&module))); + } + None => spreads.push(loader.namespace(&module)), } } statement => { diff --git a/libs/extractor/src/source_type_tests.rs b/libs/extractor/src/source_type_tests.rs index ec247303..5624b9ba 100644 --- a/libs/extractor/src/source_type_tests.rs +++ b/libs/extractor/src/source_type_tests.rs @@ -22,11 +22,11 @@ fn extracts_real_filename_when_compiled_mode_is_supplied() -> Result<(), Box Result<(), Box> { let resolver = |_: &str, _: &str| { - Some(ResolvedModule { + Some(ModuleResolution::Resolved(ResolvedModule { path: "token.ts".to_string(), code: "export enum Colors { PRIMARY = 'red' }".to_string(), source_type: None, - }) + })) }; let output = extract_with_source_type( "page.mdown", @@ -45,11 +45,11 @@ fn derives_child_language_when_root_is_compiled_mdx() -> Result<(), Box Result<(), Box> { let resolver = |_: &str, _: &str| { - Some(ResolvedModule { + Some(ModuleResolution::Resolved(ResolvedModule { path: "token.mdown".to_string(), code: "export function view() { return
; } export const PRIMARY = 'blue';" .to_string(), source_type: Some(ExtractSourceType::CompiledMdx), - }) + })) }; let output = extract_with_source_type( "theme.css.ts", @@ -107,11 +107,11 @@ fn evaluates_generated_values_when_root_keeps_custom_filename() -> Result<(), Bo #[serial] fn reads_commonjs_exports_when_compiled_jsx_precedes_assignment() -> Result<(), Box> { let resolver = |_: &str, _: &str| { - Some(ResolvedModule { + Some(ModuleResolution::Resolved(ResolvedModule { path: "tokens.mdown".to_string(), code: "function view() { return
; } exports.PRIMARY = 'blue';".to_string(), source_type: Some(ExtractSourceType::CompiledMdx), - }) + })) }; let output = extract_with_source_type( "page.tsx", diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 482e3dba..b4cb7ab4 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -1498,10 +1498,12 @@ mod tests { path.trim_end_matches(".ts") == specifier.trim_start_matches('.').trim_end_matches(".ts") }) - .map(|(path, code)| crate::ResolvedModule { - source_type: None, - path: (*path).to_string(), - code: (*code).to_string(), + .map(|(path, code)| { + crate::ModuleResolution::Resolved(crate::ResolvedModule { + source_type: None, + path: (*path).to_string(), + code: (*code).to_string(), + }) }) }; execute_stylesheet( diff --git a/packages/bun-plugin/src/mdx-ownership.ts b/packages/bun-plugin/src/mdx-ownership.ts index 472dd66a..bfcf0e84 100644 --- a/packages/bun-plugin/src/mdx-ownership.ts +++ b/packages/bun-plugin/src/mdx-ownership.ts @@ -52,7 +52,7 @@ export function createMdxOwnership(options: { observe(filename: string, contents: string, typescript: boolean) { for (const reference of scanImports(contents, true, typescript)) { const target = resolver(reference.specifier, filename) - if (target) reach(target.path, filename) + if (target?.path !== undefined) reach(target.path, filename) } }, loaded(filename: string) { diff --git a/packages/plugin-utils/src/__tests__/ignored-alias.test.ts b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts new file mode 100644 index 00000000..69ab7e85 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts @@ -0,0 +1,258 @@ +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { ModuleAliasPackageError, resolveModuleAlias } from '../module-alias' +import { collectNumberedFiles, seedFileNumbers } from '../numbering' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each(['provider', 'provider/feature'])( + 'ignores scalar false when request is %s', + (request) => { + const { root, entry, installed } = fixture + const alias = { provider: false } as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + }, +) + +it.each([false, true])( + 'selects ordered false candidates when earlier file exists=%s', + (exists) => { + const { root, entry, file } = fixture + const chosen = join(root, 'chosen.js') + if (exists) file('chosen.js') + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: [chosen, false] }, + }) + expect(resolve('provider', entry)).toEqual( + exists ? { path: chosen, code: 'export {}' } : { ignored: true }, + ) + }, +) + +it('stops before later strings when false is first', () => { + const { root, entry, file } = fixture + const chosen = file('chosen.js') + expect( + createModuleResolver({ cwd: root, alias: { provider: [false, chosen] } })( + 'provider', + entry, + ), + ).toEqual({ ignored: true }) +}) + +it('does not claim public factory parity for mixed false arrays', () => { + const { root, installed } = fixture + expect(() => + installed('provider', { + alias: { provider: [join(root, 'missing'), false] }, + }), + ).toThrow(TypeError) +}) + +it('keeps exact false aliases from matching subpaths', () => { + const { root, entry, installed } = fixture + const alias = { provider$: false } as const + expect(installed('provider', { alias })).toBe(false) + expect( + createModuleResolver({ cwd: root, alias })('provider/feature', entry), + ).toBeUndefined() +}) + +it.each(['provider', 'provider/feature'])( + 're-enters scalar false chains for %s', + (request) => { + const { root, entry, installed } = fixture + const alias = { provider: 'nested', nested: 'empty', empty: false } as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + }, +) + +it.each(['provider', 'provider/feature'])( + 'reaches false after equal/below-target guards for %s', + (request) => { + const { root, entry } = fixture + expect( + createModuleResolver({ + cwd: root, + alias: { provider: ['provider', false] }, + })(request, entry), + ).toEqual({ ignored: true }) + }, +) + +it('distinguishes excluded false from ignored success after a missing candidate', () => { + const { entry } = fixture + expect( + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['excluded', false] }, + resolveRequest: () => false, + }), + ).toEqual({ ignored: true }) +}) + +it('keeps fatal cycles before a later false candidate', () => { + const { root, entry } = fixture + expect(() => + createModuleResolver({ + cwd: root, + alias: { provider: ['nested', false], nested: 'provider' }, + })('provider', entry), + ).toThrow('Module alias cycle') +}) + +it('reaches false after a complete nested ordinary miss', () => { + const { root, entry } = fixture + expect( + createModuleResolver({ + cwd: root, + alias: { provider: ['nested', false], nested: join(root, 'missing') }, + })('provider', entry), + ).toEqual({ ignored: true }) +}) + +it.each(['exports', 'manifest'])( + 'preserves fatal %s faults before a false candidate', + (kind) => { + const { root, entry, file } = fixture + file( + 'node_modules/broken/package.json', + kind === 'exports' ? '{"exports":{"browser":"./missing.js"}}' : '{', + ) + expect(() => + createModuleResolver({ + cwd: root, + alias: { provider: ['broken', false] }, + })('provider', entry), + ).toThrow(kind === 'exports' ? ModuleAliasPackageError : ConfigLoadError) + }, +) + +it('keeps directory exclusion false separate from ordinary misses', () => { + const { entry } = fixture + expect( + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['excluded'] }, + resolveRequest: () => false, + }), + ).toBe(false) + expect(() => + resolveModuleAlias('provider', { + importer: entry, + alias: { provider: ['missing', 'excluded'] }, + resolveRequest: (name) => (name === 'excluded' ? false : undefined), + }), + ).toThrow('cannot resolve candidates') +}) + +it('seeds only physically scanned source files when graph requests are ignored', () => { + const { root, entry, file } = fixture + const token = file('src/token.ts') + file('src/main.ts', "import 'provider';") + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: false }, + }) + const files = collectNumberedFiles({ + roots: [join(root, 'src')], + toId: (path) => path, + }) + const seeded: string[][] = [] + seedFileNumbers({ seedFileMap: (paths) => seeded.push(paths) }, files) + expect(graph.files).toEqual([entry, token].sort()) + expect(seeded).toEqual([[entry, token].sort()]) +}) + +it.each(['absent', 'root', 'src'])( + 'resolves stock-shaped instrumentation chain when file is %s', + (location) => { + const { root, entry, file, installed } = fixture + const request = 'private-next-instrumentation-client-user' + const alias = { + [request]: [ + join(root, 'src/instrumentation-client'), + join(root, 'instrumentation-client'), + 'private-next-empty-module', + ], + 'private-next-empty-module': false, + } as const + const expected = + location === 'absent' + ? undefined + : file(`${location === 'src' ? 'src/' : ''}instrumentation-client.js`) + expect(installed(request, { alias })).toBe(expected ?? false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual( + expected ? { path: expected, code: 'export {}' } : { ignored: true }, + ) + }, +) + +it('retries earlier candidates after a previously ignored resolution', () => { + const { root, entry, file } = fixture + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: [join(root, 'earlier'), 'empty'], empty: false }, + }) + expect(resolve('provider', entry)).toEqual({ ignored: true }) + const path = file('earlier.js') + expect(resolve('provider', entry)).toEqual({ path, code: 'export {}' }) +}) + +it('bypasses preparation and numbering IDs for ignored requests', () => { + const { root, entry } = fixture + const seen: string[] = [] + const resolve = createModuleResolver({ + cwd: root, + alias: { provider: false }, + prepareSource: (path) => { + seen.push(path) + return '' + }, + toId: (path) => { + seen.push(path) + return path + }, + }) + expect(resolve('provider', entry)).toEqual({ ignored: true }) + expect(seen).toEqual([]) +}) + +it.each([false, true])( + 'omits ignored static/dynamic/reexport edges when prepared=%s', + async (prepared) => { + const { root, entry, file } = fixture + file( + 'src/main.ts', + "import 'provider'; import('provider/feature'); export * from 'provider';", + ) + const options = { cwd: root, alias: { provider: false } } as const + const graph = prepared + ? await buildStaticImportGraph('src', undefined, { + ...options, + prepareSource: () => + "import 'provider'; import('provider/feature'); export * from 'provider';", + }) + : buildStaticImportGraph('src', undefined, options) + expect(graph.files).toEqual([entry]) + expect([...(graph.staticImports.get(entry) ?? [])]).toEqual([]) + expect([...(graph.dynamicImports.get(entry) ?? [])]).toEqual([]) + expect([...(graph.externalImports.get(entry) ?? [])]).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 5d5b17a3..aecdfba9 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -270,6 +270,7 @@ function* traverseGraph( for (const importRef of imports) { const resolution = resolver(importRef.specifier, file) if (resolution === false) continue + if (resolution?.ignored === true) continue const resolved = resolution?.path if (resolved && excludedDirectory(dirname(resolved))) continue const rewritten = resolution?.request ?? importRef.specifier @@ -973,11 +974,23 @@ export function __setOxcParserForTest( /** A module an import resolved to, as `setModuleResolver` expects it. */ export interface ResolvedModule { + readonly ignored?: never path: string code: string readonly sourceType?: import('./prepared-source').SourceType } +/** Successful native ignore: no filesystem module or manufactured source. */ +export interface IgnoredModule { + readonly ignored: true + readonly path?: never + readonly code?: never + readonly sourceType?: never + readonly request?: never +} + +export type ModuleResolution = ResolvedModule | IgnoredModule + export interface CreateModuleResolverOptions { readonly prepareSource?: PrepareSource readonly alias?: ModuleAliases @@ -1011,6 +1024,7 @@ export function createModuleResolver({ (specifier: string, importer: string) => { const resolution = resolver(specifier, importer) if (!resolution) return undefined + if (resolution.ignored === true) return resolution const { path } = resolution const source = prepared.read( path, @@ -1034,7 +1048,7 @@ function createModulePathResolver( ): ( specifier: string, importer: string, -) => AliasResolution | false | undefined { +) => AliasResolution | IgnoredModule | false | undefined { const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) const extensions = sourceExtensions(includeMdx) const fileResolver = (path: string) => diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index f6289bb9..97d45e9f 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -26,7 +26,9 @@ export { type ComputeReachableFilesOptions, createModuleResolver, type CreateModuleResolverOptions, + type IgnoredModule, listSourceFiles, + type ModuleResolution, planAtomHoist, type PreparedGraphOptions, type PreparedSource, diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts index 1bcf2ff5..6178dbd8 100644 --- a/packages/plugin-utils/src/module-alias.ts +++ b/packages/plugin-utils/src/module-alias.ts @@ -1,3 +1,4 @@ +import type { IgnoredModule } from './import-graph' import type { ModuleAliases } from './types' export class ModuleAliasError extends Error { @@ -33,6 +34,7 @@ export class ModuleAliasPackageError extends Error { } export interface AliasResolution { + readonly ignored?: never readonly path: string readonly request: string } @@ -48,14 +50,14 @@ export function resolveModuleAlias( aliased: boolean, ) => string | false | undefined }, -): AliasResolution | false | undefined { +): AliasResolution | IgnoredModule | false | undefined { let failure: { readonly key: string; readonly candidates: readonly string[] } | undefined function visit( request: string, visited: ReadonlySet, aliased: boolean, - ): AliasResolution | false | undefined { + ): AliasResolution | IgnoredModule | false | undefined { if (visited.has(request)) throw new ModuleAliasError(context.importer, specifier) const next = new Set(visited).add(request) @@ -64,16 +66,24 @@ export function resolveModuleAlias( const name = exact ? key.slice(0, -1) : key if (request !== name && (exact || !request.startsWith(`${name}/`))) continue - const targets = typeof value === 'string' ? [value] : value + const targets = typeof value === 'object' ? value : [value] const candidates = targets .filter( - (target) => request !== target && !request.startsWith(`${target}/`), + (target) => + target === false || + (request !== target && !request.startsWith(`${target}/`)), + ) + .map((target) => + target === false ? false : target + request.slice(name.length), ) - .map((target) => target + request.slice(name.length)) if (targets.length && !candidates.length) continue - failure ??= { key, candidates } + failure ??= { + key, + candidates: candidates.filter((candidate) => candidate !== false), + } let missing = !candidates.length for (const candidate of candidates) { + if (candidate === false) return { ignored: true } const resolved = visit(candidate, next, true) if (resolved) return resolved if (resolved === undefined) missing = true diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts index bc8d53e5..0fe44996 100644 --- a/packages/plugin-utils/src/prepared-resolver.ts +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import { extname } from 'node:path' import type { + ModuleResolution, PreparedSource, PrepareSource, ResolvedModule, @@ -11,7 +12,7 @@ import { readPreparedSource } from './prepared-source' import type { MdxSelection } from './source-selection' export interface ModuleResolver { - (specifier: string, importer: string): ResolvedModule | undefined + (specifier: string, importer: string): ModuleResolution | undefined remapError(error: unknown): Error } diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 03691792..cc9637f2 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -63,8 +63,10 @@ export interface DevupTheme { /** Custom style prop aliases mapped to one or more CSS property names. */ export type CustomShorthands = Record -/** Ordered native module alias candidates; false/ignore aliases are unsupported. */ -export type ModuleAliases = Readonly> +/** Ordered alias candidates; a reached false explicitly ignores the module. */ +export type ModuleAliases = Readonly< + Record +> /** * Devup configuration file structure (devup.json) From 93846b90a4c34235450f50198435503ad5e3f9fb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 10:41:47 +0900 Subject: [PATCH 43/50] test(extractor): cover ignored object serialization and style paths Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/ignored_module_tests.rs | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/libs/extractor/src/ignored_module_tests.rs b/libs/extractor/src/ignored_module_tests.rs index 26bfb8c3..84c1193f 100644 --- a/libs/extractor/src/ignored_module_tests.rs +++ b/libs/extractor/src/ignored_module_tests.rs @@ -1,6 +1,72 @@ use super::*; use serial_test::serial; +#[test] +#[serial] +fn ignored_default_when_serialized_is_an_ordinary_empty_object() -> Result<(), String> { + let filename = "/src/ignored-literal.ts"; + let allocator = Allocator::default(); + let parsed = Parser::new( + &allocator, + "import ignored from 'empty';", + SourceType::from_path(filename).map_err(|error| error.to_string())?, + ) + .parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let option = ExtractOption::default(); + let check = + imported_constants::ChangeCheck::new(&parsed.program, filename, &option, Some(&resolver)); + let literal = check + .known("ignored") + .ok_or_else(|| "Ignored default import must have a known value".to_string())?; + let value = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&format!( + "const value = {literal}; Object.keys(value).length === 0 && Object.getPrototypeOf(value) === Object.prototype;" + ))) + .map_err(|error| error.to_string())?; + assert_eq!(value.as_boolean(), Some(true)); + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_spread_into_style_preserves_static_color() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored-spread.ts", + "import {css} from '@devup-ui/react'; import ignored from 'empty'; const input = {...ignored, color: 'red'}; export const cls = css(input);", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.styles.len(), 1); + assert!(output.styles.iter().all(|style| matches!( + style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + ))); + assert!(!output.code.contains("css("), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + +#[test] +#[serial] +fn ignored_object_when_used_as_style_extracts_empty_static_rules() -> Result<(), Box> { + let resolver = |_: &str, _: &str| Some(ModuleResolution::Ignored); + let output = extract_with_modules( + "/src/ignored-style.ts", + "import {css} from '@devup-ui/react'; import ignored from 'empty'; export const cls = css(ignored);", + ExtractOption::default(), + false, + &resolver, + )?; + assert_eq!(output.styles.len(), 0, "{:?}", output.styles); + assert!(!output.code.contains("css("), "{}", output.code); + assert!(!output.code.contains("--"), "{}", output.code); + assert_eq!(output.dependencies, Vec::::new()); + Ok(()) +} + #[test] #[serial] fn ignored_named_values_when_resolved_have_no_dynamic_style_or_dependency() From fce47fe969b59e029032533f58372b6c0088c502 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 10:48:19 +0900 Subject: [PATCH 44/50] fix(plugins): preserve ordered native alias descriptor options Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/alias-array.test.ts | 21 +- .../src/__tests__/alias-descriptors.test.ts | 206 ++++++++++++++++++ packages/plugin-utils/src/import-graph.ts | 6 +- packages/plugin-utils/src/index.ts | 2 + packages/plugin-utils/src/module-alias.ts | 39 +++- packages/plugin-utils/src/types.ts | 10 + .../src/__tests__/alias-native.test.ts | 66 ++++++ packages/rsbuild-plugin/src/plugin.ts | 2 + .../src/__tests__/alias-native.test.ts | 79 +++++++ .../src/__tests__/loader.test.ts | 37 ++++ packages/webpack-plugin/src/loader.ts | 20 +- packages/webpack-plugin/src/plugin.ts | 3 + 12 files changed, 464 insertions(+), 27 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/alias-descriptors.test.ts create mode 100644 packages/rsbuild-plugin/src/__tests__/alias-native.test.ts create mode 100644 packages/webpack-plugin/src/__tests__/alias-native.test.ts diff --git a/packages/plugin-utils/src/__tests__/alias-array.test.ts b/packages/plugin-utils/src/__tests__/alias-array.test.ts index abf1b6d1..772f8f7b 100644 --- a/packages/plugin-utils/src/__tests__/alias-array.test.ts +++ b/packages/plugin-utils/src/__tests__/alias-array.test.ts @@ -78,17 +78,18 @@ it('reports all candidates at the real importer without original or lateral fall ).toThrow(message) }) -it('fails an empty matched candidate list without falling through', () => { - const { root, entry, file } = fixture +it('continues to the next key when a matched candidate list is empty', () => { + const { root, entry, file, installed } = fixture file('src/main.ts', "import 'provider'") - const alias = { provider: [], provider$: file('later.js') } - const message = `${entry}:1:1: Module alias provider cannot resolve candidates []` - expect(() => - createModuleResolver({ cwd: root, alias })('provider', entry), - ).toThrow(message) - expect(() => - buildStaticImportGraph('src', undefined, { cwd: root, alias }), - ).toThrow(message) + const chosen = file('later.js') + const alias = { provider: [], provider$: chosen } + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) }) it('tries another candidate after a complete nested alias miss', () => { diff --git a/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts new file mode 100644 index 00000000..3e45d3f2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts @@ -0,0 +1,206 @@ +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([false, true])( + 'selects the first native descriptor success when earlier file exists=%s', + (exists) => { + const { root, entry, file, installed } = fixture + const first = join(root, 'first.js') + if (exists) file('first.js') + const second = file('second.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: [first, second] }, + { name: 'provider', alias: false }, + ] as const + const expected = exists ? first : second + expect(installed('provider', { alias })).toBe(expected) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(expected) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, expected].sort()) + }, +) + +it.each([false, true])( + 'keeps a rewriting miss terminal despite a duplicate later ignored=%s', + (ignored) => { + const { root, entry, file, installed } = fixture + const missing = join(root, 'missing.js') + const later = file('later.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: missing }, + { name: 'provider', alias: ignored ? false : later }, + ] as const + expect(() => installed('provider', { alias })).toThrow() + const message = `${entry}:1:1: Module alias provider cannot resolve candidates ${JSON.stringify([missing])}` + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(message) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(message) + }, +) + +it.each(['empty', 'self'])( + 'advances to a duplicate descriptor when the first is %s without a rewrite', + (kind) => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: kind === 'empty' ? [] : ['provider'] }, + { name: 'provider', alias: chosen }, + ] as const + expect(installed('provider', { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['empty-map', 'empty-descriptor', 'self', 'below-target', 'disabled'])( + 'uses the raw real file when %s makes no rewrite', + (kind) => { + const { root, entry, file, installed } = fixture + file('node_modules/provider/package.json', '{"main":"index.js"}') + const request = kind === 'below-target' ? 'provider/child' : 'provider' + const chosen = file( + kind === 'below-target' + ? 'node_modules/provider/child.js' + : 'node_modules/provider/index.js', + ) + file('src/main.ts', `import '${request}'`) + const alias = + kind === 'empty-map' + ? { provider: [] } + : kind === 'disabled' + ? false + : [ + { + name: 'provider', + alias: kind === 'empty-descriptor' ? [] : 'provider', + }, + ] + expect(installed(request, { alias })).toBe(chosen) + expect( + createModuleResolver({ cwd: root, alias })(request, entry)?.path, + ).toBe(chosen) + expect( + buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + include: ['provider'], + }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['provider$', 'provider$/child'])( + 'keeps a literal dollar prefix when onlyModule=false and request=%s', + (request) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', `import '${request}'`) + const alias = [ + { name: 'provider$', alias: false, onlyModule: false }, + ] as const + expect(installed(request, { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual({ + ignored: true, + }) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry]) + }, +) + +it.each(['provider', 'provider/child'])( + 'matches onlyModule=true like map trailing dollar for %s', + (request) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', `import '${request}'`) + const chosen = file('target/child.js') + const alias = [ + { name: 'provider', alias: false, onlyModule: true }, + { name: 'provider', alias: dirname(chosen) }, + ] as const + const map = { provider$: false, provider: dirname(chosen) } as const + const expected = + request === 'provider' + ? { ignored: true } + : { path: chosen, code: 'export {}' } + expect(installed(request, { alias })).toBe( + request === 'provider' ? false : chosen, + ) + expect(createModuleResolver({ cwd: root, alias })(request, entry)).toEqual( + expected, + ) + expect( + createModuleResolver({ cwd: root, alias: map })(request, entry), + ).toEqual(expected) + }, +) + +it('re-enters descriptors through finite chains to an ignored module', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const alias = [ + { name: 'provider', alias: ['missing', 'empty'] }, + { name: 'empty', alias: false }, + ] as const + expect(installed('provider', { alias })).toBe(false) + expect(createModuleResolver({ cwd: root, alias })('provider', entry)).toEqual( + { ignored: true }, + ) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry]) +}) + +it.each([true, false])( + 'uses reached false candidate semantics while native Factory rejects mixed arrays; first false=%s', + (first) => { + const { root, entry, file, installed } = fixture + const chosen = file('chosen.js') + const alias = [ + { + name: 'provider', + alias: first ? [false, chosen] : [join(root, 'missing'), false], + }, + ] as const + expect(() => installed('provider', { alias })).toThrow(TypeError) + expect( + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toEqual({ ignored: true }) + }, +) + +it.each([false, true])( + 'reports unsupported wildcard alias names instead of dropping their native form; descriptor=%s', + (descriptor) => { + const { root, entry } = fixture + const alias = descriptor + ? ([{ name: 'provider/*', alias: false }] as const) + : ({ 'provider/*': false } as const) + expect(() => + createModuleResolver({ cwd: root, alias })('provider/child', entry), + ).toThrow(`${entry}:1:1: Module alias provider/* cannot use wildcard names`) + }, +) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index aecdfba9..3fe5d115 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -36,7 +36,7 @@ import { type SourceSelectionOptions, } from './source-selection' import { type PathAlias, readPathAliases } from './tsconfig' -import type { ModuleAliases } from './types' +import type { ModuleAliasOptions } from './types' /** * How map keys (and bucket-root values) are stringified. @@ -360,7 +360,7 @@ function* traverseGraph( export interface StaticImportGraphOptions { /** Include MDX only when the caller compiles it before extraction. */ readonly includeMdx?: MdxSelection - readonly alias?: ModuleAliases + readonly alias?: ModuleAliasOptions readonly cwd?: string readonly include?: readonly string[] readonly conditions?: readonly string[] @@ -993,7 +993,7 @@ export type ModuleResolution = ResolvedModule | IgnoredModule export interface CreateModuleResolverOptions { readonly prepareSource?: PrepareSource - readonly alias?: ModuleAliases + readonly alias?: ModuleAliasOptions readonly includeMdx?: MdxSelection cwd?: string tsconfigPath?: string diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 97d45e9f..8d44c9b4 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -91,7 +91,9 @@ export type { DevupConfig, DevupTheme, ImportAliases, + ModuleAliasDescriptor, ModuleAliases, + ModuleAliasOptions, ThemeColors, ThemeTypography, Typography, diff --git a/packages/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts index 6178dbd8..a9c8634f 100644 --- a/packages/plugin-utils/src/module-alias.ts +++ b/packages/plugin-utils/src/module-alias.ts @@ -1,5 +1,5 @@ import type { IgnoredModule } from './import-graph' -import type { ModuleAliases } from './types' +import type { ModuleAliasDescriptor, ModuleAliasOptions } from './types' export class ModuleAliasError extends Error { constructor(importer: string, specifier: string) { @@ -33,6 +33,18 @@ export class ModuleAliasPackageError extends Error { } } +export class ModuleAliasConfigurationError extends Error { + readonly name = 'ModuleAliasConfigurationError' + constructor( + readonly importer: string, + readonly key: string, + ) { + super( + `${importer}:1:1: Module alias ${key} cannot use wildcard names in the shared resolver`, + ) + } +} + export interface AliasResolution { readonly ignored?: never readonly path: string @@ -43,7 +55,7 @@ export interface AliasResolution { export function resolveModuleAlias( specifier: string, context: { - readonly alias: ModuleAliases + readonly alias: ModuleAliasOptions readonly importer: string readonly resolveRequest: ( request: string, @@ -51,6 +63,19 @@ export function resolveModuleAlias( ) => string | false | undefined }, ): AliasResolution | IgnoredModule | false | undefined { + const descriptors: readonly (ModuleAliasDescriptor & { + readonly key: string + })[] = + context.alias === false + ? [] + : Array.isArray(context.alias) + ? context.alias.map((entry) => ({ ...entry, key: entry.name })) + : Object.entries(context.alias).map(([key, alias]) => ({ + key, + name: key.endsWith('$') ? key.slice(0, -1) : key, + onlyModule: key.endsWith('$'), + alias, + })) let failure: { readonly key: string; readonly candidates: readonly string[] } | undefined function visit( @@ -61,9 +86,9 @@ export function resolveModuleAlias( if (visited.has(request)) throw new ModuleAliasError(context.importer, specifier) const next = new Set(visited).add(request) - for (const [key, value] of Object.entries(context.alias)) { - const exact = key.endsWith('$') - const name = exact ? key.slice(0, -1) : key + for (const { key, name, alias: value, onlyModule: exact } of descriptors) { + if (name.includes('*')) + throw new ModuleAliasConfigurationError(context.importer, key) if (request !== name && (exact || !request.startsWith(`${name}/`))) continue const targets = typeof value === 'object' ? value : [value] @@ -76,12 +101,12 @@ export function resolveModuleAlias( .map((target) => target === false ? false : target + request.slice(name.length), ) - if (targets.length && !candidates.length) continue + if (!candidates.length) continue failure ??= { key, candidates: candidates.filter((candidate) => candidate !== false), } - let missing = !candidates.length + let missing = false for (const candidate of candidates) { if (candidate === false) return { ignored: true } const resolved = visit(candidate, next, true) diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index cc9637f2..5dc4cd65 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -68,6 +68,16 @@ export type ModuleAliases = Readonly< Record > +/** Native ordered entries preserve duplicate names and literal dollar suffixes. */ +export interface ModuleAliasDescriptor { + readonly name: string + readonly alias: string | false | readonly (string | false)[] + readonly onlyModule?: boolean +} + +export type ModuleAliasOptions = + ModuleAliases | readonly ModuleAliasDescriptor[] | false + /** * Devup configuration file structure (devup.json) */ diff --git a/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 00000000..8ff7c952 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,66 @@ +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { createRsbuild } from '@rsbuild/core' +import { expect, it } from 'bun:test' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { mdxLanding } from '../../../webpack-plugin/src/__tests__/mdx-build-fixture' +import { DevupUI } from '../plugin' + +it.each(['false', 'later-false', 'first-success', 'empty'])( + 'passes the actual Rspack alias map when %s', + async (kind) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + file('node_modules/provider/package.json', '{"main":"index.js"}') + file('node_modules/provider/index.js', 'export const value="blue"') + const target: string | false | (string | false)[] = + kind === 'false' + ? false + : kind === 'later-false' + ? [join(root, 'missing'), false] + : kind === 'empty' + ? [] + : [blue, false] + wasm.resetBuildState() + try { + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: 'development', + plugins: [DevupUI({ singleCss: true, include: ['provider'] })], + source: { entry: { main: entry } }, + resolve: { + alias: { + provider: target, + '@devup-ui/react': mdxLanding.resolve('@devup-ui/react'), + }, + }, + output: { + target: 'node', + distPath: { root: join(root, 'out') }, + minify: false, + }, + }, + }) + const result = await builder.build() + try { + expect(wasm.getCss(null, false)).toContain( + kind === 'false' || kind === 'later-false' + ? 'color:red' + : 'color:blue', + ) + } finally { + await result.close() + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 9e400492..0ac4a425 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -328,6 +328,7 @@ export const DevupUI = ({ cwd: root, includeMdx: mdxExtensions, conditions, + alias: normalized.resolve?.alias, toId, }) setModuleResolver(resolver) @@ -336,6 +337,7 @@ export const DevupUI = ({ cwd: root, include, conditions, + alias: normalized.resolve?.alias, exclude: [basename(outputDir), basename(cssDir)], }) const plan = { graph, roots, entries, resolver } diff --git a/packages/webpack-plugin/src/__tests__/alias-native.test.ts b/packages/webpack-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 00000000..2fa5b482 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/alias-native.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats } from 'webpack' + +import { aliasFixture } from '../../../plugin-utils/src/__tests__/alias-array-fixture' +import { DevupUIWebpackPlugin } from '../plugin' +import { mdxLanding } from './mdx-build-fixture' + +const bundled: { webpack(config: Configuration): Compiler } = mdxLanding( + 'next/dist/compiled/webpack/webpack', +) + +it.each([false, true])( + 'passes native descriptors through graph, setup and loader when watch=%s', + async (watch) => { + const fixture = aliasFixture() + const { root, file } = fixture + const entry = file( + 'src/main.js', + 'import {css} from "@devup-ui/react"; import {value} from "provider"; export const cls=css({color: value === undefined ? "red" : value})', + ) + const blue = file('outside/blue.js', 'export const value="blue"') + try { + for (const target of [false, blue] as const) { + wasm.resetBuildState() + const compiler = bundled.webpack({ + context: root, + mode: 'development', + entry, + target: 'node', + output: { path: join(root, 'out'), library: { type: 'commonjs2' } }, + resolve: { + alias: [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + { + name: '@devup-ui/react', + alias: mdxLanding.resolve('@devup-ui/react'), + }, + ], + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + plugins: [new DevupUIWebpackPlugin({ singleCss: true, watch })], + }) + try { + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) reject(error) + else if (result) done(result) + else reject(new Error('Missing build stats')) + }), + ) + expect( + stats.toJson({ all: false, errors: true }).errors ?? [], + ).toEqual([]) + const result: { exports: { cls?: string } } = { exports: {} } + new Function( + 'module', + 'exports', + readFileSync(join(root, 'out/main.js'), 'utf8'), + )(result, result.exports) + expect(wasm.getCss(null, false)).toContain( + `.${result.exports.cls}{color:${target === false ? 'red' : 'blue'}}`, + ) + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + } + } + } finally { + fixture.dispose() + } + }, +) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 0aedad58..521c62c2 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -130,6 +130,43 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it('selects a fresh native alias resolver when ordered entries change at the same root', async () => { + const root = mkdtempSync(join(tmpdir(), 'loader-alias-')) + const target = join(root, 'chosen.js') + writeFileSync(target, 'export const value="blue"') + const selected: unknown[] = [] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('provider', 'src/main.ts')) + }, + ) + try { + for (const alias of [ + [{ name: 'provider', alias: false }], + [ + { name: 'provider', alias: target }, + { name: 'provider', alias: false }, + ], + [{ name: 'provider', alias: false }], + ] as const) { + await new Promise((done) => { + const context = createLoaderContext( + { rootDir: root, alias, cssDir: join(root, 'df') }, + () => done(), + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + expect(selected).toEqual([ + { ignored: true }, + { path: 'chosen.js', code: 'export const value="blue"' }, + { ignored: true }, + ]) + } finally { + register.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) it('keys resolver reuse by the explicit project root and active conditions', async () => { const root = mkdtempSync( join(process.env.DEVUP_PLUGIN_TEST_TMP ?? tmpdir(), 'loader-'), diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index b0c67677..95bfab72 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -5,6 +5,7 @@ import { createModuleResolver, createStateWriter, isMdxSource, + type ModuleAliasOptions, remapMdxError, } from '@devup-ui/plugin-utils' import { @@ -29,6 +30,7 @@ export interface DevupUILoaderOptions { rootDir?: string conditions?: readonly string[] mdxExtensions?: readonly string[] + alias?: ModuleAliasOptions } function toLoaderError(error: unknown): Error { @@ -51,18 +53,21 @@ const moduleResolvers = new Map< >() /** Resolve imports to the cwd-relative ids this loader extracts files under */ -function setCwdModuleResolver( - rootDir: string, - conditions: readonly string[], - mdxExtensions: readonly string[], -): void { - const key = JSON.stringify([rootDir, conditions, mdxExtensions]) +function setCwdModuleResolver(options: { + readonly rootDir: string + readonly conditions: readonly string[] + readonly mdxExtensions: readonly string[] + readonly alias: ModuleAliasOptions | undefined +}): void { + const { rootDir, conditions, mdxExtensions, alias } = options + const key = JSON.stringify([rootDir, conditions, mdxExtensions, alias]) let moduleResolver = moduleResolvers.get(key) if (!moduleResolver) { moduleResolver = createModuleResolver({ cwd: rootDir, includeMdx: mdxExtensions, conditions, + alias, toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), }) moduleResolvers.set(key, moduleResolver) @@ -84,6 +89,7 @@ const devupUILoader: RawLoaderDefinitionFunction = rootDir = process.cwd(), conditions = ['import', 'module', 'node'], mdxExtensions = ['.mdx'], + alias, } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -104,7 +110,7 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver(rootDir, conditions, mdxExtensions) + setCwdModuleResolver({ rootDir, conditions, mdxExtensions, alias }) const { code, css = '', diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index d6717a90..1b028c9b 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -277,6 +277,7 @@ export class DevupUIWebpackPlugin { cwd, includeMdx: this.mdxExtensions, conditions, + alias: resolveOptions?.alias, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) @@ -349,6 +350,7 @@ export class DevupUIWebpackPlugin { cwd, include: this.options.include, conditions, + alias: resolveOptions?.alias, }) const canonicalMap = buildCanonicalMap({ srcDir, @@ -541,6 +543,7 @@ export class DevupUIWebpackPlugin { importAliases: this.importAliases, rootDir: cwd, conditions, + alias: resolveOptions?.alias, mdxExtensions: this.mdxExtensions, }, } From 76a591814dccaf062985c8371e28ea94887e02b7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 11:48:36 +0900 Subject: [PATCH 45/50] fix(plugin-utils): resolve aliased directory package entries Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/alias-array-fixture.ts | 5 +- .../src/__tests__/alias-directory.test.ts | 219 ++++++++++++++++++ packages/plugin-utils/src/import-graph.ts | 68 ++++-- 3 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/alias-directory.test.ts diff --git a/packages/plugin-utils/src/__tests__/alias-array-fixture.ts b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts index 2c3e0bf1..d12585d0 100644 --- a/packages/plugin-utils/src/__tests__/alias-array-fixture.ts +++ b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts @@ -32,7 +32,9 @@ export function aliasFixture() { dispose: () => fs.rmSync(root, { recursive: true, force: true }), installed: ( request: string, - options: CreateModuleResolverOptions, + options: CreateModuleResolverOptions & { + readonly mainFields?: readonly string[] + }, ): string | false => { const resolver = enhancedResolve.ResolverFactory.createResolver({ fileSystem: fs, @@ -40,6 +42,7 @@ export function aliasFixture() { extensions: ['.js', '.ts'], alias: options.alias, conditionNames: options.conditions ?? ['import', 'browser'], + mainFields: options.mainFields ?? ['main'], }) return resolver.resolveSync({}, dirname(entry), request) }, diff --git a/packages/plugin-utils/src/__tests__/alias-directory.test.ts b/packages/plugin-utils/src/__tests__/alias-directory.test.ts new file mode 100644 index 00000000..90cedd7d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-directory.test.ts @@ -0,0 +1,219 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { aliasFixture } from './alias-array-fixture' + +let fixture: ReturnType +beforeEach(() => { + fixture = aliasFixture() +}) +afterEach(() => fixture.dispose()) + +it.each([ + { conditions: ['import'] }, + { conditions: ['require'] }, + { conditions: [] }, +])( + 'reads the installed Next SWC absolute directory module regardless of conditions=%j', + ({ conditions }) => { + // Given the actual helper installation resolved from this benchmark's Next. + const { root, entry, file, installed } = fixture + const next = createRequire( + resolve('benchmark/next-devup-ui/package.json'), + ).resolve('next/package.json') + const helpers = dirname( + createRequire(next).resolve('@swc/helpers/package.json'), + ) + const request = '@swc/helpers/_/_interop_require_wildcard' + const alias = { '@swc/helpers/_': join(helpers, '_') } + const expected = realpathSync( + join(helpers, 'esm/_interop_require_wildcard.js'), + ) + file('src/main.ts', `import '${request}'`) + expect( + installed(request, { alias, conditions, mainFields: ['module', 'main'] }), + ).toBe(expected) + expect( + installed(request, { alias, conditions, mainFields: ['main'] }), + ).toBe(realpathSync(join(helpers, 'cjs/_interop_require_wildcard.cjs'))) + // When the shared resolver and graph follow the same real directory alias. + const selected = createModuleResolver({ cwd: root, alias, conditions })( + request, + entry, + ) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + conditions, + include: ['@swc/helpers'], + }) + // Then both match native module-first resolution and the installed code. + expect(selected).toEqual({ + path: expected, + code: readFileSync(expected, 'utf-8'), + }) + expect(graph.staticImports.get(entry)).toEqual(new Set([expected])) + expect(graph.files).toEqual([entry, expected].sort()) + }, +) + +it.each(['absolute', 'relative'])( + 'uses directory main fields before index and ignores exports when alias is %s', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file( + 'target/package.json', + JSON.stringify({ + module: './module.js', + main: './main.js', + exports: { import: './export.js', require: './absent.js' }, + }), + ) + const chosen = file('target/module.js', "import './leaf'") + file('target/main.js') + file('target/index.js') + file('target/export.js') + const leaf = file('target/leaf.js') + const alias = { + provider: kind === 'absolute' ? join(root, 'target') : '../target', + } + expect( + installed('provider', { alias, mainFields: ['module', 'main'] }), + ).toBe(chosen) + const selected = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + const graph = buildStaticImportGraph('src', undefined, { cwd: root, alias }) + expect(selected).toEqual({ path: chosen, code: "import './leaf'" }) + expect(graph.files).toEqual([entry, chosen, leaf].sort()) + }, +) + +it.each(['main', 'index', 'self', 'nested', 'extension', 'file'])( + 'matches native directory entry fallback when selected target is %s', + (kind) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + file( + 'target/package.json', + JSON.stringify({ + module: + kind === 'self' ? '.' : kind === 'nested' ? './child' : './absent', + main: kind === 'index' || kind === 'self' ? './absent-main' : './main', + }), + ) + const chosen = file( + kind === 'file' + ? 'target.js' + : kind === 'nested' + ? 'target/child/index.js' + : kind === 'index' || kind === 'self' + ? 'target/index.js' + : 'target/main.js', + 'export const selected = 42', + ) + const alias = { provider: join(root, 'target') } + expect( + installed('provider', { alias, mainFields: ['module', 'main'] }), + ).toBe(chosen) + const selected = createModuleResolver({ cwd: root, alias })( + 'provider', + entry, + ) + expect(selected).toEqual({ + path: chosen, + code: 'export const selected = 42', + }) + expect( + buildStaticImportGraph('src', undefined, { cwd: root, alias }).files, + ).toEqual([entry, chosen].sort()) + }, +) + +it.each(['{', '[]', 'null'])( + 'keeps malformed directory manifests fatal before a later false (%s)', + (content) => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = file('target/package.json', content) + file('target/index.js') + const target = join(root, 'target') + if (content === '[]') { + expect(installed('provider', { alias: { provider: target } })).toBe( + join(target, 'index.js'), + ) + } else { + expect(() => + installed('provider', { alias: { provider: target } }), + ).toThrow() + } + const alias = { provider: [target, false] } + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(ConfigLoadError) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${manifest}:1:1: Cannot load configuration`) + }, +) + +it('keeps directory entry cycles fatal and located instead of reaching false', () => { + const { root, entry, file, installed } = fixture + file('src/main.ts', "import 'provider'") + const manifest = file('target/package.json', '{"module":"./child"}') + file('target/child/package.json', '{"module":".."}') + const target = join(root, 'target') + expect(() => + installed('provider', { + alias: { provider: target }, + mainFields: ['module', 'main'], + }), + ).toThrow() + const alias = { provider: [target, false] } + expect(() => + createModuleResolver({ cwd: root, alias })('provider', entry), + ).toThrow(`${manifest}:1:1:`) + expect(() => + buildStaticImportGraph('src', undefined, { cwd: root, alias }), + ).toThrow(`${manifest}:1:1:`) +}) + +it.each([false, true])( + 'skips excluded poisoned directory manifests before IO (all excluded=%s)', + (all) => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const poison = dirname(file('poison/package.json', '{')) + const chosen = file('target/entry.js') + const target = dirname(file('target/package.json', '{"main":"entry.js"}')) + const alias = { provider: [poison, target] } + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias, + exclude: all ? [poison, target] : [poison], + }) + expect(graph.files).toEqual((all ? [entry] : [entry, chosen]).sort()) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) + }, +) + +it('excludes the selected main directory before reading its poisoned manifest', () => { + const { root, entry, file } = fixture + file('src/main.ts', "import 'provider'") + const target = dirname(file('target/package.json', '{"module":"../poison"}')) + const poison = dirname(file('poison/package.json', '{')) + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: target }, + exclude: [poison], + }) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set()) +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 3fe5d115..899c1401 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -565,7 +565,8 @@ export function computeReachableFiles( const queue = opts.entries .map((entry) => resolveFile(resolve(entry))) .filter( - (entry): entry is string => entry !== undefined && fileSet.has(entry), + (entry): entry is string => + typeof entry === 'string' && fileSet.has(entry), ) const reached = new Set() for (let index = 0; index < queue.length; index += 1) { @@ -1052,9 +1053,7 @@ function createModulePathResolver( const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) const extensions = sourceExtensions(includeMdx) const fileResolver = (path: string) => - excludedDirectory(dirname(path)) || excludedDirectory(path) - ? false - : resolveFile(path, extensions) + resolveFile(path, { extensions, excludedDirectory }) return (specifier, importer) => { const from = resolve(cwd, importer) return resolveModuleAlias(specifier, { @@ -1109,14 +1108,7 @@ function resolvePackage( ) return false const manifestFile = join(packageDir, 'package.json') - let manifest: unknown - try { - manifest = JSON.parse(readFileSync(manifestFile, 'utf-8')) - if (!isRecord(manifest) || Array.isArray(manifest)) - throw new TypeError('Expected a package manifest object') - } catch (cause) { - throw new ConfigLoadError(manifestFile, cause) - } + const manifest = readPackageManifest(manifestFile) const found = resolvePackageEntry( packageDir, manifest, @@ -1129,6 +1121,17 @@ function resolvePackage( return found ? realpathSync(found) : found } +function readPackageManifest(file: string): Record { + try { + const manifest: unknown = JSON.parse(readFileSync(file, 'utf-8')) + if (!isRecord(manifest) || Array.isArray(manifest)) + throw new TypeError('Expected a package manifest object') + return manifest + } catch (cause) { + throw new ConfigLoadError(file, cause) + } +} + function findPackage( dir: string, name: string, @@ -1245,15 +1248,48 @@ function resolveAliasCandidates( function resolveFile( candidateBase: string, - extensions: readonly string[] = jsExtensions, -): string | undefined { + options: { + readonly extensions: readonly string[] + readonly excludedDirectory: (directory: string) => boolean + } = { + extensions: jsExtensions, + excludedDirectory: createDirectoryExclusion(), + }, + stack: readonly string[] = [], +): string | false | undefined { + if ( + options.excludedDirectory(dirname(candidateBase)) || + options.excludedDirectory(candidateBase) + ) + return false if (isFile(candidateBase)) return resolve(candidateBase) - for (const jsExtension of extensions) { + for (const jsExtension of options.extensions) { const candidate = `${candidateBase}${jsExtension}` if (isFile(candidate)) return resolve(candidate) } - for (const jsExtension of extensions) { + const manifestFile = join(candidateBase, 'package.json') + if (isFile(manifestFile)) { + const canonical = realpathSync(candidateBase) + if (stack.includes(canonical)) + throw new ConfigLoadError( + manifestFile, + new Error( + `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, + ), + ) + const manifest = readPackageManifest(manifestFile) + for (const main of [manifest.module, manifest.main]) { + if (typeof main !== 'string' || !main || main === '.' || main === './') + continue + const found = resolveFile(join(candidateBase, main), options, [ + ...stack, + canonical, + ]) + if (found !== undefined) return found + } + } + for (const jsExtension of options.extensions) { const candidate = join(candidateBase, `index${jsExtension}`) if (isFile(candidate)) return resolve(candidate) } From c4e8c2e9979ed407550cb973e95eff45cb486bfd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 11:59:37 +0900 Subject: [PATCH 46/50] docs(plugins): explain ignored aliases and guarded MDX boundaries Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bindings/devup-ui-wasm/README.md | 46 +++++++++++- packages/bun-plugin/README.md | 46 ++++++++++-- packages/plugin-utils/README.md | 118 +++++++++++++++++++++++++----- packages/rsbuild-plugin/README.md | 35 +++++++++ packages/vite-plugin/README.md | 37 ++++++++++ packages/webpack-plugin/README.md | 32 ++++++++ 6 files changed, 285 insertions(+), 29 deletions(-) diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 07ace89a..e2167ef6 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -9,8 +9,10 @@ generation, including when a module resolver is installed; its `map` getter is `undefined`. Both functions return transformed source and watched dependencies. `setModuleResolver` accepts a synchronous `(specifier, importer)` callback. -Return `{ path: string, code: string }` for a resolved module. `null` and -`undefined` mean ordinary unresolved imports. Thrown exceptions, property-getter +Return `{ path: string, code: string }` for a resolved module, or +`{ ignored: true }` for an explicitly ignored native alias. An ignored result +has no source/path and never reads `path`, `code` or `sourceType` getters. +`null` and `undefined` mean ordinary unresolved imports. Thrown exceptions, property-getter and reflection failures, and missing/non-string fields instead fail extraction: ```text @@ -24,6 +26,13 @@ extractor's unresolved-module fallback and is checked **before** sheet updates. The previous stylesheet contribution therefore survives a failed re-extraction. Class/file numbering performed inside extraction is not rolled back. +Ignored imports are empty CommonJS modules, not renamed or virtual files. Missing +named values are `undefined`, defaults follow the existing CommonJS interop, and +namespace/require values are empty objects. No ignored file becomes a watched +dependency or is sent through the parser. The `ignored` discriminator must be +`true` or absent; malformed discriminators retain the same located resolver-fault +priority and previous-sheet preservation as malformed file results. + ## Panic diagnostics WASM initialization installs this binding's own `std::panic::set_hook`, with no @@ -51,6 +60,7 @@ repository root: ```sh node bindings/devup-ui-wasm/boundary-regressions.mjs +node bindings/devup-ui-wasm/ignored-alias-regressions.mjs ``` This standalone runner loads the real generated JS and WASM, outside Bun's @@ -60,6 +70,38 @@ callback exceptions, getters, proxy reflection, malformed fields and hostile exception objects. Every fault case verifies the previous sheet is unchanged. Native tests cover every branch of the internal fault-precedence helper. +## Explicit Compiler Source Type + +Both extraction functions accept an optional ninth argument after +`import_aliases`: `sourceType?: 'compiled-mdx'`. Use it for project-compiled MDX +under a custom extension, without changing the real filename: + +```ts +const output = codeExtract( + 'src/page.mdown', compiledCode, '@devup-ui/react', 'df', + true, false, false, {}, 'compiled-mdx', +) +``` + +The value means JavaScript with JSX, not TypeScript or raw Markdown. Omission +keeps filename-derived parsing, including compiled JavaScript/JSX for `.md` and +`.mdx`. Unknown extensions without an override fail with a located error even +when the source has no styling import. Invalid source-type values also fail +before stylesheet publication. + +Resolved modules may return `{ path, code, sourceType: 'compiled-mdx' }`. The +child's own type reaches imported constants, reexports, stylesheet evaluation +and stripping caches; it never inherits the importing file's override. This +includes an ordinary TSX file importing a constant after JSX in compiled `.md` +or `.mdown`. Parser/source-type failures preserve the previous stylesheet. +Existing imported-value coordinate limitations remain owned by #757. + +After rebuilding the bindings, run the real project-compiler/WASM matrix: + +```sh +node bindings/devup-ui-wasm/source-type-regressions.mjs +``` + The same JS runner also uses the installed `@mdx-js/mdx` compiler, resolved through `@mdx-js/loader` in `apps/landing` (no added dependency). Both default `_jsx` output and `jsx: true` output are extracted under real `.mdx` and `.md` filenames. diff --git a/packages/bun-plugin/README.md b/packages/bun-plugin/README.md index 8563cdbf..8f4064ae 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -71,12 +71,14 @@ watch/loading behavior; restart Bun after adding a new styling module or changing `include`. Precompiled libraries must still publish their extracted CSS for consumers to import. -Raw `.mdx` files are compiled asynchronously using the project's optional -`@mdx-js/mdx` installation before extraction. Install that compiler in the -project when authoring Devup UI/compatibility/StyleX code in MDX. Without a -compiler, unrelated MDX is left to another plugin; styling MDX fails with a -located installation diagnostic. Extractor errors use the compiler's source -map when available, otherwise their locations are labeled `in compiled MDX`. +`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such +as `.mdown` are opt-in. Devup compiles every selected file asynchronously using +the project's own `@mdx-js/mdx`, with explicit MDX format, before extraction. +Install that compiler even for plain selected MDX: missing compilation is a +located installation error, not a silent handoff to another plugin. The same +list controls loading, graph discovery and numbering. Compiled JavaScript/JSX +keeps its real filename. Extractor errors use the compiler's source map when +available, otherwise their locations are labeled `in compiled MDX`. `root` defaults to `Bun.build`'s root, or the runtime's working directory. Relative `devupFile` (default `devup.json`), `distDir` (default `df`) and @@ -90,7 +92,37 @@ so runtime watch mode can reload their importers. `Bun.build` takes CSS only after extraction completes. Runtime imports synchronously publish changed CSS to disk; identical revisions skip writes, without delaying read-after-import. -## Custom Shorthands +## MDX Ownership And Public API Limits + +In `Bun.build`, selected MDX entries and selected files reached by literal +imports in modules Devup processes must go through Devup's own loader. A file +claimed first by another plugin is a located ownership error naming its target +and extension. Let Devup compile that extension, or remove it from +`mdxExtensions`. CSS finalization checks after `defer()`; an independent +`onEnd` check covers builds without stylesheet imports. Watch rebuilds that +execute these build callbacks receive the same check. + +Bun does not expose loader chaining or another plugin's final module/resolve +target. A module loaded by another plugin under an extension Devup does not own +is invisible, including definitely used Devup exports. Such placeholders may +throw `Cannot run on the runtime` when rendered. Add the extension to +`mdxExtensions` and let Devup's project compiler load it instead of a separate +MDX plugin. Devup does not reject an indistinguishable harmless module or claim +to inspect another loader's final code. + +Whole namespace/require-result uses and dynamic imports do not prove which +export is used. They remain opaque rather than being conservatively rejected. +Runtime helpers and unused imports are not compile-time-use violations. + +The whole-build ownership guarantee does not apply to `Bun.plugin` runtime +registration: that API has no build `onEnd`/`defer` finalization point. Runtime +loading still compiles files Devup owns. An `onEnd` rejection can occur after +output files are written, even with `throw: false`; publish build artifacts only +after successful completion. Ownership diagnostics use the real importer +`:1:1` as an honest file-level fallback because this callback has no import +span; a structured CSS-load diagnostic may identify the virtual stylesheet. + +## Custom Shorthands To configure custom shorthands, preload a local module instead of the zero-config entry: diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md index b8eb689c..41f74ba0 100644 --- a/packages/plugin-utils/README.md +++ b/packages/plugin-utils/README.md @@ -69,7 +69,11 @@ const prepared = await buildStaticImportGraph('src', undefined, { prepareSource: async (filename) => { if (!filename.endsWith('.mdx') && !filename.endsWith('.mdown')) return undefined const compiled = await compileProjectMarkdown(filename) - return { code: String(compiled.value), map: compiled.map } + return { + code: String(compiled.value), + map: compiled.map, + sourceType: 'compiled-mdx', + } }, }) ``` @@ -80,26 +84,57 @@ real absolute filename once, including discovered providers and eligible included-package dependencies. It returns a string, `{ code: string, map?: unknown }`, `undefined`, or a Promise of those values. Only `undefined` reads raw source; `''` is valid prepared code. Relative imports, keys and diagnostics keep -the actual filename. Prepared JS/JSX bypasses the raw Markdown ESM-block filter, +the actual filename. Prepared code bypasses the raw Markdown ESM-block filter, so compiler-injected provider/remark/rehype imports can be discovered. Configure the project compiler to interpret custom extensions as MDX where required. +Prepared objects and resolved modules accept `sourceType?: 'compiled-mdx'`. +This explicitly selects JavaScript with JSX, including under `.mdown` or a TS +filename; ordinary filename-derived JS/TS grammar is unchanged when omitted. +The extraction functions receive this value as their optional ninth argument. +`.md` and `.mdx` retain their compiled-MDX extractor defaults; a custom extraction +extension without an explicit type is a located error, never a renamed-file +workaround. Invalid values or throwing type getters fail at the preparation +boundary with the real filename/importer and cause. The field follows prepared +resolver generations into WASM, including imported constants after JSX and +reexports, and caches include the selected type. + The graph is an **import scanner, not a full syntax validator**. The caller's compiler and bundler own validation of the same compiled output. No parser -dependency is installed by this package. If optional `oxc-parser` is available, -its prepared-source exceptions/diagnostics fail rather than falling back to raw -source or lexical scanning. Compiler failures retain the real filename and +dependency is installed by this package. **Every edge always comes from one +dependency-free scanner.** If optional `oxc-parser` is available, it supplies +syntax diagnostics only: its AST and `program` getter never determine edges. +Parser exceptions/diagnostics fail rather than falling back to raw source or +changing edge authority. Compiler failures retain the real filename and original cause; setup/hook failures reject the Promise. Parser positions are remapped using `remapMdxError` when a map is supplied; unmapped positions are explicitly labeled `in compiled output`, not presented as Markdown coordinates. -The dependency-free lexer masks ordinary strings, template contents, comments, -regular expressions and JSX text, while preserving import literal arguments and -JSX expression imports. It retains existing type-only elision. It is not a -general JavaScript parser: computed imports, CommonJS `require` discovery and -template-interpolation import discovery are not a new guarantee. The legacy -optional AST/fallback difference for template-interpolation imports is unchanged. -Raw opted-in Markdown scans only ESM blocks, not code fences or prose. +The lexer keeps real filename language context: comparisons/shifts are not JSX, +TSX generic arrows are not tags, and type-only imports are not runtime edges. +Prepared ordinary TS retains its TS grammar; explicit compiled-MDX output uses +JS/JSX grammar even under a TS filename. The lexer masks ordinary strings, outer +template text, comments, regular +expressions and JSX text/quoted attributes. Template `${...}` expressions, +including nested templates, and JSX brace/attribute expressions are real code: +literal `require('...')` adds static edges and literal `import('...')` adds dynamic +edges there just as in ordinary code. Normal imports, side-effect imports and +export-from declarations add static edges; type-only declarations/specifiers are +elided without dropping value bindings named `type`. Escaped specifiers are +decoded without evaluating source. Nonliteral/computed or concatenated arguments +such as `import(name)`, `require(name)` and `import('./' + name)` add no edge. +The scanner is not a full parser; builders retain fail-closed checks for modules +the bundler actually loads but the static graph misses. Raw opted-in Markdown +scans only ESM blocks, not code fences or prose. + +The checked-in differential gate is runnable from the repository root with +`bun test ./packages/plugin-utils/src/__tests__/parser-corpus.test.ts` (and runs +in the root suite). It compares complete graphs for every package `src` root and +`apps/landing/src`, including real landing docs compiled once using the project's +MDX compiler/provider options, with diagnostics disabled and injected. It asserts +one diagnostic invocation per source and zero AST getter reads; independent +targeted fixtures also assert expected literal edges and reject contradictory AST +dependencies. The optional Oxc package is not installed by this gate. ## Synchronous prepared module resolver @@ -138,8 +173,10 @@ Each resolved generation replaces its prior code/map; ordinary raw JS/TS removes its stale map. Keep the resolver for its build/compiler context, replace cache entries when recompiling, and discard it when that context ends. Vite and Bun create build-context resolvers; Webpack creates compiler/loader-context resolvers. -Only Next's prepared-source integration on #752 supplies this hook and wraps -extraction errors; this utility change adds no wrappers to other plugins. +Bun supplies this hook for its own compiled MDX cache and wraps extraction +errors; Next's prepared-source integration on #752 also owns its preparation +and wrapping. Vite, Webpack and Rsbuild do not gain a separate project MDX +preparation cache from this shared API. Every exact prepared-id location in an error is remapped through `remapMdxError`; other file locations are untouched. Missing/uncovered mappings explicitly say @@ -155,7 +192,21 @@ compiler maps; the imported-module end-to-end mapping check runs after #755 and ## Resolver aliases Both `createModuleResolver` and graph options accept an optional readonly -`alias: ModuleAliases` (`Readonly>`). Targets are strings or ordered string candidate arrays: +`alias: ModuleAliasOptions`. The existing `ModuleAliases` map type remains +`Readonly>`. +The option also accepts native `false` (disable aliases) or ordered descriptors: + +```ts +const alias = [ + { name: 'provider', alias: ['first-provider', false], onlyModule: true }, + { name: 'provider$', alias: false, onlyModule: false }, +] as const +``` + +Descriptors preserve declaration order and duplicate names; a literal `$` in +their name remains a prefix when `onlyModule` is false. In the map form, +`provider$` instead means the exact name `provider`. +Targets are strings, `false`, or ordered string/false candidate arrays: absolute file/directory paths or package requests. They run before tsconfig paths, baseUrl and package resolution. @@ -165,20 +216,47 @@ paths, baseUrl and package resolution. key before an overlapping broad key. - Each candidate undergoes full native resolution in order; the first resolving candidate wins. A failed matched key never falls through to the original request - or a later key. All unresolved candidates (including an empty array) produce an + or a later key. All unresolved rewriting candidates produce an importer-located error naming the key and every rewritten candidate tried. - Unaliased unresolved requests still return `undefined`. + Later descriptors with the same name do not bypass that failure. Empty arrays + and selfguard-only entries perform no rewrite, so they continue to the next + descriptor/key and then the raw request. Unaliased unresolved requests still + return `undefined`. - Finite chains resolve; self-aliases are skipped; cycles produce an importer-located failure. +- A reached `false` immediately resolves to `{ ignored: true }`, including when + reached through another string alias. String candidates re-enter the alias map; + a request equal to or below its target is not aliased again. Earlier resolving + candidates win, and newly created earlier files are visible on the next call. - Package exports and active conditions are applied after rewriting. Graph package eligibility uses the actual target package, not the alias name. +- Filesystem directory requests read their own `package.json` and try the shared + `module`, `main`, then index order after file/extension probes. These absolute + or relative directory paths follow native main-field resolution, not a bare + package's exports restriction. Recursive entries honor exclusions before IO; + malformed manifests and directory cycles are located errors. Installed Next's + absolute `@swc/helpers/_` prefix alias is covered with the real helper package. - Export/active-target failures, configuration/I/O errors and cycles are fatal, not ordinary misses that advance to the next candidate. Excluded candidates are skipped before I/O; an entirely excluded list stays excluded, not external. - Project-local aliased providers outside configured source roots can be followed; excluded directories and excluded external packages remain excluded. +Ignored results have no `path`, `code` or `sourceType`. `ModuleResolution` is +`ResolvedModule | IgnoredModule`; narrow the ignored result before reading file +fields. Neither the graph nor the evaluator scans, prepares, watches or numbers +a manufactured ignored file. Physically enumerated real source files retain +their normal numbering. Evaluated ignored imports use an empty CommonJS export +object: missing named values are `undefined`, the default follows existing +CommonJS interop, and namespace/require values are empty objects. + This subset is tested against installed enhanced-resolve 5.25.1 with real files. -Alias `false` (ignore) and wildcard keys are unsupported; `false` is rejected by -the public type. Empty arrays intentionally fail rather than enhanced-resolve's -empty-array fallthrough behavior. +Its `AliasUtils` supports reached false candidates, but its public +`ResolverFactory` currently rejects mixed string/false arrays before that handler; +Webpack therefore stops with its own construction error for those arrays. +Rspack's native map-or-false format accepts mixed false arrays and has the same +terminal-rewrite/empty-array behavior. Webpack passes its native map or ordered +descriptors unchanged through graph, setup and loader resolver/cache; Rsbuild +passes the actual Rspack map-or-false form. Unsupported wildcard alias names fail +with an importer-located configuration error naming the key instead of being +silently dropped. diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index a3a5b724..9c1f7d4a 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -220,6 +220,41 @@ arrival order. `atomHoist` composes its shared CSS cache group with existing groups and creates `splitChunks` when absent; `splitChunks: false` is an error when atom hoisting is requested. +## MDX Selection And Compiled-Source Guards + +The graph and build-time resolver receive the same native Rspack `resolve.alias` +map-or-false option. Rspack accepts ordered mixed string/false candidates: +the first resolving file wins, and a reached false ignores the module. Empty +arrays fall through to the raw request; a real rewriting all-miss remains +terminal. Rspack's public alias type does not accept Webpack-style descriptor +arrays. Unsupported wildcard names fail with the importer/key instead of being +silently dropped. + +`mdxExtensions` defaults to `['.mdx']`; `.md` and custom literal extensions such +as `.mdown` are opt-in. Configure the project MDX loader for the same list. +Devup runs on its compiled JavaScript/JSX in a post-loader, retaining the actual +filename. Extraction, graph discovery and numbering share the selected list. + +With extraction enabled, build, dev-server compilations and watch rebuilds use +Rspack's public used-dependency export IDs and actual module targets. A +definitely named compile-time export used by a module outside the supported +JS/TS or selected MDX extensions produces a located module/import error and an +`mdxExtensions` remedy. Static namespace/require member uses remain guarded when +the dependency names the export. Runtime helpers and unused imports are allowed. +For an intended runtime compatibility alias, set `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Rspack additionally exposes some namespace/require destructuring uses +with `ids = []`, and the installed Rspack may omit export IDs for CommonJS +require members, bound results and destructuring altogether. Some renamed +forwarding cannot be traced through its public export data. Those forms are +also opaque and allowed, even though another +bundler with definite export IDs can reject the same source. No provided-export +query is made at `finishModules`. Hidden compile-time exports can still throw +`Cannot run on the runtime` when rendered; add the extension to `mdxExtensions` +and let Devup compile it. The guard does not expand ordinary JS/TS `include` +selection or change `extractCss: false` publishing. + ## Custom Shorthands ```ts diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a9fe1dac..d662c59f 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -225,6 +225,43 @@ that library, or publish separately imported, precompiled CSS. The real-build matrix is qualified with Vite 8.3.1 / Rolldown 1.2.10; no private adapter or bundler version pin is required. +## MDX Selection And Compiled-Source Guards + +`mdxExtensions` defaults to `['.mdx']`. Add literal extensions such as +`['.mdx', '.md', '.mdown']` only when your project compiler handles them as MDX. +The same list controls post-compiler extraction, graph discovery and numbering. +Compiled output is JavaScript with JSX under its real filename, never renamed +to a JavaScript extension. Configure the MDX compiler separately; Devup runs +after it. Ordinary JavaScript/TypeScript extensions keep their existing handling. + +`DevupUI()` returns the main `devup-ui` plugin, the unchanged +`devup-ui:restore-forwarded-css` plugin, and three independent companions: +`devup-ui:mdx`, `devup-ui:aggregate-css-guard`, and +`devup-ui:compiled-source-guard`. The main plugin remains `enforce: 'pre'`; +the MDX companion is `enforce: 'post'` and only transforms source. + +During builds with extraction enabled, the compiled-source guard inspects final +code through Vite's public parser and resolved module graph. An extension outside +the JavaScript/TypeScript and MDX lists fails with its module, used import and +`mdxExtensions` remedy when a compile-time export is definitely referenced by +name, static namespace/member access or destructuring. Runtime helpers such as +`getTheme`, `setTheme`, `initTheme`, `useTheme` and `ThemeScript`, and unused +imports, are allowed. An active compatibility alias can be disabled with +`importAliases[package] = false` when runtime use is intended. + +Whole namespace/require-result objects and dynamic imports are opaque and are +not rejected; compile-time exports reached only that way may still throw +`Cannot run on the runtime` when rendered. Let Devup transform the extension +instead. Ordinary JS/TS excluded by `include`, and intentional +`extractCss: false` publishing, are not broadened by this guard. + +The complete forwarding graph is finalized by build hooks, not available as an +equivalent completed graph in the dev server. This guard is build-only, including +build-watch compilations; unselected extensions in dev may therefore retain +placeholders and throw when rendered. Add the extension to `mdxExtensions` and +configure its compiler for both dev and build. Vite's native alias format has +no `false` target. + ## Custom Shorthands Custom shorthands are build-plugin options, not theme tokens. Every target diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 42009afc..598d992f 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -196,6 +196,38 @@ output is not standalone browser JavaScript: without this consumer setup, the component placeholders throw `Cannot run on the runtime`. No styling runtime is added by either supported publishing mode. +## MDX Selection And Compiled-Source Guards + +Native `resolve.alias` maps or ordered descriptors are passed unchanged to the +shared graph, build-time resolver and loader; resolver cache identity includes +their order and values. Duplicate descriptors and literal-dollar names retain +native semantics. Empty candidate arrays perform no rewrite and fall through; +an actual rewriting all-miss is terminal. The installed enhanced-resolve factory +rejects mixed string/false arrays before its alias handler, so Webpack reports +its own construction error for those arrays. Wildcard names the shared resolver +cannot represent are located configuration errors, never silently dropped. + +`mdxExtensions` defaults to `['.mdx']`. Configure a project MDX loader for every +literal extension you opt into, for example `['.mdx', '.mdown']`; `.md` is not +selected by default. Devup's post-loader extracts compiler-produced JavaScript +with JSX under the actual filename. Graph discovery and numbering use the same +selection as extraction. + +With extraction enabled, the guard runs after loaders in build, development +compilations and watch rebuilds. Public parser facts, used dependency export IDs +and actual module targets identify definitely used compile-time exports in +modules outside the supported JS/TS and selected MDX extensions. Named bindings, +static namespace/require-result members and destructuring with known export IDs +produce a located module/import error and an `mdxExtensions` remedy. Runtime +helpers and unused imports are allowed; an intended runtime compatibility alias +can be disabled through `importAliases[package] = false`. + +Whole namespace/require-result objects and dynamic imports are opaque and are +allowed. Compile-time exports reached only through these opaque forms may throw +`Cannot run on the runtime` when rendered; let Devup compile the extension +instead. The guard does not expand extraction to ordinary JS/TS excluded by +`include` or change intentional uncompiled-library publishing. + ## Custom Shorthands ```ts From c07d6c4991458d0a7e1676db4789e96db863554b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 19:19:05 +0900 Subject: [PATCH 47/50] feat(plugin-utils): expose deterministic raw import request evidence Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/request-evidence.test.ts | 229 ++++++++++++++++ .../src/__tests__/request-source.test.ts | 227 ++++++++++++++++ .../plugin-utils/src/directory-exclusion.ts | 30 ++- packages/plugin-utils/src/import-graph.ts | 250 ++++++++++++------ packages/plugin-utils/src/import-requests.ts | 60 +++++ packages/plugin-utils/src/import-scanner.ts | 75 +++++- packages/plugin-utils/src/index.ts | 6 + 7 files changed, 775 insertions(+), 102 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/request-evidence.test.ts create mode 100644 packages/plugin-utils/src/__tests__/request-source.test.ts create mode 100644 packages/plugin-utils/src/import-requests.ts diff --git a/packages/plugin-utils/src/__tests__/request-evidence.test.ts b/packages/plugin-utils/src/__tests__/request-evidence.test.ts new file mode 100644 index 00000000..3339c6f4 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-evidence.test.ts @@ -0,0 +1,229 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('retains four request kinds, written escapes and duplicate token positions', () => { + // Given distinct literal occurrences resolving to the same module. + const source = String.raw`import './\x61'; +export * from './a'; +import('./a'); require('./a'); import './a';` + const entry = file('src/page.ts', source) + const leaf = file('src/a.ts') + // When building the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then every occurrence survives with genuine quote-token coordinates. + expect(graph.requests).toEqual( + [ + ['static-import', './\\x61', 7, 1, 8], + ['re-export', './a', 31, 2, 15], + ['literal-dynamic-import', './a', 45, 3, 8], + ['literal-require', './a', 61, 3, 24], + ['static-import', './a', 76, 3, 39], + ].map(([kind, request, offset, line, column]) => ({ + importer: entry, + kind, + request, + specifier: './a', + position: { offset, line, column }, + source: 'source', + outcome: { kind: 'resolved', path: leaf }, + })), + ) + expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) + expect(graph.dynamicImports.get(entry)).toEqual(new Set([leaf])) + expect(Object.isFrozen(graph.requests)).toBe(true) + expect( + graph.requests.every( + (request) => + Object.isFrozen(request) && + Object.isFrozen(request.position) && + Object.isFrozen(request.outcome), + ), + ).toBe(true) +}) + +it('reports external, unresolved and ignored boundaries without inventing files', () => { + // Given requests that legacy traversal does not follow. + const entry = file( + 'src/main.ts', + "import 'external'; import './missing'; require('ignored')", + ) + // When resolving an explicit native false alias. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { ignored: false }, + }) + // Then the three successful observations are distinct. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'external', request: 'external' }, + { kind: 'unresolved' }, + { kind: 'ignored' }, + ]) + expect(graph.files).toEqual([entry]) + expect(graph.externalImports?.get(entry)).toEqual(new Set(['external'])) +}) + +it.each(['bare', 'absolute', 'nested', 'alias', 'package', 'directory-entry'])( + 'retains the actual matched exclusion entry for %s', + (mode) => { + // Given a poisoned target that must never be read or followed. + const poison = file('blocked/nested/index.ts', "import 'bad'") + file('node_modules/blocked/package.json', '{') + const exclusion = + mode === 'bare' || mode === 'nested' ? 'blocked' : join(root, 'blocked') + const request = + mode === 'absolute' + ? poison.replaceAll('\\', '/') + : mode === 'alias' + ? 'provider' + : mode === 'package' + ? 'blocked' + : mode === 'directory-entry' + ? '../container' + : '../blocked/nested/index' + if (mode === 'directory-entry') + file('container/package.json', '{"main":"../blocked/nested/index.ts"}') + const excludes = + mode === 'package' ? [join(root, 'node_modules/blocked')] : [exclusion] + const entry = file('src/main.ts', `import '${request}'`) + // When resolving with the existing exclusion matcher. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: excludes, + alias: { provider: poison }, + }) + // Then evidence names the original matched entry, not a fabricated target. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: excludes[0] }, + ]) + expect(graph.files).toEqual([entry]) + }, +) + +it('orders requests by importer then offset rather than discovery order', () => { + // Given a followed alias target whose name sorts before the source root. + const entry = file('src/z.ts', "import 'provider'; import('external')") + const target = file('outside/a.ts', "require('other')") + // When traversal discovers the target after the entry. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: target }, + }) + // Then stable evidence ordering is independent of traversal append order. + expect( + graph.requests.map(({ importer, position }) => [importer, position.offset]), + ).toEqual([ + [target, 8], + [entry, 7], + [entry, 26], + ]) +}) + +it('retains an exclusion reached only after canonical package resolution', () => { + // Given a package symlink whose real target is excluded. + const manifest = file('blocked/pkg/package.json', '{"main":"index.js"}') + file('blocked/pkg/index.js', "import 'poison'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/linked'), + 'junction', + ) + const entry = file('src/main.ts', "import 'linked'") + // When native package resolution canonicalizes the target. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + exclude: [dirname(manifest)], + include: ['linked'], + }) + // Then the post-resolution exclusion names the actual matcher entry. + expect(graph.requests.map(({ outcome }) => outcome)).toEqual([ + { kind: 'excluded', entry: dirname(manifest) }, + ]) + expect(graph.files).toEqual([entry]) +}) + +it('keeps the original thrown object untouched and stores only deterministic failing-edge evidence', () => { + // Given an I/O failure at a real resolution boundary. + const entry = file('src/main.ts', "import './leaf'") + const leaf = file('src/leaf.ts') + const cause = new Error('original cause') + const original = Object.freeze(new Error('original failure', { cause })) + const originalStat = fs.statSync + const stat = spyOn(fs, 'statSync').mockImplementation( + new Proxy(originalStat, { + apply(target, thisArg, args) { + if (args[0] === leaf) throw original + return Reflect.apply(target, thisArg, args) + }, + }), + ) + try { + const evidence = [] + for (let run = 0; run < 2; run += 1) { + // When the unchanged resolver throws. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { cwd: root }) + } catch (error) { + caught = error + } + // Then identity, cause and the failing occurrence are preserved. + expect(caught).toBe(original) + expect(original.cause).toBe(cause) + expect(Object.keys(original)).toEqual([]) + const failure = importGraphFailureOf(caught) + expect(failure).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 7, line: 1, column: 8 }, + source: 'source', + outcome: { kind: 'error', error: original }, + }, + ]) + evidence.push(failure) + } + expect(evidence[0]).toEqual(evidence[1]) + expect(importGraphFailureOf(new Error('unrelated'))).toBeUndefined() + expect(importGraphFailureOf(null)).toBeUndefined() + expect(importGraphFailureOf('primitive')).toBeUndefined() + expect(importGraphFailureOf(() => undefined)).toBeUndefined() + } finally { + stat.mockRestore() + } +}) + +it('records alias resolution errors without changing their located diagnostic', () => { + // Given a terminal alias miss. + const entry = file('src/main.ts', "export * from 'provider'") + // When the existing resolver rejects the rewritten candidate. + let caught: unknown + try { + buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { provider: './absent' }, + }) + } catch (error) { + caught = error + } + // Then the original error is the same object exposed as evidence. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)?.[0]).toMatchObject({ + importer: entry, + kind: 're-export', + request: 'provider', + outcome: { kind: 'error', error: caught }, + }) +}) diff --git a/packages/plugin-utils/src/__tests__/request-source.test.ts b/packages/plugin-utils/src/__tests__/request-source.test.ts new file mode 100644 index 00000000..b10f42dc --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-source.test.ts @@ -0,0 +1,227 @@ +import { expect, it } from 'bun:test' + +import { scanImports } from '../import-scanner' +import { buildStaticImportGraph, importGraphFailureOf } from '../index' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('preserves raw Markdown offsets through prose, fences, comments and CRLF', () => { + // Given an ESM block after masked Markdown. + const source = + '# Heading\r\n\r\n```js\r\nimport "fake"\r\n```\r\n\r\n\r\nimport "./leaf"\r\n\r\nProse' + const entry = file('src/page.mdx', source) + const leaf = file('src/leaf.ts') + // When scanning raw opted-in Markdown. + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + }) + // Then coordinates refer to the actual source, not compacted ESM blocks. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 66, line: 8, column: 8 }, + source: 'source', + outcome: { kind: 'resolved', path: leaf }, + }, + ]) +}) + +it.each([ + undefined, + { version: 3, sources: ['original.mdx'], names: [], mappings: 'AAAA' }, +])('labels prepared token positions honestly with map %j', async (map) => { + // Given different raw and compiled text under the real Markdown filename. + const entry = file('src/page.mdx', '# Raw') + const leaf = file('src/leaf.ts') + const visits: string[] = [] + // When the unchanged asynchronous driver prepares actual JavaScript. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => { + visits.push(filename) + return filename === entry + ? { code: '\nimport "./leaf"', sourceType: 'compiled-mdx', map } + : undefined + }, + }) + // Then compiled coordinates and caller-owned map are not claimed as raw positions. + expect(graph.requests).toEqual([ + { + importer: entry, + request: './leaf', + specifier: './leaf', + kind: 'static-import', + position: { offset: 8, line: 2, column: 8 }, + source: 'compiled', + map, + outcome: { kind: 'resolved', path: leaf }, + }, + ]) + expect(visits.sort()).toEqual(graph.files) +}) + +it.each(['.js', '.tsx', '.ts'])( + 'preserves scanner masks and type-only/nonliteral parity in %s', + (extension) => { + // Given text, expressions, type syntax and nonliteral calls. + const source = + extension === '.js' + ? 'const x = a < b; import("external"); require(name)' + : extension === '.tsx' + ? 'const x =
import("fake"){require("external")}
; import type { T } from "types"' + : 'type T = import("types").T; const x = a; import("external"); import(name)' + const entry = file(`src/main${extension}`, source) + // When the graph uses the same lexer context as the legacy scanner. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then only the runtime literal occurrence survives and old object shape is exact. + expect(graph.requests.map(({ specifier }) => specifier)).toEqual([ + 'external', + ]) + expect( + scanImports(source, extension === '.tsx', extension !== '.js'), + ).toEqual([ + { + kind: extension === '.tsx' ? 'static' : 'dynamic', + specifier: 'external', + }, + ]) + expect(graph.requests[0]?.importer).toBe(entry) + }, +) + +it('keeps explicit compiled-MDX JavaScript grammar under a TS filename', async () => { + // Given JavaScript using the value identifier type with JSX. + const entry = file('src/main.ts') + // When preparation explicitly selects JS/JSX. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => ({ + code: 'const type = 1; const x =
{import("external")}
', + sourceType: 'compiled-mdx', + }), + }) + // Then the actual compiled literal is retained under its real filename. + expect( + graph.requests.map(({ importer, specifier, source }) => ({ + importer, + specifier, + source, + })), + ).toEqual([{ importer: entry, specifier: 'external', source: 'compiled' }]) +}) + +it('does not invent failed-edge evidence for preparation errors before any request', async () => { + // Given a hook failure before scanning. + file('src/page.mdx') + // When the established driver wraps the compiler failure. + const failure = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: () => { + throw new Error('compiler') + }, + }).catch((error: unknown) => error) + // Then no synthetic scanner edge is attributed to it. + expect(importGraphFailureOf(failure)).toBeUndefined() +}) + +it.each([ + ['\r\n', 19], + ['\r', 18], + ['\n', 18], + ['\u2028', 18], + ['\u2029', 18], +] as const)( + 'locates raw requests after line terminator %j', + (separator, offset) => { + // Given actual source line terminators, not literal backslash escapes. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', source) + // When scanning the raw source through the public graph. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then independently known coordinates and legacy selection are preserved. + expect(graph.requests).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset, line: 2, column: 8 }, + source: 'source', + outcome: { kind: 'external', request: 'external' }, + }, + ]) + expect(scanImports(source, false)).toEqual([ + { kind: 'static', specifier: 'external' }, + ]) + }, +) + +it.each(['\u2028', '\u2029'])( + 'locates prepared requests after Unicode line terminator %j', + async (separator) => { + // Given compiled text with an actual Unicode line terminator. + const entry = file('src/main.ts', 'const raw = 1;') + // When preparation supplies the exact audit witness. + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + prepareSource: () => `const n=0;${separator}import 'external';`, + }) + // Then the position describes compiled text, not the raw file. + expect(graph.requests[0]).toMatchObject({ + importer: entry, + request: 'external', + position: { offset: 18, line: 2, column: 8 }, + source: 'compiled', + outcome: { kind: 'external', request: 'external' }, + }) + }, +) + +it.each([ + ['\u2028', false], + ['\u2029', false], + ['\u2028', true], + ['\u2029', true], +] as const)( + 'locates failing requests after Unicode line terminator %j with preparation %j', + async (separator, prepared) => { + // Given a terminal alias miss in raw or compiled witness text. + const source = `const n=0;${separator}import 'external';` + const entry = file('src/main.ts', prepared ? 'const raw = 1;' : source) + // When the existing resolver rejects the requested alias. + let caught: unknown + try { + await buildStaticImportGraph('src', undefined, { + cwd: root, + alias: { external: './absent' }, + ...(prepared ? { prepareSource: () => source } : {}), + }) + } catch (error) { + caught = error + } + // Then failing-edge evidence retains the independently known position. + expect(caught).toBeInstanceOf(Error) + expect(importGraphFailureOf(caught)).toEqual([ + { + importer: entry, + request: 'external', + specifier: 'external', + kind: 'static-import', + position: { offset: 18, line: 2, column: 8 }, + source: prepared ? 'compiled' : 'source', + ...(prepared ? { map: undefined } : {}), + outcome: { kind: 'error', error: caught }, + }, + ]) + }, +) diff --git a/packages/plugin-utils/src/directory-exclusion.ts b/packages/plugin-utils/src/directory-exclusion.ts index 9b80c24d..1a515c89 100644 --- a/packages/plugin-utils/src/directory-exclusion.ts +++ b/packages/plugin-utils/src/directory-exclusion.ts @@ -3,7 +3,9 @@ import { posix, win32 } from 'node:path' export function createDirectoryExclusion( exclude: readonly string[] = [], platform: string = process.platform, -): (directory: string) => boolean { +): ((directory: string) => boolean) & { + readonly match: (directory: string) => string | undefined +} { const windows = platform === 'win32' const path = windows ? win32 : posix const normalize = (value: string) => { @@ -13,18 +15,26 @@ export function createDirectoryExclusion( const trimmed = normalized.replace(windows ? /\\+$/ : /\/+$/, '') return windows ? trimmed.toLowerCase() : trimmed } - const names = new Set( - exclude.filter((entry) => !path.isAbsolute(entry)).map(normalize), + const names = new Map( + exclude + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => [normalize(entry), entry]), ) const absolute = exclude .filter((entry) => path.isAbsolute(entry)) - .map(normalize) - return (directory) => { + .map((entry) => ({ normalized: normalize(entry), entry })) + const match = (directory: string) => { const candidate = normalize(directory) - if (candidate.split(windows ? '\\' : '/').some((part) => names.has(part))) - return true - return absolute.some( - (root) => candidate === root || candidate.startsWith(root + path.sep), - ) + for (const part of candidate.split(windows ? '\\' : '/')) { + const entry = names.get(part) + if (entry !== undefined) return entry + } + return absolute.find( + ({ normalized }) => + candidate === normalized || candidate.startsWith(normalized + path.sep), + )?.entry } + return Object.assign((directory: string) => match(directory) !== undefined, { + match, + }) } diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 899c1401..a8cc73dd 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -10,7 +10,16 @@ import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { createDirectoryExclusion } from './directory-exclusion' -import { type ImportReference, scanImports } from './import-scanner' +import { + freezeImportRequests, + type ImportGraphRequest, + type ImportRequestOutcome, + retainImportGraphFailure, +} from './import-requests' +import { + type ImportRequestReference, + scanImportRequests, +} from './import-scanner' import { ConfigLoadError } from './load-config' import { remapMdxError } from './mdx-errors' import { @@ -95,6 +104,7 @@ let cachedOxcParser: false | OxcParser | undefined * hoisting reads and parses every source file only ONCE per build. */ export interface StaticImportGraph { + readonly requests?: readonly ImportGraphRequest[] /** absolute paths, sorted by POSIX path relative to `srcDir`. */ files: string[] fileSet: Set @@ -119,6 +129,10 @@ export interface StaticImportGraph { externalImports?: Map> } +export interface ScannedStaticImportGraph extends StaticImportGraph { + readonly requests: readonly ImportGraphRequest[] +} + /** * Scan `srcDir` once (`listSourceFiles` -> read -> parse imports -> resolve) * and return the full static import graph. All three graph consumers accept @@ -128,17 +142,17 @@ export function buildStaticImportGraph( srcDir: string | string[], tsconfigPath: string | undefined, options: PreparedGraphOptions, -): Promise +): Promise export function buildStaticImportGraph( srcDir: string | string[], tsconfigPath?: string, options?: SyncGraphOptions, -): StaticImportGraph +): ScannedStaticImportGraph export function buildStaticImportGraph( srcDir: string | string[], tsconfigPath?: string, options: SyncGraphOptions | PreparedGraphOptions = {}, -): StaticImportGraph | Promise { +): ScannedStaticImportGraph | Promise { if (options.prepareSource) return drivePreparedGraph(srcDir, tsconfigPath, options) const traversal = traverseGraph(srcDir, tsconfigPath, options) @@ -187,7 +201,7 @@ async function drivePreparedGraph( srcDir: string | string[], tsconfigPath: string | undefined, options: PreparedGraphOptions, -): Promise { +): Promise { const traversal = traverseGraph(srcDir, tsconfigPath, options) let step = traversal.next() while (!step.done) { @@ -216,7 +230,7 @@ function* traverseGraph( srcDir: string | string[], tsconfigPath: string | undefined, options: StaticImportGraphOptions, -): Generator { +): Generator { const cwd = resolve(options.cwd ?? process.cwd()) const roots = (typeof srcDir === 'string' ? [srcDir] : srcDir).map((dir) => resolve(cwd, dir), @@ -227,7 +241,16 @@ function* traverseGraph( ), ].sort(compareCodePoints) const fileSet = new Set(files) - const excludedDirectory = createDirectoryExclusion(options.exclude) + const directoryExclusion = createDirectoryExclusion(options.exclude) + const exclusionObservation: { outcome: ImportRequestOutcome } = { + outcome: { kind: 'unresolved' }, + } + const excludedDirectory = (directory: string) => { + const entry = directoryExclusion.match(directory) + if (entry !== undefined) + exclusionObservation.outcome = { kind: 'excluded', entry } + return entry !== undefined + } const resolver = createModulePathResolver( { cwd, @@ -246,6 +269,7 @@ function* traverseGraph( const dynamicImports = new Map>() const dynamicTargets = new Set() const externalImports = new Map>() + const requests: ImportGraphRequest[] = [] for (const file of files) { staticImporters.set(file, new Set()) @@ -268,85 +292,130 @@ function* traverseGraph( typeof prepared === 'object' ? prepared.sourceType : undefined, ) for (const importRef of imports) { - const resolution = resolver(importRef.specifier, file) - if (resolution === false) continue - if (resolution?.ignored === true) continue - const resolved = resolution?.path - if (resolved && excludedDirectory(dirname(resolved))) continue - const rewritten = resolution?.request ?? importRef.specifier - const local = - resolved !== undefined && - isInsideDir(cwd, resolved) && - !relative(cwd, resolved).split(/[\\/]/).includes('node_modules') - if (resolved && local && rewritten !== importRef.specifier) - localAliasRoots.add(dirname(resolved)) - if ( - resolved && - !importRef.specifier.startsWith('.') && - !isAbsolute(importRef.specifier) - ) { - const request = rewritten - const parts = request.split('/') - const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') - if (!excluded.test(`node_modules/${name}/`)) { - const dir = findPackage(dirname(file), name) - if (dir) includedRoots.add(realpathSync(dir)) + const request = { + importer: file, + request: importRef.request, + specifier: importRef.specifier, + kind: importRef.requestKind, + position: importRef.position, + source: code === undefined ? 'source' : 'compiled', + ...(code === undefined + ? {} + : { map: typeof prepared === 'object' ? prepared.map : undefined }), + } satisfies Omit + exclusionObservation.outcome = { kind: 'unresolved' } + try { + const resolution = resolver(importRef.specifier, file) + if (resolution === false) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue } - if (isAbsolute(request) && !local && !excluded.test(resolved)) { - let directory = dirname(resolved) - while ( - dirname(directory) !== directory && - !isFile(join(directory, 'package.json')) - ) - directory = dirname(directory) - if (isFile(join(directory, 'package.json'))) - includedRoots.add(realpathSync(directory)) + if (resolution?.ignored === true) { + requests.push({ ...request, outcome: { kind: 'ignored' } }) + continue } - } - const target = - resolved && - isSelectedSource(resolved, options.includeMdx) && - (fileSet.has(resolved) || - roots.some( - (root) => - isInsideDir(root, resolved) && - !relative(root, resolved).split(/[\\/]/).includes('node_modules'), - ) || - (local && - [...localAliasRoots].some((root) => isInsideDir(root, resolved))) || - (!excluded.test(resolved) && - [...includedRoots].some((root) => isInsideDir(root, resolved)))) - ? resolved - : undefined - if (!target) { + const resolved = resolution?.path + if (resolved && excludedDirectory(dirname(resolved))) { + requests.push({ ...request, outcome: exclusionObservation.outcome }) + continue + } + requests.push({ + ...request, + outcome: + resolved !== undefined + ? { kind: 'resolved', path: resolved } + : !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ? { kind: 'external', request: importRef.specifier } + : { kind: 'unresolved' }, + }) + const rewritten = resolution?.request ?? importRef.specifier + const local = + resolved !== undefined && + isInsideDir(cwd, resolved) && + !relative(cwd, resolved).split(/[\\/]/).includes('node_modules') + if (resolved && local && rewritten !== importRef.specifier) + localAliasRoots.add(dirname(resolved)) if ( + resolved && !importRef.specifier.startsWith('.') && - !importRef.specifier.startsWith('/') && !isAbsolute(importRef.specifier) ) { - externalImports.get(file)?.add(importRef.specifier) + const request = rewritten + const parts = request.split('/') + const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') + if (!excluded.test(`node_modules/${name}/`)) { + const dir = findPackage(dirname(file), name) + if (dir) includedRoots.add(realpathSync(dir)) + } + if (isAbsolute(request) && !local && !excluded.test(resolved)) { + let directory = dirname(resolved) + while ( + dirname(directory) !== directory && + !isFile(join(directory, 'package.json')) + ) + directory = dirname(directory) + if (isFile(join(directory, 'package.json'))) + includedRoots.add(realpathSync(directory)) + } } - continue - } - if (!fileSet.has(target)) { - fileSet.add(target) - files.push(target) - staticImporters.set(target, new Set()) - staticImports.set(target, new Set()) - dynamicImports.set(target, new Set()) - externalImports.set(target, new Set()) - } - if (importRef.kind === 'dynamic') { - dynamicTargets.add(target) - dynamicImports.get(file)?.add(target) - continue + const target = + resolved && + isSelectedSource(resolved, options.includeMdx) && + (fileSet.has(resolved) || + roots.some( + (root) => + isInsideDir(root, resolved) && + !relative(root, resolved) + .split(/[\\/]/) + .includes('node_modules'), + ) || + (local && + [...localAliasRoots].some((root) => + isInsideDir(root, resolved), + )) || + (!excluded.test(resolved) && + [...includedRoots].some((root) => isInsideDir(root, resolved)))) + ? resolved + : undefined + if (!target) { + if ( + !importRef.specifier.startsWith('.') && + !importRef.specifier.startsWith('/') && + !isAbsolute(importRef.specifier) + ) { + externalImports.get(file)?.add(importRef.specifier) + } + continue + } + if (!fileSet.has(target)) { + fileSet.add(target) + files.push(target) + staticImporters.set(target, new Set()) + staticImports.set(target, new Set()) + dynamicImports.set(target, new Set()) + externalImports.set(target, new Set()) + } + if (importRef.kind === 'dynamic') { + dynamicTargets.add(target) + dynamicImports.get(file)?.add(target) + continue + } + staticImporters.get(target)?.add(file) + staticImports.get(file)?.add(target) + } catch (error) { + retainImportGraphFailure(error, { + ...request, + outcome: { kind: 'error', error }, + }) + throw error } - staticImporters.get(target)?.add(file) - staticImports.get(file)?.add(target) } } return { + requests: freezeImportRequests(requests), files: files.sort(compareCodePoints), fileSet, staticImports, @@ -853,9 +922,12 @@ export function listSourceFiles( ) } -function parseImports(filename: string, source: string): ImportReference[] { +function parseImports( + filename: string, + source: string, +): ImportRequestReference[] { if (!SOURCE_FILE_RE.test(filename)) - return scanImports(mdxEsmSource(source), false, false) + return scanImportRequests(mdxEsmSource(source), false, false) return parseSourceImports(filename, source) } @@ -864,7 +936,7 @@ function parsePreparedImports( source: string, map: unknown, sourceType?: import('./prepared-source').SourceType, -): ImportReference[] { +): ImportRequestReference[] { return parseSourceImports(filename, source, { map, sourceType }) } @@ -875,7 +947,7 @@ function parseSourceImports( readonly map: unknown readonly sourceType?: import('./prepared-source').SourceType }, -): ImportReference[] { +): ImportRequestReference[] { const parser = getOxcParser() try { const ast = parser?.parseSync( @@ -902,7 +974,7 @@ function parseSourceImports( { cause }, ) } - return scanImports( + return scanImportRequests( source, prepared?.sourceType === 'compiled-mdx' || /\.[jt]sx$/i.test(filename) || @@ -916,11 +988,13 @@ function mdxEsmSource(source: string): string { let fence = '' let esm = false let comment = false - for (const line of source.split(/\r?\n/)) { + for (const line of source.split(/(?<=\n)/)) { + const blank = line.replace(/[^\r\n]/g, ' ') if (line.includes('')) comment = false esm = false + blocks.push(blank) continue } const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1] @@ -929,18 +1003,22 @@ function mdxEsmSource(source: string): string { else if (marker[0] === fence[0] && marker.length >= fence.length) fence = '' esm = false + blocks.push(blank) + continue + } + if (fence) { + blocks.push(blank) continue } - if (fence) continue if (!line.trim()) { esm = false - blocks.push('') + blocks.push(blank) continue } if (/^(?:import\s+(?!\()|export\s+)/.test(line)) esm = true - if (esm) blocks.push(line) + blocks.push(esm ? line : blank) } - return blocks.join('\n') + return blocks.join('') } function getOxcParser(): OxcParser | undefined { @@ -1045,7 +1123,9 @@ function createModulePathResolver( alias = {}, includeMdx, }: CreateModuleResolverOptions = {}, - excludedDirectory = createDirectoryExclusion(), + excludedDirectory: ( + directory: string, + ) => boolean = createDirectoryExclusion(), ): ( specifier: string, importer: string, diff --git a/packages/plugin-utils/src/import-requests.ts b/packages/plugin-utils/src/import-requests.ts new file mode 100644 index 00000000..fbf25b50 --- /dev/null +++ b/packages/plugin-utils/src/import-requests.ts @@ -0,0 +1,60 @@ +import type { ImportRequestReference } from './import-scanner' + +export type ImportRequestOutcome = + | { readonly kind: 'resolved'; readonly path: string } + | { readonly kind: 'external'; readonly request: string } + | { readonly kind: 'ignored' } + | { readonly kind: 'excluded'; readonly entry: string } + | { readonly kind: 'unresolved' } + | { readonly kind: 'error'; readonly error: unknown } + +export interface ImportGraphRequest { + readonly importer: string + readonly request: string + readonly specifier: string + readonly kind: ImportRequestReference['requestKind'] + readonly position: ImportRequestReference['position'] + readonly source: 'source' | 'compiled' + readonly map?: unknown + readonly outcome: ImportRequestOutcome +} + +const failures = new WeakMap() + +export function importGraphFailureOf( + error: unknown, +): readonly ImportGraphRequest[] | undefined { + return (typeof error === 'object' && error !== null) || + typeof error === 'function' + ? failures.get(error) + : undefined +} + +export function retainImportGraphFailure( + error: unknown, + request: ImportGraphRequest, +): void { + if ( + (typeof error === 'object' && error !== null) || + typeof error === 'function' + ) + failures.set(error, freezeImportRequests([request])) +} + +export function freezeImportRequests( + requests: ImportGraphRequest[], +): readonly ImportGraphRequest[] { + requests.sort((a, b) => + a.importer < b.importer + ? -1 + : a.importer > b.importer + ? 1 + : a.position.offset - b.position.offset, + ) + for (const request of requests) { + Object.freeze(request.position) + Object.freeze(request.outcome) + Object.freeze(request) + } + return Object.freeze(requests) +} diff --git a/packages/plugin-utils/src/import-scanner.ts b/packages/plugin-utils/src/import-scanner.ts index 0672a1ef..71d18f20 100644 --- a/packages/plugin-utils/src/import-scanner.ts +++ b/packages/plugin-utils/src/import-scanner.ts @@ -5,6 +5,17 @@ export interface ImportReference { readonly specifier: string } +export interface ImportRequestReference extends ImportReference { + readonly requestKind: + 'static-import' | 're-export' | 'literal-dynamic-import' | 'literal-require' + readonly request: string + readonly position: { + readonly offset: number + readonly line: number + readonly column: number + } +} + function literalValue(raw: string): string { return raw .slice(1, -1) @@ -62,17 +73,58 @@ export function scanImports( jsx: boolean, typescript = true, ): ImportReference[] { + return scanImportRequests(source, jsx, typescript).map( + ({ kind, specifier }) => ({ + kind, + specifier, + }), + ) +} + +export function scanImportRequests( + source: string, + jsx: boolean, + typescript = true, +): ImportRequestReference[] { const tokens = [ ...maskImportText(source, jsx, typescript).matchAll( /(['"])[^'"]*\1|[\w$]+|[^\s]/g, ), ].map((match) => ({ value: match[0], + offset: match.index, literal: match[1] ? literalValue(source.slice(match.index, match.index + match[0].length)) : undefined, })) - const imports: ImportReference[] = [] + const imports: ImportRequestReference[] = [] + function reference( + token: (typeof tokens)[number] & { readonly literal: string }, + requestKind: ImportRequestReference['requestKind'], + ): ImportRequestReference { + const before = source.slice(0, token.offset) + return { + kind: requestKind === 'literal-dynamic-import' ? 'dynamic' : 'static', + specifier: token.literal, + requestKind, + request: source.slice( + token.offset + 1, + token.offset + token.value.length - 1, + ), + position: { + offset: token.offset, + line: before.split(/\r\n|[\r\n\u2028\u2029]/).length, + column: + token.offset - + Math.max( + before.lastIndexOf('\n'), + before.lastIndexOf('\r'), + before.lastIndexOf('\u2028'), + before.lastIndexOf('\u2029'), + ), + }, + } + } for (let index = 0; index < tokens.length; index += 1) { const word = tokens[index].value if ( @@ -85,15 +137,19 @@ export function scanImports( const argument = tokens[index + 2]?.literal const end = tokens[index + 3]?.value if (argument && (end === ')' || (word === 'import' && end === ','))) - imports.push({ - kind: word === 'require' ? 'static' : 'dynamic', - specifier: argument, - }) + imports.push( + reference( + { ...tokens[index + 2], literal: argument }, + word === 'require' ? 'literal-require' : 'literal-dynamic-import', + ), + ) continue } if (word === 'require') continue if (word === 'import' && next?.literal) { - imports.push({ kind: 'static', specifier: next.literal }) + imports.push( + reference({ ...next, literal: next.literal }, 'static-import'), + ) continue } const clause: string[] = [] @@ -111,7 +167,12 @@ export function scanImports( !(clause[0] === 'type' && clause.length > 1 && clause[1] !== ',') && !allInlineTypes(clause) ) - imports.push({ kind: 'static', specifier }) + imports.push( + reference( + { ...tokens[cursor + 1], literal: specifier }, + word === 'export' ? 're-export' : 'static-import', + ), + ) break } if ( diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 8d44c9b4..6e6a7693 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -34,10 +34,16 @@ export { type PreparedSource, type PrepareSource, type ResolvedModule, + type ScannedStaticImportGraph, type StaticImportGraph, type StaticImportGraphOptions, type SyncGraphOptions, } from './import-graph' +export { + importGraphFailureOf, + type ImportGraphRequest, + type ImportRequestOutcome, +} from './import-requests' export { type ImportReference, scanImports } from './import-scanner' export { ConfigLoadError, From 23463285f31d6d12e86039ff8da1ebff645a5f48 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 22:08:08 +0900 Subject: [PATCH 48/50] fix(plugins): watch build-time module resolution inputs Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__tests__/packaged-config-inputs.test.ts | 28 ++ .../src/__tests__/request-evidence.test.ts | 12 +- .../src/__tests__/request-source.test.ts | 26 +- .../src/__tests__/resolution-inputs.test.ts | 276 ++++++++++++++++++ packages/plugin-utils/src/import-graph.ts | 137 ++++++--- packages/plugin-utils/src/import-requests.ts | 7 +- packages/plugin-utils/src/index.ts | 5 + .../src/packaged-config-inputs.ts | 35 +++ .../plugin-utils/src/prepared-resolver.ts | 22 +- .../plugin-utils/src/resolution-inputs.ts | 72 +++++ .../plugin-utils/src/resolution-watch-path.ts | 24 ++ packages/plugin-utils/src/tsconfig.ts | 122 +++++--- .../src/__tests__/core-integration.test.ts | 2 + .../src/__tests__/plugin.test.ts | 2 + .../src/__tests__/resolution-watch.mjs | 190 ++++++++++++ .../src/__tests__/resolution-watch.test.ts | 19 ++ packages/rsbuild-plugin/src/plugin.ts | 58 +++- .../vite-plugin/src/__tests__/plugin.test.ts | 1 + .../src/__tests__/resolution-cold.mjs | 35 +++ .../resolution-watch-environments.test.ts | 114 ++++++++ .../src/__tests__/resolution-watch.test.ts | 201 +++++++++++++ packages/vite-plugin/src/plugin.ts | 110 +++++-- packages/vite-plugin/src/resolution-watch.ts | 52 ++++ .../src/__tests__/loader.test.ts | 68 ++++- .../src/__tests__/plugin.test.ts | 18 +- .../src/__tests__/resolution-watch.mjs | 160 ++++++++++ .../src/__tests__/resolution-watch.test.ts | 23 ++ packages/webpack-plugin/src/loader.ts | 46 +-- packages/webpack-plugin/src/plugin.ts | 25 ++ 29 files changed, 1747 insertions(+), 143 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts create mode 100644 packages/plugin-utils/src/__tests__/resolution-inputs.test.ts create mode 100644 packages/plugin-utils/src/packaged-config-inputs.ts create mode 100644 packages/plugin-utils/src/resolution-inputs.ts create mode 100644 packages/plugin-utils/src/resolution-watch-path.ts create mode 100644 packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs create mode 100644 packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts create mode 100644 packages/vite-plugin/src/__tests__/resolution-cold.mjs create mode 100644 packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts create mode 100644 packages/vite-plugin/src/__tests__/resolution-watch.test.ts create mode 100644 packages/vite-plugin/src/resolution-watch.ts create mode 100644 packages/webpack-plugin/src/__tests__/resolution-watch.mjs create mode 100644 packages/webpack-plugin/src/__tests__/resolution-watch.test.ts diff --git a/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts new file mode 100644 index 00000000..59b81afd --- /dev/null +++ b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts @@ -0,0 +1,28 @@ +import { createRequire } from 'node:module' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { recordPackagedConfigInputs } from '../packaged-config-inputs' +import { createResolutionInputs } from '../resolution-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records only preceding missing package directories when Node selects a config', () => { + // Given a native public lookup resolving below an absent nearer package directory. + const importer = file('nested/tsconfig.json', '{}') + file('node_modules/preset/base.json', '{}') + const resolved = createRequire(importer).resolve('preset/base.json') + const inputs = createResolutionInputs() + // When recording public search-directory checks for the completed native lookup. + recordPackagedConfigInputs({ importer, request: 'preset', resolved }, inputs) + // Then no Node-internal manifest or in-package probe has been invented. + expect(inputs.snapshot()).toEqual({ + fileDependencies: [], + missingDependencies: [join(root, 'nested/node_modules/preset')], + }) +}) diff --git a/packages/plugin-utils/src/__tests__/request-evidence.test.ts b/packages/plugin-utils/src/__tests__/request-evidence.test.ts index 3339c6f4..93f2364c 100644 --- a/packages/plugin-utils/src/__tests__/request-evidence.test.ts +++ b/packages/plugin-utils/src/__tests__/request-evidence.test.ts @@ -35,7 +35,17 @@ import('./a'); require('./a'); import './a';` specifier: './a', position: { offset, line, column }, source: 'source', - outcome: { kind: 'resolved', path: leaf }, + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/a'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, })), ) expect(graph.staticImports.get(entry)).toEqual(new Set([leaf])) diff --git a/packages/plugin-utils/src/__tests__/request-source.test.ts b/packages/plugin-utils/src/__tests__/request-source.test.ts index b10f42dc..b4509c05 100644 --- a/packages/plugin-utils/src/__tests__/request-source.test.ts +++ b/packages/plugin-utils/src/__tests__/request-source.test.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path' + import { expect, it } from 'bun:test' import { scanImports } from '../import-scanner' @@ -29,7 +31,17 @@ it('preserves raw Markdown offsets through prose, fences, comments and CRLF', () kind: 'static-import', position: { offset: 66, line: 8, column: 8 }, source: 'source', - outcome: { kind: 'resolved', path: leaf }, + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, }, ]) }) @@ -63,7 +75,17 @@ it.each([ position: { offset: 8, line: 2, column: 8 }, source: 'compiled', map, - outcome: { kind: 'resolved', path: leaf }, + outcome: { + kind: 'resolved', + path: leaf, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, }, ]) expect(visits.sort()).toEqual(graph.files) diff --git a/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts new file mode 100644 index 00000000..66820b8f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts @@ -0,0 +1,276 @@ +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { buildStaticImportGraph, createModuleResolver } from '../index' +import { + createResolutionInputs, + readResolutionFile, + realpathResolution, +} from '../resolution-inputs' +import { resolutionWatchPath } from '../resolution-watch-path' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records consulted manifests and missing probes when resolving a linked package', () => { + // Given a linked workspace package with an exports-only entry. + file('src/main.ts', "import 'palette'") + const manifest = file( + 'packages/palette/package.json', + '{"exports":"./red.js"}', + ) + file('packages/palette/red.js', "export const color='red'") + file('node_modules/placeholder/package.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/palette'), + 'junction', + ) + const originalRead = fs.readFileSync + const reads: string[] = [] + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When scanning with real filesystem IO. + const graph = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the outcome names the lexical consulted manifest, not a guessed physical read. + const lexical = join(root, 'node_modules/palette/package.json') + expect(reads.filter((path) => path.endsWith('package.json'))).toEqual([ + lexical, + ]) + expect(graph.requests[0]?.outcome).toMatchObject({ + kind: 'resolved', + inputs: { + fileDependencies: [ + lexical, + join(root, 'node_modules/palette/red.js'), + ].sort(), + missingDependencies: [ + join(root, 'src/node_modules/palette/package.json'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }) + } finally { + read.mockRestore() + } +}) + +it('observes setup inheritance and failed earlier candidates when evaluating an import', () => { + // Given inherited aliases with eager multiple targets. + const config = file( + 'tsconfig.json', + '{"extends":"./base","compilerOptions":{}}', + ) + const base = file( + 'base.json', + '{"compilerOptions":{"paths":{"color":["absent","red.ts","later"]}}}', + ) + const red = file('red.ts', "export const color='red'") + const observations: unknown[] = [] + // When the public callable resolves and reads source. + const resolver = createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + expect(resolver('color', join(root, 'main.ts'))).toMatchObject({ path: red }) + // Then actual setup and eager failed candidates are reported without changing resolution. + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fileDependencies: [base, config].sort(), + missingDependencies: [join(root, 'base')], + }), + expect.objectContaining({ + fileDependencies: [base, red, config].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'absent'), + join(root, 'later'), + ]), + }), + ]), + ) + expect(observations.every((inputs) => Object.isFrozen(inputs))).toBe(true) +}) + +it('freezes deterministic snapshots without redoing successful resolution IO', () => { + // Given a real extensionless import and unchanged filesystem. + file('src/main.ts', "import './red'") + file('src/red.ts', "export const color='red'") + // When scanning twice independently. + const first = buildStaticImportGraph('src', undefined, { cwd: root }) + const second = buildStaticImportGraph('src', undefined, { cwd: root }) + // Then the full evidence is stable and its nested arrays cannot change. + expect(first.requests).toEqual(second.requests) + const outcome = first.requests[0]?.outcome + expect(outcome?.kind).toBe('resolved') + if (outcome && 'inputs' in outcome) { + expect(Object.isFrozen(outcome.inputs)).toBe(true) + expect(Object.isFrozen(outcome.inputs.fileDependencies)).toBe(true) + expect(Object.isFrozen(outcome.inputs.missingDependencies)).toBe(true) + } +}) + +it.each(['main', 'fallback', 'json'])( + 'records actual loaded packaged config chain when using %s resolution', + (mode) => { + // Given a package-config extension found below an absent nearer node_modules directory. + const config = file('src/tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + mode === 'main' + ? '{"main":"index.js","tsconfig":"base.json"}' + : mode === 'json' + ? '{"main":"base.json"}' + : '{}', + ) + if (mode === 'main') + file('node_modules/preset/index.js', 'module.exports={}') + const base = file( + `node_modules/preset/${mode === 'fallback' ? 'tsconfig.json' : 'base.json'}`, + '{}', + ) + const observations: unknown[] = [] + // When constructing the existing synchronous resolver. + createModuleResolver({ + cwd: root, + tsconfigPath: config, + onResolutionInputs: (inputs) => observations.push(inputs), + }) + // Then only owned reads and public-search missing directories are recorded. + expect(observations).toContainEqual({ + fileDependencies: (mode === 'main' + ? [config, manifest, base] + : [config, base] + ).sort(), + missingDependencies: [join(root, 'src/node_modules/preset')], + }) + }, +) + +it('reports a missing inherited configuration before preserving its established failure', () => { + // Given a top config pointing at a nonexistent parent. + file('tsconfig.json', '{"extends":"./missing"}') + const observations: unknown[] = [] + // When setup reaches the original IO failure. + expect(() => + createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }), + ).toThrow(join(root, 'missing.json')) + // Then the repair paths were observed even though no callable was returned. + expect(observations).toContainEqual({ + fileDependencies: [join(root, 'tsconfig.json')], + missingDependencies: [ + join(root, 'missing'), + join(root, 'missing.json'), + ].sort(), + }) +}) + +it('canonicalizes only transport identity when watching a missing descendant below a symlink', () => { + // Given a symlink and a missing file below its existing target. + const physical = file('physical/existing.ts') + file('links/placeholder.ts') + fs.symlinkSync(dirname(physical), join(root, 'links/package'), 'junction') + const lexical = join(root, 'links/package/new.ts') + // When selecting default and preserved transport identities. + const canonical = resolutionWatchPath(lexical) + // Then resolver evidence remains lexical while transport follows the bundler setting. + expect(canonical).toBe(join(root, 'physical/new.ts')) + expect(resolutionWatchPath(lexical, true)).toBe(lexical) +}) + +it('preserves IO failure identity and records attempted repair inputs', () => { + // Given a permission failure at the narrow existing read seam. + const target = file('blocked.json') + const original = Object.assign(new Error('denied'), { code: 'EACCES' }) + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(fn, receiver, args) { + if (args[0] === target) throw original + return Reflect.apply(fn, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When reading the input through the observed seam. + let caught: unknown + try { + readResolutionFile(target, inputs) + } catch (error) { + caught = error + } + // Then the original exception and actual attempted filename survive. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [target], + missingDependencies: [], + }) + } finally { + read.mockRestore() + } +}) + +it.each(['EACCES', 'ENOENT'])( + 'retains lexical watch identity when canonicalization fails with %s', + (code) => { + // Given a transport-only realpath failure, including an unavailable root. + const path = join(root, 'unavailable.ts') + const original = Object.assign(new Error('unavailable'), { code }) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + try { + // When asking for watcher identity without changing resolution semantics. + const watched = resolutionWatchPath(path) + // Then no secondary error replaces the original extraction/config failure. + expect(watched).toBe(path) + } finally { + realpath.mockRestore() + } + }, +) + +it('records a failed canonical probe without replacing the original IO error', () => { + // Given a real file whose existing canonicalization seam fails after resolution probes. + const path = file('canonical.ts') + const original = Object.freeze( + Object.assign(new Error('removed during resolution'), { code: 'ENOENT' }), + ) + const realpath = spyOn(fs, 'realpathSync').mockImplementation(() => { + throw original + }) + const inputs = createResolutionInputs() + try { + // When observing that existing realpath invocation. + let caught: unknown + try { + realpathResolution(path, inputs) + } catch (error) { + caught = error + } + // Then its exact path is repairable and its thrown object is identical. + expect(caught).toBe(original) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [], + missingDependencies: [path], + }) + } finally { + realpath.mockRestore() + } +}) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index a8cc73dd..faa019b0 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -33,6 +33,13 @@ import { type ModuleResolver, } from './prepared-resolver' import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' +import { + createResolutionInputs, + readResolutionFile, + realpathResolution, + type ResolutionInputCollector, + type ResolutionInputObserver, +} from './resolution-inputs' import { createNodeModulesExcludeRegex, SOURCE_EXTENSIONS, @@ -251,6 +258,8 @@ function* traverseGraph( exclusionObservation.outcome = { kind: 'excluded', entry } return entry !== undefined } + let resolutionInputs = createResolutionInputs().snapshot() + const graphInputs = createResolutionInputs() const resolver = createModulePathResolver( { cwd, @@ -258,6 +267,10 @@ function* traverseGraph( conditions: options.conditions, alias: options.alias, includeMdx: options.includeMdx, + onResolutionInputs: (inputs) => { + resolutionInputs = inputs + options.onResolutionInputs?.(inputs) + }, }, excludedDirectory, ) @@ -281,6 +294,8 @@ function* traverseGraph( for (let index = 0; index < files.length; index += 1) { const file = files[index] const prepared = yield file + graphInputs.file(file) + options.onResolutionInputs?.(graphInputs.snapshot()) const code = typeof prepared === 'string' ? prepared : prepared?.code const imports = code === undefined @@ -323,7 +338,7 @@ function* traverseGraph( ...request, outcome: resolved !== undefined - ? { kind: 'resolved', path: resolved } + ? { kind: 'resolved', path: resolved, inputs: resolutionInputs } : !importRef.specifier.startsWith('.') && !importRef.specifier.startsWith('/') && !isAbsolute(importRef.specifier) @@ -346,17 +361,17 @@ function* traverseGraph( const parts = request.split('/') const name = parts.slice(0, request.startsWith('@') ? 2 : 1).join('/') if (!excluded.test(`node_modules/${name}/`)) { - const dir = findPackage(dirname(file), name) + const dir = findPackage(dirname(file), name, undefined, graphInputs) if (dir) includedRoots.add(realpathSync(dir)) } if (isAbsolute(request) && !local && !excluded.test(resolved)) { let directory = dirname(resolved) while ( dirname(directory) !== directory && - !isFile(join(directory, 'package.json')) + !isFile(join(directory, 'package.json'), graphInputs) ) directory = dirname(directory) - if (isFile(join(directory, 'package.json'))) + if (isFile(join(directory, 'package.json'), graphInputs)) includedRoots.add(realpathSync(directory)) } } @@ -410,6 +425,8 @@ function* traverseGraph( outcome: { kind: 'error', error }, }) throw error + } finally { + options.onResolutionInputs?.(graphInputs.snapshot()) } } } @@ -427,6 +444,7 @@ function* traverseGraph( } export interface StaticImportGraphOptions { + readonly onResolutionInputs?: ResolutionInputObserver /** Include MDX only when the caller compiles it before extraction. */ readonly includeMdx?: MdxSelection readonly alias?: ModuleAliasOptions @@ -1071,6 +1089,7 @@ export interface IgnoredModule { export type ModuleResolution = ResolvedModule | IgnoredModule export interface CreateModuleResolverOptions { + readonly onResolutionInputs?: ResolutionInputObserver readonly prepareSource?: PrepareSource readonly alias?: ModuleAliasOptions readonly includeMdx?: MdxSelection @@ -1122,6 +1141,7 @@ function createModulePathResolver( conditions = ['import', 'module', 'require', 'node'], alias = {}, includeMdx, + onResolutionInputs, }: CreateModuleResolverOptions = {}, excludedDirectory: ( directory: string, @@ -1130,34 +1150,51 @@ function createModulePathResolver( specifier: string, importer: string, ) => AliasResolution | IgnoredModule | false | undefined { - const { aliases, baseDir, baseUrl } = readPathAliases(tsconfigPath) + const setupInputs = createResolutionInputs() + const config = (() => { + try { + return readPathAliases(tsconfigPath, setupInputs) + } finally { + onResolutionInputs?.(setupInputs.snapshot()) + } + })() + const { aliases, baseDir, baseUrl } = config + const setup = setupInputs.snapshot() const extensions = sourceExtensions(includeMdx) - const fileResolver = (path: string) => - resolveFile(path, { extensions, excludedDirectory }) return (specifier, importer) => { + const inputs = createResolutionInputs(setup) + const fileResolver = (path: string) => + resolveFile(path, { extensions, excludedDirectory, inputs }) const from = resolve(cwd, importer) - return resolveModuleAlias(specifier, { - alias, - importer: from, - resolveRequest: (request, aliased) => - request.startsWith('.') - ? fileResolver(resolve(dirname(from), request)) - : isAbsolute(request) - ? fileResolver(request) - : (resolveAliasCandidates(request, { - aliases, - aliasBaseDir: baseDir, - }) - .map(fileResolver) - .find((candidate) => candidate !== undefined) ?? - (baseUrl ? fileResolver(resolve(baseUrl, request)) : undefined) ?? - resolvePackage(request, from, { - conditions, - fileResolver, - excludedDirectory, - aliased, - })), - }) + try { + return resolveModuleAlias(specifier, { + alias, + importer: from, + resolveRequest: (request, aliased) => + request.startsWith('.') + ? fileResolver(resolve(dirname(from), request)) + : isAbsolute(request) + ? fileResolver(request) + : (resolveAliasCandidates(request, { + aliases, + aliasBaseDir: baseDir, + }) + .map(fileResolver) + .find((candidate) => candidate !== undefined) ?? + (baseUrl + ? fileResolver(resolve(baseUrl, request)) + : undefined) ?? + resolvePackage(request, from, { + conditions, + fileResolver, + excludedDirectory, + aliased, + inputs, + })), + }) + } finally { + onResolutionInputs?.(inputs.snapshot()) + } } } @@ -1169,6 +1206,7 @@ function resolvePackage( readonly fileResolver: (path: string) => string | false | undefined readonly excludedDirectory: (directory: string) => boolean readonly aliased: boolean + readonly inputs?: ResolutionInputCollector }, ): string | false | undefined { const parts = specifier.split('/') @@ -1178,6 +1216,7 @@ function resolvePackage( dirname(importer), name, options.excludedDirectory, + options.inputs, ) if (packageDir === false) return false if (!packageDir) return undefined @@ -1188,7 +1227,7 @@ function resolvePackage( ) return false const manifestFile = join(packageDir, 'package.json') - const manifest = readPackageManifest(manifestFile) + const manifest = readPackageManifest(manifestFile, options.inputs) const found = resolvePackageEntry( packageDir, manifest, @@ -1198,12 +1237,15 @@ function resolvePackage( ) if (found === undefined && options.aliased && manifest.exports !== undefined) throw new ModuleAliasPackageError(importer, specifier) - return found ? realpathSync(found) : found + return found ? realpathResolution(found, options.inputs) : found } -function readPackageManifest(file: string): Record { +function readPackageManifest( + file: string, + inputs?: ResolutionInputCollector, +): Record { try { - const manifest: unknown = JSON.parse(readFileSync(file, 'utf-8')) + const manifest: unknown = JSON.parse(readResolutionFile(file, inputs)) if (!isRecord(manifest) || Array.isArray(manifest)) throw new TypeError('Expected a package manifest object') return manifest @@ -1216,14 +1258,15 @@ function findPackage( dir: string, name: string, excludedDirectory?: (directory: string) => boolean, + inputs?: ResolutionInputCollector, ): string | false | undefined { const packageDir = join(dir, 'node_modules', name) if (excludedDirectory?.(packageDir)) return false - if (isFile(join(packageDir, 'package.json'))) return packageDir + if (isFile(join(packageDir, 'package.json'), inputs)) return packageDir const parent = dirname(dir) return parent === dir ? undefined - : findPackage(parent, name, excludedDirectory) + : findPackage(parent, name, excludedDirectory, inputs) } function resolvePackageEntry( dir: string, @@ -1331,6 +1374,7 @@ function resolveFile( options: { readonly extensions: readonly string[] readonly excludedDirectory: (directory: string) => boolean + readonly inputs?: ResolutionInputCollector } = { extensions: jsExtensions, excludedDirectory: createDirectoryExclusion(), @@ -1342,15 +1386,15 @@ function resolveFile( options.excludedDirectory(candidateBase) ) return false - if (isFile(candidateBase)) return resolve(candidateBase) + if (isFile(candidateBase, options.inputs)) return resolve(candidateBase) for (const jsExtension of options.extensions) { const candidate = `${candidateBase}${jsExtension}` - if (isFile(candidate)) return resolve(candidate) + if (isFile(candidate, options.inputs)) return resolve(candidate) } const manifestFile = join(candidateBase, 'package.json') - if (isFile(manifestFile)) { - const canonical = realpathSync(candidateBase) + if (isFile(manifestFile, options.inputs)) { + const canonical = realpathResolution(candidateBase, options.inputs) if (stack.includes(canonical)) throw new ConfigLoadError( manifestFile, @@ -1358,7 +1402,7 @@ function resolveFile( `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, ), ) - const manifest = readPackageManifest(manifestFile) + const manifest = readPackageManifest(manifestFile, options.inputs) for (const main of [manifest.module, manifest.main]) { if (typeof main !== 'string' || !main || main === '.' || main === './') continue @@ -1371,22 +1415,27 @@ function resolveFile( } for (const jsExtension of options.extensions) { const candidate = join(candidateBase, `index${jsExtension}`) - if (isFile(candidate)) return resolve(candidate) + if (isFile(candidate, options.inputs)) return resolve(candidate) } return undefined } -function isFile(path: string): boolean { +function isFile(path: string, inputs?: ResolutionInputCollector): boolean { try { - return statSync(path).isFile() + const file = statSync(path).isFile() + if (file) inputs?.file(path) + return file } catch (cause) { if ( cause instanceof Error && 'code' in cause && (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') - ) + ) { + inputs?.missing(path) return false + } + inputs?.file(path) throw cause } } diff --git a/packages/plugin-utils/src/import-requests.ts b/packages/plugin-utils/src/import-requests.ts index fbf25b50..1baf71aa 100644 --- a/packages/plugin-utils/src/import-requests.ts +++ b/packages/plugin-utils/src/import-requests.ts @@ -1,7 +1,12 @@ import type { ImportRequestReference } from './import-scanner' +import type { ResolutionInputs } from './resolution-inputs' export type ImportRequestOutcome = - | { readonly kind: 'resolved'; readonly path: string } + | { + readonly kind: 'resolved' + readonly path: string + readonly inputs: ResolutionInputs + } | { readonly kind: 'external'; readonly request: string } | { readonly kind: 'ignored' } | { readonly kind: 'excluded'; readonly entry: string } diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 6e6a7693..c20dfe3e 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -67,6 +67,11 @@ export { } from './numbering' export type { ModuleResolver } from './prepared-resolver' export type { SourceType } from './prepared-source' +export { + type ResolutionInputObserver, + type ResolutionInputs, +} from './resolution-inputs' +export { resolutionWatchPath } from './resolution-watch-path' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, diff --git a/packages/plugin-utils/src/packaged-config-inputs.ts b/packages/plugin-utils/src/packaged-config-inputs.ts new file mode 100644 index 00000000..2e66cd0c --- /dev/null +++ b/packages/plugin-utils/src/packaged-config-inputs.ts @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { isAbsolute, join, relative } from 'node:path' + +import type { ResolutionInputCollector } from './resolution-inputs' +import { resolutionWatchPath } from './resolution-watch-path' + +interface PackagedConfigLookup { + readonly request: string + readonly importer: string + readonly resolved: string | undefined +} + +export function recordPackagedConfigInputs( + lookup: PackagedConfigLookup, + inputs?: ResolutionInputCollector, +): void { + if (!inputs) return + const { request, importer, resolved } = lookup + const require = createRequire(importer) + const name = request + .split('/') + .slice(0, request.startsWith('@') ? 2 : 1) + .join('/') + for (const directory of require.resolve.paths(request) ?? []) { + const candidate = join(directory, name) + const exists = existsSync(candidate) + inputs.probe(candidate, exists) + if (exists && resolved) { + const canonical = resolutionWatchPath(candidate) + const subpath = relative(canonical, resolved) + if (!subpath.startsWith('..') && !isAbsolute(subpath)) break + } + } +} diff --git a/packages/plugin-utils/src/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts index 0fe44996..237a04db 100644 --- a/packages/plugin-utils/src/prepared-resolver.ts +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { extname } from 'node:path' import type { @@ -9,6 +8,11 @@ import type { } from './import-graph' import { remapMdxError } from './mdx-errors' import { readPreparedSource } from './prepared-source' +import { + createResolutionInputs, + readResolutionFile, + type ResolutionInputObserver, +} from './resolution-inputs' import type { MdxSelection } from './source-selection' export interface ModuleResolver { @@ -39,6 +43,7 @@ function isThenable(value: unknown): value is PromiseLike { export function createPreparedResolver(options: { readonly prepareSource?: PrepareSource readonly includeMdx?: MdxSelection + readonly onResolutionInputs?: ResolutionInputObserver }) { const generations = new Map< string, @@ -74,7 +79,13 @@ export function createPreparedResolver(options: { markdownExtensions.has(extname(filename).toLowerCase()) ) throw new TypeError('Markdown source has no prepared JavaScript') - return readPreparedSource(prepared) + const source = readPreparedSource(prepared) + if (source !== undefined) { + const inputs = createResolutionInputs() + inputs.file(filename) + options.onResolutionInputs?.(inputs.snapshot()) + } + return source } catch (cause) { throw new ModulePreparationError(importer, filename, cause) } @@ -86,7 +97,12 @@ export function createPreparedResolver(options: { ): ResolvedModule { if (prepared === undefined) { generations.delete(id) - return { path: id, code: readFileSync(filename, 'utf-8') } + const inputs = createResolutionInputs() + try { + return { path: id, code: readResolutionFile(filename, inputs) } + } finally { + options.onResolutionInputs?.(inputs.snapshot()) + } } const source = typeof prepared === 'string' ? { code: prepared } : prepared diff --git a/packages/plugin-utils/src/resolution-inputs.ts b/packages/plugin-utils/src/resolution-inputs.ts new file mode 100644 index 00000000..b7e283a6 --- /dev/null +++ b/packages/plugin-utils/src/resolution-inputs.ts @@ -0,0 +1,72 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +export interface ResolutionInputs { + readonly fileDependencies: readonly string[] + readonly missingDependencies: readonly string[] +} + +export type ResolutionInputObserver = (inputs: ResolutionInputs) => void + +export function createResolutionInputs(initial?: ResolutionInputs) { + const files = new Set(initial?.fileDependencies) + const missing = new Set(initial?.missingDependencies) + return { + file(path: string) { + files.add(resolve(path)) + }, + missing(path: string) { + missing.add(resolve(path)) + }, + probe(path: string, exists: boolean) { + if (!exists) missing.add(resolve(path)) + }, + snapshot(): ResolutionInputs { + return Object.freeze({ + fileDependencies: Object.freeze([...files].sort()), + missingDependencies: Object.freeze([...missing].sort()), + }) + }, + } +} + +export type ResolutionInputCollector = ReturnType + +export function recordResolutionFailure( + path: string, + error: unknown, + inputs?: ResolutionInputCollector, +): void { + const code: unknown = + error instanceof Error + ? Object.getOwnPropertyDescriptor(error, 'code')?.value + : undefined + if (code === 'ENOENT' || code === 'ENOTDIR') inputs?.missing(path) + else inputs?.file(path) +} + +export function realpathResolution( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + return realpathSync(path) + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} + +export function readResolutionFile( + path: string, + inputs?: ResolutionInputCollector, +): string { + try { + const source = readFileSync(path, 'utf-8') + inputs?.file(path) + return source + } catch (error) { + recordResolutionFailure(path, error, inputs) + throw error + } +} diff --git a/packages/plugin-utils/src/resolution-watch-path.ts b/packages/plugin-utils/src/resolution-watch-path.ts new file mode 100644 index 00000000..221bf992 --- /dev/null +++ b/packages/plugin-utils/src/resolution-watch-path.ts @@ -0,0 +1,24 @@ +import { realpathSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' + +function canonicalWatchPath(path: string, parent: string): string { + try { + return join(realpathSync(parent), relative(parent, path)) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + ) + return path + const next = dirname(parent) + return next === parent ? path : canonicalWatchPath(path, next) + } +} + +export function resolutionWatchPath( + path: string, + preserveSymlinks = false, +): string { + return preserveSymlinks ? path : canonicalWatchPath(path, path) +} diff --git a/packages/plugin-utils/src/tsconfig.ts b/packages/plugin-utils/src/tsconfig.ts index 1fdc6e4c..9ccf0011 100644 --- a/packages/plugin-utils/src/tsconfig.ts +++ b/packages/plugin-utils/src/tsconfig.ts @@ -1,8 +1,15 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, resolve } from 'node:path' import { ConfigLoadError } from './load-config' +import { recordPackagedConfigInputs } from './packaged-config-inputs' +import { + readResolutionFile, + realpathResolution, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' export interface PathAlias { readonly prefix: string @@ -61,58 +68,84 @@ function parseJsonc(source: string): unknown { return JSON.parse(json) } -function resolveParent(specifier: string, file: string): string { +function resolveParent( + specifier: string, + file: string, + inputs?: ResolutionInputCollector, +): string { if (specifier.startsWith('.') || isAbsolute(specifier)) { const candidate = resolve(dirname(file), specifier) - return existsSync(candidate) ? candidate : `${candidate}.json` + const exists = existsSync(candidate) + inputs?.probe(candidate, exists) + return exists ? candidate : `${candidate}.json` } const require = createRequire(file) + let selected: string | undefined try { - const resolved = require.resolve(specifier) - if (resolved.endsWith('.json')) return resolved - const manifestPath = require.resolve(`${specifier}/package.json`) - const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) - return join( - dirname(manifestPath), - isRecord(manifest) && typeof manifest.tsconfig === 'string' - ? manifest.tsconfig - : 'tsconfig.json', - ) - } catch (cause) { - if ( - !(cause instanceof Error) || - !('code' in cause) || - cause.code !== 'MODULE_NOT_FOUND' - ) - throw cause try { - return require.resolve(`${specifier}/tsconfig.json`) - } catch (fallback) { - if ( - !(fallback instanceof Error) || - !('code' in fallback) || - fallback.code !== 'MODULE_NOT_FOUND' - ) - throw fallback + const resolved = require.resolve(specifier) + selected = resolved + if (resolved.endsWith('.json')) return resolved const manifestPath = require.resolve(`${specifier}/package.json`) - const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf-8')) - const configName = + const manifest: unknown = JSON.parse( + readResolutionFile(manifestPath, inputs), + ) + return join( + dirname(manifestPath), isRecord(manifest) && typeof manifest.tsconfig === 'string' ? manifest.tsconfig - : 'tsconfig.json' - return join(dirname(manifestPath), configName) + : 'tsconfig.json', + ) + } catch (cause) { + if ( + !(cause instanceof Error) || + !('code' in cause) || + cause.code !== 'MODULE_NOT_FOUND' + ) + throw cause + try { + const resolved = require.resolve(`${specifier}/tsconfig.json`) + selected = resolved + return resolved + } catch (fallback) { + if ( + !(fallback instanceof Error) || + !('code' in fallback) || + fallback.code !== 'MODULE_NOT_FOUND' + ) + throw fallback + const manifestPath = require.resolve(`${specifier}/package.json`) + selected = manifestPath + const manifest: unknown = JSON.parse( + readResolutionFile(manifestPath, inputs), + ) + const configName = + isRecord(manifest) && typeof manifest.tsconfig === 'string' + ? manifest.tsconfig + : 'tsconfig.json' + return join(dirname(manifestPath), configName) + } } + } finally { + recordPackagedConfigInputs( + { request: specifier, importer: file, resolved: selected }, + inputs, + ) } } -function loadAliases(file: string, stack: readonly string[]): AliasConfig { +function loadAliases( + file: string, + stack: readonly string[], + inputs?: ResolutionInputCollector, +): AliasConfig { try { - const canonical = realpathSync(file) + const canonical = realpathResolution(file, inputs) if (stack.includes(canonical) || stack.length >= 128) throw new Error( `Tsconfig inheritance cycle or depth limit: ${[...stack, canonical].join(' -> ')}`, ) - const config = parseJsonc(readFileSync(file, 'utf-8')) + const config = parseJsonc(readResolutionFile(file, inputs)) if (!isRecord(config)) throw new TypeError('Expected a tsconfig object') const parents = config.extends === undefined @@ -129,7 +162,11 @@ function loadAliases(file: string, stack: readonly string[]): AliasConfig { for (const parent of parents) inherited = { ...inherited, - ...loadAliases(resolveParent(parent, file), [...stack, canonical]), + ...loadAliases( + resolveParent(parent, file, inputs), + [...stack, canonical], + inputs, + ), } if (config.compilerOptions === undefined) return inherited if (!isRecord(config.compilerOptions)) @@ -164,19 +201,24 @@ function loadAliases(file: string, stack: readonly string[]): AliasConfig { return { ...inherited, ...current } } catch (cause) { if (cause instanceof ConfigLoadError) throw cause + recordResolutionFailure(file, cause, inputs) throw new ConfigLoadError(file, cause) } } -export function readPathAliases(tsconfigPath?: string): { +export function readPathAliases( + tsconfigPath?: string, + inputs?: ResolutionInputCollector, +): { aliases: PathAlias[] baseDir: string baseUrl?: string } { - if (!tsconfigPath || !existsSync(tsconfigPath)) - return { aliases: [], baseDir: process.cwd() } + const exists = tsconfigPath ? existsSync(tsconfigPath) : false + if (tsconfigPath) inputs?.probe(tsconfigPath, exists) + if (!tsconfigPath || !exists) return { aliases: [], baseDir: process.cwd() } const file = resolve(tsconfigPath) - const config = loadAliases(file, []) + const config = loadAliases(file, [], inputs) const baseDir = config.baseUrl ?? config.pathsOrigin ?? dirname(file) const aliases = Object.entries(config.paths ?? {}).map(([alias, targets]) => { const star = alias.indexOf('*') diff --git a/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts index 4d686ffd..11f82c2b 100644 --- a/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts @@ -182,6 +182,7 @@ it.each(['mts', 'cts', 'cjs', 'mjs'])( code: readFileSync(path, 'utf-8'), resourcePath: path, addDependency: mock(), + addMissingDependency: mock(), environment: { name: 'web' }, }) expect(result.code).not.toContain('css(') @@ -210,6 +211,7 @@ it('extracts compiled MDX in the post-loader without prewarming raw markdown', a code: box('red'), resourcePath: path, addDependency: mock(), + addMissingDependency: mock(), environment: { name: 'web' }, }) expect(result.code).not.toContain(' `, config.plugins![0]!.apply(compiler) const compilation = { assets: { 'index.js': {} }, + fileDependencies: new Set(), + missingDependencies: new Set(), deleteAsset: mock(), hooks: { finishModules: { tap: tap('finishModules') }, diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 00000000..bd2e93b3 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { DevupUI } from '../../dist/index.mjs' + +const require = createRequire(import.meta.url) +const { createRsbuild } = await import( + pathToFileURL(require.resolve('@rsbuild/core')).href +) +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const cold = process.argv[2] === 'cold' +const fixture = cold + ? undefined + : await realpath(await mkdtemp(join(tmpdir(), 'rsbuild-resolution-watch-'))) +const root = cold ? process.argv[3] : join(fixture, 'apps/app') +const palette = resolve(root, '../../packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +let server +let result +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (stats) => { + clearTimeout(timer) + next = undefined + resolveBuild(stats) + } + if (pending.length) next(pending.shift()) + }) +const state = (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const compilation = stats.stats + ? stats.stats[0].compilation + : stats.compilation + const module = [...compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const files = Object.keys(compilation.assets).filter((name) => + name.endsWith('.css'), + ) + assert.ok(files.length) + return { + manifestWatched: compilation.fileDependencies.has(manifest), + code, + selected, + files, + } +} +try { + if (!cold) { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + } + const builder = await createRsbuild({ + cwd: root, + rsbuildConfig: { + mode: cold ? 'production' : 'development', + plugins: [DevupUI()], + source: { entry: { main: entry } }, + resolve: { + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + server: { host: '127.0.0.1', port: 0 }, + output: { + injectStyles: false, + minify: false, + distPath: { root: join(root, cold ? 'cold' : 'out') }, + }, + }, + }) + if (cold) { + result = await builder.build() + const files = await import('node:fs/promises').then(({ readdir }) => + readdir(join(root, 'cold'), { recursive: true }), + ) + const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) + ).join('\n') + assert.match(css, /background:\s*(?:blue|#00f)(?:;|\s*\})/) + assert.doesNotMatch(css, /background:\s*(?:red|#f00)(?:;|\s*\})/) + console.info(JSON.stringify({ cold: css })) + } else { + builder.onAfterDevCompile(({ stats }) => + next ? next(stats) : pending.push(stats), + ) + const initial = nextBuild() + server = (await builder.startDevServer()).server + const before = state(await initial) + assert.ok(before.manifestWatched, `Manifest not registered: ${manifest}`) + const { port } = server.httpServer.address() + const css = async (snapshot) => + ( + await Promise.all( + snapshot.files.map(async (file) => { + const response = await fetch(`http://127.0.0.1:${port}/${file}`, { + signal: AbortSignal.timeout(10000), + }) + assert.equal(response.status, 200) + return response.text() + }), + ) + ).join('\n') + const beforeCss = await css(before) + assert.match( + beforeCss, + new RegExp( + `\\.${before.selected}\\s*\\{[^}]*background:\\s*red(?:;|\\s*\\})`, + ), + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = state(await rebuilt) + for (let attempt = 0; attempt < 3; attempt += 1) { + if (after.selected !== before.selected) break + after = state(await nextBuild()) + } + const afterCss = await css(after) + assert.notEqual(after.selected, before.selected) + assert.match( + afterCss, + new RegExp( + `\\.${after.selected}\\s*\\{[^}]*background:\\s*(?:blue|#00f)(?:;|\\s*\\})`, + ), + ) + await server.close() + server = undefined + const built = spawnSync('node', [import.meta.filename, 'cold', root], { + encoding: 'utf-8', + }) + assert.equal(built.status, 0, built.stderr) + console.info( + JSON.stringify({ + before: beforeCss, + after: afterCss, + selected: after.selected, + cold: built.stdout, + }), + ) + } +} finally { + await server?.close() + await result?.close() + if (fixture) await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 00000000..82ba4a1c --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('serves the new selected CSS rule when only linked workspace exports change', () => { + // Given the real Rsbuild dev-server fixture and two existing palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When only exports is edited and public compilation callbacks complete. + const result = Bun.spawnSync(['node', runner], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then HTTP-served CSS selects blue and the separate cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') +}, 45000) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 0ac4a425..ca227007 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -12,6 +12,7 @@ import { createCompatTypes, createDependencyGuard, createModuleResolver, + type CreateModuleResolverOptions, createNodeModulesExcludeRegex, createStateWriter, createThemeInterfaceArgs, @@ -27,6 +28,8 @@ import { normalizeMdxExtensions, planAtomHoist, remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, resolveProjectPaths, resolveSourceDirs, seedFileNumbers, @@ -212,9 +215,14 @@ export const DevupUI = ({ roots: string[] entries: string[] resolver: ReturnType + resolverOptions: CreateModuleResolverOptions + preserveSymlinks: boolean + inputFiles: Set + missingInputs: Set } >() const prewarm = (plan: NonNullable>) => { + plan.resolver = createModuleResolver(plan.resolverOptions) setModuleResolver(plan.resolver) for (const file of computeReachableFiles({ srcDir: plan.roots, @@ -323,14 +331,25 @@ export const DevupUI = ({ ]), ] const tsconfigPath = resolve(root, 'tsconfig.json') + const inputFiles = new Set() + const missingInputs = new Set() + const preserveSymlinks = normalized.resolve?.symlinks === false + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add(resolutionWatchPath(path, preserveSymlinks)) + for (const path of inputs.missingDependencies) + missingInputs.add(resolutionWatchPath(path, preserveSymlinks)) + } try { - const resolver = createModuleResolver({ + const resolverOptions = { cwd: root, includeMdx: mdxExtensions, conditions, alias: normalized.resolve?.alias, toId, - }) + onResolutionInputs, + } + const resolver = createModuleResolver(resolverOptions) setModuleResolver(resolver) const graph = buildStaticImportGraph(roots, tsconfigPath, { includeMdx: mdxExtensions, @@ -339,8 +358,18 @@ export const DevupUI = ({ conditions, alias: normalized.resolve?.alias, exclude: [basename(outputDir), basename(cssDir)], + onResolutionInputs, }) - const plan = { graph, roots, entries, resolver } + const plan = { + graph, + roots, + entries, + resolver, + resolverOptions, + preserveSymlinks, + inputFiles, + missingInputs, + } plans.set(environment.name, plan) if (atomMode) { const canonicalMap = buildCanonicalMap({ @@ -408,6 +437,11 @@ export const DevupUI = ({ passes = 0 }) compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + const plan = plans.get(environment.name) + for (const path of plan?.inputFiles ?? []) + compilation.fileDependencies.add(path) + for (const path of plan?.missingInputs ?? []) + compilation.missingDependencies.add(path) const served = new Map() servedCss.set(environment.name, served) const basePath = join(cssDir, 'devup-ui.css') @@ -528,11 +562,27 @@ export const DevupUI = ({ code, resourcePath, addDependency, + addMissingDependency, environment, }) => { if (excludeModules.test(resourcePath)) return code const plan = plans.get(environment?.name) - if (plan) setModuleResolver(plan.resolver) + if (plan) + setModuleResolver( + createModuleResolver({ + ...plan.resolverOptions, + onResolutionInputs: (inputs) => { + for (const path of inputs.fileDependencies) + addDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + for (const path of inputs.missingDependencies) + addMissingDependency( + resolutionWatchPath(path, plan.preserveSymlinks), + ) + }, + }), + ) // The stylesheet import is emitted relative to the importing file, as // in the next/webpack/vite loaders. An absolute cssDir would bake this // checkout's path into the emitted module, so byte-identical sources diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index c1cf7b1e..0ba21d75 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -318,6 +318,7 @@ describe('devupUIVitePlugin', () => { apply: expect.any(Function), generateBundle: expect.any(Function), configResolved: expect.any(Function), + configureServer: expect.any(Function), resolveId: expect.any(Function), }) expect(plugin.apply()).toBe(true) diff --git a/packages/vite-plugin/src/__tests__/resolution-cold.mjs b/packages/vite-plugin/src/__tests__/resolution-cold.mjs new file mode 100644 index 00000000..82be9923 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-cold.mjs @@ -0,0 +1,35 @@ +import { readdir, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const { build } = await import(pathToFileURL(require.resolve('vite')).href) +const { DevupUI } = await import('../../dist/index.mjs') +const root = process.argv[2] +await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { + outDir: 'cold', + lib: { + entry: join(root, 'src/main.js'), + fileName: 'main', + formats: ['es'], + }, + minify: false, + }, +}) +const files = await readdir(join(root, 'cold')) +const css = ( + await Promise.all( + files + .filter((name) => name.endsWith('.css')) + .map((name) => readFile(join(root, 'cold', name), 'utf-8')), + ) +).join('\n') +if (!/background:\s*blue/.test(css) || /background:\s*red/.test(css)) + throw new Error(`Invalid cold CSS: ${css}`) +console.info(css) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts new file mode 100644 index 00000000..5eac84ac --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('retains client missing-input watches when SSR transforms the same importer later', async () => { + // Given different condition branches for one importer in two real environments. + resetBuildState() + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-input-environments-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const candidate = join(palette, 'browser/entry.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(join(palette, 'browser'), { recursive: true }) + await mkdir(join(palette, 'node'), { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile( + join(palette, 'package.json'), + '{"name":"palette","exports":{"browser":"./browser/entry","node":"./node/entry.js"}}', + ) + await writeFile( + join(palette, 'browser/entry.js'), + "export const color='red'", + ) + await writeFile( + join(palette, 'node/entry.js'), + "export const color='green'", + ) + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'environment-watch-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await server.environments.ssr.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When a client-only missing candidate is created after SSR's distinct observations. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No client candidate add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then client cache invalidation survived the later SSR extraction. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/resolution-watch.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 00000000..63b82be8 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,201 @@ +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { resetBuildState } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +it('refreshes the selected CSS class when linked workspace exports alone change', async () => { + resetBuildState() + // Given a monorepo palette outside node_modules and two already existing entries. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-resolution-watch-')), + ) + const root = join(fixture, 'apps/app') + const palette = join(fixture, 'packages/palette') + const manifest = join(palette, 'package.json') + let server: ViteDevServer | undefined + let ready: Promise | undefined + const observed = new Set() + let changed: (() => void) | undefined + const update = new Promise((done) => { + changed = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + const configuration = () => ({ + root, + configFile: false as const, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'resolution-watch-observation', + configureServer(current: ViteDevServer) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + }, + hotUpdate({ file }: { readonly file: string }) { + observed.add(file) + if (file === manifest.replaceAll('\\', '/')) changed?.() + }, + }, + ], + }) + server = await createServer(configuration()) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the only mutation switches the package exports target. + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + await Promise.race([ + update, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('No manifest hotUpdate')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the new class selects blue; retained earlier atoms do not define its effective style. + expect(observed).toContain(manifest.replaceAll('\\', '/')) + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect(selected).toBeDefined() + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + await server.close() + server = undefined + const cold = Bun.spawnSync( + ['node', join(import.meta.dir, 'resolution-cold.mjs'), root], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ + exitCode: cold.exitCode, + error: cold.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(cold.stdout.toString()).toMatch(/background:\s*blue/) + expect(cold.stdout.toString()).not.toMatch(/background:\s*red/) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) + +it('refreshes an importer when a missing earlier paths candidate outside the root is created', async () => { + resetBuildState() + // Given an existing fallback and a missing candidate outside the default root watch. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-missing-input-')), + ) + const root = join(fixture, 'apps/app') + const outside = join(fixture, 'packages/alternate') + const candidate = join(outside, 'color.ts') + let server: ViteDevServer | undefined + let ready: Promise | undefined + let signal: (() => void) | undefined + const added = new Promise((done) => { + signal = done + }) + try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile( + join(root, 'tsconfig.json'), + '{"compilerOptions":{"allowJs":true,"paths":{"palette":["../../packages/alternate/color.ts","src/red.ts"]}}}', + ) + await writeFile(join(root, 'src/red.ts'), "export const color='red'") + await writeFile( + join(root, 'src/main.js'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + server = await createServer({ + root, + configFile: false, + resolve: { tsconfigPaths: true }, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'missing-input-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { + signal: AbortSignal.timeout(10000), + }) + current.watcher.on('add', (file) => { + if ( + file.replaceAll('\\', '/') === candidate.replaceAll('\\', '/') + ) + signal?.() + }) + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.js') + await ready + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain('background:red') + // When the sole mutation creates the previously missing higher-priority candidate. + const replacement = join(fixture, 'candidate-replacement.ts') + await writeFile(replacement, "export const color='blue'") + await rename(replacement, candidate) + await Promise.race([ + added, + new Promise((_, reject) => { + const timeout = setTimeout( + () => reject(new Error('Missing outside-root add event')), + 10000, + ) + timeout.unref() + }), + ]) + const after = await server.transformRequest('/src/main.js') + // Then the cached importer is invalidated by public watch transport and selects blue. + expect(after?.code).not.toBe(before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + expect( + await readFile(join(root, 'df/devup-ui/devup-ui-0.css'), 'utf-8'), + ).toContain(`.${selected}{background:blue}`) + } finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index adb72582..df579c8f 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -24,6 +24,8 @@ import { normalizeMdxExtensions, planAtomHoist, remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, resolveProjectPaths, resolveSourceDirs, seedFileNumbers, @@ -55,6 +57,7 @@ import type { import { createAggregateCssPreparation } from './aggregate-css' import { createCompiledGuard } from './compiled-guard' +import { createMissingInputWatch } from './resolution-watch' /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... @@ -324,20 +327,33 @@ export function DevupUI({ let isServe = false let isProduction = false let seedWarningEmitted = false - const resolvers = new Map>() - function moduleResolver(conditions: readonly string[]) { - const key = JSON.stringify([projectRoot, conditions]) - let resolver = resolvers.get(key) - if (!resolver) { - resolver = createModuleResolver({ - cwd: projectRoot, - includeMdx: mdxExtensions, - conditions, - toId: (path) => path.replaceAll('\\', '/'), - }) - resolvers.set(key, resolver) - } - return resolver + const setupWatchFiles = new Set() + const missingWatchContexts = new Map void>() + const missingInputWatch = createMissingInputWatch() + let fallbackConditions: readonly string[] = [ + 'import', + 'module', + 'require', + 'node', + ] + const observeSetup: ResolutionInputObserver = (inputs) => { + for (const path of [ + ...inputs.fileDependencies, + ...inputs.missingDependencies, + ]) + setupWatchFiles.add(path) + } + function moduleResolver( + conditions: readonly string[], + onResolutionInputs: ResolutionInputObserver = observeSetup, + ) { + return createModuleResolver({ + cwd: projectRoot, + includeMdx: mdxExtensions, + conditions, + onResolutionInputs, + toId: (path) => path.replaceAll('\\', '/'), + }) } // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that @@ -357,21 +373,45 @@ export function DevupUI({ resolveFallbackPaths() const fileName = id.split('?')[0] if (excludeModules.test(fileName)) return + const environment = this.environment ?? plugin + const preserveSymlinks = + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks + missingInputWatch.start(fileName, environment) aggregateCss.observe(this.environment ?? plugin, id) const environmentConditions = this.environment?.config.resolve.conditions - if (environmentConditions) { - setModuleResolver( - moduleResolver( - ['import', ...environmentConditions].map((condition) => - condition === 'development|production' - ? isProduction - ? 'production' - : 'development' - : condition, - ), + setModuleResolver( + moduleResolver( + (environmentConditions + ? ['import', ...environmentConditions] + : fallbackConditions + ).map((condition) => + condition === 'development|production' + ? isProduction + ? 'production' + : 'development' + : condition, ), - ) - } + (inputs) => { + for (const path of inputs.fileDependencies) + this.addWatchFile( + resolutionWatchPath(path, preserveSymlinks).replaceAll( + '\\', + '/', + ), + ) + for (const path of inputs.missingDependencies) { + const watched = resolutionWatchPath( + path, + preserveSymlinks, + ).replaceAll('\\', '/') + setupWatchFiles.add(path) + missingInputWatch.observe(fileName, watched, environment) + missingWatchContexts.get(this.environment ?? plugin)?.(watched) + } + }, + ), + ) let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') if (!rel.startsWith('./')) rel = `./${rel}` const { @@ -422,6 +462,9 @@ export function DevupUI({ // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, + configureServer(server) { + missingInputWatch.attach(server) + }, async configResolved(config) { resolvedConfig = config try { @@ -454,6 +497,7 @@ export function DevupUI({ : condition, ) // Vite ids are POSIX absolute paths + fallbackConditions = conditions setModuleResolver(moduleResolver(conditions)) const sourceDirs = resolveSourceDirs(projectRoot, configuredSourceDirs) const input = @@ -512,6 +556,7 @@ export function DevupUI({ cwd: root, include, conditions, + onResolutionInputs: observeSetup, }) const canonicalMap = buildCanonicalMap({ @@ -612,9 +657,22 @@ export function DevupUI({ return true }, closeBundle(this: void) { + missingInputWatch.close() + missingWatchContexts.clear() endBuild() }, buildStart(options) { + missingWatchContexts.set(this.environment ?? plugin, (path) => + this.addWatchFile(path), + ) + for (const path of setupWatchFiles) + this.addWatchFile( + resolutionWatchPath( + path, + this.environment?.config.resolve.preserveSymlinks ?? + resolvedConfig?.resolve?.preserveSymlinks, + ).replaceAll('\\', '/'), + ) if (isServe || !extractCss) return aggregateCss.start( this.environment ?? plugin, diff --git a/packages/vite-plugin/src/resolution-watch.ts b/packages/vite-plugin/src/resolution-watch.ts new file mode 100644 index 00000000..f72ae825 --- /dev/null +++ b/packages/vite-plugin/src/resolution-watch.ts @@ -0,0 +1,52 @@ +import { dirname } from 'node:path' + +import type { ViteDevServer } from 'vite' + +export function createMissingInputWatch() { + const missingByEnvironment = new Map>>() + let server: ViteDevServer | undefined + const changed = (file: string) => { + const path = file.replaceAll('\\', '/') + let affected = false + for (const environment of Object.values(server?.environments ?? {})) { + for (const [importer, inputs] of missingByEnvironment.get(environment) ?? + []) { + if (!inputs.has(path)) continue + for (const module of environment.moduleGraph.getModulesByFile( + importer, + ) ?? []) { + environment.moduleGraph.invalidateModule(module) + affected = true + } + } + } + if (affected) server?.ws.send({ type: 'full-reload' }) + } + return { + attach(current: ViteDevServer) { + server = current + current.watcher + .on('add', changed) + .on('change', changed) + .on('unlink', changed) + }, + start(importer: string, environment: object) { + const importers = + missingByEnvironment.get(environment) ?? new Map>() + importers.set(importer, new Set()) + missingByEnvironment.set(environment, importers) + }, + observe(importer: string, path: string, environment: object) { + missingByEnvironment.get(environment)?.get(importer)?.add(path) + server?.watcher.add(dirname(path)) + }, + close() { + server?.watcher + .off('add', changed) + .off('change', changed) + .off('unlink', changed) + server = undefined + missingByEnvironment.clear() + }, + } +} diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 521c62c2..6c8efe49 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -26,7 +26,7 @@ type LoaderThis = ThisParameterType type LoaderCallback = ReturnType interface TestLoaderContext extends Pick< LoaderThis, - 'async' | 'resourcePath' | 'addDependency' + 'async' | 'resourcePath' | 'addDependency' | 'addMissingDependency' > { getOptions: () => Partial _compiler?: { __DEVUP_CACHE: string } @@ -58,6 +58,7 @@ function createLoaderContext( async: mock().mockReturnValue(callback), resourcePath, addDependency: mock(), + addMissingDependency: mock(), ...overrides, } as unknown as LoaderThis } @@ -130,6 +131,71 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it('refreshes same-root configuration and isolates watches when successive loaders evaluate imports', async () => { + // Given unchanged loader options and separately owned dependency subscriptions. + const rootDir = mkdtempSync(join(tmpdir(), 'loader-inputs-')) + writeFileSync(join(rootDir, 'red.ts'), "export const color='red'") + writeFileSync(join(rootDir, 'blue.ts'), "export const color='blue'") + const selected: (string | undefined)[] = [] + const observed = [mock(), mock(), mock()] + const missing = [mock(), mock(), mock()] + const register = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver: ReturnType) => { + selected.push(resolver('color', 'main.ts')?.code) + }, + ) + try { + // When a missing config is created and its inherited target changes at the same root. + for (const index of [0, 1, 2]) { + if (index === 1) + writeFileSync( + join(rootDir, 'tsconfig.json'), + '{"extends":"./base.json"}', + ) + if (index > 0) + writeFileSync( + join(rootDir, 'base.json'), + JSON.stringify({ + compilerOptions: { + paths: { color: [index === 1 ? 'red.ts' : 'blue.ts'] }, + }, + }), + ) + await new Promise((done) => { + const context = createLoaderContext( + { rootDir, cssDir: join(rootDir, 'df') }, + () => done(), + join(rootDir, 'main.ts'), + { + addDependency: observed[index], + addMissingDependency: missing[index], + }, + ) + devupUILoader.call(context, Buffer.from('export {}')) + }) + } + // Then each invocation sees current config and watches only through its own context. + expect(selected).toEqual([ + undefined, + "export const color='red'", + "export const color='blue'", + ]) + expect(missing[0]?.mock.calls).toContainEqual([ + join(rootDir, 'tsconfig.json'), + ]) + expect(observed[1]?.mock.calls).toContainEqual([join(rootDir, 'red.ts')]) + expect(observed[2]?.mock.calls).toContainEqual([join(rootDir, 'blue.ts')]) + expect(observed[0]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + expect(observed[1]?.mock.calls).not.toContainEqual([ + join(rootDir, 'blue.ts'), + ]) + } finally { + register.mockRestore() + rmSync(rootDir, { recursive: true, force: true }) + } + }) it('selects a fresh native alias resolver when ordered entries change at the same root', async () => { const root = mkdtempSync(join(tmpdir(), 'loader-alias-')) const target = join(root, 'chosen.js') diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2ac6b4c9..e450adb5 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -458,16 +458,30 @@ describe('devupUIWebpackPlugin', () => { expect(statSpy).not.toHaveBeenCalled() } } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + const resolutionTap = compiler.hooks.afterCompile.tap.mock.calls.find( + ([name]) => name === 'DevupUIResolutionInputs', + ) + expect(resolutionTap).toBeDefined() + const inputFiles = new Set() + const missingInputs = new Set() + resolutionTap?.[1]({ + fileDependencies: inputFiles, + missingDependencies: missingInputs, + }) + expect(missingInputs).toContain(resolve('tsconfig.json')) + const themeTaps = compiler.hooks.afterCompile.tap.mock.calls.filter( + ([name]) => name === 'DevupUIWebpackPlugin', + ) if (options.existsDevupFile) { expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() const add = mock() - compiler.hooks.afterCompile.tap.mock.calls[0][1]({ + themeTaps[0]?.[1]({ fileDependencies: { add, }, }) expect(add).toHaveBeenCalledWith(resolve(plugin.options.devupFile)) - } else expect(compiler.hooks.afterCompile.tap).not.toHaveBeenCalled() + } else expect(themeTaps).toHaveLength(0) if (options.existsCssDir) { expect(mkdirSpy).not.toHaveBeenCalledWith(plugin.options.cssDir, { recursive: true, diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.mjs b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 00000000..8b4e10c1 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { DevupUIWebpackPlugin } from '../../dist/index.mjs' + +const landing = createRequire( + join(import.meta.dirname, '../../../../apps/landing/package.json'), +) +const { webpack } = landing('next/dist/compiled/webpack/webpack') +const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'webpack-resolution-watch-')), +) +const root = join(fixture, 'apps/app') +const palette = join(fixture, 'packages/palette') +const entry = join(root, 'src/main.js') +const manifest = join(palette, 'package.json') +const preserveSymlinks = process.argv[2] === 'preserve' +let compiler +let watching +let next +const pending = [] +const nextBuild = () => + new Promise((resolveBuild, rejectBuild) => { + const timer = setTimeout( + () => rejectBuild(new Error('No real exports-only rebuild')), + 12000, + ) + timer.unref() + next = (error, stats) => { + clearTimeout(timer) + next = undefined + error ? rejectBuild(error) : resolveBuild(stats) + } + if (pending.length) next(...pending.shift()) + }) +const state = async (stats) => { + assert.equal( + stats.hasErrors(), + false, + stats.toString({ all: false, errors: true }), + ) + const module = [...stats.compilation.modules].find( + (candidate) => candidate.resource === entry, + ) + const code = module.originalSource().source().toString() + const selected = code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected, code) + const css = ( + await Promise.all( + Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith('.css')) + .map((name) => + readFile(join(stats.compilation.outputOptions.path, name), 'utf-8'), + ), + ) + ).join('\n') + return { code, selected, css } +} +const configuration = (watch) => ({ + context: root, + mode: 'development', + entry: './src/main.js', + cache: false, + output: { path: join(root, watch ? 'out' : 'cold') }, + resolve: { + symlinks: !preserveSymlinks, + alias: { '@devup-ui/react': landing.resolve('@devup-ui/react') }, + }, + experiments: { css: true }, + module: { rules: [{ test: /\.css$/, type: 'css' }] }, + optimization: { minimize: false }, + plugins: [new DevupUIWebpackPlugin({ watch, singleCss: false })], +}) +try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules'), { recursive: true }) + await mkdir(palette, { recursive: true }) + await symlink(palette, join(root, 'node_modules/palette'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(manifest, '{"name":"palette","exports":"./red.js"}') + await writeFile(join(palette, 'red.js'), "export const color='red'") + await writeFile(join(palette, 'blue.js'), "export const color='blue'") + await writeFile( + entry, + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + compiler = webpack(configuration(true)) + const initial = nextBuild() + watching = compiler.watch({}, (error, stats) => + next ? next(error, stats) : pending.push([error, stats]), + ) + const beforeStats = await initial + const before = await state(beforeStats) + assert.ok( + before.css.includes(`.${before.selected}{background:red}`), + before.css, + ) + const watchedManifest = preserveSymlinks + ? join(root, 'node_modules/palette/package.json') + : manifest + assert.ok( + beforeStats.compilation.fileDependencies.has(watchedManifest), + `Manifest not registered: ${watchedManifest}`, + ) + const rebuilt = nextBuild() + const replacement = join(fixture, 'manifest-replacement.json') + await writeFile(replacement, '{"name":"palette","exports":"./blue.js"}') + await rename(replacement, manifest) + let after = await state(await rebuilt) + for ( + let attempt = 0; + attempt < 3 && after.selected === before.selected; + attempt += 1 + ) + after = await state(await nextBuild()) + assert.notEqual(after.selected, before.selected) + assert.ok( + after.css.includes(`.${after.selected}{background:blue}`), + after.css, + ) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + watching = undefined + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + compiler = webpack(configuration(false)) + const coldStats = await new Promise((done, reject) => + compiler.run((error, stats) => (error ? reject(error) : done(stats))), + ) + const cold = await state(coldStats) + assert.ok(cold.css.includes('background:blue'), cold.css) + assert.ok(!cold.css.includes('background:red'), cold.css) + console.info( + JSON.stringify({ preserveSymlinks, watchedManifest, before, after, cold }), + ) +} finally { + if (watching) + await new Promise((done, reject) => + watching.close((error) => (error ? reject(error) : done())), + ) + if (compiler) + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 00000000..b0ec77f0 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,23 @@ +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it.each(['canonical', 'preserve'])( + 'refreshes actual watch CSS when only linked package exports change with %s symlinks', + (mode) => { + // Given the real compiler fixture with two preexisting palette entries. + const runner = join(import.meta.dir, 'resolution-watch.mjs') + // When the runner edits only exports and awaits actual compiler callbacks. + const result = Bun.spawnSync(['node', runner, mode], { + stdout: 'pipe', + stderr: 'pipe', + }) + // Then emitted current-class CSS is blue and a cold build has no red. + expect({ + exitCode: result.exitCode, + error: result.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(result.stdout.toString()).toContain('"after"') + }, + 45000, +) diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 95bfab72..43b1cbff 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -7,6 +7,8 @@ import { isMdxSource, type ModuleAliasOptions, remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -31,6 +33,7 @@ export interface DevupUILoaderOptions { conditions?: readonly string[] mdxExtensions?: readonly string[] alias?: ModuleAliasOptions + symlinks?: boolean } function toLoaderError(error: unknown): Error { @@ -47,31 +50,24 @@ function parseSourceMap(sourceMap: string | undefined): string | null { const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) -const moduleResolvers = new Map< - string, - ReturnType ->() - /** Resolve imports to the cwd-relative ids this loader extracts files under */ function setCwdModuleResolver(options: { readonly rootDir: string readonly conditions: readonly string[] readonly mdxExtensions: readonly string[] readonly alias: ModuleAliasOptions | undefined + readonly onResolutionInputs: ResolutionInputObserver }): void { - const { rootDir, conditions, mdxExtensions, alias } = options - const key = JSON.stringify([rootDir, conditions, mdxExtensions, alias]) - let moduleResolver = moduleResolvers.get(key) - if (!moduleResolver) { - moduleResolver = createModuleResolver({ - cwd: rootDir, - includeMdx: mdxExtensions, - conditions, - alias, - toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), - }) - moduleResolvers.set(key, moduleResolver) - } + const { rootDir, conditions, mdxExtensions, alias, onResolutionInputs } = + options + const moduleResolver = createModuleResolver({ + cwd: rootDir, + includeMdx: mdxExtensions, + conditions, + alias, + onResolutionInputs, + toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), + }) setModuleResolver(moduleResolver) } @@ -90,6 +86,7 @@ const devupUILoader: RawLoaderDefinitionFunction = conditions = ['import', 'module', 'node'], mdxExtensions = ['.mdx'], alias, + symlinks = true, } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -110,7 +107,18 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(rootDir, id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver({ rootDir, conditions, mdxExtensions, alias }) + setCwdModuleResolver({ + rootDir, + conditions, + mdxExtensions, + alias, + onResolutionInputs: (inputs) => { + for (const file of inputs.fileDependencies) + this.addDependency(resolutionWatchPath(file, !symlinks)) + for (const path of inputs.missingDependencies) + this.addMissingDependency(resolutionWatchPath(path, !symlinks)) + }, + }) const { code, css = '', diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 1b028c9b..2a73744e 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -24,6 +24,8 @@ import { mergeImportAliases, normalizeMdxExtensions, planAtomHoist, + type ResolutionInputObserver, + resolutionWatchPath, resolveProjectPaths, resolveSourceDirs, seedFileNumbers, @@ -228,6 +230,26 @@ export class DevupUIWebpackPlugin { this.fileMapFile = join(paths.distDir, 'fileMap.json') const sourceDirs = resolveSourceDirs(cwd, this.options.sourceDirs) const resolveOptions = compiler.options.resolve + const inputFiles = new Set() + const missingInputs = new Set() + const onResolutionInputs: ResolutionInputObserver = (inputs) => { + for (const path of inputs.fileDependencies) + inputFiles.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + for (const path of inputs.missingDependencies) + missingInputs.add( + resolutionWatchPath(path, resolveOptions?.symlinks === false), + ) + } + compiler.hooks.afterCompile.tap( + 'DevupUIResolutionInputs', + (compilation) => { + for (const path of inputFiles) compilation.fileDependencies.add(path) + for (const path of missingInputs) + compilation.missingDependencies.add(path) + }, + ) const baseConditions = resolveOptions?.conditionNames ?? [ 'webpack', compiler.options.mode === 'development' ? 'development' : 'production', @@ -278,6 +300,7 @@ export class DevupUIWebpackPlugin { includeMdx: this.mdxExtensions, conditions, alias: resolveOptions?.alias, + onResolutionInputs, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) @@ -351,6 +374,7 @@ export class DevupUIWebpackPlugin { include: this.options.include, conditions, alias: resolveOptions?.alias, + onResolutionInputs, }) const canonicalMap = buildCanonicalMap({ srcDir, @@ -545,6 +569,7 @@ export class DevupUIWebpackPlugin { conditions, alias: resolveOptions?.alias, mdxExtensions: this.mdxExtensions, + symlinks: resolveOptions?.symlinks, }, } compiler.options.module.rules.push( From 25156dbca8278da3282cf452b10dcbb0afe8898e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 7 Oct 2026 03:50:09 +0900 Subject: [PATCH 49/50] fix(extractor): compile React development JSX runtime calls Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../devup-ui-wasm/jsx-dev-regressions.mjs | 118 ++++++++ .../devup-ui-wasm/source-type-regressions.mjs | 2 + libs/extractor/src/jsx_dev_tests.rs | 266 ++++++++++++++++++ libs/extractor/src/lib.rs | 2 + libs/extractor/src/visit.rs | 52 ++-- 5 files changed, 421 insertions(+), 19 deletions(-) create mode 100644 bindings/devup-ui-wasm/jsx-dev-regressions.mjs create mode 100644 libs/extractor/src/jsx_dev_tests.rs diff --git a/bindings/devup-ui-wasm/jsx-dev-regressions.mjs b/bindings/devup-ui-wasm/jsx-dev-regressions.mjs new file mode 100644 index 00000000..e77913a3 --- /dev/null +++ b/bindings/devup-ui-wasm/jsx-dev-regressions.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const project = createRequire( + new URL('../../apps/landing/package.json', import.meta.url), +) +const compiler = createRequire(project.resolve('@mdx-js/loader')) +const { compile } = await import( + pathToFileURL(compiler.resolve('@mdx-js/mdx')).href +) +const { parse } = compiler('acorn') +const wasm = createRequire(import.meta.url)('./pkg/index.js') +const markdown = + 'import { Box } from "@devup-ui/react"\n\n\n' +let passed = 0 +let failed = 0 + +function structure(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + ['start', 'end', 'loc', 'raw'].includes(key) ? undefined : item, + ), + ) +} + +function calls(code) { + const found = [] + function visit(node) { + if (!node || typeof node !== 'object') return + if (node.type === 'CallExpression' && node.callee.name === '_jsxDEV') + found.push(node) + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit) + else visit(value) + } + } + visit(parse(code, { ecmaVersion: 'latest', sourceType: 'module' })) + return found +} + +const root = mkdtempSync(join(tmpdir(), 'devup-jsxdev-')) +try { + for (const extension of ['mdx', 'md', 'mdown']) { + const filename = join(root, `development.${extension}`).replaceAll( + '\\', + '/', + ) + writeFileSync(filename, markdown) + const code = String( + await compile( + { path: filename, value: readFileSync(filename, 'utf8') }, + { development: true, jsx: false, format: 'mdx' }, + ), + ) + const before = calls(code) + const componentCalls = before.flatMap((call, index) => + call.arguments[0].name === 'Box' ? [index] : [], + ) + assert.equal(componentCalls.length, 2) + assert.match(code, /react\/jsx-dev-runtime/) + for (const method of ['codeExtract', 'codeExtractWithoutSourceMap']) { + wasm.resetBuildState() + try { + const output = wasm[method]( + filename, + code, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + extension === 'mdown' ? 'compiled-mdx' : undefined, + ) + try { + const after = calls(output.code) + assert.equal(after.length, before.length) + for (const [index, original] of before.entries()) { + assert.equal(after[index].arguments.length, 6) + assert.deepEqual( + structure(after[index].callee), + structure(original.callee), + ) + assert.deepEqual( + structure(after[index].arguments.slice(2)), + structure(original.arguments.slice(2)), + ) + } + assert.match(wasm.getCss(undefined, false), /background:tomato/) + assert.match(wasm.getCss(undefined, false), /padding:8px/) + assert.doesNotMatch(output.code, /(?: String { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + Codegen::new().build(&parsed.program).code +} + +#[derive(Default)] +struct Calls<'s> { + source: &'s str, + calls: Vec<(String, Vec)>, +} + +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let callee = call.callee.span().source_text(self.source); + if callee == "jsxDEV" || callee == "dev" || callee.ends_with(".jsxDEV") { + self.calls.push(( + normalized(&format!("({callee});")), + call.arguments + .iter() + .map(|arg| normalized(&format!("({});", arg.span().source_text(self.source)))) + .collect(), + )); + } + walk::walk_call_expression(self, call); + } +} + +fn calls(source: &str) -> Vec<(String, Vec)> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.errors().count(), 0); + let mut visitor = Calls { + source, + ..Calls::default() + }; + visitor.visit_program(&parsed.program); + visitor.calls +} + +#[rstest] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; import {Box} from '@devup-ui/react';", + "jsxDEV", + "Box" +)] +#[case( + "import {jsxDEV as dev} from 'react/jsx-dev-runtime'; import {Box as Card} from '@devup-ui/react';", + "dev", + "Card" +)] +#[case( + "import * as runtime from 'react/jsx-dev-runtime'; import * as UI from '@devup-ui/react';", + "runtime.jsxDEV", + "UI.Box" +)] +#[case( + "const runtime = require('react/jsx-dev-runtime'); const UI = require('@devup-ui/react');", + "runtime.jsxDEV", + "UI.Box" +)] +#[case( + "const {jsxDEV} = require('react/jsx-dev-runtime'); const {Box} = require('@devup-ui/react');", + "jsxDEV", + "Box" +)] +#[case( + "const {jsxDEV: dev} = require('react/jsx-dev-runtime'); const {Box} = require('@devup-ui/react');", + "dev", + "Box" +)] +#[serial] +fn jsx_dev_extracts_when_runtime_origin_is_known( + #[case] imports: &str, + #[case] callee: &str, + #[case] component: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports} export const view = {callee}({component}, {{as: 'section', bg: 'tomato', color: color, id: 'card', props: {{title: 'hello'}}}}, key(), true, {{fileName: 'actual.mdx', lineNumber: 7, columnNumber: 3}}, this);" + ); + let before = calls(&source); + + let output = extract("/src/page.mdx", &source, ExtractOption::default())?; + + let after = calls(&output.code); + assert_eq!(after.len(), 1); + assert_eq!(after[0].0, before[0].0); + assert_eq!(after[0].1.len(), 6); + assert_eq!(after[0].1[0], normalized("('section');")); + assert_eq!(after[0].1[2..], before[0].1[2..]); + assert!(after[0].1[1].contains("className")); + assert!(after[0].1[1].contains("title: \"hello\"")); + assert!(after[0].1[1].contains("id: \"card\"")); + assert!(after[0].1[1].contains("color")); + assert!(after[0].1[1].contains("--")); + assert!(!after[0].1[1].contains("bg:")); + assert_ne!(output.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case( + "import * as prod from 'react/jsx-runtime'; import * as other from 'react/jsx-runtime'; import * as runtime from 'react/jsx-dev-runtime'; import {Box} from '@devup-ui/react';" +)] +#[case( + "const prod = require('react/jsx-runtime'); const runtime = require('react/jsx-dev-runtime'); const other = require('react/jsx-runtime'); const {Box} = require('@devup-ui/react');" +)] +#[serial] +fn jsx_dev_extracts_when_production_and_development_objects_coexist( + #[case] imports: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports} export const view = [prod.jsx(Box, {{bg: 'red'}}), runtime.jsxDEV(Box, {{bg: 'blue'}}, void 0, false, source, self), other.jsxs(Box, {{color: 'green'}})];" + ); + + let output = extract("/src/mixed.js", &source, ExtractOption::default())?; + + assert_eq!(output.code.matches("className:").count(), 3); + assert!(!output.code.contains("(Box")); + assert_eq!(calls(&output.code)[0].1[2..], calls(&source)[0].1[2..]); + Ok(()) +} + +#[rstest] +#[case("...readProps(), bg: 'red'")] +#[case( + "as: active ? 'article' : 'section', styleOrder: active ? 1 : 2, bg: color, style: {opacity: 0.5}, styleVars: {'--custom': color}, props: {title: 'nested'}" +)] +#[serial] +fn jsx_dev_preserves_metadata_when_props_wrap_or_children_nest( + #[case] props: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{jsxDEV as dev}} from 'react/jsx-dev-runtime'; import {{Box}} from '@devup-ui/react'; export const view = dev(Box, {{{props}, children: dev(Box, {{p: 2}}, childKey, false, childSource, childSelf)}}, parentKey, true, parentSource, this);" + ); + let before = calls(&source); + + let output = extract("/src/nested.js", &source, ExtractOption::default())?; + + let after = calls(&output.code); + assert_eq!(after.len(), 2); + for (old, new) in before.iter().zip(&after) { + assert_eq!(new.0, old.0); + assert_eq!(new.1.len(), 6); + assert_eq!(new.1[2..], old.1[2..]); + assert!(new.1[1].contains("className")); + } + assert_eq!( + output.code.matches("readProps()").count(), + usize::from(props.contains("readProps")) + ); + assert!(!output.code.contains("dev(Box")); + Ok(()) +} + +#[rstest] +#[case("import {jsxDEV} from 'unrelated'; jsxDEV('div', {bg: 'red'}, key, false, source, self);")] +#[case("import * as unrelated from 'unrelated'; unrelated.jsxDEV('div', {bg: 'red'});")] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; jsxDEV('div', {bg: 'red'}, key, false, source, self);" +)] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; jsxDEV(); jsxDEV(...args); jsxDEV('div'); jsxDEV('div', ...args);" +)] +#[case("import runtime from 'react/jsx-dev-runtime'; runtime.jsxDEV('div', {bg: 'red'});")] +#[case( + "import {jsxDEV} from 'react/jsx-dev-runtime'; import * as UI from '@devup-ui/react'; jsxDEV(UI.Unknown, {bg: 'red'});" +)] +#[case( + "const runtime = require('react/jsx-dev-runtime'); const UI = require('@devup-ui/react'); runtime.jsxDEV(UI.Unknown, {bg: 'red'});" +)] +#[serial] +fn jsx_dev_keeps_calls_when_origin_component_or_arguments_are_ineligible( + #[case] control: &str, +) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react'; export const cls = css({{color: 'blue'}}); {control}" + ); + + let output = extract("/src/control.js", &source, ExtractOption::default())?; + + assert!(output.code.contains(&normalized(control))); + assert!(!output.code.contains("className")); + Ok(()) +} + +#[rstest] +#[case("jsxDEV(Box)", "react/jsx-dev-runtime")] +#[case("jsxDEV(Box, ...args)", "react/jsx-dev-runtime")] +#[case("other.jsxDEV(Box, {})", "react/jsx-dev-runtime")] +#[case("jsxDEV(Box, {})", "unrelated")] +#[serial] +fn jsx_dev_keeps_runtime_read_diagnostics_when_call_is_ineligible( + #[case] call: &str, + #[case] runtime: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{jsxDEV}} from '{runtime}'; import {{Box}} from '@devup-ui/react'; {call};" + ); + + let result = extract("/src/control.js", &source, ExtractOption::default()); + + assert!(result.is_err_and(|error| { + let message = error.to_string(); + message.contains("Box") && message.contains("runtime") + })); +} + +#[test] +#[serial] +fn jsx_dev_namespace_lookup_keeps_production_parity() -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let source = "import {jsx} from 'react/jsx-runtime'; import * as UI from '@devup-ui/react'; export const view = jsx(UI.Box, {bg: 'red'});"; + + let output = extract("/src/production.js", source, ExtractOption::default())?; + + assert!(output.code.contains("jsx(\"div\"")); + assert!(output.code.contains("className")); + assert_ne!(output.styles.len(), 0); + Ok(()) +} + +#[rstest] +#[case("jsx", "react/jsx-runtime")] +#[case("jsxDEV", "react/jsx-dev-runtime")] +#[serial] +fn jsx_dev_keeps_existing_callee_limits( + #[case] method: &str, + #[case] runtime: &str, + #[values("(0, helper)(Box, {})", "runtime['CALL'](Box, {})", "rebound(Box, {})")] call: &str, +) { + reset_class_map(); + reset_file_map(); + let call = call.replace("CALL", method); + let source = format!( + "import {{{method} as helper}} from '{runtime}'; import * as runtime from '{runtime}'; import {{Box}} from '@devup-ui/react'; const rebound = helper; {call};" + ); + + let result = extract("/src/limits.js", &source, ExtractOption::default()); + + assert!(result.is_err_and(|error| error.to_string().contains("Box"))); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ee5ce117..4fbc9ddf 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -8,6 +8,8 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +#[cfg(test)] +mod jsx_dev_tests; mod module_loader; mod mutations; mod prop_modify_utils; diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123..7ca23a24 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -123,7 +123,7 @@ pub struct DevupVisitor<'a> { import_object: Option, jsx_imports: FxHashMap, util_imports: FxHashMap>, - jsx_object: Option, + jsx_objects: FxHashSet, package: String, /// Entry the rewritten imports of absorbed third-party APIs land on. Its specifiers /// register exactly like the main package's so those calls still compile away. @@ -314,7 +314,7 @@ impl<'a> DevupVisitor<'a> { styles: FxHashSet::default(), errors: Vec::new(), import_object: None, - jsx_object: None, + jsx_objects: FxHashSet::default(), util_imports: FxHashMap::default(), split_filename, styled_imports: FxHashSet::default(), @@ -1921,31 +1921,38 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { fn visit_call_expression(&mut self, it: &mut CallExpression<'a>) { let jsx = if let Expression::Identifier(ident) = &it.callee { self.jsx_imports.get(ident.name.as_str()).cloned() - } else if let Some(name) = &self.jsx_object - && let Expression::StaticMemberExpression(member) = &it.callee + } else if let Expression::StaticMemberExpression(member) = &it.callee && let Expression::Identifier(ident) = &member.object - && name == ident.name.as_str() + && self.jsx_objects.contains(ident.name.as_str()) { Some(member.property.name.to_string()) } else { None }; if let Some(j) = jsx - && (j == "jsx" || j == "jsxs") + && (j == "jsx" || j == "jsxs" || j == "jsxDEV") && let Some(expr) = it.arguments.first().and_then(|arg| arg.as_expression()) { let element_kind = if let Expression::Identifier(ident) = expr { self.imports.get(ident.name.as_str()).cloned() } else if let Expression::StaticMemberExpression(member) = expr && let Expression::Identifier(ident) = &member.object - && self.import_object.as_deref() == Some(ident.name.as_str()) { - member - .property - .name - .as_str() - .parse::() - .ok() + self.imports + .get(&format!("{}.{}", ident.name, member.property.name)) + .cloned() + .or_else(|| { + (self.import_object.as_deref() == Some(ident.name.as_str())) + .then(|| { + member + .property + .name + .as_str() + .parse::() + .ok() + }) + .flatten() + }) } else { None }; @@ -2120,9 +2127,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { (&call.callee, &call.arguments[0]) && ident.name == "require" { - if arg.value == "react/jsx-runtime" { + if arg.value == "react/jsx-runtime" || arg.value == "react/jsx-dev-runtime" { if let BindingPattern::BindingIdentifier(ident) = &it.id { - self.jsx_object = Some(ident.name.to_string()); + self.jsx_objects.insert(ident.name.to_string()); } else if let BindingPattern::ObjectPattern(object) = &it.id { for prop in &object.properties { if let Some(name) = get_string_by_property_key(&prop.key) @@ -2236,13 +2243,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } fn visit_import_declaration(&mut self, it: &mut ImportDeclaration<'a>) { if it.source.value != self.package - && it.source.value == "react/jsx-runtime" + && (it.source.value == "react/jsx-runtime" + || it.source.value == "react/jsx-dev-runtime") && let Some(specifiers) = &it.specifiers { for specifier in specifiers { - if let ImportSpecifier(import) = specifier { - self.jsx_imports - .insert(import.local.to_string(), import.imported.to_string()); + match specifier { + ImportSpecifier(import) => { + self.jsx_imports + .insert(import.local.to_string(), import.imported.to_string()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(import) => { + self.jsx_objects.insert(import.local.name.to_string()); + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {} } } } else if (it.source.value == self.package From b70c41904600b600163adac0788f3b8a1c958810 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 7 Oct 2026 15:56:29 +0900 Subject: [PATCH 50/50] fix(plugin-utils): resolve packaged tsconfig extends with owned TypeScript semantics Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../plugin-utils/src/__tests__/jsonc.test.ts | 106 ++++++++ .../__tests__/packaged-config-inputs.test.ts | 197 ++++++++++++++- .../src/__tests__/resolution-inputs.test.ts | 34 ++- .../__tests__/tsconfig-config-policy.test.ts | 119 +++++++++ .../src/__tests__/tsconfig-manifest-cases.ts | 204 +++++++++++++++ .../__tests__/tsconfig-native-freshness.mjs | 77 ++++++ .../src/__tests__/tsconfig-native.test.ts | 24 ++ .../__tests__/tsconfig-package-scope.test.ts | 216 ++++++++++++++++ .../tsconfig-packaged-resolution.test.ts | 199 +++++++++++++++ .../tsconfig-policy-corrections.test.ts | 166 +++++++++++++ .../__tests__/tsconfig-repair-inputs.test.ts | 158 ++++++++++++ .../tsconfig-version-extensions.test.ts | 91 +++++++ .../typescript-version-range.test.ts | 86 +++++++ packages/plugin-utils/src/import-graph.ts | 235 +----------------- packages/plugin-utils/src/jsonc.ts | 54 ++++ .../plugin-utils/src/owned-file-resolution.ts | 90 +++++++ .../src/owned-module-resolution.ts | 148 +++++++++++ packages/plugin-utils/src/package-target.ts | 128 ++++++++++ .../src/packaged-config-inputs.ts | 86 ++++--- .../src/tsconfig-export-policy.ts | 130 ++++++++++ .../src/tsconfig-package-policy.ts | 172 +++++++++++++ packages/plugin-utils/src/tsconfig.ts | 128 ++-------- .../src/typescript-version-range.ts | 190 ++++++++++++++ .../__tests__/resolution-watch-scope.test.ts | 56 +++++ .../src/__tests__/tsconfig-native-watch.mjs | 121 +++++++++ .../__tests__/tsconfig-native-watch.test.ts | 20 ++ packages/vite-plugin/src/resolution-watch.ts | 8 +- 27 files changed, 2853 insertions(+), 390 deletions(-) create mode 100644 packages/plugin-utils/src/__tests__/jsonc.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-native.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts create mode 100644 packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts create mode 100644 packages/plugin-utils/src/__tests__/typescript-version-range.test.ts create mode 100644 packages/plugin-utils/src/jsonc.ts create mode 100644 packages/plugin-utils/src/owned-file-resolution.ts create mode 100644 packages/plugin-utils/src/owned-module-resolution.ts create mode 100644 packages/plugin-utils/src/package-target.ts create mode 100644 packages/plugin-utils/src/tsconfig-export-policy.ts create mode 100644 packages/plugin-utils/src/tsconfig-package-policy.ts create mode 100644 packages/plugin-utils/src/typescript-version-range.ts create mode 100644 packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts create mode 100644 packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs create mode 100644 packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts diff --git a/packages/plugin-utils/src/__tests__/jsonc.test.ts b/packages/plugin-utils/src/__tests__/jsonc.test.ts new file mode 100644 index 00000000..1cbca023 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsonc.test.ts @@ -0,0 +1,106 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { parseJsonc } from '../jsonc' +import { ConfigLoadError } from '../load-config' +import { readPackageManifest } from '../packaged-config-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + parseConfigFileTextToJson( + file: string, + source: string, + ): { + readonly config?: unknown + readonly error?: unknown + } +} = createRequire(import.meta.url)('@typescript/typescript6') +const hexOverflow = `0x${'F'.repeat(400)}` +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([ + ['0xff', 255], + ['-0xff', -255], + ['1_000', 1000], + ['-1_000', -1000], + ['0b10', 2], + ['0o10', 8], + [hexOverflow, Infinity], + [`-${hexOverflow}`, -Infinity], + ['1_0e999', Infinity], + ['-1_0e999', -Infinity], + ['1e999', Infinity], + ['-1e999', -Infinity], +] as const)( + 'retains numeric value and unrelated selection metadata for %s', + (literal, expected) => { + // Given valid JSONC numeric syntax and independently expected selecting/numeric fields. + expect(ts.version).toBe('6.0.3') + const source = `{"tsconfig":"blue.json","other":${literal}}` + const fields = { tsconfig: 'blue.json', other: expected } + // When the installed compiler and owned parser convert the same literal. + const oracle = ts.parseConfigFileTextToJson('package.json', source) + // Then neither drops metadata nor caps/nulls the exact numeric result. + expect(oracle.error).toBeUndefined() + expect(oracle.config).toEqual(fields) + expect(parseJsonc(source, true)).toEqual(fields) + }, +) + +it.each([hexOverflow, '1_0e999', '0xff', '1_000'])( + 'keeps ordinary module manifests strict for JSONC literal %s', + (literal) => { + // Given a real module manifest whose numeric spelling requires the config-only JSONC policy. + const manifest = file( + 'node_modules/preset/package.json', + `{"main":"blue.js","other":${literal}}`, + ) + file('node_modules/preset/blue.js', "export const color='blue'") + // When the ordinary module purpose encounters that non-JSON spelling. + expect(() => readPackageManifest(manifest, 'module')).toThrow( + ConfigLoadError, + ) + // Then the unchanged public module resolver retains the real manifest load failure. + expect(() => + createModuleResolver({ cwd: root })('preset', 'main.ts'), + ).toThrow(manifest) + }, +) + +it('preserves standard JSON decimal overflow in ordinary module manifests', () => { + // Given valid standard JSON with an unrelated overflow field and a real main module. + const manifest = file( + 'node_modules/preset/package.json', + '{"main":"blue.js","other":1e999}', + ) + const target = file( + 'node_modules/preset/blue.js', + "export const color='blue'", + ) + // When the unchanged strict JSON policy and module resolver read it. + const fields = readPackageManifest(manifest, 'module') + // Then standard JSON semantics retain Infinity and ordinary main selection. + expect(fields).toEqual({ main: 'blue.js', other: Infinity }) + expect(createModuleResolver({ cwd: root })('preset', 'main.ts')?.path).toBe( + target, + ) +}) + +it.each(['0x', '1__0'])( + 'keeps malformed numeric literal %s as a config-manifest parse miss', + (literal) => { + // Given malformed syntax beside a selecting field. + const source = `{"tsconfig":"blue.json","other":${literal}}` + const manifest = file('package.json', source) + // When the config-only parser and manifest boundary encounter the invalid literal. + expect(() => parseJsonc(source, true)).toThrow(SyntaxError) + // Then the established malformed-manifest fallback remains an empty record. + expect(readPackageManifest(manifest, 'tsconfig-extends')).toEqual({}) + }, +) diff --git a/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts index 59b81afd..4411e9c0 100644 --- a/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts +++ b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts @@ -1,9 +1,10 @@ -import { createRequire } from 'node:module' -import { join } from 'node:path' +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' -import { expect, it } from 'bun:test' +import { expect, it, spyOn } from 'bun:test' -import { recordPackagedConfigInputs } from '../packaged-config-inputs' +import { resolvePackage } from '../owned-module-resolution' import { createResolutionInputs } from '../resolution-inputs' import { createPreparedFixture } from './prepared-graph-fixture' @@ -12,17 +13,189 @@ const file = createPreparedFixture((directory) => { root = directory }) -it('records only preceding missing package directories when Node selects a config', () => { +it('reads the matched root manifest when a nested config has an unrelated manifest', () => { // Given a native public lookup resolving below an absent nearer package directory. const importer = file('nested/tsconfig.json', '{}') - file('node_modules/preset/base.json', '{}') - const resolved = createRequire(importer).resolve('preset/base.json') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/configs/package.json', '{}') + file('node_modules/preset/configs/base.json', '{}') const inputs = createResolutionInputs() + const reads: string[] = [] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) // When recording public search-directory checks for the completed native lookup. - recordPackagedConfigInputs({ importer, request: 'preset', resolved }, inputs) - // Then no Node-internal manifest or in-package probe has been invented. - expect(inputs.snapshot()).toEqual({ - fileDependencies: [], - missingDependencies: [join(root, 'nested/node_modules/preset')], + try { + expect( + resolvePackage('preset/configs/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/configs/base.json')) + // Then the exact owned read excludes the nearer nested manifest and later search directories. + expect(reads).toEqual([manifest]) + expect(inputs.snapshot().fileDependencies).toEqual( + [manifest, join(root, 'node_modules/preset/configs/base.json')].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/preset/package.json'), + ) + } finally { + read.mockRestore() + } +}) + +it('records the lexical selecting manifest when a scoped package is linked', () => { + // Given a scoped package whose physical config lies outside node_modules. + const importer = file('nested/tsconfig.json', '{}') + const physical = file( + 'workspace/preset/package.json', + '{"exports":"./base.json"}', + ) + file('workspace/preset/base.json', '{}') + file('node_modules/@scope/placeholder.json', '{}') + fs.symlinkSync( + dirname(physical), + join(root, 'node_modules/@scope/preset'), + 'junction', + ) + const inputs = createResolutionInputs() + // When observing ownership using the already selected canonical path. + expect( + resolvePackage('@scope/preset', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'workspace/preset/base.json')) + // Then evidence retains the matched lexical root rather than its physical location. + expect(inputs.snapshot().fileDependencies).toEqual( + [ + join(root, 'node_modules/@scope/preset/package.json'), + join(root, 'node_modules/@scope/preset/base.json'), + ].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/@scope/preset/package.json'), + ) +}) + +it('records a missing exact manifest when a manifestless deep file resolves', () => { + // Given a valid deep-file package without a root manifest. + const importer = file('nested/tsconfig.json', '{}') + file('node_modules/preset/base.json', '{}') + const inputs = createResolutionInputs() + // When observing the completed lookup without requiring a manifest to exist. + expect( + resolvePackage('preset/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/base.json')) + // Then the absent root manifest is repairable without rejecting the valid config. + expect(inputs.snapshot().fileDependencies).toEqual([ + join(root, 'node_modules/preset/base.json'), + ]) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'node_modules/preset/package.json'), + ) +}) + +it('skips an existing nearer package when it does not contain the selected file', () => { + // Given a nearer package missing the requested deep file and a matching outer package. + const importer = file('nested/tsconfig.json', '{}') + const nearer = file('nested/node_modules/preset/package.json', '{}') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/..configs/base.json', '{}') + const inputs = createResolutionInputs() + // When recording the native selected file rather than guessing the nearest package. + expect( + resolvePackage('preset/..configs/base.json', importer, { + purpose: 'tsconfig-extends', + inputs, + }), + ).toBe(join(root, 'node_modules/preset/..configs/base.json')) + // Then containment recognizes a dot-prefixed child and excludes the unrelated manifest. + expect(inputs.snapshot().fileDependencies).toEqual( + [ + nearer, + manifest, + join(root, 'node_modules/preset/..configs/base.json'), + ].sort(), + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, 'nested/node_modules/preset/..configs/base.json'), + ) +}) + +it('preserves the real read failure when the matched manifest cannot be read', () => { + // Given a resolved config and a failure at the owned manifest read boundary. + const importer = file('tsconfig.json', '{}') + const manifest = file('node_modules/preset/package.json', '{}') + file('node_modules/preset/base.json', '{}') + const failure = Object.assign(new Error('denied'), { + code: 'EACCES', + path: manifest, }) + const original = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === manifest) throw failure + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When the exact matched manifest read fails. + let caught: unknown + try { + resolvePackage('preset', importer, { + purpose: 'tsconfig-extends', + inputs, + }) + } catch (error) { + caught = error + } + // Then neither the exception nor its attempted input identity is replaced. + expect(caught).toBe(failure) + expect(inputs.snapshot().fileDependencies).toEqual([manifest]) + } finally { + read.mockRestore() + } +}) + +it('changes the observed content fingerprint when only selecting exports changes', () => { + // Given two preexisting configs and an observed selection, independent of native freshness. + const importer = file('tsconfig.json', '{}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./red.json"}', + ) + file( + 'node_modules/preset/red.json', + '{"compilerOptions":{"paths":{"color":["red.ts"]}}}', + ) + file( + 'node_modules/preset/blue.json', + '{"compilerOptions":{"paths":{"color":["blue.ts"]}}}', + ) + const inputs = createResolutionInputs() + resolvePackage('preset', importer, { purpose: 'tsconfig-extends', inputs }) + const before = createHash('sha256') + for (const path of inputs.snapshot().fileDependencies) + before.update(fs.readFileSync(path)) + // When only the selecting manifest content changes. + fs.writeFileSync(manifest, '{"exports":"./blue.json"}') + const after = createHash('sha256') + for (const path of inputs.snapshot().fileDependencies) + after.update(fs.readFileSync(path)) + // Then unchanged input paths still expose the selecting-content change. + expect(after.digest('hex')).not.toBe(before.digest('hex')) }) diff --git a/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts index 66820b8f..3f452476 100644 --- a/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts +++ b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts @@ -122,23 +122,30 @@ it('freezes deterministic snapshots without redoing successful resolution IO', ( } }) -it.each(['main', 'fallback', 'json'])( +it.each(['main', 'fallback', 'json', 'exports', 'direct'])( 'records actual loaded packaged config chain when using %s resolution', (mode) => { // Given a package-config extension found below an absent nearer node_modules directory. - const config = file('src/tsconfig.json', '{"extends":"preset"}') + const config = file( + 'src/tsconfig.json', + JSON.stringify({ + extends: mode === 'direct' ? 'preset/base.json' : 'preset', + }), + ) const manifest = file( 'node_modules/preset/package.json', mode === 'main' ? '{"main":"index.js","tsconfig":"base.json"}' - : mode === 'json' - ? '{"main":"base.json"}' - : '{}', + : mode === 'exports' + ? '{"exports":"./base.json"}' + : mode === 'json' + ? '{"main":"base.json"}' + : '{}', ) if (mode === 'main') file('node_modules/preset/index.js', 'module.exports={}') const base = file( - `node_modules/preset/${mode === 'fallback' ? 'tsconfig.json' : 'base.json'}`, + `node_modules/preset/${mode === 'fallback' || mode === 'json' ? 'tsconfig.json' : 'base.json'}`, '{}', ) const observations: unknown[] = [] @@ -149,13 +156,14 @@ it.each(['main', 'fallback', 'json'])( onResolutionInputs: (inputs) => observations.push(inputs), }) // Then only owned reads and public-search missing directories are recorded. - expect(observations).toContainEqual({ - fileDependencies: (mode === 'main' - ? [config, manifest, base] - : [config, base] - ).sort(), - missingDependencies: [join(root, 'src/node_modules/preset')], - }) + expect(observations).toContainEqual( + expect.objectContaining({ + fileDependencies: [config, manifest, base].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'src/node_modules/preset/package.json'), + ]), + }), + ) }, ) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts new file mode 100644 index 00000000..2b1361c7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-config-policy.test.ts @@ -0,0 +1,119 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { resolvePackage } from '../owned-module-resolution' +import { readPackageManifest } from '../packaged-config-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { readonly resolvedModule?: { readonly resolvedFileName: string } } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('uses a nested package config before root versions when root has no exports property', () => { + const importer = file('tsconfig.json', '{}') + file( + 'node_modules/preset/package.json', + '{"typesVersions":{"*":{"config":["red.json"]}}}', + ) + file('node_modules/preset/red.json', '{}') + file('node_modules/preset/config/package.json', '{"tsconfig":"blue.json"}') + const expected = file('node_modules/preset/config/blue.json', '{}') + const oracle = ts.nodeNextJsonConfigResolver( + 'preset/config', + importer, + ts.sys, + ).resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + expect( + resolvePackage('preset/config', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) + +it('returns a missing imports result when no package scope exists', () => { + const importer = file('tsconfig.json', '{}') + const oracle = ts.nodeNextJsonConfigResolver('#missing', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle).toBeUndefined() + expect( + resolvePackage('#missing', importer, { purpose: 'tsconfig-extends' }), + ).toBeUndefined() +}) + +it('preserves non-syntax parser failures rather than translating them to empty manifests', () => { + const manifest = file('package.json', '{}') + const failure = new TypeError('parser failure') + const parse = spyOn(JSON, 'parse').mockImplementation(() => { + throw failure + }) + try { + expect(() => readPackageManifest(manifest, 'tsconfig-extends')).toThrow( + failure, + ) + } finally { + parse.mockRestore() + } +}) + +it('ignores package exports when the compiler takes the dot filesystem-directory branch', () => { + // Given a directory config and a distinct exported JSON in the same directory. + const importer = file('src/tsconfig.json', '{"extends":"."}') + file('src/package.json', '{"exports":"./blue.json"}') + file('src/blue.json', '{}') + // When the packaged entry delegates dot requests to its CJS directory policy. + const oracle = ts.nodeNextJsonConfigResolver('.', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(importer) + // Then exports encapsulation does not govern the filesystem-directory branch. + expect(resolvePackage('.', importer, { purpose: 'tsconfig-extends' })).toBe( + importer, + ) +}) + +it('preserves compiler casing identity when realpath differs only in case', () => { + // Given an exports target whose casing differs from the real directory entry. + const importer = file('tsconfig.json', '{}') + file('node_modules/preset/package.json', '{"exports":"./BLUE.json"}') + file('node_modules/preset/blue.json', '{}') + const expected = + process.platform === 'win32' + ? join(root, 'node_modules/preset/BLUE.json') + : undefined + // When both resolvers use the actual case-sensitive/case-insensitive filesystem. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + // Then canonicalization changes only genuine symlink origins, not spelling-only identity. + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) + +it('applies root versions to dot-prefixed child directories without treating them as parent escapes', () => { + // Given an in-package field path whose child name starts with two dots. + const importer = file('tsconfig.json', '{}') + file( + 'node_modules/preset/package.json', + '{"tsconfig":"..configs/red.json","typesVersions":{"*":{"..configs/red.json":["blue.json"]}}}', + ) + file('node_modules/preset/..configs/red.json', '{}') + const expected = file('node_modules/preset/blue.json', '{}') + // When resolving versions before the package field. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(expected) + // Then only an actual parent path component excludes version remapping. + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts b/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts new file mode 100644 index 00000000..0d91e92c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-manifest-cases.ts @@ -0,0 +1,204 @@ +export const manifestCases: readonly [ + string, + unknown, + string | undefined, + string?, +][] = [ + [ + 'versioned types matches', + { exports: { 'types@>=6': './blue.json', default: './red.json' } }, + 'blue.json', + ], + [ + 'versioned types misses', + { exports: { 'types@<6': './blue.json', default: './red.json' } }, + 'red.json', + ], + [ + 'invalid range misses', + { exports: { 'types@bad': './blue.json', default: './red.json' } }, + 'red.json', + ], + [ + 'typesVersions root key is tsconfig', + { typesVersions: { '*': { tsconfig: ['blue.json'] } } }, + 'blue.json', + ], + [ + 'typesVersions uses field path', + { + tsconfig: 'red.json', + typesVersions: { '>=6': { 'red.json': ['blue.json'] } }, + }, + 'blue.json', + ], + [ + 'typesVersions deep maps', + { typesVersions: { '^6.0.0': { 'config/*': ['*.json'] } } }, + 'blue.json', + 'preset/config/blue', + ], + [ + 'typesVersions first matching range', + { + typesVersions: { + '*': { tsconfig: ['blue.json'] }, + '>=6': { tsconfig: ['red.json'] }, + }, + }, + 'blue.json', + ], + [ + 'exports overrides versions', + { + exports: './red.json', + typesVersions: { '*': { tsconfig: ['blue.json'] } }, + }, + 'red.json', + ], + ['malformed manifest defaults', '{', 'tsconfig.json'], + [ + 'JSONC manifest selects', + '{/*comment*/"tsconfig":"blue.json",}', + 'blue.json', + ], + ['BOM manifest selects', '\uFEFF{"tsconfig":"blue.json"}', 'blue.json'], + ['unquoted manifest defaults', '{tsconfig:"blue.json"}', 'tsconfig.json'], + [ + 'single quoted manifest defaults', + "{'tsconfig':'blue.json'}", + 'tsconfig.json', + ], + [ + 'hex manifest parse policy', + '{"tsconfig":"blue.json","number":0x1}', + 'blue.json', + ], + [ + 'hex overflow metadata retains tsconfig selection', + `{"tsconfig":"blue.json","other":0x${'F'.repeat(400)}}`, + 'blue.json', + ], + [ + 'separator overflow metadata retains tsconfig selection', + '{"tsconfig":"blue.json","other":1_0e999}', + 'blue.json', + ], + [ + 'hex overflow exports still encapsulates the package', + `{"exports":0x${'F'.repeat(400)}}`, + undefined, + ], + [ + 'negative hex overflow metadata retains tsconfig selection', + `{"tsconfig":"blue.json","other":-0x${'F'.repeat(400)}}`, + 'blue.json', + ], + [ + 'negative separator overflow metadata retains tsconfig selection', + '{"tsconfig":"blue.json","other":-1_0e999}', + 'blue.json', + ], + [ + 'standard JSON decimal overflow retains tsconfig selection', + '{"tsconfig":"blue.json","other":1e999}', + 'blue.json', + ], + [ + 'binary manifest parse policy', + '{"tsconfig":"blue.json","number":0b1}', + 'blue.json', + ], + [ + 'octal manifest parse policy', + '{"tsconfig":"blue.json","number":0o1}', + 'blue.json', + ], + [ + 'numeric separator manifest parse policy', + '{"tsconfig":"blue.json","number":1_000}', + 'blue.json', + ], + [ + 'leading decimal manifest parse policy', + '{"tsconfig":"blue.json","number":.5}', + 'blue.json', + ], + [ + 'trailing decimal manifest parse policy', + '{"tsconfig":"blue.json","number":1.}', + 'blue.json', + ], + [ + 'invalid separator manifest defaults', + '{"tsconfig":"blue.json","number":1__0}', + 'tsconfig.json', + ], + [ + 'invalid hex manifest defaults', + '{"tsconfig":"blue.json","number":0x}', + 'tsconfig.json', + ], + [ + 'leading zero manifest parse policy', + '{"tsconfig":"blue.json","number":01}', + 'tsconfig.json', + ], + [ + 'undefined manifest defaults', + '{"tsconfig":"blue.json","other":undefined}', + 'tsconfig.json', + ], + [ + 'invalid requested subpath', + { exports: { './*': './*.json' } }, + undefined, + 'preset/dir/../blue', + ], + [ + 'invalid first target segment', + { exports: './node_modules/blue.json' }, + undefined, + ], + [ + 'typesVersions invalid value ignored', + { typesVersions: { '*': 1 } }, + 'tsconfig.json', + ], + [ + 'typesVersions matched miss is terminal', + { typesVersions: { '*': { tsconfig: ['missing.json'] } } }, + undefined, + ], + [ + 'typesVersions invalid patterns ignored', + { typesVersions: { '*': { '**': ['blue.json'] } } }, + 'tsconfig.json', + ], + [ + 'typesVersions targets miss then hit', + { typesVersions: { '*': { tsconfig: ['missing.json', 'blue'] } } }, + 'blue.json', + ], + [ + 'version range misses use default', + { typesVersions: { '<6': { tsconfig: ['blue.json'] } } }, + 'tsconfig.json', + ], + [ + 'version mapping long prefix wins', + { + typesVersions: { + '*': { 'config/*': ['red.json'], 'config/long/*': ['blue.json'] }, + }, + }, + 'blue.json', + 'preset/config/long/item', + ], + [ + 'deep version matched miss stops default', + { typesVersions: { '*': { 'blue.json': ['missing.json'] } } }, + undefined, + 'preset/blue.json', + ], +] diff --git a/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs b/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs new file mode 100644 index 00000000..a8d39c22 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-native-freshness.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const { createModuleResolver } = createRequire(import.meta.url)( + '../../dist/index.cjs', +) +const root = realpathSync(mkdtempSync(join(tmpdir(), 'devup-tsconfig-native-'))) +const file = (path, source) => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, source) + return target +} +try { + // Given both configs and both module targets exist before the first setup. + file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./red.json"}', + ) + for (const color of ['red', 'blue']) { + file( + `node_modules/preset/${color}.json`, + JSON.stringify({ + compilerOptions: { paths: { color: [`${color}.ts`] } }, + }), + ) + file(`node_modules/preset/${color}.ts`, `export const color='${color}'`) + } + const pid = process.pid + const first = createModuleResolver({ cwd: root }) + assert.equal(first('color', 'main.ts')?.code, "export const color='red'") + const fs = createRequire(import.meta.url)('node:fs') + const originalRead = fs.readFileSync + const reads = [] + fs.readFileSync = (...args) => { + reads.push(String(args[0])) + return originalRead(...args) + } + try { + for (let i = 0; i < 3; i++) first('color', 'main.ts') + assert.deepEqual( + reads, + Array(3).fill(join(root, 'node_modules/preset/red.ts')), + ) + } finally { + fs.readFileSync = originalRead + } + // When only exports changes and setup is reconstructed in this same native process. + writeFileSync(manifest, '{"exports":"./blue.json"}') + const second = createModuleResolver({ cwd: root }) + // Then the new resolver selects blue while the old setup remains frozen. + assert.equal(second('color', 'main.ts')?.code, "export const color='blue'") + assert.equal(first('color', 'main.ts')?.code, "export const color='red'") + assert.equal(process.pid, pid) + console.info( + JSON.stringify({ + runtime: process.release.name, + version: process.version, + pid, + before: 'red', + after: 'blue', + repeatedReads: reads, + }), + ) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts new file mode 100644 index 00000000..4bc37c77 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-native.test.ts @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('selects blue in a genuine same-PID native process after exports-only setup reconstruction', () => { + // Given the checked-in persistent native driver and built public package. + const driver = join(import.meta.dir, 'tsconfig-native-freshness.mjs') + // When the foreground child inherits the caller's process guard. + const result = spawnSync('node', [driver], { + encoding: 'utf8', + timeout: 20000, + }) + // Then the driver proves fresh selection and zero repeated inheritance reads. + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(JSON.parse(result.stdout)).toMatchObject({ + runtime: 'node', + before: 'red', + after: 'blue', + }) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts new file mode 100644 index 00000000..9435dfca --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-package-scope.test.ts @@ -0,0 +1,216 @@ +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createResolutionInputs } from '../resolution-inputs' +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { readonly resolvedModule?: { readonly resolvedFileName: string } } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it.each([ + ['self', 'app', { name: 'app', exports: './blue.json' }, 'blue.json'], + [ + 'self subpath', + 'app/base', + { name: 'app', exports: { './base': './blue.json' } }, + 'blue.json', + ], + ['imports', '#base', { imports: { '#base': './blue.json' } }, 'blue.json'], + [ + 'imports pattern', + '#config/blue', + { imports: { '#config/*': './*.json' } }, + 'blue.json', + ], + [ + 'imports bare target ignores main and tsconfig', + '#base', + { imports: { '#base': 'preset' } }, + undefined, + ], + ['self unavailable', 'app', { name: 'app', exports: {} }, undefined], + ['imports unavailable', '#missing', { imports: {} }, undefined], + ['imports null', '#base', { imports: { '#base': null } }, undefined], + [ + 'imports parent prohibited', + '#base', + { imports: { '#base': '../blue.json' } }, + undefined, + ], + [ + 'imports absolute prohibited', + '#base', + { imports: { '#base': '/blue.json' } }, + undefined, + ], + ['invalid hash', '#', {}, undefined], + ['URI misses', 'https:blue', {}, undefined], + ['dot CJS branch', '.', {}, 'src/tsconfig.json'], + ['parent CJS branch', '..', {}, 'tsconfig.json'], +] as const)( + 'matches compiler nearest scope when %s', + (_, request, manifest, expected) => { + // Given a nearest scope and distinct real entry files. + const importer = file( + 'src/tsconfig.json', + JSON.stringify({ extends: request }), + ) + file('package.json', JSON.stringify(manifest)) + file('tsconfig.json', '{}') + file('blue.json', '{}') + file( + 'node_modules/preset/package.json', + '{"main":"blue.json","tsconfig":"red.json"}', + ) + file('node_modules/preset/blue.json', '{}') + file('node_modules/preset/red.json', '{}') + // When resolving with the installed compiler and the shared owned engine. + const target = expected === undefined ? undefined : join(root, expected) + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(target) + // Then both select the independent expected target, including misses. + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(target) + }, +) + +it('keeps selected physical origins while recording lexical scope and target probes', () => { + // Given a scoped junction with nested inheritance and relative alias origins. + const config = file('src/tsconfig.json', '{"extends":"@scope/preset"}') + const manifest = file( + 'workspace/preset/package.json', + '{"exports":"./nested/base.json"}', + ) + const base = file( + 'workspace/preset/nested/base.json', + '{"extends":"../shared.json"}', + ) + const shared = file( + 'workspace/preset/shared.json', + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const value = file('workspace/preset/value.ts', 'blue') + file('node_modules/@scope/placeholder.json', '{}') + fs.symlinkSync( + dirname(manifest), + join(root, 'node_modules/@scope/preset'), + 'junction', + ) + const inputs = createResolutionInputs() + // When inheritance selects through the owned resolver. + const aliases = readPathAliases(config, inputs) + // Then physical config owns relative paths but lexical selecting IO remains repairable. + expect(aliases.aliases[0]?.targets).toEqual([value]) + expect(inputs.snapshot().fileDependencies).toEqual( + [ + config, + base, + shared, + join(root, 'node_modules/@scope/preset/package.json'), + join(root, 'node_modules/@scope/preset/nested/base.json'), + ].sort(), + ) +}) + +it('reuses frozen setup evidence without rereading inheritance during repeated imports', () => { + // Given a public resolver and a narrow real read spy. + file('tsconfig.json', '{"extends":"preset"}') + file('node_modules/preset/package.json', '{"exports":"./base.json"}') + file( + 'node_modules/preset/base.json', + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const value = file('node_modules/preset/value.ts', 'blue') + const reads: string[] = [] + const originalRead = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + const resolver = createModuleResolver({ cwd: root }) + reads.length = 0 + // When repeated imports use the same setup. + for (let i = 0; i < 3; i++) + expect(resolver('color', 'main.ts')?.path).toBe(value) + // Then only source content, never configs or selecting manifests, is read. + expect(reads).toEqual([value, value, value]) + } finally { + read.mockRestore() + } +}) + +it('locates a cyclic imports map instead of exhausting the native stack', () => { + const importer = file('tsconfig.json', '{"extends":"#base"}') + file('package.json', '{"imports":{"#base":"#base"}}') + expect(() => + ts.nodeNextJsonConfigResolver('#base', importer, ts.sys), + ).toThrow(RangeError) + expect(() => readPathAliases(importer)).toThrow( + `${join(root, 'package.json')}:1:1: Cannot load configuration: Package imports cycle`, + ) +}) + +it.each(['exports', 'manifestless', 'nested'])( + 'searches outward after an unresolvable nearer %s package', + (mode) => { + // Given a nearer existing package and an independently resolving outer candidate. + const importer = file('src/tsconfig.json', '{}') + const request = mode === 'nested' ? 'preset/config' : 'preset' + file( + 'src/node_modules/preset/package.json', + mode === 'exports' ? '{"exports":{}}' : '{}', + ) + file('node_modules/preset/package.json', '{}') + const expected = file( + `node_modules/preset/${mode === 'nested' ? 'config/' : ''}tsconfig.json`, + '{}', + ) + // When trying the complete ancestor search. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then nearest-manifest existence cannot prematurely terminate resolution. + expect(oracle && resolve(oracle)).toBe(expected) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(expected) + }, +) + +it.each(['.preset', '@scope/preset'])( + 'treats %s as a package rather than a relative path', + (request) => { + // Given a legal package name with its default config. + const importer = file('tsconfig.json', JSON.stringify({ extends: request })) + file(`node_modules/${request}/package.json`, '{}') + const target = file(`node_modules/${request}/tsconfig.json`, '{}') + // When resolving inheritance. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + expect(oracle && resolve(oracle)).toBe(target) + // Then Devup uses the same package selection. + expect(readPathAliases(importer).aliases).toEqual([]) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts new file mode 100644 index 00000000..60f2cfbf --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-packaged-resolution.test.ts @@ -0,0 +1,199 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' +import { manifestCases } from './tsconfig-manifest-cases' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +const cases: readonly [string, unknown, string | undefined, string?][] = [ + ['ignores JSON main', { main: 'red.json' }, 'tsconfig.json'], + [ + 'ignores module/types fields', + { module: 'red.json', types: 'red.json' }, + 'tsconfig.json', + ], + ['uses tsconfig field', { tsconfig: 'blue.json' }, 'blue.json'], + ['appends field extension', { tsconfig: 'blue' }, 'blue.json'], + [ + 'falls back after field miss', + { tsconfig: 'missing.json' }, + 'tsconfig.json', + ], + ['ignores empty field', { tsconfig: '' }, 'tsconfig.json'], + ['ignores non-string field', { tsconfig: 1 }, 'tsconfig.json'], + [ + 'exports precedes field', + { exports: './blue.json', tsconfig: 'red.json' }, + 'blue.json', + ], + [ + 'types precedes require by insertion', + { + exports: { + import: './red.json', + types: './blue.json', + require: './red.json', + }, + }, + 'blue.json', + ], + [ + 'require precedes types by insertion', + { exports: { require: './red.json', types: './blue.json' } }, + 'red.json', + ], + [ + 'node is active', + { exports: { module: './red.json', node: './blue.json' } }, + 'blue.json', + ], + [ + 'early default wins', + { exports: { default: './red.json', types: './blue.json' } }, + 'red.json', + ], + [ + 'condition miss advances', + { exports: { require: './missing.json', types: './blue.json' } }, + 'blue.json', + ], + [ + 'condition null blocks', + { exports: { require: null, types: './blue.json' } }, + undefined, + ], + [ + 'array miss advances', + { exports: ['./missing.json', './blue.json'] }, + 'blue.json', + ], + [ + 'invalid array target advances', + { exports: ['../red.json', './blue.json'] }, + 'blue.json', + ], + ['array null blocks', { exports: [null, './blue.json'] }, undefined], + ['empty array misses', { exports: [] }, undefined], + ['false target advances', { exports: [false, './blue.json'] }, 'blue.json'], + ['null exports falls back', { exports: null }, 'tsconfig.json'], + ['false exports falls back', { exports: false }, 'tsconfig.json'], + ['empty exports falls back', { exports: '' }, 'tsconfig.json'], + ['empty map encapsulates', { exports: {} }, undefined], + ['root map encapsulates', { exports: { './red': './red.json' } }, undefined], + ['export JS substitutes JSON', { exports: './red.js' }, 'red.json'], + ['export TS substitutes JSON', { exports: './red.d.ts' }, 'red.json'], + ['export extensionless stays exact', { exports: './blue' }, undefined], + ['export directory stays exact', { exports: './dir' }, undefined], + ['explicit subpath resolves', {}, 'blue.json', 'preset/blue.json'], + ['deep JS substitutes JSON', {}, 'red.json', 'preset/red.js'], + ['deep unknown appends JSON', {}, 'red.xyz.json', 'preset/red.xyz'], + ['deep MJS appends JSON', {}, 'red.mjs.json', 'preset/red.mjs'], + ['directory default is tsconfig', {}, 'dir/tsconfig.json', 'preset/dir'], + ['field directory default', { tsconfig: 'dir' }, 'dir/tsconfig.json'], + [ + 'exact precedes patterns', + { exports: { './*': './red.json', './blue': './blue.json' } }, + 'blue.json', + 'preset/blue', + ], + [ + 'longest pattern wins', + { exports: { './*': './red.json', './config/*': './blue.json' } }, + 'blue.json', + 'preset/config/value', + ], + [ + 'pattern trailer matches', + { exports: { './*.json': './*.json' } }, + 'blue.json', + 'preset/blue.json', + ], + [ + 'slash prefix matches', + { exports: { './config/': './' } }, + 'blue.json', + 'preset/config/blue.json', + ], + [ + 'slash target needs slash', + { exports: { './config/': './blue.json' } }, + undefined, + 'preset/config/blue.json', + ], + [ + 'mixed map root follows dot branch', + { exports: { types: './red.json', '.': './blue.json' } }, + 'blue.json', + ], + [ + 'mixed map deep is unavailable', + { exports: { types: './red.json', './blue': './blue.json' } }, + undefined, + 'preset/blue', + ], + ['invalid target dot segment', { exports: './dir/../blue.json' }, undefined], + [ + 'invalid target node_modules segment', + { exports: './dir/node_modules/blue.json' }, + undefined, + ], + ...manifestCases, +] + +it.each(cases)( + 'matches installed TypeScript when %s', + (_, manifest, expected, request = 'preset') => { + // Given independent, distinct config targets and the installed compiler oracle. + expect(ts.version).toBe('6.0.3') + const config = file('tsconfig.json', JSON.stringify({ extends: request })) + file( + 'node_modules/preset/package.json', + typeof manifest === 'string' ? manifest : JSON.stringify(manifest), + ) + for (const target of [ + 'red.json', + 'blue.json', + 'tsconfig.json', + 'dir/tsconfig.json', + 'red.xyz.json', + 'red.mjs.json', + ]) + file( + `node_modules/preset/${target}`, + JSON.stringify({ compilerOptions: { paths: { selected: [target] } } }), + ) + const target = + expected === undefined + ? undefined + : join(root, 'node_modules/preset', expected) + // When both resolvers select against the same real files without a shared cache. + const oracle = ts.nodeNextJsonConfigResolver(request, config, ts.sys) + .resolvedModule?.resolvedFileName + // Then both must match the fixture's independent expected target (not each other alone). + expect(oracle === undefined ? undefined : resolve(oracle)).toBe(target) + if (target === undefined) + expect(() => readPathAliases(config)).toThrow('Cannot load configuration') + else + expect(readPathAliases(config).aliases[0]?.targets[0]).toBe( + resolve(join(target, '..'), expected ?? ''), + ) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts new file mode 100644 index 00000000..5cd7cae1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-policy-corrections.test.ts @@ -0,0 +1,166 @@ +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) +const red = '{"compilerOptions":{"paths":{"color":["red.ts"]}}}' +const blue = '{"compilerOptions":{"paths":{"color":["blue.ts"]}}}' + +it.each([false, true])( + 'retains root versions in manifestless child directories (root field=%s)', + (withField) => { + // Given child configs with distinct aliases and root-only version metadata. + const importer = file('tsconfig.json', '{"extends":"preset/dir"}') + file( + 'node_modules/preset/package.json', + JSON.stringify({ + ...(withField ? { tsconfig: 'red.json' } : {}), + typesVersions: { '*': { tsconfig: ['blue.json'] } }, + }), + ) + file('node_modules/preset/red.json', red) + file('node_modules/preset/dir/red.json', red) + file('node_modules/preset/dir/tsconfig.json', red) + const selected = file('node_modules/preset/dir/blue.json', blue) + file('node_modules/preset/dir/red.ts', "export const color='red'") + const target = file( + 'node_modules/preset/dir/blue.ts', + "export const color='blue'", + ) + // When resolving the child directory without a child package.json. + const oracle = ts.nodeNextJsonConfigResolver('preset/dir', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then root versions apply relative to the child, but its root tsconfig field does not. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset/dir', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + expect(createModuleResolver({ cwd: root })('color', 'main.ts')?.path).toBe( + target, + ) + }, +) + +it.each([ + [ + 'miss', + './missing.json', + 'node_modules/preset/blue.json', + 'node_modules/preset/blue.ts', + ], + ['resolved', './red.json', 'red.json', 'red.ts'], + ['blocked', { require: null, default: './red.json' }, undefined, undefined], +] as const)( + 'distinguishes self exports %s from installed same-name fallback', + (_, exports, selectedPath, targetPath) => { + // Given a self scope and an independently resolving same-name installed package. + const importer = file('tsconfig.json', '{"extends":"preset"}') + file('package.json', JSON.stringify({ name: 'preset', exports })) + file('red.json', red) + file('red.ts', "export const color='red'") + file('node_modules/preset/package.json', '{"exports":"./blue.json"}') + file('node_modules/preset/blue.json', blue) + file('node_modules/preset/blue.ts', "export const color='blue'") + const selected = + selectedPath === undefined ? undefined : join(root, selectedPath) + // When the nearest self lookup returns a resolved target, a miss, or actual null block. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then only a miss reaches installed fallback; a real block stays unresolved. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + if (targetPath === undefined) + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot resolve tsconfig extends', + ) + else + expect( + createModuleResolver({ cwd: root })('color', 'main.ts')?.path, + ).toBe(join(root, targetPath)) + }, +) + +it.each([ + ['./dir\\..\\blue.json', undefined], + ['./dir\\blue.json', 'node_modules/preset/dir/blue.json'], + ['.\\blue.json', undefined], +] as const)( + 'normalizes export components without broadening raw eligibility for %s', + (exports, selectedPath) => { + // Given both escaped and ordinary candidate files, with exact raw export syntax. + const importer = file('tsconfig.json', '{}') + file('node_modules/preset/package.json', JSON.stringify({ exports })) + file('node_modules/preset/blue.json', blue) + file('node_modules/preset/dir/blue.json', blue) + const selected = + selectedPath === undefined ? undefined : join(root, selectedPath) + // When TypeScript and the owned engine validate then resolve components. + const oracle = ts.nodeNextJsonConfigResolver('preset', importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then backslash parent components fail while a valid raw ./ prefix can resolve normalized children. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage('preset', importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + }, +) + +it.each([ + [ + 'preset/', + 'node_modules/preset/tsconfig.json', + 'node_modules/preset/blue.ts', + ], + ['preset', 'node_modules/preset.json', 'node_modules/red.ts'], + [ + 'preset/dir/', + 'node_modules/preset/dir/tsconfig.json', + 'node_modules/preset/dir/blue.ts', + ], +] as const)( + 'preserves directory identity and bare sibling precedence for %s', + (request, selectedPath, targetPath) => { + // Given existing sibling JSON files and distinct directory configs. + const importer = file('tsconfig.json', JSON.stringify({ extends: request })) + file('node_modules/preset.json', red) + file('node_modules/red.ts', "export const color='red'") + file('node_modules/preset/package.json', '{}') + file('node_modules/preset/tsconfig.json', blue) + file('node_modules/preset/blue.ts', "export const color='blue'") + file('node_modules/preset/dir.json', red) + file('node_modules/preset/dir/tsconfig.json', blue) + file('node_modules/preset/dir/blue.ts', "export const color='blue'") + // When selecting a bare or trailing-separator request. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then the request's independent file/directory priority determines selection. + expect(oracle && resolve(oracle)).toBe(join(root, selectedPath)) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(join(root, selectedPath)) + expect(createModuleResolver({ cwd: root })('color', 'main.ts')?.path).toBe( + join(root, targetPath), + ) + }, +) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts new file mode 100644 index 00000000..b7df5ce7 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-repair-inputs.test.ts @@ -0,0 +1,158 @@ +import * as fs from 'node:fs' +import { join, resolve } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { ConfigLoadError } from '../load-config' +import { createResolutionInputs } from '../resolution-inputs' +import { readPathAliases } from '../tsconfig' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +it('records every actual failed lexical probe and consulted read without evidence-only lookups', () => { + // Given a nearer miss, an exports array miss, and an outer resolving package. + const config = file('src/tsconfig.json', '{"extends":"preset"}') + const nearer = file('src/node_modules/preset/package.json', '{"exports":{}}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":["./absent.json","./blue.json"]}', + ) + const blue = file('node_modules/preset/blue.json', '{}') + const missing = new Set() + const reads: string[] = [] + const originalStat = fs.statSync + const originalRead = fs.readFileSync + const stat = spyOn(fs, 'statSync').mockImplementation( + new Proxy(originalStat, { + apply(target, receiver, args) { + try { + return Reflect.apply(target, receiver, args) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + missing.add(resolve(String(args[0]))) + throw error + } + }, + }), + ) + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(originalRead, { + apply(target, receiver, args) { + reads.push(String(args[0])) + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + // When one setup performs real owned selection. + readPathAliases(config, inputs) + // Then repair evidence is exactly the real lexical IO, with no second resolution pass. + expect(reads).toEqual([config, nearer, manifest, blue]) + expect(inputs.snapshot()).toEqual({ + fileDependencies: [config, nearer, manifest, blue].sort(), + missingDependencies: [...missing].sort(), + }) + expect(missing).toContain(join(root, 'node_modules/preset/absent.json')) + } finally { + stat.mockRestore() + read.mockRestore() + } +}) + +it.each(['EACCES', 'EIO'])( + 'retains actual manifest read failure %s and its lexical repair input', + (code) => { + // Given a real selecting manifest whose read fails at the narrow filesystem seam. + const config = file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":"./blue.json"}', + ) + file('node_modules/preset/blue.json', '{}') + const failure = Object.assign(new Error('denied'), { code }) + const original = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === manifest) throw failure + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + try { + let caught: unknown + // When setup attempts the owned read. + try { + readPathAliases(config, inputs) + } catch (error) { + caught = error + } + // Then located config errors preserve the original IO exception, not a resolution miss. + expect(caught).toBeInstanceOf(ConfigLoadError) + if (caught instanceof ConfigLoadError) expect(caught.cause).toBe(failure) + expect(inputs.snapshot().fileDependencies).toEqual( + [config, manifest].sort(), + ) + } finally { + read.mockRestore() + } + }, +) + +it.each(['./exact.txt', './folder', './missing.json'])( + 'retains TypeScript direct extends rules for %s', + (request) => { + // Given an exact non-JSON config and a directory that must not gain index lookup. + const config = file('tsconfig.json', JSON.stringify({ extends: request })) + file('exact.txt', '{"compilerOptions":{"paths":{"value":["blue.ts"]}}}') + file('folder/tsconfig.json', '{}') + const inputs = createResolutionInputs() + // When loading the direct extends branch. + if (request === './exact.txt') { + // Then existing exact filenames are accepted regardless of extension. + expect(readPathAliases(config, inputs).aliases[0]?.targets).toEqual([ + join(root, 'blue.ts'), + ]) + } else { + expect(() => readPathAliases(config, inputs)).toThrow( + 'Cannot load configuration', + ) + expect(inputs.snapshot().missingDependencies).toContain( + join(root, request === './folder' ? 'folder.json' : 'missing.json'), + ) + } + }, +) + +it('publishes setup failure probes through the unchanged public observer', () => { + // Given a blocked export with an existing unexported config. + const config = file('tsconfig.json', '{"extends":"preset"}') + const manifest = file( + 'node_modules/preset/package.json', + '{"exports":{".":null}}', + ) + file('node_modules/preset/tsconfig.json', '{}') + const observations: unknown[] = [] + // When synchronous public setup fails. + expect(() => + createModuleResolver({ + cwd: root, + onResolutionInputs: (inputs) => observations.push(inputs), + }), + ).toThrow(`Cannot resolve tsconfig extends "preset"`) + // Then its finally observer retains the real selecting manifest, even with no returned callable. + expect(observations).toContainEqual( + expect.objectContaining({ fileDependencies: [config, manifest].sort() }), + ) +}) diff --git a/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts b/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts new file mode 100644 index 00000000..f25770a8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tsconfig-version-extensions.test.ts @@ -0,0 +1,91 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { createModuleResolver } from '../import-graph' +import { resolvePackage } from '../owned-module-resolution' +import { createPreparedFixture } from './prepared-graph-fixture' + +const ts: { + readonly version: string + readonly sys: object + nodeNextJsonConfigResolver( + request: string, + importer: string, + host: object, + ): { + readonly resolvedModule?: { readonly resolvedFileName: string } + } +} = createRequire(import.meta.url)('@typescript/typescript6') +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +for (const request of ['preset', 'preset/entry']) + it.each([ + ['js', true], + ['jsx', true], + ['mjs', true], + ['cjs', true], + ['ts', true], + ['tsx', true], + ['mts', true], + ['cts', true], + ['json', true], + ['d.ts', true], + ['d.mts', true], + ['d.cts', true], + ['mjsx', false], + ['cjsx', false], + ['mtsx', false], + ['ctsx', false], + ] as const)( + `uses only installed TS recognized extension %s for ${request}`, + (extension, recognized) => { + // Given a mapped config whose content cannot overwrite the separate value.ts module. + expect(ts.version).toBe('6.0.3') + const importer = file( + 'tsconfig.json', + JSON.stringify({ extends: request }), + ) + file( + 'node_modules/preset/package.json', + JSON.stringify({ + typesVersions: { + '*': { + tsconfig: [`blue.${extension}`], + entry: [`blue.${extension}`], + }, + }, + }), + ) + const config = file( + `node_modules/preset/blue.${extension}`, + '{"compilerOptions":{"paths":{"color":["value.ts"]}}}', + ) + const target = file( + 'node_modules/preset/value.ts', + "export const color='blue'", + ) + file('node_modules/preset/tsconfig.json', '{}') + const selected = recognized ? config : undefined + // When this selecting seam encounters an explicit substitution extension. + const oracle = ts.nodeNextJsonConfigResolver(request, importer, ts.sys) + .resolvedModule?.resolvedFileName + // Then both match the independently enumerated extension policy, not each other alone. + expect(oracle && resolve(oracle)).toBe(selected) + expect( + resolvePackage(request, importer, { purpose: 'tsconfig-extends' }), + ).toBe(selected) + if (recognized) + expect( + createModuleResolver({ cwd: root })('color', 'main.ts')?.path, + ).toBe(target) + else + expect(() => createModuleResolver({ cwd: root })).toThrow( + 'Cannot resolve tsconfig extends', + ) + }, + ) diff --git a/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts b/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts new file mode 100644 index 00000000..a5641e26 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' + +import { matchesTypeScriptVersion } from '../typescript-version-range' + +const ts: { + getPackageJsonTypesVersionsPaths( + value: object, + ): { readonly version: string } | undefined +} = createRequire(import.meta.url)('@typescript/typescript6') +it.each([ + ['', true], + ['*', true], + ['||', true], + ['x', true], + ['X', true], + ['6', true], + ['6.0', true], + ['6.0.3', true], + ['=6.0.3', true], + ['7', false], + ['~6', true], + ['~6.0', true], + ['~6.0.3', true], + ['~6.1', false], + ['^6', true], + ['^6.0.3', true], + ['^0', false], + ['^0.1', false], + ['^0.0.3', false], + ['<6', false], + ['<=6', true], + ['>6', false], + ['>=6', true], + ['<=6.0', true], + ['>6.0', false], + ['<6.0.3', false], + ['<=6.0.3', true], + ['>6.0.3', false], + ['>=6.0.3', true], + ['>5.9', true], + ['<7.0', true], + ['<*', false], + ['>*', false], + ['>=*', true], + ['6 - 6', true], + ['6.0 - 6.0', true], + ['6.0.3 - 6.0.3', true], + ['* - 6', true], + ['6 - *', true], + ['* - *', true], + ['7 - 8', false], + ['bad - 6', false], + ['6 - bad', false], + ['>=6.0.3-beta', true], + ['>6.0.3-0', true], + ['6.0.3+build', true], + ['6.0.3-BETA.1', false], + ['6.0.3+abc.1', true], + ['>=5 <7', true], + ['<5 || >=6', true], + ['>=7 || <5', false], + [' 6 ', true], + ['>= 6', false], + ['v6', false], + ['6.0.3.4', false], + ['01', false], +] as const)('tests pinned compiler version against %s', (range, expected) => { + // Given a range and an independently specified answer for stable 6.0.3. + const selection = ts.getPackageJsonTypesVersionsPaths({ [range]: {} }) + // When using the installed parser and the owned range evaluator. + expect(selection !== undefined).toBe(expected) + // Then both match the independent answer, including invalid ranges. + expect(matchesTypeScriptVersion(range)).toBe(expected) +}) + +it.each(['6.0.3-01', '6.0.3-a..b', '6.0.3+..'])( + 'preserves compiler failure for malformed components in %s', + (range) => { + expect(() => ts.getPackageJsonTypesVersionsPaths({ [range]: {} })).toThrow() + expect(() => matchesTypeScriptVersion(range)).toThrow( + 'Invalid TypeScript version range', + ) + }, +) diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index faa019b0..92ec1f77 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -3,7 +3,6 @@ import { readdirSync, readFileSync, realpathSync, - statSync, writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' @@ -20,13 +19,11 @@ import { type ImportRequestReference, scanImportRequests, } from './import-scanner' -import { ConfigLoadError } from './load-config' import { remapMdxError } from './mdx-errors' -import { - type AliasResolution, - ModuleAliasPackageError, - resolveModuleAlias, -} from './module-alias' +import { type AliasResolution, resolveModuleAlias } from './module-alias' +import { resolveFile } from './owned-file-resolution' +import { findPackage, resolvePackage } from './owned-module-resolution' +import { isResolutionFile as isFile } from './packaged-config-inputs' import { preparedDiagnostics } from './prepared-diagnostics' import { createPreparedResolver, @@ -35,16 +32,9 @@ import { import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' import { createResolutionInputs, - readResolutionFile, - realpathResolution, - type ResolutionInputCollector, type ResolutionInputObserver, } from './resolution-inputs' -import { - createNodeModulesExcludeRegex, - SOURCE_EXTENSIONS, - SOURCE_FILE_RE, -} from './shared' +import { createNodeModulesExcludeRegex, SOURCE_FILE_RE } from './shared' import { isSelectedSource, type MdxSelection, @@ -95,7 +85,6 @@ interface ResolveContext { srcDir: string } -const jsExtensions: readonly string[] = SOURCE_EXTENSIONS const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/i const routeFileRegex = /(^|\/)(page|layout|template|default|loading|error|not-found|global-error)\.[^./]+$/ @@ -1198,149 +1187,6 @@ function createModulePathResolver( } } -function resolvePackage( - specifier: string, - importer: string, - options: { - readonly conditions: readonly string[] - readonly fileResolver: (path: string) => string | false | undefined - readonly excludedDirectory: (directory: string) => boolean - readonly aliased: boolean - readonly inputs?: ResolutionInputCollector - }, -): string | false | undefined { - const parts = specifier.split('/') - const nameLength = specifier.startsWith('@') ? 2 : 1 - const name = parts.slice(0, nameLength).join('/') - const packageDir = findPackage( - dirname(importer), - name, - options.excludedDirectory, - options.inputs, - ) - if (packageDir === false) return false - if (!packageDir) return undefined - const target = join(packageDir, ...parts.slice(nameLength)) - if ( - options.excludedDirectory(dirname(target)) || - options.excludedDirectory(target) - ) - return false - const manifestFile = join(packageDir, 'package.json') - const manifest = readPackageManifest(manifestFile, options.inputs) - const found = resolvePackageEntry( - packageDir, - manifest, - ['.', ...parts.slice(nameLength)].join('/'), - options.conditions, - options.fileResolver, - ) - if (found === undefined && options.aliased && manifest.exports !== undefined) - throw new ModuleAliasPackageError(importer, specifier) - return found ? realpathResolution(found, options.inputs) : found -} - -function readPackageManifest( - file: string, - inputs?: ResolutionInputCollector, -): Record { - try { - const manifest: unknown = JSON.parse(readResolutionFile(file, inputs)) - if (!isRecord(manifest) || Array.isArray(manifest)) - throw new TypeError('Expected a package manifest object') - return manifest - } catch (cause) { - throw new ConfigLoadError(file, cause) - } -} - -function findPackage( - dir: string, - name: string, - excludedDirectory?: (directory: string) => boolean, - inputs?: ResolutionInputCollector, -): string | false | undefined { - const packageDir = join(dir, 'node_modules', name) - if (excludedDirectory?.(packageDir)) return false - if (isFile(join(packageDir, 'package.json'), inputs)) return packageDir - const parent = dirname(dir) - return parent === dir - ? undefined - : findPackage(parent, name, excludedDirectory, inputs) -} -function resolvePackageEntry( - dir: string, - manifest: Record, - subpath: string, - conditions: readonly string[], - fileResolver: (path: string) => string | false | undefined, -): string | false | undefined { - if (manifest.exports !== undefined) { - const target = exportsTarget(manifest.exports, subpath, conditions) - return typeof target !== 'string' - ? undefined - : fileResolver(join(dir, target)) - } - if (subpath !== '.') return fileResolver(join(dir, subpath)) - const main = [manifest.module, manifest.main].find( - (entry): entry is string => typeof entry === 'string', - ) - return fileResolver(join(dir, main ?? 'index')) -} - -function exportsTarget( - exports: unknown, - subpath: string, - conditions: readonly string[], -): string | null | undefined { - const subpaths = - isRecord(exports) && !Array.isArray(exports) - ? Object.keys(exports).filter((key) => key.startsWith('.')) - : [] - if (subpaths.length === 0) { - return subpath === '.' ? conditionTarget(exports, conditions) : undefined - } - const map = exports as Record - if (subpath in map) return conditionTarget(map[subpath], conditions) - for (const key of subpaths) { - const [prefix, suffix = ''] = key.split('*') - if ( - key.includes('*') && - subpath.startsWith(prefix) && - subpath.endsWith(suffix) && - subpath.length >= prefix.length + suffix.length - ) { - const matched = subpath.slice( - prefix.length, - subpath.length - suffix.length, - ) - return conditionTarget(map[key], conditions)?.replaceAll('*', matched) - } - } - return undefined -} - -function conditionTarget( - value: unknown, - conditions: readonly string[], -): string | null | undefined { - if (typeof value === 'string') return value - if (value === null) return null - if (Array.isArray(value)) { - for (const entry of value) { - const target = conditionTarget(entry, conditions) - if (target !== undefined) return target - } - return undefined - } - if (!isRecord(value)) return undefined - for (const [condition, branch] of Object.entries(value)) { - if (condition !== 'default' && !conditions.includes(condition)) continue - const target = conditionTarget(branch, conditions) - if (target !== undefined) return target - } - return undefined -} function resolveAliasCandidates( specifier: string, context: Pick, @@ -1369,77 +1215,6 @@ function resolveAliasCandidates( return candidates } -function resolveFile( - candidateBase: string, - options: { - readonly extensions: readonly string[] - readonly excludedDirectory: (directory: string) => boolean - readonly inputs?: ResolutionInputCollector - } = { - extensions: jsExtensions, - excludedDirectory: createDirectoryExclusion(), - }, - stack: readonly string[] = [], -): string | false | undefined { - if ( - options.excludedDirectory(dirname(candidateBase)) || - options.excludedDirectory(candidateBase) - ) - return false - if (isFile(candidateBase, options.inputs)) return resolve(candidateBase) - - for (const jsExtension of options.extensions) { - const candidate = `${candidateBase}${jsExtension}` - if (isFile(candidate, options.inputs)) return resolve(candidate) - } - const manifestFile = join(candidateBase, 'package.json') - if (isFile(manifestFile, options.inputs)) { - const canonical = realpathResolution(candidateBase, options.inputs) - if (stack.includes(canonical)) - throw new ConfigLoadError( - manifestFile, - new Error( - `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, - ), - ) - const manifest = readPackageManifest(manifestFile, options.inputs) - for (const main of [manifest.module, manifest.main]) { - if (typeof main !== 'string' || !main || main === '.' || main === './') - continue - const found = resolveFile(join(candidateBase, main), options, [ - ...stack, - canonical, - ]) - if (found !== undefined) return found - } - } - for (const jsExtension of options.extensions) { - const candidate = join(candidateBase, `index${jsExtension}`) - if (isFile(candidate, options.inputs)) return resolve(candidate) - } - - return undefined -} - -function isFile(path: string, inputs?: ResolutionInputCollector): boolean { - try { - const file = statSync(path).isFile() - if (file) inputs?.file(path) - return file - } catch (cause) { - if ( - cause instanceof Error && - 'code' in cause && - (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') - ) { - inputs?.missing(path) - return false - } - inputs?.file(path) - throw cause - } -} - function isInsideDir(dir: string, file: string): boolean { const relPath = relative(dir, file) return relPath === '' || (!relPath.startsWith('..') && !isAbsolute(relPath)) diff --git a/packages/plugin-utils/src/jsonc.ts b/packages/plugin-utils/src/jsonc.ts new file mode 100644 index 00000000..dc569749 --- /dev/null +++ b/packages/plugin-utils/src/jsonc.ts @@ -0,0 +1,54 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseJsonc(source: string, numericLiterals = false): unknown { + let text = '' + let quoted = false + for (let index = 0; index < source.length; index += 1) { + const char = source[index] + const next = source[index + 1] + if (quoted) { + text += char + if (char === '\\') text += source[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + text += char + } else if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') index += 1 + text += '\n' + } else if (char === '/' && next === '*') { + const end = source.indexOf('*/', index + 2) + if (end === -1) throw new SyntaxError('Unterminated JSON comment') + text += ' ' + index = end + 1 + } else text += char + } + let json = '' + quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + json += char + if (char === '\\') json += text[++index] ?? '' + else if (char === '"') quoted = false + } else if (char === '"') { + quoted = true + json += char + } else if (numericLiterals && /[\d.]/.test(char)) { + const numeric = + /^(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[bB][01](?:_?[01])*|0[oO][0-7](?:_?[0-7])*|(?:0|[1-9][\d_]*)(?:\.[\d_]*)?(?:[eE][+-]?[\d_]+)?|\.[\d_]+(?:[eE][+-]?[\d_]+)?)/.exec( + text.slice(index), + )?.[0] + if (!numeric || /^_|_$|__|_[.eE]|[.eE]_/.test(numeric)) + throw new SyntaxError('Invalid numeric literal') + const value = Number(numeric.replaceAll('_', '')) + // TS 6.0.3:42867-42873 keeps infinity; 1e999 is its valid JSON number spelling. + json += value === Infinity ? '1e999' : String(value) + index += numeric.length - 1 + } else if (char !== ',' || !/^\s*[}\]]/.test(text.slice(index + 1))) + json += char + } + return JSON.parse(json.replace(/^\uFEFF/, '')) +} diff --git a/packages/plugin-utils/src/owned-file-resolution.ts b/packages/plugin-utils/src/owned-file-resolution.ts new file mode 100644 index 00000000..bc377314 --- /dev/null +++ b/packages/plugin-utils/src/owned-file-resolution.ts @@ -0,0 +1,90 @@ +import { dirname, join, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { ConfigLoadError } from './load-config' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { + realpathResolution, + type ResolutionInputCollector, +} from './resolution-inputs' +import { SOURCE_EXTENSIONS } from './shared' + +export function resolveFile( + candidateBase: string, + options: { + readonly extensions: readonly string[] + readonly excludedDirectory: (directory: string) => boolean + readonly inputs?: ResolutionInputCollector + } = { + extensions: SOURCE_EXTENSIONS, + excludedDirectory: createDirectoryExclusion(), + }, + stack: readonly string[] = [], +): string | false | undefined { + if ( + options.excludedDirectory(dirname(candidateBase)) || + options.excludedDirectory(candidateBase) + ) + return false + if (isFile(candidateBase, options.inputs)) return resolve(candidateBase) + for (const extension of options.extensions) { + const candidate = `${candidateBase}${extension}` + if (isFile(candidate, options.inputs)) return resolve(candidate) + } + const manifestFile = join(candidateBase, 'package.json') + if (isFile(manifestFile, options.inputs)) { + const canonical = realpathResolution(candidateBase, options.inputs) + if (stack.includes(canonical)) + throw new ConfigLoadError( + manifestFile, + new Error( + `Package directory entry cycle: ${[...stack, canonical].join(' -> ')}`, + ), + ) + const manifest = readPackageManifest(manifestFile, 'module', options.inputs) + for (const main of [manifest.module, manifest.main]) { + if (typeof main !== 'string' || !main || main === '.' || main === './') + continue + const found = resolveFile(join(candidateBase, main), options, [ + ...stack, + canonical, + ]) + if (found !== undefined) return found + } + } + for (const extension of options.extensions) { + const candidate = join(candidateBase, `index${extension}`) + if (isFile(candidate, options.inputs)) return resolve(candidate) + } + return undefined +} + +export function resolveJsonFile( + candidate: string, + options: { + readonly inputs?: ResolutionInputCollector + readonly exact?: boolean + readonly configLookup?: boolean + }, +): string | undefined { + const { inputs, exact = false, configLookup = true } = options + const known = /(?:\.d\.ts|\.[^./\\]+)$/.exec(candidate)?.[0] + if ( + known === '.json' || + (configLookup && (known === '.js' || known === '.ts' || known === '.d.ts')) + ) { + const json = + known === '.json' + ? candidate + : candidate.slice(0, -known.length) + '.json' + if (isFile(json, inputs)) return json + } + if (!exact && configLookup) { + const json = `${candidate}.json` + if (isFile(json, inputs)) return json + } + return undefined +} diff --git a/packages/plugin-utils/src/owned-module-resolution.ts b/packages/plugin-utils/src/owned-module-resolution.ts new file mode 100644 index 00000000..66b78946 --- /dev/null +++ b/packages/plugin-utils/src/owned-module-resolution.ts @@ -0,0 +1,148 @@ +import { basename, dirname, join, resolve } from 'node:path' + +import { ModuleAliasPackageError } from './module-alias' +import { exportsTarget } from './package-target' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { + realpathResolution, + type ResolutionInputCollector, +} from './resolution-inputs' +import { configScopeTarget } from './tsconfig-export-policy' +import { + configPackageCandidate, + directoryConfig, +} from './tsconfig-package-policy' + +interface ModulePolicy { + readonly purpose?: 'module' + readonly conditions: readonly string[] + readonly fileResolver: (path: string) => string | false | undefined + readonly excludedDirectory: (directory: string) => boolean + readonly aliased: boolean + readonly inputs?: ResolutionInputCollector +} +export interface ConfigPolicy { + readonly purpose: 'tsconfig-extends' + readonly inputs?: ResolutionInputCollector + readonly configLookup?: boolean + readonly stack?: readonly string[] +} + +export function resolvePackage( + specifier: string, + importer: string, + options: ModulePolicy | ConfigPolicy, +): string | false | undefined { + const parts = specifier.split('/') + const nameLength = specifier.startsWith('@') ? 2 : 1 + const name = parts.slice(0, nameLength).join('/') + if (options.purpose === 'tsconfig-extends') { + // Installed TS 6.0.3 lib/typescript.js:45327-45343,45963-46575: + // JSON config lookup, CJS require/types/node conditions, no resolution cache. + if (specifier === '.' || specifier === '..') { + const directory = resolve(dirname(importer), specifier) + return directoryConfig( + directory, + { + directory, + manifest: readPackageManifest( + join(directory, 'package.json'), + 'tsconfig-extends', + options.inputs, + ), + }, + options, + ) + } + const scoped = configScopeTarget(specifier, importer, options) + if (scoped !== undefined) return scoped ?? undefined + if (specifier.includes(':')) return undefined + } + let directory = dirname(importer) + while (true) { + const packageDir = join(directory, 'node_modules', name) + switch (options.purpose) { + case 'tsconfig-extends': + if (basename(directory) !== 'node_modules') { + const found = configPackageCandidate( + specifier.endsWith('/') ? `${packageDir}/` : packageDir, + parts.slice(nameLength).join('/'), + options, + ) + if (found) return realpathResolution(found, options.inputs) + } + break + case 'module': + case undefined: { + if (options.excludedDirectory(packageDir)) return false + if (isFile(join(packageDir, 'package.json'), options.inputs)) { + const target = join(packageDir, ...parts.slice(nameLength)) + if ( + options.excludedDirectory(dirname(target)) || + options.excludedDirectory(target) + ) + return false + const manifest = readPackageManifest( + join(packageDir, 'package.json'), + 'module', + options.inputs, + ) + const found = resolvePackageEntry( + packageDir, + manifest, + ['.', ...parts.slice(nameLength)].join('/'), + options, + ) + if ( + found === undefined && + options.aliased && + manifest.exports !== undefined + ) + throw new ModuleAliasPackageError(importer, specifier) + return found ? realpathResolution(found, options.inputs) : found + } + break + } + } + const parent = dirname(directory) + if (parent === directory) return undefined + directory = parent + } +} + +export function findPackage( + dir: string, + name: string, + excludedDirectory?: (directory: string) => boolean, + inputs?: ResolutionInputCollector, +): string | false | undefined { + const packageDir = join(dir, 'node_modules', name) + if (excludedDirectory?.(packageDir)) return false + if (isFile(join(packageDir, 'package.json'), inputs)) return packageDir + const parent = dirname(dir) + return parent === dir + ? undefined + : findPackage(parent, name, excludedDirectory, inputs) +} + +function resolvePackageEntry( + dir: string, + manifest: Record, + subpath: string, + options: ModulePolicy, +): string | false | undefined { + if (manifest.exports !== undefined) { + const target = exportsTarget(manifest.exports, subpath, options.conditions) + return typeof target !== 'string' + ? undefined + : options.fileResolver(join(dir, target)) + } + if (subpath !== '.') return options.fileResolver(join(dir, subpath)) + const main = [manifest.module, manifest.main].find( + (entry): entry is string => typeof entry === 'string', + ) + return options.fileResolver(join(dir, main ?? 'index')) +} diff --git a/packages/plugin-utils/src/package-target.ts b/packages/plugin-utils/src/package-target.ts new file mode 100644 index 00000000..c016f0c2 --- /dev/null +++ b/packages/plugin-utils/src/package-target.ts @@ -0,0 +1,128 @@ +import { isRecord } from './jsonc' +import { matchesTypeScriptVersion } from './typescript-version-range' + +interface TargetPolicy { + readonly purpose: 'module' | 'tsconfig-extends' + readonly conditions: readonly string[] + readonly load: (target: string) => string | undefined +} + +export function conditionTarget( + value: unknown, + policy: TargetPolicy, +): string | null | undefined { + if (typeof value === 'string') return policy.load(value) + if (value === null) return null + if (Array.isArray(value)) { + for (const entry of value) { + const target = conditionTarget(entry, policy) + if (target !== undefined) return target + } + return undefined + } + if (!isRecord(value)) return undefined + for (const [condition, branch] of Object.entries(value)) { + if ( + condition !== 'default' && + !policy.conditions.includes(condition) && + !( + policy.purpose === 'tsconfig-extends' && + condition.startsWith('types@') && + matchesTypeScriptVersion(condition.slice(6)) + ) + ) + continue + const target = conditionTarget(branch, policy) + if (target !== undefined) return target + } + return undefined +} + +export function exportsTarget( + exports: unknown, + subpath: string, + conditions: readonly string[], +): string | null | undefined { + const subpaths = isRecord(exports) + ? Object.keys(exports).filter((key) => key.startsWith('.')) + : [] + const policy: TargetPolicy = { + purpose: 'module', + conditions, + load: (target) => target, + } + if (subpaths.length === 0) + return subpath === '.' ? conditionTarget(exports, policy) : undefined + if (!isRecord(exports)) return undefined + if (subpath in exports) return conditionTarget(exports[subpath], policy) + for (const key of subpaths) { + const [prefix, suffix = ''] = key.split('*') + if ( + key.includes('*') && + subpath.startsWith(prefix) && + subpath.endsWith(suffix) && + subpath.length >= prefix.length + suffix.length + ) { + const matched = subpath.slice( + prefix.length, + subpath.length - suffix.length, + ) + return conditionTarget(exports[key], policy)?.replaceAll('*', matched) + } + } + return undefined +} + +export function matchConfigTarget( + table: unknown, + request: string, +): + | { + readonly target: unknown + readonly subpath: string + readonly pattern: boolean + } + | undefined { + if (!isRecord(table)) return undefined + if ( + !request.endsWith('/') && + !request.includes('*') && + Object.hasOwn(table, request) + ) + return { target: table[request], subpath: '', pattern: false } + const keys = Object.keys(table).filter( + (key) => + key.endsWith('/') || + (key.includes('*') && key.indexOf('*') === key.lastIndexOf('*')), + ) + keys.sort((a, b) => { + const ai = a.indexOf('*') + const bi = b.indexOf('*') + const length = + (bi === -1 ? b.length : bi + 1) - (ai === -1 ? a.length : ai + 1) + return length || (ai === -1 ? 1 : bi === -1 ? -1 : b.length - a.length) + }) + for (const key of keys) { + const star = key.indexOf('*') + if ( + star !== -1 && + request.startsWith(key.slice(0, star)) && + request.endsWith(key.slice(star + 1)) + ) + return { + target: table[key], + subpath: request.substring( + star, + request.length - (key.length - star - 1), + ), + pattern: true, + } + if (request.startsWith(key)) + return { + target: table[key], + subpath: request.slice(key.length), + pattern: false, + } + } + return undefined +} diff --git a/packages/plugin-utils/src/packaged-config-inputs.ts b/packages/plugin-utils/src/packaged-config-inputs.ts index 2e66cd0c..8d86b8fa 100644 --- a/packages/plugin-utils/src/packaged-config-inputs.ts +++ b/packages/plugin-utils/src/packaged-config-inputs.ts @@ -1,35 +1,65 @@ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { isAbsolute, join, relative } from 'node:path' +import { statSync } from 'node:fs' -import type { ResolutionInputCollector } from './resolution-inputs' -import { resolutionWatchPath } from './resolution-watch-path' +import { isRecord, parseJsonc } from './jsonc' +import { ConfigLoadError } from './load-config' +import { + readResolutionFile, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' -interface PackagedConfigLookup { - readonly request: string - readonly importer: string - readonly resolved: string | undefined +export function isResolutionFile( + path: string, + inputs?: ResolutionInputCollector, +): boolean { + try { + const file = statSync(path).isFile() + if (file) inputs?.file(path) + return file + } catch (cause) { + recordResolutionFailure(path, cause, inputs) + if ( + cause instanceof Error && + 'code' in cause && + (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') + ) + return false + throw cause + } } -export function recordPackagedConfigInputs( - lookup: PackagedConfigLookup, +export function readPackageManifest( + file: string, + purpose: 'module' | 'tsconfig-extends', inputs?: ResolutionInputCollector, -): void { - if (!inputs) return - const { request, importer, resolved } = lookup - const require = createRequire(importer) - const name = request - .split('/') - .slice(0, request.startsWith('@') ? 2 : 1) - .join('/') - for (const directory of require.resolve.paths(request) ?? []) { - const candidate = join(directory, name) - const exists = existsSync(candidate) - inputs.probe(candidate, exists) - if (exists && resolved) { - const canonical = resolutionWatchPath(candidate) - const subpath = relative(canonical, resolved) - if (!subpath.startsWith('..') && !isAbsolute(subpath)) break - } +): Record { + // TypeScript 6.0.3 lib/typescript.js:21164-21184 reads JSON/JSONC, parse misses => {}. + // The @typescript/typescript6 6.0.2 wrapper reexports this installed compiler. + switch (purpose) { + case 'module': + try { + const manifest: unknown = JSON.parse(readResolutionFile(file, inputs)) + if (!isRecord(manifest)) + throw new TypeError('Expected a package manifest object') + return manifest + } catch (cause) { + throw new ConfigLoadError(file, cause) + } + case 'tsconfig-extends': + if (!isResolutionFile(file, inputs)) return {} + try { + const source = readResolutionFile(file, inputs) + let manifest: unknown + try { + manifest = JSON.parse(source) + } catch (cause) { + if (!(cause instanceof SyntaxError)) throw cause + manifest = parseJsonc(source, true) + } + return isRecord(manifest) ? manifest : {} + } catch (cause) { + if (cause instanceof SyntaxError) return {} + throw cause + } } } diff --git a/packages/plugin-utils/src/tsconfig-export-policy.ts b/packages/plugin-utils/src/tsconfig-export-policy.ts new file mode 100644 index 00000000..a5fb04d0 --- /dev/null +++ b/packages/plugin-utils/src/tsconfig-export-policy.ts @@ -0,0 +1,130 @@ +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { isRecord } from './jsonc' +import { ConfigLoadError } from './load-config' +import { resolveJsonFile } from './owned-file-resolution' +import { type ConfigPolicy, resolvePackage } from './owned-module-resolution' +import { conditionTarget, matchConfigTarget } from './package-target' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' + +interface PackageScope extends ConfigPolicy { + readonly directory: string + readonly imports?: boolean +} + +export function configExport( + table: unknown, + request: string, + scope: PackageScope, +): string | null | undefined { + let match: ReturnType + if (scope.imports) match = matchConfigTarget(table, request) + else if (request === '.') { + const target = + typeof table === 'string' || + Array.isArray(table) || + (isRecord(table) && + !Object.keys(table).some((key) => key.startsWith('.'))) + ? table + : isRecord(table) + ? table['.'] + : undefined + if (target) match = { target, subpath: '', pattern: false } + } else if ( + isRecord(table) && + Object.keys(table).every((key) => key.startsWith('.')) + ) + match = matchConfigTarget(table, request) + if (!match) return undefined + const { target, subpath, pattern } = match + return conditionTarget(target, { + purpose: 'tsconfig-extends', + conditions: ['require', 'types', 'node'], + load(value) { + if (!pattern && subpath && !value.endsWith('/')) return undefined + if (!value.startsWith('./')) { + if (scope.imports && !value.startsWith('../') && !isAbsolute(value)) { + const request = pattern + ? value.replaceAll('*', subpath) + : value + subpath + const stack = scope.stack ?? [] + if (stack.includes(request)) + throw new ConfigLoadError( + join(scope.directory, 'package.json'), + new Error( + `Package imports cycle: ${[...stack, request].join(' -> ')}`, + ), + ) + const found = resolvePackage( + request, + join(scope.directory, 'tsconfig.json'), + { ...scope, configLookup: false, stack: [...stack, request] }, + ) + return typeof found === 'string' ? found : undefined + } + return undefined + } + const prohibited = (parts: readonly string[]) => + parts.some( + (part) => part === '.' || part === '..' || part === 'node_modules', + ) + const targetPath = value.replaceAll('\\', '/') + const targetSubpath = subpath.replaceAll('\\', '/') + if ( + prohibited(targetPath.split('/').slice(1)) || + prohibited(targetSubpath.split('/')) + ) + return undefined + const selected = resolve( + scope.directory, + pattern + ? targetPath.replaceAll('*', targetSubpath) + : targetPath + targetSubpath, + ) + return resolveJsonFile(selected, { ...scope, exact: true }) + }, + }) +} + +export function configScopeTarget( + request: string, + importer: string, + options: ConfigPolicy, +): string | null | undefined { + if (request === '#') return null + const directory = dirname(importer) + const manifestPath = join(directory, 'package.json') + if (isFile(manifestPath, options.inputs)) { + const manifest = readPackageManifest( + manifestPath, + 'tsconfig-extends', + options.inputs, + ) + if (request.startsWith('#')) + return ( + configExport(manifest.imports, request, { + ...options, + directory, + imports: true, + }) ?? null + ) + const name = manifest.name + if ( + manifest.exports && + typeof name === 'string' && + (request === name || request.startsWith(`${name}/`)) + ) + return configExport( + manifest.exports, + request === name ? '.' : `.${request.slice(name.length)}`, + { ...options, directory }, + ) + return undefined + } + const parent = dirname(directory) + if (parent === directory) return request.startsWith('#') ? null : undefined + return configScopeTarget(request, join(parent, 'tsconfig.json'), options) +} diff --git a/packages/plugin-utils/src/tsconfig-package-policy.ts b/packages/plugin-utils/src/tsconfig-package-policy.ts new file mode 100644 index 00000000..6f116bb7 --- /dev/null +++ b/packages/plugin-utils/src/tsconfig-package-policy.ts @@ -0,0 +1,172 @@ +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +import { isRecord } from './jsonc' +import { resolveJsonFile } from './owned-file-resolution' +import type { ConfigPolicy } from './owned-module-resolution' +import { + isResolutionFile as isFile, + readPackageManifest, +} from './packaged-config-inputs' +import { configExport } from './tsconfig-export-policy' +import { matchesTypeScriptVersion } from './typescript-version-range' + +interface PackageConfigManifest { + readonly directory: string + readonly manifest: Record +} + +// Installed TS 6.0.3:46566 uses tryGetExtensionFromPath2 (22743,22822-22823). +const recognizedExtension = /\.(?:json|js|jsx|mjs|cjs|ts|tsx|mts|cts)$/ + +function versionPaths( + manifest: Record, +): Record | undefined { + if (!isRecord(manifest.typesVersions)) return undefined + for (const [range, paths] of Object.entries(manifest.typesVersions)) + if (matchesTypeScriptVersion(range)) + return isRecord(paths) ? paths : undefined + return undefined +} + +function mappedPath( + request: string, + paths: Record, + load: (target: string) => string | undefined, +): string | null | undefined { + let key = Object.hasOwn(paths, request) ? request : undefined + let matched = '' + if (key === undefined) { + let length = -1 + for (const pattern of Object.keys(paths)) { + const star = pattern.indexOf('*') + if (star === -1 || star !== pattern.lastIndexOf('*')) continue + const prefix = pattern.slice(0, star) + const suffix = pattern.slice(star + 1) + if ( + star > length && + request.startsWith(prefix) && + request.endsWith(suffix) && + request.length >= prefix.length + suffix.length + ) { + key = pattern + length = star + matched = request.slice(star, request.length - suffix.length) + } + } + } + if (key === undefined) return undefined + const targets = paths[key] + if (Array.isArray(targets)) + for (const target of targets) { + if (typeof target !== 'string') continue + const found = load(matched ? target.replace('*', matched) : target) + if (found) return found + } + return null +} + +export function directoryConfig( + directory: string, + packageConfig: PackageConfigManifest, + options: ConfigPolicy, +): string | undefined { + const { manifest } = packageConfig + const field = + options.configLookup !== false && + relative(packageConfig.directory, directory) === '' + ? manifest.tsconfig + : undefined + const packageFile = + typeof field === 'string' && field ? resolve(directory, field) : undefined + const load = (candidate: string) => + resolveJsonFile(candidate, options) ?? + resolveJsonFile( + join(candidate, options.configLookup === false ? 'index' : 'tsconfig'), + options, + ) + const paths = versionPaths(manifest) + const fieldPath = packageFile ? relative(directory, packageFile) : '' + if ( + paths && + fieldPath !== '..' && + !fieldPath.startsWith(`..${sep}`) && + !isAbsolute(fieldPath) + ) { + const found = mappedPath( + relative( + directory, + packageFile ?? + join( + directory, + options.configLookup === false ? 'index' : 'tsconfig', + ), + ).replaceAll('\\', '/'), + paths, + (target) => { + const candidate = resolve(directory, target) + return recognizedExtension.test(target) && + isFile(candidate, options.inputs) + ? candidate + : load(candidate) + }, + ) + if (found !== undefined) return found ?? undefined + } + return ( + (packageFile ? load(packageFile) : undefined) ?? + resolveJsonFile( + join(directory, options.configLookup === false ? 'index' : 'tsconfig'), + options, + ) + ) +} + +export function configPackageCandidate( + directory: string, + rest: string, + options: ConfigPolicy, +): string | undefined { + const candidate = join(directory, rest) + const nestedFile = join(candidate, 'package.json') + const nestedExists = isFile(nestedFile, options.inputs) + const nested = nestedExists + ? readPackageManifest(nestedFile, 'tsconfig-extends', options.inputs) + : {} + const manifest = rest + ? readPackageManifest( + join(directory, 'package.json'), + 'tsconfig-extends', + options.inputs, + ) + : nested + if (rest && nestedExists && !Object.hasOwn(manifest, 'exports')) + return ( + resolveJsonFile(candidate, options) ?? + directoryConfig( + candidate, + { directory: candidate, manifest: nested }, + options, + ) + ) + if (manifest.exports) + return ( + configExport(manifest.exports, rest ? `./${rest}` : '.', { + ...options, + directory, + }) ?? undefined + ) + const load = (target: string) => + resolveJsonFile(target, options) ?? + directoryConfig(target, { directory, manifest }, options) + const paths = rest ? versionPaths(manifest) : undefined + if (paths) { + const mapped = mappedPath(rest, paths, (target) => { + const path = resolve(directory, target) + return recognizedExtension.test(target) && isFile(path, options.inputs) + ? path + : load(path) + }) + if (mapped !== undefined) return mapped ?? undefined + } + return load(candidate) +} diff --git a/packages/plugin-utils/src/tsconfig.ts b/packages/plugin-utils/src/tsconfig.ts index 9ccf0011..d7354aa5 100644 --- a/packages/plugin-utils/src/tsconfig.ts +++ b/packages/plugin-utils/src/tsconfig.ts @@ -1,9 +1,10 @@ import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { dirname, isAbsolute, resolve } from 'node:path' +import { isRecord, parseJsonc } from './jsonc' import { ConfigLoadError } from './load-config' -import { recordPackagedConfigInputs } from './packaged-config-inputs' +import { resolvePackage } from './owned-module-resolution' +import { isResolutionFile } from './packaged-config-inputs' import { readResolutionFile, realpathResolution, @@ -24,114 +25,31 @@ interface AliasConfig { readonly pathsOrigin?: string } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function parseJsonc(source: string): unknown { - let text = '' - let quoted = false - for (let index = 0; index < source.length; index += 1) { - const char = source[index] - const next = source[index + 1] - if (quoted) { - text += char - if (char === '\\') text += source[++index] ?? '' - else if (char === '"') quoted = false - } else if (char === '"') { - quoted = true - text += char - } else if (char === '/' && next === '/') { - while (index < source.length && source[index] !== '\n') index += 1 - text += '\n' - } else if (char === '/' && next === '*') { - const end = source.indexOf('*/', index + 2) - if (end === -1) throw new SyntaxError('Unterminated JSON comment') - text += ' ' - index = end + 1 - } else text += char - } - let json = '' - quoted = false - for (let index = 0; index < text.length; index += 1) { - const char = text[index] - if (quoted) { - json += char - if (char === '\\') json += text[++index] ?? '' - else if (char === '"') quoted = false - } else if (char === '"') { - quoted = true - json += char - } else if (char !== ',' || !/^\s*[}\]]/.test(text.slice(index + 1))) - json += char - } - return JSON.parse(json) -} - function resolveParent( specifier: string, file: string, inputs?: ResolutionInputCollector, ): string { - if (specifier.startsWith('.') || isAbsolute(specifier)) { - const candidate = resolve(dirname(file), specifier) - const exists = existsSync(candidate) - inputs?.probe(candidate, exists) - return exists ? candidate : `${candidate}.json` - } - const require = createRequire(file) - let selected: string | undefined - try { - try { - const resolved = require.resolve(specifier) - selected = resolved - if (resolved.endsWith('.json')) return resolved - const manifestPath = require.resolve(`${specifier}/package.json`) - const manifest: unknown = JSON.parse( - readResolutionFile(manifestPath, inputs), - ) - return join( - dirname(manifestPath), - isRecord(manifest) && typeof manifest.tsconfig === 'string' - ? manifest.tsconfig - : 'tsconfig.json', - ) - } catch (cause) { - if ( - !(cause instanceof Error) || - !('code' in cause) || - cause.code !== 'MODULE_NOT_FOUND' - ) - throw cause - try { - const resolved = require.resolve(`${specifier}/tsconfig.json`) - selected = resolved - return resolved - } catch (fallback) { - if ( - !(fallback instanceof Error) || - !('code' in fallback) || - fallback.code !== 'MODULE_NOT_FOUND' - ) - throw fallback - const manifestPath = require.resolve(`${specifier}/package.json`) - selected = manifestPath - const manifest: unknown = JSON.parse( - readResolutionFile(manifestPath, inputs), - ) - const configName = - isRecord(manifest) && typeof manifest.tsconfig === 'string' - ? manifest.tsconfig - : 'tsconfig.json' - return join(dirname(manifestPath), configName) - } - } - } finally { - recordPackagedConfigInputs( - { request: specifier, importer: file, resolved: selected }, - inputs, - ) + const request = specifier.replaceAll('\\', '/') + if ( + request.startsWith('./') || + request.startsWith('../') || + isAbsolute(request) + ) { + const candidate = resolve(dirname(file), request) + return isResolutionFile(candidate, inputs) || candidate.endsWith('.json') + ? candidate + : `${candidate}.json` } + const selected = resolvePackage(request, file, { + purpose: 'tsconfig-extends', + inputs, + }) + if (typeof selected === 'string') return selected + throw new ConfigLoadError( + file, + new Error(`Cannot resolve tsconfig extends ${JSON.stringify(specifier)}`), + ) } function loadAliases( diff --git a/packages/plugin-utils/src/typescript-version-range.ts b/packages/plugin-utils/src/typescript-version-range.ts new file mode 100644 index 00000000..9d8272c5 --- /dev/null +++ b/packages/plugin-utils/src/typescript-version-range.ts @@ -0,0 +1,190 @@ +// Adapted from Microsoft TypeScript 6.0.3 (Apache-2.0), lib/typescript.js:4805-5073. +// Resolution pins the actual installed JS authority, not native TypeScript 7.0.2. +const compiler = [6, 0, 3] as const +type Version = readonly [number, number, number, readonly string[]] +type PartialVersion = { + readonly version: Version + readonly major: string + readonly minor: string + readonly patch: string +} +const wildcard = (part: string) => part === '*' || part === 'x' || part === 'X' +export class TypeScriptVersionRangeError extends Error { + constructor( + readonly range: string, + readonly component: 'prerelease' | 'build', + ) { + super( + `Invalid TypeScript version range ${JSON.stringify(range)}: ${component}`, + ) + this.name = 'TypeScriptVersionRangeError' + } +} + +function partial(text: string): PartialVersion | undefined { + const match = + /^([x*0]|[1-9]\d*)(?:\.([x*0]|[1-9]\d*)(?:\.([x*0]|[1-9]\d*)(?:-([a-z0-9-.]+))?(?:\+([a-z0-9-.]+))?)?)?$/i.exec( + text, + ) + if (!match) return undefined + const [, major, minor = '*', patch = '*', pre = '', build = ''] = match + if ( + pre && + !/^(?:0|[1-9]\d*|[a-z-][a-z0-9-]*)(?:\.(?:0|[1-9]\d*|[a-z-][a-z0-9-]*))*$/i.test( + pre, + ) + ) + throw new TypeScriptVersionRangeError(text, 'prerelease') + if (build && !/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/i.test(build)) + throw new TypeScriptVersionRangeError(text, 'build') + return { + major, + minor, + patch, + version: [ + wildcard(major) ? 0 : Number(major), + wildcard(major) || wildcard(minor) ? 0 : Number(minor), + wildcard(major) || wildcard(minor) || wildcard(patch) ? 0 : Number(patch), + pre ? pre.split('.') : [], + ], + } +} + +function increment( + value: Version, + field: 0 | 1 | 2, + prerelease = false, +): Version { + return [ + value[0] + Number(field === 0), + field === 0 ? 0 : value[1] + Number(field === 1), + field < 2 ? 0 : value[2] + 1, + prerelease ? ['0'] : [], + ] +} + +function compare(value: Version): number { + return ( + compiler[0] - value[0] || + compiler[1] - value[1] || + compiler[2] - value[2] || + Number(value[3].length > 0) + ) +} +type Operator = '<' | '<=' | '>' | '>=' | '=' +type Comparator = readonly [Operator, Version] +function test([operator, value]: Comparator): boolean { + const order = compare(value) + switch (operator) { + case '<': + return order < 0 + case '<=': + return order <= 0 + case '>': + return order > 0 + case '>=': + return order >= 0 + case '=': + return order === 0 + } +} + +function comparators(text: string): readonly Comparator[] | undefined { + const hyphen = /^\s*([a-z0-9-+.*]+)\s+-\s+([a-z0-9-+.*]+)\s*$/i.exec(text) + const result: Comparator[] = [] + if (hyphen) { + const left = partial(hyphen[1]) + const right = partial(hyphen[2]) + if (!left || !right) return undefined + if (!wildcard(left.major)) result.push(['>=', left.version]) + if (!wildcard(right.major)) + result.push( + wildcard(right.minor) + ? ['<', increment(right.version, 0)] + : wildcard(right.patch) + ? ['<', increment(right.version, 1)] + : ['<=', right.version], + ) + return result + } + for (const simple of text.split(/\s+/)) { + const match = /^([~^<>=]|<=|>=)?\s*([a-z0-9-+.*]+)$/i.exec(simple.trim()) + if (!match) return undefined + const value = partial(match[2]) + if (!value) return undefined + const { major, minor, patch, version } = value + const operator = match[1] + if (wildcard(major)) { + if (operator === '<' || operator === '>') + result.push(['<', [0, 0, 0, ['0']]]) + continue + } + switch (operator) { + case '~': + result.push( + ['>=', version], + ['<', increment(version, wildcard(minor) ? 0 : 1)], + ) + break + case '^': + result.push( + ['>=', version], + [ + '<', + increment( + version, + version[0] > 0 || wildcard(minor) + ? 0 + : version[1] > 0 || wildcard(patch) + ? 1 + : 2, + ), + ], + ) + break + case '<': + case '>=': + result.push([ + operator, + wildcard(minor) || wildcard(patch) + ? [version[0], version[1], version[2], ['0']] + : version, + ]) + break + case '<=': + case '>': + result.push( + wildcard(minor) || wildcard(patch) + ? [ + operator === '<=' ? '<' : '>=', + increment(version, wildcard(minor) ? 0 : 1, true), + ] + : [operator, version], + ) + break + case '=': + case undefined: + if (wildcard(minor) || wildcard(patch)) + result.push( + ['>=', [version[0], version[1], version[2], ['0']]], + ['<', increment(version, wildcard(minor) ? 0 : 1, true)], + ) + else result.push(['=', version]) + break + } + } + return result +} + +export function matchesTypeScriptVersion(range: string): boolean { + const alternatives = range + .trim() + .split(/\|\|/) + .filter(Boolean) + .map((part) => comparators(part.trim())) + return ( + alternatives.every((part) => part !== undefined) && + (alternatives.length === 0 || + alternatives.some((part) => part?.every(test))) + ) +} diff --git a/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts new file mode 100644 index 00000000..39a6a9e6 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-scope.test.ts @@ -0,0 +1,56 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' +import { createServer, type ViteDevServer } from 'vite' + +import { createMissingInputWatch } from '../resolution-watch' + +it('preserves exact missing scope targets when enrolling public watcher repair inputs', async () => { + // Given a missing scope manifest beside an unrelated existing source tree. + const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'vite-watch-scope-')), + ) + const root = join(fixture, 'app') + const scope = join(fixture, 'scope') + const unrelated = join(scope, 'unrelated') + const manifest = join(scope, 'package.json') + let server: ViteDevServer | undefined + const watch = createMissingInputWatch() + try { + await mkdir(root, { recursive: true }) + await mkdir(unrelated, { recursive: true }) + await writeFile(join(unrelated, 'value.ts'), 'export {}') + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + }) + watch.attach(server) + watch.start(join(root, 'main.ts'), server.environments.client) + const requested: unknown[] = [] + const original = server.watcher.add + const add = spyOn(server.watcher, 'add').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + requested.push(args[0]) + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When enrolling one ancestor-scope repair input on the real public watcher. + watch.observe(join(root, 'main.ts'), manifest, server.environments.client) + // Then the target stays a file, not an entire unrelated ancestor subtree. + expect(requested).toEqual([manifest]) + } finally { + add.mockRestore() + } + } finally { + watch.close() + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs new file mode 100644 index 00000000..74ae0de8 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const { createServer } = await import( + pathToFileURL(require.resolve('vite')).href +) +const { DevupUI } = require('../../dist/index.cjs') +const fixture = await realpath( + await mkdtemp(join(tmpdir(), 'devup-config-watch-')), +) +const root = join(fixture, 'apps/app') +const preset = join(fixture, 'packages/preset') +const manifest = join(preset, 'package.json') +let server +try { + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'node_modules/@scope'), { recursive: true }) + await mkdir(preset, { recursive: true }) + await symlink(preset, join(root, 'node_modules/@scope/preset'), 'junction') + await writeFile(join(root, 'tsconfig.json'), '{"extends":"@scope/preset"}') + await writeFile(manifest, '{"name":"@scope/preset","exports":"./red.json"}') + for (const color of ['red', 'blue']) { + await writeFile( + join(preset, `${color}.json`), + JSON.stringify({ + compilerOptions: { paths: { palette: [`${color}.ts`] } }, + }), + ) + await writeFile( + join(preset, `${color}.ts`), + `export const color='${color}'`, + ) + } + await writeFile( + join(root, 'src/main.tsx'), + "import {color} from 'palette'; import {css} from '@devup-ui/react'; export const style=css({bg:color})", + ) + let ready + let notify + const update = new Promise((done) => { + notify = done + }) + const signal = AbortSignal.timeout(15000) + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { port: 0 }, + plugins: [ + DevupUI(), + { + name: 'config-watch-observation', + configureServer(current) { + ready = once(current.watcher, 'ready', { signal }) + }, + hotUpdate({ file }) { + if (file === manifest.replaceAll('\\', '/')) notify() + }, + resolveId(id) { + if (id === 'palette') return join(preset, 'red.ts') + }, + }, + ], + }) + await server.listen() + const before = await server.transformRequest('/src/main.tsx') + await ready + const cssPath = join(root, 'df/devup-ui/devup-ui-0.css') + assert.match(await readFile(cssPath, 'utf8'), /background:red/) + const pid = process.pid + // Only the selecting manifest changes; watcher transport is Vite's public API. + const replacement = join(fixture, 'replacement.json') + await writeFile( + replacement, + '{"name":"@scope/preset","exports":"./blue.json"}', + ) + await rename(replacement, manifest) + await Promise.race([ + update, + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }), + ]) + const after = await server.transformRequest('/src/main.tsx') + assert.notEqual(after?.code, before?.code) + const selected = after?.code.match(/style\s*=\s*["']([^"']+)["']/)?.[1] + assert.ok(selected) + const css = await readFile(cssPath, 'utf8') + assert.ok(css.includes(`.${selected}{background:blue}`), css) + assert.equal(process.pid, pid) + console.info( + JSON.stringify({ + runtime: process.version, + pid, + before: before?.code, + after: after?.code, + selected, + css, + }), + ) +} finally { + await server?.close() + await rm(fixture, { recursive: true, force: true }) +} diff --git a/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts new file mode 100644 index 00000000..5d496627 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/tsconfig-native-watch.test.ts @@ -0,0 +1,20 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +it('reextracts blue CSS through public invalidation in persistent genuine native dev', () => { + // Given the checked-in manifest-only native fixture and built public plugin. + const driver = join(import.meta.dir, 'tsconfig-native-watch.mjs') + // When native Node owns the entire awaited server lifecycle without a restart. + const result = spawnSync('node', [driver], { + encoding: 'utf8', + timeout: 25000, + }) + // Then its actual selected class has a blue declaration, not merely a watcher event. + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(result.stdout).toContain('background:blue') +}, 30000) diff --git a/packages/vite-plugin/src/resolution-watch.ts b/packages/vite-plugin/src/resolution-watch.ts index f72ae825..79179057 100644 --- a/packages/vite-plugin/src/resolution-watch.ts +++ b/packages/vite-plugin/src/resolution-watch.ts @@ -1,4 +1,4 @@ -import { dirname } from 'node:path' +import { basename, dirname } from 'node:path' import type { ViteDevServer } from 'vite' @@ -38,7 +38,11 @@ export function createMissingInputWatch() { }, observe(importer: string, path: string, environment: object) { missingByEnvironment.get(environment)?.get(importer)?.add(path) - server?.watcher.add(dirname(path)) + // Scope manifests can be probed up to the drive root; never enroll that tree. + // Competing source candidates retain their established directory transport. + server?.watcher.add( + basename(path) === 'package.json' ? path : dirname(path), + ) }, close() { server?.watcher