From 77d8a5337f2a346113dd46e3717e84a07755d9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:21:12 +0000 Subject: [PATCH 1/2] spec(gate): check-duration-unit-keys admits by declaration; retire the name-shape token list The census admitted a numeric key three ways: a unit in its describe prose, a unit token in its key name, or a match against a 25-token list of words that "read like a duration" (`timeout`, `ttl`, `window`, `stale`, `age`, ...). The third one is retired. Admission is now by DECLARATION: a closed duration or instant type in the zod chain, or a unit token in the key name, plus prose so that prose can still be judged against the name. - the token-set constant and the predicate that read it are deleted, and a self-test case reads this source and asserts both identifiers are absent, with a positive control, so the retirement cannot rot back in - the closed duration vocabulary is read as an admission channel, exactly the way the epoch instant root already was: the type states the unit at the authoring site and in the published JSON schema, so it waives the key-name requirement and nothing else. Both contradiction directions stay refusable - a `dimensionless` schema marker joins `externalVocabulary` in the same reader, for a count whose prose names a time unit belonging to something else in the sentence - the census drops from 208 rows to 185. Every one of the 23 rows that left is unit-free through every channel this file reads -- name, describe, JSDoc and type -- measured one by one, against a positive control on the 185 that stayed. Nothing entered The cost is recorded rather than hidden: the JSDoc-divergence class's original positive controls (`timeout`, `window`, `interval` with a unit in the JSDoc alone) all rested on the retired list, so those keys are no longer admitted and no longer refused. The surviving half is the one that rests on a declaration -- a key whose name carries a unit its JSDoc contradicts -- and the route back for the others is a `Duration*` type. All three shapes are pinned as explicit cost cases so a future re-admission goes red and says what it is re-opening. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../spec/scripts/check-duration-unit-keys.ts | 511 ++++++++++++++---- 1 file changed, 411 insertions(+), 100 deletions(-) diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index ce7605e4d3..c646c682e4 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -2,13 +2,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * check-duration-unit-keys — a duration-shaped `z.number()` key carries its - * unit in its NAME, never only in its `.describe()` prose (#14478, maintainer - * ruling 2026-09-02, recorded on the card as "ruled B"). + * check-duration-unit-keys — a `z.number()` key that DECLARES a time unit + * carries it in its NAME or in its TYPE, never only in its `.describe()` prose + * (#14478, maintainer ruling 2026-09-02, recorded on the card as "ruled B"). * * tsx scripts/check-duration-unit-keys.ts # gate: exit 1 on any offender * tsx scripts/check-duration-unit-keys.ts --self-test # prove the detector still detects - * tsx scripts/check-duration-unit-keys.ts --list # every duration-shaped number key it sees + * tsx scripts/check-duration-unit-keys.ts --list # every unit-declaring number key it sees * tsx scripts/check-duration-unit-keys.ts --root # judge another tree (ablation / demo), same rule * * ## The defect class @@ -51,26 +51,41 @@ * — recognised structurally, by the sibling `unit` key on the same object * literal, never by name. * - * ## What `--list` reports and the verdict does NOT judge: no unit anywhere + * ## ADMISSION: a key is judged when it DECLARES a unit, never when it merely + * ## LOOKS like a duration * - * A duration-SHAPED name (`sessionTimeout`, `flushInterval`) whose describe - * names no unit at all is the #14519 shape — the reference-page reader gets a - * bare `3600`. It is outside this verdict on purpose, and the reason is - * measured, not aesthetic: judged by name alone on `ca46f8f12` (2026-09-04) - * that rule fired 44 times, and most were counts wearing a duration's - * vocabulary — `contextWindow`, `slidingWindowSize`, `snapshotInterval` - * ("every N events"), `reflectionInterval` ("every N interactions"), - * `backoffMultiplier`, `staleKeys`. A rule that cannot tell a window of - * tokens from a window of seconds would either grandfather those by name - * (an exception list) or teach authors to append `Ms` to a count. The - * describe-driven rule has no such ambiguity: prose that says "seconds" is - * talking about time. `--list` still prints the unit-nowhere keys so the - * population stays visible; closing it is a describe-by-describe decision. + * A numeric key enters the census when it declares a unit through one of two + * channels — a closed duration/instant TYPE in its zod chain ({@link + * DURATION_ROOTS}, {@link INSTANT_ROOT}), or a unit token in its key NAME + * ({@link unitsInKey}) — plus the describe prose, which is admitted for one + * reason only: the founding rule of this gate is that a unit named in the prose + * and NOWHERE else is an offence, and a key the census never draws cannot be + * refused for anything. Prose admits so that prose can be judged against the + * name. It never satisfies the rule and it never exempts. * - * ⛔ "No unit anywhere" now means no unit in EITHER prose channel — see the - * JSDoc section below. A key whose describe is silent but whose JSDoc names a - * unit is not this shape at all: its unit IS written down, just not where the - * reader can see it, and that is the divergence class rather than this one. + * ⛔ A key's NAME SHAPE is no longer an admission channel. Until this rule + * landed, a 25-token list (`timeout`, `ttl`, `interval`, `window`, `backoff`, + * `stale`, `age`, …) pulled a key into the census on the strength of its name + * alone. The list is RETIRED: the token-set constant and the predicate that + * read it are both deleted, and there is no name-shape reading left in this + * file — a self-test case reads this source and asserts both identifiers are + * absent, with a positive control, so the retirement cannot rot back in under + * a new spelling. A bare `z.number()` called `timeout` + * — no unit token, no duration type, no unit in its prose — declares nothing, + * is not in the census, and is not judged. + * + * That is a cost, and it is accepted rather than hidden. The list was what made + * "no unit ANYWHERE" (the #14519 shape) visible at all, and it was what admitted + * the JSDoc-divergence class's whole population (see below). Two things bought + * it: the list could not tell a window of TOKENS from a window of SECONDS — + * judged by name alone on `ca46f8f12` (2026-09-04) it fired 44 times and most + * were counts wearing a duration's vocabulary (`contextWindow`, + * `slidingWindowSize`, `snapshotInterval` "every N events", `reflectionInterval` + * "every N interactions", `backoffMultiplier`, `staleKeys`) — and a list of 25 + * words is a special case that drifts, maintained forever against a vocabulary + * nobody agreed to. A key whose unit is genuinely missing is fixed by giving it + * a `Duration*` type or a unit-carrying name, which is a declaration the next + * reader can see, not by being recognised from a word list. * * ## The SECOND prose channel: a JSDoc that names a unit the describe does not * @@ -92,10 +107,23 @@ * key NAME. * * ⛔ SO THE JSDoc IS READ IN EXACTLY ONE DIRECTION: to refuse, never to - * satisfy. Nothing about the #14519 shape moves — a duration-shaped key with - * no unit in EITHER channel is still listed and still not judged. The - * divergence branch tests for a unit PRESENT in the JSDoc; it never tests for - * one absent from the describe, which is what would have made it option 1. + * satisfy. The divergence branch tests for a unit PRESENT in the JSDoc; it + * never tests for one absent from the describe, which is what would have made + * it option 1. + * + * ⚠️ WHAT THE NAME-LIST RETIREMENT COST THIS CLASS, stated plainly because a + * silent repeal is the worse outcome. The divergence branch used to be guarded + * by the name-shape list, so its whole live population was keys like `timeout`, + * `window` and `interval` — names that declare nothing. Those keys are no + * longer admitted, so the branch can no longer reach them: a bare `timeout` + * whose unit is written only in a JSDoc is now neither listed nor refused. What + * survives is the half that rests on a declaration — a key whose NAME carries a + * unit, whose describe names none, and whose JSDoc names a DIFFERENT unit. The + * two channels still disagree and the disagreement is still refused; the shape + * where nothing was declared in the first place went out with the list. The + * route back for such a key is step ③'s conversion to a `Duration*` type, at + * which point the schema declares the unit and the contradiction branch reaches + * it again. * * Why the divergence is worth a refusal and the blindness was not: the card * that filed it measured the cost. #15678 recorded in its changeset that @@ -105,11 +133,11 @@ * why it was missed. A gate that cannot see a channel writes falsehoods about * it. * - * ## The two exemptions, DECLARED ON THE SCHEMA (#15676, ruling B) + * ## The exemptions, DECLARED ON THE SCHEMA (#15676, ruling B) * - * The rule governs every authored and every runtime-emitted duration MINUS two + * The rule governs every authored and every runtime-emitted duration MINUS four * structural classes, and the ruling is explicit about the mechanism: they are - * "declared ON THE SCHEMA, never in a gate ledger". So neither of them appears + * "declared ON THE SCHEMA, never in a gate ledger". So none of them appears * in this file as a key, a path or a name. What appears here is the ability to * READ a declaration the schema itself carries. * @@ -131,14 +159,29 @@ * — so the reference page prints the unit as "per the named standard" * (`scripts/lib/schema-section.ts`) instead of the reader having to guess. * - * ⛔ Neither exemption is a pass on lying. A marked key still fails + * 3. **Declared durations** — a key whose value IS one of the closed duration + * schemas ({@link DURATION_ROOTS}, `src/shared/duration.zod.ts`) states its + * unit in the TYPE, which the authoring site shows and the published JSON + * schema carries. The unit is written down where the reader reaches it, so + * the key-NAME requirement is waived — and nothing else is. This is the + * channel step ③ converts the unit-nowhere rows into. + * + * 4. **Dimensionless numbers** — a key that carries + * `.meta({ dimensionless: '' })` is a count, a multiplier or + * a ratio whose prose happens to name a time unit belonging to something + * else in the sentence. Same mechanism as class 2, same literal-only + * validation, same visibility. + * + * ⛔ No exemption is a pass on lying. A marked key still fails * `name-unit-contradicts-prose` (a marker waives the RENAME, never a - * contradiction), and an `EpochMs` key whose describe names a unit other than + * contradiction), an `EpochMs` key whose describe names a unit other than * milliseconds fails `instant-unit-contradicts-schema` — the schema says - * milliseconds, so prose that says seconds is one of the two being wrong. A - * declaration that could never be refused is an allowlist wearing a `.meta()`. + * milliseconds, so prose that says seconds is one of the two being wrong — and + * a `DurationMs` key whose prose or name says seconds fails + * `duration-unit-contradicts-schema` for the same reason. A declaration that + * could never be refused is an allowlist wearing a `.meta()`. * - * Both classes stay VISIBLE in the census: `--list` marks them and the verdict + * Every class stays VISIBLE in the census: `--list` marks them and the verdict * line counts them. An exemption nobody can see is the ledger this ruling * refused. * @@ -303,13 +346,6 @@ const POSITION_IDIOMS: readonly RegExp[] = [ /** A describe that names a unit as the denominator of a RATE. */ const RATE_IDIOM = /\b(per|a|each|every)\s+(milli)?(second|minute|hour|day)\b/i; -/** Key names (or key-name tokens) that read as a duration even when no unit is named anywhere. */ -const DURATION_SHAPED_TOKENS = new Set([ - 'timeout', 'ttl', 'interval', 'delay', 'duration', 'maxage', 'expireafter', 'retention', - 'cooldown', 'debounce', 'throttle', 'window', 'grace', 'lifetime', 'expiry', 'expiration', - 'heartbeat', 'backoff', 'idle', 'stale', 'age', 'period', 'every', 'wait', 'timeouts', -]); - const NUMERIC_ROOTS = new Set(['z.number', 'z.int', 'z.coerce.number']); /** @@ -327,6 +363,29 @@ const INSTANT_ROOT = 'EpochMs'; /** Where {@link INSTANT_ROOT} is declared — read by the self-test, not by the scan. */ const INSTANT_ROOT_MODULE = 'src/shared/epoch.zod.ts'; +/** + * The closed duration vocabulary (`src/shared/duration.zod.ts`) — the TYPE half + * of the admission rule, read exactly the way {@link INSTANT_ROOT} is: as the + * identifier a property's value chain bottoms out at, never through module + * resolution. A key declared `DurationMs` states its unit at the authoring site + * and in the published JSON schema, so the unit is written down in a channel + * the reference-page reader reaches — which is the whole thing the key-name + * requirement exists to secure. The declaration therefore waives the RENAME, + * and nothing else: a `DurationMs` key whose prose or whose name names another + * unit is still refused, exactly as an `EpochMs` instant is. + * + * Held honest from the same side as the instant root: a self-test case reads + * {@link DURATION_ROOT_MODULE} and asserts each identifier really is exported + * there, so renaming a schema without renaming it here is RED rather than a + * silently-empty admission channel. + */ +const DURATION_ROOTS: ReadonlyMap = new Map([ + ['DurationMs', 'ms'], + ['DurationSeconds', 'seconds'], +]); +/** Where the {@link DURATION_ROOTS} identifiers are declared — read by the self-test, not by the scan. */ +const DURATION_ROOT_MODULE = 'src/shared/duration.zod.ts'; + /** * The `.meta()` key that declares exemption class (ii). A key carrying it * mirrors a name fixed by an external standard, so the RENAME is waived — never @@ -335,6 +394,23 @@ const INSTANT_ROOT_MODULE = 'src/shared/epoch.zod.ts'; */ const EXTERNAL_VOCABULARY_META_KEY = 'externalVocabulary'; +/** + * The `.meta()` key that declares exemption class (iii): this number is + * DIMENSIONLESS — a count, a multiplier, a ratio — even though something about + * it reads like a duration. Its value names what the number counts, as a + * non-empty string literal, under the same rule the mirror marker carries: an + * unverifiable claim exempts nothing, so a computed value or an empty string + * leaves the key judged. + * + * It sits beside {@link EXTERNAL_VOCABULARY_META_KEY} in this reader on + * purpose. Both are declarations ON THE SCHEMA rather than rows in a gate + * ledger, both stay visible and counted in the census, and neither is a pass on + * lying: a dimensionless key whose NAME carries a unit token still fails + * `name-unit-contradicts-prose`, because "this number counts events" and "this + * number is a span of milliseconds" cannot both be true of one key. + */ +const DIMENSIONLESS_META_KEY = 'dimensionless'; + export interface DurationKey { file: string; line: number; @@ -350,11 +426,43 @@ export interface DurationKey { keyUnits: string[]; /** true when a sibling `unit` key sits on the same object literal */ valueUnitPair: boolean; - durationShaped: boolean; + /** the closed-vocabulary duration schema the value chain is rooted at (`DurationMs`), when it is one */ + durationType: string | undefined; + /** the unit that {@link durationType} declares (canonical), when there is one */ + typeUnits: string[]; /** true when the value chain is rooted at the shared `EpochMs` schema — exemption (i) */ instant: boolean; /** the standard named by `.meta({ externalVocabulary })`, when one is declared — exemption (ii) */ externalVocabulary: string | undefined; + /** what this number counts, named by `.meta({ dimensionless })`, when it is declared — exemption (iii) */ + dimensionless: string | undefined; +} + +/** + * ADMISSION — the one predicate that decides whether a numeric key is in the + * census at all, and therefore whether any verdict can reach it. + * + * A key is admitted when it DECLARES a unit, through one of the two declaration + * channels the ruling names: a closed duration/instant TYPE in its zod chain + * ({@link DURATION_ROOTS} / {@link INSTANT_ROOT}), or a unit token in its key + * NAME ({@link unitsInKey}). The describe prose is the third entry, and it is + * here for one reason: the founding rule of this gate is that a unit named in + * the prose and nowhere else is an offence, and a key the census never drew + * cannot be refused for anything. Prose admits a key so the prose can be judged + * against the name; it never SATISFIES the rule and it never exempts. + * + * ⛔ What is NOT here any more is the key's NAME SHAPE. A bare `z.number()` + * called `timeout`, with no unit token, no duration type and no unit in its + * prose, declares nothing and is not judged — the cost the ruling accepted when + * it retired the 25-token name list. Those keys become genuine durations again + * by being given a `Duration*` type or a unit-carrying name, not by being + * recognised from a list of words. + */ +export function declaresUnit(site: DurationKey): boolean { + return site.instant + || site.durationType !== undefined + || site.keyUnits.length > 0 + || site.proseUnits.length > 0; } export interface Finding { @@ -363,6 +471,7 @@ export interface Finding { | 'unit-in-prose-not-in-name' | 'name-unit-contradicts-prose' | 'instant-unit-contradicts-schema' + | 'duration-unit-contradicts-schema' | 'unit-in-jsdoc-not-in-describe'; message: string; } @@ -393,16 +502,6 @@ export function unitsInProse(describe: string | undefined): string[] { return [...out]; } -export function isDurationShaped(key: string): boolean { - const toks = keyTokens(key); - if (toks.some((t) => DURATION_SHAPED_TOKENS.has(t))) return true; - // `maxAge` / `expireAfter` split into two tokens each; test the joined pairs too. - for (let i = 0; i + 1 < toks.length; i++) { - if (DURATION_SHAPED_TOKENS.has(toks[i] + toks[i + 1])) return true; - } - return false; -} - // ── AST ──────────────────────────────────────────────────────────────────── /** @@ -437,10 +536,12 @@ function chainInfo(expr: ts.Expression): { describes: string[]; metaDescription: string | undefined; externalVocabulary: string | undefined; + dimensionless: string | undefined; } { const describes: string[] = []; let metaDescription: string | undefined; let externalVocabulary: string | undefined; + let dimensionless: string | undefined; let cur: ts.Expression = expr; for (;;) { if (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur)) { @@ -450,12 +551,12 @@ function chainInfo(expr: ts.Expression): { if (ts.isIdentifier(cur)) { // A bare schema constant, or the receiver a chain bottomed out at: // `createdAt: EpochMs` / `createdAt: EpochMs.optional()`. - return { root: cur.text, describes, metaDescription, externalVocabulary }; + return { root: cur.text, describes, metaDescription, externalVocabulary, dimensionless }; } - if (!ts.isCallExpression(cur)) return { root: undefined, describes, metaDescription, externalVocabulary }; + if (!ts.isCallExpression(cur)) return { root: undefined, describes, metaDescription, externalVocabulary, dimensionless }; if (!ts.isPropertyAccessExpression(cur.expression)) { // `someHelper(...)` — a call whose callee is not `a.b`; not a `z.` root - return { root: undefined, describes, metaDescription, externalVocabulary }; + return { root: undefined, describes, metaDescription, externalVocabulary, dimensionless }; } const method = cur.expression.name.text; if (method === 'describe' && cur.arguments.length > 0) { @@ -482,6 +583,10 @@ function chainInfo(expr: ts.Expression): { && externalVocabulary === undefined) { externalVocabulary = value; } + if (name === DIMENSIONLESS_META_KEY && value !== undefined && value.trim() !== '' + && dimensionless === undefined) { + dimensionless = value; + } } } } @@ -491,7 +596,7 @@ function chainInfo(expr: ts.Expression): { while (ts.isPropertyAccessExpression(p)) { parts.unshift(p.name.text); p = p.expression; } if (ts.isIdentifier(p) && p.text === 'z') { // reached `z.number(...)` / `z.coerce.number(...)`: this call is the root - return { root: ['z', ...parts].join('.'), describes, metaDescription, externalVocabulary }; + return { root: ['z', ...parts].join('.'), describes, metaDescription, externalVocabulary, dimensionless }; } // otherwise `p` is the receiver of this method call — keep walking down it cur = p; @@ -543,9 +648,10 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.parent)) { const name = ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name) ? node.name.text : undefined; if (name) { - const { root, describes, metaDescription, externalVocabulary } = chainInfo(node.initializer); + const { root, describes, metaDescription, externalVocabulary, dimensionless } = chainInfo(node.initializer); const instant = root === INSTANT_ROOT; - if (root && (NUMERIC_ROOTS.has(root) || instant)) { + const durationType = root !== undefined && DURATION_ROOTS.has(root) ? root : undefined; + if (root && (NUMERIC_ROOTS.has(root) || instant || durationType !== undefined)) { const siblings = node.parent.properties; const valueUnitPair = siblings.some( (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'unit', @@ -565,9 +671,11 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey jsdocUnits: unitsInProse(jsdoc), keyUnits: unitsInKey(name), valueUnitPair, - durationShaped: isDurationShaped(name), + durationType, + typeUnits: durationType === undefined ? [] : [DURATION_ROOTS.get(durationType) as string], instant, externalVocabulary, + dimensionless, }); } } @@ -601,6 +709,36 @@ export function judge(site: DurationKey): Finding | undefined { return undefined; } + // Exemption (iv): the value IS one of the closed duration schemas, so the + // unit is declared by the TYPE — at the authoring site and in the published + // JSON schema alike. That waives the key-NAME requirement and nothing else. + // Both contradiction directions stay refusable, because a declaration that + // could never be refused is an allowlist wearing an import: `DurationMs` + // states milliseconds, so a describe that says seconds, or a name spelled + // `*Seconds`, means the site and the schema disagree and one of them is the + // 1000x bug. + if (site.durationType !== undefined) { + const declared = site.typeUnits.join('/'); + // All three channels are read here, and every one of them is read ONLY to + // refuse — the JSDoc included, which is batch #65's direction unchanged: it + // can contradict the type, it can never declare one. + const conflicting = [ + ...site.proseUnits.filter((u) => !site.typeUnits.includes(u)).map((u) => `the describe says ${u}`), + ...site.keyUnits.filter((u) => !site.typeUnits.includes(u)).map((u) => `the key name says ${u}`), + ...site.jsdocUnits.filter((u) => !site.typeUnits.includes(u)).map((u) => `the JSDoc says ${u}`), + ]; + if (conflicting.length > 0) { + return { + site, + rule: 'duration-unit-contradicts-schema', + message: `${where} — typed \`${site.durationType}\` (${declared}) but ${conflicting.join(' and ')}. ` + + `One of them is lying; either fix the prose and the name, or this is not a ${declared} duration ` + + 'and must not carry that type.', + }; + } + return undefined; + } + if (site.proseUnits.length > 0) { if (site.keyUnits.length === 0) { // Exemption (ii): the key mirrors a name fixed outside this repo, declared @@ -610,6 +748,14 @@ export function judge(site: DurationKey): Finding | undefined { // contradiction branch below is not reachable past a `return` here because // a marked key with a unit token in its NAME never takes this branch. if (site.externalVocabulary !== undefined) return undefined; + // Exemption (iii): the schema declares this number DIMENSIONLESS — a + // count, a multiplier, a ratio — so the time unit its prose names belongs + // to something else in the sentence ("events in the last 5 minutes"), not + // to the number. Like the mirror marker it waives the RENAME only: the + // contradiction branch below is still reached by a marked key that DOES + // carry a unit token in its name, because a dimensionless number spelled + // `*Ms` is two declarations that cannot both be true. + if (site.dimensionless !== undefined) return undefined; return { site, rule: 'unit-in-prose-not-in-name', @@ -633,29 +779,44 @@ export function judge(site: DurationKey): Finding | undefined { return undefined; } - // The DIVERGENCE class (#15939, ruling 2026-09-07, decision batch #65). + // The DIVERGENCE class (#15939, ruling 2026-09-07, decision batch #65), + // re-seated on the admission rule now that the name-shape list is retired. + // + // Reached only when the describe named no unit at all — every branch above + // returns for a key whose describe did, and for a key whose TYPE declares the + // unit. So what is left here is a key admitted by its NAME alone, whose JSDoc + // names a unit that name does not carry. // - // Reached only when the describe named no unit at all — the branch above - // returns for every key whose describe did. A duration-shaped key whose - // JSDoc names a unit its describe does not is refused: the two prose - // channels disagree about whether this number's unit is written down, and - // the one that is published is the one that is silent. + // ⛔ Two halves of the guard, and each one is load-bearing: // - // ⛔ The JSDoc is NEVER read as a way to SATISFY the rule — that was option - // 1 and it was not adopted. It is read in one direction only: to refuse. - // A key with no unit in EITHER channel stays "listed, not judged" (the - // #14519 shape), which is why this branch tests `jsdocUnits`, never the - // absence of `proseUnits` alone. - if (site.durationShaped && site.jsdocUnits.length > 0) { + // `keyUnits.length > 0` — the key must DECLARE something. The old guard was + // the retired name-shape flag, i.e. the key merely LOOKED like a duration, and that is + // the reading the ruling retired: a bare `timeout` whose unit lives only in + // a JSDoc is no longer admitted and is therefore no longer refused. That is + // a real narrowing of this class and it is recorded as such — see the + // header — not smuggled in as a guard that happens to be equivalent. + // + // the unit must not already BE in the name — a JSDoc that says milliseconds + // over a key called `latencyMs` diverges from nothing: the published + // reference page prints that key name, so the reader who most needs the + // unit can see it. Refusing there would manufacture offenders out of + // agreement, and the two rows in this tree that sit in exactly that shape + // (`latencyMs`, `frequencyHours`) are the measured proof of it. + // + // The JSDoc is still NEVER read as a way to SATISFY the rule — that was + // option 1 and it was not adopted. It is read in one direction only: to + // refuse. + if (site.keyUnits.length > 0 && site.jsdocUnits.length > 0 + && !site.jsdocUnits.some((u) => site.keyUnits.includes(u))) { return { site, rule: 'unit-in-jsdoc-not-in-describe', - message: `${where} — the JSDoc above the key names ${site.jsdocUnits.join('/')} but the describe names no unit` + message: `${where} — the JSDoc above the key names ${site.jsdocUnits.join('/')}, the key name says ` + + `${site.keyUnits.join('/')} and the describe names no unit` + `${site.describe === undefined ? ' (there is no describe at all)' : ` (${JSON.stringify(site.describe)})`}. ` - + 'The JSDoc is developer commentary; the describe is what `content/docs/references/**` publishes, so the ' - + 'reader who most needs the unit is the one who cannot see it. Move the unit into the describe — the ' - + 'existing rule then applies and the unit goes into the key NAME too, with an ADR-0087 conversion if the ' - + 'key is published.', + + 'The JSDoc is developer commentary; the describe and the key name are what ' + + '`content/docs/references/**` publishes, so the only unit the reader can see is the one the JSDoc ' + + 'disagrees with. One of them is wrong — fix whichever it is, and state the unit in the describe.', }; } return undefined; @@ -763,10 +924,37 @@ function selfTest(): number { expect('offender: name says Ms, describe says seconds → name-unit-contradicts-prose', rulesOf(`const S = z.object({ ttlMs: z.number().describe('Cache TTL in seconds') });`) .join() === 'name-unit-contradicts-prose'); - expect('listed, not judged: duration-shaped name with no unit anywhere (the #14519 shape) is a census row', + // ── the RETIREMENT of the name-shape list ──────────────────────────────── + // + // #18115 option A, batch #134 item 1, sharpened by batch #139 item 3 + // (maintainer 2026-09-16): 「名字表退休」. The 25-token list is deleted and a + // key that merely LOOKS like a duration declares nothing. These cases pin the + // cost as well as the rule — a repeal nobody can see in the self-test is a + // repeal that comes back as a surprise. + expect('RETIRED: a name-shaped key with no unit anywhere declares nothing — NOT admitted, NOT judged', (() => { const sites = collectDurationKeys('fixture.ts', `const S = z.object({ sessionTimeout: z.number().int().positive().default(3600).describe('Session timeout'), idleTimeout: z.number().optional() });`); - return sites.length === 2 && sites.every((x) => x.durationShaped && judge(x) === undefined); + return sites.length === 2 && sites.every((x) => !declaresUnit(x) && judge(x) === undefined); + })()); + expect('RETIRED: every token the old list carried is now inert — none of them admits a bare number', + (() => { + const names = ['timeout', 'ttl', 'interval', 'delay', 'duration', 'maxAge', 'expireAfter', 'retention', + 'cooldown', 'debounce', 'throttle', 'window', 'grace', 'lifetime', 'expiry', 'expiration', + 'heartbeat', 'backoff', 'idle', 'stale', 'age', 'period', 'every', 'wait', 'timeouts']; + const code = `const S = z.object({ ${names.map((n) => `${n}: z.number()`).join(', ')} });`; + const sites = collectDurationKeys('fixture.ts', code); + return sites.length === names.length && sites.every((x) => !declaresUnit(x) && judge(x) === undefined); + })()); + expect(`RETIRED: this file no longer contains the token list or its reader (positive control: it still contains \`${'judge'}\`)`, + (() => { + // The identifiers are assembled rather than written, so this assertion + // cannot match ITSELF and report a retirement that never happened. The + // positive control is the point: a source read that finds nothing proves + // nothing until the same read finds something it should. + const src = readFileSync(fileURLToPath(import.meta.url), 'utf8'); + const gone = ['DURATION_SHAPED' + '_TOKENS', 'is' + 'DurationShaped', 'duration' + 'Shaped']; + const control = 'export function ' + 'judge'; + return gone.every((ident) => !src.includes(ident)) && src.includes(control); })()); expect('offender through `z.int()` and `z.coerce.number()` roots', rulesOf(`const S = z.object({ a: z.int().describe('Delay in seconds'), b: z.coerce.number().describe('Delay in hours') });`) @@ -870,6 +1058,86 @@ function selfTest(): number { return sites.length === 1 && sites[0].externalVocabulary === 'RFC 9111' && sites[0].proseUnits.join() === 'seconds'; })()); + // ── exemption (iii): DIMENSIONLESS, declared on the schema ─────────────── + // + // Pinned in both directions, like every other declaration here: it waives the + // rename, and it does NOT waive a contradiction. ⚠️ Measured on this tree at + // the time it landed: ZERO keys carry it. The rows the census used to class + // dimensionless left the census with the name list instead, so this channel + // exists for the key whose PROSE names a time unit that belongs to something + // else in the sentence — the only shape the gate still refuses and the marker + // can save. + expect('exempt (iii): `.meta({ dimensionless })` waives the rename on a count whose prose names a time unit', + rulesOf(`const S = z.object({ recentFailures: z.number().describe('Failures seen in the last 5 minutes').meta({ dimensionless: 'failed attempts' }) });`) + .join() === ''); + expect('REFUSED (iii): a dimensionless key whose NAME carries a unit token is two declarations that cannot both hold', + rulesOf(`const S = z.object({ recentFailuresMs: z.number().describe('Failures seen in the last 5 minutes').meta({ dimensionless: 'failed attempts' }) });`) + .join() === 'name-unit-contradicts-prose'); + expect('REFUSED (iii): an EMPTY dimensionless marker declares nothing and exempts nothing', + rulesOf(`const S = z.object({ recentFailures: z.number().describe('Failures seen in the last 5 minutes').meta({ dimensionless: '' }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('REFUSED (iii): a non-literal dimensionless marker is unverifiable and exempts nothing', + rulesOf(`const S = z.object({ recentFailures: z.number().describe('Failures seen in the last 5 minutes').meta({ dimensionless: SOME_CONST }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('a dimensionless site is COUNTED in the census with what it counts, not vanished from it', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ recentFailures: z.number().describe('Failures seen in the last 5 minutes').meta({ dimensionless: 'failed attempts' }) });`); + return sites.length === 1 && sites[0].dimensionless === 'failed attempts' && declaresUnit(sites[0]); + })()); + + // ── the TYPE channel: the closed duration vocabulary (#18122, step ①) ──── + // + // The half of the admission rule that did not exist before this change. It is + // read exactly as the instant root is — as the identifier the value chain + // bottoms out at — so the cases that keep the instant root honest are the + // cases that keep this one honest. + expect('admitted by TYPE: a `DurationMs` key needs no unit in its name', + rulesOf(`const S = z.object({ gracePeriod: DurationMs.default(30000).describe('How long to wait before forcing the operation') });`) + .join() === ''); + expect('admitted by TYPE: a BARE `DurationSeconds` key (no chain at all)', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ refreshInterval: DurationSeconds });`); + return sites.length === 1 && sites[0].durationType === 'DurationSeconds' + && sites[0].typeUnits.join() === 'seconds' && declaresUnit(sites[0]) && judge(sites[0]) === undefined; + })()); + expect('REFUSED by TYPE: `DurationMs` whose describe says seconds → duration-unit-contradicts-schema', + rulesOf(`const S = z.object({ gracePeriod: DurationMs.describe('How long to wait, in seconds') });`) + .join() === 'duration-unit-contradicts-schema'); + expect('REFUSED by TYPE: `DurationSeconds` whose NAME says Ms → duration-unit-contradicts-schema', + rulesOf(`const S = z.object({ refreshIntervalMs: DurationSeconds.optional() });`) + .join() === 'duration-unit-contradicts-schema'); + expect('compliant by TYPE: name, describe and type all agree', + rulesOf(`const S = z.object({ refreshIntervalSeconds: DurationSeconds.describe('Refresh every N seconds') });`) + .join() === ''); + expect('the type channel is the CLOSED vocabulary alone — another identifier root stays outside the population', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ gracePeriod: PositiveInt.describe('How long to wait, in seconds') });`); + return sites.length === 0; + })()); + + // ⚠️ THE #18427 SHAPE, the reason this predicate is a chain WALK and not a + // read of the value's opening token. A predicate that judged only what a + // value STARTS with lets a live key go silent by chaining one more method + // onto it. Both of these keys are live and both must still be seen. + expect('adversarial: `DurationMs.or(z.string())` still resolves to the duration root — the chain is walked, not peeked at', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ ttl: DurationMs.or(z.string()).describe('Grace period in seconds') });`); + return sites.length === 1 && sites[0].durationType === 'DurationMs' + && judge(sites[0])?.rule === 'duration-unit-contradicts-schema'; + })()); + expect('adversarial: a unit token in the name survives an `.or()` tail too', + rulesOf(`const S = z.object({ ttlMs: z.number().or(z.string()).describe('Cache TTL in seconds') });`) + .join() === 'name-unit-contradicts-prose'); + // The documented BOUNDARY on the other side of that walk, pinned so it is a + // known edge rather than a surprise: a root wrapped in a COMBINATOR CALL + // (`z.union([...])`) is not this chain shape and is outside the population — + // for the duration roots exactly as it already was for the instant root. + expect('boundary: `z.union([DurationMs, z.string()])` is a `z.union` root and stays outside the population', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ ttl: z.union([DurationMs, z.string()]).describe('Grace period in seconds') });`); + return sites.length === 0; + })()); + // ── the DIVERGENCE class (#15939, ruling 2026-09-07, decision batch #65) ── // // The three POSITIVE CONTROLS are the three sites the card measured, reduced @@ -882,15 +1150,32 @@ function selfTest(): number { // the case below that keeps a JSDoc-plus-describe key failing // `unit-in-prose-not-in-name` is what stops this reader drifting into it. - expect('REFUSED (divergence): JSDoc names ms, describe names none → unit-in-jsdoc-not-in-describe', - rulesOf(`const S = z.object({\n /**\n * Execution timeout in milliseconds\n */\n timeout: z.number().int().min(0).optional().describe('Maximum execution time') });`) + expect('REFUSED (divergence): JSDoc says seconds, the NAME says ms, describe names none → unit-in-jsdoc-not-in-describe', + rulesOf(`const S = z.object({\n /**\n * Execution timeout in seconds\n */\n timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time') });`) .join() === 'unit-in-jsdoc-not-in-describe'); - expect('REFUSED (divergence): JSDoc names seconds, describe names none → unit-in-jsdoc-not-in-describe', - rulesOf(`const S = z.object({\n /**\n * Window size in seconds\n */\n window: z.number().int().positive().describe('Window size') });`) + expect('REFUSED (divergence): JSDoc says ms, the NAME says seconds, and there is NO describe at all', + rulesOf(`const S = z.object({\n /**\n * Export interval in milliseconds\n */\n intervalSeconds: z.number().int().positive().optional().default(60) });`) .join() === 'unit-in-jsdoc-not-in-describe'); - expect('REFUSED (divergence): JSDoc names seconds and there is NO describe at all', + + // ⚠️ THE COST OF THE RETIREMENT, pinned rather than quietly dropped. These + // three shapes were this class's original positive controls (#15939) and + // every one of them rested on the name-shape list: `timeout`, `window` and + // `interval` declare nothing, so nothing admits them any more and nothing + // refuses them. The route back is step ③'s conversion to a `Duration*` type, + // pinned two cases below. ⛔ If a future change re-admits these, it is + // re-opening a retired list — these cases go red first and say so. + expect('COST of 退休: JSDoc names ms over a bare `timeout` — no longer admitted, no longer refused', + rulesOf(`const S = z.object({\n /**\n * Execution timeout in milliseconds\n */\n timeout: z.number().int().min(0).optional().describe('Maximum execution time') });`) + .join() === ''); + expect('COST of 退休: JSDoc names seconds over a bare `window` — no longer admitted, no longer refused', + rulesOf(`const S = z.object({\n /**\n * Window size in seconds\n */\n window: z.number().int().positive().describe('Window size') });`) + .join() === ''); + expect('COST of 退休: JSDoc names seconds over a bare `interval` with no describe — no longer refused', rulesOf(`const S = z.object({\n /**\n * Export interval in seconds\n */\n interval: z.number().int().positive().optional().default(60) });`) - .join() === 'unit-in-jsdoc-not-in-describe'); + .join() === ''); + expect('the route back: the SAME key typed `DurationMs` is admitted again and its JSDoc contradiction is refused', + rulesOf(`const S = z.object({\n /**\n * Export interval in seconds\n */\n interval: DurationMs.optional().default(60) });`) + .join() === 'duration-unit-contradicts-schema'); expect('compliant (negative control): the unit is in BOTH channels and in the name', rulesOf(`const S = z.object({\n /**\n * Cache TTL in milliseconds\n */\n ttlMs: z.number().int().default(60_000).describe('Cache TTL in milliseconds') });`) @@ -909,10 +1194,10 @@ function selfTest(): number { // Unchanged by this class, and pinned again from the JSDoc side: no unit in // EITHER channel stays a census row (the #14519 shape). The divergence // branch tests for a unit IN the JSDoc, never for its absence in the describe. - expect('listed, not judged: a JSDoc that names no unit leaves the #14519 shape exactly where it was', + expect('a JSDoc that names no unit adds nothing: an undeclared key stays undeclared and unjudged', (() => { const sites = collectDurationKeys('fixture.ts', `const S = z.object({\n /**\n * Session timeout\n */\n sessionTimeout: z.number().int().positive().default(3600).describe('Session timeout') });`); - return sites.length === 1 && sites[0].durationShaped && sites[0].jsdocUnits.length === 0 && judge(sites[0]) === undefined; + return sites.length === 1 && !declaresUnit(sites[0]) && sites[0].jsdocUnits.length === 0 && judge(sites[0]) === undefined; })()); // The two ways this reader could OVER-fire, both measured against the AST @@ -935,19 +1220,22 @@ function selfTest(): number { rulesOf(`const S = z.object({\n /**\n * Heartbeats per second\n */\n heartbeat: z.number().describe('Heartbeat rate') });`) .join() === ''); - expect('the divergence class is DURATION-SHAPED only: a non-duration name with a unit in its JSDoc is not refused', + expect('the divergence class needs a DECLARATION: an undeclared name with a unit in its JSDoc is not refused', rulesOf(`const S = z.object({\n /**\n * Sampled over 30 seconds\n */\n sampleCount: z.number().describe('Samples taken') });`) .join() === ''); expect('exempt (i) survives the new class: an `EpochMs` instant with an ms JSDoc is not newly refused', rulesOf(`const S = z.object({\n /**\n * Creation timestamp in milliseconds\n */\n createdAt: EpochMs });`) .join() === ''); - expect('REFUSED (ii) extends here: an `externalVocabulary` marker waives the RENAME, never the divergence', + expect('a mirror whose name carries no unit declares no unit to diverge FROM — the marker is not what saves it', rulesOf(`const S = z.object({\n /**\n * Maximum cache age in seconds\n */\n maxAge: z.number().meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) + .join() === ''); + expect('REFUSED: a mirror whose NAME does carry a unit is still judged against its JSDoc', + rulesOf(`const S = z.object({\n /**\n * Maximum cache age in seconds\n */\n maxAgeMs: z.number().meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) .join() === 'unit-in-jsdoc-not-in-describe'); - expect('a divergent site carries its JSDoc units in the census, not just in the verdict', + expect('a site carries its JSDoc units in the census reading, not just in the verdict', (() => { - const sites = collectDurationKeys('fixture.ts', `const S = z.object({\n /**\n * Window size in seconds\n */\n window: z.number().describe('Window size') });`); + const sites = collectDurationKeys('fixture.ts', `const S = z.object({\n /**\n * Window size in seconds\n */\n windowMs: z.number().describe('Window size') });`); return sites.length === 1 && sites[0].jsdocUnits.join() === 'seconds' && sites[0].proseUnits.length === 0 && sites[0].jsdoc !== undefined && sites[0].jsdoc.includes('Window size in seconds'); })()); @@ -972,6 +1260,19 @@ function selfTest(): number { const src = readFileSync(join(pkgRoot, INSTANT_ROOT_MODULE), 'utf8'); return new RegExp(`export const ${INSTANT_ROOT}\\b`).test(src); })()); + // The same coupling for the TYPE channel, which now carries the same hazard: + // an identifier this file names and the module no longer exports is an + // admission channel that is silently empty, and every key step ③ converts + // would drop straight back out of the census with nothing going red. + for (const root of DURATION_ROOTS.keys()) { + expect(`\`${root}\` is exported from \`${DURATION_ROOT_MODULE}\``, + (() => { + const src = readFileSync(join(pkgRoot, DURATION_ROOT_MODULE), 'utf8'); + return new RegExp(`export const ${root}\\b`).test(src); + })()); + } + expect('the declared duration units are units this reader actually knows', + [...DURATION_ROOTS.values()].every((u) => Object.keys(UNIT_SPELLINGS).includes(u))); // ── the DECLARED population, held against the LIVE workspace (#15682) ──── // @@ -1058,7 +1359,7 @@ function main(argv: string[]): number { return 2; } const { sites, findings, files } = scanTree(root ? resolve(root) : undefined); - const durationSites = sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0); + const durationSites = sites.filter(declaresUnit); // The two DECLARED exemptions, counted rather than hidden. A key exempted by // a declaration stays in the census and stays countable — that is what makes @@ -1066,9 +1367,14 @@ function main(argv: string[]): number { // ruling B refused. Counted over the same `durationSites` population the // verdict line reports, so the three numbers add up on the page. const instants = durationSites.filter((s) => s.instant); + const declaredDurations = durationSites.filter((s) => s.durationType !== undefined); const mirrors = durationSites.filter((s) => !s.instant && s.externalVocabulary !== undefined); - const exemptions = `${instants.length} declared \`${INSTANT_ROOT}\` instant(s), ` - + `${mirrors.length} declared \`${EXTERNAL_VOCABULARY_META_KEY}\` mirror(s)`; + const dimensionless = durationSites.filter((s) => s.dimensionless !== undefined); + const exemptions = `${declaredDurations.length} declared duration type(s) ` + + `(${[...DURATION_ROOTS.keys()].map((r) => `\`${r}\``).join('/')}), ` + + `${instants.length} declared \`${INSTANT_ROOT}\` instant(s), ` + + `${mirrors.length} declared \`${EXTERNAL_VOCABULARY_META_KEY}\` mirror(s), ` + + `${dimensionless.length} declared \`${DIMENSIONLESS_META_KEY}\` number(s)`; if (argv.includes('--list')) { for (const s of durationSites) { @@ -1076,29 +1382,34 @@ function main(argv: string[]): number { s.jsdocUnits.length ? ` [jsdoc: ${s.jsdocUnits.join('/')}]` : '', s.valueUnitPair ? ' [value/unit pair]' : '', s.instant ? ` [instant: ${INSTANT_ROOT}]` : '', + s.durationType !== undefined ? ` [type: ${s.durationType}]` : '', s.externalVocabulary !== undefined ? ` [${EXTERNAL_VOCABULARY_META_KEY}: ${s.externalVocabulary}]` : '', + s.dimensionless !== undefined ? ` [${DIMENSIONLESS_META_KEY}: ${s.dimensionless}]` : '', ].join(''); console.log(`${s.file}:${s.line} ${s.key} [name: ${s.keyUnits.join('/') || '-'}] [prose: ${s.proseUnits.join('/') || '-'}]${marks} ${JSON.stringify(s.describe ?? null)}`); } - console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all; ${exemptions}.`); + console.log(`\n${durationSites.length} unit-declaring numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all; ${exemptions}.`); } if (findings.length === 0) { - console.log(`✓ check:duration-unit-keys — ${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`, or under a declared exemption: ${exemptions}); zero offenders, no baseline.`); + console.log(`✓ check:duration-unit-keys — ${durationSites.length} unit-declaring numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`, or under a declared exemption: ${exemptions}); zero offenders, no baseline.`); return 0; } - console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} duration-shaped numeric key(s) in ${files} source file(s) (${exemptions}):\n`); + console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} unit-declaring numeric key(s) in ${files} source file(s) (${exemptions}):\n`); for (const f of findings) console.error(` [${f.rule}] ${f.message}`); console.error( - '\nThe unit of a duration-shaped number lives in the KEY NAME (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`)' + '\nThe unit of a duration lives in the KEY NAME (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`), in its TYPE' + ' or in a unit-carrying VALUE (a duration literal, or a `{ value, unit }` pair) — never only in the describe prose,' + ' and never nowhere. There is no baseline: a published key is renamed under an ADR-0087 conversion (registry entry +' + ' a loud refusal of the old spelling naming the new key); see the header of this script.' - + '\n\nTwo structural classes are exempt, and both are DECLARED ON THE SCHEMA — there is no list to add a key to:' + + '\n\nFour structural classes are exempt, and every one is DECLARED ON THE SCHEMA — there is no list to add a key to:' + `\n - an epoch INSTANT is typed \`${INSTANT_ROOT}\` (\`${INSTANT_ROOT_MODULE}\`) and named \`*At\`;` + + `\n - a DURATION may state its unit through its type instead of its name — ${[...DURATION_ROOTS.keys()].map((r) => `\`${r}\``).join(' / ')}` + + ` (\`${DURATION_ROOT_MODULE}\`), which waives the rename and nothing else;` + `\n - a key mirroring a name fixed outside this repo carries \`.meta({ ${EXTERNAL_VOCABULARY_META_KEY}: '' })\`,` - + ' which the reference page prints as "unit per ".' - + '\nIf the offender above is neither, it is a rename.', + + ' which the reference page prints as "unit per ";' + + `\n - a DIMENSIONLESS number — a count, a multiplier, a ratio — carries \`.meta({ ${DIMENSIONLESS_META_KEY}: '' })\`.` + + '\nIf the offender above is none of them, it is a rename.', ); return 1; } From 750aa5b0954a31434d6c6df428478fad8b6035ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:32:48 +0000 Subject: [PATCH 2/2] spec(gate): declare the agreement carve-out as deferred and pin it in both directions The name-list retirement repealed a second base refusal that the header, the judge() comment and the PR body did not name: a key whose stem was in the retired list AND whose name carries a unit token, whose JSDoc names the SAME unit and whose describe names none (timeoutMs + JSDoc "in milliseconds" + describe 'Maximum execution time'; intervalSeconds + JSDoc "in seconds" + no describe). The base guard was the name-shape predicate, whose reach was the STEM, so both satisfied it; the head lets them through via the agreement carve-out on the divergence branch. Documentation and pins only -- no behaviour change, census stays 185/0. - header: name both repealed shapes and state that the second is DEFERRED to #18075, not decided correct. The retired predicate is described rather than named because this file's own retirement pin asserts that identifier is gone from the source. - judge() comment: replace the argument that refusing agreement "would manufacture offenders" -- which adjudicated open card #18075 -- with a deferral naming it. - two self-test cases labelled DEFERRED to #18075 pin both fixtures as '' today, so deleting the carve-out goes red. It reded 0 cases before. - de-vacuify the DurationMs type-channel case: rulesOf() returns '' both when a key is admitted and when it is outside the population, so assert durationType and typeUnits the way the DurationSeconds case already does. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../spec/scripts/check-duration-unit-keys.ts | 89 +++++++++++++++---- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index c646c682e4..556ad8fa4a 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -113,17 +113,36 @@ * * ⚠️ WHAT THE NAME-LIST RETIREMENT COST THIS CLASS, stated plainly because a * silent repeal is the worse outcome. The divergence branch used to be guarded - * by the name-shape list, so its whole live population was keys like `timeout`, - * `window` and `interval` — names that declare nothing. Those keys are no - * longer admitted, so the branch can no longer reach them: a bare `timeout` - * whose unit is written only in a JSDoc is now neither listed nor refused. What - * survives is the half that rests on a declaration — a key whose NAME carries a - * unit, whose describe names none, and whose JSDoc names a DIFFERENT unit. The - * two channels still disagree and the disagreement is still refused; the shape - * where nothing was declared in the first place went out with the list. The - * route back for such a key is step ③'s conversion to a `Duration*` type, at - * which point the schema declares the unit and the contradiction branch reaches - * it again. + * by the retired name-shape predicate — the `duration`-shaped-name flag, + * spelled out here rather than named because the retirement pin below asserts + * that identifier is gone from this file — and that guard's reach was WIDER + * than the bare list words: it admitted any key whose STEM was in the list, + * unit suffix or not. So TWO shapes this branch used to refuse are + * no longer refused here, and both are named rather than one: + * + * (a) a bare list-shaped key that declares nothing (`timeout`, `window`, + * `interval`) whose unit lives only in a JSDoc. It is no longer admitted, + * so the branch can no longer reach it — the direct, intended cost of + * retiring the list, and the route back is step ③'s conversion to a + * `Duration*` type, at which point the schema declares the unit and the + * contradiction branch reaches the key again. + * + * (b) a key whose stem was in the retired list AND whose name carries a unit + * token, whose JSDoc names the SAME unit and whose describe names none + * (`timeoutMs` + JSDoc "in milliseconds" + describe 'Maximum execution + * time'; `intervalSeconds` + JSDoc "in seconds" + no describe). This one + * is still ADMITTED — its NAME declares a unit — and it goes unrefused + * because of the agreement carve-out on the branch below, ⛔ not because + * the retirement removed it from the population. It is DEFERRED to + * #18075, ⛔ not decided correct here: refusing the agreement shape today + * reds `latencyMs` / `frequencyHours` on `main` (that card's ordering + * constraint — remediation before widening), and whether agreement is an + * offence at all is that card's open question. + * + * What survives as a live refusal is the half resting on a declaration the + * JSDoc CONTRADICTS — a key whose NAME carries a unit, whose describe names + * none, and whose JSDoc names a DIFFERENT unit. Those two channels disagree and + * the disagreement is still refused. * * Why the divergence is worth a refusal and the blindness was not: the card * that filed it measured the cost. #15678 recorded in its changeset that @@ -797,11 +816,14 @@ export function judge(site: DurationKey): Finding | undefined { // header — not smuggled in as a guard that happens to be equivalent. // // the unit must not already BE in the name — a JSDoc that says milliseconds - // over a key called `latencyMs` diverges from nothing: the published - // reference page prints that key name, so the reader who most needs the - // unit can see it. Refusing there would manufacture offenders out of - // agreement, and the two rows in this tree that sit in exactly that shape - // (`latencyMs`, `frequencyHours`) are the measured proof of it. + // over a key called `latencyMs` names no unit the key name does not already + // carry, and the published reference page prints that key name. ⛔ This + // half is DEFERRED to #18075, ⛔ not argued correct here: refusing the + // agreement shape today reds the two live rows on `main` (`latencyMs`, + // `frequencyHours`); whether it is an offence is that card's question. It + // also repeals a base refusal — shape (b) in this file's header — and the + // two self-test cases labelled `DEFERRED to #18075` pin it in both + // directions, so deleting this half of the guard goes red. // // The JSDoc is still NEVER read as a way to SATISFY the rule — that was // option 1 and it was not adopted. It is read in one direction only: to @@ -1091,9 +1113,17 @@ function selfTest(): number { // read exactly as the instant root is — as the identifier the value chain // bottoms out at — so the cases that keep the instant root honest are the // cases that keep this one honest. + // ⛔ Asserted through `durationType`, never through `rulesOf` alone: a key + // that is OUTSIDE the population also yields '', so a bare `rulesOf(...) === + // ''` here passes just as well when the type channel is ablated away. The + // `DurationSeconds` case below already asserts the root; this one now does + // too, so both legs of the vocabulary are pinned to a reading that can fail. expect('admitted by TYPE: a `DurationMs` key needs no unit in its name', - rulesOf(`const S = z.object({ gracePeriod: DurationMs.default(30000).describe('How long to wait before forcing the operation') });`) - .join() === ''); + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ gracePeriod: DurationMs.default(30000).describe('How long to wait before forcing the operation') });`); + return sites.length === 1 && sites[0].durationType === 'DurationMs' + && sites[0].typeUnits.join() === 'ms' && declaresUnit(sites[0]) && judge(sites[0]) === undefined; + })()); expect('admitted by TYPE: a BARE `DurationSeconds` key (no chain at all)', (() => { const sites = collectDurationKeys('fixture.ts', `const S = z.object({ refreshInterval: DurationSeconds });`); @@ -1157,6 +1187,29 @@ function selfTest(): number { rulesOf(`const S = z.object({\n /**\n * Export interval in milliseconds\n */\n intervalSeconds: z.number().int().positive().optional().default(60) });`) .join() === 'unit-in-jsdoc-not-in-describe'); + // ⚠️ THE AGREEMENT CARVE-OUT — DEFERRED to #18075, pinned here so it cannot + // move silently. These are the two fixtures directly above with ONE word + // changed: the JSDoc names the SAME unit the key name already carries. The + // base gate refused both as `unit-in-jsdoc-not-in-describe` — its guard was + // the retired name-shape predicate, whose reach was the STEM, so `timeoutMs` + // and `intervalSeconds` both satisfied it. They pass here, and they pass because + // of the `!jsdocUnits.some(...)` half of the guard below, ⛔ NOT because the + // retirement removed them from the population and ⛔ NOT because agreement + // has been ruled not to be an offence. + // + // #18075 holds the opposite and asked for exactly the first fixture as a + // POSITIVE control. Refusing it today reds `latencyMs` / `frequencyHours` on + // `main` — that card's ordering constraint, remediation before widening — so + // this is a sequencing accommodation with a card attached, not a decision. + // ⛔ Delete the carve-out and BOTH of these go red: that is what they are + // for, and it is what was missing when this repeal first landed unnoticed. + expect('DEFERRED to #18075: `timeoutMs` + JSDoc naming the SAME unit (ms) + describe naming none — base REFUSED this, head does not', + rulesOf(`const S = z.object({\n /**\n * Execution timeout in milliseconds\n */\n timeoutMs: z.number().int().min(0).optional().describe('Maximum execution time') });`) + .join() === ''); + expect('DEFERRED to #18075: `intervalSeconds` + JSDoc naming the SAME unit (seconds) + NO describe — base REFUSED this, head does not', + rulesOf(`const S = z.object({\n /**\n * Export interval in seconds\n */\n intervalSeconds: z.number().int().positive().optional().default(60) });`) + .join() === ''); + // ⚠️ THE COST OF THE RETIREMENT, pinned rather than quietly dropped. These // three shapes were this class's original positive controls (#15939) and // every one of them rested on the name-shape list: `timeout`, `window` and