@boyingliu01/xp-gate 0.30.5 → 0.31.1

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.
Files changed (37) hide show
  1. package/adapters/powershell.sh +195 -12
  2. package/gate-10.sh +16 -0
  3. package/hooks/gate-10.sh +16 -0
  4. package/hooks/pre-commit +28 -1
  5. package/lib/__tests__/check-all-skip-accounting.test.js +171 -0
  6. package/lib/__tests__/find-bash-wsl.test.js +121 -0
  7. package/lib/__tests__/gate-runner-verdict.test.js +105 -0
  8. package/lib/__tests__/hook-health-path.test.js +101 -0
  9. package/lib/__tests__/principles-directory.test.js +87 -0
  10. package/lib/check.js +62 -8
  11. package/lib/gate-runner.js +8 -1
  12. package/lib/hook-health.js +65 -5
  13. package/lib/mattpocock-license-verdict.json +1 -1
  14. package/lib/principles.js +105 -2
  15. package/mock-policy/AGENTS.md +3 -3
  16. package/mutation/AGENTS.md +3 -3
  17. package/package.json +1 -1
  18. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  20. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/opencode/package.json +1 -1
  27. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/qoder/plugin.json +1 -1
  31. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/principles/AGENTS.md +3 -3
  35. package/skills/delphi-review/AGENTS.md +3 -3
  36. package/skills/sprint-flow/AGENTS.md +3 -3
  37. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @test REQ-563 runGate must not discard a synchronous gate verdict
