@boyingliu01/xp-gate 0.26.0 → 0.27.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/bin/xp-gate.js +161 -28
- package/hooks/pre-commit +27 -10
- package/lib/__tests__/check-alignment-cli.test.mjs +77 -0
- package/lib/__tests__/doctor-469-hook-env.test.js +12 -7
- package/lib/__tests__/doctor-exec-retry.test.js +152 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +15 -6
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +16 -4
- package/lib/__tests__/test-alignment.test.ts +37 -0
- package/lib/__tests__/windows-bash-probe.test.js +123 -0
- package/lib/check-alignment-runner.cjs +74 -0
- package/lib/detect-deps.js +139 -20
- package/lib/doctor.js +45 -11
- package/lib/gate-runner.js +12 -5
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-status.js +5 -2
- package/lib/test-alignment.ts +136 -12
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/README.md +1 -1
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +2 -2
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/src/skills.test.ts +20 -7
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/scripts/prepack.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/principles/rules/__tests__/clean-code/code-duplication.test.ts +9 -8
- package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +3 -4
- package/principles/rules/__tests__/clean-code/god-class.test.ts +2 -4
- package/principles/rules/__tests__/clean-code/large-file.test.ts +2 -4
- package/principles/rules/__tests__/clean-code/long-function.test.ts +2 -4
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +2 -3
- package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +2 -4
- package/principles/rules/__tests__/clean-code/too-many-params.test.ts +2 -4
- package/principles/rules/__tests__/clean-code/unused-imports.test.ts +4 -4
- package/principles/rules/__tests__/solid/dip.test.ts +2 -4
- package/principles/rules/__tests__/solid/isp.test.ts +2 -4
- package/principles/rules/__tests__/solid/lsp.test.ts +2 -4
- package/principles/rules/__tests__/solid/ocp.test.ts +2 -4
- package/principles/rules/__tests__/solid/srp.test.ts +2 -4
- package/principles/rules/clean-code/code-duplication.ts +12 -14
- package/principles/rules/clean-code/deep-nesting.ts +18 -20
- package/principles/rules/clean-code/god-class.ts +32 -34
- package/principles/rules/clean-code/large-file.ts +14 -16
- package/principles/rules/clean-code/long-function.ts +26 -28
- package/principles/rules/clean-code/magic-numbers.ts +39 -38
- package/principles/rules/clean-code/many-exports.ts +21 -23
- package/principles/rules/clean-code/missing-error-handling.ts +13 -15
- package/principles/rules/clean-code/too-many-params.ts +15 -17
- package/principles/rules/clean-code/unused-imports.ts +13 -15
- package/principles/rules/solid/dip.ts +16 -18
- package/principles/rules/solid/isp.ts +17 -19
- package/principles/rules/solid/lsp.ts +4 -6
- package/principles/rules/solid/ocp.ts +17 -19
- package/principles/rules/solid/srp.ts +20 -22
- package/scripts/__tests__/sync-package-content.test.js +0 -1
- package/scripts/sync-package-content.js +0 -1
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/claude-code/skills/clipboard-vision/SKILL.md +0 -48
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
- package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
- package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.sh +0 -161
- package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
- package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
- package/plugins/dsh/skills/clipboard-vision/SKILL.md +0 -48
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
- package/plugins/dsh/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
- package/plugins/dsh/skills/clipboard-vision/describe-clipboard.sh +0 -161
- package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
- package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
- package/plugins/opencode/skills/clipboard-vision/SKILL.md +0 -48
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
- package/plugins/opencode/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
- package/plugins/opencode/skills/clipboard-vision/describe-clipboard.sh +0 -161
- package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
- package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
- package/plugins/qoder/skills/clipboard-vision/SKILL.md +0 -48
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
- package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
- package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +0 -161
- package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
- package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
- package/skills/clipboard-vision/SKILL.md +0 -48
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -64
- package/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +0 -185
- package/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +0 -138
- package/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +0 -273
- package/skills/clipboard-vision/describe-clipboard.ps1 +0 -115
- package/skills/clipboard-vision/describe-clipboard.sh +0 -161
- package/skills/clipboard-vision/scripts/clipboard-vision.ps1 +0 -86
- package/skills/clipboard-vision/scripts/clipboard-vision.sh +0 -137
|
@@ -325,6 +325,43 @@ test('multiple assertions', () => {
|
|
|
325
325
|
expect(result.tests[0].assertions).toBeGreaterThanOrEqual(4);
|
|
326
326
|
expect(result.totalAssertions).toBeGreaterThanOrEqual(4);
|
|
327
327
|
});
|
|
328
|
+
|
|
329
|
+
it('AC-FIXTURE-ASSERT: should count assertions per-test, not per-file (#392)', async () => {
|
|
330
|
+
// Regression for #392-#1: assertion counting must be scoped to the test
|
|
331
|
+
// body, otherwise totalAssertions is inflated by fileCount x perFileCount.
|
|
332
|
+
const testTs = `
|
|
333
|
+
/**
|
|
334
|
+
* @test REQ-MULTI-001 First test
|
|
335
|
+
* @intent Assertion counting is per-test
|
|
336
|
+
* @covers AC-FIXTURE-MULTI-01
|
|
337
|
+
*/
|
|
338
|
+
test('first test', () => {
|
|
339
|
+
expect(a()).toBe(1);
|
|
340
|
+
expect(b()).toBe(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @test REQ-MULTI-002 Second test
|
|
345
|
+
* @intent Assertion counting is per-test
|
|
346
|
+
* @covers AC-FIXTURE-MULTI-02
|
|
347
|
+
*/
|
|
348
|
+
test('second test', () => {
|
|
349
|
+
expect(c()).toBe(3);
|
|
350
|
+
});
|
|
351
|
+
`;
|
|
352
|
+
const testFile = writeTempFile('multi-assert.test.ts', testTs);
|
|
353
|
+
const result = parseTestFiles([testFile]);
|
|
354
|
+
|
|
355
|
+
expect(result.tests).toHaveLength(2);
|
|
356
|
+
// countAssertions counts matching *patterns*, so `expect(x).toBe(y)` yields
|
|
357
|
+
// 2 (one `expect(` + one `toBe(`). First test body has 2 such assertions
|
|
358
|
+
// => 4; second has 1 => 2. The key regression: each test counts ONLY its own
|
|
359
|
+
// body (total 6), never the whole file (3 expect + 3 toBe = 6 per test,
|
|
360
|
+
// which would inflate totalAssertions to 12 under the old per-file bug).
|
|
361
|
+
expect(result.tests[0].assertions).toBe(4);
|
|
362
|
+
expect(result.tests[1].assertions).toBe(2);
|
|
363
|
+
expect(result.totalAssertions).toBe(6);
|
|
364
|
+
});
|
|
328
365
|
});
|
|
329
366
|
|
|
330
367
|
// ============================================================================
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-windows-bash probe behavior
|
|
3
|
+
* @intent resolveWindowsBashCandidates must accept only a bash that round-trips
|
|
4
|
+
* `-c` positional argv AND reports MINGW/MSYS/CYGWIN via uname — the WSL
|
|
5
|
+
* System32 stub (Linux uname, dropped $1) is rejected even when PATH
|
|
6
|
+
* leads with it. Behavior is exercised through an injected fake spawner,
|
|
7
|
+
* so no real interpreter configuration is assumed on the host (#469
|
|
8
|
+
* follow-up, Delphi walkthrough R1 MN-06).
|
|
9
|
+
* @covers AC-WINBASH-01, AC-WINBASH-02
|
|
10
|
+
*/
|
|
11
|
+
const { describe, it, expect } = await import('vitest');
|
|
12
|
+
const { resolveWindowsBashCandidates, checkBashVersion } = require('../detect-deps.js');
|
|
13
|
+
|
|
14
|
+
// The candidate loop reads `where bash` from the host; tests inject both the
|
|
15
|
+
// discovery runner and the probe spawner, so results are deterministic on any
|
|
16
|
+
// machine regardless of what interpreter configuration it happens to have.
|
|
17
|
+
function fakeWhere(whereOutput) {
|
|
18
|
+
return (cmd) => {
|
|
19
|
+
if (/^where bash/.test(cmd)) {
|
|
20
|
+
if (whereOutput instanceof Error) throw whereOutput;
|
|
21
|
+
return whereOutput || '';
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`unexpected execSync: ${cmd}`);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeSpawn(behaviors) {
|
|
28
|
+
const probed = [];
|
|
29
|
+
const fn = (candidate) => {
|
|
30
|
+
probed.push(candidate);
|
|
31
|
+
const b = behaviors[candidate];
|
|
32
|
+
if (b instanceof Error) throw b;
|
|
33
|
+
// Unknown candidates model a missing file: no stdout at all.
|
|
34
|
+
return { stdout: b !== undefined ? b : '' };
|
|
35
|
+
};
|
|
36
|
+
fn.probed = probed;
|
|
37
|
+
return fn;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('resolveWindowsBashCandidates (win32 authoritative bash)', () => {
|
|
41
|
+
it('AC-WINBASH-01: rejects a WSL stub whose $1 is empty and uname is Linux', () => {
|
|
42
|
+
// The resolver must find nothing rather than settle for the stub — and on
|
|
43
|
+
// current Windows the System32 stub is skipped by name without even being
|
|
44
|
+
// spawned (cold-launch stall); this world uses a non-System32 stub to keep
|
|
45
|
+
// testing the output-based rejection path itself.
|
|
46
|
+
const spawn = fakeSpawn({ 'C:\\wsl-stub\\bash.exe': '[ ]\nLinux\n' });
|
|
47
|
+
expect(resolveWindowsBashCandidates(spawn, fakeWhere('C:\\wsl-stub\\bash.exe\r\n'))).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('AC-WINBASH-01: accepts the first candidate with argv round-trip + MINGW uname', () => {
|
|
51
|
+
const spawn = fakeSpawn({
|
|
52
|
+
'C:\\a\\bash.exe': '[]\nLinux\n', // drops $1 → reject
|
|
53
|
+
'C:\\b\\bash.exe': '[XPGATE_ARGV_PROBE]\nMINGW64_NT-10.0\n', // accept
|
|
54
|
+
});
|
|
55
|
+
const result = resolveWindowsBashCandidates(spawn, fakeWhere('C:\\a\\bash.exe\r\nC:\\b\\bash.exe\r\n'));
|
|
56
|
+
expect(result).toBe('C:\\b\\bash.exe');
|
|
57
|
+
// Known Git paths are probed BEFORE PATH discoveries (cold WSL launches
|
|
58
|
+
// stall the event loop for seconds), so a/b keep their relative order but
|
|
59
|
+
// trail the hardcoded fallbacks.
|
|
60
|
+
expect(spawn.probed.slice(-2)).toEqual(['C:\\a\\bash.exe', 'C:\\b\\bash.exe']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('AC-WINBASH-02: probes known Git paths before PATH discoveries', () => {
|
|
64
|
+
// Regression for the doctor fan-out starvation: System32\bash.exe leads
|
|
65
|
+
// `where bash` on WSL machines and its cold launch blocks ~3.4s. It must
|
|
66
|
+
// never be spawned at all — neither from where-output nor as any fallback.
|
|
67
|
+
const spawn = fakeSpawn({
|
|
68
|
+
'C:\\Program Files\\Git\\bin\\bash.exe': '[XPGATE_ARGV_PROBE]\nMSYS_NT-10.0\n',
|
|
69
|
+
});
|
|
70
|
+
const { result } = (() => ({
|
|
71
|
+
result: resolveWindowsBashCandidates(spawn, fakeWhere('C:\\Windows\\System32\\bash.exe\r\n')),
|
|
72
|
+
}))();
|
|
73
|
+
expect(result).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
|
|
74
|
+
expect(spawn.probed).not.toContain('C:\\Windows\\System32\\bash.exe');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('AC-WINBASH-01: a Linux uname with intact argv still fails the platform test', () => {
|
|
78
|
+
// A real WSL bash answers [XPGATE_ARGV_PROBE] correctly under some
|
|
79
|
+
// invocations but its filesystem view cannot run these hooks — uname wins.
|
|
80
|
+
const spawn = fakeSpawn({ 'C:\\wsl\\bash.exe': '[XPGATE_ARGV_PROBE]\nLinux\n' });
|
|
81
|
+
expect(resolveWindowsBashCandidates(spawn, fakeWhere('C:\\wsl\\bash.exe\r\n'))).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('AC-WINBASH-01: a throwing candidate is skipped, not fatal', () => {
|
|
85
|
+
const spawn = fakeSpawn({
|
|
86
|
+
'C:\\gone\\bash.exe': new Error('ENOENT'),
|
|
87
|
+
'C:\\msys64\\usr\\bin\\bash.exe': '[XPGATE_ARGV_PROBE]\nMSYS_NT-10.0\n',
|
|
88
|
+
});
|
|
89
|
+
const result = resolveWindowsBashCandidates(spawn, fakeWhere(new Error('where unavailable')));
|
|
90
|
+
expect(result).toBe('C:\\msys64\\usr\\bin\\bash.exe');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('AC-WINBASH-02: WSL stubs are skipped by name regardless of separators or case', () => {
|
|
94
|
+
// The filter normalizes slashes and ignores case/drive: forward-slash and
|
|
95
|
+
// lowercase variants of the same stub must still produce NO spawn call.
|
|
96
|
+
// The AppX alias lives under <Windows>\SystemApps\Microsoft.WSL_* — a
|
|
97
|
+
// user's own install merely NAMED ...\WindowsApps\... is NOT the stub and
|
|
98
|
+
// must still be probed (output-based rejection covers it).
|
|
99
|
+
const spawn = fakeSpawn({});
|
|
100
|
+
resolveWindowsBashCandidates(spawn, fakeWhere(
|
|
101
|
+
'c:/windows/sysnative/bash.exe\r\nC:\\WINDOWS\\System32\\bash.exe\r\nC:\\Windows\\SysWOW64\\bash.exe\r\nC:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\bash.exe\r\n'
|
|
102
|
+
));
|
|
103
|
+
expect(spawn.probed.filter(p => /system32|sysnative|syswow64|appdata\\local\\microsoft\\windowsapps/i.test(p.replace(/\//g, '\\')))).toEqual([]);
|
|
104
|
+
const userOwned = fakeSpawn({});
|
|
105
|
+
resolveWindowsBashCandidates(userOwned, fakeWhere('D:\\Tools\\WindowsApps\\bash.exe\r\n'));
|
|
106
|
+
expect(userOwned.probed).toContain('D:\\Tools\\WindowsApps\\bash.exe');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('AC-WINBASH-01: checkBashVersion returns the bash VERSION tuple, not the uname line', () => {
|
|
110
|
+
// Contract regression (walkthrough R5 MJ-01/FC-02): doctor prints "GNU
|
|
111
|
+
// bash, version <field>" — the field must be the version number.
|
|
112
|
+
const spawn = (file, args) => ({
|
|
113
|
+
stdout: 'MINGW64_NT-10.0-26300\nGNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)\n',
|
|
114
|
+
});
|
|
115
|
+
expect(checkBashVersion(spawn, 'C:\\bash.exe')).toBe('5.3.9(1)-release');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('AC-WINBASH-01: checkBashVersion degrades to unknown, never a crash, on odd output', () => {
|
|
119
|
+
expect(checkBashVersion(() => ({ stdout: '' }), 'C:\\bash.exe')).toBe('unknown');
|
|
120
|
+
expect(checkBashVersion(() => ({ stdout: 'MINGW64_NT-10.0\n' }), 'C:\\bash.exe')).toBe('unknown');
|
|
121
|
+
expect(checkBashVersion(() => { throw new Error('spawn failed'); }, 'C:\\bash.exe')).toBe('unknown');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-alignment-runner.cjs — Thin argv shim for test-alignment.ts
|
|
4
|
+
*
|
|
5
|
+
* Bridges the `xp-gate check-alignment` CLI to the TypeScript engine without
|
|
6
|
+
* shell-command interpolation of user-supplied paths (#392-#6).
|
|
7
|
+
*
|
|
8
|
+
* It is designed to be launched EITHER way:
|
|
9
|
+
* 1. In-repo (fast path): `node` loads this file after xp-gate.js already
|
|
10
|
+
* called `require('tsx/cjs')`, so requiring `./test-alignment.ts` works.
|
|
11
|
+
* 2. Published global install (zero-install path): `npx -y tsx` runs this
|
|
12
|
+
* file, and npx self-provisions tsx on demand (never a package dep), so
|
|
13
|
+
* `./test-alignment.ts` loads even though the package ships zero deps.
|
|
14
|
+
*
|
|
15
|
+
* All user-supplied paths are passed as discrete argv elements, never embedded
|
|
16
|
+
* into a shell command string, preserving the #392-#6 injection fix.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
// --- argv parsing ----------------------------------------------------------
|
|
23
|
+
function argValue(argv, flag) {
|
|
24
|
+
const idx = argv.indexOf(flag);
|
|
25
|
+
return idx >= 0 && idx + 1 < argv.length ? argv[idx + 1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
function argFlag(argv, flag) {
|
|
28
|
+
return argv.includes(flag);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const specPath = argValue(process.argv, '--spec');
|
|
32
|
+
const testDirsArg = argValue(process.argv, '--tests');
|
|
33
|
+
const outputPath = argValue(process.argv, '--output');
|
|
34
|
+
const jsonMode = argFlag(process.argv, '--json');
|
|
35
|
+
|
|
36
|
+
if (!specPath) {
|
|
37
|
+
console.error('check-alignment: --spec <path> is required');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const testDirs = testDirsArg ? [testDirsArg] : undefined;
|
|
42
|
+
|
|
43
|
+
let headCommit = 'unknown';
|
|
44
|
+
try {
|
|
45
|
+
headCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() || 'unknown';
|
|
46
|
+
} catch (err) {
|
|
47
|
+
headCommit = 'unknown';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { runFullAlignment } = require('./test-alignment.ts');
|
|
51
|
+
|
|
52
|
+
let report;
|
|
53
|
+
try {
|
|
54
|
+
report = runFullAlignment({
|
|
55
|
+
specPath,
|
|
56
|
+
testDirs,
|
|
57
|
+
headCommit,
|
|
58
|
+
outputPath: outputPath || undefined,
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('check-alignment failed:', err instanceof Error ? err.message : String(err));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (jsonMode) {
|
|
66
|
+
console.log(JSON.stringify(report, null, 2));
|
|
67
|
+
} else {
|
|
68
|
+
console.log('Status:', report.alignment_status);
|
|
69
|
+
console.log('Score:', report.score);
|
|
70
|
+
console.log('Misaligned:', report.misaligned_tests.length);
|
|
71
|
+
}
|
|
72
|
+
// Set exitCode instead of process.exit() so buffered stdout (notably the
|
|
73
|
+
// --json document) flushes fully before the process terminates (#392 review).
|
|
74
|
+
process.exitCode = report.alignment_status === 'PASS' ? 0 : 1;
|
package/lib/detect-deps.js
CHANGED
|
@@ -270,12 +270,143 @@ function getSkillsDirs(platform) {
|
|
|
270
270
|
return profile.skillsDirs;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Resolve a bash that can actually run the hooks.
|
|
275
|
+
*
|
|
276
|
+
* On Windows, `bash` on PATH is ambiguous: WSL installs C:\Windows\System32\
|
|
277
|
+
* bash.exe (a Linux interpreter whose filesystem view does not contain the
|
|
278
|
+
* Windows temp dirs the tooling passes to it), and its argv handling silently
|
|
279
|
+
* drops the positional arguments a `-c` script expects ($1 empty → scripts
|
|
280
|
+
* source nothing and "succeed" vacuously). Git for Windows ships the MSYS bash
|
|
281
|
+
* the hooks are written for. Probe candidates with an argv round-trip plus an
|
|
282
|
+
* uname check — only an MSYS/MINGW/CYGWIN bash counts on win32; a plain
|
|
283
|
+
* "bash --version" success proves nothing.
|
|
284
|
+
* @returns {string|null}
|
|
285
|
+
*/
|
|
286
|
+
function probeWindowsBash() {
|
|
287
|
+
const { spawnSync } = require('child_process');
|
|
288
|
+
// `where` runs through the same module so tests can fake discovery + probe.
|
|
289
|
+
const { execSync: localExecSync } = require('child_process');
|
|
290
|
+
return resolveWindowsBashCandidates(spawnSync, localExecSync);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Candidate list + probe loop split out for behavior tests: a fake spawner can
|
|
294
|
+
// simulate the WSL stub (echoes Linux / drops $1) vs an MSYS bash without any
|
|
295
|
+
// real interpreter on PATH (Delphi walkthrough R1, MN-06).
|
|
296
|
+
function resolveWindowsBashCandidates(spawnProbe, whereExecSync) {
|
|
297
|
+
const _execSync = whereExecSync || execSync;
|
|
298
|
+
const candidates = [];
|
|
299
|
+
// Well-known interpreters FIRST. Two reasons, both measured on win32:
|
|
300
|
+
// 1. A cold WSL launch (System32\bash.exe leads `where bash` when WSL is
|
|
301
|
+
// installed) blocks its spawnSync for ~3.4s. checkBash runs inside
|
|
302
|
+
// doctor's parallel fan-out, and that synchronous stall starves the
|
|
303
|
+
// event loop past EXEC_TIMEOUT_MS — node/git then falsely report
|
|
304
|
+
// "Not found" while spawning them standalone takes ~300ms.
|
|
305
|
+
// 2. The known Git paths ARE the answer we are looking for; probing them
|
|
306
|
+
// first also skips pointless launches of stubs we would reject anyway.
|
|
307
|
+
candidates.push(
|
|
308
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
309
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
310
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
311
|
+
'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe',
|
|
312
|
+
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
313
|
+
'C:\\cygwin64\\bin\\bash.exe'
|
|
314
|
+
);
|
|
315
|
+
try {
|
|
316
|
+
const found = _execSync('where bash', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
317
|
+
for (const line of found.split(/\r?\n/)) {
|
|
318
|
+
const p = line.trim();
|
|
319
|
+
// Skip the WSL launcher outright: uname would reject it, and a cold
|
|
320
|
+
// launch costs seconds (see above). WindowsApps\bash.exe is the same
|
|
321
|
+
// WSL stub family (it just errors differently when WSL is absent).
|
|
322
|
+
if (!p) continue;
|
|
323
|
+
// Match on normalized separators: forward slashes, mixed case, or a
|
|
324
|
+
// non-C drive would otherwise slip a WSL stub past the filter and back
|
|
325
|
+
// into the multi-second cold-launch stall.
|
|
326
|
+
const norm = p.replace(/\//g, '\\');
|
|
327
|
+
// The WSL launcher appears as System32/Sysnative/SysWOW64\bash.exe, the
|
|
328
|
+
// AppX alias <Windows>\SystemApps\Microsoft.WSL_*\bash.exe, and the
|
|
329
|
+
// per-user AppX execution alias ...\AppData\Local\Microsoft\WindowsApps\
|
|
330
|
+
// bash.exe. A user's own install merely NAMED ...\WindowsApps\... (not
|
|
331
|
+
// under Microsoft\) is NOT the stub and stays in the list.
|
|
332
|
+
if (/\\windowsapps\\bash\.exe$/i.test(norm)
|
|
333
|
+
&& (/\\systemapps\\/i.test(norm) || /\\appdata\\local\\microsoft\\windowsapps\\/i.test(norm))) continue;
|
|
334
|
+
// The WSL stub lives in the matching-arch System32 view: 64-bit
|
|
335
|
+
// C:\Windows\System32\bash.exe AND the 32-bit emulation redirect target
|
|
336
|
+
// C:\Windows\SysWOW64\bash.exe (where reports either depending on the
|
|
337
|
+
// bitness of the caller).
|
|
338
|
+
if (/\\windows\\system32\\bash\.exe$/i.test(norm) || /\\windows\\sysnative\\bash\.exe$/i.test(norm) || /\\windows\\syswow64\\bash\.exe$/i.test(norm)) continue;
|
|
339
|
+
if (!candidates.includes(p)) candidates.push(p);
|
|
340
|
+
}
|
|
341
|
+
} catch { /* where unavailable */ }
|
|
342
|
+
for (const candidate of candidates) {
|
|
343
|
+
try {
|
|
344
|
+
const r = spawnProbe(candidate, ['-c', 'echo "[$1]"; uname -s', 'sh', 'XPGATE_ARGV_PROBE'], {
|
|
345
|
+
encoding: 'utf8',
|
|
346
|
+
timeout: 5000,
|
|
347
|
+
windowsHide: true,
|
|
348
|
+
});
|
|
349
|
+
const out = String((r && r.stdout) || '');
|
|
350
|
+
if (out.includes('[XPGATE_ARGV_PROBE]') && /(MINGW|MSYS|CYGWIN)/i.test(out)) {
|
|
351
|
+
return candidate;
|
|
352
|
+
}
|
|
353
|
+
} catch { /* next candidate */ }
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
273
358
|
/**
|
|
274
359
|
* Check if bash is available on the system.
|
|
275
360
|
* XP-Gate hooks are bash scripts — Windows users need Git Bash installed.
|
|
276
|
-
* @returns {{ok: boolean, path?: string, message?: string}}
|
|
361
|
+
* @returns {{ok: boolean, path?: string, version?: string, message?: string}}
|
|
277
362
|
*/
|
|
363
|
+
// Split out for behavior tests: checkBashVersion takes the resolved bash path
|
|
364
|
+
// and a spawner so the version-field contract (a bash VERSION tuple, not the
|
|
365
|
+
// uname string) is assertable without a real interpreter on the host.
|
|
366
|
+
function checkBashVersion(spawnProbe, winBash) {
|
|
367
|
+
let version = 'unknown';
|
|
368
|
+
try {
|
|
369
|
+
// --version through the SAME probe-style argv as the candidate loop:
|
|
370
|
+
// Git Bash's login startup (`bash --version`) costs ~1.7s, and checkBash
|
|
371
|
+
// sits on hot paths (doctor fan-out, every gate-runner launch) where a
|
|
372
|
+
// synchronous stall starves concurrent execs past their timeouts. The
|
|
373
|
+
// -c form is non-login (~300ms). uname proves the MSYS family AND bash
|
|
374
|
+
// --version inside it still yields the real bash version number — the
|
|
375
|
+
// `version` field must stay a bash version, not the uname string. No
|
|
376
|
+
// sed in the command: the parse lives in JS below, so a stripped MSYS
|
|
377
|
+
// install without sed cannot silently degrade to "unknown".
|
|
378
|
+
// NOTE the cp. prefix: this module binds only execSync at file scope,
|
|
379
|
+
// and checkBash sits outside probeWindowsBash's local spawnSync binding
|
|
380
|
+
// — a bare spawnSync here is a ReferenceError swallowed by the catch,
|
|
381
|
+
// which used to pin the detail line at "unknown" forever.
|
|
382
|
+
const result = spawnProbe(winBash, ['-c', 'uname -s; bash --version', 'sh'], { encoding: 'utf8', timeout: 5000 });
|
|
383
|
+
const out = String((result && result.stdout) || '').trim();
|
|
384
|
+
// Keep the field in the same shape checkEnv prints for every other tool
|
|
385
|
+
// ("GNU bash, version X.Y.Z(...)"): doctor prefixes "GNU bash, version ",
|
|
386
|
+
// so hand back the bare version tuple, not the whole line.
|
|
387
|
+
const versionLine = out.split('\n').find(line => /version/.test(line));
|
|
388
|
+
const match = versionLine && versionLine.match(/version\s+(\S+)/);
|
|
389
|
+
if (match) version = match[1];
|
|
390
|
+
} catch { /* keep unknown */ }
|
|
391
|
+
return version;
|
|
392
|
+
}
|
|
393
|
+
|
|
278
394
|
function checkBash() {
|
|
395
|
+
// On Windows resolve explicitly: PATH may lead with the WSL System32 stub,
|
|
396
|
+
// which cannot execute these hooks (wrong filesystem view, dropped $1).
|
|
397
|
+
if (process.platform === 'win32') {
|
|
398
|
+
const winBash = probeWindowsBash();
|
|
399
|
+
if (winBash) {
|
|
400
|
+
const version = checkBashVersion((...args) => require('child_process').spawnSync(...args), winBash);
|
|
401
|
+
return { ok: true, path: winBash, version };
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
message: 'bash not found. Windows users must install [Git for Windows](https://git-scm.com/download/win).\n' +
|
|
406
|
+
' During installation, ensure "Git Bash Here" is checked — this adds bash.exe to PATH.\n' +
|
|
407
|
+
' After installation, restart your terminal and run `npm install` again.'
|
|
408
|
+
};
|
|
409
|
+
}
|
|
279
410
|
try {
|
|
280
411
|
// Try 'bash' first (works on Linux/macOS and when Git Bash is in PATH)
|
|
281
412
|
const result = execSync('bash --version', {
|
|
@@ -289,17 +420,11 @@ function checkBash() {
|
|
|
289
420
|
version: versionMatch ? versionMatch[1] : 'unknown'
|
|
290
421
|
};
|
|
291
422
|
} catch (e) {
|
|
292
|
-
//
|
|
423
|
+
// POSIX fallback: common absolute locations
|
|
293
424
|
const bashPaths = [
|
|
294
|
-
|
|
295
|
-
'
|
|
296
|
-
'
|
|
297
|
-
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
298
|
-
'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe',
|
|
299
|
-
// MSYS2
|
|
300
|
-
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
301
|
-
// Cygwin
|
|
302
|
-
'C:\\cygwin64\\bin\\bash.exe',
|
|
425
|
+
'/bin/bash',
|
|
426
|
+
'/usr/bin/bash',
|
|
427
|
+
'/usr/local/bin/bash',
|
|
303
428
|
];
|
|
304
429
|
|
|
305
430
|
for (const bashPath of bashPaths) {
|
|
@@ -319,15 +444,7 @@ function checkBash() {
|
|
|
319
444
|
}
|
|
320
445
|
}
|
|
321
446
|
|
|
322
|
-
// Windows
|
|
323
|
-
if (process.platform === 'win32') {
|
|
324
|
-
return {
|
|
325
|
-
ok: false,
|
|
326
|
-
message: 'bash not found. Windows users must install [Git for Windows](https://git-scm.com/download/win).\n' +
|
|
327
|
-
' During installation, ensure "Git Bash Here" is checked — this adds bash.exe to PATH.\n' +
|
|
328
|
-
' After installation, restart your terminal and run `npm install` again.'
|
|
329
|
-
};
|
|
330
|
-
}
|
|
447
|
+
// Windows is handled above; this fallback only runs on POSIX now.
|
|
331
448
|
|
|
332
449
|
return {
|
|
333
450
|
ok: false,
|
|
@@ -505,6 +622,8 @@ function compareVersions(a, b) {
|
|
|
505
622
|
module.exports = {
|
|
506
623
|
checkDeps,
|
|
507
624
|
checkBash,
|
|
625
|
+
checkBashVersion,
|
|
626
|
+
resolveWindowsBashCandidates,
|
|
508
627
|
autoInstallDeps,
|
|
509
628
|
detectPlatform,
|
|
510
629
|
getSkillsDirs,
|
package/lib/doctor.js
CHANGED
|
@@ -104,18 +104,52 @@ function noteSandboxAttribution(err) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
async function execWithTimeout(cmd, opts = {}) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
// Win32 false-negative guard. Under cmd.exe (exec's shell), a spawned probe
|
|
108
|
+
// can exit 0 while its stdout never reaches us — measured on this machine:
|
|
109
|
+
// `node --version` returns exit 0 with an EMPTY stream when many spawns are
|
|
110
|
+
// in flight at once (doctor's parallel fan-out), while the same command
|
|
111
|
+
// standalone prints fine in ~300ms. The old code read that empty stream as
|
|
112
|
+
// "Not found" and red-flagged Node/Git/hooksPath. A version/config probe is
|
|
113
|
+
// idempotent, so quiet retries distinguish the lost stream from a tool that
|
|
114
|
+
// genuinely fails. Contract: every caller here runs an IDEMPOTENT probe
|
|
115
|
+
// (`--version`, `config --get`, `rev-parse`) — a future side-effecting call
|
|
116
|
+
// site must pass { attempts: 1 } to opt out of the retry below. The option
|
|
117
|
+
// is consumed here and never forwarded to exec.
|
|
118
|
+
// Scope note: only the exit-0-with-empty-stream shape is retried. A probe
|
|
119
|
+
// that exits non-zero is a REAL failure verdict (cmd.exe reports a missing
|
|
120
|
+
// tool as exit 1 + "'x' is not recognized"), and re-running it would only
|
|
121
|
+
// burn time, so that path still returns null immediately.
|
|
122
|
+
const { attempts, ...execOpts } = opts;
|
|
123
|
+
const maxAttempts = process.platform === 'win32' ? (attempts || 3) : 1;
|
|
124
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
127
|
+
...execOpts,
|
|
128
|
+
timeout: execOpts.timeout || EXEC_TIMEOUT_MS,
|
|
129
|
+
});
|
|
130
|
+
if (!stdout.trim() && !stderr.trim()) {
|
|
131
|
+
// Exit-0-but-empty: nothing usable came back. Retry while attempts
|
|
132
|
+
// remain; report null once they are exhausted, so every caller's
|
|
133
|
+
// existing null check ("probe produced nothing") applies — an empty
|
|
134
|
+
// non-null result would read as a present tool with no version.
|
|
135
|
+
if (attempt + 1 < maxAttempts) {
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// #525: a bare null here reads as "tool missing" and has already cost a
|
|
144
|
+
// misdiagnosis round. Name the sandbox once when it is the sandbox.
|
|
145
|
+
noteSandboxAttribution(err);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
118
148
|
}
|
|
149
|
+
// Exhausted all attempts with exit-0-but-empty streams: report null so every
|
|
150
|
+
// caller's existing null check ("probe produced nothing usable") applies —
|
|
151
|
+
// an empty non-null result would read as a present tool with no version.
|
|
152
|
+
return null;
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
/**
|
package/lib/gate-runner.js
CHANGED
|
@@ -18,15 +18,22 @@ const path = require('node:path');
|
|
|
18
18
|
* behaves exactly as it does inside the commit hook.
|
|
19
19
|
*/
|
|
20
20
|
function runGateAdapter(scriptPath, cwd = process.cwd()) {
|
|
21
|
+
let bashCommand = 'bash';
|
|
21
22
|
if (process.platform === 'win32') {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// PATH may lead with the WSL System32 stub, which cannot run these hooks
|
|
24
|
+
// (Linux filesystem view, and `-c` scripts receive an empty $1 through it).
|
|
25
|
+
// checkBash probes for an MSYS/MINGW bash that round-trips argv correctly.
|
|
26
|
+
const { checkBash } = require('./detect-deps.js');
|
|
27
|
+
const bash = checkBash();
|
|
28
|
+
if (!bash.ok) {
|
|
25
29
|
console.log('⚠️ This gate requires bash to run shell scripts.');
|
|
26
|
-
|
|
30
|
+
// No WSL suggestion: these hooks need Git Bash specifically — the WSL
|
|
31
|
+
// bash is rejected by the probe above (Linux filesystem view, dropped $1).
|
|
32
|
+
console.log(' On Windows, install Git for Windows (https://git-scm.com/download/win).');
|
|
27
33
|
console.log(' Alternatively, install the required tool directly and run the gate again.');
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
36
|
+
bashCommand = bash.path;
|
|
30
37
|
}
|
|
31
38
|
const scriptDir = path.dirname(scriptPath);
|
|
32
39
|
const commonScript = [
|
|
@@ -62,7 +69,7 @@ function runGateAdapter(scriptPath, cwd = process.cwd()) {
|
|
|
62
69
|
// execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
|
|
63
70
|
// single argv arg (no shell-metacharacter injection through the path).
|
|
64
71
|
execFileSync(
|
|
65
|
-
|
|
72
|
+
bashCommand,
|
|
66
73
|
['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath],
|
|
67
74
|
{ stdio: 'inherit', cwd }
|
|
68
75
|
);
|
package/lib/sprint-status.js
CHANGED
|
@@ -345,8 +345,11 @@ async function handleSprintStatus(args = []) {
|
|
|
345
345
|
|
|
346
346
|
if (dirIdx >= 0 && dirIdx + 1 < args.length) {
|
|
347
347
|
searchDir = path.resolve(args[dirIdx + 1]);
|
|
348
|
-
// Path traversal protection: must be under cwd
|
|
349
|
-
|
|
348
|
+
// Path traversal protection: must be under cwd. Use path.relative instead
|
|
349
|
+
// of a prefix match so a sibling like `<cwd>2` cannot bypass the check
|
|
350
|
+
// (#392-#5).
|
|
351
|
+
const rel = path.relative(process.cwd(), searchDir);
|
|
352
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
350
353
|
console.error('Error: --dir path must be under current working directory');
|
|
351
354
|
return 1;
|
|
352
355
|
}
|