From a4499414f476c2ab5ed8141bd78496c6da43d22a Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 18:54:03 -0500 Subject: [PATCH 1/4] test: compile published declarations under consumer conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This package compiles with strict, strictNullChecks and noImplicitAny all on. Consumers need not, and the same declaration can enforce something here and enforce nothing for them. A failure branch marked `data?: undefined` errors correctly under strictNullChecks and collapses without it, because `T | undefined` reduces to `T` when the flag is off — the unguarded read then compiles clean and throws at runtime. The current types are already the right shape: ParseFailure and MemberMiss omit the property entirely, so the guarantee rests on property existence and fires either way. But nothing enforced that shape. Every existing gate runs under this repository's own settings, so all of them are structurally incapable of telling the two forms apart. The unspoken precondition — that a consumer shares our null-checking setting — was true until recently, is now false, and was tracked by nothing. Adds a third gate that does not overlap the other two: - tsconfig.consumer.json compiles test-consumer/ against the BUILT lib/*.d.ts, reached by self-name import through the package's own exports map, with strictNullChecks and noImplicitAny off. Verified with --listFiles that only lib/interface/*.d.ts and the fixture compile; no file from src/ is read. It deliberately does not extend tsconfig.json, because a consumer inherits nothing from it. - Negative cases use @ts-expect-error with descriptions, so they are self-proving: if a guarantee breaks the expected error stops occurring, the directive goes unused, and tsc fails with TS2578. Each is paired with a narrowed positive so a merely unusable type cannot satisfy it. - Inert same-shape controls carry no directive and must compile clean. They prove the settings really are permissive enough to miss the marker form, and they pin the config: forcing strictNullChecks on errors on exactly those two lines and nothing else. Also corrects a claim in the ParseFailure docs that this package compiles with strictNullChecks off, which stopped being true, and records a measured consumer-visible caveat: where strictNullChecks is off, negative narrowing of a boolean discriminant does not fire at all, so `r.ok ? a : r.err` and the else of `if (r.ok)` leave the value un-narrowed. `=== false`, `=== true` and `in` narrow under both settings. eslint.config.js now covers test-consumer/. Measured before the change: the directory was linted only by the default rule set, not this repository's own block — a planted 255-character line reported no-unused-vars (a rule this repo turns off) and not max-len (a rule it turns on). After: max-len is reported. Adding tsconfig.consumer.json to parserOptions.project is load-bearing; without it the fixture is a parsing error. --- .github/copilot-instructions.md | 13 +- .../serialized-models.instructions.md | 48 +++ .github/instructions/tests.instructions.md | 47 ++- .github/workflows/nodejs.yml | 29 ++ eslint.config.js | 6 + lib/interface/schema.d.ts | 43 ++- package.json | 1 + src/interface/schema.ts | 43 ++- .../interface/schema.consumer-types.ts | 291 ++++++++++++++++++ tsconfig.consumer.json | 59 ++++ 10 files changed, 556 insertions(+), 24 deletions(-) create mode 100644 test-consumer/interface/schema.consumer-types.ts create mode 100644 tsconfig.consumer.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 75349b8..7eaddac 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -193,6 +193,7 @@ Any update to the root `README.MD` must: | Compile only | `npm run compile` (`tsc -p ./tsconfig.json`) | | Test (CI mode) | `npm test` (`vitest run`) | | **Typecheck (required)** | `npm run typecheck` (`tsc -p ./tsconfig.test.json`) | +| **Consumer-conditions typecheck (required)** | `npm run typecheck:consumer` (`tsc -p ./tsconfig.consumer.json`) — build first | | Test (direct / watch / coverage) | `npx vitest run` · `npx vitest` · `npx vitest run --coverage` | | Private-marker check | `./.github/scripts/check-private-markers.sh` | | Build-output drift check | `npm run build && git status --porcelain -- lib/` (must be empty) | @@ -204,6 +205,16 @@ Any update to the root `README.MD` must: > is **not** the gate — Vitest's `typecheck.include` defaults to `**/*.test-d.ts`, and this repo > has none, so it checks zero files and always reports "no errors". +> **`npm run typecheck:consumer` is a third, non-overlapping gate.** The two above run under *this* +> package's settings, where `strict`, `strictNullChecks` and `noImplicitAny` are all on. Consumers +> need not set any of them, and a type-level guarantee can hold under one null-checking setting and +> be completely inert under the other — a failure branch marked `data?: undefined` errors correctly +> here and compiles clean where `strictNullChecks` is off. This gate compiles fixtures in +> `test-consumer/` against the **built `lib/*.d.ts`**, through the package's own `exports` map, with +> those flags off. Run `npm run build` first; it reads compiled output, not `src/`. The rule it +> enforces is in `.github/instructions/serialized-models.instructions.md` §8. + > **CI gate:** `.github/workflows/nodejs.yml` runs on `push`/`pull_request` to `main` across Node > `22.x` and `24.x`, executing `npm ci` → `npm run build` → build-output drift check → -> private-marker check → `npm test` → `npm run typecheck`. Changes must keep all of these green. +> private-marker check → `npm test` → `npm run typecheck` → `npm run typecheck:consumer`. Changes +> must keep all of these green. diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index 5e56df5..170cd0f 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -126,3 +126,51 @@ Schemas are the intended resolution to §3 and §4, so they will arrive. When th passes review and never runs.** - Test the rejection path, not only the happy path, and positive-control it: a schema test that only asserts a valid object parses passes identically whether the schema is strict or wide open. + +--- + +## 8. A type-level guarantee must not depend on a compiler flag the consumer might not set + +This package compiles with `strict`, `strictNullChecks` and `noImplicitAny` all on. **Consumers +need not**, and the same declaration can enforce something here and enforce nothing for them. + +The canonical pair — identical in intent, not in effect: + +```ts +// ❌ Rests on NULL-CHECKING. Inert wherever strictNullChecks is off: +// `T | undefined` reduces to `T`, the marker vanishes, and the unguarded +// read compiles clean and throws at runtime. +interface Failure { success: false; data?: undefined } + +// ✅ Rests on PROPERTY EXISTENCE. `Property 'data' does not exist` fires +// under every setting. +interface Failure { success: false } +``` + +**Prefer the construction that holds either way.** Omitting a property beats marking it +`?: undefined`; a required discriminant beats an optional one; `unknown` beats `any` regardless of +flags. When you must depend on a flag, say so in the JSDoc so the next reader knows the guarantee +has a precondition they do not control. + +The trap is not the rule, it is that **nothing in a strict repository can show you the difference**. +The strict gate passes identically for both shapes above, so the precondition — "the consumer +shares our settings" — stays unspoken until it silently stops being true. + +`npm run typecheck:consumer` is what closes that. It compiles fixtures in `test-consumer/` against +the **built `lib/*.d.ts`**, reached through the package's own `exports` map, with `strictNullChecks` +and `noImplicitAny` **off**. Add a case there whenever you add a type-level guarantee: + +- express the negative with `@ts-expect-error` **plus a description** — if the guarantee breaks, the + expected error stops occurring, the directive goes unused, and the compile fails with `TS2578`; +- pair it with the narrowed positive, so a type that is merely unusable cannot satisfy the negative; +- keep the inert same-shape control that carries no directive and must compile clean. It is the + proof the settings are genuinely permissive, and it makes the config self-pinning: restore + strictness and the control errors rather than quietly turning the gate into a copy of the strict + one. + +One consumer-visible consequence worth knowing, measured rather than assumed: where +`strictNullChecks` is off, **negative narrowing of a boolean discriminant does not fire** — every +type includes `undefined` there, so the truthy branch cannot be excluded. `r.ok ? … : r.err` and +`if (r.ok) {} else { … }` leave the value un-narrowed for such a consumer; `r.ok === false`, +`r.ok === true` and `in` narrow under both settings. Design discriminated results so the failure +branch is reachable with the explicit comparison, and say so in the JSDoc. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 0a13e8c..5aa605e 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -1,6 +1,6 @@ --- description: Vitest conventions, positive controls, and the limits of a runtime suite over erased types. -applyTo: "test/**/*.ts,vitest.config.ts,tsconfig.test.json" +applyTo: "test/**/*.ts,test-consumer/**/*.ts,vitest.config.ts,tsconfig.test.json,tsconfig.consumer.json" --- # Testing Instructions — `@furcata/core-node` @@ -54,12 +54,17 @@ member's value produced **3 failed, exit 1**. | Watch | `npx vitest` | | Coverage | `npx vitest run --coverage` | | **Type-level check (required)** | `npm run typecheck` → `tsc -p ./tsconfig.test.json` | +| **Consumer-conditions check (required)** | `npm run typecheck:consumer` → `tsc -p ./tsconfig.consumer.json` | > ⚠️ `npx vitest run --typecheck` is **not** the type gate. Vitest's `typecheck.include` defaults > to `**/*.test-d.ts`, and this repository has no such files, so it type-checks **zero files** and > reports "no errors" no matter what is broken. Verified: with an interface field deleted it still > reported `Type Errors no errors` and exited `0`. Use `npm run typecheck`. +> ⚠️ `npm run typecheck:consumer` reads the **built** `lib/*.d.ts`, so run `npm run build` first. +> Against a stale `lib/` it reports on declarations that no longer match `src/`. CI orders it after +> the build and the drift check for exactly that reason. + --- ## 3. Conventions @@ -123,3 +128,43 @@ When the thing under test is a type, assert against something with runtime exist asserting only the happy path is vacuous in the most dangerous way: it passes identically whether the schema is strict or wide open. Always include the rejection case, and positive-control it by confirming the valid case still parses. + +--- + +## 6. The third gate: `test-consumer/` + +`npm test` proves runtime values. `npm run typecheck` proves the shapes **under this package's own +compiler settings**. Neither can see how a published declaration behaves for a consumer who +compiles more permissively — and a type-level guarantee can hold under one null-checking setting +and be completely inert under the other. + +`test-consumer/` closes that. It is not a Vitest suite and it is never executed: the compile *is* +the test. `tsconfig.consumer.json` compiles it against the **built `lib/*.d.ts`**, reached through +the package's own `exports` map, with `strictNullChecks` and `noImplicitAny` **off**. + +Conventions, which differ from `test/`: + +- **Mirror the source path** as elsewhere: `src/interface/schema.ts` → + `test-consumer/interface/schema.consumer-types.ts`. The `.consumer-types.ts` suffix keeps the + files out of Vitest's collection globs. +- **Import by package name**, not by relative path: + `import {type ParseResult} from '@furcata/core-node/interface';`. The self-name import resolves + through `exports` to the shipped declaration. A relative import of `src/` would test a file + consumers never receive. Verified with `tsc --listFiles`: only `lib/interface/*.d.ts` and the + fixture are compiled, no file from `src/`. +- **Negative cases use `@ts-expect-error` with a description.** That makes them self-proving — if + the guarantee breaks the expected error disappears, the directive goes unused, and `tsc` fails + with `TS2578`. It fails when the guarantee breaks *and* when it stops being tested. +- **Every negative is paired with a narrowed positive**, so a type that is merely unusable cannot + satisfy the negative. +- **Keep the inert same-shape controls.** They carry no directive and must compile clean; they are + the proof the settings are genuinely permissive, and they make the config self-pinning. +- **Fixtures must be obviously synthetic.** This repository is public. + +Mutation-validated, as §4 requires: reintroducing `data?: undefined` on the parse failure branch, +rebuilding, and re-running both gates turns `npm run typecheck:consumer` **red** (`TS2578`) while +`npm run typecheck` stays **green**. That divergence is the reason the gate exists — a gate that +never disagrees with an existing one is not adding a check, it is adding a duplicate. + +The rule this enforces is in +[`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c54f6df..99db650 100755 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -107,3 +107,32 @@ jobs: run: npm run typecheck env: CI: true + + # Neither gate above can observe how these types behave for a consumer + # that compiles more permissively than this package does. `npm run + # typecheck` uses this repository's own settings, where `strict`, + # `strictNullChecks` and `noImplicitAny` are all on; a consumer need not + # set any of them. A type-level guarantee can hold under one + # null-checking setting and be completely inert under the other — a + # failure branch marked `data?: undefined` errors correctly here and + # compiles clean for a consumer with `strictNullChecks` off, because + # `T | undefined` reduces to `T` when the flag is off. The unspoken + # precondition of the strict gate is that the consumer shares our + # settings, and nothing tracked it. + # + # This step compiles fixtures against the BUILT `lib/*.d.ts` — reached + # through the package's own `exports` map, so it type-checks the + # declarations consumers actually receive rather than `src/` — with + # `strictNullChecks` and `noImplicitAny` off. It runs after the build and + # the drift check deliberately: the declarations it reads are only + # current because those two steps already passed. + # + # Positive-controlled, not assumed. Reintroducing `data?: undefined` on + # the failure branch, rebuilding, and re-running both gates turns THIS + # step red (TS2578, the expected error stopped occurring) while + # `npm run typecheck` stays green — which is the entire point, since a + # gate that never disagrees with an existing one adds nothing. + - name: Typecheck (published declarations under consumer conditions) + run: npm run typecheck:consumer + env: + CI: true diff --git a/eslint.config.js b/eslint.config.js index 61df12f..657b9ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,6 +45,7 @@ export default tseslint.config( project: [ './tsconfig.json', './tsconfig.test.json', + './tsconfig.consumer.json', ], jsDocParsingMode: 'type-info', ecmaVersion: 'latest', @@ -61,6 +62,11 @@ export default tseslint.config( files: [ 'src/**/*.ts', 'test/**/*.ts', + // Consumer-conditions type fixtures. Listed so the directory is linted + // rather than exempt: a path ESLint does not visit is a path a real + // problem can sit in unnoticed. Verified by planting a `no-dupe-keys` + // violation here and observing it reported. + 'test-consumer/**/*.ts', ], rules: { 'no-restricted-syntax': [ diff --git a/lib/interface/schema.d.ts b/lib/interface/schema.d.ts index 7501a67..4b65bc6 100644 --- a/lib/interface/schema.d.ts +++ b/lib/interface/schema.d.ts @@ -160,13 +160,17 @@ export interface ParseSuccess { * an optional `data?: undefined`. That distinction is load-bearing rather than * stylistic, and it was measured rather than assumed. * - * With `strictNullChecks: false`, which both this package and its consumers - * compile under, `undefined` is assignable to every type. So a sibling marker of - * the form `data?: undefined` **collapses**, and `result.data.amount` on an - * un-narrowed {@link ParseResult} compiles cleanly and throws `TypeError` at - * runtime. Omitting the property entirely produces `Property 'data' does not - * exist on type 'ParseFailure'` regardless of the null-checking setting, which - * is the only form of the guarantee that actually fires here. + * A sibling marker of the form `data?: undefined` **collapses** wherever + * `strictNullChecks` is off, because `T | undefined` reduces to `T` under that + * setting — so `result.data.amount` on an un-narrowed {@link ParseResult} + * compiles cleanly and throws `TypeError` at runtime. This package now compiles + * with `strictNullChecks` on, so the marker form would look correct here while + * protecting nothing for a consumer who leaves it off. Omitting the property + * entirely produces `Property 'data' does not exist on type 'ParseFailure'` + * regardless of the null-checking setting, which is the only form of the + * guarantee that fires either way. `npm run typecheck:consumer` compiles this + * package's built declarations under the permissive setting and is what keeps + * that true. * * The asymmetry with {@link ParseSuccess} is deliberate. Reading `.issues` off a * success yields `undefined` where a caller expected none to exist — wrong, but @@ -174,6 +178,15 @@ export interface ParseSuccess { * a failure yields an absent value typed as a valid document, which is the exact * defect this whole module exists to prevent. Only the dangerous direction is * closed. + * + * One consumer-visible caveat, measured rather than assumed: where + * `strictNullChecks` is off, **negative** narrowing of a boolean discriminant + * does not fire, because every type includes `undefined` there and so the + * success branch cannot be excluded by a falsy test. `r.success ? … : r.issues` + * and `if (r.success) {} else { … }` both leave the value un-narrowed for such a + * consumer. `r.success === false`, `r.success === true` and the `in` operator + * narrow under both settings; prefer the explicit comparison in code meant to be + * portable across consumers. */ export interface ParseFailure { /** @@ -514,10 +527,18 @@ export interface MemberMatch { * Failed narrowing: the value is not a member of the enumeration. * * There is deliberately **no `member` property on this branch at all**, for the - * same measured reason as {@link ParseFailure}: under `strictNullChecks: false` - * a sibling `member?: undefined` marker collapses, and reading `.member` off an - * un-narrowed result would compile cleanly. Omitting it makes the unhandled case - * a compile error regardless of the null-checking setting. + * same measured reason as {@link ParseFailure}: wherever `strictNullChecks` is + * off a sibling `member?: undefined` marker collapses, and reading `.member` off + * an un-narrowed result would compile cleanly. Omitting it makes the unhandled + * case a compile error regardless of the null-checking setting, which + * `npm run typecheck:consumer` verifies against the built declarations. + * + * Reaching {@link MemberMiss.value} needs the explicit form. Unlike + * {@link ParseSuccess}, {@link MemberMatch} declares no mirroring + * `value?: undefined`, so there is no second route to the property when negative + * narrowing does not fire — and it does not fire for a consumer with + * `strictNullChecks` off. Write `result.matched === false`, not + * `!result.matched` and not the `else` of `if (result.matched)`. */ export interface MemberMiss { /** diff --git a/package.json b/package.json index 200d696..2ae8e4a 100755 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "scripts": { "test": "vitest run", "typecheck": "tsc -p ./tsconfig.test.json", + "typecheck:consumer": "tsc -p ./tsconfig.consumer.json", "build": "npm run clear && npm run lint && npm run compile", "build:watch": "npm run clear && npm run lint && npm run compile:watch", "clear": "rm -rf ./lib", diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 6e7da81..3da3dae 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -173,13 +173,17 @@ export interface ParseSuccess { * an optional `data?: undefined`. That distinction is load-bearing rather than * stylistic, and it was measured rather than assumed. * - * With `strictNullChecks: false`, which both this package and its consumers - * compile under, `undefined` is assignable to every type. So a sibling marker of - * the form `data?: undefined` **collapses**, and `result.data.amount` on an - * un-narrowed {@link ParseResult} compiles cleanly and throws `TypeError` at - * runtime. Omitting the property entirely produces `Property 'data' does not - * exist on type 'ParseFailure'` regardless of the null-checking setting, which - * is the only form of the guarantee that actually fires here. + * A sibling marker of the form `data?: undefined` **collapses** wherever + * `strictNullChecks` is off, because `T | undefined` reduces to `T` under that + * setting — so `result.data.amount` on an un-narrowed {@link ParseResult} + * compiles cleanly and throws `TypeError` at runtime. This package now compiles + * with `strictNullChecks` on, so the marker form would look correct here while + * protecting nothing for a consumer who leaves it off. Omitting the property + * entirely produces `Property 'data' does not exist on type 'ParseFailure'` + * regardless of the null-checking setting, which is the only form of the + * guarantee that fires either way. `npm run typecheck:consumer` compiles this + * package's built declarations under the permissive setting and is what keeps + * that true. * * The asymmetry with {@link ParseSuccess} is deliberate. Reading `.issues` off a * success yields `undefined` where a caller expected none to exist — wrong, but @@ -187,6 +191,15 @@ export interface ParseSuccess { * a failure yields an absent value typed as a valid document, which is the exact * defect this whole module exists to prevent. Only the dangerous direction is * closed. + * + * One consumer-visible caveat, measured rather than assumed: where + * `strictNullChecks` is off, **negative** narrowing of a boolean discriminant + * does not fire, because every type includes `undefined` there and so the + * success branch cannot be excluded by a falsy test. `r.success ? … : r.issues` + * and `if (r.success) {} else { … }` both leave the value un-narrowed for such a + * consumer. `r.success === false`, `r.success === true` and the `in` operator + * narrow under both settings; prefer the explicit comparison in code meant to be + * portable across consumers. */ export interface ParseFailure { /** @@ -612,10 +625,18 @@ export interface MemberMatch { * Failed narrowing: the value is not a member of the enumeration. * * There is deliberately **no `member` property on this branch at all**, for the - * same measured reason as {@link ParseFailure}: under `strictNullChecks: false` - * a sibling `member?: undefined` marker collapses, and reading `.member` off an - * un-narrowed result would compile cleanly. Omitting it makes the unhandled case - * a compile error regardless of the null-checking setting. + * same measured reason as {@link ParseFailure}: wherever `strictNullChecks` is + * off a sibling `member?: undefined` marker collapses, and reading `.member` off + * an un-narrowed result would compile cleanly. Omitting it makes the unhandled + * case a compile error regardless of the null-checking setting, which + * `npm run typecheck:consumer` verifies against the built declarations. + * + * Reaching {@link MemberMiss.value} needs the explicit form. Unlike + * {@link ParseSuccess}, {@link MemberMatch} declares no mirroring + * `value?: undefined`, so there is no second route to the property when negative + * narrowing does not fire — and it does not fire for a consumer with + * `strictNullChecks` off. Write `result.matched === false`, not + * `!result.matched` and not the `else` of `if (result.matched)`. */ export interface MemberMiss { /** diff --git a/test-consumer/interface/schema.consumer-types.ts b/test-consumer/interface/schema.consumer-types.ts new file mode 100644 index 0000000..7daabf1 --- /dev/null +++ b/test-consumer/interface/schema.consumer-types.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * Consumer-conditions type test for `src/interface/schema.ts`. + * + * ## What this file is + * + * Not a Vitest suite. It has no assertions and it is never executed — the + * whole test *is* the compile, run by `npm run typecheck:consumer` against + * `tsconfig.consumer.json`. A pass means `tsc` reported zero errors; a failure + * means it reported at least one, or that a `@ts-expect-error` below stopped + * being needed. + * + * ## Why it exists + * + * This package compiles with `strict`, `strictNullChecks` and `noImplicitAny` + * all on. Consumers need not. A type-level guarantee can hold under one + * null-checking setting and be **completely inert** under the other, so a + * repository that only ever compiles under its own settings is structurally + * incapable of noticing that a guarantee it ships protects nobody. + * + * The two shapes that look interchangeable, and are not: + * + * ```ts + * // Inert for a consumer with strictNullChecks off. + * interface Failure { success: false; data?: undefined } + * + * // Holds either way. + * interface Failure { success: false } + * ``` + * + * The first rests on **null-checking**: `T | undefined` reduces to `T` when + * `strictNullChecks` is off, so the marker collapses and the unguarded read + * compiles cleanly, then throws at runtime. The second rests on **property + * existence** — `Property 'data' does not exist` — which fires under every + * setting. `ParseFailure` and `MemberMiss` are deliberately the second shape; + * this file is what keeps them that way. + * + * ## How each direction is covered + * + * - The `@ts-expect-error` directives carry descriptions, so they are subject + * to `ban-ts-comment` **and**, more importantly, they are self-proving: if a + * guarantee breaks, the expected error disappears, the directive becomes + * unused, and `tsc` fails with TS2578. The assertion therefore fails when + * the guarantee breaks *and* when it stops being tested. + * - The `INERT CONTROL` blocks are the same-shape known-positive. They carry + * **no** directive and must compile clean. They are the proof that these + * settings really are permissive enough to miss the inert form — without + * them, a passing `@ts-expect-error` above could be passing for some + * unrelated reason. They also pin the config: restore `strictNullChecks` + * here and those lines start erroring, so the gate cannot be quietly + * defanged into a duplicate of the strict one. + * + * ## Why the import is a self-name import + * + * `@furcata/core-node/interface` resolves through this package's own `exports` + * map to the built `lib/interface/index.d.ts` — the declaration a consumer + * receives — rather than to `src/`. Compiling `src/` here would test a file + * that is not the published contract. + */ + +import {z} from 'zod'; +import { + matchMember, + parseResult, + type MemberResult, + type ParseResult, +} from '@furcata/core-node/interface'; + +/** + * Synthetic document shape. Deliberately meaningless: a fixture that looks + * like a real record is a leak in a public repository. + */ +interface SyntheticDoc { + /** + * Arbitrary numeric field, present only so there is something to read. + */ + amount: number; +} + +/* ------------------------------------------------------------------------ * + * ParseResult — declared form + * ------------------------------------------------------------------------ */ + +declare const declaredParse: ParseResult; + +// @ts-expect-error ParseFailure has no `data` property at all, so reading it off an un-narrowed ParseResult must not compile even with strictNullChecks off. +const unguardedParseRead: number = declaredParse.data.amount; + +/** + * Narrowing on the discriminant must still reach the payload. Without this the + * negative case above could be satisfied by a type that is simply unusable. + */ +const guardedParseRead: number = declaredParse.success ? declaredParse.data.amount : 0; + +/** + * The failure branch must remain reachable and carry its issues. + * + * The explicit `=== false` is not a style choice. Measured under this project's + * settings: with `strictNullChecks` off, **negative** narrowing of a boolean + * discriminant does not happen. Because every type includes `null` and + * `undefined` when the flag is off, the success branch cannot be excluded by a + * falsy test, so `r.success ? … : r.issues` and `if (r.success) {} else { … }` + * both leave the value un-narrowed. `=== false`, `=== true` and the `in` + * operator narrow correctly under both settings; truthiness and `!` narrow only + * the positive branch when the flag is off. + * + * That is a property of the consumer's compiler rather than of this package's + * types, so it is not asserted as a guarantee here — a future TypeScript could + * legitimately change it. It is written the portable way and recorded, because + * a caller who follows the "narrow on the discriminant" advice with the obvious + * `else` gets a compile error that the advice does not predict. + */ +const guardedIssuesRead: number = declaredParse.success === false ? declaredParse.issues.length : 0; + +/** + * Documented asymmetry: `ParseSuccess` declares `issues?: undefined`, so the + * key exists on both branches and an un-narrowed read is legal by design. This + * is only benign in this direction — a caller learns there are no issues, which + * is true. It doubles as a resolution check: if the self-name import silently + * resolved to something else, this would not compile. + */ +const unguardedIssuesRead = declaredParse.issues; + +/* ------------------------------------------------------------------------ * + * ParseResult — inferred form + * ------------------------------------------------------------------------ */ + +/** + * A consumer reaches the guarded type through the helper's return type rather + * than by naming it, so the inferred path is checked separately. A generic + * signature can lose a guarantee the alias still advertises. + */ +const syntheticShape = z.object({amount: z.number()}); + +const inferredParse = parseResult(syntheticShape, {amount: 1}, 'synthetic'); + +// @ts-expect-error The inferred return of parseResult carries the same closed failure branch, so the unguarded read must not compile here either. +const unguardedInferredRead: number = inferredParse.data.amount; + +const guardedInferredRead: number = inferredParse.success ? inferredParse.data.amount : 0; + +/* ------------------------------------------------------------------------ * + * MemberResult + * ------------------------------------------------------------------------ */ + +/** + * Synthetic enumeration standing in for a vocabulary owned elsewhere. + */ +const syntheticMembers = { + alpha: 'alpha', + beta: 'beta', +} as const; + +declare const declaredMember: MemberResult<'alpha' | 'beta'>; + +// @ts-expect-error MemberMiss has no `member` property at all, so reading it off an un-narrowed MemberResult must not compile even with strictNullChecks off. +const unguardedMemberRead: string = declaredMember.member; + +const guardedMemberRead: string = declaredMember.matched ? declaredMember.member : 'alpha'; + +/** + * The miss branch must remain reachable and carry the offending value. Same + * `=== false` reasoning as {@link guardedIssuesRead}, and here it is load-bearing + * rather than merely portable: `MemberMatch` declares no mirroring + * `value?: undefined`, so unlike `issues` on a parse result there is no second + * route to this property when negative narrowing does not fire. + */ +const guardedMissRead: unknown = declaredMember.matched === false ? declaredMember.value : undefined; + +const inferredMember = matchMember(syntheticMembers, 'alpha'); + +// @ts-expect-error The inferred return of matchMember carries the same closed miss branch, so the unguarded read must not compile here either. +const unguardedInferredMemberRead: string = inferredMember.member; + +const guardedInferredMemberRead: string = inferredMember.matched ? inferredMember.member : 'beta'; + +/* ------------------------------------------------------------------------ * + * INERT CONTROL — the shape that must never be used, proving these settings + * genuinely fail to catch it. + * ------------------------------------------------------------------------ */ + +/** + * Success branch of the inert mirror. + */ +interface InertSuccess { + /** + * Discriminant. + */ + success: true; + /** + * The payload. + */ + data: SyntheticDoc; +} + +/** + * Failure branch written the wrong way: the sibling marker `data?: undefined` + * instead of omitting the property. + */ +interface InertFailure { + /** + * Discriminant. + */ + success: false; + /** + * The marker that collapses. Declared `undefined`, which reduces away + * entirely when `strictNullChecks` is off, leaving the union's `data` + * indistinguishable from the success branch's. + */ + data?: undefined; +} + +/** + * The inert union. Structurally identical to a correctly closed result apart + * from that one marker. + */ +type InertResult = InertSuccess | InertFailure; + +declare const inertResult: InertResult; + +/** + * No `@ts-expect-error` on the next line, deliberately. + * + * Under these consumer settings this read **must compile**, which is the whole + * point: the marker guarantee is worth nothing here. If this line ever starts + * erroring, `tsc` fails and the message is not "the code regressed" but "this + * config is no longer permissive, so the assertions above prove less than they + * claim" — the control has failed, and a control that cannot be observed + * failing is not a control. + */ +const inertUnguardedRead: number = inertResult.data.amount; + +/** + * Match branch of the inert mirror. + */ +interface InertMatch { + /** + * Discriminant. + */ + matched: true; + /** + * The narrowed member. + */ + member: 'alpha' | 'beta'; +} + +/** + * Miss branch of the inert mirror, for the member-narrowing equivalent. + */ +interface InertMiss { + /** + * Discriminant. + */ + matched: false; + /** + * The marker that collapses, as above. + */ + member?: undefined; +} + +declare const inertMember: InertMatch | InertMiss; + +/** + * Same control, same reasoning: this must compile under consumer settings. + */ +const inertUnguardedMemberRead: string = inertMember.member; + +/** + * Every binding above is referenced here so that none of them can be dropped + * as unused by a future tool, and so the file has an export and is a module. + * The array is never evaluated; this project compiles with `noEmit`. + */ +export const consumerConditionsChecked: unknown[] = [ + unguardedParseRead, + guardedParseRead, + guardedIssuesRead, + unguardedIssuesRead, + unguardedInferredRead, + guardedInferredRead, + unguardedMemberRead, + guardedMemberRead, + guardedMissRead, + unguardedInferredMemberRead, + guardedInferredMemberRead, + inertUnguardedRead, + inertUnguardedMemberRead, +]; diff --git a/tsconfig.consumer.json b/tsconfig.consumer.json new file mode 100644 index 0000000..a80a194 --- /dev/null +++ b/tsconfig.consumer.json @@ -0,0 +1,59 @@ +{ + // Consumer-conditions type gate. + // + // This project deliberately does NOT extend ./tsconfig.json. It is not a + // variant of this package's own build; it is a stand-in for a CONSUMER's + // compiler, and a consumer inherits nothing from this file. Extending the + // strict base and switching two flags back off would leave every other + // strictness option silently coupled to ours, which is the coupling this + // gate exists to break. + // + // Two properties are load-bearing: + // + // 1. It compiles the fixtures in `test-consumer/` against the BUILT + // `lib/*.d.ts` — reached through the package's own `exports` map by + // self-name import — not against `src/`. The declarations are what a + // consumer actually receives; `src/` is not shipped as the type entry. + // + // 2. `strictNullChecks` and `noImplicitAny` are OFF. This package compiles + // with both ON, so its own gates can never observe how its published + // types behave for a consumer that leaves them off — and a type-level + // guarantee can hold under one setting and be completely inert under + // the other. See test-consumer/interface/schema.consumer-types.ts. + // + // `skipLibCheck` suppresses errors reported INSIDE .d.ts files only; errors + // in the fixtures that USE those declarations are still reported, which is + // what this gate asserts on. It is on because a permissive consumer's own + // dependency declarations are not this package's contract to police. + "compilerOptions": { + "lib": [ + "ES2020" + ], + "target": "ES2020", + "module": "Node16", + "moduleResolution": "Node16", + "strict": false, + "strictNullChecks": false, + "noImplicitAny": false, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "node" + ], + "typeRoots": [ + "node_modules/@types" + ] + }, + "include": [ + "test-consumer" + ], + "exclude": [ + "node_modules", + "src", + "test", + "lib" + ] +} From 2decd422fc3c8ecbc45f5b0267ca7b68ecfa1c31 Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 18:58:33 -0500 Subject: [PATCH 2/4] docs: correct the mutation claim after measuring it, and pin the shallow read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-cell experiment gives the wrong answer, and I had written it up as a success before running it. Reintroducing `data?: undefined` on the failure branch turns BOTH gates red, not only the new one: the existing assertions in test/interface/schema.test.ts read the shallow property `result.data`, and `Property 'data' does not exist` fires under every null-checking setting. The divergence is real but needs a 2x2 to see. Rewriting those reads as `result.data.amount` — the natural way to express the guarantee, and what it is actually about — leaves `npm run typecheck` green under the marker, because TS18048 keeps its directive used, while `npm run typecheck:consumer` reports TS2578. Control cell, same deep reads with the correct type, leaves both green, ruling out a simply-broken test. The identical 2x2 on `member?: undefined` and MemberMiss behaves the same and fails the fixture's other two directives, so all four have now been observed failing under the mutation they exist to catch. So the strict gate's coverage of this class is incidental to how one line was phrased; the consumer gate's is structural. That makes the shallow phrasing in test/interface/schema.test.ts load-bearing rather than lazy, and it looks exactly like something a reader would tidy up into a deep access. Documented in place, with the measurement, so the "improvement" is visibly a regression. Also drops a stale sentence there claiming this repository compiles with strictNullChecks off. Corrects the same overstated claim in the CI step comment and in tests.instructions.md, which now carries the 2x2 as a table. --- .../serialized-models.instructions.md | 6 +++++ .github/instructions/tests.instructions.md | 26 ++++++++++++++++--- .github/workflows/nodejs.yml | 17 ++++++++---- .../interface/schema.consumer-types.ts | 7 +++++ test/interface/schema.test.ts | 19 +++++++++++--- 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index 170cd0f..56fd907 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -162,6 +162,12 @@ and `noImplicitAny` **off**. Add a case there whenever you add a type-level guar - express the negative with `@ts-expect-error` **plus a description** — if the guarantee breaks, the expected error stops occurring, the directive goes unused, and the compile fails with `TS2578`; +- **in the strict gate (`test/`), read shallow, not deep, when the guarantee is property absence.** + `r.data` fails with `Property 'data' does not exist`, which fires under every setting; + `r.data.amount` fails with `TS18048` under strict, so a re-added marker keeps that directive used + and the strict gate stays green while protecting nobody. In the consumer fixture either form + works — it reads deep because that is the runtime hazard being modelled. Measured both ways; see + [`tests.instructions.md`](tests.instructions.md) §6; - pair it with the narrowed positive, so a type that is merely unusable cannot satisfy the negative; - keep the inert same-shape control that carries no directive and must compile clean. It is the proof the settings are genuinely permissive, and it makes the config self-pinning: restore diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 5aa605e..00fe767 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -161,10 +161,28 @@ Conventions, which differ from `test/`: the proof the settings are genuinely permissive, and they make the config self-pinning. - **Fixtures must be obviously synthetic.** This repository is public. -Mutation-validated, as §4 requires: reintroducing `data?: undefined` on the parse failure branch, -rebuilding, and re-running both gates turns `npm run typecheck:consumer` **red** (`TS2578`) while -`npm run typecheck` stays **green**. That divergence is the reason the gate exists — a gate that -never disagrees with an existing one is not adding a check, it is adding a duplicate. +Mutation-validated as §4 requires, with a 2×2 rather than a single cell — because the obvious +one-cell experiment gives the wrong answer and would have been reported as a success: + +| `ParseFailure` | strict assertion form in `test/` | `npm run typecheck` | `npm run typecheck:consumer` | +|---|---|---|---| +| property omitted (as shipped) | shallow `result.data` | green `0` | green `0` | +| `data?: undefined` | shallow `result.data` | **red `2`** | **red `2`** | +| property omitted (as shipped) | deep `result.data.amount` | green `0` | green `0` | +| `data?: undefined` | deep `result.data.amount` | **green `0`** | **red `2`** | + +Row 2 is why "reintroduce the marker and watch only the new gate fail" does not work here: the +existing assertions read the **shallow** property, and `Property 'data' does not exist` fires under +every setting, so the strict gate catches that mutation too. Row 4 is the divergence. Deepening the +read to `result.data.amount` — the natural way to write it, and what the guarantee is actually +about — leaves the strict gate green under the marker, because `TS18048` keeps its directive used. +Only the consumer gate reports `TS2578`. Row 3 is the control that rules out "the deep test is +simply broken". The identical 2×2 on `member?: undefined` and `MemberMiss` behaves the same way, +failing the fixture's other two directives. + +So the strict gate's coverage of this class is **incidental to how one line was phrased**; the +consumer gate's is structural. All four `@ts-expect-error` directives in the fixture have been +observed failing under the mutation they exist to catch — none of them is vacuous. The rule this enforces is in [`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 99db650..3cc081c 100755 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -127,11 +127,18 @@ jobs: # the drift check deliberately: the declarations it reads are only # current because those two steps already passed. # - # Positive-controlled, not assumed. Reintroducing `data?: undefined` on - # the failure branch, rebuilding, and re-running both gates turns THIS - # step red (TS2578, the expected error stopped occurring) while - # `npm run typecheck` stays green — which is the entire point, since a - # gate that never disagrees with an existing one adds nothing. + # Mutation-validated with a 2×2, because the obvious one-cell experiment + # gives the wrong answer. Reintroducing `data?: undefined` on the failure + # branch turns BOTH this step and `npm run typecheck` red — the existing + # assertions in test/interface/schema.test.ts read the SHALLOW property + # `result.data`, and a property-existence error fires under every setting. + # The strict gate's coverage there is incidental to how one line was + # phrased. Rewrite those reads as `result.data.amount`, which is the + # natural way to express the guarantee, and the marker mutation leaves + # `npm run typecheck` GREEN (TS18048 keeps its directive used) while this + # step goes RED with TS2578. Control cell: the same deep reads with the + # correct type leave both green. The same 2×2 on `member?: undefined` and + # MemberMiss behaves identically. That is the divergence this step is for. - name: Typecheck (published declarations under consumer conditions) run: npm run typecheck:consumer env: diff --git a/test-consumer/interface/schema.consumer-types.ts b/test-consumer/interface/schema.consumer-types.ts index 7daabf1..5ec6dd8 100644 --- a/test-consumer/interface/schema.consumer-types.ts +++ b/test-consumer/interface/schema.consumer-types.ts @@ -46,6 +46,13 @@ * guarantee breaks, the expected error disappears, the directive becomes * unused, and `tsc` fails with TS2578. The assertion therefore fails when * the guarantee breaks *and* when it stops being tested. + * - The reads below are **deep** (`.data.amount`, not `.data`), which is the + * runtime hazard being modelled and is what makes this file disagree with the + * strict gate. The mirror-image rule applies over in `test/`: the equivalent + * assertions there are deliberately **shallow**, because a deep read fails + * under strict with TS18048 even when the marker is back, which keeps that + * directive used and that gate green. Measured; see + * `.github/instructions/tests.instructions.md` §6 for the full 2×2. * - The `INERT CONTROL` blocks are the same-shape known-positive. They carry * **no** directive and must compile clean. They are the proof that these * settings really are permissive enough to miss the inert form — without diff --git a/test/interface/schema.test.ts b/test/interface/schema.test.ts index 3d592e2..a1948c9 100644 --- a/test/interface/schema.test.ts +++ b/test/interface/schema.test.ts @@ -746,10 +746,21 @@ describe('member narrowing', () => { * It is enforced by the existing `npm run typecheck` gate, which includes * `test/`, so no new tooling is involved. * - * The mechanism matters because the obvious alternative does not work here. - * Under this repository's `strictNullChecks: false` a `T | undefined` return - * type collapses to `T`, so the unhandled case would compile cleanly; only the - * absence of the property from the other branch survives that setting. + * 🔴 **The reads below are deliberately shallow — `result.data`, not + * `result.data.amount` — and that is load-bearing rather than lazy.** A shallow + * read fails with `Property 'data' does not exist`, which fires under every + * null-checking setting. Deepening it to `result.data.amount` looks like a + * strictly better assertion and is measurably worse: re-add a `data?: undefined` + * marker and the deep form still errors here with `TS18048`, so the directive + * stays used and this gate stays **green** while the marker protects nobody who + * compiles with `strictNullChecks` off. Measured both ways; do not "improve" + * these lines. + * + * That hazard is why `npm run typecheck:consumer` exists. This gate runs under + * this repository's settings, where `strict`, `strictNullChecks` and + * `noImplicitAny` are all on, so it cannot see whether a guarantee survives for + * a consumer who leaves them off. The consumer gate compiles the built + * declarations with those flags off and catches exactly the case above. */ describe('compile-time guarantees', () => { it('should make an un-narrowed data access a compile error', () => { From bcabe70e42a19a61bd144f939d75aa1de40afbce Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 19:08:56 -0500 Subject: [PATCH 3/4] test: split the consumer fixture so its positive control is observable Two corrections to the gate as first written. 1. The differential was not controlled. "Un-narrowed read failed to compile" is produced just as readily by a fixture that cannot compile at all, and both routes are live here, measured: TS5112, where passing files on the command line means tsconfig is never loaded and nothing is analysed at all (tsc --noEmit somefile.ts exits 1 here), and TS2307, where the exports map exposes only ./model and ./interface so a deep path does not resolve (exits 2, taking every read in the file with it). Either makes the un-narrowed AND narrowed reads fail together, which reads as a confirmed guarantee. The first version put both in one file, so only the whole-file exit code was observable. Split into schema.consumer-unguarded.ts (must error) and schema.consumer-guarded.ts (must compile), the latter compiled alone by tsconfig.consumer-control.json, which extends the gate's own project and overrides nothing but include, so their settings cannot drift. The two exit codes now read together: gate red + control green is a regressed guarantee, gate red + control red is a broken harness. Re-ran the mutation under the corrected three-observation protocol; results in tests.instructions.md 6.2. The project form also happens to be immune to TS5112, which is a reason it is required rather than merely tidier. Said so in the config, because otherwise someone simplifies it back to a file list. 2. @ts-expect-error is satisfied by ANY error, including the wrong one. When a type weakens, a deep read's error merely changes identity - TS2339 to TS18048 - the directive stays used, and the gate passes while protecting nobody. The assertion degrades from "the property is absent" to "the property is possibly undefined" and nothing can tell. Verified which error each of the four directives actually suppresses by stripping them: all four are TS2339. Written up as 6.1. Also promotes the narrowing finding into the JSDoc on ParseResult and MemberResult, with the measured conversion table, since it is a property of the consumer's compiler rather than of the shape and there is nowhere else a consumer could learn it. The bare form compiles, lints and tests green while removing the discrimination; a numeric payload field read off an un-narrowed result yields undefined, which does not throw but propagates as NaN or takes a default branch, so a failed result flows onward into a computation with the compiler's blessing. Comment-only: 51 insertions into lib/, zero deletions, every changed line inside a JSDoc block, no declaration touched. --- .github/copilot-instructions.md | 11 +- .../serialized-models.instructions.md | 35 +++-- .github/instructions/tests.instructions.md | 131 +++++++++++----- .github/workflows/nodejs.yml | 21 +++ lib/interface/schema.d.ts | 51 +++++++ package.json | 1 + src/interface/schema.ts | 51 +++++++ ...er-types.ts => schema.consumer-guarded.ts} | 135 +++++++--------- .../interface/schema.consumer-unguarded.ts | 144 ++++++++++++++++++ tsconfig.consumer-control.json | 31 ++++ 10 files changed, 477 insertions(+), 134 deletions(-) rename test-consumer/interface/{schema.consumer-types.ts => schema.consumer-guarded.ts} (55%) create mode 100644 test-consumer/interface/schema.consumer-unguarded.ts create mode 100644 tsconfig.consumer-control.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7eaddac..99949dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -194,6 +194,7 @@ Any update to the root `README.MD` must: | Test (CI mode) | `npm test` (`vitest run`) | | **Typecheck (required)** | `npm run typecheck` (`tsc -p ./tsconfig.test.json`) | | **Consumer-conditions typecheck (required)** | `npm run typecheck:consumer` (`tsc -p ./tsconfig.consumer.json`) — build first | +| **Consumer-conditions control (required)** | `npm run typecheck:consumer:control` (`tsc -p ./tsconfig.consumer-control.json`) — must always be `0` | | Test (direct / watch / coverage) | `npx vitest run` · `npx vitest` · `npx vitest run --coverage` | | Private-marker check | `./.github/scripts/check-private-markers.sh` | | Build-output drift check | `npm run build && git status --porcelain -- lib/` (must be empty) | @@ -213,8 +214,14 @@ Any update to the root `README.MD` must: > `test-consumer/` against the **built `lib/*.d.ts`**, through the package's own `exports` map, with > those flags off. Run `npm run build` first; it reads compiled output, not `src/`. The rule it > enforces is in `.github/instructions/serialized-models.instructions.md` §8. +> +> Read it together with `npm run typecheck:consumer:control`, which compiles only the fixture whose +> every line must compile. A negative assertion is evidence only if the harness works, and a fixture +> that cannot compile at all fails its un-narrowed *and* narrowed reads alike — which reads as a +> confirmed guarantee. **Gate red + control green** means a guarantee regressed; **gate red + control +> red** means the harness broke and the gate proves nothing. > **CI gate:** `.github/workflows/nodejs.yml` runs on `push`/`pull_request` to `main` across Node > `22.x` and `24.x`, executing `npm ci` → `npm run build` → build-output drift check → -> private-marker check → `npm test` → `npm run typecheck` → `npm run typecheck:consumer`. Changes -> must keep all of these green. +> private-marker check → `npm test` → `npm run typecheck` → `npm run typecheck:consumer` → +> `npm run typecheck:consumer:control`. Changes must keep all of these green. diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index 56fd907..ba317db 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -156,9 +156,10 @@ The trap is not the rule, it is that **nothing in a strict repository can show y The strict gate passes identically for both shapes above, so the precondition — "the consumer shares our settings" — stays unspoken until it silently stops being true. -`npm run typecheck:consumer` is what closes that. It compiles fixtures in `test-consumer/` against -the **built `lib/*.d.ts`**, reached through the package's own `exports` map, with `strictNullChecks` -and `noImplicitAny` **off**. Add a case there whenever you add a type-level guarantee: +`npm run typecheck:consumer` is what closes that, with `npm run typecheck:consumer:control` as its +liveness proof. Both compile fixtures in `test-consumer/` against the **built `lib/*.d.ts`**, +reached through the package's own `exports` map, with `strictNullChecks` and `noImplicitAny` +**off**. Add a case there whenever you add a type-level guarantee: - express the negative with `@ts-expect-error` **plus a description** — if the guarantee breaks, the expected error stops occurring, the directive goes unused, and the compile fails with `TS2578`; @@ -174,9 +175,25 @@ and `noImplicitAny` **off**. Add a case there whenever you add a type-level guar strictness and the control errors rather than quietly turning the gate into a copy of the strict one. -One consumer-visible consequence worth knowing, measured rather than assumed: where -`strictNullChecks` is off, **negative narrowing of a boolean discriminant does not fire** — every -type includes `undefined` there, so the truthy branch cannot be excluded. `r.ok ? … : r.err` and -`if (r.ok) {} else { … }` leave the value un-narrowed for such a consumer; `r.ok === false`, -`r.ok === true` and `in` narrow under both settings. Design discriminated results so the failure -branch is reachable with the explicit comparison, and say so in the JSDoc. +### The narrowing consequence, which a consumer cannot see from the type + +Measured rather than assumed: where `strictNullChecks` is off, **negative narrowing of a boolean +discriminant does not fire at all** — every type includes `undefined` there, so the truthy branch +cannot be excluded. `r.ok ? … : r.err` and `if (r.ok) {} else { … }` leave the value un-narrowed +for such a consumer; `r.ok === false`, `r.ok === true` and `in` narrow under both settings. + +Treat this as a design constraint, not trivia. The bare form **compiles, lints and tests green**; +what it silently removes is the discrimination the discriminated result exists to provide. Reading +a missing field does not throw either — a numeric payload field read off an un-narrowed result +yields `undefined`, which propagates as `NaN` or takes a default branch, so a *failed* result can +flow onward into a computation with the compiler's blessing. That is the defect a parse boundary is +built to remove, reintroduced by the idiomatic spelling. + +So: design discriminated results so the failure branch is reachable with the explicit comparison, +and **say so in the JSDoc on the type itself** — with the conversion table, as `ParseResult` and +`MemberResult` now carry. It is a property of the consumer's compiler rather than of the shape, so +there is nowhere else a consumer could learn it. + +Do not, however, *assert* it in `test-consumer/`. It is the consumer's compiler, not this package's +contract, and a future TypeScript could legitimately change it — an `@ts-expect-error` on it would +one day go red for a reason that is nobody's regression. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 00fe767..26bf9b6 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -138,51 +138,102 @@ compiler settings**. Neither can see how a published declaration behaves for a c compiles more permissively — and a type-level guarantee can hold under one null-checking setting and be completely inert under the other. -`test-consumer/` closes that. It is not a Vitest suite and it is never executed: the compile *is* -the test. `tsconfig.consumer.json` compiles it against the **built `lib/*.d.ts`**, reached through -the package's own `exports` map, with `strictNullChecks` and `noImplicitAny` **off**. - -Conventions, which differ from `test/`: - -- **Mirror the source path** as elsewhere: `src/interface/schema.ts` → - `test-consumer/interface/schema.consumer-types.ts`. The `.consumer-types.ts` suffix keeps the - files out of Vitest's collection globs. -- **Import by package name**, not by relative path: - `import {type ParseResult} from '@furcata/core-node/interface';`. The self-name import resolves - through `exports` to the shipped declaration. A relative import of `src/` would test a file - consumers never receive. Verified with `tsc --listFiles`: only `lib/interface/*.d.ts` and the - fixture are compiled, no file from `src/`. -- **Negative cases use `@ts-expect-error` with a description.** That makes them self-proving — if - the guarantee breaks the expected error disappears, the directive goes unused, and `tsc` fails - with `TS2578`. It fails when the guarantee breaks *and* when it stops being tested. -- **Every negative is paired with a narrowed positive**, so a type that is merely unusable cannot - satisfy the negative. -- **Keep the inert same-shape controls.** They carry no directive and must compile clean; they are - the proof the settings are genuinely permissive, and they make the config self-pinning. +`test-consumer/` closes that. It is not a Vitest suite and is never executed: the compile *is* the +test. Two projects, two jobs: + +| project | script | fixture | must | +|---|---|---|---| +| `tsconfig.consumer.json` | `npm run typecheck:consumer` | `*.consumer-unguarded.ts` (and everything else) | exit `0`, meaning every `@ts-expect-error` was needed | +| `tsconfig.consumer-control.json` | `npm run typecheck:consumer:control` | `*.consumer-guarded.ts` only | exit `0` **always** | + +Both compile against the **built `lib/*.d.ts`**, reached through the package's own `exports` map, +with `strictNullChecks` and `noImplicitAny` **off**. The control project `extends` the gate's own +project and overrides nothing but `include`, so their settings cannot drift apart. + +### Why there are two projects + +**A negative assertion is evidence only if the harness that produced it works.** "The un-narrowed +read failed to compile" is produced just as readily by a fixture that cannot compile *at all*, and +both ways of getting there are live in this repository — measured, not supposed: + +- **`TS5112`** — *"tsconfig.json is present but will not be loaded if files are specified on + commandline"* — fires **before any type analysis**. `tsc --noEmit somefile.ts` here exits `1` + with exactly that and checks nothing. This is why the gate is a `-p` project and must stay one; + `--ignoreConfig` is the other way out, and the project form needs no escape hatch. +- **`TS2307`** — the `exports` map exposes only `./model` and `./interface`, so a deep path like + `@furcata/core-node/lib/interface/schema.js` does not resolve. Exits `2`, and every read in the + file fails alike because the type is unresolvable. + +Either one makes the un-narrowed **and** narrowed reads fail together, which reads as a confirmed +guarantee. So the required evidence is **three observations, not two**, and the two exit codes are +read together: + +- gate red, control green → **a guarantee regressed.** Fix the type. +- gate red, control red → **the harness broke.** The gate proves nothing until it is repaired. + +### Conventions, which differ from `test/` + +- **Mirror the source path**, as elsewhere: `src/interface/schema.ts` → + `test-consumer/interface/schema.consumer-{guarded,unguarded}.ts`. The suffixes keep the files out + of Vitest's collection globs. +- **Import by package subpath**, never a relative path: + `import {type ParseResult} from '@furcata/core-node/interface';`. That resolves through `exports` + to the shipped declaration, and it is what real consumer code writes. Verified with + `tsc --listFiles`: only `lib/interface/*.d.ts` and the fixture compile, no file from `src/`. +- **Negative cases use `@ts-expect-error` with a description**, and each is answered by a narrowed + positive in the guarded file, so a type that is merely unusable cannot satisfy the negative. +- **Read shallow when the guarantee is property absence** — see §6.1, this is the subtle one. +- **Keep the inert same-shape controls.** They carry no directive and must compile clean. They are + what makes the config self-pinning: a permissive gate's failure mode is quietly **becoming a + duplicate of the gate it was meant to complement**, and two green gates look exactly like two + passes. A control that breaks when the config drifts strict makes that divergence + self-announcing. - **Fixtures must be obviously synthetic.** This repository is public. -Mutation-validated as §4 requires, with a 2×2 rather than a single cell — because the obvious -one-cell experiment gives the wrong answer and would have been reported as a success: +### 6.1 `@ts-expect-error` is satisfied by *any* error, including the wrong one -| `ParseFailure` | strict assertion form in `test/` | `npm run typecheck` | `npm run typecheck:consumer` | -|---|---|---|---| -| property omitted (as shipped) | shallow `result.data` | green `0` | green `0` | -| `data?: undefined` | shallow `result.data` | **red `2`** | **red `2`** | -| property omitted (as shipped) | deep `result.data.amount` | green `0` | green `0` | -| `data?: undefined` | deep `result.data.amount` | **green `0`** | **red `2`** | - -Row 2 is why "reintroduce the marker and watch only the new gate fail" does not work here: the -existing assertions read the **shallow** property, and `Property 'data' does not exist` fires under -every setting, so the strict gate catches that mutation too. Row 4 is the divergence. Deepening the -read to `result.data.amount` — the natural way to write it, and what the guarantee is actually -about — leaves the strict gate green under the marker, because `TS18048` keeps its directive used. -Only the consumer gate reports `TS2578`. Row 3 is the control that rules out "the deep test is -simply broken". The identical 2×2 on `member?: undefined` and `MemberMiss` behaves the same way, -failing the fixture's other two directives. +This is the trap one layer above the harness-liveness rule, and it is easy to walk into because the +wrong form looks like the better assertion. + +> When the guarantee is **property absence**, read **shallow** (`r.data`). A deep read +> (`r.data.amount`) admits a **substitute error**: as the type weakens, the error merely changes +> identity — `TS2339` → `TS18048` — the directive stays *used*, and the gate passes while protecting +> nobody. + +The assertion silently degrades from *"the property is absent"* to *"the property is possibly +undefined"*, and nothing can tell. Absences and failures are both cheap to manufacture, so neither +is a finish condition on its own: check **which** error you are suppressing, not merely that one +occurred. Verified for the four directives here by stripping them and reading the diagnostics — all +four are `TS2339`. + +The consumer fixture reads deep on purpose, because there `TS18048` cannot arise: with +`strictNullChecks` off there is no possibly-undefined error to substitute in, so a weakened type +produces no error at all and the directive goes unused. That asymmetry is the whole divergence. + +### 6.2 Mutation validation, measured + +Validated with a 2×2 rather than a single cell, because **the obvious one-cell experiment gives the +wrong answer and would have been reported as a success**: + +| `ParseFailure` | strict assertion form in `test/` | `npm run typecheck` | `typecheck:consumer` | `typecheck:consumer:control` | +|---|---|---|---|---| +| property omitted (as shipped) | shallow `result.data` | green `0` | green `0` | green `0` | +| `data?: undefined` | shallow `result.data` | **red `2`** | **red `2`** | green `0` | +| property omitted (as shipped) | deep `result.data.amount` | green `0` | green `0` | green `0` | +| `data?: undefined` | deep `result.data.amount` | **green `0`** | **red `2`** | green `0` | + +Row 2 is why "reintroduce the marker and watch only the new gate fail" does not work: the existing +assertions read the **shallow** property, and `Property 'data' does not exist` fires under every +setting, so the strict gate catches that mutation too. **Row 4 is the divergence** — and the control +column is what makes it evidence rather than a coincidence, since a dead harness would have shown +red there too. Row 3 rules out "the deep test is simply broken". The identical 2×2 on +`member?: undefined` and `MemberMiss` behaves the same way and fails the fixture's other two +directives. So the strict gate's coverage of this class is **incidental to how one line was phrased**; the -consumer gate's is structural. All four `@ts-expect-error` directives in the fixture have been -observed failing under the mutation they exist to catch — none of them is vacuous. +consumer gate's is structural. All four `@ts-expect-error` directives have been observed failing +under the mutation they exist to catch, each with the control green in the same state — none is +vacuous. The rule this enforces is in [`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 3cc081c..dc341b8 100755 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -143,3 +143,24 @@ jobs: run: npm run typecheck:consumer env: CI: true + + # Positive control for the step above, and not optional. + # + # A negative assertion is evidence only if the harness that produced it + # works. "The un-narrowed read failed to compile" is produced just as + # readily by a fixture that cannot compile at all, and both failure modes + # are live here: TS2307, because the `exports` map exposes only `./model` + # and `./interface` so a deep path does not resolve; and TS5112, because + # passing files on the command line errors before any type analysis + # happens. Both were measured. Both take the un-narrowed AND the narrowed + # reads down together, which reads as a confirmed guarantee. + # + # This step compiles only the fixture whose every line must compile, so + # the two exit codes can be read together: + # gate red, control green -> a guarantee regressed. Fix the type. + # gate red, control red -> the harness broke. The gate above proves + # nothing until it is repaired. + - name: Typecheck control (consumer harness liveness) + run: npm run typecheck:consumer:control + env: + CI: true diff --git a/lib/interface/schema.d.ts b/lib/interface/schema.d.ts index 4b65bc6..08ce1df 100644 --- a/lib/interface/schema.d.ts +++ b/lib/interface/schema.d.ts @@ -209,6 +209,43 @@ export interface ParseFailure { * Narrow on `success` to reach the data; there is no branch that offers both a * typed document and an unverified one. * + * ## 🔴 Narrow with `=== false`, not with `else` + * + * Write `result.success === false`, `result.success === true` or an `in` test. + * **Do not** rely on `result.success ? … : …` or the `else` of + * `if (result.success)` to reach the failure branch. + * + * This is not style. Where `strictNullChecks` is off — which this package no + * longer does but a consumer may — **negative narrowing of a boolean + * discriminant does not fire at all.** Every type includes `undefined` under + * that setting, so the success branch cannot be excluded by a falsy test and + * the value stays un-narrowed in the branch where it should have been a + * failure. Measured against this package's own built declarations; the + * conversion is: + * + * | form | `strictNullChecks` on | off | + * |---|---|---| + * | `r.success === false` | narrows | **narrows** | + * | `r.success === true` | narrows | **narrows** | + * | `'issues' in r` | narrows | **narrows** | + * | `r.success ? a : b` (false arm) | narrows | **does not narrow** | + * | `!r.success` | narrows | **does not narrow** | + * | `if (r.success) {} else {}` | narrows | **does not narrow** | + * + * The reason this is worth a warning rather than a footnote is the failure + * mode. The bare form **compiles, lints and tests green**; what it silently + * removes is the discrimination this whole module exists to provide. And + * reading a missing field does not throw — a numeric payload field read off an + * un-narrowed result yields `undefined`, which propagates as `NaN` or takes a + * default branch, so a *failed* parse can flow onward into a computation with + * the compiler's blessing. That is the exact defect the parse boundary was + * built to remove, reintroduced by the idiomatic spelling. + * + * A consumer cannot discover any of this from the shape of the type, which is + * why it is documented here rather than left to be found. `npm run + * typecheck:consumer` compiles the built declarations under the permissive + * setting so the portable form stays exercised. + * * @template T The interface the schema produces. */ export type ParseResult = ParseSuccess | ParseFailure; @@ -559,6 +596,20 @@ export interface MemberMiss { /** * Result of narrowing an untrusted value to a member of an enumeration. * + * ## 🔴 Narrow with `=== false`, not with `else` + * + * Write `result.matched === false` to reach {@link MemberMiss.value}. The + * `else` of `if (result.matched)` and the false arm of + * `result.matched ? … : …` **do not narrow** where `strictNullChecks` is off; + * see {@link ParseResult} for the measured conversion table and why the bare + * form compiles, lints and tests green while removing the discrimination. + * + * It bites harder here than on a parse result. {@link MemberMatch} declares no + * mirroring `value?: undefined`, so unlike `issues` on a parse result there is + * no second route to the property — the un-narrowed read is a compile error + * rather than a silently wrong value, which is the better of the two failures + * but still surprises a caller who followed the obvious spelling. + * * @template TMember The enumeration's member type. */ export type MemberResult = MemberMatch | MemberMiss; diff --git a/package.json b/package.json index 2ae8e4a..5681d87 100755 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test": "vitest run", "typecheck": "tsc -p ./tsconfig.test.json", "typecheck:consumer": "tsc -p ./tsconfig.consumer.json", + "typecheck:consumer:control": "tsc -p ./tsconfig.consumer-control.json", "build": "npm run clear && npm run lint && npm run compile", "build:watch": "npm run clear && npm run lint && npm run compile:watch", "clear": "rm -rf ./lib", diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 3da3dae..d02b26a 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -223,6 +223,43 @@ export interface ParseFailure { * Narrow on `success` to reach the data; there is no branch that offers both a * typed document and an unverified one. * + * ## 🔴 Narrow with `=== false`, not with `else` + * + * Write `result.success === false`, `result.success === true` or an `in` test. + * **Do not** rely on `result.success ? … : …` or the `else` of + * `if (result.success)` to reach the failure branch. + * + * This is not style. Where `strictNullChecks` is off — which this package no + * longer does but a consumer may — **negative narrowing of a boolean + * discriminant does not fire at all.** Every type includes `undefined` under + * that setting, so the success branch cannot be excluded by a falsy test and + * the value stays un-narrowed in the branch where it should have been a + * failure. Measured against this package's own built declarations; the + * conversion is: + * + * | form | `strictNullChecks` on | off | + * |---|---|---| + * | `r.success === false` | narrows | **narrows** | + * | `r.success === true` | narrows | **narrows** | + * | `'issues' in r` | narrows | **narrows** | + * | `r.success ? a : b` (false arm) | narrows | **does not narrow** | + * | `!r.success` | narrows | **does not narrow** | + * | `if (r.success) {} else {}` | narrows | **does not narrow** | + * + * The reason this is worth a warning rather than a footnote is the failure + * mode. The bare form **compiles, lints and tests green**; what it silently + * removes is the discrimination this whole module exists to provide. And + * reading a missing field does not throw — a numeric payload field read off an + * un-narrowed result yields `undefined`, which propagates as `NaN` or takes a + * default branch, so a *failed* parse can flow onward into a computation with + * the compiler's blessing. That is the exact defect the parse boundary was + * built to remove, reintroduced by the idiomatic spelling. + * + * A consumer cannot discover any of this from the shape of the type, which is + * why it is documented here rather than left to be found. `npm run + * typecheck:consumer` compiles the built declarations under the permissive + * setting so the portable form stays exercised. + * * @template T The interface the schema produces. */ export type ParseResult = ParseSuccess | ParseFailure; @@ -658,6 +695,20 @@ export interface MemberMiss { /** * Result of narrowing an untrusted value to a member of an enumeration. * + * ## 🔴 Narrow with `=== false`, not with `else` + * + * Write `result.matched === false` to reach {@link MemberMiss.value}. The + * `else` of `if (result.matched)` and the false arm of + * `result.matched ? … : …` **do not narrow** where `strictNullChecks` is off; + * see {@link ParseResult} for the measured conversion table and why the bare + * form compiles, lints and tests green while removing the discrimination. + * + * It bites harder here than on a parse result. {@link MemberMatch} declares no + * mirroring `value?: undefined`, so unlike `issues` on a parse result there is + * no second route to the property — the un-narrowed read is a compile error + * rather than a silently wrong value, which is the better of the two failures + * but still surprises a caller who followed the obvious spelling. + * * @template TMember The enumeration's member type. */ export type MemberResult = MemberMatch | MemberMiss; diff --git a/test-consumer/interface/schema.consumer-types.ts b/test-consumer/interface/schema.consumer-guarded.ts similarity index 55% rename from test-consumer/interface/schema.consumer-types.ts rename to test-consumer/interface/schema.consumer-guarded.ts index 5ec6dd8..9a40d3e 100644 --- a/test-consumer/interface/schema.consumer-types.ts +++ b/test-consumer/interface/schema.consumer-guarded.ts @@ -4,69 +4,56 @@ */ /** - * Consumer-conditions type test for `src/interface/schema.ts`. + * Consumer-conditions **positive control** for `src/interface/schema.ts`. * - * ## What this file is + * ## What this file is for * - * Not a Vitest suite. It has no assertions and it is never executed — the - * whole test *is* the compile, run by `npm run typecheck:consumer` against - * `tsconfig.consumer.json`. A pass means `tsc` reported zero errors; a failure - * means it reported at least one, or that a `@ts-expect-error` below stopped - * being needed. + * Everything here **must compile**. It is the liveness proof for its sibling + * `schema.consumer-unguarded.ts`, which asserts the opposite — that certain + * reads must *not* compile. * - * ## Why it exists + * ## Why a separate file with its own project * - * This package compiles with `strict`, `strictNullChecks` and `noImplicitAny` - * all on. Consumers need not. A type-level guarantee can hold under one - * null-checking setting and be **completely inert** under the other, so a - * repository that only ever compiles under its own settings is structurally - * incapable of noticing that a guarantee it ships protects nobody. + * A negative assertion is only evidence if the harness that produced it works. + * "The un-narrowed read failed to compile" is produced just as readily by a + * fixture that cannot compile *at all* — an unresolved import, a bad flag, a + * config that was never loaded. Every one of those makes the un-narrowed **and** + * narrowed reads fail together, which reads as a confirmed guarantee and is in + * fact a dead harness. * - * The two shapes that look interchangeable, and are not: + * Both failure modes are live here and both were measured: * - * ```ts - * // Inert for a consumer with strictNullChecks off. - * interface Failure { success: false; data?: undefined } + * - `TS5112` — *"tsconfig.json is present but will not be loaded if files are + * specified on commandline"* — fires **before any type analysis**, so nothing + * is checked at all. Verified: `tsc --noEmit somefile.ts` in this repository + * exits `1` with exactly that error. **This is why the gate is a `-p` project + * rather than a file list**, and why it must stay one. `--ignoreConfig` is the + * other way out; the project form needs no escape hatch. + * - `TS2307` — the package's `exports` map exposes only `./model` and + * `./interface`, so a deep path such as + * `@furcata/core-node/lib/interface/schema.js` does not resolve. Verified: + * exits `2`, and every read in the file fails alike because the type is + * unresolvable. The subpath import used here is the one real consumer code + * uses, so the fixture exercises the genuine path. * - * // Holds either way. - * interface Failure { success: false } - * ``` + * `tsconfig.consumer-control.json` compiles **only this file**, so its exit code + * is observable on its own. The required evidence is three observations, not + * two: under the mutation the assertions file must go red **while this file + * still exits `0`**. Both red means the harness died and the run proves nothing. * - * The first rests on **null-checking**: `T | undefined` reduces to `T` when - * `strictNullChecks` is off, so the marker collapses and the unguarded read - * compiles cleanly, then throws at runtime. The second rests on **property - * existence** — `Property 'data' does not exist` — which fires under every - * setting. `ParseFailure` and `MemberMiss` are deliberately the second shape; - * this file is what keeps them that way. + * That project `extends` the gate's own `tsconfig.consumer.json` and overrides + * nothing but `include`, so the two cannot drift apart in compiler settings — + * a control compiled under different flags from the thing it controls is not a + * control. * - * ## How each direction is covered + * ## The inert controls * - * - The `@ts-expect-error` directives carry descriptions, so they are subject - * to `ban-ts-comment` **and**, more importantly, they are self-proving: if a - * guarantee breaks, the expected error disappears, the directive becomes - * unused, and `tsc` fails with TS2578. The assertion therefore fails when - * the guarantee breaks *and* when it stops being tested. - * - The reads below are **deep** (`.data.amount`, not `.data`), which is the - * runtime hazard being modelled and is what makes this file disagree with the - * strict gate. The mirror-image rule applies over in `test/`: the equivalent - * assertions there are deliberately **shallow**, because a deep read fails - * under strict with TS18048 even when the marker is back, which keeps that - * directive used and that gate green. Measured; see - * `.github/instructions/tests.instructions.md` §6 for the full 2×2. - * - The `INERT CONTROL` blocks are the same-shape known-positive. They carry - * **no** directive and must compile clean. They are the proof that these - * settings really are permissive enough to miss the inert form — without - * them, a passing `@ts-expect-error` above could be passing for some - * unrelated reason. They also pin the config: restore `strictNullChecks` - * here and those lines start erroring, so the gate cannot be quietly - * defanged into a duplicate of the strict one. - * - * ## Why the import is a self-name import - * - * `@furcata/core-node/interface` resolves through this package's own `exports` - * map to the built `lib/interface/index.d.ts` — the declaration a consumer - * receives — rather than to `src/`. Compiling `src/` here would test a file - * that is not the published contract. + * The `INERT CONTROL` blocks at the bottom pin the settings themselves. They + * model the shape that must never be used — a sibling `data?: undefined` marker + * — and they must compile clean here, which is only true because + * `strictNullChecks` is off. Force it on and exactly those lines error. Without + * them a passing assertion next door could be passing because the gate had + * quietly become a duplicate of the strict one. */ import {z} from 'zod'; @@ -89,17 +76,16 @@ interface SyntheticDoc { } /* ------------------------------------------------------------------------ * - * ParseResult — declared form + * ParseResult — narrowed reads must reach the payload * ------------------------------------------------------------------------ */ declare const declaredParse: ParseResult; -// @ts-expect-error ParseFailure has no `data` property at all, so reading it off an un-narrowed ParseResult must not compile even with strictNullChecks off. -const unguardedParseRead: number = declaredParse.data.amount; - /** - * Narrowing on the discriminant must still reach the payload. Without this the - * negative case above could be satisfied by a type that is simply unusable. + * The narrowed read. This is the observation that proves the harness is alive: + * it must exit `0` both with the guarantee intact and with it deliberately + * broken, so that the difference the assertions file reports is attributable to + * the type rather than to the fixture. */ const guardedParseRead: number = declaredParse.success ? declaredParse.data.amount : 0; @@ -116,7 +102,7 @@ const guardedParseRead: number = declaredParse.success ? declaredParse.data.amou * the positive branch when the flag is off. * * That is a property of the consumer's compiler rather than of this package's - * types, so it is not asserted as a guarantee here — a future TypeScript could + * types, so it is not asserted as a guarantee — a future TypeScript could * legitimately change it. It is written the portable way and recorded, because * a caller who follows the "narrow on the discriminant" advice with the obvious * `else` gets a compile error that the advice does not predict. @@ -127,15 +113,11 @@ const guardedIssuesRead: number = declaredParse.success === false ? declaredPars * Documented asymmetry: `ParseSuccess` declares `issues?: undefined`, so the * key exists on both branches and an un-narrowed read is legal by design. This * is only benign in this direction — a caller learns there are no issues, which - * is true. It doubles as a resolution check: if the self-name import silently + * is true. It doubles as a resolution check: if the subpath import silently * resolved to something else, this would not compile. */ const unguardedIssuesRead = declaredParse.issues; -/* ------------------------------------------------------------------------ * - * ParseResult — inferred form - * ------------------------------------------------------------------------ */ - /** * A consumer reaches the guarded type through the helper's return type rather * than by naming it, so the inferred path is checked separately. A generic @@ -145,13 +127,10 @@ const syntheticShape = z.object({amount: z.number()}); const inferredParse = parseResult(syntheticShape, {amount: 1}, 'synthetic'); -// @ts-expect-error The inferred return of parseResult carries the same closed failure branch, so the unguarded read must not compile here either. -const unguardedInferredRead: number = inferredParse.data.amount; - const guardedInferredRead: number = inferredParse.success ? inferredParse.data.amount : 0; /* ------------------------------------------------------------------------ * - * MemberResult + * MemberResult — narrowed reads must reach the member * ------------------------------------------------------------------------ */ /** @@ -164,9 +143,6 @@ const syntheticMembers = { declare const declaredMember: MemberResult<'alpha' | 'beta'>; -// @ts-expect-error MemberMiss has no `member` property at all, so reading it off an un-narrowed MemberResult must not compile even with strictNullChecks off. -const unguardedMemberRead: string = declaredMember.member; - const guardedMemberRead: string = declaredMember.matched ? declaredMember.member : 'alpha'; /** @@ -180,9 +156,6 @@ const guardedMissRead: unknown = declaredMember.matched === false ? declaredMemb const inferredMember = matchMember(syntheticMembers, 'alpha'); -// @ts-expect-error The inferred return of matchMember carries the same closed miss branch, so the unguarded read must not compile here either. -const unguardedInferredMemberRead: string = inferredMember.member; - const guardedInferredMemberRead: string = inferredMember.matched ? inferredMember.member : 'beta'; /* ------------------------------------------------------------------------ * @@ -235,8 +208,8 @@ declare const inertResult: InertResult; * Under these consumer settings this read **must compile**, which is the whole * point: the marker guarantee is worth nothing here. If this line ever starts * erroring, `tsc` fails and the message is not "the code regressed" but "this - * config is no longer permissive, so the assertions above prove less than they - * claim" — the control has failed, and a control that cannot be observed + * config is no longer permissive, so the assertions next door prove less than + * they claim" — the control has failed, and a control that cannot be observed * failing is not a control. */ const inertUnguardedRead: number = inertResult.data.amount; @@ -279,19 +252,15 @@ const inertUnguardedMemberRead: string = inertMember.member; /** * Every binding above is referenced here so that none of them can be dropped * as unused by a future tool, and so the file has an export and is a module. - * The array is never evaluated; this project compiles with `noEmit`. + * The array is never evaluated; these projects compile with `noEmit`. */ -export const consumerConditionsChecked: unknown[] = [ - unguardedParseRead, +export const consumerControlChecked: unknown[] = [ guardedParseRead, guardedIssuesRead, unguardedIssuesRead, - unguardedInferredRead, guardedInferredRead, - unguardedMemberRead, guardedMemberRead, guardedMissRead, - unguardedInferredMemberRead, guardedInferredMemberRead, inertUnguardedRead, inertUnguardedMemberRead, diff --git a/test-consumer/interface/schema.consumer-unguarded.ts b/test-consumer/interface/schema.consumer-unguarded.ts new file mode 100644 index 0000000..b9abe5b --- /dev/null +++ b/test-consumer/interface/schema.consumer-unguarded.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * Consumer-conditions **assertions** for `src/interface/schema.ts`. + * + * ## What this file is + * + * Not a Vitest suite. It has no runtime assertions and is never executed — the + * compile *is* the test, run by `npm run typecheck:consumer` against + * `tsconfig.consumer.json`. Every read below **must be a compile error**. A + * pass means each `@ts-expect-error` was needed; a failure means one stopped + * being needed and `tsc` reported `TS2578`. + * + * Its positive control lives in `schema.consumer-guarded.ts`, which holds the + * narrowed reads that must always compile. Read that file's header for why the + * split exists — briefly: a negative result from a harness that cannot compile + * at all looks identical to a confirmed guarantee, so the narrowed reads must + * be observed exiting `0` in the same state that turns these red. + * + * ## Why it exists + * + * This package compiles with `strict`, `strictNullChecks` and `noImplicitAny` + * all on. Consumers need not. A type-level guarantee can hold under one + * null-checking setting and be **completely inert** under the other, so a + * repository that only ever compiles under its own settings is structurally + * incapable of noticing that a guarantee it ships protects nobody. + * + * The two shapes that look interchangeable, and are not: + * + * ```ts + * // Inert for a consumer with strictNullChecks off. + * interface Failure { success: false; data?: undefined } + * + * // Holds either way. + * interface Failure { success: false } + * ``` + * + * The first rests on **null-checking**: `T | undefined` reduces to `T` when + * `strictNullChecks` is off, so the marker collapses and the unguarded read + * compiles cleanly, then throws at runtime. The second rests on **property + * existence** — `Property 'data' does not exist` — which fires under every + * setting. `ParseFailure` and `MemberMiss` are deliberately the second shape; + * this file is what keeps them that way. Measured against the shipped + * declaration rather than a reproduction: the un-narrowed read below reports + * `TS2339`. + * + * ## Why the reads are deep + * + * `.data.amount`, not `.data`. The deep read is the runtime hazard being + * modelled, and it is what makes this file disagree with the strict gate. The + * mirror-image rule applies in `test/`: the equivalent assertions there are + * deliberately **shallow**, because a deep read still errors under strict with + * `TS18048` even once the marker is back, which keeps that directive used and + * that gate green. See `.github/instructions/tests.instructions.md` §6 for the + * full 2×2. + * + * ## Why the import is a package subpath + * + * `@furcata/core-node/interface` resolves through this package's own `exports` + * map to the built `lib/interface/index.d.ts` — the declaration a consumer + * receives — rather than to `src/`. Compiling `src/` here would test a file + * that is not the published contract, and `lib/` is what consumers execute, + * since it is committed and there is no `prepare` script. Deep paths such as + * `@furcata/core-node/lib/interface/schema.js` are **not** resolvable: the map + * exposes only `./model` and `./interface`, and a deep path fails with + * `TS2307`, taking every read in the file down with it. + */ + +import {z} from 'zod'; +import { + matchMember, + parseResult, + type MemberResult, + type ParseResult, +} from '@furcata/core-node/interface'; + +/** + * Synthetic document shape. Deliberately meaningless: a fixture that looks + * like a real record is a leak in a public repository. + */ +interface SyntheticDoc { + /** + * Arbitrary numeric field, present only so there is something to read. + */ + amount: number; +} + +/* ------------------------------------------------------------------------ * + * ParseResult — un-narrowed reads must not compile + * ------------------------------------------------------------------------ */ + +declare const declaredParse: ParseResult; + +// @ts-expect-error ParseFailure has no `data` property at all, so reading it off an un-narrowed ParseResult must not compile even with strictNullChecks off. +const unguardedParseRead: number = declaredParse.data.amount; + +/** + * A consumer reaches the guarded type through the helper's return type rather + * than by naming it, so the inferred path is asserted separately. A generic + * signature can lose a guarantee the alias still advertises. + */ +const syntheticShape = z.object({amount: z.number()}); + +const inferredParse = parseResult(syntheticShape, {amount: 1}, 'synthetic'); + +// @ts-expect-error The inferred return of parseResult carries the same closed failure branch, so the unguarded read must not compile here either. +const unguardedInferredRead: number = inferredParse.data.amount; + +/* ------------------------------------------------------------------------ * + * MemberResult — un-narrowed reads must not compile + * ------------------------------------------------------------------------ */ + +/** + * Synthetic enumeration standing in for a vocabulary owned elsewhere. + */ +const syntheticMembers = { + alpha: 'alpha', + beta: 'beta', +} as const; + +declare const declaredMember: MemberResult<'alpha' | 'beta'>; + +// @ts-expect-error MemberMiss has no `member` property at all, so reading it off an un-narrowed MemberResult must not compile even with strictNullChecks off. +const unguardedMemberRead: string = declaredMember.member.length ? 'x' : 'y'; + +const inferredMember = matchMember(syntheticMembers, 'alpha'); + +// @ts-expect-error The inferred return of matchMember carries the same closed miss branch, so the unguarded read must not compile here either. +const unguardedInferredMemberRead: string = inferredMember.member.length ? 'x' : 'y'; + +/** + * Every binding above is referenced here so that none of them can be dropped + * as unused by a future tool, and so the file has an export and is a module. + * The array is never evaluated; this project compiles with `noEmit`. + */ +export const consumerAssertionsChecked: unknown[] = [ + unguardedParseRead, + unguardedInferredRead, + unguardedMemberRead, + unguardedInferredMemberRead, +]; diff --git a/tsconfig.consumer-control.json b/tsconfig.consumer-control.json new file mode 100644 index 0000000..fd31307 --- /dev/null +++ b/tsconfig.consumer-control.json @@ -0,0 +1,31 @@ +{ + // Positive control for the consumer-conditions gate. + // + // Compiles ONLY test-consumer/interface/schema.consumer-guarded.ts, the file + // whose every line must compile. Its exit code is therefore observable + // separately from the assertions file next door, which is the whole reason + // this project exists. + // + // A negative assertion is evidence only if the harness that produced it + // works. "The un-narrowed read failed to compile" is produced just as + // readily by a fixture that cannot compile at all — an unresolved import + // (TS2307, measured: the exports map blocks deep paths), or a config that + // was never loaded (TS5112, measured: passing files on the command line + // errors before any type analysis). Both make the un-narrowed AND narrowed + // reads fail together, which reads as a confirmed guarantee and is a dead + // harness. So the required evidence is three observations, not two: under a + // mutation the assertions must go red WHILE this project still exits 0. + // + // Read the two exit codes together: + // gate red, control green -> a guarantee regressed. Fix the type. + // gate red, control red -> the harness broke. The gate proves nothing + // until it is repaired. + // + // It `extends` the gate's own project and overrides nothing but `include`, + // so the two cannot drift apart in compiler settings. A control compiled + // under different flags from the thing it controls is not a control. + "extends": "./tsconfig.consumer.json", + "include": [ + "test-consumer/interface/schema.consumer-guarded.ts" + ] +} From f5a041af160aca280cb45ecd957077e0a932ce0b Mon Sep 17 00:00:00 2001 From: Erny Sans Date: Sat, 22 Aug 2026 19:30:01 -0500 Subject: [PATCH 4/4] test: encode the gate's coverage boundary, which an index signature defeats The gate rests entirely on TS2339, "Property 'x' does not exist". Where a type carries an index signature that error cannot fire, so the gate is structurally unable to report anything about it: green means "cannot be checked", not "is safe", and the two are indistinguishable from outside. That is the failure shape this gate was built to detect, aimed at the gate itself. Measured on the shipped declarations: BaseFirestore declares [x: string]: any deliberately, so stored documents predating a change still type-check, and 10 declarations in lib/ extend it (control on a nonsense base name: 0). All are outside the gate's reach. Probed both arms in one run against the shipped types: a key declared nowhere is legal on Ledger.Interface and is TS2339 on Idempotency.Response, so the boundary runs between two types in one namespace. The boundary is NOT "nullable fields cannot be protected" - a nullability guarantee restates cleanly as a presence union and property existence is config-independent. It is "types that admit arbitrary keys cannot be protected". Encoded as base_db.consumer-boundary.ts rather than left as folklore: the blind-spot half must compile, and its compiling IS the recorded limitation; the reachable half is the same regression on an index-free type and is the positive control. The first draft of that fixture did not work, which is the more useful half of this commit. It asserted the reachable arm with @ts-expect-error over a deep read, and adding an index signature to a protected type left the gate GREEN: data became reachable as unknown and the read then failed on .amount instead. The substitute error carried the SAME code, TS2339, on a different subject - "Property 'data' does not exist on type 'ClosedResult'" became "Property 'amount' does not exist on type 'unknown'". Pinning the error code would not have caught it either. So the assertions are now written in the must-compile direction over a KeyIsReachable predicate, which cannot be satisfied by a substitute error because it is not satisfied by an error at all. Mutation-validated both ways, control green in each: adding an index signature to a protected type turns the gate red (TS2322 + TS2578), and removing it from BaseFirestore turns it red the other way (TS2322 x2 + TS2339), which is what should happen if the blind spot ever closes. Also documents, at ParseResult, why the bare narrowing form looks like it works: ParseSuccess declares issues?: undefined and message?: undefined so an un-narrowed result can be logged, so those keys exist on both branches and the | undefined collapses. Measured: in the else of if (result.success), where no narrowing occurred, result.issues.length and result.message.toUpperCase() compile clean permissively and error strictly. Those are the fields an error path reaches for, so the failure branch works and the consumer concludes the spelling is fine. The concealment is the hazard, not the two reads, which are correct at runtime on that branch. Comment-only in lib/: 16 insertions, zero deletions, no declaration touched. --- .github/copilot-instructions.md | 6 + .../serialized-models.instructions.md | 10 + .github/instructions/tests.instructions.md | 46 +++- lib/interface/schema.d.ts | 16 ++ src/interface/schema.ts | 16 ++ .../interface/base_db.consumer-boundary.ts | 238 ++++++++++++++++++ 6 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 test-consumer/interface/base_db.consumer-boundary.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 99949dc..5128249 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -220,6 +220,12 @@ Any update to the root `README.MD` must: > that cannot compile at all fails its un-narrowed *and* narrowed reads alike — which reads as a > confirmed guarantee. **Gate red + control green** means a guarantee regressed; **gate red + control > red** means the harness broke and the gate proves nothing. +> +> **It is not blanket coverage.** Its mechanism is `TS2339`, which cannot fire on a type carrying an +> index signature — so on the 10 declarations extending `BaseFirestore`, green means "cannot be +> checked", not "is safe". That boundary is itself encoded as a test in +> `test-consumer/interface/base_db.consumer-boundary.ts`; see +> `.github/instructions/tests.instructions.md` §6.3. > **CI gate:** `.github/workflows/nodejs.yml` runs on `push`/`pull_request` to `main` across Node > `22.x` and `24.x`, executing `npm ci` → `npm run build` → build-output drift check → diff --git a/.github/instructions/serialized-models.instructions.md b/.github/instructions/serialized-models.instructions.md index ba317db..4116df2 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -197,3 +197,13 @@ there is nowhere else a consumer could learn it. Do not, however, *assert* it in `test-consumer/`. It is the consumer's compiler, not this package's contract, and a future TypeScript could legitimately change it — an `@ts-expect-error` on it would one day go red for a reason that is nobody's regression. + +### Know which types the gate can protect at all + +An absence-based guarantee is only enforceable on a type that **rejects undeclared keys**. Where a +type carries an index signature — every document interface here, via `BaseFirestore` — property +access is legal by construction, so omitting a field from a branch protects nothing and the gate +cannot report it. The boundary is *"types that admit arbitrary keys"*, not *"nullable fields"*: a +nullability guarantee restates cleanly as a presence union, an index signature does not restate at +all. Before relying on omission, check which side of that line your type is on; the boundary is +encoded as a test in `test-consumer/interface/base_db.consumer-boundary.ts`. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 26bf9b6..8433581 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -203,8 +203,20 @@ wrong form looks like the better assertion. The assertion silently degrades from *"the property is absent"* to *"the property is possibly undefined"*, and nothing can tell. Absences and failures are both cheap to manufacture, so neither is a finish condition on its own: check **which** error you are suppressing, not merely that one -occurred. Verified for the four directives here by stripping them and reading the diagnostics — all -four are `TS2339`. +occurred. Verified for the four directives in `schema.consumer-unguarded.ts` by stripping them and +reading the diagnostics — all four are `TS2339`. + +**And checking the error code is not always enough.** Measured while building the boundary fixture +in §6.3: giving a protected type an index signature left the gate green, because `data` became +reachable as `unknown` and the deep read then failed on `.amount` instead. The substitute error +carried the **same code** — `TS2339` — on a **different subject**: *"Property 'data' does not exist +on type 'ClosedResult'"* became *"Property 'amount' does not exist on type 'unknown'"*. + +> When a negative assertion has to survive that, stop inverting and **assert in the must-compile +> direction** instead — over a type-level predicate such as +> `type KeyIsReachable = K extends keyof T ? true : false`, asserted with +> `const hides: false = …`. A must-compile assertion cannot be satisfied by a substitute error, +> because it is not satisfied by an error at all. The consumer fixture reads deep on purpose, because there `TS18048` cannot arise: with `strictNullChecks` off there is no possibly-undefined error to substitute in, so a weakened type @@ -235,5 +247,35 @@ consumer gate's is structural. All four `@ts-expect-error` directives have been under the mutation they exist to catch, each with the control green in the same state — none is vacuous. +### 6.3 What the gate cannot see — read a green run accordingly + +> 🔴 **A green `npm run typecheck:consumer` is not blanket coverage.** The gate's whole mechanism is +> `TS2339`, and **where a type carries an index signature that error cannot fire**, so the gate is +> structurally unable to report anything about it. On those types green means *"cannot be checked"*, +> not *"is safe"*. + +`BaseFirestore` declares `[x: string]: any` deliberately, so stored documents predating a change +still type-check. Measured on the shipped declarations: **10** declarations in `lib/` extend it +(control on a nonsense base name: `0`), and every one is outside the gate's reach — including the +document types. + +The boundary is **not** "nullable fields cannot be protected": any nullability guarantee can be +restated as a presence union, `{has: true; x: T} | {has: false}`, and property existence is +config-independent. It is **"types that admit arbitrary keys cannot be protected."** It can run +between two types in one namespace — `Idempotency.Interface` extends `BaseFirestore` and is +unprotectable; `Idempotency.Response` does not and is protectable. + +`test-consumer/interface/base_db.consumer-boundary.ts` encodes this as a test rather than as +folklore, so it outlives everyone who currently knows it. Both directions are self-announcing, and +both were mutation-validated: + +| mutation | gate | control | meaning | +|---|---|---|---| +| index signature **added** to a protected type | **red** (`TS2322` + `TS2578`) | green `0` | that type just left coverage silently | +| index signature **removed** from `BaseFirestore` | **red** (`TS2322` ×2, `TS2339`) | green `0` | the blind spot closed; update the fixture and this section | + +Do not "fix" the blind-spot half by making it error. Its **compiling is the assertion**, and it is +not an endorsement. + The rule this enforces is in [`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/lib/interface/schema.d.ts b/lib/interface/schema.d.ts index 08ce1df..c52b5e7 100644 --- a/lib/interface/schema.d.ts +++ b/lib/interface/schema.d.ts @@ -241,6 +241,22 @@ export interface ParseFailure { * the compiler's blessing. That is the exact defect the parse boundary was * built to remove, reintroduced by the idiomatic spelling. * + * ### Why the bare form looks like it works + * + * Because on the error path it does, which is worse than if it plainly failed. + * {@link ParseSuccess} declares `issues?: undefined` and `message?: undefined` + * so that an un-narrowed result can be logged, so those keys exist on **both** + * branches — and with `strictNullChecks` off the `| undefined` collapses. + * Measured: in the `else` of `if (result.success)`, where no narrowing has + * occurred, `result.issues.length` and `result.message.toUpperCase()` compile + * clean under the permissive setting and error under the strict one. + * + * Those are exactly the fields an error path reaches for. So a consumer writes + * the bare form, the failure branch works, and they conclude the spelling is + * fine — while the narrowing they think they performed never happened. The + * concealment is the hazard, not the two field reads, which are correct at + * runtime on that branch. + * * A consumer cannot discover any of this from the shape of the type, which is * why it is documented here rather than left to be found. `npm run * typecheck:consumer` compiles the built declarations under the permissive diff --git a/src/interface/schema.ts b/src/interface/schema.ts index d02b26a..8499425 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -255,6 +255,22 @@ export interface ParseFailure { * the compiler's blessing. That is the exact defect the parse boundary was * built to remove, reintroduced by the idiomatic spelling. * + * ### Why the bare form looks like it works + * + * Because on the error path it does, which is worse than if it plainly failed. + * {@link ParseSuccess} declares `issues?: undefined` and `message?: undefined` + * so that an un-narrowed result can be logged, so those keys exist on **both** + * branches — and with `strictNullChecks` off the `| undefined` collapses. + * Measured: in the `else` of `if (result.success)`, where no narrowing has + * occurred, `result.issues.length` and `result.message.toUpperCase()` compile + * clean under the permissive setting and error under the strict one. + * + * Those are exactly the fields an error path reaches for. So a consumer writes + * the bare form, the failure branch works, and they conclude the spelling is + * fine — while the narrowing they think they performed never happened. The + * concealment is the hazard, not the two field reads, which are correct at + * runtime on that branch. + * * A consumer cannot discover any of this from the shape of the type, which is * why it is documented here rather than left to be found. `npm run * typecheck:consumer` compiles the built declarations under the permissive diff --git a/test-consumer/interface/base_db.consumer-boundary.ts b/test-consumer/interface/base_db.consumer-boundary.ts new file mode 100644 index 0000000..cb392bf --- /dev/null +++ b/test-consumer/interface/base_db.consumer-boundary.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * The consumer-conditions gate's **coverage boundary**, written as a test. + * + * ## Why this file exists + * + * `npm run typecheck:consumer` rests entirely on one mechanism: `TS2339`, + * *"Property 'x' does not exist"*. That is what makes its guarantees hold + * regardless of the consumer's null-checking setting. + * + * **Where a type carries an index signature, that mechanism cannot fire.** + * Every property access is legal by construction, so the gate is structurally + * unable to report anything. A green run over such a type means *"cannot be + * checked"*, not *"is safe"* — and the two are indistinguishable from outside, + * which is the exact shape of failure this gate was built to detect, aimed at + * the gate itself. + * + * {@link BaseFirestore} declares `[x: string]: any` deliberately, so that stored + * documents predating any given change still type-check. That is a defensible + * decision for a sparse document store and this file does not argue with it. + * What it does is stop the consequence from being something a reader has to + * already know. Measured on the shipped declarations at the time of writing: + * **10** declarations in `lib/` extend {@link BaseFirestore} (control on a + * nonsense base name: 0), and all of them are outside the gate's reach. + * + * The boundary is **not** "nullable fields cannot be protected" — any + * nullability guarantee can be restated as a presence union, + * `{has: true; x: T} | {has: false}`, and property existence is + * config-independent. It is **"types that admit arbitrary keys cannot be + * protected."** + * + * ## Why the assertions are predicates, not `@ts-expect-error` + * + * The first draft of this file asserted the reachable half with + * `@ts-expect-error` over a deep read, and **it did not work**. Adding an index + * signature to a protected type left the gate **green**: `data` became reachable + * as `unknown`, and the deep read then failed on `.amount` instead. The + * directive stayed used, so nothing was reported. + * + * That is `tests.instructions.md` §6.1 in a sharper form than §6.1 itself + * describes. The substitute error was not merely *an* error — it carried the + * **same code**, `TS2339`, on a **different subject**: *"Property 'data' does + * not exist on type 'ClosedResult'"* became *"Property 'amount' does not exist + * on type 'unknown'"*. Pinning the error code would not have caught it either. + * + * So the assertions below are written in the **must-compile direction** instead, + * over {@link KeyIsReachable}, which evaluates property reachability directly as + * a type. A must-compile assertion cannot be satisfied by a substitute error, + * because it is not satisfied by an error at all. Both directions are then + * self-announcing: + * + * - a **REACHABLE** assertion breaks → someone gave a protected type an index + * signature, and it has just left the gate's coverage silently; + * - a **BLIND SPOT** assertion breaks → the index signature was removed, or the + * compiler changed; the blind spot has closed, and this file plus + * `tests.instructions.md` §6.3 should be updated to say so. + */ + +import type {BaseFirestore} from '@furcata/core-node/interface'; +import type {Idempotency, Ledger} from '@furcata/core-node/model'; + +/** + * Resolves to `true` when reading `K` off `T` type-checks, and `false` when it + * is a compile error. + * + * This is the gate's own mechanism expressed as a type, which is what makes it + * assertable in the must-compile direction. An index signature puts `string` + * into `keyof T`, so every key satisfies the constraint and the result is + * `true` for anything. + * + * @template T The type whose key set is being examined. + * @template K The key being tested for reachability. + */ +type KeyIsReachable = K extends keyof T ? true : false; + +/** + * Synthetic payload. Deliberately meaningless: this repository is public. + */ +interface SyntheticPayload { + /** + * Arbitrary numeric field, present only so there is something to read. + */ + amount: number; +} + +/* ------------------------------------------------------------------------ * + * BLIND SPOT — the gate's construction, defeated by an inherited index + * signature. Everything here must compile; that is the recorded limitation. + * ------------------------------------------------------------------------ */ + +/** + * Success branch of a correctly closed union, built on a base that admits + * arbitrary keys. + */ +interface OpenSuccess extends BaseFirestore { + /** + * Discriminant. + */ + ok: true; + /** + * The payload. + */ + data: SyntheticPayload; +} + +/** + * Failure branch of the same union. `data` is **omitted**, which is the shape + * this gate exists to require — and which buys nothing here, because the + * inherited index signature supplies the property anyway. + */ +interface OpenFailure extends BaseFirestore { + /** + * Discriminant. + */ + ok: false; +} + +/** + * The union. Identical in construction to a correctly closed parse result, + * differing only in extending a type with an index signature. + */ +type OpenResult = OpenSuccess | OpenFailure; + +declare const openFailureDataReachable: KeyIsReachable; + +/** + * The limitation, stated as a proposition: omitting `data` from the failure + * branch does not hide it, because the index signature reaches it anyway. + * + * This assignment compiling **is** the assertion. If it ever fails to compile, + * the blind spot has closed and this file is out of date. + */ +const openFailureExposesData: true = openFailureDataReachable; + +declare const openResult: OpenResult; + +/** + * The same thing as a value read, to show the practical consequence rather than + * only the proposition. On any type without an index signature this is a + * compile error and the gate catches it; here it is legal. + */ +const openUnguardedRead: number = openResult.data.amount; + +declare const shippedOpenReachable: KeyIsReachable; + +/** + * The same limitation on a real shipped document type rather than a local + * model: a key declared nowhere is still reachable, so no absence-based + * guarantee can be enforced on a document interface. + */ +const shippedOpenExposesAnyKey: true = shippedOpenReachable; + +/* ------------------------------------------------------------------------ * + * REACHABLE — the positive control: the same shapes without an index + * signature, where the gate's mechanism does work. + * ------------------------------------------------------------------------ */ + +/** + * Success branch of the identical union, on a plain base. + */ +interface ClosedSuccess { + /** + * Discriminant. + */ + ok: true; + /** + * The payload. + */ + data: SyntheticPayload; +} + +/** + * Failure branch, `data` omitted exactly as above. + */ +interface ClosedFailure { + /** + * Discriminant. + */ + ok: false; +} + +/** + * The union the gate can actually see. + */ +type ClosedResult = ClosedSuccess | ClosedFailure; + +declare const closedFailureDataReachable: KeyIsReachable; + +/** + * The control, and the load-bearing half of this file: without an index + * signature the omitted property is genuinely unreachable. + * + * Give {@link ClosedFailure} an index signature and this assignment becomes + * `true` where `false` is required, so the gate goes red. That is the mutation + * the `@ts-expect-error` form silently missed, and it is why this is written as + * an assignment rather than as a directive over a read. + */ +const closedFailureHidesData: false = closedFailureDataReachable; + +declare const closedResult: ClosedResult; + +// @ts-expect-error The read form of the assertion above. Deliberately SHALLOW per tests.instructions.md 6.1: a deep read admits a substitute error on the resolved value instead. +const closedUnguardedRead: unknown = closedResult.data; + +declare const shippedClosedReachable: KeyIsReachable; + +/** + * The same control on a real shipped type. `Idempotency.Response` does **not** + * extend {@link BaseFirestore}, so it sits on the protectable side of the + * boundary, while `Idempotency.Interface` does extend it and does not. The + * boundary runs between two types in the same namespace, which is why it is + * worth asserting rather than describing. + */ +const shippedClosedHidesUnknownKey: false = shippedClosedReachable; + +declare const shippedClosed: Idempotency.Response; + +// @ts-expect-error Shallow read on a type with no index signature: an undeclared key is rejected, which is what makes the gate's mechanism work at all. +const shippedClosedRead: unknown = shippedClosed.thisKeyIsDeclaredNowhere; + +/** + * Every binding above is referenced here so that none of them can be dropped + * as unused by a future tool, and so the file has an export and is a module. + * The array is never evaluated; this project compiles with `noEmit`. + */ +export const consumerBoundaryChecked: unknown[] = [ + openFailureExposesData, + openUnguardedRead, + shippedOpenExposesAnyKey, + closedFailureHidesData, + closedUnguardedRead, + shippedClosedHidesUnknownKey, + shippedClosedRead, +];