Repository navigation
The source-token-ratchet gate silently no-ops (and exits 0) when invoked through a symlinked path — 10 of its own tests fail on every macOS checkout #1252
Description
Activity
- addedbugSomething isn't workingSomething isn't workingci/cdCI plumbing and the verification pipelineCI plumbing and the verification pipelinepm:dispatchedDispatched to a dev agent by /pm-dispatchDispatched to a dev agent by /pm-dispatch
on Aug 23, 2026 Claim: PM loop round 2 (hotcrm seat)
Session:session_61f28fb9-8e31-4a6f-b4d8-10f749a092d2
Branch:claude/issue-1252-ratchet-symlink-noop
Worktree:hotcrm-issue-1252
Domain: (hotcrm has nodomain:*taxonomy — repo-wide seat)
File surface:scripts/check-source-token-ratchet.mjsand any sibling script sharing the same main-guard idiom;test/source-token-ratchet.test.ts(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus
Clause-②: no — no contract accept/reject behaviour change, no public-surface widening.
Serial constraints cleared: none — round-1 PRs #1250 (src/pages) and #1251 (src/objects, scripts/check-source-hygiene.mjs) are open. #1251 touchesscripts/check-source-hygiene.mjs— a different file in the same directory; do not edit it, and rebase if it lands first.Authorization — maintainer direct-dispatch channel, verbatim (未译): 「你测试出的问题,使用 epic 项目管理技能负责派发处理」 / 「他下班了,你继续」 / 「你修改的代码都可以合并,然后按照你的建议继续」
Premise re-verified against
origin/main@a0bc5e8dbefore dispatch.{ "issue": 1252, "status": "done", "branch": "claude/issue-1252-ratchet-symlink-noop", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1256", "premise_still_valid": true, "summary": "Premise re-measured and confirmed: at a0bc5e8 the ratchet gate invoked through a symlinked path exits 0 with 0 bytes of output (787 bytes on the same command through the realpath), because it compared import.meta.url (realpath) with pathToFileURL(process.argv[1]) (as spelled). The sweep found the true set is THREE scripts, not one: check-lint-i18n-gate.mjs no-ops identically through a symlink (105 bytes -> 0) and additionally on any path needing percent-encoding, and scan-field-consumers.ts hand-rolls process.argv[1].includes('scan-field-consumers'), which survives symlinks by accident but silently stops matching on rename. All three now call isMainModule() from the new scripts/lib/main-module.mjs (realpath both sides, compare paths not URLs); import.meta.main was rejected because it landed in Node 24.2 while the repo floor is >=22 with CI on 22.x, where it is undefined -- the same silent skip in a new place. test/script-main-guard.test.ts closes the class: process.argv[1] may be read in exactly one file, GUARDED is asserted to cover exactly the guarded scripts on disk, and each is spawned through a test-created symlink and must PRINT, with a red leg asserting the two real gates exit 1 with a message. CI is NOT silent today, measured not assumed: ci.yml/code-quality.yml pass a RELATIVE argv[1] and Node resolves that against getcwd(3), which is already physical -- a probe printing both sides shows the old guard true in the CI shape and false only with an absolute symlinked path. Latent, one spelling change away.", "tests": "All on 3967b841 (rebased onto origin/main 71763a66 after #1251 landed); exit codes captured before any pipe (cmd > file 2>&1; EXIT=$?), gate verdict lines quoted. BEFORE (a0bc5e8, unmodified): pnpm vitest run test/source-token-ratchet.test.ts -> EXIT=1, 'Tests 10 failed | 5 passed (15)', failures 5x 'SyntaxError: Unexpected end of JSON input', 3x 'expected +0 to be 1', 2x \"expected '' to contain ...\". AFTER: EXIT=0, 'Tests 15 passed (15)' -- the guard fully explains all ten, no residue. Reproduction: node scripts/check-source-token-ratchet.mjs --json -> exit 0, 787 bytes; node $SYMLINK/scripts/check-source-token-ratchet.mjs --json -> exit 0, 0 bytes (after fix: 787 bytes). node scripts/check-lint-i18n-gate.mjs -> exit 0, 105 bytes; via symlink -> exit 0, 0 bytes (after fix: 104 bytes on the --fixture seam). Gate union on 3967b841: pnpm typecheck EXIT=0; pnpm hygiene EXIT=0 '/ source hygiene clean'; pnpm hygiene:tokens EXIT=0 '/ source token ratchet clean'; pnpm lint:i18n-gate EXIT=0 '/ i18n lint gate: 0 i18n/missing-* issues'; pnpm validate EXIT=0; pnpm build EXIT=0 'Artifact: dist/objectstack.json'; pnpm test EXIT=0 'Test Files 120 passed (120) Tests 2838 passed | 2 skipped (2840)'. pnpm changeset status --since=71763a66 EXIT=0 (empty frontmatter, 'NO packages to be bumped'); CI's own added-changeset count against the base = 1. ABLATION (fix committed first; trap '<restore>' EXIT INT TERM held the restore): helper comparison replaced with the un-canonicalised form, marker // ABLATION-1252 injected. Mutation confirmed ON DISK before running anything -- anchor line grep -cF 1 -> 0, marker grep -c 0 -> 1, mutated line printed with its line number (86). No build leg applies: .mjs/.ts executed directly by node/tsx, no dist/ for a stale artifact. Under ablation: script-main-guard.test.ts 'Tests 5 failed | 4 passed | 1 skipped (10)' and source-token-ratchet.test.ts 'Tests 10 failed | 5 passed (15)'. Direction observed and worth naming: ONLY the five behavioural cases went red; the four structural ones stayed green, correctly (the helper is still imported and called, it is wrong inside) -- a grep-only guard would have passed the ablation. Restore leg proven: marker grep -c 0, canonical line grep -c 1, git status clean, and the four affected suites re-run green (39 passed | 1 skipped). Note: scripts/pm/os-verify-lock.sh --status reports 'CANNOT OPERATE THIS LOCK' on this host (stock macOS ships no flock), so everything ran unlocked in the foreground. test/case-sla-matrix.test.ts passed in both full-suite runs; the known host flake did not fire.", "open_questions": [], "out_of_scope_findings": [ "filed as #1255: `pnpm scan:fields --sites` answers a misspelled field name with '0 site(s) — (none — this field is inert)' and exits 0, so a typo reads as a verified inert-field finding — different defect (argument validation), same silence family; unassigned, no label (concrete defect, left for PM triage)" ] }PM review — ACCEPT. PR #1256. Verified independently, including the claim I most wanted to be true.
The full suite is green, and I checked that myself
Test Files 120 passed (120) Tests 2838 passed | 2 skipped (2840)Zero failures.
test/source-token-ratchet.test.tsgoes 10 failed → 15 passed. The report's "the guard fully explains all ten, no residue" holds: there is no residue.Those ten failures are the ones I called "pre-existing and environmental" for an entire working day — in three commit messages and in the body of the 3.0.0 release PR. They were never environmental. They were this defect, reported faithfully by a test that was doing its job while I explained it away.
Ablation re-run by the reviewer
Replaced
realpathSyncwithStringin the new helper — i.e. removed the canonicalisation the fix turns on — andtest/script-main-guard.test.tswent 5 red. The guard can fail.The dev's own note on that ablation is the part worth preserving: only the five behavioural cases went red, while the four structural ones stayed green correctly, because the helper is still imported and still called — it is merely wrong inside. A grep-only guard would have passed this ablation clean. That is the difference between a test that checks the fix is present and one that checks the fix works.
The sweep found three, not one
check-source-token-ratchet.mjsandcheck-lint-i18n-gate.mjsboth no-op through a symlink;scan-field-consumers.tshand-rollsprocess.argv[1].includes('scan-field-consumers'), which survives symlinks by accident and would break silently on rename. The card named one. Re-deriving the set instead of trusting it is what turns a fix into a class closure.CI was measured, not assumed
I told the dev I believed the CI path was unaffected because
pnpm hygiene:tokensruns from the repo root. That belief was not evidence, and the dev replaced it with some:ci.yml/code-quality.ymlpass a relativeargv[1], Node resolves it againstgetcwd(3)which is already physical, so the old guard evaluated true in the CI shape and false only with an absolute symlinked path. Latent, one spelling change away — not currently silent. That distinction decides this card's urgency and I had no right to assert it.import.meta.mainwas correctly rejected: it landed in Node 24.2, the repo floor is>=22and CI runs 22.x, where it isundefined— the same silent skip relocated.Disposition
- PR fix(scripts): one shared entry-point guard — a gate reached through a symlink measured nothing and exited 0 #1256 → ready, merge queue.
pnpm scan:fields --sitesanswers a misspelled field name with "this field is inert" and exits 0 #1255 filed (unassigned, unlabeled, awaiting triage):pnpm scan:fields --sitesanswers a misspelled field name with0 site(s) — (none — this field is inert)and exits 0, so a typo reads as a verified inert-field finding. Same silence family, different mechanism (argument validation). Worth triage attention: this repo has an enforce-or-remove ledger driven by exactly that command's output.
- removedpm:dispatchedDispatched to a dev agent by /pm-dispatchDispatched to a dev agent by /pm-dispatch
on Aug 23, 2026
scripts/check-source-token-ratchet.mjsdecides whether to run by comparingimport.meta.urlagainstpathToFileURL(process.argv[1]):import.meta.urlcarries the realpath;process.argv[1]carries the path as the caller spelled it. When any component of that path is a symlink the two differ, the guard is false,main()never runs — and the process prints zero bytes and exits 0.Measured
Reproduced outside vitest, on an untouched copy of the gate and a synthetic tree, on macOS where
mktemp -dreturns/var/folders/…(a symlink to/private/var/folders/…):What it costs today
test/source-token-ratchet.test.tscopies the gate into amktemp -droot and runs it there, so 10 of its 15 cases fail on any macOS checkout — every one of them asSyntaxError: Unexpected end of JSON inputfromJSON.parse(''), which reads like a bug in the gate's--jsonoutput rather than the gate never having run. Linux CI is green because/tmpis a real directory there, so the failure is invisible to the farm and permanently in the way of localpnpm test.Why it is worth more than a local annoyance
The exit code is 0. A gate invoked through a symlinked path measures nothing and reads as a pass — the exact "a check that scans nothing is worse than no check at all" hazard this file's sibling,
check-source-hygiene.mjs, spends three separate loudness guards defending against. Today only the test harness hits it; a checkout under a symlinked path (a common shape for CI caches and for/tmp-based sandboxes) would hit it for the realpnpm hygiene:tokensrun.Fix shape
Compare realpaths on both sides, e.g.
pathToFileURL(realpathSync(process.argv[1])).href, or useprocess.argv[1]only after resolving it. Whatever the form, the guard should be loud when it declines to run rather than silent — that is the property that turned this into ten opaque failures instead of one clear message.Found while verifying #1243 (PR #1251); nothing in that change touches this file or its test. Filed unassigned.