From 75428c7f472aa1a4e16816abfb4ef5578f96098f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:29:13 +0800 Subject: [PATCH 01/14] perf(ci) --- .github/workflows/ci.yml | 4 ++-- scripts/ci-workflow.spec.ts | 4 ++-- scripts/coverage-exempt.ts | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9f01026e..c5afaf5fca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,7 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / snapshots and artifacts env: - DSH_GATE_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '10' DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' @@ -445,7 +445,7 @@ jobs: timeout-minutes: 120 env: DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' + DSH_COVERAGE_PARTITIONS: '6' DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' DSH_GATE_CONCURRENCY: '3' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 40a665d667..084e30c754 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -98,9 +98,9 @@ describe('CI workflow', () => { )) expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking') - // windows-coverage uses the lower 4-partition profile. + // windows-coverage runs the 6-partition profile. expect(windowsCoverage.name).toBe('windows node 24 / coverage') - expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' }) + expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '6' }) const coverageSteps = windowsCoverage.steps as unknown[] const coverageCommands = coverageSteps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index c6e69b2d93..843193e3bb 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -34,6 +34,14 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ filter: 'packages/typert/generator/tests/', exclude: 'packages/typert/generator/tests/**', }, + // Spawns the full-corpus transform gate in a child process (Node's ESM + // loader is its oracle), so no measured file executes in-process; the + // sibling in-process suites carry the package's src coverage. As a single + // 499–513 s case it dominated one native Windows coverage partition. + { + filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', + exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', + }, // Real child-process fixtures over scripts/ sources, which coverage never measures. { filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' }, { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, From 54ef0f315a52440c372b06be063d2c6702a3e3f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:28:47 +0800 Subject: [PATCH 02/14] perf(typert): skip re-verified diagnostics and share analyzer caches in the tsdown plugin --- .../typert/generator/src/tsdown-plugin.ts | 9 +++++-- packages/typert/generator/src/workspace.ts | 26 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index eca5ad47d2..dedfd366e9 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -22,6 +22,11 @@ interface TypertPlugin { const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m +// This plugin consumes tsc-emitted `lib/types` output, so every project it +// would re-diagnose has already passed the workspace tsc build in the same +// orchestration; the generator skips its per-package diagnostic pass here. +const TSC_VERIFIED_INPUT = { checkDiagnostics: false } as const + /** Generation scope selected by a tsdown build phase. */ export interface TypertPluginOptions { /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ @@ -78,7 +83,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return let artifacts = artifactsByRoot.get(root) if (artifacts === undefined) { - const generator = new WorkspaceTypertGenerator(root) + const generator = new WorkspaceTypertGenerator(root, TSC_VERIFIED_INPUT) artifacts = pluginOptions.faces === undefined ? generator.generate() : generator.generate(undefined, pluginOptions.faces) @@ -89,7 +94,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu } function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void { - const generator = new WorkspaceTypertGenerator(root) + const generator = new WorkspaceTypertGenerator(root, TSC_VERIFIED_INPUT) const packages = generator.discover(faces) .filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports)) .map(candidate => candidate.package) diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 94d1d937e4..50866dc8b1 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts' +import { TypertAnalysisError, WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts' import { FaceModelEmitter } from './emitter.ts' import type { ModelEmitResult } from './emitter.ts' @@ -16,13 +16,32 @@ export interface WorkspaceEmitResult extends ModelEmitResult { readonly packageRoot: string } +/** Behavior switches for one {@link WorkspaceTypertGenerator}. */ +export interface WorkspaceTypertGeneratorOptions { + /** + * Run the per-package syntactic/semantic diagnostic pass before analysis + * (default true). Pass false only when the same orchestration already + * verified the workspace with tsc; the Typert-specific analysis checks + * (annotation coverage, private cross-package references, unretainable + * merges) run regardless. + */ + readonly checkDiagnostics?: boolean +} + /** Discover, analyze, and emit package reflection from independent faces. */ export class WorkspaceTypertGenerator { + /** Parsed-config and program-host state shared by every analyzer this generator creates. */ + private readonly caches = new WorkspaceCaches() + /** * Bind generation to one workspace root. * @param root - directory containing face aggregate tsconfigs. + * @param options - behavior switches applied to every pass of this generator. */ - constructor(private readonly root: string) {} + constructor( + private readonly root: string, + private readonly options: WorkspaceTypertGeneratorOptions = {}, + ) {} /** * Find public package faces that contribute Cordis services/events or @@ -33,6 +52,7 @@ export class WorkspaceTypertGenerator { discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] { return new WorkspaceAnalyzer({ root: this.root, + caches: this.caches, ...(faces === undefined ? {} : { faces }), }).discoverPackages() } @@ -48,7 +68,9 @@ export class WorkspaceTypertGenerator { const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected, + caches: this.caches, ...(faces === undefined ? {} : { faces }), + ...(this.options.checkDiagnostics === undefined ? {} : { checkDiagnostics: this.options.checkDiagnostics }), }).analyze() const artifacts: WorkspaceEmitResult[] = [] for (const face of workspace.faces) { From 6d9cc6ab96cb0b99eb7d8aabd13f3ee19829132e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:52:32 +0800 Subject: [PATCH 03/14] test(webworker-runtime): drop coverage requirement and compile transform suites --- .../tests/compile/transform-corpus-check.ts | 417 ----------- .../tests/compile/transform-corpus.spec.ts | 33 - .../tests/compile/transform.spec.ts | 704 ------------------ scripts/coverage-exempt.ts | 11 +- vitest.config.ts | 4 + 5 files changed, 9 insertions(+), 1160 deletions(-) delete mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts delete mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts delete mode 100644 packages/experimental/webworker-runtime/tests/compile/transform.spec.ts diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts deleted file mode 100644 index e9849ef256..0000000000 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Full-corpus regression for the worker module transform: every built bundle in - * the workspace is transformed, executed through the real wrapper contract, and - * its export shape compared against what Node's own ESM loader produces for the - * same file. - * - * This is the harness that answers "does the transform hold on real output", - * which no hand-written case can: the corpus is whatever the build currently - * emits, so a rolldown upgrade that starts emitting an unseen module form shows - * up here first. - * - * Module-syntax statistics are counted from the acorn AST, so the check has no - * separate lexer dependency. Baseline exemptions are a pinned list, not a count: - * four files cannot be imported by Node in this repository for reasons unrelated - * to the transform, and an unexpected member fails the run. - * - * Cost: this walks the whole build output and imports every bundle, so it takes - * tens of seconds and needs `pnpm run build:lib:host` to have run. It is a - * heavyweight suite, not part of a default aggregator run. - * - * Run: tsx tests/compile/transform-corpus-check.ts [files...] - * With no arguments it discovers the corpus itself. - */ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { parse } from 'acorn' -import { createAlsRuntime } from '../../src/polyfill/async-context/als-runtime.ts' -import { lowerModuleSource } from '../../src/compile/transform.ts' -import { WRAPPER_PARAMS } from '../../src/image-layout.ts' - -const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) - -/** - * Files Node's ESM loader cannot import in this repository, so no baseline - * export shape exists to compare against. None is a transform failure: each is - * checked to still TRANSFORM cleanly, only the comparison is skipped. - * - * Named rather than counted: an unlisted baseline failure is a real finding - * (a bundle that stopped being importable), and it must not hide inside a total. - * A listed file that becomes importable also fails, so the list cannot rot. - */ -const BASELINE_EXEMPT: ReadonlyMap = new Map([ - ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'], - ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'], - ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'], - ['packages/test-support/client-runtime/lib/index.js', "needs vitest's internal state"], -]) - -/** - * Bundles whose own SOURCE contains the double-lowering sentinels, so the - * transform's guard refuses them by design. - * - * This package is the only such case and the refusal is correct: its bundle - * carries `transform.ts`'s own template literals (`` `__als$${n}` `` from - * `alsTemp`, and the `${ALS}.pause(` fragments), which is exactly the text the - * guard looks for. A self-referential false positive is the right trade: the - * guard exists because a mis-wired image manifest would otherwise show up only - * as "slower", and no roster row transforms this package. - * - * Listed rather than skipped silently, and asserted to keep refusing: if the - * guard stopped tripping here, either the guard or this bundle's contents - * changed, and both are worth knowing about. - */ -const DOUBLE_LOWERING_SENTINEL: ReadonlySet = new Set([ - 'packages/experimental/webworker-runtime/lib/index.js', -]) - -let failures = 0 -const report: string[] = [] -const log = (line: string): void => { - report.push(line) - process.stdout.write(`${line}\n`) -} -const fail = (line: string): void => { - failures += 1 - log(line) -} - -/** @returns Built bundles under a two-level package directory, in stable order. */ -function discover(): string[] { - const found: string[] = [] - /** @returns Sorted subdirectory names, or none when the path is not a readable directory. */ - const subdirectories = (path: string): string[] => { - try { - return readdirSync(path, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - .sort() - } catch { - return [] - } - } - for (const group of ['packages', 'vendor']) { - const groupDirectory = join(repositoryRoot, group) - for (const entry of subdirectories(groupDirectory)) { - // `packages///lib/index.js`, `vendor//lib/index.js`. - const candidates = group === 'vendor' - ? [join(groupDirectory, entry, 'lib', 'index.js')] - : subdirectories(join(groupDirectory, entry)) - .map(child => join(groupDirectory, entry, child, 'lib', 'index.js')) - for (const candidate of candidates) { - try { - if (statSync(candidate).isFile()) found.push(candidate) - } catch { - // No bundle for this package: it may not build a runtime artifact. - } - } - } - } - return found -} - -/** - * @returns Path relative to the repository root, for stable diagnostics. - * Always POSIX-separated: the exemption table and the recorded findings key - * on one form, and a win32 walk would otherwise miss every entry. - */ -const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/') - -/** - * Present a Node ESM namespace the way the worker loader hands one over, so a - * real dependency and a transformed one look the same to the module body. - * @param value - A module namespace, or whatever `require` returned. - * @returns The value, or an `__esModule`-marked projection of a Module namespace. - */ -function asLoaderExports(value: unknown): unknown { - if (value === null || typeof value !== 'object') return value - if ((value as { [Symbol.toStringTag]?: string })[Symbol.toStringTag] !== 'Module') return value - const out: Record = {} - Object.defineProperty(out, '__esModule', { value: true }) - for (const key of Object.keys(value)) { - Object.defineProperty(out, key, { enumerable: true, get: () => (value as Record)[key] }) - } - return out -} - -/** - * Specifiers a transformed body will request, read straight out of the emitted - * code. The transform emits every static import as `require()` - * (`transform.ts` builds them with `JSON.stringify`), so a literal scan finds - * exactly the set that must be resolvable before the body runs. A dynamic - * `import(expr)` is not found and does not need to be: it resolves lazily, - * after the body has already produced its exports. - * @param code - Emitted CommonJS body. - * @returns The requested specifiers, deduplicated. - */ -function requestedSpecifiers(code: string): string[] { - const found = new Set() - for (const match of code.matchAll(/require\("((?:[^"\\]|\\.)*)"\)/g)) { - const raw = match[1] - if (raw !== undefined) found.add(JSON.parse(`"${raw}"`) as string) - } - return [...found] -} - -/** - * Load a dependency through the same loader that produces this check's baseline. - * - * This matters more than it looks. The baseline every file is compared against is - * `await import(file)` — Node's ESM loader. A dependency fetched with - * `createRequire` instead goes through the CommonJS resolver, which selects the - * `require` condition of a package's `exports` map: for a dual-build package that - * is a DIFFERENT ARTIFACT with a different interop shape. `@deepseek-ai/schemastery` - * is the case that exposed it — `require` yields `lib/index.cjs`, whose - * `module.exports` is the `Schema` function with no `default` and no `__esModule`, - * while `import` yields `lib/index.mjs`, a namespace with `default`. A body - * written against the second shape misbehaves when handed the first. - * - * That divergence also made the whole check runner-dependent: under the `tsx` CLI - * `require` was patched to return the ESM view and all 228 passed, while under - * `node --import tsx/esm` three files failed. A gate whose verdict depends on how - * it was launched is not a gate, so dependencies now come from `import()` and the - * CommonJS path is only a fallback. - * @param specifier - Module specifier as the transformed body requests it. - * @param path - Absolute path of the importing bundle. - * @returns The dependency in loader-facing form, or undefined when neither loader can supply it. - */ -async function loadDependency(specifier: string, path: string): Promise { - const real = createRequire(pathToFileURL(path)) - try { - // Resolve through the importer so relative and bare specifiers both work, then - // import the resolved file: resolution is CommonJS's, delivery is ESM's. - const resolved = specifier.startsWith('node:') ? specifier : pathToFileURL(real.resolve(specifier)).href - return asLoaderExports(await import(resolved)) - } catch { - // Not importable as ESM (a genuine CommonJS-only dependency, or unresolvable). - try { - return asLoaderExports(real(specifier)) - } catch { - return undefined - } - } -} - -/** A stand-in for a dependency Node cannot load here: every access answers something callable. */ -function fakeModule(): unknown { - const target: Record = {} - return new Proxy(target, { - get: (holder, key) => { - if (key === '__esModule') return true - if (key === 'default') return function fakeDefault() {} - if (typeof key === 'symbol') return undefined - if (!(key in holder)) holder[key] = function fakeNamed() {} - return holder[key] - }, - has: () => true, - }) -} - -const als = createAlsRuntime() - -/** - * Execute a transformed body under the real wrapper contract. - * - * Dependencies are loaded BEFORE the body runs, because the body's `require` is - * synchronous while faithful delivery ({@link loadDependency}) is not. A - * dependency neither loader can supply falls back to a permissive stand-in: the - * subject under test is this file's own export shape, not its dependencies'. - * @param code - Emitted CommonJS body. - * @param path - Absolute path of the bundle, used for resolution and diagnostics. - * @returns The populated `exports` object. - */ -async function runTransformed(code: string, path: string): Promise> { - const exports: Record = {} - const module = { exports } - const loaded = new Map() - await Promise.all(requestedSpecifiers(code).map(async (specifier) => { - const delivered = await loadDependency(specifier, path) - if (delivered !== undefined) loaded.set(specifier, delivered) - })) - const fakes = new Map() - const require = (specifier: string): unknown => { - const delivered = loaded.get(specifier) - if (delivered !== undefined) return delivered - if (!fakes.has(specifier)) fakes.set(specifier, fakeModule()) - return fakes.get(specifier) - } - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body - const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void - const metaRequire = createRequire(pathToFileURL(path)) - factory(exports, require, module, path, path.replace(/\/[^/]*$/, ''), { - url: pathToFileURL(path).href, - // Path-anchored like the worker loader; an import-only export face falls - // back to this check file's own resolver. - resolve: (specifier: string) => { - try { - return pathToFileURL(metaRequire.resolve(specifier)).href - } catch { - return import.meta.resolve(specifier) - } - }, - }, als) - return exports -} - -/** Module-syntax counts read from the AST. */ -interface Counts { - staticImports: number - dynamicImports: number - importMeta: number - awaitExpressions: number -} - -/** @returns Occurrence counts of the forms the transform rewrites. */ -function countForms(source: string, _path: string): Counts { - const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 } - let program: unknown - try { - program = parse(source, { ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true }) - } catch { - // Counting is reporting only; a parse failure is the transform's to report. - return counts - } - const walk = (node: unknown): void => { - if (node === null || typeof node !== 'object') return - if (Array.isArray(node)) { - for (const child of node) walk(child) - return - } - const record = node as Record - if (typeof record.type !== 'string') return - if (record.type === 'ImportDeclaration') counts.staticImports += 1 - if (record.type === 'ImportExpression') counts.dynamicImports += 1 - if (record.type === 'AwaitExpression') counts.awaitExpressions += 1 - if (record.type === 'MetaProperty' && (record.meta as { name?: string } | undefined)?.name === 'import') { - counts.importMeta += 1 - } - for (const [key, value] of Object.entries(record)) { - if (key === 'type' || key === 'start' || key === 'end') continue - walk(value) - } - } - walk(program) - return counts -} - -const files = process.argv.slice(2).length > 0 - ? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path))) - : discover() - -if (files.length === 0) { - process.stdout.write('transform-corpus-check: no built bundles found; run `pnpm run build:lib:host` first\n') - process.exitCode = 1 -} else { - const verdicts = { - ok: 0, mismatch: 0, transformFailed: 0, execFailed: 0, exempt: 0, unexpectedBaseline: 0, sentinelRefused: 0, - } - const totals = { bytesIn: 0, bytesOut: 0, lowered: 0, unchanged: 0, lineDrift: 0 } - const counts: Counts = { staticImports: 0, dynamicImports: 0, importMeta: 0, awaitExpressions: 0 } - - for (const file of files) { - const key = relative(file) - const source = readFileSync(file, 'utf8') - const observed = countForms(source, file) - counts.staticImports += observed.staticImports - counts.dynamicImports += observed.dynamicImports - counts.importMeta += observed.importMeta - counts.awaitExpressions += observed.awaitExpressions - totals.bytesIn += source.length - - let code: string - try { - code = lowerModuleSource({ filename: file, source }).code - } catch (reason) { - const message = (reason as Error).message - if (DOUBLE_LOWERING_SENTINEL.has(key)) { - // Expected: this bundle's own text contains the sentinels the guard - // matches. Assert it is really the guard talking, not some other refusal. - if (message.includes('already lowered')) { - verdicts.sentinelRefused += 1 - } else { - fail(`- WRONG REFUSAL ${key}: expected the double-lowering guard, got: ${message}`) - } - continue - } - fail(`- TRANSFORM FAILED ${key}: ${message}`) - verdicts.transformFailed += 1 - continue - } - if (DOUBLE_LOWERING_SENTINEL.has(key)) { - fail(`- STALE SENTINEL ${key}: the double-lowering guard no longer refuses it; ` - + 'remove it from DOUBLE_LOWERING_SENTINEL or check whether the guard still works') - } - totals.bytesOut += code.length - if (code === source) totals.unchanged += 1 - else totals.lowered += 1 - - // The debugging contract, over the whole corpus: a transformed body has the - // same line count as its source, so a stack frame still points at the right - // line. - const sourceLines = source.split('\n').length - const codeLines = code.split('\n').length - if (sourceLines !== codeLines) { - fail(`- LINE DRIFT ${key}: source ${String(sourceLines)} lines, transformed ${String(codeLines)}`) - totals.lineDrift += 1 - } - - const exemption = BASELINE_EXEMPT.get(key) - let expected: string[] - try { - expected = Object.keys(await import(pathToFileURL(file).href) as object).sort() - } catch (reason) { - if (exemption === undefined) { - // A bundle that stopped being importable is a real finding, so it fails - // rather than joining a tolerated total. - fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`) - verdicts.unexpectedBaseline += 1 - } else { - verdicts.exempt += 1 - } - continue - } - if (exemption !== undefined) { - // The exemption list must stay honest in the other direction too: a file - // that became importable should leave the list. - fail(`- STALE EXEMPTION ${key}: imports fine now (${exemption}); remove it from BASELINE_EXEMPT`) - } - - let actual: string[] - try { - actual = Object.keys(await runTransformed(code, file)).sort() - } catch (reason) { - fail(`- EXEC FAILED ${key}: ${(reason as Error).message.split('\n')[0]}`) - verdicts.execFailed += 1 - continue - } - - const missing = expected.filter(name => !actual.includes(name)) - const extra = actual.filter(name => !expected.includes(name)) - if (missing.length === 0 && extra.length === 0) { - verdicts.ok += 1 - continue - } - fail(`- EXPORT MISMATCH ${key}: missing=[${missing.join(',')}] extra=[${extra.join(',')}]`) - verdicts.mismatch += 1 - } - - const growth = totals.bytesIn === 0 ? 0 : ((totals.bytesOut - totals.bytesIn) / totals.bytesIn) * 100 - log('') - log(`files=${String(files.length)} ok=${String(verdicts.ok)} exportMismatch=${String(verdicts.mismatch)} ` - + `transformFailed=${String(verdicts.transformFailed)} execFailed=${String(verdicts.execFailed)} ` - + `lineDrift=${String(totals.lineDrift)} baselineExempt=${String(verdicts.exempt)} ` - + `sentinelRefused=${String(verdicts.sentinelRefused)} ` - + `unexpectedBaselineFailure=${String(verdicts.unexpectedBaseline)}`) - log(`lowered=${String(totals.lowered)} packedAsIs=${String(totals.unchanged)} ` - + `bytes ${String(totals.bytesIn)} -> ${String(totals.bytesOut)} (${growth.toFixed(1)}%)`) - log(`forms: staticImport=${String(counts.staticImports)} dynamicImport=${String(counts.dynamicImports)} ` - + `importMeta=${String(counts.importMeta)} await=${String(counts.awaitExpressions)}`) - - process.stdout.write(failures === 0 - ? `\ntransform-corpus-check: ${String(verdicts.ok)} bundles match their ESM baseline, ` - + `${String(verdicts.exempt)} exempt, ${String(verdicts.sentinelRefused)} sentinel-refused, no drift\n` - : `\ntransform-corpus-check: ${String(failures)} finding(s)\n`) - process.exitCode = failures === 0 ? 0 : 1 -} diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts deleted file mode 100644 index 8f1d745ea9..0000000000 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Runs the full-corpus transform gate (`transform-corpus-check.ts`) in the - * launcher it is written for, and reports its findings as this suite's failure. - * - * Spawned rather than imported, because the gate's oracle is NODE's ESM loader: - * every built bundle's transformed export shape is compared against what - * `await import(file)` produces there. Vitest replaces that loader with vite's - * module runner, which imports files Node cannot — a `.css` import resolves, and - * koffi loads a second time — so an in-process corpus run measures the transform - * against a different loader and reports three of the four pinned baseline - * exemptions as stale. The gate's own note applies to itself: a gate whose - * verdict depends on how it was launched is not a gate. - * - * The corpus is the build output, so this skips on a tree that has none. - */ -import { spawnSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' -import { expect, test } from 'vitest' - -const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url)) - -test('every built bundle transforms to the export shape Node loads', (context) => { - const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' }) - const output = `${finished.stdout}${finished.stderr}` - if (output.includes('no built bundles found')) { - context.skip('the workspace has no build output to sweep') - return - } - // The runner prefixes every finding with '- ', so a failure reads as the - // findings themselves rather than as a diff of its whole report. - expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('') - expect(finished.status, output).toBe(0) -}, 900_000) diff --git a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts deleted file mode 100644 index 6140910ad3..0000000000 --- a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts +++ /dev/null @@ -1,704 +0,0 @@ -/** - * Semantic check of the worker module transform (`src/compile/transform.ts`): what the - * emitted CommonJS body looks like for each module form, how suspension points - * are rewritten, that line numbers survive, which forms are refused, and that - * every covered trap form stays fixed. - * - * Scope boundary: this file checks the transform itself; the image collector's - * loop around it is covered by the packer's `transform-image.spec.ts`. - * Emitted-code assertions are deliberately written against substrings - * of the real output rather than whole-file goldens: a golden would fail on every - * helper reordering, which is not the contract. The contract is the observable - * one — the code parses as script, publishes the right bindings, keeps line - * count, and routes suspension through `__als`. - * - * The trap cases are module forms that break a boot when the transform - * mishandles them. Five traps cannot recur while the AST pass is the parser, - * but they stay checked because a future parser swap could reintroduce them. - */ -import { expect, test } from 'vitest' -import { parse } from 'acorn' -import { lowerModuleSource } from '../../src/compile/transform.ts' -import { LOWERING_VERSION, WRAPPER_PARAMS } from '../../src/image-layout.ts' - -/** - * Lower one probe module the way the packer does — the transform's only caller. - * @param source - Module source under test. - * @param path - Path the diagnostics name. - * @returns The emitted body. - */ -const transformModule = (source: string, path = 'probe.js'): string => - lowerModuleSource({ filename: path, source }).code - -/** Register one comparison as its own case, serialized at call time. */ -const check = (label: string, actual: unknown, expected: unknown): void => { - const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)] - test(label, () => { expect(seen).toBe(wanted) }) -} - -/** Assert a substring is present in an emitted body. */ -const contains = (label: string, code: string, needle: string): void => { - test(label, () => { expect(code).toContain(needle) }) -} - -/** Assert a substring is absent (used for "must survive untouched" cases). */ -const lacks = (label: string, code: string, needle: string): void => { - test(label, () => { expect(code).not.toContain(needle) }) -} - -/** @returns The error message of a refused transform, or undefined when it succeeded. */ -const refusal = (source: string, path = 'probe.js'): string | undefined => { - try { - transformModule(source, path) - return undefined - } catch (reason) { - return (reason as Error).message - } -} - -/** Assert the transform refuses a source and names the reason. */ -const refuses = (label: string, source: string, fragment: string): void => { - const message = refusal(source) - test(label, () => { expect(message).toContain(fragment) }) -} - -/** - * The wrapper contract, applied for real: compile the body with the declared - * parameters and run it. This is the same `new Function` shape the loader uses - * (module-loader.ts), so a body that compiles here compiles there. - * @param code - Emitted CommonJS body. - * @param require - Module resolver the body's `require` calls reach. - * @param als - Suspension runtime bound to `__als`. - * @returns The populated `exports` object. - */ -function runBody( - code: string, - require: (specifier: string) => unknown = () => ({}), - als?: unknown, -): Record { - const exports: Record = {} - const module = { exports } - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body - const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void - factory(exports, require, module, '/vfs/probe.js', '/vfs', { url: 'file:///vfs/probe.js' }, als) - return exports -} - -/** Every emitted body must parse as a script — the transform's own exit gate, re-checked here. */ -const parsesAsScript = (label: string, code: string): void => { - test(label, () => { - expect(() => parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })).not.toThrow() - }) -} - -// --------------------------------------------------------------------------- -// 1. The published contract: the three names the packer and loader share. -// --------------------------------------------------------------------------- - -check('LOWERING_VERSION is a non-empty string', typeof LOWERING_VERSION === 'string' && LOWERING_VERSION.length > 0, true) -check('WRAPPER_PARAMS is the frozen 7-parameter shape', [...WRAPPER_PARAMS], [ - 'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als', -]) -// The wrapper signature is a contract with the loader's `new Function`, so the -// parameters must be valid identifiers in that position. -check( - 'every wrapper parameter is a usable identifier', - (() => { - try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- proves the parameter names compile where the loader uses them - new Function(...WRAPPER_PARAMS, 'return 0') - return true - } catch { - return false - } - })(), - true, -) - -// --------------------------------------------------------------------------- -// 2. lowerModuleSource: the packer face. `lowered` is the pack-time decision. -// --------------------------------------------------------------------------- - -{ - const esm = lowerModuleSource({ filename: 'node_modules/p/index.js', source: 'export const a = 1\n' }) - check('lowered=true for a module that needed rewriting', esm.lowered, true) - check('lowered code differs from source', esm.code !== 'export const a = 1\n', true) - - // Plain CommonJS with no suspension point is the "pack as-is" case: the - // collector relies on this to leave 1693-odd entries untouched. - const plain = 'module.exports = 1\n' - const cjs = lowerModuleSource({ filename: 'node_modules/p/legacy.cjs', source: plain }) - check('lowered=false for plain CommonJS', cjs.lowered, false) - check('unlowered code is the input verbatim', cjs.code, plain) - - // A CommonJS body that still contains a suspension point must be rewritten: - // `await` inside a function is the ALS protocol's business even with no ESM. - const cjsAwait = lowerModuleSource({ - filename: 'node_modules/p/async.cjs', - source: 'module.exports = async () => { await 1 }\n', - }) - check('lowered=true for CommonJS carrying a suspension point', cjsAwait.lowered, true) - contains('CommonJS await still routes through __als', cjsAwait.code, '__als.pause(') - - // `lowered` must agree with the code/source comparison by construction. - check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n') -} - -// --------------------------------------------------------------------------- -// 3. Import forms. -// --------------------------------------------------------------------------- - -{ - // Side-effect import: a bare require, nothing bound. - const code = transformModule("import './side-effect.js'\n", 'probe.js') - contains('side-effect import becomes a bare require', code, 'require("./side-effect.js")') - parsesAsScript('side-effect import', code) - - const requested: string[] = [] - runBody(code, (specifier) => { - requested.push(specifier) - return {} - }) - check('side-effect import actually requires at run time', requested, ['./side-effect.js']) -} - -{ - // Named imports are snapshots (CommonJS destructuring semantics), which is the - // documented, accepted divergence from ESM live bindings on the import side. - const code = transformModule("import { a, b as c } from 'p'\nexport const out = [a, c]\n", 'probe.js') - parsesAsScript('named imports', code) - const exports = runBody(code, () => ({ a: 1, b: 2 })) - check('named import binds by imported name, honouring the alias', exports.out, [1, 2]) -} - -{ - // Default and namespace imports go through the two interop helpers, which must - // agree with `Loader.unwrapExports` on the `__esModule` convention. - const code = transformModule("import d from 'p'\nimport * as ns from 'q'\nexport const seen = [d, ns.x, ns.default]\n", 'probe.js') - parsesAsScript('default and namespace imports', code) - - // An `__esModule` module: default comes from `.default`, namespace passes through. - const esModule = { __esModule: true, default: 'D', x: 'X' } - const withEsm = runBody(code, () => esModule) - check('default import of an __esModule module reads .default', (withEsm.seen as unknown[])[0], 'D') - - // A plain CommonJS module: the module object *is* the default, and the - // namespace gains a `default` key pointing at it. - const plain = { x: 'X' } - const withCjs = runBody(code, () => plain) - check('default import of plain CommonJS is the module object', (withCjs.seen as unknown[])[0], plain) - check('namespace of plain CommonJS keeps the named key', (withCjs.seen as unknown[])[1], 'X') - check('namespace of plain CommonJS synthesizes default', (withCjs.seen as unknown[])[2], plain) -} - -// --------------------------------------------------------------------------- -// 4. Export forms, including the live-binding contract. -// --------------------------------------------------------------------------- - -{ - const code = transformModule('export const a = 1\nexport function f() {}\nexport class K {}\n', 'probe.js') - parsesAsScript('exported declarations', code) - contains('module bodies get the __esModule marker', code, '__esModule') - contains('use strict is part of the prologue', code, '"use strict"') - const exports = runBody(code) - check('exported const is published', exports.a, 1) - check('exported function is published', typeof exports.f, 'function') - check('exported class is published', typeof exports.K, 'function') -} - -{ - // Local exports are getters, so a later assignment is observable through - // `exports` — the ESM live-binding property. - const code = transformModule('export let counter = 0\nexport function bump() { counter += 1 }\n', 'probe.js') - parsesAsScript('live binding', code) - const exports = runBody(code) - check('live binding starts at its initializer', exports.counter, 0) - ;(exports.bump as () => void)() - check('live binding observes a later assignment', exports.counter, 1) - // A getter, not a data property: this is what makes the above work. - check( - 'exported local is an accessor', - typeof Object.getOwnPropertyDescriptor(exports, 'counter')?.get, - 'function', - ) -} - -{ - // Trap 4: a multi-declarator export publishes every binding. - const code = transformModule('export const a = 1, b = 2\n', 'probe.js') - parsesAsScript('multi-declarator export', code) - const exports = runBody(code) - check('multi-declarator export publishes every binding', [exports.a, exports.b], [1, 2]) -} - -{ - // Destructuring exports exercise the pattern walker (object, array, rest, - // default) — every branch of `declaredBindings`. - const code = transformModule( - 'export const { p, q: renamed, ...restObj } = { p: 1, q: 2, z: 3 }\n' - + 'export const [first, , third = 30, ...restArr] = [10, 20, undefined, 40, 50]\n', - 'probe.js', - ) - parsesAsScript('destructuring exports', code) - const exports = runBody(code) - check('object pattern export', [exports.p, exports.renamed], [1, 2]) - check('object rest export', exports.restObj, { z: 3 }) - check('array pattern export with hole', [exports.first, exports.third], [10, 30]) - check('array rest export', exports.restArr, [40, 50]) - // The renamed target is what is published; the source key is not a binding. - check('object pattern publishes the local name, not the source key', 'q' in exports, false) -} - -{ - const code = transformModule('const x = 1\nexport { x as y }\n', 'probe.js') - parsesAsScript('local export clause', code) - const exports = runBody(code) - check('local export clause publishes under the exported name', exports.y, 1) - check('local export clause does not publish the local name', 'x' in exports, false) -} - -{ - // Re-export clause: a getter onto the required module, so it also stays live. - const module: Record = { a: 1 } - const code = transformModule("export { a, a as aliased } from 'p'\n", 'probe.js') - parsesAsScript('re-export clause', code) - const exports = runBody(code, () => module) - check('re-export publishes the name', exports.a, 1) - check('re-export publishes the alias', exports.aliased, 1) - module.a = 2 - check('re-export is live against the source module', exports.a, 2) -} - -{ - // `export *` copies enumerable keys, skips `default`, and must not clobber an - // existing local export. - const code = transformModule("export const own = 'local'\nexport * from 'p'\n", 'probe.js') - parsesAsScript('export all', code) - const exports = runBody(code, () => ({ extra: 'E', default: 'D', own: 'theirs' })) - check('export * copies named keys', exports.extra, 'E') - check('export * skips default', 'default' in exports, false) - check('export * does not overwrite an existing export', exports.own, 'local') -} - -{ - const code = transformModule("export * as ns from 'p'\n", 'probe.js') - parsesAsScript('export all as namespace', code) - const exports = runBody(code, () => ({ x: 1 })) - check('export * as ns publishes a namespace object', (exports.ns as Record).x, 1) -} - -{ - const code = transformModule('export default 42\n', 'probe.js') - parsesAsScript('default export value', code) - check('default export lands on exports.default', runBody(code).default, 42) -} - -{ - // Documented cost: the function name stops being a module-scope binding, but - // the named function expression can still refer to itself. - const code = transformModule('export default function self(n) { return n <= 0 ? 0 : self(n - 1) }\n', 'probe.js') - parsesAsScript('default export function', code) - const fn = runBody(code).default as (n: number) => number - check('default-exported function keeps self-reference', fn(3), 0) -} - -{ - const code = transformModule("export { x as default } from 'p'\n", 'probe.js') - parsesAsScript('re-export as default', code) - check('re-export as default publishes default', runBody(code, () => ({ x: 'D' })).default, 'D') -} - -// --------------------------------------------------------------------------- -// 5. import.meta and dynamic import. -// --------------------------------------------------------------------------- - -{ - const code = transformModule('export const here = import.meta.url\n', 'probe.js') - parsesAsScript('import.meta', code) - contains('import.meta becomes the wrapper parameter', code, '__dsh$meta') - check('import.meta.url resolves through the wrapper', runBody(code).here, 'file:///vfs/probe.js') -} - -{ - // Dynamic import routes through the same require chain (which is what makes - // typert-loader's absolute-path `import()` land on the VFS resolver), and the - // result is namespace-shaped. - const code = transformModule("export const load = () => import('p')\n", 'probe.js') - parsesAsScript('dynamic import', code) - contains('dynamic import becomes the helper call', code, '__dsh$dynImport') - const load = runBody(code, () => ({ x: 1 })).load as () => Promise> - const namespace = await load() - check('dynamic import resolves to a namespace object', namespace.x, 1) - check('dynamic import namespace has a default', 'default' in namespace, true) -} - -// --------------------------------------------------------------------------- -// 6. Suspension points. Behaviour is checked against a recording runtime, so -// these assert the protocol shape rather than re-testing als-runtime. -// --------------------------------------------------------------------------- - -/** A recording stand-in for the ALS runtime: proves the emitted calls happen in order. */ -function recordingAls(): { als: Record; calls: string[] } { - const calls: string[] = [] - const als = { - pause: (value: unknown) => { - calls.push('pause') - return Promise.resolve(value).then( - settled => ({ ok: true, value: settled, snapshot: 'S' }), - (error: unknown) => ({ ok: false, error, snapshot: 'S' }), - ) - }, - resume: (token: { ok: boolean; value?: unknown; error?: unknown }) => { - calls.push('resume') - if (token.ok) return token.value - throw token.error - }, - snapshot: () => { - calls.push('snapshot') - return 'S' - }, - afterYield: (_snapshot: unknown, sent: unknown) => { - calls.push('afterYield') - return sent - }, - iterator: (value: unknown) => { - calls.push('iterator') - const source = value as Record - const asyncFactory = source[Symbol.asyncIterator] as (() => AsyncIterator) | undefined - if (typeof asyncFactory === 'function') return asyncFactory.call(source) - const syncFactory = source[Symbol.iterator] as () => Iterator - const inner = syncFactory.call(source) - return { - next: async (...args: unknown[]) => { - const step = inner.next(...args as [unknown]) - return { done: step.done ?? false, value: await step.value } - }, - return: async (sent?: unknown) => { - const step = inner.return?.(sent) ?? { done: true, value: undefined } - return { done: step.done ?? true, value: await step.value } - }, - } - }, - close: async (iterator: AsyncIterator) => { - calls.push('close') - return iterator.return?.(undefined) - }, - } - return { als, calls } -} - -{ - const code = transformModule('export const run = async () => await 7\n', 'probe.js') - parsesAsScript('await rewrite', code) - contains('await is wrapped in resume(await pause(', code, '__als.resume(await __als.pause(') - const { als, calls } = recordingAls() - const run = runBody(code, () => ({}), als).run as () => Promise - check('await still yields its value', await run(), 7) - check('await goes pause-then-resume', calls, ['pause', 'resume']) -} - -{ - // The rejection path is the half that a naive "snapshot on success" rewrite - // gets wrong, so it is checked as its own case. - const code = transformModule( - "export const run = async () => { try { await Promise.reject(new Error('boom')) } catch (reason) { return `caught:${reason.message}` } }\n", - 'probe.js', - ) - parsesAsScript('await rejection', code) - const { als, calls } = recordingAls() - const run = runBody(code, () => ({}), als).run as () => Promise - check('rejection surfaces through resume', await run(), 'caught:boom') - check('rejection path also goes pause-then-resume', calls, ['pause', 'resume']) -} - -{ - // for-await desugars to an explicit loop; `return()` must run only on abrupt - // completion, which is the language rule. The two - // completion paths need two different loop bodies, so they are separate cases. - const plain = 'export const run = async (src) => { const seen = []\n' - + 'for await (const item of src) { seen.push(item) }\n' - + 'return seen }\n' - const code = transformModule(plain, 'probe.js') - parsesAsScript('for-await', code) - contains('for-await uses the iterator helper', code, '__als.iterator(') - contains('for-await closes on abrupt completion', code, '__als.close(') - - /** An async iterable counting up to `n`, rebuilt per case so state cannot leak. */ - const counting = (n: number): unknown => ({ - [Symbol.asyncIterator]: () => { - let emitted = 0 - return { - next: () => Promise.resolve( - emitted < n ? { done: false, value: ++emitted } : { done: true, value: undefined }, - ), - } - }, - }) - - // Normal completion: the iterator is exhausted, so `return()` must NOT run. - const { als, calls } = recordingAls() - const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise - check('for-await over an async source collects values', await run(counting(2)), [1, 2]) - check('normal completion does not close the iterator', calls.includes('close'), false) - - // Abrupt completion (break), and a sync source whose values are promises - // (async-from-sync): close must run exactly once. - const breaking = 'export const run = async (src) => { const seen = []\n' - + 'for await (const item of src) { seen.push(item); if (item === 2) break }\n' - + 'return seen }\n' - const breakingCode = transformModule(breaking, 'probe.js') - parsesAsScript('for-await with break', breakingCode) - const { als: als2, calls: calls2 } = recordingAls() - const run2 = runBody(breakingCode, () => ({}), als2).run as (src: unknown) => Promise - const syncSource = { - [Symbol.iterator]: () => [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)][Symbol.iterator](), - } - check('for-await accepts a sync source of promises', await run2(syncSource), [1, 2]) - check('break closes the iterator exactly once', calls2.filter(name => name === 'close').length, 1) -} - -{ - // Destructuring in the loop head goes through the same binding path. - const code = transformModule( - 'export const run = async (src) => { const seen = []\nfor await (const { v } of src) seen.push(v)\nreturn seen }\n', - 'probe.js', - ) - parsesAsScript('for-await destructuring', code) - const { als } = recordingAls() - const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise - check('for-await destructures each step', await run([{ v: 1 }, { v: 2 }]), [1, 2]) -} - -{ - // A non-block body must still be wrapped, or the emitted loop would swallow - // the following statement. - const code = transformModule( - 'export const run = async (src) => { let sum = 0\nfor await (const n of src) sum += n\nreturn sum }\n', - 'probe.js', - ) - parsesAsScript('for-await single-statement body', code) - const { als } = recordingAls() - const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise - check('for-await with a non-block body runs correctly', await run([1, 2, 3]), 6) -} - -{ - // `yield` in an async generator: the snapshot is taken before suspending and - // the consumer's sent value comes back through afterYield. - const code = transformModule( - 'export async function* gen() { const got = yield 1\nyield got * 2 }\n', - 'probe.js', - ) - parsesAsScript('yield rewrite', code) - contains('yield is wrapped in afterYield(snapshot(), yield ...)', code, '__als.afterYield(__als.snapshot(),yield ') - const { als, calls } = recordingAls() - const gen = runBody(code, () => ({}), als).gen as () => AsyncGenerator - const iterator = gen() - check('first yield produces its value', (await iterator.next(0)).value, 1) - check('sent value returns through afterYield', (await iterator.next(21)).value, 42) - check('yield recorded snapshot and afterYield', calls.filter(name => name === 'afterYield').length >= 1, true) -} - -{ - // Statement-position `yield*` desugars into a forwarding loop. - const code = transformModule( - 'export async function* outer(inner) { yield* inner\nyield "tail" }\n', - 'probe.js', - ) - parsesAsScript('yield* rewrite', code) - const { als } = recordingAls() - const outer = runBody(code, () => ({}), als).outer as (inner: unknown) => AsyncGenerator - const collected: unknown[] = [] - for await (const value of outer(['a', 'b'])) collected.push(value) - check('yield* forwards inner values then continues', collected, ['a', 'b', 'tail']) -} - -// --------------------------------------------------------------------------- -// 7. Line numbers. The debugging contract: a stack frame in a transformed body -// points at the same line as the artifact it came from. -// --------------------------------------------------------------------------- - -/** @returns Line count of a string, counting a trailing newline's line as the last. */ -const lineCount = (text: string): number => text.split('\n').length - -{ - // The prologue is emitted without a trailing newline, so a transformed body - // has exactly as many lines as its source. Anything else is line drift. - const cases: Array<{ readonly label: string; readonly source: string }> = [ - { label: 'imports and exports', source: "import { a } from 'p'\n\nexport const b = a\n\nexport default b\n" }, - { label: 'await in a function', source: 'export const f = async () => {\n const v = await g()\n return v\n}\n' }, - { - label: 'for-await (body re-emitted)', - source: 'export const f = async (src) => {\n for await (const x of src) {\n use(x)\n }\n done()\n}\n', - }, - { - label: 'yield* (statement desugared)', - source: 'export async function* f(inner) {\n yield* inner\n after()\n}\n', - }, - { label: 'export * with following lines', source: "export * from 'p'\nconst tail = 1\nexport { tail }\n" }, - { label: 'multi-line import clause', source: "import {\n a,\n b,\n} from 'p'\nexport const out = [a, b]\n" }, - ] - for (const { label, source } of cases) { - const code = transformModule(source, 'probe.js') - check(`line count survives: ${label}`, lineCount(code), lineCount(source)) - } -} - -// --------------------------------------------------------------------------- -// 8. Refusals. Every one of these is a form the transform must reject loudly -// rather than emit something that breaks later. -// --------------------------------------------------------------------------- - -refuses('top-level await is refused', 'export const a = 1\nawait boot()\n', 'top-level await') -refuses('top-level for-await is refused', 'for await (const x of src) use(x)\n', 'top-level for-await') -refuses( - 'labeled for-await is refused', - 'export const f = async (src) => { outer: for await (const x of src) { break outer } }\n', - 'labeled for-await', -) -refuses( - 'import attributes are refused', - "import data from './d.json' with { type: 'json' }\n", - 'import attributes', -) -refuses( - 'value-position yield* is refused', - 'export async function* f(inner) { const v = yield* inner\nuse(v) }\n', - 'yield* is only supported as a statement', -) -refuses( - 'assignment around yield* is refused, never silently dropped', - 'export async function* f(inner) { let v\nv = yield* inner\nuse(v) }\n', - 'yield* is only supported as the whole statement expression', -) -refuses( - 'a call around yield* is refused, never silently dropped', - 'export async function* f(inner) { use(yield* inner) }\n', - 'yield* is only supported as the whole statement expression', -) -refuses( - 'already-lowered source is refused', - 'const x = __als.pause(1)\n', - 'already lowered', -) -refuses('unparseable source is refused', 'export const = \n', 'parse failed') - -{ - // A refusal must name the file and the line, which is what makes a build - // failure actionable. - const message = refusal('export const a = 1\n\n\nawait boot()\n', 'node_modules/p/index.js') - check('refusal names the file', message?.includes('node_modules/p/index.js'), true) - check('refusal names the offending line', message?.includes(':4'), true) -} - -// --------------------------------------------------------------------------- -// 9. Trap regressions. Each case is a module form that breaks a boot when the -// transform mishandles it; the AST pass must keep them fixed. -// --------------------------------------------------------------------------- - -{ - // Trap 1: a file with no module syntax can still contain a dynamic import. A - // transform that skips such files would leave it unrewritten, and it would - // escape to the host engine's parser. - const code = transformModule("module.exports = () => import('./x.js')\n", 'probe.js') - contains('trap 1: dynamic import in a CommonJS file is still rewritten', code, '__dsh$dynImport') - parsesAsScript('trap 1', code) -} - -{ - // Trap 2: `export {}` is a bundler module marker and must be removed before - // `new Function` parses the body. The needle is the keyword in statement - // position, since `exports.` in the prologue legitimately contains the same - // letters. - const code = transformModule('export {};\n', 'probe.js') - lacks('trap 2: bare export {} is removed', code, 'export {') - lacks('trap 2: no export keyword survives', code, 'export;') - parsesAsScript('trap 2', code) - check('trap 2: emitted body still marks __esModule', '__esModule' in runBody(code), true) -} - -{ - // Trap 3/4: every declarator is published, including declarations without an - // initializer. - const code = transformModule('export let x, y\nexport const set = () => { x = 1; y = 2 }\n', 'probe.js') - parsesAsScript('trap 3', code) - const exports = runBody(code) - ;(exports.set as () => void)() - check('trap 3: every declarator is exported, initializer or not', [exports.x, exports.y], [1, 2]) -} - -{ - // Trap 6: a block comment before a class member named `import` must not be - // treated as a dynamic import. Renaming `EntryTree.prototype.import` breaks - // the loading chain at `Entry._init` with - // "this.parent.tree.import is not a function". - const source = 'export class A {\n /** doc */ import(name) { return name }\n}\n' - const code = transformModule(source, 'probe.js') - lacks('trap 6: a method named import is not rewritten', code, '__dsh$dynImport') - parsesAsScript('trap 6', code) - const A = runBody(code).A as new () => { import: (name: string) => string } - check('trap 6: the method is still callable under its own name', new A().import('kept'), 'kept') -} - -{ - // Trap 7: a comment between `export` and the declaration keyword must not - // hide the declaration; refusing zod's - // `export /*@__NO_SIDE_EFFECTS__*/ function` takes 30-odd roster rows down - // with it. - const code = transformModule('export /*@__NO_SIDE_EFFECTS__*/ function $constructor(x) { return x }\n', 'probe.js') - parsesAsScript('trap 7', code) - check('trap 7: export with an interposed comment still publishes', typeof runBody(code).$constructor, 'function') -} - -{ - // `new.target` is also a MetaProperty. Replacing every MetaProperty would - // make `new.target === Cls` permanently false, silently disabling - // abstract-seam guards in `jobs` and `llm`. - const source = 'export class Base {\n constructor() { this.direct = new.target === Base }\n}\n' - const code = transformModule(source, 'probe.js') - contains('trap 8: new.target survives verbatim', code, 'new.target') - lacks('trap 8: new.target is not replaced by the meta parameter', code, '__dsh$meta') - parsesAsScript('trap 8', code) - const Base = runBody(code).Base as new () => { direct: boolean } - class Derived extends Base {} - check('trap 8: new.target compares true for a direct construction', new Base().direct, true) - check('trap 8: new.target compares false for a subclass', new Derived().direct, false) -} - -{ - // Shebang handling: `#!` is only legal at offset 0, which the prologue - // occupies. It is commented out in place so both offsets and the line count - // stay put. - const source = '#!/usr/bin/env node\nexport const main = 1\n' - const code = transformModule(source, 'probe.js') - lacks('shebang is not left in the emitted body', code, '#!') - parsesAsScript('shebang', code) - check('shebang: line count still survives', lineCount(code), lineCount(source)) - check('shebang: the module still works', runBody(code).main, 1) -} - -{ - // The exit gate itself: the transform re-parses its own output as a script. - // Any leftover module syntax or mis-spliced interval fails there, not at load. - // Re-checked here over a source that exercises several edits at once. - const source = "import a from 'p'\nexport * from 'q'\nexport const f = async () => { for await (const x of a) { await x } }\n" - parsesAsScript('exit gate over combined edits', transformModule(source, 'probe.js')) -} - -// --------------------------------------------------------------------------- -// 10. Caching: the transform memoizes by source text, and the cache must not -// leak a different file's result. -// --------------------------------------------------------------------------- - -{ - const source = 'export const cached = 1\n' - const first = transformModule(source, 'a.js') - const second = transformModule(source, 'b.js') - check('identical sources return the identical cached body', first === second, true) - // Distinct sources must not collide. - check( - 'distinct sources produce distinct bodies', - transformModule('export const other = 2\n', 'c.js') !== first, - true, - ) -} diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 843193e3bb..38b295512c 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -34,13 +34,12 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ filter: 'packages/typert/generator/tests/', exclude: 'packages/typert/generator/tests/**', }, - // Spawns the full-corpus transform gate in a child process (Node's ESM - // loader is its oracle), so no measured file executes in-process; the - // sibling in-process suites carry the package's src coverage. As a single - // 499–513 s case it dominated one native Windows coverage partition. + // The webworker-runtime package is outside the coverage requirement by + // decision: vitest.config.ts threshold-excludes its src, so every suite + // runs uninstrumented. { - filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', + filter: 'packages/experimental/webworker-runtime/tests/', + exclude: 'packages/experimental/webworker-runtime/tests/**', }, // Real child-process fixtures over scripts/ sources, which coverage never measures. { filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' }, diff --git a/vitest.config.ts b/vitest.config.ts index 133089e651..7374c355a6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -306,6 +306,10 @@ export default defineConfig({ // would put whole-workspace compiler analysis under v8 // instrumentation — the coverage lane's longest tail. 'packages/typert/generator/src/*.ts', + // Experimental webworker-runtime is outside the coverage requirement + // by decision: its correctness signal is its uninstrumented suite and + // the packer's end-to-end image spec. + 'packages/experimental/webworker-runtime/src/**/*.ts', 'packages/host/apiproxy/src/index.ts', 'packages/host/apiproxy/src/invariant.ts', 'packages/host/apiproxy/src/api-proxy.ts', From 8793cd477ba2be85edfa47c8f0a7062d329c1437 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:07:34 +0800 Subject: [PATCH 04/14] test(webworker-runtime): keep the corpus gate as a Node import sweep --- .../tests/compile/transform-corpus-check.ts | 139 ++++++++++++++++++ .../tests/compile/transform-corpus.spec.ts | 32 ++++ scripts/coverage-exempt.ts | 14 +- 3 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts new file mode 100644 index 0000000000..6c041b93c1 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts @@ -0,0 +1,139 @@ +/** + * Full-corpus import gate: every built bundle in the workspace — + * `packages///lib/index.js` and `vendor//lib/index.js` + * — must be importable by Node's ESM loader. A bundle that stops importing (a + * stray `.css` import, an emitted module Node cannot parse, a dependency that + * throws at module scope) is reported by name. + * + * Baseline exemptions are a pinned list, not a count: an unlisted import + * failure is a real finding (a bundle that stopped being importable), and it + * must not hide inside a total. A listed file that becomes importable also + * fails, so the list cannot rot. + * + * Cost: this walks the whole build output and imports every bundle serially in + * one process, so it takes minutes on loaded runners and needs + * `pnpm run build:lib:host` to have run. It is a heavyweight suite, not part + * of a default aggregator run. + * + * Run: tsx tests/compile/transform-corpus-check.ts [files...] + * With no arguments it discovers the corpus itself. + */ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) + +/** + * Files Node's ESM loader cannot import in this repository. None is a finding: + * each is listed with the reason the import fails, and the run refuses a + * listed file that imports cleanly so the list stays current in both + * directions. The koffi entry depends on corpus order: sandbox-windows-acl + * imports the win32-process package earlier in the serial sweep (a distinct + * module instance under its node_modules URL), so win32-process's own file-URL + * import re-registers koffi's type names and fails as the second load. + */ +const BASELINE_EXEMPT: ReadonlyMap = new Map([ + ['packages/client/ui-primitives/lib/index.js', 'imports .css, which bare Node cannot load'], + ['packages/client/web/lib/index.js', 'imports .css, which bare Node cannot load'], + ['packages/subprocess/win32-process/lib/index.js', 'koffi type-name collision on a second load'], + ['packages/test-support/client-runtime/lib/index.js', "needs vitest's internal state"], +]) + +let failures = 0 +const report: string[] = [] +const log = (line: string): void => { + report.push(line) + process.stdout.write(`${line}\n`) +} +const fail = (line: string): void => { + failures += 1 + log(line) +} + +/** @returns Built bundles under a two-level package directory, in stable order. */ +function discover(): string[] { + const found: string[] = [] + /** @returns Sorted subdirectory names, or none when the path is not a readable directory. */ + const subdirectories = (path: string): string[] => { + try { + return readdirSync(path, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + } catch { + return [] + } + } + for (const group of ['packages', 'vendor']) { + const groupDirectory = join(repositoryRoot, group) + for (const entry of subdirectories(groupDirectory)) { + // `packages///lib/index.js`, `vendor//lib/index.js`. + const candidates = group === 'vendor' + ? [join(groupDirectory, entry, 'lib', 'index.js')] + : subdirectories(join(groupDirectory, entry)) + .map(child => join(groupDirectory, entry, child, 'lib', 'index.js')) + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) found.push(candidate) + } catch { + // No bundle for this package: it may not build a runtime artifact. + } + } + } + } + return found +} + +/** + * @returns Path relative to the repository root, for stable diagnostics. + * Always POSIX-separated: the exemption table keys on one form, and a win32 + * walk would otherwise miss every entry. + */ +const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/') + +const files = process.argv.slice(2).length > 0 + ? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path))) + : discover() + +if (files.length === 0) { + process.stdout.write('transform-corpus-check: no built bundles found; run `pnpm run build:lib:host` first\n') + process.exitCode = 1 +} else { + const verdicts = { ok: 0, exempt: 0, unexpectedBaseline: 0 } + + for (const file of files) { + const key = relative(file) + const exemption = BASELINE_EXEMPT.get(key) + try { + await import(pathToFileURL(file).href) + } catch (reason) { + if (exemption === undefined) { + // A bundle that stopped being importable is a real finding, so it + // fails rather than joining a tolerated total. + fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`) + verdicts.unexpectedBaseline += 1 + } else { + verdicts.exempt += 1 + } + continue + } + if (exemption !== undefined) { + // The exemption list must stay honest in the other direction too: a file + // that became importable should leave the list. + fail(`- STALE EXEMPTION ${key}: imports fine now (${exemption}); remove it from BASELINE_EXEMPT`) + continue + } + verdicts.ok += 1 + } + + log('') + log(`files=${String(files.length)} ok=${String(verdicts.ok)} baselineExempt=${String(verdicts.exempt)} ` + + `unexpectedBaselineFailure=${String(verdicts.unexpectedBaseline)}`) + + process.stdout.write(failures === 0 + ? `\ntransform-corpus-check: ${String(verdicts.ok)} bundles import under Node, ` + + `${String(verdicts.exempt)} exempt\n` + : `\ntransform-corpus-check: ${String(failures)} finding(s)\n`) + process.exitCode = failures === 0 ? 0 : 1 +} diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts new file mode 100644 index 0000000000..43d5f3dd66 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -0,0 +1,32 @@ +/** + * Runs the full-corpus import gate (`transform-corpus-check.ts`) in the + * launcher it is written for, and reports its findings as this suite's failure. + * + * Spawned rather than imported, because the gate's oracle is NODE's ESM loader: + * whether a built bundle imports is judged by `await import(file)` there. + * Vitest replaces that loader with vite's module runner, which imports files + * Node cannot — a `.css` import resolves, and koffi loads a second time — so an + * in-process run measures a different loader and reports the pinned baseline + * exemptions as stale. A gate whose verdict depends on how it was launched is + * not a gate. + * + * The corpus is the build output, so this skips on a tree that has none. + */ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, test } from 'vitest' + +const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url)) + +test('every built bundle imports under Node', (context) => { + const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' }) + const output = `${finished.stdout}${finished.stderr}` + if (output.includes('no built bundles found')) { + context.skip('the workspace has no build output to sweep') + return + } + // The runner prefixes every finding with '- ', so a failure reads as the + // findings themselves rather than as a diff of its whole report. + expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('') + expect(finished.status, output).toBe(0) +}, 900_000) diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 38b295512c..e7dc90bb29 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -36,7 +36,10 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ }, // The webworker-runtime package is outside the coverage requirement by // decision: vitest.config.ts threshold-excludes its src, so every suite - // runs uninstrumented. + // runs uninstrumented. This tree includes the full-corpus import gate, a + // single 900s-budget case that spawns a child sweep over every built + // bundle; inside an instrumented partition it exceeds the Windows + // partition budget under load. { filter: 'packages/experimental/webworker-runtime/tests/', exclude: 'packages/experimental/webworker-runtime/tests/**', @@ -46,15 +49,6 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, - // Spawns the full-corpus transform gate in a child process (Node's ESM - // loader is its oracle), so no measured file executes in-process; the - // package src is threshold-excluded in vitest.config.ts. A single - // 900s-budget case; running it inside an instrumented partition exceeds - // the Windows partition budget under load. - { - filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - }, // Built-artifact proof. Packer/runtime src is threshold-excluded, and the // native Windows aggregate makes this uninstrumented gate wait for build so // the suite never observes a partially emitted workspace closure. From 2d89a76b943ec0eb43c7f1fb6323d0a724eaecd9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:33:10 +0800 Subject: [PATCH 05/14] test(webworker-runtime): restore the transform semantic spec --- .../tests/compile/transform.spec.ts | 704 ++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 packages/experimental/webworker-runtime/tests/compile/transform.spec.ts diff --git a/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts new file mode 100644 index 0000000000..bf8b74a9bb --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/compile/transform.spec.ts @@ -0,0 +1,704 @@ +/** + * Semantic check of the worker module transform (`src/compile/transform.ts`): what the + * emitted CommonJS body looks like for each module form, how suspension points + * are rewritten, that line numbers survive, which forms are refused, and that + * every covered trap form stays fixed. + * + * Scope boundary: this file checks the transform itself; the pack-time loop + * around it is covered end-to-end by the packer's `image-loadable.spec.ts`. + * Emitted-code assertions are deliberately written against substrings + * of the real output rather than whole-file goldens: a golden would fail on every + * helper reordering, which is not the contract. The contract is the observable + * one — the code parses as script, publishes the right bindings, keeps line + * count, and routes suspension through `__als`. + * + * The trap cases are module forms that break a boot when the transform + * mishandles them. Five traps cannot recur while the AST pass is the parser, + * but they stay checked because a future parser swap could reintroduce them. + */ +import { expect, test } from 'vitest' +import { parse } from 'acorn' +import { lowerModuleSource } from '../../src/compile/transform.ts' +import { LOWERING_VERSION, WRAPPER_PARAMS } from '../../src/image-layout.ts' + +/** + * Lower one probe module the way the packer does — the transform's only caller. + * @param source - Module source under test. + * @param path - Path the diagnostics name. + * @returns The emitted body. + */ +const transformModule = (source: string, path = 'probe.js'): string => + lowerModuleSource({ filename: path, source }).code + +/** Register one comparison as its own case, serialized at call time. */ +const check = (label: string, actual: unknown, expected: unknown): void => { + const [seen, wanted] = [JSON.stringify(actual), JSON.stringify(expected)] + test(label, () => { expect(seen).toBe(wanted) }) +} + +/** Assert a substring is present in an emitted body. */ +const contains = (label: string, code: string, needle: string): void => { + test(label, () => { expect(code).toContain(needle) }) +} + +/** Assert a substring is absent (used for "must survive untouched" cases). */ +const lacks = (label: string, code: string, needle: string): void => { + test(label, () => { expect(code).not.toContain(needle) }) +} + +/** @returns The error message of a refused transform, or undefined when it succeeded. */ +const refusal = (source: string, path = 'probe.js'): string | undefined => { + try { + transformModule(source, path) + return undefined + } catch (reason) { + return (reason as Error).message + } +} + +/** Assert the transform refuses a source and names the reason. */ +const refuses = (label: string, source: string, fragment: string): void => { + const message = refusal(source) + test(label, () => { expect(message).toContain(fragment) }) +} + +/** + * The wrapper contract, applied for real: compile the body with the declared + * parameters and run it. This is the same `new Function` shape the loader uses + * (module-loader.ts), so a body that compiles here compiles there. + * @param code - Emitted CommonJS body. + * @param require - Module resolver the body's `require` calls reach. + * @param als - Suspension runtime bound to `__als`. + * @returns The populated `exports` object. + */ +function runBody( + code: string, + require: (specifier: string) => unknown = () => ({}), + als?: unknown, +): Record { + const exports: Record = {} + const module = { exports } + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- the wrapper contract under test is a `new Function` body + const factory = new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void + factory(exports, require, module, '/vfs/probe.js', '/vfs', { url: 'file:///vfs/probe.js' }, als) + return exports +} + +/** Every emitted body must parse as a script — the transform's own exit gate, re-checked here. */ +const parsesAsScript = (label: string, code: string): void => { + test(label, () => { + expect(() => parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })).not.toThrow() + }) +} + +// --------------------------------------------------------------------------- +// 1. The published contract: the three names the packer and loader share. +// --------------------------------------------------------------------------- + +check('LOWERING_VERSION is a non-empty string', typeof LOWERING_VERSION === 'string' && LOWERING_VERSION.length > 0, true) +check('WRAPPER_PARAMS is the frozen 7-parameter shape', [...WRAPPER_PARAMS], [ + 'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als', +]) +// The wrapper signature is a contract with the loader's `new Function`, so the +// parameters must be valid identifiers in that position. +check( + 'every wrapper parameter is a usable identifier', + (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- proves the parameter names compile where the loader uses them + new Function(...WRAPPER_PARAMS, 'return 0') + return true + } catch { + return false + } + })(), + true, +) + +// --------------------------------------------------------------------------- +// 2. lowerModuleSource: the packer face. `lowered` is the pack-time decision. +// --------------------------------------------------------------------------- + +{ + const esm = lowerModuleSource({ filename: 'node_modules/p/index.js', source: 'export const a = 1\n' }) + check('lowered=true for a module that needed rewriting', esm.lowered, true) + check('lowered code differs from source', esm.code !== 'export const a = 1\n', true) + + // Plain CommonJS with no suspension point is the "pack as-is" case: the + // collector relies on this to leave 1693-odd entries untouched. + const plain = 'module.exports = 1\n' + const cjs = lowerModuleSource({ filename: 'node_modules/p/legacy.cjs', source: plain }) + check('lowered=false for plain CommonJS', cjs.lowered, false) + check('unlowered code is the input verbatim', cjs.code, plain) + + // A CommonJS body that still contains a suspension point must be rewritten: + // `await` inside a function is the ALS protocol's business even with no ESM. + const cjsAwait = lowerModuleSource({ + filename: 'node_modules/p/async.cjs', + source: 'module.exports = async () => { await 1 }\n', + }) + check('lowered=true for CommonJS carrying a suspension point', cjsAwait.lowered, true) + contains('CommonJS await still routes through __als', cjsAwait.code, '__als.pause(') + + // `lowered` must agree with the code/source comparison by construction. + check('lowered mirrors code !== source', cjsAwait.lowered, cjsAwait.code !== 'module.exports = async () => { await 1 }\n') +} + +// --------------------------------------------------------------------------- +// 3. Import forms. +// --------------------------------------------------------------------------- + +{ + // Side-effect import: a bare require, nothing bound. + const code = transformModule("import './side-effect.js'\n", 'probe.js') + contains('side-effect import becomes a bare require', code, 'require("./side-effect.js")') + parsesAsScript('side-effect import', code) + + const requested: string[] = [] + runBody(code, (specifier) => { + requested.push(specifier) + return {} + }) + check('side-effect import actually requires at run time', requested, ['./side-effect.js']) +} + +{ + // Named imports are snapshots (CommonJS destructuring semantics), which is the + // documented, accepted divergence from ESM live bindings on the import side. + const code = transformModule("import { a, b as c } from 'p'\nexport const out = [a, c]\n", 'probe.js') + parsesAsScript('named imports', code) + const exports = runBody(code, () => ({ a: 1, b: 2 })) + check('named import binds by imported name, honouring the alias', exports.out, [1, 2]) +} + +{ + // Default and namespace imports go through the two interop helpers, which must + // agree with `Loader.unwrapExports` on the `__esModule` convention. + const code = transformModule("import d from 'p'\nimport * as ns from 'q'\nexport const seen = [d, ns.x, ns.default]\n", 'probe.js') + parsesAsScript('default and namespace imports', code) + + // An `__esModule` module: default comes from `.default`, namespace passes through. + const esModule = { __esModule: true, default: 'D', x: 'X' } + const withEsm = runBody(code, () => esModule) + check('default import of an __esModule module reads .default', (withEsm.seen as unknown[])[0], 'D') + + // A plain CommonJS module: the module object *is* the default, and the + // namespace gains a `default` key pointing at it. + const plain = { x: 'X' } + const withCjs = runBody(code, () => plain) + check('default import of plain CommonJS is the module object', (withCjs.seen as unknown[])[0], plain) + check('namespace of plain CommonJS keeps the named key', (withCjs.seen as unknown[])[1], 'X') + check('namespace of plain CommonJS synthesizes default', (withCjs.seen as unknown[])[2], plain) +} + +// --------------------------------------------------------------------------- +// 4. Export forms, including the live-binding contract. +// --------------------------------------------------------------------------- + +{ + const code = transformModule('export const a = 1\nexport function f() {}\nexport class K {}\n', 'probe.js') + parsesAsScript('exported declarations', code) + contains('module bodies get the __esModule marker', code, '__esModule') + contains('use strict is part of the prologue', code, '"use strict"') + const exports = runBody(code) + check('exported const is published', exports.a, 1) + check('exported function is published', typeof exports.f, 'function') + check('exported class is published', typeof exports.K, 'function') +} + +{ + // Local exports are getters, so a later assignment is observable through + // `exports` — the ESM live-binding property. + const code = transformModule('export let counter = 0\nexport function bump() { counter += 1 }\n', 'probe.js') + parsesAsScript('live binding', code) + const exports = runBody(code) + check('live binding starts at its initializer', exports.counter, 0) + ;(exports.bump as () => void)() + check('live binding observes a later assignment', exports.counter, 1) + // A getter, not a data property: this is what makes the above work. + check( + 'exported local is an accessor', + typeof Object.getOwnPropertyDescriptor(exports, 'counter')?.get, + 'function', + ) +} + +{ + // Trap 4: a multi-declarator export publishes every binding. + const code = transformModule('export const a = 1, b = 2\n', 'probe.js') + parsesAsScript('multi-declarator export', code) + const exports = runBody(code) + check('multi-declarator export publishes every binding', [exports.a, exports.b], [1, 2]) +} + +{ + // Destructuring exports exercise the pattern walker (object, array, rest, + // default) — every branch of `declaredBindings`. + const code = transformModule( + 'export const { p, q: renamed, ...restObj } = { p: 1, q: 2, z: 3 }\n' + + 'export const [first, , third = 30, ...restArr] = [10, 20, undefined, 40, 50]\n', + 'probe.js', + ) + parsesAsScript('destructuring exports', code) + const exports = runBody(code) + check('object pattern export', [exports.p, exports.renamed], [1, 2]) + check('object rest export', exports.restObj, { z: 3 }) + check('array pattern export with hole', [exports.first, exports.third], [10, 30]) + check('array rest export', exports.restArr, [40, 50]) + // The renamed target is what is published; the source key is not a binding. + check('object pattern publishes the local name, not the source key', 'q' in exports, false) +} + +{ + const code = transformModule('const x = 1\nexport { x as y }\n', 'probe.js') + parsesAsScript('local export clause', code) + const exports = runBody(code) + check('local export clause publishes under the exported name', exports.y, 1) + check('local export clause does not publish the local name', 'x' in exports, false) +} + +{ + // Re-export clause: a getter onto the required module, so it also stays live. + const module: Record = { a: 1 } + const code = transformModule("export { a, a as aliased } from 'p'\n", 'probe.js') + parsesAsScript('re-export clause', code) + const exports = runBody(code, () => module) + check('re-export publishes the name', exports.a, 1) + check('re-export publishes the alias', exports.aliased, 1) + module.a = 2 + check('re-export is live against the source module', exports.a, 2) +} + +{ + // `export *` copies enumerable keys, skips `default`, and must not clobber an + // existing local export. + const code = transformModule("export const own = 'local'\nexport * from 'p'\n", 'probe.js') + parsesAsScript('export all', code) + const exports = runBody(code, () => ({ extra: 'E', default: 'D', own: 'theirs' })) + check('export * copies named keys', exports.extra, 'E') + check('export * skips default', 'default' in exports, false) + check('export * does not overwrite an existing export', exports.own, 'local') +} + +{ + const code = transformModule("export * as ns from 'p'\n", 'probe.js') + parsesAsScript('export all as namespace', code) + const exports = runBody(code, () => ({ x: 1 })) + check('export * as ns publishes a namespace object', (exports.ns as Record).x, 1) +} + +{ + const code = transformModule('export default 42\n', 'probe.js') + parsesAsScript('default export value', code) + check('default export lands on exports.default', runBody(code).default, 42) +} + +{ + // Documented cost: the function name stops being a module-scope binding, but + // the named function expression can still refer to itself. + const code = transformModule('export default function self(n) { return n <= 0 ? 0 : self(n - 1) }\n', 'probe.js') + parsesAsScript('default export function', code) + const fn = runBody(code).default as (n: number) => number + check('default-exported function keeps self-reference', fn(3), 0) +} + +{ + const code = transformModule("export { x as default } from 'p'\n", 'probe.js') + parsesAsScript('re-export as default', code) + check('re-export as default publishes default', runBody(code, () => ({ x: 'D' })).default, 'D') +} + +// --------------------------------------------------------------------------- +// 5. import.meta and dynamic import. +// --------------------------------------------------------------------------- + +{ + const code = transformModule('export const here = import.meta.url\n', 'probe.js') + parsesAsScript('import.meta', code) + contains('import.meta becomes the wrapper parameter', code, '__dsh$meta') + check('import.meta.url resolves through the wrapper', runBody(code).here, 'file:///vfs/probe.js') +} + +{ + // Dynamic import routes through the same require chain (which is what makes + // typert-loader's absolute-path `import()` land on the VFS resolver), and the + // result is namespace-shaped. + const code = transformModule("export const load = () => import('p')\n", 'probe.js') + parsesAsScript('dynamic import', code) + contains('dynamic import becomes the helper call', code, '__dsh$dynImport') + const load = runBody(code, () => ({ x: 1 })).load as () => Promise> + const namespace = await load() + check('dynamic import resolves to a namespace object', namespace.x, 1) + check('dynamic import namespace has a default', 'default' in namespace, true) +} + +// --------------------------------------------------------------------------- +// 6. Suspension points. Behaviour is checked against a recording runtime, so +// these assert the protocol shape rather than re-testing als-runtime. +// --------------------------------------------------------------------------- + +/** A recording stand-in for the ALS runtime: proves the emitted calls happen in order. */ +function recordingAls(): { als: Record; calls: string[] } { + const calls: string[] = [] + const als = { + pause: (value: unknown) => { + calls.push('pause') + return Promise.resolve(value).then( + settled => ({ ok: true, value: settled, snapshot: 'S' }), + (error: unknown) => ({ ok: false, error, snapshot: 'S' }), + ) + }, + resume: (token: { ok: boolean; value?: unknown; error?: unknown }) => { + calls.push('resume') + if (token.ok) return token.value + throw token.error + }, + snapshot: () => { + calls.push('snapshot') + return 'S' + }, + afterYield: (_snapshot: unknown, sent: unknown) => { + calls.push('afterYield') + return sent + }, + iterator: (value: unknown) => { + calls.push('iterator') + const source = value as Record + const asyncFactory = source[Symbol.asyncIterator] as (() => AsyncIterator) | undefined + if (typeof asyncFactory === 'function') return asyncFactory.call(source) + const syncFactory = source[Symbol.iterator] as () => Iterator + const inner = syncFactory.call(source) + return { + next: async (...args: unknown[]) => { + const step = inner.next(...args as [unknown]) + return { done: step.done ?? false, value: await step.value } + }, + return: async (sent?: unknown) => { + const step = inner.return?.(sent) ?? { done: true, value: undefined } + return { done: step.done ?? true, value: await step.value } + }, + } + }, + close: async (iterator: AsyncIterator) => { + calls.push('close') + return iterator.return?.(undefined) + }, + } + return { als, calls } +} + +{ + const code = transformModule('export const run = async () => await 7\n', 'probe.js') + parsesAsScript('await rewrite', code) + contains('await is wrapped in resume(await pause(', code, '__als.resume(await __als.pause(') + const { als, calls } = recordingAls() + const run = runBody(code, () => ({}), als).run as () => Promise + check('await still yields its value', await run(), 7) + check('await goes pause-then-resume', calls, ['pause', 'resume']) +} + +{ + // The rejection path is the half that a naive "snapshot on success" rewrite + // gets wrong, so it is checked as its own case. + const code = transformModule( + "export const run = async () => { try { await Promise.reject(new Error('boom')) } catch (reason) { return `caught:${reason.message}` } }\n", + 'probe.js', + ) + parsesAsScript('await rejection', code) + const { als, calls } = recordingAls() + const run = runBody(code, () => ({}), als).run as () => Promise + check('rejection surfaces through resume', await run(), 'caught:boom') + check('rejection path also goes pause-then-resume', calls, ['pause', 'resume']) +} + +{ + // for-await desugars to an explicit loop; `return()` must run only on abrupt + // completion, which is the language rule. The two + // completion paths need two different loop bodies, so they are separate cases. + const plain = 'export const run = async (src) => { const seen = []\n' + + 'for await (const item of src) { seen.push(item) }\n' + + 'return seen }\n' + const code = transformModule(plain, 'probe.js') + parsesAsScript('for-await', code) + contains('for-await uses the iterator helper', code, '__als.iterator(') + contains('for-await closes on abrupt completion', code, '__als.close(') + + /** An async iterable counting up to `n`, rebuilt per case so state cannot leak. */ + const counting = (n: number): unknown => ({ + [Symbol.asyncIterator]: () => { + let emitted = 0 + return { + next: () => Promise.resolve( + emitted < n ? { done: false, value: ++emitted } : { done: true, value: undefined }, + ), + } + }, + }) + + // Normal completion: the iterator is exhausted, so `return()` must NOT run. + const { als, calls } = recordingAls() + const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise + check('for-await over an async source collects values', await run(counting(2)), [1, 2]) + check('normal completion does not close the iterator', calls.includes('close'), false) + + // Abrupt completion (break), and a sync source whose values are promises + // (async-from-sync): close must run exactly once. + const breaking = 'export const run = async (src) => { const seen = []\n' + + 'for await (const item of src) { seen.push(item); if (item === 2) break }\n' + + 'return seen }\n' + const breakingCode = transformModule(breaking, 'probe.js') + parsesAsScript('for-await with break', breakingCode) + const { als: als2, calls: calls2 } = recordingAls() + const run2 = runBody(breakingCode, () => ({}), als2).run as (src: unknown) => Promise + const syncSource = { + [Symbol.iterator]: () => [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)][Symbol.iterator](), + } + check('for-await accepts a sync source of promises', await run2(syncSource), [1, 2]) + check('break closes the iterator exactly once', calls2.filter(name => name === 'close').length, 1) +} + +{ + // Destructuring in the loop head goes through the same binding path. + const code = transformModule( + 'export const run = async (src) => { const seen = []\nfor await (const { v } of src) seen.push(v)\nreturn seen }\n', + 'probe.js', + ) + parsesAsScript('for-await destructuring', code) + const { als } = recordingAls() + const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise + check('for-await destructures each step', await run([{ v: 1 }, { v: 2 }]), [1, 2]) +} + +{ + // A non-block body must still be wrapped, or the emitted loop would swallow + // the following statement. + const code = transformModule( + 'export const run = async (src) => { let sum = 0\nfor await (const n of src) sum += n\nreturn sum }\n', + 'probe.js', + ) + parsesAsScript('for-await single-statement body', code) + const { als } = recordingAls() + const run = runBody(code, () => ({}), als).run as (src: unknown) => Promise + check('for-await with a non-block body runs correctly', await run([1, 2, 3]), 6) +} + +{ + // `yield` in an async generator: the snapshot is taken before suspending and + // the consumer's sent value comes back through afterYield. + const code = transformModule( + 'export async function* gen() { const got = yield 1\nyield got * 2 }\n', + 'probe.js', + ) + parsesAsScript('yield rewrite', code) + contains('yield is wrapped in afterYield(snapshot(), yield ...)', code, '__als.afterYield(__als.snapshot(),yield ') + const { als, calls } = recordingAls() + const gen = runBody(code, () => ({}), als).gen as () => AsyncGenerator + const iterator = gen() + check('first yield produces its value', (await iterator.next(0)).value, 1) + check('sent value returns through afterYield', (await iterator.next(21)).value, 42) + check('yield recorded snapshot and afterYield', calls.filter(name => name === 'afterYield').length >= 1, true) +} + +{ + // Statement-position `yield*` desugars into a forwarding loop. + const code = transformModule( + 'export async function* outer(inner) { yield* inner\nyield "tail" }\n', + 'probe.js', + ) + parsesAsScript('yield* rewrite', code) + const { als } = recordingAls() + const outer = runBody(code, () => ({}), als).outer as (inner: unknown) => AsyncGenerator + const collected: unknown[] = [] + for await (const value of outer(['a', 'b'])) collected.push(value) + check('yield* forwards inner values then continues', collected, ['a', 'b', 'tail']) +} + +// --------------------------------------------------------------------------- +// 7. Line numbers. The debugging contract: a stack frame in a transformed body +// points at the same line as the artifact it came from. +// --------------------------------------------------------------------------- + +/** @returns Line count of a string, counting a trailing newline's line as the last. */ +const lineCount = (text: string): number => text.split('\n').length + +{ + // The prologue is emitted without a trailing newline, so a transformed body + // has exactly as many lines as its source. Anything else is line drift. + const cases: Array<{ readonly label: string; readonly source: string }> = [ + { label: 'imports and exports', source: "import { a } from 'p'\n\nexport const b = a\n\nexport default b\n" }, + { label: 'await in a function', source: 'export const f = async () => {\n const v = await g()\n return v\n}\n' }, + { + label: 'for-await (body re-emitted)', + source: 'export const f = async (src) => {\n for await (const x of src) {\n use(x)\n }\n done()\n}\n', + }, + { + label: 'yield* (statement desugared)', + source: 'export async function* f(inner) {\n yield* inner\n after()\n}\n', + }, + { label: 'export * with following lines', source: "export * from 'p'\nconst tail = 1\nexport { tail }\n" }, + { label: 'multi-line import clause', source: "import {\n a,\n b,\n} from 'p'\nexport const out = [a, b]\n" }, + ] + for (const { label, source } of cases) { + const code = transformModule(source, 'probe.js') + check(`line count survives: ${label}`, lineCount(code), lineCount(source)) + } +} + +// --------------------------------------------------------------------------- +// 8. Refusals. Every one of these is a form the transform must reject loudly +// rather than emit something that breaks later. +// --------------------------------------------------------------------------- + +refuses('top-level await is refused', 'export const a = 1\nawait boot()\n', 'top-level await') +refuses('top-level for-await is refused', 'for await (const x of src) use(x)\n', 'top-level for-await') +refuses( + 'labeled for-await is refused', + 'export const f = async (src) => { outer: for await (const x of src) { break outer } }\n', + 'labeled for-await', +) +refuses( + 'import attributes are refused', + "import data from './d.json' with { type: 'json' }\n", + 'import attributes', +) +refuses( + 'value-position yield* is refused', + 'export async function* f(inner) { const v = yield* inner\nuse(v) }\n', + 'yield* is only supported as a statement', +) +refuses( + 'assignment around yield* is refused, never silently dropped', + 'export async function* f(inner) { let v\nv = yield* inner\nuse(v) }\n', + 'yield* is only supported as the whole statement expression', +) +refuses( + 'a call around yield* is refused, never silently dropped', + 'export async function* f(inner) { use(yield* inner) }\n', + 'yield* is only supported as the whole statement expression', +) +refuses( + 'already-lowered source is refused', + 'const x = __als.pause(1)\n', + 'already lowered', +) +refuses('unparseable source is refused', 'export const = \n', 'parse failed') + +{ + // A refusal must name the file and the line, which is what makes a build + // failure actionable. + const message = refusal('export const a = 1\n\n\nawait boot()\n', 'node_modules/p/index.js') + check('refusal names the file', message?.includes('node_modules/p/index.js'), true) + check('refusal names the offending line', message?.includes(':4'), true) +} + +// --------------------------------------------------------------------------- +// 9. Trap regressions. Each case is a module form that breaks a boot when the +// transform mishandles it; the AST pass must keep them fixed. +// --------------------------------------------------------------------------- + +{ + // Trap 1: a file with no module syntax can still contain a dynamic import. A + // transform that skips such files would leave it unrewritten, and it would + // escape to the host engine's parser. + const code = transformModule("module.exports = () => import('./x.js')\n", 'probe.js') + contains('trap 1: dynamic import in a CommonJS file is still rewritten', code, '__dsh$dynImport') + parsesAsScript('trap 1', code) +} + +{ + // Trap 2: `export {}` is a bundler module marker and must be removed before + // `new Function` parses the body. The needle is the keyword in statement + // position, since `exports.` in the prologue legitimately contains the same + // letters. + const code = transformModule('export {};\n', 'probe.js') + lacks('trap 2: bare export {} is removed', code, 'export {') + lacks('trap 2: no export keyword survives', code, 'export;') + parsesAsScript('trap 2', code) + check('trap 2: emitted body still marks __esModule', '__esModule' in runBody(code), true) +} + +{ + // Trap 3/4: every declarator is published, including declarations without an + // initializer. + const code = transformModule('export let x, y\nexport const set = () => { x = 1; y = 2 }\n', 'probe.js') + parsesAsScript('trap 3', code) + const exports = runBody(code) + ;(exports.set as () => void)() + check('trap 3: every declarator is exported, initializer or not', [exports.x, exports.y], [1, 2]) +} + +{ + // Trap 6: a block comment before a class member named `import` must not be + // treated as a dynamic import. Renaming `EntryTree.prototype.import` breaks + // the loading chain at `Entry._init` with + // "this.parent.tree.import is not a function". + const source = 'export class A {\n /** doc */ import(name) { return name }\n}\n' + const code = transformModule(source, 'probe.js') + lacks('trap 6: a method named import is not rewritten', code, '__dsh$dynImport') + parsesAsScript('trap 6', code) + const A = runBody(code).A as new () => { import: (name: string) => string } + check('trap 6: the method is still callable under its own name', new A().import('kept'), 'kept') +} + +{ + // Trap 7: a comment between `export` and the declaration keyword must not + // hide the declaration; refusing zod's + // `export /*@__NO_SIDE_EFFECTS__*/ function` takes 30-odd roster rows down + // with it. + const code = transformModule('export /*@__NO_SIDE_EFFECTS__*/ function $constructor(x) { return x }\n', 'probe.js') + parsesAsScript('trap 7', code) + check('trap 7: export with an interposed comment still publishes', typeof runBody(code).$constructor, 'function') +} + +{ + // `new.target` is also a MetaProperty. Replacing every MetaProperty would + // make `new.target === Cls` permanently false, silently disabling + // abstract-seam guards in `jobs` and `llm`. + const source = 'export class Base {\n constructor() { this.direct = new.target === Base }\n}\n' + const code = transformModule(source, 'probe.js') + contains('trap 8: new.target survives verbatim', code, 'new.target') + lacks('trap 8: new.target is not replaced by the meta parameter', code, '__dsh$meta') + parsesAsScript('trap 8', code) + const Base = runBody(code).Base as new () => { direct: boolean } + class Derived extends Base {} + check('trap 8: new.target compares true for a direct construction', new Base().direct, true) + check('trap 8: new.target compares false for a subclass', new Derived().direct, false) +} + +{ + // Shebang handling: `#!` is only legal at offset 0, which the prologue + // occupies. It is commented out in place so both offsets and the line count + // stay put. + const source = '#!/usr/bin/env node\nexport const main = 1\n' + const code = transformModule(source, 'probe.js') + lacks('shebang is not left in the emitted body', code, '#!') + parsesAsScript('shebang', code) + check('shebang: line count still survives', lineCount(code), lineCount(source)) + check('shebang: the module still works', runBody(code).main, 1) +} + +{ + // The exit gate itself: the transform re-parses its own output as a script. + // Any leftover module syntax or mis-spliced interval fails there, not at load. + // Re-checked here over a source that exercises several edits at once. + const source = "import a from 'p'\nexport * from 'q'\nexport const f = async () => { for await (const x of a) { await x } }\n" + parsesAsScript('exit gate over combined edits', transformModule(source, 'probe.js')) +} + +// --------------------------------------------------------------------------- +// 10. Caching: the transform memoizes by source text, and the cache must not +// leak a different file's result. +// --------------------------------------------------------------------------- + +{ + const source = 'export const cached = 1\n' + const first = transformModule(source, 'a.js') + const second = transformModule(source, 'b.js') + check('identical sources return the identical cached body', first === second, true) + // Distinct sources must not collide. + check( + 'distinct sources produce distinct bodies', + transformModule('export const other = 2\n', 'c.js') !== first, + true, + ) +} From e1c49dab59767acb3a609a3c364c15f4d05bf3ab Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:34:29 +0800 Subject: [PATCH 06/14] ci(windows): clear stale pnpm setup state before install --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5afaf5fca..182d66d6fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,6 +421,13 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + # Best-effort: a torn-down job on this self-hosted pool can leave a + # locked @reflink native module under the action's install destination, + # and pnpm/action-setup's self-installer then fails its unlink with + # EPERM. Clearing the destination gives every attempt fresh state. + - name: Clear stale pnpm setup state + shell: pwsh + run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js @@ -457,6 +464,10 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. + - name: Clear stale pnpm setup state + shell: pwsh + run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js @@ -491,6 +502,10 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. + - name: Clear stale pnpm setup state + shell: pwsh + run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js @@ -533,6 +548,10 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. + - name: Clear stale pnpm setup state + shell: pwsh + run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js From f18ab429e42a6c917329cb71e9c43a9673d91161 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:46:17 +0800 Subject: [PATCH 07/14] ci(release): pack rehearsal tarballs concurrently --- .github/workflows/release-vendor.yml | 4 ++- .github/workflows/release.yml | 6 ++-- scripts/release/pack.ts | 44 ++++++++++++++++++++++------ scripts/release/process.ts | 22 +++++++++++++- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index 34290bb7a2..8e1d531a03 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -71,8 +71,10 @@ jobs: - name: Build run: pnpm run build:lib:host + # Concurrency here is rehearsal-only: the credentialed publish workflows + # invoke release:pack without the flag and keep the strictly serial path. - name: Pack release tarballs - run: pnpm run release:pack --family vendor --out dist/npm-vendor + run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8 - name: Verify packed install run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b84a299b9..4ae1940c0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,8 +68,10 @@ jobs: - name: Build run: pnpm run build:official + # Concurrency here is rehearsal-only: the credentialed publish workflows + # invoke release:pack without the flag and keep the strictly serial path. - name: Pack release tarballs - run: pnpm run release:pack --family dsh --out dist/npm + run: pnpm run release:pack --family dsh --out dist/npm --concurrency 8 # The harness packages declare the vendored framework as a peer, and this # verification must not depend on the registry already carrying matching @@ -77,7 +79,7 @@ jobs: # publishes — so it installs that family's pack output too. Only dist/npm # is published. - name: Pack the vendored framework for verification - run: pnpm run release:pack --family vendor --out dist/npm-vendor + run: pnpm run release:pack --family vendor --out dist/npm-vendor --concurrency 8 # dsh-sandbox-local declares the Landlock entry as a runtime dependency, so # the verification needs its tarball. Its platform packages stay out: they diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 3b68a1e49c..0742eedcda 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -11,7 +11,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' -import { isEntry, run } from './process.ts' +import { isEntry, runConcurrent } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' /** Where pack output lands when `--out` is omitted. */ @@ -24,8 +24,8 @@ const DEFAULT_OUTPUT = 'dist/npm' * @param destination - absolute output directory. * @returns The tarball filename. */ -function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string { - run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) +async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise { + await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) const filename = tarballName(member) const tarball = join(destination, filename) @@ -34,13 +34,27 @@ function packMember(family: ReleaseFamily, member: ReleaseMember, destination: s return filename } +/** + * @returns The validated `--concurrency` value; 1 (the default) packs the + * members one at a time, exactly as the credentialed publish workflows run it. + */ +function parseConcurrency(raw: string | undefined): number { + if (raw === undefined) return 1 + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`--concurrency must be a positive integer, got ${JSON.stringify(raw)}`) + } + return parsed +} + /** Pack the family named by `--family` into `--out`. */ -function main(): void { +async function main(): Promise { const { values } = parseArgs({ - options: { family: { type: 'string' }, out: { type: 'string' } }, + options: { family: { type: 'string' }, out: { type: 'string' }, concurrency: { type: 'string' } }, allowPositionals: false, }) - if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm]') + if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm] [--concurrency 1]') + const concurrency = parseConcurrency(values.concurrency) const family = releaseFamily(values.family) const root = process.cwd() @@ -52,11 +66,23 @@ function main(): void { rmSync(destination, { recursive: true, force: true }) mkdirSync(destination, { recursive: true }) - const order: string[] = [] - for (const member of members) order.push(packMember(family, member, destination)) + // Members pack in a bounded pool; the recorded publish order stays the + // members' order regardless of completion order, because each worker writes + // its result at the member's own position. + const order = new Array(members.length) + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(concurrency, members.length) }, async () => { + while (cursor < members.length) { + const index = cursor + cursor += 1 + const member = members[index] + if (member === undefined) break + order[index] = await packMember(family, member, destination) + } + })) writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`) console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) } -if (isEntry(import.meta.url)) main() +if (isEntry(import.meta.url)) await main() diff --git a/scripts/release/process.ts b/scripts/release/process.ts index f73d8d53b8..3147be4d4a 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -3,7 +3,7 @@ * `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours. */ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -83,6 +83,26 @@ export function run(command: string, args: readonly string[], options: RunOption if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) } +/** + * Run a command with inherited streams without blocking the event loop, so a + * caller can hold several commands in flight, and fail on a non-zero exit. + * Concurrent children interleave their output at line granularity. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + * @returns Resolves when the command exits with status zero. + */ +export function runConcurrent(command: string, args: readonly string[], options: RunOptions = {}): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' }) + child.once('error', rejectRun) + child.once('close', (status, signal) => { + if (status === 0) resolveRun() + else rejectRun(new Error(`${command} ${args.join(' ')} exited with ${String(status ?? signal)}`)) + }) + }) +} + /** * Return whether Node started the given module as the process entry point. * @param moduleUrl - the caller's `import.meta.url`. From 327536548942da0b8520d814de22185a0db9fe6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:09:53 +0800 Subject: [PATCH 08/14] fix(docs): make site builds idempotent --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 4 +- .../2026-08-20-doc-site-raw-markdown-twins.md | 4 +- ...26-08-20-doc-site-raw-markdown-twins.zh.md | 4 +- package.json | 4 +- scripts/project-doc-site.spec.ts | 43 ++++++++++++++ website/AGENTS.md | 2 + website/build.ts | 57 +++++++++++++++++++ 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 website/build.ts diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml index b6cb38880f..2b2e377275 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md -2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2 -2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 +2026-08-20-doc-site-raw-markdown-twins.md: 85f6cc957d5f3a1675a38abc4675e76448ffb5f5 +2026-08-20-doc-site-raw-markdown-twins.zh.md: df6f0780223dc10fc9b5832f97a360e08e68b46b diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md index 5b45657d13..85f6cc957d 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure. -An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission. +An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. + +Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and removes that directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission. `llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label]():
` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point. diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md index 1730c9e23e..df6f078022 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -14,7 +14,9 @@ Status: implemented 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 -index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。 +index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。 + +每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并在打包前删除该目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 diff --git a/package.json b/package.json index 65b9786b40..bec66ae4c9 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments", + "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", + "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index df1c4dc4a4..5d58509fab 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -9,6 +9,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts' import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage, @@ -70,6 +71,48 @@ describe('website source layout', () => { }) }) +describe('documentation site build', () => { + it.each([ + { mode: 'SPA', mpa: false, expectedMpa: undefined }, + { mode: 'MPA', mpa: true, expectedMpa: 'true' }, + ])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-')) + roots.push(root) + const outDir = join(root, '.dist') + const stale = join(outDir, 'stale.md') + const fresh = join(outDir, 'index.html') + mkdirSync(outDir) + writeFileSync(stale, 'stale\n') + + const options = docSiteBuildOptions(root, mpa) + expect(options.mpa).toBe(expectedMpa) + expect(existsSync(stale)).toBe(true) + await options.onAfterConfigResolve?.({ outDir } as never) + expect(existsSync(outDir)).toBe(false) + mkdirSync(outDir) + writeFileSync(fresh, 'fresh\n') + + expect(readFileSync(fresh, 'utf8')).toBe('fresh\n') + }) + + it('refuses to remove the site root or an outside directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-')) + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-')) + roots.push(root, outside) + writeFileSync(join(root, 'keep'), 'root\n') + writeFileSync(join(outside, 'keep'), 'outside\n') + + expect(() => { + cleanDocSiteOutput(root, root) + }).toThrow('must be a child of site root') + expect(() => { + cleanDocSiteOutput(root, outside) + }).toThrow('must be a child of site root') + expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n') + expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n') + }) +}) + describe('publishableImage', () => { it('accepts a regular file inside the repository', () => { const { root } = fixture() diff --git a/website/AGENTS.md b/website/AGENTS.md index 217417660b..65ca9d28e0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -10,6 +10,8 @@ Keep canonical prose and generated catalogs in their owning `docs/` tier, then e The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`. +Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them. + The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree. Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. diff --git a/website/build.ts b/website/build.ts new file mode 100644 index 0000000000..dad6e4a5cd --- /dev/null +++ b/website/build.ts @@ -0,0 +1,57 @@ +/** Production documentation-site build with project-owned output preparation. */ + +import { rmSync } from 'node:fs' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'vitepress' + +const websiteRoot = resolve(import.meta.dirname) +type DocSiteBuildOptions = NonNullable[1]> + +/** + * Remove one documentation build output without permitting the site root or an outside path. + * @param siteRoot - VitePress site root that owns the output. + * @param outDir - Resolved VitePress output directory. + * @throws When `outDir` is not a proper child of `siteRoot`. + */ +export function cleanDocSiteOutput(siteRoot: string, outDir: string): void { + const root = resolve(siteRoot) + const output = resolve(outDir) + const child = relative(root, output) + if (child === '' || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) { + throw new Error(`build-doc-site: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`) + } + rmSync(output, { recursive: true, force: true }) +} + +/** + * Create VitePress build options that remove the resolved output directory before bundling. + * @param siteRoot - VitePress site root to build. + * @param mpa - Whether to use VitePress's multi-page application build. + * @returns VitePress options with project-owned output preparation. + */ +export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions { + const root = resolve(siteRoot) + return { + ...mpa ? { mpa: 'true' } : {}, + onAfterConfigResolve(siteConfig) { + cleanDocSiteOutput(root, siteConfig.outDir) + }, + } +} + +async function buildDocSite(siteRoot: string, mpa: boolean): Promise { + const root = resolve(siteRoot) + await build(root, docSiteBuildOptions(root, mpa)) +} + +function parseMpa(args: string[]): boolean { + if (args.length === 0) return false + if (args.length === 1 && args[0] === '--mpa') return true + throw new Error(`build-doc-site: expected no arguments or --mpa, got ${JSON.stringify(args)}.`) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) { + await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2))) +} From b342b09401c27ce6f4dc8036b333d834aeab609a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:23:41 +0800 Subject: [PATCH 09/14] chore(release): drop the unused synchronous runner --- scripts/release/process.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/scripts/release/process.ts b/scripts/release/process.ts index 3147be4d4a..c85ca2c719 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -70,19 +70,6 @@ export function capture(command: string, args: readonly string[], options: RunOp return result.stdout.trim() } -/** - * Run a command with inherited streams, so its progress reaches the log, and - * fail on a non-zero exit. - * @param command - executable name. - * @param args - command arguments. - * @param options - working directory and environment. - */ -export function run(command: string, args: readonly string[], options: RunOptions = {}): void { - const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) -} - /** * Run a command with inherited streams without blocking the event loop, so a * caller can hold several commands in flight, and fail on a non-zero exit. From 2a1a2605dc62e3c11661a8e5df7aa7b7fb190e77 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:28:44 +0800 Subject: [PATCH 10/14] test(workflow-worker-thread): budget startup waits for the contended Windows pool --- .../tests/workflow-worker-thread.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index 6b819aa367..acd2702dab 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -23,11 +23,13 @@ function fakeParent(): Agent { vi.setConfig({ testTimeout: 30_000 }) /** - * Wait up to 10 seconds for CPU-bound worker startup or cross-thread delivery on contended CI. - * Host reactions after an observed event use explicit tight overrides, so this generous startup + * Wait up to 60 seconds for CPU-bound worker startup or cross-thread delivery on contended CI: + * startup is the only environment-sensitive phase of a same-process worker exchange, and the + * loaded self-hosted Windows pool stretches the tsx-in-worker boot past 10 seconds. Host + * reactions after an observed event use explicit tight overrides, so this generous startup * allowance cannot hide multi-second reap regressions. */ -function waitFor(assertion: () => void, timeout = 10_000): Promise { +function waitFor(assertion: () => void, timeout = 60_000): Promise { return vi.waitFor(assertion, { timeout, interval: 50 }) } @@ -181,7 +183,9 @@ async function run(ctx: Context, parent: Agent, source: { script: string; meta: } } -describe('dsh-workflow-worker-thread', () => { +// The per-test cap leaves room for one generous startup wait plus the tight +// post-event assertions; explicit narrower timeouts inside stay authoritative. +describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { describe('script execution over a real worker thread', () => { it('runs a script end-to-end: agent() text results, phases, log, args, return value, events', async () => { const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) }) From f7890f591a6e2ff681a34d1879968a77f963dd3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:31:08 +0800 Subject: [PATCH 11/14] fix(agent-presets): make a preset's failures legible where they happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery proved only that a composition parsed, so a preset naming a package a later rename took away kept a healthy card and its place in every picker until a person switched to it. It now resolves each row it can prove will start, reading the package off disk and falling back to the resolver only for names that look absent — the resolver costs a synchronous hooks-thread round-trip under the source launch's tsx hook, which the walk avoids for every row it clears. The mount diagnostic followed `AggregateError.errors` but never a cause, so a group that failed on two rows named neither. It now follows a cause that carries more than its own message. A refused switch left the chip's label snapping back with no account of why, which is the only account there can be for a preset that resolves and then refuses. It announces through the shared Toast, which gained a caller-set hold for a cause that names packages and rows. --- ...8-26-preset-health-resolves-rows.i18n.yaml | 6 + .../2026-08-26-preset-health-resolves-rows.md | 73 +++++++ ...26-08-26-preset-health-resolves-rows.zh.md | 73 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 49 ++++- .../agent-preset-selection/menu.expected.md | 1 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 4 +- packages/client/ui-agent-preset/README.zh.md | 4 +- .../src/client/AgentPresetSeat.tsx | 137 ++++++++----- .../src/client/AgentPresetSection.module.css | 50 ++++- .../src/client/AgentPresetSection.tsx | 23 ++- .../ui-agent-preset/src/client/locales.ts | 4 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../tests/apply.client.spec.ts | 24 +++ .../tests/components.client.spec.tsx | 60 +++++- .../tests/section.client.spec.tsx | 16 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Toast.module.css | 10 +- packages/client/ui-primitives/src/Toast.tsx | 28 ++- .../ui-primitives/tests/toast.client.spec.tsx | 18 ++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 4 +- packages/preset/agent-presets/README.zh.md | 4 +- packages/preset/agent-presets/package.json | 1 + .../preset/agent-presets/src/discovery.ts | 182 +++++++++++++++++- packages/preset/agent-presets/src/index.ts | 26 ++- packages/preset/agent-presets/src/mount.ts | 46 ++++- .../preset/agent-presets/src/specifier.ts | 45 +++++ .../agent-presets/tests/authoring.spec.ts | 7 +- .../agent-presets/tests/discovery.spec.ts | 161 ++++++++++++++-- .../tests/fixtures/plugins/throws.js | 9 + .../fixtures/user/broken/agent.cordis.yml | 6 +- .../user/nested-broken/agent.cordis.yml | 16 ++ .../fixtures/user/two-broken/agent.cordis.yml | 14 +- .../preset/agent-presets/tests/mount.spec.ts | 38 +++- .../agent-presets/tests/shipped-root.spec.ts | 8 +- pnpm-lock.yaml | 3 + 39 files changed, 1041 insertions(+), 145 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md create mode 100644 packages/preset/agent-presets/src/specifier.ts create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/throws.js create mode 100644 packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml new file mode 100644 index 0000000000..48dbb0f806 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md +2026-08-26-preset-health-resolves-rows.md: 1615b2b86bee27a282357e1d5f023797672cb9b5 +2026-08-26-preset-health-resolves-rows.zh.md: ea287ae8204aa1730d80199b0f4e86e3cf7b42a3 diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md new file mode 100644 index 0000000000..1615b2b86b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md @@ -0,0 +1,73 @@ +# Agent Note: Preset health resolves the rows it can prove will start + +Status: implemented + +English | [中文](2026-08-26-preset-health-resolves-rows.zh.md) + +## Problem + +A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config. + +`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose". + +The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded. + +The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row. + +## Decision + +**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `/package.json` — and only a name that finds nothing there is confirmed through `import.meta.resolve`, whose refusals are then remembered for the process. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way. + +The disk lookup is the fast path because the resolver is not one: a registered ESM loader hook turns every `import.meta.resolve` call into a synchronous round-trip to the hooks thread. Under the `tsx` hook the source launch installs, that measured 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node, which put 238ms of resolver time into each roster read. The walk answers the same 135 rows in 0.7ms. Keeping the resolver for names the walk cannot find leaves a read paying for the failures it reports rather than for every row it clears, and keeps a package only a loader can resolve — through tsconfig paths, or an import map — from being called broken. A Node builtin short-circuits ahead of both. + +The refusal memo sits behind the walk, never in front of it, so a package installed since is found on disk and a recorded refusal cannot go stale in the direction that matters. + +**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load. + +**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner. + +**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end. + +**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them. + +**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is the badge's tooltip for a pointer, and a visually hidden `role="alert"` node carries it to assistive technology — the card body is disabled when a preset is broken, so it leaves the tab order and the tooltip has no keyboard path. + +**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand. + +The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice. + +## Alternatives considered + +**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict. + +**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost. + +**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design. + +**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one. + +**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp. + +**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine. + +**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part. + +**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops. + +**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way. + +**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line. + +**Make the badge a focusable control so the tooltip has a keyboard path.** Rejected for now. The badge sits inside the card's own ` + <> + { setOpen(false) }} + items={state.options.map((option) => { + const text = presetDisplayText(option, t) + return { + id: option.id, + // Name and description together: the id alone never says what a + // preset does, which is why the roster carries display copy. + label: ( + + {text.name} + {text.description ?? t('noDescription')} + + ), + } + })} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + const picked = state.options.find(option => option.id === id) + // The fallback is for the row shape `find` cannot promise; the menu's + // items ARE `state.options`, so an emitted id is always one of them. + /* v8 ignore next */ + const name = picked === undefined ? id : presetDisplayText(picked, t).name + void select(id).then((refusal) => { + // Announced only for a pick a person just made: `apply()` also runs + // when a session becomes current, and a banner over that would + // report a refusal nobody asked for. + if (refusal === undefined) return + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) }) + }) + }} + align="start" + portal + anchor={( + + )} + /> + {toast !== null && ( + } + holdMs={REFUSAL_HOLD_MS} + // The composer card, which is the content column this chip sits + // above — not an ancestor of it, so the lookup is a page query + // rather than `closest`. Absent, the banner centers on the window, + // which is off-center whenever the sidebar is open. + anchor={ + seatRef.current?.closest('[data-composer-card]') + ?? document.querySelector('[data-composer-card]') + } + onDone={() => { setToast(null) }} + /> )} - /> + ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 0a8d2fa8a3..3b1378d3a3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -94,12 +94,49 @@ color: var(--dsw-alias-bg-layer-3); } -/* The discovery-reported reason, verbatim: it names the file and the fix. */ -.cardBrokenReason { - font-size: 12px; +/* The discovery-reported reason, verbatim: it names the rows and the fix. + A real element rather than the icon row's `data-tip` pseudo-element, for two + reasons: generated content joins the card's accessible text, where this would + repeat what the alert already carries, and that tooltip is one `nowrap` line + for an icon label while this names package specifiers one per line. */ +.brokenTip { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 1; + width: max-content; + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + font-weight: 400; line-height: 1.5; - color: var(--dsw-alias-state-error-primary); + text-align: left; + white-space: pre-line; overflow-wrap: anywhere; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.brokenBadge:hover .brokenTip { + opacity: 1; +} + +/* The same reason, for assistive technology only. The card body is disabled + when a preset is broken, so it leaves the tab order and the badge's tooltip + has no keyboard or screen-reader path; this node is that path. Sighted + pointer users read the badge instead, which keeps a picker card showing the + preset's own description rather than a package specifier. */ +.cardBrokenReason { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } /* The card body is the control that picks the preset. */ @@ -132,6 +169,11 @@ display: flex; align-items: center; gap: 8px; + /* Anchors the broken badge's tooltip: the badge itself stays unpositioned so + its `::after` resolves against the card's own width instead of against a + badge that sits partway across it. A tooltip grown from the badge would + run past the card, and past the section for a card in the last column. */ + position: relative; } .cardName { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 59ccb19226..60056e6b7a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -257,7 +257,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is - disabled and the card says why instead of offering it. */} + disabled; the reason rides the badge rather than the card + face, which stays the preset's own description. */}