@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.
Files changed (47) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/gate-10.sh +110 -13
  5. package/gate-3.sh +17 -0
  6. package/gate-4.sh +12 -0
  7. package/gate-7.sh +4 -0
  8. package/hooks/gate-10.sh +110 -13
  9. package/hooks/gate-3.sh +17 -0
  10. package/hooks/gate-4.sh +12 -0
  11. package/hooks/gate-7.sh +4 -0
  12. package/hooks/hook-module-resolution.sh +14 -0
  13. package/hooks/lib/coverage-total.sh +1 -1
  14. package/hooks/pre-commit +138 -17
  15. package/hooks/pre-push +150 -9
  16. package/hooks/sprint-gate.sh +81 -2
  17. package/lib/__tests__/hook-health.test.js +85 -0
  18. package/lib/coverage-total.sh +92 -0
  19. package/lib/doctor.js +44 -0
  20. package/lib/hook-health.js +121 -0
  21. package/lib/mattpocock-license-verdict.json +1 -1
  22. package/mock-policy/AGENTS.md +2 -2
  23. package/mutation/AGENTS.md +2 -2
  24. package/mutation/__tests__/gate-m-survivors.test.ts +133 -0
  25. package/mutation/gate-m.ts +114 -11
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/skills/delphi-review/AGENTS.md +2 -2
  45. package/skills/sprint-flow/AGENTS.md +2 -2
  46. package/skills/test-specification-alignment/AGENTS.md +2 -2
  47. 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 };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-20T12:36:08.136Z",
5
+ "checked_at": "2026-10-20T23:31:25.547Z",
6
6
  "source": "network"
7
7
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 54318594
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
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.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-20
4
- **Commit:** 9fab5b3b
4
+ **Commit:** 54318594
5
5
  **Branch:** main
6
- **Version:** 0.30.2.0
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
+ });
@@ -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
- if (result.warnings.length > 0) {
615
- console.log('\nWarnings:');
616
- for (const warning of result.warnings) {
617
- console.log(` ⚠ ${warning}`);
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
- if (result.errors.length > 0) {
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)