@boyingliu01/xp-gate 0.30.1 → 0.30.2

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 (33) hide show
  1. package/bin/xp-gate.js +8 -0
  2. package/gate-3.sh +30 -6
  3. package/hooks/gate-3.sh +30 -6
  4. package/lib/__tests__/crap-cli.test.js +76 -0
  5. package/lib/__tests__/crap.test.js +137 -0
  6. package/lib/__tests__/retro-gate-m.test.js +144 -0
  7. package/lib/crap-cli.js +191 -0
  8. package/lib/crap.js +196 -0
  9. package/lib/mattpocock-license-verdict.json +1 -1
  10. package/lib/retro.js +139 -2
  11. package/mock-policy/AGENTS.md +2 -2
  12. package/mutation/AGENTS.md +2 -2
  13. package/package.json +1 -1
  14. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  15. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  16. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  17. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  18. package/plugins/dsh/package.json +1 -1
  19. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  20. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  21. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/opencode/package.json +1 -1
  23. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  25. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  26. package/plugins/qoder/plugin.json +1 -1
  27. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  28. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  29. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  30. package/principles/AGENTS.md +2 -2
  31. package/skills/delphi-review/AGENTS.md +2 -2
  32. package/skills/sprint-flow/AGENTS.md +2 -2
  33. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -350,6 +350,14 @@ const COMMANDS = {
350
350
  },
351
351
  usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
352
352
  },
