@boyingliu01/xp-gate 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/python.sh +53 -3
- package/bin/xp-gate.js +161 -28
- package/hooks/lib/python-incremental-typecheck.sh +104 -0
- package/hooks/lib/typecheck.sh +10 -0
- package/hooks/pre-commit +84 -32
- 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-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 +73 -0
- package/lib/check-alignment-runner.cjs +74 -0
- package/lib/detect-deps.js +82 -19
- 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 +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/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 +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/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 +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/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 +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- 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/adapters/python.sh
CHANGED
|
@@ -91,12 +91,62 @@ run_static_analysis() {
|
|
|
91
91
|
return 0
|
|
92
92
|
fi
|
|
93
93
|
fi
|
|
94
|
-
#
|
|
95
|
-
|
|
94
|
+
# #534: engine selection. mypy is ALWAYS the authoritative engine (determinism —
|
|
95
|
+
# ty is an alpha, ~70% spec conformance, config-incompatible). ty is used ONLY
|
|
96
|
+
# as an explicit opt-in accelerator when XP_GATE_TY=1 AND `ty` is installed:
|
|
97
|
+
# - XP_GATE_TY=1 + ty present -> `ty check <target>` (ty's CLI is `ty check`,
|
|
98
|
+
# not `ty`; exit 1 = type errors -> BLOCK, exit 2 = config/CLI error -> SKIP)
|
|
99
|
+
# - XP_GATE_TY=1 + ty missing -> SKIP (return 0), never BLOCK: the opt-in
|
|
100
|
+
# path must not route through require_tool mypy and false-block a repo that
|
|
101
|
+
# opted into ty but lacks mypy (Delphi R1 Critical).
|
|
102
|
+
# - XP_GATE_TY unset -> pure mypy (authoritative), unchanged.
|
|
103
|
+
# Scope semantics (.xp-gate-mypy-scope / src/ scripts/) are engine-independent
|
|
104
|
+
# and preserved verbatim below.
|
|
105
|
+
local _engine=mypy
|
|
106
|
+
if [ "${XP_GATE_TY:-}" = "1" ]; then
|
|
107
|
+
if command -v ty >/dev/null 2>&1; then
|
|
108
|
+
_engine=ty
|
|
109
|
+
echo " ⚙ engine: ty (Rust, explicit opt-in via XP_GATE_TY=1) — mypy remains the default"
|
|
110
|
+
elif command -v mypy >/dev/null 2>&1; then
|
|
111
|
+
# ty opted in but missing; mypy present -> fall back to the authoritative
|
|
112
|
+
# engine with a warning (never silently drop the type gate, #534 R2 arch).
|
|
113
|
+
echo " ⚠ ty requested (XP_GATE_TY=1) but not installed — falling back to mypy"
|
|
114
|
+
else
|
|
115
|
+
echo " ⚠ ty opted in via XP_GATE_TY=1 but neither ty nor mypy is installed — SKIPPING type check (no BLOCK)"
|
|
116
|
+
echo " install: pip install mypy, or pip install ty (alpha)"
|
|
117
|
+
return 0
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
# require_tool mypy only on the authoritative (mypy) engine path: when the
|
|
121
|
+
# user opted into ty, a missing mypy must NOT false-BLOCK (Delphi R1 Critical).
|
|
122
|
+
if [ "$_engine" = "mypy" ]; then
|
|
123
|
+
require_tool mypy "mypy" || return 1
|
|
124
|
+
fi
|
|
96
125
|
local _mypy_rc=0
|
|
97
126
|
local _mypy_t
|
|
98
127
|
for _mypy_t in "${MYPY_TARGETS[@]}"; do
|
|
99
|
-
|
|
128
|
+
if [ "$_engine" = "ty" ]; then
|
|
129
|
+
# ty's CLI is `ty check <path>`, not `ty <path>`. Bare `ty`/`ty check .`
|
|
130
|
+
# without targets would recurse the whole tree (== the anti `mypy .`
|
|
131
|
+
# guard #501/#513): targets are always explicit here. Capture ty's own
|
|
132
|
+
# exit code and diagnostics via a temp file (never discard them): a BLOCK
|
|
133
|
+
# with no reason is undebuggable, and a pipe `| head` would lose the code.
|
|
134
|
+
local _ty_rc=0
|
|
135
|
+
local _ty_tmp
|
|
136
|
+
_ty_tmp="$(mktemp)"
|
|
137
|
+
ty check "$_mypy_t" > "$_ty_tmp" 2>&1 || _ty_rc=$?
|
|
138
|
+
if [ "$_ty_rc" -eq 2 ]; then
|
|
139
|
+
echo " ⚠ ty exited $_ty_rc on target '$_mypy_t' (config/CLI error) — SKIPPING, not blocking"
|
|
140
|
+
head -30 "$_ty_tmp"
|
|
141
|
+
elif [ "$_ty_rc" -ne 0 ]; then
|
|
142
|
+
echo " ty report for '$_mypy_t' (exit $_ty_rc):"
|
|
143
|
+
head -30 "$_ty_tmp"
|
|
144
|
+
_mypy_rc=1
|
|
145
|
+
fi
|
|
146
|
+
rm -f "$_ty_tmp"
|
|
147
|
+
else
|
|
148
|
+
mypy "$_mypy_t" || _mypy_rc=1
|
|
149
|
+
fi
|
|
100
150
|
done
|
|
101
151
|
return $_mypy_rc
|
|
102
152
|
}
|
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': {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Incremental Python type-checking helpers for the pre-commit Python branch
|
|
4
|
+
# (#534). This is the FAST staged-only pass: pre-commit runs it on just the
|
|
5
|
+
# .py/.pyi files that changed, so a large repo doesn't pay full-inference CPU
|
|
6
|
+
# on every commit. It complements the FULL mypy pass (githooks/adapters/
|
|
7
|
+
# python.sh run_static_analysis, reachable via the adapter CLI / CI), which is
|
|
8
|
+
# where cross-module blind spots get caught.
|
|
9
|
+
#
|
|
10
|
+
# Sourced by githooks/pre-commit (the python) case. Requires CHANGED_FILES
|
|
11
|
+
# (space/newline separated, repo-root relative) to be in scope, matching the
|
|
12
|
+
# rest of the inline branch.
|
|
13
|
+
#
|
|
14
|
+
# Shellcheck: functions are the public API used by pre-commit; SC1091 sources
|
|
15
|
+
# are intentionally not resolved here (adapter-common not needed).
|
|
16
|
+
|
|
17
|
+
# Extract the staged .py/.pyi files from CHANGED_FILES, one per line.
|
|
18
|
+
# Excludes vendored/ignored trees (.venv, venv, node_modules) that a hook
|
|
19
|
+
# should never type-check. Handles whitespace in paths (echo + grep would
|
|
20
|
+
# split them).
|
|
21
|
+
python_staged_py_files() {
|
|
22
|
+
local f
|
|
23
|
+
while IFS= read -r f || [ -n "$f" ]; do
|
|
24
|
+
case "$f" in
|
|
25
|
+
.venv/*|venv/*|node_modules/*) continue ;;
|
|
26
|
+
*.py|*.pyi) printf '%s\n' "$f" ;;
|
|
27
|
+
esac
|
|
28
|
+
done <<< "$CHANGED_FILES"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Run incremental mypy over the given staged files, returning mypy's REAL exit
|
|
32
|
+
# code (never a pipe stage's). Adds --follow-imports=silent so that a change to
|
|
33
|
+
# A.py that references an unchanged B.py does not surface B's own pre-existing
|
|
34
|
+
# diagnostics — only caller-level signature errors are reported. Callers must
|
|
35
|
+
# pass the file list as arguments (already filtered to .py/.pyi).
|
|
36
|
+
#
|
|
37
|
+
# Scope discipline (same .xp-gate-mypy-scope contract as python.sh): a staged
|
|
38
|
+
# file OUTSIDE the declared mypy scope is skipped with a note rather than type-
|
|
39
|
+
# checked, keeping the incremental pass consistent with the full pass. Files in
|
|
40
|
+
# scope default to "under src/ or scripts/" when no scope file exists.
|
|
41
|
+
python_typecheck_incremental() {
|
|
42
|
+
[ "$#" -gt 0 ] || return 0
|
|
43
|
+
|
|
44
|
+
# Resolve the declared scope (identical precedence to run_static_analysis):
|
|
45
|
+
# .xp-gate-mypy-scope (one target per line) wins; else src/ and scripts/
|
|
46
|
+
# are the implicit default. Any staged .py outside this surface is skipped.
|
|
47
|
+
local -a SCOPE_DIRS=()
|
|
48
|
+
local _declared=0 line target
|
|
49
|
+
if [ -f .xp-gate-mypy-scope ]; then
|
|
50
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
51
|
+
line="${line%%#*}"
|
|
52
|
+
line="${line#"${line%%[![:space:]]*}"}"
|
|
53
|
+
line="${line%"${line##*[![:space:]]}"}"
|
|
54
|
+
[ -z "$line" ] && continue
|
|
55
|
+
_declared=1
|
|
56
|
+
# Normalize trailing slashes (`src/` -> `src`) so the `$target/*` prefix
|
|
57
|
+
# match below never sees a doubled slash (`src//a.py` would never match).
|
|
58
|
+
line="${line%/}"
|
|
59
|
+
SCOPE_DIRS+=("$line")
|
|
60
|
+
done < .xp-gate-mypy-scope
|
|
61
|
+
else
|
|
62
|
+
[ -d src ] && SCOPE_DIRS+=(src)
|
|
63
|
+
[ -d scripts ] && SCOPE_DIRS+=(scripts)
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# If a scope file is declared but yields nothing, every staged .py is out of
|
|
67
|
+
# scope -> SKIP (exit 0), mirroring the full pass.
|
|
68
|
+
local _in_scope=false
|
|
69
|
+
local -a TYPECHECK_FILES=()
|
|
70
|
+
local f
|
|
71
|
+
if [ "${#SCOPE_DIRS[@]}" -gt 0 ]; then
|
|
72
|
+
for f in "$@"; do
|
|
73
|
+
_in_scope=false
|
|
74
|
+
for target in "${SCOPE_DIRS[@]}"; do
|
|
75
|
+
case "$f" in
|
|
76
|
+
"$target"/*) _in_scope=true ;;
|
|
77
|
+
esac
|
|
78
|
+
[ "$_in_scope" = true ] && break
|
|
79
|
+
done
|
|
80
|
+
if [ "$_in_scope" = true ]; then
|
|
81
|
+
TYPECHECK_FILES+=("$f")
|
|
82
|
+
else
|
|
83
|
+
echo " ℹ️ mypy incremental: '$f' is outside declared scope — skipped"
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
[ "${#TYPECHECK_FILES[@]}" -gt 0 ] || return 0
|
|
89
|
+
|
|
90
|
+
# Capture mypy's exit code directly. A pipeline like
|
|
91
|
+
# mypy ... | head -20 | tail -10; rc=$?
|
|
92
|
+
# loses mypy's real status (the pipe stage's rc is used instead), which made
|
|
93
|
+
# the gate pass silently on real type errors. Redirect to a temp file, take
|
|
94
|
+
# mypy's own exit code, then print a truncated view.
|
|
95
|
+
local tmp
|
|
96
|
+
tmp="$(mktemp)"
|
|
97
|
+
echo "Running mypy type checking (incremental)..."
|
|
98
|
+
mypy --follow-imports=silent --ignore-missing-imports "${TYPECHECK_FILES[@]}" > "$tmp" 2>&1
|
|
99
|
+
local _mypy_rc=$?
|
|
100
|
+
head -20 "$tmp"
|
|
101
|
+
tail -10 "$tmp"
|
|
102
|
+
rm -f "$tmp"
|
|
103
|
+
return "$_mypy_rc"
|
|
104
|
+
}
|
package/hooks/lib/typecheck.sh
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
# Gate 1 TypeScript static analysis — the single place that decides HOW a project
|
|
4
4
|
# is type-checked (#436).
|
|
5
5
|
#
|
|
6
|
+
# NOT incremental (deliberately, #534): TypeScript type-checking cannot be
|
|
7
|
+
# reduced to "staged files only". `tsc` has no single-file check mode — it must
|
|
8
|
+
# resolve the whole module graph to report a file's real type errors, so
|
|
9
|
+
# type-checking only the changed files would silently miss "changing B.ts breaks
|
|
10
|
+
# an unchanged caller in A.ts". Pre-commit therefore runs the project's full
|
|
11
|
+
# typecheck (`npm run typecheck` / `npx tsc --noEmit`) every commit. This is the
|
|
12
|
+
# CORRECTNESS-preserving exception to xp-gate's incremental rule: lint (ESLint /
|
|
13
|
+
# Biome) IS incremental (staged files / --staged), but type-check stays full
|
|
14
|
+
# because incremental type-checking would be wrong, not just slower.
|
|
15
|
+
#
|
|
6
16
|
# Before this file, `githooks/pre-commit` and `githooks/adapters/typescript.sh` each
|
|
7
17
|
# hardcoded `npx tsc --noEmit`. Plain tsc cannot resolve `.vue` single-file
|
|
8
18
|
# components, so every `import X from './Foo.vue'` became a TS2307 and a Vue project
|
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"
|
|
@@ -235,6 +239,11 @@ source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
|
|
|
235
239
|
# unit-testable without executing this hook.
|
|
236
240
|
source "${AUDIT_SCRIPT_DIR}/lib/python-test-selection.sh"
|
|
237
241
|
|
|
242
|
+
# Issue #534: incremental Python type-check for the pre-commit python branch —
|
|
243
|
+
# staged-only mypy (fast) that complements python.sh's CI-aligned full pass.
|
|
244
|
+
# Kept in a library so the exit-code capture and scope handling are unit-testable.
|
|
245
|
+
source "${AUDIT_SCRIPT_DIR}/lib/python-incremental-typecheck.sh"
|
|
246
|
+
|
|
238
247
|
# Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
|
|
239
248
|
_MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
|
|
240
249
|
|
|
@@ -979,10 +988,14 @@ IN_PROJECT_SUBDIR=0
|
|
|
979
988
|
_lang_count=$(echo "$PROJECT_LANGS" | wc -w)
|
|
980
989
|
if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
|
|
981
990
|
ORIGINAL_DIR=$(pwd)
|
|
982
|
-
cd
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
|
986
999
|
fi
|
|
987
1000
|
|
|
988
1001
|
# ============================================================================
|
|
@@ -1308,24 +1321,38 @@ else
|
|
|
1308
1321
|
fi
|
|
1309
1322
|
fi
|
|
1310
1323
|
|
|
1311
|
-
# Optional: Type checking with mypy
|
|
1324
|
+
# Optional: Type checking with mypy — INCREMENTAL (fast, staged-only).
|
|
1325
|
+
#
|
|
1326
|
+
# Responsibility boundary (#534):
|
|
1327
|
+
# * This branch (python case) runs mypy ONLY over the staged .py/.pyi
|
|
1328
|
+
# files via python_typecheck_incremental — seconds, not a full inference
|
|
1329
|
+
# pass. Uses --follow-imports=silent so a change to A.py that references
|
|
1330
|
+
# an unchanged B.py does NOT surface B's pre-existing diagnostics; only
|
|
1331
|
+
# caller-level signature errors still block. Its exit code is captured
|
|
1332
|
+
# directly (temp file), which is deterministic and does not depend on
|
|
1333
|
+
# set -o pipefail (the old `... | head | tail; rc=$?` worked only under
|
|
1334
|
+
# pipefail and could drop mypy's status when the pattern is copied into
|
|
1335
|
+
# a non-pipefail context).
|
|
1336
|
+
# * The FULL mypy pass lives OUTSIDE this hook: run_static_analysis()
|
|
1337
|
+
# (githooks/adapters/python.sh) is reachable via the adapter CLI and the
|
|
1338
|
+
# general adapter route, and CI installs mypy (quality-gates.yml) for
|
|
1339
|
+
# typed projects. NOTE: the pre-commit python case is fully inline and
|
|
1340
|
+
# never routes to run_static_analysis — so the incremental blind spot
|
|
1341
|
+
# (cross-module signature break on an unchanged file) is the accepted
|
|
1342
|
+
# trade-off here, backstopped by full mypy wherever that surface runs.
|
|
1343
|
+
# Both paths coexist: this one is the fast pre-commit pass.
|
|
1312
1344
|
if command -v mypy &> /dev/null; then
|
|
1345
|
+
# Collect staged .py/.pyi via the helper (whitespace-safe, bash-3
|
|
1346
|
+
# compatible — macOS default bash is 3.2, so avoid mapfile).
|
|
1313
1347
|
MYPY_ARGS=()
|
|
1314
1348
|
while IFS= read -r staged_file; do
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
|
|
1320
|
-
MYPY_EXIT=0
|
|
1321
|
-
else
|
|
1322
|
-
echo "Running mypy type checking (optional)..."
|
|
1323
|
-
mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
|
|
1324
|
-
MYPY_EXIT=$?
|
|
1325
|
-
fi
|
|
1349
|
+
MYPY_ARGS+=("$staged_file")
|
|
1350
|
+
done < <(python_staged_py_files)
|
|
1351
|
+
python_typecheck_incremental "${MYPY_ARGS[@]}"
|
|
1352
|
+
MYPY_EXIT=$?
|
|
1326
1353
|
if [ "$MYPY_EXIT" -ne 0 ]; then
|
|
1327
1354
|
echo ""
|
|
1328
|
-
echo "❌ BLOCKED -
|
|
1355
|
+
echo "❌ BLOCKED - MYPY TYPE ERRORS detected"
|
|
1329
1356
|
echo "Fix the type errors above before committing."
|
|
1330
1357
|
exit 1
|
|
1331
1358
|
fi
|
|
@@ -1342,16 +1369,32 @@ else
|
|
|
1342
1369
|
echo "ℹ️ golangci-lint not available — skipping Go linting"
|
|
1343
1370
|
echo "⏭️ SKIPPED - Code quality (golangci-lint not available)"
|
|
1344
1371
|
else
|
|
1345
|
-
|
|
1346
|
-
golangci-lint run
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
echo "
|
|
1352
|
-
|
|
1372
|
+
# #534: run golangci-lint over ONLY the staged .go files (incremental),
|
|
1373
|
+
# not the whole repo. `golangci-lint run` with no args scans every package
|
|
1374
|
+
# and dominates pre-commit time on large Go repos. golangci-lint accepts
|
|
1375
|
+
# explicit file targets. No staged .go files -> SKIP (nothing to lint).
|
|
1376
|
+
GO_FILES=$(echo "$CHANGED_FILES" | grep '\.go$' || true)
|
|
1377
|
+
if [ -z "$GO_FILES" ]; then
|
|
1378
|
+
echo "✅ PASSED - No staged Go files to lint."
|
|
1379
|
+
else
|
|
1380
|
+
echo "Running golangci-lint (incremental, staged .go)..."
|
|
1381
|
+
# #534: capture golangci-lint's REAL exit code via a temp file, not a
|
|
1382
|
+
# pipe stage's (`... | head -30; rc=$?` would take head's status; it only
|
|
1383
|
+
# works because of set -o pipefail, which is fragile and inconsistent with
|
|
1384
|
+
# the python incremental helper's temp-file pattern).
|
|
1385
|
+
GO_TMP=$(mktemp)
|
|
1386
|
+
golangci-lint run $GO_FILES > "$GO_TMP" 2>&1
|
|
1387
|
+
GOLANGCI_EXIT=$?
|
|
1388
|
+
head -30 "$GO_TMP"
|
|
1389
|
+
rm -f "$GO_TMP"
|
|
1390
|
+
if [ "$GOLANGCI_EXIT" -ne 0 ]; then
|
|
1391
|
+
echo ""
|
|
1392
|
+
echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
|
|
1393
|
+
echo "Fix the lint errors above before committing."
|
|
1394
|
+
exit 1
|
|
1395
|
+
fi
|
|
1396
|
+
echo "✅ PASSED - golangci-lint."
|
|
1353
1397
|
fi
|
|
1354
|
-
echo "✅ PASSED - golangci-lint."
|
|
1355
1398
|
fi
|
|
1356
1399
|
;;
|
|
1357
1400
|
|
|
@@ -1604,7 +1647,16 @@ else
|
|
|
1604
1647
|
echo "Running jscpd for Go duplicate code detection..."
|
|
1605
1648
|
FILES_TO_CHECK=$(echo "$CHANGED_FILES" | grep "\.go$" | head -20)
|
|
1606
1649
|
if [ -n "$FILES_TO_CHECK" ]; then
|
|
1607
|
-
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
|
|
1608
1660
|
fi
|
|
1609
1661
|
echo "✅ PASSED - Go duplicate code check completed."
|
|
1610
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
|
+
});
|