test: coverage for the memorable bridge (remove-source regression for every KNOWN_HOOKS source)

- settings-hook: identity removal pinned for each source in KNOWN_HOOKS;
  list-items unknown flag and combined --owned-by/--command-regex
- gstack-memorable: enable/disable failure paths (lock give-up exit 5
  with the test-only lock timeout override, consent-write failures
  guarded by canRevokeWrites, canonical-version mismatch, no-bun status)
- hook: non-object JSON, missing cwd, non-ASCII bytes, held-open stdin,
  shim without bun, stripControl, resolveVendor, runExternal ENOENT
- egress-receipt: lockBudgetMs 0 and writeOutcome on garbage input
- uninstall: no memorable entry present reports nothing removed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-09 02:50:32 +00:00
co-authored by Claude Fable 5.1
parent 45035b7f94
commit 77b7b31892
5 changed files with 332 additions and 6 deletions
@@ -963,6 +963,68 @@ describe('remove-source: identity-aware (tag OR table)', () => {
});
});
describe('remove-source: identity removal holds for EVERY KNOWN_HOOKS source (regression)', () => {
// The semantics change applies to all six rows, but setup's --no-team path
// and uninstall lean on four sources this file never exercised behaviourally.
const seedAll = () => fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [
{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] },
{ hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] },
{ hooks: [{ type: 'command', command: '/Users/me/my-stop-hook' }] },
],
PostToolUse: [
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/auq-error-fallback-hook' }] },
{ matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] },
],
SessionStart: [
{ hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] },
{ hooks: [{ type: 'command', command: '/Users/me/my-session-hook' }] },
],
},
}, null, 2));
const allCommands = () => {
const h = settings().hooks ?? {};
return Object.values(h).flatMap((entries: any) => entries.flatMap((e: any) => e.hooks.map((i: any) => i.command))).sort();
};
for (const [source, own] of [
['gstack-timeline-stop', '/x/hosts/claude/hooks/timeline-stop-hook'],
['verify-gate', '/x/bin/gstack-verify-gate'],
['auq-error-fallback', '/x/hosts/claude/hooks/auq-error-fallback-hook'],
['gstack-session-update', '/x/bin/gstack-session-update'],
['plan-tune-cathedral', '/x/hosts/claude/hooks/question-log-hook'],
] as const) {
test(`remove-source --source ${source} removes exactly its own UNTAGGED item and nothing else`, () => {
seedAll();
const before = allCommands();
const r = run(['remove-source', '--source', source]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 1 /);
expect(allCommands()).toEqual(before.filter((c) => c !== own));
});
}
test('a non-array hooks.<event> value is never touched (foreign shape), exit 0', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: { weird: true }, Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }] } }, null, 2));
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.exitCode).toBe(0);
expect(r.stdout).toMatch(/removed 0 /);
expect(settings().hooks.UserPromptSubmit).toEqual({ weird: true });
});
test('a tagged entry holding only a command-less item, and a tagged multi-item entry with no table rows, are kept with their tags', () => {
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command' }] },
{ _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/a/foreign' }, { type: 'command', command: '/b/foreign' }] },
] } }, null, 2));
const before = fs.readFileSync(settingsFile, 'utf-8');
const r = run(['remove-source', '--source', 'gstack-memorable']);
expect(r.stdout).toMatch(/removed 0 /);
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
});
});
describe('list-items: read-only identity view', () => {
const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook';
const foreign = '/Users/me/my-user-prompt-hook';
@@ -1005,6 +1067,17 @@ describe('list-items: read-only identity view', () => {
expect(run(['list-items', '--event', 'UserPromptSubmit'])).toMatchObject({ exitCode: 0, stdout: '' });
});
test('an unknown flag exits 1; --owned-by combined with --command-regex intersects (a regex never widens a selection)', () => {
seed();
expect(run(['list-items', '--event', 'UserPromptSubmit', '--bogus', 'x']).exitCode).toBe(1);
const both = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'memorable-user-prompt-hook$']);
expect(both.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]);
const none = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'no-such-thing']);
expect(none).toMatchObject({ exitCode: 0, stdout: '' });
const vendorOnly = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', 'memorable']);
expect(vendorOnly.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]); // regex alone still excludes owned items
});
test('exit codes mirror the mutating verbs: 1 usage, 3 unparseable, 4 unexpected shape', () => {
seed();
expect(run(['list-items']).exitCode).toBe(1);