@sabaiway/agent-workflow-kit 1.9.1 → 1.11.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.
@@ -1,6 +1,6 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -11,6 +11,7 @@ import {
11
11
  extractSlot,
12
12
  ensureSlot,
13
13
  reconcileSlot,
14
+ slotNeedsFill,
14
15
  METHODOLOGY_ANCHOR,
15
16
  EMPTY_SLOT,
16
17
  AGENTS_MD_CAP,
@@ -20,7 +21,47 @@ import {
20
21
 
21
22
  const HERE = dirname(fileURLToPath(import.meta.url));
22
23
  const SCRIPT = join(HERE, 'inject-methodology.mjs');
23
- const FRAGMENT = readFileSync(join(HERE, 'methodology-slot.md'), 'utf8');
24
+
25
+ // The bounded methodology fragment is read LIVE from the installed engine now (the kit mirror is
26
+ // retired in Plan 3D), so this suite no longer reads a bundled methodology-slot.md. Tests use an
27
+ // inline fragment and, for the live-read CLI cases, an on-the-fly engine fixture that ships exactly
28
+ // this fragment — keeping the kit suite decoupled from the sibling engine's on-disk presence.
29
+ // Single-line (like the canonical fragment) so byte-equality holds in both LF and CRLF documents.
30
+ const FRAGMENT =
31
+ '> **Workflow methodology (test fixture)** — plan → execute → review. Plans are ephemeral, gitignored, never committed; every Plan ends with a mandatory **Phase: Cleanup**.\n';
32
+
33
+ // Temp dirs created by the fixtures below — cleaned up once after the whole file.
34
+ const tmpDirs = [];
35
+ after(() => tmpDirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
36
+
37
+ // A minimal but VALID installed-engine fixture: a methodology-engine capability.json + a SKILL.md
38
+ // whose metadata.version matches it (the validator's authoritative version source when there is no
39
+ // package.json) + the live fragment at references/methodology-slot.md. detectEngine accepts it.
40
+ const makeEngineFixture = (fragment = FRAGMENT, version = '1.0.0') => {
41
+ const dir = mkdtempSync(join(tmpdir(), 'engine-fixture-'));
42
+ tmpDirs.push(dir);
43
+ const manifest = {
44
+ family: 'agent-workflow',
45
+ schema: 1,
46
+ name: 'agent-workflow-engine',
47
+ kind: 'methodology-engine',
48
+ version,
49
+ available: true,
50
+ provides: ['plan'],
51
+ roles: {},
52
+ };
53
+ writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifest, null, 2));
54
+ writeFileSync(join(dir, 'SKILL.md'), `---\nname: agent-workflow-engine\nmetadata:\n version: '${version}'\n---\n# engine\n`);
55
+ mkdirSync(join(dir, 'references'), { recursive: true });
56
+ writeFileSync(join(dir, 'references', 'methodology-slot.md'), fragment);
57
+ return dir;
58
+ };
59
+
60
+ const ENGINE = makeEngineFixture();
61
+ // A path that is guaranteed NOT to be a valid engine — proves the no-op / explicit-override paths
62
+ // never consult the engine, and drives the fail-loud STOP.
63
+ const NO_ENGINE = join(tmpdir(), `definitely-no-engine-${process.pid}`);
64
+ const withEngine = (engineDir) => ({ ...process.env, AGENT_WORKFLOW_ENGINE_DIR: engineDir });
24
65
 
25
66
  const wrap = (inner) =>
26
67
  `# AGENTS.md\n\nprefix bytes\n\n## Session Protocols\n\nintro line.\n\n${START_MARKER}${inner}${END_MARKER}\n\n## Hard Constraints\n\nsuffix bytes\n`;
@@ -309,7 +350,46 @@ describe('reconcileSlot — ensure + inject-if-empty + cap, as one atomic policy
309
350
  });
310
351
  });
311
352
 
