diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 75349b8..5128249 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -193,6 +193,8 @@ 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 | +| **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) | @@ -204,6 +206,28 @@ 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. +> +> 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. +> +> **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 → -> 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` → +> `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 5e56df5..4116df2 100644 --- a/.github/instructions/serialized-models.instructions.md +++ b/.github/instructions/serialized-models.instructions.md @@ -126,3 +126,84 @@ 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, 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`; +- **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 + strictness and the control errors rather than quietly turning the gate into a copy of the strict + one. + +### 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. + +### 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 0a13e8c..8433581 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,154 @@ 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 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. + +### 6.1 `@ts-expect-error` is satisfied by *any* error, including the wrong one + +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 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 +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 have been observed failing +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/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c54f6df..dc341b8 100755 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -107,3 +107,60 @@ 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. + # + # 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: + 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/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..c52b5e7 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 { /** @@ -196,6 +209,59 @@ 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. + * + * ### 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 + * setting so the portable form stays exercised. + * * @template T The interface the schema produces. */ export type ParseResult = ParseSuccess | ParseFailure; @@ -514,10 +580,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 { /** @@ -538,6 +612,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 200d696..5681d87 100755 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "scripts": { "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 6e7da81..8499425 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 { /** @@ -210,6 +223,59 @@ 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. + * + * ### 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 + * setting so the portable form stays exercised. + * * @template T The interface the schema produces. */ export type ParseResult = ParseSuccess | ParseFailure; @@ -612,10 +678,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 { /** @@ -637,6 +711,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/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, +]; diff --git a/test-consumer/interface/schema.consumer-guarded.ts b/test-consumer/interface/schema.consumer-guarded.ts new file mode 100644 index 0000000..9a40d3e --- /dev/null +++ b/test-consumer/interface/schema.consumer-guarded.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright Furcata. All Rights Reserved. + */ + +/** + * Consumer-conditions **positive control** for `src/interface/schema.ts`. + * + * ## What this file is for + * + * 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 a separate file with its own project + * + * 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. + * + * Both failure modes are live here and both were measured: + * + * - `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. + * + * `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. + * + * 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. + * + * ## The inert controls + * + * 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'; +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 — narrowed reads must reach the payload + * ------------------------------------------------------------------------ */ + +declare const declaredParse: ParseResult; + +/** + * 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; + +/** + * 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 — 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 subpath import silently + * resolved to something else, this would not compile. + */ +const unguardedIssuesRead = declaredParse.issues; + +/** + * 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'); + +const guardedInferredRead: number = inferredParse.success ? inferredParse.data.amount : 0; + +/* ------------------------------------------------------------------------ * + * MemberResult — narrowed reads must reach the member + * ------------------------------------------------------------------------ */ + +/** + * Synthetic enumeration standing in for a vocabulary owned elsewhere. + */ +const syntheticMembers = { + alpha: 'alpha', + beta: 'beta', +} as const; + +declare const declaredMember: MemberResult<'alpha' | 'beta'>; + +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'); + +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 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; + +/** + * 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; these projects compile with `noEmit`. + */ +export const consumerControlChecked: unknown[] = [ + guardedParseRead, + guardedIssuesRead, + unguardedIssuesRead, + guardedInferredRead, + guardedMemberRead, + guardedMissRead, + 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/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', () => { 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" + ] +} 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" + ] +}