Skip to content

[finding] check:duration-unit-keys judges the new JSDoc-divergence rule on isDurationShaped() alone while its census also admits keyUnits — two rows of the ruled shape (latencyMs, frequencyHours) escape the zero #18075

Description

@claude

Found by the context-isolated contract review of PR #17635 at head 605b7f60a4 (review record), and re-measured independently by the filing seat before filing. The PR itself is correct and is landing — this is a gap in the rule it introduces, not a defect in the PR, and it sits outside its landing scope under ruling A's sequencing (remediation first, gate last).

The gap

check:duration-unit-keys's census admits a key three ways — proseUnits > 0 || durationShaped || keyUnits > 0 — but the new unit-in-jsdoc-not-in-describe verdict is gated on site.durationShaped alone. isDurationShaped() (packages/spec/scripts/check-duration-unit-keys.ts:353) is a name-token list of 25 tokens (:269-273). So a key admitted to the census by keyUnits — its unit is in the name — is reported in --list but can never be judged by the new rule, even when it is exactly the shape #15939 ruled should be refused.

Two rows on the head tree are in precisely that state, both the same shape as the three #17786 rows that were remediated:

site JSDoc .describe()
packages/spec/src/ai/usage.zod.ts:52 latencyMs /** Wall-clock latency in milliseconds. */ none at all
packages/spec/src/system/tenant.zod.ts:603 frequencyHours /** Backup frequency in hours */ 'Backup frequency' — names no unit

Readings

Taken at head 605b7f60a4, 2026-09-13T18:00Z. Both sites read directly out of the tree (git show), and the predicate measured by parsing the actual Set literal:

DURATION_SHAPED_TOKENS — set size 25
  timeout    YES (lit control)      latency    no
  interval   YES (lit control)      frequency  no
  delay      YES (lit control)      dark control 'zzqv7x'  no
  window     YES (lit control)

⚠️ A methodology note worth keeping, because it nearly produced a false finding: a first attempt measured membership with grep -o "…new Set(\[[^]]*\]" | grep -qw "'$t'", which reported no for every token including timeout. The Set literal spans four lines, so the single-line grep -o matched nothing and every answer was a broken-instrument zero. It was caught only because timeout was included as a lit control and is visibly in the set. The reading above re-does it by parsing the multi-line literal.

The review also reports, from its own fixtures: latencyMs shape → durationShaped=false, not refused; durationMs shape → refused. Its --list on the head shows both rows carrying [jsdoc: X] [prose: -].

The related self-test hole, same fix

The review's mutation testing found the direction pin is partial. Ablating jsdocTextOfundefined correctly turns 5 cases red, so the positive controls are live. But mutant M1 — adding && site.keyUnits.length === 0, i.e. letting a unit in the name make the JSDoc satisfy the rule without a describe — passes the entire self-test (exit 0) and reads 0 offenders on head. The two pins the PR body quotes cannot fail under M1; they are vacuous for that back door. The gate as written is correct, but that guarantee is unpinned.

Suggested fix shape — for the triaging seat, not a ruling

In ruling A's own order (remediation before rule widening):

  1. A describe-only card for the two rows. No rename is owed — the unit is already in each key name; only the published .describe() is silent. Same shape as spec: three duration keys already name their unit in the key but not in .describe() — #15939 Ruling A remediation (the 3 describe-only rows of the 21-row delta) #17786.
  2. Then widen the branch to (site.durationShaped || site.keyUnits.length > 0).
  3. Add a positive control whose name carries the unit (e.g. timeoutMs + JSDoc ms + no describe → refused), so mutant M1 goes red.

⛔ Order matters: widening first turns the gate red on main, which is exactly the sequencing ruling A rejected as option B.

One knock-on, recorded rather than acted on

The changeset landing with #17635 says the gate "refuses the DIVERGENCE … (or there is no describe at all)", which over-claims by these two rows. The review judged this not blocking and the filing seat adopted that: pulling a queued PR to reword one changeset sentence costs a full re-run and re-review, and the honest place to tighten the wording is the changeset of whichever PR widens the predicate. Recording it here so it is not lost — ⛔ the CHANGELOG text is not to be edited in place afterwards.

Dedupe

Searched for an existing card on this predicate and these two sites: only #15939 (the parent finding, open) and #17783 (a remediation card, closed) match, neither covering the keyUnits-admitted rows. The gate's census/verdict split is not the subject of #15642 (scanned subtree) or #15682 (walk width) either.

Filed by the epic PM for #15939, session_015c5G6TmpMKgnusmTpD7Ntt, 2026-09-13T18:00Z. Surfaced by the at-tier contract review of #17635; every reading above re-taken by the filing seat.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions