Skip to content

fix: make a delivered file set define the rule directory - #236

Merged
thecodedrift merged 3 commits into
mainfrom
fix/233-delivered-set-is-the-directory
Sep 2, 2026
Merged

fix: make a delivered file set define the rule directory#236
thecodedrift merged 3 commits into
mainfrom
fix/233-delivered-set-is-the-directory

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

The defect

writeDeliveredFileSet (packages/cli/src/rules/deliver.ts) wrote every file in a delivered set and removed nothing, so a file already on disk that the set did not mention survived the write.

That is unremarkable for rule create, where the directory is normally new, and nearly so for rule improve. It is wrong for the repair path in check: repair runs precisely because the directory's trustworthiness is in question — an unsafe verdict means the on-disk rule diverged from what the server blessed. Only check.ts is signed and reconciled, so a stray file elsewhere in the rule directory (a leftover under captures/, say) is never reported by reconcile, was not replaced by the repair, and went on changing what the rule matched. The rule read as repaired and was not.

The decision, and it is one decision for all three callers

The delivered set defines the rule directory's contents, for every caller. The published schema calls files "Every file the rule directory must contain"; if that is the contract, writing must make the directory match the set rather than merge into it. A file the set does not name is not part of the rule.

Repair is deliberately not special-cased. Either the set defines the directory or it does not. A helper that merged for create/improve and purged for repair would mean improve leaves behind exactly the stray that check later has to remove, and "what is in this rule directory" would have two answers depending on which command wrote it last.

Blast radius across the three callers, all via writeRuleFile:

Caller Effect
rule create (commands/rules.ts) Directory is normally new, so nothing to purge. A re-create over an existing directory now yields exactly the delivered set.
rule improve / iterate (commands/rules.ts) A file dropped between revisions (a capture the new version no longer uses) now actually goes away instead of continuing to run.
repair (repairWithheldRules, commands/check.ts) The reported case. The notice was carrying an explicit caveat citing #233; it now states what the directory contains rather than only what was written.

The single-content envelope is untouched — there is no set to be authoritative about, so it keeps overwriting one file. Pinned by a test.

How deletion is bounded

Scoped strictly to .taskless/rules/<engine>/<id>/, twice over:

  • By construction. Every deletion candidate is discovered by walking that directory, never derived from payload content. No string the service sends can name a deletion target.
  • By check. Each candidate still goes through describeUnsafePath, the same guard the delivered paths pass — the existing machinery, not a second path guard — so the rule for "written inside this directory" and the rule for "removed from it" cannot drift apart. A candidate it refuses is left alone.

Two further properties:

  • Assess and purge are a unit, the same way assess and write already were. writeDeliveredFileSet takes an assessDelivery verdict, obtainable no other way, so a refused set never reaches the purge and leaves the directory exactly as it was. A half-purged directory is worse than a merged one.
  • Writes come first, deletions second. Every delivered file is on disk before anything is unlinked, so an IO failure part-way leaves a superset of the blessed rule — today's merge behavior, survivable — rather than a rule missing pieces.

Details worth a reviewer's eye:

  • Symlinks are removed, not followed. readdir does not follow them, so a link to a directory is listed as a file; rm unlinks the link and never touches its target. Tested.
  • Emptied directories are pruned with rmdir, not a recursive rm, because rmdir refuses a non-empty directory: a directory still holding a delivered file, or the preserved .tests/, survives by the filesystem's answer rather than by bookkeeping. Only ENOTEMPTY/EEXIST/ENOENT are swallowed.
  • Comparison is case-folded, matching assessDelivery. On a case-insensitive filesystem a delivered captures/Logs.yml written into an existing captures/logs.yml keeps the on-disk spelling, and an exact compare would delete the file just written — leaving a rule with no capture, which verifies as incomplete and never fires. The cost is that a stale case-differing sibling is spared on a case-sensitive filesystem. That is the right way round to be wrong: sparing a stale file leaves the merge behavior this change narrows; deleting a live one makes the rule inert, which is the failure mode this codebase keeps hitting and does not trade for tidiness.

What happens to .tests/

.tests/ is preserved, and the code says so explicitly (a PRESERVED_SUBTREES constant carrying the reasoning). Two reasons, the first being the one that matters:

  • Nothing under .tests/ reaches an engine. The dot is what makes ast-grep skip the directory during rule discovery (documented on RULE_TESTS_DIRECTORY); strayModules already exempts it for the same reason, and runtime capture discovery skips it by name. A stale fixture cannot change what a rule matches, which is the entire harm the purge exists to prevent. A stale fixture fails a test run loudly, in front of someone already looking at that rule.
  • This CLI writes files there that no delivered set will ever name. writeRuleTestFile writes <id>-<timestamp>-test.yml on every single-content create and iterate, so fixtures accumulate locally and are absent from a later file-set delivery by construction. Purging .tests/ would delete a rule's whole local test history the first time it was redelivered as a file set.

