Skip to content

fix(pm): resolve a module-relative watch-hint literal against the script that wrote it - #12471

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-12371-extract-watch-hints-producer
Aug 26, 2026
Merged

fix(pm): resolve a module-relative watch-hint literal against the script that wrote it#12471
yinlianghui merged 1 commit into
mainfrom
claude/issue-12371-extract-watch-hints-producer

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12371

Option A of the maintainer's A+B ruling (2026-08-26, 「同意」). B — the residue-side repair in unreachableReason — is #12299's and is not here; #12299 is not addressed by this PR and remains open.

The defect

extractWatchHints stripped a leading ./ / ../ run. The strip carried an unstated premise: that the writer sits at the depth its own prefix climbs to, i.e. that the literal is spelled from the repo root. That holds for scripts/*.mjs writing '../../packages/…'; it fails for a gate that lives inside a package.

packages/spec/scripts/check-exported-any.ts './lib/dist-freshness' -> lib/dist-freshness
packages/spec/scripts/build-spec-changes.ts '../src/kernel/protocol-version'
-> src/kernel/protocol-version

This tree has no top-level lib/ or src/, so those hints reached nothing — while the files they name are on disk. The residue then said so out loud, about files that exist:

check:exported-any dead: 'lib/dist-freshness' — no tracked path under its first segment; never was a repo path

The fix

The literal is resolved against dirname(scriptPath) via resolveModuleRelativeHint, which is the same relative(root, resolve(join(root, dirname(scriptPath)), specifier)) idiom firstPartyImportTargets already uses — one resolver, not two, and the self-test pins that the two agree on a shared specimen. Two refusals: a literal that escapes the repo, and one that resolves to the root itself (the empty string covers the whole tree).

Admission is unchanged.looksPathy still reads the literal as the author wrote it, minus the depth prefix; only the hint's VALUE moves. Widening admission to the resolved form was implemented, measured and refused — see "The widening not taken" below.

scriptPath is optional; a caller without one keeps the strip. Both production call sites in discoverFamilies have the path (f for a gate file, rel for a followed module), and every self-test call site that reads a real repo file now passes it too, so the pins read the way the derivation reads.

Pair-count measurement — the firstPartyImportTargets pricing discipline

Fleet-wide MATCHED-column widening, so it carries its own number. Measured on 176 discovered families x 6899 tracked files at e09d833e6a, every reading driven through hintCovers — never collapseHint alone, never a re-implementation.

beforeafterdelta
watch-hint (gate, file) pairs7633077079+749
pairs LOST1 (see below)
pairs RE-ATTRIBUTED0
distinct hints828827−1
dead (inert) hints400384−16
unreachable families1211−1

Families gaining coverage: 2.

  • check:authorable-surface+749build-schemas.ts does import * as Data from '../src/data' for all sixteen spec namespaces. Those resolve to packages/spec/src/data, …/src/kernel, …/src/ui … and the gate really is a function of every one of them. It leaves the unreachable list entirely.
  • scripts/check-engine-split-ratio.mjs+1'./pm/git-history.mjs' resolves to scripts/pm/git-history.mjs.

The one lost pair changes no card's answer, asked through the predicate the output actually uses:

check:doc-anchors coveringKey('scripts/check-doc-anchors.mjs') -> {"key":"scripts/check-doc-anchors.mjs","via":"gate script"}
classifyEntry verdict -> matched

Its provenance: check-doc-anchors.mjs prints a node -e "import('./scripts/check-doc-anchors.mjs')…" help line — a cwd-relative command string, not a module-relative read, so resolving it yields scripts/scripts/check-doc-anchors.mjs (reaches 0). The hint was duplicating the identity key the card was already matched by, so the family's verdict is unchanged. That fabricated-looking residue string is inert and is the bucket #12299/B addresses.

The fabrication risk being retired, re-measured

The card's filing-time figures predate PR #12386 and PR #12435 and are not carried forward. Re-measured here — "pairs that would appear if the tree gained that top-level root", probed through hintCovers:

convertible rootbeforeafter
@objectstack307307
src12136
@fx8888
lib382
dist97
pm4retired
docs-audit1retired
total exposure665536 (−129)
convertible roots3129

@objectstack and @fx are genuine package specifiers, class (c) in the card — nothing here touches them. The src / lib residue that remains is class (b), the package-src-relative path TABLES, which carry no ./ prefix and are out of this card's scope.

The residue stops being false

9 of the 11 remaining unreachable families move from by construction / "never was a repo path" to layout moved / "the tree stops at …" — rendered by the unchangedunreachableReason, so the only variable is the hint spelling:

before check:api-surface [by construction] 'lib/dist-freshness' — no tracked path under its first segment; never was a repo path
after check:api-surface [layout moved] 'packages/spec/scripts/lib/dist-freshness' — the tree stops at packages/spec/scripts/lib; the layout moved under it

The same nine: check:api-surface, check:dual-source-exports, check:entry-nameability, check:export-origins, check:exported-any, check:meta-url-spelling, check:spec-changes, check:strictness-ledger, check:upgrade-guide. Sharpening that wording further — "resolves to a real file under its writing script's directory" — is B's job, and after this change B's recognizer only has to append a source extension.

The widening not taken (measured, then refused)

Admitting on the RESOLVED form instead would add packages/spec/src from the two live spec builders' path.resolve(__dirname, '../src')+2062 more true pairs. It was built and measured, and it costs:

Recorded as a standalone finding: #12470.

Verification

node scripts/pm/dispatch-gates.mjs --self-test717 cases pass (703 before, 14 new pins).

Ablation, both directions, each mutation confirmed on disk by grepping the injected text and the deleted text (never an editor exit code), each restored under trap … EXIT INT TERM and proven byte-identical with git hash-object. No dist/ is involved — this script runs from source — so the restore leg is the only rebuild leg.

  1. Put the strip back: ✗ 5 of 717a gate inside a package resolves its own-directory literal against ITSELF, …and a '../' literal against its parent, the top-level spelling the strip used to produce is GONE, a literal that climbs out of the repo names nothing, the live spec builder names its own src subtree, resolved.
  2. Widen admission to the resolved form (the shape refused above): ✗ 5 of 717 — including the two must-NOT-convert pins (a single-segment sibling specifier does NOT convert into a hint, …nor does a bare sibling manifest name) plus the three live structural invariants listed in the previous section.

Restore proof both times: RESTORED sha=cdd0d0b8d075ca4ddefc47cc7b91870ccca769c1 base=cdd0d0b8d075ca4ddefc47cc7b91870ccca769c1, git status clean.

Derived gate union, run at e09d833e6a (the final commit), exit codes captured before any pipe — derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read the change set from the merge base itself:

nul-bytes EXIT=0 check-nul-bytes: OK (scanned 6892 text file(s); no raw ASCII control bytes)
pm-dispatch-gates EXIT=0 ✓ dispatch-gates self-test: 717 cases pass.
agent-test-spelling EXIT=0
bash32-floor EXIT=0 ✓ check-bash32-floor self-test: 98 cases pass.
cli-command-ids EXIT=0 ✓ check-cli-command-ids: 280 command-id literal(s) ... all resolve
cross-package-test-inputs EXIT=0 OK: 18 package(s) read outside themselves, all declared
entry-guard EXIT=0 ✓ check:entry-guard: 170 scripts/ file(s)
parse-guard EXIT=0
pnpm-filter-targets EXIT=0 ✓ check:pnpm-filter-targets: 140/177 --filter occurrence(s) resolve
ci-filter-parity EXIT=0 OK: all 105 declared cross-package glob(s) ... covered
cross-package-test-inputs (direct) EXIT=0
self-test-wired EXIT=0 ✓ every one of the 140 script(s) CI runs that ship a --self-test has it run by CI
comment-mask-adoption EXIT=0

ci-filter-parity first refused with PREREQUISITE NOT MET — the dependency yaml is not installed (its own text: "Nothing was measured … NOT a finding"); re-run green after pnpm install in the worktree.

No changeset

CI-internal tooling — scripts/pm/dispatch-gates.mjs ships in no package and releases nothing. lint.yml names "this PR edits a CI-internal script" as the textbook skip-changeset case, and both immediate predecessors touching this same file landed the same way (PR #12386, PR #12435). Labelled skip-changeset.


Generated by Claude Code

…ipt that wrote it
`extractWatchHints` stripped a leading `./`/`../` run, which assumed the writer
sits at the depth its own prefix climbs to. That holds for `scripts/*.mjs` and
fails for a gate inside a package: `packages/spec/scripts/*` writing
`'./lib/dist-freshness'` produced the top-level `lib/dist-freshness`, a string
this tree has no `lib/` for, while the file it names is on disk. The residue
then asserted "never was a repo path" about files that exist.
The literal is now resolved against `dirname(scriptPath)` through the same
`relative(root, resolve(...))` idiom `firstPartyImportTargets` already uses.
Admission is unchanged — it still reads the literal as the author wrote it —
so only the hint's VALUE moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026 — with Claude
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:devx @ objectstack seat (#6023), session session_01UjM2ia8Av1v5NqfqQEQmC6, round R10 collection.

Zone 1 compliance: clean

What I verified independently

  • import { basename, join, dirname, relative, resolve } from 'node:path' (:164) — all four names the new helper needs are actually imported. A 717-case self-test passing is strong evidence, but it ran in your worktree; this one is cheap and decisive from mine.
  • The disclosed fabricated hint is genuinely inert, and I measured it rather than accepting it: scripts/check-doc-anchors.mjs's cwd-relative node -e help string resolves to scripts/scripts/check-doc-anchors.mjs, and this tree has 0 tracked files under scripts/scripts/ (positive control: 273 under scripts/). No tracked file, no pair, no verdict moves.

On that last point — your routing is right and I am deliberately not escalating it. There is a real tension: this function's own docblock says it errs toward a missing lead rather than a fabricated one, and the resolve introduces a fabricated spelling. But it is inert today, it is exactly the residue bucket #12299/B owns, and a card here would duplicate B. Naming it in the PR body instead of filing was the correct call, not a shortcut.

On the numbers

I did not re-derive the +749 / −1 pair count — that is a fleet-wide measurement and re-running it under the shared lock would cost more than it buys, given you drove every reading through hintCovers (never collapseHint alone, never a re-implementation) and both ablations restored byte-identical under git hash-object. ⭐ Two things I do want on the record because they are the parts that would have been easiest to fudge and were not:

  • Zone 2a was RE-MEASURED, not carried forward from filing time — 31 → 29 convertible roots, 665 → 536 fabricated-pair exposure. Filing-time numbers age; quoting them would have been the cheap path.
  • The 1 lost pair was chased to an answer rather than rounded off: check:doc-anchors still returns its key via gate script, so the hint was duplicating the identity key. "Net positive" would have hidden that.

Landing condition

Unchanged and unarmed: Lint & Repo Gates green, read by name — not from an aggregate, not from check_suite.completed. ⛔ Auto-merge is not armed on this PR yet.


Generated by Claude Code

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing condition met — armed.

Lint & Repo Gates read by name (check run 98064802118): status: completed, conclusion: success. Read at perPage: 100 and reconciled against total_count (35/35) so no job could be silently omitted from the page — the failure mode that once hid the exact job this condition depends on.

Marked ready for review, auto-merge enabled. scripts/pm/** is not a governed surface, so no human-merge requirement applies.

Serial queue behind this PR, for whoever picks up next: #12299 (ruling B — unreachableReason residue, and its own pm:blocked / Blocked-by: #12329 should come off since #12329 landed with PR #12386) and #12322. Both hold this same file; neither is dispatchable until this merges.


Generated by Claude Code

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

1 participant

@yinlianghui