Skip to content

hyp purge <path> reports success and exits 0 while silently retaining rows recorded under an aliased spelling of that directory #485

Description

@philcunliffe

hyp purge <path> reports success and exits 0 while silently retaining every row recorded under a different spelling of that directory. The user asked for their captured LLM traffic to be deleted, the command said it worked, and the data is still in the cache.

This is a defect on master. It is not introduced by PR #484 or #482. It is being filed now because PR #484 closes #483, whose body lists this purge behaviour as one of the symptoms, so closing #483 would retire the only place it was written down.

Reproduction (executed on Linux, master @ 1555f13)

Rows are recorded under one spelling of a directory, the purge names the other. Both spellings are built as string literals, so this is fully witnessable off macOS: the subtree predicate is a byte comparison and never touches the filesystem.

$ hyp purge ~/café/proj --yes      # typed NFC; rows were recorded under NFD
purged 0 rows from 0 partitions
$ echo $?
0
rows recorded as purge argument exit stdout stderr rows surviving
NFC NFC (control) 0 purged 1 row from 1 partition resurrection warning 0
NFD NFC 0 purged 0 rows from 0 partitions (empty) 1
NFC NFD 0 purged 0 rows from 0 partitions (empty) 1
/home/u/proj /home/u/Proj 0 purged 0 rows from 0 partitions (empty) 1

Identical on master (1555f13) and on PR #484's head, so #484 ships no regression here.

Why it is worse than the count suggests

Cause

src/core/cache/purge.js, the subtree branch of buildPredicate:

if (!isEqualOrDescendant(path.resolve(row.cwd), base)) return false

isEqualOrDescendant is a pure lexical prefix test and path.resolve folds neither Unicode normalization nor case. Two processes at different times produce the two strings (a CLI resolving the purge target, versus a client that recorded a cwd), so divergence is ordinary rather than user error. Same root cause as #479 (symlinks) and #483 (case / normalization), different call site.

What already covers part of this

So the residue this issue tracks is precisely: the subtree predicate does not fold the spellings #483 is about, after both PRs land.

The fix is not a one-liner, and that is the point

Do not close this by dropping foldPath into the predicate. At the gate, widening the match is free because the resolved class is max(declared, folded), so a wrong fold can only over-restrict. In a deletion predicate widening deletes. On a Linux volume, caf + U+00E9 and cafe + U+0301 are two directories with two inodes that can both exist in one parent (verified on this host: distinct ino, distinct contents, both listed by readdir), so folding NFC inside the purge predicate would delete cached rows for a genuinely different sibling directory the user never named. Same argument applies to case folding on a case-sensitive volume.

A correct fix needs the fold gated on the volume actually being normalization-insensitive. No such probe exists today: createVolumeCaseProbe (src/core/usage-policy/fold.js) answers only the case question. Options, for whoever picks this up:

  1. Add a per-volume normalization-insensitivity probe alongside the case probe (mkdir/stat a decomposed spelling of an existing entry and compare dev/ino), and gate the purge fold on it. Most correct, most work.
  2. Restrict purge-side folding to darwin, matching the existing probe's inertness off darwin. Cheap, and no worse than the platform assumption already shipped.
  3. Widen the match but require confirmation, i.e. report "N additional rows match a different spelling of this path" and let the user opt in. Preserves the no-surprise-deletion property without needing a probe.

Whichever is chosen, it belongs in the one shared predicate (scopeGoverns, introduced by #482) rather than as a second folding rule at the call site, per #465.

Acceptance

  • Purging a directory whose rows were recorded under an aliased spelling either deletes them or tells the user it did not, on a volume that treats the spellings as one directory.
  • No over-deletion on a volume that treats them as two: a purge of caf+U+00E9 must not touch rows recorded under cafe+U+0301 on Linux.
  • A regression test in test/core/purge-command.test.js covering both directions, with the two spellings written as \u escapes so an editor re-normalizing the file cannot collapse them (see the tripwire in test/core/usage-policy-fold.test.js).

Related

#479 (symlink class, PR #482), #483 (case/normalization class, PR #484), #465 (the duplicated-invariant pattern to avoid), LLP 0050 §normalization "Not covered", LLP 0104.

Filed by the round-2 review of PR #484.

Metadata

Metadata

Assignees

No one assigned

    Labels

    neutral:fixDelegate this issue to neutral for an autonomous fix attempt (reproduce -> fix -> PR)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions