@boyingliu01/xp-gate 0.30.2 → 0.30.4
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/iac.sh +45 -4
- package/adapters/java.sh +82 -16
- package/adapters/powershell.sh +70 -9
- package/gate-10.sh +110 -13
- package/gate-3.sh +17 -0
- package/gate-4.sh +12 -0
- package/gate-7.sh +4 -0
- package/hooks/gate-10.sh +110 -13
- package/hooks/gate-3.sh +17 -0
- package/hooks/gate-4.sh +12 -0
- package/hooks/gate-7.sh +4 -0
- package/hooks/hook-module-resolution.sh +14 -0
- package/hooks/lib/coverage-total.sh +1 -1
- package/hooks/pre-commit +138 -17
- package/hooks/pre-push +150 -9
- package/hooks/sprint-gate.sh +81 -2
- package/lib/__tests__/hook-health.test.js +85 -0
- package/lib/coverage-total.sh +92 -0
- package/lib/doctor.js +44 -0
- package/lib/hook-health.js +121 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/mutation/__tests__/gate-m-survivors.test.ts +133 -0
- package/mutation/gate-m.ts +114 -11
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/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/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/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/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +81 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-546 hook health: line endings and bash parseability
|
|
3
|
+
* @intent Verify doctor can tell a hook that merely EXISTS from a hook git can
|
|
4
|
+
* actually run. #546: a pre-commit rewritten with CRLF line endings
|
|
5
|
+
* failed every commit while `xp-gate doctor` reported it healthy,
|
|
6
|
+
* because the check only asked whether the file was there.
|
|
7
|
+
* @covers AC-05, AC-08
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const { inspectHookFile } = require('../hook-health');
|
|
14
|
+
|
|
15
|
+
let TMP_DIR;
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-health-'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function write(name, content) {
|
|
26
|
+
const p = path.join(TMP_DIR, name);
|
|
27
|
+
fs.writeFileSync(p, content);
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('inspectHookFile', () => {
|
|
32
|
+
test('a healthy LF hook reports ok', () => {
|
|
33
|
+
const p = write('good.sh', '#!/bin/bash\nfoo() {\n echo hi\n}\nfoo\n');
|
|
34
|
+
const r = inspectHookFile(p);
|
|
35
|
+
expect(r.ok).toBe(true);
|
|
36
|
+
expect(r.crlfLines).toBe(0);
|
|
37
|
+
expect(r.problems).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an all-CRLF hook is reported with a CRLF count', () => {
|
|
41
|
+
const p = write('crlf.sh', '#!/bin/bash\r\nfoo() {\r\n echo hi\r\n}\r\nfoo\r\n');
|
|
42
|
+
const r = inspectHookFile(p);
|
|
43
|
+
expect(r.ok).toBe(false);
|
|
44
|
+
expect(r.crlfLines).toBe(5);
|
|
45
|
+
expect(r.problems.join(' ')).toMatch(/CRLF/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a single stray CRLF is enough to fail', () => {
|
|
49
|
+
// One bad line is all bash needs to choke on, and it is the case an
|
|
50
|
+
// "is the file mostly fine" heuristic would wave through.
|
|
51
|
+
const p = write('one.sh', '#!/bin/bash\necho one\necho two\r\necho three\n');
|
|
52
|
+
const r = inspectHookFile(p);
|
|
53
|
+
expect(r.ok).toBe(false);
|
|
54
|
+
expect(r.crlfLines).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('CRLF in the middle of a multi-line construct is flagged', () => {
|
|
58
|
+
// The exact shape from #546: the \r lands inside a construct bash is
|
|
59
|
+
// mid-way through parsing, so the reported line number drifts far from
|
|
60
|
+
// the real cause.
|
|
61
|
+
const p = write('mid.sh', 'case "$x" in\r\n a) echo a ;;\r\nesac\n');
|
|
62
|
+
const r = inspectHookFile(p);
|
|
63
|
+
expect(r.ok).toBe(false);
|
|
64
|
+
expect(r.crlfLines).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a syntax error with clean line endings is reported as unparseable', () => {
|
|
68
|
+
const p = write('bad.sh', '#!/bin/bash\nif [ 1 -eq 1 ]; then\n echo x\n');
|
|
69
|
+
const r = inspectHookFile(p);
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect(r.problems.join(' ')).toMatch(/not parseable|syntax/i);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a binary-looking file does not crash the inspector', () => {
|
|
75
|
+
const p = path.join(TMP_DIR, 'bin.sh');
|
|
76
|
+
fs.writeFileSync(p, Buffer.from([0x00, 0xff, 0xfe, 0x0a]));
|
|
77
|
+
expect(() => inspectHookFile(p)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a missing file reports missing rather than throwing', () => {
|
|
81
|
+
const r = inspectHookFile(path.join(TMP_DIR, 'nope.sh'));
|
|
82
|
+
expect(r.ok).toBe(false);
|
|
83
|
+
expect(r.problems.join(' ')).toMatch(/missing/i);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Gate 5's Python coverage parsing and threshold verdict (#523).
|
|
3
|
+
#
|
|
4
|
+
# Sourced by githooks/pre-commit; kept in its own file so the contract is
|
|
5
|
+
# unit-testable without executing the hook
|
|
6
|
+
# (githooks/__tests__/issue-523-python-coverage-parse.test.bats).
|
|
7
|
+
#
|
|
8
|
+
# The defect this file exists for: `coverage report --format=total` prints a
|
|
9
|
+
# BARE number ("97"), but the hook piped it through `grep -o '[0-9]*%'` — a
|
|
10
|
+
# pattern that requires a literal percent sign — so COVERAGE_PERCENT was always
|
|
11
|
+
# empty and the 80% threshold branch never executed. The gate answered "Could
|
|
12
|
+
# not determine" forever: a silent no-op, not a verdict.
|
|
13
|
+
|
|
14
|
+
# Parse the raw output of `coverage report --format=total` (stdin) and echo the
|
|
15
|
+
# integer total percentage — or nothing when the input carries no number.
|
|
16
|
+
#
|
|
17
|
+
# coverage.py 7.x --format=total prints a bare number; other tools (and older
|
|
18
|
+
# forms) append a percent sign. Only a line that IS a bare number (optionally
|
|
19
|
+
# %-suffixed) is accepted — a line that merely CONTAINS a number ("WARNING: 2
|
|
20
|
+
# files skipped") must not be mistaken for the total, so the whole line is
|
|
21
|
+
# matched, not the first token. A decimal part is truncated (floor — the
|
|
22
|
+
# conservative direction for a threshold gate, the same convention as the Java
|
|
23
|
+
# path's ${VAR%.*}). Inputs without any bare-number line (empty, "No data to
|
|
24
|
+
# report.", warnings) produce NO output so the caller takes its
|
|
25
|
+
# cannot-determine path: a wrong number is worse than none.
|
|
26
|
+
parse_coverage_total() {
|
|
27
|
+
awk '
|
|
28
|
+
{
|
|
29
|
+
line = $0
|
|
30
|
+
sub(/%$/, "", line)
|
|
31
|
+
gsub(/^[ \t]+|[ \t]+$/, "", line)
|
|
32
|
+
if (line ~ /^[0-9]+(\.[0-9]+)?$/) {
|
|
33
|
+
sub(/\.[0-9]+$/, "", line)
|
|
34
|
+
print line
|
|
35
|
+
exit
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Gate 5 Stage 2 Python coverage enforcement — the hook's python case body,
|
|
42
|
+
# extracted so the mock-PATH test exercises the real control flow. Echoes the
|
|
43
|
+
# verdict lines and returns 1 on BLOCK (the caller turns that into `exit 1`).
|
|
44
|
+
#
|
|
45
|
+
# COVERAGE_PERCENT is deliberately NOT declared local: the hook's quality
|
|
46
|
+
# report reads it after the Stage-2 loop (last writer wins across languages),
|
|
47
|
+
# exactly as the inline branch did.
|
|
48
|
+
enforce_python_coverage() {
|
|
49
|
+
# Critical-1 (#399): when the test run was skipped (PY_STRATEGY=skip, set in
|
|
50
|
+
# pre-commit before this Stage-2 function), refuse to consume a stale
|
|
51
|
+
# .coverage left by an earlier subset run — its low % is meaningless for a
|
|
52
|
+
# commit that changed no Python and must not BLOCK a docs-only change. This
|
|
53
|
+
# mirrors the TS branch's TESTS_SKIPPED guard.
|
|
54
|
+
if [ "${PY_TEST_SKIPPED:-false}" = "true" ]; then
|
|
55
|
+
echo "ℹ️ Test run was skipped — skipping Python coverage check."
|
|
56
|
+
return 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
if ! command -v coverage &> /dev/null; then
|
|
60
|
+
echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
COVERAGE_PERCENT=""
|
|
65
|
+
if [ -f ".coverage" ]; then
|
|
66
|
+
COVERAGE_PERCENT=$(coverage report --format=total 2>/dev/null | parse_coverage_total) || true
|
|
67
|
+
fi
|
|
68
|
+
if [ -z "$COVERAGE_PERCENT" ] && [ -f "coverage/.coverage" ]; then
|
|
69
|
+
COVERAGE_PERCENT=$(coverage report --format=total -i coverage 2>/dev/null | parse_coverage_total) || true
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
if [ -n "$COVERAGE_PERCENT" ]; then
|
|
73
|
+
if [ "$COVERAGE_PERCENT" -lt "${COVERAGE_THRESHOLD:-80}" ]; then
|
|
74
|
+
# #399: a subset run (smart test selection) measures only the tested
|
|
75
|
+
# files, so its global total is meaningless as a verdict — the full
|
|
76
|
+
# suite may well be >=80% even when the subset is not. Warn exactly as
|
|
77
|
+
# the TypeScript branch does (#286) instead of blocking. Full runs keep
|
|
78
|
+
# the hard BLOCK below.
|
|
79
|
+
if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
|
|
80
|
+
echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
|
|
81
|
+
echo " Run the full suite for a real Python coverage check."
|
|
82
|
+
return 0
|
|
83
|
+
fi
|
|
84
|
+
echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
|
|
85
|
+
return 1
|
|
86
|
+
fi
|
|
87
|
+
echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
|
|
88
|
+
else
|
|
89
|
+
echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
|
|
90
|
+
fi
|
|
91
|
+
return 0
|
|
92
|
+
}
|
package/lib/doctor.js
CHANGED
|
@@ -18,6 +18,14 @@ const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('
|
|
|
18
18
|
// #505: the single source of truth for module resolution order. The hook reads a
|
|
19
19
|
// generated shell projection of this same module.
|
|
20
20
|
const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
|
|
21
|
+
const { inspectHookFile } = require('./hook-health.js');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Files above this size get a `bash -n` probe in addition to the CRLF scan.
|
|
25
|
+
* Real xp-gate hooks are 40 KB (pre-push) to 190 KB (pre-commit); test fixtures
|
|
26
|
+
* and stubs are a few hundred bytes. 4 KB separates them comfortably.
|
|
27
|
+
*/
|
|
28
|
+
const PARSE_CHECK_MIN_BYTES = 4096;
|
|
21
29
|
const {
|
|
22
30
|
diagnoseTuiRegistration, diagnoseInstalledSkills,
|
|
23
31
|
fixTuiRegistration, ensureTuiRegistration, readTuiJson,
|
|
@@ -1256,10 +1264,46 @@ function checkSingleHook(hooksDir, name, signature, label, checks) {
|
|
|
1256
1264
|
checks.push({ name: `${label}: ${name}`, status: 'FAIL', detail: 'Missing or not xp-gate' });
|
|
1257
1265
|
return 1;
|
|
1258
1266
|
}
|
|
1267
|
+
// Existence and signature are not enough (#546): a hook rewritten with CRLF
|
|
1268
|
+
// line endings, or truncated mid-write, satisfies both and still cannot run.
|
|
1269
|
+
// Reporting it as healthy is worse than reporting nothing, because the
|
|
1270
|
+
// operator stops looking.
|
|
1271
|
+
//
|
|
1272
|
+
// The CRLF scan is pure and cheap; the `bash -n` probe spawns a process, so it
|
|
1273
|
+
// is skipped for files under the size floor below — test fixtures and stub
|
|
1274
|
+
// hooks are not the things that break here, and spawning bash per stub made
|
|
1275
|
+
// doctor slower than its own test timeout.
|
|
1276
|
+
const health = inspectHookFile(hookPath, { parseCheck: shouldParseCheck(hookPath) });
|
|
1277
|
+
if (!health.ok) {
|
|
1278
|
+
checks.push({
|
|
1279
|
+
name: `${label}: ${name}`,
|
|
1280
|
+
status: 'FAIL',
|
|
1281
|
+
detail: health.problems.join('; '),
|
|
1282
|
+
});
|
|
1283
|
+
return 1;
|
|
1284
|
+
}
|
|
1259
1285
|
checks.push({ name: `${label}: ${name}`, status: 'PASS', detail: hookPath });
|
|
1260
1286
|
return 0;
|
|
1261
1287
|
}
|
|
1262
1288
|
|
|
1289
|
+
/**
|
|
1290
|
+
* Should the `bash -n` probe run for this hook?
|
|
1291
|
+
*
|
|
1292
|
+
* Measured on a real install: a 190 KB pre-commit takes ~90 ms to parse, but a
|
|
1293
|
+
* dozen stubs in a test fixture still add up to seconds of process spawns. The
|
|
1294
|
+
* CRLF scan catches the #546 failure without spawning anything, so the probe is
|
|
1295
|
+
* reserved for files large enough to be a real hook.
|
|
1296
|
+
* @param {string} hookPath - Hook path
|
|
1297
|
+
* @returns {boolean} Whether to run the parse check
|
|
1298
|
+
*/
|
|
1299
|
+
function shouldParseCheck(hookPath) {
|
|
1300
|
+
try {
|
|
1301
|
+
return fs.statSync(hookPath).size > PARSE_CHECK_MIN_BYTES;
|
|
1302
|
+
} catch {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1263
1307
|
/**
|
|
1264
1308
|
* Expected gate scripts that should be present in the adapters directory.
|
|
1265
1309
|
* gate-5.sh and gate-6.sh are inline in pre-commit, so they are NOT expected here.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook file health inspection (#546).
|
|
3
|
+
*
|
|
4
|
+
* `xp-gate doctor` used to answer one question about an installed hook: does the
|
|
5
|
+
* file exist and does it carry the xp-gate signature. A hook can satisfy both
|
|
6
|
+
* and still be unrunnable — #546 was a pre-commit that had been rewritten with
|
|
7
|
+
* CRLF line endings, which fails every commit while doctor reported ✓.
|
|
8
|
+
*
|
|
9
|
+
* Two independent failure modes are checked here:
|
|
10
|
+
* - CRLF line endings: git runs hooks through bash, and a stray \r makes the
|
|
11
|
+
* parser choke at a line number far from the real cause (the reported line
|
|
12
|
+
* is wherever the multi-line construct that swallowed the \r began).
|
|
13
|
+
* - Unparseable syntax: a truncated or mangled write leaves a hook that
|
|
14
|
+
* exists but cannot run.
|
|
15
|
+
*
|
|
16
|
+
* Both are reported per file rather than as a single boolean, because the
|
|
17
|
+
* operator needs to know which one happened: CRLF has a mechanical fix, a
|
|
18
|
+
* syntax error does not.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Locate a bash binary without assuming it is on PATH under that exact name.
|
|
26
|
+
* @returns {string|null} Path or bare command name, null when none found
|
|
27
|
+
*/
|
|
28
|
+
function findBash() {
|
|
29
|
+
const candidates = ['bash'];
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
// Git for Windows ships bash outside PATH far more often than inside it.
|
|
32
|
+
const pf = process.env.ProgramFiles || 'C:\\Program Files';
|
|
33
|
+
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
|
34
|
+
const local = process.env.LOCALAPPDATA;
|
|
35
|
+
if (local) candidates.push(path.join(local, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
36
|
+
candidates.push(path.join(pf, 'Git', 'bin', 'bash.exe'));
|
|
37
|
+
candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'));
|
|
38
|
+
candidates.push(path.join(pf, 'Git', 'usr', 'bin', 'bash.exe'));
|
|
39
|
+
}
|
|
40
|
+
for (const c of candidates) {
|
|
41
|
+
if (path.isAbsolute(c)) {
|
|
42
|
+
if (fs.existsSync(c)) return c;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const probe = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 10000 });
|
|
46
|
+
if (!probe.error && probe.status === 0) return c;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Count lines that end with CRLF.
|
|
53
|
+
* @param {string} text - File content
|
|
54
|
+
* @returns {number} Number of CRLF-terminated lines
|
|
55
|
+
*/
|
|
56
|
+
function countCrlfLines(text) {
|
|
57
|
+
const matches = text.match(/\r\n/g);
|
|
58
|
+
return matches ? matches.length : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ask bash whether it can parse the file (`bash -n`).
|
|
63
|
+
* @param {string} filePath - Hook to check
|
|
64
|
+
* @param {string} bashBin - Resolved bash binary
|
|
65
|
+
* @returns {{checked: boolean, ok: boolean, detail: string}} Parse verdict
|
|
66
|
+
*/
|
|
67
|
+
function checkParseable(filePath, bashBin) {
|
|
68
|
+
if (!bashBin) return { checked: false, ok: true, detail: 'bash not found; parse check skipped' };
|
|
69
|
+
const res = spawnSync(bashBin, ['-n', filePath], { encoding: 'utf8', timeout: 30000 });
|
|
70
|
+
if (res.error) return { checked: false, ok: true, detail: `bash -n could not run: ${res.error.message}` };
|
|
71
|
+
if (res.status === 0) return { checked: true, ok: true, detail: 'parses' };
|
|
72
|
+
// bash reports the first syntax error it hits. Under CRLF that line number is
|
|
73
|
+
// unreliable, which is exactly why the CRLF count is reported alongside.
|
|
74
|
+
const firstLine = String(res.stderr || '').split('\n').find(l => l.trim()) || `exit ${res.status}`;
|
|
75
|
+
return { checked: true, ok: false, detail: firstLine.trim() };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Inspect one hook file for the conditions that make git unable to run it.
|
|
80
|
+
* @param {string} filePath - Hook path
|
|
81
|
+
* @param {{bashBin?: string|null, parseCheck?: boolean}} [options] - Overrides
|
|
82
|
+
* @returns {{ok: boolean, crlfLines: number, parseable: boolean, problems: string[]}}
|
|
83
|
+
*/
|
|
84
|
+
function inspectHookFile(filePath, options = {}) {
|
|
85
|
+
const problems = [];
|
|
86
|
+
|
|
87
|
+
if (!fs.existsSync(filePath)) {
|
|
88
|
+
return { ok: false, crlfLines: 0, parseable: false, problems: [`hook file is missing: ${filePath}`] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let text = '';
|
|
92
|
+
try {
|
|
93
|
+
text = fs.readFileSync(filePath, 'latin1');
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, crlfLines: 0, parseable: false, problems: [`hook file is unreadable: ${err.message}`] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const crlfLines = countCrlfLines(text);
|
|
99
|
+
if (crlfLines > 0) {
|
|
100
|
+
problems.push(
|
|
101
|
+
`hook has CRLF line endings (${crlfLines} line(s)) — bash will fail to parse it; ` +
|
|
102
|
+
'run `tr -d "\\r" < hook > hook.fixed && mv hook.fixed hook`'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A CRLF hit already answers the question this probe exists to answer, and
|
|
107
|
+
// the probe is the expensive half. Callers can also opt out for stubs.
|
|
108
|
+
if (crlfLines > 0 || options.parseCheck === false) {
|
|
109
|
+
return { ok: problems.length === 0, crlfLines, parseable: crlfLines === 0, problems };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const bashBin = options.bashBin !== undefined ? options.bashBin : findBash();
|
|
113
|
+
const parsed = checkParseable(filePath, bashBin);
|
|
114
|
+
if (!parsed.ok) {
|
|
115
|
+
problems.push(`hook is not parseable by bash: ${parsed.detail}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: problems.length === 0, crlfLines, parseable: parsed.ok, problems };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { inspectHookFile, findBash, countCrlfLines };
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-20
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 54318594
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.30.
|
|
6
|
+
**Version:** 0.30.4.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-20
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 54318594
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.30.
|
|
6
|
+
**Version:** 0.30.4.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.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test #544 - Gate M survivor detail for retro
|
|
3
|
+
* @intent Verify Gate M reports per-file mutant outcome counts, not just a
|
|
4
|
+
* score. #544: the pre-push journal carried
|
|
5
|
+
* {skip_reason, duration_ms, mutation_score} and nothing else, so
|
|
6
|
+
* `xp-gate retro` could not answer "which module has the most surviving
|
|
7
|
+
* mutants". The data was never missing at the source: Stryker's JSON
|
|
8
|
+
* reporter emits files[].nrOfSurvivedMutants, and stryker-types.ts
|
|
9
|
+
* already declares it. The counts were dropped on the way through, then
|
|
10
|
+
* buildMutationScores hardcoded {mutants: 0, killed: 0, survived: 0}.
|
|
11
|
+
* Three zeros written next to a real score read as "measured: no
|
|
12
|
+
* survivors" -- the same class of false success this project keeps
|
|
13
|
+
* fixing, so unknown must stay absent, never zero.
|
|
14
|
+
* @covers REQ-MUT-001 AC-001
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
summarizeSurvivors,
|
|
20
|
+
type StrykerReportLike,
|
|
21
|
+
type SurvivorSummary,
|
|
22
|
+
} from '../gate-m';
|
|
23
|
+
|
|
24
|
+
/** A report shaped like Stryker's JSON output. */
|
|
25
|
+
function report(files: Record<string, [number, number, number]>): StrykerReportLike {
|
|
26
|
+
const entries = Object.entries(files);
|
|
27
|
+
const totalMutants = entries.reduce((a, [, v]) => a + v[0], 0);
|
|
28
|
+
const totalKilled = entries.reduce((a, [, v]) => a + v[1], 0);
|
|
29
|
+
const totalSurvived = entries.reduce((a, [, v]) => a + v[2], 0);
|
|
30
|
+
return {
|
|
31
|
+
mutationScore: totalMutants > 0 ? parseFloat(((totalKilled / totalMutants) * 100).toFixed(1)) : 100,
|
|
32
|
+
nrOfMutants: totalMutants,
|
|
33
|
+
nrOfKilledMutants: totalKilled,
|
|
34
|
+
nrOfSurvivedMutants: totalSurvived,
|
|
35
|
+
files: Object.fromEntries(
|
|
36
|
+
entries.map(([k, [m, kd, sv]]) => [k, {
|
|
37
|
+
mutationScore: m > 0 ? parseFloat(((kd / m) * 100).toFixed(1)) : 100,
|
|
38
|
+
nrOfMutants: m,
|
|
39
|
+
nrOfKilledMutants: kd,
|
|
40
|
+
nrOfSurvivedMutants: sv,
|
|
41
|
+
}])
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Narrow to a non-null summary, failing the test with a clear message. */
|
|
47
|
+
function expectSummary(s: SurvivorSummary | null): SurvivorSummary {
|
|
48
|
+
expect(s).not.toBeNull();
|
|
49
|
+
if (s === null) throw new Error('expected a survivor summary, got null');
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('AC-544: Gate M survivor detail', () => {
|
|
54
|
+
it('reports per-file survived counts from the Stryker report', () => {
|
|
55
|
+
const summary = expectSummary(summarizeSurvivors(report({
|
|
56
|
+
'src/mutation/gate-m.ts': [20, 8, 12],
|
|
57
|
+
'src/principles/analyzer.ts': [10, 3, 7],
|
|
58
|
+
})));
|
|
59
|
+
|
|
60
|
+
expect(summary.survivedByFile).toEqual({
|
|
61
|
+
'src/mutation/gate-m.ts': 12,
|
|
62
|
+
'src/principles/analyzer.ts': 7,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('totals killed, survived and mutants', () => {
|
|
67
|
+
const summary = expectSummary(summarizeSurvivors(report({
|
|
68
|
+
'a.ts': [20, 8, 12],
|
|
69
|
+
'b.ts': [10, 3, 7],
|
|
70
|
+
})));
|
|
71
|
+
|
|
72
|
+
expect(summary.mutants).toBe(30);
|
|
73
|
+
expect(summary.killed).toBe(11);
|
|
74
|
+
expect(summary.survived).toBe(19);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('orders survivedByFile worst-first so retro can take the top N', () => {
|
|
78
|
+
const summary = expectSummary(summarizeSurvivors(report({
|
|
79
|
+
'small.ts': [10, 9, 1],
|
|
80
|
+
'worst.ts': [10, 0, 10],
|
|
81
|
+
'middle.ts': [10, 5, 5],
|
|
82
|
+
})));
|
|
83
|
+
|
|
84
|
+
expect(Object.keys(summary.survivedByFile)).toEqual(['worst.ts', 'middle.ts', 'small.ts']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('caps the per-file map so one huge repo cannot bloat the journal', () => {
|
|
88
|
+
const many: Record<string, [number, number, number]> = {};
|
|
89
|
+
// Every file must have at least one survivor, otherwise this asserts on a
|
|
90
|
+
// smaller population than intended and the count below drifts.
|
|
91
|
+
for (let i = 0; i < 50; i++) {
|
|
92
|
+
const survived = (i % 9) + 1; // 1..9, never 0
|
|
93
|
+
many[`f${i}.ts`] = [10, 10 - survived, survived];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const summary = expectSummary(summarizeSurvivors(report(many), { limit: 20 }));
|
|
97
|
+
|
|
98
|
+
expect(Object.keys(summary.survivedByFile)).toHaveLength(20);
|
|
99
|
+
// Truncation must be visible, not silent: a capped list that looks complete
|
|
100
|
+
// is how "top 20" gets mistaken for "all files".
|
|
101
|
+
expect(summary.truncated).toBe(true);
|
|
102
|
+
expect(summary.filesWithSurvivors).toBe(50);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not claim truncation when everything fits', () => {
|
|
106
|
+
const summary = expectSummary(summarizeSurvivors(report({ 'a.ts': [10, 5, 5] }), { limit: 20 }));
|
|
107
|
+
expect(summary.truncated).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('omits files with zero survivors', () => {
|
|
111
|
+
const summary = expectSummary(summarizeSurvivors(report({
|
|
112
|
+
'clean.ts': [10, 10, 0],
|
|
113
|
+
'dirty.ts': [10, 4, 6],
|
|
114
|
+
})));
|
|
115
|
+
|
|
116
|
+
expect(summary.survivedByFile).toEqual({ 'dirty.ts': 6 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('degrades to null instead of inventing zeros when no report is available', () => {
|
|
120
|
+
// A missing/timed-out report must not be reported as "0 survivors" -- that
|
|
121
|
+
// is the #544 failure repeated. null means unknown.
|
|
122
|
+
expect(summarizeSurvivors(null)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('degrades to null when the report has no per-file section', () => {
|
|
126
|
+
expect(summarizeSurvivors({
|
|
127
|
+
mutationScore: 80,
|
|
128
|
+
nrOfMutants: 10,
|
|
129
|
+
nrOfKilledMutants: 8,
|
|
130
|
+
nrOfSurvivedMutants: 2,
|
|
131
|
+
})).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
package/mutation/gate-m.ts
CHANGED
|
@@ -522,6 +522,86 @@ function buildMutationScores(fileScores: Record<string, number>): Record<string,
|
|
|
522
522
|
return scores;
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
/**
|
|
526
|
+
* The subset of a runner report that survivor summarisation needs. Declared
|
|
527
|
+
* structurally rather than as a concrete Stryker type because every runner
|
|
528
|
+
* (Stryker, mutmut, gremlins, go-mutant) normalises to `MutationRunResult`,
|
|
529
|
+
* which carries the same field names -- the summary must work for all of them,
|
|
530
|
+
* not just the TS one.
|
|
531
|
+
*/
|
|
532
|
+
export interface StrykerReportLike {
|
|
533
|
+
mutationScore: number;
|
|
534
|
+
nrOfMutants: number;
|
|
535
|
+
nrOfKilledMutants: number;
|
|
536
|
+
nrOfSurvivedMutants: number;
|
|
537
|
+
files?: Record<string, {
|
|
538
|
+
mutationScore: number;
|
|
539
|
+
nrOfMutants: number;
|
|
540
|
+
nrOfKilledMutants: number;
|
|
541
|
+
nrOfSurvivedMutants: number;
|
|
542
|
+
}>;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Per-file mutant outcome detail for the pre-push journal (#544). */
|
|
546
|
+
export interface SurvivorSummary {
|
|
547
|
+
/** Files with at least one surviving mutant, worst first, capped by `limit`. */
|
|
548
|
+
survivedByFile: Record<string, number>;
|
|
549
|
+
/**
|
|
550
|
+
* How many files actually had survivors. Present so a capped map can be told
|
|
551
|
+
* apart from a complete one -- a truncated list that looks exhaustive is how
|
|
552
|
+
* "top 20" gets read as "all files".
|
|
553
|
+
*/
|
|
554
|
+
filesWithSurvivors: number;
|
|
555
|
+
/** True when `survivedByFile` dropped entries to stay under `limit`. */
|
|
556
|
+
truncated: boolean;
|
|
557
|
+
mutants: number;
|
|
558
|
+
killed: number;
|
|
559
|
+
survived: number;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Condense a Stryker report into survivor detail (#544).
|
|
564
|
+
*
|
|
565
|
+
* Returns `null` -- never a summary of zeros -- when the report is missing or
|
|
566
|
+
* carries no per-file section. A timed-out or failed run has no measurements,
|
|
567
|
+
* and writing `survived: 0` next to a real-looking score would report
|
|
568
|
+
* "measured: no survivors" for a run that measured nothing. That is the exact
|
|
569
|
+
* shape of false success this project keeps finding, so unknown stays `null`.
|
|
570
|
+
*/
|
|
571
|
+
export function summarizeSurvivors(
|
|
572
|
+
report: StrykerReportLike | null | undefined,
|
|
573
|
+
options: { limit?: number } = {},
|
|
574
|
+
): SurvivorSummary | null {
|
|
575
|
+
if (!report || !report.files) return null;
|
|
576
|
+
|
|
577
|
+
const limit = options.limit ?? 20;
|
|
578
|
+
const survivors: Array<[string, number]> = [];
|
|
579
|
+
let mutants = 0;
|
|
580
|
+
let killed = 0;
|
|
581
|
+
let survived = 0;
|
|
582
|
+
|
|
583
|
+
for (const [file, entry] of Object.entries(report.files)) {
|
|
584
|
+
const fileSurvived = entry.nrOfSurvivedMutants ?? 0;
|
|
585
|
+
mutants += entry.nrOfMutants ?? 0;
|
|
586
|
+
killed += entry.nrOfKilledMutants ?? 0;
|
|
587
|
+
survived += fileSurvived;
|
|
588
|
+
if (fileSurvived > 0) survivors.push([file, fileSurvived]);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Worst first, then by path so equal counts order deterministically.
|
|
592
|
+
survivors.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
593
|
+
const kept = survivors.slice(0, limit);
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
survivedByFile: Object.fromEntries(kept),
|
|
597
|
+
filesWithSurvivors: survivors.length,
|
|
598
|
+
truncated: survivors.length > kept.length,
|
|
599
|
+
mutants,
|
|
600
|
+
killed,
|
|
601
|
+
survived,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
525
605
|
export async function runGateM(options: GateMOptions): Promise<GateMResult> {
|
|
526
606
|
const warnings: string[] = [];
|
|
527
607
|
const errors: string[] = [];
|
|
@@ -595,6 +675,22 @@ async function collectTestIntentWarnings(sourceFiles: string[], warnings: string
|
|
|
595
675
|
}
|
|
596
676
|
}
|
|
597
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Read the mutation report the runner left on disk and summarise it (#544).
|
|
680
|
+
* Returns null when there is no readable report -- the caller then prints
|
|
681
|
+
* nothing, and the journal field stays absent. Every failure here is silent by
|
|
682
|
+
* design: #544 explicitly requires that missing detail must not turn Gate M
|
|
683
|
+
* itself into a failure.
|
|
684
|
+
*/
|
|
685
|
+
async function readSurvivorSummary(): Promise<SurvivorSummary | null> {
|
|
686
|
+
try {
|
|
687
|
+
const raw = await fs.readFile('.stryker-report.json', 'utf-8');
|
|
688
|
+
return summarizeSurvivors(JSON.parse(raw) as StrykerReportLike);
|
|
689
|
+
} catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
598
694
|
export async function main(args: string[]): Promise<number> {
|
|
599
695
|
const options = parseArgs(args);
|
|
600
696
|
|
|
@@ -611,19 +707,14 @@ export async function main(args: string[]): Promise<number> {
|
|
|
611
707
|
|
|
612
708
|
const result = await runGateM(options);
|
|
613
709
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
710
|
+
// #544: survivor detail on a machine-readable line. Printed only when a report
|
|
711
|
+
// exists -- absent means "not measured", never "measured: zero survivors".
|
|
712
|
+
const survivors = await readSurvivorSummary();
|
|
713
|
+
if (survivors) {
|
|
714
|
+
console.log(`GATE_M_SURVIVORS_JSON=${JSON.stringify(survivors)}`);
|
|
619
715
|
}
|
|
620
716
|
|
|
621
|
-
|
|
622
|
-
console.log('\nErrors:');
|
|
623
|
-
for (const error of result.errors) {
|
|
624
|
-
console.error(` ✗ ${error}`);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
717
|
+
logDiagnostics(result.warnings, result.errors);
|
|
627
718
|
|
|
628
719
|
const statusIcon = result.status === 'pass' ? '✓' :
|
|
629
720
|
result.status === 'skip' ? '⊘' :
|
|
@@ -634,6 +725,18 @@ export async function main(args: string[]): Promise<number> {
|
|
|
634
725
|
return result.exitCode;
|
|
635
726
|
}
|
|
636
727
|
|
|
728
|
+
/** Print Gate M's warnings and errors. Errors go to stderr. */
|
|
729
|
+
function logDiagnostics(warnings: string[], errors: string[]): void {
|
|
730
|
+
if (warnings.length > 0) {
|
|
731
|
+
console.log('\nWarnings:');
|
|
732
|
+
for (const warning of warnings) console.log(` ⚠ ${warning}`);
|
|
733
|
+
}
|
|
734
|
+
if (errors.length > 0) {
|
|
735
|
+
console.log('\nErrors:');
|
|
736
|
+
for (const error of errors) console.error(` ✗ ${error}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
637
740
|
const cliArgs = process.argv.slice(2);
|
|
638
741
|
if (typeof require !== 'undefined' && require.main === module) {
|
|
639
742
|
main(cliArgs)
|