353
+ 'crap': {
354
+ description: 'Rank functions by CRAP score (complexity x weak coverage) — advisory, never blocks (#404)',
355
+ run: subargs => {
356
+ const { handleCrap } = require('../lib/crap-cli.js');
357
+ handleCrap(subargs).then(code => process.exit(code));
358
+ },
359
+ usage: 'xp-gate crap [--top N] [--threshold N] [--json] [--paths <p...>] [--include-mirrors]'
360
+ },
353
361
  'sprint-init': {
354
362
  description: 'Initialize sprint state (single entry point for sprint creation, resolves #366)',
355
363
  run: subargs => {
package/gate-3.sh CHANGED
@@ -179,16 +179,40 @@ else
179
179
  GATE_3_STATUS="PASS"
180
180
  fi
181
181
  else
182
- # Parse warning count from the summary table: "Warning cnt 8"
183
- # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
184
- CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
185
- CC_WARNINGS=${CC_WARNINGS:-0}
186
-
187
- if [ "$CC_WARNINGS" -gt 0 ]; then
182
+ # #543: count from lizard's machine-readable CSV, never its tables.
183
+ #
184
+ # The previous parser grepped '^Warning cnt' for a summary row. Two things
185
+ # made that never match: "Warning cnt" is a column header in the MIDDLE of
186
+ # a line ("Total nloc Avg.NLOC ... Warning cnt Fun Rt nloc Rt"), and
187
+ # lizard omits the summary block entirely when given explicit file
188
+ # arguments -- which is how this gate always invokes it. CC_WARNINGS was
189
+ # therefore always 0 and the flat-threshold path could not block anything.
190
+ #
191
+ # --csv has a stable column order (NLOC,CCN,token,PARAM,length,location,
192
+ # file,name,signature,...), so the verdict comes from the same numbers
193
+ # lizard uses, independent of version and table formatting. --csv here is
194
+ # the flat-threshold run; the ratchet branch above builds its own.
195
+ CC_CSV_FLAT=$("$LIZARD_PATH" --csv -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
196
+ CC_WARNINGS=$(printf '%s\n' "$CC_CSV_FLAT" | awk -F, -v t="$CCN_THRESHOLD" '
197
+ NF > 1 && $2 + 0 > t + 0 { c++ }
198
+ END { print c + 0 }
199
+ ')
200
+ # A lizard that produced no parsable row must not read as "clean". Without
201
+ # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
202
+ # reports PASSED on zero analysed functions -- the same silent-success
203
+ # failure this issue is about.
204
+ if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
205
+ END { exit(found ? 0 : 1) }'; then
206
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
207
+ GATE_3_STATUS="WARN"
208
+ GATE_3_REASON="lizard_output_unparsable"
209
+ elif [ "$CC_WARNINGS" -gt 0 ]; then
188
210
  echo "$CC_OUTPUT"
189
211
  echo ""
190
212
  echo "❌ BLOCKED - $CC_WARNINGS functions with CCN > $CCN_THRESHOLD found."
191
213
  echo "Refactor high-complexity functions to keep below $CCN_THRESHOLD complexity."
214
+ GATE_3_STATUS="BLOCK"
215
+ GATE_3_REASON="ccn_over_threshold"
192
216
  exit 1
193
217
  else
194
218
  echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
package/hooks/gate-3.sh CHANGED
@@ -179,16 +179,40 @@ else
179
179
  GATE_3_STATUS="PASS"
180
180
  fi
181
181
  else
182
- # Parse warning count from the summary table: "Warning cnt 8"
183
- # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
184
- CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
185
- CC_WARNINGS=${CC_WARNINGS:-0}
186
-
187
- if [ "$CC_WARNINGS" -gt 0 ]; then
182
+ # #543: count from lizard's machine-readable CSV, never its tables.
183
+ #
184
+ # The previous parser grepped '^Warning cnt' for a summary row. Two things
185
+ # made that never match: "Warning cnt" is a column header in the MIDDLE of
186
+ # a line ("Total nloc Avg.NLOC ... Warning cnt Fun Rt nloc Rt"), and
187
+ # lizard omits the summary block entirely when given explicit file
188
+ # arguments -- which is how this gate always invokes it. CC_WARNINGS was
189
+ # therefore always 0 and the flat-threshold path could not block anything.
190
+ #
191
+ # --csv has a stable column order (NLOC,CCN,token,PARAM,length,location,
192
+ # file,name,signature,...), so the verdict comes from the same numbers
193
+ # lizard uses, independent of version and table formatting. --csv here is
194
+ # the flat-threshold run; the ratchet branch above builds its own.
195
+ CC_CSV_FLAT=$("$LIZARD_PATH" --csv -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
196
+ CC_WARNINGS=$(printf '%s\n' "$CC_CSV_FLAT" | awk -F, -v t="$CCN_THRESHOLD" '
197
+ NF > 1 && $2 + 0 > t + 0 { c++ }
198
+ END { print c + 0 }
199
+ ')
200
+ # A lizard that produced no parsable row must not read as "clean". Without
201
+ # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
202
+ # reports PASSED on zero analysed functions -- the same silent-success
203
+ # failure this issue is about.
204
+ if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
205
+ END { exit(found ? 0 : 1) }'; then
206
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
207
+ GATE_3_STATUS="WARN"
208
+ GATE_3_REASON="lizard_output_unparsable"
209
+ elif [ "$CC_WARNINGS" -gt 0 ]; then
188
210
  echo "$CC_OUTPUT"
189
211
  echo ""
190
212
  echo "❌ BLOCKED - $CC_WARNINGS functions with CCN > $CCN_THRESHOLD found."
191
213
  echo "Refactor high-complexity functions to keep below $CCN_THRESHOLD complexity."
214
+ GATE_3_STATUS="BLOCK"
215
+ GATE_3_REASON="ccn_over_threshold"
192
216
  exit 1
193
217
  else
194
218
  echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @test REQ-404 CRAP advisory CLI
3
+ * @intent Verify `xp-gate crap` renders a ranking, stays advisory (exit 0 no
4
+ * matter what it finds), and refuses to invent a ranking when its
5
+ * inputs are missing.
6
+ * @covers AC-404
7
+ */
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const { handleCrap, parseArgs } = require('../crap-cli.js');
13
+
14
+ function capture(fn) {
15
+ const logs = [];
16
+ const errs = [];
17
+ const origLog = console.log;
18
+ const origErr = console.error;
19
+ console.log = (...a) => logs.push(a.join(' '));
20
+ console.error = (...a) => errs.push(a.join(' '));
21
+ return fn()
22
+ .then((code) => ({ code, out: logs.join('\n'), err: errs.join('\n') }))
23
+ .finally(() => {
24
+ console.log = origLog;
25
+ console.error = origErr;
26
+ });
27
+ }
28
+
29
+ describe('crap-cli.js - argument parsing', () => {
30
+ it('defaults to top 20, threshold 6, mirrors excluded', () => {
31
+ const opts = parseArgs([]);
32
+ expect(opts.top).toBe(20);
33
+ expect(opts.threshold).toBe(6);
34
+ expect(opts.includeMirrors).toBe(false);
35
+ expect(opts.json).toBe(false);
36
+ });
37
+
38
+ it('parses the documented flags', () => {
39
+ const opts = parseArgs(['--top', '5', '--threshold', '12', '--json', '--include-mirrors']);
40
+ expect(opts.top).toBe(5);
41
+ expect(opts.threshold).toBe(12);
42
+ expect(opts.json).toBe(true);
43
+ expect(opts.includeMirrors).toBe(true);
44
+ });
45
+
46
+ it('collects multiple --paths values', () => {
47
+ expect(parseArgs(['--paths', 'src', 'scripts']).paths).toEqual(['src', 'scripts']);
48
+ });
49
+ });
50
+
51
+ describe('crap-cli.js - advisory contract', () => {
52
+ let tmpDir;
53
+
54
+ beforeEach(() => {
55
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crap-cli-'));
56
+ });
57
+
58
+ afterEach(() => {
59
+ fs.rmSync(tmpDir, { recursive: true, force: true });
60
+ });
61
+
62
+ it('prints usage and exits 0 on --help', async () => {
63
+ const res = await capture(() => handleCrap(['--help']));
64
+ expect(res.code).toBe(0);
65
+ expect(res.out).toContain('ADVISORY ONLY');
66
+ });
67
+
68
+ it('refuses to rank when there is no coverage artifact', async () => {
69
+ // Without coverage every function is unmeasured; printing an empty ranking
70
+ // would read as "nothing to fix", which is the opposite of the truth.
71
+ const missing = path.join(tmpDir, 'does-not-exist.json');
72
+ const res = await capture(() => handleCrap(['--coverage', missing]));
73
+ expect(res.code).toBe(1);
74
+ expect(res.err).toContain('No coverage artifact');
75
+ });
76
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @test REQ-404 CRAP advisory ranking
3
+ * @intent Verify the CRAP indicator ranks "complex and weakly tested" functions
4
+ * without silently treating missing coverage as either perfect or zero,
5
+ * and that it never presents itself as a blocking gate (#404 P1-2).
6
+ * @covers AC-404
7
+ */
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const {
13
+ computeCrap,
14
+ joinCoverage,
15
+ buildCrapReport,
16
+ isMirrorPath,
17
+ } = require('../crap.js');
18
+
19
+ describe('crap.js - CRAP formula', () => {
20
+ it('computes comp^2 * (1 - cov) + comp', () => {
21
+ // CCN 10, 50% covered -> 100 * 0.5 + 10 = 60
22
+ expect(computeCrap(10, 0.5)).toBeCloseTo(60, 5);
23
+ // CCN 4, fully covered -> 0 + 4 = 4
24
+ expect(computeCrap(4, 1)).toBeCloseTo(4, 5);
25
+ // CCN 12, no coverage -> 144 + 12 = 156
26
+ expect(computeCrap(12, 0)).toBeCloseTo(156, 5);
27
+ });
28
+
29
+ it('returns null when coverage is unknown rather than guessing', () => {
30
+ // The whole reason this function exists: an unmeasured function must not be
31
+ // scored as if it were untested (that inflates it to the top of the ranking)
32
+ // nor as if it were fully tested (that hides it). It is simply unmeasured.
33
+ expect(computeCrap(10, null)).toBeNull();
34
+ expect(computeCrap(10, undefined)).toBeNull();
35
+ expect(computeCrap(10, NaN)).toBeNull();
36
+ });
37
+
38
+ it('clamps out-of-range coverage instead of producing negative scores', () => {
39
+ // Istanbul occasionally reports slightly out-of-range values; a negative
40
+ // CRAP would sort below genuinely clean functions and hide a hotspot.
41
+ expect(computeCrap(5, 1.2)).toBeCloseTo(5, 5);
42
+ expect(computeCrap(5, -0.1)).toBeCloseTo(30, 5);
43
+ });
44
+ });
45
+
46
+ describe('crap.js - coverage joining', () => {
47
+ it('joins per-file coverage using a repo-relative path', () => {
48
+ const coverage = new Map([['src/a.ts', 0.5]]);
49
+ const functions = [{ file: 'src/a.ts', name: 'f', ccn: 10 }];
50
+ const joined = joinCoverage(functions, coverage);
51
+ expect(joined).toHaveLength(1);
52
+ expect(joined[0].cov).toBeCloseTo(0.5, 5);
53
+ expect(joined[0].crap).toBeCloseTo(60, 5);
54
+ });
55
+
56
+ it('marks functions whose file has no coverage as unmeasured', () => {
57
+ const coverage = new Map([['src/a.ts', 0.5]]);
58
+ const functions = [{ file: 'src/never-loaded.ts', name: 'g', ccn: 20 }];
59
+ const joined = joinCoverage(functions, coverage);
60
+ expect(joined[0].cov).toBeNull();
61
+ expect(joined[0].crap).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe('crap.js - report', () => {
66
+ const functions = [
67
+ { file: 'src/gates/gate-7.ts', name: 'runGate7', ccn: 14, cov: 0 },
68
+ { file: 'src/gates/ok.ts', name: 'clean', ccn: 3, cov: 1 },
69
+ { file: 'src/gates/mid.ts', name: 'mid', ccn: 9, cov: 0.8 },
70
+ ];
71
+
72
+ it('ranks by CRAP descending and drops functions below the bar', () => {
73
+ const report = buildCrapReport(functions, { threshold: 6 });
74
+ // clean: 9 * 0 + 3 = 3 -> below the bar, excluded
75
+ // mid: 81 * 0.2 + 9 = 25.2
76
+ // runGate7: 196 + 14 = 210
77
+ expect(report.hotspots.map((h) => h.name)).toEqual(['runGate7', 'mid']);
78
+ expect(report.hotspots[0].crap).toBeCloseTo(210, 5);
79
+ });
80
+
81
+ it('reports how many functions could not be measured', () => {
82
+ const withUnknown = [
83
+ ...functions,
84
+ { file: 'src/gates/never.ts', name: 'nope', ccn: 30, cov: null },
85
+ ];
86
+ const report = buildCrapReport(withUnknown, { threshold: 6 });
87
+ expect(report.unmeasured).toBe(1);
88
+ expect(report.measured).toBe(3);
89
+ });
90
+
91
+ it('labels itself advisory', () => {
92
+ const report = buildCrapReport(functions, { threshold: 6 });
93
+ // A ranking nobody is blocked by must say so; the RFC is explicit that this
94
+ // navigates refactoring and does not replace security/performance review.
95
+ expect(report.advisory).toBe(true);
96
+ expect(report.blocking).toBe(false);
97
+ });
98
+
99
+ it('honours an explicit top-N limit', () => {
100
+ const report = buildCrapReport(functions, { threshold: 6, top: 1 });
101
+ expect(report.hotspots).toHaveLength(1);
102
+ });
103
+ });
104
+
105
+ describe('crap.js - mirror exclusion', () => {
106
+ it('recognises the generated npm-package mirror', () => {
107
+ // Refactoring a mirror is refactoring a copy that prepack overwrites.
108
+ expect(isMirrorPath('src/npm-package/lib/doctor.js')).toBe(true);
109
+ expect(isMirrorPath('src/npm-package/principles/config.ts')).toBe(true);
110
+ });
111
+
112
+ it('does not treat real source as a mirror', () => {
113
+ expect(isMirrorPath('src/gates/gate-7.ts')).toBe(false);
114
+ expect(isMirrorPath('plugins/qoder/src/index.ts')).toBe(false);
115
+ });
116
+
117
+ it('excludes mirror-tree functions from the ranking by default', () => {
118
+ // A mirror hotspot outranks everything real (measured: findBodyBrace at
119
+ // CRAP 4830 for being a big untested generated copy), so the default has to
120
+ // be exclusion or the ranking is useless out of the box.
121
+ const mixed = [
122
+ { file: 'src/npm-package/lib/huge.js', name: 'mirrorFn', ccn: 60, cov: 0 },
123
+ { file: 'src/gates/real.ts', name: 'realFn', ccn: 10, cov: 0 },
124
+ ];
125
+ const report = buildCrapReport(mixed, { threshold: 6 });
126
+ expect(report.hotspots.map((h) => h.name)).toEqual(['realFn']);
127
+ });
128
+
129
+ it('includes mirrors only when explicitly asked', () => {
130
+ const mixed = [
131
+ { file: 'src/npm-package/lib/huge.js', name: 'mirrorFn', ccn: 60, cov: 0 },
132
+ { file: 'src/gates/real.ts', name: 'realFn', ccn: 10, cov: 0 },
133
+ ];
134
+ const report = buildCrapReport(mixed, { threshold: 6, mirrors: true });
135
+ expect(report.hotspots.map((h) => h.name)).toEqual(['mirrorFn', 'realFn']);
136
+ });
137
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @test REQ-404 Gate M in retrospective
3
+ * @intent Verify `xp-gate retro` reports Gate M (mutation) evidence from the
4
+ * pre-push journal, and that it distinguishes "never measured" from
5
+ * "measured and scored well" instead of printing a healthy average over
6
+ * an empty sample (#404 P1-3).
7
+ *
8
+ * @covers AC-404
9
+ */
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+
14
+ const { handleRetro } = require('../retro');
15
+
16
+ let TMP_DIR;
17
+ let cwdSpy;
18
+
19
+ beforeAll(() => {
20
+ TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'retro-gatem-test-'));
21
+ cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(TMP_DIR);
22
+ });
23
+
24
+ afterAll(() => {
25
+ cwdSpy.mockRestore();
26
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
27
+ });
28
+
29
+ function cleanDir() {
30
+ for (const sub of ['.xp-gate', '.sprint-state', 'coverage']) {
31
+ fs.rmSync(path.join(TMP_DIR, sub), { recursive: true, force: true });
32
+ }
33
+ fs.rmSync(path.join(TMP_DIR, '.quality-history.jsonl'), { force: true });
34
+ }
35
+
36
+ /** Write pre-push journals. `runs` is a list of Gate M detail objects. */
37
+ function writeJournals(runs) {
38
+ const dir = path.join(TMP_DIR, '.xp-gate', 'reports', 'pre-push');
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ runs.forEach((gateM, i) => {
41
+ const stamp = `2026-10-${String(10 + i).padStart(2, '0')}T00:00:00Z`;
42
+ fs.writeFileSync(
43
+ path.join(dir, `${stamp.replace(/[:]/g, '-')}.json`),
44
+ JSON.stringify({
45
+ timestamp: stamp,
46
+ trigger: 'pre-push',
47
+ branch: 'main',
48
+ commit: `sha${i}`,
49
+ changed_files: ['src/a.ts'],
50
+ overall: gateM.status,
51
+ gates: [{ id: 'M', name: 'Mutation Testing', status: gateM.status, details: gateM.details || {} }],
52
+ warnings: [],
53
+ errors: [],
54
+ })
55
+ );
56
+ });
57
+ }
58
+
59
+ async function runRetro(args = ['--days', '30']) {
60
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
61
+ const code = await handleRetro(args);
62
+ const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
63
+ logSpy.mockRestore();
64
+ return { code, output };
65
+ }
66
+
67
+ describe('retro - Gate M evidence (#404)', () => {
68
+ test('reports skip rate, measured duration and score distribution', async () => {
69
+ cleanDir();
70
+ writeJournals([
71
+ { status: 'PASS', details: { duration_ms: '120000', mutation_score: '75.5', skip_reason: '' } },
72
+ { status: 'PASS', details: { duration_ms: '180000', mutation_score: '82.1', skip_reason: '' } },
73
+ { status: 'SKIP', details: { skip_reason: 'stryker_not_installed' } },
74
+ { status: 'SKIP', details: { skip_reason: 'no_mutable_files' } },
75
+ ]);
76
+
77
+ const { code, output } = await runRetro();
78
+ expect(code).toBe(0);
79
+ expect(output).toContain('Gate M');
80
+ // 2 of 4 skipped
81
+ expect(output).toContain('50.0%');
82
+ // Both skip reasons must survive into the report, not be collapsed.
83
+ expect(output).toContain('stryker_not_installed');
84
+ expect(output).toContain('no_mutable_files');
85
+ // score median of [75.5, 82.1] is 82.1 with this sort convention; assert the
86
+ // scores are present rather than pinning the exact tie-break.
87
+ expect(output).toMatch(/75\.5|82\.1/);
88
+ });
89
+
90
+ test('says "never measured" rather than reporting a clean 0% average', async () => {
91
+ cleanDir();
92
+ // The exact shape of all 68 journals before the #404 fix: SKIP with an empty
93
+ // details object. An average over zero samples is not a healthy score.
94
+ writeJournals([
95
+ { status: 'SKIP', details: {} },
96
+ { status: 'SKIP', details: {} },
97
+ ]);
98
+
99
+ const { code, output } = await runRetro();
100
+ expect(code).toBe(0);
101
+ expect(output).toContain('Gate M');
102
+ expect(output).toMatch(/no measured|never measured|not measured/i);
103
+ // A fabricated average over an empty sample is the failure being prevented.
104
+ expect(output).not.toMatch(/median mutation score.*\d+\.\d+%/i);
105
+ });
106
+
107
+ test('reports no Gate M data at all when there are no journals', async () => {
108
+ cleanDir();
109
+ const { code, output } = await runRetro();
110
+ expect(code).toBe(0);
111
+ expect(output).toMatch(/no pre-push|no Gate M/i);
112
+ });
113
+
114
+ test('counts a TIMEOUT separately from a SKIP', async () => {
115
+ cleanDir();
116
+ writeJournals([
117
+ { status: 'TIMEOUT', details: { skip_reason: 'exceeded_budget' } },
118
+ { status: 'SKIP', details: { skip_reason: 'no_mutable_files' } },
119
+ ]);
120
+
121
+ const { output } = await runRetro();
122
+ // Timeouts are a distinct failure mode: lumping them into SKIP hides a
123
+ // gate that started work and could not finish.
124
+ expect(output).toMatch(/TIMEOUT|timeout/i);
125
+ });
126
+
127
+ test('counts BLOCKED, which is the spelling the journals actually write', async () => {
128
+ cleanDir();
129
+ // Measured against this repo's real journals: 62 SKIP, 5 BLOCKED, 1 PASS.
130
+ // An implementation that only matched "BLOCK" reported the 5 real mutation
131
+ // blocks as zero -- precisely the silent-success failure #404 is about.
132
+ writeJournals([
133
+ { status: 'BLOCKED', details: { skip_reason: 'mutation_score_below_threshold' } },
134
+ { status: 'BLOCKED', details: {} },
135
+ { status: 'SKIP', details: { skip_reason: 'no_mutable_files' } },
136
+ { status: 'PASS', details: { mutation_score: '71.0' } },
137
+ ]);
138
+
139
+ const { output } = await runRetro();
140
+ expect(output).toContain('BLOCK 2');
141
+ expect(output).toContain('SKIP 1');
142
+ expect(output).toContain('PASS 1');
143
+ });
144
+ });
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `xp-gate crap` — advisory CRAP hotspot ranking (#404 P1-2).
5
+ *
6
+ * Prints the functions whose CRAP score is at or above the bar, worst first.
7
+ * This is a NAVIGATION aid: it exits 0 whatever it finds, because the indicator
8
+ * depends on coverage that 21.8% of this repo's source files do not have (see
9
+ * crap.js). A ranking that cannot measure a fifth of its population must not
10
+ * be allowed to fail a build.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { execFileSync } = require('child_process');
16
+
17
+ const {
18
+ buildCrapReport,
19
+ joinCoverage,
20
+ readFileCoverage,
21
+ parseLizardCsv,
22
+ } = require('./crap.js');
23
+
24
+ const USAGE = `Usage: xp-gate crap [options]
25
+
26
+ Rank functions by CRAP score = CCN^2 * (1 - coverage) + CCN, worst first.
27
+ ADVISORY ONLY: never blocks, always exits 0.
28
+
29
+ Options:
30
+ --top N show at most N hotspots (default 20)
31
+ --threshold N CRAP bar; functions at or above it are listed (default 6)
32
+ --json machine-readable output
33
+ --coverage <file> istanbul coverage-final.json (default coverage/coverage-final.json)
34
+ --paths <p...> directories to scan (default src)
35
+ --include-mirrors also rank src/npm-package/** (overwritten at publish)
36
+ -h, --help show this help
37
+
38
+ Unmeasured = the function's file was never loaded by any test, so coverage is
39
+ unknown. Those functions are counted, not scored. Treating them as 0% would put
40
+ every untested file at the top of the ranking; treating them as 100% would hide
41
+ them.
42
+ `;
43
+
44
+ function parseArgs(argv) {
45
+ const opts = {
46
+ top: 20,
47
+ threshold: 6,
48
+ json: false,
49
+ coverage: path.join(process.cwd(), 'coverage', 'coverage-final.json'),
50
+ paths: ['src'],
51
+ includeMirrors: false,
52
+ help: false,
53
+ };
54
+
55
+ for (let i = 0; i < argv.length; i += 1) {
56
+ const arg = argv[i];
57
+ switch (arg) {
58
+ case '--top':
59
+ opts.top = Number(argv[++i]);
60
+ break;
61
+ case '--threshold':
62
+ opts.threshold = Number(argv[++i]);
63
+ break;
64
+ case '--json':
65
+ opts.json = true;
66
+ break;
67
+ case '--coverage':
68
+ opts.coverage = argv[++i];
69
+ break;
70
+ case '--include-mirrors':
71
+ opts.includeMirrors = true;
72
+ break;
73
+ case '--paths': {
74
+ opts.paths = [];
75
+ while (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
76
+ opts.paths.push(argv[++i]);
77
+ }
78
+ break;
79
+ }
80
+ case '-h':
81
+ case '--help':
82
+ opts.help = true;
83
+ break;
84
+ default:
85
+ break;
86
+ }
87
+ }
88
+ return opts;
89
+ }
90
+
91
+ /** Run lizard and return its CSV. Missing lizard is not an error for a
92
+ * navigation command: the caller reports that it could not measure. */
93
+ function runLizard(paths) {
94
+ try {
95
+ return execFileSync('lizard', ['--csv', '-C', '99999', ...paths], {
96
+ encoding: 'utf8',
97
+ maxBuffer: 64 * 1024 * 1024,
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ });
100
+ } catch (err) {
101
+ // lizard exits non-zero when it cannot scan everything; its stdout is still
102
+ // usable. Anything else (ENOENT) means no data at all.
103
+ if (err && typeof err.stdout === 'string' && err.stdout.length) return err.stdout;
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function formatRow(hotspot) {
109
+ const crap = hotspot.crap.toFixed(1).padStart(8);
110
+ const ccn = String(hotspot.ccn).padStart(4);
111
+ const cov = `${Math.round(hotspot.cov * 100)}%`.padStart(4);
112
+ return ` ${crap} CCN ${ccn} cov ${cov} ${hotspot.file} ${hotspot.name}`;
113
+ }
114
+
115
+ function render(report, opts) {
116
+ const lines = [];
117
+ lines.push('━━━ CRAP hotspot ranking (advisory, never blocks) ━━━');
118
+
119
+ if (!report.measured && !report.unmeasured) {
120
+ lines.push('');
121
+ lines.push('No functions found. Check that lizard is installed and --paths points at real code:');
122
+ lines.push(` lizard --csv -C 99999 ${opts.paths.join(' ')}`);
123
+ return lines.join('\n');
124
+ }
125
+
126
+ lines.push('');
127
+ lines.push(
128
+ `measured ${report.measured} functions, ${report.unmeasured} unmeasured (no coverage for their file)`
129
+ );
130
+ lines.push(`CRAP >= ${report.threshold}: ${report.hotspots.length} shown`);
131
+
132
+ if (!report.hotspots.length) {
133
+ lines.push('');
134
+ lines.push(`✅ Nothing at or above CRAP ${report.threshold}.`);
135
+ return lines.join('\n');
136
+ }
137
+
138
+ lines.push('');
139
+ lines.push(' CRAP complexity coverage location');
140
+ const dash = ' ' + '─'.repeat(74);
141
+ lines.push(dash);
142
+ for (const hotspot of report.hotspots) lines.push(formatRow(hotspot));
143
+ lines.push(dash);
144
+ lines.push('');
145
+ lines.push('Navigation aid: pick from the top. This does not replace security,');
146
+ lines.push('performance or architecture review, and score-chasing is not a goal.');
147
+ return lines.join('\n');
148
+ }
149
+
150
+ async function handleCrap(argv) {
151
+ const opts = parseArgs(argv);
152
+
153
+ if (opts.help) {
154
+ console.log(USAGE);
155
+ return 0;
156
+ }
157
+
158
+ const csv = runLizard(opts.paths);
159
+ if (csv === null) {
160
+ console.error('✗ Could not run lizard. Install it with: pip install --user lizard');
161
+ return 1;
162
+ }
163
+
164
+ if (!fs.existsSync(opts.coverage)) {
165
+ console.error(`✗ No coverage artifact at ${opts.coverage}`);
166
+ console.error(' Run the test suite with coverage first: npx vitest run --coverage');
167
+ console.error(' (Without it every function is unmeasured, which is not a ranking.)');
168
+ return 1;
169
+ }
170
+
171
+ const functions = parseLizardCsv(csv);
172
+ const coverage = readFileCoverage(opts.coverage);
173
+ const joined = joinCoverage(functions, coverage);
174
+
175
+ const report = buildCrapReport(joined, {
176
+ threshold: opts.threshold,
177
+ top: opts.top,
178
+ mirrors: opts.includeMirrors,
179
+ });
180
+
181
+ if (opts.json) {
182
+ console.log(JSON.stringify(report, null, 2));
183
+ return 0;
184
+ }
185
+
186
+ console.log(render(report, opts));
187
+ // Advisory by construction: the ranking never changes the exit code.
188
+ return 0;
189
+ }
190
+
191
+ module.exports = { handleCrap, parseArgs, render, runLizard };