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
26 changes: 25 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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.
81 changes: 81 additions & 0 deletions .github/instructions/serialized-models.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
158 changes: 157 additions & 1 deletion .github/instructions/tests.instructions.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T, K extends PropertyKey> = 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.
Loading
Loading