Skip to content

Commit 4844840

Browse files
os-billclaude
andauthored
feat(spec): refuse a duration key whose JSDoc names a unit its describe does not (#17635)
Part of #15939 The `check:duration-unit-keys` widening, landing **last** as ruling A sequenced it. All seven remediation cards (#17780–#17786) have merged, so the population this rule adds is already remediated and the widened gate reads **zero offenders** on the merged tree. ## The ruling this executes Option **2** of #15939, recorded 2026-09-07 (decision batch #65), maintainer 「同意」; sequencing set by **ruling A**, director seat 2026-09-11, maintainer 「同意」 (decision batch #115): > **Ruling A.** Batch #65's direction stands (the gate refuses a duration key whose JSDoc names a unit its describe does not). Sequencing: the remediation lands **first, per file** … and PR **#17635** (the gate + self-test + the two corrected prose sites) lands **last**, into a tree it already reads as clean. ⛔ Not B (18 published-key renames in one PR); ⛔ C is gate weakening. JSDoc is developer commentary and is **not** "prose" in the sense of the #14478 rule, so the gate does **not** start reading JSDoc as a unit channel — option 1 was explicitly not adopted. What it refuses is the **divergence**: a duration-shaped numeric key whose JSDoc names a unit and whose `.describe()` names none (or which has no describe at all). ⛔ **The JSDoc is read in exactly one direction: to refuse, never to satisfy.** A key with no unit in *either* channel is still listed and still not judged (the #14519 shape, unmoved). Two self-test cases pin that direction and neither was relaxed to reach zero: ``` ✓ the JSDoc does NOT satisfy the rule: describe names the unit, name does not → still unit-in-prose-not-in-name ✓ listed, not judged: a JSDoc that names no unit leaves the #14519 shape exactly where it was ``` ## What changed while this PR waited Merged `origin/main` in (⛔ never rebased, never force-pushed — the branch is `os-bill`'s). Merge base **`b06b2db5c4`**, two commits past the `6d647858b7` the dispatch recorded; both land in `packages/spec/src/` and neither introduces an offender. **The diff is now 4 files, not 5.** #17635's `metrics.test.ts` half was **obsolete, not merely conflicted**: #17783 rewrote the enclosing `it(...)`, moved the same correction into a narrowed header comment, and renamed the key itself. Verified by content rather than line number — the hunk's exact target text (`outside the gate population entirely and keeps its bare name`) returns **0** occurrences on the merged tree, while the bare phrase `outside the gate population entirely` returns **1**, now quoted as the *superseded* reading. Lit control `burn-rate` **2**, dark control (token invented at read time) **0**. That half is dropped; nothing is lost. **`packages/spec/src/migrations/registry.ts` is generated and was NOT resolved textually.** It text-merged without a conflict, which is exactly the state AGENTS.md §10 says never to trust, so it was regenerated with `pnpm gen:migration-registry` and proved byte-identical to the merged bytes (blob `cef78f2140` before and after). `check:migration-registry` then read: ``` ✓ src/migrations/registry.ts is current (215 semantic, 186 retired-key, 178 retired-def) ``` The only conflict was `metrics.test.ts`, resolved to main's side. `os-regen-merge.sh` stopped with *"✗ merge stopped on conflicts in NON-generated files — resolve those by hand"* — **correct for this merge**, since the sole conflict really was a hand-written file. The #18047 misclassification did not manifest here. ## The prose repair — one site, not two The blindness did not merely miss keys, it produced confident wrong prose about why they were missed. One site still carried it. **The replacement text #17635 was carrying had itself gone stale** and would have landed already wrong: it described the neighbouring `RuntimeConfig.resourceLimits.timeout` as a key whose "describe names no unit at all", "inside the gate's census and outside its verdict", and "a rename waiting on that gate change". On this tree that key is **already renamed** — `timeoutMs … .describe('Maximum execution time in milliseconds')` at `plugin-security-advanced.zod.ts:316-317`, its tombstone at `:320`, its retirement prose at `:185`. The note now repairs the *original* wrong reason without re-asserting a landed rename as pending, and points at the neighbour's own entry instead of restating its story. It spells no package version, so it does not join the `@objectstack/spec 18` class filed as #18040. `registry.ts` regenerated to mirror it. ⛔ No pin assertion, title or body changed. The second site — the `metrics.test.ts` burn-rate pin — was corrected by #17783 when it renamed that key, so nothing is owed there. ## The gate reading, from the right instrument ⛔ `check:duration-unit-keys` on plain `main` proves nothing here: `main` carries the *old* gate. This is **this branch's widened gate on the merged tree**: ``` ✓ check:duration-unit-keys — 211 duration-shaped numeric key(s) across 2482 source file(s) all carry their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)); zero offenders, no baseline. exit 0 ``` **A zero is only as good as the proof the instrument can still say non-zero.** Lit control, drawn a different way than the self-test — a real on-disk mutation of a real source file, not a synthetic fixture: the unit was stripped from `resourceLimits.timeoutMs`'s describe, leaving its JSDoc naming milliseconds. Blob `160ab186` → `b7d06799`, deleted anchor `0` / injected anchor `1`. The gate then: ``` ✗ check:duration-unit-keys — 1 offender(s) among 211 duration-shaped numeric key(s) in 2482 source file(s) [unit-in-jsdoc-not-in-describe] packages/spec/src/kernel/plugin-security-advanced.zod.ts:316 `timeoutMs` — the JSDoc above the key names ms but the describe names no unit ("Maximum execution time"). exit 1 ``` The **new** rule class fires, on the real tree walk, at the exact mutated key, with the census unchanged at 211/2482 — so only the verdict moved. Restored and proved restored by observed state: on-disk blob back to `160ab186`, `git diff HEAD` empty, working tree clean. The mutation ran under a `trap … EXIT INT TERM` with an absolute path. **Population, measured here rather than relayed:** 18 renames each carrying a `retiredKey()` tombstone entry naming #15939 on `origin/main`, plus 3 describe-only corrections = **21**. Counted with an instrument independent of the gate (tombstone entry files), lit control 15 (`#15678` entries), dark control 0. ## Ruling A's objectui pre-check — inapplicable, and why Ruling A requires a `git grep` of `objectui` at the pinned SHA before landing a **published-key rename** (AGENTS.md Post-Task Checklist step 4). **This PR renames nothing.** The 18 renames all landed in the seven remediation cards, each of which owed that check on its own landing. Measured here: every changed line under `packages/spec/src/` is a **comment** — the non-comment changed-line set is empty. So the check is inapplicable rather than done, and it is ⛔ not silently skipped. ## Verification Heavy runs through `scripts/pm/os-verify-lock.sh` (`OS_VERIFY_LOCK_SLOT=issue-15939-dev`), verdicts quoted from the lock's own `VERDICT command-exit` line, ⛔ never a bare `$?`. | check | result | |:--|:--| | `pnpm --filter '@objectstack/spec^...' build` | `VERDICT command-exit 0` — **empty closure** (`No projects matched`); `packages/spec` has no workspace dependencies, so this is a declared no-op, ⛔ not counted as a pass | | `pnpm --filter @objectstack/spec build` | `VERDICT command-exit 0` (199s) — run before every gate that reads `dist/` | | `pnpm --filter @objectstack/spec typecheck` | `VERDICT command-exit 0` — `tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck` | | `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` — **476 files / 13578 tests passed** | | `check:duration-unit-keys --self-test` | exit 0 — **156 cases, 0 failures**; the two one-directional pins quoted above intact | | `check:duration-unit-keys` (widened, merged tree) | **exit 0, zero offenders**, + the lit/dark controls above | | `check:migration-registry` | exit 0 — *"registry.ts is current (215 semantic, 186 retired-key, 178 retired-def)"* | | `check:generated` | exit 0 — **all 15** generated artifacts up to date | | `dispatch-gates.mjs --ran` | **82 derived, 81 run, 1 NOT-MEASURED, 0 UNRUN** | Gate family derived **after** the merge and changeset existed, with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, and reconciled with `--ran` carrying a recorded exit code per family. Two prerequisites were built rather than counted green (`@objectstack/formula`, `@objectstack/lint`, `@objectstack/objectql`), after which `check:doc-formula-expressions` and `check:lean-entry-closure` both read exit 0. **⊘ NOT MEASURED — declared, ⛔ not counted green:** - `pnpm check:dual-build-cjs-loads` — **exit 3, `PREREQUISITE NOT MET`**: reads built output for 87 packages and needs a whole-repo `pnpm build`. That is CI's `Build Core`. Nothing was measured; this is neither a pass nor a finding. - `pnpm check:pm-dispatch-gates` — **exit 124**, my own `timeout` kill at 560s under the container's foreground cap, ⛔ not a gate verdict. Its partial output showed no failing case, but no verdict line was reached. Note the reconciler derives NOT-MEASURED only from exit 3, so it counts this family as run; it is declared here instead. The repo-wide farm (`pnpm lint` and the 48 artifact-roster, 11 wide-population and 5 path-scheduled CI families the derivation names as outside its total) is CI's run. This narrowing is declared, not silent. ## Changeset `.changeset/15939-duration-unit-keys-jsdoc-divergence.md`, `@objectstack/spec: patch`. Rewritten against the merged tree: it had claimed a live *"0 → 21 offenders"* population delta and *"two wrongly-recorded reasons repaired"*, both false now that the remediation has landed and one of the two sites belongs to #17783. ⛔ No published key, accept set, default or runtime behaviour moves in this diff. --- ## Gate declaration for this PR - **Clause-②: no** **Why `no` is the honest answer for THIS diff**, re-measured after the merge: the four files are the gate script (`packages/spec/scripts/**`, not `src/**`), one comment-only prose repair, the regenerated migration registry mirroring it, and the changeset. Mechanically checked — **every changed line under `packages/spec/src/` is a comment**; the non-comment changed-line set is empty. No new exported symbol, no new key on a published payload, and no accept/reject outcome moved. What the diff does is make an existing gate refuse *more* inputs, which **narrows** the accept set; narrowing is the semantic face, never clause ②. `check:pm-widening-tells` reads exit 0. ⚠️ The path leg of the enqueue gate still fires (three of the paths are under `packages/spec/`), so an in-seat contract review at `CONTRACT_REVIEW_TIER` is owed before this may turn ready or enqueue. Scheduled, not waived — and this round ⛔ did not turn it ready, ⛔ did not enqueue it, and ⛔ did not touch `needs:contract-review`. Prepared by the `os-dev` seat in session `session_015c5G6TmpMKgnusmTpD7Ntt`; merge commit `d9b3fd5a3d`, merge base `b06b2db5c4`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d288d4 commit 4844840

4 files changed

Lines changed: 261 additions & 11 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`check:duration-unit-keys` refuses a duration key whose JSDoc names a unit its describe does not
6+
7+
The gate read a key's unit from `.describe()` and `.meta({ description })` only.
8+
A duration-shaped `z.number()` whose unit was written solely in the JSDoc block
9+
above it appeared in `--list` as a census row with `[prose: -]` and was never
10+
judged — and its own self-test pins *"a describe declared through
11+
`.meta({ description })` is READ — no exemption by blindness"*, which made the
12+
JSDoc blindness read as deliberate, measured coverage.
13+
14+
**Ruled 2026-09-07 (decision batch #65).** JSDoc is developer commentary, not
15+
governed prose: `.describe()` is what `content/docs/references/**` renders and
16+
what rides into the published dist, and the JSDoc stops at the source file. So
17+
the gate does **not** start reading JSDoc as a unit channel — a unit written
18+
only there still has not satisfied the rule. What it now refuses is the
19+
DIVERGENCE: the JSDoc names a unit and the describe names none (or there is no
20+
describe at all), so the two channels disagree about whether this number's unit
21+
is written anywhere a reader can reach, and the channel that is silent is the
22+
published one. New rule `unit-in-jsdoc-not-in-describe`; the remedy is to move
23+
the unit into the describe, where the existing rule then puts it in the key
24+
name.
25+
26+
⛔ **The JSDoc is read in exactly one direction: to refuse, never to satisfy.**
27+
A duration-shaped key with no unit in *either* channel is still listed and
28+
still not judged (the #14519 shape, unmoved). The new branch tests for a unit
29+
PRESENT in the JSDoc; it never tests for one absent from the describe, which is
30+
what would have made it the option the ruling declined.
31+
32+
**The population this rule adds was remediated before the rule landed.** When
33+
the gate was written it found **21** offenders. Ruling A on #15939 sequenced
34+
those out of this change and into seven per-file cards (#17780–#17786), all
35+
merged: eighteen were renames of published keys, each carrying its own ADR-0087
36+
conversion and `retiredKey()` tombstone, and the other three needed only their
37+
describe corrected. On this tree the gate reads **zero offenders** among **211**
38+
duration-shaped numeric keys across **2482** source files (6 declared `EpochMs`
39+
instants, 11 declared `externalVocabulary` mirrors). ⛔ **No offender was
40+
exempted to reach that zero** — there is no baseline in this gate by ruling, and
41+
none was added.
42+
43+
**One wrongly-recorded reason repaired, comment-only.** The blindness did not
44+
merely miss keys, it produced confident wrong prose about why they were missed:
45+
the retired-key entry for `SandboxConfig:process.timeout` said the neighbouring
46+
`RuntimeConfig.resourceLimits.timeout` was "outside the gate's population", when
47+
that key was inside the census and merely never judged — its unit lived in a
48+
source JSDoc only. That note now records the true reason, and points at the
49+
neighbour's own entry rather than describing a landed rename as pending.
50+
`registry.ts` regenerated to mirror it. The same wrong reason in the
51+
`metrics.test.ts` burn-rate pin was corrected by #17783 when it renamed that
52+
key, so nothing is owed there.
53+
54+
⛔ No published key, accept set, default or runtime behaviour moves.

‎packages/spec/scripts/check-duration-unit-keys.ts‎

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,44 @@
6767
* talking about time. `--list` still prints the unit-nowhere keys so the
6868
* population stays visible; closing it is a describe-by-describe decision.
6969
*
70+
* ⛔ "No unit anywhere" now means no unit in EITHER prose channel — see the
71+
* JSDoc section below. A key whose describe is silent but whose JSDoc names a
72+
* unit is not this shape at all: its unit IS written down, just not where the
73+
* reader can see it, and that is the divergence class rather than this one.
74+
*
75+
* ## The SECOND prose channel: a JSDoc that names a unit the describe does not
76+
*
77+
* A key's unit can be written in two places, and only one of them is governed.
78+
* `.describe()` / `.meta({ description })` is what `content/docs/references/**`
79+
* renders and what rides into the published dist; the JSDoc block above the key
80+
* is developer commentary that stops at the source file. Ruled 2026-09-07
81+
* (decision batch #65, on #15939): JSDoc is NOT "prose" in the sense of this
82+
* rule, so this gate does not read it as a unit channel — a key whose unit
83+
* lives only in a JSDoc has NOT satisfied the rule, and option 1 of that card
84+
* ("read the JSDoc too") was not adopted.
85+
*
86+
* What the ruling did adopt is the DIVERGENCE: when the JSDoc names a unit and
87+
* the describe names none (or there is no describe at all), the two channels
88+
* disagree about whether this number's unit is written down anywhere a reader
89+
* can reach — and the channel that is silent is the published one. That is
90+
* refused as `unit-in-jsdoc-not-in-describe`, and the remedy is to move the
91+
* unit into the describe, where the rule above then applies and puts it in the
92+
* key NAME.
93+
*
94+
* ⛔ SO THE JSDoc IS READ IN EXACTLY ONE DIRECTION: to refuse, never to
95+
* satisfy. Nothing about the #14519 shape moves — a duration-shaped key with
96+
* no unit in EITHER channel is still listed and still not judged. The
97+
* divergence branch tests for a unit PRESENT in the JSDoc; it never tests for
98+
* one absent from the describe, which is what would have made it option 1.
99+
*
100+
* Why the divergence is worth a refusal and the blindness was not: the card
101+
* that filed it measured the cost. #15678 recorded in its changeset that
102+
* `RuntimeConfig.resourceLimits.timeout` "names no unit anywhere in its prose"
103+
* — and the JSDoc two lines above it says milliseconds. The blindness did not
104+
* merely miss the key; it produced a confident, wrong, PINNED explanation of
105+
* why it was missed. A gate that cannot see a channel writes falsehoods about
106+
* it.
107+
*
70108
* ## The two exemptions, DECLARED ON THE SCHEMA (#15676, ruling B)
71109
*
72110
* The rule governs every authored and every runtime-emitted duration MINUS two
@@ -304,6 +342,10 @@ export interface DurationKey {
304342
describe: string | undefined;
305343
/** units the describe prose names (canonical) */
306344
proseUnits: string[];
345+
/** the JSDoc block written immediately above the key, when there is one */
346+
jsdoc: string | undefined;
347+
/** units that JSDoc block names (canonical) — read ONLY to refuse a divergence, never to satisfy the rule */
348+
jsdocUnits: string[];
307349
/** units the key name carries (canonical) */
308350
keyUnits: string[];
309351
/** true when a sibling `unit` key sits on the same object literal */
@@ -320,7 +362,8 @@ export interface Finding {
320362
rule:
321363
| 'unit-in-prose-not-in-name'
322364
| 'name-unit-contradicts-prose'
323-
| 'instant-unit-contradicts-schema';
365+
| 'instant-unit-contradicts-schema'
366+
| 'unit-in-jsdoc-not-in-describe';
324367
message: string;
325368
}
326369

@@ -467,6 +510,31 @@ function concatLiteral(e: ts.Expression): string | undefined {
467510
return undefined;
468511
}
469512

513+
/**
514+
* The JSDoc block written immediately above a property — the SECOND prose
515+
* channel, read only so a divergence can be refused (#15939, ruling 2026-09-07).
516+
*
517+
* Read through `ts.getJSDocCommentsAndTags`, not through a leading-comment scan,
518+
* because the two differ exactly where it matters. A `//` line comment above a
519+
* key is NOT a JSDoc block and must not be read as one, and — the hazard that
520+
* would make this reader silently over-fire — an enclosing declaration's JSDoc
521+
* must not be inherited by the first property of the object literal it
522+
* introduces. Both are measured: a schema whose own docblock says
523+
* "timeouts in milliseconds" contributes NOTHING to the bare `timeout` key
524+
* declared first inside it, and the self-test pins that direction.
525+
*
526+
* The whole block's SOURCE TEXT is taken (leading asterisks, tags and all)
527+
* rather than just the description: a unit named in an `@default 60 seconds`
528+
* tag is the same divergence as one named in the summary line, and
529+
* {@link unitsInProse}'s word-boundary matching is unbothered by the
530+
* comment punctuation carried along with it.
531+
*/
532+
function jsdocTextOf(node: ts.Node, sf: ts.SourceFile): string | undefined {
533+
const docs = ts.getJSDocCommentsAndTags(node).filter((d): d is ts.JSDoc => ts.isJSDoc(d));
534+
if (docs.length === 0) return undefined;
535+
return docs.map((d) => d.getText(sf)).join('\n');
536+
}
537+
470538
/** Every numeric-chain property in one source text. */
471539
export function collectDurationKeys(fileName: string, code: string): DurationKey[] {
472540
const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, /* setParentNodes */ true, ts.ScriptKind.TS);
@@ -486,12 +554,15 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey
486554
// what every site in this tree writes, and where a key carries both,
487555
// the describe is the one an author reads at the declaration.
488556
const describe = describes.length ? describes[describes.length - 1] : metaDescription;
557+
const jsdoc = jsdocTextOf(node, sf);
489558
out.push({
490559
file: fileName,
491560
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
492561
key: name,
493562
describe,
494563
proseUnits: unitsInProse(describe),
564+
jsdoc,
565+
jsdocUnits: unitsInProse(jsdoc),
495566
keyUnits: unitsInKey(name),
496567
valueUnitPair,
497568
durationShaped: isDurationShaped(name),
@@ -561,6 +632,32 @@ export function judge(site: DurationKey): Finding | undefined {
561632
}
562633
return undefined;
563634
}
635+
636+
// The DIVERGENCE class (#15939, ruling 2026-09-07, decision batch #65).
637+
//
638+
// Reached only when the describe named no unit at all — the branch above
639+
// returns for every key whose describe did. A duration-shaped key whose
640+
// JSDoc names a unit its describe does not is refused: the two prose
641+
// channels disagree about whether this number's unit is written down, and
642+
// the one that is published is the one that is silent.
643+
//
644+
// ⛔ The JSDoc is NEVER read as a way to SATISFY the rule — that was option
645+
// 1 and it was not adopted. It is read in one direction only: to refuse.
646+
// A key with no unit in EITHER channel stays "listed, not judged" (the
647+
// #14519 shape), which is why this branch tests `jsdocUnits`, never the
648+
// absence of `proseUnits` alone.
649+
if (site.durationShaped && site.jsdocUnits.length > 0) {
650+
return {
651+
site,
652+
rule: 'unit-in-jsdoc-not-in-describe',
653+
message: `${where} — the JSDoc above the key names ${site.jsdocUnits.join('/')} but the describe names no unit`
654+
+ `${site.describe === undefined ? ' (there is no describe at all)' : ` (${JSON.stringify(site.describe)})`}. `
655+
+ 'The JSDoc is developer commentary; the describe is what `content/docs/references/**` publishes, so the '
656+
+ 'reader who most needs the unit is the one who cannot see it. Move the unit into the describe — the '
657+
+ 'existing rule then applies and the unit goes into the key NAME too, with an ADR-0087 conversion if the '
658+
+ 'key is published.',
659+
};
660+
}
564661
return undefined;
565662
}
566663

@@ -773,6 +870,88 @@ function selfTest(): number {
773870
return sites.length === 1 && sites[0].externalVocabulary === 'RFC 9111' && sites[0].proseUnits.join() === 'seconds';
774871
})());
775872

873+
// ── the DIVERGENCE class (#15939, ruling 2026-09-07, decision batch #65) ──
874+
//
875+
// The three POSITIVE CONTROLS are the three sites the card measured, reduced
876+
// to their shape. They are the reason this class exists, so they are pinned
877+
// here rather than described: if the reader ever stops seeing them, these
878+
// cases go red instead of the population quietly shrinking by three.
879+
//
880+
// ⛔ The direction is load-bearing. The JSDoc is read ONLY to refuse, never
881+
// to satisfy — option 1 (read JSDoc as a unit channel) was NOT adopted, and
882+
// the case below that keeps a JSDoc-plus-describe key failing
883+
// `unit-in-prose-not-in-name` is what stops this reader drifting into it.
884+
885+
expect('REFUSED (divergence): JSDoc names ms, describe names none → unit-in-jsdoc-not-in-describe',
886+
rulesOf(`const S = z.object({\n /**\n * Execution timeout in milliseconds\n */\n timeout: z.number().int().min(0).optional().describe('Maximum execution time') });`)
887+
.join() === 'unit-in-jsdoc-not-in-describe');
888+
expect('REFUSED (divergence): JSDoc names seconds, describe names none → unit-in-jsdoc-not-in-describe',
889+
rulesOf(`const S = z.object({\n /**\n * Window size in seconds\n */\n window: z.number().int().positive().describe('Window size') });`)
890+
.join() === 'unit-in-jsdoc-not-in-describe');
891+
expect('REFUSED (divergence): JSDoc names seconds and there is NO describe at all',
892+
rulesOf(`const S = z.object({\n /**\n * Export interval in seconds\n */\n interval: z.number().int().positive().optional().default(60) });`)
893+
.join() === 'unit-in-jsdoc-not-in-describe');
894+
895+
expect('compliant (negative control): the unit is in BOTH channels and in the name',
896+
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') });`)
897+
.join() === '');
898+
expect('compliant: JSDoc names a unit the describe ALSO names — no divergence, nothing to refuse',
899+
rulesOf(`const S = z.object({\n /**\n * Duration in milliseconds\n */\n durationMs: z.number().describe('Elapsed time in milliseconds') });`)
900+
.join() === '');
901+
902+
// ⛔ The JSDoc never SATISFIES the rule. A key whose describe names the unit
903+
// and whose name does not is still a rename, JSDoc or no JSDoc — otherwise
904+
// this reader would have quietly implemented option 1 by the back door.
905+
expect('the JSDoc does NOT satisfy the rule: describe names the unit, name does not → still unit-in-prose-not-in-name',
906+
rulesOf(`const S = z.object({\n /**\n * Cache TTL in seconds\n */\n ttl: z.number().describe('Cache TTL in seconds') });`)
907+
.join() === 'unit-in-prose-not-in-name');
908+
909+
// Unchanged by this class, and pinned again from the JSDoc side: no unit in
910+
// EITHER channel stays a census row (the #14519 shape). The divergence
911+
// branch tests for a unit IN the JSDoc, never for its absence in the describe.
912+
expect('listed, not judged: a JSDoc that names no unit leaves the #14519 shape exactly where it was',
913+
(() => {
914+
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') });`);
915+
return sites.length === 1 && sites[0].durationShaped && sites[0].jsdocUnits.length === 0 && judge(sites[0]) === undefined;
916+
})());
917+
918+
// The two ways this reader could OVER-fire, both measured against the AST
919+
// rather than assumed. Either one would manufacture offenders out of prose
920+
// that is not attached to the key at all.
921+
expect('a `//` line comment above a key is NOT a JSDoc block and is not read as one',
922+
rulesOf(`const S = z.object({\n // Execution timeout in milliseconds\n timeout: z.number().describe('Maximum execution time') });`)
923+
.join() === '');
924+
expect('an ENCLOSING declaration\'s JSDoc is not inherited by the first property inside it',
925+
rulesOf(`/**\n * The whole schema, timeouts in milliseconds\n */\nexport const S = z.object({ timeout: z.number().describe('Maximum execution time') });`)
926+
.join() === '');
927+
928+
// The idiom suppressions that keep `unitsInProse` honest apply to this
929+
// channel too — it is the SAME reader, deliberately, so a calendar position
930+
// or a rate cannot become an offender by being written in a JSDoc instead.
931+
expect('skipped in the JSDoc channel too: a calendar position is not a duration',
932+
rulesOf(`const S = z.object({\n /**\n * Hour of the day (0-23)\n */\n windowHour: z.number().describe('Start hour') });`)
933+
.join() === '');
934+
expect('skipped in the JSDoc channel too: a rate is not a duration',
935+
rulesOf(`const S = z.object({\n /**\n * Heartbeats per second\n */\n heartbeat: z.number().describe('Heartbeat rate') });`)
936+
.join() === '');
937+
938+
expect('the divergence class is DURATION-SHAPED only: a non-duration name with a unit in its JSDoc is not refused',
939+
rulesOf(`const S = z.object({\n /**\n * Sampled over 30 seconds\n */\n sampleCount: z.number().describe('Samples taken') });`)
940+
.join() === '');
941+
expect('exempt (i) survives the new class: an `EpochMs` instant with an ms JSDoc is not newly refused',
942+
rulesOf(`const S = z.object({\n /**\n * Creation timestamp in milliseconds\n */\n createdAt: EpochMs });`)
943+
.join() === '');
944+
expect('REFUSED (ii) extends here: an `externalVocabulary` marker waives the RENAME, never the divergence',
945+
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)' }) });`)
946+
.join() === 'unit-in-jsdoc-not-in-describe');
947+
948+
expect('a divergent site carries its JSDoc units in the census, not just in the verdict',
949+
(() => {
950+
const sites = collectDurationKeys('fixture.ts', `const S = z.object({\n /**\n * Window size in seconds\n */\n window: z.number().describe('Window size') });`);
951+
return sites.length === 1 && sites[0].jsdocUnits.join() === 'seconds' && sites[0].proseUnits.length === 0
952+
&& sites[0].jsdoc !== undefined && sites[0].jsdoc.includes('Window size in seconds');
953+
})());
954+
776955
expect('a describe declared through `.meta({ description })` is READ — no exemption by blindness',
777956
rulesOf(`const S = z.object({ timeout: z.number().meta({ description: 'Timeout in milliseconds' }) });`)
778957
.join() === 'unit-in-prose-not-in-name');
@@ -894,6 +1073,7 @@ function main(argv: string[]): number {
8941073
if (argv.includes('--list')) {
8951074
for (const s of durationSites) {
8961075
const marks = [
1076+
s.jsdocUnits.length ? ` [jsdoc: ${s.jsdocUnits.join('/')}]` : '',
8971077
s.valueUnitPair ? ' [value/unit pair]' : '',
8981078
s.instant ? ` [instant: ${INSTANT_ROOT}]` : '',
8991079
s.externalVocabulary !== undefined ? ` [${EXTERNAL_VOCABULARY_META_KEY}: ${s.externalVocabulary}]` : '',

‎packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@
44
// said "Process timeout in ms" in prose and nothing else. Renamed to
55
// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside
66
// the live `process` block. ⚠️ Note for anyone grepping this file: the
7-
// neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key whose
8-
// describe names no unit at all, so it is outside the gate's population and is
9-
// untouched here. No D2 conversion: a `SandboxConfig` is the isolation
10-
// argument a host or a plugin security manifest constructs, never a stack
11-
// collection member or a stored row. See
7+
// neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key and is
8+
// not covered by this entry. It was never "outside the gate's population", the
9+
// reason this note gave until #15939: its unit lived in a source JSDoc only
10+
// ("Execution timeout in milliseconds") while the `.describe()` the reference
11+
// pages publish read "Maximum execution time" and named none, so
12+
// `check:duration-unit-keys` listed the key in its census and never judged it.
13+
// Ruling A on #15939 remediated that JSDoc-channel population per file, so that
14+
// key is renamed to `timeoutMs` as well — landed, not pending — under its own
15+
// entry `kernel/RuntimeConfig:resourceLimits.timeout`; see
16+
// `kernel-runtime-config-timeout-unit-in-key` for its record.
17+
// No D2 conversion: a `SandboxConfig` is the isolation argument a host or a
18+
// plugin security manifest constructs, never a stack collection member or a
19+
// stored row. See
1220
// `kernel-plugin-security-durations-unit-in-key`.
1321
export const entry = 'kernel/SandboxConfig:process.timeout';

0 commit comments

Comments
 (0)