From dcfad6fcf96713d0d522d8db3bdc4239a62f6b5f Mon Sep 17 00:00:00 2001 From: rejifald Date: Thu, 6 Aug 2026 16:02:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(contract):=20R10=20gates=20the=20cap=20voc?= =?UTF-8?q?abulary=20=E2=80=94=20a=20`max`=20count=20cap=20(Refs=20#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4/D2 says a count upper bound is a bare plural noun (`attempts`, `entries`, `pages`, `failures`, `tokens`) and that `max` is retained only for a continuous magnitude ceiling. Nothing in the ratchet enforced it: the 2026-07-31 sweep found its P4 violations by hand while `check:contract` reported a clean baseline. R10 flags a `max` cap — bare or prefixed, since the sweep record fixed both `maxAttempts`-style and `paginate.max` — on a consumer-input envelope, unless the field is on MAX_CAP_ALLOW, a curated list of verified magnitude ceilings carrying one written reason each. It also flags any `*Threshold`, which P4 grants no carve-out. The container filter is R6's, extracted as `isConsumerEnvelope()` so the two cannot drift, and that reuse is the precision guarantee rather than a convenience: P4 itself blesses `max*` on resolved internals, and those sit outside the `*Options` family by name rather than on a hand-kept skip list. The issue proposed numbering this R9; R9 already exists (P17/P25 duration and size conformance), so this is R10 and nothing is renumbered. Green on today's tree — the P4 sweep it gates was already done by hand, so this is a regression guard, not a fix. Replayed over the trees that carried them, it reproduces that sweep's own list: CacheOptions.maxEntries, ReconnectOptions.maxAttempts, CircuitOptions.failureThreshold, DenoKvStoreOptions.maxIncrRetries, RetryOptions.maxMs/maxDelay. `Refs`, not `Closes`: R10 does not catch the issue's own motivating case. `LlmOptions` was a `type … & { … }` literal, which no member rule scans, and `LlmRequest` is an exported interface that is not `*Options`. Both limits are pre-existing and now recorded in CONTRACT.md §7 rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 --- docs/CONTRACT.md | 46 ++++++++++++-- scripts/check-contract.mjs | 126 +++++++++++++++++++++++++++++++++---- 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index b44516be..5c27f5c2 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -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 @@ -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 @@ -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> & { … }` + — 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 diff --git a/scripts/check-contract.mjs b/scripts/check-contract.mjs index bc870dff..d8abbb88 100644 --- a/scripts/check-contract.mjs +++ b/scripts/check-contract.mjs @@ -42,6 +42,23 @@ // construction. Generic aliases (`type X = …`) 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 @@ -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([ @@ -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. @@ -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 @@ -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; @@ -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