@sabaiway/agent-workflow-kit 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,463 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join, dirname, basename, resolve } from 'node:path';
4
+ import {
5
+ hideFootprint,
6
+ inferVisibility,
7
+ migrateFromGlobal,
8
+ buildBlock,
9
+ START_MARKER,
10
+ END_MARKER,
11
+ } from './hide-footprint.mjs';
12
+ import { FOOTPRINT_STOP } from './known-footprint.mjs';
13
+
14
+ const DIR = '/repo';
15
+ const EXCLUDE = join(DIR, '.git/info/exclude');
16
+ const ENOENT = (p) => Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' });
17
+
18
+ // ── in-memory world (absolute path → node) ───────────────────────────────────────
19
+
20
+ const makeWorld = (seed = {}) => {
21
+ const nodes = new Map(); // abspath → { kind:'file'|'dir', content? }
22
+ const world = {
23
+ file: (rel, content = '') => { nodes.set(join(DIR, rel), { kind: 'file', content }); return world; },
24
+ dir: (rel) => { nodes.set(join(DIR, rel.replace(/\/$/, '')), { kind: 'dir' }); return world; },
25
+ abs: (p, content = '') => { nodes.set(p, { kind: 'file', content }); return world; },
26
+ read: (rel) => nodes.get(join(DIR, rel))?.content,
27
+ nodes,
28
+ };
29
+ if (seed.exclude !== undefined) world.file('.git/info/exclude', seed.exclude);
30
+ return world;
31
+ };
32
+
33
+ const fsDeps = (world) => ({
34
+ readFile: (p) => { const n = world.nodes.get(p); if (!n || n.kind !== 'file') throw ENOENT(p); return n.content; },
35
+ writeFile: (p, c) => { world.nodes.set(p, { kind: 'file', content: c }); },
36
+ stat: (p) => { const n = world.nodes.get(p); if (!n) throw ENOENT(p); return { isFile: () => n.kind === 'file' }; },
37
+ readdir: (p) => {
38
+ const n = world.nodes.get(p);
39
+ if (!n || n.kind !== 'dir') throw ENOENT(p);
40
+ const names = [];
41
+ for (const k of world.nodes.keys()) if (dirname(k) === p) names.push(basename(k));
42
+ return names;
43
+ },
44
+ });
45
+
46
+ // ── mock git (asserts cwd + repo-relative probes) ────────────────────────────────
47
+
48
+ const gitDeps = (cfg, world) => (args, opts) => {
49
+ assert.equal(opts.cwd, DIR, 'every git call runs with cwd = the project dir');
50
+ const [cmd, ...rest] = args;
51
+ if (cmd === 'rev-parse') return { status: 0, stdout: '.git/info/exclude\n', stderr: '' };
52
+ if (cmd === 'config') {
53
+ if (cfg.global == null) return { status: 1, stdout: '', stderr: '' }; // unset
54
+ return { status: 0, stdout: `${cfg.global}\n`, stderr: '' };
55
+ }
56
+ const probe = rest[rest.length - 1];
57
+ assert.ok(!probe.startsWith('/'), `probe is repo-relative, never the anchored "/" form: ${probe}`);
58
+ if (cmd === 'ls-files') {
59
+ if (cfg.lsFilesFails) return { status: 128, stdout: '', stderr: 'fatal: boom' };
60
+ const hits = (cfg.tracked ?? []).filter((t) => (probe.endsWith('/') ? t.startsWith(probe) : t === probe));
61
+ return { status: 0, stdout: hits.map((h) => `${h}\0`).join(''), stderr: '' };
62
+ }
63
+ if (cmd === 'check-ignore') {
64
+ if (cfg.checkIgnoreFails) return { status: 128, stdout: '', stderr: 'fatal: bad config' };
65
+ // Real precedence: a tracked .gitignore > .git/info/exclude > global core.excludesFile.
66
+ const stat = (cfg.ignored ?? {})[probe];
67
+ const fmt = (s) => ({ status: 0, stdout: `${s.source}:${s.line ?? 1}:${s.pattern ?? probe}\t${probe}\n`, stderr: '' });
68
+ if (stat && basename(stat.source) === '.gitignore') return fmt(stat); // gitignore wins
69
+ const content = world.nodes.get(EXCLUDE)?.content ?? '';
70
+ const lines = content.split('\n').map((l) => l.replace(/\r$/, '')); // git is EOL-agnostic
71
+ if (lines.includes(`/${probe}`)) return { status: 0, stdout: `.git/info/exclude:1:/${probe}\t${probe}\n`, stderr: '' };
72
+ if (stat) return fmt(stat); // global (lower than the local exclude)
73
+ return { status: 1, stdout: '', stderr: '' };
74
+ }
75
+ return { status: 128, stdout: '', stderr: 'unknown' };
76
+ };
77
+
78
+ const mk = (cfg = {}, seed = {}) => {
79
+ const world = makeWorld(seed);
80
+ if (seed.world) seed.world(world);
81
+ return { world, deps: { git: gitDeps(cfg, world), ...fsDeps(world), home: '/home/u', log: () => {}, errlog: () => {} } };
82
+ };
83
+
84
+ // ── buildBlock ────────────────────────────────────────────────────────────────────
85
+
86
+ describe('buildBlock', () => {
87
+ it('sorts + dedupes', () => {
88
+ assert.deepEqual(buildBlock(['/b', '/a', '/b', '/c']), ['/a', '/b', '/c']);
89
+ });
90
+ });
91
+
92
+ // ── idempotency + EOL + splice ────────────────────────────────────────────────────
93
+
94
+ describe('hide flow — idempotency & splice', () => {
95
+ it('apply twice → byte-identical (zero diff)', () => {
96
+ const { world, deps } = mk();
97
+ const r1 = hideFootprint({ dir: DIR }, deps);
98
+ assert.equal(r1.action, 'created');
99
+ const after1 = world.read('.git/info/exclude');
100
+ const r2 = hideFootprint({ dir: DIR }, deps);
101
+ assert.equal(r2.action, 'noop');
102
+ assert.equal(world.read('.git/info/exclude'), after1, 'second run leaves the file byte-for-byte');
103
+ });
104
+
105
+ it('one managed block, markers present, KIT_OWN hidden', () => {
106
+ const { world, deps } = mk();
107
+ const r = hideFootprint({ dir: DIR }, deps);
108
+ const content = world.read('.git/info/exclude');
109
+ assert.equal(content.split(START_MARKER).length - 1, 1, 'exactly one start marker');
110
+ assert.equal(content.split(END_MARKER).length - 1, 1, 'exactly one end marker');
111
+ assert.ok(r.wrote.includes('/AGENTS.md') && r.wrote.includes('/docs/ai/'));
112
+ assert.ok(content.endsWith('\n'));
113
+ });
114
+
115
+ it('preserves outside boilerplate; appends the fence', () => {
116
+ const boiler = '# git ls-files --others --exclude-from=.git/info/exclude\n# *~\n';
117
+ const { world, deps } = mk({}, { exclude: boiler });
118
+ hideFootprint({ dir: DIR }, deps);
119
+ const content = world.read('.git/info/exclude');
120
+ assert.ok(content.startsWith('# git ls-files --others'), 'boilerplate kept at the top');
121
+ assert.ok(content.includes('# *~'), 'comment kept');
122
+ assert.ok(content.indexOf(START_MARKER) > content.indexOf('# *~'), 'fence appended after boilerplate');
123
+ });
124
+
125
+ it('CRLF preserved when the file uses CRLF', () => {
126
+ const { world, deps } = mk({}, { exclude: '# boiler\r\n' });
127
+ hideFootprint({ dir: DIR }, deps);
128
+ const content = world.read('.git/info/exclude');
129
+ assert.ok(content.includes('\r\n'), 'CRLF EOL preserved');
130
+ assert.ok(!/[^\r]\n/.test(content), 'no lone LF introduced');
131
+ });
132
+
133
+ it('empty/missing exclude → LF default, still idempotent', () => {
134
+ const { world, deps } = mk(); // no exclude file at all
135
+ hideFootprint({ dir: DIR }, deps);
136
+ const content = world.read('.git/info/exclude');
137
+ assert.ok(content.includes('\n') && !content.includes('\r'));
138
+ const before = content;
139
+ hideFootprint({ dir: DIR }, deps);
140
+ assert.equal(world.read('.git/info/exclude'), before);
141
+ });
142
+
143
+ it('malformed markers (single / reversed / duplicate) → STOP, file byte-for-byte unchanged', () => {
144
+ const cases = [
145
+ `${START_MARKER}\n/AGENTS.md\n`, // single: start, no end
146
+ `${END_MARKER}\n/x\n${START_MARKER}\n`, // reversed: end before start
147
+ `${START_MARKER}\n/x\n${START_MARKER}\n/y\n${END_MARKER}\n`, // duplicate start
148
+ ];
149
+ for (const bad of cases) {
150
+ const { world, deps } = mk({}, { exclude: bad });
151
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP, `STOP on: ${JSON.stringify(bad)}`);
152
+ assert.equal(world.read('.git/info/exclude'), bad, 'left byte-for-byte');
153
+ }
154
+ });
155
+ });
156
+
157
+ // ── classify: tracked → ASK; order; drop; risk ───────────────────────────────────
158
+
159
+ describe('classify', () => {
160
+ it('a tracked KIT_OWN path → ASK (excluded by default), with rm guidance', () => {
161
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
162
+ const r = hideFootprint({ dir: DIR }, deps);
163
+ assert.ok(!r.wrote.includes('/.claude/settings.json'), 'tracked path not written');
164
+ assert.ok(r.asks.some((a) => a.path === '/.claude/settings.json'), 'surfaced as ASK');
165
+ });
166
+
167
+ it('--include opts a tracked ASK path into needsUntrack (written line + rm guidance, NOT hidden)', () => {
168
+ const { world, deps } = mk({ tracked: ['.claude/settings.json'] });
169
+ const r = hideFootprint({ dir: DIR, include: ['/.claude/settings.json'] }, deps);
170
+ assert.ok(r.wrote.includes('/.claude/settings.json'), 'now written into the block');
171
+ assert.ok(r.needsUntrack.some((n) => n.path === '/.claude/settings.json' && n.command.includes('git rm --cached')));
172
+ assert.ok(!r.verify.some((v) => v.path === '/.claude/settings.json' && v.hidden), 'tracked → never counted hidden');
173
+ });
174
+
175
+ it('--include rejects a path that is not one of this run’s asks', () => {
176
+ const { deps } = mk();
177
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['/src/'] }, deps), (e) => e.code === FOOTPRINT_STOP);
178
+ });
179
+
180
+ it('--include rejects traversal / globs', () => {
181
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
182
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['../etc'] }, deps), (e) => e.code === FOOTPRINT_STOP);
183
+ assert.throws(() => hideFootprint({ dir: DIR, include: ['/.github/copilot-*'] }, deps), (e) => e.code === FOOTPRINT_STOP);
184
+ });
185
+
186
+ it('untracked + covered by a tracked .gitignore → DROPPED; tracked + covered → ASK (tracked checked first)', () => {
187
+ const { deps } = mk({
188
+ tracked: ['.gitignore', 'AGENTS.md'],
189
+ ignored: { 'AGENTS.md': { source: '.gitignore' }, 'docs/ai/': { source: '.gitignore' } },
190
+ });
191
+ const r = hideFootprint({ dir: DIR }, deps);
192
+ assert.ok(r.dropped.includes('/docs/ai/'), 'untracked + gitignore-covered → dropped as redundant');
193
+ assert.ok(r.asks.some((a) => a.path === '/AGENTS.md'), 'tracked wins over the gitignore-drop → ASK');
194
+ assert.ok(!r.dropped.includes('/AGENTS.md'));
195
+ });
196
+
197
+ it('ls-files failure → fail-closed STOP (UNKNOWN never counts as safe-to-hide)', () => {
198
+ const { deps } = mk({ lsFilesFails: true });
199
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP);
200
+ });
201
+
202
+ it('check-ignore exit 128 → fail-closed STOP (the symmetric guard to ls-files)', () => {
203
+ const { deps } = mk({ checkIgnoreFails: true });
204
+ assert.throws(() => hideFootprint({ dir: DIR }, deps), (e) => e.code === FOOTPRINT_STOP);
205
+ });
206
+ });
207
+
208
+ // ── present external footprint + glob ────────────────────────────────────────────
209
+
210
+ describe('external footprint', () => {
211
+ it('a present low-risk external dir → HIDE; an absent external → not emitted', () => {
212
+ const { deps } = mk({}, { world: (w) => w.dir('.continue') });
213
+ const r = hideFootprint({ dir: DIR }, deps);
214
+ assert.ok(r.wrote.includes('/.continue/'), 'present external dir hidden');
215
+ assert.ok(!r.wrote.includes('/.cursorrules'), 'absent external not emitted');
216
+ });
217
+
218
+ it('a present high-risk external file (generic name) → ASK, not hidden by default', () => {
219
+ const { deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
220
+ const r = hideFootprint({ dir: DIR }, deps);
221
+ assert.ok(!r.wrote.includes('/GEMINI.md'), 'present-high-risk excluded by default');
222
+ assert.ok(r.asks.some((a) => a.path === '/GEMINI.md'));
223
+ });
224
+
225
+ it('glob /.github/copilot-* expands to each present file; a tracked sibling does not pull them into ASK', () => {
226
+ const { deps } = mk(
227
+ { tracked: ['.github/workflows/ci.yml'] },
228
+ { world: (w) => w.dir('.github').file('.github/copilot-instructions.md').file('.github/copilot-setup.yml').dir('.github/workflows') },
229
+ );
230
+ const r = hideFootprint({ dir: DIR }, deps);
231
+ // copilot-* is falsePositiveRisk:true → present → ASK each (not hidden by default). The ASK reason
232
+ // must be the RISK reason, NOT the tracked reason — proving each file was probed on its OWN path
233
+ // and the tracked sibling did not leak into their classification.
234
+ const a1 = r.asks.find((a) => a.path === '/.github/copilot-instructions.md');
235
+ const a2 = r.asks.find((a) => a.path === '/.github/copilot-setup.yml');
236
+ assert.ok(a1 && /generic|confirm before hiding/.test(a1.reason) && !/tracked/.test(a1.reason));
237
+ assert.ok(a2 && /generic|confirm before hiding/.test(a2.reason));
238
+ assert.ok(!r.wrote.some((p) => p.startsWith('/.github/copilot')));
239
+ });
240
+
241
+ it('a glob-expanded file consented via --include is RETAINED on re-run (zero diff, idempotent)', () => {
242
+ const seed = { world: (w) => w.dir('.github').file('.github/copilot-instructions.md') };
243
+ const { world, deps } = mk({}, seed);
244
+ const r1 = hideFootprint({ dir: DIR, include: ['/.github/copilot-instructions.md'] }, deps);
245
+ assert.ok(r1.wrote.includes('/.github/copilot-instructions.md'), 'consented glob child written');
246
+ const c1 = world.read('.git/info/exclude');
247
+ const r2 = hideFootprint({ dir: DIR }, deps); // no --include — consent must survive
248
+ assert.ok(r2.wrote.includes('/.github/copilot-instructions.md'), 'glob-child consent survives re-run');
249
+ assert.ok(!r2.asks.some((a) => a.path === '/.github/copilot-instructions.md'), 'not re-asked');
250
+ assert.equal(r2.action, 'noop');
251
+ assert.equal(world.read('.git/info/exclude'), c1, 'zero diff (no silent un-hide of the copilot file)');
252
+ });
253
+ });
254
+
255
+ // ── re-run consent (D4) ──────────────────────────────────────────────────────────
256
+
257
+ describe('re-run preserves prior consent', () => {
258
+ it('a consented present-high-risk path stays on re-run without --include (zero diff, not re-asked)', () => {
259
+ const { world, deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
260
+ const r1 = hideFootprint({ dir: DIR, include: ['/GEMINI.md'] }, deps); // opt in once
261
+ assert.ok(r1.wrote.includes('/GEMINI.md'));
262
+ const c1 = world.read('.git/info/exclude');
263
+ const r2 = hideFootprint({ dir: DIR }, deps); // no --include this time
264
+ assert.ok(r2.wrote.includes('/GEMINI.md'), 'prior in-block consent retained');
265
+ assert.ok(!r2.asks.some((a) => a.path === '/GEMINI.md'), 'not re-asked');
266
+ assert.equal(r2.action, 'noop');
267
+ assert.equal(world.read('.git/info/exclude'), c1, 'zero diff');
268
+ });
269
+
270
+ it('a previously-consented high-risk path that DISAPPEARED is dropped (consent does not survive)', () => {
271
+ const { world, deps } = mk({}, { world: (w) => w.file('GEMINI.md') });
272
+ hideFootprint({ dir: DIR, include: ['/GEMINI.md'] }, deps); // in the block now
273
+ world.nodes.delete(join(DIR, 'GEMINI.md')); // GEMINI.md disappears from disk
274
+ const r = hideFootprint({ dir: DIR }, deps);
275
+ assert.ok(!r.wrote.includes('/GEMINI.md'), 'disappeared high-risk path dropped');
276
+ assert.ok(!world.read('.git/info/exclude').includes('/GEMINI.md'));
277
+ });
278
+ });
279
+
280
+ // ── absorb (D8) — verbatim present-machine exclude ───────────────────────────────
281
+
282
+ describe('absorb pre-existing recognized local lines', () => {
283
+ const presentMachine = [
284
+ '# git ls-files --others --exclude-from=.git/info/exclude',
285
+ "# Lines that start with '#' are comments.",
286
+ '',
287
+ '# standalone local-dev agent skills — kept out of THIS repo (project-local; visibility is a project setting)',
288
+ '.claude/skills/',
289
+ '',
290
+ ].join('\n');
291
+
292
+ it('folds the AD-013 comment + bare .claude/skills/ (dir ABSENT) into the fence, no orphan comment, apply-twice zero diff', () => {
293
+ const { world, deps } = mk({}, { exclude: presentMachine });
294
+ const r1 = hideFootprint({ dir: DIR }, deps);
295
+ const c1 = world.read('.git/info/exclude');
296
+ assert.ok(r1.wrote.includes('/.claude/skills/'), 'folded into the fence (preserved despite absent dir)');
297
+ assert.ok(!c1.includes('# standalone local-dev agent skills'), 'orphan comment removed');
298
+ assert.equal((c1.match(/\.claude\/skills\//g) ?? []).length, 1, 'exactly one .claude/skills/ rule, inside the fence');
299
+ assert.ok(c1.startsWith('# git ls-files --others'), 'git boilerplate preserved');
300
+ const r2 = hideFootprint({ dir: DIR }, deps);
301
+ assert.equal(r2.action, 'noop');
302
+ assert.equal(world.read('.git/info/exclude'), c1, 'apply-twice = zero diff');
303
+ });
304
+
305
+ it('a loose pre-existing HIGH-RISK line whose file is PRESENT is folded as consent (no report mismatch, one block)', () => {
306
+ const { world, deps } = mk({}, { exclude: '# boiler\n/.cursorrules\n', world: (w) => w.file('.cursorrules') });
307
+ const r = hideFootprint({ dir: DIR }, deps);
308
+ const content = world.read('.git/info/exclude');
309
+ assert.ok(r.wrote.includes('/.cursorrules'), 'folded into the managed block as prior consent');
310
+ assert.ok(!r.asks.some((a) => a.path === '/.cursorrules'), 'not reported as un-consented ASK while it is in fact hidden');
311
+ assert.equal((content.match(/\.cursorrules/g) ?? []).length, 1, 'exactly one .cursorrules rule, inside the fence');
312
+ assert.ok(content.indexOf('/.cursorrules') > content.indexOf(START_MARKER), 'inside the fence, not loose');
313
+ const v = r.verify.find((x) => x.path === '/.cursorrules');
314
+ assert.ok(v && v.hidden === true, 'verified hidden');
315
+ });
316
+
317
+ it('a loose HIGH-RISK line whose file is ABSENT is left untouched (a pre-emptive hide is never silently removed)', () => {
318
+ const { world, deps } = mk({}, { exclude: '# boiler\n/.cursorrules\n' }); // .cursorrules absent
319
+ const r = hideFootprint({ dir: DIR }, deps);
320
+ assert.ok(!r.wrote.includes('/.cursorrules'), 'absent high-risk path not folded');
321
+ assert.ok(world.read('.git/info/exclude').includes('/.cursorrules'), 'the user’s loose line is preserved as-is');
322
+ });
323
+ });
324
+
325
+ // ── verify (D5) ──────────────────────────────────────────────────────────────────
326
+
327
+ describe('verify', () => {
328
+ it('a tracked path written via --include reports NOT hidden', () => {
329
+ const { deps } = mk({ tracked: ['.claude/settings.json'] });
330
+ const r = hideFootprint({ dir: DIR, include: ['/.claude/settings.json'] }, deps);
331
+ const v = r.verify.find((x) => x.path === '/.claude/settings.json');
332
+ assert.ok(v && v.tracked === true && v.hidden === false);
333
+ });
334
+
335
+ it('an untracked auto-HIDE path verifies hidden by our exclude source', () => {
336
+ const { deps } = mk();
337
+ const r = hideFootprint({ dir: DIR }, deps);
338
+ const v = r.verify.find((x) => x.path === '/AGENTS.md');
339
+ assert.ok(v && v.hidden === true && /info\/exclude/.test(v.source));
340
+ });
341
+ });
342
+
343
+ // ── migrateFromGlobal (D6/D7) ────────────────────────────────────────────────────
344
+
345
+ describe('migrateFromGlobal', () => {
346
+ const GLOBAL = '/home/u/.gitignore_global';
347
+ const kitOld = [
348
+ '',
349
+ '# agent-workflow-kit hidden mode (machine-local; remove these lines to un-hide)',
350
+ '/AGENTS.md', '/CLAUDE.md', '/docs/ai/',
351
+ '/scripts/_expect-shim.mjs', '/scripts/install-git-hooks.mjs',
352
+ '',
353
+ ].join('\n');
354
+
355
+ it('default KEEPS + reports the legacy global block; --remove-global removes it + returns a backup', () => {
356
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, kitOld) });
357
+ const kept = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: false, dryRun: false });
358
+ assert.equal(kept.action, 'kept');
359
+ assert.ok(kept.removedLines.includes('/AGENTS.md'));
360
+ assert.equal(world.nodes.get(GLOBAL).content, kitOld, 'kept = file untouched');
361
+
362
+ const { world: w2, deps: d2 } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, kitOld) });
363
+ const removed = migrateFromGlobal(d2, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
364
+ assert.equal(removed.action, 'removed');
365
+ assert.ok(removed.backup.includes('/AGENTS.md'));
366
+ assert.ok(!w2.nodes.get(GLOBAL).content.includes('/AGENTS.md'), 'global block removed');
367
+ });
368
+
369
+ it('preserves a user line interleaved with the legacy block', () => {
370
+ const withUser = `/AGENTS.md\n/CLAUDE.md\n\n/my/own/rule\n`;
371
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, withUser) });
372
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
373
+ assert.equal(r.action, 'removed');
374
+ assert.ok(world.nodes.get(GLOBAL).content.includes('/my/own/rule'), 'user line preserved');
375
+ assert.ok(!world.nodes.get(GLOBAL).content.includes('/AGENTS.md'));
376
+ });
377
+
378
+ it('core.excludesFile unset (exit 1) → no-op, never a STOP', () => {
379
+ const { deps } = mk({ global: null });
380
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
381
+ assert.equal(r.found, false);
382
+ assert.equal(r.action, 'none');
383
+ });
384
+
385
+ it('a memory-old global path-set (no header) is recognized + removed', () => {
386
+ const memOld = `/AGENTS.md\n/docs/ai/\n/docs/ai/.memory-version\n/docs/plans/\n`;
387
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, memOld) });
388
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
389
+ assert.equal(r.action, 'removed');
390
+ assert.equal(world.nodes.get(GLOBAL).content, '', 'whole memory-old block recognized + removed');
391
+ });
392
+
393
+ it('the AD-013 standalone form as a GLOBAL block is recognized + removed (the third D7 fixture)', () => {
394
+ const ad013 = '# standalone local-dev agent skills — kept out of THIS repo (project-local; visibility is a project setting)\n.claude/skills/\n';
395
+ const { world, deps } = mk({ global: GLOBAL }, { world: (w) => w.abs(GLOBAL, ad013) });
396
+ const r = migrateFromGlobal(deps, DIR, { home: '/home/u', removeGlobal: true, dryRun: false });
397
+ assert.equal(r.action, 'removed');
398
+ assert.ok(r.backup.includes('.claude/skills/'));
399
+ assert.equal(world.nodes.get(GLOBAL).content, '', 'AD-013 comment + bare line removed');
400
+ });
401
+ });
402
+
403
+ // ── --unhide (D12) ───────────────────────────────────────────────────────────────
404
+
405
+ describe('--unhide', () => {
406
+ const GLOBAL = '/home/u/.gitignore_global';
407
+ it('deletes the fence; default reports the residual global block; --remove-global removes it', () => {
408
+ const exclude = `# boiler\n${START_MARKER}\n/AGENTS.md\n/docs/ai/\n${END_MARKER}\n`;
409
+ const kitOld = '# agent-workflow-kit hidden mode (machine-local; remove these lines to un-hide)\n/AGENTS.md\n/docs/ai/\n';
410
+ const { world, deps } = mk({ global: GLOBAL }, { exclude, world: (w) => w.abs(GLOBAL, kitOld) });
411
+ const r = hideFootprint({ dir: DIR, unhide: true }, deps);
412
+ assert.equal(r.action, 'unhidden');
413
+ const content = world.read('.git/info/exclude');
414
+ assert.ok(!content.includes(START_MARKER), 'fence removed');
415
+ assert.ok(content.includes('# boiler'), 'user boilerplate kept');
416
+ assert.equal(r.global.action, 'kept', 'residual global reported, not removed by default');
417
+ assert.equal(world.nodes.get(GLOBAL).content, kitOld);
418
+
419
+ const { world: w2, deps: d2 } = mk({ global: GLOBAL }, { exclude, world: (w) => w.abs(GLOBAL, kitOld) });
420
+ const r2 = hideFootprint({ dir: DIR, unhide: true, removeGlobal: true }, d2);
421
+ assert.equal(r2.global.action, 'removed');
422
+ assert.ok(!w2.nodes.get(GLOBAL).content.includes('/AGENTS.md'));
423
+ });
424
+ });
425
+
426
+ // ── visibility inference (D16) ───────────────────────────────────────────────────
427
+
428
+ describe('inferVisibility + --reconcile', () => {
429
+ it('tracked anchor → visible; --reconcile writes zero bytes', () => {
430
+ const { world, deps } = mk({ tracked: ['AGENTS.md'] }, { world: (w) => w.file('AGENTS.md') });
431
+ assert.equal(inferVisibility(deps, DIR).visibility, 'visible');
432
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
433
+ assert.equal(r.action, 'noop');
434
+ assert.equal(r.visibility, 'visible');
435
+ assert.equal(world.read('.git/info/exclude'), undefined, 'no exclude file written');
436
+ });
437
+
438
+ it('untracked + ignored anchor → hidden; --reconcile runs the hide', () => {
439
+ const { deps } = mk(
440
+ { ignored: { 'AGENTS.md': { source: '/home/u/.gitignore_global' } } },
441
+ { world: (w) => w.file('AGENTS.md') },
442
+ );
443
+ assert.equal(inferVisibility(deps, DIR).visibility, 'hidden');
444
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
445
+ assert.equal(r.visibility, 'hidden');
446
+ assert.ok(r.wrote.includes('/AGENTS.md'));
447
+ });
448
+
449
+ it('untracked + not ignored anchor → ambiguous; --reconcile surfaces it and writes nothing', () => {
450
+ const { world, deps } = mk({}, { world: (w) => w.file('AGENTS.md') });
451
+ assert.equal(inferVisibility(deps, DIR).visibility, 'ambiguous');
452
+ const r = hideFootprint({ dir: DIR, reconcile: true }, deps);
453
+ assert.equal(r.ambiguous, true);
454
+ assert.equal(r.action, 'noop');
455
+ assert.equal(world.read('.git/info/exclude'), undefined);
456
+ });
457
+
458
+ it('a committed-but-DELETED AGENTS.md still infers VISIBLE (tracked-ness, not disk presence)', () => {
459
+ // AGENTS.md tracked but absent from the worktree; docs/ai/ present, untracked, not ignored.
460
+ const { deps } = mk({ tracked: ['AGENTS.md'] }, { world: (w) => w.dir('docs/ai') });
461
+ assert.equal(inferVisibility(deps, DIR).visibility, 'visible');
462
+ });
463
+ });
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // known-footprint.mjs — the kit-owned registry of what "hidden mode" must keep out of a repo.
3
+ //
4
+ // Two registries, both holding ANCHORED gitignore patterns (repo-root-relative, leading "/"):
5
+ // KIT_OWN_PATHS — the kit's OWN deployed artifacts (AGENTS.md, docs/ai/, the added scripts, the
6
+ // attribution settings). Always candidates in hidden mode.
7
+ // KNOWN_FOOTPRINT — every OTHER AI/agent tool's footprint (Claude skills, Cursor, Windsurf, Gemini,
8
+ // Copilot, Aider, Continue, …) that would otherwise leak into a commit. Only the
9
+ // ones PRESENT on disk become candidates (no pre-emptive hiding of absent paths).
10
+ //
11
+ // Source of truth is here, not an on-disk manifest (the AD-008 `KNOWN_BACKENDS` pattern): a foreign
12
+ // tool's footprint has no file in the kit tarball to read, so the per-tool facts must live in-tool.
13
+ // The drift-guard test (known-footprint.test.mjs) keeps the registry honest — a frozen snapshot +
14
+ // count sentinel + intrinsic invariants (anchoring, uniqueness, disjointness, no subsumption) — and
15
+ // references/contracts.md carries the human-readable mirror table, kept in sync by review.
16
+ //
17
+ // Pure, dependency-free, Node >= 18. The only fs touch is expandGlob (readdir/stat of a glob parent),
18
+ // injected so the registry stays unit-testable without the real filesystem.
19
+
20
+ import { readdirSync, statSync } from 'node:fs';
21
+
22
+ // A typed STOP — a deliberate refusal we surface (never a silent skip, never a fail-open). Shared by
23
+ // the writer tool (hide-footprint.mjs imports it) so both speak one error vocabulary. The codebase's
24
+ // typed-error idiom: Object.assign(new Error(), { code }) — no classes (agent_rules §2.3).
25
+ export const FOOTPRINT_STOP = 'FOOTPRINT_STOP';
26
+ export const stop = (message, fields = {}) =>
27
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'FootprintStop', code: FOOTPRINT_STOP, ...fields });
28
+
29
+ // ── registries ────────────────────────────────────────────────────────────────
30
+
31
+ // The kit's OWN footprint — canonical anchored patterns. `/docs/ai/` subsumes the deployment stamp
32
+ // (`.workflow-version`); the 8 enforcement scripts are enumerated (no bare `/scripts/` — a host repo
33
+ // may have unrelated scripts). `/.claude/settings.json` is carried HIDDEN-ONLY: in hidden mode the
34
+ // kit's own attribution file is a footprint; in visible mode the kit commits it and never runs this
35
+ // tool. It passes the same tracked→ASK classifier, so a project that already commits it gets an ASK,
36
+ // never a silent un-track. `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
37
+ // hidden deploy has no tracked `.gitignore`; the classifier drops any candidate a tracked `.gitignore`
38
+ // already covers, so in a repo that DOES track those ignores they are never re-written.
39
+ export const KIT_OWN_PATHS = [
40
+ '/AGENTS.md',
41
+ '/CLAUDE.md',
42
+ '/docs/ai/',
43
+ '/scripts/_expect-shim.mjs',
44
+ '/scripts/archive-changelog.mjs',
45
+ '/scripts/archive-changelog.test.mjs',
46
+ '/scripts/archive-issues.mjs',
47
+ '/scripts/archive-issues.test.mjs',
48
+ '/scripts/check-docs-size.mjs',
49
+ '/scripts/check-docs-size.test.mjs',
50
+ '/scripts/install-git-hooks.mjs',
51
+ '/docs/plans/',
52
+ '/.claude/settings.local.json',
53
+ '/.claude/settings.json',
54
+ ];
55
+
56
+ // Every OTHER tool's footprint. `falsePositiveRisk` flags a name generic/ambiguous enough that a
57
+ // present-but-untracked instance should be ASKed about rather than hidden by default (D-policy A).
58
+ // `glob:true` marks the ONE reviewed wildcard (`/.github/copilot-*`) — it is expanded against the
59
+ // filesystem (expandGlob) to concrete present files before any git probe; never fed to ls-files.
60
+ export const KNOWN_FOOTPRINT = [
61
+ { pattern: '/.claude/skills/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'local-dev skills; absorbs the AD-013 one-off' },
62
+ { pattern: '/.cursor/rules/', owner: 'Cursor', type: 'dir', falsePositiveRisk: false, note: 'project rule files' },
63
+ { pattern: '/.cursorrules', owner: 'Cursor (legacy)', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
64
+ { pattern: '/.codeium/', owner: 'Codeium/Windsurf', type: 'dir', falsePositiveRisk: false, note: 'home-scoped launchers live under ~/, out of scope' },
65
+ { pattern: '/.windsurf/', owner: 'Windsurf (Devin)', type: 'dir', falsePositiveRisk: false, note: 'project config dir' },
66
+ { pattern: '/.windsurfrules', owner: 'Windsurf', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
67
+ { pattern: '/GEMINI.md', owner: 'Gemini/Antigravity', type: 'file', falsePositiveRisk: true, note: 'context file; generic name' },
68
+ { pattern: '/.antigravity.md', owner: 'Antigravity', type: 'file', falsePositiveRisk: true, note: 'context file' },
69
+ { pattern: '/.github/copilot-*', owner: 'GitHub Copilot', type: 'file', falsePositiveRisk: true, glob: true, note: 'covers copilot-instructions.md; the one reviewed glob' },
70
+ { pattern: '/.aider.conf.yml', owner: 'Aider', type: 'file', falsePositiveRisk: false, note: 'config' },
71
+ { pattern: '/.aider.chat.history.md', owner: 'Aider', type: 'file', falsePositiveRisk: false, note: 'chat history' },
72
+ { pattern: '/.aider.input.history', owner: 'Aider', type: 'file', falsePositiveRisk: false, note: 'input history' },
73
+ { pattern: '/.continue/', owner: 'Continue', type: 'dir', falsePositiveRisk: false, note: 'project config dir' },
74
+ ];
75
+
76
+ // ── pure pattern helpers ────────────────────────────────────────────────────────
77
+
78
+ // Forward-slash normalize (Windows `\` → `/`) — every fs/git path is compared in this canonical form.
79
+ export const normalizeSlashes = (p) => p.replace(/\\/g, '/');
80
+
81
+ // A pattern naming a directory ends with a trailing "/". Used to derive the type of a bare KIT_OWN
82
+ // pattern (KNOWN_FOOTPRINT entries carry an explicit `type`).
83
+ export const isDirPattern = (pattern) => normalizeSlashes(pattern).endsWith('/');
84
+
85
+ // Is this an unexpanded glob pattern? (Only `/.github/copilot-*` today — `glob:true` in the registry.)
86
+ export const isGlobPattern = (pattern) => normalizeSlashes(pattern).includes('*');
87
+
88
+ // Convert an ANCHORED gitignore pattern to a repo-relative probe path for `git ls-files` /
89
+ // `git check-ignore` (both run with cwd = the project dir, so the probe must be repo-relative, NOT
90
+ // the leading-"/" gitignore form which git reads as an absolute path → "outside repository", exit 128).
91
+ // Strips the leading "/", preserves a trailing "/" (dir), rejects traversal, and REFUSES a glob — a
92
+ // glob must be expandGlob'd to concrete files first (never handed to git verbatim).
93
+ export const patternToProbe = (pattern) => {
94
+ const p = normalizeSlashes(pattern);
95
+ if (p.includes('*')) throw stop(`refusing to probe an unexpanded glob: ${pattern} (expandGlob it first)`);
96
+ if (p.split('/').includes('..')) throw stop(`refusing a traversal pattern: ${pattern}`);
97
+ if (!p.startsWith('/')) throw stop(`pattern is not anchored (must start with "/"): ${pattern}`);
98
+ return p.slice(1); // keeps a trailing "/" for dir patterns
99
+ };
100
+
101
+ // Turn a basename glob (`copilot-*`) into an anchored regex. Only a single-level `*` is supported
102
+ // (no `**`, no `/`): split on `*`, regex-escape each literal segment, then join with `.*`.
103
+ const basenameGlobToRegExp = (glob) => {
104
+ const parts = glob.split('*').map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&'));
105
+ return new RegExp(`^${parts.join('.*')}$`);
106
+ };
107
+
108
+ // Expand a `glob:true` registry pattern against the filesystem → the anchored canonical patterns of
109
+ // the concrete present FILES it matches (each is then probed/classified on its own). One directory
110
+ // level only: readdir the glob's parent, match basenames, keep regular files. An absent parent →
111
+ // no candidates (empty array); any OTHER fs error → typed STOP (fail-closed, never a silent drop).
112
+ // Is a CONCRETE anchored path (e.g. `/.github/copilot-instructions.md`) a child that a `glob:true`
113
+ // registry entry would match? Recognizes an already-written/consented glob expansion (the concrete
114
+ // file, not the glob pattern) as a pre-existing hide rule — so consent survives a re-run.
115
+ export const matchesKnownGlob = (pattern) => {
116
+ const p = normalizeSlashes(pattern);
117
+ return KNOWN_FOOTPRINT.some((e) => {
118
+ if (!e.glob) return false;
119
+ const probe = normalizeSlashes(e.pattern).slice(1); // ".github/copilot-*"
120
+ const slash = probe.lastIndexOf('/');
121
+ if (slash === -1) return false;
122
+ const parent = `/${probe.slice(0, slash)}`; // "/.github"
123
+ const base = probe.slice(slash + 1); // "copilot-*"
124
+ if (!p.startsWith(`${parent}/`)) return false;
125
+ const tail = p.slice(parent.length + 1);
126
+ return !tail.includes('/') && basenameGlobToRegExp(base).test(tail);
127
+ });
128
+ };
129
+
130
+ export const expandGlob = (pattern, { dir, readdir = readdirSync, stat = statSync } = {}) => {
131
+ const p = normalizeSlashes(pattern);
132
+ if (!p.includes('*')) throw stop(`expandGlob called on a non-glob pattern: ${pattern}`);
133
+ const probe = p.slice(1); // strip leading "/" → e.g. ".github/copilot-*"
134
+ const slash = probe.lastIndexOf('/');
135
+ if (slash === -1) throw stop(`glob must live under a directory (no bare top-level glob): ${pattern}`);
136
+ const parentRel = probe.slice(0, slash);
137
+ const baseGlob = probe.slice(slash + 1);
138
+ if (baseGlob.includes('*') === false) throw stop(`glob has no wildcard in its basename: ${pattern}`);
139
+ const re = basenameGlobToRegExp(baseGlob);
140
+ const parentAbs = `${dir}/${parentRel}`;
141
+ let names;
142
+ try {
143
+ names = readdir(parentAbs);
144
+ } catch (err) {
145
+ if (err && err.code === 'ENOENT') return [];
146
+ throw stop(`cannot read glob parent (${err.code ?? 'fs error'}): ${parentAbs}`);
147
+ }
148
+ const out = [];
149
+ for (const name of names) {
150
+ if (!re.test(name)) continue;
151
+ let st;
152
+ try {
153
+ st = stat(`${parentAbs}/${name}`);
154
+ } catch (err) {
155
+ if (err && err.code === 'ENOENT') continue; // raced away — not a present footprint
156
+ throw stop(`cannot stat glob match (${err.code ?? 'fs error'}): ${parentAbs}/${name}`);
157
+ }
158
+ if (st.isFile()) out.push(`/${parentRel}/${name}`);
159
+ }
160
+ return out.sort();
161
+ };