312
- describe('reconcile CLI atomic ensure+inject-if-empty+cap on the real filesystem', () => {
353
+ describe('slotNeedsFilllazy-read predicate (matches reconcileSlot fill decision)', () => {
354
+ it('present empty slot → true', () => {
355
+ assert.equal(slotNeedsFill(wrap('\n')), true);
356
+ });
357
+ it('markerless legacy with one anchor (insertable empty slot) → true', () => {
358
+ assert.equal(slotNeedsFill(legacyWithAnchor()), true);
359
+ });
360
+ it('present filled/customized slot → false (fragment not needed)', () => {
361
+ assert.equal(slotNeedsFill(wrap('\nuser notes\n')), false);
362
+ });
363
+ it('malformed slot → false (reconcileSlot error path fires, not a fill)', () => {
364
+ assert.equal(slotNeedsFill(`${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`), false);
365
+ });
366
+ it('markerless with no anchor → false (cannot insert; reconcile errors without the engine)', () => {
367
+ assert.equal(slotNeedsFill('# AGENTS.md\n\nno anchor here\n'), false);
368
+ });
369
+
370
+ // slotNeedsFill and reconcileSlot share the SAME emptiness primitives (ensureSlot + extractSlot), so
371
+ // the lazy "read the engine only when needed" guard cannot disagree with reconcileSlot's actual fill
372
+ // decision. Pin that equivalence across representative inputs: needsFill === true IFF reconcile fills
373
+ // (reconciled-filled / reconciled-inserted); needsFill === false IFF reconcile does NOT fill
374
+ // (present-filled / error). This forecloses any future divergence that could silently drop a slot.
375
+ it('agrees with reconcileSlot across every slot state (no divergence → no silent drop)', () => {
376
+ const cases = [
377
+ wrap('\n'), // present empty slot → fill
378
+ legacyWithAnchor(), // markerless + anchor → insert + fill
379
+ wrap('\nuser notes\n'), // filled slot → no fill
380
+ `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`, // malformed → no fill (error)
381
+ '# AGENTS.md\n\nno anchor here\n', // no anchor → no fill (error)
382
+ ];
383
+ for (const text of cases) {
384
+ const needs = slotNeedsFill(text);
385
+ const result = reconcileSlot(text, FRAGMENT, { maxLines: AGENTS_MD_CAP });
386
+ const filled = result.status === 'reconciled-filled' || result.status === 'reconciled-inserted';
387
+ assert.equal(needs, filled, `slotNeedsFill (${needs}) must match whether reconcile fills (${result.status})`);
388
+ }
389
+ });
390
+ });
391
+
392
+ describe('reconcile CLI — atomic ensure+inject-if-empty+cap, reading the fragment LIVE from the engine', () => {
313
393
  const withTempAgents = (contents, run) => {
314
394
  const dir = mkdtempSync(join(tmpdir(), 'reconcile-cli-'));
315
395
  const agents = join(dir, 'AGENTS.md');
@@ -321,35 +401,100 @@ describe('reconcile CLI — atomic ensure+inject-if-empty+cap on the real filesy
321
401
  }
322
402
  };
323
403
 
324
- it('markerless legacy (with anchor) → slot inserted and filled (exit 0)', () => {
404
+ it('markerless legacy (with anchor) → slot inserted + filled from the live engine fragment (exit 0)', () => {
325
405
  withTempAgents(legacyWithAnchor(), (agents) => {
326
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
406
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
327
407
  const out = readFileSync(agents, 'utf8');
328
408
  assert.equal(findSlot(out).state, 'ok');
329
409
  assert.equal(extractSlot(out).trim(), FRAGMENT.trim());
330
410
  });
331
411
  });
332
412
 
333
- it('present empty slot → slot filled (exit 0)', () => {
413
+ it('present empty slot → filled from the live engine fragment (exit 0)', () => {
334
414
  withTempAgents(wrap('\n'), (agents) => {
335
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
415
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(ENGINE) });
336
416
  assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), FRAGMENT.trim());
337
417
  });
338
418
  });
339
419
 
340
- it('filled/customized slot → file left byte-for-byte untouched', () => {
420
+ it('filled/customized slot → zero-diff no-op WITHOUT consulting the engine (engine absent, exit 0)', () => {
341
421
  const custom = wrap('\nuser notes\n');
342
422
  withTempAgents(custom, (agents) => {
343
- execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' });
423
+ // Engine pointed at a path that does not exist — a filled slot must NOT require it.
424
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
344
425
  assert.equal(readFileSync(agents, 'utf8'), custom);
345
426
  });
346
427
  });
347
428
 
348
- it('malformed slot → STOP with non-zero exit, file byte-unchanged', () => {
429
+ it('present empty slot + engine ABSENT hard STOP (nonzero) printing the install command, file unchanged', () => {
430
+ withTempAgents(wrap('\n'), (agents) => {
431
+ const err = (() => {
432
+ try {
433
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
434
+ return null;
435
+ } catch (e) {
436
+ return e;
437
+ }
438
+ })();
439
+ assert.ok(err, 'expected a non-zero exit when a fill is needed but the engine is absent');
440
+ const stderr = String(err.stderr);
441
+ assert.match(stderr, /methodology engine not found\/invalid/);
442
+ assert.match(stderr, /npx @sabaiway\/agent-workflow-engine@latest init/);
443
+ assert.equal(readFileSync(agents, 'utf8'), wrap('\n'), 'no partial write on STOP');
444
+ });
445
+ });
446
+
447
+ it('explicit [fragment.md] override fills from that file and skips engine resolution (engine absent)', () => {
448
+ const override = '> custom override fragment line\n';
449
+ const fdir = mkdtempSync(join(tmpdir(), 'frag-'));
450
+ tmpDirs.push(fdir);
451
+ const fpath = join(fdir, 'frag.md');
452
+ writeFileSync(fpath, override);
453
+ withTempAgents(wrap('\n'), (agents) => {
454
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents, fpath], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
455
+ assert.equal(extractSlot(readFileSync(agents, 'utf8')).trim(), override.trim());
456
+ });
457
+ });
458
+
459
+ it('malformed slot → STOP with non-zero exit, file byte-unchanged (engine never consulted)', () => {
349
460
  const malformed = `${START_MARKER}\n${END_MARKER}\n${START_MARKER}\n${END_MARKER}\n`;
350
461
  withTempAgents(malformed, (agents) => {
351
- assert.throws(() => execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe' }));
462
+ assert.throws(() =>
463
+ execFileSync(process.execPath, [SCRIPT, 'reconcile', agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
464
+ );
352
465
  assert.equal(readFileSync(agents, 'utf8'), malformed);
353
466
  });
354
467
  });
468
+
469
+ it('legacy inject mode: markerless AGENTS.md → no-op WITHOUT the engine (exit 0)', () => {
470
+ const markerless = '# AGENTS.md\n\nlegacy, no slot\n';
471
+ withTempAgents(markerless, (agents) => {
472
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) });
473
+ assert.equal(readFileSync(agents, 'utf8'), markerless);
474
+ });
475
+ });
476
+
477
+ // Legacy `inject` mode FORCE-OVERWRITES any present (ok) slot — filled or empty — unlike `reconcile`,
478
+ // which preserves a filled slot. So for an ok slot it genuinely NEEDS the fragment and reads the
479
+ // engine; the read-on-`state==='ok'` guard is correct (reading only an EMPTY ok slot would inject ''
480
+ // and WIPE a filled slot). These two tests pin that contract.
481
+ it('legacy inject mode: a FILLED slot is OVERWRITTEN from the live engine (engine present, exit 0)', () => {
482
+ const filled = wrap('\nstale user content\n');
483
+ withTempAgents(filled, (agents) => {
484
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(ENGINE) });
485
+ const out = readFileSync(agents, 'utf8');
486
+ assert.equal(extractSlot(out).trim(), FRAGMENT.trim(), 'slot overwritten with the live fragment');
487
+ assert.ok(!out.includes('stale user content'), 'prior slot content replaced');
488
+ });
489
+ });
490
+
491
+ it('legacy inject mode: a present (ok) slot + engine ABSENT → hard STOP, file unchanged', () => {
492
+ const filled = wrap('\nstale user content\n');
493
+ withTempAgents(filled, (agents) => {
494
+ assert.throws(() =>
495
+ execFileSync(process.execPath, [SCRIPT, agents], { stdio: 'pipe', env: withEngine(NO_ENGINE) }),
496
+ );
497
+ assert.equal(readFileSync(agents, 'utf8'), filled, 'no partial write on STOP');
498
+ });
499
+ });
355
500
  });
@@ -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
+ };
@@ -0,0 +1,271 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ KIT_OWN_PATHS,
7
+ KNOWN_FOOTPRINT,
8
+ FOOTPRINT_STOP,
9
+ normalizeSlashes,
10
+ isDirPattern,
11
+ isGlobPattern,
12
+ patternToProbe,
13
+ expandGlob,
14
+ matchesKnownGlob,
15
+ } from './known-footprint.mjs';
16
+
17
+ // An ENOENT-typed error, matching the shape Node's fs throws.
18
+ const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
19
+ const eacces = () => Object.assign(new Error('EACCES'), { code: 'EACCES' });
20
+ const fileStat = { isFile: () => true };
21
+ const dirStat = { isFile: () => false };
22
+
23
+ // The probe form of an anchored pattern, for subsumption/coverage reasoning (strip leading "/").
24
+ const probeOf = (pattern) => normalizeSlashes(pattern).replace(/^\//, '');
25
+ // A pattern's "scope" — the path-prefix it ignores. A glob's scope is its parent directory.
26
+ const scopeOf = (pattern) => {
27
+ const probe = probeOf(pattern);
28
+ if (isGlobPattern(pattern)) return `${probe.slice(0, probe.lastIndexOf('/') + 1)}`; // e.g. ".github/"
29
+ return probe;
30
+ };
31
+ // Does `pattern` (as a gitignore rule) cover the concrete repo-relative `path`?
32
+ const covers = (pattern, path) => {
33
+ const scope = scopeOf(pattern);
34
+ return scope.endsWith('/') ? path === scope.slice(0, -1) || path.startsWith(scope) : path === scope;
35
+ };
36
+
37
+ // ── (a) shape + type enum ───────────────────────────────────────────────────────
38
+
39
+ describe('KNOWN_FOOTPRINT shape', () => {
40
+ it('every entry is well-shaped with type ∈ {dir,file}', () => {
41
+ for (const e of KNOWN_FOOTPRINT) {
42
+ assert.equal(typeof e.pattern, 'string', `pattern is a string`);
43
+ assert.equal(typeof e.owner, 'string', `${e.pattern}: owner is a string`);
44
+ assert.ok(e.type === 'dir' || e.type === 'file', `${e.pattern}: type is dir|file`);
45
+ assert.equal(typeof e.falsePositiveRisk, 'boolean', `${e.pattern}: falsePositiveRisk is boolean`);
46
+ assert.equal(typeof e.note, 'string', `${e.pattern}: note is a string`);
47
+ if ('glob' in e) assert.equal(e.glob, true, `${e.pattern}: glob, when present, is true`);
48
+ }
49
+ });
50
+
51
+ it('a dir entry ends with "/", a file entry does not (glob excepted)', () => {
52
+ for (const e of KNOWN_FOOTPRINT) {
53
+ if (isGlobPattern(e.pattern)) continue;
54
+ assert.equal(isDirPattern(e.pattern), e.type === 'dir', `${e.pattern}: trailing slash matches type`);
55
+ }
56
+ });
57
+ });
58
+
59
+ // ── (b) uniqueness ────────────────────────────────────────────────────────────────
60
+
61
+ describe('uniqueness', () => {
62
+ it('KIT_OWN_PATHS has no duplicate patterns', () => {
63
+ assert.equal(new Set(KIT_OWN_PATHS).size, KIT_OWN_PATHS.length);
64
+ });
65
+ it('KNOWN_FOOTPRINT has no duplicate patterns', () => {
66
+ const pats = KNOWN_FOOTPRINT.map((e) => e.pattern);
67
+ assert.equal(new Set(pats).size, pats.length);
68
+ });
69
+ });
70
+
71
+ // ── (c) anchoring ─────────────────────────────────────────────────────────────────
72
+
73
+ describe('anchoring', () => {
74
+ const allPatterns = [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((e) => e.pattern)];
75
+ it('every pattern is anchored (starts with "/") and has no traversal', () => {
76
+ for (const p of allPatterns) {
77
+ assert.ok(p.startsWith('/'), `${p}: anchored`);
78
+ assert.ok(!normalizeSlashes(p).split('/').includes('..'), `${p}: no ".."`);
79
+ }
80
+ });
81
+ it('a bare trailing "*" appears ONLY on a glob:true entry', () => {
82
+ for (const p of KIT_OWN_PATHS) assert.ok(!p.includes('*'), `KIT_OWN ${p}: no wildcard`);
83
+ for (const e of KNOWN_FOOTPRINT) {
84
+ if (e.pattern.includes('*')) assert.equal(e.glob, true, `${e.pattern}: wildcard ⇒ glob:true`);
85
+ }
86
+ const globs = KNOWN_FOOTPRINT.filter((e) => e.glob);
87
+ assert.deepEqual(globs.map((e) => e.pattern), ['/.github/copilot-*'], 'exactly one reviewed glob');
88
+ });
89
+ });
90
+
91
+ // ── (d) disjoint registries ───────────────────────────────────────────────────────
92
+
93
+ describe('disjointness', () => {
94
+ it('KIT_OWN_PATHS ∩ KNOWN_FOOTPRINT == ∅', () => {
95
+ const own = new Set(KIT_OWN_PATHS);
96
+ for (const e of KNOWN_FOOTPRINT) assert.ok(!own.has(e.pattern), `${e.pattern}: not in both`);
97
+ });
98
+ });
99
+
100
+ // ── (e) no prefix/subsumption between non-glob patterns (glob exempt) ──────────────
101
+
102
+ describe('no subsumption', () => {
103
+ it('no non-glob pattern is an ancestor of another across both arrays', () => {
104
+ const all = [...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.map((e) => e.pattern)].filter((p) => !isGlobPattern(p));
105
+ for (const a of all) {
106
+ for (const b of all) {
107
+ if (a === b) continue;
108
+ // a subsumes b iff a is a directory pattern and b sits under it.
109
+ if (isDirPattern(a)) {
110
+ assert.ok(!probeOf(b).startsWith(probeOf(a)), `${a} subsumes ${b} — remove the redundant child`);
111
+ }
112
+ }
113
+ }
114
+ });
115
+ });
116
+
117
+ // ── (f) external set must NOT reach the kit's own .claude/settings.json ────────────
118
+
119
+ describe('external set excludes .claude/settings.json', () => {
120
+ it('no KNOWN_FOOTPRINT pattern covers .claude/settings.json (KIT_OWN carries it, hidden-only)', () => {
121
+ for (const e of KNOWN_FOOTPRINT) {
122
+ assert.ok(!covers(e.pattern, '.claude/settings.json'), `${e.pattern}: must not cover .claude/settings.json`);
123
+ }
124
+ // sanity: KIT_OWN intentionally DOES carry it.
125
+ assert.ok(KIT_OWN_PATHS.includes('/.claude/settings.json'));
126
+ });
127
+ });
128
+
129
+ // ── (g) frozen snapshot + count sentinel ──────────────────────────────────────────
130
+
131
+ describe('frozen snapshot', () => {
132
+ it('KIT_OWN_PATHS matches the frozen expected set + count', () => {
133
+ const expected = [
134
+ '/AGENTS.md',
135
+ '/CLAUDE.md',
136
+ '/docs/ai/',
137
+ '/scripts/_expect-shim.mjs',
138
+ '/scripts/archive-changelog.mjs',
139
+ '/scripts/archive-changelog.test.mjs',
140
+ '/scripts/archive-issues.mjs',
141
+ '/scripts/archive-issues.test.mjs',
142
+ '/scripts/check-docs-size.mjs',
143
+ '/scripts/check-docs-size.test.mjs',
144
+ '/scripts/install-git-hooks.mjs',
145
+ '/docs/plans/',
146
+ '/.claude/settings.local.json',
147
+ '/.claude/settings.json',
148
+ ];
149
+ assert.equal(KIT_OWN_PATHS.length, 14, 'KIT_OWN_PATHS count sentinel — edit deliberately');
150
+ assert.deepEqual(KIT_OWN_PATHS, expected);
151
+ });
152
+
153
+ it('KNOWN_FOOTPRINT matches the frozen expected pattern set + count', () => {
154
+ const expected = [
155
+ '/.claude/skills/',
156
+ '/.cursor/rules/',
157
+ '/.cursorrules',
158
+ '/.codeium/',
159
+ '/.windsurf/',
160
+ '/.windsurfrules',
161
+ '/GEMINI.md',
162
+ '/.antigravity.md',
163
+ '/.github/copilot-*',
164
+ '/.aider.conf.yml',
165
+ '/.aider.chat.history.md',
166
+ '/.aider.input.history',
167
+ '/.continue/',
168
+ ];
169
+ assert.equal(KNOWN_FOOTPRINT.length, 13, 'KNOWN_FOOTPRINT count sentinel — edit deliberately');
170
+ assert.deepEqual(KNOWN_FOOTPRINT.map((e) => e.pattern), expected);
171
+ });
172
+ });
173
+
174
+ // ── (h) patternToProbe + expandGlob ───────────────────────────────────────────────
175
+
176
+ describe('patternToProbe', () => {
177
+ it('strips the leading "/" and round-trips a file', () => {
178
+ assert.equal(patternToProbe('/AGENTS.md'), 'AGENTS.md');
179
+ assert.equal(patternToProbe('/.cursorrules'), '.cursorrules');
180
+ });
181
+ it('preserves a trailing "/" for a dir', () => {
182
+ assert.equal(patternToProbe('/docs/ai/'), 'docs/ai/');
183
+ assert.equal(patternToProbe('/.claude/skills/'), '.claude/skills/');
184
+ });
185
+ it('normalizes Windows back-slashes', () => {
186
+ assert.equal(patternToProbe('\\docs\\ai\\'), 'docs/ai/');
187
+ });
188
+ it('STOPs on a glob pattern (must expandGlob first)', () => {
189
+ assert.throws(() => patternToProbe('/.github/copilot-*'), (e) => e.code === FOOTPRINT_STOP);
190
+ });
191
+ it('STOPs on traversal', () => {
192
+ assert.throws(() => patternToProbe('/x/../y'), (e) => e.code === FOOTPRINT_STOP);
193
+ });
194
+ it('STOPs on an unanchored pattern', () => {
195
+ assert.throws(() => patternToProbe('AGENTS.md'), (e) => e.code === FOOTPRINT_STOP);
196
+ });
197
+ });
198
+
199
+ describe('expandGlob', () => {
200
+ const dir = '/repo';
201
+ const entries = {
202
+ '/repo/.github': ['copilot-instructions.md', 'copilot-setup-steps.yml', 'copilot-agents', 'workflows', 'README.md'],
203
+ };
204
+ const kinds = {
205
+ '/repo/.github/copilot-instructions.md': fileStat,
206
+ '/repo/.github/copilot-setup-steps.yml': fileStat,
207
+ '/repo/.github/copilot-agents': dirStat, // matches the glob but is a directory → excluded
208
+ '/repo/.github/workflows': dirStat,
209
+ '/repo/.github/README.md': fileStat,
210
+ };
211
+ const readdir = (p) => {
212
+ if (p in entries) return entries[p];
213
+ throw enoent();
214
+ };
215
+ const stat = (p) => {
216
+ if (p in kinds) return kinds[p];
217
+ throw enoent();
218
+ };
219
+
220
+ it('matches only the concrete present FILES under the parent (dirs + non-matches excluded)', () => {
221
+ assert.deepEqual(expandGlob('/.github/copilot-*', { dir, readdir, stat }), [
222
+ '/.github/copilot-instructions.md',
223
+ '/.github/copilot-setup-steps.yml',
224
+ ]);
225
+ });
226
+ it('an absent parent dir → no candidates', () => {
227
+ assert.deepEqual(expandGlob('/.github/copilot-*', { dir: '/empty', readdir, stat }), []);
228
+ });
229
+ it('a non-ENOENT readdir error → STOP (never a silent drop)', () => {
230
+ const boom = () => { throw eacces(); };
231
+ assert.throws(() => expandGlob('/.github/copilot-*', { dir, readdir: boom, stat }), (e) => e.code === FOOTPRINT_STOP);
232
+ });
233
+ it('STOPs when called on a non-glob pattern', () => {
234
+ assert.throws(() => expandGlob('/AGENTS.md', { dir, readdir, stat }), (e) => e.code === FOOTPRINT_STOP);
235
+ });
236
+ });
237
+
238
+ // ── small pure helpers ────────────────────────────────────────────────────────────
239
+
240
+ describe('source hygiene', () => {
241
+ it('the shipped hidden-mode tools contain no NUL bytes (text-safe; never classified binary)', () => {
242
+ for (const f of ['known-footprint.mjs', 'hide-footprint.mjs']) {
243
+ const src = readFileSync(fileURLToPath(new URL(`./${f}`, import.meta.url)), 'utf8');
244
+ assert.ok(!src.includes('\u0000'), `${f} must not contain a NUL byte`);
245
+ }
246
+ });
247
+ });
248
+
249
+ describe('matchesKnownGlob', () => {
250
+ it('recognizes a concrete child of a glob:true entry', () => {
251
+ assert.equal(matchesKnownGlob('/.github/copilot-instructions.md'), true);
252
+ assert.equal(matchesKnownGlob('/.github/copilot-setup-steps.yml'), true);
253
+ });
254
+ it('rejects a non-matching sibling, a nested path, and a non-glob registry pattern', () => {
255
+ assert.equal(matchesKnownGlob('/.github/dependabot.yml'), false, 'sibling not matching the glob');
256
+ assert.equal(matchesKnownGlob('/.github/copilot/extra.md'), false, 'one directory level only');
257
+ assert.equal(matchesKnownGlob('/AGENTS.md'), false, 'a registry pattern is not a glob child');
258
+ });
259
+ });
260
+
261
+ describe('pure helpers', () => {
262
+ it('normalizeSlashes converts back-slashes', () => assert.equal(normalizeSlashes('a\\b\\c'), 'a/b/c'));
263
+ it('isDirPattern keys on a trailing slash', () => {
264
+ assert.equal(isDirPattern('/docs/ai/'), true);
265
+ assert.equal(isDirPattern('/AGENTS.md'), false);
266
+ });
267
+ it('isGlobPattern detects a wildcard', () => {
268
+ assert.equal(isGlobPattern('/.github/copilot-*'), true);
269
+ assert.equal(isGlobPattern('/AGENTS.md'), false);
270
+ });
271
+ });