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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 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 8/9] 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 9/9] 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}`) })