diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5128249..9f2ec03 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,9 +223,10 @@ Any update to the root `README.MD` must: > > **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. +> checked", not "is safe". A second, independent blind spot: `T | null` collapses where +> `strictNullChecks` is off, so the gate proves nothing about nullability either. Both boundaries are +> themselves encoded as tests in `test-consumer/interface/`; see +> `.github/instructions/tests.instructions.md` §6.3 and §6.4. > **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 c07b7be..c23ce08 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -237,3 +237,12 @@ cannot report it. The boundary is *"types that admit arbitrary keys"*, not *"nul 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`. + +A **second, independent** boundary sits alongside it: `T | null` is invisible to the consumer gate, +because `null` is assignable to everything where `strictNullChecks` is off. That does not make +`T | null` wrong — it is the right model for a field the store holds as `null`, and it is +load-bearing for strict consumers — but it does mean the gate proves nothing about it. Where a +nullability guarantee must hold **regardless** of the consumer's flags, restate it as +`{has: true; value: T} | {has: false}`; property existence is config-independent. Measured and +encoded in `test-consumer/interface/schema.consumer-nullability.ts`, together with the proof that +the two boundaries are independent rather than one cause. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 8433581..3173529 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -277,5 +277,49 @@ both were mutation-validated: Do not "fix" the blind-spot half by making it error. Its **compiling is the assertion**, and it is not an endorsement. +### 6.4 The second blind spot: `T | null` is invisible to this gate + +> 🔴 **The gate cannot observe nullability at all.** `null` is assignable to every type where +> `strictNullChecks` is off — which is the condition the gate compiles under — so a `T | null` union +> collapses and there is nothing left to check. + +Unlike §6.3 the mechanism is not broken here; it fires perfectly. It is simply that `T | null` is not +a *property-existence* fact, and property existence is all this gate can see. + +**The two boundaries are independent, and were measured separately** — the obvious reading conflates +them, because the nullable properties mostly live on document types that carry an index signature +too. If the index signature were doing the hiding, the nullability question would be moot. It is not: +`Idempotency.Response` has **no** index signature — proven in the same compilation, where an +undeclared key on that exact type is still `TS2339` — and its `body: string | null` is still +invisible. Same file, same declarations, both settings: + +| read | consumer settings | strict settings | +|---|---|---| +| `resp.body.toUpperCase()` where `body: string \| null` | **compiles** | `TS18047` | +| `led.consumed + 1` where `consumed?: number \| null` | **compiles** | `TS18049` | +| `resp.undeclaredKey` (no index signature) | `TS2339` | `TS2339` | + +The third row is the liveness control: the mechanism is demonstrably working in the run where the +first two report nothing. + +**This is a statement about the gate, not about the types.** `T | null` is the right way to model a +field the store genuinely holds as `null`, and it is load-bearing for every strict consumer. Just +never cite a green `typecheck:consumer` as evidence about nullability. + +**The remedy, where a nullability guarantee must hold regardless of the consumer's flags:** restate +it as presence/absence — `{has: true; value: T} | {has: false}` rather than `value: T | null`. Property +existence is config-independent, so the restated form is enforceable where the union form is not. +That is not a reason to go re-encode existing fields; the honest fix for a permissive consumer is for +that consumer to enable `strictNullChecks`. + +`test-consumer/interface/schema.consumer-nullability.ts` encodes the boundary **and** the remedy as a +paired fixture. Mutation-validated, control green in each: + +| mutation | gate | meaning | +|---|---|---| +| presence union regressed to `value: null` | **red** (`TS2322` + `TS2578`) | the restated form is genuinely enforceable | +| flags drift strict (`--strictNullChecks`) | **red** (`TS18047`, `TS18049`) | the blind spot closed; update the fixture and this section | +| the control's subject gains an index signature | **red** (`TS2578`) | the liveness control has stopped controlling | + The rule this enforces is in [`serialized-models.instructions.md`](serialized-models.instructions.md) §8. diff --git a/test-consumer/interface/schema.consumer-nullability.ts b/test-consumer/interface/schema.consumer-nullability.ts new file mode 100644 index 0000000..d251cdb --- /dev/null +++ b/test-consumer/interface/schema.consumer-nullability.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * The consumer-conditions gate's **second coverage boundary**: nullability. + * + * Companion to `base_db.consumer-boundary.ts`, which records the first one. + * Both exist for the same reason — a green run of `npm run typecheck:consumer` + * must never be read as blanket coverage — but they are **independent**, and + * the difference matters: + * + * - **Index signature** (`base_db.consumer-boundary.ts`): the gate's mechanism + * cannot fire, because every property access is legal by construction. + * - **Nullability** (this file): the gate's mechanism fires correctly, but + * `T | null` is not a *property-existence* fact. Under `strictNullChecks: + * false` — the condition this gate compiles under — `null` is assignable to + * every type, so the union collapses and there is nothing left to observe. + * + * These were measured **separately**, because the obvious reading conflates + * them: the properties widened to `| null` mostly live on document types, which + * extend {@link BaseFirestore} and so carry an index signature too. If the + * index signature were doing the hiding, the nullability question would be moot. + * It is not. `Idempotency.Response` has **no** index signature — proven in the + * same compilation by the `REACHABLE` half below, where an undeclared key on + * that exact type is still `TS2339` — and its `body: string | null` is *still* + * invisible here. Two independent blind spots, not one. + * + * Measured under the two settings, same file, same declarations: + * + * | read | consumer settings | strict settings | + * |---|---|---| + * | `resp.body.toUpperCase()` where `body: string \| null` | **compiles** | `TS18047` | + * | `led.consumed + 1` where `consumed?: number \| null` | **compiles** | `TS18049` | + * | `resp.undeclaredKey` (no index signature) | `TS2339` | `TS2339` | + * + * The third row is the liveness control: the mechanism is demonstrably working + * in the very run where the first two report nothing. + * + * ## This is a statement about the gate, not a defect in the types + * + * `T | null` is the correct way to model a field a document store genuinely + * holds as `null`, and widening to it is a real, load-bearing improvement for + * every consumer that compiles strictly. This file does not argue with that. It + * records that **this gate cannot see it**, so nobody cites a green run as + * evidence about nullability. + * + * ## The remedy, which is why the halves are paired + * + * A nullability guarantee that must survive a permissive consumer can be + * **restated as presence/absence**: `{has: true; value: T} | {has: false}` + * instead of `value: T | null`. Property existence is config-independent, so the + * restated form is enforceable where the union form is not. The `REACHABLE` + * half below is that restatement, and it compiles as a compile error exactly as + * intended — so this file states the boundary *and* demonstrates the way across + * it. + * + * That is not a recommendation to go re-encode existing fields. The honest fix + * for a permissive consumer is for that consumer to enable `strictNullChecks`. + * The restatement is for a guarantee that must hold *regardless*. + * + * Both directions are self-announcing. If a `BLIND SPOT` assertion starts + * erroring, the collapse has stopped happening — the compiler changed, or the + * project's flags drifted strict — and this file plus + * `tests.instructions.md` §6.4 should be updated to say so. + */ + +import type {Idempotency, Ledger} from '@furcata/core-node/model'; + +/* ------------------------------------------------------------------------ * + * BLIND SPOT — `| null` collapses under the settings this gate compiles with. + * Everything here must compile; that compiling IS the recorded limitation. + * ------------------------------------------------------------------------ */ + +declare const shippedResponse: Idempotency.Response; + +/** + * `Idempotency.Response.body` is declared `string | null`. Reading a method off + * it without a null check is `TS18047` under strict settings and **legal** + * here. + * + * No `@ts-expect-error`, deliberately: this must compile, and it compiling is + * the assertion. The type is right; the gate simply cannot observe it. + */ +const nullUnionIsInvisible: string = shippedResponse.body.toUpperCase(); + +declare const shippedLedger: Ledger.Interface; + +/** + * The same on an optional-and-nullable property of a document type + * (`consumed?: number | null`), which is `TS18049` under strict settings — + * `possibly 'null' or 'undefined'` — and legal here. + * + * This one carries an index signature as well, so it is doubly invisible. It is + * kept because it is the shape most of the widened properties actually have, + * and the single-cause case above is what proves the two causes are separable. + */ +const optionalNullUnionIsInvisible: number = shippedLedger.consumed + 1; + +/* ------------------------------------------------------------------------ * + * REACHABLE — the same guarantee restated as presence/absence, which the gate + * can enforce. This half is the positive control AND the remedy. + * ------------------------------------------------------------------------ */ + +/** + * Synthetic payload. Deliberately meaningless: this repository is public. + */ +interface SyntheticPayload { + /** + * Arbitrary numeric field, present only so there is something to read. + */ + amount: number; +} + +/** + * Present branch of the restated form. + */ +interface PayloadPresent { + /** + * Discriminant. + */ + has: true; + /** + * The value, reachable only after narrowing. + */ + value: SyntheticPayload; +} + +/** + * Absent branch. The property is **omitted**, not declared `value: null` and not + * declared `value?: undefined` — omission is what makes the guarantee rest on + * property existence and therefore hold under either null-checking setting. + */ +interface PayloadAbsent { + /** + * Discriminant. + */ + has: false; +} + +/** + * The restated guarantee. + */ +type PayloadSlot = PayloadPresent | PayloadAbsent; + +/** + * Resolves to `true` when reading `K` off `T` type-checks, and `false` when it + * is a compile error. + * + * Asserted in the must-compile direction, per `tests.instructions.md` §6.1: an + * assertion that something *fails* can be satisfied by a substitute failure, + * and a must-compile assertion cannot, because it is not satisfied by an error + * at all. + * + * @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; + +declare const absentValueReachable: KeyIsReachable; + +/** + * The load-bearing half: restated as presence/absence, the absent case genuinely + * hides the value, and the gate can prove it under permissive settings. + * + * Change {@link PayloadAbsent} to carry `value: null` or `value?: undefined` and + * this becomes `true` where `false` is required, so the gate goes red — which is + * precisely the regression the union form cannot be made to report. + */ +const absentSlotHidesValue: false = absentValueReachable; + +declare const slot: PayloadSlot; + +// @ts-expect-error Deliberately SHALLOW per tests.instructions.md 6.1. The restated form makes the unhandled case a property-existence error, which fires regardless of the null-checking setting — unlike the `| null` form above. +const unguardedSlotRead: unknown = slot.value; + +/** + * The liveness control for the blind-spot half, and the proof that the two + * boundaries are independent. + * + * `Idempotency.Response` carries **no** index signature, so an undeclared key on + * it is a compile error — in the same compilation where its `body: string | null` + * above reported nothing. Without this line, the blind-spot assertions could be + * silent because the gate was broken rather than because it cannot see + * nullability. + */ +// @ts-expect-error A type with no index signature rejects an undeclared key. If this stops erroring, this type has gained one and has left the gate's coverage entirely. +const responseRejectsUnknownKey: unknown = shippedResponse.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 nullabilityBoundaryChecked: unknown[] = [ + nullUnionIsInvisible, + optionalNullUnionIsInvisible, + absentSlotHidesValue, + unguardedSlotRead, + responseRejectsUnknownKey, +];