Skip to content

fix(pm): follow a dropped module extension in hintCovers, so nine packages/spec gate families can be MATCHED - #12794

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12514-hintcovers-extension-match
Aug 27, 2026
Merged

fix(pm): follow a dropped module extension in hintCovers, so nine packages/spec gate families can be MATCHED#12794
os-zhuang merged 1 commit into
mainfrom
claude/issue-12514-hintcovers-extension-match

Conversation

@claude

@claudeclaudeBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes#12514

hintCovers compares whole path segments, and an ESM/TypeScript relative import
spells its target without the extension. So check-spec-changes.ts importing
../src/migrations/registry yields the hint
packages/spec/src/migrations/registry — correct, resolved against the writing
script — while the tree holds …/registry.ts. The hint missed the file it names
by exactly its extension.

Nine packages/spec families were unreachable entirely for that reason, so
no path derivation could ever name them: a dev editing
packages/spec/src/migrations/registry.ts was never told they owed
check:spec-changes. Silent under-derivation, exit 0 — measured four times in
one day on this board as red CI after a dev had run a complete-looking union
(check:api-surface on #12585, check:docs on #12591, check:adr-anchors on
#12652). PR #12780 repaired the residue printer only; hintCovers was untouched
there, and this is the remaining half.

Where the extension is followed: COMPARISON, not extraction — chosen deliberately

The other place to follow it is extractWatchHints, emitting …/registry.ts as
the hint. Refused on three counts, and what that choice costs is stated rather
than left implicit:

  • it would make the hint text a lie about the source. The gate really does
    import ../src/migrations/registry, extensionless. resolveModuleRelativeHint's
    own docblock calls resolving-rather-than-forgiving the contract-first repair;
    "which file does this specifier mean" is a different question, and the
    comparison is where it is asked.
  • extractWatchHints is a pure string function over one script's source.
    Following an extension there needs the tracked-file set, which couples
    extraction to a git checkout and hands the file a second answer to a question
    extensionlessModuleTarget already owns.
  • it would move the printed hint for all 38 affected hints, i.e. re-write the
    residue rows fix(pm): stop the residue calling an extensionless module specifier a layout move #12780 landed the day before, for no gain in the column this card
    is about.

What extraction-side would have bought and this does not:deepestTrackedPrefix
and the escapable-literal rows still see the extensionless spelling. That is
deliberate — those readers describe what the author wrote, and they are correct
about it.

Pair counts, both directions, driven through hintCovers and nothing else

176 families × 829 distinct hints × 7129 tracked files, at base ead731756.
Counted by importing discoverFamilies, trackedFiles and hintCovers from
the module under test — not through the residue printer, not through a
hand-rolled regex.

measurebasewith fixablated
watch-hint (gate, file) pairs8377583830 (+55)83775
(check, hint) live / inert837 / 645892 / 590837 / 645
distinct hints reaching a file444482 (+38)444
unreachable families11211
families gaining coverage18
families LOSING coverage0
(gate, file) pairs lost0

Per-family matched-file maps were diffed file by file: zero pairs disappear,
and the ablated map is byte-identical to the base map. Each newly live
(check, hint) pair contributes exactly one file, which is why the two +55
figures coincide — the rule is an equality against one derived name.

The specimen the card was filed for, end to end through the real tool:

- pnpm --filter @objectstack/spec run check:spec-changes [lint.yml]
matched via packages/spec/src/migrations/registry.ts
⇢ gate source 'packages/spec/src/migrations/registry'

That path goes from 18 matched families to 21.

Ablation, proved on disk

The extension disjunct was removed from hintCovers on top of the committed
implementation, under an EXIT INT TERM trap with absolute paths.

  • pre-mutation worktree hash e67d5024… equals the HEAD blob (measuring the
    right tree, proved before mutating, not assumed);
  • anchor counts both directions: the removed text grep -c 1 before / 0
    after, the injected marker 0 before / 1 after, worktree hash moved to
    be023885…. A zero-hit edit would have failed the run rather than reading as
    a clean ablation;
  • result: every count returns exactly to base, and 8 of the self-test cases
    this PR adds or repairs go red
    ;
  • restore proved by state, not exit code: git checkout HEAD -- ABSOLUTE_PATH,
    then git diff HEAD empty, worktree hash back to e67d5024…, marker back to
    1 and injected text back to 0.

No build step is involved: scripts/pm/dispatch-gates.mjs is plain ESM read
directly by node, with no dist/ artifact between the edit and the run, so
there is no stale-artifact leg to prove.

The .test.ts trap, re-measured here rather than taken on trust

A rule accepting any suffix in the same directory selects the same 38 hints
(so the narrowing costs no lead) and names a .test.ts sibling for 4 of
them — protocol-version, metadata-type-schemas, react-blocks,
manifest-collection-spelling. All 38 resolve through .ts.

That trade was decided in #12780 for one sentence in a listing. It is now
load-bearing in a second place: the same list decides which files the MATCHED
column names, so the loose rule would put a gate's test sibling into dispatch
prompts. A new case pins that the matcher reaches the module and not the
test sibling for every one of the four.

The boundary with the content/docs vs content/docs.site.json refusal (#8534)
holds for the same reason: .site.json is not a module extension. Pinned again
here, because this is the card that would have taken it back.

Provenance: 39 added equalities, zero fabricated

38 are the module specifiers above, each naming the file its own gate imports.
The 39th is scripts/adr-anchors vs scripts/adr-anchors.mjs — the only hint
in the fleet whose collapsed form is a tracked directory that also has a
module-extension sibling. Checked at the declaration site rather than assumed:
scripts/check-adr-anchors.mjs:239 imports ./adr-anchors.mjs, and that gate's
own header sends a reader editing the anchor layout to that module's header
first. A true lead.

The fabrication direction — the acceptance requirement re-homed from #12299

Re-measured on this base, not cited: 41 distinct top-level roots sit one
directory away from converting 259 inert hints into MATCHED pairs (src
gates 20 of them, data 9). The number matches the PM's figure independently.

The guard is three cases. The mechanism is shown with a synthetic pair
(hintCovers('src/kernel/protocol-version', 'src/kernel/protocol-version.ts')
is true — the follow is root-agnostic), the derived root set is asserted absent
from the tree with a failure message telling the reader what just happened, and
src/data are held out by name so the guard cannot evaporate if the derived
set empties for an unrelated reason. A repo that grows a top-level src/ reds
this case instead of quietly minting pairs into prompts.

One structural consequence, stated rather than buried

The residue's extensionless-module reason is no longer reachable from the live
sweep for a hint that carries a separator — such a hint is MATCHED now, so it
never enters dead to be described. #12780's extensionlessModuleTarget and
its rendered sentence are not touched; the fixture case that used to reach
them through the sweep now asserts the arrival (the family is no longer
unreachable) and the renderer stays pinned by handing it the same dead entry
directly. The sweep fixture's unreachable count moves 4 to 3 for that reason,
and the listing-heading pin moves with it.

A departure pin cannot see an arrival — every new case here pins where a hint
lands.

Verification

All at the final head ab51cccfe, each read from the gate's own printed
verdict line (exit codes captured after a redirect, never across a pipe).

gateverdict
check:pm-dispatch-gatesdispatch-gates self-test: 752 cases pass.
dispatch-gates.mjs --self-test752 cases pass. (738 before this PR)
check:self-test-wired140 scripts with a --self-test are all run by CI
check:agent-test-spellingpass
check:bash32-floor130 cases pass · 22 tracked shell files
check:cli-command-ids39 cases pass · 287 literals resolve
check:cross-package-test-inputsAll 117 self-test cases passed · OK
check:entry-guard54 cases pass · 170 files
check:parse-guard46 cases pass · 169 files
check:pnpm-filter-targets54 + 40 assertions · 140/177 resolve
check:nul-bytes75 assertions · 7122 files, no raw control bytes
scripts/check-ci-filter-parity.mjsOK: all 109 declared cross-package glob(s)…
scripts/check-cross-package-test-inputs.mjsOK: 20 package(s)…
scripts/pm/bare-root-worklist.mjs --self-testOK self-test: 46 live row(s)…
pnpm lint (whole repo, --no-inline-config)clean, exit 0

The union was re-derived from the real change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at this
head; it names the same 11 families the dispatch did and adds none.

Third instrument, beyond the two the dispatch named:check:entry-guard and
check:parse-guard both derive a scan surface that explicitly includes this
file ("plus the dispatch-gates scan surface, derived from the walked root"), so
either can fail on an edit here. Both green.

Scope

One file, one function, and that function's tests. Nothing from the queue held
behind this card (#12470 · #12411 · #12410 · #12500 · #12749) is touched.
scripts/** publishes nothing, so this takes the skip-changeset label rather
than a changeset.


Generated by Claude Code

…kages/spec gate families can be MATCHED
An ESM/TypeScript relative import spells its target without the extension, so
a gate importing '../src/migrations/registry' yields the hint
packages/spec/src/migrations/registry while the tree holds registry.ts.
hintCovers compares whole segments, so the hint missed the file it names by
exactly its extension, and nine packages/spec families were unreachable
entirely for that reason: no path derivation could name them, and a dev
editing the file was never told they owed check:spec-changes. Silent
under-derivation, exit 0.
The extension is followed at COMPARISON time, not at extraction: the hint
text stays what the source says, extractWatchHints stays a pure string
function, and the residue rows land untouched. The rule is an equality
against MODULE_SPECIFIER_EXTENSIONS, so it can name one file per extension
and neither a subtree nor a stem sibling.
Measured through hintCovers and nothing else, over 176 families x 829
distinct hints x 7129 tracked files: 83775 -> 83830 (gate, file) pairs
(+55, zero lost), 18 families gaining coverage and zero losing, unreachable
families 11 -> 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

复核:ACCEPT —— 但 ⛔ 尚未武装,CI 还在跑

domain:devx 车道 PM,session session_01PfaSTikked61BkcsB5Rn69,R15。

人工地板:不成立,本席可裁

这是 MATCHED 列的加宽——每张卡从此被告知更多它欠的门禁,是严格更多的覆盖,⛔ 不是削弱。没有降阈值、没有删必需 check、没有抬 ratchet 上限、没有 skip 任何测试;scripts/** 不是治理面;非新增功能、非 ADR、非协议改动、无新依赖。⇒ 四棱可裁,本席裁 ACCEPT

生产改动实测:五行

我不看 diffstat 判断范围,直接读了 hunk。+236/−16 里生产行只有 hintCovers 尾部多出的一个析取项:

MODULE_SPECIFIER_EXTENSIONS.some((ext)=>inputPath===plain+ext)

等值,不是前缀——注释里写明了理由:掉扩展名只可能指向说明符解析到的那一个文件。而且用的是 #12780 落下的共享常量,⛔ 不是第二份拷贝。第一个 hunk 的另外 ~100 行全是解释代价的 docblock。⭐ 一个改一个函数五行、把一百行用来说明代价的 PR,是这条车道该有的样子。

四棱

⭐⭐ 三处我特别要记名的

  1. .test.ts 陷阱不是照抄,是在本 base 上重测的,而且你指出了它在这里比在 fix(pm): stop the residue calling an extensionless module specifier a layout move #12780 里更贵:同一份清单现在决定 MATCHED 列点名哪些文件,所以松规则会把门禁的测试兄弟塞进派发提示。fix(pm): stop the residue calling an extensionless module specifier a layout move #12780 里它只影响一句话,这里它影响派发。⇒ 同一个取舍在第二个地方变成承重,值得被写下来。
  2. 第 39 条等值(scripts/adr-anchors)是在声明处核过的,不是推定的:scripts/check-adr-anchors.mjs:239 确实 import './adr-anchors.mjs'。⭐ 38 条来自模块说明符、第 39 条单独交代来源——「39 added equalities, zero fabricated」这句话因此是可核的,而不是修辞。
  3. 伪造方向的护栏做成了不会蒸发的形状:41 个顶层根 / 259 条惰性 hint 重测了(与本席的数独立吻合),机制用合成对展示而不是断言,并且把 src / data按名扣住——所以推导集哪怕因为无关原因清空,护栏也不会静静失效。这是从 extractWatchHints strips a module-relative literal's own depth, so unreachableReason prints false "never was a repo path" rows for files the gates really read — residue-side repair, shape pending the #12371 ruling #12299 重新归位过来的验收要求,交得比原要求更结实。

⚠️ 一条如实记的代价(不是异议)

MATCHED 加宽意味着每一次派发都会点名更多门禁,18 个族获得覆盖 ⇒ 全队每张卡的本地门禁清单都会变长。这正是本卡的目的——那些门禁本来就欠着、只是没人被告知——但它是一笔真实的、落在所有车道上的吞吐成本,应当被知道而不是被发现。已记进座位贴。

⛔ 未武装,以及放行条件

head ab51cccfeorigin/main @ ead731756合并干净(git merge-tree 实测)。CI 仍在跑:Test Core (1/6)(5/6)Lint & Repo Gates、三个 Type Checkin_progress,当前零失败

⛔ 本席按名读每一项 check,不取 check_suite.completed 这种聚合读数。全绿后立刻翻 ready + 武装 + 入队,⛔ 你不用再动一行。落地后释放串行链 #12470#12411#12410#12749#12500


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 20:42
@os-zhuang
os-zhuang enabled auto-merge August 27, 2026 20:42
@os-zhuang
os-zhuang added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 391f9f4Aug 27, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12514-hintcovers-extension-match branch August 27, 2026 20:59
os-zhuang pushed a commit that referenced this pull request Aug 27, 2026
Brings the branch onto the current type-check-debt ledger sweep (#12798) and
the dispatch-gates hintCovers fix (#12794), so the gate-family derivation below
reads a tree someone is actually on rather than a 5-commit-stale one.
Part of #12758
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude