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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
9 changes: 9 additions & 0 deletions .github/instructions/serialized-models.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
44 changes: 44 additions & 0 deletions .github/instructions/tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
202 changes: 202 additions & 0 deletions test-consumer/interface/schema.consumer-nullability.ts
Original file line number Diff line number Diff line change
@@ -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<T, K extends PropertyKey> = K extends keyof T ? true : false;

declare const absentValueReachable: KeyIsReachable<PayloadAbsent, 'value'>;

/**
* 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,
];
Loading