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
46 changes: 42 additions & 4 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ policy in `engine.ts`, the local `maxEntries` in `cache.ts`, the `failureThresho
in `resilience.ts` — which name a computed value, never a field a consumer writes. That
split is the rule's boundary: P4 governs the **authoring** surface.

Enforced by lint **R10** (§7), and that same split is what makes the check high-precision
without type info: it scans the consumer-input envelope family only (`*Options` plus the
blessed `*Config` types — R6's filter), so the blessed resolved internals sit outside it **by
name** rather than on a hand-kept skip list. `*Threshold` is flagged outright, having no
carve-out to check against. A `max` cap — bare or prefixed, since the sweep record above fixed
both — is flagged unless it is on the rule's allow-list of **verified magnitude ceilings**,
which is three entries today: `BackoffOptions.max` (a delay), `ServeBodyOptions.max` and
`ShellBufferOptions.max` (byte caps). Each carries its one-line reason, so a new `max` forces a
written magnitude-or-count judgement rather than passing by resemblance to those three.

Note what the allow-list does **not** contain: the `chars` caps. A count of UTF-16 code units
is a **count**, so `trace.body.chars` and `stream.buffer.chars` are bare nouns and never a
`max` — the same reading that makes them the marked, string-free side of
[P25](#p25--one-canonical-size-form). Only the **byte** caps are magnitudes.

---

## 3. Typing — predictable and consistent
Expand Down Expand Up @@ -1146,9 +1161,10 @@ shape, not as today's surface: nothing on the surface carries an alias.
pre-existing real match it had not yet fixed, since converted; the 2026-08-04 **R9**
addition found one more (`Surface.resumeRetry`) and fixed it at the source rather than
baselining it — the baseline is **zero again**, and the lint fails on **any** new
violation. Two rule additions, two pre-existing matches: a new rule landing green is
the surprise, not the norm, which is the argument for writing the gate with the rule
rather than after it. The ratchet mechanics
violation. Two of the first three rule additions turned up a pre-existing match, which is
the argument for writing the gate with the rule rather than after it; the third,
**R10** (2026-08-06), landed **green** — the P4 sweep it gates had already been done by
hand, so it is a regression guard, not a fix. The ratchet mechanics
stay (mirroring the repo's ESLint-suppression ratchet) purely as the shrink-only
guarantee: the surface can only get more consistent, never less.
- Rules implemented (high-precision, source-text level): **R1** banned type-name
Expand Down Expand Up @@ -1192,16 +1208,38 @@ shape, not as today's surface: nothing on the surface carries an alias.
**return** rather than a field — the position the 2026-07 sweep's end-user-config
checklist missed. Adding a name to the vocabulary is a contract decision; the added
entry is reviewed like an allow-list entry, in the other direction.
**R10** the cap vocabulary (P4/D2) — a `max` cap, bare or prefixed, on a consumer-input
envelope, unless it is on a curated allow-list of **verified magnitude ceilings**
(`BackoffOptions.max`, `ServeBodyOptions.max`, `ShellBufferOptions.max`, one reason each);
plus any `*Threshold`, which P4 grants no carve-out. It reuses R6's container filter — the
same `*Options` + blessed `*Config` family, `*Like` excluded — and that reuse **is** the
precision guarantee rather than a convenience: P4 itself blesses `max*` on the resolved
internals, and those sit outside the envelope family by name. Inherited members are scanned
like R6's, reported once at the declaration site. This is the rule the 2026-07-31 sweep
wanted and did by hand; measured against history it reproduces that sweep's own list
(`CacheOptions.maxEntries`, `ReconnectOptions.maxAttempts`, `CircuitOptions.failureThreshold`,
`DenoKvStoreOptions.maxIncrRetries`, `RetryOptions.maxMs`/`maxDelay`) on the trees that
carried them, and finds nothing on today's surface.
- Deferred to a type-aware phase (needs the TS checker, not regex): full
same-name-different-**shape** detection, default-value inversion (P8), and the
**parse half** of P17/P25 — R9 pins the type, but whether the widened value actually
reaches `parseDuration`/`parseBytes` before a sleep or comparison is dataflow, and a
widened type over an unparsed read site is the silent-collapse bug (#609); the parse
is pinned behaviourally by test instead. Tracked as comments in the lint. R8 and R9
is pinned behaviourally by test instead. Tracked as comments in the lint. R8, R9 and R10
are also source-text-only in a second sense — they scan exported `interface` bodies,
not `type`-literal object shapes or class fields, which is why `SurfaceOutcome.after`
(a union member) sits outside R9's reach; no R8 group was found in either at the
2026-07-08 audit, but a future one wouldn't be caught until it grows an `interface`.
- **R10 would not have caught its own motivating case, and that is worth writing down.**
The 2026-07-31 sweep found `LlmOptions.maxTokens` / `LlmRequest.maxTokens` by hand; replayed
against the trees that carried them, R10 reports neither, for two separate reasons already
listed above. `LlmOptions` was `type LlmOptions = Partial<Omit<StitchConfig,'kind'>> & { … }`
— a **`type`-literal**, not an `interface`, so no member rule sees it. `LlmRequest` **is** an
exported interface but is not `*Options`, so the container filter rejects the container —
the same blind spot as `MockRoute.respond` (#564 item 2). R10 is therefore a real guard over
the `interface`-shaped `*Options` surface, which is where P4's whole resolved list lived, and
**not** a claim that the 2026-07-31 class is now mechanically covered. Closing either half
is the same decision deferred elsewhere on this page, not an R10 tweak.
- **R8's known gap: the shared subject must lead.** R8 buckets by **leading** word, so a pair that
names its subject in the **trailing** position never groups. `CacheOptions.transformVersion` +
`trustTransform` — one capability by any reading, folded 2026-08-04 (§6) — bucketed under
Expand Down
126 changes: 115 additions & 11 deletions scripts/check-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@
// construction. Generic aliases (`type X<T> = …`) are skipped: whether they admit `{}` depends
// on the argument, which is the type-aware phase's problem.
//
// R10 covers the cap vocabulary (P4/D2). A count cap is a bare plural noun, so a `max` cap —
// bare or prefixed, since the P4 sweep record fixed both — on a consumer-input envelope is a
// violation unless it is a verified continuous MAGNITUDE ceiling on MAX_CAP_ALLOW; `*Threshold`
// gets no carve-out at all. It shares R6's container filter (now `isConsumerEnvelope()`,
// extracted so the two cannot drift), which is also the precision guarantee: P4 blesses `max*`
// on RESOLVED INTERNALS, and those live outside `*Options` by construction rather than on a
// skip list. Replayed over history it reproduces the 2026-07 sweep's own P4 list —
// CacheOptions.maxEntries, ReconnectOptions.maxAttempts, CircuitOptions.failureThreshold,
// DenoKvStoreOptions.maxIncrRetries, RetryOptions.maxMs/maxDelay.
//
// What it does NOT catch, stated here because it is the case that motivated the rule: the
// 2026-07-31 sweep's `LlmOptions.maxTokens` / `LlmRequest.maxTokens`. `LlmOptions` was a
// `type X = … & { … }` LITERAL, which no member rule scans (the same limit R8/R9 carry), and
// `LlmRequest` is an exported interface that is not `*Options`, so the container filter rejects
// it — the `MockRoute` blind spot of #564 item 2. R10 guards the interface-shaped `*Options`
// surface, where P4's whole resolved list lived; it is not a claim that class is now covered.
//
// Still deferred to a type-aware phase (needs the TS checker): shape-diffing (full P9 —
// R5's watch list is the by-name proxy), default-value inversion (P8), cross-PACKAGE parity
// of the same capability (P16), and the CONTAINER half of the alias gap — R6 scans only the
Expand Down Expand Up @@ -293,6 +310,15 @@ const SUFFIX_CARVEOUT = new Set([
]);
const isLike = (n) => /Like/.test(n);

// The CONSUMER-INPUT envelope family — P3's own line between what a caller AUTHORS and what the
// engine hands back: `*Options`, plus the blessed `*Config` authoring types, minus any `*Like`
// foreign duck-type (P18). Shared by R6 and R10 so the two can never drift, the same reason
// `unionArms()` was extracted. Scanning every exported interface instead floods both rules with
// resolved views (`ResolvedNormalizations`), spec mirrors (`OpenApiDocument`, JSON Schema's
// `SchemaNode`) and duck-types (`PinoLoggerLike`) — none of which a consumer ever writes.
const isConsumerEnvelope = (name) =>
(/Options$/.test(name) || SUFFIX_CARVEOUT.has(name)) && !isLike(name);

// P9/P16 — identifiers that MUST be unique-by-shape across packages (a curated watch list;
// full shape-diff is the deferred type-aware phase). Flagged when ≥2 packages export one.
const UNIQUE_WATCH = new Set([
Expand Down Expand Up @@ -416,6 +442,44 @@ const PREFIX_GROUP_ALLOW = new Map([
],
]);

// P4/D2 — the ONE cap vocabulary. A **count** upper bound is a bare plural noun (`attempts`,
// `entries`, `pages`, `failures`, `concurrency`, `tokens`) — never `max`-prefixed, never a bare
// `max`, never `*Threshold`. `max` survives only where it bounds a continuous **magnitude** and a
// bare noun would be ambiguous, so every `max` cap on a consumer-input envelope has to be a
// VERIFIED magnitude. That verification is what this allow-list records — keyed
// `Interface.member`, one rationale per entry, the same idiom as R5's de-listed UNIQUE_WATCH names
// and R8's PREFIX_GROUP_ALLOW. A new `max` cap therefore forces a written judgement (magnitude or
// count?) instead of passing by resemblance to the three below.
//
// Keyed on the DECLARING interface, which is where the rename would go — not on the exported
// envelope a member may be reached through by `extends`.
const MAX_CAP_ALLOW = new Map([
[
'BackoffOptions.max',
'the retry delay ceiling — a DURATION, the continuous magnitude D2 explicitly leaves `max`; it sits beside `base` (the first-step delay), where a bare noun would name neither end',
],
[
'ServeBodyOptions.max',
'ceiling on the buffered request body in BYTES — a magnitude, not a count of units; unsuffixed because bytes are the house size unit (P25) and a body off the socket is natively bytes, unlike the `chars` caps (trace.body.chars, stream.buffer.chars) which ARE counts and are spelled as such',
],
[
'ShellBufferOptions.max',
"ceiling on the buffered subprocess stdout/stderr in BYTES — the size analogue of BackoffOptions.max, one thing to measure (P1) and a magnitude; the dimension execFile's own `maxBuffer` bounds",
],
]);

// A `max` cap: bare `max`, or `max`-prefixed. Both are P4's target — the sweep record fixed
// `ReconnectOptions.maxAttempts`/`CacheOptions.maxEntries`/`LlmOptions.maxTokens` (prefixed) AND
// `paginate.max` (bare) to plural nouns, so a rule that read only `/max[A-Z]/` would miss the
// second half of the vocabulary and, worse, would give the allow-list nothing to verify: every
// legitimate magnitude ceiling on today's surface is a BARE `max`. `maximum` does not match —
// the prefix must end a camel word.
const isMaxCap = (name) => /^max(?:$|[A-Z0-9])/.test(name);
// `*Threshold` gets no allow-list because P4 grants it no carve-out: it is a count word by
// construction, whatever it counts. `CircuitOptions.failureThreshold` → `failures` was the real
// instance, fixed in the 2026-07 sweep.
const isThresholdCap = (name) => /Threshold$/.test(name);

// P17/P25 — the house duration + byte-size member vocabulary. A value a consumer AUTHORS in one
// of these positions must take `number | string` ("if it accepts a duration/size at all, it also
// accepts a string"); a bare `number` is the violation R9 reports.
Expand Down Expand Up @@ -548,6 +612,7 @@ function collect() {
const packages = publishedPackages();
const exportsByName = new Map(); // identifier -> Set(dir)
const seenR6 = new Set(); // declaration sites already reported (see R6)
const seenR10 = new Set(); // ditto for R10 — a separate set, so neither rule mutes the other

// PRE-PASS — every all-optional interface name, per package. R6 used to resolve only
// against declarations in the SAME file, so an envelope imported from a sibling module
Expand Down Expand Up @@ -799,17 +864,9 @@ function collect() {
for (const blk of blocks) {
// Only CONSUMER-INPUT envelopes — P20 governs what a caller authors, not what
// the engine hands back. P3 already draws that line, so reuse it: `*Options`
// plus the blessed `*Config` authoring family. Scanning every exported
// interface instead floods the rule with resolved views
// (`ResolvedNormalizations`), spec mirrors (`OpenApiDocument`, JSON Schema's
// `SchemaNode`) and `*Like` duck-types (`PinoLoggerLike`) — none of which a
// consumer ever writes, all of which legitimately allow `{}`.
if (
!/Options$/.test(blk.name) &&
!SUFFIX_CARVEOUT.has(blk.name)
)
continue;
if (isLike(blk.name)) continue; // P18 foreign duck-type
// plus the blessed `*Config` authoring family, minus `*Like` duck-types. See
// isConsumerEnvelope() for why a wider net floods the rule.
if (!isConsumerEnvelope(blk.name)) continue;
for (const owner of withBases(blk)) {
const mre =
/(?:^|\n)\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*\??:\s*([^;\n]+);/g;
Expand Down Expand Up @@ -847,6 +904,53 @@ function collect() {
}
}

// R10 — the cap vocabulary on a consumer-input envelope (P4/D2). A COUNT upper
// bound is a bare plural noun; `max` is reserved for a continuous MAGNITUDE
// ceiling and must be verified on MAX_CAP_ALLOW; `*Threshold` is banned outright.
//
// The container filter is R6's, and it is what makes the rule high-precision:
// P4 governs the AUTHORING surface, and P4 itself blesses `max*` on the resolved
// internals — engine.ts's reconnect policy, shell's `ShellDefaults.maxBuffer`,
// the `maxBufferChars` parameters — which name a computed value no consumer
// writes. Those are outside `*Options` by construction, so they are excluded by
// name rather than by a hand-kept skip list.
//
// Inherited members count: a base's member is as writable at the slot as a
// declared one (R6's reason for walking `extends`), so the scan walks bases and
// reports the DECLARATION site once, deduped — one rename, one finding.
for (const blk of blocks) {
if (!isConsumerEnvelope(blk.name)) continue;
for (const owner of withBases(blk)) {
// A `*Like` BASE keeps its upstream spelling too (P18) — the same
// exclusion the top-level filter applies, applied down the chain.
if (isLike(owner.name)) continue;
for (const { name, index } of topLevelMembersTyped(
owner.body,
)) {
const max = isMaxCap(name);
const threshold = isThresholdCap(name);
if (!max && !threshold) continue;
if (max && MAX_CAP_ALLOW.has(`${owner.name}.${name}`))
continue;
const at = owner.bodyStart + index;
const osrc = owner.src ?? src;
if (deprecatedBefore(osrc, at)) continue;
const site = `${owner.file ?? file}|${at}`;
if (seenR10.has(site)) continue;
seenR10.add(site);
add(
'R10',
owner.file ?? file,
`${owner.name}.${name}`,
threshold
? `*Threshold names a count cap → a bare plural noun (failureThreshold → failures); P4 leaves it no carve-out (P4/D2)`
: `\`${name}\` caps a consumer-input envelope → if it counts, spell it a bare plural (attempts/entries/pages/tokens); if it is a verified MAGNITUDE ceiling, add it to MAX_CAP_ALLOW with the reason (P4/D2)`,
lineOf(osrc, at),
);
}
}
}

// R8 — a shared leading-word prefix across ≥2 flat members of the SAME exported
// interface, not on the curated allow-list (P24). The conventional on*/is*/percentile
// prefixes are excluded from grouping entirely (never even reach the allow-list); a
Expand Down
Loading