@boyingliu01/xp-gate 0.31.1 → 0.31.3
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/adapter-common.sh +41 -3
- package/adapters/gate-3.sh +8 -1
- package/adapters/gate-8.sh +67 -6
- package/adapters/go.sh +18 -1
- package/adapters/iac.sh +18 -1
- package/adapters/powershell.sh +37 -1
- package/adapters/python.sh +18 -4
- package/gate-3.sh +254 -10
- package/gate-8.sh +67 -6
- package/hooks/adapter-common.sh +41 -3
- package/hooks/gate-3.sh +254 -10
- package/hooks/gate-8.sh +67 -6
- package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
- package/hooks/lib/coverage-total.sh +20 -5
- package/hooks/lib/gate-5c-alignment.sh +145 -0
- package/hooks/lib/jscpd-run.sh +40 -1
- package/hooks/lib/native-path.sh +106 -0
- package/hooks/lib/prepush-score.py +143 -0
- package/hooks/lib/sprint-gate-report.sh +23 -0
- package/hooks/lib/toolchain-path.sh +370 -0
- package/hooks/lib/validate-code-walkthrough.cjs +224 -23
- package/hooks/pre-commit +403 -165
- package/hooks/pre-push +192 -74
- package/hooks/sprint-gate.sh +14 -3
- package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
- package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
- package/lib/__tests__/doctor-delphi.test.js +46 -0
- package/lib/__tests__/doctor-exec-retry.test.js +20 -10
- package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
- package/lib/__tests__/doctor.test.js +7 -1
- package/lib/__tests__/hook-health-path.test.js +9 -1
- package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
- package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
- package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
- package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
- package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
- package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
- package/lib/arch.js +261 -44
- package/lib/check-alignment-runner.cjs +23 -0
- package/lib/check.js +4 -1
- package/lib/doctor.js +194 -49
- package/lib/gate-runner.js +265 -58
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +228 -6
- package/lib/shared-paths.js +53 -19
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +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/delphi-review/SKILL.md +50 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
- 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/delphi-config-env-diagnostics.test.ts +210 -0
- package/plugins/dsh/src/delphi-config.ts +151 -9
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
- 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/delphi-review/SKILL.md +50 -0
- 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/__tests__/issue-587-java-member-count.test.ts +279 -0
- package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
- package/principles/adapters/java.ts +127 -11
- package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
- package/principles/rules/solid/lsp.ts +348 -23
- package/scripts/__tests__/sync-package-content.test.js +32 -1
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +50 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/sprint-gate.sh +14 -3
package/lib/gate-runner.js
CHANGED
|
@@ -3,6 +3,159 @@ const fs = require('node:fs');
|
|
|
3
3
|
const os = require('node:os');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Source-file extensions the gate fragments know how to analyse.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the `--all` scope resolver so a full-tree check and the adapters agree
|
|
10
|
+
* on what a "source file" is. Data and documentation formats are deliberately
|
|
11
|
+
* absent: handing a markdown file to lizard is not a check, it is noise.
|
|
12
|
+
*/
|
|
13
|
+
const SOURCE_FILE_EXTENSIONS = [
|
|
14
|
+
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
|
15
|
+
'py', 'go', 'java', 'kt', 'kts', 'scala',
|
|
16
|
+
'c', 'cc', 'cpp', 'cxx', 'h', 'hpp',
|
|
17
|
+
'rs', 'php', 'dart', 'swift', 'm', 'mm',
|
|
18
|
+
'sh', 'bash', 'ps1', 'psm1', 'rb',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every tracked source file in the repository, one per line.
|
|
23
|
+
*
|
|
24
|
+
* This is what `--all` must hand the gates that read CHANGED_FILES.
|
|
25
|
+
*
|
|
26
|
+
* #589: `check --all` was documented as a full-tree check but still built
|
|
27
|
+
* CHANGED_FILES from `git diff`, so on a clean tree — the normal state after a
|
|
28
|
+
* commit, and the state CI runs in — Gate 3 received an empty list and printed
|
|
29
|
+
*
|
|
30
|
+
* ⏭️ SKIPPED - Complexity (no source files to check)
|
|
31
|
+
*
|
|
32
|
+
* `--all` therefore checked LESS than an ordinary commit, while reading like a
|
|
33
|
+
* pass. Gate 4 does not depend on CHANGED_FILES and worked fine under the same
|
|
34
|
+
* preamble, which is what proves the file SOURCE was the defect.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} [cwd] repository directory
|
|
37
|
+
* @returns {string} newline-separated repo-relative paths, empty on any failure
|
|
38
|
+
*/
|
|
39
|
+
function resolveAllModeChangedFiles(cwd = process.cwd()) {
|
|
40
|
+
const pathspecs = SOURCE_FILE_EXTENSIONS.map((ext) => `*.${ext}`);
|
|
41
|
+
try {
|
|
42
|
+
const out = execFileSync('git', ['ls-files', ...pathspecs], {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
46
|
+
});
|
|
47
|
+
return out.split('\n').filter(Boolean).join('\n');
|
|
48
|
+
} catch {
|
|
49
|
+
// Not a git repo, or git missing: an empty list is honest, and the caller's
|
|
50
|
+
// message distinguishes "empty diff" from "empty tree".
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Explain an empty CHANGED_FILES honestly, given which scope produced it.
|
|
57
|
+
*
|
|
58
|
+
* The old wording ("no source files to check") described an empty PROJECT when
|
|
59
|
+
* the actual cause was an empty DIFF — and the natural reading of the old message
|
|
60
|
+
* is "this gate passed". #589's reporter worked around it by running lizard by
|
|
61
|
+
* hand, which is the cost of an ambiguous skip.
|
|
62
|
+
*
|
|
63
|
+
* @param {'diff'|'all'} scope which scope was used
|
|
64
|
+
* @param {number} count number of files in the list
|
|
65
|
+
* @returns {string} one line, no trailing newline
|
|
66
|
+
*/
|
|
67
|
+
function describeEmptyChangedFiles(scope, count) {
|
|
68
|
+
if (count > 0) {
|
|
69
|
+
return scope === 'all'
|
|
70
|
+
? `${count} tracked source file(s) in scope (--all: whole tree)`
|
|
71
|
+
: `${count} changed source file(s) in scope`;
|
|
72
|
+
}
|
|
73
|
+
if (scope === 'all') {
|
|
74
|
+
return 'no tracked source files in this repository (--all already scanned the whole tree)';
|
|
75
|
+
}
|
|
76
|
+
// Name the real cause and the action that fixes it. Deliberately does NOT say
|
|
77
|
+
// "no source files": that describes the project, which is not what happened.
|
|
78
|
+
return 'no changed files in this run (0 staged, 0 vs HEAD) — run with `--all` to scan the whole tree';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Report which scope a gate actually used.
|
|
83
|
+
*
|
|
84
|
+
* A silent switch between diff and tree is the same defect one level up: the
|
|
85
|
+
* reader cannot tell what was inspected. Printed once per standalone run so
|
|
86
|
+
* "full check" cannot quietly mean "diff check".
|
|
87
|
+
*
|
|
88
|
+
* @param {'diff'|'all'} scope
|
|
89
|
+
* @param {number} count
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function describeChangedFilesScope(scope, count) {
|
|
93
|
+
const label = scope === 'all' ? 'all' : 'diff';
|
|
94
|
+
return `scope: ${label} (${count} file(s))`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Which bash can run the gate fragments.
|
|
99
|
+
*
|
|
100
|
+
* On Windows, PATH may lead with the WSL System32 stub, which cannot run these
|
|
101
|
+
* hooks (Linux filesystem view, and `-c` scripts receive an empty $1 through it).
|
|
102
|
+
* checkBash probes for an MSYS/MINGW bash that round-trips argv correctly.
|
|
103
|
+
*
|
|
104
|
+
* @returns {string|null} the bash command, or null when only an unusable one exists
|
|
105
|
+
*/
|
|
106
|
+
function resolveBashForGates() {
|
|
107
|
+
if (process.platform !== 'win32') return 'bash';
|
|
108
|
+
const { checkBash } = require('./detect-deps.js');
|
|
109
|
+
const bash = checkBash();
|
|
110
|
+
return bash.ok ? bash.path : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The first candidate that exists on disk, or the empty string.
|
|
115
|
+
*
|
|
116
|
+
* The empty string (not a throw) is deliberate: the preamble tolerates a missing
|
|
117
|
+
* helper script and the fragment then fails the same way it does in the commit
|
|
118
|
+
* hook.
|
|
119
|
+
*
|
|
120
|
+
* @param {string[]} candidates paths to try in order
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function firstExisting(candidates) {
|
|
124
|
+
try {
|
|
125
|
+
return candidates.find(candidate => fs.existsSync(candidate)) || '';
|
|
126
|
+
} catch {
|
|
127
|
+
// existsSync only throws on a malformed path; treat that as "none".
|
|
128
|
+
return '';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The shell lines that populate CHANGED_FILES, chosen by scan scope.
|
|
134
|
+
*
|
|
135
|
+
* Extracted so `runGateAdapter` stays readable: the diff form is three lines of
|
|
136
|
+
* git plumbing and the tree form is a single assignment, and mixing them inline
|
|
137
|
+
* made the wrapper harder to follow than the thing it wraps.
|
|
138
|
+
*
|
|
139
|
+
* @param {boolean} allMode true for `--all` (whole tree)
|
|
140
|
+
* @param {string} allModeFiles pre-resolved file list for all mode
|
|
141
|
+
* @returns {string[]} shell lines
|
|
142
|
+
*/
|
|
143
|
+
function buildFileSourceLines(allMode, allModeFiles) {
|
|
144
|
+
if (allMode) {
|
|
145
|
+
return ['#589: --all scans the tracked tree, not the diff', `CHANGED_FILES=${JSON.stringify(allModeFiles)}`];
|
|
146
|
+
}
|
|
147
|
+
// The historical diff form, unchanged: staged first, then anything vs HEAD.
|
|
148
|
+
// Interpolating the literal `${CHANGED_FILES}` brace-by-brace would be torn apart
|
|
149
|
+
// by this file's own template literals, hence the split below.
|
|
150
|
+
const changedFilesParameter = ['$', '{CHANGED_FILES}'].join('');
|
|
151
|
+
return [
|
|
152
|
+
'CHANGED_FILES="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)"',
|
|
153
|
+
`if [ -z "${changedFilesParameter}" ] && [ -n "$(git rev-parse HEAD 2>/dev/null)" ]; then`,
|
|
154
|
+
' CHANGED_FILES="$(git diff HEAD --name-only --diff-filter=ACM 2>/dev/null || true)"',
|
|
155
|
+
'fi',
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
6
159
|
/**
|
|
7
160
|
* Run a gate adapter fragment standalone.
|
|
8
161
|
*
|
|
@@ -16,75 +169,96 @@ const path = require('node:path');
|
|
|
16
169
|
* the fragments fail with "command not found". This wrapper reconstructs the
|
|
17
170
|
* same preamble the pre-commit hook sets up, then *sources* the fragment so it
|
|
18
171
|
* behaves exactly as it does inside the commit hook.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} scriptPath gate fragment to source
|
|
174
|
+
* @param {string} [cwd] project directory
|
|
175
|
+
* @param {{scope?: 'diff'|'all'}} [opts] `all` resolves CHANGED_FILES from the
|
|
176
|
+
* tracked tree instead of the diff (#589)
|
|
19
177
|
*/
|
|
20
|
-
function runGateAdapter(scriptPath, cwd = process.cwd()) {
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
console.log('⚠️ This gate requires bash to run shell scripts.');
|
|
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).');
|
|
33
|
-
console.log(' Alternatively, install the required tool directly and run the gate again.');
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
bashCommand = bash.path;
|
|
178
|
+
function runGateAdapter(scriptPath, cwd = process.cwd(), opts = {}) {
|
|
179
|
+
const bashCommand = resolveBashForGates();
|
|
180
|
+
if (!bashCommand) {
|
|
181
|
+
console.log('⚠️ This gate requires bash to run shell scripts.');
|
|
182
|
+
// No WSL suggestion: these hooks need Git Bash specifically — the WSL bash is
|
|
183
|
+
// rejected by the probe (Linux filesystem view, dropped $1).
|
|
184
|
+
console.log(' On Windows, install Git for Windows (https://git-scm.com/download/win).');
|
|
185
|
+
console.log(' Alternatively, install the required tool directly and run the gate again.');
|
|
186
|
+
return;
|
|
37
187
|
}
|
|
38
188
|
const scriptDir = path.dirname(scriptPath);
|
|
39
|
-
const commonScript = [
|
|
189
|
+
const commonScript = firstExisting([
|
|
40
190
|
path.join(scriptDir, 'adapter-common.sh'),
|
|
41
191
|
path.join(scriptDir, '..', 'adapter-common.sh'),
|
|
42
192
|
path.join(cwd, 'githooks', 'adapter-common.sh'),
|
|
43
|
-
]
|
|
44
|
-
const nowMsScript = [
|
|
193
|
+
]);
|
|
194
|
+
const nowMsScript = firstExisting([
|
|
45
195
|
path.join(scriptDir, 'lib', 'now-ms.sh'),
|
|
46
196
|
path.join(scriptDir, '..', 'lib', 'now-ms.sh'),
|
|
47
197
|
path.resolve(__dirname, '..', 'hooks', 'lib', 'now-ms.sh'),
|
|
48
|
-
]
|
|
49
|
-
|
|
198
|
+
]);
|
|
199
|
+
// #589: the scope determines the file SOURCE. Under `--all` the gates that read
|
|
200
|
+
// CHANGED_FILES must see the whole tree, not the diff — otherwise a full check
|
|
201
|
+
// scans less than an ordinary commit and reads like a pass.
|
|
202
|
+
const allMode = opts.scope === 'all';
|
|
203
|
+
const fileSourceLines = buildFileSourceLines(allMode, allMode ? resolveAllModeChangedFiles(cwd) : '');
|
|
204
|
+
const wrap = buildPreamble(allMode, fileSourceLines);
|
|
50
205
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
206
|
+
// execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
|
|
207
|
+
// single argv arg (no shell-metacharacter injection through the path).
|
|
208
|
+
execFileSync(
|
|
209
|
+
bashCommand,
|
|
210
|
+
['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath],
|
|
211
|
+
{ stdio: 'inherit', cwd }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The shell preamble the pre-commit hook would otherwise inject before sourcing a
|
|
217
|
+
* gate fragment.
|
|
218
|
+
*
|
|
219
|
+
* Deliberately NOT `set -u` (the pre-commit hook does not use it), so fragments
|
|
220
|
+
* that reference a not-yet-defined variable fail the same way inside the commit
|
|
221
|
+
* hook.
|
|
222
|
+
*
|
|
223
|
+
* @param {boolean} allMode whether the run scope is the whole tree
|
|
224
|
+
* @param {string[]} fileSourceLines lines that populate CHANGED_FILES
|
|
225
|
+
* @returns {string} the script to hand bash with -c
|
|
226
|
+
*/
|
|
227
|
+
function buildPreamble(allMode, fileSourceLines) {
|
|
228
|
+
return [
|
|
56
229
|
'if [ -n "$1" ] && [ -f "$1" ]; then source "$1"; fi',
|
|
57
230
|
'if [ -n "$2" ] && [ -f "$2" ]; then source "$2"; fi',
|
|
58
231
|
'if ! command -v now_ms >/dev/null 2>&1; then now_ms() { "$4" -e "console.log(Date.now())"; }; fi',
|
|
59
232
|
'gate_start_ms() { now_ms; }',
|
|
60
233
|
'record_gate_audit() { :; }',
|
|
61
234
|
'PROJECT_LANG="$(detect_project_lang 2>/dev/null || echo unknown)"',
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
235
|
+
// #589: fragments need to know which scope produced the file list, so an empty
|
|
236
|
+
// list can say "empty diff" instead of "empty project".
|
|
237
|
+
`GATE_SCOPE=${JSON.stringify(allMode ? 'all' : 'diff')}`,
|
|
238
|
+
...fileSourceLines,
|
|
66
239
|
'source "$3"',
|
|
67
240
|
].join('\n');
|
|
68
|
-
|
|
69
|
-
// execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
|
|
70
|
-
// single argv arg (no shell-metacharacter injection through the path).
|
|
71
|
-
execFileSync(
|
|
72
|
-
bashCommand,
|
|
73
|
-
['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath],
|
|
74
|
-
{ stdio: 'inherit', cwd }
|
|
75
|
-
);
|
|
76
241
|
}
|
|
77
242
|
|
|
78
243
|
// Gate metadata registry — maps gate IDs to names, descriptions, and how to run them.
|
|
79
244
|
// Standalone gates (<gate-id>: { run }) are invokable via xp-gate gate-<id>.
|
|
80
245
|
// Pre-commit-only gates (<gate-id>: { preCommitOnly: true }) run only in git commit context.
|
|
81
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Wall-clock budget for one TypeScript gate run.
|
|
249
|
+
*
|
|
250
|
+
* Generous on purpose: `gate-5-runner-error.test.ts` alone takes ~400s on CI, and a
|
|
251
|
+
* gate killed for being slow must be reported as "did not report a verdict", never
|
|
252
|
+
* silently counted as a pass.
|
|
253
|
+
*/
|
|
254
|
+
const TS_GATE_TIMEOUT_MS = 120000;
|
|
255
|
+
|
|
82
256
|
/**
|
|
83
257
|
* Run a TypeScript gate module via npx tsx.
|
|
84
258
|
* Finds the .ts entry point, spawns tsx with --run flag.
|
|
85
259
|
* Falls back to bash script if TypeScript module not found.
|
|
86
260
|
*/
|
|
87
|
-
function runTsGate(gateModule, targetPath) {
|
|
261
|
+
function runTsGate(gateModule, targetPath, opts = {}) {
|
|
88
262
|
const targetCwd = targetPath ? path.resolve(targetPath) : process.cwd();
|
|
89
263
|
const entry = findTsGateEntry(gateModule);
|
|
90
264
|
if (!entry) {
|
|
@@ -92,7 +266,7 @@ function runTsGate(gateModule, targetPath) {
|
|
|
92
266
|
const gateNum = gateModule.replace('gate-', '');
|
|
93
267
|
const script = resolveGateScript(gateNum, targetCwd);
|
|
94
268
|
if (script) {
|
|
95
|
-
runGateAdapter(script, targetCwd);
|
|
269
|
+
runGateAdapter(script, targetCwd, opts);
|
|
96
270
|
} else {
|
|
97
271
|
console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
|
|
98
272
|
}
|
|
@@ -102,14 +276,26 @@ function runTsGate(gateModule, targetPath) {
|
|
|
102
276
|
const args = ['-y', 'tsx', entry, '--run'];
|
|
103
277
|
if (targetPath) args.push('--cwd', targetPath);
|
|
104
278
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
279
|
+
// spawnSync reports a spawn failure (npx missing) on `error`, throws only on a
|
|
280
|
+
// malformed argv, and reports a killed child (timeout, signal) as a null status.
|
|
281
|
+
// All three mean "the gate did not report a verdict", which must not be read as
|
|
282
|
+
// success: a gate that never ran is not a gate that passed.
|
|
283
|
+
try {
|
|
284
|
+
const result = spawnSync('npx', args, {
|
|
285
|
+
stdio: 'inherit',
|
|
286
|
+
shell: process.platform === 'win32',
|
|
287
|
+
cwd: targetCwd,
|
|
288
|
+
timeout: TS_GATE_TIMEOUT_MS,
|
|
289
|
+
});
|
|
290
|
+
if (result.error) {
|
|
291
|
+
console.error(`Gate ${gateModule}: failed to run npx tsx: ${result.error.message}`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
return result.status === null ? 1 : result.status;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error(`Gate ${gateModule}: could not spawn npx tsx: ${err.message}`);
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
113
299
|
}
|
|
114
300
|
|
|
115
301
|
function findTsGateEntry(gateModule) {
|
|
@@ -159,8 +345,8 @@ const GATE_REGISTRY = {
|
|
|
159
345
|
name: 'Cyclomatic Complexity',
|
|
160
346
|
description: 'lizard cyclomatic complexity analysis',
|
|
161
347
|
aliases: ['complexity', 'ccn', '3'],
|
|
162
|
-
run: (targetPath) => {
|
|
163
|
-
return runTsGate('gate-3', targetPath);
|
|
348
|
+
run: (targetPath, opts) => {
|
|
349
|
+
return runTsGate('gate-3', targetPath, opts);
|
|
164
350
|
},
|
|
165
351
|
},
|
|
166
352
|
'4': {
|
|
@@ -295,6 +481,26 @@ function findConfig(basePath, fileName) {
|
|
|
295
481
|
return null;
|
|
296
482
|
}
|
|
297
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The basename of the first `gate-<num>-*.sh` in a directory, or null.
|
|
486
|
+
*
|
|
487
|
+
* A missing directory is the normal case (most projects have no `githooks/gates/`),
|
|
488
|
+
* so it returns null rather than throwing; a read error is also treated as "no
|
|
489
|
+
* match" because the caller falls through to the next candidate either way.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} dir directory to list
|
|
492
|
+
* @param {string} gateNum gate number as a string
|
|
493
|
+
* @returns {string|null}
|
|
494
|
+
*/
|
|
495
|
+
function matchGateFileIn(dir, gateNum) {
|
|
496
|
+
try {
|
|
497
|
+
if (!fs.existsSync(dir)) return null;
|
|
498
|
+
return fs.readdirSync(dir).find(f => f.startsWith(`gate-${gateNum}-`) && f.endsWith('.sh')) || null;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
298
504
|
function resolveGateScript(gateNum, cwd = process.cwd()) {
|
|
299
505
|
const candidates = [
|
|
300
506
|
path.join(cwd, 'githooks', 'gates', `gate-${gateNum}-*.sh`),
|
|
@@ -305,10 +511,8 @@ function resolveGateScript(gateNum, cwd = process.cwd()) {
|
|
|
305
511
|
for (const pattern of candidates) {
|
|
306
512
|
if (pattern.includes('*')) {
|
|
307
513
|
const dir = path.dirname(pattern);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (match) return path.join(dir, match);
|
|
311
|
-
}
|
|
514
|
+
const match = matchGateFileIn(dir, gateNum);
|
|
515
|
+
if (match) return path.join(dir, match);
|
|
312
516
|
} else if (fs.existsSync(pattern)) {
|
|
313
517
|
return pattern;
|
|
314
518
|
}
|
|
@@ -337,7 +541,7 @@ function getAllGates() {
|
|
|
337
541
|
}));
|
|
338
542
|
}
|
|
339
543
|
|
|
340
|
-
async function runGate(gateId, targetPath) {
|
|
544
|
+
async function runGate(gateId, targetPath, opts = {}) {
|
|
341
545
|
const gate = GATE_REGISTRY[String(gateId)];
|
|
342
546
|
if (!gate) {
|
|
343
547
|
console.error(`Unknown gate: ${gateId}`);
|
|
@@ -356,7 +560,10 @@ async function runGate(gateId, targetPath) {
|
|
|
356
560
|
if (gate.run) {
|
|
357
561
|
console.log(`━━━ Gate ${gateId}: ${gate.name} ━━━`);
|
|
358
562
|
try {
|
|
359
|
-
|
|
563
|
+
// #589: pass the scope through so a handler that shells out to a fragment
|
|
564
|
+
// can resolve its file list from the tree under --all. Handlers that ignore
|
|
565
|
+
// the second argument are unaffected.
|
|
566
|
+
const result = gate.run(targetPath, opts);
|
|
360
567
|
if (result && typeof result.then === 'function') {
|
|
361
568
|
return await result;
|
|
362
569
|
}
|
|
@@ -378,4 +585,4 @@ async function runGate(gateId, targetPath) {
|
|
|
378
585
|
return 1;
|
|
379
586
|
}
|
|
380
587
|
|
|
381
|
-
module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript };
|
|
588
|
+
module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
|
package/lib/principles.js
CHANGED
|
@@ -2,8 +2,147 @@
|
|
|
2
2
|
|
|
3
3
|
const { spawnSync } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Above this many characters of argv, the file list is handed over by reference
|
|
10
|
+
* (a temp file with `@`) instead of on the command line.
|
|
11
|
+
*
|
|
12
|
+
* #586: on Windows, `shell: true` routes through cmd.exe, whose command line is
|
|
13
|
+
* capped at 8191 characters. Measure from the issue: 135 Java files came to 12944
|
|
14
|
+
* characters before the `npx -y tsx <entry> --files --format json` prefix. That is
|
|
15
|
+
* not an unusually large project — it is a mid-sized one, every time.
|
|
16
|
+
*
|
|
17
|
+
* 6000 leaves room for the executable, the entry path and the flags without
|
|
18
|
+
* reaching the limit on any plausible installation path.
|
|
19
|
+
*/
|
|
20
|
+
const ARGV_CHAR_BUDGET = 6000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the executable + argument list for one principles run.
|
|
24
|
+
*
|
|
25
|
+
* Two things this must guarantee, both from #586:
|
|
26
|
+
*
|
|
27
|
+
* 1. no shell. `shell: true` is what reaches cmd.exe's 8191-character ceiling, and
|
|
28
|
+
* the fix is to not need a shell at all: prefer the project's own tsx run
|
|
29
|
+
* through `process.execPath` (an absolute node.exe — no shim, so no shell).
|
|
30
|
+
* 2. bounded argv. Splitting `files.join(' ')` into many argv elements was
|
|
31
|
+
* explicitly ruled out in the issue and it is right to rule it out: the
|
|
32
|
+
* bottleneck is total characters, not quoting. So a long list is handed over by
|
|
33
|
+
* reference through an `@file` argument, which the engine expands.
|
|
34
|
+
*
|
|
35
|
+
* @param {string[]} files source files to analyse
|
|
36
|
+
* @param {string} format 'json' or 'text'
|
|
37
|
+
* @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
|
|
38
|
+
*/
|
|
39
|
+
function buildPrinciplesInvocation(files, format) {
|
|
40
|
+
const entry = findPrinciplesEntry();
|
|
41
|
+
const listChars = files.reduce((n, f) => n + String(f).length + 1, 0);
|
|
42
|
+
|
|
43
|
+
// Preferred shape: node.exe + tsx's own CLI, so no shim and no shell are involved.
|
|
44
|
+
const tsxCli = [findLocalTsxCli(), path.resolve(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs')].find(
|
|
45
|
+
(candidate) => candidate && fs.existsSync(candidate)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const command = tsxCli ? process.execPath : 'npx';
|
|
49
|
+
const prefix = tsxCli ? [tsxCli, entry] : ['-y', 'tsx', entry];
|
|
50
|
+
|
|
51
|
+
if (listChars <= ARGV_CHAR_BUDGET) {
|
|
52
|
+
return {
|
|
53
|
+
command,
|
|
54
|
+
args: [...prefix, '--files', files.join(' '), '--format', format],
|
|
55
|
+
useShell: false,
|
|
56
|
+
cleanup: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// By reference: one short argument instead of thousands of characters.
|
|
61
|
+
return buildByReferenceInvocation(prefix, files, format, command);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `@file` form, used when the list is too long to inline.
|
|
66
|
+
*
|
|
67
|
+
* Split out for two reasons: it keeps `buildPrinciplesInvocation` inside the
|
|
68
|
+
* long-function limit, and it gives the temp-file IO a place to fail with a message
|
|
69
|
+
* that names the actual cause. An unwritable temp dir would otherwise surface as an
|
|
70
|
+
* unhandled throw from deep inside a spawn helper.
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} prefix executable-adjacent arguments (tsx cli + entry)
|
|
73
|
+
* @param {string[]} files source files to analyse
|
|
74
|
+
* @param {string} format 'json' or 'text'
|
|
75
|
+
* @param {string} command executable to run
|
|
76
|
+
* @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
|
|
77
|
+
*/
|
|
78
|
+
function buildByReferenceInvocation(prefix, files, format, command) {
|
|
79
|
+
const listFile = path.join(os.tmpdir(), `xp-gate-principles-${process.pid}-${Date.now()}.txt`);
|
|
80
|
+
try {
|
|
81
|
+
fs.writeFileSync(listFile, files.join('\n'));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// No list file means no run. Throw with the cause named, rather than letting the
|
|
84
|
+
// spawn fail later on a path that never existed.
|
|
85
|
+
throw new Error(`xp-gate principles: cannot write the file list to ${listFile}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
command,
|
|
89
|
+
// The @ prefix is the engine's own "read the list from this file" form.
|
|
90
|
+
args: [...prefix, '--files', `@${listFile}`, '--format', format],
|
|
91
|
+
useShell: false,
|
|
92
|
+
cleanup: listFile,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How far up from the working directory to look for a project-local tsx.
|
|
98
|
+
*
|
|
99
|
+
* Enough for a hoisted monorepo `node_modules` (a package three or four levels deep),
|
|
100
|
+
* bounded so a filesystem with a cycle cannot loop.
|
|
101
|
+
*/
|
|
102
|
+
const TSDX_SEARCH_DEPTH = 8;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Path to the project's own tsx CLI, or null.
|
|
106
|
+
*
|
|
107
|
+
* Walks up from the working directory so a monorepo's hoisted node_modules is found.
|
|
108
|
+
*
|
|
109
|
+
* @returns {string|null}
|
|
110
|
+
*/
|
|
111
|
+
function findLocalTsxCli() {
|
|
112
|
+
let dir = process.cwd();
|
|
113
|
+
for (let depth = 0; depth < TSDX_SEARCH_DEPTH; depth += 1) {
|
|
114
|
+
const candidate = path.join(dir, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
115
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
116
|
+
const parent = path.dirname(dir);
|
|
117
|
+
if (parent === dir) break;
|
|
118
|
+
dir = parent;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Was this failure the tool not running, or the code having problems?
|
|
125
|
+
*
|
|
126
|
+
* #586's last request, and #596's finding in the shell toolchain: a failure to
|
|
127
|
+
* LAUNCH the checker was reported as "Failed to run principles check", which reads
|
|
128
|
+
* like a principles verdict. It is not one — nothing was analysed, so nothing was
|
|
129
|
+
* found. Collapsing the two sends the reader to fix source code when the problem is
|
|
130
|
+
* the environment.
|
|
131
|
+
*
|
|
132
|
+
* @param {{error?: Error, stdout?: string, stderr?: string, status?: number|null}} result
|
|
133
|
+
* @returns {'TOOLCHAIN'|'FINDING'}
|
|
134
|
+
*/
|
|
135
|
+
function classifyPrinciplesFailure(result) {
|
|
136
|
+
const error = result && result.error;
|
|
137
|
+
if (error && ['E2BIG', 'ENAMETOOLONG', 'ENOENT'].includes(error.code)) return 'TOOLCHAIN';
|
|
138
|
+
const text = `${(result && result.stdout) || ''} ${(result && result.stderr) || ''} ${(error && error.message) || ''}`;
|
|
139
|
+
if (/command line is too long|argument list too long|ENAMETOOLONG|E2BIG/i.test(text)) return 'TOOLCHAIN';
|
|
140
|
+
// Everything else is a genuine verdict from a run that happened. Do NOT widen this
|
|
141
|
+
// to "any non-zero exit": that would make Gate 4 unable to block on a real
|
|
142
|
+
// violation, which is worse than the defect being fixed.
|
|
143
|
+
return 'FINDING';
|
|
144
|
+
}
|
|
145
|
+
|
|
7
146
|
// Resolves repo root for the installed xp-gate npm package, or the development
|
|
8
147
|
// checkout when running from source. Walks up looking for either:
|
|
9
148
|
// - src/principles/index.ts (this repo / dev mode)
|
|
@@ -145,12 +284,95 @@ function principles(args) {
|
|
|
145
284
|
return Promise.resolve(1);
|
|
146
285
|
}
|
|
147
286
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
287
|
+
// #586: build before spawning so the Windows command-line limit is a planning
|
|
288
|
+
// concern, not a runtime explosion. See buildPrinciplesInvocation().
|
|
289
|
+
return Promise.resolve(runPrinciplesPlan(buildPrinciplesInvocation(files, format)));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Execute a plan and return the exit code.
|
|
294
|
+
*
|
|
295
|
+
* Split from `principles` so that function stays inside the long-function limit, and
|
|
296
|
+
* so the launch-failure handling lives in one place with a comment saying what it is
|
|
297
|
+
* worth. `spawnSync` normally reports failures via `result.error`; a throw here means
|
|
298
|
+
* the call itself was malformed. Either way nothing was analysed, which is the fact
|
|
299
|
+
* worth stating rather than letting a stack trace imply the code was judged.
|
|
300
|
+
*
|
|
301
|
+
* @param {{command: string, args: string[], useShell: boolean, cleanup: string|null}} plan
|
|
302
|
+
* @returns {number} process exit code
|
|
303
|
+
*/
|
|
304
|
+
function runPrinciplesPlan(plan) {
|
|
305
|
+
let result;
|
|
306
|
+
try {
|
|
307
|
+
result = spawnSync(plan.command, plan.args, {
|
|
308
|
+
stdio: 'inherit',
|
|
309
|
+
// Never a shell: `shell: true` is what reached cmd.exe's 8191-character ceiling.
|
|
310
|
+
shell: plan.useShell,
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
removeListFile(plan.cleanup);
|
|
314
|
+
console.error(`[xp-gate principles] TOOLCHAIN: could not launch the checker: ${err.message}`);
|
|
315
|
+
console.error(' This is an environment problem, not a principles violation - nothing was analysed.');
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
removeListFile(plan.cleanup);
|
|
320
|
+
return reportPrinciplesResult(result);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Delete the by-reference list file, if one was written.
|
|
325
|
+
*
|
|
326
|
+
* Best effort: a leftover temp file is not worth failing a gate over, but swallowing
|
|
327
|
+
* the error silently is how temp directories fill up unnoticed, so it is named.
|
|
328
|
+
*
|
|
329
|
+
* @param {string|null} listFile path to remove, or null
|
|
330
|
+
*/
|
|
331
|
+
function removeListFile(listFile) {
|
|
332
|
+
if (!listFile) return;
|
|
333
|
+
try {
|
|
334
|
+
fs.unlinkSync(listFile);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Expected when the child already consumed/removed it; only surprising otherwise.
|
|
337
|
+
if (err.code !== 'ENOENT') {
|
|
338
|
+
console.error(`[xp-gate principles] could not remove ${listFile}: ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Turn a spawn result into the exit code to return, printing which kind of failure it
|
|
345
|
+
* was when there is one.
|
|
346
|
+
*
|
|
347
|
+
* Split out of `principles` to keep that function inside the long-function limit, and
|
|
348
|
+
* because this is the one place that decides whether the caller is looking at a code
|
|
349
|
+
* verdict or an environment problem — worth being able to point at directly.
|
|
350
|
+
*
|
|
351
|
+
* @param {{error?: Error, status?: number|null}} result spawnSync result
|
|
352
|
+
* @returns {number} process exit code
|
|
353
|
+
*/
|
|
354
|
+
function reportPrinciplesResult(result) {
|
|
355
|
+
if (!result.error && result.status !== null) return result.status;
|
|
152
356
|
|
|
153
|
-
|
|
357
|
+
// #586's last request: say which kind of failure this is. "Failed to run principles
|
|
358
|
+
// check" read like a principles verdict on a run where nothing was analysed. Same
|
|
359
|
+
// confusion #596 fixed in the shell toolchain.
|
|
360
|
+
const kind = classifyPrinciplesFailure(result);
|
|
361
|
+
const detail = result.error ? result.error.message : 'no exit status';
|
|
362
|
+
if (kind === 'TOOLCHAIN') {
|
|
363
|
+
console.error(`[xp-gate principles] TOOLCHAIN: the checker could not be run: ${detail}`);
|
|
364
|
+
console.error(' This is an environment problem, not a principles violation - nothing was analysed.');
|
|
365
|
+
} else {
|
|
366
|
+
console.error(`[xp-gate principles] checker failed: ${detail}`);
|
|
367
|
+
}
|
|
368
|
+
return 1;
|
|
154
369
|
}
|
|
155
370
|
|
|
156
|
-
module.exports = {
|
|
371
|
+
module.exports = {
|
|
372
|
+
principles,
|
|
373
|
+
expandTargets,
|
|
374
|
+
collectSourceFiles,
|
|
375
|
+
buildPrinciplesInvocation,
|
|
376
|
+
classifyPrinciplesFailure,
|
|
377
|
+
ARGV_CHAR_BUDGET,
|
|
378
|
+
};
|