test: inventory the two schemas a dropped field could leave unguarded - #19
Merged
Conversation
A field deleted from a document schema stops being validated and falls through to the loose-object passthrough, where any value is accepted, while the interface continues to promise it was checked. Nothing in the pipeline caught that for Account or EventData. The compile-time proof cannot. `AssertSchemaOutput` is assignability-based and `z.looseObject` infers a `[x: string]: unknown` index signature, which satisfies an *optional* interface property whether or not the schema still declares it. Only removing a *required* field is a compile error. Measured: deleting Price.amount left tsc clean, deleting Price.account did not. This is the same mechanism already recorded against the consumer gate's TS2339 and against the links type-equality proof. It is a third guard weakened by the same index signature, and it is pre-existing rather than introduced by the null widening. Twelve of the fourteen other shapes were already covered by a literal key inventory, which is why deleting Price.amount turned the suite red. Account had only a toContain spot-check naming 22 of its 55 keys, and EventData one naming 18 of 37, so a deletion outside those lists passed both the compiler and the suite. Measured before the change: deleting Account.stockTicker and EventData.views left everything green. Both inventories are literal lists rather than anything derived from Schema.shape, which would shrink alongside the deleted field and assert nothing. The existing spot-checks are kept, since they document intent per group. Positive-controlled: five deletions across both shapes, including the two that previously passed and three fields named in no spot-check, all turn the suite red, with the unmodified tree green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
A field deleted from a document schema stops being validated. It falls through to the
z.looseObjectpassthrough, where any value is accepted, while the interface continues to promise it was checked. ForAccountandEventData, nothing in the pipeline caught that.This came out of generalizing the
linksSchemafinding in #17. If an assignability-based proof against an index-signature-carrying type is weak, that should apply to everySchemaOutputin the package — all 16 schemas arez.looseObject. It does.Why the compile-time proof can't see it
AssertSchemaOutput<TOutput extends TInterface, TInterface>is assignability-based, andz.looseObjectinfers[x: string]: unknown. That index signature satisfies an optional interface property whether or not the schema still declares it. Only removing a required field is a compile error.Measured on merged
main, before any change here:tscvitestPrice.amountAccount.stockTickerEventData.viewsPrice.accountPrice.amountnumber → stringThis is the third guard weakened by the same index signature, after the consumer gate's
TS2339and the links type-equality proof. It is pre-existing, not introduced by the null widening.Why two shapes and not sixteen
12 of the other 14 shapes already carry a literal key inventory — which is why deleting
Price.amountturned the suite red. The two exceptions:Account— atoContainspot-check naming 22 of 55 keysEventData— atoContainspot-check naming 18 of 37 keysA deletion outside those lists passed both the compiler and the suite.
Accountis the largest and most compliance-sensitive shape in the package.The fix
An exhaustive literal key inventory for each, alongside the existing spot-checks (kept — they document intent per group). The lists are literal on purpose: deriving them from
Schema.shapewould shrink along with the deleted field and assert nothing, which is the same vacuity trap as a fixture that passes under two spellings.Positive control
Five deletions, all turning the suite red, with the unmodified tree green:
Account.stockTickerandEventData.views— the two that previously passed both gatesAccount.useName,Account.postalCode,EventData.duration— fields named in no spot-checkGates
npm run buildnpm test(966 passed, 18 files)npm run typechecknpm run typecheck:consumernpm run typecheck:consumer:controlnpm run lintcheck-private-markers.shTests only — no
src/change, solib/is untouched and the drift check is clean by construction rather than by rebuild.No
Co-authored-by:trailer: 0 overorigin/main..HEAD, authorship a single identity matching the committer.