fix(#2665): let the guard see deletions, in both shapes it can take

diffLiveConfig walked `after` alone, so it had no branch for a path that
existed before the run and does not after. A test run that DELETES a file from
the developer's real config dir passed the guard silently -- the least
recoverable case in the threat model this guard exists to cover.

The review named the missing `pre.exists && !post.exists` branch. That branch is
necessary and not sufficient: deletion arrives in two shapes and it reaches only
one of them.

  - A FIXED owned entry (GSD_OWNED_ENTRIES x roots, plus every extra target) is
    recorded at both ends whether it exists or not, so a deletion reads
    {exists:true} -> {exists:false}. This is the shape the named branch fixes.
  - A gsd-prefixed child is DISCOVERED by readdirSync, so a deleted one is
    absent from `after` entirely and never enters an after-keyed loop at all.
    The named branch is unreachable for it.

So the walk is now over the UNION of both key sets, with the explicit branch for
the first shape and an `!post` branch for the second. Both are covered by a
test, and reverting the fix fails both -- the prefixed-child test is the one
that would still fail with only the prescribed branch in place.

Addresses review finding: Blocker 2.
This commit is contained in:
0xdhx
2026-08-06 16:30:04 -05:00
parent ecea537194
commit 6a1fbf96fd
2 changed files with 68 additions and 4 deletions

View File

@@ -162,6 +162,46 @@ describe('#2665: live-config hermeticity guard', () => {
}
});
test('detects a DELETED top-level GSD entry', () => {
const root = tmpRoot();
try {
// A fixed owned entry is recorded at BOTH ends whether or not it exists,
// so a deletion reads {exists:true} -> {exists:false}. Before the union
// walk that pair matched no branch at all and the run passed silently.
fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true });
fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x');
const before = snapshotLiveConfig([root]);
fs.rmSync(path.join(root, 'gsd-core'), { recursive: true, force: true });
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
const deleted = violations.filter((v) => v.kind === 'deleted');
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
assert.strictEqual(path.basename(deleted[0].path), 'gsd-core');
} finally {
cleanup(root);
}
});
test('detects a DELETED gsd-prefixed child of a shared dir', () => {
const root = tmpRoot();
try {
// The shape a `pre.exists && !post.exists` branch cannot reach on its own:
// prefixed children are DISCOVERED by readdir, so a deleted one is absent
// from the `after` snapshot entirely and never enters an after-keyed loop.
fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true });
fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x');
const before = snapshotLiveConfig([root]);
fs.rmSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true, force: true });
const violations = diffLiveConfig(before, snapshotLiveConfig([root]));
const deleted = violations.filter((v) => v.kind === 'deleted');
assert.strictEqual(deleted.length, 1, JSON.stringify(violations));
assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences');
} finally {
cleanup(root);
}
});
test('the report names the path and the remedy', () => {
const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]);
assert.match(out, /HERMETICITY WARNING/);