@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.
- package/adapters/powershell.sh +195 -12
- package/gate-10.sh +16 -0
- package/hooks/gate-10.sh +16 -0
- package/hooks/pre-commit +28 -1
- package/lib/__tests__/check-all-skip-accounting.test.js +171 -0
- package/lib/__tests__/find-bash-wsl.test.js +121 -0
- package/lib/__tests__/gate-runner-verdict.test.js +105 -0
- package/lib/__tests__/hook-health-path.test.js +101 -0
- package/lib/__tests__/principles-directory.test.js +87 -0
- package/lib/check.js +62 -8
- package/lib/gate-runner.js +8 -1
- package/lib/hook-health.js +65 -5
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +105 -2
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- 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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
|
|
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] ${
|
|
111
|
-
|
|
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 };
|
package/lib/gate-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/hook-health.js
CHANGED
|
@@ -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 = [
|
|
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 (
|
|
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
|
-
|
|
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 };
|
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',
|
|
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 };
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-22
|
|
4
|
+
**Commit:** 0678b903
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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.
|