Only the top-level .tests/ is exempt. RULE_TESTS_DIRECTORY is relative to the rule directory, so a nested captures/.tests/ is a file an engine reads, not a fixture directory, and it is purged like any other — otherwise the exemption is a hiding place. Tested.

The repair notice names this rather than glossing it: it now says the rule directory holds exactly the delivered files apart from test fixtures under .tests/.

Cannot leave a rule inert

A complete set cannot be purged into an incomplete rule: assessDelivery already requires the rule file, the engine config where there is one, and at least one capture where the engine uses them, and all of those are written before the purge runs. Two of the new tests assert the rule is still whole after the purge, not merely that the stray is gone.

Tests, and the revert check

Extended both files the change touches:

  • packages/cli/test/deliver.test.ts — 7 new cases: stray capture removed; stray module and notes.md removed with the emptied lib/ pruned while captures/ survives; .tests/ fixtures kept; nested captures/.tests/ purged; symlink unlinked without touching its target; refused set leaves the directory untouched; single-content envelope does not purge.
  • packages/cli/test/repair-integration.test.ts — end to end against the mock: a rule directory carrying a stray capture and a .tests/ fixture, repaired from a restore response, ends with the stray gone, the fixture kept, check.ts and the blessed capture in place, and the notice saying was restored and naming .tests/.

Revert check. With the purge call disabled (writeDeliveredFileSet returning without calling purgeUndeliveredFiles) and the CLI rebuilt, 5 of the 8 new tests fail: all four positive deletion cases in deliver.test.ts and the repair-integration case. The three that still pass are the negative ones — .tests/ kept, refused set leaves the directory untouched, single-content does not purge — which pin behavior that must not change, so passing against the old code is the correct result for them. Restored and re-run green.

Checks

pnpm build, pnpm typecheck, pnpm lint, pnpm --filter @taskless/cli exec vitest run (70 files, 1148 tests, all passing), and pnpm cli check against a fresh build. check reports only the 4 pre-existing no-hedging warnings in route.txt and onboard.txt, none in files this PR touches.

Changeset added: .changeset/delivered-set-is-the-directory.md (patch, @taskless/cli).

Fixes #233

`writeDeliveredFileSet` wrote every file in a delivered set and removed
nothing, so a file already on disk that the set did not mention survived
the write. The schema calls `files` "every file the rule directory must
contain", so the write now makes the directory match the set.

