diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 000000000..667a75271 --- /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/.changepacks/changepack_log_deterministic_numbering.json b/.changepacks/changepack_log_deterministic_numbering.json new file mode 100644 index 000000000..053739d3d --- /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 (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/.changepacks/changepack_log_plugin_core.json b/.changepacks/changepack_log_plugin_core.json new file mode 100644 index 000000000..e74f874ac --- /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" +} diff --git a/.changepacks/changepack_log_plugin_core_ci.json b/.changepacks/changepack_log_plugin_core_ci.json new file mode 100644 index 000000000..398fa5756 --- /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" +} diff --git a/.changepacks/changepack_log_plugin_core_mdx.json b/.changepacks/changepack_log_plugin_core_mdx.json new file mode 100644 index 000000000..da179669f --- /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/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 613206dd7..e2167ef6c 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -1,85 +1,117 @@ -
+# @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, 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: -Built with 🦀🕸 by The Rust and WebAssembly Working Group - -
- -## About +```text +/src/App.tsx:1:1: module resolver cannot use `./tokens` at build time: original cause +``` -[**📚 Read this template tutorial! 📚**][template-docs] +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. -This template is designed for compiling Rust libraries into WebAssembly and -publishing the resulting package to NPM. +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. -Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other -templates and usages of `wasm-pack`. +## Panic diagnostics -[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 +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. -## 🚴 Usage +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. -### 🐑 Use `cargo generate` to Clone this Template +## Verification -[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate) +From the repository root: -``` -cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project -cd my-project +```sh +cargo test -p devup-ui-wasm boundary_ -- --nocapture ``` -### 🛠️ Build with `wasm-pack build` +After rebuilding `pkg` with this package's `bun run build`, run from the +repository root: -``` -wasm-pack build +```sh +node bindings/devup-ui-wasm/boundary-regressions.mjs +node bindings/devup-ui-wasm/ignored-alias-regressions.mjs ``` -### 🔬 Test in Headless Browsers with `wasm-pack test` +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. -``` -wasm-pack test --headless --firefox -``` +## Explicit Compiler Source Type -### 🎁 Publish to NPM with `wasm-pack publish` +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', +) ``` -wasm-pack publish -``` - -## 🔋 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 +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. -## License +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. -Licensed under either of +After rebuilding the bindings, run the real project-compiler/WASM matrix: -- 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) +```sh +node bindings/devup-ui-wasm/source-type-regressions.mjs +``` -at your option. +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. -### Contribution +Focused native MDX verification: -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. +```sh +cargo test -p extractor mdx_ -- --nocapture +``` diff --git a/bindings/devup-ui-wasm/boundary-regressions.mjs b/bindings/devup-ui-wasm/boundary-regressions.mjs new file mode 100644 index 000000000..ac30a81a9 --- /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/ignored-alias-regressions.mjs b/bindings/devup-ui-wasm/ignored-alias-regressions.mjs new file mode 100644 index 000000000..76b50fa45 --- /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 000000000..62e89c7bb --- /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/jsx-dev-regressions.mjs b/bindings/devup-ui-wasm/jsx-dev-regressions.mjs new file mode 100644 index 000000000..e77913a3b --- /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, /(?:\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 0a2347ac2..4c819f749 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -2,10 +2,11 @@ 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, + ExtractOption, ImportAlias, ModuleResolution, ModuleResolver, ResolvedModule, has_devup_ui, }; use rustc_hash::FxHashSet; use sheet::StyleSheet; @@ -14,6 +15,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())); @@ -53,6 +56,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, @@ -243,7 +292,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 +317,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 +344,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 +364,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 @@ -345,6 +437,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, @@ -357,6 +450,8 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, + source_type, ) } @@ -370,6 +465,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, @@ -382,6 +478,8 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, + source_type, ) } @@ -397,6 +495,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( @@ -410,6 +509,8 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, + source_type, ) } @@ -425,6 +526,8 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_fault: Option<&RefCell>>, + source_type: Option, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -433,19 +536,16 @@ 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 extracted { + match checked_extraction(extracted, resolver_fault) { Ok(output) => Ok(Output::new( output.code, output.styles, @@ -456,7 +556,7 @@ fn code_extract_internal_impl( import_main_css_in_css, output.dependencies, )), - Err(error) => Err(error.to_string()), + Err(error) => Err(error), } } @@ -475,19 +575,53 @@ 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); + } + 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()) - .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(ModuleResolution::Resolved(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) + ) + })?, + )?, + }))) } /// Extract with the resolver set by `setModuleResolver`, if any @@ -503,10 +637,29 @@ 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); 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, @@ -521,10 +674,23 @@ fn code_extract_js( resolver .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, @@ -544,7 +710,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, @@ -555,6 +721,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, @@ -566,11 +733,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, @@ -581,6 +749,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, @@ -591,10 +760,17 @@ pub fn code_extract_without_source_map( import_main_css_in_code, import_main_css_in_css, import_aliases, - SourceMapMode::Generate, + 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)); @@ -689,6 +865,53 @@ 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), + None, + ); + 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() { @@ -726,6 +949,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); code_extract_internal( @@ -737,6 +961,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); @@ -871,6 +1096,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -921,6 +1147,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1023,6 +1250,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1183,9 +1411,12 @@ mod tests { *sheet = StyleSheet::default(); } let resolver = |specifier: &str, _: &str| { - (specifier == "./tokens").then(|| ResolvedModule { - 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(), + }) }) }; { @@ -1198,6 +1429,7 @@ mod tests { false, false, HashMap::new(), + None, &resolver, ) .unwrap(); @@ -1709,6 +1941,7 @@ mod tests { false, false, HashMap::new(), + None, ) .unwrap(); } @@ -1929,6 +2162,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(); + 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() { @@ -1947,6 +2234,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -1971,6 +2259,7 @@ mod tests { false, false, HashMap::new(), + None, ); assert!(result.is_ok()); @@ -1996,6 +2285,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 000000000..d38d3a7b9 --- /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(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", + "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(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", + "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/bun.preload.ts b/bun.preload.ts new file mode 100644 index 000000000..de91cc78a --- /dev/null +++ b/bun.preload.ts @@ -0,0 +1,13 @@ +import { parseTestGroup } from './test-harness/groups' +import { prepareTests } from './test-harness/preload' +import { rootTestRun } from './test-harness/run' + +export async function initialiseHarness(): Promise { + const status = await prepareTests( + parseTestGroup(process.env.DEVUP_TEST_GROUP), + rootTestRun(import.meta.dir), + ) + if (status !== undefined) process.exit(status) +} + +await initialiseHarness() diff --git a/bunfig.toml b/bunfig.toml index 1241b1841..67ea09046 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,11 +1,8 @@ [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 } 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/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..f0a20a939 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, 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 = files.iter().map(|file| canonical(file)).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,47 @@ 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() { + 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/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..db8d5be59 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; @@ -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 = SourceType::from_path(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/ignored_loader_tests.rs b/libs/extractor/src/ignored_loader_tests.rs new file mode 100644 index 000000000..89303f3ef --- /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 000000000..84c1193fd --- /dev/null +++ b/libs/extractor/src/ignored_module_tests.rs @@ -0,0 +1,254 @@ +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() +-> 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/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..e8fb47d20 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; @@ -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 = SourceType::from_path(filename).unwrap_or_default(); // Parse the code let parser_ret = Parser::new(&allocator, code, source_type).parse(); @@ -566,7 +573,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/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..a0501cee8 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,14 +15,20 @@ 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; 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 { @@ -38,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>), @@ -53,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 @@ -74,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() { @@ -479,7 +491,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 +570,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,35 +736,42 @@ 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, } impl Modules<'_> { - fn exports( - &mut self, - specifier: &str, - importer: &str, - ) -> Option>> { - let module = (self.resolver?)(specifier, importer)?; - if let Some(exports) = self.exports.get(&module.path) { - return Some(exports.clone()); + 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::Partial(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()); - Some(exports) + self.exports.insert(key, exports.clone()); + Some(Exports::Partial(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(); @@ -785,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); } } } @@ -798,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()) @@ -1072,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 { @@ -1251,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()), } } @@ -1456,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) => { @@ -1509,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) } @@ -1570,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() @@ -1854,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() { @@ -1941,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 000000000..12aa76779 --- /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/imported_source_type_tests.rs b/libs/extractor/src/imported_source_type_tests.rs new file mode 100644 index 000000000..9aace6f85 --- /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/jsx_dev_tests.rs b/libs/extractor/src/jsx_dev_tests.rs new file mode 100644 index 000000000..c40130373 --- /dev/null +++ b/libs/extractor/src/jsx_dev_tests.rs @@ -0,0 +1,266 @@ +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::ast::CallExpression; +use oxc_ast_visit::{Visit, walk}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use rstest::rstest; +use serial_test::serial; +use std::error::Error; + +fn normalized(source: &str) -> 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 a5853d5b5..fc10cdf62 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; @@ -219,11 +221,25 @@ pub struct ExtractOutput { pub struct ResolvedModule { pub path: String, pub code: String, + 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 = dyn Fn(&str, &str) -> Option; +pub type ModuleResolver<'a> = dyn Fn(&str, &str) -> Option + 'a; #[derive(Clone)] pub struct ExtractOption { @@ -274,6 +290,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, @@ -281,7 +317,47 @@ 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)?; + 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; + } + 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 @@ -293,7 +369,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'` @@ -302,13 +380,38 @@ 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 = 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, 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(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(), @@ -326,7 +429,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 @@ -344,6 +453,7 @@ fn extract_source( ), &option, &referenced, + source_mode, )? }; let code = vanilla_extract::collected_styles_to_code_with_keyframes( @@ -396,7 +506,6 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(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 +521,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()); } @@ -456,6 +582,7 @@ fn extract_source( &option, resolver, &inlined.unknown, + source_mode, ) { let mut output = extract_source( @@ -465,6 +592,7 @@ fn extract_source( option, source_map, resolver, + source_mode, )?; let mut files: std::collections::BTreeSet = output.dependencies.into_iter().collect(); @@ -640,8 +768,9 @@ fn extract_class_map_from_code( partial_code: &str, option: &ExtractOption, style_names: &FxHashSet, + source_mode: Option, ) -> Result, Box> { - let source_type = SourceType::from_path(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(); @@ -707,7 +836,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 +845,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 +879,153 @@ 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, + None, + ) + .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 +5056,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(); @@ -13631,6 +13908,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() { @@ -14355,6 +14656,7 @@ export const card = style({ import_aliases: HashMap::new(), }, &style_names, + None, ) .unwrap(); @@ -18168,7 +18470,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('/') @@ -18181,9 +18483,12 @@ export const d = style([cond && base]);", .iter() .any(|extension| format!("{path}{extension}") == *file) }) - .map(|(file, code)| ResolvedModule { - 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 4082ff395..de274e408 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -14,13 +14,31 @@ 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__"; /// 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 @@ -72,11 +90,14 @@ 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, + 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 @@ -97,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, @@ -121,13 +145,31 @@ 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 { 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) { @@ -139,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", @@ -150,15 +193,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) } @@ -172,12 +215,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); @@ -185,7 +229,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 @@ -222,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 { @@ -368,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) + ); } } } @@ -404,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)), )); } } @@ -430,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.rs b/libs/extractor/src/source_type.rs new file mode 100644 index 000000000..fb1e90d28 --- /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 000000000..5624b9ba3 --- /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(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", + "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(ModuleResolution::Resolved(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(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", + "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(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", + "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 9e736b09a..b4cb7ab4a 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()) }) @@ -276,6 +289,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())) @@ -284,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()), ); }); @@ -299,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 @@ -317,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 { @@ -557,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) @@ -1470,9 +1498,12 @@ mod tests { path.trim_end_matches(".ts") == specifier.trim_start_matches('.').trim_end_matches(".ts") }) - .map(|(path, code)| crate::ResolvedModule { - 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( @@ -1514,11 +1545,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( @@ -1539,11 +1570,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/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..7ca23a245 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 diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 3f3ea2732..9987c8b5b 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/README.md b/packages/bun-plugin/README.md index 289863c3e..8f4064ae6 100644 --- a/packages/bun-plugin/README.md +++ b/packages/bun-plugin/README.md @@ -57,10 +57,72 @@ 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. + +`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 +`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 +## 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/bun-plugin/__regression__/conditional.bun.ts b/packages/bun-plugin/__regression__/conditional.bun.ts new file mode 100644 index 000000000..77e135f45 --- /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 000000000..4d6f65f7c --- /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 000000000..d3a4fb4eb --- /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/__tests__/callback-fixture.test.ts b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts new file mode 100644 index 000000000..b03b35573 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/callback-fixture.test.ts @@ -0,0 +1,108 @@ +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[] = [] + let success = true + 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}`) + 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 }) + 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__/mdx-ownership.test.ts b/packages/bun-plugin/src/__tests__/mdx-ownership.test.ts new file mode 100644 index 000000000..7db453379 --- /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__/mdx.test.ts b/packages/bun-plugin/src/__tests__/mdx.test.ts new file mode 100644 index 000000000..9da06fe83 --- /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/__tests__/plugin-boundaries.test.ts b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts new file mode 100644 index 000000000..a85ad5d7f --- /dev/null +++ b/packages/bun-plugin/src/__tests__/plugin-boundaries.test.ts @@ -0,0 +1,224 @@ +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: [] }) + await expect(f.load(path)).rejects.toThrow('page.mdx:1:1') + } 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 e93d67418..f8435ce09 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -1,199 +1,280 @@ -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, mock, 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 -} +it.each([undefined, 'code', 'dependencies', 'acquire'])( + 'releases Bun Output after needed getters when fault %s occurs', + async (fault) => { + const f = fixture() + const path = f.write( + 'src/style.ts', + "import {css} from '@devup-ui/react'; export const cls=css({bg:'red'})", + ) + const dependency = f.write('src/token.ts', 'export const token=1') + const events: string[] = [] + const error = new Error('ownership fault') + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + const output = { + get code() { + expect(live).toBe(true) + events.push('code') + if (fault === 'code') throw error + return 'export const cls="copied"' + }, + get dependencies() { + expect(live).toBe(true) + events.push('dependencies') + if (fault === 'dependencies') throw error + return [dependency] + }, + get css(): never { + throw new Error('unneeded css getter') + }, + get map(): never { + throw new Error('unneeded map getter') + }, + get cssFile(): never { + throw new Error('unneeded cssFile getter') + }, + get updatedBaseStyle(): never { + throw new Error('unneeded updatedBaseStyle getter') + }, + free, + [Symbol.dispose]: free, + } + await f.setup() + const extract = spyOn(wasm, 'codeExtract').mockImplementation(() => { + if (fault === 'acquire') throw error + return output + }) + try { + const run = f.load(path) + if (fault) await expect(run).rejects.toMatchObject({ cause: error }) + else { + const code = sourceContents(await run) + expect(code).toContain('copied') + expect(code).toContain('token.ts') + } + const fields = ['code', 'dependencies'] + expect(events).toEqual( + fault === 'acquire' + ? [] + : [ + ...fields.slice( + 0, + fault ? fields.indexOf(fault) + 1 : fields.length, + ), + 'free', + ], + ) + expect(free).toHaveBeenCalledTimes(fault === 'acquire' ? 0 : 1) + } finally { + extract.mockRestore() + await f.cleanup() + } + }, +) -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/__tests__/source.test.ts b/packages/bun-plugin/src/__tests__/source.test.ts new file mode 100644 index 000000000..ef0c8a3c1 --- /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/devup-plugin.ts b/packages/bun-plugin/src/devup-plugin.ts new file mode 100644 index 000000000..95d80af90 --- /dev/null +++ b/packages/bun-plugin/src/devup-plugin.ts @@ -0,0 +1,280 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' + +import { + beginBuild, + collectNumberedFiles, + createCompatTypes, + createModuleResolver, + createThemeInterfaceArgs, + type CustomShorthands, + isMdxSource, + listSourceFiles, + loadDevupConfig, + mdxSourceFilter, + normalizeMdxExtensions, + type PreparedSource, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + selectedSourceFilter, +} from '@devup-ui/plugin-utils' +import { + 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 { createMdxOwnership } from './mdx-ownership' +import { + compiledPackages, + importAliases, + libPackage, + mentionsCompiledPackage, + runtimeSourceFilter, +} from './source' +import { loadSourceFile, type SourceProject } from './source-load' + +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 + mdxExtensions?: readonly string[] +} + +type Project = ReturnType & SourceProject + +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: project.mdxExtensions, + 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) +} + +/** + * 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 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, + 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 }, + { + integration: 'Bun', + root, + }, + ) + 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 + ? selectedSourceFilter(mdxExtensions) + : new RegExp( + `${ + runtimeSourceFilter([ + ...listSourceFiles( + root, + [ + 'target', + 'dist', + basename(project.distDir), + '.git', + 'coverage', + ], + { includeMdx: mdxExtensions }, + ).filter( + (file) => + isMdxSource(file, mdxExtensions) || + mentionsCompiledPackage(readFileSync(file, 'utf-8')), + ), + ...collectNumberedFiles({ + roots: resolveSourceDirs(root, options.sourceDirs), + includeMdx: mdxExtensions, + cwd: root, + include: ['@devup-ui/components', ...(options.include ?? [])], + needles: compiledPackages, + toId: (path) => path, + }), + ]).source + }|${mdxSourceFilter(mdxExtensions).source}|\\.(?:test|spec)\\.[mc]?[jt]sx?$`, + 'i', + ) + + // 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() + ownership.validate() + 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 (isMdxSource(path, mdxExtensions)) ownership.loaded(path) + 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/mdx-ownership.ts b/packages/bun-plugin/src/mdx-ownership.ts new file mode 100644 index 000000000..bfcf0e84d --- /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?.path !== undefined) 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 new file mode 100644 index 000000000..015f643e6 --- /dev/null +++ b/packages/bun-plugin/src/mdx.ts @@ -0,0 +1,64 @@ +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; readonly format: 'mdx' }, + ) => 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, format: 'mdx' }, + ) +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 5fc4b01c9..dec7ace58 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,198 +1,11 @@ -import { existsSync, writeFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, join, relative, resolve } from 'node:path' +import { plugin } from 'bun' -import { - createCompatTypes, - createModuleResolver, - createThemeInterfaceArgs, - type CustomShorthands, - loadDevupConfig, - mergeImportAliases, -} from '@devup-ui/plugin-utils' -import { - codeExtract, - getCss, - getThemeInterface, - registerShorthands, - registerTheme, - setDebug, - setModuleResolver, -} from '@devup-ui/wasm' -import { type BunPlugin, plugin, type PluginBuilder } from 'bun' +import { DevupUI, type DevupUIBunPluginOptions } from './devup-plugin' -import { cssDirName, cssNamespace, resolveCssId } from './css-id' - -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`. - */ - debug?: boolean -} - -type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' - -async function writeDataFiles() { - let theme = {} - try { - const config = await loadDevupConfig(devupFile) - theme = config.theme ?? {} - } catch { - // Error reading devup.json, use empty theme - } - registerTheme(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({ shorthands }: DevupUIBunPluginOptions = {}) { - registerShorthands(shorthands ?? {}) - setModuleResolver(createModuleResolver()) - 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() -} - -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, 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() - - 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 } - } - 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 - await initialize(options) - 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, - // content-keyed transpiler cache. See ./css-id. - build.onResolve( - { filter: /devup-ui(-\d+)?\.css$/ }, - ({ path, importer }) => resolveCssId(path, importer, 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: /\.(?:tsx?|jsx|mjs)$|[\\/]@devup-ui[\\/].*\.js$/, - }, - ({ 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"). +// 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 } diff --git a/packages/bun-plugin/src/source-load.ts b/packages/bun-plugin/src/source-load.ts new file mode 100644 index 000000000..3b5abb715 --- /dev/null +++ b/packages/bun-plugin/src/source-load.ts @@ -0,0 +1,84 @@ +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 output = codeExtract( + filePath, + contents, + libPackage, + relative(dirname(filePath), project.cssDir).replaceAll('\\', '/'), + true, + true, + false, + importAliases, + ...(mdx ? (['compiled-mdx'] as const) : ([] as const)), + ) + const { code, dependencies } = (() => { + try { + return { code: output.code, dependencies: output.dependencies } + } finally { + output.free() + } + })() + return { + contents: preserveDependencies(code, filePath, 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/bun-plugin/src/source.ts b/packages/bun-plugin/src/source.ts new file mode 100644 index 000000000..caf35e7a5 --- /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) +} 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 000000000..421ba1d17 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +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__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 72d812adb..ab48e0a67 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -18,6 +18,7 @@ import { flushCoordinatorWrites, resetCoordinator, startCoordinator, + takeExtractOutput, } from '../coordinator' let codeExtractSpy: ReturnType @@ -31,6 +32,69 @@ let writeFileSyncSpy: ReturnType const tmpDir = join(process.cwd(), '.tmp-coordinator-test') +it.each([ + undefined, + 'code', + 'css', + 'cssFile', + 'map', + 'updatedBaseStyle', + 'dependencies', +])('preserves modern Next snapshot cleanup when getter %s fails', (fault) => { + const events: string[] = [] + const error = new Error('getter fault') + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + const output = { + code: 'copied', + css: 'sheet', + cssFile: 'sheet.css', + map: 'map', + updatedBaseStyle: true, + dependencies: ['dep.ts'], + free, + [Symbol.dispose]: free, + } + const expected = { + code: 'copied', + css: 'sheet', + cssFile: 'sheet.css', + map: 'map', + updatedBaseStyle: true, + dependencies: ['dep.ts'], + } + const fields = [ + 'code', + 'css', + 'cssFile', + 'map', + 'updatedBaseStyle', + 'dependencies', + ] as const + for (const field of fields) { + const value = output[field] + Object.defineProperty(output, field, { + get() { + expect(live).toBe(true) + events.push(field) + if (fault === field) throw error + return value + }, + }) + } + if (fault) expect(() => takeExtractOutput(output)).toThrow(error) + else expect(takeExtractOutput(output)).toEqual(expected) + expect(events).toEqual([ + ...fields.slice(0, fault ? fields.indexOf(fault) + 1 : fields.length), + 'free', + ]) + expect(free).toHaveBeenCalledTimes(1) +}) + function makeOptions( overrides: Partial = {}, ): CoordinatorOptions { diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f7187..af08e5a64 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__/deterministic-numbering.test.ts b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts new file mode 100644 index 000000000..1234961c1 --- /dev/null +++ b/packages/next-plugin/src/__tests__/deterministic-numbering.test.ts @@ -0,0 +1,149 @@ +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'], + needles: ['@devup-ui/react'], + 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, + {}, + ) + try { + return { file, code: output.code, cssFile: output.cssFile } + } finally { + output.free() + } + } + 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 3f4df0644..6ea725bb1 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -102,6 +102,105 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it.each([ + undefined, + 'code', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + 'acquire', + 'consumer', + ])( + 'releases legacy Next Output before consumers when fault %s occurs', + async (fault) => { + const events: string[] = [] + const error = new Error('ownership fault') + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + const output = { + code: 'copied', + css: undefined, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + dependencies: ['dependency.ts'], + free, + [Symbol.dispose]: free, + } + for (const field of [ + 'code', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ] as const) { + const value = output[field] + Object.defineProperty(output, field, { + get() { + expect(live).toBe(true) + events.push(field) + if (fault === field) throw error + return value + }, + }) + } + codeExtractSpy.mockImplementation(() => { + if (fault === 'acquire') throw error + return output + }) + const result = await new Promise<{ + error: Error | null | undefined + code: string | undefined + }>((done) => { + const context = asLoaderContext({ + resourcePath: 'output.tsx', + getOptions: () => ({ + watch: false, + cssDir: 'df', + package: '@devup-ui/react', + }), + async: mock( + () => (error: Error | null | undefined, code?: string) => + done({ error, code }), + ), + addDependency: mock(() => { + expect(free).toHaveBeenCalledTimes(1) + if (fault === 'consumer') throw error + }), + }) + devupUILoader.call(context, Buffer.from('source')) + }) + expect(result).toEqual( + fault ? { error, code: undefined } : { error: null, code: 'copied' }, + ) + const fields = [ + 'code', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ] + expect(events).toEqual( + fault === 'acquire' + ? [] + : [ + ...fields.slice( + 0, + fault && fields.includes(fault) + ? fields.indexOf(fault) + 1 + : fields.length, + ), + 'free', + ], + ) + expect(free).toHaveBeenCalledTimes(fault === 'acquire' ? 0 : 1) + }, + ) // Test BUILD mode init (lines 68-73) it('should use default maps in non-watch mode on init', async () => { const asyncCallback = mock() @@ -125,15 +224,18 @@ describe('devupUILoader', () => { addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: undefined, - free: mock(), - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - dependencies: ['src/tokens.ts'], - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: undefined, + free, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + dependencies: ['src/tokens.ts'], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))( @@ -181,14 +283,18 @@ describe('devupUILoader', () => { addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: '{}', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: true, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: 'css', + free, + map: '{}', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: true, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))( @@ -243,14 +349,18 @@ describe('devupUILoader', () => { resourcePath: 'index.tsx', addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: undefined, - free: mock(), - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: undefined, + free, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') @@ -275,14 +385,18 @@ describe('devupUILoader', () => { resourcePath: 'index.tsx', addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: undefined, - free: mock(), - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: undefined, + free, + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') @@ -378,14 +492,18 @@ describe('devupUILoader', () => { addDependency: mock(), } writeFileSpy.mockRejectedValueOnce(writeError) - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: '{}', - cssFile: 'devup-ui-1.css', - updatedBaseStyle: false, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: 'css', + free, + map: '{}', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: false, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))( @@ -415,14 +533,18 @@ describe('devupUILoader', () => { resourcePath: './foo/index.tsx', addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: undefined, - cssFile: 'cssFile', - updatedBaseStyle: false, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: 'css', + free, + map: undefined, + cssFile: 'cssFile', + updatedBaseStyle: false, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))( Buffer.from('code'), @@ -449,14 +571,18 @@ describe('devupUILoader', () => { resourcePath: 'index.tsx', addDependency: mock(), } - codeExtractSpy.mockReturnValue({ - code: 'code', - css: 'css', - free: mock(), - map: '{}', - cssFile: 'cssFile', - updatedBaseStyle: true, - [Symbol.dispose]: mock(), + codeExtractSpy.mockImplementation(() => { + const free = mock() + return { + code: 'code', + css: 'css', + free, + map: '{}', + cssFile: 'cssFile', + updatedBaseStyle: true, + dependencies: [], + [Symbol.dispose]: free, + } }) devupUILoader.bind(asLoaderContext(t))(Buffer.from('code'), 'index.tsx') @@ -603,6 +729,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') @@ -856,7 +1043,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/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7b..cb2385be0 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -38,7 +38,11 @@ function createWebpackConfig(): NextWebpackConfig { function createWebpackContext( overrides: Partial = {}, ): NextWebpackContext { - return { buildId: 'tmpBuildId', ...overrides } as NextWebpackContext + return { + buildId: 'tmpBuildId', + config: {}, + ...overrides, + } as NextWebpackContext } function setNodeEnv(value: string): void { @@ -187,9 +191,12 @@ describe('DevupUINextPlugin', () => { ret.webpack!(createWebpackConfig(), createWebpackContext()) - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith( + { + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }, + expect.objectContaining({ complete: true }), + ) }) it('should apply webpack plugin with dev', async () => { @@ -197,10 +204,13 @@ describe('DevupUINextPlugin', () => { ret.webpack!(createWebpackConfig(), createWebpackContext({ dev: true })) - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - cssDir: resolve('df', 'devup-ui_tmpBuildId'), - watch: true, - }) + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith( + { + cssDir: resolve('df', 'devup-ui_tmpBuildId'), + watch: true, + }, + expect.objectContaining({ complete: true }), + ) }) it('should apply webpack plugin with config', async () => { @@ -213,10 +223,13 @@ describe('DevupUINextPlugin', () => { ret.webpack!(createWebpackConfig(), createWebpackContext()) - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith( + { + package: 'new-package', + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }, + expect.objectContaining({ complete: true }), + ) }) it('should apply webpack plugin with webpack obj', async () => { @@ -232,10 +245,13 @@ describe('DevupUINextPlugin', () => { ret.webpack!(createWebpackConfig(), createWebpackContext()) - expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith({ - package: 'new-package', - cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), - }) + expect(devupUIWebpackPluginSpy).toHaveBeenCalledWith( + { + package: 'new-package', + cssDir: resolve('.next/cache', 'devup-ui_tmpBuildId'), + }, + expect.objectContaining({ complete: true }), + ) expect(webpack).toHaveBeenCalled() }) }) @@ -415,13 +431,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 +479,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 +536,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 +576,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?$', ), }, }, @@ -784,6 +796,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/__tests__/webpack-generation.test.ts b/packages/next-plugin/src/__tests__/webpack-generation.test.ts new file mode 100644 index 000000000..abdb745eb --- /dev/null +++ b/packages/next-plugin/src/__tests__/webpack-generation.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from 'bun:test' + +import { createWebpackGenerationThread } from '../webpack-generation' + +it('shares only the live public context when related compiler configs are constructed', () => { + // Given + const thread = createWebpackGenerationThread() + const config = {} + const server = thread({ config, dev: false, isServer: true }) + // When + const client = thread({ config, dev: false, isServer: false }) + // Then + expect(client.owner).toBe(server.owner) + expect(server.complete).toBe(false) + expect(client.complete).toBe(true) +}) + +it('mints a fresh generation when Next reuses its cached config after completion', () => { + // Given + const thread = createWebpackGenerationThread() + const context = { config: {}, dev: false, isServer: false, buildId: 'fixed' } + const first = thread(context) + first.owner.acquire(first.complete)() + // When + const second = thread(context) + // Then + expect(second.owner).not.toBe(first.owner) + expect(second.owner.disposed).toBe(false) +}) + +it('keeps independent wrapper calls apart even with identical public context and fixed buildId', () => { + // Given + const context = { config: {}, dev: false, isServer: true, buildId: 'fixed' } + const first = createWebpackGenerationThread()(context) + // When + const second = createWebpackGenerationThread()(context) + // Then + expect(second.owner).not.toBe(first.owner) +}) + +it('does not pin a generation when the edge configuration remains unused', () => { + // Given + const thread = createWebpackGenerationThread() + const config = {} + thread({ config, dev: false, isServer: true }) + const client = thread({ config, dev: false, isServer: false }) + // When + client.owner.acquire(client.complete)() + // Then + expect(thread({ config, dev: false, isServer: true }).owner).not.toBe( + client.owner, + ) +}) + +it('closes dev ownership only when every actual watch participant closes', () => { + // Given + const thread = createWebpackGenerationThread() + const config = {} + const server = thread({ config, dev: true, isServer: true }) + const client = thread({ config, dev: true, isServer: false }) + const serverClose = server.owner.acquire(server.complete) + client.owner.acquire(client.complete)() + expect(server.owner.disposed).toBe(false) + // When + serverClose() + // Then + expect(thread({ config, dev: true, isServer: true }).owner).not.toBe( + server.owner, + ) +}) diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts new file mode 100644 index 000000000..1335a4c36 --- /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 17b9c7f54..892f8287d 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 type { DevupWasm } from './wasm' @@ -666,7 +667,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') } }) @@ -680,11 +681,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 1b37059e8..b74038ca3 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 e11c789b4..bc949934c 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,10 +3,21 @@ 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 { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} 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 @@ -44,7 +55,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 } @@ -153,7 +164,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 { @@ -174,7 +185,17 @@ const devupUILoader: RawLoaderDefinitionFunction = port, body, (err, content, sourceMap, dependencies = []) => { - 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) + } for (const dependency of dependencies) { this.addDependency(resolve(dependency)) } @@ -243,13 +264,7 @@ const devupUILoader: RawLoaderDefinitionFunction = const relativePath = relative(process.cwd(), id).replaceAll('\\', '/') if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - const { - code, - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( + const output = codeExtract( relativePath, source.toString(), libPackage, @@ -259,6 +274,25 @@ const devupUILoader: RawLoaderDefinitionFunction = true, importAliases, ) + const { + code, + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return { + code: output.code, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + output.free() + } + })() for (const dependency of dependencies) { this.addDependency(resolve(dependency)) } @@ -266,19 +300,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 7905cc7d0..6998b4f33 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -11,15 +11,18 @@ import { deserialize, serialize } from 'node:v8' import { buildCanonicalMap, buildStaticImportGraph, + collectNumberedFiles, computeCompiledFiles, computeFileRoutes, createCompatTypes, createNodeModulesExcludeRegex, createThemeInterfaceArgs, type DevupUIBasePluginOptions, + extractedNeedles, loadDevupConfigSync, mergeImportAliases, planAtomHoist, + seedFileNumbers, type StaticImportGraph, } from '@devup-ui/plugin-utils' import { type NextConfig } from 'next' @@ -29,9 +32,14 @@ import { startCoordinator, takeExtractOutput, } from './coordinator' +import { + removeStalePortFile, + resolveCoordinatorPortFile, +} from './coordinator-port' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' import { loadWasm, loadWebpackPlugin } from './wasm' +import { createWebpackGenerationThread } from './webpack-generation' /** Options accepted by the Next.js integration. */ export type DevupUINextPluginOptions = Partial & { @@ -229,9 +237,7 @@ export function DevupUI( registerShorthands(shorthands ?? {}) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) writeFileSync( join(distDir, 'compat.d.ts'), @@ -246,7 +252,7 @@ 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 } const devupConfig = loadDevupConfigSync(devupFile) @@ -264,7 +270,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 @@ -358,6 +364,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 @@ -462,11 +486,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, @@ -621,6 +643,7 @@ export function DevupUI( } const { webpack } = config + const generation = createWebpackGenerationThread() config.webpack = (config, _options) => { const { DevupUIWebpackPlugin } = loadWebpackPlugin() options.cssDir ??= resolve( @@ -628,10 +651,13 @@ export function DevupUI( `devup-ui_${_options.buildId}`, ) config.plugins.push( - new DevupUIWebpackPlugin({ - ...options, - watch: _options.dev, - }), + new DevupUIWebpackPlugin( + { + ...options, + watch: _options.dev, + }, + generation(_options), + ), ) if (typeof webpack === 'function') return webpack(config, _options) return config diff --git a/packages/next-plugin/src/webpack-generation.ts b/packages/next-plugin/src/webpack-generation.ts new file mode 100644 index 000000000..7471f194a --- /dev/null +++ b/packages/next-plugin/src/webpack-generation.ts @@ -0,0 +1,24 @@ +import type { WebpackGenerationBinding } from '@devup-ui/webpack-plugin' + +import { loadWebpackPlugin } from './wasm' + +/** + * @internal Next16.3.6 passes one public context.config through the three + * configurations (webpack-build/impl.js146-197; hot-reloader-webpack.js556-595). + * loadConfig caches that identity, so only a LIVE owner may be reused. + */ +export function createWebpackGenerationThread() { + const generations = new WeakMap() + return (context: { + readonly config: object + readonly dev: boolean + readonly isServer: boolean + }): WebpackGenerationBinding => { + let owner = generations.get(context.config) + if (!owner || owner.disposed) { + owner = loadWebpackPlugin().createWebpackGeneration() + generations.set(context.config, owner) + } + return { owner, complete: context.dev || !context.isServer } + } +} diff --git a/packages/plugin-utils/README.md b/packages/plugin-utils/README.md new file mode 100644 index 000000000..41f74ba01 --- /dev/null +++ b/packages/plugin-utils/README.md @@ -0,0 +1,262 @@ +# @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, 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. + +## 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, + sourceType: 'compiled-mdx', + } + }, +}) +``` + +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 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. **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 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 + +`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. +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 +`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 +`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. + +- `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. +- 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 rewriting candidates produce an + importer-located error naming the key and every rewritten candidate tried. + 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. +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/plugin-utils/package.json b/packages/plugin-utils/package.json index a83918294..9a4be5264 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -18,7 +18,7 @@ "version": "1.0.17", "scripts": { "lint": "eslint", - "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" + "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/build-admission.cts --production --env=disable --outfile dist/build-admission.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" }, "publishConfig": { "access": "public" @@ -27,6 +27,11 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { + "./internal/build-admission": { + "types": "./dist/build-admission.d.cts", + "bun": "./src/build-admission.cts", + "default": "./dist/build-admission.cjs" + }, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", @@ -34,7 +39,8 @@ } }, "files": [ - "dist" + "dist", + "src/build-admission.cts" ], "devDependencies": { "typescript": "^7.0.2" diff --git a/packages/plugin-utils/src/__tests__/admission-publication.test.ts b/packages/plugin-utils/src/__tests__/admission-publication.test.ts new file mode 100644 index 000000000..f9721e314 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/admission-publication.test.ts @@ -0,0 +1,36 @@ +import { spawnSync } from 'node:child_process' + +import { expect, it } from 'bun:test' + +it('uses one fail-closed authority when native Node loads actual published CJS and ESM entries', () => { + const result = spawnSync( + 'node', + [ + '--input-type=module', + '--eval', + ` + import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const required=createRequire(${JSON.stringify(import.meta.url)})('@devup-ui/plugin-utils'); + const imported=await import(${JSON.stringify(new URL('../../dist/index.mjs', import.meta.url).href)}); + assert.equal(required.MixedBuildIntegrationError,imported.MixedBuildIntegrationError); + const legacy={integration:'Vite',root:'legacy-root'}; + const owner={integration:'Webpack',root:'owner-root'}; + for(const [begin,run] of [[required,imported],[imported,required]]) { + const end=begin.beginBuild({},legacy); + try {assert.throws(()=>run.runBuildOperation(owner,()=>assert.fail('admitted')),required.MixedBuildIntegrationError)} + finally {end();end()} + let resets=0; + run.runBuildOperation(owner,()=>assert.throws(()=>begin.beginBuild({resetBuildState(){resets++}},legacy),required.MixedBuildIntegrationError)); + assert.equal(resets,0); + begin.beginBuild({resetBuildState(){resets++}},legacy)(); + assert.equal(resets,1); + } + `, + ], + { encoding: 'utf8' }, + ) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stderr).toBe('') +}) 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 000000000..d12585d05 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array-fixture.ts @@ -0,0 +1,50 @@ +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 & { + readonly mainFields?: readonly string[] + }, + ): string | false => { + const resolver = enhancedResolve.ResolverFactory.createResolver({ + fileSystem: fs, + useSyncFileSystemCalls: true, + 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-array-package.test.ts b/packages/plugin-utils/src/__tests__/alias-array-package.test.ts new file mode 100644 index 000000000..6450aa73c --- /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 000000000..2a70a7f9d --- /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 000000000..772f8f7b9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/alias-array.test.ts @@ -0,0 +1,129 @@ +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('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 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', () => { + 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__/alias-descriptors.test.ts b/packages/plugin-utils/src/__tests__/alias-descriptors.test.ts new file mode 100644 index 000000000..3e45d3f25 --- /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/__tests__/alias-directory.test.ts b/packages/plugin-utils/src/__tests__/alias-directory.test.ts new file mode 100644 index 000000000..90cedd7d8 --- /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/__tests__/build-admission.test.ts b/packages/plugin-utils/src/__tests__/build-admission.test.ts new file mode 100644 index 000000000..aa5229573 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/build-admission.test.ts @@ -0,0 +1,115 @@ +import { createRequire } from 'node:module' + +import { expect, it } from 'bun:test' + +import admission from '../build-admission.cts' +import { BuildGeneration } from '../build-generation' + +const owner = { integration: 'Webpack', root: 'owner-root' } +const legacy = { integration: 'Bun', root: 'legacy-root' } + +it.each(['reset', 'restore', 'configure', 'action', 'capture'] as const)( + 'releases operation admission when owner %s throws', + (stage) => { + const generation = new BuildGeneration() + const cause = new TypeError(stage) + let enabled = false + const fault = (current: string) => { + if (enabled && current === stage) throw cause + } + const engine = { + reset() { + fault('reset') + }, + restore() { + fault('restore') + }, + capture() { + fault('capture') + return 'sheet' + }, + } + if (stage === 'restore') generation.run(engine, () => undefined) + enabled = true + expect(() => + admission.runBuildOperation(owner, () => + generation.run(engine, () => { + fault('configure') + fault('action') + }), + ), + ).toThrow(cause) + let resets = 0 + const end = admission.beginBuild( + { + resetBuildState() { + resets += 1 + }, + }, + legacy, + ) + end() + expect(resets).toBe(1) + }, +) + +it('restores the enclosing owner when a nested operation releases', () => { + const inner = { integration: 'NextWebpack', root: 'inner-root' } + let active + admission.runBuildOperation(owner, () => { + admission.runBuildOperation(inner, () => undefined) + try { + admission.beginBuild({}, legacy) + } catch (error) { + if (!(error instanceof admission.MixedBuildIntegrationError)) throw error + active = error.active + } + }) + expect(active).toEqual(owner) +}) + +it('keeps the legacy overlap ABI when the last idempotent release ends', () => { + let resets = 0 + const engine = { + resetBuildState() { + resets += 1 + }, + } + const first = admission.beginBuild(engine, legacy) + const second = admission.beginBuild(engine, { + integration: 'Vite', + root: 'vite-root', + }) + first() + first() + expect(() => admission.runBuildOperation(owner, () => undefined)).toThrow( + admission.MixedBuildIntegrationError, + ) + second() + second() + admission.beginBuild(engine, legacy)() + expect(resets).toBe(2) +}) + +it('shares canonical authority when source, published require and ESM are loaded by Bun', async () => { + const require = createRequire(import.meta.url) + const required: typeof import('../index') = require('@devup-ui/plugin-utils') + const imported = await import('../../dist/index.mjs') + expect(required.MixedBuildIntegrationError).toBe( + admission.MixedBuildIntegrationError, + ) + expect(imported.MixedBuildIntegrationError).toBe( + admission.MixedBuildIntegrationError, + ) + const end = imported.beginBuild({}, legacy) + try { + expect(() => required.runBuildOperation(owner, () => undefined)).toThrow( + admission.MixedBuildIntegrationError, + ) + expect(() => admission.runBuildOperation(owner, () => undefined)).toThrow( + admission.MixedBuildIntegrationError, + ) + } finally { + end() + } +}) diff --git a/packages/plugin-utils/src/__tests__/build-generation.test.ts b/packages/plugin-utils/src/__tests__/build-generation.test.ts new file mode 100644 index 000000000..6f7e9535e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/build-generation.test.ts @@ -0,0 +1,146 @@ +import { expect, it } from 'bun:test' + +import { + BuildGeneration, + ClosedBuildGenerationError, +} from '../build-generation' + +function memoryEngine() { + let value = '' + return { + reset() { + value = '' + }, + restore(state: string) { + value = state + }, + capture() { + return value + }, + append(part: string) { + value += part + }, + } +} + +it('roundtrips the in-memory engine when reset and restore are used', () => { + // Given + const engine = memoryEngine() + engine.append('red') + const state = engine.capture() + // When + engine.reset() + engine.restore(state) + // Then + expect(engine.capture()).toBe('red') +}) + +it('retains a contribution when related sequential participants run', () => { + // Given + const owner = new BuildGeneration() + const engine = memoryEngine() + owner.run(engine, () => engine.append('red')) + owner.acquire(false)() + // When + const cssOnlyConsumer = owner.run(engine, () => engine.capture()) + // Then + expect(cssOnlyConsumer).toBe('red') +}) + +it('isolates live siblings when their operations interleave', () => { + // Given + const first = new BuildGeneration() + const second = new BuildGeneration() + const engine = memoryEngine() + first.run(engine, () => engine.append('red')) + second.run(engine, () => engine.append('blue')) + // When + const resumed = first.run(engine, () => engine.capture()) + // Then + expect(resumed).toBe('red') +}) + +it('starts empty when an independent owner uses the same engine', () => { + // Given + const engine = memoryEngine() + new BuildGeneration().run(engine, () => engine.append('red')) + // When + const fresh = new BuildGeneration().run(engine, () => + engine.capture(), + ) + // Then + expect(fresh).toBe('') +}) + +it('ignores unused configurations when the actual final participant closes', () => { + // Given + const owner = new BuildGeneration() + const serverClose = owner.acquire(false) + const clientClose = owner.acquire(true) + serverClose() + // When + clientClose() + clientClose() + // Then + expect(owner.disposed).toBe(true) +}) + +it('waits for the last actual watch participant when the client closes first', () => { + // Given + const owner = new BuildGeneration() + const serverClose = owner.acquire(true) + owner.acquire(true)() + expect(owner.disposed).toBe(false) + // When + serverClose() + // Then + expect(owner.disposed).toBe(true) +}) + +it('remembers terminal completion when another applied participant closes later', () => { + // Given + const owner = new BuildGeneration() + const serverClose = owner.acquire(false) + owner.acquire(true)() + expect(owner.disposed).toBe(false) + // When + serverClose() + // Then + expect(owner.disposed).toBe(true) +}) + +it('retains allocated state when an extraction fails before sheet publication', () => { + // Given + const owner = new BuildGeneration() + const engine = memoryEngine() + const cause = new TypeError('extraction fault') + // When + expect(() => + owner.run(engine, () => { + engine.append('allocation') + throw cause + }), + ).toThrow(cause) + // Then + expect(owner.run(engine, () => engine.capture())).toBe('allocation') +}) + +it('clears rather than republishes a handoff when setup aborts inside an operation', () => { + // Given + const owner = new BuildGeneration() + const engine = memoryEngine() + // When + owner.run(engine, () => owner.dispose()) + // Then + expect(() => owner.run(engine, () => engine.capture())).toThrow( + ClosedBuildGenerationError, + ) +}) + +it('rejects a new participant when an integration has disposed', () => { + // Given + const owner = new BuildGeneration() + owner.dispose() + // When / Then + expect(() => owner.acquire(false)).toThrow(ClosedBuildGenerationError) +}) 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 000000000..822453a72 --- /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/__tests__/config-errors.test.ts b/packages/plugin-utils/src/__tests__/config-errors.test.ts new file mode 100644 index 000000000..622aa2c1c --- /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__/coverage-diagnostics-native.test.ts b/packages/plugin-utils/src/__tests__/coverage-diagnostics-native.test.ts new file mode 100644 index 000000000..3cb3cd15b --- /dev/null +++ b/packages/plugin-utils/src/__tests__/coverage-diagnostics-native.test.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeAll, describe, expect, it } from 'bun:test' + +import { auditCoverage } from '../../../../test-harness/coverage' +import { + CoverageError, + parseLcov, + type SourceCoverage, +} from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { reconstructNative } from '../../../../test-harness/producer-native' +import { captureProcess } from '../../../../test-harness/producer-process' + +describe('genuine native coverage diagnostics', () => { + let sources: readonly SourceCoverage[] = [] + let expected: readonly object[] = [] + + beforeAll(async () => { + // Given: two partially exercised modules and an independently complete control. + const root = mkdtempSync(join(tmpdir(), 'devup-coverage-diagnostics-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'first.ts'), + 'export function used() { return 1 }\nexport function missed() {\n return 2\n}\n', + ) + writeFileSync( + join(root, 'second.ts'), + 'export function used() { return 3 }\nexport function missed() {\n return 4\n}\nexport function alsoMissed() {\n return 5\n}\n', + ) + writeFileSync( + join(root, 'complete.ts'), + 'export function used() { return 6 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {used as first} from './first';import {used as second} from './second';import {used as complete} from './complete';it('actual',()=>{expect(first()).toBe(1);expect(second()).toBe(3);expect(complete()).toBe(6)})", + ) + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + expect(result.value.status).toBe(0) + const official = parseLcov(result.value.lcov) + sources = VerifiedSourceCoverage.bind(result, root).filter((source) => + ['first.ts', 'second.ts', 'complete.ts'].includes(source.file), + ) + expect(sources.map((source) => source.file).sort()).toEqual([ + 'complete.ts', + 'first.ts', + 'second.ts', + ]) + expected = ['first.ts', 'second.ts'].map((file) => { + const source = official.find((item) => item.file === file) + const capture = result.value.captures.find( + (item) => item.url === join(root, file), + ) + const bound = sources.find((item) => item.file === file) + if (!source || !capture || !bound) + throw new CoverageError('missing actual diagnostics fixture') + const native = reconstructNative(capture, source) + const zeroHitLines = [...source.lines] + .filter(([, hits]) => hits === 0) + .map(([line]) => line) + .sort((a, b) => a - b) + expect(zeroHitLines.length).toBeGreaterThan(0) + expect(source.hitFunctions).toBeLessThan(source.foundFunctions) + expect(native.functions.length).toBe(source.foundFunctions) + expect(native.functions.filter((fn) => fn.hasExecuted).length).toBe( + source.hitFunctions, + ) + expect([...native.lines].sort(([a], [b]) => a - b)).toEqual( + [...source.lines].sort(([a], [b]) => a - b), + ) + expect([...bound.lines]).toEqual([...source.lines]) + expect([bound.hitFunctions, bound.foundFunctions]).toEqual([ + source.hitFunctions, + source.foundFunctions, + ]) + expect(bound.functions.size).toBe(0) + return { + file, + zeroHitLines, + FNH: source.hitFunctions, + FNF: source.foundFunctions, + LH: [...source.lines.values()].filter((hits) => hits > 0).length, + LF: source.lines.size, + functionNames: 'unavailable from verified producer', + } + }) + console.info( + JSON.stringify({ + bun: result.value.bun, + revision: result.value.revision, + pid: result.value.pid, + official: result.value.lcov, + captures: result.value.captures.filter((capture) => + ['first.ts', 'second.ts', 'complete.ts'].some( + (file) => capture.url === join(root, file), + ), + ), + expected, + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 120000) + + it('reports every partial source with exact zero rows and native counts', () => { + // When: audit the real issued inventory, including the complete control. + let failure: CoverageError | undefined + try { + auditCoverage(sources, ['first.ts', 'second.ts', 'complete.ts']) + } catch (error) { + if (!(error instanceof CoverageError)) throw error + failure = error + } + // Then: one structured detail contains precisely the two failing records. + if (!failure) throw new CoverageError('expected partial native failure') + const prefix = 'below 100% lines/functions: ' + expect(failure.detail.startsWith(prefix)).toBe(true) + const detail: unknown = JSON.parse(failure.detail.slice(prefix.length)) + expect(detail).toEqual(expected) + }) + + it('accepts the genuinely complete source selection', () => { + const complete = sources.filter((source) => source.file === 'complete.ts') + expect(() => auditCoverage(complete, ['complete.ts'])).not.toThrow() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/coverage-merge.test.ts b/packages/plugin-utils/src/__tests__/coverage-merge.test.ts new file mode 100644 index 000000000..06025b9c8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/coverage-merge.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from 'bun:test' + +import { + auditCoverage, + coverageTable, + mergeCoverage, + writeLcov, +} from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' + +const anonymous = (hitFunctions: number, hitLine: number) => + parseLcov( + `SF:a.ts\nFNF:2\nFNH:${hitFunctions}\nDA:1,${hitLine}\nLF:1\nLH:${hitLine > 0 ? 1 : 0}\nend_of_record`, + ) +const named = (first: number, second: number) => + parseLcov( + `SF:a.ts\nFN:1,first\nFN:2,second\nFNDA:${first},first\nFNDA:${second},second\nFNF:2\nFNH:${Number(first > 0) + Number(second > 0)}\nDA:1,${first}\nDA:2,${second}\nLF:2\nLH:${Number(first > 0) + Number(second > 0)}\nBRDA:1,0,0,${first}\nBRF:1\nBRH:${Number(first > 0)}\nend_of_record`, + ) + +it('refuses named source identities without producer binding and preserves their observation serialization', () => { + const reports = [named(1, 0), named(0, 1)] as const + expect(() => mergeCoverage(reports)).toThrow(CoverageError) + expect(parseLcov(writeLcov(reports[0]))).toEqual(reports[0]) + expect(reports[0]?.[0]?.functions.get('second')?.hits).toBe(0) +}) + +it('refuses invented function unions when Bun has only partial totals', () => { + expect(() => mergeCoverage([anonymous(1, 0), anonymous(1, 1)])).toThrow( + CoverageError, + ) + expect(coverageTable(anonymous(1, 1))).toContain('a.ts | 50.00 | 100.00') +}) + +it('refuses summary-only completeness when another process covers an uncovered line', () => { + expect(() => + auditCoverage(mergeCoverage([anonymous(2, 0), anonymous(1, 1)]), ['a.ts']), + ).toThrow(CoverageError) +}) + +it('rejects the one uncovered source when other files are completely covered', () => { + const complete = anonymous(2, 1) + const partial = parseLcov( + writeLcov(anonymous(2, 0)).replace('SF:a.ts', 'SF:b.ts'), + ) + const merged = [...complete, ...partial] + expect(() => auditCoverage(merged, ['a.ts', 'b.ts'])).toThrow(CoverageError) + expect(coverageTable(merged)).toContain('b.ts | 100.00 | 0.00 | 1') +}) + +it.each([ + () => auditCoverage([], []), + () => auditCoverage(anonymous(2, 1), ['missing.ts']), + () => mergeCoverage([[...anonymous(2, 1), ...anonymous(2, 1)]]), + () => mergeCoverage([named(1, 0), anonymous(1, 1)]), + () => + auditCoverage( + mergeCoverage([ + anonymous(2, 1), + parseLcov(writeLcov(anonymous(1, 1)).replace('FNF:2', 'FNF:3')), + ]), + ['a.ts'], + ), + () => + mergeCoverage([ + named(1, 0), + parseLcov(writeLcov(named(0, 1)).replace('FN:2,second', 'FN:3,second')), + ]), + () => + mergeCoverage([ + named(1, 0), + parseLcov(writeLcov(named(0, 1)).replaceAll('second', 'third')), + ]), +])('fails closed when inventory or identity evidence is invalid', (action) => { + expect(action).toThrow(CoverageError) +}) + +it('reports vacuous line and function coverage when a loaded module has no executable definitions', () => { + const sources = parseLcov( + 'SF:empty.ts\nFNF:0\nFNH:0\nLF:0\nLH:0\nend_of_record', + ) + expect(coverageTable(sources)).toContain('empty.ts | 100.00 | 100.00') +}) + +it('refuses coarse-span deletion when import-only identity has no actual producer proof', () => { + const executed = anonymous(2, 1) + const imported = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:0\nDA:1,0\nDA:2,0\nLF:2\nLH:0\nend_of_record', + ) + expect(() => mergeCoverage([executed, imported])).toThrow(CoverageError) + expect(imported[0]?.lines.has(2)).toBe(true) +}) + +it('retains a genuinely uncovered executable line when all processes report it', () => { + const report = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:2\nDA:1,1\nDA:2,0\nLF:2\nLH:1\nend_of_record', + ) + expect(() => + auditCoverage(mergeCoverage([report, report]), ['a.ts']), + ).toThrow(CoverageError) +}) diff --git a/packages/plugin-utils/src/__tests__/coverage-parser.test.ts b/packages/plugin-utils/src/__tests__/coverage-parser.test.ts new file mode 100644 index 000000000..1e31816ac --- /dev/null +++ b/packages/plugin-utils/src/__tests__/coverage-parser.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from 'bun:test' + +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' + +const complete = + 'TN:\nSF:src\\a.ts\nFN:2,first\nFNDA:1,first\nFNF:1\nFNH:1\nDA:2,1\nLF:1\nLH:1\nBRDA:2,0,0,-\nBRDA:2,0,1,1\nBRF:2\nBRH:1\nend_of_record\n' + +it('retains source, function and branch identities when LCOV is parsed', () => { + const [record] = parseLcov(complete) + expect(record?.file).toBe('src/a.ts') + expect(record?.functions.get('first')).toEqual({ line: 2, hits: 1 }) + expect([...(record?.branches ?? [])]).toEqual([ + ['2,0,0', 0], + ['2,0,1', 1], + ]) +}) + +it('accepts Bun function totals when no FN identities are emitted', () => { + const [record] = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:1\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + expect(record?.foundFunctions).toBe(2) + expect(record?.hitFunctions).toBe(1) +}) + +it.each([ + '', + 'TN:', + 'DA:1,1', + 'SF:a.ts', + 'SF:a.ts\nSF:b.ts', + complete.replace('FNF:1', 'FNF:-1'), + complete.replace('FNF:1', 'FNF:9007199254740992'), + complete.replace('FNF:1', 'FNF:1\nFNF:1'), + complete.replace('FNH:1', 'FNH:2'), + complete.replace('LF:1', 'LF:2'), + complete.replace('LH:1', 'LH:0'), + complete.replace('FN:2,first\n', ''), + complete.replace('FN:2,first', 'FN:2,first\nFN:3,first'), + complete.replace('FN:2,first', 'FN:2,'), + complete.replace('DA:2,1', 'DA:2,1,checksum'), + complete.replace('DA:2,1', 'DA:2,1\nDA:2,1'), + complete.replace('BRDA:2,0,0,-', 'BRDA:2,0,0,-\nBRDA:2,0,0,1'), + complete.replace('BRF:2', 'BRF:1'), + complete.replace('BRH:1', 'BRH:2'), + complete.replace('FNDA:1,first', 'FNDA:0,first'), + complete.replace('FNF:1', 'FNF:2'), + complete.replace('FNF:1\n', ''), + complete.replace('end_of_record', 'unknown:1'), +])('rejects malformed coverage when the report is %s', (report) => { + expect(() => parseLcov(report)).toThrow(CoverageError) +}) diff --git a/packages/plugin-utils/src/__tests__/coverage-publication.test.ts b/packages/plugin-utils/src/__tests__/coverage-publication.test.ts new file mode 100644 index 000000000..86c182e50 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/coverage-publication.test.ts @@ -0,0 +1,55 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { CoverageError } from '../../../../test-harness/lcov' +import { runTestGroups } from '../../../../test-harness/run' + +it('leaves no accepted artifact when actual loader outputs differ despite identical raw source', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-coverage-publication-')) + const output = spyOn(console, 'info').mockImplementation(() => undefined) + const config = join(root, 'bunfig.toml') + const accepted = join(root, 'coverage/lcov.info') + try { + mkdirSync(join(root, 'coverage')) + writeFileSync(accepted, 'stale previously accepted report') + writeFileSync( + config, + '[test]\npreload=["./transform.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 1 }\n', + ) + writeFileSync( + join(root, 'transform.ts'), + "import {plugin} from 'bun';await plugin({name:'actual-input',setup(build){build.onLoad({filter:/source\\.ts$/},()=>({contents:`export function value() { return ${process.env.DEVUP_TEST_GROUP==='runtime'?2:1} }\\n`,loader:'ts'}))}})", + ) + for (const name of ['a', 'b']) { + writeFileSync( + join(root, `${name}.test.ts`), + `import {it,expect} from 'bun:test';import {value} from './source';it('calls actual source',()=>expect(value()).toBe(${name === 'a' ? 1 : 2}))`, + ) + } + await expect( + runTestGroups({ + root, + config, + groups: { isolated: ['a.test.ts'], runtime: ['b.test.ts'] }, + required: ['source.ts'], + }), + ).rejects.toThrow(CoverageError) + expect(existsSync(accepted)).toBe(false) + } finally { + output.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/coverage-universe.test.ts b/packages/plugin-utils/src/__tests__/coverage-universe.test.ts new file mode 100644 index 000000000..f0df7bcb3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/coverage-universe.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'bun:test' + +import { auditCoverage, mergeCoverage } from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' + +const oracleA = + 'SF:a.ts\nFNF:2\nFNH:2\nDA:1,1\nDA:2,0\nLF:2\nLH:1\nend_of_record' +const oracleB = + 'SF:a.ts\nFNF:2\nFNH:1\nDA:1,1\nDA:3,0\nLF:2\nLH:1\nend_of_record' + +it.each([ + [oracleA, oracleB], + [oracleB, oracleA], +])( + 'refuses false100 when anonymous observations have divergent zero-hit universes', + (first, second) => { + const reports = [parseLcov(first), parseLcov(second)] + expect(() => auditCoverage(mergeCoverage(reports), ['a.ts'])).toThrow( + CoverageError, + ) + }, +) + +it('refuses an invented function union when identical DA keys hide incompatible anonymous inventories', () => { + const first = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:2\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + const second = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:1\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + expect(() => auditCoverage(mergeCoverage([first, second]), ['a.ts'])).toThrow( + CoverageError, + ) +}) + +it('refuses unknown union cardinality when both anonymous observations claim complete coverage', () => { + const report = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:2\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + expect(() => mergeCoverage([report, report])).toThrow(CoverageError) +}) 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 000000000..604a38c3d --- /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 new file mode 100644 index 000000000..329095f72 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-exclude.test.ts @@ -0,0 +1,83 @@ +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, { + includeMdx: true, + 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'], { includeMdx: true }), + ).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 000000000..ed9c8ac49 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/graph-modern.test.ts @@ -0,0 +1,416 @@ +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.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), + ).toThrow(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', + [ + '# 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, + 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([ + '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__/ignored-alias.test.ts b/packages/plugin-utils/src/__tests__/ignored-alias.test.ts new file mode 100644 index 000000000..69ab7e858 --- /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/__tests__/import-mask.test.ts b/packages/plugin-utils/src/__tests__/import-mask.test.ts new file mode 100644 index 000000000..a74082d7a --- /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__/import-scanner-boundary.test.ts b/packages/plugin-utils/src/__tests__/import-scanner-boundary.test.ts new file mode 100644 index 000000000..99ffec472 --- /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 000000000..6d68cd7bd --- /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 000000000..e78ae7910 --- /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 000000000..dab92e615 --- /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 000000000..34be6b3ed --- /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 000000000..504bf497f --- /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 000000000..2a16b4d48 --- /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__/index.test.ts b/packages/plugin-utils/src/__tests__/index.test.ts new file mode 100644 index 000000000..b3995db30 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/index.test.ts @@ -0,0 +1,136 @@ +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 * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { enumerateProductionSourceFiles } from '../production-source-files' + +it('exposes the existing physical and ID-policy boundaries without persisting numeric state', async () => { + // Given actual physical source and public additive consumer exports. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-v1-public-')), + ) + const path = join(root, 'plain.ts') + writeFileSync(path, 'export const value=1') + try { + const api = await import('../index') + // When consumers enumerate and apply the unchanged source-only difference policy. + const files = api.enumerateProductionSourceFiles({ roots: [root] }) + const identity = api.normalizeNonphysicalModuleId( + { namespace: null, id: 'virtual:actual.js' }, + { namespace: null, specifier: 'request' }, + undefined, + ) + const ids = api.nextNonphysicalIdList([identity.id, path], [path]) + // Then provenance and native identity survive while only the scan miss remains. + expect(files).toEqual([{ path, realPath: path }]) + expect(identity).toEqual({ namespace: null, id: 'virtual:actual.js' }) + expect(ids).toEqual(['virtual:actual.js']) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('round-trips dormant ID-only storage through the public entrypoint', async () => { + // Given the public factory and a unique physical output root. + const root = mkdtempSync(join(tmpdir(), 'devup-n1-public-')) + const { createNonphysicalIdStore } = await import('../index') + const scope = { + integration: 'public', + resolvedRoot: root, + contextKey: 'build', + } + try { + const store = createNonphysicalIdStore(scope) + // When writing actual extracted IDs minus scan reservations. + await store.rewrite(['virtual:current', 'physical:miss', 'scan'], ['scan']) + // Then an equivalent public owner reloads only the current scan-missed IDs. + expect(await createNonphysicalIdStore(scope).read()).toEqual({ + kind: 'loaded', + ids: ['physical:miss', 'virtual:current'], + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('exposes dormant closure when the package entrypoint is used', async () => { + // Given physical source and the package's public exports. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-p2-index-')), + ) + const path = join(root, 'value.js') + writeFileSync(path, 'export {}') + try { + // When public path resolution and manifest collection use the actual file. + const { collectProductionFileManifest, createModulePathResolver } = + await import('../index') + const resolved = createModulePathResolver({ cwd: root })( + './value.js', + join(root, 'entry.js'), + ) + const manifest = await collectProductionFileManifest({ + contexts: [ + { + key: 'public', + files: enumerateProductionSourceFiles({ roots: [root] }), + resolverOptions: { cwd: root }, + toId: (filename) => filename, + }, + ], + }) + // Then exports return physical extraction membership without allocating anything. + expect(resolved).toEqual({ path, request: './value.js' }) + expect(manifest).toEqual([ + { context: 'public', path, realPath: path, id: path }, + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('exposes dormant physical reservation when the public entrypoint is used', async () => { + // Given public exports and an independently specified physical plan. + const { BuildGeneration, ProductionNumbering, ProductionNumberingError } = + await import('../index') + const file = { + context: 'public', + id: 'original', + path: '/file', + realPath: '/file', + } + const numbering = new ProductionNumbering(new BuildGeneration(), [ + { + context: 'public', + files: [file], + canonical: { original: 'bucket' }, + nonphysical: [], + }, + ]) + const input = { + context: 'public', + id: 'original', + source: { kind: 'physical' as const, path: '/file', realPath: '/file' }, + location: { filename: '/file' }, + } + // When the public primitive seeds its direct engine and admits a physical source. + const debug = wasm.isDebug() + try { + wasm.resetBuildState() + numbering.seed(wasm, 'public') + numbering.assertReserved(input) + // Then real allocation through the public contract reserves originals/buckets and exposes the typed miss. + expect(wasm.exportFileMap()).toBe('{"bucket":0,"original":1}') + expect(() => numbering.assertReserved({ ...input, id: 'missing' })).toThrow( + ProductionNumberingError, + ) + } finally { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.setDebug(debug) + } +}) 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 000000000..1cbca0235 --- /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__/jsx-boundary-fixtures.ts b/packages/plugin-utils/src/__tests__/jsx-boundary-fixtures.ts new file mode 100644 index 000000000..c9b5ccfc3 --- /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 000000000..0848bde9b --- /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__/load-config.test.ts b/packages/plugin-utils/src/__tests__/load-config.test.ts index bc3510ba7..8f5c1fdf0 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/__tests__/mdx-errors.test.ts b/packages/plugin-utils/src/__tests__/mdx-errors.test.ts new file mode 100644 index 000000000..3ba25cb46 --- /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/__tests__/module-alias.test.ts b/packages/plugin-utils/src/__tests__/module-alias.test.ts new file mode 100644 index 000000000..7c7a50d24 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/module-alias.test.ts @@ -0,0 +1,223 @@ +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), + ).toThrow( + `${entry}:1:1: Module alias original cannot resolve candidates ${JSON.stringify([alias.original])}`, + ) +}) + +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__/native-casefold.test.ts b/packages/plugin-utils/src/__tests__/native-casefold.test.ts new file mode 100644 index 000000000..cd7198535 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/native-casefold.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { discoverTests } from '../../../../test-harness/groups' + +it('preserves original paths when native discovery casefolds suffixes, extensions and directory boundaries', () => { + // Given: independently named upper/mixed witnesses for every native suffix and extension. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-native-casefold-')), + ) + const receipt = join(root, 'executed.txt') + const extensions = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts'] + const suffixes = ['.test', '_test', '.spec', '_spec'] + const expected = [ + ...extensions.flatMap((extension) => + suffixes.flatMap((suffix) => [ + `packages/UPPER/case${suffix.toUpperCase()}.${extension.toUpperCase()}`, + `packages/Mixed/case${suffix.replace('t', 'T').replace('s', 'S')}.${extension[0]?.toUpperCase()}${extension.slice(1)}`, + ]), + ), + 'packages/x_TEST.ts', + 'packages/Dist/generated.SpEc.Ts', + 'packages/Pkg/generated_TeSt.Js', + 'packages/Node_Modules-extra/boundary_TEST.ts', + ].sort() + const ignored = [ + 'packages/Node_Modules/dependency_TEST.TS', + 'packages/NODE_MODULES/dependency_SPEC.JS', + 'packages/Mixed/Node_Modules/nested.TeSt.Ts', + 'packages/.Hidden/hidden_TEST.TS', + 'packages/Mixed/not_TEST.TSXx', + 'packages/Mixed/not_SpEc.CTsX', + 'packages/Mixed/not_TEST_extra.TS', + ] + try { + writeFileSync(join(root, 'bunfig.toml'), '[test]\nroot="packages"\n') + for (const file of [...expected, ...ignored]) { + const path = join(root, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + `import {it,expect} from 'bun:test';import {appendFileSync} from 'node:fs';it(${JSON.stringify(file)},()=>{appendFileSync(${JSON.stringify(receipt)},${JSON.stringify(`${file}\n`)});expect(1).toBe(1)})`, + ) + } + // When: native automatic discovery executes the corpus without file arguments. + const native = spawnSync( + process.execPath, + [`--config=${join(root, 'bunfig.toml')}`, 'test'], + { cwd: root, encoding: 'utf8' }, + ) + if (native.error) throw native.error + const executed = readFileSync(receipt, 'utf8').trim().split(/\r?\n/).sort() + console.info( + JSON.stringify({ + case: 'native-casefold-parity', + native: native.stdout + native.stderr, + executed, + }), + ) + // Then: actual native witnesses and the discoverer agree, retaining original spelling. + expect(native.status).toBe(0) + expect(executed).toEqual(expected) + expect(discoverTests(root)).toEqual(executed) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/plugin-utils/src/__tests__/native-discovery.test.ts b/packages/plugin-utils/src/__tests__/native-discovery.test.ts new file mode 100644 index 000000000..c503d64de --- /dev/null +++ b/packages/plugin-utils/src/__tests__/native-discovery.test.ts @@ -0,0 +1,88 @@ +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { discoverTests } from '../../../../test-harness/groups' + +it('matches independently executed native files when all supported names and scanner directory boundaries occur', () => { + // Given: independent corpus, not generated by the harness discoverer. + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-native-discovery-')), + ) + const receipt = join(root, 'executed.txt') + const supported = [ + 'js', + 'jsx', + 'ts', + 'tsx', + 'mjs', + 'cjs', + 'mts', + 'cts', + ] as const + const suffixes = ['.test', '_test', '.spec', '_spec'] as const + const expected = [ + ...supported.flatMap((extension) => + suffixes.map((suffix) => `packages/corpus/case${suffix}.${extension}`), + ), + 'packages/x_test.ts', + 'packages/x_spec.cts', + 'packages/dist/generated.test.ts', + 'packages/pkg/generated.spec.js', + ].sort() + const ignored = [ + 'packages/node_modules/dependency.test.ts', + 'packages/.hidden/hidden.test.ts', + 'packages/corpus/case.test.mjsx', + 'packages/corpus/case.test.cjsx', + 'packages/corpus/case.test.mtsx', + 'packages/corpus/case.test.ctsx', + 'packages/corpus/case_test_extra.ts', + 'packages/corpus/case_spec_extra.ts', + ] as const + try { + writeFileSync(join(root, 'bunfig.toml'), '[test]\nroot="packages"\n') + for (const file of [...expected, ...ignored]) { + const path = join(root, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + `import {it,expect} from 'bun:test';import {appendFileSync} from 'node:fs';it(${JSON.stringify(file)},()=>{appendFileSync(${JSON.stringify(receipt)},${JSON.stringify(`${file}\n`)});expect(1).toBe(1)})`, + ) + } + // When: actual native automatic discovery, without explicit file arguments. + const native = spawnSync( + process.execPath, + [`--config=${join(root, 'bunfig.toml')}`, 'test'], + { + cwd: root, + encoding: 'utf8', + }, + ) + if (native.error) throw native.error + const executed = readFileSync(receipt, 'utf8').trim().split(/\r?\n/).sort() + console.info( + JSON.stringify({ + case: 'native-discovery-parity', + native: native.stdout + native.stderr, + executed, + }), + ) + // Then: positive native receipt and independent harness/native parity. + expect(native.status).toBe(0) + expect(executed).toEqual(expected) + expect(discoverTests(root)).toEqual(executed) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) 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 000000000..f2d37040f --- /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__/nonphysical-id-list.test.ts b/packages/plugin-utils/src/__tests__/nonphysical-id-list.test.ts new file mode 100644 index 000000000..6ac553909 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/nonphysical-id-list.test.ts @@ -0,0 +1,272 @@ +import { expect, it, spyOn } from 'bun:test' + +import { + nextNonphysicalIdList, + parseNonphysicalIdList, + serializeNonphysicalIdList, +} from '../nonphysical-id-list' + +it.each([ + { serialized: undefined, expected: { kind: 'missing', ids: [] } }, + { serialized: '[]', expected: { kind: 'loaded', ids: [] } }, + { + serialized: '["z","a","z","__proto__","constructor"]', + expected: { kind: 'loaded', ids: ['__proto__', 'a', 'constructor', 'z'] }, + }, +])( + 'returns the typed outcome for $serialized with immutable set contents', + ({ serialized, expected }) => { + // Given absent or valid strict serialized ID arrays. + // When parsing at the serialized boundary. + const result = parseNonphysicalIdList(serialized) + // Then outcome distinguishes absence from a valid empty list, with frozen data. + expect(result).toEqual(expected) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.ids)).toBe(true) + }, +) + +it.each([ + '', + ' ', + '[', + '["valid",]', + '// comment\n[]', + 'null', + '{}', + '"id"', + '0', + 'true', + '{"version":1,"files":{"id":0}}', + '["valid",0]', + '["valid",null]', + '["valid",{}]', + '["valid",true]', + '["valid",[]]', + '["valid",""]', + '["", "valid"]', +])( + 'rejects the entire corrupt list %j without partial salvage', + (serialized) => { + // Given malformed JSON, a non-array or an invalid array element. + // When real JSON parsing and element narrowing run. + const result = parseNonphysicalIdList(serialized) + // Then corruption becomes a typed frozen empty outcome, not a build error. + expect(result).toEqual({ kind: 'corrupt', ids: [] }) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.ids)).toBe(true) + }, +) + +it('preserves opaque whitespace Unicode suffix NUL and backslash IDs while parsing', () => { + // Given independently spelled opaque normalized extraction IDs. + const serialized = + '["😀","é","é","文件","virtual:x?raw#x","\\u0000id","back\\\\slash"," ","é"]' + // When parsing real strict JSON once. + const result = parseNonphysicalIdList(serialized) + // Then exact values survive, with duplicates removed and UTF-16 ordering. + expect(result).toEqual({ + kind: 'loaded', + ids: [ + '\0id', + ' ', + 'back\\slash', + 'e\u0301', + 'virtual:x?raw#x', + 'é', + '文件', + '😀', + ], + }) +}) + +it('rethrows the same unrelated boundary fault instead of reporting corruption', () => { + // Given the explicitly authorized unavailable JSON.parse runtime fault. + const fault = new TypeError('unrelated parser boundary fault') + const parse = spyOn(JSON, 'parse').mockImplementation(() => { + throw fault + }) + try { + // When parsing an otherwise valid string encounters this non-SyntaxError. + let caught: unknown + try { + parseNonphysicalIdList('[]') + } catch (error) { + caught = error + } + // Then the original fault escapes unchanged, not as an empty corrupt list. + expect(caught).toBe(fault) + } finally { + parse.mockRestore() + } +}) + +interface NextCase { + readonly label: string + readonly extracted: readonly string[] + readonly known: readonly string[] + readonly expected: readonly string[] +} +const nextCases: readonly NextCase[] = [ + { + label: 'saved-but-still-scan-missed survives while saved-only stale drops', + extracted: ['savedUnknown', 'newUnknown', 'savedUnknown'], + known: ['known'], + expected: ['newUnknown', 'savedUnknown'], + }, + { + label: 'promoted known ID drops even when currently extracted', + extracted: ['promotedKnown', 'currentUnknown'], + known: ['promotedKnown'], + expected: ['currentUnknown'], + }, + { + label: + 'physical-promoted saved ID is absent from the caller nonphysical cohort', + extracted: ['currentUnknown'], + known: [], + expected: ['currentUnknown'], + }, + { + label: 'an empty build drops every saved-only stale ID', + extracted: [], + known: [], + expected: [], + }, + { + label: 'all currently extracted IDs are now known', + extracted: ['known', 'known'], + known: ['known', 'known'], + expected: [], + }, + { + label: 'deduplicates opaque values without trimming or renormalizing', + extracted: [' ', '\0x?raw', ' ', 'a\\b'], + known: ['other'], + expected: ['\0x?raw', ' ', 'a\\b'], + }, +] +it.each([...nextCases])( + 'replaces the next list when $label', + ({ extracted, known, expected }) => { + // Given only actual extracted nonphysical and complete known-scan cohorts. + // When replacing the store set without any previous-list argument. + const result = nextNonphysicalIdList(extracted, known) + // Then only unique current scan-missed IDs remain, frozen and detached. + expect(result).toEqual(expected) + expect(Object.isFrozen(result)).toBe(true) + expect(result).not.toBe(extracted) + }, +) + +it('keeps saved scan-missed IDs independent of changed physical seed metadata', () => { + // Given caller fixtures with different physical seeds but the same cohorts. + const fixtures = [ + { + physical: ['oldPhysical'], + buckets: ['oldBucket'], + extracted: ['savedUnknown'], + known: [], + }, + { + physical: ['newPhysical', 'extraPhysical'], + buckets: ['newBucket'], + extracted: ['savedUnknown'], + known: [], + }, + ] + // When applying only the two authorized nonphysical cohorts. + const results = fixtures.map(({ extracted, known }) => + nextNonphysicalIdList(extracted, known), + ) + // Then metadata neither changes ID membership nor promises stable Rust numbers. + expect(results).toEqual([['savedUnknown'], ['savedUnknown']]) +}) + +it('does not mutate input cohorts and returns a frozen copy', () => { + // Given mutable arrays in noncanonical arrival order. + const extracted = ['z', 'known', 'a', 'z'] + const known = ['known'] + // When computing the exact current difference. + const result = nextNonphysicalIdList(extracted, known) + // Then inputs are unchanged and subsequent input edits cannot alter the result. + expect(extracted).toEqual(['z', 'known', 'a', 'z']) + expect(known).toEqual(['known']) + extracted.push('late') + known.push('a') + expect(result).toEqual(['a', 'z']) + expect(Object.isFrozen(result)).toBe(true) +}) + +it.each([ + { + ids: [ + '\ue000', + '😀', + 'é', + 'e\u0301', + 'z', + '\0', + ' ', + 'back\\slash', + 'virtual:x?raw#fragment', + 'é', + ], + }, + { + ids: [ + 'é', + 'virtual:x?raw#fragment', + 'back\\slash', + ' ', + '\0', + 'z', + 'e\u0301', + 'é', + '😀', + '\ue000', + ], + }, +])( + 'serializes opposite arrival orders to identical canonical ID-only bytes', + ({ ids }) => { + // Given the same opaque ID set in independent arrival order with duplicates. + // When serializing a copied set using UTF-16 lexical order, not Rust order. + const serialized = serializeNonphysicalIdList(ids) + // Then independently specified compact JSON contains exact IDs and no envelope. + expect(serialized).toBe( + '["\\u0000"," ","back\\\\slash","é","virtual:x?raw#fragment","z","é","😀",""]', + ) + }, +) + +it('serializes the empty list as a bare JSON array', () => { + // Given no current scan-missed extracted IDs. + const ids: readonly string[] = [] + // When serializing that ID set. + const serialized = serializeNonphysicalIdList(ids) + // Then no domain, counter, version or newline is added. + expect(serialized).toBe('[]') +}) + +it('copies serialization inputs without changing their order or duplicates', () => { + // Given an unsorted mutable input array. + const ids = ['z', 'a', 'z'] + // When serializing its unique copied set. + const serialized = serializeNonphysicalIdList(ids) + // Then caller order/multiplicity remain unchanged and bytes are canonical. + expect(ids).toEqual(['z', 'a', 'z']) + ids.push('late') + expect(serialized).toBe('["a","z"]') +}) + +it('round-trips canonical opaque list bytes without Unicode normalization', () => { + // Given a canonical independently specified list with distinct Unicode forms. + const serialized = '["é","é","😀"]' + // When composing parsing and serialization at the storage boundary. + const rewritten = serializeNonphysicalIdList( + parseNonphysicalIdList(serialized).ids, + ) + // Then opaque identity spelling survives the additional composition case. + expect(rewritten).toBe(serialized) +}) diff --git a/packages/plugin-utils/src/__tests__/nonphysical-id-store.test.ts b/packages/plugin-utils/src/__tests__/nonphysical-id-store.test.ts new file mode 100644 index 000000000..d2fc6e948 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/nonphysical-id-store.test.ts @@ -0,0 +1,289 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { + createNonphysicalIdStore, + type NonphysicalIdStore, +} from '../nonphysical-id-store' + +let root: string +let store: NonphysicalIdStore +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-n1-')) + store = createNonphysicalIdStore({ + integration: 'vite', + resolvedRoot: root, + contextKey: 'client', + }) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +it('does no IO when constructed and reports missing parents as typed empty', async () => { + // Given an authoritative root and absent default output directory. + // When reading the newly constructed fixture without writing. + const result = await store.read() + // Then no constructor/read side effects occur and the tuple selects the path. + const key = createHash('sha256') + .update(JSON.stringify(['vite', root, 'client'])) + .digest('hex') + expect(store.filePath).toBe(join(root, 'df', 'numbering', `${key}.json`)) + expect(result).toEqual({ kind: 'missing', ids: [] }) + expect(readdirSync(root)).toEqual([]) +}) + +it.each(['[]', '["z","a","z"]'])( + 'loads a real valid list %s', + async (bytes) => { + // Given actual valid serialized list bytes. + mkdirSync(dirname(store.filePath), { recursive: true }) + writeFileSync(store.filePath, bytes) + // When reading through the real IO boundary. + const result = await store.read() + // Then valid emptiness differs from absence and contents are immutable sets. + expect(result).toEqual({ + kind: 'loaded', + ids: bytes === '[]' ? [] : ['a', 'z'], + }) + expect(Object.isFrozen(result.ids)).toBe(true) + }, +) + +it.each(['[', '{"files":{"old":1}}', '["valid",1]', '["valid",""]'])( + 'returns typed corrupt empty for real bytes %s', + async (bytes) => { + // Given malformed, old numeric or partially invalid persisted data. + mkdirSync(dirname(store.filePath), { recursive: true }) + writeFileSync(store.filePath, bytes) + // When parsing after a successful native read. + const result = await store.read() + // Then no partial salvage or new error is returned. + expect(result).toEqual({ kind: 'corrupt', ids: [] }) + }, +) + +it('retains saved extracted scan misses but drops stale and promoted IDs', async () => { + // Given stored history and physical/nonphysical extraction spellings. + await store.rewrite(['saved', 'stale', 'promoted'], []) + const physical = join(root, 'scan-missed.tsx') + const extracted = [ + 'saved', + 'new', + 'promoted', + physical, + 'physical-promoted', + 'new', + ] + const scan = ['promoted', 'physical-promoted', 'scan-only'] + // When replacing from ALL current extraction IDs minus SCAN reservations only. + await store.rewrite(extracted, scan) + // Then history is not unioned, physical misses survive and bytes are ID-only. + expect(await store.read()).toEqual({ + kind: 'loaded', + ids: [physical, 'new', 'saved'].sort(), + }) + expect(readFileSync(store.filePath, 'utf-8')).toBe( + JSON.stringify([physical, 'new', 'saved'].sort()), + ) +}) + +it.each([false, true])( + 'round-trips opaque spellings with reversed arrivals=%s', + async (reverse) => { + // Given exact NUL, suffix, namespace, whitespace and distinct Unicode IDs. + const ids = [ + '\0x?raw#part', + ' ', + 'back\\slash', + 'e\u0301', + 'ns-a:item', + 'ns-b:item', + 'é', + '😀', + ] + const store = createNonphysicalIdStore({ + integration: 'rsbuild', + resolvedRoot: root, + contextKey: 'server', + distDir: 'nested/output', + }) + // When persisting either arrival order with duplicates. + await store.rewrite([...(reverse ? [...ids].reverse() : ids), 'é'], []) + // Then exact identity survives and compact bytes are arrival-independent. + expect(readFileSync(store.filePath, 'utf-8')).toBe( + '["\\u0000x?raw#part"," ","back\\\\slash","é","ns-a:item","ns-b:item","é","😀"]', + ) + expect(await store.read()).toEqual({ kind: 'loaded', ids }) + }, +) + +it.each([{ ids: ['short'] }, { ids: [] }])( + 'atomically replaces a longer list with $ids without leftover temps', + async ({ ids }) => { + // Given an existing longer snapshot in initially missing nested parents. + await store.rewrite(['long-a', 'long-b', 'long-c'], []) + // When rewriting a shorter or completed empty cohort. + await store.rewrite(ids, []) + // Then there are no trailing bytes or temporary files. + expect(readFileSync(store.filePath, 'utf-8')).toBe(JSON.stringify(ids)) + expect(readdirSync(dirname(store.filePath))).toEqual([ + basename(store.filePath), + ]) + }, +) + +it('leaves the latest concurrent submission on the retained writer', async () => { + // Given one retained store with real asynchronous filesystem writes. + // When submitting snapshots without sleeps or separate writer instances. + await Promise.all([ + store.rewrite(['first'], []), + store.rewrite(['second'], []), + store.rewrite(['latest'], []), + ]) + // Then the latest submitted set wins and temp siblings are drained. + expect(await store.read()).toEqual({ kind: 'loaded', ids: ['latest'] }) + expect(readdirSync(dirname(store.filePath))).toEqual([ + basename(store.filePath), + ]) +}) + +it('isolates exact tuple scopes under shared absolute output without traversal', async () => { + // Given distinct integrations, roots, contexts and delimiter-adversarial tuples. + const distDir = join(root, 'shared') + const scope = { + integration: 'a|b', + resolvedRoot: root, + contextKey: 'c', + distDir, + } + const scopes = [ + scope, + { ...scope, integration: 'a', contextKey: 'b|c' }, + { ...scope, integration: 'other' }, + { ...scope, resolvedRoot: `${root}-other` }, + { ...scope, contextKey: '../../escape\\x\0' }, + { ...scope, resolvedRoot: root.toUpperCase() }, + ] + const stores = scopes.map(createNonphysicalIdStore) + // When writing every independent scope to the same absolute output. + await Promise.all( + stores.map((store, index) => store.rewrite([String(index)], [])), + ) + // Then no scope aliases or escapes, and equivalent authoritative scope reloads. + expect(new Set(stores.map((store) => store.filePath)).size).toBe( + scopes.length, + ) + for (const [index, store] of stores.entries()) { + expect(dirname(store.filePath)).toBe(join(distDir, 'numbering')) + expect(basename(store.filePath)).toMatch(/^[a-f0-9]{64}\.json$/) + expect(await store.read()).toEqual({ kind: 'loaded', ids: [String(index)] }) + } + expect(await createNonphysicalIdStore(scope).read()).toEqual({ + kind: 'loaded', + ids: ['0'], + }) +}) + +it('propagates native directory-target read faults instead of empty outcomes', async () => { + // Given a real directory where the list file belongs. + mkdirSync(store.filePath, { recursive: true }) + // When reading a native directory target. + const operation = store.read() + // Then the native read fault escapes without a wrapper. + await expect(operation).rejects.toBeInstanceOf(Error) + await expect(operation).rejects.toMatchObject({ + code: 'EISDIR', + syscall: 'read', + }) +}) + +it.each(['read', 'rewrite'] as const)( + 'honors native file-as-parent %s outcomes', + async (operation) => { + // Given an actual file blocking the output parent. + const distDir = join(root, 'file') + writeFileSync(distDir, 'parent is a file') + const store = createNonphysicalIdStore({ + integration: 'vite', + resolvedRoot: root, + contextKey: 'fault', + distDir, + }) + // When the native operation encounters that parent. + const pending = + operation === 'read' ? store.read() : store.rewrite(['id'], []) + // Then Windows native ENOENT is missing; other native faults still propagate. + if (operation === 'read' && process.platform === 'win32') + expect(await pending).toEqual({ kind: 'missing', ids: [] }) + else await expect(pending).rejects.toMatchObject({ code: 'ENOTDIR' }) + }, +) + +it('forwards native replacement rejection and retains writer cleanup', async () => { + // Given a nonempty directory blocking atomic replacement. + mkdirSync(store.filePath, { recursive: true }) + writeFileSync(join(store.filePath, 'child'), 'untouched') + // When the default writer attempts rename over that directory. + const operation = store.rewrite(['id'], []) + // Then actual native rename rejection escapes and its temp sibling is cleaned. + await expect(operation).rejects.toBeInstanceOf(Error) + await expect(operation).rejects.toMatchObject({ + syscall: 'rename', + dest: store.filePath, + }) + expect(readdirSync(dirname(store.filePath))).toEqual([ + basename(store.filePath), + ]) +}) + +it.each(['read', 'rewrite'] as const)( + 'propagates genuinely denied native %s IO for the current account', + async (operation) => { + // Given real OS permissions denying the current account, not chmod-on-Windows. + await store.rewrite(['initial'], []) + const target = + operation === 'read' ? store.filePath : dirname(store.filePath) + const account = + process.platform === 'win32' + ? execFileSync('whoami', { encoding: 'utf8' }).trim() + : '' + if (process.platform === 'win32') { + execFileSync('icacls', [ + target, + '/deny', + `${account}:(${operation === 'read' ? 'R' : 'W'})`, + ]) + } else { + chmodSync(target, operation === 'read' ? 0o000 : 0o500) + } + try { + // When real IO runs as the same account with access actually denied. + const pending = + operation === 'read' ? store.read() : store.rewrite(['next'], []) + // Then success is a BLOCKED permission fixture, never a skipped/substituted proof. + await expect(pending).rejects.toBeInstanceOf(Error) + await expect(pending).rejects.toMatchObject({ + code: process.platform === 'win32' ? 'EPERM' : 'EACCES', + syscall: 'open', + }) + } finally { + if (process.platform === 'win32') + execFileSync('icacls', [target, '/remove:d', account]) + else chmodSync(target, 0o700) + } + }, +) diff --git a/packages/plugin-utils/src/__tests__/nonphysical-module-id.test.ts b/packages/plugin-utils/src/__tests__/nonphysical-module-id.test.ts new file mode 100644 index 000000000..fff38269e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/nonphysical-module-id.test.ts @@ -0,0 +1,242 @@ +import { expect, it } from 'bun:test' + +import { + type NonphysicalModuleId, + normalizeNonphysicalModuleId, + type PhysicalImporter, +} from '../nonphysical-module-id' + +it.each([ + '\0virtual:entry.tsx?raw#fragment', + 'virtual:entry.tsx?one#first', + 'virtual:entry.tsx?two#second', + 'virtual:entry.tsx#first?one', + 'virtual:entry.tsx#second?one', + '.\\native\\..\\entry.tsx', + './native/../entry.tsx', + 'é/e\u0301/😀/文件.tsx', + ' opaque ', +])('preserves native identity %j over a different request', (id) => { + // Given a native identity distinct from the importer-relative fallback. + const resolved = { namespace: 'native:producer', id } + const request = { namespace: 'written', specifier: './other.tsx' } + const importer: PhysicalImporter = { + path: '/real/src/main.tsx', + pathStyle: 'posix', + } + // When native resolution supplies the identity. + const result = normalizeNonphysicalModuleId(resolved, request, importer) + // Then every opaque byte and the native namespace survive in a frozen copy. + expect(result).toEqual(resolved) + expect(result).not.toBe(resolved) + expect(Object.isFrozen(result)).toBe(true) +}) + +interface RelativeCase { + readonly importer: PhysicalImporter + readonly specifier: string + readonly expected: string +} +const relatives: readonly RelativeCase[] = [ + { + importer: { path: '/real/src/main.tsx', pathStyle: 'posix' }, + specifier: './a/../child', + expected: '/real/src/child', + }, + { + importer: { path: '/real/src/main.tsx', pathStyle: 'posix' }, + specifier: '../child?raw#x?again', + expected: '/real/child?raw#x?again', + }, + { + importer: { path: '/real/src/main.tsx', pathStyle: 'posix' }, + specifier: './child#x?raw', + expected: '/real/src/child#x?raw', + }, + { + importer: { path: '/real/src/main.tsx', pathStyle: 'posix' }, + specifier: './back\\slash?x\\y', + expected: '/real/src/back\\slash?x\\y', + }, + { + importer: { path: '/real/src/main.tsx', pathStyle: 'posix' }, + specifier: './é%2Fchild', + expected: '/real/src/é%2Fchild', + }, + { + importer: { path: 'C:\\real\\src\\main.tsx', pathStyle: 'win32' }, + specifier: '.\\a\\..\\child', + expected: 'C:\\real\\src\\child', + }, + { + importer: { path: 'C:\\real\\src\\main.tsx', pathStyle: 'win32' }, + specifier: '..\\child?raw#x/../y', + expected: 'C:\\real\\child?raw#x/../y', + }, + { + importer: { path: 'C:\\real\\src\\main.tsx', pathStyle: 'win32' }, + specifier: './child#x?raw', + expected: 'C:\\real\\src\\child#x?raw', + }, + { + importer: { path: 'C:\\real\\src\\main.tsx', pathStyle: 'win32' }, + specifier: '../child', + expected: 'C:\\real\\child', + }, + { + importer: { path: '\\\\server\\share\\src\\main.tsx', pathStyle: 'win32' }, + specifier: '.\\child?😀', + expected: '\\\\server\\share\\src\\child?😀', + }, +] +it.each([...relatives])( + 'resolves only the relative path portion of $specifier', + ({ importer, specifier, expected }) => { + // Given caller-decoded nonphysical input and an actual physical importer. + const request = { namespace: 'fallback', specifier } + // When no native resolved identity exists. + const result = normalizeNonphysicalModuleId(undefined, request, importer) + // Then explicit path style controls the path and suffix bytes remain exact. + expect(result).toEqual({ namespace: 'fallback', id: expected }) + expect(Object.isFrozen(result)).toBe(true) + expect(request.specifier).toBe(specifier) + }, +) + +it.each([ + 'bare?raw#x', + 'virtual:./entry', + 'file:../entry', + '/absolute/../entry', + 'C:\\absolute\\entry', + '\\\\server\\share\\entry', + '\0./entry', + './entry?\0raw', + '../entry#\0fragment', + '.', + '..', +])('retains opaque fallback %j under either path style', (specifier) => { + // Given an opaque request with a genuine importer for each explicit style. + const importers: readonly PhysicalImporter[] = [ + { path: '/real/main.tsx', pathStyle: 'posix' }, + { path: 'C:\\real\\main.tsx', pathStyle: 'win32' }, + ] + // When fallback identity is selected for each caller context. + const results = importers.map((importer) => + normalizeNonphysicalModuleId( + undefined, + { namespace: null, specifier }, + importer, + ), + ) + // Then requests are not reclassified or normalized as physical paths. + expect(results).toEqual( + importers.map(() => ({ namespace: null, id: specifier })), + ) +}) + +it.each(['./child?raw#x', '../child', '.\\child', '..\\child', '\0virtual:x'])( + 'retains %j without an actual physical importer', + (specifier) => { + // Given caller-established nonphysical identity without importer provenance. + const request = { namespace: '', specifier } + // When no native identity or physical importer exists. + const result = normalizeNonphysicalModuleId(undefined, request, undefined) + // Then no host cwd, separator inference or extension is introduced. + expect(result).toEqual({ namespace: '', id: specifier }) + expect(Object.isFrozen(result)).toBe(true) + }, +) + +it('keeps Windows-relative spelling opaque under POSIX style', () => { + // Given backslashes are not POSIX path separators. + const request = { namespace: null, specifier: '.\\child' } + // When a POSIX physical importer is supplied. + const result = normalizeNonphysicalModuleId(undefined, request, { + path: '/real/main.tsx', + pathStyle: 'posix', + }) + // Then the spelling survives without host-platform inference. + expect(result).toEqual({ namespace: null, id: '.\\child' }) +}) + +it('uses decoded request evidence without decoding opaque percent bytes again', () => { + // Given written escape bytes and a separately decoded specifier. + const occurrence = { + written: String.raw`./\x61%2Fchild`, + specifier: './a%2Fchild', + } + // When the caller supplies its decoded specifier, not written request bytes. + const result = normalizeNonphysicalModuleId( + undefined, + { namespace: null, specifier: occurrence.specifier }, + { path: '/real/main.tsx', pathStyle: 'posix' }, + ) + // Then it neither decodes twice nor invents a parser filename extension. + expect(result).toEqual({ namespace: null, id: '/real/a%2Fchild' }) +}) + +it('keeps namespace boundaries distinct even when colon flattening would collide', () => { + // Given null, empty, named and ambiguous colon-containing pairs. + const identities: readonly NonphysicalModuleId[] = [ + { namespace: null, id: 'same' }, + { namespace: '', id: 'same' }, + { namespace: 'one', id: 'same' }, + { namespace: 'two', id: 'same' }, + { namespace: 'a:b', id: 'c' }, + { namespace: 'a', id: 'b:c' }, + ] + // When native identities are copied without a flattened filename encoding. + const results = identities.map((identity) => + normalizeNonphysicalModuleId( + identity, + { namespace: 'ignored', specifier: 'ignored' }, + undefined, + ), + ) + // Then structured identities remain lossless and all six differ. + expect(results).toEqual([...identities]) + expect( + new Set(results.map(({ namespace, id }) => JSON.stringify([namespace, id]))) + .size, + ).toBe(6) +}) + +it('detaches native copies from later input mutation without mutating inputs', () => { + // Given independently mutable caller fixtures. + const resolved = { namespace: 'native', id: '\0opaque?raw#x' } + const request = { namespace: 'request', specifier: './other' } + const importer: PhysicalImporter = { + path: '/real/main.tsx', + pathStyle: 'posix', + } + // When normalization returns a native copy. + const result = normalizeNonphysicalModuleId(resolved, request, importer) + // Then caller inputs are unchanged and later native mutation cannot alter it. + expect(resolved).toEqual({ namespace: 'native', id: '\0opaque?raw#x' }) + expect(request).toEqual({ namespace: 'request', specifier: './other' }) + expect(importer).toEqual({ path: '/real/main.tsx', pathStyle: 'posix' }) + resolved.namespace = 'changed' + resolved.id = 'changed' + request.specifier = 'changed' + expect(result).toEqual({ namespace: 'native', id: '\0opaque?raw#x' }) +}) + +it('detaches fallback identity from later request and importer mutation', () => { + // Given mutable caller request and physical-importer records. + const request = { namespace: 'fallback', specifier: './child?x#y' } + const importer: { path: string; pathStyle: 'posix' } = { + path: '/real/main.tsx', + pathStyle: 'posix', + } + // When fallback normalization returns its frozen value. + const result = normalizeNonphysicalModuleId(undefined, request, importer) + // Then input records are unchanged and later changes do not reach the result. + expect(request).toEqual({ namespace: 'fallback', specifier: './child?x#y' }) + expect(importer.path).toBe('/real/main.tsx') + request.namespace = 'changed' + request.specifier = 'changed' + importer.path = '/other/main.tsx' + expect(result).toEqual({ namespace: 'fallback', id: '/real/child?x#y' }) + expect(Object.isFrozen(result)).toBe(true) +}) 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 000000000..86d7d09df --- /dev/null +++ b/packages/plugin-utils/src/__tests__/numbering.test.ts @@ -0,0 +1,186 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { listSourceFiles } from '../import-graph' +import { + collectNumberedFiles, + extractedNeedles, + seedFileNumbers, +} from '../numbering' + +describe('collectNumberedFiles', () => { + let root: string + + const write = (path: string) => { + fs.mkdirSync(dirname(join(root, path)), { recursive: true }) + fs.writeFileSync(join(root, path), 'export {}') + } + + beforeEach(() => { + root = fs.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(() => { + fs.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('numbers only the files that mention what the build extracts', () => { + fs.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')], + cwd: root, + }) + const second = collectNumberedFiles({ + roots: [join(root, 'node_modules/@acme/ui'), join(root, 'src')], + cwd: root, + }) + 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'), [], { includeMdx: true }), + ).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')], + 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__/oracle-scanner-fixtures.ts b/packages/plugin-utils/src/__tests__/oracle-scanner-fixtures.ts new file mode 100644 index 000000000..bcfb9e530 --- /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 000000000..65b1f624e --- /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 000000000..4eacba67c --- /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__/packaged-config-inputs.test.ts b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts new file mode 100644 index 000000000..4411e9c0e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/packaged-config-inputs.test.ts @@ -0,0 +1,201 @@ +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { resolvePackage } from '../owned-module-resolution' +import { createResolutionInputs } from '../resolution-inputs' +import { createPreparedFixture } from './prepared-graph-fixture' + +let root: string +const file = createPreparedFixture((directory) => { + root = directory +}) + +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', '{}') + 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. + 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__/parser-corpus.test.ts b/packages/plugin-utils/src/__tests__/parser-corpus.test.ts new file mode 100644 index 000000000..abdc1884a --- /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 000000000..6a601b968 --- /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-compiler.test.ts b/packages/plugin-utils/src/__tests__/prepared-compiler.test.ts new file mode 100644 index 000000000..3b9e66b74 --- /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 000000000..26a7fcaf9 --- /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 000000000..33504a0fd --- /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 000000000..4ba045459 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/prepared-graph.test.ts @@ -0,0 +1,214 @@ +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 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: './phantom' }, + }), + }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + 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 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() { + throw new Error('AST must not decide edges') + }, + get errors() { + return [] + }, + }) + __setOxcParserForTest({ parseSync: () => result }) + const graph = await buildStaticImportGraph('src', undefined, { + cwd: root, + includeMdx: true, + prepareSource: (filename) => + filename === entry ? "import './leaf'" : undefined, + }) + 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)).toThrow( + 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('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)).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 000000000..7deb84897 --- /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 000000000..6d246e25e --- /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/__tests__/producer-deleted.test.ts b/packages/plugin-utils/src/__tests__/producer-deleted.test.ts new file mode 100644 index 000000000..db762e7c0 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-deleted.test.ts @@ -0,0 +1,264 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, it, spyOn } from 'bun:test' + +import { auditCoverage } from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { object, text } from '../../../../test-harness/producer-data' +import { decodeMap } from '../../../../test-harness/producer-map' +import { + reconstructNative, + validateNative, +} from '../../../../test-harness/producer-native' +import { captureProcess } from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' +import { parseTestResult, runTestGroups } from '../../../../test-harness/run' + +function deletedFixture(extension: 'cjs' | 'mjs', partial = false) { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-deleted-')) + const modules = mkdtempSync(join(tmpdir(), 'devup-deleted-modules-')) + const config = join(root, 'bunfig.toml') + const sources = { + cjs: { + 'plugin.cjs': + 'exports.value = function value(input) { return input + 1 }\n', + 'loader.cjs': + "const plugin = require('./plugin.cjs'); exports.load = function load(input) { return plugin.value(input) }\n", + 'caller.cjs': + "const loader = require('./loader.cjs'); exports.modifyConfig = function modifyConfig(input) { return loader.load(input) }\n", + }, + mjs: { + 'plugin.mjs': 'export function value(input) { return input + 1 }\n', + 'loader.mjs': + "import {value} from './plugin.mjs'; export function load(input) { return value(input) }\n", + 'caller.mjs': + "import {load} from './loader.mjs'; export function modifyConfig(input) { return load(input) }\n", + }, + } as const + const inputs = Object.entries(sources[extension]).map(([name, source]) => ({ + path: join(modules, name), + source: + source + + (partial && name.startsWith('plugin') + ? 'export function unused() { return 99 }\n' + : ''), + })) + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\ncoverageSkipTestFiles=true\n', + ) + const caller = JSON.stringify(join(modules, `caller.${extension}`)) + const load = { + cjs: `createRequire(import.meta.url)(${caller})`, + mjs: `await import(${caller}+'?first')`, + } + const reload = { + cjs: '', + mjs: `const last=await import(${caller}+'?last');expect(last.modifyConfig(41)).toBe(42);`, + } + writeFileSync( + join(root, 'source.test.ts'), + `import {it,expect} from 'bun:test';import {mkdirSync,writeFileSync,rmSync} from 'node:fs';import {createRequire} from 'node:module';it('write load delete',async()=>{mkdirSync(${JSON.stringify(modules)},{recursive:true});for(const input of ${JSON.stringify(inputs)})writeFileSync(input.path,input.source);try{const caller=${load[extension]};expect(caller.modifyConfig(41)).toBe(42);${reload[extension]}}finally{rmSync(${JSON.stringify(modules)},{recursive:true});Bun.gc(true)}})`, + ) + return { root, modules, config, inputs } +} + +it.each(['cjs', 'mjs'] as const)( + 'binds actual %s caller loader and plugin evidence when teardown deletes their files before final capture', + async (extension) => { + // Given: the child writes and loads real modules, then deletes them and runs GC. + const fixture = deletedFixture(extension) + try { + // When: the real producer captures its final public/native lifetime. + const result = await captureProcess({ + root: fixture.root, + config: fixture.config, + coverage: join(fixture.root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const bound = VerifiedSourceCoverage.bind(result, fixture.root) + // Then: deleted inputs retain actual sourceID, emitted code/map and native rows. + expect(result.value.status).toBe(0) + const witnesses = fixture.inputs.map((input) => { + const captures = result.value.captures.filter( + (item) => item.url === input.path, + ) + const capture = captures.at(-1) + const source = parseLcov(result.value.lcov).find( + (item) => resolve(fixture.root, item.file) === input.path, + ) + if (!capture || !source) + throw new CoverageError('missing deleted native witness') + expect(decodeMap(capture.code, capture.sourceMapURL).input).toBe( + input.source, + ) + expect(capture.blocks.length).toBeGreaterThan(0) + expect( + captures.every( + (item) => + item.code === capture.code && + item.sourceMapURL === capture.sourceMapURL, + ), + ).toBe(true) + expect(() => + validateNative(reconstructNative(capture, source), source), + ).not.toThrow() + return { + capture, + source: { + file: source.file, + functions: [source.foundFunctions, source.hitFunctions], + lines: [...source.lines], + }, + } + }) + expect(() => + auditCoverage( + bound, + witnesses.map((item) => item.source.file), + ), + ).not.toThrow() + console.info( + JSON.stringify({ + case: 'actual-deleted-producer', + extension, + pid: result.value.pid, + bun: result.value.bun, + revision: result.value.revision, + witnesses, + }), + ) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + rmSync(fixture.modules, { recursive: true, force: true }) + } + }, + 120000, +) + +it('publishes both real groups when each recreates loads and deletes the same ESM chain', async () => { + // Given: each real group exercises identical effective modules before teardown. + const fixture = deletedFixture('mjs') + const output = spyOn(console, 'info').mockImplementation(() => undefined) + try { + const required = fixture.inputs.map((input) => + relative(fixture.root, input.path).replaceAll('\\', '/'), + ) + // When: the unchanged root merge and strict per-source auditor publish them. + const summary = await runTestGroups({ + root: fixture.root, + config: fixture.config, + groups: { isolated: ['source.test.ts'], runtime: ['source.test.ts'] }, + required, + }) + // Then: full-universe output includes every deleted module at strict100. + expect(parseTestResult(summary)).toEqual({ pass: 2, fail: 0 }) + const sources = parseLcov( + readFileSync(join(fixture.root, 'coverage/lcov.info'), 'utf8'), + ) + expect( + sources.filter((source) => required.includes(source.file)).length, + ).toBe(3) + } finally { + output.mockRestore() + rmSync(fixture.root, { recursive: true, force: true }) + rmSync(fixture.modules, { recursive: true, force: true }) + } +}, 120000) + +it('rejects incomplete native coverage when an uncalled function belongs to a deleted module', async () => { + const fixture = deletedFixture('mjs', true) + try { + // Given / When: actual loaded deleted code contains an unexecuted function. + const result = await captureProcess({ + root: fixture.root, + config: fixture.config, + coverage: join(fixture.root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const bound = VerifiedSourceCoverage.bind(result, fixture.root) + // Then: teardown cannot waive any native function or zero-hit row. + expect(() => auditCoverage(bound, [])).toThrow( + 'below 100% lines/functions:', + ) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + rmSync(fixture.modules, { recursive: true, force: true }) + } +}, 120000) + +it('rejects missing repository capture when the actual inspector transport loses its parsed URL', async () => { + // Given: a genuine repository module executes; transport corruption omits only its URL. + const root = mkdtempSync(join(tmpdir(), 'devup-producer-repo-missing-')) + const source = fileURLToPath( + new URL('../../../../test-harness/groups.ts', import.meta.url), + ) + const original = ProducerProtocol.prototype.request + let suppressed = 0 + const transport = spyOn( + ProducerProtocol.prototype, + 'request', + ).mockImplementation(async function ( + this: ProducerProtocol, + method, + params = {}, + ) { + const response = await original.call(this, method, params) + if (method === 'Inspector.initialized') { + this.onEvent = (event) => { + if (event['method'] !== 'Debugger.scriptParsed') return + const parsed = object(event['params']) + if ( + text(parsed['url']).replaceAll('\\', '/') === + source.replaceAll('\\', '/') + ) { + parsed['url'] = '' + suppressed++ + } + } + } + return response + }) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + `import {it,expect} from 'bun:test';import {parseTestGroup} from ${JSON.stringify(source)};it('repository',()=>expect(parseTestGroup('isolated')).toBe('isolated'))`, + ) + // When: the capturer issues only actual observed evidence, never a fabricated receipt. + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + // Then: LCOV still includes the repository source and bind refuses specifically its gap. + expect(result.value.status).toBe(0) + expect(suppressed).toBe(1) + expect( + parseLcov(result.value.lcov).some( + (item) => resolve(root, item.file) === source, + ), + ).toBe(true) + const bind = () => VerifiedSourceCoverage.bind(result, root) + expect(bind).toThrow(CoverageError) + expect(bind).toThrow('missing producer capture') + } finally { + transport.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-evidence.test.ts b/packages/plugin-utils/src/__tests__/producer-evidence.test.ts new file mode 100644 index 000000000..17ff7b24d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-evidence.test.ts @@ -0,0 +1,230 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { captureProcess } from '../../../../test-harness/producer-process' +import { parseTestResult, runTestGroups } from '../../../../test-harness/run' + +it('accepts exact function union when independent real children execute complementary functions of the same effective source', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-positive-')) + const output = spyOn(console, 'info').mockImplementation(() => undefined) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function first() { return 1 }\nexport function second() { return 2 }\n', + ) + for (const [name, fn, value] of [ + ['a', 'first', 1], + ['b', 'second', 2], + ] as const) { + writeFileSync( + join(root, `${name}.test.ts`), + `import {it,expect} from 'bun:test';import {${fn}} from './source';it('actual producer',()=>expect(${fn}()).toBe(${value}))`, + ) + } + const summary = await runTestGroups({ + root, + config, + groups: { isolated: ['a.test.ts'], runtime: ['b.test.ts'] }, + required: ['source.ts'], + }) + expect(parseTestResult(summary)).toEqual({ pass: 2, fail: 0 }) + const source = parseLcov( + readFileSync(join(root, 'coverage/lcov.info'), 'utf8'), + ).find((record) => record.file === 'source.ts') + expect(source?.foundFunctions).toBe(2) + expect(source?.hitFunctions).toBe(2) + } finally { + output.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it.each([ + [ + 'configuration', + "import {writeFileSync} from 'node:fs';writeFileSync(process.env.DEVUP_PRODUCER_CONFIG,'[test]\\ncoverage=true\\ncoverageReporter=[\"lcov\"]\\ncoverageThreshold=0.1\\n')", + ], + [ + 'new script', + "process.stdin.on('data',chunk=>{if(String(chunk).includes('final evidence collected'))eval('globalThis.lateEvidence = 1')})", + ], +] as const)( + 'refuses an actual %s change instead of accepting a stale final receipt', + async (_name, change) => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-change-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 1 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + `import {it,expect} from 'bun:test';import {value} from './source';${change};it('actual',()=>expect(value()).toBe(1))`, + ) + await expect( + captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) + +it('refuses an existing native report before launching a producer that could reuse stale output', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-stale-')) + try { + const coverage = join(root, 'coverage') + mkdirSync(coverage) + writeFileSync(join(coverage, 'lcov.info'), 'stale native output') + await expect( + captureProcess({ + root, + config: join(root, 'bunfig.toml'), + coverage, + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('refuses already parsed function execution when it occurs after the final raw observation', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-late-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./late.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() {\n return 1\n}\n', + ) + writeFileSync( + join(root, 'late.ts'), + "import {value} from './source';process.stdin.on('data',chunk=>{if(String(chunk).includes('final evidence collected'))value()})\n", + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {value} from './source';it('actual',()=>expect(value()).toBe(1))", + ) + await expect( + captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it('binds the actual path-keyed native source when equivalent query imports reload one module', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-reload-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 1 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',async()=>{const first=await import('./source?first');expect(first.value()).toBe(1);const last=await import('./source?last');expect(last.value()).toBe(1)})", + ) + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const bound = VerifiedSourceCoverage.bind(result, root) + expect( + bound.find((source) => source.file === 'source.ts')?.hitFunctions, + ).toBe(1) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it('retains early dynamically imported preload code when native coverage outlives its script object', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-early-')) + try { + // Given: source executes before the capture preload and is no longer retained by the importer. + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./early.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function first() { return 1 }\nfirst()\n', + ) + writeFileSync( + join(root, 'early.ts'), + "await import('./source');Bun.gc(true)\n", + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + // When: the actual producer is bound to native coverage. + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const bound = VerifiedSourceCoverage.bind(result, root) + // Then: every early native source has its actual code, map and function receipt. + expect( + bound.find((source) => source.file === 'source.ts')?.hitFunctions, + ).toBe(1) + expect(bound.some((source) => source.file === 'early.ts')).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-final-capture.test.ts b/packages/plugin-utils/src/__tests__/producer-final-capture.test.ts new file mode 100644 index 000000000..44c87ec63 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-final-capture.test.ts @@ -0,0 +1,266 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, jest, spyOn } from 'bun:test' + +import { auditCoverage } from '../../../../test-harness/coverage' +import { CoverageError } from '../../../../test-harness/lcov' +import { ProducerChild } from '../../../../test-harness/producer-child' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { object, text } from '../../../../test-harness/producer-data' +import { + captureProcess, + isProducerResult, +} from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' +import { PRODUCER_TIMEOUT_MS } from '../../../../test-harness/producer-timeout' + +function record(value: unknown) { + console.info(JSON.stringify(value)) +} + +function observeChild() { + const children = new Set() + const original = ProducerChild.prototype.wait + const spy = spyOn(ProducerChild.prototype, 'wait').mockImplementation( + function (this: ProducerChild, condition) { + children.add(this) + return original.call(this, condition) + }, + ) + return { spy, child: () => [...children][0] } +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-final-capture-')) + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\ncoverageSkipTestFiles=true\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 42 }\n', + ) + const files = ['final.test.ts'] as const + writeFileSync( + join(root, files[0]), + "import {writeSync} from 'node:fs';import {it,expect} from 'bun:test';import {value} from './source';it('real final capture',()=>{writeSync(1,'FINAL-OUT\\n');writeSync(2,'FINAL-ERR\\n');expect(value()).toBe(42)})", + ) + return { + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated' as const, + files, + executable: process.execPath, + } +} + +it.serial( + 'issues genuine native coverage when final acknowledgement is delayed beyond five seconds', + async () => { + // Given: a real child and source, with the original completed fence before a deliberate parent delay. + const run = fixture() + const observation = observeChild() + let timer: ReturnType | undefined + const original = ProducerProtocol.prototype.armEndFence + let delay = 0 + const fence = spyOn( + ProducerProtocol.prototype, + 'armEndFence', + ).mockImplementation(async function ( + this: ProducerProtocol, + captures, + preload, + ) { + await original.call(this, captures, preload) + const started = performance.now() + await new Promise((done) => { + timer = setTimeout(done, 10000) + }) + delay = performance.now() - started + }) + try { + // When: final stdin acknowledgement waits beyond the former hook default, without further RPC or evaluation. + const result = await captureProcess(run) + record({ case: 'delayed-final-ack', delay, status: result.value.status }) + // Then: the actual native producer survives and issues strictly authenticated coverage. + expect(delay).toBeGreaterThan(5000) + expect(result.value.status).toBe(0) + expect(isProducerResult(result)).toBe(true) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.value.captures)).toBe(true) + const bound = VerifiedSourceCoverage.bind(result, run.root) + expect(() => auditCoverage(bound, ['source.ts'])).not.toThrow() + const capture = result.value.captures.find( + (item) => item.url === join(run.root, 'source.ts'), + ) + if (!capture) throw new CoverageError('missing final source witness') + expect(capture.profile).toBeDefined() + expect(capture.blocks.length).toBeGreaterThan(0) + record({ + case: 'final-native-witness', + bun: result.value.bun, + revision: result.value.revision, + pid: result.value.pid, + config: result.value.config, + sourceID: capture.scriptId, + profile: capture.profile, + blocks: capture.blocks, + code: createHash('sha256').update(capture.code).digest('hex'), + map: createHash('sha256').update(capture.sourceMapURL).digest('hex'), + native: bound.map((source) => ({ + file: source.file, + found: source.foundFunctions, + hit: source.hitFunctions, + lines: [...source.lines], + })), + }) + } finally { + clearTimeout(timer) + await observation.child()?.finish() + fence.mockRestore() + observation.spy.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } + }, + PRODUCER_TIMEOUT_MS, +) + +it.serial( + 'registers the shared bound for a real producer and its pending evidence observations', + async () => { + // Given: the original timers and actual inspector flow, observed without replacing their behavior. + const run = fixture() + const timer = spyOn(globalThis, 'setTimeout') + const delays: unknown[] = [] + const original = ProducerChild.prototype.wait + const observation = spyOn( + ProducerChild.prototype, + 'wait', + ).mockImplementation(function (this: ProducerChild, condition) { + const before = timer.mock.calls.length + const pending = original.call(this, condition) + delays.push(...timer.mock.calls.slice(before).map((call) => call[1])) + return pending + }) + try { + // When: a real producer completes both lifetime captures. + const result = await captureProcess(run) + // Then: both deadline and registered observation timers use the existing shared bound. + expect(result.value.status).toBe(0) + expect(timer.mock.calls[0]?.[1]).toBe(PRODUCER_TIMEOUT_MS) + expect(delays.length).toBeGreaterThan(0) + expect(delays.every((delay) => delay === PRODUCER_TIMEOUT_MS)).toBe(true) + record({ + case: 'registered-producer-bound', + bun: result.value.bun, + deadline: timer.mock.calls[0]?.[1], + observations: delays, + }) + } finally { + timer.mockRestore() + observation.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } + }, + PRODUCER_TIMEOUT_MS, +) + +it.serial( + 'expires the real producer at the shared fake-timer boundary when final acknowledgement is withheld', + async () => { + // Given: a real child held after its original fence; only the parent's test-framework clock is virtual. + const run = fixture() + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const failed = Promise.withResolvers() + const observation = observeChild() + const original = ProducerProtocol.prototype.armEndFence + const fence = spyOn( + ProducerProtocol.prototype, + 'armEndFence', + ).mockImplementation(async function ( + this: ProducerProtocol, + captures, + preload, + ) { + await original.call(this, captures, preload) + const observe = this.onFailure + this.onFailure = (error) => { + observe?.(error) + failed.resolve(error) + } + ready.resolve() + await release.promise + }) + jest.useFakeTimers() + const timer = spyOn(globalThis, 'setTimeout') + const pending = captureProcess(run).then( + (result) => result, + (error: unknown) => error, + ) + try { + await Promise.race([ + ready.promise, + pending.then((outcome) => { + throw outcome + }), + ]) + const producer = observation.child() + if (!producer) throw new CoverageError('missing actual final producer') + expect(timer.mock.calls[0]?.[1]).toBe(PRODUCER_TIMEOUT_MS) + // When: the real unacknowledged child reaches its existing parent deadline under framework fake timers. + jest.advanceTimersByTime(PRODUCER_TIMEOUT_MS - 1) + expect(producer.child.exitCode).toBeNull() + expect(producer.child.signalCode).toBeNull() + expect(producer.child.killed).toBe(false) + jest.advanceTimersByTime(1) + expect(producer.child.killed).toBe(true) + timer.mockRestore() + jest.clearAllTimers() + jest.useRealTimers() + const cause = await failed.promise + release.resolve() + const outcome = await pending + // Then: the original failure and drained, located actual exit survive; no trusted receipt is issued. + expect(outcome).toBeInstanceOf(CoverageError) + if (!(outcome instanceof CoverageError)) throw outcome + expect(outcome.cause).toBe(cause) + const context = object(object(outcome)['diagnostics']) + expect(context['group']).toBe(run.group) + expect(context['files']).toEqual(run.files) + for (const file of run.files) expect(outcome.message).toContain(file) + expect(context['cleanupRequested']).toBe(true) + expect(context['naturalExit']).toBeNull() + expect(context['exitBeforeCleanup']).toBeNull() + const final = object(context['finalExit']) + expect(final['code'] !== 0 || final['signal'] !== null).toBe(true) + expect( + producer.child.exitCode !== null || producer.child.signalCode !== null, + ).toBe(true) + for (const stream of ['stdoutTail', 'stderrTail']) { + const bytes = Buffer.byteLength(text(context[stream])) + expect(bytes).toBeGreaterThan(0) + expect(bytes).toBeLessThanOrEqual(8192) + } + expect(text(context['stderrTail'])).toContain('FINAL-ERR') + record({ case: 'fake-timer-final-expiry', bun: Bun.version, context }) + } finally { + release.resolve() + timer.mockRestore() + if (jest.isFakeTimers()) jest.clearAllTimers() + jest.useRealTimers() + observation.child()?.terminate() + await pending + await observation.child()?.finish() + fence.mockRestore() + observation.spy.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } + }, + PRODUCER_TIMEOUT_MS, +) diff --git a/packages/plugin-utils/src/__tests__/producer-history.test.ts b/packages/plugin-utils/src/__tests__/producer-history.test.ts new file mode 100644 index 000000000..a5eef6573 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-history.test.ts @@ -0,0 +1,200 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { auditCoverage, mergeCoverage } from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { + reconstructNative, + validateNative, +} from '../../../../test-harness/producer-native' +import { + captureProcess, + type ProducerResult, +} from '../../../../test-harness/producer-process' + +it('retains opposite native branch histories when the public precise deltas are both zero after their baselines', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-history-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./early.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function choice(active: boolean) {\n if (active) {\n return 1\n }\n return 2\n}\n', + ) + writeFileSync( + join(root, 'early.ts'), + "import {choice} from './source';choice(process.env.DEVUP_TEST_GROUP==='isolated')\n", + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + const results: ProducerResult[] = [] + for (const group of ['isolated', 'runtime'] as const) { + results.push( + await captureProcess({ + root, + config, + coverage: join(root, group), + group, + files: ['source.test.ts'], + executable: process.execPath, + }), + ) + } + const observations = results.map((result) => { + const capture = result.value.captures.find( + (item) => item.url === join(root, 'source.ts'), + ) + const source = parseLcov(result.value.lcov).find( + (item) => item.file === 'source.ts', + ) + if (!capture || !source || !capture.profile) + throw new CoverageError('missing actual history fixture') + return { capture, source, result } + }) + const first = observations[0] + const second = observations[1] + if (!first || !second) + throw new CoverageError('missing actual history pair') + expect( + first.capture.profile?.functions.every((fn) => + fn.ranges.every((range) => range.count === 0), + ), + ).toBe(true) + expect( + second.capture.profile?.functions.every((fn) => + fn.ranges.every((range) => range.count === 0), + ), + ).toBe(true) + expect([...first.source.lines]).not.toEqual([...second.source.lines]) + expect(() => + validateNative( + reconstructNative(first.capture, first.source), + second.source, + ), + ).toThrow(CoverageError) + const sources = mergeCoverage( + results.map((result) => VerifiedSourceCoverage.bind(result, root)), + ) + expect(() => auditCoverage(sources, ['source.ts'])).not.toThrow() + console.info( + JSON.stringify({ + case: 'actual-prebaseline-history', + observations: observations.map(({ capture, source, result }) => ({ + pid: result.value.pid, + code: capture.code, + map: capture.sourceMapURL, + profile: capture.profile, + blocks: capture.blocks, + functions: [source.foundFunctions, source.hitFunctions], + lines: [...source.lines], + })), + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it('refuses actual f-g versus f-h producer inputs when raw source, anonymous totals and range keys cannot prove compatibility', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-effective-')) + try { + const config = join(root, 'bunfig.toml') + const raw = + 'export function f() { return 1 }\nexport function g() { return 2 }\n' + writeFileSync( + config, + '[test]\npreload=["./transform.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync(join(root, 'source.ts'), raw) + writeFileSync( + join(root, 'transform.ts'), + `import {plugin} from 'bun';await plugin({name:'actual-effective-input',setup(build){build.onLoad({filter:/source\\.ts$/},()=>({contents:process.env.DEVUP_TEST_GROUP==='isolated'?${JSON.stringify(raw)}:${JSON.stringify(raw.replace('g()', 'h()'))},loader:'ts'}))}})`, + ) + writeFileSync( + join(root, 'a.test.ts'), + "import {it,expect} from 'bun:test';import {f,g} from './source';it('actual',()=>{expect(f()).toBe(1);expect(g()).toBe(2)})", + ) + writeFileSync( + join(root, 'b.test.ts'), + "import {it,expect} from 'bun:test';import {f} from './source';it('actual',()=>expect(f()).toBe(1))", + ) + const results: ProducerResult[] = [] + for (const [group, file] of [ + ['isolated', 'a.test.ts'], + ['runtime', 'b.test.ts'], + ] as const) { + results.push( + await captureProcess({ + root, + config, + coverage: join(root, group), + group, + files: [file], + executable: process.execPath, + }), + ) + } + const observations = results.map((result) => { + const capture = result.value.captures.find( + (item) => item.url === join(root, 'source.ts'), + ) + const source = parseLcov(result.value.lcov).find( + (item) => item.file === 'source.ts', + ) + if (!capture || !source) + throw new CoverageError('missing actual effective-input fixture') + return { capture, source } + }) + const first = observations[0] + const second = observations[1] + if (!first || !second) + throw new CoverageError('missing actual effective-input pair') + expect(readFileSync(join(root, 'source.ts'), 'utf8')).toBe(raw) + expect(observations.map((item) => item.source.foundFunctions)).toEqual([ + 2, 2, + ]) + expect([...first.source.lines.keys()]).toEqual([ + ...second.source.lines.keys(), + ]) + expect( + first.capture.profile?.functions + .map((fn) => fn.ranges[0]) + .map((range) => [range?.startOffset, range?.endOffset]), + ).toEqual( + second.capture.profile?.functions + .map((fn) => fn.ranges[0]) + .map((range) => [range?.startOffset, range?.endOffset]), + ) + expect(first.capture.code).not.toBe(second.capture.code) + expect(() => + mergeCoverage( + results.map((result) => VerifiedSourceCoverage.bind(result, root)), + ), + ).toThrow(CoverageError) + console.info( + JSON.stringify({ + case: 'actual-effective-input-countercase', + raw, + observations: observations.map(({ capture, source }) => ({ + code: capture.code, + map: capture.sourceMapURL, + profile: capture.profile, + blocks: capture.blocks, + functions: [source.foundFunctions, source.hitFunctions], + lines: [...source.lines], + })), + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-identity.test.ts b/packages/plugin-utils/src/__tests__/producer-identity.test.ts new file mode 100644 index 000000000..60b44f49d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-identity.test.ts @@ -0,0 +1,201 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { CoverageError } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { parseProducerIdentity } from '../../../../test-harness/producer-data' +import { captureProcess } from '../../../../test-harness/producer-process' + +const otherIdentity = + Bun.version === '1.4.0' + ? { bun: '1.4.2', revision: '744846f844374847c902b5e7fd59b4342a51ef99' } + : { bun: '1.4.0', revision: '34cbb9a40b4bd1bd767d134a7065e66c2432a676' } + +it('admits the actual qualified runtime only when its native coverage can be bound', async () => { + // Given: the real current executable, not a fabricated accepted identity. + const root = mkdtempSync(join(tmpdir(), 'devup-producer-qualified-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 1 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {value} from './source';it('actual',()=>expect(value()).toBe(1))", + ) + // When: the actual producer issues a receipt and native replay binds it. + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const source = VerifiedSourceCoverage.bind(result, root).find( + (source) => source.file === 'source.ts', + ) + // Then: both runtime identity and its executed native function are retained. + expect([result.value.bun, result.value.revision]).toEqual([ + Bun.version, + Bun.revision, + ]) + expect([source?.foundFunctions, source?.hitFunctions]).toEqual([1, 1]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) + +it.each([ + "Object.defineProperty(Bun,'version',{value:'unsupported'})", + "Object.defineProperty(Bun,'revision',{value:'unknown-revision'})", + "Object.defineProperty(process,'pid',{value:0})", +] as const)( + 'refuses actual metadata tampering before bootstrap admission when %s', + async (alteration) => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-identity-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./identity.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync(join(root, 'identity.ts'), alteration) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + await expect( + captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) + +it.each([ + { bun: 'unsupported' }, + { revision: 'unknown' }, + { pid: 0 }, + { config: 'stale' }, + { bun: otherIdentity.bun }, + { revision: otherIdentity.revision }, + otherIdentity, +])( + 'rejects the altered identity $0 derived from an actual producer receipt', + async (change) => { + const root = mkdtempSync( + join(tmpdir(), 'devup-producer-identity-boundary-'), + ) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const expected = { pid: result.value.pid, config: result.value.config } + const validate = () => + parseProducerIdentity({ ...result.value, ...change }, expected) + expect(validate).toThrow(CoverageError) + expect(validate).toThrow( + 'unknown/stale producer process or configuration identity', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) + +it.each(['bootstrap', 'final'] as const)( + 'refuses another qualified version/revision when actual code substitutes it at %s', + async (phase) => { + // Given: substituting a valid pair differs from an unknown or crossed pair. + const root = mkdtempSync(join(tmpdir(), 'devup-producer-mixed-version-')) + const alteration = `const original=process.stdout.write.bind(process.stdout);process.stdout.write=(chunk,...args)=>{const text=String(chunk);if(text.startsWith(process.env.DEVUP_PRODUCER_TOKEN+' ')&&text.includes(${JSON.stringify(`"phase":"${phase}"`)})){const separator=text.indexOf(' ');const record=JSON.parse(text.slice(separator+1));return original(text.slice(0,separator+1)+JSON.stringify({...record,...${JSON.stringify(otherIdentity)}})+'\\n',...args)}return original(chunk,...args)}` + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./identity.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync(join(root, 'identity.ts'), alteration) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + // When / Then: admission refuses before another lifetime can be issued. + await expect( + captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow( + 'unknown/stale producer process or configuration identity', + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) + +it('rejects duplicate final handshakes when actual test code emits an extra token-bearing record', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-duplicate-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';const original=process.stdout.write.bind(process.stdout);process.stdout.write=(chunk,...args)=>{const text=String(chunk);if(text.includes('\\\"phase\\\":\\\"final\\\"'))original(chunk,...args);return original(chunk,...args)};it('actual',()=>expect(1).toBe(1))", + ) + await expect( + captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-issuance.test.ts b/packages/plugin-utils/src/__tests__/producer-issuance.test.ts new file mode 100644 index 000000000..4d2ff7442 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-issuance.test.ts @@ -0,0 +1,245 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, beforeAll, expect, it } from 'bun:test' + +import { auditCoverage, mergeCoverage } from '../../../../test-harness/coverage' +import { + CoverageError, + parseLcov, + type SourceCoverage, +} from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import type { NativeView } from '../../../../test-harness/producer-native' +import { + captureProcess, + ProducerResult, +} from '../../../../test-harness/producer-process' + +const root = mkdtempSync(join(tmpdir(), 'devup-producer-issuance-')) +let partial: ProducerResult +let genuine: VerifiedSourceCoverage +let source: SourceCoverage +const invented: NativeView = { + code: 'invented executed input', + map: 'invented map', + input: 'invented source input', + functions: [ + { startOffset: 0, endOffset: 1, hasExecuted: true }, + { startOffset: 2, endOffset: 3, hasExecuted: true }, + ], + lines: new Map([[1, 1]]), + blockLines: new Set(), + coarse: new Map(), +} +beforeAll(async () => { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function first() { return 1 }; export function second() { return 2 }\n', + ) + writeFileSync( + join(root, 'partial.test.ts'), + "import {it,expect} from 'bun:test';import {first} from './source';it('partial',()=>expect(first()).toBe(1))", + ) + writeFileSync( + join(root, 'complete.test.ts'), + "import {it,expect} from 'bun:test';import {first,second} from './source';it('complete',()=>{expect(first()).toBe(1);expect(second()).toBe(2)})", + ) + partial = await captureProcess({ + root, + config, + coverage: join(root, 'partial'), + group: 'isolated', + files: ['partial.test.ts'], + executable: process.execPath, + }) + const native = parseLcov(partial.value.lcov).find( + (row) => row.file === 'source.ts', + ) + if (!native) throw new CoverageError('missing partial issuance witness') + source = native + const complete = await captureProcess({ + root, + config, + coverage: join(root, 'complete'), + group: 'isolated', + files: ['complete.test.ts'], + executable: process.execPath, + }) + const verified = VerifiedSourceCoverage.bind(complete, root).find( + (row) => row.file === 'source.ts', + ) + if (!verified) throw new CoverageError('missing genuine issuance witness') + genuine = verified +}, 120000) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +it.each(['native', 'exact-oracle'] as const)( + 'refuses reflected issuance when %s partial FNF2/FNH1 is paired with two invented executed functions', + (kind) => { + // Given: real partial native data, not an invented issuer receipt. + expect(source.foundFunctions).toBe(2) + expect(source.hitFunctions).toBe(1) + expect([...source.lines.keys()]).toEqual([1]) + expect(source.lines.get(1)).toBeGreaterThan(0) + const [exact] = parseLcov( + 'SF:a.ts\nFNF:2\nFNH:1\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + if (!exact) throw new CoverageError('missing exact oracle observation') + const observation = kind === 'native' ? source : exact + // When / Then: the original countercase cannot create a value for merge/audit. + expect(() => { + const forged: VerifiedSourceCoverage = Reflect.construct( + VerifiedSourceCoverage, + [observation, invented, partial.value.config], + ) + auditCoverage(mergeCoverage([[forged]]), [observation.file]) + }).toThrow(CoverageError) + }, +) + +it.each(['legacy', 'guessed'] as const)( + 'rejects %s reflected producer issuance before reading untrusted payload getters', + (shape) => { + // Given: a hostile value whose getters record whether the constructor touched it. + let reads = 0 + const payload = { + get captures() { + reads++ + return [] + }, + get lcov() { + reads++ + return partial.value.lcov + }, + } + const args = shape === 'legacy' ? [payload] : [Symbol('issuer'), payload] + // When / Then: neither the old signature nor a guessed capability issues anything. + expect(() => Reflect.construct(ProducerResult, args)).toThrow(CoverageError) + expect(reads).toBe(0) + }, +) + +it.each(['legacy', 'guessed'] as const)( + 'rejects %s reflected coverage issuance before reading untrusted source or view getters', + (shape) => { + // Given: getters must not run even when the attacker knows the new argument shape. + let reads = 0 + const hostile = { + get file() { + reads++ + return 'source.ts' + }, + get functions() { + reads++ + return invented.functions + }, + get source() { + reads++ + return source + }, + } + const args = + shape === 'legacy' + ? [hostile, hostile, 'config'] + : [Symbol('issuer'), hostile] + // When / Then: rejection is a provenance error, before payload evaluation. + expect(() => Reflect.construct(VerifiedSourceCoverage, args)).toThrow( + CoverageError, + ) + expect(reads).toBe(0) + }, +) + +it('refuses reflected producer data even when it copies every attribute of a genuine partial capture', () => { + // Given: genuine data is public, issuance authority is not. + const copied = { ...partial.value } + // When / Then: replaying attributes cannot manufacture another producer lifetime. + expect(() => { + const forged: ProducerResult = Reflect.construct(ProducerResult, [copied]) + VerifiedSourceCoverage.bind(forged, root) + }).toThrow(CoverageError) +}) + +it('refuses cross-class public payloads when prototypes and copied values are exchanged', () => { + // Given: neither class can issue values belonging to the other authority. + const producer: ProducerResult = Object.create( + VerifiedSourceCoverage.prototype, + { value: { value: partial.value } }, + ) + const coverage: SourceCoverage = Object.create( + ProducerResult.prototype, + Object.getOwnPropertyDescriptors(genuine), + ) + // When / Then: copied genuine data carries no issuance capability or membership. + expect(() => VerifiedSourceCoverage.bind(producer, root)).toThrow( + CoverageError, + ) + expect(() => mergeCoverage([[coverage]])).toThrow(CoverageError) + expect(() => auditCoverage([coverage], ['source.ts'])).toThrow(CoverageError) +}) + +it.each(['merge', 'audit', 'peer', 'receiver'] as const)( + 'refuses own-attribute coverage prototype spoof at %s before payload getters', + (boundary) => { + // Given: own attributes override all prototype getters and pretend strict100. + let reads = 0 + const spoof: VerifiedSourceCoverage = Object.create( + VerifiedSourceCoverage.prototype, + Object.fromEntries( + [ + 'file', + 'foundFunctions', + 'hitFunctions', + 'lines', + 'functions', + 'branches', + ].map((key) => [ + key, + { + get() { + reads++ + return key === 'file' + ? 'source.ts' + : key.endsWith('Functions') + ? 2 + : new Map([[1, 1]]) + }, + }, + ]), + ), + ) + // When: each boundary is driven directly, including an invalid merge receiver. + const actions = { + merge: () => mergeCoverage([[spoof]]), + audit: () => auditCoverage([spoof], ['source.ts']), + peer: () => genuine.merge(spoof), + receiver: () => + Reflect.apply(VerifiedSourceCoverage.prototype.merge, spoof, [genuine]), + } + // Then: refuse before trusting any public field or accessing private payload. + expect(actions[boundary]).toThrow(CoverageError) + expect(reads).toBe(0) + }, +) + +it('accepts genuine capture and merge while keeping genuine partial native coverage incomplete', () => { + // Given: both values originated from actual public producer captures. + const bound = VerifiedSourceCoverage.bind(partial, root).find( + (row) => row.file === 'source.ts', + ) + if (!bound) throw new CoverageError('missing genuine partial source') + // When / Then: genuine union remains accepted, partial alone remains below100. + expect(() => + auditCoverage(mergeCoverage([[genuine], [genuine]]), ['source.ts']), + ).not.toThrow() + expect(() => auditCoverage(mergeCoverage([[bound]]), ['source.ts'])).toThrow( + CoverageError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/producer-lifecycle.test.ts b/packages/plugin-utils/src/__tests__/producer-lifecycle.test.ts new file mode 100644 index 000000000..48cc72df1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-lifecycle.test.ts @@ -0,0 +1,183 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { auditCoverage } from '../../../../test-harness/coverage' +import { CoverageError } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { object, text } from '../../../../test-harness/producer-data' +import { + captureProcess, + ProducerResult, +} from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-fixed-lifecycle-')) + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\ncoverageSkipTestFiles=true\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() { return 42 }\n', + ) + const files = ['handler.test.ts', 'peer.test.ts'] as const + writeFileSync( + join(root, files[0]), + "import {writeSync} from 'node:fs';import {it,expect} from 'bun:test';import {value} from './source';process.on('SIGTERM',()=>{writeSync(1,'CLEANUP-HANDLER-47\\n');process.exit(47)});it('handler ready',()=>{writeSync(2,'HANDLER-READY\\n');expect(value()).toBe(42)})", + ) + writeFileSync( + join(root, files[1]), + "import {it,expect} from 'bun:test';import {value} from './source';it('peer',()=>expect(value()).toBe(42))", + ) + return { + root, + config, + coverage: join(root, 'coverage'), + group: 'runtime' as const, + files, + executable: process.execPath, + } +} + +function closedFence(propagate: boolean) { + let cause: CoverageError | undefined + const original = ProducerProtocol.prototype.armEndFence + const fence = spyOn( + ProducerProtocol.prototype, + 'armEndFence', + ).mockImplementation(async function ( + this: ProducerProtocol, + captures, + preload, + ) { + await original.call(this, captures, preload) + const notification = new Promise((resolve) => { + const observe = this.onFailure + this.onFailure = (error) => { + observe?.(error) + resolve(error) + } + }) + this.close() + cause = await notification + if (propagate) throw cause + }) + return { fence, cause: () => cause } +} + +function diagnostics(error: unknown, run: ReturnType) { + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBeInstanceOf(CoverageError) + const context = object(object(error)['diagnostics']) + expect(context['group']).toBe(run.group) + expect(context['files']).toEqual(run.files) + for (const file of run.files) expect(error.message).toContain(file) + expect(Buffer.byteLength(text(context['stdoutTail']))).toBeLessThanOrEqual( + 8192, + ) + expect(Buffer.byteLength(text(context['stderrTail']))).toBeLessThanOrEqual( + 8192, + ) + expect(text(context['stderrTail'])).toContain('HANDLER-READY') + return context +} + +it('rejects the latched real socket failure when the original fence completes before final acknowledgement', async () => { + // Given: an actual producer waits for its final acknowledgement after the original fence. + const run = fixture() + const wire = closedFence(false) + try { + // When: the real socket closes and delivers onFailure before the fence wrapper returns. + const outcome = await captureProcess(run).then( + (result) => result, + (error: unknown) => error, + ) + console.info( + JSON.stringify({ + case: 'actual-post-fence-pre-ack', + observed: + outcome instanceof ProducerResult ? 'issued-receipt' : 'rejected', + cause: String(wire.cause()), + }), + ) + // Then: no trusted receipt escapes and the contextual failure preserves the same cause. + expect(outcome).toBeInstanceOf(CoverageError) + if (!(outcome instanceof CoverageError)) throw outcome + expect(outcome.cause).toBe(wire.cause()) + const context = diagnostics(outcome, run) + console.info(JSON.stringify({ case: 'actual-latched-failure', context })) + } finally { + wire.fence.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } +}, 120000) + +it('retains only observed pre-cleanup origin when a real SIGTERM handler can change the final exit', async () => { + // Given: the real child has installed a SIGTERM handler that exits47 with an output marker. + const run = fixture() + const wire = closedFence(true) + try { + // When: the real socket failure is propagated while the child still waits for final ack. + const error = await captureProcess(run).then( + () => null, + (error: unknown) => error, + ) + // Then: our cleanup cannot manufacture a natural status from the resulting exit code. + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBe(wire.cause()) + const context = diagnostics(error, run) + const final = object(context['finalExit']) + const stdout = text(context['stdoutTail']) + expect(context['cleanupRequested']).toBe(true) + expect(context['exitBeforeCleanup']).toBeNull() + expect(context['naturalExit']).toBeNull() + if (process.platform === 'win32' && final['code'] !== 47) { + expect(final).toEqual({ code: null, signal: 'SIGTERM' }) + expect(stdout).not.toContain('CLEANUP-HANDLER-47') + } else { + expect(final).toEqual({ code: 47, signal: null }) + expect(stdout).toContain('CLEANUP-HANDLER-47') + } + console.info( + JSON.stringify({ + case: 'actual-cleanup-handler', + platform: process.platform, + context, + }), + ) + } finally { + wire.fence.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } +}, 120000) + +it('issues verified native coverage when the real child shuts down normally after final acknowledgement', async () => { + // Given: the same real sources and handler, with the original inspector flow left intact. + const run = fixture() + try { + // When: the ordinary final acknowledgement releases the producer. + const result = await captureProcess(run) + // Then: terminal socket shutdown still permits a genuine strictly covered receipt. + expect(result.value.status).toBe(0) + expect(() => + auditCoverage(VerifiedSourceCoverage.bind(result, run.root), [ + 'source.ts', + ]), + ).not.toThrow() + console.info( + JSON.stringify({ + case: 'actual-normal-shutdown', + bun: result.value.bun, + pid: result.value.pid, + status: result.value.status, + }), + ) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-merge.test.ts b/packages/plugin-utils/src/__tests__/producer-merge.test.ts new file mode 100644 index 000000000..977eeb6ca --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-merge.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { parseLcov } from '../../../../test-harness/lcov' +import { runTestGroups } from '../../../../test-harness/run' + +it('removes only proven coarse blank spans when real complementary functions cover the same mapped source', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-spans-')) + const output = spyOn(console, 'info').mockImplementation(() => undefined) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\ncoveragePathIgnorePatterns=["**/pkg/**","**/dist/**"]\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function first() {\n\n return 1\n}\nexport function second() {\n\n return 2\n}\n', + ) + for (const [name, fn, value] of [ + ['a', 'first', 1], + ['b', 'second', 2], + ] as const) { + writeFileSync( + join(root, `${name}.test.ts`), + `import {it,expect} from 'bun:test';import {${fn}} from './source';it('actual',()=>expect(${fn}()).toBe(${value}))`, + ) + } + const summary = await runTestGroups({ + root, + config, + groups: { isolated: ['a.test.ts'], runtime: ['b.test.ts'] }, + required: ['source.ts'], + }) + const source = parseLcov( + readFileSync(join(root, 'coverage/lcov.info'), 'utf8'), + ).find((item) => item.file === 'source.ts') + expect(summary).toContain('2 pass\n0 fail') + expect(source?.hitFunctions).toBe(2) + expect(source?.foundFunctions).toBe(2) + expect(source?.lines.has(2)).toBe(false) + expect(source?.lines.has(6)).toBe(false) + expect(source?.lines.get(3)).toBeGreaterThan(0) + expect(source?.lines.get(7)).toBeGreaterThan(0) + } finally { + output.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-native.test.ts b/packages/plugin-utils/src/__tests__/producer-native.test.ts new file mode 100644 index 000000000..ff003f5b6 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-native.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { + reconstructNative, + validateNative, +} from '../../../../test-harness/producer-native' +import { captureProcess } from '../../../../test-harness/producer-process' + +it('replays every native DA pair and seven actual counted functions when nested async generator and inverted public ranges occur', async () => { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-native-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function f() { return 1 }\nexport function h() { return 2 }\nexport function nested() { return () => 3 }\nexport async function asyncValue() { return 4 }\nexport function* generator() { yield 5 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {f,nested,asyncValue,generator} from './source';it('actual',async()=>{expect(f()).toBe(1);expect(nested()()).toBe(3);expect(await asyncValue()).toBe(4);expect(generator().next().value).toBe(5)})", + ) + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const source = parseLcov(result.value.lcov).find( + (item) => item.file === 'source.ts', + ) + const capture = result.value.captures.find( + (item) => item.url === join(root, 'source.ts'), + ) + if (!source || !capture) + throw new CoverageError('missing actual native fixture') + const view = reconstructNative(capture, source) + expect([source.foundFunctions, source.hitFunctions]).toEqual([7, 6]) + expect(capture.profile?.functions.length).toBe(9) + expect( + capture.blocks.some((block) => block.endOffset <= block.startOffset), + ).toBe(true) + expect([...view.lines].sort(([a], [b]) => a - b)).toEqual( + [...source.lines].sort(([a], [b]) => a - b), + ) + expect(() => validateNative(view, source)).not.toThrow() + console.info( + JSON.stringify({ + case: 'actual-native-seven-functions', + pid: result.value.pid, + code: capture.code, + map: capture.sourceMapURL, + profile: capture.profile, + blocks: capture.blocks, + functions: [source.foundFunctions, source.hitFunctions], + lines: [...source.lines], + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/producer-process.test.ts b/packages/plugin-utils/src/__tests__/producer-process.test.ts new file mode 100644 index 000000000..9cfd6ba61 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-process.test.ts @@ -0,0 +1,269 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { auditCoverage } from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { object, text } from '../../../../test-harness/producer-data' +import { captureProcess } from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' + +function fixture(termination: string) { + const root = mkdtempSync(join(tmpdir(), 'devup-producer-failure-')) + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\npreload=["./failure.ts"]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'failure.ts'), + `import {writeSync} from 'node:fs';writeSync(1,'OUT-BEGIN'+'o'.repeat(10000)+'OUT-END\\n');writeSync(2,'ERR-BEGIN'+'e'.repeat(10000)+'ERR-END\\n');${termination}`, + ) + const files = ['first.test.ts', 'second.test.ts'] + for (const file of files) + writeFileSync( + join(root, file), + "import {it,expect} from 'bun:test';it('actual',()=>expect(1).toBe(1))", + ) + return { + root, + config, + coverage: join(root, 'coverage'), + group: 'runtime' as const, + files, + executable: process.execPath, + } +} + +function diagnostics(error: unknown, run: ReturnType) { + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBeInstanceOf(Error) + const context = object(object(error)['diagnostics']) + expect(context['group']).toBe(run.group) + expect(context['files']).toEqual(run.files) + for (const file of run.files) expect(error.message).toContain(file) + const stdout = text(context['stdoutTail']) + const stderr = text(context['stderrTail']) + expect(Buffer.byteLength(stdout)).toBeLessThanOrEqual(8192) + expect(Buffer.byteLength(stderr)).toBeLessThanOrEqual(8192) + expect(stdout).not.toContain('ERR-END') + expect(stderr).not.toContain('OUT-END') + expect(stdout).not.toContain('OUT-BEGIN') + expect(stderr).not.toContain('ERR-BEGIN') + console.info(JSON.stringify({ case: 'actual-producer-failure', context })) + return context +} + +it.each([ + ['early exit', 'process.exit(23)', 23], + ['abrupt crash', 'process.abort()', null], +] as const)( + 'retains actual final status and both drained tails when a real producer suffers %s before completion', + async (_name, termination, code) => { + // Given: two actual test files and a real preload that exits/crashes. + const run = fixture(termination) + try { + // When: the actual producer fails without issuing final evidence. + const error = await captureProcess(run).then( + () => null, + (error: unknown) => error, + ) + // Then: actual final status survives, with natural origin limited to pre-cleanup observation. + const context = diagnostics(error, run) + expect(text(context['stdoutTail'])).toContain('OUT-END') + expect(text(context['stderrTail'])).toContain('ERR-END') + const final = object(context['finalExit']) + if (code === 23) expect(final).toEqual({ code: 23, signal: null }) + else { + expect(final['code'] !== 0 || final['signal'] !== null).toBe(true) + expect(final['signal']).not.toBe('SIGTERM') + } + expect(context['naturalExit']).toEqual(context['exitBeforeCleanup']) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } + }, + 120000, +) + +it('preserves the socket cause and labels cleanup separately when the real child is still alive', async () => { + // Given: the real inspector closes during collection while the child waits. + const run = fixture('') + let cause: unknown + const original = ProducerProtocol.prototype.request + const request = spyOn( + ProducerProtocol.prototype, + 'request', + ).mockImplementation(async function ( + this: ProducerProtocol, + method, + params = {}, + ) { + if (method === 'Debugger.getScriptSource') { + this.close() + try { + return await original.call(this, method, params) + } catch (error) { + cause = error + throw error + } + } + return original.call(this, method, params) + }) + try { + // When: a real socket closes before the final capture completes. + const error = await captureProcess(run).then( + () => null, + (error: unknown) => error, + ) + // Then: own cleanup is never represented as a natural crash. + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBe(cause) + const context = diagnostics(error, run) + expect(context['naturalExit']).toBeNull() + expect(context['cleanupRequested']).toBe(true) + expect(object(context['finalExit'])).toHaveProperty('signal') + } finally { + request.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } +}, 120000) + +it('rejects a real socket close when the child is alive with no pending RPC', async () => { + // Given: a real child and inspector, with bootstrap observation still pending. + const run = fixture('') + const original = ProducerProtocol.prototype.request + const request = spyOn( + ProducerProtocol.prototype, + 'request', + ).mockImplementation(async function ( + this: ProducerProtocol, + method, + params = {}, + ) { + const result = await original.call(this, method, params) + if (method === 'Inspector.initialized') this.close() + return result + }) + try { + // When: the real socket closes outside an active request. + const error = await captureProcess(run).then( + () => null, + (error: unknown) => error, + ) + // Then: failure rejects promptly with its wire cause instead of waiting for timeout. + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBeInstanceOf(CoverageError) + expect(String(error.cause)).toContain('producer socket closed') + expect(object(object(error)['diagnostics'])['files']).toEqual(run.files) + } finally { + request.mockRestore() + rmSync(run.root, { recursive: true, force: true }) + } +}, 5000) + +it.each([32, 550])( + 'retains every real native module when the producer loads %s unique sources', + async (count) => { + // Given: unique real source files, all loaded and called by the actual child. + const root = mkdtempSync(join(tmpdir(), 'devup-producer-scale-')) + try { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + const files = Array.from( + { length: count }, + (_, index) => `module-${index}.ts`, + ) + for (const [index, file] of files.entries()) + writeFileSync( + join(root, file), + `export function value() { return ${index} }\n`, + ) + writeFileSync( + join(root, 'scale.test.ts'), + `import {it,expect} from 'bun:test';it('actual modules',async()=>{for(const [index,file] of ${JSON.stringify(files)}.entries()){const source=await import('./'+file);expect(source.value()).toBe(index)}})`, + ) + // When: the real producer/protocol captures code, maps, blocks and native rows. + const result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['scale.test.ts'], + executable: process.execPath, + }) + const bound = VerifiedSourceCoverage.bind(result, root) + // Then: the unchanged auditor requires every original source at strict100. + expect(result.value.status).toBe(0) + expect(() => auditCoverage(bound, files)).not.toThrow() + expect( + parseLcov(result.value.lcov).filter((source) => + files.includes(source.file), + ).length, + ).toBe(count) + const witnesses = files.map((file) => { + const capture = result.value.captures.find( + (capture) => capture.url === join(root, file), + ) + if (!capture) + throw new CoverageError(`missing actual scale witness ${file}`) + return { + file, + sourceID: capture.scriptId, + blocks: capture.blocks.length, + code: createHash('sha256').update(capture.code).digest('hex'), + map: createHash('sha256').update(capture.sourceMapURL).digest('hex'), + } + }) + expect(new Set(witnesses.map((witness) => witness.sourceID)).size).toBe( + count, + ) + console.info( + JSON.stringify({ + case: 'actual-producer-scale', + count, + bun: result.value.bun, + revision: result.value.revision, + pid: result.value.pid, + nativeSources: bound.length, + witnesses, + }), + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 600000, +) + +it('retains the original spawn error without waiting for a nonexistent exit event when the executable is missing', async () => { + // Given: a real spawn of a nonexistent executable. + const run = fixture('') + try { + // When: the operating system refuses the launch. + const error = await captureProcess({ + ...run, + executable: join(run.root, 'missing-executable'), + }).then( + () => null, + (error: unknown) => error, + ) + // Then: contextual failure retains the actual OS cause and every file. + if (!(error instanceof CoverageError)) throw error + expect(error.cause).toBeInstanceOf(Error) + expect(String(error.cause)).toContain('missing-executable') + const context = object(object(error)['diagnostics']) + expect(context['files']).toEqual(run.files) + expect(context['naturalExit']).toBeNull() + expect(context['cleanupRequested']).toBe(false) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}, 120000) +import { createHash } from 'node:crypto' diff --git a/packages/plugin-utils/src/__tests__/producer-protocol.test.ts b/packages/plugin-utils/src/__tests__/producer-protocol.test.ts new file mode 100644 index 000000000..5b465ff58 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-protocol.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from 'bun:test' + +import { CoverageError } from '../../../../test-harness/lcov' +import { object } from '../../../../test-harness/producer-data' +import { + connectProducer, + type ProducerProtocol, +} from '../../../../test-harness/producer-protocol' + +type WireMode = 'success' | 'rejected' | 'malformed' | 'silent' | 'closed' + +function wire(mode: WireMode) { + return Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(request, server) { + if (server.upgrade(request)) return undefined + return new Response('upgrade required', { status: 400 }) + }, + websocket: { + message(socket, data) { + const request = object(JSON.parse(String(data))) + if (request['method'] !== 'Probe') { + socket.send(JSON.stringify({ id: request['id'], result: {} })) + return + } + switch (mode) { + case 'success': + socket.send( + JSON.stringify({ method: 'Console.messagesCleared', params: {} }), + ) + socket.send( + JSON.stringify({ id: request['id'], result: { value: 42 } }), + ) + return + case 'rejected': + socket.send( + JSON.stringify({ + id: request['id'], + error: { code: -32601, message: 'unsupported' }, + }), + ) + return + case 'malformed': + socket.send('not-json') + return + case 'silent': + return + case 'closed': + socket.close() + return + default: { + const exhaustive: never = mode + throw new Error(`unreachable wire mode ${exhaustive}`) + } + } + }, + }, + }) +} + +it('returns only the matched RPC response when a real wire also emits an event', async () => { + const server = wire('success') + let protocol: ProducerProtocol | undefined + try { + protocol = await connectProducer(`ws://127.0.0.1:${server.port}`) + const result = await protocol.request('Probe') + expect(result).toEqual({ value: 42 }) + expect( + protocol.events.some( + (event) => event['method'] === 'Console.messagesCleared', + ), + ).toBe(true) + } finally { + protocol?.close() + await server.stop(true) + } +}) + +it.each(['rejected', 'malformed', 'silent', 'closed'] as const)( + 'rejects the real %s transport response instead of emitting trusted producer data', + async (mode) => { + const server = wire(mode) + let protocol: ProducerProtocol | undefined + try { + protocol = await connectProducer(`ws://127.0.0.1:${server.port}`) + await expect(protocol.request('Probe')).rejects.toThrow(Error) + } finally { + protocol?.close() + await server.stop(true) + } + }, + 30000, +) + +it('rejects actual connection refusal when the endpoint has stopped', async () => { + const server = wire('success') + const url = `ws://127.0.0.1:${server.port}` + await server.stop(true) + await expect(connectProducer(url)).rejects.toThrow(CoverageError) +}) diff --git a/packages/plugin-utils/src/__tests__/producer-provenance.test.ts b/packages/plugin-utils/src/__tests__/producer-provenance.test.ts new file mode 100644 index 000000000..a84da49e6 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-provenance.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from 'bun:test' + +import { auditCoverage, mergeCoverage } from '../../../../test-harness/coverage' +import { + CoverageError, + type SourceCoverage, +} from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { ProducerResult } from '../../../../test-harness/producer-process' + +it.each(['prototype', 'foreign'] as const)( + 'refuses %s producer spoof at bind before its own value getter', + (kind) => { + // Given: unissued prototype/foreign data deliberately throws if its payload is read. + let reads = 0 + class ForeignProducer {} + const spoof: ProducerResult = Object.create( + kind === 'prototype' + ? ProducerResult.prototype + : ForeignProducer.prototype, + { + value: { + get() { + reads++ + throw new TypeError('untrusted payload read') + }, + }, + }, + ) + // When / Then: runtime membership, not prototype identity, controls admission. + expect(() => VerifiedSourceCoverage.bind(spoof, '.')).toThrow(CoverageError) + expect(reads).toBe(0) + }, +) + +it('refuses producer value access when its genuine getter is applied to an unissued prototype', () => { + // Given: a public getter cannot serve as an alternate receipt issuer. + const getter = Object.getOwnPropertyDescriptor( + ProducerResult.prototype, + 'value', + )?.get + if (!getter) throw new CoverageError('missing producer value getter') + const spoof: ProducerResult = Object.create(ProducerResult.prototype) + // When / Then: provenance rejection precedes private field access. + expect(() => Reflect.apply(getter, spoof, [])).toThrow(CoverageError) +}) + +it.each(['merge', 'audit'] as const)( + 'refuses foreign coverage attributes at %s before their file getter', + (boundary) => { + // Given: structurally compatible foreign data is not an issued source. + let reads = 0 + class ForeignCoverage { + get file() { + reads++ + return 'source.ts' + } + } + const spoof: SourceCoverage = Object.assign(new ForeignCoverage(), { + foundFunctions: 2, + hitFunctions: 2, + lines: new Map([[1, 1]]), + functions: new Map(), + branches: new Map(), + }) + // When / Then: neither public boundary reads the untrusted file. + expect(() => + boundary === 'merge' + ? mergeCoverage([[spoof]]) + : auditCoverage([spoof], ['source.ts']), + ).toThrow(CoverageError) + expect(reads).toBe(0) + }, +) diff --git a/packages/plugin-utils/src/__tests__/producer-validation.test.ts b/packages/plugin-utils/src/__tests__/producer-validation.test.ts new file mode 100644 index 000000000..35a917502 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/producer-validation.test.ts @@ -0,0 +1,248 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, beforeAll, expect, it } from 'bun:test' + +import { auditCoverage, mergeCoverage } from '../../../../test-harness/coverage' +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { + array, + boolean, + integer, + object, + parseBlocks, + parseProfiles, + type ScriptCapture, + text, +} from '../../../../test-harness/producer-data' +import { decodeMap } from '../../../../test-harness/producer-map' +import { + reconstructNative, + validateNative, +} from '../../../../test-harness/producer-native' +import { + captureProcess, + type ProducerResult, +} from '../../../../test-harness/producer-process' + +const root = mkdtempSync(join(tmpdir(), 'devup-producer-validation-')) +let result: ProducerResult +let capture: ScriptCapture +beforeAll(async () => { + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function first() { return 1 }\nexport function second() { return 2 }\n', + ) + writeFileSync( + join(root, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {first,second} from './source';it('actual',()=>{expect(first()).toBe(1);expect(second()).toBe(2)})", + ) + result = await captureProcess({ + root, + config, + coverage: join(root, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const source = result.value.captures.find( + (capture) => capture.url === join(root, 'source.ts'), + ) + if (!source) throw new CoverageError('missing actual validation fixture') + capture = source +}, 120000) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +it('binds every actual native counted function when public synthetic and module entries are removed correctly', () => { + const bound = VerifiedSourceCoverage.bind(result, root).find( + (source) => source.file === 'source.ts', + ) + expect(bound?.foundFunctions).toBe(2) + expect(bound?.hitFunctions).toBe(2) + expect(capture.profile?.functions.length).toBe(4) +}) + +it.each([ + () => object(null), + () => object([]), + () => text(1), + () => integer(-1), + () => integer(1.5), + () => boolean('true'), + () => array(null), + () => parseProfiles([{}]), + () => parseBlocks([{}]), +])('refuses malformed public data at its parsing boundary', (action) => + expect(action).toThrow(CoverageError), +) + +it.each([ + () => reconstructNative({ ...capture, profile: undefined }), + () => reconstructNative({ ...capture, blocks: [] }), + () => + reconstructNative({ ...capture, blocks: [...capture.blocks].reverse() }), + () => + reconstructNative({ + ...capture, + code: capture.code.replace('sourceMappingURL', 'missingSourceMap'), + }), + () => + reconstructNative({ + ...capture, + sourceMapURL: 'data:application/json;base64,e30=', + }), + () => decodeMap(capture.code, ''), +])( + 'refuses contradictory or unmapped real producer evidence instead of inferring identities', + (action) => expect(action).toThrow(CoverageError), +) + +it('refuses a stale native function total despite actual public code and ranges', () => { + const source = parseLcov(result.value.lcov).find( + (source) => source.file === 'source.ts', + ) + if (!source) throw new CoverageError('missing native fixture record') + expect(() => + validateNative(reconstructNative(capture), { + ...source, + foundFunctions: source.foundFunctions + 1, + }), + ).toThrow(CoverageError) +}) + +it('refuses named public functions when a corrupted copy of the actual anonymous profile supplies them', () => { + const profile = capture.profile + if (!profile) throw new CoverageError('missing actual fixture profile') + expect(() => + parseProfiles([ + { + ...profile, + functions: profile.functions.map((fn) => ({ + ...fn, + functionName: 'unsupported', + })), + }, + ]), + ).toThrow(CoverageError) +}) + +it('refuses named LCOV data instead of assigning native anonymous functions an invented name', () => { + const [named] = parseLcov( + 'SF:untrusted.ts\nFN:1,untrusted\nFNDA:1,untrusted\nFNF:1\nFNH:1\nDA:1,1\nLF:1\nLH:1\nend_of_record', + ) + if (!named) throw new CoverageError('missing invalid observation') + expect(() => validateNative(reconstructNative(capture), named)).toThrow( + CoverageError, + ) +}) + +it('refuses contradictory native zero hits against the actual mapped block evidence', () => { + const source = parseLcov(result.value.lcov).find( + (source) => source.file === 'source.ts', + ) + if (!source) throw new CoverageError('missing native fixture record') + expect(() => + validateNative(reconstructNative(capture), { + ...source, + lines: new Map([...source.lines].map(([line]) => [line, 0])), + }), + ).toThrow(CoverageError) +}) + +it('retains native identity when the documented public profile omits a collected script', () => { + const source = parseLcov(result.value.lcov).find( + (source) => source.file === 'source.ts', + ) + if (!source) throw new CoverageError('missing native fixture record') + const view = reconstructNative({ ...capture, profile: undefined }, source) + expect(() => validateNative(view, source)).not.toThrow() + expect(view.functions.length).toBe(2) +}) + +it('rejects a missing public frame when no possible tail reproduces the native function inventory', () => { + const source = parseLcov(result.value.lcov).find( + (source) => source.file === 'source.ts', + ) + if (!source) throw new CoverageError('missing native fixture record') + expect(() => + reconstructNative( + { ...capture, profile: undefined }, + { ...source, foundFunctions: source.foundFunctions + 10 }, + ), + ).toThrow(CoverageError) +}) + +it('prevents native row removal from a captured receipt instead of trusting its class prototype', () => { + const value = result.value + const original = value.lcov + try { + expect(() => + Object.defineProperty(value, 'lcov', { + value: original.replace( + /TN:\nSF:source\.ts\n[\s\S]*?end_of_record\n/, + '', + ), + configurable: true, + }), + ).toThrow() + } finally { + if (value.lcov !== original) + Object.defineProperty(value, 'lcov', { + value: original, + configurable: true, + }) + } +}) + +it('preserves native lines when a consumer mutates the exposed line collection', () => { + const bound = VerifiedSourceCoverage.bind(result, root).find( + (source) => source.file === 'source.ts', + ) + if (!bound) throw new CoverageError('missing verified fixture') + const expected = [...bound.lines] + bound.lines.clear() + expect([...bound.lines]).toEqual(expected) +}) + +it('rejects incomplete coverage per actual source even when its sibling source is fully covered', async () => { + const local = mkdtempSync(join(tmpdir(), 'devup-producer-coarse-')) + try { + const config = join(local, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\n', + ) + writeFileSync( + join(local, 'source.ts'), + 'export function first() {\n return 1\n}\nexport function second() {\n return 2\n}\n', + ) + writeFileSync( + join(local, 'source.test.ts'), + "import {it,expect} from 'bun:test';import {first} from './source';it('actual',()=>expect(first()).toBe(1))", + ) + const partial = await captureProcess({ + root: local, + config, + coverage: join(local, 'coverage'), + group: 'isolated', + files: ['source.test.ts'], + executable: process.execPath, + }) + const sources = VerifiedSourceCoverage.bind(partial, local) + const view = sources.find((source) => source.file === 'source.ts') + expect(view?.hitFunctions).toBe(1) + expect(view?.foundFunctions).toBe(2) + expect(() => + auditCoverage(mergeCoverage([sources]), ['source.ts']), + ).toThrow(CoverageError) + } finally { + rmSync(local, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-best-known.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-best-known.test.ts new file mode 100644 index 000000000..2283c48bf --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-best-known.test.ts @@ -0,0 +1,121 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import type { PrepareSource } from '../import-graph' +import { PreparedSourceTypeError } from '../prepared-source' +import { collectProductionFileManifest } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' + +it('rejects invalid prepared source types despite reserve-only opt-in', async () => { + // Given a malformed boundary object, not missing compiler output. + const prepared = Object.defineProperty({ code: 'export {}' }, 'sourceType', { + value: 'invalid-type', + }) + const selected = { + ...context(() => prepared), + unpreparedMarkdown: 'reserve-only' as const, + } + // When the actual prepared-source parser receives that object. + const operation = collectProductionFileManifest({ contexts: [selected] }) + // Then reserve-only cannot turn invalid preparation into physical-only success. + await expect(operation).rejects.toMatchObject({ + cause: expect.any(PreparedSourceTypeError), + }) +}) + +let root: string +let page: string +let leaf: string +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'devup-v1-best-known-'))) + page = join(root, 'page.mdx') + leaf = join(root, 'leaf.js') + await writeFile(page, '# Documentation\n\nimport "./missing.js"') + await writeFile(leaf, 'export const value=1') +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) +function context(prepareSource?: PrepareSource) { + return { + key: 'native', + files: enumerateProductionSourceFiles({ + roots: [], + sourceFiles: [page], + includeMdx: true, + }), + resolverOptions: { cwd: root, includeMdx: true }, + toId: (path: string) => path.replaceAll('\\', '/'), + ...(prepareSource === undefined ? {} : { prepareSource }), + } +} + +it('keeps missing Markdown preparation fatal under the strict default', async () => { + // Given selected actual Markdown without compiler output. + const selected = context() + // When collecting without the integration opt-in. + const operation = collectProductionFileManifest({ contexts: [selected] }) + // Then source-located strict rejection survives. + await expect(operation).rejects.toThrow(`${page}:1:1`) +}) + +it('reserves unprepared Markdown without reading it as JavaScript when opted in', async () => { + // Given a selected source whose raw text contains an unresolved import. + const observed: string[] = [] + const selected = { + ...context(), + ...{ unpreparedMarkdown: 'reserve-only' as const }, + resolverOptions: { + cwd: root, + includeMdx: true, + onResolutionInputs(inputs: { + readonly fileDependencies: readonly string[] + }) { + observed.push(...inputs.fileDependencies) + }, + }, + } + // When reservation uses the explicit integration policy. + const files = await collectProductionFileManifest({ contexts: [selected] }) + // Then exact physical membership and source observation survive without fake compiled output. + expect(files.map(({ path }) => path)).toEqual([page]) + expect(observed).toContain(page) + expect(await readFile(page, 'utf8')).toBe( + '# Documentation\n\nimport "./missing.js"', + ) +}) + +it.each(['', "export {value} from './leaf.js'"])( + 'scans genuine prepared JavaScript %j rather than treating it as unprepared', + async (code) => { + // Given actual supplied compiled output under the Markdown identity. + const selected = { + ...context((path) => (path === page ? code : undefined)), + ...{ unpreparedMarkdown: 'reserve-only' as const }, + } + // When the prepared branch collects ordinary compiler edges. + const files = await collectProductionFileManifest({ contexts: [selected] }) + // Then only the real prepared import can add the physical child. + expect(files.map(({ path }) => path).sort()).toEqual( + (code ? [leaf, page] : [page]).sort(), + ) + }, +) + +it('propagates compiler rejection despite reserve-only opt-in', async () => { + // Given an actual asynchronous preparation fault. + const cause = new RangeError('compiler failed') + const selected = { + ...context(async () => { + throw cause + }), + ...{ unpreparedMarkdown: 'reserve-only' as const }, + } + // When preparation fails before any reservation fallback. + const operation = collectProductionFileManifest({ contexts: [selected] }) + // Then the existing preparation failure retains its cause. + await expect(operation).rejects.toMatchObject({ cause }) +}) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-cycles.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-cycles.test.ts new file mode 100644 index 000000000..6ac1d5fad --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-cycles.test.ts @@ -0,0 +1,244 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { compareCodePoints } from '../import-graph' +import type { ProductionFileManifestOptions } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' + +async function collectProductionFileManifest( + options: ProductionFileManifestOptions, +) { + return ( + await import('../production-file-manifest') + ).collectProductionFileManifest(options) +} + +function memberships(paths: readonly string[], context: string) { + return paths + .map((path) => ({ + context, + path, + realPath: realpathSync.native(path), + id: path, + })) + .sort( + (a, b) => + compareCodePoints(a.realPath, b.realPath) || + compareCodePoints(a.path, b.path), + ) +} + +async function fixture( + run: ( + root: string, + file: (path: string, code?: string) => string, + ) => Promise, +) { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-p2-cycles-')), + ) + const file = (path: string, code = 'export {}') => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, code) + return target + } + try { + await run(root, file) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it.each([false, true])( + 'retains a finite growing backedge with reversed seeds=%s', + async (reverse) => { + await fixture(async (root, file) => { + // Given a real directory self-link and an import growing its lexical spelling. + const source = file('physical/main.ts', "import './again/main.ts'") + const plain = file('plain.ts') + symlinkSync(dirname(source), join(root, 'physical/again'), 'junction') + const files = enumerateProductionSourceFiles({ + roots: [], + sourceFiles: [source, plain], + }) + // When file recursion meets the same active physical source. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'cycle', + files: reverse ? files.toReversed() : files, + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then the selected physical backedge is reserved once without infinite lexical expansion. + const back = join(root, 'physical/again/main.ts') + expect(result).toEqual(memberships([source, back, plain], 'cycle')) + }) + }, +) + +it('keeps both nearest package copies when physical importers have two lexical aliases', async () => { + await fixture(async (root, file) => { + // Given two independent aliases to one importer, with distinct ancestor installations. + const physical = file('physical/main.ts', "import 'palette'") + const targets = ['left', 'right'].map((side) => { + file(`${side}/node_modules/palette/package.json`, '{"main":"value.js"}') + const target = file(`${side}/node_modules/palette/value.js`) + symlinkSync(dirname(physical), join(root, `${side}/alias`), 'junction') + return target + }) + const files = enumerateProductionSourceFiles({ + roots: [], + sourceFiles: ['left/alias/main.ts', 'right/alias/main.ts'], + cwd: root, + }) + const context = { + key: 'aliases', + files, + resolverOptions: { cwd: root }, + toId: (path: string) => path, + } + // When the two seed orders independently close their importer-sensitive dependencies. + const first = await collectProductionFileManifest({ contexts: [context] }) + const second = await collectProductionFileManifest({ + contexts: [{ ...context, files: files.toReversed() }], + }) + // Then neither global physical visitation nor arrival order can discard a target. + expect(second).toEqual(first) + expect(first).toEqual( + memberships([...files.map(({ path }) => path), ...targets], 'aliases'), + ) + }) +}) + +it('replays local adjacency when an earlier incoming ancestry cut a later alias', async () => { + await fixture(async (root, file) => { + // Given A -> B -> alias(A); reaching B first must expand alias(A)'s nearest package. + const a = file('physical/a.ts', "import 'palette'; import '../bridge/b.ts'") + const b = file('bridge/b.ts', "import '../alternate/alias/a.ts'") + file('alternate/node_modules/palette/package.json', '{"main":"value.js"}') + const leaf = file('alternate/node_modules/palette/value.js') + symlinkSync(dirname(a), join(root, 'alternate/alias'), 'junction') + const files = enumerateProductionSourceFiles({ + roots: [], + sourceFiles: [a, b], + }) + const context = { + key: 'replay', + files, + resolverOptions: { cwd: root }, + toId: (path: string) => path, + } + // When independent incoming paths revisit a lexical node after a physical backedge cut. + const first = await collectProductionFileManifest({ contexts: [context] }) + const second = await collectProductionFileManifest({ + contexts: [{ ...context, files: files.toReversed() }], + }) + // Then ancestry-pruned subtree memoization cannot hide the alternate lexical dependency. + expect(second).toEqual(first) + expect(first).toEqual( + memberships([a, b, leaf, join(root, 'alternate/alias/a.ts')], 'replay'), + ) + }) +}) + +it.each([false, true])( + 'cuts package-link recursion with reversed seeds=%s', + async (reverse) => { + await fixture(async (root, file) => { + // Given a physically recursive admitted package installation. + const entry = file('src/main.ts') + const plain = file('src/plain.ts') + file('node_modules/@devup-ui/ui/package.json', '{}') + const source = file('node_modules/@devup-ui/ui/view.ts') + const scope = join( + root, + 'node_modules/@devup-ui/ui/node_modules/@devup-ui', + ) + mkdirSync(scope, { recursive: true }) + symlinkSync(dirname(source), join(scope, 'ui'), 'junction') + // When distribution jobs inherit active physical-package ancestry. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'packages', + files: [ + ...enumerateProductionSourceFiles({ roots: ['src'], cwd: root }), + ].sort((a, b) => + reverse + ? b.path.localeCompare(a.path) + : a.path.localeCompare(b.path), + ), + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then the original finite package inventory is enough; no recursive synthetic aliases exist. + expect(result).toEqual(memberships([entry, source, plain], 'packages')) + }) + }, +) + +it.each([3, 6, 9])( + 'closes an external diamond ladder with %s levels under a bounded witness', + async (levels) => { + await fixture(async (root, file) => { + // Given an external DAG with convergent paths and incomparable file ancestries. + const paths = [ + file( + 'src/main.ts', + "import '../outside/0-left.js'; import '../outside/0-right.js'", + ), + ] + for (let level = 0; level < levels; level++) { + const code = + level + 1 === levels + ? "import './leaf.js'" + : `import './${level + 1}-left.js'; import './${level + 1}-right.js'` + for (const side of ['left', 'right']) + paths.push(file(`outside/${level}-${side}.js`, code)) + } + paths.push(file('outside/leaf.js')) + let mappings = 0 + const context = { + key: 'diamond', + files: enumerateProductionSourceFiles({ roots: ['src'], cwd: root }), + resolverOptions: { cwd: root }, + toId: (path: string) => { + if (++mappings > 8000) + throw new RangeError('bounded diamond work exceeded') + return path + }, + } + // When opposite seed orders close each independently retained obligation. + const first = await collectProductionFileManifest({ contexts: [context] }) + const second = await collectProductionFileManifest({ + contexts: [{ ...context, files: context.files.toReversed() }], + }) + // Then full physical membership survives; the witness exposes rather than hides replay growth. + expect(second).toEqual(first) + expect(first).toEqual(memberships(paths, 'diamond')) + console.info( + JSON.stringify({ + diamondLevels: levels, + records: first.length, + mappingsBothOrders: mappings, + }), + ) + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-depth.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-depth.test.ts new file mode 100644 index 000000000..1ae29276a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-depth.test.ts @@ -0,0 +1,173 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { enumerateProductionSourceFiles } from '../production-source-files' + +class DepthBudgetExceeded extends RangeError { + readonly name = 'DepthBudgetExceeded' + constructor( + readonly meter: 'file' | 'filesystem', + readonly count: number, + readonly budget: number, + ) { + super(`${meter} depth work exceeded: ${count} > ${budget}`) + } +} + +it.each(['AB', 'BA'] as const)( + 'closes a flat 2048-edge real chain when origins arrive %s', + async (order) => { + // Given a flat external chain and two real P1 origins with independent full records. + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-depth-')), + ) + try { + const depth = 2048 + const vertices = [ + 'src/a.ts', + 'src/b.ts', + ...Array.from( + { length: depth + 1 }, + (_, index) => `outside/${index}.js`, + ), + ] + fs.mkdirSync(join(root, 'src')) + fs.mkdirSync(join(root, 'outside')) + for (const name of vertices) { + const index = Number.parseInt(name.slice('outside/'.length), 10) + const code = name.startsWith('src/') + ? "import '../outside/0.js'" + : index < depth + ? `import './${index + 1}.js'` + : 'export {}' + fs.writeFileSync(join(root, name), code) + } + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + expect(files).toEqual( + ['src/a.ts', 'src/b.ts'].map((name) => ({ + path: join(root, name), + realPath: join(root, name), + })), + ) + const expected = vertices + .map((name) => ({ + path: join(root, name), + realPath: join(root, name), + context: 'depth', + id: name, + })) + .sort((a, b) => { + for (const field of ['realPath', 'path', 'context', 'id'] as const) { + if (a[field] < b[field]) return -1 + if (a[field] > b[field]) return 1 + } + return 0 + }) + const V = depth + 3 + const E = depth + 2 + const O = 2 + const N = V + E + O + const starts = [root, join(root, 'src'), join(root, 'outside')] + const H = Math.max( + ...starts.map((start) => { + let height = 1 + for (let path = start; dirname(path) !== path; path = dirname(path)) + height++ + return height + }), + ) + const fileBudget = 16 * N + const filesystemBudget = 512 * N * (H + 1) + console.info( + JSON.stringify({ + event: 'depth-inventory', + order, + V, + E, + O, + N, + H, + starts, + expected, + fileBudget, + filesystemBudget, + }), + ) + let mappings = 0 + let filesystem = 0 + let completed = false + const originalDirectory = fs.readdirSync + const originalProbe = fs.statSync + const meter = unknown>( + operation: T, + ): T => + new Proxy(operation, { + apply(target, receiver, args) { + if (++filesystem > filesystemBudget) + throw new DepthBudgetExceeded( + 'filesystem', + filesystem, + filesystemBudget, + ) + return Reflect.apply(target, receiver, args) + }, + }) + const { collectProductionFileManifest } = + await import('../production-file-manifest') + const directories = spyOn(fs, 'readdirSync').mockImplementation( + meter(originalDirectory), + ) + try { + const probes = spyOn(fs, 'statSync').mockImplementation( + meter(originalProbe), + ) + try { + // When the actual collector follows the deep source graph under forwarded meters. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'depth', + files: order === 'AB' ? files : files.toReversed(), + resolverOptions: { cwd: root }, + toId: (path) => { + if (++mappings > fileBudget) + throw new DepthBudgetExceeded('file', mappings, fileBudget) + return relative(root, path).replaceAll('\\', '/') + }, + }, + ], + }) + // Then the full independent union fits the same graph-sized work bounds. + expect(result).toEqual(expected) + expect(mappings).toBeLessThanOrEqual(fileBudget) + expect(filesystem).toBeLessThanOrEqual(filesystemBudget) + completed = true + } finally { + probes.mockRestore() + } + } finally { + directories.mockRestore() + console.info( + JSON.stringify({ + event: 'depth-outcome', + order, + completed, + mappings, + filesystem, + fileBudget, + filesystemBudget, + }), + ) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, + 120000, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-errors.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-errors.test.ts new file mode 100644 index 000000000..bf295f52c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-errors.test.ts @@ -0,0 +1,278 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import type { ProductionFileManifestOptions } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' +import type { ModuleAliasOptions } from '../types' + +function sourceContext(root: string) { + return { + key: 'errors', + files: enumerateProductionSourceFiles({ roots: ['src'], cwd: root }), + resolverOptions: { cwd: root }, + toId: (path: string) => path, + } +} + +async function collectProductionFileManifest( + options: ProductionFileManifestOptions, +) { + return ( + await import('../production-file-manifest') + ).collectProductionFileManifest(options) +} + +async function fixture( + run: ( + root: string, + file: (path: string, code?: string) => string, + ) => Promise, +) { + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-errors-')), + ) + const file = (path: string, code = 'export {}') => { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, code) + return target + } + try { + await run(root, file) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +} + +it('keeps ignores exclusions and misses pathless when real source resolves alongside them', async () => { + await fixture(async (root, file) => { + // Given ignored, excluded, unresolved and ordinary rewritten requests. + const entry = file( + 'src/main.ts', + "import 'ignored'; import 'blocked'; import 'external'; import './missing'; import 'chosen';", + ) + const chosen = file('outside/chosen.js') + const blocked = file('blocked/value.js', "import 'poison'") + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + // When the same resolver returns all four distinct outcome classes. + const result = await collectProductionFileManifest({ + exclude: [dirname(blocked)], + contexts: [ + { + key: 'outcomes', + files, + resolverOptions: { + cwd: root, + alias: [ + { + name: 'ignored', + alias: [join(root, 'absent'), false], + onlyModule: true, + }, + { name: 'blocked', alias: blocked }, + { name: 'chosen', alias: [join(root, 'missing'), chosen] }, + ], + }, + toId: (path) => path, + }, + ], + }) + // Then only actual physical resolutions receive memberships. + expect(result.map(({ path }) => path)).toEqual([entry, chosen].sort()) + }) +}) + +it.each(['cycle', 'manifest', 'exports', 'candidate', 'config'])( + 'preserves fatal %s failures when closure cannot resolve an edge', + async (kind) => { + await fixture(async (root, file) => { + // Given a real fatal resolver boundary with a tempting later alias candidate. + const entry = file('src/main.ts', "import 'provider'") + const later = file('later.js') + const alias: ModuleAliasOptions = + kind === 'cycle' + ? { provider: ['nested', later], nested: 'provider' } + : kind === 'candidate' + ? { provider: join(root, 'absent') } + : { provider: ['broken', later] } + const manifest = file( + 'node_modules/broken/package.json', + kind === 'manifest' ? '{' : '{"exports":{"browser":"./absent.js"}}', + ) + const config = kind === 'config' ? file('tsconfig.json', '{') : undefined + // When existing resolver setup/edge failure is encountered. + const result = collectProductionFileManifest({ + contexts: [ + { + key: 'fatal', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }), + resolverOptions: { cwd: root, alias, conditions: ['browser'] }, + toId: (path) => path, + }, + ], + }) + // Then the located existing failure is never converted into a miss/fallback. + await expect(result).rejects.toThrow( + kind === 'config' ? config : kind === 'manifest' ? manifest : entry, + ) + }) + }, +) + +it.each(['mdx', 'mdown'])( + 'rejects missing preparation when Markdown extension is %s', + async (extension) => { + await fixture(async (root, file) => { + // Given selected physical Markdown that cannot be interpreted as compiled JavaScript. + const entry = file(`src/page.${extension}`, '# Raw source') + // When no preparation is available before dormant closure. + const result = collectProductionFileManifest({ + contexts: [ + { + key: 'unprepared', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + includeMdx: [`.${extension}`], + }), + resolverOptions: { cwd: root, includeMdx: [`.${extension}`] }, + toId: (path) => path, + }, + ], + }) + // Then the error locates the physical file and asks for preparation, not a virtual workaround. + await expect(result).rejects.toThrow(`${entry}:1:1:`) + }) + }, +) + +it.each([false, true])( + 'retains compiler cause when preparer rejects asynchronously=%s', + async (asyncHook) => { + await fixture(async (root, file) => { + // Given the original compiler failure and real prepared-source filename. + const entry = file('src/page.ts') + const cause = Object.assign(new Error('compiler failed'), { + line: 3, + column: 4, + }) + const fail = () => { + throw cause + } + // When caller preparation fails at its actual boundary. + const error: unknown = await collectProductionFileManifest({ + contexts: [ + { + ...sourceContext(root), + prepareSource: asyncHook ? async () => fail() : fail, + }, + ], + }).catch((failure: unknown) => failure) + // Then location is honestly compiled output and cause identity survives. + expect(error).toMatchObject({ cause }) + if (!(error instanceof Error)) + throw new TypeError('Expected located compiler failure') + expect(error.message).toContain(`${entry}:3:4`) + expect(error.message).toContain('in compiled output') + }) + }, +) + +it('preserves type getter cause when a prepared mode cannot be read', async () => { + await fixture(async (root, file) => { + // Given a typed prepared boundary with a throwing mode getter. + const entry = file('src/page.ts') + const cause = new TypeError('mode getter') + const prepared = { + code: '', + get sourceType(): 'compiled-mdx' { + throw cause + }, + } + // When prepared-source parsing reads the hook value. + const error: unknown = await collectProductionFileManifest({ + contexts: [{ ...sourceContext(root), prepareSource: () => prepared }], + }).catch((failure: unknown) => failure) + // Then both the boundary and original getter remain in the cause chain. + expect(error).toMatchObject({ cause: { cause } }) + if (!(error instanceof Error)) throw new TypeError('Expected mode failure') + expect(error.message).toContain(`${entry}:1:1:`) + }) +}) + +it('observes lexical config probes and source reads when inherited paths resolve outside roots', async () => { + await fixture(async (root, file) => { + // Given inherited config aliases and an earlier nonexistent candidate. + const config = file( + 'tsconfig.json', + '{"extends":"./base","compilerOptions":{}}', + ) + const base = file( + 'base.json', + '{"compilerOptions":{"paths":{"value":["absent","outside/value.ts"]}}}', + ) + const entry = file('src/main.ts', "import 'value'") + const target = file('outside/value.ts') + const observed: { + readonly fileDependencies: readonly string[] + readonly missingDependencies: readonly string[] + }[] = [] + // When actual resolver/setup and source reads report their existing provenance. + await collectProductionFileManifest({ + contexts: [ + { + key: 'inputs', + files: enumerateProductionSourceFiles({ roots: ['src'], cwd: root }), + resolverOptions: { + cwd: root, + onResolutionInputs: (inputs) => observed.push(inputs), + }, + toId: (path) => path, + }, + ], + }) + // Then lexical consulted inputs and misses remain repairable. + expect( + observed.flatMap(({ fileDependencies }) => fileDependencies), + ).toEqual(expect.arrayContaining([config, base, entry, target])) + expect( + observed.flatMap(({ missingDependencies }) => missingDependencies), + ).toContain(join(root, 'absent')) + }) +}) + +it('retains read cause when an ordinary physical source becomes unreadable', async () => { + await fixture(async (root, file) => { + // Given a real physical source and a narrow deterministic OS permission failure. + const entry = file('src/main.ts') + const cause = Object.assign(new Error('read denied'), { code: 'EACCES' }) + const original = fs.readFileSync + const read = spyOn(fs, 'readFileSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === entry) throw cause + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When actual raw source reading fails after enumeration. + const error: unknown = await collectProductionFileManifest({ + contexts: [sourceContext(root)], + }).catch((failure: unknown) => failure) + // Then closure fails with the actual source location and original IO cause. + expect(error).toMatchObject({ cause }) + if (!(error instanceof Error)) + throw new TypeError('Expected read failure') + expect(error.message).toContain(entry) + } finally { + read.mockRestore() + } + }) +}) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-file-frontiers.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-file-frontiers.test.ts new file mode 100644 index 000000000..ff95143c1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-file-frontiers.test.ts @@ -0,0 +1,207 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import type { ProductionManifestFile } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' +import type { ResolutionInputs } from '../resolution-inputs' + +function ordered(records: readonly ProductionManifestFile[]) { + return records.toSorted((a, b) => { + for (const field of ['realPath', 'path', 'context', 'id'] as const) { + if (a[field] < b[field]) return -1 + if (a[field] > b[field]) return 1 + } + return 0 + }) +} + +async function fixture( + run: ( + root: string, + file: (name: string, code?: string) => string, + ) => Promise, +) { + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-file-frontier-')), + ) + const file = (name: string, code = 'export {}') => { + const path = join(root, name) + fs.mkdirSync(dirname(path), { recursive: true }) + fs.writeFileSync(path, code) + return path + } + try { + await run(root, file) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +} + +const cases = [false, true].flatMap((transitive) => + [false, true].flatMap((cutFirst) => + [false, true].map((reverse) => ({ transitive, cutFirst, reverse })), + ), +) + +it.each(cases)( + 'opens an external file cut when transitive=$transitive cutFirst=$cutFirst reverse=$reverse', + async ({ transitive, cutFirst, reverse }) => { + await fixture(async (root, file) => { + // Given two intermediary origins whose lexical ranks change actual dispatch order. + const bridge = transitive ? 'c.ts' : 'b.ts' + const cut = file( + `src/${cutFirst ? 'a' : 'z'}-cut.ts`, + "import '../physical/a.ts'", + ) + const open = file( + `src/${cutFirst ? 'z' : 'a'}-open.ts`, + `import '../bridge/${bridge}'`, + ) + const a = file( + 'physical/a.ts', + `import 'palette'; import '../bridge/${bridge}'`, + ) + const b = file('bridge/b.ts', "import '../alternate/alias/a.ts'") + const c = transitive ? [file('bridge/c.ts', "import './b.ts'")] : [] + const manifest = file( + 'alternate/node_modules/palette/package.json', + '{"main":"value.js"}', + ) + const leaf = file('alternate/node_modules/palette/value.js') + fs.symlinkSync(dirname(a), join(root, 'alternate/alias'), 'junction') + const alias = join(root, 'alternate/alias/a.ts') + const paths = [cut, open, a, b, ...c, alias, leaf] + const expected = ordered( + paths.map((path) => ({ + path, + realPath: path === alias ? a : path, + context: 'frontier', + id: relative(root, path), + })), + ) + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + const inputs: ResolutionInputs[] = [] + const visits: string[] = [] + let mappings = 0 + const { collectProductionFileManifest } = + await import('../production-file-manifest') + // When a later genuine origin can reopen the alias, including through c. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'frontier', + files: reverse ? files.toReversed() : files, + resolverOptions: { + cwd: root, + onResolutionInputs: (input) => inputs.push(input), + }, + toId: (path) => { + if (++mappings > 400) + throw new RangeError('bounded file frontier work') + return relative(root, path) + }, + prepareSource: (path) => { + visits.push(path) + return undefined + }, + }, + ], + }) + // Then every independent lexical record and its real consulted input survive. + expect(result).toEqual(expected) + expect(new Set(visits)).toEqual(new Set(paths)) + expect(inputs.flatMap((input) => input.fileDependencies)).toEqual( + expect.arrayContaining([manifest, alias, leaf]), + ) + console.info( + JSON.stringify({ + event: 'file-frontier', + transitive, + cutFirst, + reverse, + expected, + mappings, + visits, + }), + ) + }) + }, +) + +it.each([false, true])( + 'separates equal context keys when context order reverses=%s', + async (reverse) => { + await fixture(async (root, file) => { + // Given equal keys but different prepared code, aliases, conditions and ID values. + const entry = file('src/main.ts', "import 'phantom'") + const environments = ['blue', 'red'].map((color) => { + const observed: ResolutionInputs[] = [] + const visited: string[] = [] + return { + color, + provider: file(`outside/${color}.js`), + leaf: file(`node_modules/palette/${color}.js`), + observed, + visited, + } + }) + const manifest = file( + 'node_modules/palette/package.json', + '{"exports":{"blue":"./blue.js","red":"./red.js"}}', + ) + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + const contexts = environments.map((env) => ({ + key: 'equal', + files, + resolverOptions: { + cwd: root, + conditions: [env.color], + alias: { provider: env.provider }, + onResolutionInputs: (input: ResolutionInputs) => + env.observed.push(input), + }, + toId: (path: string) => `${env.color}:${relative(root, path)}`, + prepareSource: (path: string) => { + env.visited.push(path) + return path === entry + ? `import 'provider'; import 'palette'; export const color='${env.color}'` + : undefined + }, + })) + const expected = ordered( + environments.flatMap((env) => + [entry, env.provider, env.leaf].map((path) => ({ + path, + realPath: path, + context: 'equal', + id: `${env.color}:${relative(root, path)}`, + })), + ), + ) + const { collectProductionFileManifest } = + await import('../production-file-manifest') + // When independent context instances converge on the same lexical entry. + const result = await collectProductionFileManifest({ + contexts: reverse ? contexts.toReversed() : contexts, + }) + // Then both environments remain in the full union and lexical source provenance. + expect(result).toEqual(expected) + for (const env of environments) { + const paths = [entry, env.provider, env.leaf] + expect(new Set(env.visited)).toEqual(new Set(paths)) + expect(env.observed.flatMap((input) => input.fileDependencies)).toEqual( + expect.arrayContaining([entry, manifest, ...paths]), + ) + } + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-growth.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-growth.test.ts new file mode 100644 index 000000000..f0c54a877 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-growth.test.ts @@ -0,0 +1,258 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { enumerateProductionSourceFiles } from '../production-source-files' + +type Shape = 'diamond' | 'ladder' +type Edge = readonly [string, string] +type Graph = { + readonly vertices: readonly string[] + readonly edges: readonly Edge[] + readonly V: number + readonly E: number +} + +function graph(shape: Shape, levels: number): Graph { + const vertices = ['src/a.ts', 'src/b.ts', 'outside/leaf.js'] + const edges: Edge[] = [] + switch (shape) { + case 'diamond': + for (const origin of ['src/a.ts', 'src/b.ts']) + edges.push([origin, 'outside/0-hub.js']) + for (let level = 0; level < levels; level++) { + const hub = `outside/${level}-hub.js` + vertices.push(hub) + for (const side of ['left', 'right']) { + const arm = `outside/${level}-${side}.js` + vertices.push(arm) + edges.push([hub, arm]) + edges.push([ + arm, + level + 1 === levels + ? 'outside/leaf.js' + : `outside/${level + 1}-hub.js`, + ]) + } + } + return { vertices, edges, V: 3 * levels + 3, E: 4 * levels + 2 } + case 'ladder': + for (const origin of ['src/a.ts', 'src/b.ts']) + for (const side of ['left', 'right']) + edges.push([origin, `outside/0-${side}.js`]) + for (let level = 0; level < levels; level++) { + for (const side of ['left', 'right']) { + const vertex = `outside/${level}-${side}.js` + vertices.push(vertex) + const targets = + level + 1 === levels + ? ['outside/leaf.js'] + : [ + `outside/${level + 1}-left.js`, + `outside/${level + 1}-right.js`, + ] + for (const target of targets) edges.push([vertex, target]) + } + } + return { vertices, edges, V: 2 * levels + 3, E: 4 * levels + 2 } + default: { + const unreachable: never = shape + return unreachable + } + } +} + +class GrowthBudgetExceeded extends RangeError { + readonly name = 'GrowthBudgetExceeded' + constructor( + readonly meter: 'file' | 'filesystem', + readonly count: number, + readonly budget: number, + ) { + super(`${meter} growth budget exceeded: ${count} > ${budget}`) + } +} + +const cases = (['diamond', 'ladder'] as const).flatMap((shape) => + [3, 6, 9, 12, 18].flatMap((levels) => + (['AB', 'BA'] as const).map((order) => ({ shape, levels, order })), + ), +) + +it.each(cases)( + 'bounds actual external $shape at $levels levels when seeds arrive $order', + async ({ shape, levels, order }) => { + // Given independently declared vertices/edges, two real P1 origins and frozen bounds. + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-growth-')), + ) + try { + const declared = graph(shape, levels) + expect(declared.vertices).toHaveLength(declared.V) + expect(new Set(declared.vertices).size).toBe(declared.V) + expect(declared.edges).toHaveLength(declared.E) + for (const [from, to] of declared.edges) { + expect(declared.vertices).toContain(from) + expect(declared.vertices).toContain(to) + } + fs.mkdirSync(join(root, 'src')) + fs.mkdirSync(join(root, 'outside')) + for (const vertex of declared.vertices) { + const path = join(root, vertex) + const imports = declared.edges + .filter(([from]) => from === vertex) + .map(([, to]) => { + const request = relative(dirname(path), join(root, to)).replaceAll( + '\\', + '/', + ) + return `import '${request.startsWith('.') ? request : `./${request}`}';` + }) + fs.writeFileSync(path, [...imports, 'export {}'].join('\n')) + } + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + expect(files).toEqual( + ['src/a.ts', 'src/b.ts'].map((name) => ({ + path: join(root, name), + realPath: join(root, name), + })), + ) + const context = `${shape}-${levels}` + const expected = declared.vertices + .map((name) => ({ + path: join(root, name), + realPath: join(root, name), + context, + id: `${context}:${relative(root, join(root, name))}`, + })) + .sort((a, b) => { + for (const field of ['realPath', 'path', 'context', 'id'] as const) { + if (a[field] < b[field]) return -1 + if (a[field] > b[field]) return 1 + } + return 0 + }) + const starts = [root, join(root, 'src'), join(root, 'outside')] + const H = Math.max( + ...starts.map((start) => { + let depth = 1 + for (let path = start; dirname(path) !== path; path = dirname(path)) + depth++ + return depth + }), + ) + const O = 2 + const N = declared.V + declared.E + O + const fileBudget = 16 * N + const filesystemBudget = 512 * N * (H + 1) + const supplied = order === 'AB' ? files : files.toReversed() + const evidence = { + shape, + levels, + order, + declaredV: declared.V, + declaredE: declared.E, + } + console.info( + JSON.stringify({ + event: 'growth-inventory', + ...evidence, + O, + N, + H, + starts, + seeds: supplied, + expected, + fileBudget, + filesystemBudget, + }), + ) + const { collectProductionFileManifest } = + await import('../production-file-manifest') + let mappings = 0 + let filesystem = 0 + let crossing: GrowthBudgetExceeded | undefined + let completed = false + const originalDirectory = fs.readdirSync + const originalProbe = fs.statSync + const meter = unknown>( + operation: T, + ): T => + new Proxy(operation, { + apply(target, receiver, args) { + if (++filesystem > filesystemBudget) { + crossing = new GrowthBudgetExceeded( + 'filesystem', + filesystem, + filesystemBudget, + ) + throw crossing + } + return Reflect.apply(target, receiver, args) + }, + }) + const directories = spyOn(fs, 'readdirSync').mockImplementation( + meter(originalDirectory), + ) + try { + const probes = spyOn(fs, 'statSync').mockImplementation( + meter(originalProbe), + ) + try { + // When the unchanged collector follows actual source imports under forwarded FS meters. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: context, + files: supplied, + resolverOptions: { cwd: root }, + toId: (path) => { + if (++mappings > fileBudget) { + crossing = new GrowthBudgetExceeded( + 'file', + mappings, + fileBudget, + ) + throw crossing + } + return `${context}:${relative(root, path)}` + }, + }, + ], + }) + // Then the complete independent membership survives within graph-sized work. + expect(result).toEqual(expected) + expect(mappings).toBeLessThanOrEqual(fileBudget) + expect(filesystem).toBeLessThanOrEqual(filesystemBudget) + completed = true + } finally { + probes.mockRestore() + } + } finally { + directories.mockRestore() + console.info( + JSON.stringify({ + event: 'growth-outcome', + ...evidence, + completed, + mappings, + filesystem, + fileBudget, + filesystemBudget, + crossing, + restored: + fs.readdirSync === originalDirectory && + fs.statSync === originalProbe, + }), + ) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-package-frontiers.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-package-frontiers.test.ts new file mode 100644 index 000000000..d3c033eca --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-package-frontiers.test.ts @@ -0,0 +1,203 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +import { enumerateProductionSourceFiles } from '../production-source-files' +import type { ResolutionInputs } from '../resolution-inputs' + +function records(paths: readonly string[]) { + return paths + .map((path) => ({ + path, + realPath: fs.realpathSync.native(path), + context: 'packages', + id: path, + })) + .sort((a, b) => { + for (const field of ['realPath', 'path', 'context', 'id'] as const) { + if (a[field] < b[field]) return -1 + if (a[field] > b[field]) return 1 + } + return 0 + }) +} + +async function fixture( + run: ( + root: string, + file: (name: string, code?: string) => string, + ) => Promise, +) { + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-package-frontier-')), + ) + const file = (name: string, code = 'export {}') => { + const path = join(root, name) + fs.mkdirSync(dirname(path), { recursive: true }) + fs.writeFileSync(path, code) + return path + } + try { + await run(root, file) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +} + +const cases = [false, true].flatMap((cutFirst) => + [false, true].map((reverse) => ({ cutFirst, reverse })), +) + +it.each(cases)( + 'opens a linked inventory when cutFirst=$cutFirst reverse=$reverse', + async ({ cutFirst, reverse }) => { + await fixture(async (root, file) => { + // Given an inventory-derived bridge and a later intermediary origin without PACKAGE p. + const cut = file(`src/${cutFirst ? 'a' : 'z'}-cut.ts`) + const open = file( + `src/${cutFirst ? 'z' : 'a'}-open.ts`, + "import '../outside/b.ts'", + ) + const b = file('outside/b.ts') + const manifest = file('node_modules/@devup-ui/p/package.json', '{}') + const source = file('node_modules/@devup-ui/p/view.js', "import 'bridge'") + const scope = join(root, 'outside/node_modules/@devup-ui') + fs.mkdirSync(scope, { recursive: true }) + const linked = join(scope, 'alias-p') + fs.symlinkSync(dirname(source), linked, 'junction') + const alias = join(linked, 'view.js') + const expected = records([cut, open, b, source, alias]) + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + const inputs: ResolutionInputs[] = [] + const visits: string[] = [] + let mappings = 0 + const { collectProductionFileManifest } = + await import('../production-file-manifest') + // When the later genuine FILE path reopens the physically cut package spelling. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'packages', + files: reverse ? files.toReversed() : files, + resolverOptions: { + cwd: root, + alias: { bridge: b }, + onResolutionInputs: (input) => inputs.push(input), + }, + toId: (path) => { + if (++mappings > 500) + throw new RangeError('bounded package frontier work') + return path + }, + prepareSource: (path) => { + visits.push(path) + return undefined + }, + }, + ], + }) + // Then the complete finite inventory includes the newly permitted lexical source. + expect(result).toEqual(expected) + expect(new Set(visits)).toEqual(new Set([cut, open, b, source, alias])) + expect(inputs.flatMap((input) => input.fileDependencies)).toEqual( + expect.arrayContaining([manifest, join(linked, 'package.json'), alias]), + ) + console.info( + JSON.stringify({ + event: 'package-frontier', + cutFirst, + reverse, + expected, + mappings, + visits, + }), + ) + }) + }, +) + +it.each([false, true])( + 'keeps lexical nearest package leaves when linked origins reverse=%s', + async (reverse) => { + await fixture(async (root, file) => { + // Given two lexical installations of one physical importer with distinct nearest palettes. + file('workspace/package.json', '{}') + const physical = file('workspace/view.js', "import 'palette'") + const declarations = ['left', 'right'].map((side) => { + const seed = file(`src/${side}.ts`, `import '../${side}/bridge.ts'`) + const bridge = file(`${side}/bridge.ts`) + const manifest = file( + `${side}/node_modules/palette/package.json`, + '{"main":"value.js"}', + ) + const leaf = file(`${side}/node_modules/palette/value.js`) + const scope = join(root, side, 'node_modules/@devup-ui') + fs.mkdirSync(scope, { recursive: true }) + const linked = join(scope, 'shared') + fs.symlinkSync(dirname(physical), linked, 'junction') + return { + seed, + bridge, + leaf, + manifest, + linked, + source: join(linked, 'view.js'), + } + }) + const expected = records( + declarations.flatMap(({ seed, bridge, source, leaf }) => [ + seed, + bridge, + source, + leaf, + ]), + ) + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + const inputs: ResolutionInputs[] = [] + let mappings = 0 + const { collectProductionFileManifest } = + await import('../production-file-manifest') + // When importer-local discovery expands both genuine lexical installations. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'packages', + files: reverse ? files.toReversed() : files, + resolverOptions: { + cwd: root, + onResolutionInputs: (input) => inputs.push(input), + }, + toId: (path) => { + if (++mappings > 500) + throw new RangeError('bounded lexical package work') + return path + }, + }, + ], + }) + // Then no global physical winner can lose a lexical record, leaf or consulted manifest. + expect(result).toEqual(expected) + for (const declaration of declarations) { + expect(inputs.flatMap((input) => input.fileDependencies)).toEqual( + expect.arrayContaining([ + declaration.manifest, + join(declaration.linked, 'package.json'), + declaration.source, + declaration.leaf, + ]), + ) + expect(inputs.flatMap((input) => input.missingDependencies)).toContain( + join(declaration.linked, 'node_modules/palette/package.json'), + ) + } + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-packages.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-packages.test.ts new file mode 100644 index 000000000..b0135d7a0 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-packages.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'node:fs' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import type { ProductionFileManifestOptions } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' + +async function collectProductionFileManifest( + options: ProductionFileManifestOptions, +) { + return ( + await import('../production-file-manifest') + ).collectProductionFileManifest(options) +} + +async function fixture( + run: ( + root: string, + file: (path: string, code?: string) => string, + ) => Promise, +) { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-p2-packages-')), + ) + const file = (path: string, code = 'export {}') => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, code) + return target + } + try { + await run(root, file) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it('enumerates implicit and exact admitted distributions when none is imported', async () => { + await fixture(async (root, file) => { + // Given implicit scopes, exact include, a prefix impostor and own output. + const entry = file('src/main.ts') + const kept = ['@devup-ui/ui', '@devup-editor/editor', 'included'].map( + (name) => { + file(`node_modules/${name}/package.json`, '{}') + return file(`node_modules/${name}/dist/view.test.js`) + }, + ) + file('node_modules/included-other/package.json', '{}') + file('node_modules/included-other/absent.js') + const own = file('build/dist/poison.ts', "import 'broken'") + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + // When dormant closure discovers admitted installations, not blanket dependencies. + const result = await collectProductionFileManifest({ + include: ['included', 'missing'], + exclude: [dirname(own)], + contexts: [ + { + key: 'packages', + files, + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then installed dist/test source is retained independently of import or needle text. + expect(result.map(({ path }) => path)).toEqual([entry, ...kept].sort()) + }) +}) + +it('discovers nested installations when a distribution and outside source are reached', async () => { + await fixture(async (root, file) => { + // Given a direct included distribution and a non-admitted outside intermediary. + const entry = file('src/main.ts', "import '../outside/bridge.js'") + const bridge = file('outside/bridge.js', "import 'ordinary'") + file('outside/node_modules/ordinary/package.json', '{"main":"index.js"}') + const ordinary = file('outside/node_modules/ordinary/index.js') + file('node_modules/included/package.json', '{}') + const distribution = file( + 'node_modules/included/dist/view.js', + "import './other.js'", + ) + const sibling = file('node_modules/included/dist/other.js') + const nested = [ + 'node_modules/included/dist', + 'outside/node_modules/ordinary', + ].map((base) => { + file(`${base}/node_modules/@devup-ui/nested/package.json`, '{}') + return file(`${base}/node_modules/@devup-ui/nested/unused.js`) + }) + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + // When installation discovery repeats at newly reached physical source locations. + const result = await collectProductionFileManifest({ + include: ['included'], + contexts: [ + { + key: 'nested', + files, + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then both nested physical copies survive with the intermediary closure. + expect(result.map(({ path }) => path)).toEqual( + [entry, bridge, ordinary, distribution, sibling, ...nested].sort(), + ) + }) +}) + +it('preserves package aliases when a workspace distribution is linked', async () => { + await fixture(async (root, file) => { + // Given a linked implicit package and an excluded lexical installation. + const entry = file('src/main.ts') + file('workspace/package.json', '{}') + const physical = file('workspace/view.js') + mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }) + symlinkSync( + join(root, 'workspace'), + join(root, 'node_modules/@devup-ui/linked'), + 'junction', + ) + symlinkSync( + join(root, 'workspace'), + join(root, 'node_modules/@devup-ui/blocked'), + 'junction', + ) + const lexical = join(root, 'node_modules/@devup-ui/linked/view.js') + // When only one installation spelling is admitted by exclusions. + const result = await collectProductionFileManifest({ + exclude: [join(root, 'node_modules/@devup-ui/blocked')], + contexts: [ + { + key: 'linked', + files: enumerateProductionSourceFiles({ roots: ['src'], cwd: root }), + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then source extraction identity remains lexical while physical identity follows the link. + expect(result).toEqual( + expect.arrayContaining([ + { context: 'linked', path: entry, realPath: entry, id: entry }, + { context: 'linked', path: lexical, realPath: physical, id: lexical }, + ]), + ) + expect(result).toHaveLength(2) + }) +}) + +it('discovers admitted files when a context has cwd but no selected project seeds', async () => { + await fixture(async (root, file) => { + // Given an empty project and an implicit installation. + file('node_modules/@devup-ui/ui/package.json', '{}') + const path = file('node_modules/@devup-ui/ui/view.js') + // When the actual context cwd provides the installation origin. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'empty', + files: [], + resolverOptions: { cwd: root }, + toId: (source) => source, + }, + ], + }) + // Then empty project inventory does not hide directly admitted source. + expect(result).toEqual([ + { context: 'empty', path, realPath: path, id: path }, + ]) + }) +}) + +it('excludes physical seeds and scopes when lexical links point into blocked output', async () => { + await fixture(async (root, file) => { + // Given linked selected seeds and an implicit scope pointing at excluded physical source. + const physical = file('blocked/view.ts', "import 'poison'") + const kept = file('src/kept.ts') + symlinkSync(dirname(physical), join(root, 'src/alias'), 'junction') + mkdirSync(join(root, 'node_modules')) + symlinkSync( + dirname(physical), + join(root, 'node_modules/@devup-ui'), + 'junction', + ) + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + // When physical exclusions apply before preparation or scope/source reads. + const result = await collectProductionFileManifest({ + exclude: [dirname(physical)], + contexts: [ + { + key: 'excluded', + files, + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }) + // Then no excluded linked source can reach its poisoned import. + expect(result.map(({ path }) => path)).toEqual([kept]) + }) +}) + +it('preserves directory faults when an existing implicit scope cannot be read', async () => { + await fixture(async (root, file) => { + // Given a real installed scope and a narrow filesystem permission failure. + file('src/main.ts') + file('node_modules/@devup-ui/ui/package.json', '{}') + const scope = join(root, 'node_modules/@devup-ui') + const cause = Object.assign(new Error('scope denied'), { + code: 'EACCES', + path: scope, + }) + const original = fs.readdirSync + const read = spyOn(fs, 'readdirSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === scope) throw cause + return Reflect.apply(target, receiver, args) + }, + }), + ) + try { + // When importer-local distribution discovery reads that existing scope. + const error: unknown = await collectProductionFileManifest({ + contexts: [ + { + key: 'scope', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }), + resolverOptions: { cwd: root }, + toId: (path) => path, + }, + ], + }).catch((failure: unknown) => failure) + // Then the original IO failure and lexical repair path survive, not an ordinary miss. + expect(error).toBe(cause) + } finally { + read.mockRestore() + } + }) +}) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest-prepared.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest-prepared.test.ts new file mode 100644 index 000000000..a0cac6df3 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest-prepared.test.ts @@ -0,0 +1,214 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + 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 { type PreparedSource } from '../import-graph' +import type { ProductionFileManifestOptions } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' + +async function collectProductionFileManifest( + options: ProductionFileManifestOptions, +) { + return ( + await import('../production-file-manifest') + ).collectProductionFileManifest(options) +} + +async function fixture( + run: ( + root: string, + file: (path: string, code?: string) => string, + ) => Promise, +) { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-p2-prepared-')), + ) + const file = (path: string, code = 'export {}') => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, code) + return target + } + try { + await run(root, file) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it('follows a real project-compiled provider when selected Markdown is prepared', async () => { + await fixture(async (root, file) => { + // Given the landing project's actual compiler and a provider outside selected roots. + const project = createRequire(resolve('apps/landing/package.json')) + const compiler = createRequire(project.resolve('@mdx-js/loader')) + const { + compile, + }: { + readonly compile: ( + input: { readonly path: string; readonly value: string }, + options: { + readonly providerImportSource: string + readonly jsx: boolean + readonly SourceMapGenerator: unknown + }, + ) => Promise<{ readonly value: string; readonly map?: unknown }> + } = compiler('@mdx-js/mdx') + const { + SourceMapGenerator, + }: { + readonly SourceMapGenerator: unknown + } = compiler('source-map') + const markdown = '# Plain documentation\n\n
import "phantom"
' + const entry = file('src/page.mdx', markdown) + const provider = file('outside/provider.js', "export * from './leaf.js'") + const leaf = file('outside/leaf.js') + file('node_modules/phantom/package.json', '{') + const prepared = await compile( + { path: entry, value: markdown }, + { providerImportSource: 'provider', jsx: true, SourceMapGenerator }, + ) + const visits: string[] = [] + // When the existing PrepareSource hook supplies actual compiled JavaScript/JSX. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'mdx', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + includeMdx: true, + }), + resolverOptions: { cwd: root, includeMdx: true, alias: { provider } }, + toId: (path) => path, + prepareSource: (path) => { + visits.push(path) + return path === entry + ? { + code: String(prepared), + map: prepared.map, + sourceType: 'compiled-mdx', + } + : undefined + }, + }, + ], + }) + // Then compiler-injected physical dependencies, not Markdown prose, close the inventory. + expect(result.map(({ path }) => path)).toEqual( + [entry, provider, leaf].sort(), + ) + expect(visits.sort()).toEqual([entry, provider, leaf].sort()) + expect(prepared.map).toBeDefined() + }) +}) + +it.each([undefined, '', { code: '' }])( + 'uses raw code only when preparation is %j', + async (prepared) => { + await fixture(async (root, file) => { + // Given a raw ordinary source import and a preparer with distinguishable empty output. + const entry = file('src/main.ts', "import '../outside/leaf'") + const leaf = file('outside/leaf.ts') + // When existing prepared-source semantics determine the scanned code. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'ordinary', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }), + resolverOptions: { cwd: root }, + toId: (path) => path, + prepareSource: () => prepared, + }, + ], + }) + // Then empty prepared output cannot fall back to the raw import. + expect(result.map(({ path }) => path)).toEqual( + (prepared === undefined ? [entry, leaf] : [entry]).sort(), + ) + }) + }, +) + +it.each([false, true])( + 'uses filename grammar when ordinary TS preparation exists=%s', + async (prepared) => { + await fixture(async (root, file) => { + // Given a comparison expression whose import would disappear under unconditional JSX grammar. + const code = + 'const less = a < b; import("../outside/leaf.js"); import type {T} from "phantom";' + const entry = file('src/main.ts', code) + const leaf = file('outside/leaf.js') + file('node_modules/phantom/package.json', '{') + // When ordinary TS is raw or prepared without compiled-mdx mode. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'ts', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }), + resolverOptions: { cwd: root }, + toId: (path) => path, + ...(prepared + ? { + prepareSource: (path: string) => + path === entry ? code : undefined, + } + : {}), + }, + ], + }) + // Then the literal edge and type-only elision follow the actual TS grammar. + expect(result.map(({ path }) => path)).toEqual([entry, leaf].sort()) + }) + }, +) + +it.each(['ts', 'mdown'])( + 'uses compiled JSX grammar when physical filename ends in %s', + async (extension) => { + await fixture(async (root, file) => { + // Given compiled JSX text, real brace imports, reexports and literal require. + const entry = file(`src/page.${extension}`, '# Raw decoy') + const leaves = ['dynamic', 'required', 'exported'].map((name) => + file(`outside/${name}.js`), + ) + file('node_modules/phantom/package.json', '{') + const prepared: PreparedSource = { + code: 'const v=
import "phantom" {import("../outside/dynamic.js")}{require("../outside/required.js")}
; export * from "../outside/exported.js";', + sourceType: 'compiled-mdx', + } + // When explicit sourceType overrides the physical extension's grammar. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'jsx', + files: enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + includeMdx: ['.mdown'], + }), + resolverOptions: { cwd: root, includeMdx: ['.mdown'] }, + toId: (path) => path, + prepareSource: (path) => (path === entry ? prepared : undefined), + }, + ], + }) + // Then only scanner-authoritative code expressions contribute dependencies. + expect(result.map(({ path }) => path)).toEqual([entry, ...leaves].sort()) + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-file-manifest.test.ts b/packages/plugin-utils/src/__tests__/production-file-manifest.test.ts new file mode 100644 index 000000000..d92d51f93 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-file-manifest.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'node:fs' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { collectNumberedFiles } from '../numbering' +import type { ProductionFileManifestOptions } from '../production-file-manifest' +import { enumerateProductionSourceFiles } from '../production-source-files' + +async function collectProductionFileManifest( + options: ProductionFileManifestOptions, +) { + return ( + await import('../production-file-manifest') + ).collectProductionFileManifest(options) +} + +async function fixture( + run: ( + root: string, + file: (path: string, code?: string) => string, + ) => Promise, +) { + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-p2-union-')), + ) + const file = (path: string, code = 'export {}') => { + const target = join(root, path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, code) + return target + } + try { + await run(root, file) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +it('unions independent conditional file results when contexts and seeds reverse', async () => { + await fixture(async (root, file) => { + // Given different styled FILE exports, an outside intermediary and two ID conventions. + const entry = file( + 'src/main.ts', + "import 'palette'; import '../outside/bridge';", + ) + const plain = file('src/plain.spec.ts') + const bridge = file('outside/bridge.js', "export * from 'plain';") + file('node_modules/plain/package.json', '{"main":"leaf.js"}') + const leaf = file('node_modules/plain/leaf.js') + file( + 'node_modules/palette/package.json', + '{"exports":{"browser":"./browser.js","react-server":"./server.js"}}', + ) + const browser = file( + 'node_modules/palette/browser.js', + "import '@devup-ui/react';", + ) + const server = file( + 'node_modules/palette/server.js', + "import '@devup-ui/react';", + ) + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + const contexts = ['browser', 'react-server'].map((key) => ({ + key, + files, + resolverOptions: { cwd: root, conditions: [key] }, + toId: (path: string) => `${key}:${relative(root, path)}`, + })) + // When the same physical inputs arrive in opposite orders. + const first = await collectProductionFileManifest({ contexts }) + const reversed = await collectProductionFileManifest({ + contexts: contexts + .toReversed() + .map((context) => ({ ...context, files: files.toReversed() })), + }) + // Then membership is the independent result union, never a flattened condition winner. + expect(reversed).toEqual(first) + const expected = contexts.flatMap(({ key }) => + [entry, plain, bridge, leaf, key === 'browser' ? browser : server].map( + (path) => ({ + context: key, + path, + realPath: path, + id: `${key}:${relative(root, path)}`, + }), + ), + ) + expect(first).toEqual(expect.arrayContaining(expected)) + expect(first).toHaveLength(expected.length) + expect(Object.isFrozen(first) && first.every(Object.isFrozen)).toBe(true) + expect( + collectNumberedFiles({ + roots: [join(root, 'src')], + needles: ['@devup-ui/react'], + }), + ).toEqual([]) + }) +}) + +it.each([[undefined], [false], [true], [[]], [['.md', '.mdown']]])( + 'retains exact source selection when selection is %j', + async (includeMdx) => { + await fixture(async (root, file) => { + // Given eight source extensions and distinct Markdown sources without styling needles. + const js = 'ts tsx mts cts js jsx mjs cjs' + .split(' ') + .map((extension) => file(`src/source.${extension}`)) + const md = file('src/page.md') + const mdx = file('src/page.mdx') + const custom = file('src/page.mdown') + const selection = includeMdx === undefined ? {} : { includeMdx } + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + ...selection, + }) + // When physical selection is carried unchanged into dormant closure. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'selection', + files, + resolverOptions: { cwd: root, ...selection }, + toId: (path) => path, + prepareSource: () => '', + }, + ], + }) + // Then only the exact selected physical records survive. + const extra = + includeMdx === true + ? [mdx] + : Array.isArray(includeMdx) && includeMdx.length + ? [md, custom] + : [] + expect(result.map(({ path }) => path)).toEqual([...js, ...extra].sort()) + }) + }, +) + +it('deduplicates exact memberships when overlapping P1 seeds repeat', async () => { + await fixture(async (root, file) => { + // Given repeated lexical seeds and a selected physical source. + const path = file('src/one.ts') + const files = enumerateProductionSourceFiles({ roots: ['src'], cwd: root }) + // When the same context repeats seed records. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'one', + files: [...files, ...files], + resolverOptions: { cwd: root }, + toId: () => 'original-id', + }, + ], + }) + // Then one exact extraction membership remains. + expect(result).toEqual([ + { context: 'one', path, realPath: path, id: 'original-id' }, + ]) + }) +}) + +it.each([4, 8])( + 'bounds real distribution work with %s implicit packages', + async (count) => { + await fixture(async (root, file) => { + // Given independent import-free distributions, including test-named source. + const paths = [file('src/main.ts')] + for (let index = 0; index < count; index++) { + const base = `node_modules/@devup-ui/p${index}` + file(`${base}/package.json`, '{}') + for (const name of ['a.js', 'b.test.js', 'c.spec.js']) + paths.push(file(`${base}/dist/${name}`)) + } + const files = enumerateProductionSourceFiles({ + roots: ['src'], + cwd: root, + }) + let mappings = 0 + let filesystem = 0 + const meter = unknown>( + operation: T, + ): T => + new Proxy(operation, { + apply(target, receiver, args) { + if (++filesystem > count * 4000) + throw new RangeError( + 'distribution filesystem work budget exceeded', + ) + return Reflect.apply(target, receiver, args) + }, + }) + const originalDirectory = fs.readdirSync + const originalProbe = fs.statSync + const directories = spyOn(fs, 'readdirSync').mockImplementation( + meter(originalDirectory), + ) + const probes = spyOn(fs, 'statSync').mockImplementation( + meter(originalProbe), + ) + try { + // When real discovery, P1 enumeration and ID mapping run under generous work budgets. + const result = await collectProductionFileManifest({ + contexts: [ + { + key: 'budget', + files, + resolverOptions: { cwd: root }, + toId: (path) => { + if (++mappings > count * 250) + throw new RangeError( + 'distribution traversal work budget exceeded', + ) + return path + }, + }, + ], + }) + // Then every independently expected record survives without permutation replay. + expect(result).toEqual( + paths.sort().map((path) => ({ + context: 'budget', + path, + realPath: path, + id: path, + })), + ) + expect(mappings).toBeLessThanOrEqual(count * 250) + expect(filesystem).toBeLessThanOrEqual(count * 4000) + console.info( + JSON.stringify({ + distributionPackages: count, + records: result.length, + mappings, + filesystem, + }), + ) + } finally { + directories.mockRestore() + probes.mockRestore() + } + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-manifest-certificates.test.ts b/packages/plugin-utils/src/__tests__/production-manifest-certificates.test.ts new file mode 100644 index 000000000..9d37276ed --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-manifest-certificates.test.ts @@ -0,0 +1,240 @@ +import { expect, it } from 'bun:test' + +import { + type ManifestAncestry, + type ManifestCertificate, + ManifestCertificates, + ManifestClosureError, + type ManifestEdge, + type ManifestJob, + type ManifestKind, + type ManifestObligation, + type ManifestState, +} from '../production-manifest-certificates' + +function ancestry( + files: readonly string[] = [], + packages: readonly string[] = [], +) { + return { files: new Set(files), packages: new Set(packages) } +} +function job(kind: ManifestKind, path: string, realPath = path): ManifestJob { + return { kind, path, realPath } +} +function node( + descriptor: ManifestJob, + incoming: ManifestAncestry = ancestry(), +): ManifestObligation { + return { job: descriptor, ancestry: incoming, state: { phase: 'pending' } } +} +function cut( + parent: ManifestObligation, + kind: ManifestKind, + path: string, +): ManifestEdge { + return { delta: parent.job, evidence: { kind: 'cut', job: job(kind, path) } } +} +function link( + parent: ManifestObligation, + child: ManifestObligation, +): ManifestEdge { + return { delta: parent.job, evidence: { kind: 'node', node: child } } +} +function closed( + parent: ManifestObligation, + witness: ManifestCertificate, +): ManifestEdge { + return { delta: parent.job, evidence: { kind: 'closed', witness } } +} +function expanded( + current: ManifestObligation, + edges: readonly ManifestEdge[] = [], +) { + current.state = { phase: 'expanded', edges } +} +function frontier( + files: readonly string[] = [], + packages: readonly string[] = [], +) { + return { files, packages } +} +function complete( + store: ManifestCertificates, + nodes: readonly ManifestObligation[], +) { + for (const [current, witness] of store.seal(nodes)) + current.state = { phase: 'completed', witness } +} + +it('requires both external namespaces when irrelevant and extra ancestors differ', () => { + // Given real cut evidence in both namespaces and unused original ancestry. + const store = new ManifestCertificates() + const current = node( + job('file', 'entry'), + ancestry(['x', 'unused'], ['x', 'unused']), + ) + expanded(current, [cut(current, 'file', 'x'), cut(current, 'package', 'x')]) + // When its successfully expanded closure is sealed. + complete(store, [current]) + // Then only both relevant frontiers authorize reuse, irrespective of unused/extra ancestors. + expect( + store.lookup(current.job, ancestry(['x', 'extra'], ['x', 'extra'])) + ?.requirements, + ).toEqual(frontier(['x'], ['x'])) + expect(store.lookup(current.job, ancestry(['x'], []))).toBeUndefined() + expect(store.lookup(current.job, ancestry([], ['x']))).toBeUndefined() + expect( + store.lookup(job('package', 'entry'), current.ancestry), + ).toBeUndefined() + expect( + store.lookup(job('file', 'alias', 'entry'), current.ancestry), + ).toBeUndefined() + expect( + new ManifestCertificates().lookup(current.job, current.ancestry), + ).toBeUndefined() +}) + +it('keeps a sibling alias cut when the same physical source expanded elsewhere', () => { + // Given a genuine left alias closure and a right cut under a distinct incoming ancestry. + const store = new ManifestCertificates() + const left = node(job('file', 'left/a', 'a')) + expanded(left, [cut(left, 'file', 'a')]) + complete(store, [left]) + const parent = node(job('file', 'c'), ancestry(['a'], ['c'])) + expanded(parent, [ + cut(parent, 'file', 'a'), + cut(parent, 'package', 'c'), + link(parent, left), + ]) + // When translated child evidence closes the parent. + complete(store, [parent]) + // Then sibling expansion and a FILE c delta erase neither external a nor PACKAGE c. + expect(store.lookup(parent.job, parent.ancestry)?.requirements).toEqual( + frontier(['a'], ['c']), + ) + expect(store.lookup(parent.job, ancestry([], ['c']))).toBeUndefined() +}) + +it('solves a mutual component when every local expansion and outside child is complete', () => { + // Given an outside package witness and genuine mutually dependent file obligations. + const store = new ManifestCertificates() + const outside = node(job('package', 'z'), ancestry([], ['p'])) + expanded(outside, [cut(outside, 'package', 'p')]) + complete(store, [outside]) + const witness = store.lookup(outside.job, outside.ancestry) + if (!witness) throw new TypeError('Expected outside completion') + const a = node(job('file', 'a'), ancestry(['ext'], ['p'])) + const b = node(job('file', 'b'), ancestry(['ext', 'a'], ['p'])) + expanded(a, [link(a, b)]) + expanded(b, [ + link(b, a), + closed(b, witness), + cut(b, 'file', 'a'), + cut(b, 'file', 'ext'), + ]) + // When the whole component reaches its least fixed point before publication. + complete(store, [a, b]) + // Then actual a transition removes only a locally; transitive external cuts survive. + expect(store.lookup(a.job, a.ancestry)?.requirements).toEqual( + frontier(['ext'], ['p']), + ) + expect(store.lookup(b.job, b.ancestry)?.requirements).toEqual( + frontier(['a', 'ext'], ['p']), + ) + expect(store.lookup(a.job, ancestry([], ['p']))).toBeUndefined() +}) + +const openStates: readonly ManifestState[] = [ + { phase: 'pending' }, + { phase: 'expanding' }, + { phase: 'expanded', edges: [] }, +] +it.each([...openStates])( + 'rejects an outside dependency when its phase is $phase', + (state) => { + // Given an expanded parent with a genuine but unclosed outside dependency. + const store = new ManifestCertificates() + const parent = node(job('file', 'parent')) + const child = node(job('file', 'child')) + child.state = state + expanded(parent, [link(parent, child)]) + // When sealing attempts to use that incomplete child. + expect(() => store.seal([parent])).toThrow( + new ManifestClosureError('dependency-open'), + ) + // Then no parent evidence was published. + expect(store.lookup(parent.job, parent.ancestry)).toBeUndefined() + }, +) + +it.each([{ phase: 'pending' }, { phase: 'expanding' }] as const)( + 'rejects component publication when a member is $phase', + (state) => { + // Given one locally expanded member and another unfinished member. + const store = new ManifestCertificates() + const ready = node(job('file', 'ready')) + expanded(ready) + const unfinished = node(job('file', 'unfinished')) + unfinished.state = state + // When the component is submitted before all local expansions succeed. + expect(() => store.seal([ready, unfinished])).toThrow( + new ManifestClosureError('member-open'), + ) + // Then even the locally successful member has no certificate. + expect(store.lookup(ready.job, ready.ancestry)).toBeUndefined() + }, +) + +it('retains held immutable witnesses when completed antichain entries are replaced', () => { + // Given two incomparable successful witnesses and a later identical covered witness. + const store = new ManifestCertificates() + const first = node(job('file', 'j'), ancestry(['x'])) + expanded(first, [cut(first, 'file', 'x')]) + complete(store, [first]) + const held = store.lookup(first.job, first.ancestry) + if (!held) throw new TypeError('Expected held completion') + const second = node(first.job, ancestry([], ['p'])) + expanded(second, [cut(second, 'package', 'p')]) + complete(store, [second]) + const same = node(first.job, first.ancestry) + expanded(same, [cut(same, 'file', 'x')]) + complete(store, [same]) + expect(store.lookup(first.job, first.ancestry)).toBe(held) + const empty = node(first.job) + expanded(empty) + const parent = node(job('file', 'parent'), ancestry(['x'])) + expanded(parent, [closed(parent, held)]) + // When a smaller completed frontier replaces lookup entries while a parent holds old evidence. + complete(store, [empty, parent]) + // Then the old edge still supplies its frontier and all published requirements are immutable. + expect(store.lookup(parent.job, parent.ancestry)?.requirements).toEqual( + frontier(['x']), + ) + expect(store.lookup(first.job, ancestry())?.requirements).toEqual(frontier()) + expect( + Object.isFrozen(held.requirements) && + Object.isFrozen(held.requirements.files), + ).toBe(true) + expect(() => store.seal([parent])).toThrow( + new ManifestClosureError('member-open'), + ) +}) + +it('rejects foreign closed evidence when stores represent different context instances', () => { + // Given a real closed witness minted by a different context's store. + const foreign = new ManifestCertificates() + const child = node(job('file', 'child')) + expanded(child) + complete(foreign, [child]) + const witness = foreign.lookup(child.job, child.ancestry) + if (!witness) throw new TypeError('Expected foreign completion') + const store = new ManifestCertificates() + const parent = node(job('file', 'parent')) + expanded(parent, [closed(parent, witness)]) + // When the context attempts to consume that foreign witness. + expect(() => store.seal([parent])).toThrow( + new ManifestClosureError('foreign-witness'), + ) + // Then no local completed evidence appears. + expect(store.lookup(parent.job, parent.ancestry)).toBeUndefined() +}) diff --git a/packages/plugin-utils/src/__tests__/production-manifest-worklist.test.ts b/packages/plugin-utils/src/__tests__/production-manifest-worklist.test.ts new file mode 100644 index 000000000..22185d541 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-manifest-worklist.test.ts @@ -0,0 +1,242 @@ +import { expect, it } from 'bun:test' + +import type { + ManifestJob, + ManifestKind, +} from '../production-manifest-certificates' +import { ManifestWorklist } from '../production-manifest-worklist' + +function job( + path: string, + kind: ManifestKind = 'file', + realPath = path, +): ManifestJob { + return { kind, path, realPath } +} +class ExpansionFailure extends Error { + readonly name = 'ExpansionFailure' + constructor(readonly path: string) { + super(`Expansion failed: ${path}`) + } +} +function fixture( + edges: readonly (readonly [ManifestJob, readonly ManifestJob[]])[], + cause?: ExpansionFailure, +) { + const graph = new Map() + for (const [source, targets] of edges) + graph.set(JSON.stringify([source.kind, source.path]), targets) + const records: string[] = [] + const expansions: ManifestJob[] = [] + const list = new ManifestWorklist({ + admit: (source) => + source.path !== 'excluded' && source.realPath !== 'blocked', + record: (source) => records.push(source.path), + expand: async (source, request) => { + expansions.push(source) + if (cause && source.path === cause.path) throw cause + for (const target of graph.get( + JSON.stringify([source.kind, source.path]), + ) ?? []) + request(target) + }, + }) + return { list, records, expansions } +} + +it('records boundary batches when file and package physical backedges cut expansion', async () => { + // Given genuine initial origins, excluded descriptors and different lexical cut aliases. + const a = job('a') + const z = job('z') + const p = job('p', 'package') + const left = job('left') + const right = job('right') + const alias = job('alias', 'file', 'a') + const { list, records, expansions } = fixture([ + [ + a, + [alias, left, right, job('excluded'), job('hidden', 'file', 'blocked')], + ], + [p, [job('package-alias', 'package', 'p')]], + ]) + for (const origin of [a, z, p, job('excluded')]) list.seed(origin) + // When one iterative traversal executes the fully registered origins and child batches. + await list.run() + // Then membership precedes cuts and all local siblings precede descendant execution. + expect(records).toEqual(['a', 'z', 'alias', 'left', 'right']) + expect(expansions).toEqual([a, left, right, z, p]) +}) + +it.each([false, true])( + 'retains both ancestry namespaces when branch order reverses=%s', + async (reverse) => { + // Given incomparable FILE x and PACKAGE x paths to the same lexical b. + const s = job('s') + const f = job('f', 'file', 'x') + const p = job('p', 'package', 'x') + const b = job('b') + const fileAlias = job('file-alias', 'file', 'x') + const packageAlias = job('package-alias', 'package', 'x') + const { list, records, expansions } = fixture([ + [s, reverse ? [p, f] : [f, p]], + [f, [b]], + [p, [b]], + [b, [fileAlias, packageAlias]], + [fileAlias, [job('file-leaf')]], + [packageAlias, [job('package-leaf')]], + ]) + list.seed(s) + // When neither namespace alone may dominate or certify the other path. + await list.run() + // Then both reopened leaves and exact lexical aliases survive in the genuine union. + expect(new Set(records)).toEqual( + new Set(['s', 'f', 'b', 'file-alias', 'file-leaf', 'package-leaf']), + ) + expect(expansions.filter((source) => source.path === 'b')).toHaveLength(2) + }, +) + +it.each([false, true])( + 'finishes mutual genuine cwd origins when an outside child fails=%s', + async (fails) => { + // Given empty-ancestry package origins covering requests made from a physical file alias. + const s = job('s', 'file', 'f') + const p = job('p', 'package') + const q = job('q', 'package') + const f = job('f') + const g = job('g') + const z = job('z') + const cause = new ExpansionFailure('z') + const { list, expansions } = fixture( + [ + [s, [p]], + [p, [f]], + [f, [q]], + [q, [g]], + [g, [p, z]], + ], + fails ? cause : undefined, + ) + for (const origin of [s, p, q]) list.seed(origin) + // When pending covers execute their original genuine obligations instead of inherited ones. + const completion = list.run() + // Then all local cycle expansions are required and an outside failure remains fatal. + if (fails) await expect(completion).rejects.toBe(cause) + else await completion + expect(expansions).toEqual([s, p, f, q, g, z]) + }, +) + +it('preserves active dependency references when a smaller genuine representative replaces them', async () => { + // Given an inherited b and a genuine cwd chain capable of registering a smaller b. + const s = job('s') + const inherited = job('linked-p', 'package', 'p') + const p = job('p', 'package') + const q = job('q', 'package') + const x = job('x') + const b = job('b') + const leaf = job('leaf') + const { list, records, expansions } = fixture([ + [s, [inherited]], + [inherited, [x]], + [x, [b]], + [b, [q, leaf]], + [q, [p]], + [p, [b]], + ]) + for (const origin of [s, p, q]) list.seed(origin) + // When q helps the original p while an older b dependency is still active. + await list.run() + // Then stable references finish both real b obligations and their finite leaf union. + expect(expansions.filter((source) => source.path === 'b')).toHaveLength(2) + expect(new Set(records)).toEqual(new Set(['s', 'x', 'b', 'leaf'])) +}) + +it('forwards late completed evidence when a queued suffix later protects an external cut', async () => { + // Given a queued j and a genuine h that completes an incomparable j first with frontier a. + const s = job('s') + const h = job('h') + const t = job('t') + const a = job('a') + const alternate = job('alternate-a', 'file', 'a') + const p = job('p') + const j = job('j') + const alias = job('alias-a', 'file', 'a') + const leaf = job('leaf') + const { list, records, expansions } = fixture([ + [s, [a]], + [a, [p]], + [p, [h, j]], + [h, [alternate]], + [alternate, [j]], + [j, [alias]], + [t, [p]], + [alias, [leaf]], + ]) + for (const origin of [s, h, t]) list.seed(origin) + // When late queued completion must retain the closed witness on p's existing edge. + await list.run() + // Then later t reopens p and j rather than losing the alias-specific leaf. + expect(new Set(records)).toEqual( + new Set(['s', 'h', 't', 'a', 'alternate-a', 'p', 'j', 'alias-a', 'leaf']), + ) + expect(expansions.filter((source) => source.path === 'p')).toHaveLength(2) + expect(expansions.filter((source) => source.path === 'j')).toHaveLength(2) +}) + +it('separates kind and lexical keys when physical identities and origin records overlap', async () => { + // Given two kinds sharing one string and two lexical file spellings sharing one physical ID. + const file = job('same') + const pkg = job('same', 'package') + const alias = job('alias', 'file', 'same') + const { list, records, expansions } = fixture([ + [file, [job('file-leaf')]], + [pkg, [job('package-leaf')]], + [alias, [job('alias-leaf')]], + ]) + for (const origin of [file, pkg, alias, file]) list.seed(origin) + // When repeated origins are registered genuinely before one collection run. + await list.run() + // Then no physical or kind winner discards a separately expanded source. + expect(new Set(records)).toEqual( + new Set(['same', 'alias', 'file-leaf', 'package-leaf', 'alias-leaf']), + ) + expect(expansions).toHaveLength(6) +}) + +it('uses iterative traversal when a genuine two-origin chain has 4096 edges', async () => { + // Given an independently declared flat owner graph and a generous expansion-work budget. + const depth = 4096 + const chain = Array.from({ length: depth + 1 }, (_, index) => + job(String(index)), + ) + const origins = [job('a'), job('b')] + const declared = [...origins, ...chain] + const graph: (readonly [ManifestJob, readonly ManifestJob[]])[] = [] + for (const origin of origins) graph.push([origin, chain.slice(0, 1)]) + for (const [index, source] of chain.entries()) + graph.push([source, chain.slice(index + 1, index + 2)]) + const { list, records, expansions } = fixture(graph) + for (const origin of origins) list.seed(origin) + // When the whole deep dependency graph is discharged without a recursive promise tree. + await list.run() + // Then every declared source appears and actual local expansion work stays graph-sized. + expect(new Set(records)).toEqual( + new Set(declared.map((source) => source.path)), + ) + expect(new Set(expansions.map((source) => source.path))).toEqual( + new Set(declared.map((source) => source.path)), + ) + expect(expansions.length).toBeLessThanOrEqual( + 16 * (declared.length + depth + origins.length), + ) + console.info( + JSON.stringify({ + event: 'direct-depth', + edges: depth, + vertices: declared.length, + expansions: expansions.length, + requests: records.length, + }), + ) +}, 120000) diff --git a/packages/plugin-utils/src/__tests__/production-numbering-owner.test.ts b/packages/plugin-utils/src/__tests__/production-numbering-owner.test.ts new file mode 100644 index 000000000..14fe97ae5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-numbering-owner.test.ts @@ -0,0 +1,267 @@ +import { + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + BuildGeneration, + ClosedBuildGenerationError, +} from '../build-generation' +import { runBuildOperation } from '../build-session' +import { + ProductionNumbering, + ProductionNumberingError, +} from '../production-numbering' + +const capture = () => ({ + sheet: wasm.exportSheet(), + classes: wasm.exportClassMap(), + files: wasm.exportFileMap(), + canonical: wasm.exportCanonicalMap(), +}) +type State = Readonly> +const readMap = (serialized: string): unknown => JSON.parse(serialized) +const canonicalFor = (context: string) => ({ + 'a.tsx': `${context}/a`, + 'b.tsx': `${context}/b`, +}) +const engine = { + reset: wasm.resetBuildState, + restore(state: State) { + wasm.importSheet(readMap(state.sheet)) + wasm.importClassMap(readMap(state.classes)) + wasm.importFileMap(readMap(state.files)) + wasm.importCanonicalMap(readMap(state.canonical)) + }, + capture, +} +let root: string +let debug: boolean +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'devup-p3-owner-'))) + for (const id of ['a.tsx', 'b.tsx']) + writeFileSync( + join(root, id), + "import {Box} from '@devup-ui/react'; export const view=;", + ) + debug = wasm.isDebug() + wasm.resetBuildState() +}) +afterEach(() => { + try { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.setDebug(debug) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +function reservation(owner: BuildGeneration, context: string) { + return new ProductionNumbering(owner, [ + { + context, + files: ['a.tsx', 'b.tsx'].map((id) => ({ + context, + id, + path: join(root, id), + realPath: join(root, id), + })), + nonphysical: [], + canonical: canonicalFor(context), + }, + ]) +} +function extract( + numbering: ProductionNumbering, + context: string, + id = 'a.tsx', +) { + const path = join(root, id) + numbering.assertReserved({ + context, + id, + source: { kind: 'physical', path, realPath: path }, + location: { filename: path }, + }) + const output = wasm.codeExtract( + id, + readFileSync(path, 'utf8'), + '@devup-ui/react', + 'df', + false, + false, + false, + {}, + ) + try { + return { + code: output.code, + map: output.map, + css: output.css, + cssFile: output.cssFile, + dependencies: output.dependencies, + updatedBaseStyle: output.updatedBaseStyle, + } + } finally { + output.free() + } +} +function run( + owner: BuildGeneration, + context: string, + action: () => T, +): T { + return runBuildOperation({ integration: 'Webpack P3 test', root }, () => + owner.run(engine, () => { + wasm.setDebug(false) + wasm.setModuleResolver(undefined) + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.importCanonicalMap(canonicalFor(context)) + return action() + }), + ) +} + +it('restores four real maps without reseeding when A resumes after B', () => { + // Given separate owners with independently specified original/bucket universes. + const A = new BuildGeneration() + const B = new BuildGeneration() + const a = reservation(A, 'A') + const b = reservation(B, 'B') + let seeds = 0 + const counting = { + ...wasm, + seedFileMap(files: string[]) { + seeds += 1 + wasm.seedFileMap(files) + }, + } + const first = run(A, 'A', () => { + a.seed(counting, 'A') + return extract(a, 'A') + }) + const captured = engine.capture() + expect(captured.files).toBe('{"A/a":0,"A/b":1,"a.tsx":2,"b.tsx":3}') + const other = run(B, 'B', () => { + b.seed(wasm, 'B') + return extract(b, 'B', 'b.tsx') + }) + expect(engine.capture().files).toBe('{"B/a":0,"B/b":1,"a.tsx":2,"b.tsx":3}') + // When the admitted A operation resets/restores/configures and calls its retained primitive. + const resumed = run(A, 'A', () => { + a.seed(counting, 'A') + expect(engine.capture()).toEqual(captured) + return extract(a, 'A') + }) + // Then raw output and all four captured maps survive the unrelated B operation. + expect(resumed).toEqual({ ...first, css: undefined }) + expect(other.code).toContain('df/devup-ui-1.css') + expect(first.code).toContain('df/devup-ui-0.css') + expect(engine.capture()).toEqual(captured) + expect(seeds).toBe(1) + expect(wasm.getCss(0, false)).toContain('color:red') +}) + +it.each(['action', 'capture'] as const)( + 'requires caller abort when %s fails after real seed', + (mode) => { + // Given actual seed/allocation and a narrow fault at the selected owner boundary. + const owner = new BuildGeneration() + const numbering = reservation(owner, 'A') + const fault = new RangeError(mode) + const selected = + mode === 'capture' + ? { + ...engine, + capture(): State { + throw fault + }, + } + : engine + // When the admitted operation fails and the integration aborts its owner. + expect(() => { + try { + runBuildOperation({ integration: 'Webpack P3 test', root }, () => + owner.run(selected, () => { + numbering.seed(wasm, 'A') + extract(numbering, 'A') + if (mode === 'action') throw fault + }), + ) + } catch (error) { + owner.dispose() + throw error + } + }).toThrow(fault) + // Then no retained primitive or owner can reuse a successful-but-uncommitted seed. + expect(owner.disposed).toBe(true) + expect(() => numbering.seed(wasm, 'A')).toThrow(ClosedBuildGenerationError) + expect(() => run(owner, 'A', () => extract(numbering, 'A'))).toThrow( + ClosedBuildGenerationError, + ) + }, +) + +it.each(['seed', 'restore'] as const)( + 'keeps the primitive terminal when %s fails inside an owner', + (mode) => { + // Given a real owner and unavailable downstream fault injected only at that seam. + const owner = new BuildGeneration() + const numbering = reservation(owner, 'A') + const fault = new RangeError(mode) + const faulty = { + seedFileMap(files: string[]) { + if (mode === 'seed') throw fault + wasm.seedFileMap(files) + }, + importCanonicalMap(map: Readonly>) { + if (mode === 'restore' && Object.keys(map).length > 0) throw fault + wasm.importCanonicalMap(map) + }, + } + // When seed fails and subsequent owner operations try the same primitive. + expect(() => run(owner, 'A', () => numbering.seed(faulty, 'A'))).toThrow( + ProductionNumberingError, + ) + // Then replay of real maps cannot revive that primitive; the caller aborts. + expect(() => run(owner, 'A', () => numbering.seed(wasm, 'A'))).toThrow( + ProductionNumberingError, + ) + owner.dispose() + expect(() => numbering.seed(wasm, 'A')).toThrow(ClosedBuildGenerationError) + }, +) + +it('replays fresh reservations when an old owner and primitive have disposed', () => { + // Given an old real captured generation followed by actual disposal. + const old = new BuildGeneration() + const retained = reservation(old, 'A') + const first = run(old, 'A', () => { + retained.seed(wasm, 'A') + return extract(retained, 'A') + }) + const state = engine.capture() + old.dispose() + const fresh = new BuildGeneration() + const numbering = reservation(fresh, 'A') + // When a fresh owner constructs and seeds its own primitive. + const replayed = run(fresh, 'A', () => { + numbering.seed(wasm, 'A') + return extract(numbering, 'A') + }) + // Then raw output/four maps replay, while the disposed primitive remains rejected. + expect(replayed).toEqual(first) + expect(engine.capture()).toEqual(state) + expect(() => retained.seed(wasm, 'A')).toThrow(ClosedBuildGenerationError) +}) diff --git a/packages/plugin-utils/src/__tests__/production-numbering-wasm-fixture.ts b/packages/plugin-utils/src/__tests__/production-numbering-wasm-fixture.ts new file mode 100644 index 000000000..57411c701 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-numbering-wasm-fixture.ts @@ -0,0 +1,144 @@ +import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' + +import { expect } from 'bun:test' + +import { collectProductionFileManifest } from '../production-file-manifest' +import type { ProductionNumberingPlan } from '../production-numbering' +import { enumerateProductionSourceFiles } from '../production-source-files' + +export const ids = [ + '@global', + 'alias/a.tsx', + 'bucket/alias-browser', + 'bucket/alias-server', + 'bucket/entry-browser', + 'bucket/entry-server', + 'bucket/raw-server', + 'bucket/server-b', + 'bucket/server-empty', + 'node_modules/palette/browser.js', + 'node_modules/palette/server.js', + 'outside/constants.ts', + 'outside/raw.css.ts', + 'src/a.tsx', + 'src/b.tsx', + 'src/empty.ts', + 'src/global.tsx', + 'src/é.tsx', + 'src/\ue000.tsx', + 'src/𐀀.tsx', +] as const +export const expected = JSON.stringify( + Object.fromEntries(ids.map((id, number) => [id, number])), +) +export const canonical = { + browser: { + 'src/a.tsx': 'bucket/entry-browser', + 'alias/a.tsx': 'bucket/alias-browser', + 'outside/raw.css.ts': 'src/empty.ts', + 'src/global.tsx': '@global', + }, + 'react-server': { + 'src/a.tsx': 'bucket/entry-server', + 'alias/a.tsx': 'bucket/alias-server', + 'outside/raw.css.ts': 'bucket/raw-server', + 'src/empty.ts': 'bucket/server-empty', + 'src/b.tsx': 'bucket/server-b', + 'src/global.tsx': '@global', + }, +} as const + +export async function createFixture(root: string) { + const toId = (path: string) => relative(root, path).replaceAll('\\', '/') + const write = (name: string, code: string) => { + const path = join(root, name) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, code) + } + write( + 'src/a.tsx', + "import {tone} from 'palette'; import {card} from '../outside/raw.css'; import {Box} from '@devup-ui/react'; export const view=;", + ) + write( + 'src/b.tsx', + "import {Box} from '@devup-ui/react'; export const view=;", + ) + write('src/empty.ts', 'export {}') + for (const name of ['global', 'é', '\ue000', '𐀀']) + write( + `src/${name}.tsx`, + "import {Box} from '@devup-ui/react'; export const view=;", + ) + write( + 'outside/raw.css.ts', + "import {style} from '@vanilla-extract/css'; import {ink} from 'tokens'; export const card=style({color:ink});", + ) + write('outside/constants.ts', "export const ink='purple'") + write( + 'node_modules/palette/package.json', + '{"exports":{"browser":"./browser.js","react-server":"./server.js"}}', + ) + write('node_modules/palette/browser.js', "export const tone='tomato'") + write('node_modules/palette/server.js', "export const tone='lime'") + write( + 'config/base.json', + '{"compilerOptions":{"paths":{"tokens":["../outside/constants.ts"]}}}', + ) + write('tsconfig.json', '{"extends":"./config/base.json"}') + symlinkSync(join(root, 'src'), join(root, 'alias'), 'junction') + // Given unique real files, inherited paths and two native export conditions. + const files = enumerateProductionSourceFiles({ + roots: ['src'], + sourceFiles: ['alias/a.tsx'], + cwd: root, + }) + const contexts = (['browser', 'react-server'] as const).map((key) => ({ + key, + files, + toId, + resolverOptions: { cwd: root, conditions: [key] }, + })) + // When actual nonallocating P1/P2 closes both context orders. + const manifest = await collectProductionFileManifest({ contexts }) + const reversed = await collectProductionFileManifest({ + contexts: contexts.toReversed().map((context) => ({ + ...context, + files: files.toReversed(), + })), + }) + const physical = [ + 'alias/a.tsx', + 'src/a.tsx', + 'src/b.tsx', + 'src/empty.ts', + 'src/global.tsx', + 'src/é.tsx', + 'src/\ue000.tsx', + 'src/𐀀.tsx', + 'outside/constants.ts', + 'outside/raw.css.ts', + ] + const memberships = contexts.flatMap(({ key }) => + [ + ...physical, + `node_modules/palette/${key === 'browser' ? 'browser' : 'server'}.js`, + ].map((id) => ({ + context: key, + id, + path: join(root, id), + realPath: realpathSync.native(join(root, id)), + })), + ) + // Then independent memberships, not the collector's own projection, match exactly. + expect(manifest).toEqual(expect.arrayContaining(memberships)) + expect(manifest).toHaveLength(memberships.length) + expect(reversed).toEqual(manifest) + const plans: readonly ProductionNumberingPlan[] = contexts.map(({ key }) => ({ + context: key, + files: manifest.filter((file) => file.context === key), + nonphysical: [], + canonical: canonical[key], + })) + return { root, plans, toId } +} diff --git a/packages/plugin-utils/src/__tests__/production-numbering-wasm.test.ts b/packages/plugin-utils/src/__tests__/production-numbering-wasm.test.ts new file mode 100644 index 000000000..33f51b8ae --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-numbering-wasm.test.ts @@ -0,0 +1,266 @@ +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, afterEach, beforeAll, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { BuildGeneration } from '../build-generation' +import { createModuleResolver } from '../import-graph' +import { + ProductionNumbering, + ProductionNumberingError, + type ProductionNumberingPlan, +} from '../production-numbering' +import { + canonical, + createFixture, + expected, + ids, +} from './production-numbering-wasm-fixture' + +let root: string +let plans: readonly ProductionNumberingPlan[] +let toId: (path: string) => string +const debug = wasm.isDebug() +beforeAll(async () => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'devup-p3-wasm-'))) + ;({ plans, toId } = await createFixture(root)) +}) +afterEach(() => { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.setDebug(debug) +}) +afterAll(() => { + try { + wasm.setModuleResolver(undefined) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +function extract(id: string) { + const output = wasm.codeExtract( + id, + readFileSync(join(root, id), 'utf8'), + '@devup-ui/react', + 'df', + false, + false, + false, + { '@vanilla-extract/css': null }, + ) + try { + return { + code: output.code, + map: output.map, + css: output.css, + cssFile: output.cssFile, + dependencies: output.dependencies, + updatedBaseStyle: output.updatedBaseStyle, + } + } finally { + output.free() + } +} +function physical(context: string, id: string, importer = id) { + const path = join(root, id) + return { + context, + id, + source: { + kind: 'physical' as const, + path, + realPath: realpathSync.native(path), + }, + location: { filename: path, importer }, + } +} +function resolverFor(numbering: ProductionNumbering, context: string) { + const resolver = createModuleResolver({ + cwd: root, + conditions: [context], + toId, + alias: { ignored: false }, + }) + return (specifier: string, importer: string) => { + const result = resolver(specifier, importer) + if (result === undefined) return result + switch (result.ignored) { + case true: + return result + case undefined: + numbering.assertReserved(physical(context, result.path, importer)) + return result + default: + return result satisfies never + } + } +} +type Control = 'complete' | 'omit-bucket' | 'omit-original' | 'wrong-canonical' +function build( + reverse: boolean, + active: string, + control: Control = 'complete', +) { + wasm.resetBuildState() + wasm.setDebug(false) + const ordered = reverse ? plans.toReversed() : plans + const numbering = new ProductionNumbering(new BuildGeneration(), ordered) + const engine = { + ...wasm, + seedFileMap(files: string[]) { + switch (control) { + case 'complete': + return wasm.seedFileMap(files) + case 'omit-bucket': + return wasm.seedFileMap( + files.filter((id) => id !== 'bucket/raw-server'), + ) + case 'omit-original': + return wasm.seedFileMap( + files.filter((id) => id !== 'outside/raw.css.ts'), + ) + case 'wrong-canonical': + wasm.importCanonicalMap(canonical['react-server']) + return wasm.seedFileMap(files) + default: + return control satisfies never + } + }, + } + numbering.seed(engine, active) + const seeded = wasm.exportFileMap() + const restored = wasm.exportCanonicalMap() + const outputs: Record, 'css'>> = {} + const publications: Record = {} + for (const plan of ordered) { + wasm.importCanonicalMap(plan.canonical) + wasm.setModuleResolver(resolverFor(numbering, plan.context)) + const direct = [ + 'outside/raw.css.ts', + 'src/a.tsx', + 'alias/a.tsx', + 'src/b.tsx', + 'src/global.tsx', + 'src/é.tsx', + 'src/\ue000.tsx', + 'src/𐀀.tsx', + ] + for (const id of reverse ? direct.toReversed() : direct) { + numbering.assertReserved(physical(plan.context, id)) + const { css, ...output } = extract(id) + outputs[`${plan.context}:${id}`] = output + if (css !== undefined) publications[output.cssFile ?? id] = css + } + } + return { + seeded, + restored, + outputs, + publications, + files: wasm.exportFileMap(), + classes: wasm.exportClassMap(), + css: ids.map((_, number) => wasm.getCss(number, false)), + global: wasm.getCss(undefined, false), + } +} + +it.each(['browser', 'react-server'])( + 'keeps raw AB/BA surfaces equal when seed restores %s', + (active) => { + // Given the same physical fixture, both frozen contexts and an independent Rust-order universe. + const AB = build(false, active) + // When every direct/recursive first allocation and context order reverses. + const BA = build(true, active) + // Then originals, single-lookup buckets, maps, code and finished CSS match without normalization. + expect(AB.seeded).toBe(expected) + expect(BA.seeded).toBe(expected) + expect(AB.files).toBe(expected) + expect(BA.files).toBe(expected) + expect(BA).toEqual(AB) + expect(JSON.parse(BA.restored)).toEqual( + active === 'browser' ? canonical.browser : canonical['react-server'], + ) + expect(AB.outputs['browser:src/a.tsx']?.code).toContain('df/devup-ui-4.css') + expect(AB.outputs['react-server:src/a.tsx']?.code).toContain( + 'df/devup-ui-5.css', + ) + expect(AB.css.join('')).toContain('color:purple') + expect(AB.global).toContain('width:11px') + console.info(JSON.stringify({ active, expected, AB, BA })) + }, +) + +it.each(['omit-bucket', 'omit-original', 'wrong-canonical'] as const)( + 'breaks the complete raw allocation gate when control is %s', + (control) => { + // Given an independently specified complete map and an intentionally defective real delegate. + const correct = build(false, 'browser') + // When a second-context bucket, raw original or identity seeding is omitted. + const defective = build(false, 'browser', control) + // Then the complete-map gate fails and extraction cannot repair the sorted assignment. + expect(defective.seeded).not.toBe(expected) + expect(defective.files).not.toBe(expected) + expect(defective.outputs).not.toEqual(correct.outputs) + expect(() => expect(defective.files).toBe(expected)).toThrow() + console.info(JSON.stringify({ control, defective })) + }, +) + +it('rejects a recursive physical child when its original membership is absent', () => { + // Given allocated union buckets but a defective browser membership. + wasm.resetBuildState() + const numbering = new ProductionNumbering( + new BuildGeneration(), + plans.map((plan) => ({ + ...plan, + files: plan.files.filter( + (file) => + plan.context !== 'browser' || file.id !== 'outside/raw.css.ts', + ), + })), + ) + numbering.seed(wasm, 'browser') + const resolver = resolverFor(numbering, 'browser') + wasm.setModuleResolver(resolver) + // When real WASM requests the physical stylesheet recursively. + expect(() => extract('src/a.tsx')).toThrow('src/a.tsx:1:1') + // Then the local structured defect survives without claiming JS identity across WASM. + expect(() => resolver('../outside/raw.css', 'src/a.tsx')).toThrow( + ProductionNumberingError, + ) +}) + +it('sorts normalized nonphysical originals with physical and single-lookup buckets when reserved', () => { + // Given normalized IDs interleaved with originals and cross-context bucket keys. + const selected = plans.map((plan) => ({ + ...plan, + nonphysical: ['bucket/alias-browser!', 'outside/raw.css.ts!', 'src/é.tsx!'], + canonical: { ...plan.canonical, 'outside/raw.css.ts!': 'alias/a.tsx!' }, + })) + const universe = [ + ...ids.slice(0, 2), + 'alias/a.tsx!', + ids[2], + 'bucket/alias-browser!', + ...ids.slice(3, 13), + 'outside/raw.css.ts!', + ...ids.slice(13, 18), + 'src/é.tsx!', + ...ids.slice(18), + ] + const numbering = new ProductionNumbering(new BuildGeneration(), selected) + // When one Rust sort/dense seed runs under the identity map. + numbering.seed(wasm, 'browser') + // Then the independent mixed universe is dense and the frozen selected map is restored. + expect(wasm.exportFileMap()).toBe( + JSON.stringify( + Object.fromEntries(universe.map((id, number) => [id, number])), + ), + ) + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual(selected[0]?.canonical) +}) diff --git a/packages/plugin-utils/src/__tests__/production-numbering.test.ts b/packages/plugin-utils/src/__tests__/production-numbering.test.ts new file mode 100644 index 000000000..d6b0471cd --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-numbering.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import * as wasm from '../../../../bindings/devup-ui-wasm/pkg' +import { + BuildGeneration, + ClosedBuildGenerationError, +} from '../build-generation' +import { + type ProductionExtractionInput, + ProductionNumbering, + ProductionNumberingError, +} from '../production-numbering' + +const file = { + context: 'A', + id: 'a.tsx', + path: '/alias/a.tsx', + realPath: '/physical/a.tsx', +} +const plan = { + context: 'A', + files: [file, file, { ...file, id: 'toString' }], + nonphysical: [], + canonical: { 'a.tsx': 'bucket', missing: 'bucket' }, +} +const input: ProductionExtractionInput = { + context: 'A', + id: 'a.tsx', + source: { kind: 'physical', path: file.path, realPath: file.realPath }, + location: { filename: file.path, importer: '/entry.tsx' }, +} +let owner: BuildGeneration +let numbering: ProductionNumbering +let debug: boolean +beforeEach(() => { + debug = wasm.isDebug() + wasm.resetBuildState() + wasm.setDebug(false) + owner = new BuildGeneration() + numbering = new ProductionNumbering(owner, [plan]) +}) +afterEach(() => { + wasm.resetBuildState() + wasm.setModuleResolver(undefined) + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.setDebug(debug) +}) +function failure(action: () => unknown): ProductionNumberingError { + try { + action() + } catch (error) { + if (error instanceof ProductionNumberingError) return error + throw error + } + throw new Error('Expected production numbering failure') +} + +it('retains deep copies when caller-owned plans mutate', () => { + // Given mutable records, arrays and canonical configuration. + const record = { ...file } + const canonical = { 'a.tsx': 'bucket', virtual: 'virtual-bucket' } + const files = [record] + const nonphysical = ['virtual'] + const plans = [{ context: 'A', files, canonical, nonphysical }] + const frozen = new ProductionNumbering(owner, plans) + record.path = '/changed' + canonical['a.tsx'] = 'changed' + canonical.virtual = 'changed-virtual' + nonphysical[0] = 'changed-original' + nonphysical.push('late') + files.length = 0 + plans.length = 0 + // When seed uses the constructor's authority. + frozen.seed(wasm, 'A') + // Then copies preserve exact membership and canonical configuration. + expect(frozen.files).toEqual([file]) + expect(frozen.buckets).toEqual([ + 'a.tsx', + 'bucket', + 'virtual', + 'virtual-bucket', + ]) + expect( + [frozen.files, ...frozen.files, frozen.buckets].every(Object.isFrozen), + ).toBe(true) + expect(wasm.exportFileMap()).toBe( + '{"a.tsx":0,"bucket":1,"virtual":2,"virtual-bucket":3}', + ) + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual({ + 'a.tsx': 'bucket', + virtual: 'virtual-bucket', + }) + expect(() => frozen.assertReserved(input)).not.toThrow() +}) + +it.each([ + { context: 'other' }, + { id: 'bucket' }, + { id: 'missing' }, + { source: { ...input.source, path: file.realPath } }, + { source: { ...input.source, realPath: '/elsewhere' } }, +])('rejects exact provenance misses when input changes by %j', (change) => { + // Given a seeded reservation whose bucket alone is not membership. + numbering.seed(wasm, 'A') + const changed = { ...input, ...change } + // When exact physical membership fails. + const error = failure(() => numbering.assertReserved(changed)) + // Then the typed discovery defect retains the actual boundary metadata. + expect(error.reason).toBe('physical-miss') + expect([error.context, error.id]).toEqual([changed.context, changed.id]) + expect(error.location).toEqual({ ...input.location, line: 1, column: 1 }) + expect(Object.isFrozen(error.location)).toBe(true) + expect(Object.hasOwn(error, 'cause')).toBe(false) +}) + +it('retains supplied coordinates when extraction is unseeded', () => { + // Given an unseeded primitive and a known source span. + const location = { filename: file.path, line: 7, column: 9 } + // When physical admission precedes seed. + const error = failure(() => numbering.assertReserved({ ...input, location })) + // Then the known span is not replaced by boundary defaults. + expect(error.reason).toBe('unseeded') + expect(error.location).toEqual(location) + expect(wasm.exportFileMap()).toBe('{}') +}) + +it.each([ + { plans: [plan, plan] }, + { plans: [{ ...plan, files: [{ ...file, context: 'B' }] }] }, + { plans: [{ ...plan, files: [file, { ...file, path: '/other' }] }] }, + { plans: [{ ...plan, files: [file, { ...file, realPath: '/other' }] }] }, +])('rejects invalid authority when plans are %j', ({ plans }) => { + // Given conflicting contexts or original-ID provenance. + // When construction validates the complete plans. + const error = failure(() => new ProductionNumbering(owner, plans)) + // Then construction fails before allocation. + expect(error.reason).toBe('invalid-plan') + expect(error.context).toBe('A') + expect(wasm.exportFileMap()).toBe('{}') +}) + +it('leaves configuration untouched when active context is unknown', () => { + // Given an independently configured real engine. + wasm.importCanonicalMap({ prior: 'target' }) + // When context selection has no frozen authority. + const error = failure(() => numbering.seed(wasm, 'B')) + // Then no identity install or allocation occurred. + expect(error.reason).toBe('invalid-plan') + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual({ prior: 'target' }) + expect(wasm.exportFileMap()).toBe('{}') +}) + +it.each([false, true])( + 'preserves caller replay when successful seed repeats, empty=%s', + (empty) => { + // Given a successfully seeded real engine, including prototype-shadowing IDs. + const selected = empty ? { ...plan, files: [] } : plan + const once = new ProductionNumbering(owner, [selected]) + once.seed(wasm, 'A') + const expected = empty ? '{}' : '{"a.tsx":0,"bucket":1,"toString":2}' + wasm.importCanonicalMap({ replay: 'independent' }) + // When seed repeats successfully. + once.seed(wasm, 'A') + // Then the entire file map and current replayed configuration survive unchanged. + expect(wasm.exportFileMap()).toBe(expected) + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual({ + replay: 'independent', + }) + }, +) + +it.each([ + 'identity', + 'seed', + 'restore', + 'dual', + 'identity-dual', + 'undefined', + 'empty', +])('retains terminal causes when %s fails', (mode) => { + // Given narrow unavailable-fault delegates around the real engine. + const selected = mode === 'empty' ? { ...plan, files: [] } : plan + const terminal = new ProductionNumbering(owner, [selected]) + const cause = mode === 'undefined' ? undefined : new RangeError(mode) + const restoreCause = new TypeError('restore fault') + const engine = { + seedFileMap(files: string[]) { + if (['seed', 'dual', 'undefined', 'empty'].includes(mode)) throw cause + wasm.seedFileMap(files) + }, + importCanonicalMap(map: Readonly>) { + if (Object.keys(map).length === 0 && mode.startsWith('identity')) + throw cause + if ( + Object.keys(map).length > 0 && + ['restore', 'dual', 'identity-dual'].includes(mode) + ) + throw restoreCause + wasm.importCanonicalMap(map) + }, + } + // When identity, seed (even empty) or restoration fails. + const error = failure(() => terminal.seed(engine, 'A')) + // Then finally cannot mask the primary fault and no terminal call retries. + expect(error.reason).toBe('seed-failure') + expect(error.cause).toBe(mode === 'restore' ? restoreCause : cause) + expect(error.restoreCause).toBe( + ['restore', 'dual', 'identity-dual'].includes(mode) + ? restoreCause + : undefined, + ) + expect(Object.hasOwn(error, 'cause')).toBe(true) + expect(failure(() => terminal.seed(wasm, 'A'))).toBe(error) + expect(failure(() => terminal.assertReserved(input))).toBe(error) +}) + +it('rejects all boundaries when the actual owner disposed', () => { + // Given a retained primitive and a closed owner. + owner.dispose() + // When constructor, seed and physical admission cross the disposed boundary. + // Then the existing owner error owns each rejection, not a new numbering reason. + expect(() => new ProductionNumbering(owner, [plan])).toThrow( + ClosedBuildGenerationError, + ) + expect(() => numbering.seed(wasm, 'A')).toThrow(ClosedBuildGenerationError) + expect(() => numbering.assertReserved(input)).toThrow( + ClosedBuildGenerationError, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/production-package-roots.test.ts b/packages/plugin-utils/src/__tests__/production-package-roots.test.ts new file mode 100644 index 000000000..9ff90addb --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-package-roots.test.ts @@ -0,0 +1,162 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { expect, it, spyOn } from 'bun:test' + +import { createDirectoryExclusion } from '../directory-exclusion' +import { createProductionPackageRoots } from '../production-package-roots' +import { + createResolutionInputs, + type ResolutionInputs, +} from '../resolution-inputs' + +async function fixture( + run: (root: string, file: (path: string) => string) => void, +) { + const root = fs.realpathSync.native( + fs.mkdtempSync(join(tmpdir(), 'devup-p2-roots-')), + ) + const file = (path: string) => { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, '{}') + return target + } + try { + run(root, file) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +} + +it('discovers both implicit scopes and exact includes with lexical-start-local caching', async () => { + await fixture((root, file) => { + // Given an ancestor installation, a nested copy and non-admitted neighbors. + const parent = ['@devup-ui/z', '@devup-editor/editor', 'chosen'].map( + (name) => dirname(file(`node_modules/${name}/package.json`)), + ) + const nested = dirname( + file('outside/node_modules/@devup-ui/z/package.json'), + ) + file('node_modules/chosen-other/package.json') + const observed: ResolutionInputs[] = [] + const roots = createProductionPackageRoots( + ['chosen', 'missing'], + createDirectoryExclusion(), + { + inputs: createResolutionInputs(), + observer: (inputs) => observed.push(inputs), + }, + ) + // When the same start is reused and a different lexical start is independently discovered. + const first = roots(root) + const read = spyOn(fs, 'readdirSync') + try { + expect(roots(root)).toBe(first) + expect(read).not.toHaveBeenCalled() + expect(roots(join(root, 'outside'))).toEqual([...parent, nested].sort()) + } finally { + read.mockRestore() + } + // Then selection is exact, and lexical consulted manifests/misses remain observable. + expect(first).toEqual(parent.sort()) + expect( + observed.flatMap(({ fileDependencies }) => fileDependencies), + ).toContain(join(nested, 'package.json')) + expect( + observed.flatMap(({ missingDependencies }) => missingDependencies), + ).toContain(join(root, 'node_modules/missing/package.json')) + }) +}) + +it.each(['scope', 'package'] as const)( + 'excludes lexical and physical %s paths before inventory', + async (kind) => { + await fixture((root, file) => { + // Given aliases into blocked physical output and a lexical blocked sibling. + const physical = dirname(file('blocked/package.json')) + const scope = join(root, 'node_modules/@devup-ui') + fs.mkdirSync(dirname(scope), { recursive: true }) + if (kind === 'scope') fs.symlinkSync(physical, scope, 'junction') + else { + fs.mkdirSync(scope) + fs.symlinkSync(physical, join(scope, 'linked'), 'junction') + } + const lexical = dirname( + file('node_modules/@devup-editor/blocked/package.json'), + ) + const excluded = createDirectoryExclusion([ + physical, + lexical, + join(root, 'node_modules/@devup-editor'), + ]) + const inputs = createResolutionInputs() + const roots = createProductionPackageRoots([], excluded, { + inputs, + observer: undefined, + }) + // When direct discovery respects physical and lexical exclusion independently. + const result = roots(root) + // Then neither alias nor lexical scope can become a distribution origin. + expect(result).toEqual([]) + }) + }, +) + +it.each(['ENOTDIR', 'EACCES', 'non-error'])( + 'retains direct discovery outcome for %s scope faults', + async (code) => { + await fixture((root, file) => { + // Given a real existing scope and a narrow fault at its directory read boundary. + file('node_modules/@devup-ui/pkg/package.json') + const scope = join(root, 'node_modules/@devup-ui') + const cause = + code === 'non-error' + ? 'fault' + : Object.assign(new Error(code), { code }) + const original = fs.readdirSync + const read = spyOn(fs, 'readdirSync').mockImplementation( + new Proxy(original, { + apply(target, receiver, args) { + if (args[0] === scope) throw cause + return Reflect.apply(target, receiver, args) + }, + }), + ) + const inputs = createResolutionInputs() + const observed: ResolutionInputs[] = [] + const roots = createProductionPackageRoots( + [], + createDirectoryExclusion(), + { + inputs, + observer: (snapshot) => observed.push(snapshot), + }, + ) + try { + // When direct discovery sees a missing-directory class or a fatal original cause. + if (code === 'ENOTDIR') expect(roots(root)).toEqual([]) + else { + let caught: unknown + try { + roots(root) + } catch (failure) { + caught = failure + } + expect(caught).toBe(cause) + } + // Then the finally observer preserves the actual lexical repair input. + expect( + observed.flatMap((snapshot) => + code === 'ENOTDIR' + ? snapshot.missingDependencies + : snapshot.fileDependencies, + ), + ).toContain(scope) + } finally { + read.mockRestore() + } + }) + }, +) diff --git a/packages/plugin-utils/src/__tests__/production-source-files.test.ts b/packages/plugin-utils/src/__tests__/production-source-files.test.ts new file mode 100644 index 000000000..46df2dca2 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/production-source-files.test.ts @@ -0,0 +1,250 @@ +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 { collectNumberedFiles } from '../numbering' +import { enumerateProductionSourceFiles } from '../production-source-files' + +let root: string +beforeEach(() => { + const parent = join(tmpdir(), 'opencode/workers/w20-plugins-core') + fs.mkdirSync(parent, { recursive: true }) + root = fs.realpathSync.native(fs.mkdtempSync(join(parent, 'w20k-p1-'))) +}) +afterEach(() => fs.rmSync(root, { recursive: true, force: true })) +function file(path: string, content = 'export {}'): string { + const target = join(root, path) + fs.mkdirSync(dirname(target), { recursive: true }) + fs.writeFileSync(target, content) + return target +} +function inventory(paths: readonly string[]) { + return [...paths].sort().map((path) => ({ path, realPath: path })) +} + +it('demonstrates physical omissions when the unchanged legacy collector filters source', () => { + // Given physical, test-named and linked extraction candidates. + file('src/plain.ts') + file('src/view.test.tsx', "import '@devup-ui/react'") + const target = file('outside/value.ts', "import '@devup-ui/react'") + fs.symlinkSync(dirname(target), join(root, 'src/linked'), 'junction') + // When legacy numbering uses its existing scanner and raw needles. + const legacy = collectNumberedFiles({ + roots: [join(root, 'src')], + needles: ['@devup-ui/react'], + }) + // Then all three real candidates are absent; legacy behavior is untouched. + expect(legacy).toEqual([]) +}) + +it('enumerates needle-free and test-named physical files when production discovery runs', () => { + // Given files whose eligibility must not depend on styling text or test names. + const plain = file('src/plain.ts') + const test = file('src/view.test.tsx') + // When the new dormant enumerator visits the source root. + const files = enumerateProductionSourceFiles({ roots: [join(root, 'src')] }) + // Then each physical source retains its lexical extraction path. + expect(files).toEqual(inventory([plain, test])) +}) + +it.each([false, true])('keeps input permutations equal (%s)', (reverse) => { + // Given overlapping project roots, outside entries and explicit sources. + const paths = ['p/Z.spec.ts', 'p/a.ts', 'e/b.ts', 's/c.ts'].map((path) => + file(path), + ) + const entry = file('e/index.html') + file('s/unselected.txt') + file('s/unselected.ts') + const roots = ['p', 'p/nested', 'missing'] + const sources = ['s/c.ts', 'p/a.ts'] + const entries = [entry, join(root, 'p/a.ts')] + // When each configuration list arrives in either order. + const files = enumerateProductionSourceFiles({ + cwd: root, + roots: reverse ? roots.toReversed() : roots, + sourceFiles: reverse ? sources.toReversed() : sources, + entries: reverse ? entries.toReversed() : entries, + }) + // Then exact paths are deduplicated and codepoint-sorted, not locale-sorted. + expect(files).toEqual(inventory(paths)) +}) + +it.each([false, true])('retains aliases despite cycles (%s)', (reverse) => { + // Given two aliases, an ancestry cycle and physical files in node_modules. + const target = file('node_modules/pkg/dist/value.ts') + fs.mkdirSync(join(root, 'src')) + fs.symlinkSync(dirname(target), join(root, 'src/first'), 'junction') + fs.symlinkSync(dirname(target), join(root, 'src/second'), 'junction') + const cycle = join(dirname(target), 'cycle') + fs.symlinkSync(join(root, 'src'), cycle, 'junction') + const roots = ['src', 'src/first'] + // When project and explicit linked roots are traversed without global realpath dedupe. + const files = enumerateProductionSourceFiles({ + cwd: root, + roots: reverse ? roots.toReversed() : roots, + }) + // Then both reachable lexical spellings survive and traversal terminates. + expect(files).toEqual( + ['first', 'second'].map((name) => ({ + path: join(root, `src/${name}/value.ts`), + realPath: target, + })), + ) +}) + +it('keeps linked source files when an explicit lexical file is selected', () => { + // Given a file alias (native file symlink on hosts permitted to create it). + const target = file('physical/value.ts') + const alias = join(root, 'alias.ts') + if (process.platform === 'win32') fs.linkSync(target, alias) + else fs.symlinkSync(target, alias, 'file') + // When only that lexical source is selected. + const files = enumerateProductionSourceFiles({ + roots: [], + sourceFiles: [alias], + }) + // Then the extraction spelling remains the alias, with native physical identity. + expect(files).toEqual([ + { path: alias, realPath: fs.realpathSync.native(alias) }, + ]) +}) + +it('keeps installed dist when only nested own output is excluded', () => { + // Given same-basename package dist and own output plus blanket dependencies. + const kept = file('node_modules/pkg/dist/kept.js') + const selected = file('node_modules/other/selected.ts') + const own = file('build/nested/dist/poison.ts') + file('node_modules/unused/absent.ts') + file('src/nested/node_modules/unused/absent.ts') + // When a project root, explicit package root and source are selected. + const files = enumerateProductionSourceFiles({ + roots: [root, dirname(kept)], + sourceFiles: [selected], + exclude: [dirname(own)], + }) + // Then absolute output exclusion does not remove installed package dist. + expect(files).toEqual(inventory([kept, selected])) +}) + +it('excludes original and physical directories before any directory read', () => { + // Given excluded roots and linked physical targets, with a retained sibling. + const kept = file('src/kept.ts') + const blocked = file('blocked/poison.ts') + file('src/generated/poison.ts') + fs.symlinkSync(dirname(blocked), join(root, 'src/physical'), 'junction') + fs.symlinkSync(dirname(kept), join(root, 'lexical'), 'junction') + const read = spyOn(fs, 'readdirSync') + try { + // When exclusions apply independently to lexical and physical targets. + const files = enumerateProductionSourceFiles({ + roots: [root, 'lexical', 'blocked'], + cwd: root, + exclude: ['generated', dirname(blocked), join(root, 'lexical')], + }) + // Then excluded paths never reach directory IO, even through a link. + expect(files).toEqual([{ path: kept, realPath: kept }]) + expect(read.mock.calls.map(([path]) => path)).toEqual([root, dirname(kept)]) + } finally { + read.mockRestore() + } +}) + +it('enumerates dependencies when node_modules itself is an explicit root', () => { + // Given an explicitly supplied dependency tree and unrelated project source. + const selected = file('node_modules/pkg/dist/value.ts') + file('src/unselected.ts') + // When node_modules is configured directly, not discovered by project descent. + const files = enumerateProductionSourceFiles({ + cwd: root, + roots: ['node_modules'], + }) + // Then its selected package source survives the basename traversal rule. + expect(files).toEqual(inventory([selected])) +}) + +it.each([[undefined], [false], [true], [[]], [['.MDOWN']]])( + 'selects exact extensions (%j)', + (selection) => { + // Given all eight JS/TS extensions and distinct Markdown candidates. + const js = 'ts tsx mts cts js jsx mjs cjs' + .split(' ') + .map((extension) => file(`src/value.${extension.toUpperCase()}`)) + const mdx = file('src/guide.MDX') + const mdown = file('src/guide.mdown') + file('src/guide.md') + // When the shared opt-in policy is passed unchanged. + const files = enumerateProductionSourceFiles({ + roots: [join(root, 'src')], + ...(selection === undefined ? {} : { includeMdx: selection }), + }) + // Then boolean true selects only MDX; lists select only literal extensions. + const extra = + selection === true + ? [mdx] + : Array.isArray(selection) && selection.length + ? [mdown] + : [] + expect(files).toEqual(inventory([...js, ...extra])) + }, +) + +it('does not read source contents when physical eligibility is enumerated', () => { + // Given a source with arbitrary bytes, not a styling needle. + const path = file('src/unread.ts', '\0arbitrary bytes') + const read = spyOn(fs, 'readFileSync') + try { + // When enumeration only inspects physical metadata. + const files = enumerateProductionSourceFiles({ roots: [dirname(path)] }) + // Then content IO is absent. + expect(files).toEqual([{ path, realPath: path }]) + expect(read).not.toHaveBeenCalled() + } finally { + read.mockRestore() + } +}) + +it.each(['sourceFiles', 'entries'] as const)( + 'rejects absent explicit %s', + (kind) => { + // Given a missing explicit source or entry, unlike an optional root. + const path = join(root, 'missing.ts') + // When explicit physical discovery is requested. + const run = () => + enumerateProductionSourceFiles({ roots: [], [kind]: [path] }) + // Then the native IO failure is not a virtual-input or fallback result. + expect(run).toThrow(/ENOENT/) + }, +) + +it('throws a native resolution error when an existing link target is absent', () => { + // Given an existing directory link whose target has been removed. + const target = join(root, 'target') + fs.mkdirSync(target) + fs.symlinkSync(target, join(root, 'dangling'), 'junction') + fs.rmdirSync(target) + // When even an optional configured root resolves that existing link. + const run = () => + enumerateProductionSourceFiles({ roots: [join(root, 'dangling')] }) + // Then the physical resolution failure propagates. + expect(run).toThrow(/ENOENT/) +}) + +it('propagates directory read faults when an existing root cannot be read', () => { + // Given an existing root and a deterministic OS read-fault seam. + const fault = Object.assign(new Error('directory read denied'), { + code: 'EACCES', + }) + const read = spyOn(fs, 'readdirSync').mockImplementation(() => { + throw fault + }) + try { + // When directory enumeration fails after physical resolution. + const run = () => enumerateProductionSourceFiles({ roots: [root] }) + // Then the exact original failure survives without a catch/fallback wrapper. + expect(run).toThrow(fault) + } finally { + read.mockRestore() + } +}) 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 000000000..93f2364c5 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-evidence.test.ts @@ -0,0 +1,239 @@ +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, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/a'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + })), + ) + 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 000000000..b4509c050 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/request-source.test.ts @@ -0,0 +1,249 @@ +import { join } from 'node:path' + +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, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) +}) + +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, + inputs: { + fileDependencies: [leaf], + missingDependencies: [ + join(root, 'src/leaf'), + join(root, 'tsconfig.json'), + ].sort(), + }, + }, + }, + ]) + 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/__tests__/resolution-inputs.test.ts b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts new file mode 100644 index 000000000..3f4524766 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/resolution-inputs.test.ts @@ -0,0 +1,284 @@ +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', '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', + 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 === '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' || mode === 'json' ? '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( + expect.objectContaining({ + fileDependencies: [config, manifest, base].sort(), + missingDependencies: expect.arrayContaining([ + join(root, 'src/node_modules/preset/package.json'), + ]), + }), + ) + }, +) + +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/__tests__/shared-api.test.ts b/packages/plugin-utils/src/__tests__/shared-api.test.ts new file mode 100644 index 000000000..a8e23d656 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shared-api.test.ts @@ -0,0 +1,193 @@ +import * as fs from 'node:fs' +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, spyOn } 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)).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', () => { + const entry = file('src/main.ts', "import '../outside'") + file('outside.js') + const graph = buildStaticImportGraph('src', undefined, { + cwd: root, + alias: {}, + }) + expect(graph.files).toEqual([entry]) +}) + +it('returns only a physical path when explicit Markdown has no preparation', async () => { + // Given raw Markdown that the evaluator cannot return without compilation. + const entry = file('src/main.ts') + const page = file('outside/page.mdx', '# Raw Markdown') + // When the path-only seam resolves the exact physical filename. + const { createModulePathResolver } = await import('../import-graph') + const read = spyOn(fs, 'readFileSync') + try { + const result = createModulePathResolver({ cwd: root })( + '../outside/page.mdx', + entry, + ) + // Then discovery has no compiled source dependency and preserves resolver spelling. + expect(result).toEqual({ path: page, request: '../outside/page.mdx' }) + expect(read.mock.calls.map(([path]) => path)).not.toContain(page) + } finally { + read.mockRestore() + } +}) 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 000000000..0d19b9b70 --- /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/__tests__/source-type.test.ts b/packages/plugin-utils/src/__tests__/source-type.test.ts new file mode 100644 index 000000000..f98f349a8 --- /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/__tests__/state-writer.test.ts b/packages/plugin-utils/src/__tests__/state-writer.test.ts new file mode 100644 index 000000000..661189cf2 --- /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/__tests__/test-groups.test.ts b/packages/plugin-utils/src/__tests__/test-groups.test.ts new file mode 100644 index 000000000..18d0ff4ff --- /dev/null +++ b/packages/plugin-utils/src/__tests__/test-groups.test.ts @@ -0,0 +1,78 @@ +import { fileURLToPath } from 'node:url' + +import { expect, it } from 'bun:test' + +import { + discoverTests, + groupRules, + parseTestGroup, + partitionTests, + TestGroupingError, +} from '../../../../test-harness/groups' +import { rootTestRun } from '../../../../test-harness/run' + +const root = fileURLToPath(new URL('../../../../', import.meta.url)) + +it('assigns every actual test exactly once when the full root is discovered', () => { + const files = discoverTests(root) + const groups = rootTestRun(root).groups + const assigned = [...groups.isolated, ...groups.runtime].sort() + expect(assigned).toEqual(files) + expect(new Set(assigned).size).toBe(files.length) + expect( + groups.runtime.every((file) => + /packages\/(bun-plugin|components|react|rsbuild-plugin|vite-plugin)\//.test( + file, + ), + ), + ).toBe(true) + expect( + groups.isolated.some((file) => file.endsWith('mixed-admission.test.ts')), + ).toBe(true) +}) + +it.each([ + { files: ['packages/unknown/src/x.test.ts'], rules: groupRules }, + { + files: [ + 'packages/plugin-utils/src/x.test.ts', + 'packages/plugin-utils/src/x.test.ts', + ], + rules: groupRules, + }, + { + files: ['packages/components/src/x.test.ts'], + rules: [ + ...groupRules, + { directory: 'packages/components/src/', group: 'isolated' as const }, + ], + }, + { + files: ['packages/components/src/x.test.ts'], + rules: [...groupRules, groupRules[1]].filter((rule) => rule !== undefined), + }, +])( + 'refuses omitted or contradictory discovery when grouping is $files', + ({ files, rules }) => { + expect(() => partitionTests(files, rules)).toThrow(TestGroupingError) + }, +) + +it('sorts the explicit groups when discovery arrives in reverse order', () => { + const files = [ + 'packages/webpack-plugin/z.test.ts', + 'packages/webpack-plugin/a.test.ts', + ] + expect(partitionTests(files).isolated).toEqual([...files].reverse()) +}) + +it.each([undefined, 'runtime', 'isolated'] as const)( + 'accepts the known preload role when it is %s', + (group) => { + expect(parseTestGroup(group)).toBe(group) + }, +) + +it('fails closed when the child role is unknown', () => { + expect(() => parseTestGroup('unknown')).toThrow(TestGroupingError) +}) diff --git a/packages/plugin-utils/src/__tests__/test-processes.test.ts b/packages/plugin-utils/src/__tests__/test-processes.test.ts new file mode 100644 index 000000000..887564e9c --- /dev/null +++ b/packages/plugin-utils/src/__tests__/test-processes.test.ts @@ -0,0 +1,163 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach, beforeEach, expect, it, spyOn } from 'bun:test' + +import { CoverageError, parseLcov } from '../../../../test-harness/lcov' +import { prepareTests } from '../../../../test-harness/preload' +import { + parseTestResult, + runTestGroups, + TestProcessError, +} from '../../../../test-harness/run' + +let restore: () => void +beforeEach(() => { + const output = spyOn(console, 'info').mockImplementation(() => undefined) + const errors = spyOn(console, 'error').mockImplementation(() => undefined) + restore = () => { + output.mockRestore() + errors.mockRestore() + } +}) +afterEach(() => restore()) + +function fixture(assertion = "expect(value()).toBe('runtime')") { + const root = mkdtempSync(join(tmpdir(), 'devup-test-harness-')) + mkdirSync(join(root, 'tests')) + const config = join(root, 'bunfig.toml') + writeFileSync( + config, + '[test]\ncoverage=true\ncoverageReporter=["lcov"]\ncoverageThreshold=0.0\ncoverageSkipTestFiles=true\n', + ) + writeFileSync( + join(root, 'source.ts'), + 'export function value() {return process.env.DEVUP_TEST_GROUP}\n', + ) + writeFileSync( + join(root, 'tests/a.test.ts'), + "import {expect,it} from 'bun:test';import {value} from '../source';it('isolated',()=>expect(value()).toBe('isolated'))", + ) + writeFileSync( + join(root, 'tests/b.test.ts'), + `import {expect,it} from 'bun:test';import {value} from '../source';it('runtime',()=>{${assertion}})`, + ) + return { + root, + config, + groups: { isolated: ['tests/a.test.ts'], runtime: ['tests/b.test.ts'] }, + required: ['source.ts'], + } +} + +it('runs both real groups separately when the root harness is invoked', async () => { + const run = fixture() + try { + const summary = await runTestGroups(run) + expect(parseTestResult(summary)).toEqual({ pass: 2, fail: 0 }) + const files = parseLcov( + readFileSync(join(run.root, 'coverage/lcov.info'), 'utf8'), + ).map((source) => source.file) + expect(files).toContain('source.ts') + expect( + files.some((file) => file.endsWith('test-harness/producer-preload.ts')), + ).toBe(true) + expect( + files.map((file) => resolve(run.root, file).replaceAll('\\', '/')).sort(), + ).toEqual( + [ + join(run.root, 'source.ts'), + resolve( + import.meta.dir, + '../../../../test-harness/producer-preload.ts', + ), + resolve( + import.meta.dir, + '../../../../test-harness/producer-timeout.ts', + ), + ] + .map((file) => file.replaceAll('\\', '/')) + .sort(), + ) + expect(files.length).toBe(3) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it('propagates a real second-process assertion failure when the first group succeeds', async () => { + const run = fixture("expect(value()).toBe('isolated')") + try { + await expect(runTestGroups(run)).rejects.toThrow(TestProcessError) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it('rejects missing source coverage when both processes pass', async () => { + const run = fixture() + try { + await expect( + runTestGroups({ ...run, required: ['missing.ts'] }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it('refuses an empty configured group when the harness would omit its tests', async () => { + const run = fixture() + try { + await expect( + runTestGroups({ + ...run, + groups: { isolated: [], runtime: run.groups.runtime }, + }), + ).rejects.toThrow(CoverageError) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it('propagates the launch error when the test executable is absent', async () => { + const run = fixture() + try { + await expect( + runTestGroups(run, join(run.root, 'missing-executable')), + ).rejects.toThrow(Error) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it.each(['', '1 pass', '0 fail'])( + 'refuses incomplete child totals when output is %s', + (output) => { + expect(() => parseTestResult(output)).toThrow(CoverageError) + }, +) + +it('returns success exactly once when root preload finishes the real synthetic groups', async () => { + const run = fixture() + try { + expect(await prepareTests(undefined, run)).toBe(0) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) + +it('returns failure instead of retrying discovery when root preload sees a failed child', async () => { + const run = fixture("expect(value()).toBe('isolated')") + try { + expect(await prepareTests(undefined, run)).toBe(1) + } finally { + rmSync(run.root, { recursive: true, force: true }) + } +}) 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 000000000..2b1361c74 --- /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 000000000..0d91e92c5 --- /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 000000000..a8d39c224 --- /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 000000000..4bc37c779 --- /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 000000000..9435dfca2 --- /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 000000000..60f2cfbfb --- /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 000000000..5cd7cae16 --- /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 000000000..b7df5ce75 --- /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 000000000..f25770a8b --- /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__/tsconfig.test.ts b/packages/plugin-utils/src/__tests__/tsconfig.test.ts new file mode 100644 index 000000000..9fd01d147 --- /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/__tests__/typescript-version-range.test.ts b/packages/plugin-utils/src/__tests__/typescript-version-range.test.ts new file mode 100644 index 000000000..a5641e26a --- /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/build-admission.cts b/packages/plugin-utils/src/build-admission.cts new file mode 100644 index 000000000..96d40ce69 --- /dev/null +++ b/packages/plugin-utils/src/build-admission.cts @@ -0,0 +1,71 @@ +/** @internal Metadata supplied by the integration at its actual boundary. */ +export interface BuildIntegration { + readonly integration: string + readonly root: string +} + +export interface ResettableEngine { + resetBuildState(): void +} + +/** @internal Mixed opaque legacy state cannot be replayed by an owner. */ +export class MixedBuildIntegrationError extends Error { + constructor( + readonly requested: BuildIntegration, + readonly active: BuildIntegration, + ) { + super( + `[devup-ui] ${requested.integration} at ${requested.root}: cannot activate while ${active.integration} at ${active.root} is using the shared build engine; run them in separate processes`, + ) + this.name = 'MixedBuildIntegrationError' + } +} + +// Only admission metadata lives here, never engine state or generation owners. +const legacy = new Set<{ readonly context: BuildIntegration }>() +const operations: BuildIntegration[] = [] + +/** Start a legacy interval; overlapping legacy intervals retain their ABI. */ +export function beginBuild( + engine: Partial, + context: BuildIntegration = { + integration: 'legacy beginBuild', + root: process.cwd(), + }, +): () => void { + const active = operations.at(-1) + if (active) throw new MixedBuildIntegrationError(context, active) + if (legacy.size === 0) engine.resetBuildState?.() + const interval = { context } + legacy.add(interval) + return () => { + legacy.delete(interval) + } +} + +/** @internal Admit before any owner reset, restore, configuration or action. */ +export function runBuildOperation( + context: BuildIntegration, + action: () => T, +): T { + const active = legacy.values().next().value + if (active) throw new MixedBuildIntegrationError(context, active.context) + operations.push(context) + try { + return action() + } finally { + operations.pop() + } +} + +/** @internal Cleanup must not reset an opaque live legacy configuration. */ +export function resetOwnedBuildState(engine: ResettableEngine): void { + if (legacy.size === 0) engine.resetBuildState() +} + +export default { + beginBuild, + MixedBuildIntegrationError, + resetOwnedBuildState, + runBuildOperation, +} diff --git a/packages/plugin-utils/src/build-generation.ts b/packages/plugin-utils/src/build-generation.ts new file mode 100644 index 000000000..f44a652fd --- /dev/null +++ b/packages/plugin-utils/src/build-generation.ts @@ -0,0 +1,65 @@ +/** @internal An adapter to the existing synchronous engine snapshot API. */ +export interface GenerationEngine { + reset(): void + restore(state: State): void + capture(): State +} + +/** @internal Raised only for an operation after its integration closed. */ +export class ClosedBuildGenerationError extends Error { + constructor() { + super('[devup-ui] build generation has already closed') + this.name = 'ClosedBuildGenerationError' + } +} + +/** + * @internal One integration-owned immutable handoff, never an engine lease. + * Construction/configuration does not count as a running compiler. Only actual + * run/watch participants count; an unused configuration cannot pin the owner. + */ +export class BuildGeneration { + private state: State | undefined + private participants: number + private finishing: boolean + private closed: boolean + + constructor() { + this.participants = 0 + this.finishing = false + this.closed = false + } + + get disposed(): boolean { + return this.closed + } + + run(engine: GenerationEngine, action: () => T): T { + if (this.closed) throw new ClosedBuildGenerationError() + engine.reset() + if (this.state !== undefined) engine.restore(this.state) + try { + return action() + } finally { + if (!this.closed) this.state = engine.capture() + } + } + + acquire(complete: boolean): () => void { + if (this.closed) throw new ClosedBuildGenerationError() + this.participants += 1 + let released = false + return () => { + if (released) return + released = true + this.participants -= 1 + this.finishing ||= complete + if (this.finishing && this.participants === 0) this.dispose() + } + } + + dispose(): void { + this.closed = true + this.state = undefined + } +} diff --git a/packages/plugin-utils/src/build-session.ts b/packages/plugin-utils/src/build-session.ts new file mode 100644 index 000000000..8c463ade9 --- /dev/null +++ b/packages/plugin-utils/src/build-session.ts @@ -0,0 +1,15 @@ +// The self-reference stays external in both published entry bundles. Bun source +// tests and consumers select the same internal module, as do Node import/require. +import admission from '@devup-ui/plugin-utils/internal/build-admission' + +export const { + beginBuild, + MixedBuildIntegrationError, + resetOwnedBuildState, + runBuildOperation, +} = admission + +export type { + BuildIntegration, + ResettableEngine, +} from '@devup-ui/plugin-utils/internal/build-admission' diff --git a/packages/plugin-utils/src/compiled-guard.ts b/packages/plugin-utils/src/compiled-guard.ts new file mode 100644 index 000000000..641de0f75 --- /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 000000000..c527fe063 --- /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/directory-exclusion.ts b/packages/plugin-utils/src/directory-exclusion.ts new file mode 100644 index 000000000..1a515c89c --- /dev/null +++ b/packages/plugin-utils/src/directory-exclusion.ts @@ -0,0 +1,40 @@ +import { posix, win32 } from 'node:path' + +export function createDirectoryExclusion( + exclude: readonly string[] = [], + platform: string = process.platform, +): ((directory: string) => boolean) & { + readonly match: (directory: string) => string | undefined +} { + 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 Map( + exclude + .filter((entry) => !path.isAbsolute(entry)) + .map((entry) => [normalize(entry), entry]), + ) + const absolute = exclude + .filter((entry) => path.isAbsolute(entry)) + .map((entry) => ({ normalized: normalize(entry), entry })) + const match = (directory: string) => { + const candidate = normalize(directory) + 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/guard-facts.ts b/packages/plugin-utils/src/guard-facts.ts new file mode 100644 index 000000000..2c5b741a4 --- /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 000000000..d8f88dc2c --- /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 000000000..c4bb269cc --- /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/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index d99a4fca4..885f501a7 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', () => { @@ -1066,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 @@ -1093,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', @@ -1211,7 +1191,6 @@ describe('oxc AST parsing path', () => { ], source: { value: './mixed' }, }, - // default specifier (no importKind) alongside an inline type -> kept { type: 'ImportDeclaration', importKind: 'value', @@ -1221,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', @@ -1261,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`, + ) }) }) @@ -1393,7 +1380,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 b221bbc1e..1df07b707 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -3,18 +3,46 @@ import { readdirSync, readFileSync, realpathSync, - statSync, writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { + freezeImportRequests, + type ImportGraphRequest, + type ImportRequestOutcome, + retainImportGraphFailure, +} from './import-requests' +import { + type ImportRequestReference, + scanImportRequests, +} from './import-scanner' +import { remapMdxError } from './mdx-errors' +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, + type ModuleResolver, +} from './prepared-resolver' +import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' import { - dirname, - extname, - isAbsolute, - join, - relative, - resolve, -} from 'node:path' + createResolutionInputs, + type ResolutionInputObserver, +} from './resolution-inputs' +import { createNodeModulesExcludeRegex, SOURCE_FILE_RE } from './shared' +import { + isSelectedSource, + type MdxSelection, + sourceExtensions, + type SourceSelectionOptions, +} from './source-selection' +import { type PathAlias, readPathAliases } from './tsconfig' +import type { ModuleAliasOptions } from './types' /** * How map keys (and bucket-root values) are stringified. @@ -33,7 +61,7 @@ function makeToKey(cwd: string, keyBy: GraphKeyMode): (file: string) => string { } export interface BuildCanonicalMapOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string hoistV?: number @@ -42,11 +70,6 @@ export interface BuildCanonicalMapOptions { graph?: StaticImportGraph } -interface ImportReference { - kind: 'static' | 'dynamic' - specifier: string -} - interface OxcParser { parseSync: ( filename: string, @@ -55,12 +78,6 @@ interface OxcParser { ) => unknown } -interface PathAlias { - prefix: string - suffix: string - targets: string[] -} - interface ResolveContext { aliases: PathAlias[] aliasBaseDir: string @@ -68,12 +85,10 @@ interface ResolveContext { srcDir: string } -const jsExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs'] -const jsFileRegex = /\.(?:tsx?|jsx?|mjs)$/ -const testFileRegex = /\.(?:test|spec)\.[mc]?[jt]sx?$/ +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)\.[^./]+$/ +const leafRouteFileRegex = /(^|\/)page\.[^./]+$/ let cachedOxcParser: false | OxcParser | undefined @@ -85,6 +100,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 @@ -109,30 +125,153 @@ 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 * 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 | undefined, + options: PreparedGraphOptions, +): Promise +export function buildStaticImportGraph( + srcDir: string | string[], tsconfigPath?: string, -): StaticImportGraph { - const resolvedSrcDir = resolve(srcDir) - const files = listSourceFiles(resolvedSrcDir) + options?: SyncGraphOptions, +): ScannedStaticImportGraph +export function buildStaticImportGraph( + srcDir: string | string[], + tsconfigPath?: string, + options: SyncGraphOptions | PreparedGraphOptions = {}, +): ScannedStaticImportGraph | 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 + readonly sourceType?: import('./prepared-source').SourceType + } + | 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 && + !(cause instanceof PreparedSourceTypeError) && + '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 = 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( + '(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, options)), + ), + ].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 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 + } + let resolutionInputs = createResolutionInputs().snapshot() + const graphInputs = createResolutionInputs() + const resolver = createModulePathResolver( + { + cwd, + tsconfigPath: tsconfigPath ?? join(cwd, 'tsconfig.json'), + conditions: options.conditions, + alias: options.alias, + includeMdx: options.includeMdx, + onResolutionInputs: (inputs) => { + resolutionInputs = inputs + options.onResolutionInputs?.(inputs) + }, + }, + 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>() const dynamicTargets = new Set() const externalImports = new Map>() + const requests: ImportGraphRequest[] = [] for (const file of files) { staticImporters.set(file, new Set()) @@ -141,32 +280,149 @@ export function buildStaticImportGraph( externalImports.set(file, new Set()) } - for (const file of files) { - const imports = parseImports(file, readFileSync(file, 'utf-8')) + 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 + ? parseImports(file, readFileSync(file, 'utf-8')) + : parsePreparedImports( + file, + code, + typeof prepared === 'object' ? prepared.map : undefined, + typeof prepared === 'object' ? prepared.sourceType : undefined, + ) for (const importRef of imports) { - const target = resolveImport(importRef.specifier, file, context) - if (!target) { + 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 (resolution?.ignored === true) { + requests.push({ ...request, outcome: { kind: 'ignored' } }) + continue + } + 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, inputs: resolutionInputs } + : !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, 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'), graphInputs) + ) + directory = dirname(directory) + if (isFile(join(directory, 'package.json'), graphInputs)) + includedRoots.add(realpathSync(directory)) + } } - continue - } - 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 + } finally { + options.onResolutionInputs?.(graphInputs.snapshot()) } - staticImporters.get(target)?.add(file) - staticImports.get(file)?.add(target) } } return { - files, + requests: freezeImportRequests(requests), + files: files.sort(compareCodePoints), fileSet, staticImports, staticImporters, @@ -176,13 +432,37 @@ export function buildStaticImportGraph( } } +export interface StaticImportGraphOptions { + readonly onResolutionInputs?: ResolutionInputObserver + /** Include MDX only when the caller compiles it before extraction. */ + readonly includeMdx?: MdxSelection + readonly alias?: ModuleAliasOptions + readonly cwd?: string + readonly include?: readonly string[] + readonly conditions?: readonly string[] + /** Bare directory names at any depth, or absolute directories and descendants. */ + 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 +507,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,14 +530,15 @@ 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))) .sort((a, b) => - toPosixRelative(srcDir, a).localeCompare(toPosixRelative(srcDir, b)), + compareCodePoints(toPosixRelative(srcDir, a), toPosixRelative(srcDir, b)), ) const routeShellFilesByDir = getRouteShellFilesByDir(files, srcDir) @@ -279,7 +560,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 +589,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 +619,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[] @@ -359,7 +641,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) { @@ -374,7 +657,7 @@ export function computeReachableFiles( } export interface ComputeFileReachOptions { - srcDir: string + srcDir: string | string[] tsconfigPath?: string cwd: string /** @@ -409,9 +692,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) { @@ -426,7 +710,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 +883,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 @@ -606,13 +895,19 @@ function getStaticClosure( * 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[] = [], + 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) => - a.name.localeCompare(b.name), + compareCodePoints(a.name, b.name), ) for (const entry of entries) { const entryPath = join(dir, entry.name) @@ -622,7 +917,7 @@ export function listSourceFiles(srcDir: string): string[] { 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)) } @@ -630,31 +925,107 @@ 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)), ) } -function parseImports(filename: string, source: string): ImportReference[] { - const astImports = parseImportsWithOxc(filename, source) - if (astImports) return astImports - return scanImports(source) +function parseImports( + filename: string, + source: string, +): ImportRequestReference[] { + if (!SOURCE_FILE_RE.test(filename)) + return scanImportRequests(mdxEsmSource(source), false, false) + return parseSourceImports(filename, source) } -function parseImportsWithOxc( +function parsePreparedImports( filename: string, source: string, -): ImportReference[] | undefined { - const parser = getOxcParser() - if (!parser) return undefined + map: unknown, + sourceType?: import('./prepared-source').SourceType, +): ImportRequestReference[] { + return parseSourceImports(filename, source, { map, sourceType }) +} +function parseSourceImports( + filename: string, + source: string, + prepared?: { + readonly map: unknown + readonly sourceType?: import('./prepared-source').SourceType + }, +): ImportRequestReference[] { + const parser = getOxcParser() try { - const ast = parser.parseSync(filename, source, { sourceType: 'module' }) - const imports: ImportReference[] = [] - collectAstImports(ast, imports) - return imports - } catch { - return undefined + const ast = parser?.parseSync( + filename, + source, + prepared && + (prepared.sourceType === 'compiled-mdx' || + !/\.[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)) + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + const located = message.startsWith(`${filename}:`) + ? cause + : new GraphPreparationError(filename, cause) + 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 scanImportRequests( + source, + prepared?.sourceType === 'compiled-mdx' || + /\.[jt]sx$/i.test(filename) || + (prepared !== undefined && !/\.[mc]?ts$/i.test(filename)), + prepared?.sourceType !== 'compiled-mdx' && /\.[mc]?tsx?$/i.test(filename), + ) +} + +function mdxEsmSource(source: string): string { + const blocks: string[] = [] + let fence = '' + let esm = false + let comment = false + 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] + if (marker) { + if (!fence) fence = marker + else if (marker[0] === fence[0] && marker.length >= fence.length) + fence = '' + esm = false + blocks.push(blank) + continue + } + if (fence) { + blocks.push(blank) + continue + } + if (!line.trim()) { + esm = false + blocks.push(blank) + continue + } + if (/^(?:import\s+(?!\()|export\s+)/.test(line)) esm = true + blocks.push(esm ? line : blank) } + return blocks.join('') } function getOxcParser(): OxcParser | undefined { @@ -676,10 +1047,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, @@ -687,306 +1058,33 @@ 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): ImportReference[] { - const imports: ImportReference[] = [] - const code = stripComments(source) - // 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 - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(exportFromRegex)) { - if (match[1] || isAllInlineTypeSpecifiers(match[2])) continue - imports.push({ kind: 'static', specifier: match[4] }) - } - for (const match of code.matchAll(dynamicImportRegex)) { - imports.push({ kind: 'dynamic', specifier: match[2] }) - } - - 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 -} - -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 { + 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 onResolutionInputs?: ResolutionInputObserver + readonly prepareSource?: PrepareSource + readonly alias?: ModuleAliasOptions + readonly includeMdx?: MdxSelection 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 @@ -1001,131 +1099,107 @@ export interface CreateModuleResolverOptions { * `module`, `main`), reading each module. */ export function createModuleResolver({ - cwd = process.cwd(), - tsconfigPath = join(cwd, 'tsconfig.json'), toId = (path) => path, -}: CreateModuleResolverOptions = {}): ( - specifier: string, - importer: string, -) => ResolvedModule | undefined { - const { aliases, baseDir } = readPathAliases(tsconfigPath) - return (specifier, importer) => { - const from = resolve(cwd, importer) - const path = specifier.startsWith('.') - ? resolveSourceFile(resolve(dirname(from), specifier)) - : isAbsolute(specifier) - ? resolveSourceFile(specifier) - : (resolveAliasCandidates(specifier, { - aliases, - aliasBaseDir: baseDir, - }) - .map(resolveSourceFile) - .find(Boolean) ?? resolvePackage(specifier, from)) - 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) + ...options +}: CreateModuleResolverOptions = {}): ModuleResolver { + const resolver = createModulePathResolver({ + ...options, + includeMdx: options.includeMdx ?? true, + }) + const prepared = createPreparedResolver(options) + return Object.assign( + (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, + resolve(options.cwd ?? process.cwd(), importer), + ) + return prepared.resolved(toId(path), path, source) + }, + { remapError: prepared.remapError }, + ) } -/** Conditions of package `exports`, in the order a bundler prefers them */ -const packageConditions = ['import', 'module', 'default', 'require', 'node'] - -function resolvePackage( +export type ModulePathResolverOptions = Pick< + CreateModuleResolverOptions, + | 'cwd' + | 'tsconfigPath' + | 'conditions' + | 'alias' + | 'includeMdx' + | 'onResolutionInputs' +> +export type ModulePathResolution = + AliasResolution | IgnoredModule | false | undefined +export type ModulePathResolver = ( specifier: string, importer: 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 found = resolvePackageEntry( - packageDir, - JSON.parse( - readFileSync(join(packageDir, 'package.json'), 'utf-8'), - ) as Record, - ['.', ...parts.slice(nameLength)].join('/'), - ) - return found && realpathSync(found) -} - -function findPackage(dir: string, name: string): string | undefined { - const packageDir = join(dir, 'node_modules', name) - if (isFile(join(packageDir, 'package.json'))) return packageDir - const parent = dirname(dir) - return parent === dir ? undefined : findPackage(parent, name) -} -function resolvePackageEntry( - dir: string, - manifest: Record, - subpath: string, -): string | undefined { - if (manifest.exports !== undefined) { - const target = exportsTarget(manifest.exports, subpath) - return target === undefined - ? undefined - : resolveSourceFile(join(dir, target)) - } - if (subpath !== '.') return resolveSourceFile(join(dir, subpath)) - const main = [manifest.module, manifest.main].find( - (entry): entry is string => typeof entry === 'string', - ) - return resolveSourceFile(join(dir, main ?? 'index')) -} - -function exportsTarget(exports: unknown, subpath: string): string | undefined { - const subpaths = - isRecord(exports) && !Array.isArray(exports) - ? Object.keys(exports).filter((key) => key.startsWith('.')) - : [] - if (subpaths.length === 0) { - return subpath === '.' ? conditionTarget(exports) : undefined - } - const map = exports as Record - if (subpath in map) return conditionTarget(map[subpath]) - 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])?.replaceAll('*', matched) +) => ModulePathResolution + +export function createModulePathResolver( + { + cwd = process.cwd(), + tsconfigPath = join(cwd, 'tsconfig.json'), + conditions = ['import', 'module', 'require', 'node'], + alias = {}, + includeMdx, + onResolutionInputs, + }: ModulePathResolverOptions = {}, + excludedDirectory: ( + directory: string, + ) => boolean = createDirectoryExclusion(), +): ModulePathResolver { + 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) + return (specifier, importer) => { + const inputs = createResolutionInputs(setup) + const fileResolver = (path: string) => + resolveFile(path, { extensions, excludedDirectory, inputs }) + const from = resolve(cwd, importer) + 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()) } } - return undefined } -function conditionTarget(value: unknown): string | undefined { - if (typeof value === 'string') return value - if (Array.isArray(value)) { - return value.map(conditionTarget).find(Boolean) - } - if (!isRecord(value)) return undefined - return packageConditions - .filter((condition) => condition in value) - .map((condition) => conditionTarget(value[condition])) - .find(Boolean) -} function resolveAliasCandidates( specifier: string, context: Pick, @@ -1134,7 +1208,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 } @@ -1147,37 +1223,11 @@ 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 - } - - for (const jsExtension of jsExtensions) { - const candidate = `${candidateBase}${jsExtension}` - if (isFile(candidate)) return resolve(candidate) - } - for (const jsExtension of jsExtensions) { - const candidate = join(candidateBase, `index${jsExtension}`) - if (isFile(candidate)) return resolve(candidate) - } - - return undefined -} - -function isFile(path: string): boolean { - try { - return statSync(path).isFile() - } catch { - return false - } -} - 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/import-mask.ts b/packages/plugin-utils/src/import-mask.ts new file mode 100644 index 000000000..dedb2802e --- /dev/null +++ b/packages/plugin-utils/src/import-mask.ts @@ -0,0 +1,251 @@ +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, + typescript = true, +): 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 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 < Math.min(end, source.length); + 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 + } + blank(start + 1, index - 1) + previous = 'value' + } + 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 (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 === '>') { + jsxDepth += tagClosing ? -1 : source[index - 1] === '/' ? 0 : 1 + tag = false + text = jsxDepth > (expressions.at(-1)?.depth ?? 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 === '`') { + templates.push(braces) + templateText = true + index += 1 + continue + } + if (char === '"' || char === "'") { + quoted(char) + continue + } + 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 || + (startsExpression(previous) && + !(typescript && typeParameterPrefix.test(source.slice(index))))) && + /[A-Za-z/>]/.test(next ?? '') + ) { + tag = true + tagClosing = next === '/' + text = false + blank(index, index + 1) + index += 1 + continue + } + 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 + } + if (char === '}') { + braces -= 1 + if (templates.at(-1) === braces) { + templateText = true + blank(index, index + 1) + } + const expression = expressions.at(-1) + if (expression?.braces === braces) { + expressions.pop() + 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) + 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 === '(') + parentheses.push(/^(?:if|while|for|with|switch|catch)$/.test(previous)) + if (!/\s/.test(char)) + previous = + char === ')' && parentheses.pop() + ? '' + : char === '!' && typescript && !startsExpression(previous) + ? 'value' + : char + index += 1 + } + const masked = output.join('') + return typescript ? maskImportTypes(masked) : masked +} diff --git a/packages/plugin-utils/src/import-requests.ts b/packages/plugin-utils/src/import-requests.ts new file mode 100644 index 000000000..1baf71aab --- /dev/null +++ b/packages/plugin-utils/src/import-requests.ts @@ -0,0 +1,65 @@ +import type { ImportRequestReference } from './import-scanner' +import type { ResolutionInputs } from './resolution-inputs' + +export type ImportRequestOutcome = + | { + readonly kind: 'resolved' + readonly path: string + readonly inputs: ResolutionInputs + } + | { 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 new file mode 100644 index 000000000..71d18f200 --- /dev/null +++ b/packages/plugin-utils/src/import-scanner.ts @@ -0,0 +1,189 @@ +import { maskImportText } from './import-mask' + +export interface ImportReference { + readonly kind: 'static' | 'dynamic' + 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) + .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[] { + 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: 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 ( + !['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( + 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( + reference({ ...next, literal: next.literal }, 'static-import'), + ) + 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( + reference( + { ...tokens[cursor + 1], literal: specifier }, + word === 'export' ? 're-export' : 'static-import', + ), + ) + 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 000000000..58e5c1f13 --- /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('') +} diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 385a2dc2b..31efc34ec 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -1,3 +1,24 @@ +export { BuildGeneration, type GenerationEngine } from './build-generation' +export { + beginBuild, + type BuildIntegration, + MixedBuildIntegrationError, + resetOwnedBuildState, + type ResettableEngine, + runBuildOperation, +} 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, @@ -11,14 +32,91 @@ export { type ComputeFileRoutesOptions, computeReachableFiles, type ComputeReachableFilesOptions, + createModulePathResolver, createModuleResolver, type CreateModuleResolverOptions, + type IgnoredModule, listSourceFiles, + type ModulePathResolution, + type ModulePathResolver, + type ModulePathResolverOptions, + type ModuleResolution, planAtomHoist, + type PreparedGraphOptions, + type PreparedSource, + type PrepareSource, type ResolvedModule, + type ScannedStaticImportGraph, type StaticImportGraph, + type StaticImportGraphOptions, + type SyncGraphOptions, } from './import-graph' -export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' +export { + importGraphFailureOf, + type ImportGraphRequest, + type ImportRequestOutcome, +} from './import-requests' +export { type ImportReference, scanImports } from './import-scanner' +export { + ConfigLoadError, + deepMerge, + loadDevupConfig, + loadDevupConfigSync, +} from './load-config' +export { remapMdxError } from './mdx-errors' +export { + isMdxSource, + mdxSourceFilter, + normalizeMdxExtensions, + selectedSourceFilter, +} from './mdx-selection' +export { + nextNonphysicalIdList, + type NonphysicalIdListOutcome, +} from './nonphysical-id-list' +export { + createNonphysicalIdStore, + type NonphysicalIdStore, + type NonphysicalIdStoreScope, +} from './nonphysical-id-store' +export { + type NonphysicalModuleId, + type NonphysicalRequest, + normalizeNonphysicalModuleId, + type PhysicalImporter, +} from './nonphysical-module-id' +export { + collectNumberedFiles, + type CollectNumberedFilesOptions, + extractedNeedles, + type FileNumbering, + seedFileNumbers, +} from './numbering' +export type { ModuleResolver } from './prepared-resolver' +export type { SourceType } from './prepared-source' +export { + collectProductionFileManifest, + type ProductionFileManifestOptions, + type ProductionManifestContext, + type ProductionManifestFile, +} from './production-file-manifest' +export { + type ProductionExtractionInput, + ProductionNumbering, + type ProductionNumberingEngine, + ProductionNumberingError, + type ProductionNumberingPlan, +} from './production-numbering' +export { + enumerateProductionSourceFiles, + type ProductionSourceFile, + type ProductionSourceFilesOptions, +} from './production-source-files' +export { + type ResolutionInputObserver, + type ResolutionInputs, +} from './resolution-inputs' +export { resolutionWatchPath } from './resolution-watch-path' export { createNodeModulesExcludeRegex, createThemeInterfaceArgs, @@ -26,12 +124,32 @@ 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 { + isSelectedSource, + type MdxSelection, + type SourceSelectionOptions, +} from './source-selection' +export { + createStateWriter, + type StateWriter, + writeFileAtomically, +} from './state-writer' export type { CustomShorthands, DevupConfig, DevupTheme, ImportAliases, + ModuleAliasDescriptor, + ModuleAliases, + ModuleAliasOptions, ThemeColors, ThemeTypography, Typography, diff --git a/packages/plugin-utils/src/jsonc.ts b/packages/plugin-utils/src/jsonc.ts new file mode 100644 index 000000000..dc5697496 --- /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/load-config.ts b/packages/plugin-utils/src/load-config.ts index a30c977ab..7564e34f7 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) diff --git a/packages/plugin-utils/src/mdx-errors.ts b/packages/plugin-utils/src/mdx-errors.ts new file mode 100644 index 000000000..54f4d1e60 --- /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 }) +} diff --git a/packages/plugin-utils/src/mdx-selection.ts b/packages/plugin-utils/src/mdx-selection.ts new file mode 100644 index 000000000..06d9829c1 --- /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/plugin-utils/src/module-alias.ts b/packages/plugin-utils/src/module-alias.ts new file mode 100644 index 000000000..a9c8634ff --- /dev/null +++ b/packages/plugin-utils/src/module-alias.ts @@ -0,0 +1,129 @@ +import type { IgnoredModule } from './import-graph' +import type { ModuleAliasDescriptor, ModuleAliasOptions } from './types' + +export class ModuleAliasError extends Error { + constructor(importer: string, specifier: string) { + super(`${importer}:1:1: Module alias cycle cannot resolve ${specifier}`) + this.name = 'ModuleAliasError' + } +} + +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 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 + readonly request: string +} + +/** Resolve every rewriting candidate completely before selecting its request. */ +export function resolveModuleAlias( + specifier: string, + context: { + readonly alias: ModuleAliasOptions + readonly importer: string + readonly resolveRequest: ( + request: string, + aliased: boolean, + ) => 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( + request: string, + visited: ReadonlySet, + aliased: boolean, + ): AliasResolution | IgnoredModule | false | undefined { + if (visited.has(request)) + throw new ModuleAliasError(context.importer, specifier) + const next = new Set(visited).add(request) + 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] + const candidates = targets + .filter( + (target) => + target === false || + (request !== target && !request.startsWith(`${target}/`)), + ) + .map((target) => + target === false ? false : target + request.slice(name.length), + ) + if (!candidates.length) continue + failure ??= { + key, + candidates: candidates.filter((candidate) => candidate !== false), + } + let missing = false + 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 + } + return missing ? undefined : false + } + const path = context.resolveRequest(request, aliased) + return typeof path === 'string' ? { path, request } : path + } + 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/nonphysical-id-list.ts b/packages/plugin-utils/src/nonphysical-id-list.ts new file mode 100644 index 000000000..d0c0d37ff --- /dev/null +++ b/packages/plugin-utils/src/nonphysical-id-list.ts @@ -0,0 +1,41 @@ +export type NonphysicalIdListOutcome = + | { readonly kind: 'loaded'; readonly ids: readonly string[] } + | { readonly kind: 'missing'; readonly ids: readonly [] } + | { readonly kind: 'corrupt'; readonly ids: readonly [] } + +export function parseNonphysicalIdList( + serialized: string | undefined, +): NonphysicalIdListOutcome { + if (serialized === undefined) + return Object.freeze({ kind: 'missing', ids: Object.freeze<[]>([]) }) + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch (error) { + if (error instanceof SyntaxError) + return Object.freeze({ kind: 'corrupt', ids: Object.freeze<[]>([]) }) + throw error + } + if (!Array.isArray(parsed)) + return Object.freeze({ kind: 'corrupt', ids: Object.freeze<[]>([]) }) + const ids = new Set() + for (const element of parsed) { + if (typeof element !== 'string' || element.length === 0) + return Object.freeze({ kind: 'corrupt', ids: Object.freeze<[]>([]) }) + ids.add(element) + } + return Object.freeze({ kind: 'loaded', ids: Object.freeze([...ids].sort()) }) +} + +export function nextNonphysicalIdList( + extractedNonphysical: readonly string[], + knownNonphysicalScan: readonly string[], +): readonly string[] { + const known = new Set(knownNonphysicalScan) + const ids = [...new Set(extractedNonphysical)].filter((id) => !known.has(id)) + return Object.freeze(ids.sort()) +} + +export function serializeNonphysicalIdList(ids: readonly string[]): string { + return JSON.stringify([...new Set(ids)].sort()) +} diff --git a/packages/plugin-utils/src/nonphysical-id-store.ts b/packages/plugin-utils/src/nonphysical-id-store.ts new file mode 100644 index 000000000..4f10e710a --- /dev/null +++ b/packages/plugin-utils/src/nonphysical-id-store.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { + nextNonphysicalIdList, + type NonphysicalIdListOutcome, + parseNonphysicalIdList, + serializeNonphysicalIdList, +} from './nonphysical-id-list' +import { resolveProjectPaths } from './shared' +import { createStateWriter } from './state-writer' + +export interface NonphysicalIdStoreScope { + readonly integration: string + readonly resolvedRoot: string + readonly contextKey: string + readonly distDir?: string +} + +export interface NonphysicalIdStore { + readonly filePath: string + read(): Promise + rewrite( + extractedIds: readonly string[], + scanReservedIds: readonly string[], + ): Promise +} + +export function createNonphysicalIdStore( + scope: NonphysicalIdStoreScope, +): NonphysicalIdStore { + const { distDir } = resolveProjectPaths( + scope.resolvedRoot, + scope.distDir === undefined ? {} : { distDir: scope.distDir }, + ) + const key = createHash('sha256') + .update( + JSON.stringify([scope.integration, scope.resolvedRoot, scope.contextKey]), + ) + .digest('hex') + const filePath = join(distDir, 'numbering', `${key}.json`) + const writer = createStateWriter() + return { + filePath, + async read() { + let serialized: string + try { + serialized = await readFile(filePath, 'utf-8') + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) + return parseNonphysicalIdList(undefined) + throw error + } + return parseNonphysicalIdList(serialized) + }, + rewrite(extractedIds, scanReservedIds) { + return writer.write( + filePath, + serializeNonphysicalIdList( + nextNonphysicalIdList(extractedIds, scanReservedIds), + ), + ) + }, + } +} diff --git a/packages/plugin-utils/src/nonphysical-module-id.ts b/packages/plugin-utils/src/nonphysical-module-id.ts new file mode 100644 index 000000000..5e69cc3f1 --- /dev/null +++ b/packages/plugin-utils/src/nonphysical-module-id.ts @@ -0,0 +1,56 @@ +import { posix, win32 } from 'node:path' + +export interface NonphysicalModuleId { + readonly namespace: string | null + readonly id: string +} + +export interface NonphysicalRequest { + readonly namespace: string | null + readonly specifier: string +} + +export interface PhysicalImporter { + readonly path: string + readonly pathStyle: 'posix' | 'win32' +} + +type Exhaustive = Remaining + +export function normalizeNonphysicalModuleId( + resolved: NonphysicalModuleId | undefined, + request: NonphysicalRequest, + importer: PhysicalImporter | undefined, +): NonphysicalModuleId { + if (resolved !== undefined) + return Object.freeze({ namespace: resolved.namespace, id: resolved.id }) + const specifier = request.specifier + if (importer === undefined || specifier.includes('\0')) + return Object.freeze({ namespace: request.namespace, id: specifier }) + switch (importer.pathStyle) { + case 'posix': + if (!/^\.\.?\//.test(specifier)) + return Object.freeze({ namespace: request.namespace, id: specifier }) + return relativeIdentity(request, importer.path, posix) + case 'win32': + if (!/^\.\.?[/\\]/.test(specifier)) + return Object.freeze({ namespace: request.namespace, id: specifier }) + return relativeIdentity(request, importer.path, win32) + } + type _RemainingPathStyle = Exhaustive +} + +function relativeIdentity( + request: NonphysicalRequest, + importerPath: string, + paths: Pick, +): NonphysicalModuleId { + const specifier = request.specifier + const boundary = specifier.search(/[?#]/) + const path = boundary === -1 ? specifier : specifier.slice(0, boundary) + const suffix = boundary === -1 ? '' : specifier.slice(boundary) + return Object.freeze({ + namespace: request.namespace, + id: paths.resolve(paths.dirname(importerPath), path) + suffix, + }) +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts new file mode 100644 index 000000000..28384cbd0 --- /dev/null +++ b/packages/plugin-utils/src/numbering.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +import { listSourceFiles } from './import-graph' +import type { MdxSelection } from './source-selection' + +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?: MdxSelection + readonly exclude?: readonly 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) + 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 = [], + includeMdx = false, + exclude, + cwd = process.cwd(), + needles, + 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, exclude, { includeMdx }) + .filter((file) => !needles || usesAny(file, needles)) + .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) +} + +/** 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/plugin-utils/src/owned-file-resolution.ts b/packages/plugin-utils/src/owned-file-resolution.ts new file mode 100644 index 000000000..bc3773143 --- /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 000000000..66b789468 --- /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 000000000..c016f0c2f --- /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 new file mode 100644 index 000000000..8d86b8fa9 --- /dev/null +++ b/packages/plugin-utils/src/packaged-config-inputs.ts @@ -0,0 +1,65 @@ +import { statSync } from 'node:fs' + +import { isRecord, parseJsonc } from './jsonc' +import { ConfigLoadError } from './load-config' +import { + readResolutionFile, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' + +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 readPackageManifest( + file: string, + purpose: 'module' | 'tsconfig-extends', + inputs?: ResolutionInputCollector, +): 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/prepared-diagnostics.ts b/packages/plugin-utils/src/prepared-diagnostics.ts new file mode 100644 index 000000000..1a0929b2a --- /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/prepared-resolver.ts b/packages/plugin-utils/src/prepared-resolver.ts new file mode 100644 index 000000000..237a04db8 --- /dev/null +++ b/packages/plugin-utils/src/prepared-resolver.ts @@ -0,0 +1,157 @@ +import { extname } from 'node:path' + +import type { + ModuleResolution, + PreparedSource, + PrepareSource, + ResolvedModule, +} 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 { + (specifier: string, importer: string): ModuleResolution | 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 + readonly onResolutionInputs?: ResolutionInputObserver +}) { + const generations = new Map< + string, + { + readonly filename: string + readonly code: string + readonly map?: unknown + readonly sourceType?: import('./prepared-source').SourceType + } + >() + 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') + 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) + } + }, + resolved( + id: string, + filename: string, + prepared: PreparedSource, + ): ResolvedModule { + if (prepared === undefined) { + generations.delete(id) + const inputs = createResolutionInputs() + try { + return { path: id, code: readResolutionFile(filename, inputs) } + } finally { + options.onResolutionInputs?.(inputs.snapshot()) + } + } + const source = + typeof prepared === 'string' ? { code: prepared } : prepared + generations.set(id, { filename, ...source }) + idsByFilename.set(filename, id) + 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 + 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 + }, + } +} diff --git a/packages/plugin-utils/src/prepared-source.ts b/packages/plugin-utils/src/prepared-source.ts new file mode 100644 index 000000000..d8f840dae --- /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) + } +} diff --git a/packages/plugin-utils/src/production-file-manifest.ts b/packages/plugin-utils/src/production-file-manifest.ts new file mode 100644 index 000000000..32821cef4 --- /dev/null +++ b/packages/plugin-utils/src/production-file-manifest.ts @@ -0,0 +1,226 @@ +import { realpathSync } from 'node:fs' +import { dirname, extname, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { + compareCodePoints, + createModulePathResolver, + type ModulePathResolverOptions, + type PrepareSource, +} from './import-graph' +import { scanImportRequests } from './import-scanner' +import { remapMdxError } from './mdx-errors' +import { PreparedSourceTypeError, readPreparedSource } from './prepared-source' +import type { ManifestJob } from './production-manifest-certificates' +import { ManifestWorklist } from './production-manifest-worklist' +import { createProductionPackageRoots } from './production-package-roots' +import { + enumerateProductionSourceFiles, + type ProductionSourceFile, +} from './production-source-files' +import { createResolutionInputs, readResolutionFile } from './resolution-inputs' + +export interface ProductionManifestContext { + readonly key: string + readonly files: readonly ProductionSourceFile[] + readonly resolverOptions: ModulePathResolverOptions + readonly toId: (path: string) => string + readonly prepareSource?: PrepareSource + readonly unpreparedMarkdown?: 'reserve-only' +} +export interface ProductionFileManifestOptions { + readonly contexts: readonly ProductionManifestContext[] + readonly include?: readonly string[] + readonly exclude?: readonly string[] +} +export interface ProductionManifestFile extends ProductionSourceFile { + readonly context: string + readonly id: string +} +class ManifestSourceError extends Error { + readonly name = 'ManifestSourceError' + constructor( + readonly filename: string, + cause: unknown, + ) { + const line: unknown = + cause instanceof Error && !(cause instanceof PreparedSourceTypeError) + ? Object.getOwnPropertyDescriptor(cause, 'line')?.value + : undefined + const column: unknown = + cause instanceof Error + ? Object.getOwnPropertyDescriptor(cause, 'column')?.value + : undefined + const position = + typeof line === 'number' + ? `${line}:${typeof column === 'number' ? column : 1}` + : '1:1' + super( + `${filename}:${position}: production manifest source cannot be read or prepared at build time: ${cause instanceof Error ? cause.message : String(cause)}; prepare selected Markdown as JavaScript before collecting the manifest`, + { cause }, + ) + } +} + +export async function collectProductionFileManifest({ + contexts, + include = [], + exclude, +}: ProductionFileManifestOptions): Promise { + const excluded = createDirectoryExclusion(exclude) + const memberships = new Map() + for (const context of contexts) { + const { resolverOptions, prepareSource } = context + const { includeMdx } = resolverOptions + const cwd = resolve(resolverOptions.cwd ?? process.cwd()) + const resolver = createModulePathResolver(resolverOptions, excluded) + const inputs = createResolutionInputs() + const inventories = new Map() + const adjacency = new Map() + const packageRoots = createProductionPackageRoots(include, excluded, { + inputs, + observer: resolverOptions.onResolutionInputs, + }) + const markdown = new Set([ + '.md', + '.mdx', + ...(typeof includeMdx === 'object' + ? includeMdx.map((extension) => extension.toLowerCase()) + : []), + ]) + + async function targets( + path: string, + ): Promise { + const cached = adjacency.get(path) + if (cached) return cached + let prepared + let source: string + try { + prepared = readPreparedSource(await prepareSource?.(path)) + if ( + prepared === undefined && + markdown.has(extname(path).toLowerCase()) && + context.unpreparedMarkdown === 'reserve-only' + ) { + inputs.file(path) + adjacency.set(path, []) + return [] + } + if (prepared === undefined && markdown.has(extname(path).toLowerCase())) + throw Object.assign( + new TypeError('Markdown source has no prepared JavaScript'), + { line: 1, column: 1 }, + ) + inputs.file(path) + source = + prepared === undefined + ? readResolutionFile(path, inputs) + : typeof prepared === 'string' + ? prepared + : prepared.code + } catch (cause) { + const error = new ManifestSourceError(path, cause) + if (prepareSource && !(cause instanceof PreparedSourceTypeError)) { + const mapped = remapMdxError(error, path) + error.message = mapped.message.replaceAll( + '(in compiled MDX)', + '(in compiled output)', + ) + } + throw error + } finally { + resolverOptions.onResolutionInputs?.(inputs.snapshot()) + } + const compiled = + typeof prepared === 'object' && prepared.sourceType === 'compiled-mdx' + const imports = scanImportRequests( + source, + compiled || + /\.[jt]sx$/i.test(path) || + (prepared !== undefined && !/\.[mc]?ts$/i.test(path)), + !compiled && /\.[mc]?tsx?$/i.test(path), + ) + const result: ProductionSourceFile[] = [] + for (const reference of imports) { + const resolution = resolver(reference.specifier, path) + if (!resolution) continue + switch (resolution.ignored) { + case true: + break + case undefined: { + const target = resolution.path + const realPath = realpathSync.native(target) + if (!excluded(realPath)) result.push({ path: target, realPath }) + break + } + } + } + result.sort((a, b) => compareCodePoints(a.path, b.path)) + adjacency.set(path, result) + return result + } + + function distributions( + start: string, + request: (job: ManifestJob) => void, + ): void { + for (const path of packageRoots(start)) { + if (excluded(path)) continue + request({ path, realPath: realpathSync.native(path), kind: 'package' }) + } + } + const worklist = new ManifestWorklist({ + admit: (file) => !excluded(file.path) && !excluded(file.realPath), + record: (file) => { + const record = Object.freeze({ + ...file, + context: context.key, + id: context.toId(file.path), + }) + const membership = [record.context, record.path, record.id] + memberships.set(JSON.stringify(membership), record) + }, + expand: async (job, request) => { + switch (job.kind) { + case 'file': { + for (const file of await targets(job.path)) + request({ ...file, kind: 'file' }) + distributions(dirname(job.path), request) + break + } + case 'package': { + const files = + inventories.get(job.path) ?? + enumerateProductionSourceFiles({ + roots: [job.path], + cwd, + ...(exclude === undefined ? {} : { exclude }), + ...(includeMdx === undefined ? {} : { includeMdx }), + }) + inventories.set(job.path, files) + for (const file of files) request({ ...file, kind: 'file' }) + break + } + default: + job.kind satisfies never + } + }, + }) + for (const file of context.files.toSorted((a, b) => + compareCodePoints(a.path, b.path), + )) + worklist.seed({ ...file, kind: 'file' }) + distributions(cwd, (job) => worklist.seed(job)) + await worklist.run() + } + return Object.freeze( + [...memberships.values()].sort( + (a, b) => + compareCodePoints(a.realPath, b.realPath) || + compareCodePoints(a.path, b.path) || + compareCodePoints(a.context, b.context) || + compareCodePoints(a.id, b.id), + ), + ) +} diff --git a/packages/plugin-utils/src/production-manifest-certificates.ts b/packages/plugin-utils/src/production-manifest-certificates.ts new file mode 100644 index 000000000..e7a34e0e4 --- /dev/null +++ b/packages/plugin-utils/src/production-manifest-certificates.ts @@ -0,0 +1,219 @@ +import type { ProductionSourceFile } from './production-source-files' + +export type ManifestKind = 'file' | 'package' +export interface ManifestJob extends ProductionSourceFile { + readonly kind: ManifestKind +} +export interface ManifestAncestry { + readonly files: ReadonlySet + readonly packages: ReadonlySet +} +interface Frontier { + readonly files: Iterable + readonly packages: Iterable +} +export type ManifestEvidence = + | { readonly kind: 'cut'; readonly job: ManifestJob } + | { readonly kind: 'node'; readonly node: ManifestObligation } + | { readonly kind: 'closed'; readonly witness: ManifestCertificate } +export interface ManifestEdge { + readonly delta: ManifestJob + readonly evidence: ManifestEvidence +} +export type ManifestState = + | { readonly phase: 'pending' | 'expanding' } + | { readonly phase: 'expanded'; readonly edges: readonly ManifestEdge[] } + | { readonly phase: 'completed'; readonly witness: ManifestCertificate } +export interface ManifestObligation { + readonly job: ManifestJob + readonly ancestry: ManifestAncestry + // Only the lifecycle accumulator mutates; entry ancestry and edge references stay fixed. + state: ManifestState +} + +export function manifestNamespace(kind: ManifestKind) { + switch (kind) { + case 'file': + return 'files' + case 'package': + return 'packages' + default: + return kind satisfies never + } +} +export function manifestJobKey(job: ManifestJob): string { + return JSON.stringify([job.kind, job.path]) +} +export function manifestCovers(a: Frontier, b: ManifestAncestry): boolean { + return ( + [...a.files].every((path) => b.files.has(path)) && + [...a.packages].every((path) => b.packages.has(path)) + ) +} + +class ClosedClosure { + readonly #frontier: ManifestAncestry + readonly requirements: { + readonly files: readonly string[] + readonly packages: readonly string[] + } + constructor( + readonly key: string, + frontier: ManifestAncestry, + ) { + this.#frontier = { + files: new Set(frontier.files), + packages: new Set(frontier.packages), + } + this.requirements = Object.freeze({ + files: Object.freeze([...frontier.files]), + packages: Object.freeze([...frontier.packages]), + }) + Object.freeze(this) + } + matches(ancestry: ManifestAncestry): boolean { + return manifestCovers(this.requirements, ancestry) + } + covers(other: ClosedClosure): boolean { + return this.matches(other.#frontier) + } +} +export type ManifestCertificate = ClosedClosure + +export class ManifestClosureError extends Error { + readonly name = 'ManifestClosureError' + constructor( + readonly reason: 'member-open' | 'dependency-open' | 'foreign-witness', + ) { + super(`Production manifest closure: ${reason}`) + } +} + +export class ManifestCertificates { + readonly #completed: Map + readonly #issued: WeakSet + + constructor() { + this.#completed = new Map() + this.#issued = new WeakSet() + } + + lookup(job: ManifestJob, ancestry: ManifestAncestry) { + return this.#completed + .get(manifestJobKey(job)) + ?.find((witness) => witness.matches(ancestry)) + } + + #requirements(witness: ClosedClosure): Frontier { + if (!this.#issued.has(witness)) + throw new ManifestClosureError('foreign-witness') + return witness.requirements + } + + #frontier( + evidence: ManifestEvidence, + internal: ReadonlyMap, + ): Frontier { + switch (evidence.kind) { + case 'cut': { + const frontier = { + files: new Set(), + packages: new Set(), + } + frontier[manifestNamespace(evidence.job.kind)].add( + evidence.job.realPath, + ) + return frontier + } + case 'closed': + return this.#requirements(evidence.witness) + case 'node': { + const frontier = internal.get(evidence.node) + if (frontier) return frontier + const state = evidence.node.state + switch (state.phase) { + case 'completed': + return this.#requirements(state.witness) + case 'pending': + case 'expanding': + case 'expanded': + throw new ManifestClosureError('dependency-open') + default: + return state satisfies never + } + } + default: + return evidence satisfies never + } + } + + seal( + component: readonly ManifestObligation[], + ): ReadonlyMap { + const internal = new Map< + ManifestObligation, + { + readonly files: Set + readonly packages: Set + readonly edges: readonly ManifestEdge[] + } + >() + for (const node of component) { + const state = node.state + switch (state.phase) { + case 'expanded': + internal.set(node, { + files: new Set(), + packages: new Set(), + edges: state.edges, + }) + break + case 'pending': + case 'expanding': + case 'completed': + throw new ManifestClosureError('member-open') + default: + state satisfies never + } + } + // Calculation sets are private until every outside proof and the least fixed point succeeds. + let changed = true + while (changed) { + changed = false + for (const target of internal.values()) { + for (const edge of target.edges) { + const child = this.#frontier(edge.evidence, internal) + const supplied = manifestNamespace(edge.delta.kind) + for (const namespace of ['files', 'packages'] as const) { + for (const path of child[namespace]) { + if (namespace === supplied && path === edge.delta.realPath) + continue + if (target[namespace].has(path)) continue + target[namespace].add(path) + changed = true + } + } + } + } + } + const staged = new Map() + for (const [node, frontier] of internal) + staged.set(node, new ClosedClosure(manifestJobKey(node.job), frontier)) + // No callbacks or awaits separate these publications, and retained handles remain immutable. + for (const [node, candidate] of staged) { + const previous = this.#completed.get(candidate.key) ?? [] + const covering = previous.find((witness) => witness.covers(candidate)) + const witness = covering ?? candidate + if (!covering) + this.#completed.set( + candidate.key, + previous + .filter((other) => !candidate.covers(other)) + .concat(candidate), + ) + this.#issued.add(witness) + staged.set(node, witness) + } + return staged + } +} diff --git a/packages/plugin-utils/src/production-manifest-worklist.ts b/packages/plugin-utils/src/production-manifest-worklist.ts new file mode 100644 index 000000000..bc4e4ccf3 --- /dev/null +++ b/packages/plugin-utils/src/production-manifest-worklist.ts @@ -0,0 +1,188 @@ +import { + type ManifestAncestry, + ManifestCertificates, + manifestCovers, + type ManifestEdge, + type ManifestEvidence, + type ManifestJob, + manifestJobKey, + manifestNamespace, + type ManifestObligation, +} from './production-manifest-certificates' +import type { ProductionSourceFile } from './production-source-files' + +interface ManifestOwner { + readonly admit: (job: ManifestJob) => boolean + readonly record: (file: ProductionSourceFile) => void + readonly expand: ( + job: ManifestJob, + request: (child: ManifestJob) => void, + ) => Promise +} +// Tarjan visits and continuation frames are the engine's mutable accumulators. +interface Visit { + readonly node: ManifestObligation + readonly index: number + lowlink: number + active: boolean +} +interface Frame { + readonly visit: Visit + readonly edges: readonly ManifestEdge[] + cursor: number + child: Visit | undefined +} + +export class ManifestWorklist { + readonly #certificates = new ManifestCertificates() + readonly #retained = new Map() + readonly #origins: ManifestObligation[] = [] + readonly #visits = new Map() + readonly #components: Visit[] = [] + + constructor(private readonly owner: ManifestOwner) {} + + #enqueue( + job: ManifestJob, + ancestry: ManifestAncestry, + ): ManifestEvidence | undefined { + if (!this.owner.admit(job)) return undefined + switch (job.kind) { + case 'file': + this.owner.record({ path: job.path, realPath: job.realPath }) + break + case 'package': + break + default: + job.kind satisfies never + } + if (ancestry[manifestNamespace(job.kind)].has(job.realPath)) + return { kind: 'cut', job } + const witness = this.#certificates.lookup(job, ancestry) + if (witness) return { kind: 'closed', witness } + const key = manifestJobKey(job) + const previous = this.#retained.get(key) ?? [] + const covering = previous.find((node) => + manifestCovers(node.ancestry, ancestry), + ) + if (covering) return { kind: 'node', node: covering } + const node: ManifestObligation = { + job: Object.freeze({ ...job }), + ancestry, + state: { phase: 'pending' }, + } + this.#retained.set( + key, + previous + .filter((other) => !manifestCovers(ancestry, other.ancestry)) + .concat(node), + ) + return { kind: 'node', node } + } + + seed(job: ManifestJob): void { + const evidence = this.#enqueue(job, { + files: new Set(), + packages: new Set(), + }) + if (!evidence) return + switch (evidence.kind) { + case 'node': + this.#origins.push(evidence.node) + break + case 'cut': + case 'closed': + break + default: + evidence satisfies never + } + } + + async #enter(node: ManifestObligation): Promise { + if (this.#visits.has(node)) return undefined + const witness = this.#certificates.lookup(node.job, node.ancestry) + if (witness) { + node.state = { phase: 'completed', witness } + return undefined + } + const index = this.#visits.size + const visit = { node, index, lowlink: index, active: true } + this.#visits.set(node, visit) + this.#components.push(visit) + node.state = { phase: 'expanding' } + const namespace = manifestNamespace(node.job.kind) + const next = { + ...node.ancestry, + [namespace]: new Set(node.ancestry[namespace]).add(node.job.realPath), + } + const edges: ManifestEdge[] = [] + await this.owner.expand(node.job, (child) => { + const evidence = this.#enqueue(child, next) + if (evidence) edges.push({ delta: node.job, evidence }) + }) + node.state = { phase: 'expanded', edges: Object.freeze(edges) } + return { visit, edges, cursor: 0, child: undefined } + } + + async run(): Promise { + for (const origin of this.#origins) { + const first = await this.#enter(origin) + if (!first) continue + const frames = [first] + for (let frame = frames.at(-1); frame; frame = frames.at(-1)) { + if (frame.child) { + if (frame.child.active) + frame.visit.lowlink = Math.min( + frame.visit.lowlink, + frame.child.lowlink, + ) + frame.child = undefined + } + const edge = frame.edges[frame.cursor++] + if (edge) { + const evidence = edge.evidence + switch (evidence.kind) { + case 'node': { + const previous = this.#visits.get(evidence.node) + if (previous) { + if (previous.active) + frame.visit.lowlink = Math.min( + frame.visit.lowlink, + previous.index, + ) + } else { + const child = await this.#enter(evidence.node) + if (child) { + frame.child = child.visit + frames.push(child) + } + } + break + } + case 'cut': + case 'closed': + break + default: + evidence satisfies never + } + continue + } + if (frame.visit.lowlink === frame.visit.index) { + const component: ManifestObligation[] = [] + for ( + let member = this.#components.pop(); + member; + member = this.#components.pop() + ) { + member.active = false + component.push(member.node) + if (member === frame.visit) break + } + for (const [node, witness] of this.#certificates.seal(component)) + node.state = { phase: 'completed', witness } + } + frames.pop() + } + } + } +} diff --git a/packages/plugin-utils/src/production-numbering.ts b/packages/plugin-utils/src/production-numbering.ts new file mode 100644 index 000000000..914e3b36d --- /dev/null +++ b/packages/plugin-utils/src/production-numbering.ts @@ -0,0 +1,211 @@ +import { + type BuildGeneration, + ClosedBuildGenerationError, +} from './build-generation' +import type { FileNumbering } from './numbering' +import type { ProductionManifestFile } from './production-file-manifest' + +export interface ProductionNumberingPlan { + readonly context: string + readonly files: readonly ProductionManifestFile[] + /** Already normalized by the caller; reservation adds no discovery or append policy. */ + readonly nonphysical: readonly string[] + readonly canonical: Readonly> +} + +export interface ProductionNumberingEngine extends FileNumbering { + importCanonicalMap(map: Readonly>): void +} + +export interface ProductionExtractionInput { + readonly context: string + readonly id: string + readonly source: { + readonly kind: 'physical' + readonly path: string + readonly realPath: string + } + readonly location: { + readonly filename: string + readonly importer?: string + readonly line?: number + readonly column?: number + } +} + +type Reason = 'physical-miss' | 'seed-failure' | 'invalid-plan' | 'unseeded' +interface ErrorDetails { + readonly context?: string + readonly id?: string + readonly location?: ProductionExtractionInput['location'] + readonly cause?: unknown + readonly restoreCause?: unknown +} +const messages = { + 'physical-miss': + 'physical source was not reserved; fix production discovery before extraction', + 'seed-failure': 'production reservation failed; abort the owning build', + 'invalid-plan': + 'production reservation plan has inconsistent context authority or provenance', + unseeded: 'production reservation must finish before physical extraction', +} as const + +export class ProductionNumberingError extends Error { + readonly name = 'ProductionNumberingError' + readonly context: string | undefined + readonly id: string | undefined + readonly location: ProductionExtractionInput['location'] | undefined + readonly restoreCause?: unknown + + constructor( + readonly reason: Reason, + details: ErrorDetails = {}, + ) { + const location = + details.location === undefined + ? undefined + : Object.freeze({ + ...details.location, + line: details.location.line ?? 1, + column: details.location.column ?? 1, + }) + const prefix = + location === undefined + ? '[devup-ui]' + : `${location.filename}:${location.line}:${location.column}:` + super( + `${prefix} ${messages[reason]} (context=${details.context ?? 'unspecified'}, id=${details.id ?? 'unspecified'})`, + 'cause' in details ? { cause: details.cause } : undefined, + ) + this.context = details.context + this.id = details.id + this.location = location + if ('restoreCause' in details) this.restoreCause = details.restoreCause + } +} + +type Phase = + | { readonly kind: 'unseeded' } + | { readonly kind: 'seeded' } + | { readonly kind: 'failed'; readonly error: ProductionNumberingError } + +/** + * Dormant reservations, constructed after all discovery/canonical plans close. + * One primitive per owner is a caller precondition. Seed the direct engine inside + * an admitted, restored, configured synchronous operation before ANY allocation. + * Seed success must reach that same owner's successful capture; on capture failure + * the caller aborts/disposes. No rollback or post-capture handshake is provided. + * This reserves file identities, not differing-value class order within one bucket. + */ +export class ProductionNumbering { + readonly files: readonly ProductionManifestFile[] + readonly buckets: readonly string[] + private readonly contexts = new Map< + string, + Readonly> + >() + private readonly memberships = new Map() + private phase: Phase = { kind: 'unseeded' } + + constructor( + private readonly owner: Pick, 'disposed'>, + plans: readonly ProductionNumberingPlan[], + ) { + if (owner.disposed) throw new ClosedBuildGenerationError() + const buckets = new Set() + for (const plan of plans) { + const context = plan.context + if (this.contexts.has(context)) + throw new ProductionNumberingError('invalid-plan', { context }) + const canonical = Object.freeze({ ...plan.canonical }) + this.contexts.set(context, canonical) + for (const input of plan.files) { + const file = Object.freeze({ ...input }) + const key = JSON.stringify([context, file.id]) + const previous = this.memberships.get(key) + if ( + file.context !== context || + (previous !== undefined && + (previous.path !== file.path || + previous.realPath !== file.realPath)) + ) + throw new ProductionNumberingError('invalid-plan', { + context, + id: file.id, + }) + this.memberships.set(key, file) + } + for (const id of [ + ...plan.files.map((file) => file.id), + ...plan.nonphysical, + ]) { + buckets.add(id) + buckets.add(Object.hasOwn(canonical, id) ? (canonical[id] ?? id) : id) + } + } + this.files = Object.freeze([...this.memberships.values()]) + this.buckets = Object.freeze([...buckets]) + } + + private seeded(): boolean { + if (this.owner.disposed) throw new ClosedBuildGenerationError() + const phase = this.phase + switch (phase.kind) { + case 'unseeded': + return false + case 'seeded': + return true + case 'failed': + throw phase.error + default: + return phase satisfies never + } + } + + seed(engine: ProductionNumberingEngine, activeContext: string): void { + const seeded = this.seeded() + const canonical = this.contexts.get(activeContext) + if (canonical === undefined) + throw new ProductionNumberingError('invalid-plan', { + context: activeContext, + }) + if (seeded) return + let failure: ProductionNumberingError | undefined + try { + engine.importCanonicalMap({}) + engine.seedFileMap([...this.buckets]) + } catch (cause) { + failure = new ProductionNumberingError('seed-failure', { + context: activeContext, + cause, + }) + } finally { + try { + engine.importCanonicalMap(canonical) + } catch (restoreCause) { + failure = new ProductionNumberingError('seed-failure', { + context: activeContext, + cause: failure === undefined ? restoreCause : failure.cause, + restoreCause, + }) + } + } + if (failure !== undefined) { + this.phase = { kind: 'failed', error: failure } + throw failure + } + this.phase = { kind: 'seeded' } + } + + /** Invoke before direct/prewarm extraction and before returning each physical child to WASM. */ + assertReserved(input: ProductionExtractionInput): void { + if (!this.seeded()) throw new ProductionNumberingError('unseeded', input) + const file = this.memberships.get(JSON.stringify([input.context, input.id])) + if ( + file === undefined || + file.path !== input.source.path || + file.realPath !== input.source.realPath + ) + throw new ProductionNumberingError('physical-miss', input) + } +} diff --git a/packages/plugin-utils/src/production-package-roots.ts b/packages/plugin-utils/src/production-package-roots.ts new file mode 100644 index 000000000..f8bd8459e --- /dev/null +++ b/packages/plugin-utils/src/production-package-roots.ts @@ -0,0 +1,57 @@ +import { readdirSync, realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { compareCodePoints } from './import-graph' +import { findPackage } from './owned-module-resolution' +import { + recordResolutionFailure, + type ResolutionInputCollector, + type ResolutionInputObserver, +} from './resolution-inputs' + +export function createProductionPackageRoots( + include: readonly string[], + excluded: (path: string) => boolean, + context: { + readonly inputs: ResolutionInputCollector + readonly observer: ResolutionInputObserver | undefined + }, +): (start: string) => readonly string[] { + const discoveries = new Map() + const { inputs, observer } = context + return (start) => { + const cached = discoveries.get(start) + if (cached) return cached + const found = new Set() + for (let directory = start; ; directory = dirname(directory)) { + const names = new Set(include) + for (const scope of ['@devup-ui', '@devup-editor']) { + const path = join(directory, 'node_modules', scope) + if (excluded(path)) continue + try { + if (excluded(realpathSync.native(path))) continue + for (const entry of readdirSync(path)) names.add(`${scope}/${entry}`) + } catch (cause) { + recordResolutionFailure(path, cause, inputs) + if (!( + cause instanceof Error && + 'code' in cause && + (cause.code === 'ENOENT' || cause.code === 'ENOTDIR') + )) + throw cause + } finally { + observer?.(inputs.snapshot()) + } + } + for (const name of names) { + const path = findPackage(directory, name, excluded, inputs) + if (path && !excluded(realpathSync.native(path))) found.add(path) + } + if (dirname(directory) === directory) break + } + observer?.(inputs.snapshot()) + const result = [...found].sort(compareCodePoints) + discoveries.set(start, result) + return result + } +} diff --git a/packages/plugin-utils/src/production-source-files.ts b/packages/plugin-utils/src/production-source-files.ts new file mode 100644 index 000000000..b8f31ec83 --- /dev/null +++ b/packages/plugin-utils/src/production-source-files.ts @@ -0,0 +1,65 @@ +import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +import { createDirectoryExclusion } from './directory-exclusion' +import { compareCodePoints } from './import-graph' +import type { SourceSelectionOptions } from './source-selection' +import { isSelectedSource } from './source-selection' + +export interface ProductionSourceFilesOptions extends SourceSelectionOptions { + readonly roots: readonly string[] + readonly sourceFiles?: readonly string[] + readonly entries?: readonly string[] + readonly exclude?: readonly string[] + readonly cwd?: string +} + +export interface ProductionSourceFile { + readonly path: string + readonly realPath: string +} + +export function enumerateProductionSourceFiles({ + roots, + sourceFiles = [], + entries = [], + exclude, + cwd = process.cwd(), + includeMdx, +}: ProductionSourceFilesOptions): readonly ProductionSourceFile[] { + const excluded = createDirectoryExclusion(exclude) + const files = new Map() + + function visit( + path: string, + ancestry: ReadonlySet, + optional = false, + ): void { + if (excluded(path)) return + const lexical = lstatSync(path, { throwIfNoEntry: !optional }) + if (!lexical) return + const realPath = realpathSync.native(path) + if (excluded(realPath)) return + const physical = lexical.isSymbolicLink() ? statSync(realPath) : lexical + if (physical.isDirectory()) { + if (ancestry.has(realPath)) return + // Only active ancestry closes cycles; sibling aliases retain their IDs. + const next = new Set([...ancestry, realPath]) + for (const entry of readdirSync(path, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue + visit(join(path, entry.name), next) + } + } else if (physical.isFile() && isSelectedSource(path, includeMdx)) { + files.set(path, { path, realPath }) + } + } + + for (const root of roots) visit(resolve(cwd, root), new Set(), true) + for (const source of sourceFiles) visit(resolve(cwd, source), new Set()) + for (const entry of entries) { + const path = resolve(cwd, entry) + visit(path, new Set()) + visit(dirname(path), new Set()) + } + return [...files.values()].sort((a, b) => compareCodePoints(a.path, b.path)) +} diff --git a/packages/plugin-utils/src/resolution-inputs.ts b/packages/plugin-utils/src/resolution-inputs.ts new file mode 100644 index 000000000..b7e283a68 --- /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 000000000..221bf9929 --- /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/shared.ts b/packages/plugin-utils/src/shared.ts index 9d5710be4..bd6f620c5 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) } diff --git a/packages/plugin-utils/src/source-selection.ts b/packages/plugin-utils/src/source-selection.ts new file mode 100644 index 000000000..1466006a6 --- /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/plugin-utils/src/state-writer.ts b/packages/plugin-utils/src/state-writer.ts new file mode 100644 index 000000000..45a21ad49 --- /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/plugin-utils/src/tsconfig-export-policy.ts b/packages/plugin-utils/src/tsconfig-export-policy.ts new file mode 100644 index 000000000..a5fb04d03 --- /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 000000000..6f116bb7d --- /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 new file mode 100644 index 000000000..d7354aa59 --- /dev/null +++ b/packages/plugin-utils/src/tsconfig.ts @@ -0,0 +1,159 @@ +import { existsSync } from 'node:fs' +import { dirname, isAbsolute, resolve } from 'node:path' + +import { isRecord, parseJsonc } from './jsonc' +import { ConfigLoadError } from './load-config' +import { resolvePackage } from './owned-module-resolution' +import { isResolutionFile } from './packaged-config-inputs' +import { + readResolutionFile, + realpathResolution, + recordResolutionFailure, + type ResolutionInputCollector, +} from './resolution-inputs' + +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 resolveParent( + specifier: string, + file: string, + inputs?: ResolutionInputCollector, +): string { + 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( + file: string, + stack: readonly string[], + inputs?: ResolutionInputCollector, +): AliasConfig { + try { + 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(readResolutionFile(file, inputs)) + 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, inputs), + [...stack, canonical], + inputs, + ), + } + 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 + recordResolutionFailure(file, cause, inputs) + throw new ConfigLoadError(file, cause) + } +} + +export function readPathAliases( + tsconfigPath?: string, + inputs?: ResolutionInputCollector, +): { + aliases: PathAlias[] + baseDir: string + baseUrl?: string +} { + 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, [], inputs) + 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, + } +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..5dc4cd65f 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -63,6 +63,21 @@ export interface DevupTheme { /** Custom style prop aliases mapped to one or more CSS property names. */ export type CustomShorthands = Record +/** Ordered alias candidates; a reached false explicitly ignores the module. */ +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/plugin-utils/src/typescript-version-range.ts b/packages/plugin-utils/src/typescript-version-range.ts new file mode 100644 index 000000000..9d8272c58 --- /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/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 404fc2997..9c1f7d4a4 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -177,6 +177,84 @@ 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. + +## 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/rsbuild-plugin/src/__tests__/alias-native.test.ts b/packages/rsbuild-plugin/src/__tests__/alias-native.test.ts new file mode 100644 index 000000000..8ff7c952a --- /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/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index 7c61a4591..39b594cf1 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -15,7 +15,6 @@ import { import { DevupUI } from '../plugin' -type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] @@ -29,6 +28,7 @@ const source = `import { Box } from '@devup-ui/react' const App = () => ` let codeExtractSpy: ReturnType +const closeCallbacks: (() => void)[] = [] beforeAll(() => { spyOn(fs, 'existsSync').mockReturnValue(true) @@ -40,18 +40,27 @@ beforeAll(() => { spyOn(wasm, 'getDefaultTheme').mockReturnValue(undefined) spyOn(wasm, 'getCss').mockReturnValue('') spyOn(wasm, 'setDebug').mockReturnValue(undefined) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue({ - code: '
', - css: '', - cssFile: 'devup-ui-0.css', - map: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult) + codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation(() => { + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + }) + return { + code: '
', + css: '', + cssFile: 'devup-ui-0.css', + map: undefined, + updatedBaseStyle: false, + dependencies: [], + free, + [Symbol.dispose]: free, + } + }) }) afterAll(() => { + for (const close of closeCallbacks) close() mock.restore() }) @@ -67,6 +76,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 000000000..11f82c2b4 --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/core-integration.test.ts @@ -0,0 +1,292 @@ +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(), + addMissingDependency: 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(), + addMissingDependency: 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__/mdx-extensions.test.ts b/packages/rsbuild-plugin/src/__tests__/mdx-extensions.test.ts new file mode 100644 index 000000000..fec4f0d70 --- /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 1a4393a97..443b1761b 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -20,20 +20,64 @@ 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 = {}, + events: string[] = [], ): CodeExtractResult { - return { + const values = { code: '
', css: '', cssFile: 'devup-ui.css', map: undefined, updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + dependencies: [], ...overrides, - } as unknown as CodeExtractResult + } + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + return { + get code() { + expect(live).toBe(true) + events.push('code') + return values.code + }, + get css() { + expect(live).toBe(true) + events.push('css') + return values.css + }, + get map() { + expect(live).toBe(true) + events.push('map') + return values.map + }, + get cssFile() { + expect(live).toBe(true) + events.push('cssFile') + return values.cssFile + }, + get updatedBaseStyle() { + expect(live).toBe(true) + events.push('updatedBaseStyle') + return values.updatedBaseStyle + }, + get dependencies() { + expect(live).toBe(true) + events.push('dependencies') + return values.dependencies + }, + free, + [Symbol.dispose]: free, + } } function createSetupContext( @@ -42,8 +86,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 +119,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 +139,7 @@ beforeAll(() => { }) afterAll(() => { + realpathSpy.mockRestore() existsSyncSpy.mockRestore() writeFileSyncSpy.mockRestore() mkdirSpy.mockRestore() @@ -97,6 +154,85 @@ afterAll(() => { }) describe('DevupUIRsbuildPlugin', () => { + it.each([ + undefined, + 'code', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + 'acquire', + 'consumer', + ])( + 'releases Rsbuild Output before consumers when fault %s occurs', + async (fault) => { + const events: string[] = [] + const output = createCodeExtractResult( + { cssFile: undefined, dependencies: ['dependency.ts'] }, + events, + ) + const error = new Error('ownership fault') + if (fault && fault !== 'acquire' && fault !== 'consumer') + Object.defineProperty(output, fault, { + get() { + events.push(fault) + throw error + }, + }) + codeExtractSpy.mockImplementation(() => { + if (fault === 'acquire') throw error + return output + }) + const transform = mock() + await DevupUI().setup(createSetupContext({ transform })) + const run = transform.mock.calls[1][1]({ + code: 'source', + resourcePath: '/src/output.tsx', + addDependency() { + expect(output.free).toHaveBeenCalledTimes(1) + if (fault === 'consumer') throw error + }, + }) + if (fault) await expect(run).rejects.toBe(error) + else expect(await run).toEqual({ code: '
', map: undefined }) + const fields = [ + 'code', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ] + expect(events).toEqual( + fault === 'acquire' + ? [] + : [ + ...fields.slice( + 0, + fault && fields.includes(fault) + ? fields.indexOf(fault) + 1 + : fields.length, + ), + 'free', + ], + ) + expect(output.free).toHaveBeenCalledTimes(fault === 'acquire' ? 0 : 1) + }, + ) + it('does not mutate shorthands when only creating a configuration', () => { + // Given + const register = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + // When + DevupUI({ shorthands: { insetX: ['left', 'right'] } }) + // Then + expect(register).not.toHaveBeenCalled() + } finally { + register.mockRestore() + } + }) + it('should export DevupUIRsbuildPlugin', () => { expect(DevupUI).toBeDefined() }) @@ -120,7 +256,7 @@ describe('DevupUIRsbuildPlugin', () => { expect(resolveModule('./plugin.test', import.meta.path)?.path).toBe( import.meta.path, ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ dependencies: ['/src/tokens.ts'] }), ) const addDependency = mock() @@ -154,7 +290,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 +310,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 +328,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 +338,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 +393,7 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) @@ -291,12 +425,12 @@ describe('DevupUIRsbuildPlugin', () => { expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: '
', css: '', @@ -344,11 +478,11 @@ const App = () => `, expect(transform).toHaveBeenCalled() expect(transform).toHaveBeenCalledWith( { - test: /\.(tsx|ts|js|mjs|jsx)$/, + test: pluginUtils.SOURCE_FILE_RE, }, expect.any(Function), ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: '
', css: '.devup-ui-1 { color: red; }', @@ -367,13 +501,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+ \*\//), @@ -408,8 +535,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')) @@ -420,7 +547,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', @@ -431,7 +558,7 @@ const App = () => `, 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -485,6 +612,48 @@ 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'], + sourceDirs: ['src'], + 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 @@ -578,14 +747,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() }) @@ -626,7 +800,7 @@ const App = () => `, '/p/src/a.tsx': [0], '/p/src/b.tsx': [1], }) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: '
', cssFile: '' }), ) const transform = mock() @@ -677,7 +851,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 () => { @@ -732,7 +911,7 @@ const App = () => `, pluginUtils, 'computeReachableFiles', ).mockReturnValue([resolve('src', 'App.tsx')]) - codeExtractSpy.mockReturnValue(createCodeExtractResult()) + codeExtractSpy.mockImplementation(() => createCodeExtractResult()) const transform = mock() const onBeforeBuild = mock() const modifyRspackConfig = mock() @@ -745,10 +924,13 @@ 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: {}, watchMode: false, rspack: { Compilation: { PROCESS_ASSETS_STAGE_REPORT: 5000 } }, hooks: { @@ -759,6 +941,8 @@ const App = () => `, config.plugins![0]!.apply(compiler) const compilation = { assets: { 'index.js': {} }, + fileDependencies: new Set(), + missingDependencies: new Set(), deleteAsset: mock(), hooks: { finishModules: { tap: tap('finishModules') }, @@ -776,6 +960,13 @@ const App = () => `, it('extracts the files the entries reach before building', async () => { const { onBeforeBuild } = await setup({ atomHoist: undefined }) + const discarded: { events: string[]; output: CodeExtractResult }[] = [] + codeExtractSpy.mockImplementation(() => { + const events: string[] = [] + const output = createCodeExtractResult({}, events) + discarded.push({ events, output }) + return output + }) codeExtractSpy.mockClear() onBeforeBuild.mock.calls[0][0]({ environments: { @@ -789,13 +980,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', @@ -806,6 +997,11 @@ const App = () => `, true, expect.anything(), ) + expect(discarded.length).toBeGreaterThan(0) + for (const { events, output } of discarded) { + expect(events).toEqual(['free']) + expect(output.free).toHaveBeenCalledTimes(1) + } // an extraction error is reported by the transform of that file codeExtractSpy.mockImplementation(() => { @@ -813,7 +1009,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/__tests__/resolution-watch.mjs b/packages/rsbuild-plugin/src/__tests__/resolution-watch.mjs new file mode 100644 index 000000000..bd2e93b33 --- /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 000000000..82ba4a1cd --- /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 47d4f2f3f..625ba0f2e 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -3,19 +3,38 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, + createDependencyGuard, createModuleResolver, + type CreateModuleResolverOptions, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, + isMdxSource, + isSelectedSource, loadDevupConfig, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -26,6 +45,8 @@ import { importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -46,6 +67,8 @@ export interface DevupUIRsbuildPluginOptions { singleCss: boolean 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). @@ -74,34 +97,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)) } /** @@ -119,41 +127,75 @@ 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, + 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) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), + ) return { name: PLUGIN_NAME, async setup(api) { - setDebug(debug) - if (prefix) { - setPrefix(prefix) - } - - 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, + const root = api.context.rootPath + const { cssDir, - devupFile, + distDir: outputDir, + devupFile: configFile, + } = resolveProjectPaths(root, { distDir, - singleCss, + 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 }, + { + integration: 'Rsbuild', + root, + }, + ) + registerShorthands(shorthands ?? {}) + api.onCloseBuild?.(endBuild) + setDebug(debug) + setPrefix(prefix ?? null) + + 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: 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 @@ -164,63 +206,38 @@ 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 + resolverOptions: CreateModuleResolverOptions + preserveSymlinks: boolean + inputFiles: Set + missingInputs: Set } - } - - // 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>) => { + plan.resolver = createModuleResolver(plan.resolverOptions) + 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( '\\', '/', @@ -228,7 +245,7 @@ export const DevupUI = ({ if (!extractCssDir.startsWith('./')) extractCssDir = `./${extractCssDir}` codeExtract( - atomMode ? file.replaceAll('\\', '/') : file, + toId(file), readFileSync(file, 'utf-8'), libPackage, extractCssDir, @@ -236,14 +253,25 @@ export const DevupUI = ({ atomMode, !atomMode, importAliases, + ).free() + } 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>() + 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 @@ -267,14 +295,159 @@ 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) => isSelectedSource(file, mdxExtensions)) + .map((file) => resolve(root, file)) + const roots = [ + ...new Set([ + ...sourceDirs, + ...entries.map((file) => dirname(file)), + ]), + ] + 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 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, + cwd: root, + include, + conditions, + alias: normalized.resolve?.alias, + exclude: [basename(outputDir), basename(cssDir)], + onResolutionInputs, + }) + const plan = { + graph, + roots, + entries, + resolver, + resolverOptions, + preserveSymlinks, + inputFiles, + missingInputs, + } + 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, + includeMdx: mdxExtensions, + 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 }) 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') @@ -293,6 +466,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( { @@ -342,16 +539,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) @@ -363,79 +564,128 @@ 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, + const extract: Parameters[1] = async ({ + code, + resourcePath, + addDependency, + addMissingDependency, + environment, + }) => { + if (excludeModules.test(resourcePath)) return code + const plan = plans.get(environment?.name) + 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), + ) + }, + }), ) - for (const dependency of dependencies) addDependency(dependency) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile( - join(cssDir, 'devup-ui.css'), - getCss(null, false), - 'utf-8', - ), - ) - } - - if (cssFile) { - promises.push( - writeFile( - join(cssDir, basename(cssFile)), - `/* ${resourcePath} ${Date.now()} */`, - 'utf-8', - ), + // 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 output = (() => { + try { + return codeExtract( + extractName, + code, + libPackage, + extractCssDir, + singleCss, + atomMode, + !atomMode, + importAliases, + ...(isMdxSource(resourcePath, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) + } catch (error) { + if (isMdxSource(resourcePath, mdxExtensions)) + throw remapMdxError(error, resourcePath) + throw error } - await Promise.all(promises) - return { - code: retCode, - map, + })() + const { + code: retCode, + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return { + code: output.code, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + output.free() } - }, + })() + 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, + } + } + 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: mdxSourceFilter(mdxExtensions), order: 'post' }, + extract, ) }, } diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a943c028c..d662c59fd 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -177,6 +177,91 @@ 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. + +### 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. + +## 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/vite-plugin/__regression__/aggregate-cycle.bun.ts b/packages/vite-plugin/__regression__/aggregate-cycle.bun.ts new file mode 100644 index 000000000..b172089f5 --- /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__/admission-metadata.test.ts b/packages/vite-plugin/src/__tests__/admission-metadata.test.ts new file mode 100644 index 000000000..17a73b146 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/admission-metadata.test.ts @@ -0,0 +1,37 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as utils from '@devup-ui/plugin-utils' +import { expect, it, spyOn } from 'bun:test' + +import { DevupUI } from '../plugin' + +type TestPlugin = { name: string; closeBundle(): void } + +it('updates admission metadata when Vite resolves a root after factory creation', async () => { + const vite: typeof import('vite') = createRequire(import.meta.url)('vite') + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-vite-admission-metadata-')), + ) + let context: utils.BuildIntegration | undefined + const begin = utils.beginBuild + const spy = spyOn(utils, 'beginBuild').mockImplementation( + (engine, metadata) => { + context = metadata + return begin(engine, metadata) + }, + ) + const plugins = DevupUI() as unknown as [TestPlugin] + try { + expect(context?.root).toBe(process.cwd()) + await vite.resolveConfig({ configFile: false, root, plugins }, 'serve') + expect(context?.root.replaceAll('\\', '/')).toBe(root.replaceAll('\\', '/')) + expect(context?.integration).toBe('Vite') + } finally { + plugins[0].closeBundle() + spy.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) 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 000000000..a9ea2d39c --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-build.test.ts @@ -0,0 +1,252 @@ +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, type HookHandler, type Plugin } from 'vite' + +import { DevupUI } from '../plugin' + +type NativeLoad = Extract< + HookHandler>, + (...args: never[]) => unknown +> +type TestPlugin = Omit & { + load(this: ThisParameterType, id: string): ReturnType +} + +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 }) as unknown as [TestPlugin] + 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 000000000..51d3cadec --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-css.test.ts @@ -0,0 +1,183 @@ +import { expect, it } from 'bun:test' +import type { UserConfig } from 'vite' + +import { createAggregateCssPreparation } from '../aggregate-css' +import { DevupUI } from '../plugin' +import { fixture, module } from './aggregate-fixture' + +type TestPlugin = { + buildStart(options: { input: string }): void | Promise + config(this: void, config: UserConfig): Omit + closeBundle(): void +} + +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 }) as unknown as [TestPlugin] + 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() as unknown as [TestPlugin] + 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 000000000..c73d76473 --- /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 000000000..69cc7a937 --- /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 000000000..73ceadb71 --- /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 000000000..195ed0882 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/aggregate-unknown.test.ts @@ -0,0 +1,138 @@ +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' + +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 }) + } +}) + +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__/core-regression.test.ts b/packages/vite-plugin/src/__tests__/core-regression.test.ts new file mode 100644 index 000000000..73853aed0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,260 @@ +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 { 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 Plugin, type ViteDevServer } from 'vite' + +import { DevupUI } from '../plugin' + +type TestPlugin = Omit & { + watchChange(id: string): Promise +} + +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() as unknown as [TestPlugin] + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + 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', + ) + 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() as unknown as [TestPlugin] + server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true }, + 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) + 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() + } +}) + +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__/guard-facts.test.ts b/packages/vite-plugin/src/__tests__/guard-facts.test.ts new file mode 100644 index 000000000..597acae06 --- /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 000000000..3a50e49c2 --- /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__/nonphysical-arrival-native.test.ts b/packages/vite-plugin/src/__tests__/nonphysical-arrival-native.test.ts new file mode 100644 index 000000000..43f23c3a0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/nonphysical-arrival-native.test.ts @@ -0,0 +1,297 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import { createNonphysicalIdStore } from '@devup-ui/plugin-utils' +import { exportFileMap } from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { type Plugin, resolveConfig } from 'vite' + +import { NonphysicalModules } from '../nonphysical-modules' +import { DevupUI } from '../plugin' +import { ProductionActivation } from '../production-numbering' +import { productionFixture } from './production-fixture' + +it('leaves a configured but unbuilt context store untouched during native completion', async () => { + // Given an actual scoped sibling store and an ordinary client entry. + const fixture = await productionFixture() + const entry = await fixture.file('app/src/main.js', 'export const value=1;') + const store = createNonphysicalIdStore({ + integration: 'Vite', + resolvedRoot: fixture.root, + contextKey: 'unbuilt', + distDir: `${fixture.root}/df`, + }) + await store.rewrite(['virtual:unbuilt'], []) + try { + // When only the client is built through the native API. + await fixture.run(false, [], { + environments: { client: {}, unbuilt: { consumer: 'server' } }, + }) + // Then the sibling is not rewritten as an empty or inferred cohort. + expect(await store.read()).toEqual({ + kind: 'loaded', + ids: ['virtual:unbuilt'], + }) + const numbers: unknown = JSON.parse(exportFileMap()) + expect(numbers).toEqual({ [entry]: 0, 'virtual:unbuilt': 1 }) + } finally { + await fixture.close() + } +}) + +it('subtracts a learned physical arrival when a fresh native generation promotes it into SCAN', async () => { + // Given a real first-arrival source outside the initially scanned parent. + const fixture = await productionFixture() + const physical = await fixture.file( + 'outside/view.js', + "import {css} from '@devup-ui/react'; export const cls=css({color:'red'});", + ) + await fixture.file('app/src/main.js', "export {cls} from 'outside-view';") + const provider: Plugin = { + name: 'promoted-arrival', + resolveId(id) { + if (id === 'outside-view') return physical + }, + } + try { + await fixture.run(false, [provider]) + expect(await fixture.learned()).toContain(physical) + // When the same actual source joins the next native generation's scan roots. + await fixture.run(false, [], { + plugins: [ + DevupUI({ sourceDirs: [`${fixture.directory}/outside`] }), + provider, + ], + }) + // Then learned storage no longer duplicates the scan reservation. + expect(await fixture.learned()).not.toContain(physical) + } finally { + await fixture.close() + } +}) + +it('propagates a genuine physical retention IO fault rather than treating it as absence (UNIT)', async () => { + // Given real OS permissions denying access outside the scanned application. + const fixture = await productionFixture() + const physical = await fixture.file('denied/source.js', 'export {}') + const directory = `${fixture.directory}/denied` + const account = + process.platform === 'win32' + ? execFileSync('whoami', { encoding: 'utf8' }).trim() + : '' + if (process.platform === 'win32') + execFileSync('icacls', [directory, '/deny', `${account}:(OI)(CI)(F)`]) + else await chmod(directory, 0o000) + const modules = new NonphysicalModules([]) + try { + // When empty-O retention follows the exact saved physical spelling. + const completion = modules.finish( + { + *getModuleIds() {}, + getModuleInfo: () => null, + }, + [physical], + ) + // Then the real filesystem failure is propagated, never converted to a missing file. + await expect(completion).rejects.toBeInstanceOf(Error) + } finally { + if (process.platform === 'win32') + execFileSync('icacls', [directory, '/remove:d', account, '/T', '/C']) + else await chmod(directory, 0o700) + await fixture.close() + } +}) + +it('propagates genuine numbering-store IO failure before native extraction', async () => { + // Given a real non-directory blocking the unchanged store path. + const fixture = await productionFixture() + await fixture.file('app/src/main.js', 'export const value=1;') + await fixture.file('app/df/numbering', 'not a directory') + try { + const { filePath } = createNonphysicalIdStore({ + integration: 'Vite', + resolvedRoot: fixture.root, + contextKey: 'client', + distDir: `${fixture.root}/df`, + }) + let expectedCode: string | undefined + // Independently follow the store's read and missing-only write boundary. + try { + try { + await readFile(filePath, 'utf-8') + } catch (error) { + if (!(error instanceof Error) || !('code' in error)) throw error + if (error.code !== 'ENOENT') throw error + await mkdir(dirname(filePath), { recursive: true }) + } + } catch (error) { + if (!(error instanceof Error) || !('code' in error)) throw error + if (typeof error.code !== 'string') throw error + expectedCode = error.code + } + if (expectedCode === undefined) + throw new TypeError('Expected independent numbering-store IO failure') + // When ordinary native preparation materializes its scoped store. + const operation = fixture.run(false) + // Then the native failure message carries the OS errno, not corrupt/missing history. + await expect(operation).rejects.toThrow(`${expectedCode}:`) + } finally { + await fixture.close() + } +}) + +it('replays changed theme state when an already-prepared production generation updates its theme', async () => { + // Given a real configuration and an allocation-inert prepared owner. + const fixture = await productionFixture() + const entry = await fixture.file('app/src/main.js', 'export const value=1;') + const theme = await fixture.file( + 'app/devup.json', + '{"theme":{"colors":{"default":{"primary":"red"}}}}', + ) + const activation = new ProductionActivation( + { + package: '@devup-ui/react', + devupFile: 'devup.json', + distDir: 'df', + cssDir: undefined, + extractCss: true, + debug: false, + include: [], + singleCss: false, + prefix: undefined, + shorthands: undefined, + sourceDirs: undefined, + mdxExtensions: ['.mdx'], + atomHoist: undefined, + importAliases: {}, + }, + () => {}, + ) + try { + activation.configure( + await resolveConfig( + { + root: fixture.root, + configFile: false, + logLevel: 'silent', + build: { lib: { entry, formats: ['es'] } }, + }, + 'build', + ), + ) + await activation.prepare(() => {}) + await writeFile( + theme, + '{"theme":{"colors":{"default":{"primary":"blue"}}}}', + ) + // When the same prepared generation executes its real theme-update boundary. + await activation.updateTheme() + // Then the resumed native sheet contains the replacement theme, not the prior snapshot. + expect(activation.css(null, false)).toContain('--primary:blue') + expect(activation.css(null, false)).not.toContain('--primary:red') + } finally { + activation.dispose() + await fixture.close() + } +}) + +it.each([false, true])( + 'learns real native arrivals and retains only eligible history with singleCss=%s', + async (singleCss) => { + // Given sources outside the scanned parent and a native opaque producer. + const fixture = await productionFixture() + const physical = await fixture.file( + 'outside/view.js', + "import {css} from '@devup-ui/react'; export const cls=css({color:'red'});", + ) + const opaque = '\0native:v1-style.js' + const provider: Plugin = { + name: 'native-arrival-provider', + resolveId(id) { + if (id === 'outside-view') return physical + if (id === 'virtual-view') return opaque + }, + load(id) { + if (id === opaque) + return "import {css} from '@devup-ui/react'; export const cls=css({background:'blue'});" + }, + } + const entry = await fixture.file( + 'app/src/main.js', + "export {cls as physical} from 'outside-view'; export {cls as virtual} from 'virtual-view';", + ) + try { + // When native resolution introduces both unreserved extraction sources. + await fixture.run(singleCss, [provider]) + const first = await fixture.learned() + expect(first).toContain(physical) + expect(first).toContain(opaque) + await fixture.run(singleCss, [provider]) + expect(await fixture.learned()).toEqual(first) + await writeFile(entry, 'export const untouched=1;') + await fixture.run(singleCss) + // Then existing-but-unreachable physical history survives while unevidenced opaque history drops. + expect(await fixture.learned()).toContain(physical) + expect(await fixture.learned()).not.toContain(opaque) + await unlink(physical) + await fixture.run(singleCss) + expect(await fixture.learned()).not.toContain(physical) + } finally { + await fixture.close() + } + }, + 30000, +) + +it.each([false, true])( + 'withholds failed input but commits completed input before a later output fault with singleCss=%s', + async (singleCss) => { + // Given a real opaque source and an entry with no styles of its own. + const fixture = await productionFixture() + await fixture.file('app/src/main.js', "export {cls} from 'virtual-view';") + const opaque = '\0native:timing.js' + const provider: Plugin = { + name: 'timing-provider', + resolveId(id) { + if (id === 'virtual-view') return opaque + }, + load(id) { + if (id === opaque) + return "import {css} from '@devup-ui/react'; export const cls=css({color:'red'});" + }, + } + try { + // When extraction succeeds and native output fails only after buildEnd completion. + await expect( + fixture.run(singleCss, [ + provider, + { + name: 'late-native-output-fault', + renderStart() { + throw new RangeError('late output fault') + }, + }, + ]), + ).rejects.toThrow('late output fault') + // Then the actual completed-input cohort is already committed. + expect(await fixture.learned()).toContain(opaque) + await fixture.file('app/src/main.js', "export {cls} from 'virtual-new';") + const before = await fixture.learned() + await expect( + fixture.run(singleCss, [ + { + name: 'native-input-fault', + resolveId(id) { + if (id === 'virtual-new') throw new RangeError('input fault') + }, + }, + ]), + ).rejects.toThrow('input fault') + expect(await fixture.learned()).toEqual(before) + } finally { + await fixture.close() + } + }, + 30000, +) diff --git a/packages/vite-plugin/src/__tests__/nonphysical-cache-native.test.ts b/packages/vite-plugin/src/__tests__/nonphysical-cache-native.test.ts new file mode 100644 index 000000000..ca57a41e3 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/nonphysical-cache-native.test.ts @@ -0,0 +1,266 @@ +import { readFile, writeFile } from 'node:fs/promises' + +import { BuildGeneration, runBuildOperation } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' +import { build, type Plugin } from 'vite' + +import { NonphysicalModules } from '../nonphysical-modules' +import { DevupUI } from '../plugin' +import { productionEngine, type ProductionState } from '../production-engine' +import { + extractOwned, + nativeCompletionDeadline, + nativeCompletions, + productionFixture, +} from './production-fixture' + +type TestPlugin = Omit & { + watchChange(this: void, id: string): Promise + closeWatcher(): void +} + +it('rejects an unsettled completion with a real deadline error (UNIT)', async () => { + // Given a completion with no native success/error signal. + let timer: ReturnType | undefined + const completion = new Promise((_resolve, reject) => { + timer = nativeCompletionDeadline(reject, 1) + }) + try { + // When the actual platform deadline fires. + // Then completion rejects with the configured deadline error. + await expect(completion).rejects.toBeInstanceOf(RangeError) + } finally { + clearTimeout(timer) + } +}) + +it('updates the production sheet through the actual tuple theme hook (UNIT dispatch)', async () => { + // Given a native-prepared tuple and a real theme file. + const fixture = await productionFixture() + const tuple = DevupUI() as unknown as [TestPlugin] + await fixture.file('app/src/main.js', 'export const value=1;') + const theme = await fixture.file( + 'app/devup.json', + '{"theme":{"colors":{"default":{"primary":"red"}}}}', + ) + try { + await fixture.run(false, [], { plugins: [tuple] }) + await writeFile( + theme, + '{"theme":{"colors":{"default":{"primary":"blue"}}}}', + ) + // When the public hook dispatches the replacement theme for that same generation. + await tuple[0].watchChange.call(undefined, theme) + // Then the actual materialized production sheet contains the new theme. + const css = await readFile( + `${fixture.root}/df/devup-ui/devup-ui.css`, + 'utf8', + ) + expect(css).toContain('--primary:blue') + expect(css).not.toContain('--primary:red') + } finally { + tuple[0].closeWatcher() + await fixture.close() + } +}) + +it('restores an owned Vite sheet after a sibling operates when only its own legacy interval ends', () => { + // Given two real snapshot owners after the actual Vite factory releases its interval. + const [plugin] = DevupUI() as unknown as [{ closeBundle(this: void): void }] + plugin.closeBundle.call(undefined) + const first = new BuildGeneration() + const sibling = new BuildGeneration() + const context = { integration: 'Vite', root: process.cwd() } + let admitted = false + function run(owner: BuildGeneration, action: () => T): T { + return runBuildOperation(context, () => { + admitted = true + return owner.run(productionEngine, action) + }) + } + try { + const original = run(first, () => extractOwned('red')) + run(sibling, () => extractOwned('blue')) + // When the first owner resumes through the real admitted reset/restore boundary. + const restored = run(first, () => ({ + state: productionEngine.capture(), + css: wasm.getCss(0, false), + })) + // Then all four actual snapshots and its finished red sheet are restored unchanged. + expect(restored.state).toEqual(original.state) + expect(restored.css).toBe(original.css) + expect(restored.css).toContain('background:red') + } finally { + first.dispose() + sibling.dispose() + if (admitted) runBuildOperation(context, () => wasm.resetBuildState()) + } +}) + +it.each([false, true])( + 'observes opaque extraction in every completed native interval with singleCss=%s', + async (singleCss) => { + // Given a real watch graph with a styled opaque module and an unrelated source. + const fixture = await productionFixture() + const opaque = '\0native:cached-style.js' + const recoveredId = '\0native:recovered-style.js' + const buildOptions = { + write: true, + rolldownOptions: { + external: ['react/jsx-runtime', 'react/jsx-dev-runtime'], + }, + } + await fixture.file( + 'app/index.html', + '', + ) + await fixture.file( + 'app/src/main.js', + "import {cls} from 'virtual-style'; import {value} from './unrelated.jsx'; globalThis.nativeStyle=cls; globalThis.nativeValue=value;", + ) + const unrelated = await fixture.file( + 'app/src/unrelated.jsx', + 'export const value=1;', + ) + const cohorts: string[][] = [] + const finish = NonphysicalModules.prototype.finish + const observed: string[][] = [] + const inputCodes: string[] = [] + const nativeCodes: (string | null)[] = [] + const outputs: (() => Promise< + { readonly name: string; readonly bytes: Buffer }[] + >)[] = [] + const provider: Plugin = { + name: 'actual-native-cache-provider', + resolveId(id) { + if (id === 'virtual-style') return opaque + if (id === 'virtual-recovered') return recoveredId + }, + load(id) { + if (id === opaque || id === recoveredId) + return "import {css} from '@devup-ui/react'; export const cls=css({color:'red'});" + }, + writeBundle(_options, bundle) { + const names = Object.keys(bundle) + .filter((name) => /\.(js|css)$/.test(name)) + .sort() + outputs.push(async () => + Promise.all( + names.map(async (name) => ({ + name, + bytes: await readFile(`${fixture.root}/dist/${name}`), + })), + ), + ) + }, + } + const spy = spyOn( + NonphysicalModules.prototype, + 'finish', + ).mockImplementation(function (this: NonphysicalModules, graph, learned) { + observed.push([...this.observations()]) + inputCodes.push(graph.getModuleInfo(unrelated)?.code ?? '') + nativeCodes.push(graph.getModuleInfo(opaque)?.code ?? null) + return finish.call(this, graph, learned).then((result) => { + cohorts.push([...result.ids]) + return result + }) + }) + let close: (() => Promise) | undefined + try { + const result = await build({ + root: fixture.root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI({ singleCss }), provider], + build: { + ...buildOptions, + watch: {}, + }, + }) + if (Array.isArray(result) || !('on' in result)) + throw new TypeError('Expected native watcher') + close = () => result.close() + const next = nativeCompletions(result) + await next() + expect(observed[0]).toContain(opaque) + expect(await fixture.learned()).toContain(opaque) + // When default native watch rebuilds an unrelated branch. + await writeFile(unrelated, 'export const value=2;') + await next() + while (!inputCodes.some((code) => /value\s*=\s*2/.test(code))) + await next() + const changed = inputCodes.findIndex((code) => /value\s*=\s*2/.test(code)) + console.info( + JSON.stringify({ + case: 'native-cache-observation', + singleCss, + nativeCodes, + inputCodes, + observations: observed, + completedCohorts: cohorts, + changed, + }), + ) + expect(typeof nativeCodes[changed]).toBe('string') + // Then Vite re-extracts the opaque module in every successfully completed input interval. + expect(cohorts.length).toBeGreaterThan(1) + expect(observed).toHaveLength(cohorts.length) + for (const [index, cohort] of cohorts.entries()) { + expect(observed[index]).toContain(opaque) + expect(typeof nativeCodes[index]).toBe('string') + expect(cohort).toContain(opaque) + } + expect(await fixture.learned()).toContain(opaque) + const before = await fixture.storeBytes() + await writeFile( + unrelated, + "import {Box} from '@devup-ui/react'; export const value=;", + ) + await next() + const recoveredCapture = outputs.at(-1) + if (!recoveredCapture) + throw new TypeError('Expected recovered output capture') + const recovered = await recoveredCapture() + expect(await fixture.learned()).toContain(recoveredId) + await close() + close = undefined + for (const [name, bytes] of Object.entries(before)) + await writeFile(`${fixture.root}/df/numbering/${name}`, bytes) + const captureCount = outputs.length + await fixture.run(singleCss, [provider], { build: buildOptions }) + const cleanCapture = outputs[captureCount] + if (!cleanCapture) + throw new TypeError('Expected new clean output capture') + const clean = await cleanCapture() + // Then raw JS/CSS equal a clean build with the same root/options/source/starting ID history. + expect(recovered).toEqual(clean) + const recoveredText = recovered + .map(({ bytes }) => bytes.toString()) + .join('') + expect(recoveredText).toMatch(/background\s*:\s*(red|#f00)/) + expect(recoveredText).toContain('div') + const rule = /\.([\w-]+)\s*\{background:(?:red|#f00)/.exec(recoveredText) + expect(rule?.[1]).toBeDefined() + expect( + clean + .filter(({ name }) => name.endsWith('.js')) + .map(({ bytes }) => bytes.toString()) + .join(''), + ).toContain(rule?.[1] ?? 'missing-class') + } finally { + await close?.() + spy.mockRestore() + await fixture.close() + } + }, + 60000, +) diff --git a/packages/vite-plugin/src/__tests__/nonphysical-modules.test.ts b/packages/vite-plugin/src/__tests__/nonphysical-modules.test.ts new file mode 100644 index 000000000..5b25509d7 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/nonphysical-modules.test.ts @@ -0,0 +1,262 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' +import { build } from 'vite' + +import { type NativePresence, NonphysicalModules } from '../nonphysical-modules' +import { DevupUI } from '../plugin' + +type NativeNode = ReturnType +function nativePresence( + nodes: ReadonlyMap, +): NativePresence { + return { + getModuleIds: () => nodes.keys(), + getModuleInfo: (id) => nodes.get(id) ?? null, + } +} + +it.each([ + 'cached', + 'replaced', + 'invalidated', + 'orphaned', + 'unavailable', +] as const)( + 'retains absent-O ready attachments only with current consumer evidence when %s (UNIT)', + async (state) => { + // Given a published real observation and an in-memory native presence implementation. + const modules = new NonphysicalModules([]) + const root = '\0unit:root.js' + const child = '\0unit:child.js' + const consumer = { + code: 'export {}', + isEntry: true, + importedIds: [], + dynamicallyImportedIds: [], + } + const nodes = new Map([[root, consumer]]) + const graph = nativePresence(nodes) + modules.direct(root, root, { code: 'export {}' }) + modules.resolved(root, { path: child, code: 'export const value=1' }) + modules.publish(await modules.finish(graph, [])) + modules.start() + switch (state) { + case 'cached': + break + case 'replaced': + modules.direct(root, root, { code: 'export {}' }) + break + case 'invalidated': + modules.invalidate(child) + break + case 'orphaned': + nodes.clear() + break + case 'unavailable': + nodes.set(root, { ...consumer, code: null }) + break + default: + state satisfies never + } + // When a completed interval has no current observation of the ready child. + const completed = await modules.finish(graph, [child]) + // Then only the unchanged, reachable, code-bearing consumer retains that exact ID. + expect(modules.observations()).not.toContain(child) + expect(completed.ids.includes(child)).toBe(state === 'cached') + }, +) + +it('retains existing unreachable physical files and reachable opaque code in an empty-O interval (UNIT)', async () => { + // Given actual files plus reachable, cyclic and orphan native vertices. + const root = ( + await realpath(await mkdtemp(join(tmpdir(), 'devup-v1-retention-'))) + ).replaceAll('\\', '/') + const physical = `${root}/existing.js` + await writeFile(physical, 'export {}') + const opaque = '\0unit:present.js' + const absent = `${root}/missing.js` + const directory = root + const nodes = new Map([ + [ + opaque, + { + code: '', + isEntry: true, + importedIds: [opaque, 'missing'], + dynamicallyImportedIds: [], + }, + ], + [ + 'orphan', + { code: '', isEntry: false, importedIds: [], dynamicallyImportedIds: [] }, + ], + ]) + const modules = new NonphysicalModules([]) + try { + // When the real retention implementation probes physical history and walks native reachability. + const completed = await modules.finish(nativePresence(nodes), [ + physical, + absent, + directory, + `${physical}/child.js`, + opaque, + 'orphan', + ]) + // Then only the existing file and reachable code survive, without invented observations. + expect(modules.observations()).toEqual([]) + expect(completed.ids.toSorted()).toEqual([physical, opaque].sort()) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('keeps existing direct and recursive extraction filenames consistent across queried native builds', async () => { + // Given a real typed source, plain typed constant and native query variants. + const root = ( + await realpath(await mkdtemp(join(tmpdir(), 'devup-v1-current-id-'))) + ).replaceAll('\\', '/') + const entry = join(root, 'src/main.ts') + const style = `${root}/src/style.ts` + const constant = `${root}/src/constant.ts` + const direct: { + readonly id: string + readonly code: string + readonly css: string | undefined + readonly map: string | undefined + }[][] = [] + const recursive: string[][] = [] + const nativeCss: { + readonly fileName: string + readonly source: string | Uint8Array + }[][] = [] + const extract = wasm.codeExtract + const installResolver = wasm.setModuleResolver + let index = 0 + const extraction = spyOn(wasm, 'codeExtract').mockImplementation( + (...args) => { + const output = extract(...args) + direct[index]?.push({ + id: args[0], + code: output.code, + css: output.css, + map: output.map, + }) + return output + }, + ) + const resolution = spyOn(wasm, 'setModuleResolver').mockImplementation( + (resolver) => { + if (typeof resolver !== 'function') { + installResolver(resolver) + return + } + installResolver((specifier: string, importer: string) => { + const result: unknown = resolver(specifier, importer) + if ( + result !== null && + typeof result === 'object' && + 'path' in result && + typeof result.path === 'string' + ) { + recursive[index]?.push(result.path) + } + return result + }) + }, + ) + try { + await mkdir(join(root, 'src')) + await writeFile( + join(root, 'package.json'), + '{"name":"devup-v1-current-id","type":"module"}', + ) + await writeFile(join(root, 'tsconfig.json'), '{}') + await writeFile(constant, "export const color: string = 'red';") + await writeFile( + style, + "import {css} from '@devup-ui/react'; import {color} from './constant'; export const cls=css({bg:color});", + ) + // When actual native builds resolve two queries through today's unmodified plugin. + for (const query of ['learning', 'first', 'second']) { + direct.push([]) + recursive.push([]) + await writeFile( + entry, + `import {cls} from './style.ts?variant=${query}'; export {cls};`, + ) + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [DevupUI()], + build: { write: false, lib: { entry, formats: ['es'] } }, + }) + nativeCss.push( + (Array.isArray(result) ? result : [result]).flatMap((bundle) => { + if (!('output' in bundle)) + throw new TypeError('Expected native build output, not a watcher') + return bundle.output.flatMap((output) => + output.type === 'asset' && output.fileName.endsWith('.css') + ? [{ fileName: output.fileName, source: output.source }] + : [], + ) + }), + ) + if (index === 0) { + const directory = join(root, 'df/numbering') + const lists: unknown[] = await Promise.all( + (await readdir(directory)).map(async (file) => + JSON.parse(await readFile(join(directory, file), 'utf8')), + ), + ) + expect( + lists.some( + (list) => + Array.isArray(list) && + list.some( + (id: unknown) => + typeof id === 'string' && + direct[0]?.some((call) => call.id === id), + ), + ), + ).toBe(true) + } + index += 1 + } + // Then real direct/resolver calls use physical filenames and produce identical extracted styling. + const first = direct[1]?.find((call) => call.id === style) + const second = direct[2]?.find((call) => call.id === style) + expect(first).toBeDefined() + expect(second?.id).toBe(first?.id) + expect(second?.code).toBe(first?.code) + expect(second?.map).toBe(first?.map) + expect(nativeCss[1]?.length).toBeGreaterThan(0) + expect(nativeCss[2]).toEqual(nativeCss[1]) + expect(first?.css).toContain('background:red') + expect(first?.map).toContain(JSON.stringify(style)) + expect(recursive[1]).toContain(constant) + expect(recursive[2]).toContain(constant) + expect(direct[0]?.find((call) => call.id === style)?.code).not.toBe( + first?.code, + ) + expect(direct.flat().some((call) => call.id.includes('?'))).toBe(false) + } finally { + extraction.mockRestore() + resolution.mockRestore() + wasm.setModuleResolver(undefined) + wasm.resetBuildState() + await rm(root, { recursive: true, force: true }) + } +}, 30000) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 4e14b5fd8..d44144106 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -14,7 +14,91 @@ import { spyOn, } from 'bun:test' -import { DevupUI } from '../plugin' +import { DevupUI as createDevupUI } from '../plugin' + +const pluginInstances = new Set() +it.each([ + undefined, + 'code', + 'css', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + 'acquire', + 'consumer', +])( + 'releases Vite Output after needed copies when getter %s is selected', + async (fault) => { + const events: string[] = [] + const output = createCodeExtractResult( + { + cssFile: undefined, + dependencies: fault === 'consumer' ? ['dep.ts'] : [], + }, + events, + ) + const error = new Error('getter fault') + if (fault && fault !== 'acquire' && fault !== 'consumer') + Object.defineProperty(output, fault, { + get() { + events.push(fault) + throw error + }, + }) + codeExtractSpy.mockImplementation(() => { + if (fault === 'acquire') throw error + return output + }) + const plugin = createPlugin() + const run = plugin.transform.call( + { + addWatchFile() { + expect(output.free).toHaveBeenCalledTimes(1) + throw error + }, + }, + 'source', + '/src/output.tsx', + ) + if (fault) await expect(run).rejects.toBe(error) + else expect(await run).toEqual({ code: 'code', map: undefined }) + const fields = [ + 'code', + 'css', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ] + expect(events).toEqual( + fault === 'acquire' + ? [] + : [ + ...fields.slice( + 0, + fault && fields.includes(fault) + ? fields.indexOf(fault) + 1 + : fields.length, + ), + 'free', + ], + ) + expect(output.free).toHaveBeenCalledTimes(fault === 'acquire' ? 0 : 1) + expect(output[Symbol.dispose]).toBe(output.free) + }, +) +function DevupUI(options?: Parameters[0]) { + const plugin = createDevupUI(options) as unknown as [ + ViteTestPlugin, + ViteTestRestorePlugin, + unknown, + unknown, + unknown, + ] + pluginInstances.add(plugin[0]) + return plugin +} type CodeExtractResult = ReturnType interface ConfigHookMeta { @@ -48,10 +132,18 @@ interface HotUpdateEnvironment { } interface ViteTestPlugin { + environment?: { + name: string + config: { + consumer: 'client' | 'server' + build?: { write?: boolean } + } + } name: string sharedDuringBuild: true enforce: 'pre' apply: () => boolean + closeBundle: () => void config: ( this: { meta?: ConfigHookMeta } | void, userConfig?: ViteConfig, @@ -59,7 +151,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string - plugins?: object[] + plugins?: readonly object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -80,6 +172,13 @@ interface ViteTestPlugin { load: (id: string) => string | undefined transform: ( this: { + addWatchFile?: (dependency: string) => void + getCombinedSourcemap?: () => { + version: number + sources: string[] + names: string[] + mappings: string + } environment?: { name: string config: { @@ -90,7 +189,7 @@ interface ViteTestPlugin { }, code: string, id: string, - ) => Promise<{ code: string } | undefined> + ) => Promise<{ code: string; map?: string } | undefined> generateBundle: ( this: { environment?: { @@ -126,17 +225,57 @@ interface ViteTestRestorePlugin { function createCodeExtractResult( overrides: Partial = {}, + events: string[] = [], ): CodeExtractResult { - return { + const values = { css: 'css code', code: 'code', cssFile: 'devup-ui.css', map: undefined, updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + dependencies: [], ...overrides, - } as unknown as CodeExtractResult + } + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + return { + get code() { + expect(live).toBe(true) + events.push('code') + return values.code + }, + get css() { + expect(live).toBe(true) + events.push('css') + return values.css + }, + get map() { + expect(live).toBe(true) + events.push('map') + return values.map + }, + get cssFile() { + expect(live).toBe(true) + events.push('cssFile') + return values.cssFile + }, + get updatedBaseStyle() { + expect(live).toBe(true) + events.push('updatedBaseStyle') + return values.updatedBaseStyle + }, + get dependencies() { + expect(live).toBe(true) + events.push('dependencies') + return values.dependencies + }, + free, + [Symbol.dispose]: free, + } } function createPlugins( @@ -146,7 +285,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 = { @@ -186,6 +328,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) @@ -195,7 +339,7 @@ beforeEach(() => { relativeSpy = spyOn(nodePath, 'relative').mockImplementation( (from: string, to: string) => originalRelative(from, to), ) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( + codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation(() => createCodeExtractResult(), ) getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css code') @@ -206,9 +350,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() @@ -221,16 +381,54 @@ 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({}) expect(plugin).toEqual({ name: 'devup-ui', sharedDuringBuild: true, + closeBundle: expect.any(Function), + buildStart: expect.any(Function), + buildApp: { order: 'pre', handler: expect.any(Function) }, + buildEnd: { order: 'post', handler: expect.any(Function) }, + closeWatcher: expect.any(Function), config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), @@ -241,6 +439,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) @@ -451,20 +650,19 @@ describe('devupUIVitePlugin', () => { }) describe('deterministic file numbering', () => { - let listSourceFilesSpy: ReturnType - let importFileMapSpy: ReturnType - let exportFileMapSpy: ReturnType + const collectNumberedFiles = pluginUtils.collectNumberedFiles + const seedFileMap = wasm.seedFileMap + 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[]) { @@ -472,90 +670,181 @@ 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', - ]) + 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']) - await createPlugin({}).configResolved({ root: '/p' }) + await createPlugin({ include: ['@acme/ui'] }).configResolved({ + root: '/p', + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/src/a.tsx': 0, - '/p/src/m.tsx': 1, - '/p/src/z.tsx': 2, + expect(collectSpy).toHaveBeenCalledWith({ + roots: [resolve('/p', 'src'), resolve('/p', 'app')], + includeMdx: ['.mdx'], + include: ['@acme/ui'], + cwd: '/p', + needles: expect.arrayContaining([ + '@devup-ui/react', + '@stylexjs/stylex', + ]), }) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/src/b.tsx', + ]) }) - it('normalizes windows separators to match vite module ids', 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(['C:\\p\\src\\a.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({ 'C:/p/src/a.tsx': 0 }) + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) }) - // 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 () => { - onlyDirs('src') - listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) - exportFileMapSpy.mockReturnValue( - '{"/p/src/a.tsx":0,"/monorepo/packages/ui/X.tsx":1}', - ) - + it('leaves numbering alone when there is nothing to number', async () => { + onlyDirs() + collectSpy.mockReturnValue([]) await createPlugin({}).configResolved({ root: '/p' }) + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) + + it('keeps building when the scan fails', async () => { + onlyDirs('src') + collectSpy.mockImplementation(() => { + throw new Error('scan boom') + }) - expect(importFileMapSpy).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() + } }) - 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'], - ) + describe('source collection before seeding', () => { + let listSourceFilesSpy: ReturnType + let readFileSyncSpy: ReturnType - await createPlugin({}).configResolved({ root: '/p' }) + beforeEach(() => { + collectSpy.mockImplementation(collectNumberedFiles) + listSourceFilesSpy = spyOn(pluginUtils, 'listSourceFiles') + readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( + "import { Box } from '@devup-ui/react'", + ) + }) - expect(importFileMapSpy).toHaveBeenCalledWith({ - '/p/app/page.tsx': 0, - '/p/shared.tsx': 1, - '/p/src/b.tsx': 2, + afterEach(() => { + listSourceFilesSpy.mockRestore() + readFileSyncSpy.mockRestore() }) - }) - 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() - await createPlugin({}).configResolved({ root: '/p' }) - expect(importFileMapSpy).not.toHaveBeenCalled() - }) + it('numbers files by sorted path, not by transform arrival order', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue([ + '/p/src/z.tsx', + '/p/src/a.tsx', + '/p/src/m.tsx', + ]) - it('keeps building when the scan fails', async () => { - onlyDirs('src') - listSourceFilesSpy.mockImplementation(() => { - throw new Error('scan boom') + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/src/a.tsx', + '/p/src/m.tsx', + '/p/src/z.tsx', + ]) }) - await createPlugin({}).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(seedFileMapSpy).toHaveBeenCalledWith(['C:/p/src/a.tsx']) + }) - expect(importFileMapSpy).not.toHaveBeenCalled() + it('leaves an already-populated map alone on a second configResolved', async () => { + onlyDirs('src') + listSourceFilesSpy.mockReturnValue(['/p/src/a.tsx']) + const previousMap = wasm.exportFileMap() + const existingMap = { + '/p/src/a.tsx': 0, + '/monorepo/packages/ui/X.tsx': 1, + } + seedFileMapSpy.mockImplementation(seedFileMap) + const plugin = createPlugin({}) + wasm.importFileMap(existingMap) + try { + await plugin.configResolved({ root: '/p' }) + await plugin.configResolved({ root: '/p' }) + + expect(seedFileMapSpy).toHaveBeenCalledTimes(2) + expect(JSON.parse(wasm.exportFileMap())).toEqual(existingMap) + } finally { + wasm.importFileMap(JSON.parse(previousMap)) + } + }) + + 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'], + ) + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'src'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(listSourceFilesSpy).toHaveBeenCalledWith( + resolve('/p', 'app'), + undefined, + { includeMdx: ['.mdx'] }, + ) + expect(seedFileMapSpy).toHaveBeenCalledWith([ + '/p/app/page.tsx', + '/p/shared.tsx', + '/p/src/b.tsx', + ]) + }) + + 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() + + await createPlugin({}).configResolved({ root: '/p' }) + + expect(seedFileMapSpy).not.toHaveBeenCalled() + }) }) }) @@ -706,6 +995,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.each([ { label: 'per-file css', @@ -894,6 +1200,146 @@ describe('devupUIVitePlugin', () => { expect(serverBundle['base.css'].fileName).toBe('base.css') }) + + it('does not forward server css that the client already emits', async () => { + const [plugin, restore] = createPlugins({}) + const base = asset('base.css', 'devup-ui.css') + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', [ + 'base.css', + 'file.css', + 'server-only.css', + ]) + const serverBundle: Bundle = { + 'base.css': base, + 'file.css': file, + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'entry.js': entry, + } + const clientBase = asset('base.css', 'devup-ui.css') + const clientFile = asset('file.css', 'devup-ui-3.css') + const clientBundle: Bundle = { + 'base.css': clientBase, + 'file.css': clientFile, + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + + expect(base.source).toEqual('base sheet') + expect(file.source).toEqual('file sheet') + expect(clientBase.source).toEqual('base sheet') + expect(clientFile.source).toEqual('file sheet') + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + expect(forwarded.emitted).toEqual([ + 'base.css.devup-forwarded', + 'file.css.devup-forwarded', + 'server-only.css', + ]) + for (const name of forwarded.emitted) { + clientBundle[name] = asset(name, name) + } + restore.generateBundle.handler({}, clientBundle) + + expect(base.fileName).toBe('base.css') + expect(file.fileName).toBe('file.css') + expect(clientBundle['base.css']).toBe(clientBase) + expect(clientBundle['file.css']).toBe(clientFile) + expect(Object.keys(clientBundle).sort()).toEqual([ + 'base.css', + 'file.css', + 'server-only.css', + ]) + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['base.css', 'file.css', 'server-only.css']), + ) + }) + + it('ignores no-write analysis bundles when tracking server css', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('file.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['file.css']) + const serverBundle: Bundle = { + 'file.css': file, + 'entry.js': entry, + } + await plugin.configResolved(rscConfig({})) + await plugin.generateBundle.call( + { + environment: { + name: 'rsc', + config: { consumer: 'server', build: { write: false } }, + }, + }, + {}, + serverBundle, + ) + const clientBundle: Bundle = { + 'file.css': asset('file.css', 'devup-ui-3.css'), + } + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual(new Set(['file.css'])) + expect(file.fileName).toBe('file.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle)).toEqual(['file.css']) + }) + + it('keeps server forwarding for a different output file name', async () => { + const [plugin, restore] = createPlugins({}) + const file = asset('devup-ui-3.server.css', 'devup-ui-3.css') + const entry = chunk('entry.js', ['devup-ui-3.server.css']) + const serverBundle: Bundle = { + 'devup-ui-3.server.css': file, + 'entry.js': entry, + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const forwarded = forwardLikePluginRsc(serverBundle) + for (const name of forwarded.emitted) clientBundle[name] = file + restore.generateBundle.handler({}, clientBundle) + + expect(entry.viteMetadata?.importedCss).toEqual( + new Set(['devup-ui-3.server.css']), + ) + expect(forwarded.emitted).toEqual(['devup-ui-3.server.css']) + expect(file.fileName).toBe('devup-ui-3.server.css') + expect(file.source).toBe('file sheet') + expect(Object.keys(clientBundle).sort()).toEqual([ + 'devup-ui-3.client.css', + 'devup-ui-3.server.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('resolves a stable id during build', async () => { @@ -951,8 +1397,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')) @@ -963,7 +1409,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', @@ -974,7 +1420,7 @@ describe('devupUIVitePlugin', () => { 'DevupTheme', ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -994,7 +1440,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) @@ -1002,10 +1448,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', ) @@ -1019,7 +1465,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', ) @@ -1048,7 +1494,7 @@ describe('devupUIVitePlugin', () => { }) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1080,7 +1526,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', ) @@ -1111,7 +1557,7 @@ describe('devupUIVitePlugin', () => { ) expect(writeFileSpy).toHaveBeenCalledWith( - join('df', 'theme.d.ts'), + resolve('df', 'theme.d.ts'), 'interface code', 'utf-8', ) @@ -1201,7 +1647,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', () => { @@ -1218,7 +1667,7 @@ describe('devupUIVitePlugin', () => { }), )('should transform', async (options) => { getCssSpy.mockReturnValue('css code') - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css: 'css code', code: 'code', @@ -1252,7 +1701,7 @@ describe('devupUIVitePlugin', () => { ), ).toEqual({ code: 'code' }) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css: 'css code test next', code: 'code', @@ -1275,7 +1724,7 @@ describe('devupUIVitePlugin', () => { } expect(await plugin.load('devup-ui.css')).toEqual(expect.any(String)) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css: 'long css code', code: 'long code', @@ -1326,7 +1775,7 @@ describe('devupUIVitePlugin', () => { { const plugin = createPlugin({ - cssDir: '', + cssDir: '.', }) expect(plugin.resolveId('devup-ui.css')).toEqual(expect.any(String)) } @@ -1358,7 +1807,7 @@ describe('devupUIVitePlugin', () => { it('sholud add relative path to css file', async () => { getCssSpy.mockReturnValue('css code') - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css: 'css code', code: 'code', @@ -1408,7 +1857,7 @@ describe('devupUIVitePlugin', () => { it('should not create css file when cssFile is empty', async () => { getCssSpy.mockReturnValue('css code') - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css: 'css code', code: 'code', @@ -1429,7 +1878,7 @@ describe('devupUIVitePlugin', () => { const plugin = createPlugin({}) const sheet = join(resolve('df', 'devup-ui'), 'devup-ui-3.css') const transformWith = (css: string | undefined) => { - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ css, cssFile: 'devup-ui-3.css' }), ) return plugin.transform('code', 'foo.tsx') @@ -1451,7 +1900,7 @@ describe('devupUIVitePlugin', () => { it('writes the base sheet only when it changes', async () => { const plugin = createPlugin({}) const baseSheet = join(resolve('df', 'devup-ui'), 'devup-ui.css') - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ cssFile: '', updatedBaseStyle: true }), ) @@ -1573,7 +2022,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)] }), ) }) @@ -1587,7 +2036,7 @@ describe('devupUIVitePlugin atom hoisting', () => { await runConfigResolved({ atomHoist: 2 }, { root: '/p' }) expect(computeFileReachSpy).toHaveBeenCalledWith( - expect.objectContaining({ srcDir: resolve('/p', 'src') }), + expect.objectContaining({ srcDir: [] }), ) }) @@ -1639,11 +2088,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() }) @@ -1663,7 +2114,7 @@ describe('module resolver', () => { wasm, 'setModuleResolver', ).mockReturnValue(undefined) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ dependencies: ['/p/src/tokens.ts'] }), ) const plugin = createPlugin({}) diff --git a/packages/vite-plugin/src/__tests__/production-fixture.ts b/packages/vite-plugin/src/__tests__/production-fixture.ts new file mode 100644 index 000000000..92f2c4573 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/production-fixture.ts @@ -0,0 +1,256 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { build, type Plugin, resolveConfig, type UserConfig } from 'vite' + +import { DevupUI } from '../plugin' +import { productionEngine } from '../production-engine' +import { ProductionActivation } from '../production-numbering' + +export function extractOwned(color: string, id = 'owned.tsx') { + wasm.registerTheme({}) + wasm.registerShorthands({}) + wasm.setDebug(false) + wasm.setPrefix(null) + wasm.setModuleResolver(undefined) + const output = wasm.codeExtract( + id, + `import {Box} from '@devup-ui/react'; export const view=;`, + '@devup-ui/react', + 'df', + false, + false, + false, + {}, + ) + try { + return { + css: output.css ?? '', + state: productionEngine.capture(), + } + } finally { + output.free() + } +} + +export function nativeCompletionDeadline( + reject: (reason: Error) => void, + milliseconds = 30000, +): ReturnType { + return setTimeout( + reject, + milliseconds, + new RangeError('Native watch completion deadline'), + ) +} + +export function nativeCompletions(watcher: { + on( + event: 'event', + listener: (event: { code: string; error?: unknown }) => void, + ): unknown +}) { + const events: { code: string; error?: unknown }[] = [] + let wake: (() => void) | undefined + watcher.on('event', (event) => { + if (event.code !== 'BUNDLE_END' && event.code !== 'ERROR') return + events.push(event) + wake?.() + }) + return async function next(): Promise { + let timer: ReturnType | undefined + try { + if (events.length === 0) + await new Promise((resolve, reject) => { + wake = resolve + timer = nativeCompletionDeadline(reject) + }) + const event = events.shift() + if (event?.code === 'ERROR') throw event.error + } finally { + clearTimeout(timer) + wake = undefined + } + } +} + +export async function productionFixture() { + const directory = ( + await realpath(await mkdtemp(join(tmpdir(), 'devup-v1-native-'))) + ).replaceAll('\\', '/') + const root = `${directory}/app` + async function file(path: string, code: string) { + const target = join(directory, path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, code) + return target.replaceAll('\\', '/') + } + await file('app/package.json', '{"name":"v1-native","type":"module"}') + await file('app/tsconfig.json', '{}') + async function activation() { + const owner = new ProductionActivation( + { + package: '@devup-ui/react', + devupFile: 'devup.json', + distDir: 'df', + cssDir: undefined, + extractCss: true, + debug: false, + include: [], + singleCss: false, + prefix: undefined, + shorthands: undefined, + sourceDirs: undefined, + mdxExtensions: ['.mdx'], + atomHoist: undefined, + importAliases: {}, + }, + () => {}, + ) + owner.configure( + await resolveConfig( + { + root, + configFile: false, + logLevel: 'silent', + build: { + watch: {}, + lib: { entry: `${root}/src/main.js`, formats: ['es'] }, + }, + }, + 'build', + ), + ) + return owner + } + async function run( + singleCss: boolean, + plugins: readonly Plugin[] = [], + options: UserConfig = {}, + ) { + const result = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: options.plugins ?? [DevupUI({ singleCss }), ...plugins], + build: { + write: false, + lib: { entry: join(root, 'src/main.js'), formats: ['es'] }, + }, + ...options, + }) + return (Array.isArray(result) ? result : [result]).flatMap((bundle) => { + if (!('output' in bundle)) + throw new TypeError('Expected completed native output') + return bundle.output + }) + } + async function learned() { + const path = join(root, 'df/numbering') + const ids = new Set() + for (const name of await readdir(path)) { + const list: unknown = JSON.parse(await readFile(join(path, name), 'utf8')) + if (!Array.isArray(list)) throw new TypeError('Expected ID list') + for (const id of list) { + if (typeof id !== 'string') throw new TypeError('Expected string ID') + ids.add(id) + } + } + return [...ids].sort() + } + async function storeBytes() { + const path = join(root, 'df/numbering') + const entries = await readdir(path) + return Object.fromEntries( + await Promise.all( + entries.map(async (name) => [ + name, + await readFile(join(path, name), 'utf8'), + ]), + ), + ) + } + async function watchGeneration(singleCss: boolean) { + const unrelated = await file( + 'app/src/unrelated.js', + 'export const value=1;', + ) + await file( + 'app/index.html', + '', + ) + await file( + 'app/src/main.js', + "import {cls} from 'virtual-style'; import {value} from './unrelated.js'; globalThis.nativeStyle=cls; globalThis.nativeValue=value;", + ) + const observations: { + readonly files: string + readonly code: string + readonly css: string + }[] = [] + const watcher = await build({ + root, + configFile: false, + logLevel: 'silent', + build: { watch: {}, write: false }, + plugins: [ + DevupUI({ singleCss }), + { + name: 'watch-generation-provider', + resolveId(id) { + if (id === 'virtual-style') return '\0native:watch-style.js' + }, + load(id) { + if (id === '\0native:watch-style.js') + return "import {css} from '@devup-ui/react'; export const cls=css({background:'red'});" + }, + generateBundle(_options, bundle) { + observations.push({ + files: wasm.exportFileMap(), + code: this.getModuleInfo(unrelated)?.code ?? '', + css: Object.values(bundle) + .filter((output) => output.type === 'asset') + .map((output) => output.source) + .join(''), + }) + }, + }, + ], + }) + if (Array.isArray(watcher) || !('on' in watcher)) + throw new TypeError('Expected native watcher') + const next = nativeCompletions(watcher) + try { + await next() + const first = observations.at(-1) + await writeFile(unrelated, 'export const value=2;') + await next() + while (!/value\s*=\s*2/.test(observations.at(-1)?.code ?? '')) + await next() + return { first, second: observations.at(-1), ids: await learned() } + } finally { + await watcher.close() + } + } + return { + directory, + root, + file, + run, + learned, + storeBytes, + activation, + watchGeneration, + close: () => rm(directory, { recursive: true, force: true }), + } +} diff --git a/packages/vite-plugin/src/__tests__/production-manifest.test.ts b/packages/vite-plugin/src/__tests__/production-manifest.test.ts new file mode 100644 index 000000000..75d19e5b1 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/production-manifest.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from 'bun:test' +import { resolveConfig } from 'vite' + +import { + prepareProductionManifest, + type ProductionOptions, +} from '../production-manifest' +import { productionFixture } from './production-fixture' + +it.each([false, true])( + 'unions real condition branches and positive hoist routes for native entry records=%s', + async (record) => { + // Given two actual routes and different physical package condition targets. + const fixture = await productionFixture() + const opaque = 'virtual:manifest.js?variant=1' + const shared = await fixture.file( + 'app/src/shared.js', + 'export const value=1;', + ) + const a = await fixture.file( + 'app/src/a.js', + "export {value} from './shared.js'; export {branch} from 'condition-branch';", + ) + const b = await fixture.file( + 'app/src/b.js', + "export {value} from './shared.js';", + ) + await fixture.file( + 'app/node_modules/condition-branch/package.json', + '{"name":"condition-branch","exports":{"browser":"./browser.js","node":"./node.js","default":"./node.js"}}', + ) + const browserFile = await fixture.file( + 'app/node_modules/condition-branch/browser.js', + 'export const branch="browser";', + ) + const nodeFile = await fixture.file( + 'app/node_modules/condition-branch/node.js', + 'export const branch="node";', + ) + const options: ProductionOptions = { + package: '@devup-ui/react', + devupFile: 'devup.json', + distDir: 'df', + cssDir: undefined, + extractCss: true, + debug: false, + include: [], + singleCss: false, + prefix: undefined, + shorthands: undefined, + sourceDirs: undefined, + mdxExtensions: ['.mdx'], + atomHoist: 2, + importAliases: {}, + } + try { + const config = await resolveConfig( + { + root: fixture.root, + configFile: false, + logLevel: 'silent', + environments: { + browser: { + consumer: 'client', + resolve: { conditions: ['browser'] }, + }, + server: { consumer: 'server', resolve: { conditions: ['node'] } }, + }, + build: { + lib: { + entry: record ? { a, b, opaque } : [a, b, opaque], + formats: ['es'], + }, + }, + }, + 'build', + ) + // When the real manifest closes independent context graphs before allocation. + const manifest = await prepareProductionManifest( + config, + options, + () => {}, + ) + // Then entry forms select distinct physical branches and retain a two-route hoist plan. + const browser = manifest.contexts.find( + (context) => context.context === 'browser', + ) + const server = manifest.contexts.find( + (context) => context.context === 'server', + ) + expect(browser?.scan).toContain(browserFile) + expect(browser?.scan).not.toContain(nodeFile) + expect(server?.scan).toContain(nodeFile) + expect(server?.scan).not.toContain(browserFile) + expect(browser?.threshold).toBe(2) + expect(browser?.scan).toContain('virtual:manifest.js') + expect(server?.scan).toContain('virtual:manifest.js') + expect(browser?.routes[shared]).toHaveLength(2) + } finally { + await fixture.close() + } + }, +) + +it('reserves all configured contexts while keeping scan authority separate from learned IDs', async () => { + // Given an ordinary entry, plain source, selected Markdown and resolved independent environments. + const fixture = await productionFixture() + const entry = await fixture.file('app/src/main.js', 'export const value=1;') + const plain = await fixture.file( + 'app/src/plain.test.ts', + 'export const plain=1;', + ) + const markdown = await fixture.file( + 'app/src/docs.mdx', + '# Reserved documentation', + ) + const options: ProductionOptions = { + package: '@devup-ui/react', + devupFile: 'devup.json', + distDir: 'df', + cssDir: undefined, + extractCss: true, + debug: false, + include: [], + singleCss: false, + prefix: undefined, + shorthands: undefined, + sourceDirs: undefined, + mdxExtensions: ['.mdx'], + atomHoist: undefined, + importAliases: {}, + } + try { + const config = await resolveConfig( + { + root: fixture.root, + configFile: false, + logLevel: 'silent', + environments: { + browser: { + consumer: 'client', + resolve: { conditions: ['browser', 'production'] }, + }, + server: { + consumer: 'server', + resolve: { conditions: ['node', 'react-server'] }, + }, + }, + build: { lib: { entry, formats: ['es'] } }, + }, + 'build', + ) + // When allocation-inert reservation closes every final context. + const manifest = await prepareProductionManifest(config, options, () => {}) + // Then plain/test/Markdown physical IDs are reserved in each named context with independent conditions. + const browser = manifest.contexts.find( + (context) => context.context === 'browser', + ) + const server = manifest.contexts.find( + (context) => context.context === 'server', + ) + expect(browser?.scan).toEqual( + expect.arrayContaining([entry, plain, markdown]), + ) + expect(server?.scan).toEqual( + expect.arrayContaining([entry, plain, markdown]), + ) + expect(browser?.conditions).toContain('browser') + expect(server?.conditions).toContain('react-server') + expect(browser?.learned).toEqual([]) + expect(browser?.canonical).toEqual({}) + expect(browser?.routes).toEqual({}) + } finally { + await fixture.close() + } +}) diff --git a/packages/vite-plugin/src/__tests__/production-numbering.test.ts b/packages/vite-plugin/src/__tests__/production-numbering.test.ts new file mode 100644 index 000000000..1497efef6 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/production-numbering.test.ts @@ -0,0 +1,262 @@ +import { readFile, rm } from 'node:fs/promises' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import { createBuilder, type UserConfig } from 'vite' + +import { DevupUI } from '../plugin' +import { extractOwned, productionFixture } from './production-fixture' + +it.each(['operation', 'native-input'])( + 'starts a fresh generation after %s failure (UNIT)', + async (failure) => { + // Given previously successful extraction and additional failed-interval state. + const fixture = await productionFixture() + await fixture.file('app/src/main.js', 'export const value=1;') + const activation = await fixture.activation() + await activation.prepare(() => {}) + const graph = { getModuleIds: () => [], getModuleInfo: () => null } + const key = activation.context + try { + activation.start(key) + activation.run(key, () => extractOwned('blue', 'previous.tsx')) + await activation.finish(key, graph) + activation.close(key) + const before = await fixture.learned() + const modules = activation.modules(key) + activation.start(key) + activation.run(key, () => extractOwned('green', 'failed.tsx')) + if (failure === 'operation') + expect(() => + activation.run(key, () => { + throw new TypeError('input fault') + }), + ).toThrow(TypeError) + await activation.finish(key, graph, new TypeError('native input fault')) + expect(await fixture.learned()).toEqual(before) + await expect(activation.prepare(() => {})).rejects.toMatchObject({ + name: 'ClosedBuildGenerationError', + }) + // When the next native input boundary prepares a fresh owner. + await activation.prepare( + () => {}, + () => {}, + ) + activation.start(activation.context) + const recovered = activation.run(activation.context, () => + extractOwned('red', 'recovered.tsx'), + ) + // Then neither prior successful nor partial failed snapshots are restored. + expect(activation.modules(activation.context)).not.toBe(modules) + expect(recovered.state.files).not.toContain('previous.tsx') + expect(recovered.state.files).not.toContain('failed.tsx') + expect(recovered.css).toContain('background:red') + activation.dispose() + await expect( + activation.prepare( + () => {}, + () => {}, + ), + ).rejects.toMatchObject({ name: 'ClosedBuildGenerationError' }) + } finally { + activation.dispose() + await fixture.close() + } + }, +) + +it.each([false, true])( + 'materializes only the fresh root when the SAME tuple is reused with singleCss=%s', + async (singleCss) => { + // Given a completed ordinary native A build and a different fresh root B. + const first = await productionFixture() + const second = await productionFixture() + const tuple = DevupUI({ singleCss }) + await first.file( + 'app/src/main.js', + "import {css} from '@devup-ui/react'; export const cls=css({background:'red'});", + ) + await second.file( + 'app/src/main.js', + "import {css} from '@devup-ui/react'; export const cls=css({background:'blue'});", + ) + try { + const a = await first.run(singleCss, [], { plugins: [tuple] }) + expect( + a + .filter((output) => output.type === 'asset') + .map((output) => output.source) + .join(''), + ).toContain('background:red') + const before = await first.learned() + // When the public native build API resolves and builds B using those exact plugin objects. + const b = await second.run(singleCss, [], { plugins: [tuple] }) + // Then B has its own linked CSS, materialization and ID-only stores, with no A bleed. + const css = b + .filter((output) => output.type === 'asset') + .map((output) => output.source) + .join('') + const js = b + .filter((output) => output.type === 'chunk') + .map((output) => output.code) + .join('') + console.info( + JSON.stringify({ case: 'same-tuple-fresh-root', singleCss, css, js }), + ) + const rule = + /\.([\w-]+)\s*\{[^}]*background\s*:\s*(?:blue|#00f|#0000ff)(?=[;}\s])/.exec( + css, + ) + expect(rule?.[1]).toBeDefined() + expect(js).toContain(rule?.[1] ?? 'missing-class') + expect(css).not.toContain('background:red') + expect(js).not.toContain('@devup-ui/react') + await readFile(`${second.root}/df/theme.d.ts`) + await readFile(`${second.root}/df/compat.d.ts`) + expect( + (await second.learned()).every((id) => !id.includes(first.root)), + ).toBe(true) + expect(await first.learned()).toEqual(before) + const files: unknown = JSON.parse(wasm.exportFileMap()) + expect(files).toEqual( + expect.objectContaining({ + [`${second.root}/src/main.js`]: expect.any(Number), + }), + ) + expect(JSON.stringify(files)).not.toContain(first.root) + } finally { + await first.close() + await second.close() + } + }, + 60000, +) + +it.each([false, true])( + 'keeps watch numbering after a native bundle closes with singleCss=%s', + async (singleCss) => { + // Given an actual default watch build with a scan-missed opaque styling source. + const fixture = await productionFixture() + try { + // When native watch rebuilds a changed unrelated branch after the first bundle closes. + const { first, second, ids } = await fixture.watchGeneration(singleCss) + console.info( + JSON.stringify({ case: 'same-tuple-watch', singleCss, first, second }), + ) + // Then the original append-only map and stylesheet survive without fresh-generation reseeding. + expect(ids).toContain('\0native:watch-style.js') + expect(first?.css).toContain('background:red') + expect(second?.files).toBe(first?.files) + expect(second?.css).toBe(first?.css) + expect(second?.code).toMatch(/value\s*=\s*2/) + } finally { + await fixture.close() + } + }, + 60000, +) + +it.each([false, true])( + 'rematerializes a clean same-root native generation with the SAME config and tuple with singleCss=%s', + async (singleCss) => { + // Given a completed build whose generated directory is removed by its owning fixture. + const fixture = await productionFixture() + const tuple = DevupUI({ singleCss }) + const options: UserConfig = { plugins: [tuple] } + await fixture.file( + 'app/src/main.js', + "import {css} from '@devup-ui/react'; export const cls=css({background:'red'});", + ) + try { + const first = await fixture.run(singleCss, [], options) + const learned = await fixture.learned() + await rm(`${fixture.root}/df`, { recursive: true }) + // When another ordinary native build resolves the same inputs and plugin tuple. + const second = await fixture.run(singleCss, [], options) + // Then actual native output is unchanged and all generated files are recreated. + expect( + second.map((output) => + output.type === 'asset' ? output.source : output.code, + ), + ).toEqual( + first.map((output) => + output.type === 'asset' ? output.source : output.code, + ), + ) + await readFile(`${fixture.root}/df/theme.d.ts`) + await readFile(`${fixture.root}/df/compat.d.ts`) + expect(await fixture.learned()).toEqual(learned) + } finally { + await fixture.close() + } + }, + 60000, +) + +it.each([false, true])( + 'retains the shared native builder union across sibling closes with singleCss=%s', + async (singleCss) => { + // Given separately resolved native sibling configurations sharing the SAME tuple. + const fixture = await productionFixture() + const first = await fixture.file( + 'app/src/main.js', + "import {css} from '@devup-ui/react'; export const cls=css({background:'red'});", + ) + const second = await fixture.file( + 'app/src/sibling.js', + "import {css} from '@devup-ui/react'; export const cls=css({background:'blue'});", + ) + const tuple = DevupUI({ singleCss }) + const maps = new Map() + try { + const builder = await createBuilder({ + root: fixture.root, + configFile: false, + logLevel: 'silent', + plugins: [ + tuple, + { + name: 'observe-shared-native-map', + generateBundle() { + maps.set(this.environment.name, wasm.exportFileMap()) + }, + }, + ], + environments: { + client: { build: { lib: { entry: first, formats: ['es'] } } }, + sibling: { + consumer: 'client', + build: { lib: { entry: second, formats: ['es'] } }, + }, + }, + build: { write: false, lib: { entry: first, formats: ['es'] } }, + builder: { + async buildApp(builder) { + const { client, sibling } = builder.environments + if (client === undefined || sibling === undefined) + throw new TypeError('Expected native siblings') + await builder.build(client) + await builder.build(sibling) + }, + }, + }) + // When public buildApp builds both siblings sequentially, closing the first before the second. + await builder.buildApp() + // Then both completed graphs retain the same union numbers, never a per-sibling reset. + expect(maps.size).toBe(2) + expect(maps.get('sibling')).toBe(maps.get('client')) + for (const map of maps.values()) { + const files: unknown = JSON.parse(map) + expect(files).toEqual( + expect.objectContaining({ + [first]: expect.any(Number), + [second]: expect.any(Number), + }), + ) + } + } finally { + await fixture.close() + } + }, + 60000, +) diff --git a/packages/vite-plugin/src/__tests__/production-rewrite-native.test.ts b/packages/vite-plugin/src/__tests__/production-rewrite-native.test.ts new file mode 100644 index 000000000..44e0c8fb0 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/production-rewrite-native.test.ts @@ -0,0 +1,142 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import { createNonphysicalIdStore } from '@devup-ui/plugin-utils' +import { expect, it, spyOn } from 'bun:test' +import type { Plugin } from 'vite' + +import { DevupUI } from '../plugin' +import { ProductionGeneration } from '../production-engine' +import { productionFixture } from './production-fixture' + +it('propagates native rewrite failure when the numbering path is obstructed after preparation', async () => { + // Given an ordinary prepared client and a genuine opaque styling source. + const fixture = await productionFixture() + const opaque = '\0native:rewrite-style.js' + const { filePath } = createNonphysicalIdStore({ + integration: 'Vite', + resolvedRoot: fixture.root, + contextKey: 'client', + distDir: `${fixture.root}/df`, + }) + let expectedCode: string | undefined + let extracted: string | undefined + let rendered = false + const events: string[] = [] + const incoming: (Error | undefined)[] = [] + const finishing: ProductionGeneration[] = [] + const failed: ProductionGeneration[] = [] + const restores: (() => void)[] = [] + let rewriteFailure: Error | undefined + let finishFailure: Error | undefined + const provider: Plugin = { + name: 'native-rewrite-obstruction', + enforce: 'post', + resolveId(id) { + if (id === 'virtual-style') return opaque + }, + async load(id) { + if (id !== opaque) return + await readFile(`${fixture.root}/df/theme.d.ts`, 'utf8') + await rm(dirname(filePath), { recursive: true, force: true }) + await writeFile(dirname(filePath), 'not a directory') + try { + await mkdir(dirname(filePath), { recursive: true }) + } catch (error) { + if (!(error instanceof Error) || !('code' in error)) throw error + if (typeof error.code !== 'string') throw error + expectedCode = error.code + } + if (expectedCode === undefined) + throw new TypeError( + 'Expected independent numbering-store mkdir failure', + ) + return "import {css} from '@devup-ui/react'; export const cls=css({color:'red'});" + }, + transform(code, id) { + if (id === opaque) extracted = code + }, + renderStart() { + rendered = true + }, + } + try { + await fixture.file('app/src/main.js', "export {cls} from 'virtual-style';") + const finish = ProductionGeneration.prototype.finish + const fail = ProductionGeneration.prototype.fail + const finishSpy = spyOn( + ProductionGeneration.prototype, + 'finish', + ).mockImplementation(async function (this: ProductionGeneration, ...args) { + finishing.push(this) + incoming.push(args[2]) + events.push('finish') + const store = this.runtime(args[0]).plan.store + const rewrite = store.rewrite + const rewriteSpy = spyOn(store, 'rewrite').mockImplementation( + async (...ids) => { + try { + return await rewrite.apply(store, ids) + } catch (error) { + if (!(error instanceof Error)) throw error + rewriteFailure = error + events.push('rewrite-rejected') + throw error + } + }, + ) + restores.push(() => rewriteSpy.mockRestore()) + try { + return await finish.apply(this, args) + } catch (error) { + if (!(error instanceof Error)) throw error + finishFailure = error + events.push('finish-rejected') + throw error + } + }) + restores.push(() => finishSpy.mockRestore()) + const failSpy = spyOn( + ProductionGeneration.prototype, + 'fail', + ).mockImplementation(function (this: ProductionGeneration) { + failed.push(this) + events.push('fail') + return fail.call(this) + }) + restores.push(() => failSpy.mockRestore()) + // When native input completes and its normal buildEnd rewrites the store. + let failure: Error | undefined + try { + await fixture.run(false, [], { + plugins: [DevupUI({ singleCss: false }), provider], + }) + } catch (error) { + if (!(error instanceof Error)) throw error + failure = error + } + // Then real extraction precedes the propagated OS fault, never output rendering. + expect(extracted).toMatch(/\bcls\s*=\s*['"][^'"]+['"]/) + expect(extracted).not.toContain('@devup-ui/react') + expect(expectedCode).toBeDefined() + expect(failure).toBeInstanceOf(Error) + expect(failure?.message).toContain(`${expectedCode}:`) + expect(failure?.message).toContain('mkdir') + expect(rendered).toBe(false) + expect(incoming).toEqual([undefined]) + expect(finishing).toHaveLength(1) + expect(failed).toHaveLength(1) + expect(failed[0]).toBe(finishing[0]) + expect(rewriteFailure).toBeInstanceOf(Error) + expect(finishFailure).toBe(rewriteFailure) + expect(events).toEqual([ + 'finish', + 'rewrite-rejected', + 'fail', + 'finish-rejected', + ]) + } finally { + for (const restore of restores.reverse()) restore() + await fixture.close() + } +}) 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 000000000..82be99235 --- /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 000000000..55b3817ae --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts @@ -0,0 +1,130 @@ +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 + const readiness = new AbortController() + let readyTimeout: ReturnType | undefined + let updateTimeout: ReturnType | 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) { + readyTimeout = setTimeout(() => readiness.abort(), 10000) + ready = once(current.watcher, 'ready', { + signal: readiness.signal, + }) + void ready.then( + () => clearTimeout(readyTimeout), + () => clearTimeout(readyTimeout), + ) + 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) + try { + await Promise.race([ + added, + new Promise((_, reject) => { + updateTimeout = setTimeout( + () => reject(new Error('No client candidate add event')), + 10000, + ) + updateTimeout.unref() + }), + ]) + } finally { + clearTimeout(updateTimeout) + } + 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 { + clearTimeout(readyTimeout) + clearTimeout(updateTimeout) + readiness.abort() + await Promise.allSettled([ready]) + await server?.close() + await rm(fixture, { recursive: true, force: true }) + } +}, 30000) 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 000000000..39a6a9e6b --- /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__/resolution-watch.test.ts b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts new file mode 100644 index 000000000..6fd6936ad --- /dev/null +++ b/packages/vite-plugin/src/__tests__/resolution-watch.test.ts @@ -0,0 +1,233 @@ +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 readiness = new AbortController() + let readyTimeout: ReturnType | undefined + let updateTimeout: ReturnType | 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) { + readyTimeout = setTimeout(() => readiness.abort(), 10000) + ready = once(current.watcher, 'ready', { + signal: readiness.signal, + }) + void ready.then( + () => clearTimeout(readyTimeout), + () => clearTimeout(readyTimeout), + ) + }, + 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) + try { + await Promise.race([ + update, + new Promise((_, reject) => { + updateTimeout = setTimeout( + () => reject(new Error('No manifest hotUpdate')), + 10000, + ) + updateTimeout.unref() + }), + ]) + } finally { + clearTimeout(updateTimeout) + } + 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 { + clearTimeout(readyTimeout) + clearTimeout(updateTimeout) + readiness.abort() + await Promise.allSettled([ready]) + 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 + const readiness = new AbortController() + let readyTimeout: ReturnType | undefined + let updateTimeout: ReturnType | 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) { + readyTimeout = setTimeout(() => readiness.abort(), 10000) + ready = once(current.watcher, 'ready', { + signal: readiness.signal, + }) + void ready.then( + () => clearTimeout(readyTimeout), + () => clearTimeout(readyTimeout), + ) + 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) + try { + await Promise.race([ + added, + new Promise((_, reject) => { + updateTimeout = setTimeout( + () => reject(new Error('Missing outside-root add event')), + 10000, + ) + updateTimeout.unref() + }), + ]) + } finally { + clearTimeout(updateTimeout) + } + 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 { + clearTimeout(readyTimeout) + clearTimeout(updateTimeout) + readiness.abort() + await Promise.allSettled([ready]) + 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 000000000..74ae0de88 --- /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 000000000..5d496627a --- /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/__tests__/wasm-output-disposal.test.ts b/packages/vite-plugin/src/__tests__/wasm-output-disposal.test.ts new file mode 100644 index 000000000..bbda53241 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/wasm-output-disposal.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +import { captureProcess } from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' + +it('unregisters real Vite Outputs before final capture and the original late-code fence', async () => { + // Given the original real watch scenarios and an early pass-through weak observer. + const root = resolve(import.meta.dir, '../../../..') + const temporary = mkdtempSync(join(tmpdir(), 'vite-output-disposal-')) + const trace = join(temporary, 'outputs.jsonl') + const observer = join(temporary, 'observer.test.mjs') + const originalFence = ProducerProtocol.prototype.armEndFence + let held = false + try { + writeFileSync(trace, '') + writeFileSync( + observer, + ` +import { appendFileSync } from 'node:fs' +const Native = globalThis.FinalizationRegistry +const pending = new Set() +let sequence = 0 +let final = false +const emit = (record) => appendFileSync(${JSON.stringify(trace)}, JSON.stringify(record) + '\\n') +globalThis.FinalizationRegistry = new Proxy(Native, { + construct(target, args, newTarget) { + const tokens = new WeakMap() + const records = new Map() + const callback = args[0] + const registry = Reflect.construct(target, [function (...values) { + const id = records.get(values[0]) + if (id !== undefined) { + records.delete(values[0]) + emit({ action: 'finalize', id, afterFinal: final }) + } + return Reflect.apply(callback, this, values) + }], newTarget) + registry.register = new Proxy(registry.register, { + apply(method, receiver, values) { + const result = Reflect.apply(method, receiver, values) + const stack = new Error().stack + if (values[0].constructor.name === 'Output' && /bindings[\\\\/]devup-ui-wasm[\\\\/]pkg[\\\\/]index.js/.test(stack)) { + const id = ++sequence + records.set(values[1], id) + tokens.set(values[2], { id, pointer: values[1] }) + pending.add(id) + emit({ action: 'register', id, stack }) + } + return result + }, + }) + registry.unregister = new Proxy(registry.unregister, { + apply(method, receiver, values) { + const result = Reflect.apply(method, receiver, values) + const record = tokens.get(values[0]) + if (result && record) { + records.delete(record.pointer) + tokens.delete(values[0]) + pending.delete(record.id) + emit({ action: 'unregister', id: record.id, afterFinal: final }) + } + return result + }, + }) + return registry + }, +}) +process.stdout.write = new Proxy(process.stdout.write, { + apply(method, receiver, args) { + const text = String(args[0]) + if (text.startsWith(process.env.DEVUP_PRODUCER_TOKEN + ' ') && text.includes('"phase":"final"')) { + final = true + emit({ action: 'final', pending: [...pending] }) + } + return Reflect.apply(method, receiver, args) + }, +}) +`, + ) + const require = createRequire(join(root, 'package.json')) + const preloads = [ + observer, + join(root, 'bun.preload.ts'), + join(root, 'bun.setup.ts'), + require.resolve('bun-test-env-dom'), + ] + const config = join(temporary, 'bunfig.toml') + writeFileSync( + config, + readFileSync(join(root, 'test-harness/bunfig.toml'), 'utf8').replace( + /^preload = .*$/m, + `preload = ${JSON.stringify(preloads.map((path) => path.replaceAll('\\', '/')))}`, + ), + ) + ProducerProtocol.prototype.armEndFence = async function ( + captures, + preload, + ) { + await originalFence.call(this, captures, preload) + await new Promise((done) => setTimeout(done, 11000)) + held = true + } + // When native capture drives the unchanged three selected-blue CSS assertions. + const result = await captureProcess({ + root, + config, + coverage: join(temporary, 'coverage'), + group: 'runtime', + files: [ + 'packages/vite-plugin/src/__tests__/resolution-watch.test.ts', + 'packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts', + ], + executable: process.execPath, + }) + const records: { + readonly action: string + readonly id?: number + readonly pending?: readonly number[] + readonly afterFinal?: boolean + }[] = readFileSync(trace, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + const registered = records + .filter((record) => record.action === 'register') + .map((record) => record.id) + expect(result.value.status).toBe(0) + expect(result.value.output).toContain('3 pass') + expect(registered.length).toBeGreaterThan(0) + expect( + records + .filter((record) => record.action === 'unregister') + .map((record) => record.id), + ).toEqual(registered) + expect( + records + .filter((record) => record.action === 'final') + .map((record) => record.pending), + ).toEqual([[]]) + expect(records.filter((record) => record.action === 'finalize')).toEqual([]) + expect(held).toBe(true) + } finally { + ProducerProtocol.prototype.armEndFence = originalFence + console.info('WASM Output lifetime evidence', readFileSync(trace, 'utf8')) + rmSync(temporary, { recursive: true, force: true }) + } +}, 120000) diff --git a/packages/vite-plugin/src/__tests__/watch-deadline-cleanup.test.ts b/packages/vite-plugin/src/__tests__/watch-deadline-cleanup.test.ts new file mode 100644 index 000000000..219dcfae1 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/watch-deadline-cleanup.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +import { expect, it, spyOn } from 'bun:test' + +import { VerifiedSourceCoverage } from '../../../../test-harness/producer-coverage' +import { array, object } from '../../../../test-harness/producer-data' +import { + captureProcess, + isProducerResult, +} from '../../../../test-harness/producer-process' +import { ProducerProtocol } from '../../../../test-harness/producer-protocol' +import { parseTestResult, rootTestRun } from '../../../../test-harness/run' + +it.serial( + 'disposes watch observation deadlines before final native capture', + async () => { + // Given the three original real-watch cases and a pass-through native timer observer. + const root = resolve(import.meta.dir, '../../../..') + const run = rootTestRun(root) + const files = [ + 'packages/vite-plugin/src/__tests__/resolution-watch-environments.test.ts', + 'packages/vite-plugin/src/__tests__/resolution-watch.test.ts', + ] + const fixture = mkdtempSync(join(tmpdir(), 'devup-watch-deadlines-')) + const journal = join(fixture, 'timers.jsonl') + const observer = join(fixture, 'observer.test.mjs') + const config = join(fixture, 'bunfig.toml') + const original = ProducerProtocol.prototype.armEndFence + let hold = 0 + const fence = spyOn( + ProducerProtocol.prototype, + 'armEndFence', + ).mockImplementation(async function ( + this: ProducerProtocol, + captures, + preload, + ) { + await original.call(this, captures, preload) + const started = performance.now() + await delay(11000) + hold = performance.now() - started + }) + try { + writeFileSync( + observer, + `import {openSync,writeSync,closeSync} from 'node:fs' +const fd = openSync(${JSON.stringify(journal)}, 'w') +const owners = ${JSON.stringify(files.map((file) => join(root, file).replaceAll('\\', '/')))} +const pending = new Map() +const timeout = globalThis.setTimeout +const clear = globalThis.clearTimeout +const write = process.stdout.write +let final = false +let sequence = 0 +const emit = (record) => writeSync(fd, JSON.stringify(record) + '\\n') +const observedTimeout = new Proxy(timeout, { + apply(target, receiver, args) { + const [callback, duration, ...rest] = args + const stack = new Error('watch deadline registration').stack.replaceAll('\\\\', '/') + const owner = owners.find((file) => stack.includes(file + ':')) + if (!owner || typeof callback !== 'function') return Reflect.apply(target, receiver, args) + const record = {id: ++sequence, owner, duration, stack} + let timer + timer = Reflect.apply(target, receiver, [function (...values) { + pending.delete(timer) + emit({action:'fire', afterFinal:final, ...record}) + return Reflect.apply(callback, this, values) + }, duration, ...rest]) + pending.set(timer, record) + emit({action:'register', ...record}) + return timer + }, +}) +const observedClear = new Proxy(clear, { + apply(target, receiver, args) { + const record = pending.get(args[0]) + if (record) { pending.delete(args[0]); emit({action:'clear', ...record}) } + return Reflect.apply(target, receiver, args) + }, +}) +globalThis.setTimeout = observedTimeout +globalThis.clearTimeout = observedClear +process.stdout.write = new Proxy(write, { + apply(target, receiver, args) { + const chunk = String(args[0]) + if (chunk.startsWith(process.env.DEVUP_PRODUCER_TOKEN + ' ') && chunk.includes('"phase":"final"')) { + final = true + emit({action:'final', pending:[...pending.values()]}) + } + return Reflect.apply(target, receiver, args) + }, +}) +process.once('exit', () => { + if (globalThis.setTimeout === observedTimeout) globalThis.setTimeout = timeout + if (globalThis.clearTimeout === observedClear) globalThis.clearTimeout = clear + process.stdout.write = write + closeSync(fd) +}) +`, + ) + const require = createRequire(join(root, 'package.json')) + const preloads = [ + observer, + join(root, 'bun.preload.ts'), + join(root, 'bun.setup.ts'), + require.resolve('bun-test-env-dom'), + ].map((file) => file.replaceAll('\\', '/')) + writeFileSync( + config, + readFileSync(run.config, 'utf8') + .replace( + /^root = .*$/m, + `root = ${JSON.stringify(join(root, 'packages').replaceAll('\\', '/'))}`, + ) + .replace(/^preload = .*$/m, `preload = ${JSON.stringify(preloads)}`), + ) + // When the original fence completes, leave the real child awaiting ack beyond its deadlines. + const started = performance.now() + const result = await captureProcess({ + root, + config, + coverage: join(fixture, 'coverage'), + group: 'runtime', + files, + executable: process.execPath, + }) + const records = readFileSync(journal, 'utf8') + .trim() + .split('\n') + .map((line) => object(JSON.parse(line))) + const snapshots = records.filter((record) => record['action'] === 'final') + const snapshot = object(snapshots[0]) + const late = records.filter( + (record) => + record['action'] === 'fire' && record['afterFinal'] === true, + ) + const bound = VerifiedSourceCoverage.bind(result, root) + console.info( + JSON.stringify({ + case: 'watch-deadline-cleanup', + bun: result.value.bun, + revision: result.value.revision, + pid: result.value.pid, + elapsed: performance.now() - started, + hold, + records, + sources: bound.length, + status: result.value.status, + }), + ) + // Then all original blue-CSS cases pass with genuine native evidence and zero surviving deadlines. + expect(result.value.status).toBe(0) + expect(isProducerResult(result)).toBe(true) + expect(parseTestResult(result.value.output)).toEqual({ pass: 3, fail: 0 }) + expect(hold).toBeGreaterThanOrEqual(11000) + expect(snapshots).toHaveLength(1) + for (const file of files) { + expect( + records.some( + (record) => + record['action'] === 'register' && + record['owner'] === join(root, file).replaceAll('\\', '/'), + ), + ).toBe(true) + } + expect( + bound.some( + (source) => + source.file === 'packages/vite-plugin/src/resolution-watch.ts' && + source.hitFunctions > 0, + ), + ).toBe(true) + expect( + result.value.captures.some((capture) => capture.url === observer), + ).toBe(false) + expect({ pending: array(snapshot['pending']), late }).toEqual({ + pending: [], + late: [], + }) + } finally { + fence.mockRestore() + rmSync(fixture, { recursive: true, force: true }) + } + }, + 120000, +) diff --git a/packages/vite-plugin/src/aggregate-css.ts b/packages/vite-plugin/src/aggregate-css.ts new file mode 100644 index 000000000..311b8da7a --- /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 000000000..562eccfb3 --- /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/compiled-guard.ts b/packages/vite-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..d2cdfc4c4 --- /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/nonphysical-modules.ts b/packages/vite-plugin/src/nonphysical-modules.ts new file mode 100644 index 000000000..9784a6097 --- /dev/null +++ b/packages/vite-plugin/src/nonphysical-modules.ts @@ -0,0 +1,218 @@ +import { stat } from 'node:fs/promises' + +import type { + ModuleResolution, + PreparedSource, + ProductionManifestFile, + ResolvedModule, +} from '@devup-ui/plugin-utils' +import { type Output, setModuleResolver } from '@devup-ui/wasm' + +export interface NativePresence { + getModuleIds(): IterableIterator + getModuleInfo(id: string): { + readonly code: string | null + readonly isEntry: boolean + readonly importedIds: readonly string[] + readonly dynamicallyImportedIds: readonly string[] + } | null +} +type ReadySource = Exclude +export interface ExtractionRoot { + readonly nativeId: string + readonly id: string + readonly source: ReadySource +} +type Attachments = ReadonlyMap> +interface Completion { + readonly ids: readonly string[] + readonly attachments: Attachments +} + +function barePhysicalPath(id: string): string | undefined { + return !/[\0?#]/.test(id) && /^(?:\/|[a-z]:\/)/i.test(id) ? id : undefined +} + +async function exists(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + return false + throw error + } +} + +export class NonphysicalModules { + private readonly paths = new Map() + private readonly realPaths = new Map() + private readonly observed = new Set() + private readonly pending = new Map>() + private completed: Attachments = new Map() + + constructor(files: readonly ProductionManifestFile[]) { + for (const file of files) { + this.paths.set(file.id, file.path) + this.realPaths.set(file.id, file.realPath.replaceAll('\\', '/')) + } + } + + start(): void { + this.observed.clear() + this.pending.clear() + } + + observations(): readonly string[] { + return [...this.observed] + } + + extract( + root: ExtractionRoot, + resolver: import('@devup-ui/plugin-utils').ModuleResolver, + action: () => Output, + ) { + this.direct(root.nativeId, root.id, root.source) + const observed = observeResolver(this, root.nativeId, resolver) + setModuleResolver(observed) + let output: Output + try { + output = action() + } catch (error) { + throw observed.remapError(error) + } + try { + return { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + output.free() + } + } + + direct(nativeId: string, id: string, source: ReadySource): void { + this.observed.add(id) + this.pending.set(nativeId, new Map([[id, source]])) + const path = barePhysicalPath(id) + if (path !== undefined) this.paths.set(id, path) + } + + resolved(nativeId: string, result: ModuleResolution | undefined): void { + if (result === undefined) return + switch (result.ignored) { + case true: + return + case undefined: { + this.observed.add(result.path) + const path = barePhysicalPath(result.path) + if (path !== undefined) this.paths.set(result.path, path) + this.pending.get(nativeId)?.set(result.path, { + code: result.code, + ...(result.sourceType === undefined + ? {} + : { sourceType: result.sourceType }), + }) + return + } + default: + return result satisfies never + } + } + + source(path: string): PreparedSource { + const id = path.replaceAll('\\', '/') + for (const entries of this.pending.values()) { + const ready = entries.get(id) + if (ready !== undefined) return ready + } + for (const [root, entries] of this.completed) { + if (this.pending.has(root)) continue + const ready = entries.get(id) + if (ready !== undefined) return ready + } + return undefined + } + + invalidate(id: string): void { + const matches = (root: string, entries: ReadonlyMap) => + root === id || + [...entries.keys()].some( + (key) => + key === id || + this.paths.get(key) === id || + this.realPaths.get(key) === id, + ) + this.completed = new Map( + [...this.completed].filter(([root, entries]) => !matches(root, entries)), + ) + for (const [root, entries] of this.pending) { + if (matches(root, entries)) this.pending.delete(root) + } + } + + async finish( + graph: NativePresence, + learned: readonly string[], + ): Promise { + const reachable = new Set() + const present = new Set() + const queue = [...graph.getModuleIds()].filter( + (id) => graph.getModuleInfo(id)?.isEntry, + ) + for (let index = 0; index < queue.length; index += 1) { + const id = queue[index] + if (id === undefined || reachable.has(id)) continue + reachable.add(id) + const info = graph.getModuleInfo(id) + if (info === null) continue + if (typeof info.code === 'string') present.add(id) + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + const attachments = new Map>() + for (const root of [...present]) { + const entries = this.pending.get(root) ?? this.completed.get(root) + if (entries !== undefined) { + attachments.set(root, new Map(entries)) + for (const id of entries.keys()) present.add(id) + } + } + const ids = new Set(this.observed) + for (const id of learned) { + if (ids.has(id)) continue + const path = this.paths.get(id) ?? barePhysicalPath(id) + if (path === undefined ? present.has(id) : await exists(path)) ids.add(id) + } + return { ids: [...ids], attachments } + } + + publish(completion: Completion): void { + this.completed = completion.attachments + this.pending.clear() + } +} + +export function observeResolver( + modules: NonphysicalModules, + nativeId: string, + resolver: import('@devup-ui/plugin-utils').ModuleResolver, +): import('@devup-ui/plugin-utils').ModuleResolver { + return Object.assign( + ( + specifier: string, + importer: string, + ): ResolvedModule | { readonly ignored: true } | undefined => { + const result = resolver(specifier, importer) + modules.resolved(nativeId, result) + return result + }, + { remapError: resolver.remapError }, + ) +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 91e585045..2548321c2 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -3,31 +3,45 @@ import { mkdir, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { + beginBuild, buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, createCompatTypes, createModuleResolver, createNodeModulesExcludeRegex, + createStateWriter, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, - listSourceFiles, + isMdxSource, + isSelectedSource, loadDevupConfig, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, } from '@devup-ui/plugin-utils' import { codeExtract, - exportFileMap, - getCss, + getCss as getEngineCss, getDefaultTheme, getThemeInterface, importCanonicalMap, - importFileMap, importFileRoutes, registerShorthands, registerTheme, + resetBuildState, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -42,6 +56,11 @@ import type { UserConfig, } from 'vite' +import { createAggregateCssPreparation } from './aggregate-css' +import { createCompiledGuard } from './compiled-guard' +import { ProductionActivation } from './production-numbering' +import { createMissingInputWatch } from './resolution-watch' + /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... * @@ -51,59 +70,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), - ) -} - -/** - * 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. @@ -274,6 +240,8 @@ export interface DevupUIPluginOptions { singleCss: boolean 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). @@ -293,33 +261,25 @@ 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)) { await mkdir(options.cssDir, { recursive: true }) } if (!options.singleCss) { - await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + await writeFile( + join(options.cssDir, 'devup-ui.css'), + getEngineCss(null, false), + ) } } @@ -327,133 +287,421 @@ 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, + mdxExtensions: configuredMdxExtensions, }: Partial = {}): PluginOption { + 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. + let projectRoot = process.cwd() + const endBuild = beginBuild( + { resetBuildState }, + { + integration: 'Vite', + get root() { + return projectRoot + }, + }, + ) registerShorthands(shorthands ?? {}) setDebug(debug) - if (prefix) { - setPrefix(prefix) - } + setPrefix(prefix ?? null) const importAliases = mergeImportAliases(userImportAliases) + const production = new ProductionActivation( + { + package: libPackage, + devupFile, + distDir, + cssDir: configuredCssDir, + extractCss, + debug, + include, + singleCss, + prefix, + shorthands, + sourceDirs: configuredSourceDirs, + mdxExtensions, + atomHoist, + importAliases, + }, + endBuild, + ) + const getCss = (file: number | null | undefined, main: boolean) => + production.active ? production.css(file, main) : getEngineCss(file, main) + const excludeModules = createNodeModulesExcludeRegex(include) + const pathOptions = { devupFile, distDir, cssDir: configuredCssDir } + 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() + 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. let restoreForwardedCss: ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false + let isProduction = false + let seedWarningEmitted = false + 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, + context?: string, + ) { + return createModuleResolver({ + cwd: projectRoot, + includeMdx: mdxExtensions, + conditions, + onResolutionInputs, + toId: (path) => path.replaceAll('\\', '/'), + ...(context === undefined + ? {} + : { + prepareSource: (path: string) => + production.modules(context).source(path), + }), + }) + } // 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') } - const plugin: Plugin = { + const sourceTransform = { + async transform(code, id) { + if (!extractCss) return + resolveFallbackPaths() + const fileName = id.replace(/\?.*$/s, '') + if (excludeModules.test(fileName)) return + const environment = this.environment ?? plugin + if (production.active) await production.prepare(observeSetup) + const context = production.active + ? (this.environment?.name ?? production.context) + : undefined + 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 + const resolver = 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) + } + }, + context, + ) + if (!production.active) setModuleResolver(resolver) + let rel = relative(dirname(id), cssDir).replaceAll('\\', '/') + if (!rel.startsWith('./')) rel = `./${rel}` + const output = (() => { + try { + const extract = () => + codeExtract( + fileName, + code, + libPackage, + rel, + singleCss, + true, + false, + importAliases, + ...(isMdxSource(fileName, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), + ) + if (!production.active) return extract() + const key = context ?? production.context + return production.run(key, () => + production.modules(key).extract( + { + nativeId: id, + id: fileName, + source: { + code, + ...(isMdxSource(fileName, mdxExtensions) + ? { + sourceType: 'compiled-mdx' as const, + map: this.getCombinedSourcemap(), + } + : {}), + }, + }, + resolver, + extract, + ), + ) + } catch (error) { + if (isMdxSource(fileName, mdxExtensions)) + throw remapMdxError(error, fileName, this.getCombinedSourcemap()) + throw error + } + })() + const { + code: extractedCode, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + if ('free' in output) output.free() + } + })() + 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 // each environment independently emit the same CSS asset. sharedDuringBuild: true, + configureServer(server) { + missingInputWatch.attach(server) + }, 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) try { - seedFileMap(sourceDirs) - } 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( - 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) { + 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 + fallbackConditions = conditions + if (production.configure(config)) writtenCss.clear() + if (production.active) return + 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) => isSelectedSource(entry, mdxExtensions)) + .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, { + includeMdx: mdxExtensions, + cwd: root, + include, + conditions, + onResolutionInputs: observeSetup, + }) + + 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, + includeMdx: mdxExtensions, + 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). } + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] setup failed at ${projectRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } }, config(this: { meta?: ConfigHookMeta } | void, userConfig: UserConfig) { @@ -476,17 +724,63 @@ 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 }, - apply() { + apply(_config?: UserConfig, environment?: { command: 'build' | 'serve' }) { + if (environment?.command === 'build' && extractCss) endBuild() return true }, - async watchChange(id) { + closeBundle(this: (object & { environment?: { name: string } }) | void) { + if (production.active) + production.close(this ? this.environment?.name : undefined) + else { + missingInputWatch.close() + missingWatchContexts.clear() + } + endBuild() + }, + async buildStart(options) { + if (production.active) { + await production.prepare(observeSetup, () => { + writtenCss.clear() + cssMap.clear() + }) + production.start(this.environment?.name ?? production.context) + } + 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, + options.input, + this.environment?.config.build ?? resolvedConfig?.build ?? {}, + ) + }, + async watchChange(this: void, id) { + if (production.active) production.invalidate(id.replaceAll('\\', '/')) + resolveFallbackPaths() if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { try { + if (production.active) { + await production.updateTheme() + return + } await writeDataFiles({ package: libPackage, cssDir, @@ -495,13 +789,39 @@ export function DevupUI({ singleCss, }) } catch (error) { - console.error(error) + console.error(`[devup-ui] theme update failed at ${devupFile}`, error) } } }, + buildApp: { + order: 'pre', + async handler(builder) { + if (!production.active) return + production.refine(builder.environments) + await production.prepare(observeSetup) + }, + }, + buildEnd: { + order: 'post', + async handler(error) { + if (production.active) + await production.finish( + this.environment?.name ?? production.context, + this, + error, + ) + }, + }, + closeWatcher() { + if (production.active) production.dispose() + missingInputWatch.close() + missingWatchContexts.clear() + endBuild() + }, // 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 @@ -540,6 +860,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 } @@ -565,6 +886,7 @@ export function DevupUI({ return [] }, resolveId(id, importer) { + resolveFallbackPaths() const fileName = basename(id).split('?')[0] if ( DEVUP_CSS_FILE_RE.test(fileName) && @@ -588,63 +910,26 @@ 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', - 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 @@ -681,7 +966,7 @@ export function DevupUI({ } } }, - } + } satisfies Plugin const restorePlugin: Plugin = { name: 'devup-ui:restore-forwarded-css', sharedDuringBuild: true, @@ -694,5 +979,42 @@ export function DevupUI({ }, }, } - return [plugin, restorePlugin] + const mdxPlugin = { + name: 'devup-ui:mdx', + enforce: 'post', + sharedDuringBuild: true, + async transform(code, id) { + if (!isMdxSource(id, mdxExtensions)) return + return sourceTransform.transform.call(this, code, id) + }, + } satisfies Plugin + 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 compiledGuard = createCompiledGuard({ + package: libPackage, + mdxExtensions, + importAliases, + extractCss, + }) + const plugins: [ + typeof plugin, + typeof restorePlugin, + typeof mdxPlugin, + typeof aggregateGuard, + typeof compiledGuard, + ] = [plugin, restorePlugin, mdxPlugin, aggregateGuard, compiledGuard] + return plugins } diff --git a/packages/vite-plugin/src/production-engine.ts b/packages/vite-plugin/src/production-engine.ts new file mode 100644 index 000000000..a740318b5 --- /dev/null +++ b/packages/vite-plugin/src/production-engine.ts @@ -0,0 +1,163 @@ +import { + BuildGeneration, + type GenerationEngine, + nextNonphysicalIdList, + type ProductionNumbering, + ProductionNumberingError, +} from '@devup-ui/plugin-utils' +import { + exportCanonicalMap, + exportClassMap, + exportFileMap, + exportSheet, + importCanonicalMap, + importClassMap, + importFileMap, + importSheet, + resetBuildState, +} from '@devup-ui/wasm' + +import type { NativePresence, NonphysicalModules } from './nonphysical-modules' +import type { + prepareProductionManifest, + ProductionContext, +} from './production-manifest' + +interface Runtime { + readonly plan: ProductionContext + readonly modules: NonphysicalModules + learned: readonly string[] +} + +export class ProductionGeneration { + declare readonly owner: BuildGeneration + declare readonly contexts: Map + declare readonly participants: Map void> + declare status: 'open' | 'failed' | 'closed' + declare pending: Promise | undefined + declare manifest: + Awaited> | undefined + declare numbering: ProductionNumbering | undefined + declare current: string | undefined + + constructor() { + this.owner = new BuildGeneration() + this.contexts = new Map() + this.participants = new Map void>() + this.status = 'open' + this.pending = undefined + this.manifest = undefined + this.numbering = undefined + this.current = undefined + } + + assertOpen(): void { + this.owner.acquire(false)() + } + + runtime(key: string): Runtime { + const runtime = this.contexts.get(key) + if (runtime === undefined) + throw new ProductionNumberingError('invalid-plan', { context: key }) + return runtime + } + + async finish( + key: string, + graph: NativePresence, + error?: Error, + ): Promise { + if (error !== undefined) this.fail() + if (!this.participants.has(key)) return + const runtime = this.runtime(key) + if (this.status !== 'open') { + runtime.modules.start() + return + } + try { + const completed = await runtime.modules.finish(graph, runtime.learned) + this.assertOpen() + await runtime.plan.store.rewrite(completed.ids, runtime.plan.scan) + runtime.learned = nextNonphysicalIdList(completed.ids, runtime.plan.scan) + runtime.modules.publish(completed) + } catch (cause) { + this.fail() + throw cause + } + } + + close(key = this.current): void { + if (key === undefined) return + this.participants.get(key)?.() + this.participants.delete(key) + } + + dispose(): void { + for (const release of this.participants.values()) release() + this.participants.clear() + this.status = 'closed' + this.owner.dispose() + } + + fail(): void { + if (this.status === 'closed') return + this.status = 'failed' + this.owner.dispose() + for (const runtime of this.contexts.values()) runtime.modules.start() + } +} + +export class ProductionLifecycle { + declare generation: ProductionGeneration + + constructor() { + this.generation = new ProductionGeneration() + } + + replace(): void { + this.generation.dispose() + this.generation = new ProductionGeneration() + } + + input(): boolean { + switch (this.generation.status) { + case 'open': + return false + case 'failed': + this.replace() + return true + case 'closed': + this.generation.assertOpen() + return false + } + } +} + +export interface ProductionState { + readonly sheet: string + readonly classes: string + readonly files: string + readonly canonical: string +} + +export const productionEngine: GenerationEngine = { + reset: resetBuildState, + restore(state) { + const sheet: unknown = JSON.parse(state.sheet) + const classes: unknown = JSON.parse(state.classes) + const files: unknown = JSON.parse(state.files) + const canonical: unknown = JSON.parse(state.canonical) + importSheet(sheet) + importClassMap(classes) + importFileMap(files) + importCanonicalMap(canonical) + }, + capture() { + return { + sheet: exportSheet(), + classes: exportClassMap(), + files: exportFileMap(), + canonical: exportCanonicalMap(), + } + }, +} diff --git a/packages/vite-plugin/src/production-manifest.ts b/packages/vite-plugin/src/production-manifest.ts new file mode 100644 index 000000000..cde2fe718 --- /dev/null +++ b/packages/vite-plugin/src/production-manifest.ts @@ -0,0 +1,188 @@ +import { dirname, isAbsolute, resolve } from 'node:path' + +import { + buildCanonicalMap, + buildStaticImportGraph, + collectProductionFileManifest, + computeFileReach, + createNonphysicalIdStore, + enumerateProductionSourceFiles, + isSelectedSource, + loadDevupConfig, + type NonphysicalIdStore, + planAtomHoist, + type ProductionNumberingPlan, + type ResolutionInputObserver, + resolveProjectPaths, + resolveSourceDirs, +} from '@devup-ui/plugin-utils' +import type { ResolvedConfig } from 'vite' + +import type { DevupUIPluginOptions } from './plugin' + +export type ProductionOptions = Readonly< + Pick< + DevupUIPluginOptions, + 'package' | 'debug' | 'include' | 'singleCss' | 'extractCss' + > +> & { + readonly devupFile: string + readonly distDir: string + readonly cssDir: string | undefined + readonly sourceDirs: string | string[] | undefined + readonly mdxExtensions: readonly string[] + readonly prefix: string | undefined + readonly shorthands: DevupUIPluginOptions['shorthands'] + readonly atomHoist: number | undefined + readonly importAliases: import('@devup-ui/plugin-utils').WasmImportAliases +} +export interface ProductionContext extends ProductionNumberingPlan { + readonly conditions: readonly string[] + readonly store: NonphysicalIdStore + readonly learned: readonly string[] + readonly scan: readonly string[] + readonly routes: Readonly> + readonly threshold: number | undefined +} + +type Environment = ResolvedConfig['environments'][string] +function inputFor(root: string, environment: Environment): readonly string[] { + const build = environment.build + const native = build.rolldownOptions?.input ?? build.rollupOptions?.input + const input = build.lib + ? (native ?? build.lib.entry) + : typeof build.ssr === 'string' + ? build.ssr + : (native ?? environment.input ?? resolve(root, 'index.html')) + const entries = + typeof input === 'string' ? [input] : Object.values(input ?? {}) + return entries.filter((entry): entry is string => typeof entry === 'string') +} + +export async function prepareProductionManifest( + config: ResolvedConfig, + options: ProductionOptions, + observe: ResolutionInputObserver, +) { + const paths = resolveProjectPaths(config.root, { + devupFile: options.devupFile, + distDir: options.distDir, + ...(options.cssDir === undefined ? {} : { cssDir: options.cssDir }), + }) + const theme = (await loadDevupConfig(paths.devupFile)).theme ?? {} + const roots = resolveSourceDirs(config.root, options.sourceDirs) + const configured = Object.entries(config.environments) + const contexts: ProductionContext[] = [] + for (const [key, environment] of configured) { + const entries = inputFor(config.root, environment) + const physicalEntries = entries.filter( + (entry) => + !/[\0?#]/.test(entry) && + (isAbsolute(entry) || !/^[a-z][\w-]*:/i.test(entry)), + ) + const nonphysical = entries + .filter((entry) => !physicalEntries.includes(entry)) + .map((entry) => entry.replace(/\?.*$/s, '')) + const conditions = ['import', ...environment.resolve.conditions].map( + (condition) => + condition === 'development|production' + ? config.isProduction + ? 'production' + : 'development' + : condition, + ) + const selectedEntries = physicalEntries + .filter((entry) => isSelectedSource(entry, options.mdxExtensions)) + .map((entry) => resolve(config.root, entry)) + const graphRoots = [...new Set([...roots, ...selectedEntries.map(dirname)])] + const exclude = [ + paths.distDir, + paths.cssDir, + resolve(config.root, environment.build.outDir), + ] + const files = await collectProductionFileManifest({ + contexts: [ + { + key, + files: enumerateProductionSourceFiles({ + roots, + entries: physicalEntries, + cwd: config.root, + includeMdx: options.mdxExtensions, + exclude, + }), + resolverOptions: { + cwd: config.root, + conditions, + includeMdx: options.mdxExtensions, + onResolutionInputs: observe, + }, + toId: (path) => path.replaceAll('\\', '/'), + unpreparedMarkdown: 'reserve-only', + }, + ], + include: options.include, + exclude, + }) + let canonical: Readonly> = {} + let routes: Readonly> = {} + let threshold: number | undefined + if ( + options.atomHoist !== undefined && + Number.isFinite(options.atomHoist) && + options.atomHoist > 0 + ) { + const graph = buildStaticImportGraph( + graphRoots, + resolve(config.root, 'tsconfig.json'), + { + cwd: config.root, + include: options.include, + includeMdx: options.mdxExtensions, + conditions, + onResolutionInputs: observe, + }, + ) + canonical = buildCanonicalMap({ + srcDir: graphRoots, + cwd: config.root, + keyBy: 'absolute', + graph, + }) + const reach = computeFileReach({ + srcDir: graphRoots, + cwd: config.root, + keyBy: 'absolute', + graph, + ...(selectedEntries.length ? { entries: selectedEntries } : {}), + }) + const hoist = planAtomHoist(canonical, reach, options.atomHoist) + if (hoist) { + routes = hoist.reachByBucket + threshold = hoist.threshold + } + } + const store = createNonphysicalIdStore({ + integration: 'Vite', + resolvedRoot: config.root, + contextKey: key, + distDir: paths.distDir, + }) + const learned = (await store.read()).ids + contexts.push( + Object.freeze({ + context: key, + files, + nonphysical: Object.freeze([...nonphysical, ...learned]), + canonical, + conditions, + routes, + threshold, + store, + learned, + scan: Object.freeze([...files.map((file) => file.id), ...nonphysical]), + }), + ) + } + return { paths, theme, contexts: Object.freeze(contexts) } +} diff --git a/packages/vite-plugin/src/production-numbering.ts b/packages/vite-plugin/src/production-numbering.ts new file mode 100644 index 000000000..e320cc7e2 --- /dev/null +++ b/packages/vite-plugin/src/production-numbering.ts @@ -0,0 +1,252 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { + createCompatTypes, + createThemeInterfaceArgs, + loadDevupConfig, + ProductionNumbering, + ProductionNumberingError, + type ResolutionInputObserver, + runBuildOperation, +} from '@devup-ui/plugin-utils' +import { + getCss, + getThemeInterface, + importCanonicalMap, + importFileRoutes, + registerShorthands, + registerTheme, + seedFileMap, + setAtomHoist, + setDebug, + setModuleResolver, + setPrefix, +} from '@devup-ui/wasm' +import type { BuildEnvironment, ResolvedConfig } from 'vite' + +import { type NativePresence, NonphysicalModules } from './nonphysical-modules' +import { + productionEngine, + type ProductionGeneration, + ProductionLifecycle, +} from './production-engine' +import { + prepareProductionManifest, + type ProductionOptions, +} from './production-manifest' + +export class ProductionActivation { + private readonly lifecycle = new ProductionLifecycle() + private configs = new WeakSet() + private config: ResolvedConfig | undefined + + constructor( + private readonly options: ProductionOptions, + private readonly endLegacy: () => void, + ) {} + + private get generation(): ProductionGeneration { + return this.lifecycle.generation + } + + get active(): boolean { + return this.config?.command === 'build' && this.options.extractCss + } + + get context(): string { + if (this.generation.current === undefined) + throw new ProductionNumberingError('unseeded') + return this.generation.current + } + + configure(config: ResolvedConfig): boolean { + if (this.generation.pending === undefined) { + this.config = config + if (config?.command === 'build') this.configs.add(config) + return false + } + // Native siblings resolve before preparation; watch closes bundles, not its generation. + const fresh = + !this.configs.has(config) && + this.generation.participants.size === 0 && + (!this.config?.build.watch || this.generation.owner.disposed) + if (fresh) { + this.lifecycle.replace() + this.configs = new WeakSet() + } + this.configs.add(config) + if (fresh) this.config = config + return fresh + } + + refine(environments: Readonly>): void { + if (this.generation.pending !== undefined || this.config === undefined) + return + this.config = { + ...this.config, + environments: Object.fromEntries( + Object.entries(environments).map(([key, environment]) => [ + key, + environment.config, + ]), + ), + } + } + + async prepare( + observe: ResolutionInputObserver, + input?: () => void, + ): Promise { + if (input !== undefined && this.lifecycle.input()) input() + const generation = this.generation + generation.assertOpen() + return (generation.pending ??= this.prepareOnce(generation, observe)) + } + + private async prepareOnce( + generation: ProductionGeneration, + observe: ResolutionInputObserver, + ): Promise { + const config = this.config + if (config === undefined) throw new ProductionNumberingError('invalid-plan') + this.endLegacy() + try { + const manifest = await prepareProductionManifest( + config, + this.options, + observe, + ) + generation.assertOpen() + generation.manifest = manifest + for (const plan of manifest.contexts) { + generation.contexts.set(plan.context, { + plan, + modules: new NonphysicalModules(plan.files), + learned: plan.learned, + }) + } + const first = manifest.contexts[0] + if (first === undefined) + throw new ProductionNumberingError('invalid-plan') + await this.writeData(first.context, generation) + generation.assertOpen() + generation.numbering = new ProductionNumbering( + generation.owner, + manifest.contexts, + ) + } catch (error) { + generation.fail() + throw error + } + } + + private async writeData( + key: string, + generation = this.generation, + ): Promise { + const manifest = generation.manifest + if (manifest === undefined) throw new ProductionNumberingError('unseeded') + const data = this.run( + key, + () => ({ + interfaceCode: getThemeInterface( + ...createThemeInterfaceArgs(this.options.package), + ), + base: getCss(null, false), + }), + generation, + ) + const { paths } = manifest + await mkdir(paths.distDir, { recursive: true }) + await mkdir(paths.cssDir, { recursive: true }) + await writeFile(join(paths.distDir, '.gitignore'), '*', 'utf8') + await writeFile( + join(paths.distDir, 'compat.d.ts'), + createCompatTypes(this.options.importAliases), + 'utf8', + ) + await writeFile( + join(paths.distDir, 'theme.d.ts'), + data.interfaceCode, + 'utf8', + ) + if (!this.options.singleCss) + await writeFile(join(paths.cssDir, 'devup-ui.css'), data.base, 'utf8') + } + + async updateTheme(): Promise { + const generation = this.generation + const manifest = generation.manifest + if (manifest === undefined) throw new ProductionNumberingError('unseeded') + const theme = (await loadDevupConfig(manifest.paths.devupFile)).theme ?? {} + generation.assertOpen() + generation.manifest = { ...manifest, theme } + await this.writeData(this.context, generation) + } + + run(key: string, action: () => T, generation = this.generation): T { + const manifest = generation.manifest + if (manifest === undefined) + throw new ProductionNumberingError('unseeded', { context: key }) + const runtime = generation.runtime(key) + try { + return runBuildOperation( + { integration: 'Vite', root: this.config?.root ?? process.cwd() }, + () => + generation.owner.run(productionEngine, () => { + registerTheme(manifest.theme) + registerShorthands(this.options.shorthands ?? {}) + setDebug(this.options.debug) + setPrefix(this.options.prefix ?? null) + importCanonicalMap(runtime.plan.canonical) + importFileRoutes(runtime.plan.routes) + setAtomHoist(runtime.plan.threshold ?? null) + setModuleResolver(undefined) + generation.numbering?.seed({ seedFileMap, importCanonicalMap }, key) + generation.current = key + return action() + }), + ) + } catch (error) { + generation.fail() + throw error + } + } + + modules(key: string): NonphysicalModules { + return this.generation.runtime(key).modules + } + + start(key: string): void { + const runtime = this.generation.runtime(key) + this.generation.close(key) + this.generation.participants.set(key, this.generation.owner.acquire(false)) + runtime.modules.start() + } + + async finish( + key: string, + graph: NativePresence, + error?: Error, + ): Promise { + await this.generation.finish(key, graph, error) + } + + css(file: number | null | undefined, main: boolean): string { + return this.run(this.context, () => getCss(file, main)) + } + + invalidate(id: string): void { + for (const runtime of this.generation.contexts.values()) + runtime.modules.invalidate(id) + } + + close(key = this.generation.current): void { + this.generation.close(key) + } + + dispose(): void { + this.generation.dispose() + } +} diff --git a/packages/vite-plugin/src/resolution-watch.ts b/packages/vite-plugin/src/resolution-watch.ts new file mode 100644 index 000000000..79179057a --- /dev/null +++ b/packages/vite-plugin/src/resolution-watch.ts @@ -0,0 +1,56 @@ +import { basename, 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) + // 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 + .off('add', changed) + .off('change', changed) + .off('unlink', changed) + server = undefined + missingByEnvironment.clear() + }, + } +} diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 343ead564..598d992fd 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -177,6 +177,57 @@ 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. + +## 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 diff --git a/packages/webpack-plugin/generation-regression.mjs b/packages/webpack-plugin/generation-regression.mjs new file mode 100644 index 000000000..50d4fb958 --- /dev/null +++ b/packages/webpack-plugin/generation-regression.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + bundleSourceEntries, + pluginUtilsFixtureManifest, + webpackFixtureManifest, +} from './native-fixture-bundle.mjs' + +const workspace = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const root = realpathSync.native( + mkdtempSync( + join(process.env.DEVUP_CSS_EVIDENCE_DIR ?? tmpdir(), 'devup-generation-'), + ), +) +try { + mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }) + for (const [name, path] of [ + ['wasm', 'bindings/devup-ui-wasm'], + ['react', 'packages/react'], + ]) { + symlinkSync( + realpathSync(join(workspace, path)), + join(root, 'node_modules/@devup-ui', name), + process.platform === 'win32' ? 'junction' : 'dir', + ) + } + for (const [pkg, entries] of [ + ['plugin-utils', ['index', 'build-admission']], + ['webpack-plugin', ['index', 'loader', 'css-loader']], + ]) { + const directory = join(root, 'node_modules/@devup-ui', pkg) + mkdirSync(directory) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify( + pkg === 'webpack-plugin' + ? webpackFixtureManifest + : pluginUtilsFixtureManifest, + ), + ) + await bundleSourceEntries( + workspace, + root, + entries.map((entry) => [pkg, entry, 'cjs', 'cjs']), + ) + } + const script = ` +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const {join}=require('node:path'); +const {createRequire}=require('node:module'); +const {webpack}=createRequire(${JSON.stringify(join(workspace, 'apps/landing/package.json'))})('next/dist/compiled/webpack/webpack'); +const {DevupUIWebpackPlugin,createWebpackGeneration}=require('@devup-ui/webpack-plugin'); +const wasm=require('@devup-ui/wasm'); +const root=__dirname; +fs.mkdirSync(join(root,'src')); +const source=color=>"import {css} from '@devup-ui/react';export const style=css({bg:'"+color+"'})"; +const options={singleCss:true}; +const cssEntry='./df/devup-ui/devup-ui.css'; +const rows=[]; +let shared; +async function close(compiler){await new Promise((done,reject)=>compiler.close(error=>error?reject(error):done()))} +function config(plugin,entry,mode='production'){ + return {context:root,mode,entry,experiments:{css:true},optimization:{minimize:false},plugins:[plugin]}; +} +async function build(plugin,entry){ + const compiler=webpack(config(plugin,entry)); + if(shared){compiler.inputFileSystem=shared;shared.purge()} + const sameFs=compiler.inputFileSystem===shared; + try{ + const stats=await new Promise((done,reject)=>compiler.run((error,stats)=>error?reject(error):done(stats))); + assert.equal(stats.hasErrors(),false,stats.toString({all:false,errors:true})); + const css=stats.compilation.getAssets().filter(asset=>asset.name.endsWith('.css')).map(asset=>fs.readFileSync(join(root,'dist',asset.name),'utf8')).join(''); + const row={sameFs,css,classes:JSON.parse(wasm.exportClassMap()),files:JSON.parse(wasm.exportFileMap()),canonical:JSON.parse(wasm.exportCanonicalMap())}; + rows.push(row);shared=compiler.inputFileSystem;return row; + }finally{await close(compiler)} +} +async function main(){ + fs.writeFileSync(join(root,'src/entry.mjs'),source('red')); + fs.writeFileSync(join(root,'src/unreachable.mjs'),source('blue')); + const owner=createWebpackGeneration(); + new DevupUIWebpackPlugin({prefix:'unused-',shorthands:{unused:['paddingLeft']}}); + const server=await build(new DevupUIWebpackPlugin(options,{owner,complete:false}),'./src/entry.mjs'); + assert.ok(server.css.includes('.a{background:red}')); + assert.ok(!server.css.includes('background:blue')); + fs.writeFileSync(join(root,'src/entry.mjs'),"export const unused='server-only-source'"); + const consumer=await build(new DevupUIWebpackPlugin(options,{owner,complete:true}),cssEntry); + assert.equal(consumer.sameFs,true);assert.ok(consumer.css.includes('.a{background:red}'));assert.equal(owner.disposed,true); + const independent=await build(new DevupUIWebpackPlugin(options),cssEntry); + assert.equal(independent.sameFs,true);assert.ok(!independent.css.includes('background:red'));assert.ok(!independent.css.includes('background:blue'));assert.deepEqual(independent.classes,{});assert.deepEqual(independent.files,{'src/unreachable.mjs':0});assert.deepEqual([...new Set(Object.values(independent.canonical))],['src/unreachable.mjs']); + fs.writeFileSync(join(root,'src/entry.mjs'),source('blue')); + const changedOptions=await build(new DevupUIWebpackPlugin({singleCss:true,prefix:'fresh-'}),'./src/entry.mjs'); + assert.ok(changedOptions.css.includes('.fresh-a{background:blue}'));assert.ok(!changedOptions.css.includes('background:red')); + fs.writeFileSync(join(root,'src/color.mjs'),"export const color='red'"); + fs.writeFileSync(join(root,'src/entry.mjs'),"import {color} from './color.mjs';import {css} from '@devup-ui/react';export const style=css({bg:color})"); + const compiler=webpack(config(new DevupUIWebpackPlugin({singleCss:true,watch:true}),'./src/entry.mjs','development')); + let watching;let deadline;let first=false;const watchCss=[]; + try{ + await new Promise((done,reject)=>{ + deadline=setTimeout(()=>reject(new Error('watch delivery deadline')),90000); + watching=compiler.watch({},(error,stats)=>{ + try{ + if(error)return reject(error); + if(stats.hasErrors())return reject(new Error(stats.toString({all:false,errors:true}))); + const css=stats.compilation.getAssets().filter(asset=>asset.name.endsWith('.css')).map(asset=>fs.readFileSync(join(root,'dist',asset.name),'utf8')).join(''); + if(!first){ + assert.ok(css.includes('.a{background:red}'));assert.ok(!css.includes('background:blue'));watchCss.push(css);first=true; + fs.writeFileSync(join(root,'src/color.mjs'),"export const color='blue'");watching.invalidate(); + }else if(css.includes('background:blue')){watchCss.push(css);done()} + }catch(cause){reject(cause)} + }); + }); + }finally{ + clearTimeout(deadline); + try{if(watching)await new Promise((done,reject)=>watching.close(error=>error?reject(error):done()))}finally{await close(compiler)} + } + const afterWatch=await build(new DevupUIWebpackPlugin(options),cssEntry); + assert.ok(!afterWatch.css.includes('background:red'));assert.ok(!afterWatch.css.includes('background:blue'));assert.deepEqual(afterWatch.classes,{}); + console.info(JSON.stringify({root,rows,watchCss})); +} +main().catch(error=>{console.error(error);process.exitCode=1}); +` + const probe = join(root, 'probe.cjs') + writeFileSync(probe, script) + const result = spawnSync('node', [probe], { + cwd: root, + encoding: 'utf8', + timeout: 150000, + maxBuffer: 8 * 1024 * 1024, + }) + if (process.env.DEVUP_CSS_EVIDENCE_DIR) + writeFileSync( + join( + process.env.DEVUP_CSS_EVIDENCE_DIR, + `${root.split(/[\\/]/).at(-1)}.log`, + ), + `${result.stdout}\n${result.stderr}`, + ) + console.info(result.stdout) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/webpack-plugin/native-fixture-bundle.mjs b/packages/webpack-plugin/native-fixture-bundle.mjs new file mode 100644 index 000000000..ba18ffebe --- /dev/null +++ b/packages/webpack-plugin/native-fixture-bundle.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { build } from 'bun' + +export const webpackFixtureManifest = { + name: '@devup-ui/webpack-plugin', + type: 'module', + exports: { + '.': './index.cjs', + './loader': './loader.cjs', + './css-loader': './css-loader.cjs', + }, +} + +export const pluginUtilsFixtureManifest = { + name: '@devup-ui/plugin-utils', + type: 'module', + exports: { + '.': { import: './index.mjs', require: './index.cjs' }, + './internal/build-admission': './build-admission.cjs', + }, +} + +export const nextSourceEntries = [ + ['next-plugin', 'index', 'esm', 'mjs'], + ['webpack-plugin', 'index', 'cjs', 'cjs'], + ['webpack-plugin', 'loader', 'cjs', 'cjs'], + ['webpack-plugin', 'css-loader', 'cjs', 'cjs'], + ['plugin-utils', 'index', 'esm', 'mjs'], + ['plugin-utils', 'index', 'cjs', 'cjs'], + ['plugin-utils', 'build-admission', 'cjs', 'cjs'], +] + +export async function bundleSourceEntries(workspace, root, entries) { + const bundles = [] + for (const [pkg, entry, format, ext] of entries) { + const source = join( + workspace, + 'packages', + pkg, + 'src', + `${entry}.${entry === 'build-admission' ? 'cts' : 'ts'}`, + ) + const emitted = join(root, 'node_modules/@devup-ui', pkg, `${entry}.${ext}`) + const built = await build({ + entrypoints: [source], + outdir: dirname(emitted), + naming: `${entry}.${ext}`, + target: 'node', + format, + packages: 'external', + define: { + 'import.meta.url': JSON.stringify(pathToFileURL(emitted).href), + }, + }) + assert.ok(built.success, String(built.logs)) + bundles.push({ source, emitted, success: built.success }) + } + return bundles +} diff --git a/packages/webpack-plugin/next-css-regression.mjs b/packages/webpack-plugin/next-css-regression.mjs new file mode 100644 index 000000000..eee696124 --- /dev/null +++ b/packages/webpack-plugin/next-css-regression.mjs @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + bundleSourceEntries, + nextSourceEntries, + pluginUtilsFixtureManifest, + webpackFixtureManifest, +} from './native-fixture-bundle.mjs' + +const workspace = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const installed = createRequire(join(workspace, 'apps/landing/package.json')) +const mode = process.argv[2] +assert.ok(mode === 'per-file' || mode === 'singleCss', 'expected CSS mode') +const singleCss = mode === 'singleCss' +const evidenceDir = process.env.DEVUP_CSS_EVIDENCE_DIR +const root = mkdtempSync( + join(evidenceDir ?? tmpdir(), `devup-next-css-${mode}-`), +) +const marker = 'native-devup-css-marker' +const links = [] +const bundles = [] +const write = (path, content) => { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, content) +} +const link = (name, target) => { + const destination = join(root, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + const physical = realpathSync(target) + symlinkSync( + physical, + destination, + process.platform === 'win32' ? 'junction' : 'dir', + ) + links.push({ name, physical }) +} +let output = '' +let status = null +let signal = null +let timedOut = false +let closed = false +let timer +let html +let css = [] +let diagnostics +const started = Date.now() +try { + // Given: real installed dependencies and privately bundled current owned source. + for (const name of readdirSync( + join(workspace, 'apps/landing/node_modules'), + )) { + if (name === '.bin' || name === '@devup-ui') continue + link(name, join(workspace, 'apps/landing/node_modules', name)) + } + for (const [name, path] of Object.entries({ + react: 'packages/react', + wasm: 'bindings/devup-ui-wasm', + })) + link(`@devup-ui/${name}`, join(workspace, path)) + write( + 'node_modules/@devup-ui/next-plugin/package.json', + JSON.stringify({ + name: '@devup-ui/next-plugin', + type: 'module', + exports: './index.mjs', + }), + ) + write( + 'node_modules/@devup-ui/webpack-plugin/package.json', + JSON.stringify(webpackFixtureManifest), + ) + write( + 'node_modules/@devup-ui/plugin-utils/package.json', + JSON.stringify(pluginUtilsFixtureManifest), + ) + bundles.push( + ...(await bundleSourceEntries(workspace, root, nextSourceEntries)), + ) + write('package.json', JSON.stringify({ private: true, type: 'module' })) + write( + 'app/layout.jsx', + 'export default function Layout({children}) {return {children}}', + ) + write( + 'app/page.jsx', + `import {Box} from '@devup-ui/react'; export default function Page(){return ${marker}}`, + ) + write( + 'next.config.mjs', + `import {DevupUI} from '@devup-ui/next-plugin'; +const contexts=new WeakMap();let nextContext=0; +const config=DevupUI({experimental:{cpus:1},webpack(config,context){ + if(!contexts.has(context.config))contexts.set(context.config,++nextContext); + console.info('NATIVE_OWNER_CONTEXT '+JSON.stringify({config:contexts.get(context.config),dev:context.dev,isServer:context.isServer,nextRuntime:context.nextRuntime,buildId:context.buildId})); + config.plugins.push({apply(compiler){compiler.hooks.thisCompilation.tap('NativeCssDeliveryObserver',compilation=>{compilation.hooks.finishModules.tap('NativeCssDeliveryObserver',()=>{ + const loaders=[...compilation.modules].filter(module=>module.resource?.endsWith('.css')).flatMap(module=>module.loaders??[]).map(loader=>loader.loader); + console.info('NATIVE_OWNER_LOADERS '+JSON.stringify(loaders)); + })})}});return config; +}},{singleCss:${singleCss}}); +if(typeof config.webpack!=='function'||config.experimental.webpackBuildWorker!==undefined) throw new TypeError('use native default custom-webpack OFF'); export default config;`, + ) + const env = { + ...process.env, + NODE_OPTIONS: '', + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + DEBUG: 'next:build:webpack-build', + } + for (const key of [ + 'TURBOPACK', + 'NEXT_TURBOPACK_USE_WORKER', + 'NEXT_PRIVATE_BUILD_WORKER', + 'NEXT_RSPACK', + 'NEXT_PRIVATE_LOCAL_WEBPACK', + ]) + delete env[key] + // When: native Next build, no private lifecycle or asset intervention. + const child = spawn( + 'node', + [installed.resolve('next/dist/bin/next'), 'build', '--webpack'], + { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] }, + ) + child.stdout.on('data', (chunk) => { + output += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + output += chunk.toString() + }) + const completed = new Promise((done, reject) => { + child.once('error', reject) + child.once('close', (code, reason) => { + closed = true + status = code + signal = reason + done() + }) + }) + timer = setTimeout(() => { + timedOut = true + if (process.platform === 'win32' && child.pid !== undefined) { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + encoding: 'utf8', + timeout: 10000, + }) + } else child.kill() + }, 180000) + await completed + clearTimeout(timer) + assert.equal(status, 0, output) + assert.equal(timedOut, false, output) + assert.equal(closed, true) + html = readFileSync(join(root, '.next/server/app/index.html'), 'utf8') + diagnostics = JSON.parse( + readFileSync( + join(root, '.next/diagnostics/build-diagnostics.json'), + 'utf8', + ), + ) + assert.equal(diagnostics.buildOptions.useBuildWorker, 'false') + const contexts = output + .split(/\r?\n/) + .filter((line) => line.startsWith('NATIVE_OWNER_CONTEXT ')) + .map((line) => JSON.parse(line.slice('NATIVE_OWNER_CONTEXT '.length))) + assert.equal(contexts.length, 3) + assert.ok(contexts.every((context) => context.config === 1)) + assert.deepEqual( + contexts.map((context) => context.nextRuntime ?? 'client').sort(), + ['client', 'edge', 'nodejs'], + ) + const loaders = output + .split(/\r?\n/) + .filter((line) => line.startsWith('NATIVE_OWNER_LOADERS ')) + .flatMap((line) => JSON.parse(line.slice('NATIVE_OWNER_LOADERS '.length))) + .filter((path) => /webpack-plugin[\\/]css-loader\.cjs$/.test(path)) + assert.ok(loaders.length > 0) + assert.ok(loaders.every((path) => path.startsWith(root))) + // Then: the native class selector must occur in a stylesheet linked by native HTML. + const section = + /]*class="([^"]+)"[^>]*>native-devup-css-marker<\/section>/.exec( + html, + ) + assert.ok(section, 'native transformed section missing') + const assets = [ + ...html.matchAll( + /]*rel="stylesheet"[^>]*href="([^"?]+\.css)(?:[^"\s]*)"/g, + ), + ].map((match) => match[1]) + assert.ok(assets.length, 'native HTML stylesheet links missing') + css = assets.map((asset) => ({ + asset, + content: readFileSync( + join(root, '.next', asset.replace(/^\/_next\//, '')), + 'utf8', + ), + })) + const selectors = section[1] + .split(/\s+/) + .map((cls) => `.${cls}{background:red}`) + assert.ok( + css.some(({ content }) => + selectors.some((selector) => content.includes(selector)), + ), + 'HTML-linked native class CSS missing; disk CSS is not acceptance', + ) +} finally { + clearTimeout(timer) + const evidence = { + mode, + workspace, + root, + status, + signal, + timedOut, + closed, + elapsedMs: Date.now() - started, + links, + bundles, + html, + css, + diagnostics, + output, + } + if (evidenceDir) { + writeFileSync( + join(evidenceDir, `${basename(root)}.json`), + JSON.stringify(evidence, null, 2), + ) + writeFileSync(join(evidenceDir, `${basename(root)}.log`), output) + } + console.info(JSON.stringify(evidence)) + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/webpack-plugin/src/__tests__/admission-release.test.ts b/packages/webpack-plugin/src/__tests__/admission-release.test.ts new file mode 100644 index 000000000..2abeda1b7 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/admission-release.test.ts @@ -0,0 +1,73 @@ +import { beginBuild } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' + +import { bindCompilerScope, createWebpackGeneration } from '../build-scope' + +it('releases admission when an actual configured owner operation throws', () => { + const scope = bindCompilerScope({}) + const cause = new TypeError('configuration failed') + scope.setConfiguration(() => { + throw cause + }) + expect(() => scope.run(() => undefined)).toThrow(cause) + let resets = 0 + const end = beginBuild({ + resetBuildState() { + resets += 1 + }, + }) + try { + expect(resets).toBe(1) + } finally { + scope.abort() + end() + } +}) + +it('retains the real sheet handoff when related owners follow an exception', () => { + const owner = createWebpackGeneration() + const first = bindCompilerScope({}, { owner, complete: false }) + const cause = new TypeError('post extraction failure') + expect(() => + first.run(() => { + wasm + .codeExtract( + 'handoff.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free() + throw cause + }), + ).toThrow(cause) + first.start() + first.close() + const second = bindCompilerScope({}, { owner, complete: true }) + second.start() + const css = second.run(() => wasm.getCss(null, false)) + second.close() + expect(css).toContain('background:red') + expect(owner.disposed).toBe(true) +}) + +it('releases admission and keeps cleanup closed when a disposed scope is called', () => { + const owner = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: true }) + scope.start() + scope.close() + scope.close() + expect(() => scope.run(() => undefined)).toThrow('already closed') + let resets = 0 + beginBuild({ + resetBuildState() { + resets += 1 + }, + })() + expect(resets).toBe(1) +}) 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 000000000..2fa5b4821 --- /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__/build-scope.test.ts b/packages/webpack-plugin/src/__tests__/build-scope.test.ts new file mode 100644 index 000000000..6182166c1 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/build-scope.test.ts @@ -0,0 +1,170 @@ +import * as wasm from '@devup-ui/wasm' +import { beforeEach, expect, it } from 'bun:test' + +import { + bindCompilerScope, + compilerScope, + createWebpackGeneration, + withCompilerScope, +} from '../build-scope' + +beforeEach(() => { + wasm.setDebug(false) + wasm.setPrefix(null) + wasm.registerShorthands({}) +}) + +it('starts fresh when a standalone compiler runs after another owner', () => { + // Given + const first = bindCompilerScope({}) + first.run(() => + wasm + .codeExtract( + 'page.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free(), + ) + // When + const css = bindCompilerScope({}).run(() => wasm.getCss(null, false)) + // Then + expect(css).not.toContain('background:red') +}) + +it('serves the shared handoff when a CSS-only compiler follows server shutdown', () => { + // Given + const owner = createWebpackGeneration() + const server = bindCompilerScope({}, { owner, complete: false }) + server.run(() => + wasm + .codeExtract( + 'page.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free(), + ) + server.start() + server.close() + const compiler = {} + bindCompilerScope(compiler, { owner, complete: true }) + // When + const css = withCompilerScope(compiler, () => wasm.getCss(null, false)) + // Then + expect(css).toContain('.a{background:red}') +}) + +it('replays compiler options when live siblings interleave operations', () => { + // Given + const first = bindCompilerScope({}) + first.setConfiguration(() => { + wasm.setPrefix('first-') + wasm.registerTheme({ colors: { default: { own: 'red' } } }) + }) + const second = bindCompilerScope({}) + second.setConfiguration(() => { + wasm.setPrefix('second') + wasm.registerTheme({ colors: { default: { own: 'blue' } } }) + }) + first.run(() => + wasm + .codeExtract( + 'page.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free(), + ) + second.run(() => + wasm + .codeExtract( + 'page.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free(), + ) + // When + const css = first.run(() => wasm.getCss(null, false)) + // Then + expect(css).toContain('.first-a{background:red}') + expect(css).not.toContain('background:blue') + expect(wasm.getPrefix()).toBe('first-') +}) + +it('does not count repeated watch starts when a participant closes', () => { + // Given + const owner = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: true }) + scope.start() + scope.start() + // When + scope.close() + scope.close() + // Then + expect(owner.disposed).toBe(true) +}) + +it('disposes a failed setup without requiring an actual run lease', () => { + // Given + const owner = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: false }) + // When + scope.abort() + // Then + expect(owner.disposed).toBe(true) +}) + +it('starts an unused configuration independently when its captured generation has closed', () => { + // Given + const owner = createWebpackGeneration() + owner.dispose() + // When + const scope = bindCompilerScope({}, { owner, complete: false }) + scope.start() + const css = scope.run(() => wasm.getCss(null, false)) + scope.close() + // Then + expect(css).not.toContain('background:red') +}) + +it('retains direct-loader behavior when no compiler scope has been installed', () => { + // Given + globalThis.__devupUiBuildScopes = undefined + // When + const value = withCompilerScope(undefined, () => 'unbound') + // Then + expect(value).toBe('unbound') + expect(compilerScope({})).toBeUndefined() +}) + +it('uses the explicit parent scope when a webpack child compiler inherits its root', () => { + // Given + const root = {} + const scope = bindCompilerScope(root) + // When + const inherited = compilerScope({ root }) + // Then + expect(inherited).toBe(scope) +}) 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 000000000..a831d0c13 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/core-regression.test.ts @@ -0,0 +1,261 @@ +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() + } +}) + +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/__tests__/generation-failure.test.ts b/packages/webpack-plugin/src/__tests__/generation-failure.test.ts new file mode 100644 index 000000000..f7db3a6eb --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/generation-failure.test.ts @@ -0,0 +1,205 @@ +import { 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 wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration, Stats, Watching } from 'webpack' + +import { compilerScope, createWebpackGeneration } from '../build-scope' +import { DevupUIWebpackPlugin } from '../plugin' + +const installed = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = installed( + 'next/dist/compiled/webpack/webpack', +) + +function closeCompiler(compiler: Compiler): Promise { + return new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) +} + +function seedSheet(compiler: Compiler): string { + const scope = compilerScope(compiler) + if (!scope) throw new Error('native compiler has no owner scope') + return scope.run(() => { + wasm + .codeExtract( + 'previous.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free() + return wasm.getCss(null, false) + }) +} + +it('discards the shared owner when native production fails before compilation', async () => { + // Given + const root = await mkdtemp(join(tmpdir(), 'devup-owner-fatal-')) + await writeFile(join(root, 'entry.mjs'), 'export const value="ordinary"') + const owner = createWebpackGeneration() + const fault = new Error('fatal-beforeCompile-marker') + const compiler = bundled.webpack({ + context: root, + mode: 'production', + optimization: { minimize: false }, + entry: './entry.mjs', + output: { path: join(root, 'out') }, + plugins: [new DevupUIWebpackPlugin({}, { owner, complete: false })], + }) + compiler.hooks.beforeCompile.tap('FatalOwnerRegression', () => { + throw fault + }) + expect(seedSheet(compiler)).toContain('background:red') + try { + // When + const observed = await new Promise((done) => + compiler.run((error) => done(error)), + ) + await closeCompiler(compiler) + // Then + expect(observed).toBe(fault) + expect(owner.disposed).toBe(true) + expect(wasm.getCss(null, false)).not.toContain('background:red') + } finally { + await closeCompiler(compiler) + await rm(root, { recursive: true, force: true }) + } +}) + +it('discards the shared owner when native production reports compilation errors', async () => { + // Given + const root = await mkdtemp(join(tmpdir(), 'devup-owner-production-error-')) + await writeFile(join(root, 'entry.txt'), 'export const value = ;') + const owner = createWebpackGeneration() + const compiler = bundled.webpack({ + context: root, + mode: 'production', + optimization: { minimize: false }, + entry: './entry.txt', + output: { path: join(root, 'out') }, + module: { rules: [{ test: /\.txt$/, type: 'javascript/auto' }] }, + plugins: [new DevupUIWebpackPlugin({}, { owner, complete: false })], + }) + try { + // When + const stats = await new Promise((done, reject) => + compiler.run((error, result) => { + if (error) return reject(error) + if (!result) return reject(new Error('webpack supplied no stats')) + done(result) + }), + ) + // Then + expect(stats.hasErrors()).toBe(true) + expect(owner.disposed).toBe(true) + } finally { + await closeCompiler(compiler) + await rm(root, { recursive: true, force: true }) + } +}) + +it.each([{}, { watch: true }])( + 'recovers watch (%j)', + async (options) => { + // Given + const root = await mkdtemp(join(tmpdir(), 'devup-owner-watch-failure-')) + const entry = join(root, 'entry.mjs') + await writeFile(entry, 'export const value="first"') + const owner = createWebpackGeneration() + const fault = new Error('watch-beforeCompile-marker') + const compiler = bundled.webpack({ + context: root, + mode: 'development', + entry: './entry.mjs', + output: { path: join(root, 'out') }, + plugins: [new DevupUIWebpackPlugin(options, { owner, complete: true })], + }) + const scope = compilerScope(compiler) + if (!scope) throw new Error('native compiler has no owner scope') + let watching: Watching | undefined + let deadline: ReturnType | undefined + let stage = 0 + let previous = '' + compiler.hooks.beforeCompile.tap('WatchOwnerFailureControl', () => { + if (stage === 1) throw fault + }) + try { + // When: successful watch -> fatal error -> compilation error -> recovery + await new Promise((done, reject) => { + deadline = setTimeout( + () => reject(new Error('watch failure recovery deadline')), + 30000, + ) + watching = compiler.watch({}, (error, stats) => { + try { + if (stage === 1) { + expect(error).toBe(fault) + expect(owner.disposed).toBe(false) + expect(scope.run(() => wasm.getCss(null, false))).toBe(previous) + stage = 2 + writeFile(entry, 'export const value = ;').then( + () => watching?.invalidate(), + reject, + ) + return + } + if (error) return reject(error) + if (!stats) + return reject(new Error('webpack supplied no watch stats')) + if (stage === 0) { + expect(stats.hasErrors()).toBe(false) + previous = seedSheet(compiler) + expect(previous).toContain('background:red') + stage = 1 + watching?.invalidate() + } else if (stage === 2 && stats.hasErrors()) { + expect(owner.disposed).toBe(false) + expect(scope.run(() => wasm.getCss(null, false))).toBe(previous) + stage = 3 + writeFile(entry, 'export const value="recovered"').then( + () => watching?.invalidate(), + reject, + ) + } else if (stage === 3 && !stats.hasErrors()) { + readFile(join(root, 'out/main.js'), 'utf8').then((output) => { + if (output.includes('recovered')) done() + }, reject) + } + } catch (cause) { + reject(cause) + } + }) + }) + // Then + expect(owner.disposed).toBe(false) + expect(scope.run(() => wasm.getCss(null, false))).toBe(previous) + expect(await readFile(join(root, 'out/main.js'), 'utf8')).toContain( + 'recovered', + ) + } finally { + clearTimeout(deadline) + try { + const active = watching + if (active) + await new Promise((done, reject) => + active.close((error) => (error ? reject(error) : done())), + ) + } finally { + await closeCompiler(compiler) + await rm(root, { recursive: true, force: true }) + } + } + }, + 40000, +) diff --git a/packages/webpack-plugin/src/__tests__/generation-native.test.ts b/packages/webpack-plugin/src/__tests__/generation-native.test.ts new file mode 100644 index 000000000..ae5371e9a --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/generation-native.test.ts @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it('delivers related CSS and isolates native same-filesystem/default-output and watch generations', () => { + // Given + const driver = resolve(import.meta.dir, '../../generation-regression.mjs') + // When + const result = spawnSync(process.execPath, [driver], { + encoding: 'utf8', + timeout: 180000, + env: process.env, + }) + // Then + if (result.status !== 0) + process.stderr.write(result.stdout + '\n' + result.stderr) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) +}, 190000) + +it('delivers inherited theme variables in HTML-linked native CSS in both modes and edited generations', () => { + // Given + const driver = resolve( + import.meta.dir, + '../../theme-generation-regression.mjs', + ) + // When + const result = spawnSync(process.execPath, [driver], { + encoding: 'utf8', + timeout: 180000, + env: process.env, + }) + // Then + if (result.status !== 0) + process.stderr.write(result.stdout + '\n' + result.stderr) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) +}, 190000) diff --git a/packages/webpack-plugin/src/__tests__/generation-watch.test.ts b/packages/webpack-plugin/src/__tests__/generation-watch.test.ts new file mode 100644 index 000000000..9fd7916f9 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/generation-watch.test.ts @@ -0,0 +1,77 @@ +import { 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 { expect, it } from 'bun:test' +import type { Compiler, Configuration, Watching } from 'webpack' + +import { DevupUIWebpackPlugin } from '../plugin' + +const installed = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = installed( + 'next/dist/compiled/webpack/webpack', +) + +it('retains live ownership when real webpack watch rebuilds and closes normally', async () => { + // Given + const root = await mkdtemp(join(tmpdir(), 'devup-owner-watch-')) + const entry = join(root, 'entry.mjs') + await writeFile(entry, 'export const value="first"') + const compiler = bundled.webpack({ + context: root, + mode: 'development', + entry: './entry.mjs', + output: { path: join(root, 'out') }, + plugins: [new DevupUIWebpackPlugin({ watch: true })], + }) + let watching: Watching | undefined + let deadline: ReturnType | undefined + let first = true + try { + // When + await new Promise((done, reject) => { + deadline = setTimeout( + () => reject(new Error('watch completion deadline')), + 30000, + ) + watching = compiler.watch({}, (error, stats) => { + if (error) return reject(error) + if (!stats) return reject(new Error('webpack supplied no watch stats')) + if (stats.hasErrors()) + return reject(new Error(stats.toString({ all: false, errors: true }))) + if (first) { + first = false + writeFile(entry, 'export const value="second"').then( + () => watching?.invalidate(), + reject, + ) + } else { + readFile(join(root, 'out/main.js'), 'utf8').then((output) => { + if (output.includes('second')) done() + }, reject) + } + }) + }) + // Then + expect(await readFile(join(root, 'out/main.js'), 'utf8')).toContain( + 'second', + ) + } finally { + clearTimeout(deadline) + try { + const active = watching + if (active) + await new Promise((done, reject) => + active.close((error) => (error ? reject(error) : done())), + ) + } finally { + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + await rm(root, { recursive: true, force: true }) + } + } +}, 40000) diff --git a/packages/webpack-plugin/src/__tests__/index.test.ts b/packages/webpack-plugin/src/__tests__/index.test.ts index 269a31642..b63a9ab74 100644 --- a/packages/webpack-plugin/src/__tests__/index.test.ts +++ b/packages/webpack-plugin/src/__tests__/index.test.ts @@ -1,3 +1,6 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + import { describe, expect, it } from 'bun:test' describe('export', () => { @@ -5,6 +8,58 @@ describe('export', () => { const index = await import('../index') expect({ ...index }).toEqual({ DevupUIWebpackPlugin: expect.any(Function), + createWebpackGeneration: expect.any(Function), + registerWebpackReturnedConfig: expect.any(Function), + readWebpackProductionManifest: expect.any(Function), + sealWebpackProductionManifest: expect.any(Function), + WebpackProductionManifestError: expect.any(Function), }) }) }) + +it('shares actual owner authority when advertised built CJS and ESM entries interleave', () => { + // Given + const cwd = fileURLToPath(new URL('../..', import.meta.url)) + const script = ` + import assert from 'node:assert/strict' + import { createRequire } from 'node:module' + const require = createRequire(process.cwd() + '/package.json') + const cjs = require('@devup-ui/webpack-plugin') + const esm = await import('@devup-ui/webpack-plugin') + assert.equal(require.resolve('@devup-ui/webpack-plugin').endsWith('.cjs'), true) + assert.equal(import.meta.resolve('@devup-ui/webpack-plugin').endsWith('.mjs'), true) + assert.notEqual(cjs.registerWebpackReturnedConfig, esm.registerWebpackReturnedConfig) + for (const [creator, writer] of [[cjs, esm], [esm, cjs]]) { + const owner = creator.createWebpackGeneration() + const config = { context: '/built-actual', name: 'native', entry: { main: './first.ts' } } + writer.registerWebpackReturnedConfig(owner, 'client', config) + assert.equal(creator.readWebpackProductionManifest(owner).configs.client[0], config) + const sealed = creator.sealWebpackProductionManifest(owner) + assert.equal(writer.readWebpackProductionManifest(owner), sealed) + assert.throws(() => writer.registerWebpackReturnedConfig(owner, 'client', config), + { code: 'sealed', operation: 'register', coordinates: [{ role: 'client', context: '/built-actual', name: 'native' }] }) + config.entry.main = './changed.ts' + assert.equal(sealed.configs.client[0].entry.main, './changed.ts') + owner.acquire(true)() + assert.equal(owner.disposed, true) + for (const api of [creator, writer]) { + assert.throws(() => api.readWebpackProductionManifest(owner), { code: 'disposed', operation: 'read' }) + assert.throws(() => api.sealWebpackProductionManifest(owner), { code: 'disposed', operation: 'seal' }) + assert.throws(() => api.registerWebpackReturnedConfig(owner, 'edge', {}), { code: 'disposed', operation: 'register' }) + } + } + console.log('mixed-entry-authority: passed') + ` + // When + const result = spawnSync('node', ['--input-type=module', '-e', script], { + cwd, + encoding: 'utf8', + }) + // Then + expect(result.error).toBeUndefined() + expect({ status: result.status, stderr: result.stderr }).toEqual({ + status: 0, + stderr: '', + }) + expect(result.stdout.trim()).toBe('mixed-entry-authority: passed') +}) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index 8697cb090..c27bdc619 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, @@ -21,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 } @@ -29,17 +34,57 @@ interface TestLoaderContext extends Pick< function createCodeExtractResult( overrides: Partial = {}, + events: string[] = [], ): CodeExtractResult { - return { + const values = { code: '', css: '', cssFile: undefined, updatedBaseStyle: false, map: undefined, - free: () => {}, - [Symbol.dispose]: () => {}, + dependencies: [], ...overrides, - } as unknown as CodeExtractResult + } + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + events.push('free') + }) + return { + get code() { + expect(live).toBe(true) + events.push('code') + return values.code + }, + get css() { + expect(live).toBe(true) + events.push('css') + return values.css + }, + get map() { + expect(live).toBe(true) + events.push('map') + return values.map + }, + get cssFile() { + expect(live).toBe(true) + events.push('cssFile') + return values.cssFile + }, + get updatedBaseStyle() { + expect(live).toBe(true) + events.push('updatedBaseStyle') + return values.updatedBaseStyle + }, + get dependencies() { + expect(live).toBe(true) + events.push('dependencies') + return values.dependencies + }, + free, + [Symbol.dispose]: free, + } } function createLoaderContext( @@ -53,6 +98,7 @@ function createLoaderContext( async: mock().mockReturnValue(callback), resourcePath, addDependency: mock(), + addMissingDependency: mock(), ...overrides, } as unknown as LoaderThis } @@ -74,7 +120,7 @@ let writeFileSpy: ReturnType let dateNowSpy: ReturnType beforeEach(() => { - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( + codeExtractSpy = spyOn(wasm, 'codeExtract').mockImplementation(() => createCodeExtractResult(), ) exportClassMapSpy = spyOn(wasm, 'exportClassMap').mockReturnValue('{}') @@ -125,12 +171,306 @@ const waitFor = async (fn: () => void, timeout = 1000) => { } describe('devupUILoader', () => { + it.each([ + undefined, + 'code', + 'css', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + 'acquire', + 'consumer', + ])( + 'releases webpack Output before consumers when fault %s occurs', + async (fault) => { + const events: string[] = [] + const output = createCodeExtractResult( + { code: 'copied', dependencies: ['dependency.ts'] }, + events, + ) + const error = new Error('ownership fault') + if (fault && fault !== 'acquire' && fault !== 'consumer') + Object.defineProperty(output, fault, { + get() { + events.push(fault) + throw error + }, + }) + codeExtractSpy.mockImplementation(() => { + if (fault === 'acquire') throw error + return output + }) + const result = await new Promise<{ + error: Error | null | undefined + code: Parameters[1] + }>((done) => { + const context = createLoaderContext( + { cssDir: 'df', package: '@devup-ui/react' }, + (error, code) => done({ error, code }), + 'output.tsx', + { + addDependency(path) { + if (path === nodePath.resolve('dependency.ts')) { + expect(output.free).toHaveBeenCalledTimes(1) + if (fault === 'consumer') throw error + } + }, + }, + ) + devupUILoader.call(context, Buffer.from('source')) + }) + expect(result).toEqual( + fault ? { error, code: undefined } : { error: null, code: 'copied' }, + ) + const fields = [ + 'code', + 'css', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ] + expect(events).toEqual( + fault === 'acquire' + ? [] + : [ + ...fields.slice( + 0, + fault && fields.includes(fault) + ? fields.indexOf(fault) + 1 + : fields.length, + ), + 'free', + ], + ) + expect(output.free).toHaveBeenCalledTimes(fault === 'acquire' ? 0 : 1) + }, + ) + 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') + 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-'), + ) + 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, 'setModuleResolver', ).mockReturnValue(undefined) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ dependencies: ['src/tokens.ts'] }), ) const callback = mock() @@ -187,7 +527,7 @@ describe('devupUILoader', () => { exportClassMapSpy.mockReturnValue('classMap') exportFileMapSpy.mockReturnValue('fileMap') getCssSpy.mockReturnValue('css') - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: 'css', @@ -245,7 +585,7 @@ describe('devupUILoader', () => { }, asyncCallback, ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: undefined, @@ -311,7 +651,7 @@ describe('devupUILoader', () => { asyncCallback, ) writeFileSpy.mockRejectedValueOnce(writeError) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: 'css', @@ -340,7 +680,7 @@ describe('devupUILoader', () => { }, asyncCallback, ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: 'css', @@ -376,7 +716,7 @@ describe('devupUILoader', () => { asyncCallback, './foo/index.tsx', ) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: 'css', @@ -404,7 +744,7 @@ describe('devupUILoader', () => { asyncCallback, ) registerThemeSpy.mockReturnValueOnce(undefined) - codeExtractSpy.mockReturnValue( + codeExtractSpy.mockImplementation(() => createCodeExtractResult({ code: 'code', css: 'css', 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 000000000..898869ac6 --- /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 000000000..425e6c984 --- /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__/mixed-admission.test.ts b/packages/webpack-plugin/src/__tests__/mixed-admission.test.ts new file mode 100644 index 000000000..234c58b55 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/mixed-admission.test.ts @@ -0,0 +1,97 @@ +import { beginBuild } from '@devup-ui/plugin-utils' +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' + +import { bindCompilerScope, createWebpackGeneration } from '../build-scope' + +function extract(color: string) { + return wasm + .codeExtract( + 'legacy.tsx', + `import {Box} from '@devup-ui/react';export const x=`, + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + .free() +} + +it('refuses an owned operation before configuration when legacy is live', () => { + // Given + const end = beginBuild({ resetBuildState: wasm.resetBuildState }) + wasm.setPrefix('legacy') + extract('red') + const before = wasm.exportSheet() + const scope = bindCompilerScope({}) + let configured = false + let acted = false + scope.setConfiguration(() => { + configured = true + }) + try { + // When + expect(() => + scope.run(() => { + acted = true + }), + ).toThrow() + // Then + expect(configured).toBe(false) + expect(acted).toBe(false) + expect(wasm.exportSheet()).toBe(before) + } finally { + scope.abort() + end() + } +}) + +it.each(['close', 'abort'] as const)( + 'preserves legacy state when unused scope cleanup is %s', + (cleanup) => { + // Given + const end = beginBuild({ resetBuildState: wasm.resetBuildState }) + wasm.setPrefix('legacy') + extract('red') + const before = wasm.exportSheet() + const owner = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: true }) + scope.start() + try { + // When + scope[cleanup]() + // Then + expect(owner.disposed).toBe(true) + expect(wasm.exportSheet()).toBe(before) + expect(wasm.getPrefix()).toBe('legacy') + } finally { + end() + } + }, +) + +it('rejects legacy begin before reset when an owned action is executing', () => { + // Given + const scope = bindCompilerScope({}) + let resets = 0 + let end: (() => void) | undefined + try { + // When + scope.run(() => { + expect(() => { + end = beginBuild({ + resetBuildState() { + resets += 1 + }, + }) + }).toThrow() + }) + // Then + expect(resets).toBe(0) + } finally { + end?.() + scope.close() + } +}) diff --git a/packages/webpack-plugin/src/__tests__/native-fixture-bundle.test.mjs b/packages/webpack-plugin/src/__tests__/native-fixture-bundle.test.mjs new file mode 100644 index 000000000..66a9d28ad --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/native-fixture-bundle.test.mjs @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { expect, it } from 'bun:test' + +import { + bundleSourceEntries, + nextSourceEntries, + pluginUtilsFixtureManifest, +} from '../../native-fixture-bundle.mjs' + +it('publishes one canonical admission artifact when native fixtures bundle fresh source entries', async () => { + const workspace = resolve(import.meta.dir, '../../../..') + const root = mkdtempSync(join(tmpdir(), 'devup-native-fixture-publication-')) + try { + const directory = join(root, 'node_modules/@devup-ui/plugin-utils') + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify(pluginUtilsFixtureManifest), + ) + const rows = await bundleSourceEntries(workspace, root, nextSourceEntries) + expect(rows.map((row) => row.source)).toContain( + join(workspace, 'packages/plugin-utils/src/build-admission.cts'), + ) + expect(rows.every((row) => row.success)).toBe(true) + const cjs = join(directory, 'index.cjs') + const esm = pathToFileURL(join(directory, 'index.mjs')).href + const result = spawnSync( + 'node', + [ + '--input-type=module', + '--eval', + ` + import assert from 'node:assert/strict'; + import {createRequire} from 'node:module'; + const required=createRequire(${JSON.stringify(cjs)})(${JSON.stringify(cjs)}); + const imported=await import(${JSON.stringify(esm)}); + assert.equal(required.MixedBuildIntegrationError,imported.MixedBuildIntegrationError); + const end=required.beginBuild({}, {integration:'Vite',root:'native-fixture'}); + try {assert.throws(()=>imported.runBuildOperation({integration:'Webpack',root:'owner'},()=>assert.fail('admitted')),required.MixedBuildIntegrationError)} + finally {end()} + `, + ], + { encoding: 'utf8' }, + ) + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/__tests__/next-css-native.test.ts b/packages/webpack-plugin/src/__tests__/next-css-native.test.ts new file mode 100644 index 000000000..692fb1155 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/next-css-native.test.ts @@ -0,0 +1,22 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +it.each(['per-file', 'singleCss'])( + 'delivers native HTML-linked class CSS when mode=%s', + (mode) => { + // Given: current owned sources, installed native Node/Next/React/WASM. + const driver = resolve(import.meta.dir, '../../next-css-regression.mjs') + // When: run outside Bun's mocked engine through the native build surface. + const result = spawnSync(process.execPath, [driver, mode], { + encoding: 'utf8', + timeout: 240000, + env: process.env, + }) + // Then: the driver verifies the emitted selector in HTML-linked CSS. + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + }, + 250000, +) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 524e755bd..c5b19c9d4 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 } } } @@ -46,16 +47,22 @@ function createCodeExtractResult( contents: string, overrides: Partial = {}, ): CodeExtractResult { + let live = true + const free = mock(() => { + expect(live).toBe(true) + live = false + }) return { css: '', code: contents, cssFile: '', map: undefined, updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), + dependencies: [], ...overrides, - } as unknown as CodeExtractResult + free, + [Symbol.dispose]: free, + } } function createStats(mtimeMs: number): Stats { @@ -85,6 +92,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( @@ -111,9 +120,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() @@ -133,6 +152,7 @@ afterEach(() => { readFileSpy.mockRestore() statSpy.mockRestore() writeFileSpy.mockRestore() + graphSpy.mockRestore() }) function createCompiler(): MockCompiler { @@ -166,6 +186,11 @@ function createCompiler(): MockCompiler { thisCompilation: { tap: mock(), }, + shutdown: { + tap: mock((_name: string, closeBuild: () => void) => { + closeBuilds.add(closeBuild) + }), + }, }, } } @@ -173,11 +198,43 @@ 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: [], + 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: [], package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), + cssDir: join('df', 'devup-ui'), devupFile: 'devup.json', distDir: 'df', watch: false, @@ -233,25 +290,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) }) }) @@ -286,10 +338,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), ) }) @@ -306,21 +358,29 @@ describe('devupUIWebpackPlugin', () => { expect(setDebugSpy).toHaveBeenCalledWith(options.debug) }) - it('should reset data files when load error', async () => { + it('starts a fresh watch owner without reading previous-session state', async () => { const plugin = new 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)) existsSyncSpy.mockReturnValue(true) plugin.apply(asCompiler(compiler)) await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() - expect(importSheetSpy).toHaveBeenCalledWith({}) - expect(importClassMapSpy).toHaveBeenCalledWith({}) - expect(importFileMapSpy).toHaveBeenCalledWith({}) + expect( + readFileSyncSpy.mock.calls.some(([path]) => + [plugin.sheetFile, plugin.classMapFile, plugin.fileMapFile].includes( + String(path), + ), + ), + ).toBe(false) + expect(importSheetSpy).not.toHaveBeenCalled() + expect(importClassMapSpy).not.toHaveBeenCalled() + expect(importFileMapSpy).not.toHaveBeenCalled() }) it.each( @@ -388,39 +448,50 @@ describe('devupUIWebpackPlugin', () => { '*', 'utf-8', ) + expect(importSheetSpy).not.toHaveBeenCalledWith({ sheet: 'sheet' }) + expect(importClassMapSpy).not.toHaveBeenCalledWith({ classMap: 'classMap' }) + expect(importFileMapSpy).not.toHaveBeenCalledWith({ fileMap: 'fileMap' }) if (options.watch) { - if (options.existsSheetFile) - expect(importSheetSpy).toHaveBeenCalledWith( - JSON.parse('{"sheet": "sheet"}'), - ) - if (options.existsClassMapFile) - expect(importClassMapSpy).toHaveBeenCalledWith( - JSON.parse('{"classMap": "classMap"}'), - ) - if (options.existsFileMapFile) - expect(importFileMapSpy).toHaveBeenCalledWith( - JSON.parse('{"fileMap": "fileMap"}'), - ) expect(compiler.hooks.watchRun.tapPromise).toHaveBeenCalled() - await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + const watch = compiler.hooks.watchRun.tapPromise.mock.calls.find( + ([name]) => name === 'DevupUIWebpackPlugin', + ) + await watch?.[1]() if (options.existsDevupFile) { expect(statSpy).toHaveBeenCalledWith(plugin.options.devupFile) - await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + await watch?.[1]() } else { expect(statSpy).not.toHaveBeenCalled() } - } else expect(compiler.hooks.watchRun.tapPromise).not.toHaveBeenCalled() + } else + expect( + compiler.hooks.watchRun.tapPromise.mock.calls.map(([name]) => name), + ).toEqual(['DevupUIBuildGeneration']) + 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, @@ -438,16 +509,8 @@ describe('devupUIWebpackPlugin', () => { }) if (!options.watch) { + await compiler.hooks.beforeRun.tapPromise.mock.calls[0][1]() expect(compiler.hooks.done.tapPromise).toHaveBeenCalled() - compiler.hooks.done.tapPromise.mock.calls[0][1]({ - hasErrors: () => true, - }) - expect(writeFileSpy).not.toHaveBeenCalledWith( - join(plugin.options.cssDir, 'devup-ui.css'), - getCssSpy.mock.results[0]?.value, - 'utf-8', - ) - await compiler.hooks.done.tapPromise.mock.calls[0][1]({ hasErrors: () => false, }) @@ -456,6 +519,11 @@ describe('devupUIWebpackPlugin', () => { getCssSpy.mock.results[0]?.value, 'utf-8', ) + writeFileSpy.mockClear() + await compiler.hooks.done.tapPromise.mock.calls[0][1]({ + hasErrors: () => true, + }) + expect(writeFileSpy).not.toHaveBeenCalled() } else { expect(compiler.hooks.done.tapPromise).not.toHaveBeenCalled() } @@ -539,6 +607,25 @@ describe('devupUIWebpackPlugin', () => { resolve(process.cwd(), 'src', 'parent.tsx'), resolve(process.cwd(), 'src', 'child.tsx'), ]) + const discarded: CodeExtractResult[] = [] + codeExtractSpy.mockImplementation((_path: string, contents: string) => { + const output = createCodeExtractResult(contents) + for (const field of [ + 'code', + 'css', + 'map', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', + ]) + Object.defineProperty(output, field, { + get() { + throw new Error(`discarded prewarm read ${field}`) + }, + }) + discarded.push(output) + return output + }) readFileSyncSpy.mockReturnValue('source') const setModuleResolverSpy = spyOn( wasm, @@ -555,14 +642,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( @@ -573,12 +662,15 @@ 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(discarded).toHaveLength(2) + for (const output of discarded) + expect(output.free).toHaveBeenCalledTimes(1) expect(codeExtractSpy).toHaveBeenCalledWith( - 'src/parent.tsx', + relative('/project', resolve('src/parent.tsx')).replaceAll('\\', '/'), 'source', '@devup-ui/react', expect.any(String), @@ -588,7 +680,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), @@ -622,7 +714,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', }) @@ -634,8 +726,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() }) @@ -683,13 +776,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() }) @@ -741,16 +835,20 @@ 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', () => { 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/__tests__/production-manifest.test.ts b/packages/webpack-plugin/src/__tests__/production-manifest.test.ts new file mode 100644 index 000000000..17f319c22 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/production-manifest.test.ts @@ -0,0 +1,280 @@ +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Configuration } from 'webpack' + +import { bindCompilerScope, createWebpackGeneration } from '../build-scope' +import { + readWebpackProductionManifest as read, + registerWebpackReturnedConfig as register, + sealWebpackProductionManifest as seal, + WebpackProductionManifestError, + type WebpackProductionRole, +} from '../production-manifest' + +function rejection(action: () => unknown): WebpackProductionManifestError { + try { + action() + } catch (error) { + if (error instanceof WebpackProductionManifestError) return error + throw error + } + throw new Error('Expected registry rejection') +} + +it.each([ + ['client', 'nodejs', 'edge'], + ['edge', 'nodejs', 'client'], +] satisfies WebpackProductionRole[][])( + 'retains exact per-role membership when arrivals are %s/%s/%s', + (...roles) => { + // Given + const owner = createWebpackGeneration() + const first: Configuration = {} + const second: Configuration = {} + // When + for (const role of roles) register(owner, role, first) + register(owner, 'client', first) + register(owner, 'client', second) + const result = read(owner) + // Then + expect(result.phase).toBe('open') + expect(result.configs.client).toEqual([first, second]) + expect(result.configs.client[0]).toBe(first) + expect(result.configs.client[1]).toBe(second) + expect(result.configs.nodejs[0]).toBe(first) + expect(result.configs.edge[0]).toBe(first) + }, +) + +it('leaves unseen owners absent when reading empty open metadata', () => { + // Given + const owner = createWebpackGeneration() + // When + const result = read(owner) + // Then + expect(result).toEqual({ + phase: 'open', + configs: { client: [], nodejs: [], edge: [] }, + }) + expect( + globalThis.__devupUiWebpackProductionManifests?.has(owner) ?? false, + ).toBe(false) +}) + +it.each([false, true])( + 'seals only current membership when partial=%s', + (partial) => { + // Given + const owner = createWebpackGeneration() + const empty = read(owner) + const config: Configuration = {} + if (partial) register(owner, 'nodejs', config) + const open = read(owner) + // When + const result = seal(owner) + // Then + expect(result.phase).toBe('sealed') + expect(result.configs).toEqual({ + client: [], + nodejs: partial ? [config] : [], + edge: [], + }) + expect(read(owner)).toBe(result) + expect(seal(owner)).toBe(result) + expect(empty.configs.nodejs).toEqual([]) + expect(open.phase).toBe('open') + expect(owner.disposed).toBe(false) + for (const snapshot of [empty, open, result]) { + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot.configs)).toBe(true) + for (const configs of Object.values(snapshot.configs)) + expect(Object.isFrozen(configs)).toBe(true) + } + }, +) + +it('retains native mutability when entries change before and after seal', () => { + // Given + const owner = createWebpackGeneration() + const entry = { main: './first.ts' } + const config: Configuration = { entry } + register(owner, 'client', config) + const before = read(owner) + entry.main = './second.ts' + // When + const result = seal(owner) + entry.main = './third.ts' + config.entry = { other: './fourth.ts' } + owner.dispose() + // Then + expect(before.configs.client[0]).toBe(config) + expect(result.configs.client[0]).toBe(config) + expect(result.configs.client[0]?.entry).toEqual({ other: './fourth.ts' }) + expect(entry.main).toBe('./third.ts') + expect(Object.isFrozen(config)).toBe(false) + expect(Object.isFrozen(entry)).toBe(false) + expect(Object.isFrozen(result)).toBe(true) +}) + +it('never evaluates entries when registering, reading, sealing or rejecting', () => { + // Given + const owner = createWebpackGeneration() + const config: Configuration = { + get entry(): Configuration['entry'] { + throw new Error('Entry evaluated') + }, + } + // When + register(owner, 'edge', config) + const open = read(owner) + const sealed = seal(owner) + const late = rejection(() => register(owner, 'edge', config)) + owner.dispose() + const disposed = rejection(() => read(owner)) + // Then + expect(open.configs.edge[0]).toBe(config) + expect(sealed.configs.edge[0]).toBe(config) + expect(late.code).toBe('sealed') + expect(disposed.coordinates).toEqual([{ role: 'edge' }]) +}) + +it.each([false, true])( + 'rejects every registration when sealed and identical=%s', + (identical) => { + // Given + const owner = createWebpackGeneration() + const config: Configuration = { context: '/actual', name: 'actual-client' } + register(owner, 'client', config) + const snapshot = seal(owner) + const attempted = identical ? config : {} + // When + const error = rejection(() => register(owner, 'client', attempted)) + // Then + expect(error.code).toBe('sealed') + expect(error.operation).toBe('register') + expect(error.coordinates).toEqual([ + identical + ? { role: 'client', context: '/actual', name: 'actual-client' } + : { role: 'client' }, + ]) + expect(Object.hasOwn(error, 'cause')).toBe(false) + expect(Object.isFrozen(error.coordinates)).toBe(true) + expect(Object.isFrozen(error.coordinates[0])).toBe(true) + expect(read(owner)).toBe(snapshot) + }, +) + +it.each(['unseen', 'open', 'sealed'] as const)( + 'rejects all access with disposed precedence when previously %s', + (state) => { + // Given + const owner = createWebpackGeneration() + const config: Configuration = { context: '/stored', name: 'stored' } + if (state !== 'unseen') { + register(owner, 'nodejs', config) + register(owner, 'edge', {}) + } + if (state === 'sealed') seal(owner) + owner.dispose() + const attempted: Configuration = { name: 'attempted' } + // When + const errors = [ + rejection(() => register(owner, 'client', attempted)), + rejection(() => read(owner)), + rejection(() => seal(owner)), + ] + // Then + expect(errors.map((error) => [error.code, error.operation])).toEqual([ + ['disposed', 'register'], + ['disposed', 'read'], + ['disposed', 'seal'], + ]) + expect(errors[0]?.coordinates).toEqual([ + { role: 'client', name: 'attempted' }, + ]) + for (const error of errors.slice(1)) { + expect(error.coordinates).toEqual( + state === 'unseen' + ? [] + : [ + { role: 'nodejs', context: '/stored', name: 'stored' }, + { role: 'edge' }, + ], + ) + expect(Object.isFrozen(error.coordinates)).toBe(true) + expect(error.coordinates.every(Object.isFrozen)).toBe(true) + expect(Object.hasOwn(error, 'cause')).toBe(false) + } + }, +) + +it('does not pin completion or alias fresh owners when unused roles are registered', () => { + // Given + const owner = createWebpackGeneration() + const config: Configuration = { context: '/same', name: 'same' } + register(owner, 'edge', config) + register(owner, 'nodejs', config) + const independent = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: true }) + scope.start() + // When + scope.close() + const fresh = createWebpackGeneration() + register(fresh, 'client', config) + // Then + expect(owner.disposed).toBe(true) + expect(read(independent).configs).toEqual({ + client: [], + nodejs: [], + edge: [], + }) + expect(read(fresh).configs).toEqual({ + client: [config], + nodejs: [], + edge: [], + }) + expect(rejection(() => read(owner)).code).toBe('disposed') +}) + +it('preserves actual engine bytes and extraction when metadata runs in a bound scope', () => { + // Given + const owner = createWebpackGeneration() + const scope = bindCompilerScope({}, { owner, complete: true }) + const capture = () => [ + wasm.exportSheet(), + wasm.exportClassMap(), + wasm.exportFileMap(), + wasm.exportCanonicalMap(), + ] + const extract = () => { + const output = wasm.codeExtract( + 'manifest.tsx', + "import {Box} from '@devup-ui/react';export const x=", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + try { + return [output.code, wasm.getCss(null, false)] + } finally { + output.free() + } + } + const expected = scope.run(extract) + // When + const actual = scope.run(() => { + const before = capture() + register(owner, 'client', {}) + read(owner) + seal(owner) + expect(capture()).toEqual(before) + return extract() + }) + // Then + expect(actual).toEqual(expected) + expect(actual[1]).toContain('background:red') + scope.abort() +}) 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 000000000..8b4e10c1c --- /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 000000000..b0ec77f01 --- /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/__tests__/theme-replay.test.ts b/packages/webpack-plugin/src/__tests__/theme-replay.test.ts new file mode 100644 index 000000000..92392a559 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/theme-replay.test.ts @@ -0,0 +1,141 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it } from 'bun:test' +import type { Compiler, Configuration } from 'webpack' + +import { compilerScope } from '../build-scope' +import { DevupUIWebpackPlugin } from '../plugin' + +const installed = createRequire( + resolve(import.meta.dir, '../../../../apps/landing/package.json'), +) +const bundled: { webpack(config: Configuration): Compiler } = installed( + 'next/dist/compiled/webpack/webpack', +) + +function extractTokens(compiler: Compiler) { + const scope = compilerScope(compiler) + if (!scope) throw new Error('native compiler has no owner scope') + return scope.run(() => { + const result = wasm.codeExtract( + 'tokens.tsx', + 'import {Box} from "@devup-ui/react";export const view=', + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + let code: string + try { + code = result.code + } finally { + result.free() + } + return { code, css: wasm.getCss(null, false) } + }) +} + +it('replays compiler-local theme tokens when owners interleave A/B/A', async () => { + // Given + const root = await mkdtemp(join(tmpdir(), 'devup-theme-replay-')) + const compilers: Compiler[] = [] + try { + for (const [name, theme] of [ + [ + 'A', + { + colors: { default: { ink: 'red' } }, + length: { default: { size: ['11px', null, '33px'] } }, + shadow: { + default: { shade: ['0 1px 2px red', null, '0 3px 4px red'] }, + }, + typography: { + body: [{ fontSize: '13px' }, null, { fontSize: '17px' }], + }, + }, + ], + [ + 'B', + { + colors: { default: { ink: 'blue' } }, + length: { default: { size: ['22px', '44px'] } }, + shadow: { default: { shade: ['0 2px 3px blue', '0 4px 5px blue'] } }, + typography: { body: [{ fontSize: '19px' }, { fontSize: '23px' }] }, + }, + ], + ] as const) { + const context = join(root, name) + await mkdir(context) + await writeFile(join(context, 'devup.json'), JSON.stringify({ theme })) + await writeFile(join(context, 'entry.mjs'), 'export const ordinary=1') + compilers.push( + bundled.webpack({ + context, + mode: 'production', + optimization: { minimize: false }, + entry: './entry.mjs', + plugins: [new DevupUIWebpackPlugin({ prefix: `${name}-` })], + }), + ) + } + const [a, b] = compilers + if (!a || !b) throw new Error('theme fixture needs both compilers') + // When + const first = extractTokens(a) + const other = extractTokens(b) + const restored = extractTokens(a) + // Then + expect(restored).toEqual(first) + expect(first.code).toContain('A-') + expect(other.code).toContain('B-') + expect(first.css).toContain('--ink:red') + expect(other.css).toContain('--ink:blue') + for (const value of [ + '11px', + '33px', + '0 1px 2px red', + '0 3px 4px red', + '13px', + '17px', + ]) + expect(first.css).toContain(value) + for (const value of [ + '22px', + '44px', + '0 2px 3px blue', + '0 4px 5px blue', + '19px', + '23px', + ]) + expect(other.css).toContain(value) + for (const result of [first, other]) { + expect( + result.code.match(/className="([^"]+)"/)?.[1]?.split(' '), + ).toHaveLength(6) + expect(result.code).toContain('typo-body') + expect(result.css).toContain('.typo-body{') + expect(result.css.match(/width:var\(--size\)/g)).toHaveLength(2) + expect(result.css.match(/box-shadow:var\(--shade\)/g)).toHaveLength(2) + } + expect(first.css).toContain('768px') + expect(first.css).not.toContain('480px') + expect(other.css).toContain('480px') + expect(other.css).not.toContain('768px') + expect(first.css).toContain('font-size:17px') + expect(other.css).toContain('font-size:23px') + expect(first.css).not.toContain('blue') + expect(other.css).not.toContain('red') + } finally { + for (const compiler of compilers) + await new Promise((done, reject) => + compiler.close((error) => (error ? reject(error) : done())), + ) + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/__tests__/vite-admission.test.ts b/packages/webpack-plugin/src/__tests__/vite-admission.test.ts new file mode 100644 index 000000000..575805fe8 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/vite-admission.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { + MixedBuildIntegrationError, + runBuildOperation, +} from '@devup-ui/plugin-utils' +import { expect, it } from 'bun:test' + +import { DevupUI } from '../../../vite-plugin/src/plugin' + +type TestPlugin = { name: string; closeBundle(this: void): void } + +it('reports the actual Vite root when an owned operation is refused during its live interval', async () => { + const vite: typeof import('vite') = createRequire( + resolve(import.meta.dir, '../../../vite-plugin/package.json'), + )('vite') + const root = realpathSync.native( + mkdtempSync(join(tmpdir(), 'devup-vite-admission-')), + ) + const plugins = DevupUI() as unknown as [TestPlugin] + try { + await vite.resolveConfig({ configFile: false, root, plugins }, 'serve') + let active + try { + runBuildOperation( + { integration: 'Webpack', root: '/attempted-webpack' }, + () => undefined, + ) + } catch (error) { + if (!(error instanceof MixedBuildIntegrationError)) throw error + active = error.active + } + expect(active?.root.replaceAll('\\', '/')).toBe(root.replaceAll('\\', '/')) + expect(active?.integration).toBe('Vite') + } finally { + plugins[0].closeBundle.call(undefined) + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/build-scope.ts b/packages/webpack-plugin/src/build-scope.ts new file mode 100644 index 000000000..97e4c88b7 --- /dev/null +++ b/packages/webpack-plugin/src/build-scope.ts @@ -0,0 +1,146 @@ +import { + BuildGeneration, + type BuildIntegration, + resetOwnedBuildState, + runBuildOperation, +} from '@devup-ui/plugin-utils' +import { + exportCanonicalMap, + exportClassMap, + exportFileMap, + exportSheet, + importCanonicalMap, + importClassMap, + importFileMap, + importSheet, + resetBuildState, +} from '@devup-ui/wasm' + +/** @internal The public WASM exports own these JSON schemas. */ +interface SerializedBuildState { + readonly sheet: string + readonly classes: string + readonly files: string + readonly canonical: string +} + +/** @internal No owner is accepted through user-facing plugin options. */ +export interface WebpackGenerationBinding { + readonly owner: BuildGeneration + readonly complete: boolean +} + +const engine = { + reset: resetBuildState, + restore(state: SerializedBuildState) { + const sheet: unknown = JSON.parse(state.sheet) + const classes: unknown = JSON.parse(state.classes) + const files: unknown = JSON.parse(state.files) + const canonical: unknown = JSON.parse(state.canonical) + importSheet(sheet) + importClassMap(classes) + importFileMap(files) + importCanonicalMap(canonical) + }, + capture(): SerializedBuildState { + return { + sheet: exportSheet(), + classes: exportClassMap(), + files: exportFileMap(), + canonical: exportCanonicalMap(), + } + }, +} + +/** @internal Minted by the integration, never inferred from paths or caches. */ +export function createWebpackGeneration(): BuildGeneration { + return new BuildGeneration() +} + +export class WebpackBuildScope { + private configure: () => void = () => undefined + private release: (() => void) | undefined + private closed = false + + constructor( + private readonly binding: WebpackGenerationBinding, + private readonly standalone: boolean, + private readonly context: BuildIntegration = { + integration: 'Webpack', + root: process.cwd(), + }, + ) {} + + setConfiguration(configure: () => void): void { + this.configure = configure + } + + run(action: () => T): T { + return runBuildOperation(this.context, () => + this.binding.owner.run(engine, () => { + this.configure() + return action() + }), + ) + } + + start(): void { + this.release ??= this.binding.owner.acquire(this.binding.complete) + } + + close(): void { + if (this.closed) return + this.closed = true + this.release?.() + this.release = undefined + if (this.standalone) this.binding.owner.dispose() + resetOwnedBuildState({ resetBuildState: engine.reset }) + } + + abort(): void { + this.binding.owner.dispose() + this.close() + } +} + +declare global { + // Bundled entry/loader modules share only weak compiler identity, never paths. + var __devupUiBuildScopes: WeakMap | undefined +} + +export function bindCompilerScope( + compiler: object, + binding?: WebpackGenerationBinding, + context?: BuildIntegration, +): WebpackBuildScope { + const selected = + binding && !binding.owner.disposed + ? binding + : { owner: createWebpackGeneration(), complete: true } + const scope = new WebpackBuildScope(selected, selected !== binding, context) + ;(globalThis.__devupUiBuildScopes ??= new WeakMap()).set(compiler, scope) + return scope +} + +interface CompilerIdentity { + readonly root?: object +} + +export function compilerScope( + compiler: CompilerIdentity | undefined, +): WebpackBuildScope | undefined { + return compiler === undefined + ? undefined + : (globalThis.__devupUiBuildScopes?.get(compiler) ?? + (compiler.root + ? globalThis.__devupUiBuildScopes?.get(compiler.root) + : undefined)) +} + +export function withCompilerScope( + compiler: CompilerIdentity | undefined, + action: () => T, +): T { + const scope = compilerScope(compiler) + return scope ? scope.run(action) : action() +} diff --git a/packages/webpack-plugin/src/compiled-guard.ts b/packages/webpack-plugin/src/compiled-guard.ts new file mode 100644 index 000000000..4e1ca767e --- /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/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index 450d9e671..3fcfc692f 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -2,12 +2,15 @@ import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { withCompilerScope } from './build-scope' import { servedCss } from './served-css' const devupUICssLoader: RawLoaderDefinitionFunction = function (_, map, meta) { // The stylesheet comes from every extracted module, not from this file this.cacheable(false) - const css = getCss(getFileNumByFilename(this.resourcePath), true) + const css = withCompilerScope(this._compiler, () => + getCss(getFileNumByFilename(this.resourcePath), true), + ) if (this._compilation) servedCss(this._compilation).set(this.resourcePath, css) this.callback(null, css, map, meta) diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts index 63cc6af13..00704c50a 100644 --- a/packages/webpack-plugin/src/index.ts +++ b/packages/webpack-plugin/src/index.ts @@ -1,4 +1,17 @@ +export { + createWebpackGeneration, + type WebpackGenerationBinding, +} from './build-scope' export { DevupUIWebpackPlugin, type DevupUIWebpackPluginOptions, } from './plugin' +export { + readWebpackProductionManifest, + registerWebpackReturnedConfig, + sealWebpackProductionManifest, + type WebpackManifestCoordinate, + type WebpackProductionManifest, + WebpackProductionManifestError, + type WebpackProductionRole, +} from './production-manifest' diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 7b7f5e758..16e3a3492 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,7 +1,15 @@ 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, + isMdxSource, + type ModuleAliasOptions, + remapMdxError, + type ResolutionInputObserver, + resolutionWatchPath, +} from '@devup-ui/plugin-utils' import { codeExtract, exportClassMap, @@ -12,6 +20,8 @@ import { } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' +import { withCompilerScope } from './build-scope' + export interface DevupUILoaderOptions { package: string cssDir: string @@ -21,6 +31,11 @@ export interface DevupUILoaderOptions { watch: boolean singleCss: boolean importAliases?: Record + rootDir?: string + conditions?: readonly string[] + mdxExtensions?: readonly string[] + alias?: ModuleAliasOptions + symlinks?: boolean } function toLoaderError(error: unknown): Error { @@ -34,18 +49,32 @@ function parseSourceMap(sourceMap: string | undefined): string | null { return sourceMap } -let moduleResolver: ReturnType | undefined - +const stateWriter = createStateWriter((path, content, encoding) => + encoding ? writeFile(path, content, encoding) : writeFile(path, content), +) /** 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(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, onResolutionInputs } = + options + const moduleResolver = createModuleResolver({ + cwd: rootDir, + includeMdx: mdxExtensions, + conditions, + alias, + onResolutionInputs, + toId: (path) => relative(rootDir, path).replaceAll('\\', '/'), }) setModuleResolver(moduleResolver) } const devupUILoader: RawLoaderDefinitionFunction = - function (source) { + function (source, inputSourceMap) { const { watch, package: libPackage, @@ -55,6 +84,11 @@ const devupUILoader: RawLoaderDefinitionFunction = fileMapFile, singleCss, importAliases = {}, + rootDir = process.cwd(), + conditions = ['import', 'module', 'node'], + mdxExtensions = ['.mdx'], + alias, + symlinks = true, } = this.getOptions() const callback = this.async() const id = this.resourcePath @@ -66,67 +100,110 @@ const devupUILoader: RawLoaderDefinitionFunction = } try { - let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') + withCompilerScope(this._compiler, () => { + let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') - // POSIX-normalize so the engine's bucket key matches the canonical map / - // 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('\\', '/') + // POSIX-normalize so the engine's bucket key matches the canonical map / + // 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(rootDir, id).replaceAll('\\', '/') - if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - setCwdModuleResolver() - const { - code, - css = '', - map, - cssFile, - updatedBaseStyle, - dependencies = [], - } = codeExtract( - relativePath, - source.toString(), - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ) - for (const dependency of dependencies) { - this.addDependency(resolve(dependency)) - } - const sourceMap = parseSourceMap(map) - const promises: Promise[] = [] - if (updatedBaseStyle) { - // update base style - promises.push( - writeFile(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( - join(cssDir, basename(cssFile)), - watch ? `/* ${content} */` : css, - ), + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + 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 output = codeExtract( + relativePath, + source.toString(), + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ...(isMdxSource(id, mdxExtensions) + ? (['compiled-mdx'] as const) + : ([] as const)), ) - if (watch) { + const { + code, + css = '', + map, + cssFile, + updatedBaseStyle, + dependencies = [], + } = (() => { + try { + return { + code: output.code, + css: output.css, + map: output.map, + cssFile: output.cssFile, + updatedBaseStyle: output.updatedBaseStyle, + dependencies: output.dependencies, + } + } finally { + output.free() + } + })() + for (const dependency of dependencies) { + this.addDependency(resolve(rootDir, dependency)) + } + const sourceMap = parseSourceMap(map) + const promises: Promise[] = [] + if (updatedBaseStyle) { + // update base style promises.push( - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), + stateWriter.write( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + ), ) } - } - Promise.all(promises).then( - () => callback(null, code, sourceMap as Parameters[2]), - (error) => callback(toLoaderError(error)), - ) + if (cssFile) { + const content = `${this.resourcePath} ${Date.now()}` + // should be reset css + promises.push( + stateWriter.write( + join(cssDir, basename(cssFile)), + watch ? `/* ${content} */` : css, + ), + ) + if (watch) { + promises.push( + stateWriter.write(sheetFile, exportSheet()), + stateWriter.write(classMapFile, exportClassMap()), + stateWriter.write(fileMapFile, exportFileMap()), + ) + } + } + Promise.all(promises).then( + () => + callback(null, code, sourceMap as Parameters[2]), + (error) => callback(toLoaderError(error)), + ) + }) } catch (error) { - callback(toLoaderError(error)) + callback( + isMdxSource(id, mdxExtensions) + ? remapMdxError( + error, + relative(rootDir, id).replaceAll('\\', '/'), + inputSourceMap, + ) + : toLoaderError(error), + ) } return } diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 83b2c2264..5079fdd04 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,10 +1,12 @@ 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 { buildCanonicalMap, + buildStaticImportGraph, + collectNumberedFiles, computeFileReach, computeReachableFiles, createCompatTypes, @@ -12,11 +14,22 @@ import { createNodeModulesExcludeRegex, createThemeInterfaceArgs, type CustomShorthands, + extractedNeedles, getFileNumByFilename, type ImportAliases, + isSelectedSource, loadDevupConfigSync, + mdxSourceFilter, mergeImportAliases, + normalizeMdxExtensions, planAtomHoist, + type ResolutionInputObserver, + resolutionWatchPath, + resolveProjectPaths, + resolveSourceDirs, + seedFileNumbers, + SOURCE_FILE_RE, + type StaticImportGraph, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -25,12 +38,10 @@ import { getDefaultTheme, getThemeInterface, importCanonicalMap, - importClassMap, - importFileMap, importFileRoutes, - importSheet, registerShorthands, registerTheme, + seedFileMap, setAtomHoist, setDebug, setModuleResolver, @@ -38,6 +49,12 @@ import { } from '@devup-ui/wasm' import { type Compiler } from 'webpack' +import { + bindCompilerScope, + type WebpackBuildScope, + type WebpackGenerationBinding, +} from './build-scope' +import { registerCompiledGuard } from './compiled-guard' import { servedCss } from './served-css' export interface DevupUIWebpackPluginOptions { @@ -51,6 +68,8 @@ export interface DevupUIWebpackPluginOptions { singleCss: boolean prefix?: string shorthands?: CustomShorthands + sourceDirs?: string | string[] + mdxExtensions?: readonly string[] /** * Atom-level route-aware hoisting threshold. * @@ -82,23 +101,38 @@ export class DevupUIWebpackPlugin { classMapFile: string fileMapFile: string private importAliases: WasmImportAliases + private excludeModules: RegExp + private readonly mdxExtensions: readonly string[] + 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'), - watch = false, - debug = false, - include = [], - singleCss = false, - prefix, - shorthands, - atomHoist, - importAliases: userImportAliases, - }: Partial = {}) { - registerShorthands(shorthands ?? {}) + constructor( + { + package: libPackage = '@devup-ui/react', + devupFile = 'devup.json', + distDir = 'df', + cssDir = join(distDir, 'devup-ui'), + watch = false, + debug = false, + include = [], + singleCss = false, + prefix, + shorthands, + sourceDirs, + atomHoist, + importAliases: userImportAliases, + mdxExtensions, + }: Partial = {}, + private readonly generationBinding?: WebpackGenerationBinding, + ) { + this.mdxExtensions = normalizeMdxExtensions(mdxExtensions) this.importAliases = mergeImportAliases(userImportAliases) + this.excludeModules = createNodeModulesExcludeRegex(include) + this.pathOptions = { devupFile, distDir, cssDir } this.options = { package: libPackage, @@ -110,7 +144,9 @@ export class DevupUIWebpackPlugin { include, singleCss, prefix, + shorthands, atomHoist, + sourceDirs, } this.sheetFile = join(this.options.distDir, 'sheet.json') @@ -118,59 +154,39 @@ export class DevupUIWebpackPlugin { this.fileMapFile = join(this.options.distDir, 'fileMap.json') } - writeDataFiles() { - try { - const config = loadDevupConfigSync(this.options.devupFile) - const theme = config.theme ?? {} + writeDataFiles(options = this.options) { + const config = loadDevupConfigSync(options.devupFile) + const theme = config.theme ?? {} - registerTheme(theme) - const interfaceCode = getThemeInterface( - ...createThemeInterfaceArgs(this.options.package), - ) + registerTheme(theme) + const interfaceCode = getThemeInterface( + ...createThemeInterfaceArgs(options.package), + ) - if (interfaceCode) { - writeFileSync(join(this.options.distDir, 'theme.d.ts'), interfaceCode, { - encoding: 'utf-8', - }) - } - } catch (error) { - console.error(error) - registerTheme({}) - } - if (!existsSync(this.options.cssDir)) - mkdirSync(this.options.cssDir, { recursive: true }) - if (this.options.watch) - writeFileSync( - join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), - ) + writeFileSync(join(options.distDir, 'theme.d.ts'), interfaceCode, { + encoding: 'utf-8', + }) + if (!existsSync(options.cssDir)) + mkdirSync(options.cssDir, { recursive: true }) + if (options.watch) + writeFileSync(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) + return theme } - /** - * 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( '\\', @@ -186,19 +202,143 @@ export class DevupUIWebpackPlugin { false, true, this.importAliases, + ).free() + } 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) { - setDebug(this.options.debug) - if (this.options.prefix) { - setPrefix(this.options.prefix) + if (compiler.hooks.afterEnvironment) { + compiler.hooks.afterEnvironment.tap('DevupUIWebpackPlugin', () => { + this.setupCompiler(compiler) + }) + } else { + this.setupCompiler(compiler) + } + } + + private setupCompiler(compiler: Compiler) { + const scope = bindCompilerScope(compiler, this.generationBinding, { + integration: 'Webpack', + root: compiler.context ?? compiler.options.context ?? process.cwd(), + }) + compiler.hooks.beforeRun.tapPromise('DevupUIBuildGeneration', async () => + scope.start(), + ) + compiler.hooks.watchRun.tapPromise('DevupUIBuildGeneration', async () => + scope.start(), + ) + compiler.hooks.shutdown?.tap('DevupUIBuildGeneration', () => scope.close()) + compiler.hooks.watchClose?.tap('DevupUIBuildGeneration', () => + scope.close(), + ) + compiler.hooks.failed?.tap('DevupUIBuildGeneration', () => { + if (!compiler.watchMode) scope.abort() + }) + try { + scope.run(() => this.configureCompiler(compiler, scope)) + } catch (cause) { + scope.abort() + throw cause } + } + + private configureCompiler(compiler: Compiler, scope: WebpackBuildScope) { + const cwd = compiler.context ?? compiler.options.context ?? process.cwd() + const paths = resolveProjectPaths(cwd, this.pathOptions) + this.options = { ...this.options, ...paths } + const options = this.options + 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 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', + '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.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 isSelectedSource(resource, this.mdxExtensions) || + !extname(resource) + ? [resolve(cwd, resource)] + : [] + }), + ) + const roots = [ + ...new Set([...sourceDirs, ...entries.map((file) => dirname(file))]), + ] + const endBuild = () => scope.abort() + let moduleResolver: ReturnType + try { + moduleResolver = createModuleResolver({ + cwd, + includeMdx: this.mdxExtensions, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }) + setModuleResolver(moduleResolver) + } 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) + registerShorthands(this.options.shorthands ?? {}) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) @@ -210,23 +350,16 @@ export class DevupUIWebpackPlugin { 'utf-8', ) - if (this.options.watch) { - try { - // load sheet - if (existsSync(this.sheetFile)) - importSheet(JSON.parse(readFileSync(this.sheetFile, 'utf-8'))) - if (existsSync(this.classMapFile)) - importClassMap(JSON.parse(readFileSync(this.classMapFile, 'utf-8'))) - if (existsSync(this.fileMapFile)) - importFileMap(JSON.parse(readFileSync(this.fileMapFile, 'utf-8'))) - } catch (error) { - console.error(error) - importSheet({}) - importClassMap({}) - importFileMap({}) - } + let theme: ReturnType + try { + theme = this.writeDataFiles(options) + } catch (cause) { + endBuild() + throw new Error( + `[devup-ui] theme setup failed at ${cwd}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ) } - this.writeDataFiles() // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any loader // runs codeExtract (apply() body is synchronous, loaders run during @@ -242,15 +375,26 @@ 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 + let routes: Record = {} + let threshold: number | undefined 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, { + includeMdx: this.mdxExtensions, + cwd, + include: this.options.include, + conditions, + alias: resolveOptions?.alias, + onResolutionInputs, + }) const canonicalMap = buildCanonicalMap({ srcDir, tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, }) importCanonicalMap(canonicalMap) @@ -260,9 +404,13 @@ export class DevupUIWebpackPlugin { tsconfigPath, cwd, keyBy: 'cwd-relative', + graph, + entries: entries.length ? entries : undefined, }) const plan = planAtomHoist(canonicalMap, fileReach, atomHoist) if (plan) { + routes = plan.reachByBucket + threshold = plan.threshold importFileRoutes(plan.reachByBucket) setAtomHoist(plan.threshold) } else { @@ -271,11 +419,38 @@ 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 { + seedFileNumbers( + { seedFileMap }, + collectNumberedFiles({ + roots, + includeMdx: this.mdxExtensions, + include: this.options.include, + cwd, + needles: extractedNeedles(this.options.package, this.importAliases), + toId: (path) => relative(cwd, path).replaceAll('\\', '/'), + }), + ) + } 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. // // Webpack builds a stylesheet module ONCE, at its FIRST import: the shared @@ -286,14 +461,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) { @@ -304,7 +477,9 @@ export class DevupUIWebpackPlugin { const modifiedTime = stats.mtimeMs if (lastModifiedTime && lastModifiedTime !== modifiedTime) - this.writeDataFiles() + scope.run(() => { + theme = this.writeDataFiles(options) + }) lastModifiedTime = modifiedTime } @@ -312,14 +487,14 @@ export class DevupUIWebpackPlugin { } if (existsDevup) compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => { - compilation.fileDependencies.add(resolve(this.options.devupFile)) + compilation.fileDependencies.add(resolve(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 @@ -332,27 +507,29 @@ export class DevupUIWebpackPlugin { compiler.hooks.thisCompilation.tap( 'DevupUIWebpackPlugin', (compilation) => { - const basePath = join(this.options.cssDir, 'devup-ui.css') - const base = getCss(null, true) + const basePath = join(options.cssDir, 'devup-ui.css') + const base = scope.run(() => 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] - }) + compilation.hooks.finishModules.tap('DevupUIWebpackPlugin', () => + scope.run(() => { + 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( { @@ -370,12 +547,14 @@ export class DevupUIWebpackPlugin { () => { if (stale.length === 0 || passes > 0) return undefined passes += 1 - for (const path of stale) - writeFileSync( - path, - getCss(getFileNumByFilename(path), true), - 'utf-8', - ) + scope.run(() => { + for (const path of stale) + writeFileSync( + path, + getCss(getFileNumByFilename(path), true), + 'utf-8', + ) + }) return true }, ) @@ -385,48 +564,57 @@ export class DevupUIWebpackPlugin { if (!stats.hasErrors()) { // write css file await writeFile( - join(this.options.cssDir, 'devup-ui.css'), - getCss(null, false), + join(options.cssDir, 'devup-ui.css'), + scope.run(() => getCss(null, false)), 'utf-8', ) - } + } else if (!compiler.watchMode) scope.abort() }) } + const exclude = this.excludeModules + // CJS bundles must resolve from their installed module, not a baked source URL. + const resolveLoader = + typeof require === 'function' + ? require.resolve + : createRequire(import.meta.url).resolve + const sourceLoader = { + loader: resolveLoader('@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, + alias: resolveOptions?.alias, + mdxExtensions: this.mdxExtensions, + symlinks: resolveOptions?.symlinks, + }, + } 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: mdxSourceFilter(this.mdxExtensions), + exclude, + enforce: 'post', + use: [sourceLoader], }, { test: this.options.cssDir, enforce: 'pre', use: [ { - loader: createRequire(import.meta.url).resolve( - '@devup-ui/webpack-plugin/css-loader', - ), + loader: resolveLoader('@devup-ui/webpack-plugin/css-loader'), options: { watch: this.options.watch, }, @@ -434,5 +622,19 @@ export class DevupUIWebpackPlugin { ], }, ) + registerCompiledGuard(compiler, { + package: this.options.package, + mdxExtensions: this.mdxExtensions, + importAliases: this.importAliases, + }) + scope.setConfiguration(() => { + setDebug(options.debug) + setPrefix(options.prefix ?? null) + registerShorthands(options.shorthands ?? {}) + registerTheme(theme) + importFileRoutes(routes) + setAtomHoist(threshold) + setModuleResolver(moduleResolver) + }) } } diff --git a/packages/webpack-plugin/src/production-manifest.ts b/packages/webpack-plugin/src/production-manifest.ts new file mode 100644 index 000000000..dd42482e2 --- /dev/null +++ b/packages/webpack-plugin/src/production-manifest.ts @@ -0,0 +1,149 @@ +import type { Configuration } from 'webpack' + +import type { WebpackGenerationBinding } from './build-scope' + +/** @internal Roles supplied by the integration returning the actual config. */ +export type WebpackProductionRole = 'client' | 'nodejs' | 'edge' + +export interface WebpackManifestCoordinate { + readonly role: WebpackProductionRole + readonly context?: string + readonly name?: string +} + +export interface WebpackProductionManifest { + readonly phase: 'open' | 'sealed' + readonly configs: Readonly< + Record + > +} + +/** @internal Pure metadata rejection; there is no underlying engine cause. */ +export class WebpackProductionManifestError extends Error { + readonly coordinates: readonly WebpackManifestCoordinate[] + + constructor( + readonly code: 'sealed' | 'disposed', + readonly operation: 'register' | 'read' | 'seal', + coordinates: readonly WebpackManifestCoordinate[], + ) { + super(`[devup-ui] cannot ${operation} returned Webpack configs: ${code}`) + this.name = 'WebpackProductionManifestError' + this.coordinates = Object.freeze( + coordinates.map((coordinate) => Object.freeze(coordinate)), + ) + } +} + +declare global { + // Independently bundled entries share actual owner identity, never roots. + var __devupUiWebpackProductionManifests: + | WeakMap + | undefined +} + +const roles = ['client', 'nodejs', 'edge'] as const + +function coordinate( + role: WebpackProductionRole, + config: Configuration, +): WebpackManifestCoordinate { + const { context, name } = config + return { + role, + ...(typeof context === 'string' ? { context } : {}), + ...(typeof name === 'string' ? { name } : {}), + } +} + +function liveManifest( + owner: WebpackGenerationBinding['owner'], + operation: 'read' | 'seal', +): WebpackProductionManifest { + const manifest = globalThis.__devupUiWebpackProductionManifests?.get(owner) + if (owner.disposed) { + throw new WebpackProductionManifestError( + 'disposed', + operation, + manifest + ? roles.flatMap((role) => + manifest.configs[role].map((config) => coordinate(role, config)), + ) + : [], + ) + } + return ( + manifest ?? + Object.freeze({ + phase: 'open', + configs: Object.freeze({ + client: Object.freeze([]), + nodejs: Object.freeze([]), + edge: Object.freeze([]), + }), + }) + ) +} + +/** @internal Records membership only, never a compiler participant or lease. */ +export function registerWebpackReturnedConfig( + owner: WebpackGenerationBinding['owner'], + role: WebpackProductionRole, + config: Configuration, +): void { + if (owner.disposed) { + throw new WebpackProductionManifestError('disposed', 'register', [ + coordinate(role, config), + ]) + } + const manifest = globalThis.__devupUiWebpackProductionManifests?.get(owner) + if (manifest?.phase === 'sealed') { + throw new WebpackProductionManifestError('sealed', 'register', [ + coordinate(role, config), + ]) + } + if (manifest?.configs[role].includes(config)) return + const configs = manifest?.configs ?? { client: [], nodejs: [], edge: [] } + const snapshot: WebpackProductionManifest = Object.freeze({ + phase: 'open', + configs: Object.freeze({ + client: Object.freeze(configs.client), + nodejs: Object.freeze(configs.nodejs), + edge: Object.freeze(configs.edge), + [role]: Object.freeze([...configs[role], config]), + }), + }) + ;(globalThis.__devupUiWebpackProductionManifests ??= new WeakMap()).set( + owner, + snapshot, + ) +} + +/** @internal Unseen live reads neither insert nor seal metadata. */ +export function readWebpackProductionManifest( + owner: WebpackGenerationBinding['owner'], +): WebpackProductionManifest { + return liveManifest(owner, 'read') +} + +/** @internal Sealing membership does not complete the real generation. */ +export function sealWebpackProductionManifest( + owner: WebpackGenerationBinding['owner'], +): WebpackProductionManifest { + const manifest = liveManifest(owner, 'seal') + switch (manifest.phase) { + case 'sealed': + return manifest + case 'open': { + const snapshot: WebpackProductionManifest = Object.freeze({ + phase: 'sealed', + configs: manifest.configs, + }) + ;(globalThis.__devupUiWebpackProductionManifests ??= new WeakMap()).set( + owner, + snapshot, + ) + return snapshot + } + } +} diff --git a/packages/webpack-plugin/theme-generation-regression.mjs b/packages/webpack-plugin/theme-generation-regression.mjs new file mode 100644 index 000000000..8314dc314 --- /dev/null +++ b/packages/webpack-plugin/theme-generation-regression.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { build } from 'bun' + +import { + pluginUtilsFixtureManifest, + webpackFixtureManifest, +} from './native-fixture-bundle.mjs' + +const workspace = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const root = realpathSync.native( + mkdtempSync( + join(process.env.DEVUP_CSS_EVIDENCE_DIR ?? tmpdir(), 'w20i-theme-'), + ), +) +try { + mkdirSync(join(root, 'node_modules/@devup-ui'), { recursive: true }) + const producer = join(root, 'producer') + mkdirSync(join(producer, 'node_modules/@devup-ui'), { recursive: true }) + for (const target of [root, producer]) { + const wasm = join(target, 'node_modules/@devup-ui/wasm') + mkdirSync(wasm) + cpSync( + join(workspace, 'bindings/devup-ui-wasm/package.json'), + join(wasm, 'package.json'), + ) + cpSync(join(workspace, 'bindings/devup-ui-wasm/pkg'), join(wasm, 'pkg'), { + recursive: true, + }) + } + for (const [name, path] of [ + ['@devup-ui/react', 'packages/react'], + ['react', 'apps/landing/node_modules/react'], + ['react-dom', 'apps/landing/node_modules/react-dom'], + ['@typescript/typescript6', 'node_modules/@typescript/typescript6'], + ]) { + mkdirSync(dirname(join(root, 'node_modules', name)), { recursive: true }) + symlinkSync( + realpathSync(join(workspace, path)), + join(root, 'node_modules', name), + process.platform === 'win32' ? 'junction' : 'dir', + ) + } + for (const [pkg, entries] of [ + ['plugin-utils', ['index', 'build-admission']], + ['webpack-plugin', ['index', 'loader', 'css-loader']], + ]) { + const directory = join(root, 'node_modules/@devup-ui', pkg) + mkdirSync(directory) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify( + pkg === 'webpack-plugin' + ? { + ...webpackFixtureManifest, + exports: Object.fromEntries( + Object.entries(webpackFixtureManifest.exports).map( + ([key, value]) => [ + key, + { import: value.replace('.cjs', '.mjs'), require: value }, + ], + ), + ), + } + : pluginUtilsFixtureManifest, + ), + ) + const sourcePackage = join(producer, 'node_modules/@devup-ui', pkg) + mkdirSync(sourcePackage) + cpSync(join(directory, 'package.json'), join(sourcePackage, 'package.json')) + cpSync( + join(workspace, 'packages', pkg, 'src'), + join(sourcePackage, 'src'), + { recursive: true }, + ) + for (const entry of entries) { + for (const format of entry === 'build-admission' + ? ['cjs'] + : ['cjs', 'esm']) { + const ext = format === 'cjs' ? 'cjs' : 'mjs' + const emitted = join(sourcePackage, `${entry}.${ext}`) + const result = await build({ + entrypoints: [ + join( + sourcePackage, + 'src', + `${entry}.${entry === 'build-admission' ? 'cts' : 'ts'}`, + ), + ], + outdir: sourcePackage, + naming: `${entry}.${ext}`, + target: 'node', + format, + packages: 'external', + minify: true, + env: 'disable', + }) + assert.ok(result.success, String(result.logs)) + cpSync(emitted, join(directory, `${entry}.${ext}`)) + } + } + } + writeFileSync( + join(root, 'jsx-loader.cjs'), + ` +const ts = require('@typescript/typescript6') +module.exports = function(source) { + return ts.transpileModule(source, {compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, + }, fileName: this.resourcePath}).outputText +} +`, + ) + writeFileSync( + join(root, 'probe.cjs'), + ` +const assert = require('node:assert/strict') +const fs = require('node:fs') +const {createHash} = require('node:crypto') +const {join} = require('node:path') +const {createRequire} = require('node:module') +const {webpack} = createRequire(${JSON.stringify(join(workspace, 'apps/landing/package.json'))})('next/dist/compiled/webpack/webpack') +const {DevupUIWebpackPlugin} = require('@devup-ui/webpack-plugin') +const failures = [] +async function main() { + for (const singleCss of [false, true]) { + const root = join(__dirname, singleCss ? 'single' : 'per-file') + fs.mkdirSync(join(root, 'src'), {recursive:true}) + fs.writeFileSync(join(root, 'devup.json'), JSON.stringify({extends:['./base-theme.json']})) + fs.writeFileSync(join(root, 'src/main.jsx'), "import {createRoot} from 'react-dom/client';import {Fixture} from './Fixture';createRoot(document.getElementById('root')).render()") + for (const edited of [false, true]) { + // Given: inherited variables and an imported css() module, not flattened styles. + fs.writeFileSync(join(root, 'base-theme.json'), JSON.stringify({theme:{colors:{default:{primary:edited?'#5b3917':'#17395b'}},length:{default:{gutter:'11px'}}}})) + fs.writeFileSync(join(root, 'src/styles.mjs'), "import {css} from '@devup-ui/react';export const badge=css({color:'#13579b',p:'"+(edited?'8px':'9px')+"'})") + fs.writeFileSync(join(root, 'src/Fixture.jsx'), \ +\`'use client' +import {Box, Flex, globalCss} from '@devup-ui/react' +import {useState} from 'react' +import {badge} from './styles.mjs' +globalCss({body:{margin:0}}) +export function Fixture() { + const [color,setColor]=useState('#123456') + return + Static extraction + Extended theme + Responsive extraction + Hover extraction + Dynamic CSS variable + +
Imported module
+
+}\`) + // When: ordinary native production Webpack with its actual CSS experiment. + const output = join(root, edited?'edited':'initial') + const compiler = webpack({context:root,mode:'production',entry:'./src/main.jsx', + resolve:{extensions:['.jsx','...']},output:{path:output,filename:'[name].[contenthash].js'}, + experiments:{css:true},optimization:{minimize:false},module:{rules:[ + {test:/\\.jsx$/,use:[join(__dirname,'jsx-loader.cjs')]},{test:/\\.css$/,type:'css'}, + ]},plugins:[new DevupUIWebpackPlugin({singleCss})]}) + try { + const stats = await new Promise((done,reject)=>compiler.run((error,result)=>error?reject(error):done(result))) + assert.equal(stats.hasErrors(),false,stats.toString({all:false,errors:true})) + const assets = stats.compilation.getAssets() + const html = ''+assets.filter(({name})=>name.endsWith('.css')).map(({name})=>'').join('')+'
'+assets.filter(({name})=>name.endsWith('.js')).map(({name})=>'').join('')+'' + fs.writeFileSync(join(output,'index.html'),html) + // Then: assert the stylesheet linked by HTML, never post-close globals or late base alone. + const sheets = [...html.matchAll(/href="\\/([^" ]+\\.css)"/g)].map((match)=>({name:match[1],css:fs.readFileSync(join(output,match[1]),'utf8')})) + const css = sheets.map(({css})=>css).join('') + const inputs=Object.fromEntries(['devup.json','base-theme.json','src/Fixture.jsx','src/styles.mjs'].map(file=>[file,createHash('sha256').update(fs.readFileSync(join(root,file))).digest('hex')])) + const loaders=compiler.options.module.rules.flatMap(rule=>(rule.use??[]).filter(use=>typeof use==='object'&&use.loader).map(use=>use.loader)) + const row={root,output,singleCss,edited,sheets,inputs,loaders,base:fs.readFileSync(join(root,'df/devup-ui/devup-ui.css'),'utf8')} + fs.writeFileSync(join(output,'receipt.json'),JSON.stringify(row,null,2)) + console.info(JSON.stringify(row)) + try { + assert.ok(sheets.length>0,'HTML must link native CSS') + assert.match(css,new RegExp('--primary:'+(edited?'#5B3917':'#17395B'),'i')) + assert.match(css,/--gutter:11px/) + assert.match(css,/color:#13579B/i) + assert.match(css,new RegExp('padding:'+(edited?'8':'9')+'px')) + } catch(error) {failures.push({singleCss,edited,error:String(error)})} + } finally {await new Promise((done,reject)=>compiler.close(error=>error?reject(error):done()))} + } + } + assert.deepEqual(failures,[]) +} +main().catch(error=>{console.error(error);process.exitCode=1}) +`, + ) + const result = spawnSync('node', [join(root, 'probe.cjs')], { + cwd: root, + encoding: 'utf8', + timeout: 150000, + maxBuffer: 8 * 1024 * 1024, + }) + console.info( + JSON.stringify({ root, stdout: result.stdout, stderr: result.stderr }), + ) + if (process.env.DEVUP_CSS_EVIDENCE_DIR) + writeFileSync(join(root, 'run.log'), `${result.stdout}\n${result.stderr}`) + assert.equal(result.error, undefined) + assert.equal(result.status, 0, result.stderr) +} finally { + if (!process.env.DEVUP_CSS_EVIDENCE_DIR) + rmSync(root, { recursive: true, force: true }) +} diff --git a/test-harness/bunfig.toml b/test-harness/bunfig.toml new file mode 100644 index 000000000..200f34bd0 --- /dev/null +++ b/test-harness/bunfig.toml @@ -0,0 +1,8 @@ +[test] +root = "packages" +preload = ["./bun.preload.ts", "./bun.setup.ts", "bun-test-env-dom"] +coverage = true +coverageReporter = ["lcov"] +coverageThreshold = 0.0 +coverageSkipTestFiles = true +coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] diff --git a/test-harness/coverage.ts b/test-harness/coverage.ts new file mode 100644 index 000000000..df1e76245 --- /dev/null +++ b/test-harness/coverage.ts @@ -0,0 +1,135 @@ +import { CoverageError, type SourceCoverage } from './lcov' +import { + isVerifiedSourceCoverage, + type VerifiedSourceCoverage, +} from './producer-coverage' + +export function mergeCoverage( + reports: readonly (readonly SourceCoverage[])[], +): SourceCoverage[] { + const sources = new Map() + for (const report of reports) { + const seen = new Set() + for (const source of report) { + if (!isVerifiedSourceCoverage(source)) + throw new CoverageError('unproven coverage universe') + if (seen.has(source.file)) + throw new CoverageError(`duplicate source ${source.file}`) + seen.add(source.file) + const previous = sources.get(source.file) + if (!previous) { + sources.set(source.file, source) + continue + } + sources.set(source.file, previous.merge(source)) + } + } + return [...sources.values()].sort((a, b) => a.file.localeCompare(b.file)) +} + +export function auditCoverage( + sources: readonly SourceCoverage[], + required: readonly string[], +): void { + if (sources.length === 0) throw new CoverageError('empty merged report') + for (const source of sources) { + if (!isVerifiedSourceCoverage(source)) + throw new CoverageError('unproven coverage universe') + } + for (const file of required) { + if (!sources.some((source) => source.file === file)) + throw new CoverageError(`missing source ${file}`) + } + const failures = [] + for (const source of sources) { + if ( + source.hitFunctions !== source.foundFunctions || + [...source.lines.values()].some((hits) => hits === 0) + ) { + failures.push({ + file: source.file, + zeroHitLines: [...source.lines] + .filter(([, hits]) => hits === 0) + .map(([line]) => line) + .sort((a, b) => a - b), + FNH: source.hitFunctions, + FNF: source.foundFunctions, + LH: [...source.lines.values()].filter((hits) => hits > 0).length, + LF: source.lines.size, + functionNames: 'unavailable from verified producer', + }) + } + } + if (failures.length > 0) + throw new CoverageError( + `below 100% lines/functions: ${JSON.stringify(failures)}`, + ) +} + +export function writeLcov(sources: readonly SourceCoverage[]): string { + return sources + .map((source) => + [ + 'TN:', + `SF:${source.file}`, + ...[...source.functions].flatMap(([name, fn]) => [ + `FN:${fn.line},${name}`, + `FNDA:${fn.hits},${name}`, + ]), + `FNF:${source.foundFunctions}`, + `FNH:${source.hitFunctions}`, + ...[...source.lines] + .sort(([a], [b]) => a - b) + .map(([line, hits]) => `DA:${line},${hits}`), + `LF:${source.lines.size}`, + `LH:${[...source.lines.values()].filter((hits) => hits > 0).length}`, + ...[...source.branches].map( + ([branch, hits]) => `BRDA:${branch},${hits}`, + ), + `BRF:${source.branches.size}`, + `BRH:${[...source.branches.values()].filter((hits) => hits > 0).length}`, + 'end_of_record', + '', + ].join('\n'), + ) + .join('') +} + +export function coverageTable(sources: readonly SourceCoverage[]): string { + const foundFunctions = sources.reduce( + (sum, source) => sum + source.foundFunctions, + 0, + ) + const hitFunctions = sources.reduce( + (sum, source) => sum + source.hitFunctions, + 0, + ) + const foundLines = sources.reduce((sum, source) => sum + source.lines.size, 0) + const hitLines = sources.reduce( + (sum, source) => + sum + [...source.lines.values()].filter((hits) => hits > 0).length, + 0, + ) + return [ + 'File | % Funcs | % Lines | Uncovered Line #s', + `All files | ${percentage(hitFunctions, foundFunctions)} | ${percentage(hitLines, foundLines)} |`, + ...sources.map((source) => { + const uncovered = [...source.lines] + .filter(([, hits]) => hits === 0) + .map(([line]) => line) + const functions = + source.foundFunctions === 0 + ? 100 + : (source.hitFunctions / source.foundFunctions) * 100 + const lines = + source.lines.size === 0 + ? 100 + : ((source.lines.size - uncovered.length) / source.lines.size) * 100 + return `${source.file} | ${functions.toFixed(2)} | ${lines.toFixed(2)} | ${uncovered.join(',')}` + }), + ].join('\n') +} + +function percentage(hits: number, total: number): string { + return (total === 0 ? 100 : (hits / total) * 100).toFixed(2) +} diff --git a/test-harness/groups.ts b/test-harness/groups.ts new file mode 100644 index 000000000..70223df7e --- /dev/null +++ b/test-harness/groups.ts @@ -0,0 +1,78 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' + +export type TestGroup = 'isolated' | 'runtime' +export interface GroupRule { + readonly directory: string + readonly group: TestGroup +} + +export const groupRules: readonly GroupRule[] = [ + { directory: 'packages/bun-plugin/', group: 'runtime' }, + { directory: 'packages/components/', group: 'runtime' }, + { directory: 'packages/react/', group: 'runtime' }, + { directory: 'packages/eslint-plugin/', group: 'isolated' }, + { directory: 'packages/next-plugin/', group: 'isolated' }, + { directory: 'packages/plugin-utils/', group: 'isolated' }, + { directory: 'packages/reset-css/', group: 'isolated' }, + { directory: 'packages/rsbuild-plugin/', group: 'runtime' }, + { directory: 'packages/vite-plugin/', group: 'isolated' }, + { directory: 'packages/webpack-plugin/', group: 'isolated' }, +] + +export class TestGroupingError extends Error { + constructor(readonly detail: string) { + super(`Test grouping: ${detail}`) + this.name = 'TestGroupingError' + } +} + +export function discoverTests(root: string, directory = 'packages'): string[] { + return readdirSync(join(root, directory), { withFileTypes: true }) + .flatMap((entry) => { + const path = `${directory}/${entry.name}` + const name = entry.name.toLowerCase() + if (entry.isDirectory()) { + return name === 'node_modules' || name.startsWith('.') + ? [] + : discoverTests(root, path) + } + return /(?:\.|_)(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/.test( + name, + ) + ? [path] + : [] + }) + .sort() +} + +export function partitionTests( + files: readonly string[], + rules: readonly GroupRule[] = groupRules, +): Record { + const result: Record = { isolated: [], runtime: [] } + const seen = new Set() + for (const file of files) { + const matches = rules.filter((rule) => file.startsWith(rule.directory)) + if (seen.has(file) || matches.length !== 1) { + throw new TestGroupingError(`${file} must belong to exactly one group`) + } + seen.add(file) + for (const rule of matches) result[rule.group].push(file) + } + for (const group of Object.values(result)) group.sort() + return result +} + +export function parseTestGroup( + value: string | undefined, +): TestGroup | undefined { + switch (value) { + case undefined: + case 'isolated': + case 'runtime': + return value + default: + throw new TestGroupingError(`unknown child group ${value}`) + } +} diff --git a/test-harness/lcov.ts b/test-harness/lcov.ts new file mode 100644 index 000000000..736b13582 --- /dev/null +++ b/test-harness/lcov.ts @@ -0,0 +1,121 @@ +export class CoverageError extends Error { + constructor(readonly detail: string) { + super(`Coverage: ${detail}`) + this.name = 'CoverageError' + } +} + +/** Mutable accumulator; all counts refer to the same source bytes. */ +export interface SourceCoverage { + readonly file: string + readonly lines: Map + readonly functions: Map + readonly branches: Map + foundFunctions: number + hitFunctions: number +} + +function count(value: string): number { + const result = Number(value) + if (!/^\d+$/.test(value) || !Number.isSafeInteger(result)) { + throw new CoverageError(`invalid count ${value}`) + } + return result +} + +export function parseLcov(text: string): SourceCoverage[] { + const result: SourceCoverage[] = [] + let source: SourceCoverage | undefined + const summaries = new Map() + for (const row of text.split(/\r?\n/)) { + if (!row || row.startsWith('TN:')) continue + if (row.startsWith('SF:')) { + if (source) throw new CoverageError('unterminated source record') + source = { + file: row.slice(3).replaceAll('\\', '/'), + lines: new Map(), + functions: new Map(), + branches: new Map(), + foundFunctions: 0, + hitFunctions: 0, + } + summaries.clear() + continue + } + if (!source) throw new CoverageError(`record outside source: ${row}`) + const separator = row.indexOf(':') + const tag = row.slice(0, separator) + const value = row.slice(separator + 1) + switch (tag) { + case 'DA': { + const [line = '', hits = '', checksum] = value.split(',') + if (checksum) throw new CoverageError('checksummed DA is unsupported') + const identity = count(line) + if (source.lines.has(identity)) throw new CoverageError('duplicate DA') + source.lines.set(identity, count(hits)) + break + } + case 'FN': { + const [line = '', ...name] = value.split(',') + const identity = name.join(',') + if (!identity || source.functions.has(identity)) { + throw new CoverageError('ambiguous function name') + } + source.functions.set(identity, { line: count(line), hits: 0 }) + break + } + case 'FNDA': { + const comma = value.indexOf(',') + const fn = source.functions.get(value.slice(comma + 1)) + if (!fn) throw new CoverageError('FNDA has no function definition') + fn.hits += count(value.slice(0, comma)) + break + } + case 'BRDA': { + const [line = '', block = '', branch = '', hits = ''] = value.split(',') + const identity = `${count(line)},${count(block)},${count(branch)}` + if (source.branches.has(identity)) + throw new CoverageError('duplicate branch') + source.branches.set(identity, hits === '-' ? 0 : count(hits)) + break + } + case 'FNF': + case 'FNH': + case 'LF': + case 'LH': + case 'BRF': + case 'BRH': + if (summaries.has(tag)) throw new CoverageError(`duplicate ${tag}`) + summaries.set(tag, count(value)) + break + default: + if (row !== 'end_of_record') + throw new CoverageError(`unknown record ${row}`) + source.foundFunctions = summaries.get('FNF') ?? 0 + source.hitFunctions = summaries.get('FNH') ?? 0 + if ( + !summaries.has('FNF') || + !summaries.has('FNH') || + source.hitFunctions > source.foundFunctions || + summaries.get('LF') !== source.lines.size || + summaries.get('LH') !== + [...source.lines.values()].filter((hits) => hits > 0).length || + (source.functions.size > 0 && + (source.functions.size !== source.foundFunctions || + [...source.functions.values()].filter((fn) => fn.hits > 0) + .length !== source.hitFunctions)) || + (source.branches.size > 0 && + (summaries.get('BRF') !== source.branches.size || + summaries.get('BRH') !== + [...source.branches.values()].filter((hits) => hits > 0) + .length)) + ) + throw new CoverageError(`inconsistent summaries for ${source.file}`) + result.push(source) + source = undefined + } + } + if (source || result.length === 0) + throw new CoverageError('incomplete LCOV report') + return result +} diff --git a/test-harness/preload.ts b/test-harness/preload.ts new file mode 100644 index 000000000..e2d4cf4c1 --- /dev/null +++ b/test-harness/preload.ts @@ -0,0 +1,26 @@ +import type { TestGroup } from './groups' +import { runTestGroups, type TestRun } from './run' + +export async function prepareTests( + group: TestGroup | undefined, + run: TestRun, +): Promise { + switch (group) { + case undefined: + try { + console.info(await runTestGroups(run)) + return 0 + } catch (error) { + // This is the root CLI boundary: report once and prevent preload retries. + console.error(error) + return 1 + } + case 'runtime': { + const { register } = await import('../packages/bun-plugin/src/register') + await register({ debug: true }) + return undefined + } + case 'isolated': + return undefined + } +} diff --git a/test-harness/producer-child.ts b/test-harness/producer-child.ts new file mode 100644 index 000000000..4e96b3bc1 --- /dev/null +++ b/test-harness/producer-child.ts @@ -0,0 +1,186 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' +import { setImmediate } from 'node:timers/promises' + +import type { TestGroup } from './groups' +import { CoverageError } from './lcov' +import type { ProducerRun } from './producer-process' +import { PRODUCER_TIMEOUT_MS } from './producer-timeout' + +interface ExitStatus { + readonly code: number | null + readonly signal: NodeJS.Signals | null +} + +interface ProducerDiagnostics { + readonly group: TestGroup + readonly files: readonly string[] + readonly naturalExit: ExitStatus | null + readonly exitBeforeCleanup: ExitStatus | null + readonly cleanupRequested: boolean + readonly finalExit: ExitStatus | null + readonly stdoutTail: string + readonly stderrTail: string +} + +class ProducerFailure extends CoverageError { + constructor( + override readonly cause: Error, + readonly diagnostics: ProducerDiagnostics, + ) { + super(`${cause.message}\nproducer failure: ${JSON.stringify(diagnostics)}`) + } +} + +export class ProducerChild { + readonly child: ChildProcessWithoutNullStreams + readonly exit: Promise + private readonly deadline: ReturnType + private stdoutTail = Buffer.alloc(0) + private stderrTail = Buffer.alloc(0) + private naturalExit: ExitStatus | null = null + private exitBeforeCleanup: ExitStatus | null = null + private finalExit: ExitStatus | null = null + private spawnError: Error | undefined + private observationError: CoverageError | undefined + private cleanupRequested = false + private notify: (() => void) | undefined + output = '' + + constructor( + private readonly run: ProducerRun, + bootstrap: { readonly token: string; readonly preload: string }, + ) { + try { + this.child = spawn( + run.executable, + [ + '--inspect-wait=127.0.0.1:0/producer', + `--config=${run.config}`, + 'test', + `--preload=${bootstrap.preload}`, + `--coverage-dir=${run.coverage}`, + ...run.files.map((file) => `./${file}`), + ], + { + cwd: run.root, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + DEVUP_TEST_GROUP: run.group, + DEVUP_PRODUCER_TOKEN: bootstrap.token, + DEVUP_PRODUCER_CONFIG: run.config, + }, + }, + ) + } catch (error) { + if (!(error instanceof Error)) throw error + this.recordSpawnError(error) + throw new ProducerFailure(error, this.diagnostics()) + } + this.child.once('error', this.recordSpawnError.bind(this)) + this.child.once('exit', (code, signal) => { + this.finalExit = { code, signal } + if (!this.cleanupRequested) + this.exitBeforeCleanup = this.naturalExit = this.finalExit + }) + this.exit = new Promise((done) => this.child.once('close', done)) + this.deadline = setTimeout(this.terminate.bind(this), PRODUCER_TIMEOUT_MS) + this.child.stdout.on('data', (chunk: Buffer) => { + this.output += chunk.toString() + this.stdoutTail = Buffer.from( + Buffer.concat([this.stdoutTail, chunk]).subarray(-8192), + ) + this.notify?.() + }) + this.child.stderr.on('data', (chunk: Buffer) => { + this.output += chunk.toString() + this.stderrTail = Buffer.from( + Buffer.concat([this.stderrTail, chunk]).subarray(-8192), + ) + this.notify?.() + }) + } + + async wait(condition: () => boolean): Promise { + this.assertObservation() + if (condition()) return + await new Promise((done, reject) => { + const timeout = setTimeout( + reject.bind( + undefined, + new CoverageError('producer observation timeout'), + ), + PRODUCER_TIMEOUT_MS, + ) + this.notify = () => { + if (this.observationError || condition()) { + clearTimeout(timeout) + if (this.observationError) reject(this.observationError) + else done() + } + } + this.exit.then(() => { + clearTimeout(timeout) + if (this.spawnError || !condition()) + reject( + this.spawnError ?? + new CoverageError('producer exited before evidence'), + ) + }) + }) + } + + assertObservation(): void { + if (this.observationError) throw this.observationError + } + + observeFailure(error: CoverageError): void { + this.observationError ??= error + this.notify?.() + } + + terminate(): void { + if ( + this.spawnError || + this.child.exitCode !== null || + this.child.signalCode !== null + ) + return + this.cleanupRequested = true + this.child.kill() + } + + async finish(): Promise { + clearTimeout(this.deadline) + await setImmediate() + if (!this.cleanupRequested && !this.spawnError) { + const code = this.child.exitCode + const signal = this.child.signalCode + if (code !== null || signal !== null) + this.exitBeforeCleanup = this.naturalExit = { code, signal } + } + this.terminate() + await this.exit + } + + failure(cause: Error): CoverageError { + return new ProducerFailure(cause, this.diagnostics()) + } + + private recordSpawnError(error: Error): void { + this.spawnError = error + } + + private diagnostics(): ProducerDiagnostics { + return { + group: this.run.group, + files: [...this.run.files], + naturalExit: this.naturalExit, + exitBeforeCleanup: this.exitBeforeCleanup, + cleanupRequested: this.cleanupRequested, + finalExit: this.finalExit, + stdoutTail: this.stdoutTail.toString(), + stderrTail: this.stderrTail.toString(), + } + } +} diff --git a/test-harness/producer-coverage.ts b/test-harness/producer-coverage.ts new file mode 100644 index 000000000..c17fe00ce --- /dev/null +++ b/test-harness/producer-coverage.ts @@ -0,0 +1,194 @@ +import { relative, resolve } from 'node:path' + +import { CoverageError, parseLcov, type SourceCoverage } from './lcov' +import { array, integer, object, text } from './producer-data' +import { + type NativeView, + reconstructNative, + validateNative, +} from './producer-native' +import { isProducerResult, type ProducerResult } from './producer-process' + +const issuance = Symbol() +const issued = new WeakSet() + +export function isVerifiedSourceCoverage( + value: object, +): value is VerifiedSourceCoverage { + return issued.has(value) +} + +export class VerifiedSourceCoverage implements SourceCoverage { + readonly file: string + readonly foundFunctions: number + readonly hitFunctions: number + readonly #view: NativeView + readonly #configuration: string + + private constructor( + capability: typeof issuance, + payload: { + readonly source: SourceCoverage + readonly view: NativeView + readonly configuration: string + }, + ) { + if (capability !== issuance) + throw new CoverageError('unproven native coverage issuance') + const { source, view, configuration } = payload + this.file = source.file + this.#view = { ...view, lines: new Map(source.lines) } + this.#configuration = configuration + this.foundFunctions = view.functions.length + this.hitFunctions = view.functions.filter((fn) => fn.hasExecuted).length + Object.freeze(this) + issued.add(this) + } + + get lines(): Map { + return new Map(this.#view.lines) + } + + get functions(): Map { + return new Map() + } + + get branches(): Map { + return new Map() + } + + static bind(result: ProducerResult, root: string): VerifiedSourceCoverage[] { + if (!isProducerResult(result)) + throw new CoverageError('unproven native producer lifetime') + const sources = parseLcov(result.value.lcov) + const config = object(object(Bun.TOML.parse(result.value.config))['test']) + const ignored = array(config['coveragePathIgnorePatterns'] ?? []).map( + (value) => new Bun.Glob(text(value)), + ) + for (const capture of result.value.captures) { + const path = relative(root, capture.url).replaceAll('\\', '/') + if ( + capture.blocks.length > 0 && + !ignored.some((pattern) => pattern.match(path)) && + !sources.some((source) => resolve(root, source.file) === capture.url) + ) + throw new CoverageError( + `missing native source from actual producer inventory: ${path}`, + ) + } + return sources.map((source) => { + const path = resolve(root, source.file).replaceAll('\\', '/') + const captures = result.value.captures.filter( + (capture) => capture.url.replaceAll('\\', '/') === path, + ) + const capture = [...captures] + .sort( + (a, b) => integer(Number(a.scriptId)) - integer(Number(b.scriptId)), + ) + .at(-1) + if (!capture) throw new CoverageError('missing producer capture') + if ( + captures.some( + (item) => + item.code !== capture.code || + item.sourceMapURL !== capture.sourceMapURL || + item.scriptType !== capture.scriptType, + ) + ) + throw new CoverageError( + `incompatible reloaded producer source: ${source.file}`, + ) + // Bun 1.4.2 registers coverage at SourceProvider creation; pinned WebKit assigns monotonically increasing SourceIDs. + const view = reconstructNative(capture, source) + validateNative(view, source) + return new VerifiedSourceCoverage(issuance, { + source, + view, + configuration: result.value.config, + }) + }) + } + + merge(other: VerifiedSourceCoverage): VerifiedSourceCoverage { + if (!isVerifiedSourceCoverage(this) || !isVerifiedSourceCoverage(other)) + throw new CoverageError('unproven native coverage merge') + if ( + this.#view.code !== other.#view.code || + this.#view.map !== other.#view.map || + this.#configuration !== other.#configuration + ) + throw new CoverageError( + `incompatible effective producer code/map/config: ${this.file}`, + ) + const identities = new Map( + this.#view.functions.map((fn) => [ + `${fn.startOffset},${fn.endOffset}`, + fn, + ]), + ) + for (const fn of other.#view.functions) { + const key = `${fn.startOffset},${fn.endOffset}` + const previous = identities.get(key) + identities.set(key, { + ...fn, + hasExecuted: fn.hasExecuted || previous?.hasExecuted === true, + }) + } + const lines = new Map(this.lines) + for (const [line, hits] of other.lines) + lines.set(line, (lines.get(line) ?? 0) + hits) + for (const [line, hits] of lines) { + const left = this.#view.coarse.get(line) ?? [] + const right = other.#view.coarse.get(line) ?? [] + const executedBy = ( + origins: readonly { + readonly startOffset: number + readonly endOffset: number + }[], + peer: NativeView, + ) => + origins.length > 0 && + origins.every((origin) => + peer.functions.some( + (fn) => + fn.startOffset === origin.startOffset && + fn.endOffset === origin.endOffset && + fn.hasExecuted, + ), + ) + if ( + hits === 0 && + !this.#view.blockLines.has(line) && + !other.#view.blockLines.has(line) && + ((executedBy(left, other.#view) && !other.lines.has(line)) || + (executedBy(right, this.#view) && !this.lines.has(line))) + ) + lines.delete(line) + } + const coarse = new Map(this.#view.coarse) + for (const [line, origins] of other.#view.coarse) + coarse.set(line, [...(coarse.get(line) ?? []), ...origins]) + const view = { + ...this.#view, + functions: [...identities.values()], + lines, + blockLines: new Set([ + ...this.#view.blockLines, + ...other.#view.blockLines, + ]), + coarse, + } + return new VerifiedSourceCoverage(issuance, { + source: { + file: this.file, + lines, + functions: this.functions, + branches: this.branches, + foundFunctions: this.foundFunctions, + hitFunctions: this.hitFunctions, + }, + view, + configuration: this.#configuration, + }) + } +} diff --git a/test-harness/producer-data.ts b/test-harness/producer-data.ts new file mode 100644 index 000000000..cf932c817 --- /dev/null +++ b/test-harness/producer-data.ts @@ -0,0 +1,150 @@ +import { CoverageError } from './lcov' + +const qualifiedRevisions: ReadonlyMap = new Map([ + ['1.4.0', '34cbb9a40b4bd1bd767d134a7065e66c2432a676'], + ['1.4.2', '744846f844374847c902b5e7fd59b4342a51ef99'], +]) +const runtimeIdentity = Object.freeze({ + bun: Bun.version, + revision: Bun.revision, +}) + +export interface ProfileRange { + readonly startOffset: number + readonly endOffset: number + readonly count: number +} +export interface ProfileFunction { + readonly ranges: readonly ProfileRange[] + readonly isBlockCoverage: boolean +} +export interface ProfileScript { + readonly scriptId: string + readonly url: string + readonly functions: readonly ProfileFunction[] +} +export interface BasicBlock extends ProfileRange { + readonly hasExecuted: boolean +} +export interface ScriptCapture { + readonly profile: ProfileScript | undefined + readonly scriptId: string + readonly url: string + readonly scriptType: string + readonly sourceMapURL: string + readonly code: string + readonly blocks: readonly BasicBlock[] +} + +export function parseProducerIdentity( + value: unknown, + expected: { readonly pid: number | undefined; readonly config: string }, +): { + readonly pid: number + readonly config: string + readonly bun: string + readonly revision: string + readonly profiles: readonly ProfileScript[] +} { + const record = object(value) + const pid = integer(record['pid']) + const config = text(record['config']) + const bun = text(record['bun']) + const revision = text(record['revision']) + if ( + qualifiedRevisions.get(bun) !== revision || + bun !== runtimeIdentity.bun || + revision !== runtimeIdentity.revision || + config !== expected.config || + pid !== expected.pid + ) + throw new CoverageError( + 'unknown/stale producer process or configuration identity', + ) + return Object.freeze({ + pid, + config, + bun, + revision, + profiles: parseProfiles(record['profiles']), + }) +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function object(value: unknown): Record { + if (!isObject(value)) throw new CoverageError('producer expected object') + return value +} +export function text(value: unknown): string { + if (typeof value !== 'string') + throw new CoverageError('producer expected string') + return value +} +export function integer(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new CoverageError('producer expected nonnegative safe integer') + return value +} +export function boolean(value: unknown): boolean { + if (typeof value !== 'boolean') + throw new CoverageError('producer expected boolean') + return value +} +function signedInteger(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) + throw new CoverageError('producer expected safe offset') + return value +} +export function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new CoverageError('producer expected array') + return value +} +export function parseProfiles(value: unknown): readonly ProfileScript[] { + return Object.freeze( + array(value).map((item) => { + const script = object(item) + return Object.freeze({ + scriptId: text(script['scriptId']), + url: text(script['url']), + functions: Object.freeze( + array(script['functions']).map((item) => { + const fn = object(item) + if (text(fn['functionName']) !== '') + throw new CoverageError( + 'unsupported native function naming profile', + ) + return Object.freeze({ + isBlockCoverage: boolean(fn['isBlockCoverage']), + ranges: Object.freeze( + array(fn['ranges']).map((item) => { + const range = object(item) + return Object.freeze({ + startOffset: integer(range['startOffset']), + endOffset: integer(range['endOffset']), + count: integer(range['count']), + }) + }), + ), + }) + }), + ), + }) + }), + ) +} +export function parseBlocks(value: unknown): readonly BasicBlock[] { + return Object.freeze( + array(value).map((item) => { + const block = object(item) + return Object.freeze({ + startOffset: signedInteger(block['startOffset']), + endOffset: signedInteger(block['endOffset']), + count: integer(block['executionCount']), + hasExecuted: boolean(block['hasExecuted']), + }) + }), + ) +} diff --git a/test-harness/producer-map.ts b/test-harness/producer-map.ts new file mode 100644 index 000000000..f4c4c2a13 --- /dev/null +++ b/test-harness/producer-map.ts @@ -0,0 +1,115 @@ +import { CoverageError } from './lcov' +import { array, integer, object, text } from './producer-data' + +interface Mapping { + readonly column: number + readonly originalLine: number +} +export interface ProducerMap { + readonly input: string + readonly bytes: Buffer + readonly starts: readonly number[] + readonly rows: readonly (readonly Mapping[])[] +} + +export function decodeMap(code: string, sourceMapURL: string): ProducerMap { + const emitted = + /\/\/# sourceMappingURL=(data:application\/json;base64,[^\r\n]+)/.exec( + code, + )?.[1] + if (!emitted || emitted !== sourceMapURL) + throw new CoverageError('missing or contradictory executed source map') + const map = object( + JSON.parse( + Buffer.from(emitted.slice(emitted.indexOf(',') + 1), 'base64').toString( + 'utf8', + ), + ), + ) + if ( + integer(map['version']) !== 3 || + array(map['sources']).length !== 1 || + array(map['sourcesContent']).length !== 1 + ) + throw new CoverageError('unsupported producer source map inventory') + const input = text(array(map['sourcesContent'])[0]) + const inputLines = input.split('\n').length + const rows: Mapping[][] = [] + let source = 0 + let originalLine = 0 + let originalColumn = 0 + for (const line of text(map['mappings']).split(';')) { + let column = 0 + const row: Mapping[] = [] + for (const segment of line.split(',').filter(Boolean)) { + const values = decodeVlq(segment) + const first = values[0] + if (first === undefined) + throw new CoverageError('empty source map segment') + column += first + if (values.length !== 4 && values.length !== 5) + throw new CoverageError('unmapped source map segment') + const [, sourceDelta = 0, lineDelta = 0, columnDelta = 0] = values + source += sourceDelta + originalLine += lineDelta + originalColumn += columnDelta + if ( + source !== 0 || + originalLine < 0 || + originalLine >= inputLines || + originalColumn < 0 || + column < 0 + ) + throw new CoverageError('invalid producer source map location') + row.push({ column, originalLine }) + } + rows.push(row) + } + const bytes = Buffer.from(code) + const starts = [0] + for (let offset = 0; offset < bytes.length; offset++) + if (bytes[offset] === 10) starts.push(offset + 1) + return { input, bytes, starts, rows } +} + +function decodeVlq(segment: string): number[] { + const result: number[] = [] + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + let value = 0 + let shift = 0 + for (const character of segment) { + const digit = alphabet.indexOf(character) + if (digit < 0 || shift > 48) + throw new CoverageError('invalid source map VLQ') + value += (digit & 31) * 2 ** shift + if (digit & 32) shift += 5 + else { + result.push(value % 2 ? -Math.floor(value / 2) : Math.floor(value / 2)) + value = 0 + shift = 0 + } + } + if (shift !== 0) throw new CoverageError('unterminated source map VLQ') + return result +} + +export function mappedLines( + map: ProducerMap, + range: { readonly startOffset: number; readonly endOffset: number }, +): number[] { + const result: number[] = [] + const min = Math.min(range.startOffset, range.endOffset) + const max = Math.max(range.startOffset, range.endOffset) + if (max > map.bytes.length) + throw new CoverageError('producer range exceeds executed source') + let line = 0 + for (let offset = min; offset < max; offset++) { + while ((map.starts[line + 1] ?? Infinity) <= offset) line++ + const column = offset - (map.starts[line] ?? 0) + if (column === 0) continue + const point = map.rows[line]?.findLast((point) => point.column <= column) + if (point) result.push(point.originalLine + 1) + } + return result +} diff --git a/test-harness/producer-native.ts b/test-harness/producer-native.ts new file mode 100644 index 000000000..76b98ede9 --- /dev/null +++ b/test-harness/producer-native.ts @@ -0,0 +1,160 @@ +import { CoverageError, type SourceCoverage } from './lcov' +import type { BasicBlock, ScriptCapture } from './producer-data' +import { decodeMap, mappedLines } from './producer-map' + +export interface NativeFunction { + readonly startOffset: number + readonly endOffset: number + readonly hasExecuted: boolean +} +export interface NativeView { + readonly code: string + readonly map: string + readonly input: string + readonly functions: readonly NativeFunction[] + readonly lines: ReadonlyMap + readonly blockLines: ReadonlySet + readonly coarse: ReadonlyMap +} + +export function reconstructNative( + capture: ScriptCapture, + source?: SourceCoverage, +): NativeView { + if (!capture.profile) { + if (!source) + throw new CoverageError( + 'missing native observation for unframed public blocks', + ) + const candidates: NativeView[] = [] + for (let split = 1; split < capture.blocks.length; split++) { + const functions = capture.blocks.slice(split) + if (functions.some((fn) => fn.count !== Number(fn.hasExecuted))) continue + const view = projectNative( + capture, + functions, + capture.blocks.slice(0, split), + ) + if (matchesNative(view, source)) candidates.push(view) + } + const unique = new Map( + candidates.map((view) => [ + JSON.stringify({ functions: view.functions, coarse: [...view.coarse] }), + view, + ]), + ) + const candidate = [...unique.values()][0] + if (unique.size !== 1 || !candidate) + throw new CoverageError( + 'ambiguous unframed public native function inventory', + ) + return candidate + } + const [synthetic, ...profiles] = capture.profile.functions + const whole = synthetic?.ranges[0] + if ( + !whole || + whole.startOffset !== 0 || + !synthetic.isBlockCoverage || + profiles.length === 0 + ) + throw new CoverageError('unsupported precise whole-script profile') + const functions = capture.blocks.slice(-profiles.length) + const blocks = capture.blocks.slice(0, -profiles.length) + if (whole.endOffset !== capture.code.length) + throw new CoverageError('contradictory executed source length') + const identities = profiles + .map((fn) => { + const range = fn.ranges[0] + if (!range) throw new CoverageError('missing precise function identity') + return `${range.startOffset},${range.endOffset},${fn.isBlockCoverage}` + }) + .sort() + const actual = functions + .map((fn) => `${fn.startOffset},${fn.endOffset},${fn.hasExecuted}`) + .sort() + if ( + JSON.stringify(actual) !== JSON.stringify(identities) || + functions.some((fn) => fn.count !== Number(fn.hasExecuted)) + ) + throw new CoverageError('contradictory public native function inventory') + return projectNative(capture, functions, blocks) +} + +function projectNative( + capture: ScriptCapture, + functions: readonly BasicBlock[], + blocks: readonly BasicBlock[], +): NativeView { + const map = decodeMap(capture.code, capture.sourceMapURL) + const lines = new Map() + const blockLines = new Set() + for (const block of blocks) { + if (block.startOffset < 0 || block.endOffset < 0) continue + for (const line of mappedLines(map, block)) { + blockLines.add(line) + lines.set( + line, + (lines.get(line) ?? 0) + Number(block.hasExecuted || block.count > 0), + ) + } + } + const nativeFunctions: NativeFunction[] = [] + const coarse = new Map() + for (const fn of functions.length > 1 ? functions.slice(1) : functions) { + if (fn.startOffset < 0 || fn.endOffset < 0) continue + const mapped = mappedLines(map, fn) + if (mapped.length === 0) continue + const identity = { + startOffset: Math.min(fn.startOffset, fn.endOffset), + endOffset: Math.max(fn.startOffset, fn.endOffset), + hasExecuted: fn.hasExecuted, + } + nativeFunctions.push(identity) + if (!fn.hasExecuted) { + const min = Math.min(...mapped) + const max = Math.max(...mapped) + for (let line = min; line < max; line++) { + lines.set(line, 0) + coarse.set(line, [...(coarse.get(line) ?? []), identity]) + } + } + } + return { + code: capture.code, + map: capture.sourceMapURL, + input: map.input, + functions: nativeFunctions, + lines, + blockLines, + coarse, + } +} + +function matchesNative(view: NativeView, source: SourceCoverage): boolean { + return ( + source.functions.size === 0 && + source.branches.size === 0 && + source.foundFunctions === view.functions.length && + source.hitFunctions === + view.functions.filter((fn) => fn.hasExecuted).length && + JSON.stringify([...view.lines].sort(([a], [b]) => a - b)) === + JSON.stringify([...source.lines].sort(([a], [b]) => a - b)) + ) +} + +export function validateNative(view: NativeView, source: SourceCoverage): void { + if (source.functions.size !== 0 || source.branches.size !== 0) + throw new CoverageError( + 'named/branch data contradicts pinned native producer', + ) + if ( + source.foundFunctions !== view.functions.length || + source.hitFunctions !== view.functions.filter((fn) => fn.hasExecuted).length + ) + throw new CoverageError( + `native function lifetime/inventory mismatch: ${source.file}`, + ) + if (!matchesNative(view, source)) + throw new CoverageError(`native line lifetime/map mismatch: ${source.file}`) +} diff --git a/test-harness/producer-preload.ts b/test-harness/producer-preload.ts new file mode 100644 index 000000000..e41f8bc55 --- /dev/null +++ b/test-harness/producer-preload.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { Session } from 'node:inspector/promises' + +import { afterAll } from 'bun:test' + +import { PRODUCER_TIMEOUT_MS } from './producer-timeout' + +const session = new Session() +session.connect() +await session.post('Profiler.enable') +await session.post('Profiler.startPreciseCoverage', { + callCount: true, + detailed: true, +}) + +async function capture(phase: string): Promise { + const continuation = new Promise((resolve) => + process.stdin.once('data', resolve), + ) + const profile = await session.post('Profiler.takePreciseCoverage') + process.stdout.write( + `${process.env['DEVUP_PRODUCER_TOKEN']} ${JSON.stringify({ + phase, + pid: process.pid, + bun: Bun.version, + revision: Bun.revision, + config: readFileSync(process.env['DEVUP_PRODUCER_CONFIG'] ?? '', 'utf8'), + profiles: profile.result, + })}\n`, + ) + await continuation +} + +await capture('bootstrap') +afterAll(() => capture('final'), PRODUCER_TIMEOUT_MS) diff --git a/test-harness/producer-process.ts b/test-harness/producer-process.ts new file mode 100644 index 000000000..5c1edd711 --- /dev/null +++ b/test-harness/producer-process.ts @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { TestGroup } from './groups' +import { CoverageError } from './lcov' +import { ProducerChild } from './producer-child' +import { + object, + parseBlocks, + parseProducerIdentity, + type ScriptCapture, + text, +} from './producer-data' +import { connectProducer, type ProducerProtocol } from './producer-protocol' + +export interface ProducerRun { + readonly root: string + readonly config: string + readonly coverage: string + readonly group: TestGroup + readonly files: readonly string[] + readonly executable: string +} +interface ProducerValue { + readonly output: string + readonly status: number | null + readonly captures: readonly ScriptCapture[] + readonly lcov: string + readonly config: string + readonly pid: number + readonly bun: string + readonly revision: string +} + +const issuance = Symbol() +const issued = new WeakSet() + +export function isProducerResult(value: object): value is ProducerResult { + return issued.has(value) +} + +export class ProducerResult { + readonly #value: ProducerValue + + private constructor(capability: typeof issuance, value: ProducerValue) { + if (capability !== issuance) + throw new CoverageError('unproven native producer issuance') + this.#value = Object.freeze({ + ...value, + captures: Object.freeze(value.captures), + }) + Object.freeze(this) + issued.add(this) + } + + get value(): ProducerValue { + if (!isProducerResult(this)) + throw new CoverageError('unproven native producer lifetime') + return this.#value + } + + static async capture(run: ProducerRun): Promise { + if (existsSync(join(run.coverage, 'lcov.info'))) + throw new CoverageError('stale native producer output directory') + const token = `DEVUP-PRODUCER-${randomUUID()}` + const config = readFileSync(run.config, 'utf8') + const preload = fileURLToPath( + new URL('./producer-preload.ts', import.meta.url), + ) + const producer = new ProducerChild(run, { token, preload }) + const { child, exit } = producer + let protocol: ProducerProtocol | undefined + const records: Record[] = [] + let remainder = '' + child.stdout.prependListener('data', (chunk: Buffer) => { + remainder += chunk.toString() + const lines = remainder.split('\n') + remainder = lines.pop() ?? '' + for (const line of lines) { + if (line.startsWith(`${token} `)) + records.push(object(JSON.parse(line.slice(token.length + 1)))) + } + }) + try { + await producer.wait(() => /ws:\/\/[^\s]+/.test(producer.output)) + const url = /ws:\/\/[^\s]+/.exec(producer.output)?.[0] + if (!url) throw new CoverageError('missing producer inspector URL') + protocol = await connectProducer(url) + protocol.onFailure = producer.observeFailure.bind(producer) + await protocol.request('Inspector.initialized') + await producer.wait(() => + records.some((record) => record['phase'] === 'bootstrap'), + ) + const bootstrap = records.find( + (record) => record['phase'] === 'bootstrap', + ) + parseProducerIdentity(bootstrap, { pid: child.pid, config }) + child.stdin.write('bootstrap accepted\n') + await producer.wait(() => + records.some((record) => record['phase'] === 'final'), + ) + const record = records.find((record) => record['phase'] === 'final') + const { profiles, ...identity } = parseProducerIdentity(record, { + pid: child.pid, + config, + }) + const captures: ScriptCapture[] = [] + let lateObserved = false + const late = new Promise((done) => { + if (!protocol) throw new CoverageError('missing producer protocol') + protocol.onEvent = (event) => { + if ( + event['method'] === 'Debugger.paused' || + event['method'] === 'Debugger.scriptParsed' + ) { + lateObserved = true + producer.terminate() + done() + } + } + }) + const scripts = protocol.events + .filter((event) => event['method'] === 'Debugger.scriptParsed') + .map((event) => object(event['params'])) + for (const event of scripts) { + const scriptId = text(event['scriptId']) + const path = text(event['url']) + if ( + path.includes('node_modules') || + /(?:\.|_)(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/.test(path) + ) + continue + const file = resolve(run.root, path.split('?')[0] ?? path) + const mappedFile = isAbsolute(path) && Boolean(event['sourceMapURL']) + if (!path || (!mappedFile && !existsSync(file))) continue + const matching = profiles.filter( + (profile) => profile.scriptId === scriptId, + ) + if (matching.length > 1) + throw new CoverageError('ambiguous producer precise identity') + const source = object( + await protocol.request('Debugger.getScriptSource', { scriptId }), + ) + const blocks = object( + await protocol.request('Runtime.getBasicBlocks', { + sourceID: scriptId, + }), + ) + captures.push( + Object.freeze({ + profile: matching[0], + scriptId, + url: resolve(run.root, path), + scriptType: text(event['scriptType']), + sourceMapURL: text(event['sourceMapURL']), + code: text(source['scriptSource']), + blocks: parseBlocks(blocks['basicBlocks']), + }), + ) + } + await protocol.armEndFence(captures, preload) + producer.assertObservation() + child.stdin.write('final evidence collected\n') + await Promise.race([exit, late]) + if (lateObserved) + throw new CoverageError( + 'late producer code/inventory after final capture', + ) + const status = await exit + await producer.finish() + if ( + records.filter((record) => record['phase'] === 'bootstrap').length !== + 1 || + records.filter((record) => record['phase'] === 'final').length !== 1 + ) + throw new CoverageError('ambiguous producer lifetime records') + protocol.close() + const lcov = readFileSync(join(run.coverage, 'lcov.info'), 'utf8') + if (readFileSync(run.config, 'utf8') !== config) + throw new CoverageError( + 'producer configuration changed during measurement', + ) + const result = { + ...identity, + output: producer.output + .split('\n') + .filter((line) => !line.startsWith(`${token} `)) + .join('\n'), + status, + captures, + lcov, + } + writeFileSync(join(run.coverage, 'producer.json'), JSON.stringify(result)) + return new ProducerResult(issuance, result) + } catch (error) { + protocol?.close() + await producer.finish() + if (!(error instanceof Error)) throw error + throw producer.failure(error) + } finally { + protocol?.close() + } + } +} + +export const captureProcess = ProducerResult.capture.bind(ProducerResult) diff --git a/test-harness/producer-protocol.ts b/test-harness/producer-protocol.ts new file mode 100644 index 000000000..0b3f754e9 --- /dev/null +++ b/test-harness/producer-protocol.ts @@ -0,0 +1,165 @@ +import { CoverageError } from './lcov' +import { + array, + integer, + object, + type ScriptCapture, + text, +} from './producer-data' + +const NativeWebSocket = globalThis.WebSocket + +interface PendingRequest { + readonly complete: (value: unknown) => void + readonly reject: (error: Error) => void + readonly timeout: ReturnType +} + +export class ProducerProtocol { + private sequence = 0 + private readonly pending = new Map() + readonly events: Record[] = [] + onEvent: ((event: Record) => void) | undefined + onFailure: ((error: CoverageError) => void) | undefined + + constructor(private readonly socket: WebSocket) { + socket.addEventListener('message', (event) => { + try { + const message = object(JSON.parse(String(event.data))) + if (message['id'] !== undefined) { + const id = integer(message['id']) + const request = this.pending.get(id) + if (!request) + throw new CoverageError('unexpected producer RPC response') + this.pending.delete(id) + clearTimeout(request.timeout) + request.complete(message) + } else { + this.events.push(message) + this.onEvent?.(message) + } + } catch (error) { + if (!(error instanceof SyntaxError || error instanceof CoverageError)) + throw error + this.fail( + new CoverageError(`invalid producer RPC message: ${error.message}`), + ) + } + }) + socket.addEventListener( + 'error', + this.fail.bind(this, new CoverageError('producer socket error')), + ) + socket.addEventListener( + 'close', + this.fail.bind(this, new CoverageError('producer socket closed')), + ) + } + + private fail(error: CoverageError): void { + this.onFailure?.(error) + for (const request of this.pending.values()) { + clearTimeout(request.timeout) + request.reject(error) + } + this.pending.clear() + this.socket.close() + } + + async request( + method: string, + params: Readonly> = {}, + ): Promise { + const id = ++this.sequence + const response = object( + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new CoverageError(`producer RPC timeout: ${method}`)) + }, 10000) + this.pending.set(id, { complete: resolve, reject, timeout }) + this.socket.send(JSON.stringify({ id, method, params })) + }), + ) + if (response['error'] !== undefined) + throw new CoverageError( + `producer RPC rejected: ${method}: ${JSON.stringify(response['error'])}`, + ) + return response['result'] + } + + close(): void { + this.socket.close() + } + + async armEndFence( + captures: readonly ScriptCapture[], + preload: string, + ): Promise { + await this.request('Debugger.setBreakpointsActive', { active: true }) + const armed = new Set() + for (const capture of captures) { + if (capture.url === preload) continue + const codeLines = capture.code.split('\n') + const locations = object( + await this.request('Debugger.getBreakpointLocations', { + start: { scriptId: capture.scriptId, lineNumber: 0, columnNumber: 0 }, + end: { + scriptId: capture.scriptId, + lineNumber: codeLines.length - 1, + columnNumber: codeLines.at(-1)?.length ?? 0, + }, + }), + ) + for (const item of array(locations['locations'])) { + const location = object(item) + if (text(location['scriptId']) !== capture.scriptId) + throw new CoverageError('contradictory producer fence identity') + const point = { + scriptId: capture.scriptId, + lineNumber: integer(location['lineNumber']), + columnNumber: integer(location['columnNumber']), + } + const key = JSON.stringify(point) + if (armed.has(key)) continue + armed.add(key) + await this.request('Debugger.setBreakpoint', { location: point }) + } + } + } +} + +export async function connectProducer(url: string): Promise { + const socket = new NativeWebSocket(url) + const protocol = new ProducerProtocol(socket) + await new Promise((resolve, reject) => { + const timeout = setTimeout( + reject.bind(undefined, new CoverageError('producer connection timeout')), + 10000, + ) + socket.addEventListener( + 'open', + () => { + clearTimeout(timeout) + resolve() + }, + { once: true }, + ) + socket.addEventListener( + 'error', + () => { + clearTimeout(timeout) + reject(new CoverageError('producer connection failed')) + }, + { once: true }, + ) + }) + try { + await protocol.request('Runtime.enable') + await protocol.request('Debugger.enable') + return protocol + } catch (error) { + protocol.close() + throw error + } +} diff --git a/test-harness/producer-timeout.ts b/test-harness/producer-timeout.ts new file mode 100644 index 000000000..77d36c393 --- /dev/null +++ b/test-harness/producer-timeout.ts @@ -0,0 +1 @@ +export const PRODUCER_TIMEOUT_MS = 600000 diff --git a/test-harness/run.ts b/test-harness/run.ts new file mode 100644 index 000000000..2db0664ad --- /dev/null +++ b/test-harness/run.ts @@ -0,0 +1,133 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + auditCoverage, + coverageTable, + mergeCoverage, + writeLcov, +} from './coverage' +import { discoverTests, partitionTests, type TestGroup } from './groups' +import { CoverageError } from './lcov' +import { VerifiedSourceCoverage } from './producer-coverage' +import { captureProcess } from './producer-process' + +export const requiredSources = [ + 'bun.preload.ts', + 'test-harness/groups.ts', + 'test-harness/lcov.ts', + 'test-harness/coverage.ts', + 'test-harness/run.ts', + 'test-harness/preload.ts', + 'test-harness/producer-data.ts', + 'test-harness/producer-map.ts', + 'test-harness/producer-native.ts', + 'test-harness/producer-coverage.ts', + 'test-harness/producer-process.ts', + 'test-harness/producer-child.ts', + 'test-harness/producer-timeout.ts', + 'test-harness/producer-protocol.ts', + 'test-harness/producer-preload.ts', + 'packages/plugin-utils/src/build-admission.cts', + 'packages/plugin-utils/src/build-generation.ts', + 'packages/webpack-plugin/src/build-scope.ts', + 'packages/next-plugin/src/webpack-generation.ts', + 'packages/bun-plugin/src/plugin.ts', + 'packages/webpack-plugin/native-fixture-bundle.mjs', +] as const + +export class TestProcessError extends Error { + constructor( + readonly group: TestGroup, + readonly status: number | null, + ) { + super(`Test process ${group} failed (${status})`) + this.name = 'TestProcessError' + } +} + +export interface TestRun { + readonly root: string + readonly config: string + readonly groups: Readonly> + readonly required: readonly string[] +} + +export function parseTestResult(output: string): { + pass: number + fail: number +} { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .join('\n') + const pass = /^(\d+) pass$/m.exec(lines)?.[1] + const fail = /^(\d+) fail$/m.exec(lines)?.[1] + if (pass === undefined || fail === undefined) + throw new CoverageError('missing child test result') + return { pass: Number(pass), fail: Number(fail) } +} + +export async function runTestGroups( + run: TestRun, + executable = process.execPath, +): Promise { + const coverageDirectory = join(run.root, 'coverage') + mkdirSync(coverageDirectory, { recursive: true }) + const acceptedReport = join(coverageDirectory, 'lcov.info') + rmSync(acceptedReport, { force: true }) + const temporary = mkdtempSync(join(coverageDirectory, 'groups-')) + const reports = [] + let pass = 0 + let fail = 0 + try { + for (const group of ['isolated', 'runtime'] as const) { + const files = run.groups[group] + if (files.length === 0) + throw new CoverageError(`empty test group ${group}`) + const child = await captureProcess({ + root: run.root, + config: run.config, + coverage: join(temporary, group), + group, + files, + executable, + }) + const output = child.value.output + // Child totals are intermediate; only the final root summary is unprefixed. + console.info( + output + .split(/\r?\n/) + .map((line) => + /^\d+ (?:pass|fail)$/.test(line.trim()) + ? `[${group}] ${line}` + : line, + ) + .join('\n'), + ) + if (child.value.status !== 0) + throw new TestProcessError(group, child.value.status) + const totals = parseTestResult(output) + pass += totals.pass + fail += totals.fail + reports.push(VerifiedSourceCoverage.bind(child, run.root)) + } + const sources = mergeCoverage(reports) + auditCoverage(sources, run.required) + const report = writeLcov(sources) + writeFileSync(acceptedReport, report) + console.info(coverageTable(sources)) + return `${pass} pass\n${fail} fail\nMerged ${sources.length} source files; 100% lines/functions per source.` + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} + +export function rootTestRun(root: string): TestRun { + return { + root, + config: join(root, 'test-harness/bunfig.toml'), + groups: partitionTests(discoverTests(root)), + required: requiredSources, + } +}