@boyingliu01/xp-gate 0.35.5 → 0.35.7
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 +61 -1
- package/hooks/lib/assertion-quality.sh +47 -0
- package/hooks/pre-commit +54 -8
- package/lib/__tests__/assertion-debt.test.js +99 -0
- package/lib/__tests__/check-single-file.test.js +63 -0
- package/lib/__tests__/doctor-brief.test.js +59 -0
- package/lib/__tests__/gate-rules.test.js +58 -0
- package/lib/assertion-debt.js +173 -0
- package/lib/doctor-brief.js +159 -0
- package/lib/doctor.js +23 -3
- package/lib/gate-rules.js +149 -0
- package/lib/gate-runner.js +49 -3
- package/lib/mattpocock-license-verdict.json +1 -1
- 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/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -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/sprint-flow/references/phase-3-build.md +14 -1
- 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/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xp-gate audit assertions` — make pre-existing assertion debt visible (#668).
|
|
3
|
+
*
|
|
4
|
+
* Gate 5d deliberately checks only the files in the diff: a full-repo scan on
|
|
5
|
+
* every commit is too slow. That has an expensive side effect. Pre-existing debt
|
|
6
|
+
* blocks unrelated changes — the author who fixed `writeReport()`'s error handling
|
|
7
|
+
* was stopped by four long-standing `expect(true).toBe(true)` lines in a file they
|
|
8
|
+
* had merely touched — and the block reads as "this change introduced a problem".
|
|
9
|
+
*
|
|
10
|
+
* The worst consequence is not that one false attribution. It is what it does to
|
|
11
|
+
* behaviour: noise in the attribution makes people reach for
|
|
12
|
+
* `@assertion-justified` instead of fixing the test. A gate that teaches people to
|
|
13
|
+
* annotate their way past it is worse than no gate. Hence a batch work queue.
|
|
14
|
+
*
|
|
15
|
+
* Scope: only the two shapes Gate 5d BLOCKS (tautological, empty catch). Weak-only
|
|
16
|
+
* assertions and mock echoes WARN there because each can be legitimate; putting them
|
|
17
|
+
* in a "debt to clear" list would invent work.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
/** Directories that never contain hand-written assertions worth auditing. */
|
|
25
|
+
const SKIP_DIRS = new Set([
|
|
26
|
+
'node_modules',
|
|
27
|
+
'.git',
|
|
28
|
+
'dist',
|
|
29
|
+
'build',
|
|
30
|
+
'coverage',
|
|
31
|
+
'.stryker-tmp',
|
|
32
|
+
'.agent-tmp',
|
|
33
|
+
// Build output mirrored from src/ — listing it again would double-count debt and
|
|
34
|
+
// send a batch fixer to a file that regenerates.
|
|
35
|
+
'.xp-gate',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Test-file patterns across the ecosystems XP-Gate supports. */
|
|
39
|
+
function isTestFile(name) {
|
|
40
|
+
return (
|
|
41
|
+
/\.(test|spec)\.[cm]?[jt]sx?$/.test(name) ||
|
|
42
|
+
/(^|\/)(__tests__)\//.test(name) ||
|
|
43
|
+
/\.test\.bats$/.test(name) ||
|
|
44
|
+
/^test_.*\.py$/.test(name) ||
|
|
45
|
+
/_test\.(go|rs|py)$/.test(name)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tautological assertions: the assertion cannot fail.
|
|
51
|
+
*
|
|
52
|
+
* Mirrors `classify_tautological_assertions` in githooks/lib/assertion-quality.sh.
|
|
53
|
+
* The two implementations must agree, because a debt list that disagrees with the
|
|
54
|
+
* gate it exists to serve sends people to fix lines that are not blocking and miss
|
|
55
|
+
* the ones that are.
|
|
56
|
+
*/
|
|
57
|
+
const TAUTOLOGY_PATTERNS = [
|
|
58
|
+
// (a) identical literal on both sides.
|
|
59
|
+
{
|
|
60
|
+
re: /expect\s*\(\s*(true|false|null|undefined|-?\d+|"[^"]*"|'[^']*')\s*\)\s*\.\s*to(?:Be|Equal|StrictEqual)\s*\(\s*\1\s*\)/,
|
|
61
|
+
},
|
|
62
|
+
// (b) a literal tested against the truthiness it already has.
|
|
63
|
+
{ re: /expect\s*\(\s*(?:true|1|-1|"[^"]+"|'[^']+')\s*\)\s*\.\s*toBeTruthy\s*\(/ },
|
|
64
|
+
{ re: /expect\s*\(\s*(?:false|0|null|undefined|""|'')\s*\)\s*\.\s*toBeFalsy\s*\(/ },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** An empty catch body: the failure it guards is discarded. */
|
|
68
|
+
const EMPTY_CATCH_RE = /catch\s*(?:\([^)]*\))?\s*\{\s*\}/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is this line a fixture rather than a real assertion?
|
|
72
|
+
*
|
|
73
|
+
* A `.bats` test that exercises the Gate 5d classifier necessarily contains
|
|
74
|
+
* `'expect(true).toBe(true);'` as a quoted INPUT. Reporting it as debt would send a
|
|
75
|
+
* batch fixer to delete the very strings that pin the classifier — turning the
|
|
76
|
+
* "fix debt" instruction into an instruction to remove test coverage.
|
|
77
|
+
*
|
|
78
|
+
* The distinguishing feature is quoting: in a fixture the shape is wrapped in
|
|
79
|
+
* quotes and usually sits inside a list/assignment, where a real assertion is bare
|
|
80
|
+
* executable code.
|
|
81
|
+
*/
|
|
82
|
+
function isFixtureLine(trimmed) {
|
|
83
|
+
// A leading quote before the assertion shape: `'expect(...)...'` or "expect(...)".
|
|
84
|
+
if (/^['"`]/.test(trimmed)) return true;
|
|
85
|
+
// Assertion shape appearing inside a quoted string on the line.
|
|
86
|
+
if (/['"][^'"`]*expect\s*\(/.test(trimmed)) return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Classify one source line, or null when it is fine. */
|
|
91
|
+
function classifyLine(text) {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (trimmed === '') return null;
|
|
94
|
+
// A commented-out assertion is not an assertion.
|
|
95
|
+
if (/^(\/\/|\*|\/\*|#)/.test(trimmed)) return null;
|
|
96
|
+
if (isFixtureLine(trimmed)) return null;
|
|
97
|
+
for (const { re } of TAUTOLOGY_PATTERNS) {
|
|
98
|
+
if (re.test(trimmed)) return 'tautological';
|
|
99
|
+
}
|
|
100
|
+
if (EMPTY_CATCH_RE.test(trimmed)) return 'swallowed-error';
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Find debt in one file's text. */
|
|
105
|
+
function scanSource(text, file) {
|
|
106
|
+
const findings = [];
|
|
107
|
+
const lines = text.split(/\r?\n/);
|
|
108
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
109
|
+
const kind = classifyLine(lines[i]);
|
|
110
|
+
if (kind) findings.push({ line: i + 1, kind, text: lines[i].trim() });
|
|
111
|
+
}
|
|
112
|
+
return findings.length > 0 ? [{ file, findings }] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Walk the tree collecting test files, bounded to avoid node_modules. */
|
|
116
|
+
function listTestFiles(root, out = []) {
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
120
|
+
} catch {
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(entries)) return out;
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
127
|
+
listTestFiles(path.join(root, entry.name), out);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (isTestFile(entry.name)) out.push(path.join(root, entry.name));
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Scan for assertion debt.
|
|
137
|
+
*
|
|
138
|
+
* @param {{cwd?: string, source?: string, file?: string}} opts
|
|
139
|
+
* @returns {Array<{file: string, findings: Array<{line: number, kind: string, text: string}>}>}
|
|
140
|
+
*/
|
|
141
|
+
function scanForAssertionDebt(opts = {}) {
|
|
142
|
+
if (typeof opts.source === 'string') {
|
|
143
|
+
return scanSource(opts.source, opts.file || '<inline>');
|
|
144
|
+
}
|
|
145
|
+
const root = opts.cwd || process.cwd();
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const abs of listTestFiles(root)) {
|
|
148
|
+
let text;
|
|
149
|
+
try {
|
|
150
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
155
|
+
results.push(...scanSource(text, rel));
|
|
156
|
+
}
|
|
157
|
+
return results.sort((a, b) => b.findings.length - a.findings.length || a.file.localeCompare(b.file));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Totals for a debt run: per-kind counts plus the file count to batch over. */
|
|
161
|
+
function summarizeDebt(results) {
|
|
162
|
+
const byKind = { tautological: 0, 'swallowed-error': 0 };
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (const entry of results) {
|
|
165
|
+
for (const finding of entry.findings) {
|
|
166
|
+
byKind[finding.kind] = (byKind[finding.kind] || 0) + 1;
|
|
167
|
+
total += 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { files: results.length, total, byKind };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { classifyLine, scanForAssertionDebt, summarizeDebt, isTestFile };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xp-gate doctor --brief` — one command that answers every common environment
|
|
3
|
+
* probe (#653).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: 32.7% of shell calls in a working session were pure probing
|
|
6
|
+
* (`where gcc`, `Test-Path`, per-file `Get-Content`, version checks), at ~16s
|
|
7
|
+
* each. The common thread is that the answers barely change, yet every question
|
|
8
|
+
* costs a full round trip. The fix is not to print less — it is to ask once.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this module holds itself to:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Read-only.** `--brief` is meant to be called often precisely because it is
|
|
13
|
+
* cheap. Anything that writes would make frequent calling compound state
|
|
14
|
+
* changes instead of only latency.
|
|
15
|
+
* 2. **Structured first, summary second.** A model that has to regex a sentence
|
|
16
|
+
* has not saved a round trip. `facts` carries the data; `summary` is a
|
|
17
|
+
* convenience for humans and nothing depends on it.
|
|
18
|
+
*
|
|
19
|
+
* No runtime dependencies: this ships inside the zero-install package.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { execFileSync } = require('node:child_process');
|
|
26
|
+
|
|
27
|
+
/** The fact groups `--brief` promises. Tests pin this list; adding a group here
|
|
28
|
+
* without teaching the model to use it only adds output, so keep it deliberate. */
|
|
29
|
+
const BRIEF_FACTS = ['hooks', 'language', 'adapters', 'coverage', 'gateState'];
|
|
30
|
+
|
|
31
|
+
/** Run a command, returning trimmed stdout or null. Never throws. */
|
|
32
|
+
function tryExec(cmd, args, cwd) {
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync(cmd, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
timeout: 5000,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
});
|
|
40
|
+
return out.trim();
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which hooks actually execute, and where they live. */
|
|
47
|
+
function readHooks(cwd) {
|
|
48
|
+
const hooksPath = tryExec('git', ['config', '--get', 'core.hooksPath'], cwd);
|
|
49
|
+
const effective = hooksPath || '.git/hooks';
|
|
50
|
+
const dir = path.isAbsolute(effective) ? effective : path.join(cwd, effective);
|
|
51
|
+
let installed = [];
|
|
52
|
+
try {
|
|
53
|
+
installed = fs.readdirSync(dir).filter((name) => !name.endsWith('.sample'));
|
|
54
|
+
} catch {
|
|
55
|
+
installed = [];
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
hooksPath: hooksPath || null,
|
|
59
|
+
effectivePath: dir,
|
|
60
|
+
installed,
|
|
61
|
+
// #451: when core.hooksPath is set, the repo copy is NOT what runs.
|
|
62
|
+
globalOverride: Boolean(hooksPath),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Project language markers, cheapest signal first. */
|
|
67
|
+
const LANGUAGE_MARKERS = [
|
|
68
|
+
['typescript', ['tsconfig.json']],
|
|
69
|
+
['python', ['pyproject.toml', 'setup.py', 'requirements.txt']],
|
|
70
|
+
['go', ['go.mod']],
|
|
71
|
+
['java', ['pom.xml', 'build.gradle', 'build.gradle.kts']],
|
|
72
|
+
['kotlin', ['build.gradle.kts']],
|
|
73
|
+
['dart', ['pubspec.yaml']],
|
|
74
|
+
['rust', ['Cargo.toml']],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function detectLanguages(cwd) {
|
|
78
|
+
const found = [];
|
|
79
|
+
for (const [lang, markers] of LANGUAGE_MARKERS) {
|
|
80
|
+
if (markers.some((m) => fs.existsSync(path.join(cwd, m)))) found.push(lang);
|
|
81
|
+
}
|
|
82
|
+
return { detected: found, primary: found[0] || null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Which adapters are reachable via the documented 3-tier resolution. */
|
|
86
|
+
function detectAdapters(cwd) {
|
|
87
|
+
const tiers = [
|
|
88
|
+
path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'xp-gate', 'adapters'),
|
|
89
|
+
path.join(cwd, 'githooks', 'adapters'),
|
|
90
|
+
];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const sources = [];
|
|
93
|
+
for (const dir of tiers) {
|
|
94
|
+
let names = [];
|
|
95
|
+
try {
|
|
96
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.sh'));
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const n of names) seen.add(n);
|
|
101
|
+
sources.push({ dir, count: names.length });
|
|
102
|
+
}
|
|
103
|
+
return { count: seen.size, sources };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where coverage would come from, if anywhere. */
|
|
107
|
+
const COVERAGE_CANDIDATES = [
|
|
108
|
+
'coverage/coverage-final.json',
|
|
109
|
+
'coverage/lcov.info',
|
|
110
|
+
'target/site/jacoco/jacoco.xml',
|
|
111
|
+
'build/reports/jacoco/test/jacocoTestReport.xml',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function detectCoverage(cwd) {
|
|
115
|
+
const hits = COVERAGE_CANDIDATES.filter((rel) => fs.existsSync(path.join(cwd, rel)));
|
|
116
|
+
return { found: hits.length > 0, files: hits };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Last gate artifact, so callers stop re-reading `.xp-gate/` by hand. */
|
|
120
|
+
function readGateState(cwd) {
|
|
121
|
+
const artifact = path.join(cwd, '.xp-gate', 'quality-status', 'main.json');
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(fs.readFileSync(artifact, 'utf8'));
|
|
124
|
+
return {
|
|
125
|
+
artifact,
|
|
126
|
+
commit: parsed.commit || null,
|
|
127
|
+
gates: Object.keys(parsed).filter((k) => k.startsWith('gate')).length,
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
// No artifact is a fact worth stating, not an error: "never run" and
|
|
131
|
+
// "failed to read" would otherwise be indistinguishable.
|
|
132
|
+
return { artifact, commit: null, gates: 0 };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the brief report.
|
|
138
|
+
*
|
|
139
|
+
* @param {{cwd?: string}} opts
|
|
140
|
+
* @returns {{facts: object, summary: string}}
|
|
141
|
+
*/
|
|
142
|
+
function buildBriefReport(opts = {}) {
|
|
143
|
+
const cwd = opts.cwd || process.cwd();
|
|
144
|
+
const facts = {
|
|
145
|
+
hooks: readHooks(cwd),
|
|
146
|
+
language: detectLanguages(cwd),
|
|
147
|
+
adapters: detectAdapters(cwd),
|
|
148
|
+
coverage: detectCoverage(cwd),
|
|
149
|
+
gateState: readGateState(cwd),
|
|
150
|
+
};
|
|
151
|
+
const summary = [
|
|
152
|
+
`lang=${facts.language.primary || 'unknown'} adapters=${facts.adapters.count} ` +
|
|
153
|
+
`coverage=${facts.coverage.found ? 'yes' : 'no'} hooks=${facts.hooks.globalOverride ? 'global' : 'local'}`,
|
|
154
|
+
`hooksPath=${facts.hooks.effectivePath}`,
|
|
155
|
+
].join('\n');
|
|
156
|
+
return { facts, summary };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { BRIEF_FACTS, buildBriefReport };
|
package/lib/doctor.js
CHANGED
|
@@ -2873,7 +2873,7 @@ function diagnoseLanguageTools() {
|
|
|
2873
2873
|
// #488: an unrecognised flag used to be silently ignored — the npm release
|
|
2874
2874
|
// that predates --sync-hooks made "doctor --sync-hooks" a silent no-op and the
|
|
2875
2875
|
// user believed a sync had happened. Fail loudly instead.
|
|
2876
|
-
const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format', '--refresh-env'];
|
|
2876
|
+
const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format', '--refresh-env', '--brief'];
|
|
2877
2877
|
|
|
2878
2878
|
/**
|
|
2879
2879
|
* The accepted flag surface of `doctor`, as a pure function.
|
|
@@ -2931,11 +2931,31 @@ function doctorFlagContract(args) {
|
|
|
2931
2931
|
errors.push('--install-tools is only read by --fix, and it is the only flag that installs toolchains onto this machine');
|
|
2932
2932
|
errors.push('Pass --fix --install-tools, or drop --install-tools');
|
|
2933
2933
|
}
|
|
2934
|
-
|
|
2934
|
+
// #653: --brief exists to collapse a class of exploratory probes (`where gcc`,
|
|
2935
|
+
// Test-Path, per-file reads) into one cheap read. It is read-only by contract:
|
|
2936
|
+
// users call it often precisely because it is cheap, so a write smuggled in
|
|
2937
|
+
// would compound state changes instead of only latency.
|
|
2938
|
+
const briefFlag = args.includes('--brief');
|
|
2939
|
+
if (briefFlag && (fixMode || syncHooks || installToolsFlag || force)) {
|
|
2940
|
+
errors.push('--brief is a read-only fact dump and cannot be combined with --fix, --sync-hooks, --install-tools or --force');
|
|
2941
|
+
}
|
|
2942
|
+
return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv, briefFlag };
|
|
2935
2943
|
}
|
|
2936
2944
|
|
|
2937
2945
|
async function doctor(args) {
|
|
2938
|
-
const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv } = doctorFlagContract(args);
|
|
2946
|
+
const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv, briefFlag } = doctorFlagContract(args);
|
|
2947
|
+
if (briefFlag && errors.length === 0) {
|
|
2948
|
+
// Answers the whole "what is this machine set up like" question in one shot.
|
|
2949
|
+
const { buildBriefReport } = require('./doctor-brief');
|
|
2950
|
+
const report = buildBriefReport({ cwd: process.cwd() });
|
|
2951
|
+
if (jsonMode) {
|
|
2952
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
2953
|
+
} else {
|
|
2954
|
+
process.stdout.write(`${report.summary}\n`);
|
|
2955
|
+
}
|
|
2956
|
+
process.exitCode = 0;
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2939
2959
|
if (errors.length > 0) {
|
|
2940
2960
|
for (const error of errors) console.error(error);
|
|
2941
2961
|
return 1;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the gate thresholds that actually block a commit, from the files that
|
|
3
|
+
* define them (#652).
|
|
4
|
+
*
|
|
5
|
+
* Why generated rather than hand-written: BUILD-phase docs used to say "there are
|
|
6
|
+
* gates 0-11" without saying what the numbers are. The numbers live in
|
|
7
|
+
* `.principlesrc`, `jscpd.conf.json`, `.ccn-baseline.json` and the hook itself. A
|
|
8
|
+
* hand-copied table is correct on the day it is written and wrong on the day
|
|
9
|
+
* someone tunes a threshold — and a self-check list that lies is worse than none,
|
|
10
|
+
* because it teaches the wrong rule and then contradicts it at commit time.
|
|
11
|
+
*
|
|
12
|
+
* Every rule therefore carries an `evidence` pointer, so "the limit is 50" can be
|
|
13
|
+
* checked or argued with instead of being indistinguishable from a guess.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
/** Read and parse a JSON file, or null when absent/unreadable. */
|
|
21
|
+
function readJson(file) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalise severity spellings.
|
|
31
|
+
*
|
|
32
|
+
* The config files and the hook do not agree: `.principlesrc` says `warning`,
|
|
33
|
+
* Gate 3's boundary is `warn`, and both mean the same thing. Carrying two spellings
|
|
34
|
+
* into one list would make it look like two different severities exist.
|
|
35
|
+
*/
|
|
36
|
+
function normaliseSeverity(value) {
|
|
37
|
+
if (value === 'warning') return 'warn';
|
|
38
|
+
if (value === 'block' || value === 'warn' || value === 'info') return value;
|
|
39
|
+
return 'warn';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Flatten `.principlesrc` rules into entries with source + evidence. */
|
|
43
|
+
function principlesRules(cwd) {
|
|
44
|
+
const file = path.join(cwd, '.principlesrc');
|
|
45
|
+
const cfg = readJson(file);
|
|
46
|
+
if (!cfg || !cfg.rules) return [];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const group of Object.keys(cfg.rules)) {
|
|
49
|
+
for (const [name, rule] of Object.entries(cfg.rules[group] || {})) {
|
|
50
|
+
out.push({
|
|
51
|
+
id: `${group}.${name}`,
|
|
52
|
+
source: 'principles',
|
|
53
|
+
severity: normaliseSeverity(rule.severity),
|
|
54
|
+
// Not every rule is threshold-shaped (e.g. unused-imports); report the
|
|
55
|
+
// one that exists rather than inventing a 0.
|
|
56
|
+
threshold: typeof rule.threshold === 'number' ? rule.threshold : null,
|
|
57
|
+
evidence: '.principlesrc',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Gate 2's duplicate-code rule.
|
|
66
|
+
*
|
|
67
|
+
* `githooks/pre-commit` prints "jscpd found duplicated code (warning, not blocking
|
|
68
|
+
* by default)" -- this gate reports, it does not block. The BUILD-phase doc used to
|
|
69
|
+
* claim "jscpd similarity >5% BLOCK", which sent people to refactor for a verdict
|
|
70
|
+
* that never fires. A generated list must record what the hook does, not what the
|
|
71
|
+
* old prose said.
|
|
72
|
+
*/
|
|
73
|
+
function jscpdRules(cwd) {
|
|
74
|
+
const cfg = readJson(path.join(cwd, 'jscpd.conf.json'));
|
|
75
|
+
// jscpd itself has no similarity threshold in this project's config and the hook
|
|
76
|
+
// never compares a percentage. Reporting null says "no limit declared" instead of
|
|
77
|
+
// inventing a number the hook does not enforce.
|
|
78
|
+
const threshold = cfg && typeof cfg.threshold === 'number' ? cfg.threshold : null;
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
id: 'duplication.jscpd',
|
|
82
|
+
source: 'jscpd',
|
|
83
|
+
severity: 'info',
|
|
84
|
+
threshold,
|
|
85
|
+
evidence: 'githooks/pre-commit (Gate 2, advisory)',
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Gate 3 warn/block boundaries, which the hook itself owns. */
|
|
91
|
+
function ccnRules(cwd) {
|
|
92
|
+
return [
|
|
93
|
+
{ id: 'ccn.warn', source: 'ccn', severity: 'warn', threshold: 5, evidence: 'githooks/pre-commit (Gate 3)' },
|
|
94
|
+
{ id: 'ccn.block', source: 'ccn', severity: 'block', threshold: 10, evidence: 'githooks/pre-commit (Gate 3)' },
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Boy Scout's structural limits, read from wherever they are configured. */
|
|
99
|
+
function boyScoutRules(cwd) {
|
|
100
|
+
const out = [
|
|
101
|
+
{
|
|
102
|
+
id: 'boy-scout.new-file',
|
|
103
|
+
source: 'boy-scout',
|
|
104
|
+
severity: 'block',
|
|
105
|
+
threshold: 0,
|
|
106
|
+
evidence: 'src/principles/boy-scout.ts',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
// The per-function line and nesting limits are principles rules; when
|
|
110
|
+
// .principlesrc exists they are already listed by principlesRules, so only add
|
|
111
|
+
// here when it does not (so the BUILD list never silently loses them).
|
|
112
|
+
const cfg = readJson(path.join(cwd, '.principlesrc'));
|
|
113
|
+
if (!cfg || !cfg.rules || !cfg.rules['clean-code']) {
|
|
114
|
+
out.push(
|
|
115
|
+
{ id: 'clean-code.long-function', source: 'boy-scout', severity: 'warn', threshold: 50, evidence: 'defaults' },
|
|
116
|
+
{ id: 'clean-code.deep-nesting', source: 'boy-scout', severity: 'warn', threshold: 4, evidence: 'defaults' },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Collect every rule that can block or warn a commit.
|
|
124
|
+
*
|
|
125
|
+
* @param {{cwd?: string}} opts
|
|
126
|
+
* @returns {Array<{id: string, source: string, severity: string, threshold: number|null, evidence: string}>}
|
|
127
|
+
*/
|
|
128
|
+
function collectGateRules(opts = {}) {
|
|
129
|
+
const cwd = opts.cwd || process.cwd();
|
|
130
|
+
const rules = [...principlesRules(cwd), ...jscpdRules(cwd), ...ccnRules(cwd), ...boyScoutRules(cwd)];
|
|
131
|
+
// Stable order: blocking first, then warn/info, so a reader stops at what hurts.
|
|
132
|
+
const rank = { block: 0, warn: 1, info: 2 };
|
|
133
|
+
return rules.sort(
|
|
134
|
+
(a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) || a.id.localeCompare(b.id),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Render the checklist as markdown with an evidence column. */
|
|
139
|
+
function renderGateRules(rules) {
|
|
140
|
+
const lines = ['| Rule | Severity | Limit | Evidence |', '|---|---|---|---|'];
|
|
141
|
+
for (const rule of rules) {
|
|
142
|
+
lines.push(
|
|
143
|
+
`| \`${rule.id}\` | ${rule.severity} | ${rule.threshold === null ? '—' : rule.threshold} | \`${rule.evidence}\` |`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { collectGateRules, renderGateRules };
|
package/lib/gate-runner.js
CHANGED
|
@@ -320,8 +320,49 @@ const GATE_NOT_RUN = 'NOT_RUN';
|
|
|
320
320
|
* Finds the .ts entry point, spawns tsx with --run flag.
|
|
321
321
|
* Falls back to bash script if TypeScript module not found.
|
|
322
322
|
*/
|
|
323
|
+
/**
|
|
324
|
+
* Resolve the working directory for a gate run.
|
|
325
|
+
*
|
|
326
|
+
* `targetPath` is documented as "file or directory" (that is what
|
|
327
|
+
* `xp-gate check <file>` passes), but spawnSync's `cwd` must be a directory.
|
|
328
|
+
* Resolving a FILE path into cwd made spawnSync fail with a bare ENOENT that read
|
|
329
|
+
* as "Gate 3 failed to run npx tsx" — a tool failure reported under the label of a
|
|
330
|
+
* code verdict. The file's own directory is the meaningful cwd in that case.
|
|
331
|
+
*/
|
|
332
|
+
function resolveGateCwd(targetPath) {
|
|
333
|
+
if (!targetPath) return process.cwd();
|
|
334
|
+
const resolved = path.resolve(targetPath);
|
|
335
|
+
try {
|
|
336
|
+
if (fs.statSync(resolved).isFile()) return path.dirname(resolved);
|
|
337
|
+
} catch {
|
|
338
|
+
// Nonexistent path: leave it for the gate itself to report.
|
|
339
|
+
}
|
|
340
|
+
return resolved;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Resolve how to invoke npx without a shell.
|
|
345
|
+
*
|
|
346
|
+
* `shell: true` on Windows looked like the right fix for resolving npx.cmd, but
|
|
347
|
+
* node passes the argv to CreateProcess as one concatenated string without
|
|
348
|
+
* escaping (DEP0190) — so any entry path with a backslash or space produced a
|
|
349
|
+
* malformed command line and spawnSync failed with a bare ENOENT. The gate then
|
|
350
|
+
* reported FAIL while nothing had actually run: a tool failure wearing the verdict
|
|
351
|
+
* "your code is broken".
|
|
352
|
+
*
|
|
353
|
+
* Calling npx.cmd directly is not the fix either: since the CVE-2024-27980
|
|
354
|
+
* hardening, node refuses .cmd/.bat from spawnSync without a shell and returns
|
|
355
|
+
* EINVAL. Running npx's own cli.js with the current node binary sidesteps both —
|
|
356
|
+
* plain argv, no shell, no batch wrapper.
|
|
357
|
+
*/
|
|
358
|
+
function resolveNpxInvocation(args) {
|
|
359
|
+
const npxCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npx-cli.js');
|
|
360
|
+
if (fs.existsSync(npxCli)) return [process.execPath, [npxCli, ...args]];
|
|
361
|
+
return ['npx', args];
|
|
362
|
+
}
|
|
363
|
+
|
|
323
364
|
function runTsGate(gateModule, targetPath, opts = {}) {
|
|
324
|
-
const targetCwd =
|
|
365
|
+
const targetCwd = resolveGateCwd(targetPath);
|
|
325
366
|
const entry = findTsGateEntry(gateModule);
|
|
326
367
|
if (!entry) {
|
|
327
368
|
// Fallback to bash script
|
|
@@ -344,10 +385,15 @@ function runTsGate(gateModule, targetPath, opts = {}) {
|
|
|
344
385
|
// malformed argv, and reports a killed child (timeout, signal) as a null status.
|
|
345
386
|
// All three mean "the gate did not report a verdict", which must not be read as
|
|
346
387
|
// success: a gate that never ran is not a gate that passed.
|
|
388
|
+
//
|
|
389
|
+
// spawnSync reports a spawn failure (npx missing) on `error`, throws only on a
|
|
390
|
+
// malformed argv, and reports a killed child (timeout, signal) as a null status.
|
|
391
|
+
// All three mean "the gate did not report a verdict", which must not be read as
|
|
392
|
+
// success: a gate that never ran is not a gate that passed.
|
|
393
|
+
const [command, commandArgs] = resolveNpxInvocation(args);
|
|
347
394
|
try {
|
|
348
|
-
const result = spawnSync(
|
|
395
|
+
const result = spawnSync(command, commandArgs, {
|
|
349
396
|
stdio: 'inherit',
|
|
350
|
-
shell: process.platform === 'win32',
|
|
351
397
|
cwd: targetCwd,
|
|
352
398
|
timeout: TS_GATE_TIMEOUT_MS,
|
|
353
399
|
});
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 9f8b24d8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.7.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 9f8b24d8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.7.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.7",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 9f8b24d8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.7.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 9f8b24d8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.7.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
|