#!/usr/bin/env bun /** * Lint: no test under `test/` is written against a shape that moved — a call with * the wrong number of arguments, an import naming something no longer exported, a * fixture missing a field or naming one its type dropped. Every diagnostic tsc * reports for a file under `test/` fails the check. * * `tsconfig.json`'s `include` is `src` / `instrument` / `scripts`, so `bun run * check` never sees `test/`. A signature change therefore does not break its * tests; it silently converts them into tests that assert a shape production can * no longer produce. Nothing goes red, and the coverage is gone until someone * reads the file. * * That is not hypothetical: `recordLlmUsage` grew from three parameters to five * and two call sites in `test/unit/metrics.test.ts` kept passing three, still * green, still asserting on a label set the runtime had stopped emitting. * * The obvious fix — put `test/**` in `include` — surfaces ~1,500 errors, almost * all strict-null and implicit-any noise in fixtures. That migration is worth * doing and is tracked separately; it is not a precondition for closing the one * failure mode above. * * So this gates every diagnostic tsc reports under `tsconfig.test.json`, the base * config with that strictness relaxed (see the options it turns off). The * compiler still resolves every signature, and what it reports under those * options has no false-positive story: the code is either wrong or it is not. * The codes below are the ones drift most often produces, with how to fix each. * * - **TS2554** "Expected N arguments, but got M" — a call site that fell behind * its callee. An arity mismatch is always wrong. * - **TS2305 / TS2724 / TS2459 / TS2614** "no exported member" — an import naming * something the module does not export. Worse than an arity mismatch: an * unresolved type import degrades to the error type, which behaves as `any`, so * every annotation written in terms of it silently stops constraining anything. * One dead import voids a whole file's type coverage while the suite stays * green. * * All four are the same defect; which one TypeScript picks turns on properties * of the *importee*, not of the mistake. TS2614 rather than TS2305 whenever the * module happens to have a default export, TS2724 when the name is a near-miss, * TS2459 when the name is declared but its `export` was dropped — the way a * named export most often rots. Gating a subset would make coverage depend on * whether an unrelated module carries a default. * - **TS2353** "Object literal may only specify known properties" — a fixture or * options object naming a field its type no longer has. The code ignores the * field, so a test that sets it to configure behavior runs without that * configuration and still passes: an auth adapter handed to a server that * takes none leaves the suite running unauthenticated. A field that is only * stale data costs nothing to drop; one the test depends on must be wired to * the option that replaced it. A record that is deliberately a shape the type * forbids (a legacy row as it sits on disk) says so with a commented cast. * - **TS2741 / TS2739** "Property is missing" — a stub without a field its type * requires. The code under test reads `undefined` there, so a test of a rule * keyed on that field (a role, a status, a counter) runs against a value the * runtime never produces. Build stubs from the shared helpers * (`test/helpers/identity.ts`, `test/helpers/fake-fetch.ts`) or give the field * the value the runtime would. * - **TS2352** "Conversion may be a mistake" — an `as` between types that do not * overlap, which hides a stub or a mock that no longer matches what it stands * in for. Narrow instead, or, for a shape deliberately off-type, cast through * `unknown` with a comment naming why. * - **TS2345** "Argument is not assignable to parameter" — a call handing a * function a shape it no longer takes: a stub missing a field the callee now * reads, a helper typed narrower than the value it is given, or a fake built * on a retired SDK. Type shared helpers on the real types * (`test/helpers/tool-result.ts`) so one signature serves every caller. * - **TS2339** "Property does not exist on type" — a read of a field the value * does not have. A read off `unknown` checks nothing, so an HTTP body is read * through `readJson` (`test/helpers/http.ts`) with the server's own type * where one exists. A read off a union narrows first: the relaxed project has * `strictNullChecks` off, which stops a boolean discriminant from narrowing * (`if (!r.ok)`), so compare it explicitly (`r.ok === false`). A class's * private member collapses an intersection with a same-named field to * `never`, taking every read on it down too. * * - **TS2304 / TS2552** "Cannot find name" — the same degradation one step * further along, a name with no import at all. TS2552 is the variant that * suggests a near-miss. The project has no DOM lib on purpose: the suite runs * on Bun, and a DOM lib would let a server test name `window` or `document` * without complaint. A browser type a test needs has a non-DOM spelling * (`webcrypto.JsonWebKey`, `RequestInit["redirect"]`), or the fake is typed as * the structure it provides. * * Tightening an option `tsconfig.test.json` relaxes means fixing what it * reports first, and expecting a fix to expose what a dead type was hiding. * * Recount to confirm you cleared them; do not grep. A fix can trade one code for * another and leave the total unchanged — repointing a dead import at the right * type resolves the TS2305 and raises a TS2304 on every use site the dead name * still has. The grep for the old code comes back empty either way. * * ## What this does NOT cover * * Only the `test/` tree. `web/`'s suite is its own package, gated under web's * strict config by `scripts/check-web-tests.ts`. The platform app UIs, excluded * from this project by the base config, are each their own package and are * gated under their own configs by `scripts/check-platform-apps.ts`. * * ## Why it proves tsc actually ran * * A gate that greens when it stops working is the same defect it exists to * catch, one level up. Filtering stdout for TS2554 does exactly that: a renamed * project file, an `include` glob that drifts off `test/`, or colourised output * all yield zero matching lines, which reads identically to "no violations". * * So the pass condition is positive, not an absence: `--listFiles` reports the * program tsc actually built, and this compares it against the source files * actually on disk under `test/`. Every file must be accounted for, which * catches total failure (a renamed project, a glob pointing elsewhere) and * partial drift (`test/unit/**` silently dropping the integration suite) alike * — neither of which enumerating known config-error codes would reach. * * The two sides must enumerate the same extensions or they cancel: a suffix the * project does not compile and this scan does not list is absent from both sets, * so it is never "unanalyzed" and the count still reconciles. `.tsx` was exactly * that until it was added to both. * `--pretty false` is belt-and-braces: it keeps ANSI escapes from landing * between `error` and `TS2554:` even when the project sets `pretty`. */ import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { $, Glob } from "bun"; import { normalizeTscPath, unlistedSources } from "./lib/tsc-paths.ts"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const PROJECT = "tsconfig.test.json"; /** * The tree this gate is responsible for, anchored at the repo root. * * Anchored rather than a bare `/test/` substring: dependencies ship their own * `test/` directories (`@types/node/test/reporters.d.ts` is in the program), and * a loose match counts those as coverage — which greened a drifted `include` in * testing. */ const TEST_ROOT = `${normalizeTscPath(join(ROOT, "test"))}/`; /** * Source suffixes under `test/`, and the half of the reconciliation this file * owns. The other half is `include` in `tsconfig.test.json` — the two must list * the same set. A suffix in neither is invisible to the check rather than * reported, because it lands in neither the analyzed set nor the on-disk one. */ const TEST_EXTENSIONS = ["ts", "tsx"]; /** Any tsc diagnostic line. */ const DIAGNOSTIC = /error TS\d+:/; async function main(): Promise { // tsc exits non-zero whenever it reports anything, and under the relaxed // project it reports plenty we deliberately ignore. So the exit code says // nothing — the output is the signal. `.nothrow()` keeps a non-zero exit from // aborting before we can read it. const result = await $`bunx tsc -p ${PROJECT} --noEmit --pretty false --listFiles` .cwd(ROOT) .nothrow() .quiet(); const lines = `${result.stdout.toString()}${result.stderr.toString()}`.split("\n"); // What tsc actually analyzed, against what is actually there. `--listFiles` // emits one absolute path per file in the program, so this reads the real // program rather than inferring coverage from an absence of complaints. const onDisk: string[] = []; for await (const rel of new Glob(`**/*.{${TEST_EXTENSIONS.join(",")}}`).scan({ cwd: join(ROOT, "test"), })) { onDisk.push(join(ROOT, "test", rel)); } const unanalyzed = unlistedSources(onDisk, lines); if (unanalyzed.length > 0) { console.error( `✗ ${PROJECT} left ${unanalyzed.length} of ${onDisk.length} files under test/ unanalyzed — this gate did not check them.\n`, ); console.error("A real violation in an unanalyzed file would go unreported, so the gate must"); console.error("fail rather than report a pass it cannot back. Usual causes: the project file"); console.error("is missing or unreadable, or its `include` no longer covers the whole tree.\n"); for (const f of unanalyzed.slice(0, 5)) console.error(` ${relative(ROOT, f)}`); if (unanalyzed.length > 5) console.error(` … and ${unanalyzed.length - 5} more`); for (const l of lines.filter((l) => /error TS/.test(l)).slice(0, 5)) { console.error(`\n ${l.trim()}`); } process.exit(1); } // Anchored to `test/` like the coverage check above. The project also compiles // `src/`, `instrument/` and `scripts/` so that signatures resolve, plus the // `web/src` files those import — already covered by `bun run check` and // `check:web` respectively, so reporting them here would only duplicate that, // under a message telling you to update a test. // // Both path forms count. `--listFiles` is absolute, diagnostics are relative to // tsc's cwd, and matching only one would make an empty `violations` mean either // "clean" or "the format moved" — the silent green this gate refuses. A path // under TEST_ROOT is unambiguously a test file, so accepting both costs nothing. const violations = lines .filter( (l) => (normalizeTscPath(l).startsWith("test/") || normalizeTscPath(l).startsWith(TEST_ROOT)) && DIAGNOSTIC.test(l), ) .map((l) => l.trim()); if (violations.length > 0) { console.error(`✗ Found ${violations.length} test(s) written against a shape that moved:\n`); for (const v of violations) console.error(` ${v}`); console.error("\nNone of these stops a test from running, which is why they survive. A wrong"); console.error("arity still executes — JavaScript drops the extras and fills the missing with"); console.error("undefined. A dead type import degrades to the error type, so every annotation"); console.error("written in terms of it stops constraining anything. An unknown property is"); console.error( "ignored by the code it configures, a missing one reads as undefined, and a cast", ); console.error("between non-overlapping types hides either. All go on passing while asserting"); console.error("something the runtime can no longer do. Update the test."); process.exit(1); } console.log(`✓ No shape drift across ${onDisk.length} files under test/`); } main().catch((err: unknown) => { console.error(err); process.exit(1); });