diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index ce7605e4d38..556ad8fa4ab 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,42 @@ * 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 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 @@ -105,11 +152,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 +178,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 +365,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 +382,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 +413,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 +445,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 +490,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 +521,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 +555,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 +570,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 +602,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 +615,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 +667,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 +690,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 +728,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 +767,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 +798,47 @@ 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` 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 + // 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 +946,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 +1080,94 @@ 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. + // ⛔ 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', + (() => { + 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 });`); + 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 +1180,55 @@ 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 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 + // `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 +1247,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 +1273,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 +1313,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 +1412,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 +1420,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 +1435,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; }