Skip to content

fix(generate): refuse to sweep orphans through a link out of the output root - #3295

Merged
dyoshikawa merged 1 commit into
mainfrom
resolve-issue-3294
Oct 3, 2026
Merged

dyoshikawa merged 1 commit into
mainfrom
resolve-issue-3294

Conversation

@dyoshikawa

Copy link
Copy Markdown
Owner

Summary

Fixes item 7 of #3294: the --delete orphan sweep could follow a symlinked output directory out of the project and delete files there.

Reproduction (on main)

mkdir -p proj/.cursor notes && echo secret > notes/precious.md
ln -s "$PWD/notes" proj/.cursor/commands
# proj/.rulesync/commands/a.md exists, targets: ["*"]
cd proj && rulesync generate --targets cursor --features commands --delete
# => Deleted: .../proj/.cursor/commands/precious.md   (notes/precious.md is gone)

The same happens for rules (.claude/rules -> ~/somewhere). subagents and skills were already refused by their own guards, so only the shared file-feature path was affected.

Candidate enumeration uses followSymbolicLinks: false, which only covers links inside the scanned directory, not the directory itself. FeatureProcessor.removeOrphanAiFiles then removed whatever was listed with no inside-root check.

Fix

  • removeOrphanAiFiles now asks refusesDeleteOutsideRoot before deleting each candidate. It uses the existing writablePathEscapesRoot on the candidate's parent directory, so:
    • a linked directory that leads outside the output root: refused, with a Refusing to delete "<path>": it resolves outside "<root>" through a symbolic link warning (same shape as the write-side refusal);
    • a linked directory that stays inside the root (dotfiles layout): swept as before;
    • an orphan that is itself a symlink: still unlinked (removing a link never touches its target).
  • The check runs before the dry-run branch, and refused files are not counted or reported, so --check / --dry-run agree with a real run instead of reporting a deletion forever.
  • Documented in docs/reference/cli-commands.md (--delete section); src/generated/docs-content.ts regenerated.

Tests

Unit tests in src/types/feature-processor.test.ts for each of the cases above, including the dry-run case. I also re-ran the reproduction against this branch: precious.md is kept and the warning is printed.

The other follow-ups from #3294 (items 1-6, about orphan-sweep.ts) go in a separate PR.

Refs #3294

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y4MwhqMc6s4ct5MzMReGJj

…ut root

The file-feature orphan sweep enumerates candidates without following links
inside the scanned directory, but the scanned directory itself was reached
through any link in its path. A checked-out `.cursor/commands -> ~/notes`
made every file in ~/notes read as an orphan, and `generate --delete`
removed them (reproduced for commands and rules).

removeOrphanAiFiles now refuses a candidate whose parent directory lands
outside the output root, with the same warning shape as the write-side
refusal, before the dry-run branch so --check agrees with a real run.

Refs #3294

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4MwhqMc6s4ct5MzMReGJj
@dyoshikawa
dyoshikawa merged commit fbc5a79 into main Oct 3, 2026
10 checks passed
@dyoshikawa

Copy link
Copy Markdown
Owner Author

@dyoshikawa Thank you!

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.

1 participant