diff --git a/packages/cli/test/dependency-range-doors.test.ts b/packages/cli/test/dependency-range-doors.test.ts index 486c860a20a..1749519a0fe 100644 --- a/packages/cli/test/dependency-range-doors.test.ts +++ b/packages/cli/test/dependency-range-doors.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 40 — spawns the real producer once per single-cause fixture: five spawns of the source CLI, each a distinct (command, fixture) pair (`os validate --json` and `os build --json` over the unreadable-range artifact, `os validate --json` and `os lint --json` over the unmet-range artifact, `os validate --json` over the met-range control); slowest CI reading 27.6 s (run 41199450735, over the five scheduled ci.yml runs 41199450735 to 41271604267) x 1.25, rounded up /** * ADR-0141 D2, author side — `os validate`, `os build` and `os lint` report diff --git a/packages/cli/test/generate-stack-reach.test.ts b/packages/cli/test/generate-stack-reach.test.ts index e4bd99011d9..f2a53cbbc2d 100644 --- a/packages/cli/test/generate-stack-reach.test.ts +++ b/packages/cli/test/generate-stack-reach.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 40 — spawns the real producer once per single-cause fixture: ten sequential spawns of the source CLI in one beforeAll, each a distinct (`os g` generator, flags, fixture state) triple over five fixtures (the wired app before and after its object and flow exist, a stack with no `requires`, one requiring `triggers` alone, the pre-fix objects-only config, a bare directory), each refusal read against its own byte snapshot; slowest CI reading 31.0 s (run 41217827347, over the five scheduled ci.yml runs 41199450735 to 41271604267) x 1.25, rounded up /** * PIN (#20215) — after writing, `os generate` says whether the scaffold diff --git a/packages/cli/test/i18n-extract-check-dry-run.test.ts b/packages/cli/test/i18n-extract-check-dry-run.test.ts index c358c920535..bce882178de 100644 --- a/packages/cli/test/i18n-extract-check-dry-run.test.ts +++ b/packages/cli/test/i18n-extract-check-dry-run.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 30 — spawns the real producer once per single-cause fixture: seven spawns of the source CLI's `os i18n extract`, the one real extract that writes the in-sync tree each seeded case copies, then six distinct (flags, `--out` state) runs (`--check` and `--check --dry-run` over an empty `--out`, `--check --dry-run` over a stale bundle and over the in-sync tree, `--dry-run` with an empty `--out` and with none); slowest CI reading 19.3 s (run 41217827347, over the five scheduled ci.yml runs 41199450735 to 41271604267) x 1.25, rounded up /** * `os i18n extract --check --dry-run` COMPARES, and says what it found (#16480). diff --git a/packages/cli/test/i18n-extract-check-json.test.ts b/packages/cli/test/i18n-extract-check-json.test.ts index 57a2fc965b6..63e01abd964 100644 --- a/packages/cli/test/i18n-extract-check-json.test.ts +++ b/packages/cli/test/i18n-extract-check-json.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 30 — spawns the real producer once per single-cause fixture: eight spawns of the source CLI's `os i18n extract`, the one real extract that writes the in-sync tree each seeded case copies, then seven distinct (flags, `--out` state) runs (`--check` and `--check --json` over an empty `--out`, `--check --json` over a stale bundle, `--check` and `--check --json` over the in-sync tree, `--check --json` with no `--out`, `--json` without `--check`); slowest CI reading 22.2 s (run 41271604267, over the five scheduled ci.yml runs 41199450735 to 41271604267) x 1.25, rounded up /** * `os i18n extract --check --json` COMPARES, and reaches the same verdict as diff --git a/packages/cli/test/lint-eval-generator-refusal-separator.test.ts b/packages/cli/test/lint-eval-generator-refusal-separator.test.ts index cb6c76d3b82..4697ebb7327 100644 --- a/packages/cli/test/lint-eval-generator-refusal-separator.test.ts +++ b/packages/cli/test/lint-eval-generator-refusal-separator.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 50 — spawns the real producer once per single-cause fixture: four spawns of the source CLI in one beforeAll, each a distinct (face, generator) pair (`os lint --eval --json --generator ""`, the same on the human face, and both faces over an unresolvable path); the same-door case reads the two machine-face runs the cases above read instead of spawning them again (six spawns before); slowest CI reading 35.7 s (run 41217827347, over the five scheduled ci.yml runs 41199450735 to 41271604267) x 1.25, rounded up /** * [#16359] `os lint --eval --generator ""` printed TWO spaces after our own @@ -94,6 +95,23 @@ interface Run { } let dir: string; +/** The unresolvable path every negative-control run names; under `dir`, so it never exists. */ +let missing: string; + +/** + * The four distinct runs this file reads, each spawned ONCE in `beforeAll`. + * + * The same-door case used to spawn the two machine-face runs a second time, + * with the same command, flags and fixture as the cases above; it now reads + * the very runs they read. The pair it compares is unchanged, and so is every + * assertion: only the duplicate spawns are gone. + */ +const runs = {} as { + emptyJson: Run; + emptyHuman: Run; + missingJson: Run; + missingHuman: Run; +}; function runLint(args: string[]): Promise { return new Promise((resolvePromise) => { @@ -139,39 +157,45 @@ function payloadOf(run: Run, label: string): { error?: string } { } } -beforeAll(() => { +beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-lint-eval-separator-')); -}); + missing = join(dir, 'does-not-exist.mjs'); + // Sequential on purpose: one source-CLI child at a time, as the cases ran. + runs.emptyJson = await runLint(['--json', '--generator', '']); + runs.emptyHuman = await runLint(['--generator', '']); + runs.missingJson = await runLint(['--json', '--generator', missing]); + runs.missingHuman = await runLint(['--generator', missing]); + // The old per-case allowance, once per spawn. +}, 4 * 120_000); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); describe('os lint --eval --generator "" — one space after our separator, on both faces', () => { - it('the machine face carries the message BYTE-EXACTLY', async () => { - const run = await runLint(['--json', '--generator', '']); + it('the machine face carries the message BYTE-EXACTLY', () => { + const run = runs.emptyJson; const payload = payloadOf(run, 'empty generator — machine face'); expect(run.code).toBe(1); // The whole composed message, not a fragment: two spaces fail this line. expect(payload.error).toBe('Failed to load generator "": is not a valid JS file'); - }, 120_000); + }); - it('the human face carries the same message, byte for byte', async () => { - const run = await runLint(['--generator', '']); + it('the human face carries the same message, byte for byte', () => { + const run = runs.emptyHuman; expect(run.code).toBe(1); // `printError` -> `errorLine` -> ` ✗ ${msg}`, uncoloured under NO_COLOR. expect(humanLine(run, 'empty generator — human face')).toBe( ' ✗ Failed to load generator "": is not a valid JS file', ); - }, 120_000); + }); }); describe('os lint --eval — the non-empty detail is untouched [negative control]', () => { - it('an unresolvable path still answers with exactly one space, on the machine face', async () => { - const missing = join(dir, 'does-not-exist.mjs'); - const run = await runLint(['--json', '--generator', missing]); + it('an unresolvable path still answers with exactly one space, on the machine face', () => { + const run = runs.missingJson; const payload = payloadOf(run, 'unresolvable path — machine face'); expect(run.code).toBe(1); @@ -180,32 +204,33 @@ describe('os lint --eval — the non-empty detail is untouched [negative control expect(payload.error).toContain(`"${missing}": Build failed with 1 error:`); // ⛔ and the detail is not otherwise re-flowed. expect(payload.error).toContain(`error: Could not resolve "${missing}"`); - }, 120_000); + }); - it('an unresolvable path still answers with exactly one space, on the human face', async () => { - const missing = join(dir, 'does-not-exist.mjs'); - const run = await runLint(['--generator', missing]); + it('an unresolvable path still answers with exactly one space, on the human face', () => { + const run = runs.missingHuman; expect(run.code).toBe(1); expect(humanLine(run, 'unresolvable path — human face')).toBe( ` ✗ Failed to load generator "${missing}": Build failed with 1 error:`, ); - }, 120_000); + }); }); describe('os lint --eval — the empty string and an unresolvable path answer through the SAME door [#16161]', () => { - it('same exit code, same envelope, same message shape', async () => { - const empty = await runLint(['--json', '--generator', '']); - const missing = await runLint(['--json', '--generator', join(dir, 'does-not-exist.mjs')]); + it('same exit code, same envelope, same message shape', () => { + // The two machine-face runs the cases above read: the same command, flags + // and fixture this case used to spawn a second time. + const empty = runs.emptyJson; + const unresolvable = runs.missingJson; const emptyPayload = payloadOf(empty, 'same door — empty'); - const missingPayload = payloadOf(missing, 'same door — unresolvable'); + const missingPayload = payloadOf(unresolvable, 'same door — unresolvable'); // ⛔ The property #16161 ruled on, and the one a "special-case the empty // value" repair would break while still satisfying every spacing // assertion above. The empty string is a path that names no module, not a // separate error class. - expect(empty.code).toBe(missing.code); + expect(empty.code).toBe(unresolvable.code); expect(empty.code).toBe(1); expect(Object.keys(emptyPayload)).toEqual(Object.keys(missingPayload)); expect(Object.keys(emptyPayload)).toEqual(['error']); @@ -216,5 +241,5 @@ describe('os lint --eval — the empty string and an unresolvable path answer th ] as const) { expect(message, label).toMatch(/^Failed to load generator "[^]*?": \S/); } - }, 120_000); + }); }); diff --git a/packages/cli/test/scaffold-emission-typechecks.test.ts b/packages/cli/test/scaffold-emission-typechecks.test.ts index 46c473bed59..8d4e294ac0d 100644 --- a/packages/cli/test/scaffold-emission-typechecks.test.ts +++ b/packages/cli/test/scaffold-emission-typechecks.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 50 — compiles the package type graph once: the lib and @objectstack/spec declaration files are parsed and bound once in this worker and shared by one TypeScript Program per emitted scaffold (each `os init` template, each `os g` generator, and the canary), each with its own tsconfig and diagnostics, instead of one `tsc` child per case; the file also imports the init and generate command modules to derive both rosters; slowest CI reading 33.0 s (run 41199450735, over the five scheduled ci.yml runs 41199450735 to 41271604267, one tsc child per case) x 1.25, rounded up /** * PIN (#15976) — every scaffold this package emits must survive the @@ -54,9 +55,23 @@ * scaffolds genuinely did parse, validate and load — they simply did not * COMPILE, and nothing here had ever asked a compiler. * - * That is the gap this file fills, and it is why it spawns `tsc` over a - * materialized project instead of importing the module. The type layer is a - * separate axis from the schema layer, and it needs its own instrument. + * That is the gap this file fills, and it is why it runs the TypeScript + * compiler over a materialized project instead of importing the module. The + * type layer is a separate axis from the schema layer, and it needs its own + * instrument. + * + * ## One compiler, one Program per sandbox + * + * Each sandbox is compiled as its OWN Program, from its own `tsconfig.json`, + * with the diagnostics `tsc --noEmit -p ` reports — so a case still asks + * exactly "does this emitted project pass its own `typecheck` script", and no + * scaffold can lean on another's declarations. What the sandboxes share is the + * read-only part every one of them used to redo in its own `tsc` child: the + * parsed and bound `lib.*.d.ts` and `@objectstack/spec` declaration files, + * held in one source-file cache (the compiler's own reuse model: the language + * service shares source files across programs the same way). The compiler is + * the `typescript` package `typescript/bin/tsc` resolves to, loaded once in + * this worker instead of once per case. * * ## The rosters are derived, both of them * @@ -100,9 +115,8 @@ import { afterAll, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; import { TEMPLATES, sanitizeNamespace, @@ -113,7 +127,6 @@ import { } from '../src/commands/init.js'; import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; import { probeBindings } from './helpers/scaffold-bindings.js'; -import { childEnv } from './helpers/serve-process.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -131,23 +144,79 @@ const PROJECT_NAME = 'my-app'; const STEM = 'probe_thing'; /** - * Run the emitted project's own `typecheck` script: `tsc --noEmit`. + * Declaration files every sandbox reads (`lib.*.d.ts`, `@objectstack/spec`'s + * `dist/`), parsed and bound once and handed to each sandbox's Program. + * + * Only files OUTSIDE the sandbox are cached: a sandbox's own sources are read + * fresh by its own Program. Every sandbox compiles under the same + * `SCAFFOLD_TSCONFIG_COMPILER_OPTIONS` (they differ only in `rootDir` and + * `include`, which change no parse), and the key carries the parse options + * the compiler asks with, so a cached file is the file that sandbox's own + * `tsc` would have parsed. + */ +const SHARED_DECLARATIONS = new Map(); + +const FORMAT_HOST: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', +}; + +/** + * Run the emitted project's own `typecheck` script: `tsc --noEmit -p `. * - * The child's environment is DECLARED (`check:cli-test-child-env`, #11595): - * every spawn under `packages/cli/test/**` owes one, so that what a child - * inherits is legible at the call site rather than being the vitest worker's - * environment by default. `childEnv()` is this directory's choke point — the - * environment minus the `VITEST_*` family — and `NO_COLOR` pairs with - * `--pretty false` to keep tsc's diagnostics greppable in the failure message. + * In process, one Program per sandbox (see the docblock's "One compiler, one + * Program per sandbox"). The verdict is `tsc`'s: exit 0 exactly when the + * config, options, global, syntactic, semantic and declaration diagnostics + * that `tsc --noEmit` reports hold no error, and the output is those + * diagnostics in `--pretty false` form, so a failure message is still + * greppable for its `TS####` code. */ function typecheckProject(root: string): { code: number; output: string } { - const tscBin = createRequire(import.meta.url).resolve('typescript/bin/tsc'); - const res = spawnSync( - process.execPath, - [tscBin, '--pretty', 'false', '--noEmit', '-p', root], - { cwd: root, encoding: 'utf-8', env: childEnv({ NO_COLOR: '1' }) }, + const configErrors: ts.Diagnostic[] = []; + const parsed = ts.getParsedCommandLineOfConfigFile( + path.join(root, 'tsconfig.json'), + { noEmit: true }, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => configErrors.push(diagnostic), + }, ); - return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; + if (!parsed) { + return { code: 1, output: ts.formatDiagnostics(configErrors, FORMAT_HOST) }; + } + + const host = ts.createCompilerHost(parsed.options); + const readSourceFile = host.getSourceFile.bind(host); + const inSandbox = (fileName: string) => !path.relative(root, fileName).startsWith('..'); + host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) => { + if (inSandbox(fileName)) { + return readSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile); + } + const key = `${fileName}\u0000${JSON.stringify(languageVersionOrOptions)}`; + let file = SHARED_DECLARATIONS.get(key); + if (file === undefined) { + file = readSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile); + if (file !== undefined) SHARED_DECLARATIONS.set(key, file); + } + return file; + }; + + const program = ts.createProgram({ + rootNames: parsed.fileNames, + options: parsed.options, + host, + projectReferences: parsed.projectReferences, + configFileParsingDiagnostics: ts.getConfigFileParsingDiagnostics(parsed), + }); + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...program.emit().diagnostics]; + const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); + return { + // The exit status `tsc --noEmit` answers an error with, measured on the + // canary against TypeScript 6.0.3; the cases read only zero vs non-zero. + code: errors.length === 0 ? 0 : 2, + output: ts.formatDiagnostics(diagnostics, FORMAT_HOST), + }; } /** A sandbox project carrying the tsconfig `init` really writes. */