diff --git a/tests/strict-tsconfig.test.mjs b/tests/strict-tsconfig.test.mjs new file mode 100644 index 0000000..467d0b9 --- /dev/null +++ b/tests/strict-tsconfig.test.mjs @@ -0,0 +1,170 @@ +/** + * Strict tsconfig test — locks in the [E00-S01-T10] strict base tsconfig + * committed at the workspace root and shared by every workspace package. + * + * Acceptance criteria covered (each test fails without the committed config): + * - "strict base tsconfig is committed" → root `tsconfig.base.json` exists + * and carries `strict: true` plus the strict family the workspace relies + * on (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, + * `noImplicitOverride`, `useUnknownInCatchVariables`, + * `verbatimModuleSyntax`) and NodeNext module resolution; every workspace + * package (apps/server, packages/core, extensions/example) commits a + * `tsconfig.json` that extends that root base config. + * - "all packages compile under the strict base config" → running + * `tsc -p /tsconfig.json --noEmit` with the workspace-resolved + * TypeScript (exact 6.0.3, locked in by E00-S01-T09) exits 0 for every + * workspace package. + * - (non-vacuousness) a scratch file that violates the strict family fails + * to compile under the committed base config with the expected + * strict-family error codes, proving the flags are enforced rather than + * merely declared. + * + * Run: `node --test tests/strict-tsconfig.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The workspace packages that must compile under the strict base config. */ +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'extensions/example']; + +/** The strict-family flags the committed base config must set. */ +const STRICT_FAMILY_FLAGS = [ + 'strict', + 'noUncheckedIndexedAccess', + 'exactOptionalPropertyTypes', + 'noImplicitOverride', + 'useUnknownInCatchVariables', + 'verbatimModuleSyntax', +]; + +/** The TypeScript compiler resolved by the frozen install (exact 6.0.3, E00-S01-T09). */ +const TSC_BIN = path.join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'); + +/** + * Runs the workspace-resolved TypeScript against a tsconfig (the same binary + * the package `typecheck` scripts use; see E00-S01-T05/T09), executed by the + * Node runtime running this test so it works from any cwd. + */ +function runTsc(cwd, args) { + return spawnSync(process.execPath, [TSC_BIN, ...args], { + cwd, + encoding: 'utf8', + timeout: 120_000, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('tsconfig.base.json is committed with the strict family enabled', () => { + const base = JSON.parse(read('tsconfig.base.json')); + assert.ok( + base && typeof base === 'object' && base.compilerOptions, + 'tsconfig.base.json must be a committed JSON object declaring compilerOptions', + ); + for (const flag of STRICT_FAMILY_FLAGS) { + assert.equal( + base.compilerOptions[flag], + true, + `tsconfig.base.json must set "${flag}": true (strict family)`, + ); + } + assert.equal( + base.compilerOptions.module, + 'NodeNext', + 'tsconfig.base.json must set module to NodeNext (ESM boundary source of truth)', + ); + assert.equal( + base.compilerOptions.moduleResolution, + 'NodeNext', + 'tsconfig.base.json must set moduleResolution to NodeNext', + ); + assert.equal( + base.compilerOptions.target, + 'ES2023', + 'tsconfig.base.json must set target to ES2023', + ); +}); + +test('every workspace package tsconfig.json extends the committed base config', () => { + for (const pkg of WORKSPACE_PACKAGES) { + const tsconfig = JSON.parse(read(path.join(pkg, 'tsconfig.json'))); + assert.equal( + tsconfig.extends, + '../../tsconfig.base.json', + `"${pkg}/tsconfig.json" must extend the committed root tsconfig.base.json`, + ); + } +}); + +test('every workspace package compiles under the strict base config (tsc --noEmit)', () => { + for (const pkg of WORKSPACE_PACKAGES) { + const result = runTsc(path.join(REPO_ROOT, pkg), ['-p', 'tsconfig.json', '--noEmit']); + assert.equal( + result.status, + 0, + `"${pkg}" must compile under the strict base config:\n${(result.stderr || result.stdout || '').trim()}`, + ); + } +}); + +test('the committed base config enforces the strict family (non-vacuous probe)', () => { + const scratch = mkdtempSync(path.join(tmpdir(), 'strict-base-probe-')); + try { + writeFileSync( + path.join(scratch, 'tsconfig.json'), + JSON.stringify( + { + extends: path.join(REPO_ROOT, 'tsconfig.base.json'), + compilerOptions: { noEmit: true }, + include: ['.'], + }, + null, + 2, + ), + ); + writeFileSync( + path.join(scratch, 'probe.ts'), + [ + '// Each line below violates one strict-family flag of tsconfig.base.json.', + 'function implicitAny(param) { return param; } // TS7006 - noImplicitAny (strict)', + 'const items: string[] = [];', + 'const first = items[0].toUpperCase(); // TS2532 - noUncheckedIndexedAccess', + 'interface Options { port?: number; }', + 'const opts: Options = { port: undefined }; // TS2375 - exactOptionalPropertyTypes', + 'class Base { greet(): string { return "hi"; } }', + 'class Derived extends Base { greet(): string { return "yo"; } } // TS4114 - noImplicitOverride', + 'try { JSON.parse(""); } catch (err) { const m = err.message; } // TS18046 - useUnknownInCatchVariables', + '', + ].join('\n'), + ); + const result = runTsc(scratch, ['-p', 'tsconfig.json', '--noEmit']); + assert.notEqual( + result.status, + 0, + 'a file violating the strict family must fail to compile under the committed base config', + ); + const output = (result.stderr || result.stdout || ''); + for (const code of ['TS7006', 'TS2532', 'TS2375', 'TS4114', 'TS18046']) { + assert.ok( + output.includes(code), + `probe output must contain ${code} (strict family not enforced):\n${output}`, + ); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +});