Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/test/dependency-range-doors.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/generate-stack-reach.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/i18n-extract-check-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/i18n-extract-check-json.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
69 changes: 47 additions & 22 deletions packages/cli/test/lint-eval-generator-refusal-separator.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Run> {
return new Promise((resolvePromise) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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']);
Expand All @@ -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);
});
});
107 changes: 88 additions & 19 deletions packages/cli/test/scaffold-emission-typechecks.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <root>` 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
*
Expand Down Expand Up @@ -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,
Expand All @@ -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));

Expand All @@ -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<string, ts.SourceFile>();

const FORMAT_HOST: ts.FormatDiagnosticsHost = {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => '\n',
};

/**
* Run the emitted project's own `typecheck` script: `tsc --noEmit -p <root>`.
*
* 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. */
Expand Down
Loading