fix(#3321): exclude .claude/.planning from no-phantom-issue-refs SKIP_DIRS (#3396)

* test(#3321): add failing-first regression proving walk() must skip .claude/.planning

RED step: SKIP_DIRS does not yet exclude .claude/.planning, so this test
is expected to fail until the next commit adds them.

* fix(#3321): exclude .claude/.planning from no-phantom-issue-refs SKIP_DIRS

walk() previously had no guard against descending into ambient,
gitignored .claude/worktrees/** or .planning/** content, so this
guard's pass/fail would have depended on the developer's local
worktree layout the next time PHANTOM is repopulated. Currently
dormant (PHANTOM=[] short-circuits via t.skip before walk() runs)
but the gap was real, per #1885 F23.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-12 20:08:35 -04:00
committed by GitHub
parent 86101ee612
commit bd31cf2bba

View File

@@ -53,7 +53,7 @@ function buildRefRe(phantom) {
const REF_RE = buildRefRe(PHANTOM);
const SCAN_EXT = new Set(['.md', '.cjs', '.js', '.cts', '.ts']);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.changeset']);
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.changeset', '.claude', '.planning']);
// This guard file itself names the phantom numbers (by necessity); exclude it.
const SELF = path.relative(ROOT, __filename);
@@ -131,6 +131,45 @@ test('walk() skips broken symlinks and does not throw ENOENT (#1545)', (t) => {
}
});
test('walk() does not scan ambient .claude/.planning content (#3321)', () => {
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'nophantom-ambient-'));
try {
fs.writeFileSync(path.join(fixture, 'real.md'), '# real, no phantom refs\n');
const claudeDir = path.join(fixture, '.claude', 'worktrees', 'some-agent-worktree');
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'offender.md'), '#94729 sentinel phantom ref\n');
const planningDir = path.join(fixture, '.planning', 'notes');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'offender.md'), '#94729 sentinel phantom ref\n');
const found = walk(fixture, []).map((f) => path.relative(fixture, f));
assert.ok(found.includes('real.md'), 'walk() must still include real.md at fixture root');
assert.ok(
!found.some((f) => f.startsWith('.claude' + path.sep)),
'walk() must NOT descend into .claude/',
);
assert.ok(
!found.some((f) => f.startsWith('.planning' + path.sep)),
'walk() must NOT descend into .planning/',
);
// Prove the offending content WOULD have matched if scanned -- i.e. this isn't a vacuous
// "no file happened to match" pass. Use a test-local sentinel, never the module PHANTOM.
const sentinelRe = buildRefRe(['94729']);
const offenderContent = fs.readFileSync(path.join(claudeDir, 'offender.md'), 'utf8');
assert.ok(
sentinelRe.test(offenderContent),
'sanity: sentinel ref pattern must actually match the offender content',
);
} finally {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in standalone guard test; no helpers import available (would introduce a test-dep cycle)
fs.rmSync(fixture, { recursive: true, force: true });
}
});
test('buildRefRe() boundaries — empty list is inert, single/double entries match exactly', () => {
assert.strictEqual(buildRefRe([]), null, 'empty list must build no regex');