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;
}