One rule for all three callers (`rule create`, `rule improve`, and
`check`'s repair) rather than a repair-only mode: a helper that merged
for two and purged for the third would let `improve` leave behind
exactly the stray `check` later has to remove.

Deletion is bounded to `.taskless/rules/<engine>/<id>/` by construction
(candidates are discovered by walking that directory, never derived from
payload content) and then by `describeUnsafePath`, the same guard the
delivered paths pass. Assess and purge are a unit, so a refused set
leaves the directory exactly as it was, and writes precede deletions so
an IO failure part-way cannot leave the rule missing pieces.

`.tests/` is exempt and the comment says so: nothing there reaches an
engine, and this CLI writes timestamped fixtures there that no delivered
set will ever name.

Fixes #233
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 6m 0s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. The .prior-review.json snapshot contains only the trigger comment (@claude /review), no prior review threads, reviews, or top-level comments — this is the first review of this PR, so the whole diff was assessed fresh.

  • Read .prior-review.json
  • Gathered PR diff (gh pr diff 236) and metadata
  • Reviewed packages/cli/src/rules/deliver.ts (the new readRuleDirectory/purgeUndeliveredFiles/writeDeliveredFileSet)
  • Reviewed packages/cli/src/commands/check.ts (repair notice text)
  • Reviewed packages/cli/src/rules/files.ts (caller wiring)
  • Reviewed deliver.test.ts and repair-integration.test.ts
  • Posted findings

Overall this is a well-reasoned, well-documented change, and the core design (purge bounded by construction + describeUnsafePath, assess-then-purge as a unit, writes-before-deletes, symlink-unlink-not-follow, .tests/ exemption with a nested-directory carve-out) is sound and thoroughly tested for the cases it targets. I posted 4 inline findings, none of which contradict the design — they're gaps in the edges of it:

  1. packages/cli/src/rules/deliver.ts:370purgeUndeliveredFiles's rm(target, { force: true }) only swallows ENOENT. A real IO error partway through the loop (EACCES, EBUSY, disk full, …) leaves a half-purged directory, which is exactly the "half-purged is worse than merged" state the docstring argues against — but that guarantee is only actually enforced for the assess-refused case, not for a mid-purge IO failure. Untested.
  2. packages/cli/src/commands/check.ts:310 — because write-then-purge now share one try/catch in writeRuleFile, a purge failure (see taskless rules create: spec version upgrade loop with init #1) is reported as "${file} could not be written", even though the delivered bytes are already correctly on disk and only the stray-file cleanup failed. Misleading for anyone reading the notice (or --json output) to decide whether the repair did anything.
  3. packages/cli/src/rules/deliver.ts:461 — the write loop (mkdir + writeFile, unchanged by this PR) follows symlinks, while the new purge path deliberately does not. A pre-existing symlinked path component inside the rule directory (e.g. captures/ itself, or check.ts) would cause delivered bytes to be written through the link, outside the rule directory. Pre-existing behavior, but newly relevant given this PR's stronger "the directory now holds exactly the delivered files" claim and its own extensive discussion of exactly this class of attack surface.
  4. packages/cli/src/rules/deliver.ts:465 — the new DeliveryWrite.written/.removed result is computed but never consumed (the sole caller in files.ts:70 discards it, and no test asserts on it). Surfacing it up to check.ts would let the notice in finding fix: ast-grep binary not found when CLI installed via pnpm dlx #2 be accurate instead of a fixed string.

Nothing else stood out: the symlink/.tests//case-folding edge cases the PR itself calls out are all correctly handled and tested, the DeliveryWrite API change has no other callers to break, and I didn't find a CLAUDE.md convention violation (imports are direct, DeliveryWrite is correctly exported since it's referenced by an exported function's return type).

Comment thread packages/cli/src/rules/deliver.ts Outdated
Comment thread packages/cli/src/commands/check.ts
Comment thread packages/cli/src/rules/deliver.ts Outdated
Comment thread packages/cli/src/rules/deliver.ts
Two gaps at the edges of the delivered-set purge, both found in review.

The write loop followed symlinks while the purge deliberately did not.
`mkdir` with `recursive` is satisfied by a link to an existing directory
and `writeFile` follows a link to a file, so a planted `captures` or
`check.ts` sent the blessed bytes outside `.taskless/rules/<engine>/<id>/`
and left the rule directory without them - a rule that verifies as
incomplete and never fires, reported as a successful delivery. A link
inside the rule directory is now unlinked before the write, matching what
the purge already does to the same entry; a rule directory that is itself
a link is refused outright, since that one would also hand the purge
whatever the link points at.

A failed removal no longer abandons the pass. `rm` with `force` swallows
only ENOENT, so a real failure on one stray left every later stray in
place, and which ones survived depended on `readdir` order. Failures are
collected, the pass continues, and the error names each entry it could
not remove - loudly, because an unremoved stray is still a file an engine
reads.

Writes still come first, so a failed purge is a named leftover beside a
complete rule, never a missing piece.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 6m 0s — Review complete… I posted 4 inline findings, none of which contradict the design — they're gaps in the edges of it…"
#236 (comment)

Both medium findings are fixed in 642fc4f, and both were real.

#3 (write path follows symlinks) was an actual escape of the boundary, not only an asymmetry with the purge: clearSymlinkedPath now unlinks a symlink standing anywhere on a delivered file's path before writing, and a rule directory that is itself a symlink is refused outright — that one would also have handed the purge whatever the link pointed at, since readdir reads through it, and describeUnsafePath cannot see it because it resolves delivered paths against that directory as a string.

#1 (partial purge on a mid-loop IO error) now collects failures and finishes the pass, then throws naming every entry it could not remove. Abandoning at the first failure made the surviving strays a function of readdir order, and swallowing them would have left a file an engine still reads with nobody told. Writes come first, so the worst case is a named leftover beside a complete rule.

Four new tests cover both (including a real EACCES, skipped as root); each was verified to fail with the code under test reverted. Findings #2 and #4 are low and left for the author to decide — they are about propagating DeliveryWrite up to check.ts so the repair notice can distinguish "write failed" from "write succeeded, cleanup failed".

— AI Coding Agent

Review follow-up on #236, taking both low items as one change because
the second is only worth doing as the first's mechanism.

A purge failure became a normal, reachable outcome in the previous
commit, and it landed in a catch that reported "could not be written".
That is the opposite of true: the blessed bytes ARE on disk and only the
cleanup is partial. A reader acting on it re-runs a repair that already
succeeded, or concludes the rule is unrepaired and edits it by hand. The
error string already said so, wrapped in a sentence contradicting it.

`PurgeIncompleteError` carries the surviving entries, so the caller
branches on a type rather than matching prose — which is the point: the
two failures ask the reader for opposite things ("the rule is not there"
versus "the rule is there and something stale is too"), and a call site
that could only read the message would get it wrong the first time the
wording changed. The repair notice now names what survived and says an
engine still reads it.

The second item was `DeliveryWrite.written`/`.removed`, computed and
never consumed. Resolved by deleting the shape rather than plumbing it
to a caller. Plumbing it would mean widening `writeRuleFile`'s return,
which changes call sites in `commands/rules.ts` — a file PR #238 owns
concurrently — for a value nothing needed once the failure path carries
its own detail. The data now lives where it is used.

Verified the new test bites by throwing a plain `Error` instead: it
fails, and passes again when restored. Five unrelated tests timed out at
the limit on one run and did not reproduce across four further runs,
including three consecutive runs of the two suites this change touches.
@thecodedrift
thecodedrift merged commit 16aa965 into main Sep 2, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the fix/233-delivered-set-is-the-directory branch September 2, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A restored rule directory can keep files the blessed set never mentioned

1 participant