Uh oh!
There was an error while loading. Please reload this page.
fix(pm): resolve a module-relative watch-hint literal against the script that wrote it - #12471
Conversation
…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
yinlianghui
commented
Aug 26, 2026
PM review — Zone 1 compliance: clean
What I verified independently
⭐ 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 numbersI 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
Landing conditionUnchanged and unarmed: Generated by Claude Code |
yinlianghui
commented
Aug 26, 2026
Landing condition met — armed.
Marked ready for review, auto-merge enabled. Serial queue behind this PR, for whoever picks up next: #12299 (ruling B — Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
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
extractWatchHintsstripped 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 forscripts/*.mjswriting'../../packages/…'; it fails for a gate that lives inside a package.This tree has no top-level
lib/orsrc/, so those hints reached nothing — while the files they name are on disk. The residue then said so out loud, about files that exist:The fix
The literal is resolved against
dirname(scriptPath)viaresolveModuleRelativeHint, which is the samerelative(root, resolve(join(root, dirname(scriptPath)), specifier))idiomfirstPartyImportTargetsalready 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.
looksPathystill 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.scriptPathis optional; a caller without one keeps the strip. Both production call sites indiscoverFamilieshave the path (ffor a gate file,relfor 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
firstPartyImportTargetspricing disciplineFleet-wide MATCHED-column widening, so it carries its own number. Measured on 176 discovered families x 6899 tracked files at
e09d833e6a, every reading driven throughhintCovers— nevercollapseHintalone, never a re-implementation.Families gaining coverage: 2.
check:authorable-surface+749 —build-schemas.tsdoesimport * as Data from '../src/data'for all sixteen spec namespaces. Those resolve topackages/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 toscripts/pm/git-history.mjs.The one lost pair changes no card's answer, asked through the predicate the output actually uses:
Its provenance:
check-doc-anchors.mjsprints anode -e "import('./scripts/check-doc-anchors.mjs')…"help line — a cwd-relative command string, not a module-relative read, so resolving it yieldsscripts/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:@objectstacksrc@fxlibdistpmdocs-audit@objectstackand@fxare genuine package specifiers, class (c) in the card — nothing here touches them. Thesrc/libresidue 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" tolayout moved/ "the tree stops at …" — rendered by the unchangedunreachableReason, so the only variable is the hint spelling: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/srcfrom the two live spec builders'path.resolve(__dirname, '../src')— +2062 more true pairs. It was built and measured, and it costs:'./invoked-as.mjs','./package.json') become a watched population — a second, unpriced answer to the questionfirstPartyImportTargetsowns, labelledgate sourcewhere the import channel saysgate source via MODULE;dispatch-gates: no-path-populationmarker end up naming a path population (check:release-body,scripts/check-prerelease-pin-watch.mjs);--self-testfamily inheritance invariant ([finding]dispatch-gates.mjshas 28 self-test cases and no CI job runs any of them #8162 / [finding] dispatch-gates never discovers a gate whose script lacks acheck-prefix - five required-lane steps, including the one that shipped a red on PR #11397, are absent from the family universe entirely #11404) breaks.Recorded as a standalone finding: #12470.
Verification
node scripts/pm/dispatch-gates.mjs --self-test— 717 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 TERMand proven byte-identical withgit hash-object. Nodist/is involved — this script runs from source — so the restore leg is the only rebuild leg.✗ 5 of 717—a 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.✗ 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 statusclean.Derived gate union, run at
e09d833e6a(the final commit), exit codes captured before any pipe — derived withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read the change set from the merge base itself:ci-filter-parityfirst refused withPREREQUISITE NOT MET — the dependency yaml is not installed(its own text: "Nothing was measured … NOT a finding"); re-run green afterpnpm installin the worktree.No changeset
CI-internal tooling —
scripts/pm/dispatch-gates.mjsships in no package and releases nothing.lint.ymlnames "this PR edits a CI-internal script" as the textbookskip-changesetcase, and both immediate predecessors touching this same file landed the same way (PR #12386, PR #12435). Labelledskip-changeset.Generated by Claude Code