@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
package/bin/xp-gate.js
CHANGED
|
@@ -19,15 +19,127 @@ const { bootstrap } = require('../lib/bootstrap.js');
|
|
|
19
19
|
const { updateHooks } = require('../lib/update-hooks.js');
|
|
20
20
|
const gateRunner = require('../lib/gate-runner.js');
|
|
21
21
|
|
|
22
|
+
// #392-#6: a user-supplied path embedded in a shell command string lets cmd.exe
|
|
23
|
+
// interpret metacharacters (& | ^ < > % ! " and newline) and execute injected
|
|
24
|
+
// commands. On POSIX a bare spawn (shell:false) passes argv as discrete,
|
|
25
|
+
// uninterpreted bytes, so nothing is injectable. On win32 a `.cmd` wrapper such
|
|
26
|
+
// as npx.cmd cannot be launched without a shell (CreateProcess raises EINVAL),
|
|
27
|
+
// so we must shell out through cmd.exe -- and we only do so AFTER rejecting any
|
|
28
|
+
// argument that carries a cmd metacharacter. Rejection, not escaping, is the
|
|
29
|
+
// safe defense: a path containing a metachar is refused outright rather than
|
|
30
|
+
// risk a quoting mistake. This guard must stay in front of every launcher.
|
|
31
|
+
// \r joins \n: cmd treats a stray carriage return as a command separator too
|
|
32
|
+
// (Delphi walkthrough R1, MN-01).
|
|
33
|
+
const CMD_METACHAR_RE = /[&|^<>%!"\n\r]/;
|
|
34
|
+
|
|
35
|
+
// The ONLY place a child process may be launched with an engaged shell.
|
|
36
|
+
// Named so a static grep stays readable, and pinned by check-alignment-cli
|
|
37
|
+
// .test.mjs: no other launcher in this file may enable a shell.
|
|
38
|
+
const SHELL_ON_WINDOWS_ONLY = process.platform === 'win32';
|
|
39
|
+
|
|
40
|
+
function firstUnsafeArg(argv) {
|
|
41
|
+
for (const arg of argv) {
|
|
42
|
+
if (typeof arg === 'string' && CMD_METACHAR_RE.test(arg)) return arg;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function quoteForCmdShell(arg) {
|
|
48
|
+
// With an engaged shell node concatenates file+args into one cmd.exe command
|
|
49
|
+
// string WITHOUT quoting (DEP0190), so an argument carrying a space would
|
|
50
|
+
// split into multiple argv elements downstream. Double-quoting is safe here
|
|
51
|
+
// ONLY because every caller has already run firstUnsafeArg: the character "
|
|
52
|
+
// that could break out of the quote is on the rejection list.
|
|
53
|
+
return `"${arg}"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function spawnNpx(tsxArgs) {
|
|
57
|
+
const { spawnSync } = require('child_process');
|
|
58
|
+
// Reject any user-supplied argument that could be interpreted by cmd.exe.
|
|
59
|
+
// Every element of tsxArgs beyond the fixed tokens (-y tsx <script>) is a
|
|
60
|
+
// user path; a metachar there is refused, not quoted, per #392-#6.
|
|
61
|
+
const unsafe = firstUnsafeArg(tsxArgs);
|
|
62
|
+
if (unsafe) {
|
|
63
|
+
console.error(`check-alignment: refusing path containing a shell metacharacter: ${unsafe}`);
|
|
64
|
+
return { status: 1 };
|
|
65
|
+
}
|
|
66
|
+
const isWin = process.platform === 'win32';
|
|
67
|
+
// The shell engages on win32 only, and is only safe because every user
|
|
68
|
+
// argument above is guaranteed metachar-free; each token additionally gets
|
|
69
|
+
// cmd quoting so spaces survive (see quoteForCmdShell).
|
|
70
|
+
// Guarded IO: a failed spawn must surface as a result, not crash the CLI.
|
|
71
|
+
const argv = ['-y', 'tsx', ...tsxArgs];
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = spawnSync(isWin ? 'npx.cmd' : 'npx', isWin ? argv.map(quoteForCmdShell) : argv, {
|
|
75
|
+
stdio: 'inherit',
|
|
76
|
+
shell: SHELL_ON_WINDOWS_ONLY,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { status: 1, error: err };
|
|
80
|
+
}
|
|
81
|
+
return result || { status: 1 };
|
|
82
|
+
}
|
|
83
|
+
|
|
22
84
|
function handleUIReview() {
|
|
23
|
-
const { execSync } = require('child_process');
|
|
24
85
|
const uiReviewPath = path.join(__dirname, '..', 'lib', 'ui-review.ts');
|
|
86
|
+
const result = spawnNpx([uiReviewPath]);
|
|
87
|
+
process.exitCode = result.error ? 1 : (result.status === 0 ? 0 : 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runAlignmentInProcess(specPath, testDirs, outputPath, jsonMode, runFullAlignment) {
|
|
91
|
+
const { execFileSync } = require('child_process');
|
|
92
|
+
if (!runFullAlignment) {
|
|
93
|
+
// Loaded lazily by the caller inside the tsx guard; kept optional so an
|
|
94
|
+
// in-repo direct call still works.
|
|
95
|
+
({ runFullAlignment } = require('../lib/test-alignment.ts'));
|
|
96
|
+
}
|
|
97
|
+
let headCommit = 'unknown';
|
|
25
98
|
try {
|
|
26
|
-
|
|
27
|
-
process.exit(0);
|
|
99
|
+
headCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() || 'unknown';
|
|
28
100
|
} catch (err) {
|
|
29
|
-
|
|
101
|
+
headCommit = 'unknown';
|
|
30
102
|
}
|
|
103
|
+
let report;
|
|
104
|
+
try {
|
|
105
|
+
report = runFullAlignment({
|
|
106
|
+
specPath,
|
|
107
|
+
testDirs: testDirs || undefined,
|
|
108
|
+
headCommit,
|
|
109
|
+
outputPath: outputPath || undefined,
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error('check-alignment failed:', err instanceof Error ? err.message : String(err));
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
if (jsonMode) {
|
|
116
|
+
console.log(JSON.stringify(report, null, 2));
|
|
117
|
+
} else {
|
|
118
|
+
console.log('Status:', report.alignment_status);
|
|
119
|
+
console.log('Score:', report.score);
|
|
120
|
+
console.log('Misaligned:', report.misaligned_tests.length);
|
|
121
|
+
}
|
|
122
|
+
// exitCode-style contract (#392 review): never process.exit() right after a
|
|
123
|
+
// JSON print — buffered stdout must flush. Returning the code and letting the
|
|
124
|
+
// dispatcher end the turn naturally is what keeps --json output complete.
|
|
125
|
+
return report.alignment_status === 'PASS' ? 0 : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function runAlignmentViaNpx(runnerPath, specPath, testDirs, outputPath, jsonMode) {
|
|
129
|
+
// User-supplied paths travel as discrete argv elements routed through
|
|
130
|
+
// spawnNpx, which refuses any argument carrying a cmd metacharacter before a
|
|
131
|
+
// shell can see it (#392-#6). The runner script path is a fixed internal path.
|
|
132
|
+
const argv = [runnerPath, '--spec', specPath];
|
|
133
|
+
if (testDirs) argv.push('--tests', testDirs[0]);
|
|
134
|
+
if (outputPath) argv.push('--output', outputPath);
|
|
135
|
+
if (jsonMode) argv.push('--json');
|
|
136
|
+
const result = spawnNpx(argv);
|
|
137
|
+
if (result.error) {
|
|
138
|
+
console.error('check-alignment: unable to load the alignment engine (tsx unavailable).');
|
|
139
|
+
console.error(result.error.message);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
return result.status === 0 ? 0 : 1;
|
|
31
143
|
}
|
|
32
144
|
|
|
33
145
|
async function runAlignmentCheck(subargs) {
|
|
@@ -42,32 +154,43 @@ async function runAlignmentCheck(subargs) {
|
|
|
42
154
|
|
|
43
155
|
const jsonMode = subargs.includes('--json');
|
|
44
156
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
157
|
+
// Injection guard (#392-#6) runs BEFORE any launcher, including the
|
|
158
|
+
// in-process fast path: a metachar path must be refused identically on both
|
|
159
|
+
// platforms (Delphi walkthrough R1, MJ-02 — behavioral, not shape-only).
|
|
160
|
+
for (const value of [specPath, ...(testDirs || []), outputPath].filter(v => v !== undefined)) {
|
|
161
|
+
const unsafe = firstUnsafeArg([value]);
|
|
162
|
+
if (unsafe) {
|
|
163
|
+
console.error(`check-alignment: refusing path containing a shell metacharacter: ${unsafe}`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const runnerPath = path.join(__dirname, '..', 'lib', 'check-alignment-runner.cjs');
|
|
48
169
|
|
|
170
|
+
// Primary: run the engine in-process via tsx/cjs (fast path, works in-repo
|
|
171
|
+
// where tsx is a devDependency). Fall back to `npx -y tsx` when tsx is not
|
|
172
|
+
// resolvable, which happens in the published global install (the package
|
|
173
|
+
// ships zero runtime deps per the zero-install contract). npx self-provisions
|
|
174
|
+
// tsx on demand, so the published command stays functional without adding a dep.
|
|
175
|
+
//
|
|
176
|
+
// The try guards ONLY the tsx loader's module-load step. The engine call sits
|
|
177
|
+
// outside it: an engine-level failure is a real bug that must surface, not be
|
|
178
|
+
// silently swallowed and misattributed to "tsx unavailable" by the fallback.
|
|
179
|
+
// Delphi walkthrough R2 CI-02: require('../lib/test-alignment.ts') also needs
|
|
180
|
+
// the loader, so it belongs INSIDE the guard — if the .ts file itself cannot
|
|
181
|
+
// load, the npx fallback re-attempts it under a provisioned tsx instead of
|
|
182
|
+
// crashing with an unhandled exception. Engine EXECUTION stays outside.
|
|
183
|
+
let runFullAlignment;
|
|
49
184
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const report = runFullAlignment({
|
|
53
|
-
specPath: '${specPath}',
|
|
54
|
-
testDirs: ${JSON.stringify(testDirs || null)},
|
|
55
|
-
headCommit: require('child_process').execSync('git rev-parse HEAD 2>/dev/null || echo unknown').toString().trim(),
|
|
56
|
-
outputPath: ${outputPath ? `'${outputPath}'` : 'undefined'},
|
|
57
|
-
});
|
|
58
|
-
if (${jsonMode}) {
|
|
59
|
-
console.log(JSON.stringify(report, null, 2));
|
|
60
|
-
} else {
|
|
61
|
-
console.log('Status:', report.alignment_status);
|
|
62
|
-
console.log('Score:', report.score);
|
|
63
|
-
console.log('Misaligned:', report.misaligned_tests.length);
|
|
64
|
-
}
|
|
65
|
-
process.exit(report.alignment_status === 'PASS' ? 0 : 1);
|
|
66
|
-
"`, { stdio: 'inherit' });
|
|
185
|
+
require('tsx/cjs');
|
|
186
|
+
({ runFullAlignment } = require('../lib/test-alignment.ts'));
|
|
67
187
|
} catch (err) {
|
|
68
|
-
|
|
188
|
+
if (!err || (err.code !== 'MODULE_NOT_FOUND' && !/Cannot find module/.test(String(err && err.message)))) {
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
return runAlignmentViaNpx(runnerPath, specPath, testDirs, outputPath, jsonMode);
|
|
69
192
|
}
|
|
70
|
-
return
|
|
193
|
+
return runAlignmentInProcess(specPath, testDirs, outputPath, jsonMode, runFullAlignment);
|
|
71
194
|
}
|
|
72
195
|
|
|
73
196
|
const COMMANDS = {
|
|
@@ -154,7 +277,7 @@ const COMMANDS = {
|
|
|
154
277
|
}
|
|
155
278
|
return doctor(subargs).then(code => process.exit(code));
|
|
156
279
|
},
|
|
157
|
-
usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force]] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks)'
|
|
280
|
+
usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force] | --refresh-env] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks; --refresh-env is read-only so it combines with them)'
|
|
158
281
|
},
|
|
159
282
|
'ui-review': {
|
|
160
283
|
description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
|
|
@@ -188,7 +311,17 @@ const COMMANDS = {
|
|
|
188
311
|
},
|
|
189
312
|
'check-alignment': {
|
|
190
313
|
description: 'Run test-specification alignment check (deterministic, no LLM)',
|
|
191
|
-
run: subargs => runAlignmentCheck(subargs).then(code =>
|
|
314
|
+
run: subargs => runAlignmentCheck(subargs).then(code => {
|
|
315
|
+
// exitCode, not process.exit(): the in-process --json document must flush
|
|
316
|
+
// its buffered stdout before the process ends (#392 review, MJ-01).
|
|
317
|
+
process.exitCode = code;
|
|
318
|
+
}).catch(err => {
|
|
319
|
+
// A quality gate must fail with a deterministic exit code and a real
|
|
320
|
+
// message — never an unhandled rejection whose code depends on Node's
|
|
321
|
+
// default mode (Delphi walkthrough R2, MJ-02).
|
|
322
|
+
console.error('check-alignment: unexpected failure:', err instanceof Error ? err.message : String(err));
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
}),
|
|
192
325
|
usage: 'xp-gate check-alignment [--spec <path>] [--tests <dir>] [--json] [--output <path>]'
|
|
193
326
|
},
|
|
194
327
|
'upgrade': {
|
package/hooks/pre-commit
CHANGED
|
@@ -179,11 +179,15 @@ run_in_subdir() {
|
|
|
179
179
|
|
|
180
180
|
if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ]; then
|
|
181
181
|
# Run command in subdirectory, then return to original directory
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
# #392-#10: guard cd/pushd so a missing directory cannot run the gate in
|
|
183
|
+
# the wrong cwd.
|
|
184
|
+
if pushd "$PROJECT_SUBDIR" > /dev/null 2>&1; then
|
|
185
|
+
eval "$cmd"
|
|
186
|
+
local exit_code=$?
|
|
187
|
+
popd > /dev/null 2>&1
|
|
188
|
+
return $exit_code
|
|
189
|
+
fi
|
|
190
|
+
return 1
|
|
187
191
|
else
|
|
188
192
|
# Run in current directory
|
|
189
193
|
eval "$cmd"
|
|
@@ -984,10 +988,14 @@ IN_PROJECT_SUBDIR=0
|
|
|
984
988
|
_lang_count=$(echo "$PROJECT_LANGS" | wc -w)
|
|
985
989
|
if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
|
|
986
990
|
ORIGINAL_DIR=$(pwd)
|
|
987
|
-
cd
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
+
# #392-#10: guard cd so a missing subdir cannot run gates in the wrong cwd.
|
|
992
|
+
if cd "$PROJECT_SUBDIR" 2>/dev/null; then
|
|
993
|
+
IN_PROJECT_SUBDIR=1
|
|
994
|
+
echo "Working in: $PROJECT_SUBDIR/"
|
|
995
|
+
echo ""
|
|
996
|
+
else
|
|
997
|
+
echo "WARN: could not enter $PROJECT_SUBDIR — continuing at repo root" >&2
|
|
998
|
+
fi
|
|
991
999
|
fi
|
|
992
1000
|
|
|
993
1001
|
# ============================================================================
|
|
@@ -1639,7 +1647,16 @@ else
|
|
|
1639
1647
|
echo "Running jscpd for Go duplicate code detection..."
|
|
1640
1648
|
FILES_TO_CHECK=$(echo "$CHANGED_FILES" | grep "\.go$" | head -20)
|
|
1641
1649
|
if [ -n "$FILES_TO_CHECK" ]; then
|
|
1642
|
-
jscpd
|
|
1650
|
+
# #392-#9: capture jscpd's real exit code via temp file instead of piping
|
|
1651
|
+
# through head (which would swallow the exit status under pipefail analysis).
|
|
1652
|
+
GO_JSCPD_TMP=$(mktemp)
|
|
1653
|
+
jscpd $FILES_TO_CHECK > "$GO_JSCPD_TMP" 2>&1
|
|
1654
|
+
JSCPD_GO_EXIT=$?
|
|
1655
|
+
head -10 "$GO_JSCPD_TMP"
|
|
1656
|
+
rm -f "$GO_JSCPD_TMP"
|
|
1657
|
+
if [ "$JSCPD_GO_EXIT" -ne 0 ]; then
|
|
1658
|
+
echo "⚠️ jscpd (Go) exited with code $JSCPD_GO_EXIT"
|
|
1659
|
+
fi
|
|
1643
1660
|
fi
|
|
1644
1661
|
echo "✅ PASSED - Go duplicate code check completed."
|
|
1645
1662
|
;;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test check-alignment CLI
|
|
3
|
+
* @intent Guard the #392-#6 injection fix and the zero-install fallback path:
|
|
4
|
+
* (1) no spawn/exec call in bin/xp-gate.js may enable a shell (which on
|
|
5
|
+
* win32 would let cmd.exe interpret metacharacters embedded in
|
|
6
|
+
* user-supplied paths); (2) the published runner emits a complete,
|
|
7
|
+
* parseable --json document instead of truncating it on process.exit().
|
|
8
|
+
* @covers AC-392-06 (injection), zero-install contract (fallback)
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import { join, dirname } from 'path';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const BIN = join(here, '..', '..', 'bin', 'xp-gate.js');
|
|
18
|
+
const RUNNER = join(here, '..', 'check-alignment-runner.cjs');
|
|
19
|
+
|
|
20
|
+
describe('check-alignment CLI injection surface (#392-#6)', () => {
|
|
21
|
+
it('bin/xp-gate.js rejects shell metacharacters before any launch', () => {
|
|
22
|
+
const src = readFileSync(BIN, 'utf8');
|
|
23
|
+
// The injection guard must be in front of every subprocess launch: any
|
|
24
|
+
// argument carrying a cmd.exe metacharacter is refused outright, never
|
|
25
|
+
// escaped, before a shell can see it (#392-#6, Delphi R1-C). This holds on
|
|
26
|
+
// both platforms -- on win32 a .cmd wrapper needs cmd.exe (EINVAL without
|
|
27
|
+
// one), so the metachar guard is the only thing that makes shelling safe.
|
|
28
|
+
expect(src).toMatch(/CMD_METACHAR_RE/);
|
|
29
|
+
expect(src).toMatch(/firstUnsafeArg/);
|
|
30
|
+
// The metachar class must cover the cmd.exe separators/redirection.
|
|
31
|
+
expect(src).toMatch(/CMD_METACHAR_RE\s*=.*[&|^<>%!]/);
|
|
32
|
+
// Any shell a launcher may engage must be gated on the metachar rejection
|
|
33
|
+
// having run first, and never enabled unconditionally on win32. The single
|
|
34
|
+
// sanctioned engagement is the named SHELL_ON_WINDOWS_ONLY constant.
|
|
35
|
+
expect(src).not.toMatch(/shell\s*:\s*(?:true|process\.platform)/);
|
|
36
|
+
expect(src).toMatch(/shell:\s*SHELL_ON_WINDOWS_ONLY/);
|
|
37
|
+
// execSync interpolates argv into a command string via a shell, so it is
|
|
38
|
+
// banned; execFileSync is fine only for hardcoded binaries with non-user
|
|
39
|
+
// argv (git rev-parse HEAD). The launchers below are the user-input path.
|
|
40
|
+
expect(src).not.toMatch(/\bexecSync\s*\(/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('a metacharacter path is refused end to end before any launch (MJ-02)', () => {
|
|
44
|
+
// Behavioral, not shape: invoke the real CLI with a --spec whose path
|
|
45
|
+
// carries a cmd metacharacter and assert the documented refusal — exit 1
|
|
46
|
+
// plus the "refusing" message, never a spawned engine.
|
|
47
|
+
const evil = join(here, 'evil&echo.spec.yaml');
|
|
48
|
+
const result = spawnSync(process.execPath, [BIN, 'check-alignment', '--spec', evil, '--json'], {
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
timeout: 60000,
|
|
51
|
+
});
|
|
52
|
+
expect(result.status).toBe(1);
|
|
53
|
+
expect(`${result.stderr}${result.stdout}`).toMatch(/metacharacter|refus/i);
|
|
54
|
+
}, 90000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('check-alignment runner output integrity', () => {
|
|
58
|
+
it('emits a complete parseable JSON document with --json', () => {
|
|
59
|
+
const spec = join(here, 'test-alignment.test.ts');
|
|
60
|
+
const result = spawnSync(process.execPath, [RUNNER, '--spec', spec, '--json'], {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
});
|
|
63
|
+
expect(result.error).toBeUndefined();
|
|
64
|
+
// The whole stdout stream must be one valid JSON document. A process.exit()
|
|
65
|
+
// immediately after console.log would truncate it on some platforms.
|
|
66
|
+
const parsed = JSON.parse(result.stdout);
|
|
67
|
+
expect(parsed).toHaveProperty('alignment_status');
|
|
68
|
+
expect(parsed).toHaveProperty('score');
|
|
69
|
+
expect(parsed).toHaveProperty('head_commit');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fails fast with exit code 1 when --spec is missing', () => {
|
|
73
|
+
const result = spawnSync(process.execPath, [RUNNER], { encoding: 'utf8' });
|
|
74
|
+
expect(result.status).toBe(1);
|
|
75
|
+
expect(result.stderr).toContain('--spec <path> is required');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -17,11 +17,16 @@ const describeWin = isWindows ? describe : describe.skip;
|
|
|
17
17
|
// Walkthrough FC-02: a bare `return` inside it() silently reports PASS on a
|
|
18
18
|
// runner without bash — a false-green in the very suite that guards against
|
|
19
19
|
// false greens. Bash-gated tests must SKIP loudly instead.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// On Windows a bare PATH 'bash' may resolve to the WSL System32 stub, whose
|
|
21
|
+
// argv handling drops `-c` positional args — probing through it answers for
|
|
22
|
+
// the wrong environment (the exact #469 failure class). Resolve the same
|
|
23
|
+
// authoritative bash the hooks get: checkBash().
|
|
24
|
+
const { checkBash } = require('../detect-deps.js');
|
|
25
|
+
const BASH_PROBE = (() => {
|
|
26
|
+
const b = checkBash();
|
|
27
|
+
return b.ok ? b.path : null;
|
|
23
28
|
})();
|
|
24
|
-
const itWithBash =
|
|
29
|
+
const itWithBash = BASH_PROBE ? it : it.skip;
|
|
25
30
|
|
|
26
31
|
describe('doctor hook-env probing (#469)', () => {
|
|
27
32
|
function mod() {
|
|
@@ -30,7 +35,7 @@ describe('doctor hook-env probing (#469)', () => {
|
|
|
30
35
|
|
|
31
36
|
itWithBash('AC-469-01: a tool the hook env sees is reported with its hook-env path', async () => {
|
|
32
37
|
const { probeToolInHookEnv } = mod();
|
|
33
|
-
const bashPath = isWindows ?
|
|
38
|
+
const bashPath = isWindows ? BASH_PROBE : '/bin/sh';
|
|
34
39
|
const p = await probeToolInHookEnv(isWindows ? 'node' : 'sh', bashPath);
|
|
35
40
|
expect(typeof p).toBe('string');
|
|
36
41
|
expect(p.length).toBeGreaterThan(0);
|
|
@@ -39,7 +44,7 @@ describe('doctor hook-env probing (#469)', () => {
|
|
|
39
44
|
itWithBash('AC-469-01: a tool absent from the hook env returns null — no fall-through', async () => {
|
|
40
45
|
const { detectCliToolAsync } = mod();
|
|
41
46
|
const result = await detectCliToolAsync('definitely-not-a-real-tool-xyz-469', isWindows, {
|
|
42
|
-
bashPath:
|
|
47
|
+
bashPath: BASH_PROBE,
|
|
43
48
|
});
|
|
44
49
|
// With bash present there is NO fall-through: `where` finding nothing is
|
|
45
50
|
// irrelevant here, the hook env verdict is final.
|
|
@@ -56,7 +61,7 @@ describe('doctor hook-env probing (#469)', () => {
|
|
|
56
61
|
|
|
57
62
|
itWithBash('AC-469-01: an installed tool is visible through the hook-env tier', async () => {
|
|
58
63
|
const { detectCliToolAsync } = mod();
|
|
59
|
-
const result = await detectCliToolAsync('node', true, { bashPath:
|
|
64
|
+
const result = await detectCliToolAsync('node', true, { bashPath: BASH_PROBE });
|
|
60
65
|
expect(result.available).toBe(true);
|
|
61
66
|
// The hook-env path is POSIX form on Windows — cmd.exe cannot run it.
|
|
62
67
|
if (isWindows) expect(result.path.startsWith('/')).toBe(true);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DOCTOR retry-on-empty-stream guard
|
|
3
|
+
* @intent On win32, cmd.exe (exec's shell) can deliver exit 0 with an EMPTY
|
|
4
|
+
* stdout+stderr when many spawns are in flight — doctor read that as
|
|
5
|
+
* "Not found" and red-flagged Node/Git/hooksPath on a healthy machine.
|
|
6
|
+
* execWithTimeout must retry an empty-but-successful probe before
|
|
7
|
+
* reporting it, and must NOT retry real failures or real output.
|
|
8
|
+
* @covers AC-DOCTOR-RETRY-01
|
|
9
|
+
*/
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { describe, it, expect, afterEach } = await import('vitest');
|
|
12
|
+
|
|
13
|
+
const cpPath = require.resolve('child_process');
|
|
14
|
+
|
|
15
|
+
function stubExec(behaviors) {
|
|
16
|
+
// doctor builds its async exec at load time via util.promisify(exec), which
|
|
17
|
+
// binds Node's INTERNAL custom promisify implementation — replacing the plain
|
|
18
|
+
// `exec` property afterwards is invisible to it (measured: stub fired 0-1
|
|
19
|
+
// times while the real cmd.exe child ran). The only patch point that holds
|
|
20
|
+
// across a fresh require is util.promisify itself.
|
|
21
|
+
const util = require('util');
|
|
22
|
+
const originalPromisify = util.promisify;
|
|
23
|
+
const calls = [];
|
|
24
|
+
util.promisify = function (fn) {
|
|
25
|
+
if (fn && fn.name === 'exec') {
|
|
26
|
+
stubWasConsumed = true;
|
|
27
|
+
return (cmd, opts = {}) => {
|
|
28
|
+
const attempt = calls.length;
|
|
29
|
+
calls.push(cmd);
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
behaviors[attempt]((err, stdout, stderr) => {
|
|
32
|
+
if (err) reject(err);
|
|
33
|
+
else resolve({ stdout, stderr });
|
|
34
|
+
}, attempt);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return originalPromisify.call(util, fn);
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
restore: () => { util.promisify = originalPromisify; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Node couples exec to promisify via a custom symbol; if a future runtime drops
|
|
47
|
+
// that coupling the name-based stub above silently stops intercepting and the
|
|
48
|
+
// real cmd.exe child runs. Each test flips this to true from inside its own
|
|
49
|
+
// stubbed load (never inherited from an earlier test), so afterEach can fail
|
|
50
|
+
// loudly instead of passing vacuously.
|
|
51
|
+
let stubWasConsumed = false;
|
|
52
|
+
|
|
53
|
+
describe('execWithTimeout empty-stream retry (#525 follow-up)', () => {
|
|
54
|
+
let stub = null;
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
if (stub) { stub.restore(); stub = null; }
|
|
57
|
+
// Fail loudly if the fn.name === 'exec' stub never intercepted this test's
|
|
58
|
+
// doctor load — that would mean Node dropped the exec/promisify custom-
|
|
59
|
+
// symbol coupling and the assertions ran against real cmd.exe children.
|
|
60
|
+
expect(stubWasConsumed, `stub did not intercept the doctor load in this test`).toBe(true);
|
|
61
|
+
stubWasConsumed = false;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function freshDoctor() {
|
|
65
|
+
const doctorPath = require.resolve('../doctor.js');
|
|
66
|
+
delete require.cache[doctorPath];
|
|
67
|
+
return require(doctorPath).execWithTimeout;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('AC-DOCTOR-RETRY-01: retries an exit-0 empty-stdout probe and keeps its own timeout', async () => {
|
|
71
|
+
stub = stubExec([
|
|
72
|
+
(cb) => cb(null, '', ''), // the lost-stream failure mode
|
|
73
|
+
(cb) => cb(null, 'v24.19.0\n', ''), // the same probe, stream intact
|
|
74
|
+
]);
|
|
75
|
+
const execWithTimeout = freshDoctor();
|
|
76
|
+
const result = await execWithTimeout('node --version', { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
77
|
+
expect(stub.calls.length).toBe(2);
|
|
78
|
+
expect(result).toEqual({ stdout: 'v24.19.0', stderr: '' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('AC-DOCTOR-RETRY-01: returns null after exhausting attempts on a persistently empty probe', async () => {
|
|
82
|
+
stub = stubExec([
|
|
83
|
+
(cb) => cb(null, '', ''),
|
|
84
|
+
(cb) => cb(null, '', ''),
|
|
85
|
+
(cb) => cb(null, '', ''),
|
|
86
|
+
(cb) => cb(null, 'unexpected fourth attempt', ''),
|
|
87
|
+
]);
|
|
88
|
+
const execWithTimeout = freshDoctor();
|
|
89
|
+
const result = await execWithTimeout('node --version', {});
|
|
90
|
+
// win32: 3 attempts then null; other platforms: single attempt, still null.
|
|
91
|
+
expect(result).toBeNull();
|
|
92
|
+
expect(stub.calls.length).toBe(process.platform === 'win32' ? 3 : 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('AC-DOCTOR-RETRY-01: does not retry when the probe produced output', async () => {
|
|
96
|
+
stub = stubExec([(cb) => cb(null, 'git version 2.54.0\n', '')]);
|
|
97
|
+
const execWithTimeout = freshDoctor();
|
|
98
|
+
const result = await execWithTimeout('git --version', {});
|
|
99
|
+
expect(stub.calls.length).toBe(1);
|
|
100
|
+
expect(result.stdout).toContain('git version');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('AC-DOCTOR-RETRY-01: does not retry a failing command', async () => {
|
|
104
|
+
stub = stubExec([(cb) => cb(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), '', '')]);
|
|
105
|
+
const execWithTimeout = freshDoctor();
|
|
106
|
+
const result = await execWithTimeout('definitely-not-a-tool --version', {});
|
|
107
|
+
expect(stub.calls.length).toBe(1);
|
|
108
|
+
expect(result).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('AC-DOCTOR-RETRY-01: attempts:1 opts a side-effecting call out of the retry', async () => {
|
|
112
|
+
stub = stubExec([
|
|
113
|
+
(cb) => cb(null, '', ''),
|
|
114
|
+
(cb) => cb(null, 'unexpected second attempt', ''),
|
|
115
|
+
]);
|
|
116
|
+
const execWithTimeout = freshDoctor();
|
|
117
|
+
const result = await execWithTimeout('some-mutating-command', { attempts: 1 });
|
|
118
|
+
expect(stub.calls.length).toBe(1);
|
|
119
|
+
expect(result).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('AC-DOCTOR-RETRY-01: the attempts option is consumed, not forwarded to exec', async () => {
|
|
123
|
+
let forwarded = null;
|
|
124
|
+
stub = stubExec([(cb) => { cb(null, 'ok\n', ''); }]);
|
|
125
|
+
// Capture what doctor hands to the promisified exec by re-stubbing with an
|
|
126
|
+
// options recorder: stubExec's behaviors receive only the callback, so
|
|
127
|
+
// assert forwarding through a second fresh module with a capturing wrapper.
|
|
128
|
+
const util = require('util');
|
|
129
|
+
const originalPromisify = util.promisify;
|
|
130
|
+
const execWithTimeout = (() => {
|
|
131
|
+
util.promisify = function (fn) {
|
|
132
|
+
if (fn && fn.name === 'exec') {
|
|
133
|
+
stubWasConsumed = true;
|
|
134
|
+
return (cmd, opts = {}) => {
|
|
135
|
+
forwarded = opts;
|
|
136
|
+
return Promise.resolve({ stdout: 'ok\n', stderr: '' });
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return originalPromisify.call(util, fn);
|
|
140
|
+
};
|
|
141
|
+
const doctorPath = require.resolve('../doctor.js');
|
|
142
|
+
delete require.cache[doctorPath];
|
|
143
|
+
const f = require(doctorPath).execWithTimeout;
|
|
144
|
+
util.promisify = originalPromisify;
|
|
145
|
+
return f;
|
|
146
|
+
})();
|
|
147
|
+
const result = await execWithTimeout('git --version', { attempts: 2, timeout: 999 });
|
|
148
|
+
expect(result.stdout).toBe('ok');
|
|
149
|
+
expect(forwarded.attempts).toBeUndefined();
|
|
150
|
+
expect(forwarded.timeout).toBe(999);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -51,13 +51,22 @@ function matchBlock(source, pattern, label) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function bashBin() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
if (process.env.XP_GATE_BASH_BIN) {
|
|
55
|
+
const probe = spawnSync(process.env.XP_GATE_BASH_BIN, ['-c', 'echo ok'], { encoding: 'utf8' });
|
|
56
|
+
if (probe.status !== 0 || probe.error) {
|
|
57
|
+
throw new Error(`bash runner unavailable (${process.env.XP_GATE_BASH_BIN}): ${probe.error ? probe.error.message : `exit ${probe.status}`}`);
|
|
58
|
+
}
|
|
59
|
+
return process.env.XP_GATE_BASH_BIN;
|
|
60
|
+
}
|
|
61
|
+
// Resolve the authoritative bash the hooks actually get. On Windows a bare
|
|
62
|
+
// PATH 'bash' may be the WSL System32 stub, whose `-c` scripts receive an
|
|
63
|
+
// empty $1 and resolve nothing — a vacuous resolver run, not a passing one.
|
|
64
|
+
const { checkBash } = require('../detect-deps.js');
|
|
65
|
+
const b = checkBash();
|
|
66
|
+
if (!b.ok) {
|
|
67
|
+
throw new Error(`bash runner unavailable: ${b.message || 'checkBash failed'}`);
|
|
59
68
|
}
|
|
60
|
-
return
|
|
69
|
+
return b.path;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
/**
|
|
@@ -32,12 +32,20 @@ describe('doctor-tui skill freshness (#439)', () => {
|
|
|
32
32
|
// HOME has no platform marker, so detectPlatform() resolves to opencode.
|
|
33
33
|
function installSkill(name, content) {
|
|
34
34
|
const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`failed to install test skill ${name}: ${err && err.message}`);
|
|
40
|
+
}
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
function readBundled(name) {
|
|
40
|
-
|
|
44
|
+
try {
|
|
45
|
+
return fs.readFileSync(path.join(bundledSkillsDir, name, 'SKILL.md'), 'utf8');
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(`failed to read bundled skill ${name}: ${err && err.message}`);
|
|
48
|
+
}
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/** Same bytes, opposite line endings — the only difference a fresh install can have. */
|
|
@@ -134,7 +142,11 @@ describe('doctor-tui skill freshness (#439)', () => {
|
|
|
134
142
|
.readdirSync(bundledSkillsDir, { withFileTypes: true })
|
|
135
143
|
.filter((d) => d.isDirectory())
|
|
136
144
|
.map((d) => d.name);
|
|
137
|
-
|
|
145
|
+
// Sanity floor only: the substantive assertion below is that every bundled
|
|
146
|
+
// skill declares a version. #522 removed clipboard-vision, dropping the
|
|
147
|
+
// bundle from 13 to 12 skills, so the floor tracks the current bundle size
|
|
148
|
+
// instead of a stale magic number (Delphi Round 1, finding A-Critical).
|
|
149
|
+
expect(skills.length).toBeGreaterThanOrEqual(12);
|
|
138
150
|
const missing = skills.filter((name) => {
|
|
139
151
|
const md = path.join(bundledSkillsDir, name, 'SKILL.md');
|
|
140
152
|
return !/^version:\s*\d+\.\d+\.\d+/m.test(fs.readFileSync(md, 'utf8'));
|