3
+ * @intent `runGate()` reports "passed" for every gate whose `run()` returns a
4
+ * number instead of a Promise. Four registry entries — gates 3, 7, 8
5
+ * and 9 — reach `runTsGate()`, which returns `result.status` as a plain
6
+ * number, and the runner's own code path was:
7
+ *
8
+ * const result = gate.run(targetPath);
9
+ * if (result && typeof result.then === 'function') return await result;
10
+ * return 0; // <-- the number is dropped here
11
+ *
12
+ * So a gate that exits 1 in its child process is reported as 0 by
13
+ * `xp-gate check`. `checkAllGates()` counts `code !== 0` as a failure, so
14
+ * it faithfully counts whatever `runGate` hands back — which is always
15
+ * 0 for those four gates. The fail-open is in exactly one place and it
16
+ * is silent: nothing in the output says the verdict was manufactured.
17
+ *
18
+ * Gates 4 and 6 escaped this by returning `Promise.resolve(...)`, and
19
+ * gate 2 escaped it because `execFileSync` throws on a non-zero exit and
20
+ * the `catch` converts that to 1. Both of those are accidents of how
21
+ * each handler was written, not a contract — which is why the fix has to
22
+ * be in the runner, not in each handler.
23
+ * @covers AC-563-01, AC-563-02, AC-563-03
24
+ */
25
+ const gateRunner = require('../gate-runner.js');
26
+ const { runGate, GATE_REGISTRY } = gateRunner;
27
+
28
+ const STUB_GATE_ID = '__stub_gate__';
29
+
30
+ /**
31
+ * Install a throwaway registry entry and return a cleanup function.
32
+ * @param {object} handler - the `run` implementation to install
33
+ * @returns {Function} cleanup
34
+ */
35
+ function withStubGate(handler) {
36
+ GATE_REGISTRY[STUB_GATE_ID] = {
37
+ name: 'Stub Gate',
38
+ description: 'test double',
39
+ aliases: [],
40
+ run: handler,
41
+ };
42
+ return () => {
43
+ delete GATE_REGISTRY[STUB_GATE_ID];
44
+ };
45
+ }
46
+
47
+ let cleanup = null;
48
+
49
+ beforeEach(() => {
50
+ // Keep the console quiet: runGate prints a banner for every invocation.
51
+ if (!global.__xpGateStubLog) {
52
+ global.__xpGateStubLog = console.log;
53
+ }
54
+ console.log = () => {};
55
+ });
56
+
57
+ afterEach(() => {
58
+ if (cleanup) {
59
+ cleanup();
60
+ cleanup = null;
61
+ }
62
+ console.log = global.__xpGateStubLog;
63
+ });
64
+
65
+ describe('runGate verdict propagation', () => {
66
+ test('AC-563-01: a synchronous non-zero verdict is returned, not swallowed', async () => {
67
+ // This is the exact shape `runTsGate` has: a plain number, no Promise.
68
+ cleanup = withStubGate(() => 1);
69
+
70
+ const code = await runGate(STUB_GATE_ID, '.');
71
+
72
+ expect(
73
+ code,
74
+ 'runGate returned a manufactured 0 for a gate whose synchronous handler ' +
75
+ 'said 1 — the caller (checkAllGates) cannot distinguish pass from fail'
76
+ ).toBe(1);
77
+ });
78
+
79
+ test('AC-563-02: a synchronous zero verdict still reads as pass', async () => {
80
+ // The guard against "fix it by always failing": a genuine 0 must stay 0.
81
+ cleanup = withStubGate(() => 0);
82
+
83
+ const code = await runGate(STUB_GATE_ID, '.');
84
+
85
+ expect(code).toBe(0);
86
+ });
87
+
88
+ test('AC-563-03: async and throwing handlers keep their existing verdicts', async () => {
89
+ // Promise-returning handlers already worked; the fix must not regress them.
90
+ cleanup = withStubGate(() => Promise.resolve(2));
91
+ expect(await runGate(STUB_GATE_ID, '.')).toBe(2);
92
+ cleanup();
93
+
94
+ cleanup = withStubGate(() => {
95
+ throw new Error('boom');
96
+ });
97
+ expect(await runGate(STUB_GATE_ID, '.')).toBe(1);
98
+ cleanup();
99
+
100
+ // An async handler that REJECTS must also fail closed, not propagate an
101
+ // unhandled rejection out of runGate.
102
+ cleanup = withStubGate(() => Promise.reject(new Error('async boom')));
103
+ expect(await runGate(STUB_GATE_ID, '.')).toBe(1);
104
+ });
105
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @test REQ-566 hook parse check must survive a Windows path
3
+ * @intent `checkParseable()` ran `spawnSync(bashBin, ['-n', filePath])` with the
4
+ * native Windows path. Git bash interprets a backslash in an argument as
5
+ * an escape, so `C:\Users\think\.config\xp-gate\hooks\pre-push` reached
6
+ * bash as `C:Usersthink.configxp-gatehookspre-push` and every hook above
7
+ * the size floor was reported "not parseable".
8
+ *
9
+ * Reproduced on this machine against the real installed hook:
10
+ *
11
+ * findBash() -> "C:\\Program Files\\Git\\bin\\bash.exe"
12
+ * hook path -> "C:\\Users\\think\\.config\\xp-gate\\hooks\\pre-push"
13
+ * verdict -> "hook is not parseable by bash:
14
+ * /bin/bash: C:Usersthink\\.config\\xp-gate\\hooks\\pre-push:
15
+ * No such file or directory"
16
+ *
17
+ * while `bash -n` on the same file from Git bash reports PARSE_OK. Note
18
+ * the mangled basename still shows its separators in some positions —
19
+ * the collapse is per-escape, which is why this looked like a missing
20
+ * file rather than a path bug for so long.
21
+ *
22
+ * `xp-gate doctor` therefore FAILed on a perfectly healthy hook, and the
23
+ * failure text named a path that does not exist, sending the operator to
24
+ * look for a missing file.
25
+ * @covers AC-566-01, AC-566-02, AC-566-03
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+
31
+ const { inspectHookFile, toPosixPath } = require('../hook-health');
32
+
33
+ let TMP;
34
+
35
+ beforeAll(() => {
36
+ TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-posix-'));
37
+ });
38
+
39
+ afterAll(() => {
40
+ fs.rmSync(TMP, { recursive: true, force: true });
41
+ });
42
+
43
+ /**
44
+ * Write a hook file large enough to clear the parse-check size floor.
45
+ * @param {string} name - file name
46
+ * @param {string} body - shell source
47
+ * @returns {string} absolute path
48
+ */
49
+ function writeHook(name, body) {
50
+ const abs = path.join(TMP, name);
51
+ try {
52
+ // Padding keeps the file above PARSE_CHECK_MIN_BYTES so the probe actually
53
+ // runs; a stub below the floor would make every assertion here vacuous.
54
+ fs.writeFileSync(abs, body + '# ' + 'x'.repeat(8192) + '\n');
55
+ } catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ throw new Error(`fixture setup failed writing ${name}: ${msg}`);
58
+ }
59
+ return abs;
60
+ }
61
+
62
+ describe('bash -n path handling', () => {
63
+ test('AC-566-01: toPosixPath converts a Windows path without mangling it', () => {
64
+ // The transform under test, isolated: drive letter preserved, separators
65
+ // flipped, no characters dropped.
66
+ expect(toPosixPath('C:\\Users\\think\\.config\\xp-gate\\hooks\\pre-push')).toBe(
67
+ 'C:/Users/think/.config/xp-gate/hooks/pre-push'
68
+ );
69
+ // A POSIX path is already correct and must not be doubled over.
70
+ expect(toPosixPath('/home/user/.config/xp-gate/hooks/pre-push')).toBe(
71
+ '/home/user/.config/xp-gate/hooks/pre-push'
72
+ );
73
+ });
74
+
75
+ test('AC-566-02: a healthy hook under a native Windows path is parseable', () => {
76
+ // The defect, end to end. The path MUST come from path.join, not from a
77
+ // string literal: path.join produces the single-backslash form that Node
78
+ // hands to spawnSync, and that is the form bash mangles. A hand-written
79
+ // fixture path with different escaping would pass before the fix and make
80
+ // this test vacuous — measured, not assumed.
81
+ const hook = writeHook('pre-push', '#!/bin/bash\nset -e\necho hi\n');
82
+ expect(hook).toContain('\\'); // the path really is the Windows form
83
+
84
+ const verdict = inspectHookFile(hook, {});
85
+
86
+ expect(verdict.problems.join('; ')).not.toMatch(/No such file or directory/);
87
+ expect(verdict.parseable, 'a syntactically valid hook must parse').toBe(true);
88
+ expect(verdict.ok, 'a healthy hook must not be reported as broken').toBe(true);
89
+ });
90
+
91
+ test('AC-566-03: a genuinely broken hook is still reported as broken', () => {
92
+ // Guards against "fix it by never failing": path handling must not turn the
93
+ // probe into a no-op. This is the anti-vacuity half of AC-566-02.
94
+ const hook = writeHook('pre-commit', '#!/bin/bash\nif [ 1 -eq 1 ]; then\n echo hi\n');
95
+
96
+ const verdict = inspectHookFile(hook, {});
97
+
98
+ expect(verdict.parseable, 'an unterminated if must fail the parse').toBe(false);
99
+ expect(verdict.ok).toBe(false);
100
+ });
101
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @test REQ-565 `xp-gate principles <dir>` must examine the files in the directory
3
+ * @intent `principles()` forwarded its target straight to
4
+ * `principles-checker --files <target>`, and the checker only accepts
5
+ * FILE paths — a directory matches no adapter extension and is dropped
6
+ * without a word. The reported result was therefore:
7
+ *
8
+ * ✓ No violations found
9
+ * Files checked: 0 <-- and exit code 0
10
+ *
11
+ * which is indistinguishable from a directory that genuinely has no
12
+ * violations. The CLI's own usage string advertises
13
+ * `<file_or_directory>`, so the directory form is not an unsupported
14
+ * extra — it is the documented one, silently doing nothing.
15
+ *
16
+ * This is the same defect class as #561 and #563: a gate that reports
17
+ * success for work it never performed. Expanding the directory belongs
18
+ * in the CLI layer because that is where the `<file_or_directory>`
19
+ * contract is declared; the checker's file-list contract is correct.
20
+ * @covers AC-565-01, AC-565-02, AC-565-03
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ const { expandTargets } = require('../principles');
27
+
28
+ let TMP;
29
+
30
+ beforeAll(() => {
31
+ TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'principles-expand-'));
32
+ });
33
+
34
+ afterAll(() => {
35
+ fs.rmSync(TMP, { recursive: true, force: true });
36
+ });
37
+
38
+ /**
39
+ * Write a file inside the temp tree.
40
+ * @param {string} rel - path relative to TMP
41
+ * @param {string} content - file body
42
+ */
43
+ function touch(rel, content) {
44
+ const abs = path.join(TMP, rel);
45
+ try {
46
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
47
+ fs.writeFileSync(abs, content);
48
+ } catch (err) {
49
+ const msg = err instanceof Error ? err.message : String(err);
50
+ throw new Error(`fixture setup failed writing ${rel}: ${msg}`);
51
+ }
52
+ }
53
+
54
+ describe('principles directory expansion', () => {
55
+ test('AC-565-01: a directory target expands to the source files inside it', () => {
56
+ touch('src/a.ts', 'export const a = 1;\n');
57
+ touch('src/b.js', 'module.exports = 1;\n');
58
+ touch('src/nested/c.ts', 'export const c = 2;\n');
59
+ touch('src/readme.md', 'not source\n');
60
+ touch('src/skip.test.ts', '// test file\n');
61
+
62
+ const out = expandTargets([path.join(TMP, 'src')]);
63
+
64
+ const names = out.map(p => path.relative(TMP, p).replace(/\\/g, '/')).sort();
65
+ expect(names).toEqual(['src/a.ts', 'src/b.js', 'src/nested/c.ts']);
66
+ });
67
+
68
+ test('AC-565-02: a file target is passed through untouched', () => {
69
+ // The file form already worked; expanding must not disturb it.
70
+ const file = path.join(TMP, 'single.ts');
71
+ touch('single.ts', 'export const x = 1;\n');
72
+
73
+ expect(expandTargets([file])).toEqual([file]);
74
+ });
75
+
76
+ test('AC-565-03: a directory with no source files yields an empty list, not the directory', () => {
77
+ // The caller must be able to see "nothing to check" and say so, rather than
78
+ // hand the checker a directory that silently matches nothing.
79
+ fs.mkdirSync(path.join(TMP, 'empty'), { recursive: true });
80
+ touch('empty/notes.txt', 'hi\n');
81
+
82
+ const out = expandTargets([path.join(TMP, 'empty')]);
83
+
84
+ expect(out).toEqual([]);
85
+ expect(out).not.toContain(path.join(TMP, 'empty'));
86
+ });
87
+ });
package/lib/check.js CHANGED
@@ -94,21 +94,75 @@ async function runDefaultChecks(target) {
94
94
  return failures === 0 ? 0 : 1;
95
95
  }
96
96
 
97
+ /**
98
+ * Render a gate tally as one honest line.
99
+ *
100
+ * Deliberately omits any state with a zero count, so a clean run reads
101
+ * "10 passed / 10 total" and a run with skips reads "5 passed / 5 skipped /
102
+ * 10 total" — the reader can see which of the two happened. The old
103
+ * `${ran - failures}/${ran}` form produced the same "10/10" for both (#568).
104
+ *
105
+ * @param {{passed: number, skipped: number, failed: number, warned: number, total: number}} tally
106
+ * @returns {string} e.g. "2 passed / 1 failed / 1 skipped / 4 total"
107
+ */
108
+ function formatGateTally(tally) {
109
+ const parts = [`${tally.passed} passed`];
110
+ if (tally.failed > 0) parts.push(`${tally.failed} failed`);
111
+ if (tally.warned > 0) parts.push(`${tally.warned} warned`);
112
+ if (tally.skipped > 0) parts.push(`${tally.skipped} skipped`);
113
+ parts.push(`${tally.total} total`);
114
+ return parts.join(' / ');
115
+ }
116
+
117
+ /**
118
+ * Run every standalone-invokable gate and report the result honestly.
119
+ *
120
+ * `preCommitOnly` entries are NOT invokable from the CLI: `runGate` prints
121
+ * "SKIPPED - <reason>" and returns 0. Counting that 0 as a pass made
122
+ * `10/10 gate(s) passed` the summary for a run where five of fifteen gates
123
+ * never executed (#568, report §5 phase-0 action 0.2). Skips are now counted
124
+ * separately, still do not fail the run, and always appear in the summary.
125
+ *
126
+ * @param {string} target - path to check
127
+ * @returns {Promise<number>} 0 when nothing failed, 1 otherwise
128
+ */
97
129
  async function checkAllGates(target) {
98
130
  const { getAllGates } = require('./gate-runner.js');
99
- const invokableGates = getAllGates().filter(g => !g.preCommitOnly);
100
- let failures = 0;
101
- let ran = 0;
131
+ const allGates = getAllGates();
132
+ const invokableGates = allGates.filter(g => !g.preCommitOnly);
133
+ const skippedGates = allGates.filter(g => g.preCommitOnly);
134
+
135
+ const tally = {
136
+ passed: 0,
137
+ skipped: skippedGates.length,
138
+ failed: 0,
139
+ warned: 0,
140
+ total: allGates.length,
141
+ };
142
+
143
+ for (const gate of skippedGates) {
144
+ console.log(`━━━ Gate ${gate.id}: ${gate.name} ━━━`);
145
+ console.log(`⏭️ SKIPPED (not invokable from the CLI) - ${gate.reason || 'runs during commit'}`);
146
+ console.log('');
147
+ }
102
148
 
103
149
  for (const gate of invokableGates) {
104
150
  const code = await runGate(gate.id, target);
105
- if (code !== 0) failures += 1;
106
- ran += 1;
151
+ if (code === 0) tally.passed += 1;
152
+ else tally.failed += 1;
107
153
  console.log('');
108
154
  }
109
155
 
110
- console.log(`[xp-gate check --all] ${ran - failures}/${ran} gate(s) passed`);
111
- return failures === 0 ? 0 : 1;
156
+ console.log(`[xp-gate check --all] ${formatGateTally(tally)}`);
157
+ if (tally.skipped > 0) {
158
+ // Name them: a bare count hides WHICH work did not happen, and the point of
159
+ // the fix is that the summary can no longer imply they ran.
160
+ console.log(
161
+ `[xp-gate check --all] not run from the CLI: ${skippedGates.map(g => g.id).join(', ')} ` +
162
+ '(pre-commit only; `git commit` runs them)'
163
+ );
164
+ }
165
+ return tally.failed === 0 ? 0 : 1;
112
166
  }
113
167
 
114
168
  async function runGatesByIds(gateIds, target) {
@@ -183,4 +237,4 @@ function listGates() {
183
237
  return 0;
184
238
  }
185
239
 
186
- module.exports = { check };
240
+ module.exports = { check, checkAllGates, formatGateTally };
@@ -360,8 +360,15 @@ async function runGate(gateId, targetPath) {
360
360
  if (result && typeof result.then === 'function') {
361
361
  return await result;
362
362
  }
363
- return 0;
363
+ // A synchronous handler's verdict MUST be propagated (#563). Gates 3, 7,
364
+ // 8 and 9 reach runTsGate(), which returns a plain number; reporting a
365
+ // manufactured 0 for them made `xp-gate check` unable to distinguish a
366
+ // failing gate from a passing one. Handlers that return nothing at all
367
+ // (gate 2, whose execFileSync throws on a non-zero exit instead) are
368
+ // still treated as pass, because their failure signal is the throw below.
369
+ return typeof result === 'number' ? result : 0;
364
370
  } catch (err) {
371
+ // Covers a synchronous throw AND a rejection from the awaited handler.
365
372
  console.error(` Gate ${gateId} FAILED: ${err.message}`);
366
373
  return 1;
367
374
  }
@@ -21,14 +21,43 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { spawnSync } = require('child_process');
23
23
 
24
+ /**
25
+ * Ask a bash binary whether it is a Windows bash rather than WSL.
26
+ *
27
+ * `bash --version` is not enough: a healthy WSL bash exits 0 for it. The
28
+ * discriminator is the kernel it reports — Git for Windows builds report a
29
+ * MINGW / MSYS / CYGWIN kernel name, WSL reports Linux. A Linux bash cannot
30
+ * open `C:/...`, so selecting one turns every hook-health probe into "No such
31
+ * file or directory" regardless of how the path is spelled.
32
+ *
33
+ * @param {string} bin - candidate binary
34
+ * @returns {boolean} true when it is a Windows bash
35
+ */
36
+ function isWindowsBash(bin) {
37
+ const probe = spawnSync(bin, ['-c', 'uname -s'], { encoding: 'utf8', timeout: 10000 });
38
+ if (probe.error || probe.status !== 0) return false;
39
+ return /MINGW|MSYS|CYGWIN/i.test(String(probe.stdout || ''));
40
+ }
41
+
24
42
  /**
25
43
  * Locate a bash binary without assuming it is on PATH under that exact name.
44
+ *
45
+ * On Windows the Git for Windows installs are probed FIRST and the bare name
46
+ * `bash` is the last resort (#570). The previous order put `bash` first and
47
+ * accepted any binary whose `--version` exited 0 — which is every healthy WSL
48
+ * install. Measured on this machine: `bash` on PATH resolves to
49
+ * `C:\WINDOWS\system32\bash.exe`, `uname -s` answers `Linux`, and the probe
50
+ * `bash -n <windows path>` fails with "No such file or directory" no matter how
51
+ * the path is spelled. `xp-gate doctor` then reported healthy hooks as
52
+ * unparseable.
53
+ *
26
54
  * @returns {string|null} Path or bare command name, null when none found
27
55
  */
28
56
  function findBash() {
29
- const candidates = ['bash'];
57
+ const candidates = [];
30
58
  if (process.platform === 'win32') {
31
- // Git for Windows ships bash outside PATH far more often than inside it.
59
+ // Git for Windows ships bash outside PATH far more often than inside it,
60
+ // and PATH's `bash` is usually WSL.
32
61
  const pf = process.env.ProgramFiles || 'C:\\Program Files';
33
62
  const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
34
63
  const local = process.env.LOCALAPPDATA;
@@ -37,13 +66,19 @@ function findBash() {
37
66
  candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'));
38
67
  candidates.push(path.join(pf, 'Git', 'usr', 'bin', 'bash.exe'));
39
68
  }
69
+ // Last resort: whatever PATH offers. On Windows it is only accepted when it
70
+ // reports a Windows kernel, so a WSL stub can never be returned.
71
+ candidates.push('bash');
72
+
40
73
  for (const c of candidates) {
41
74
  if (path.isAbsolute(c)) {
42
75
  if (fs.existsSync(c)) return c;
43
76
  continue;
44
77
  }
45
78
  const probe = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 10000 });
46
- if (!probe.error && probe.status === 0) return c;
79
+ if (probe.error || probe.status !== 0) continue;
80
+ if (process.platform === 'win32' && !isWindowsBash(c)) continue;
81
+ return c;
47
82
  }
48
83
  return null;
49
84
  }
@@ -58,6 +93,25 @@ function countCrlfLines(text) {
58
93
  return matches ? matches.length : 0;
59
94
  }
60
95
 
96
+ /**
97
+ * Convert a native filesystem path to the form Git bash can open.
98
+ *
99
+ * A backslash inside an argument to MSYS bash is an ESCAPE character, so
100
+ * `C:\Users\think\.config\xp-gate\hooks\pre-push` reaches bash as
101
+ * `C:Usersthink.configxp-gatehookspre-push` and every candidate path looks
102
+ * missing. Forward slashes are accepted by Windows APIs too, so this is the one
103
+ * form that works on both sides.
104
+ *
105
+ * Exported because the transform is the thing being tested: asserting only
106
+ * through `inspectHookFile` would leave the conversion itself unexamined.
107
+ *
108
+ * @param {string} p - native path
109
+ * @returns {string} path with forward slashes
110
+ */
111
+ function toPosixPath(p) {
112
+ return String(p).replace(/\\/g, '/');
113
+ }
114
+
61
115
  /**
62
116
  * Ask bash whether it can parse the file (`bash -n`).
63
117
  * @param {string} filePath - Hook to check
@@ -66,7 +120,13 @@ function countCrlfLines(text) {
66
120
  */
67
121
  function checkParseable(filePath, bashBin) {
68
122
  if (!bashBin) return { checked: false, ok: true, detail: 'bash not found; parse check skipped' };
69
- const res = spawnSync(bashBin, ['-n', filePath], { encoding: 'utf8', timeout: 30000 });
123
+ // Pass a POSIX path (#566). Git bash treats a backslash in its argv as an
124
+ // escape, which silently collapsed every native Windows path into a
125
+ // nonexistent one and reported healthy hooks as unparseable.
126
+ const res = spawnSync(bashBin, ['-n', toPosixPath(filePath)], {
127
+ encoding: 'utf8',
128
+ timeout: 30000,
129
+ });
70
130
  if (res.error) return { checked: false, ok: true, detail: `bash -n could not run: ${res.error.message}` };
71
131
  if (res.status === 0) return { checked: true, ok: true, detail: 'parses' };
72
132
  // bash reports the first syntax error it hits. Under CRLF that line number is
@@ -118,4 +178,4 @@ function inspectHookFile(filePath, options = {}) {
118
178
  return { ok: problems.length === 0, crlfLines, parseable: parsed.ok, problems };
119
179
  }
120
180
 
121
- module.exports = { inspectHookFile, findBash, countCrlfLines };
181
+ module.exports = { inspectHookFile, findBash, countCrlfLines, toPosixPath };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-21T05:03:41.149Z",
5
+ "checked_at": "2026-10-22T08:31:44.285Z",
6
6
  "source": "network"
7
7
  }
package/lib/principles.js CHANGED
@@ -25,6 +25,95 @@ function findPrinciplesEntry() {
25
25
  return null;
26
26
  }
27
27
 
28
+ // Extensions the principles engine has an adapter for. Kept as a literal rather
29
+ // than imported from src/principles/analyzer.ts because this file must stay
30
+ // loadable by plain Node with no transpile step (the package ships zero runtime
31
+ // dependencies and no build). Must match `getAdapterForFile`'s adapterMap.
32
+ const SOURCE_EXTENSIONS = new Set([
33
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
34
+ '.py', '.go', '.java', '.kt', '.kts',
35
+ '.dart', '.swift', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp',
36
+ '.m', '.mm',
37
+ ]);
38
+
39
+ // Directories never worth walking: they hold no project source and can be
40
+ // enormous. `node_modules` alone is ~hundreds of MB in this repo.
41
+ const SKIP_DIRS = new Set([
42
+ 'node_modules', '.git', 'dist', 'build', 'coverage',
43
+ '.stryker-tmp', '.worktrees', '__pycache__', '.venv', 'venv',
44
+ ]);
45
+
46
+ /**
47
+ * Walk a directory and collect every source file the engine can analyse.
48
+ *
49
+ * Exported for tests: the walk is the part that used to be silently absent, so
50
+ * it is worth asserting directly rather than only through the CLI.
51
+ *
52
+ * @param {string} dir - absolute or relative directory
53
+ * @returns {string[]} absolute paths, in discovery order
54
+ */
55
+ function collectSourceFiles(dir) {
56
+ const out = [];
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch (err) {
61
+ // An unreadable directory contributes nothing, but must not crash the run.
62
+ console.error(`[xp-gate principles] cannot read directory ${dir}: ${err.message}`);
63
+ return out;
64
+ }
65
+ for (const entry of entries) {
66
+ const abs = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (SKIP_DIRS.has(entry.name)) continue;
69
+ out.push(...collectSourceFiles(abs));
70
+ continue;
71
+ }
72
+ if (!entry.isFile()) continue;
73
+ // Skip test files: their assertions legitimately look like magic numbers,
74
+ // long functions and missing error handling, so including them would bury
75
+ // the real findings in noise.
76
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) continue;
77
+ if (SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) out.push(abs);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Turn CLI targets into the flat file list the checker understands (#565).
84
+ *
85
+ * `principles-checker --files` only accepts file paths; a directory matches no
86
+ * adapter extension and is dropped silently, which used to report
87
+ * "No violations found / Files checked: 0" with exit code 0. Expanding here
88
+ * keeps that contract in the layer that advertises `<file_or_directory>`.
89
+ *
90
+ * A file target is returned untouched even if its extension is unknown, so the
91
+ * caller can still surface the checker's own opinion about it.
92
+ *
93
+ * @param {string[]} targets - paths as given on the command line
94
+ * @returns {string[]} file paths to hand to the checker
95
+ */
96
+ function expandTargets(targets) {
97
+ const out = [];
98
+ for (const target of targets) {
99
+ let stat;
100
+ try {
101
+ stat = fs.statSync(target);
102
+ } catch {
103
+ // Nonexistent path: forward it so the checker reports the missing file
104
+ // rather than this layer pretending it was fine.
105
+ out.push(target);
106
+ continue;
107
+ }
108
+ if (stat.isDirectory()) {
109
+ out.push(...collectSourceFiles(target));
110
+ } else {
111
+ out.push(target);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
28
117
  function principles(args) {
29
118
  const target = args[0];
30
119
  if (!target) {
@@ -35,6 +124,20 @@ function principles(args) {
35
124
  const formatIdx = args.indexOf('--format');
36
125
  const format = formatIdx >= 0 ? args[formatIdx + 1] : 'console';
37
126
 
127
+ // Expand directories before handing them to the checker (#565). Passing a
128
+ // directory straight through made the checker examine zero files while the
129
+ // CLI printed "No violations found" and exited 0.
130
+ const files = expandTargets([target]);
131
+ if (files.length === 0) {
132
+ // Nothing to analyse is NOT a pass. Saying so is the whole point: silence
133
+ // here is indistinguishable from a clean directory. Exit 2 mirrors the
134
+ // checker's own "examined nothing" contract, which reserves 1 for
135
+ // "examined files and found violations".
136
+ console.error(`[xp-gate principles] no analysable source files under: ${target}`);
137
+ console.error(' Supported: ' + Array.from(SOURCE_EXTENSIONS).join(' '));
138
+ return Promise.resolve(2);
139
+ }
140
+
38
141
  const entry = findPrinciplesEntry();
39
142
  if (!entry) {
40
143
  console.error('[xp-gate principles] ERROR: cannot locate src/principles/index.ts');
@@ -42,7 +145,7 @@ function principles(args) {
42
145
  return Promise.resolve(1);
43
146
  }
44
147
 
45
- const result = spawnSync('npx', ['-y', 'tsx', entry, '--files', target, '--format', format], {
148
+ const result = spawnSync('npx', ['-y', 'tsx', entry, '--files', files.join(' '), '--format', format], {
46
149
  stdio: 'inherit',
47
150
  shell: process.platform === 'win32',
48
151
  });
@@ -50,4 +153,4 @@ function principles(args) {
50
153
  return Promise.resolve(result.status === null ? 1 : result.status);
51
154
  }
52
155
 
53
- module.exports = { principles };
156
+ module.exports = { principles, expandTargets, collectSourceFiles };
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-21
4
- **Commit:** 4e1f8be0
3
+ **Generated:** 2026-10-22
4
+ **Commit:** 0678b903
5
5
  **Branch:** main
6
- **Version:** 0.30.5.0
6
+ **Version:** 0.31.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.