purge's near-miss note names all three causes, not two (#497) - #505
Conversation
… 1 and 3) Deferred review findings from #493, now that it has merged. Finding 1 (behavioural, message-only). `sameDirectoryOnDisk` returns `false` for any `stat` error, not only `ENOENT`, so an alias that is present on disk but unreadable (`EACCES` on an ancestor, `ELOOP`, `ENOTDIR`) retains exactly like one that is absent. The stderr note offered "genuinely different, or no longer on disk" as an exhaustive pair, and neither member holds in that case: nothing adjudicated the spellings different, and the spelling is not gone. The note now says "genuinely different, no longer on disk, or could not be checked", which asserts none of the three. The swallow itself is kept and is now documented as deliberate. Widening a deletion onto an unproven pair would destroy rows under a directory no filesystem identified with the target, so every errno has to collapse to the same `false`; what the collapse bounds is the message, not the decision. The concrete errno stays a diagnostic on `usage_policy.alias_probe_skipped` rather than being threaded through the deletion predicate for a message-only gain. Deletion behaviour is unchanged by construction: the whole `src/` change set, excluding comments, is one stderr string literal. `test('no stat errno makes an unprovable alias deletable')` pins that for six errnos, so a later attempt to make some errno "reachable" fails here rather than in a user's cache. Finding 3 (annotation). Closed the blank line between the `@ref LLP 0104#spellings [tests]` at purge-command.test.js:718 and the test it annotates, per CLAUDE.md's attachment rule. Finding 2 is not fixed, deliberately. See the PR body. Fixes #497 Co-Authored-By: Claude <noreply@anthropic.com>
The new near-miss test is the one fixture in this file that has to *build* both spellings rather than merely name them, so it is the one that needs the volume to keep them apart. On a normalization-insensitive volume (APFS, HFS+) `path.join(root, NFD)` already names the NFC directory created the line before, so `symlink` returns EEXIST and the test errors out before it asserts anything. CI is ubuntu-only, so this never reddened there, but it fails on every Mac, which is a first-class dev platform for this project. Guard the fixture with the file's established `t.skip(...)` idiom: a folded volume cannot host this cause at all (there the alias is *proven* and deleted), and the ENOENT case above still covers the retention on such a host. Also corrects the section comment added alongside the test, which claimed "none of them depends on whether the test volume folds" - true of the other fixtures, not of this one. Co-Authored-By: Claude <noreply@anthropic.com>
Neutral review, round 1 of 2Audited Verdict: approve the substance. The message-only claim is true, and I verified it The central claim: is this message-only?Yes. Executed, and by a stronger method than the PR body's. The body's proof (a) is a
Claim (a) holds. No predicate, branch, return, type or control-flow edge moved. Independent verification that no deletion decision movedI did not take the author's word or the test's word for this. I built the cases and ran Executed. Every swallowed error resolves to
Reasoned, from reading The new test is not vacuous, and it closes a real holeMutation-tested, executed:
That last row is the interesting one. It is exactly the "improvement" the author says Fixture claimConfirmed executed: the self-referential symlink yields a real Finding 1 (LOW, test-only): the ELOOP fixture
|
| check | baseline (origin/master) |
head 2eb6f63 |
after my 5b19c3d |
|---|---|---|---|
npm test |
3087 / 3078 pass / 8 fail / 1 skip | 3089 / 3080 / 8 / 1 | 3089 / 3080 / 8 / 1 |
npm run typecheck |
exit 0 | exit 0 | |
npm run smoke -- purge_removes_cached_rows |
ok | ok |
The 8 failures are the pre-existing test/core/leave-command.test.js ones. Confirmed
by name against my own baseline run, byte-identical across all three runs
(not ok 892, 893, 894, 896, 897, 898, 899, 900). The PR body's counts reproduce
exactly. Test count rises by the 2 tests added and no more.
Round 1 of 2 complete. The fix is correct, the proof of it is sound, and the deferrals
are justified. The one finding is in test portability, is fixed, and does not touch the
deletion path. Still a draft, so no merge action taken.
Two review findings on #505. `scopeGovernance`'s JSDoc is the definition site of the `aliased` verdict and the doc any future renderer reads first, but it kept the two-cause reading the PR fixed everywhere else: it named "a live pair with two inodes" and "a spelling that could not be `stat`ed at all", and warned only against claiming a verdict. That is the exact conflation that produced #497 finding 1, left standing in the one place a new caller would consult before writing a new message. It now names all three and carries both prohibitions. The `no stat errno makes an unprovable alias deletable` pin could not fail for the reason it exists. Neither fixture spelling is on disk on any host, so a build that stopped threading `statSync` into `sameDirectoryOnDisk` would reach `aliased` through a real `ENOENT` and the test would still pass, having never raised the errno under test. It now asserts the injected `statSync` was called. Co-Authored-By: Claude <noreply@anthropic.com>
Verdict: approve with two low-severity findings, both fixed and pushedThe change is sound and does what #497 finding 1 asks. I re-derived the central I also reproduced finding 2 (symlink-inside-target) against this head to check The unwidened gate predicate really does return Checks on my head ( fail 8 # skipped 1`, unchanged from this PR's own head since my fixes addassertions rather than tests. The 8 are the pre-existing Finding 1 (low, doc correctness): the three-cause enumeration stops one fileshort of where
The PR carries the fix to Two problems, and they are the two halves of this PR's own thesis. It gives the Fixed in Finding 2 (low, test quality): the errno pin cannot fail for the reason it exists
I confirmed the injection is consulted today Fixed in Considered and deliberately not raised
|
Triage rung (LLP 0017): PR can ship safelyReview's 2-round fix-loop closed at head
Filed as a follow-up issue with full evidence and a backlink: #526 Also re-verified independently rather than trusting the PR body / review |
Deferred, non-blocking review findings from #493, actionable now that it has
merged. Each was re-verified against
masterbefore any code was written.No maintainer decision in the thread.
gh issue view 497 --json commentsreturns
{"comments":[]}, so nothing overrides the brief.Finding 1:
sameDirectoryOnDiskswallows everystaterror - behavioural (message-only), FIXEDVerified on
master. The premise holds.sameDirectoryOnDiskcatches anyerror from
statSyncand returnsfalse, so an alias spelling that is presenton disk but unreadable retains exactly like one that is absent, and the run has
no way to tell them apart. The stderr note enumerated two causes as though they
were exhaustive:
For a swallowed
EACCES/ELOOP/ENOTDIRneither member holds: nothingadjudicated the two spellings different, and the spelling is not gone.
Fix. The note now reads
(genuinely different, no longer on disk, or could not be checked), which asserts none of the three. The same three-causeenumeration is now carried in
PurgeSummary's contract comment(
src/core/cache/types.d.ts),buildPredicate'sretainedAliasesJSDoc, andLLP 0104 #spellings.The swallow itself is kept, and is now documented as deliberate
(
sameDirectoryOnDiskJSDoc). Widening a deletion on a failed probe woulddelete rows under a directory no filesystem ever identified with the target, so
every errno has to collapse to the same
false. What the collapse bounds is themessage, not the decision.
FAIL on
masterNew test run against pristine
origin/mastersource in a separate worktree:PASS on this branch
The fixture uses a self-referential symlink (
ELOOP) rather than a chmod-edancestor (
EACCES): it needs no injectedstatSync, so it exercises the realpredicate end to end through
runPurge, and unlike a permission bit it stillfails the
statif the suite ever runs as root.Proof that no deletion decision moved
Three independent lines of evidence.
1. Structural. The entire
src/change set, with comment lines removed, isone string literal:
No predicate, branch, return value or type changed. The remaining
src/diff isJSDoc and comments.
2. Asserted in the new test. The
ELOOPtest asserts the retention as wellas the wording (
purged 0 rows, and the row still present in the tableafterwards). Those assertions passed on
mastertoo - only the wordingassertion failed - so the run is a direct before/after on the deletion decision
for the exact case the fix is about.
3. Pinned against future regression.
test('no stat errno makes an unprovable alias deletable')drivesscopeGovernance(..., { proveAliases: true })with an injected
statSyncthrowing each ofENOENT,EACCES,EPERM,ELOOP,ENOTDIR,EIOand asserts'aliased'every time, never'governs'.This reproduces triage's by-execution claim as a standing test, so a later
attempt to "improve" the swallow by treating some errno as reachable fails here
rather than in someone's cache.
Finding 2: symlink-inside-target - pre-existing, NOT fixed, documented instead
Verified on
master. Reproduced directly against pristineorigin/masterwith target
<root>/proj, a real directory<root>/elsewhere/sub, and<root>/proj/link -> <root>/elsewhere:Confirmed as filed: identical with the
proveAliasesopt-in off, so it isthe plain
canonicalSpellings/matchDepthmatch from #482, not #493's fold.Concluded not fixable here. The third line is the decisive one: the
unwidened gate predicate returns
truefor the same fixture. Sohyp policy show,hyp ignore --checkand the adapter gate all consider that row insidethe target. Making purge alone retain it would desynchronise purge from the
gate and directly break the first consequence LLP 0104 already commits to:
The user would be told rows are in scope by one verb and refused by the other,
with no spelling they could name to clear them. That is a change to
LLP 0050 §canonicalization's set-of-spellings semantics, with its own disclosure
argument, not a consequence of #493 or of this PR. The issue itself says it is
"worth a decision, separately".
Recorded as a
## Consequencesbullet in LLP 0104 so the next reader finds thereasoning rather than rediscovering the behaviour, and left open for a
maintainer decision.
Finding 3:
@refseparated from its construct - prose/annotation, FIXED, no test by constructionThe blank line between the
@ref LLP 0104#spellings [tests]and thetest(...)it annotates at
test/core/purge-command.test.js:718is closed. No test -this is an annotation attachment convention with no runtime effect, so there is
nothing to assert; manufacturing one would be theatre.
What I deliberately did not change, and why
The neighbour at line 442 is left as it is, and a broader normalization is
left to a maintainer. Evidence: this shape is not a slip in this file, it is the
established convention across the test corpus. Scanning every
@refinsrc/,test/andhypaware-core/for one followed by a blank line finds 31, in28 files, essentially all of them section-level headers over a block of tests
(
usage-policy-symlink.test.js,status-layered.test.js,config-merge.test.js,cli/wizard/*.test.js, and so on).Two further notes for whoever takes that decision:
written. LLP 0000 does not define attachment semantics, although
.claude/skills/ref-check/SKILL.mdcites it as if it did.("purge deletes an aliased spelling the filesystem proves...") to
const NFC,which is a worse annotation than the one it replaces.
I did not amend CLAUDE.md. Rewriting the repo's agent-instruction file to
dissolve a convention finding is not a call an autonomous worker should make
unilaterally, however good the corpus evidence.
Checks
Both suites run serially in separate worktrees, never concurrently.
npm test(pristineorigin/masterbaseline)# tests 3087 # pass 3078 # fail 8 # skipped 1npm test(this branch)# tests 3089 # pass 3080 # fail 8 # skipped 1npm run typechecknpm run smoke -- purge_removes_cached_rowsokThe 8 failures are the pre-existing
test/core/leave-command.test.jsones,confirmed by name against my own baseline and byte-identical between the two
runs:
leave after join removes the seed and reports the server,leave clears an applied central slot, not just the seed,leave reverses org-driven attaches and drops the forward identity,leave after join also warns about a local central sink that keeps forwarding,leave is idempotent: a second leave is the not-connected no-op,leave still tears down when only a stale attach marker survives a prior partial leave,leave removes the assets its attach marker records, and leaves manual copies alone,leave self-heals an org attach whose plugin is gone: drops the marker, warns, stays clean. Test count rises byexactly the 2 tests added; pass count by the same 2.
@refvalidation over the touched files: every annotation resolves(
LLP 0104#spellings,LLP 0050#canonicalization,LLP 0050#normalization,LLP 0049#fail-safeall exist; LLP 0104 isAccepted). No new LLP number wasminted, so nothing collides with 0160-0164.
No collision with work in flight
File lists pulled live with
gh pr diff <n> --name-only. #491(
fix/issue-481) and #502 (fix/issue-421) share no file with this branch.Fixes #497