@boyingliu01/xp-gate 0.30.1 → 0.30.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/iac.sh +45 -4
- package/adapters/java.sh +82 -16
- package/adapters/powershell.sh +70 -9
- package/bin/xp-gate.js +8 -0
- package/gate-10.sh +32 -2
- package/gate-3.sh +47 -6
- package/gate-4.sh +12 -0
- package/gate-7.sh +4 -0
- package/hooks/gate-10.sh +32 -2
- package/hooks/gate-3.sh +47 -6
- 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 +97 -6
- package/hooks/sprint-gate.sh +81 -2
- package/lib/__tests__/crap-cli.test.js +76 -0
- package/lib/__tests__/crap.test.js +137 -0
- package/lib/__tests__/hook-health.test.js +85 -0
- package/lib/__tests__/retro-gate-m.test.js +144 -0
- package/lib/coverage-total.sh +92 -0
- package/lib/crap-cli.js +191 -0
- package/lib/crap.js +196 -0
- package/lib/doctor.js +44 -0
- package/lib/hook-health.js +121 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +139 -2
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/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,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,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/crap-cli.js
ADDED
|
@@ -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 };
|
package/lib/crap.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CRAP advisory ranking (#404 P1-2).
|
|
5
|
+
*
|
|
6
|
+
* CRAP(m) = comp(m)^2 * (1 - cov(m)) + comp(m)
|
|
7
|
+
*
|
|
8
|
+
* Uncle Bob's combined signal: complexity is only half the story, because a
|
|
9
|
+
* gnarly function with dense tests is cheap to change while a simple one with
|
|
10
|
+
* none is not. This module produces a RANKING for navigation. It is explicitly
|
|
11
|
+
* not a gate -- see README/issue #404 for why.
|
|
12
|
+
*
|
|
13
|
+
* Three constraints came out of measuring the indicator on this repo before
|
|
14
|
+
* writing it, each of which changes the design:
|
|
15
|
+
*
|
|
16
|
+
* 1. Coverage is per FILE in the artifact the gates already produce
|
|
17
|
+
* (coverage-final.json). Function-level positions could not be joined to
|
|
18
|
+
* lizard's reliably (8.4% match rate measured), so cov(m) here is the
|
|
19
|
+
* enclosing file's coverage. That over-smooths within a file; it is
|
|
20
|
+
* acceptable for ranking and not acceptable for a pass/fail verdict.
|
|
21
|
+
*
|
|
22
|
+
* 2. 21.8% of non-mirror source files are never loaded by any test and so have
|
|
23
|
+
* no coverage at all. Treating those as 0% would push every untested file to
|
|
24
|
+
* the top of the ranking purely for being unmeasured; treating them as 100%
|
|
25
|
+
* would hide them. They are reported separately as `unmeasured`.
|
|
26
|
+
*
|
|
27
|
+
* 3. src/npm-package/** is a generated mirror (canonical source is
|
|
28
|
+
* plugins/* / the repo root) and is overwritten at publish. Ranking it would
|
|
29
|
+
* direct refactoring at a file that does not survive. Excluded by default.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
/** Repository-relative paths whose canonical source lives elsewhere. */
|
|
36
|
+
const MIRROR_PREFIXES = ['src/npm-package/'];
|
|
37
|
+
|
|
38
|
+
function isMirrorPath(filePath) {
|
|
39
|
+
if (!filePath) return false;
|
|
40
|
+
const normalized = String(filePath).split('\\').join('/').replace(/^\.\//, '');
|
|
41
|
+
return MIRROR_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CRAP for one function.
|
|
46
|
+
*
|
|
47
|
+
* @param {number} comp cyclomatic complexity
|
|
48
|
+
* @param {number|null} cov coverage ratio in [0,1], or null when unmeasured
|
|
49
|
+
* @returns {number|null} null when coverage is unknown -- deliberately NOT a
|
|
50
|
+
* number, so an unmeasured function cannot be ranked at all
|
|
51
|
+
*/
|
|
52
|
+
function computeCrap(comp, cov) {
|
|
53
|
+
if (cov === null || cov === undefined || Number.isNaN(cov)) return null;
|
|
54
|
+
if (!Number.isFinite(comp)) return null;
|
|
55
|
+
|
|
56
|
+
// Out-of-range coverage does occur in real istanbul output (a file counted at
|
|
57
|
+
// >100% after source-map folding). Clamping keeps the score monotonic and
|
|
58
|
+
// never negative, which a negative CRAP would violate by sorting below honest
|
|
59
|
+
// clean functions.
|
|
60
|
+
const clamped = Math.min(1, Math.max(0, cov));
|
|
61
|
+
return comp * comp * (1 - clamped) + comp;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Normalise a path to the repo-relative form used by the coverage artifact. */
|
|
65
|
+
function toRepoRelative(filePath) {
|
|
66
|
+
return String(filePath).split('\\').join('/').replace(/^\.\//, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Attach each function's file coverage and CRAP score.
|
|
71
|
+
*
|
|
72
|
+
* @param {Array<{file:string,name:string,ccn:number}>} functions
|
|
73
|
+
* @param {Map<string, number>} coverage repo-relative path -> ratio
|
|
74
|
+
* @returns {Array<object>} each with `cov` and `crap` (either may be null)
|
|
75
|
+
*/
|
|
76
|
+
function joinCoverage(functions, coverage) {
|
|
77
|
+
return functions.map((fn) => {
|
|
78
|
+
const key = toRepoRelative(fn.file);
|
|
79
|
+
const cov = coverage instanceof Map ? coverage.get(key) : undefined;
|
|
80
|
+
const known = cov === undefined ? null : cov;
|
|
81
|
+
return { ...fn, file: key, cov: known, crap: computeCrap(fn.ccn, known) };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the advisory report.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array<object>} functions joined functions (may carry null cov)
|
|
89
|
+
* @param {{threshold?: number, top?: number, mirrors?: boolean}} options
|
|
90
|
+
* `mirrors: true` also ranks the generated mirror tree; default false.
|
|
91
|
+
*/
|
|
92
|
+
function buildCrapReport(functions, options = {}) {
|
|
93
|
+
const threshold = Number.isFinite(options.threshold) ? options.threshold : 6;
|
|
94
|
+
const excludeMirrors = options.mirrors !== true;
|
|
95
|
+
|
|
96
|
+
const scored = [];
|
|
97
|
+
let unmeasured = 0;
|
|
98
|
+
|
|
99
|
+
for (const fn of functions) {
|
|
100
|
+
if (excludeMirrors && isMirrorPath(fn.file)) continue;
|
|
101
|
+
|
|
102
|
+
if (fn.cov === null || fn.cov === undefined) {
|
|
103
|
+
unmeasured += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const crap = fn.crap === null || fn.crap === undefined ? computeCrap(fn.ccn, fn.cov) : fn.crap;
|
|
107
|
+
if (crap === null) {
|
|
108
|
+
unmeasured += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
scored.push({ ...fn, crap });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
scored.sort((a, b) => b.crap - a.crap || a.file.localeCompare(b.file));
|
|
115
|
+
|
|
116
|
+
const hotspots = scored.filter((s) => s.crap >= threshold);
|
|
117
|
+
const limited = Number.isFinite(options.top) && options.top >= 0 ? hotspots.slice(0, options.top) : hotspots;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
hotspots: limited,
|
|
121
|
+
measured: scored.length,
|
|
122
|
+
unmeasured,
|
|
123
|
+
threshold,
|
|
124
|
+
advisory: true,
|
|
125
|
+
blocking: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read per-file coverage ratios out of an istanbul coverage-final.json.
|
|
131
|
+
* Missing or unreadable input yields an empty map, which surfaces as "every
|
|
132
|
+
* function unmeasured" rather than as a silently perfect score.
|
|
133
|
+
*/
|
|
134
|
+
function readFileCoverage(coverageFile) {
|
|
135
|
+
const coverage = new Map();
|
|
136
|
+
let raw;
|
|
137
|
+
try {
|
|
138
|
+
raw = fs.readFileSync(coverageFile, 'utf8');
|
|
139
|
+
} catch {
|
|
140
|
+
return coverage;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
return coverage;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const root = process.cwd();
|
|
151
|
+
for (const [absPath, entry] of Object.entries(parsed)) {
|
|
152
|
+
if (!entry || !entry.s) continue;
|
|
153
|
+
const counts = Object.values(entry.s);
|
|
154
|
+
if (!counts.length) continue;
|
|
155
|
+
const covered = counts.filter((c) => c > 0).length;
|
|
156
|
+
const rel = path.isAbsolute(absPath)
|
|
157
|
+
? toRepoRelative(path.relative(root, absPath))
|
|
158
|
+
: toRepoRelative(absPath);
|
|
159
|
+
coverage.set(rel, covered / counts.length);
|
|
160
|
+
}
|
|
161
|
+
return coverage;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse lizard's CSV into functions with a repo-relative file and a CCN.
|
|
166
|
+
* Column order: NLOC,CCN,token,PARAM,length,location,file,name,signature,...
|
|
167
|
+
*/
|
|
168
|
+
function parseLizardCsv(csvText) {
|
|
169
|
+
const functions = [];
|
|
170
|
+
for (const line of String(csvText || '').split(/\r?\n/)) {
|
|
171
|
+
if (!line.trim()) continue;
|
|
172
|
+
const cells = line.match(/("([^"]|"")*"|[^,]*)(,|$)/g);
|
|
173
|
+
if (!cells || cells.length < 8) continue;
|
|
174
|
+
const clean = (i) => cells[i].replace(/,$/, '').replace(/^"|"$/g, '');
|
|
175
|
+
|
|
176
|
+
const ccn = Number(clean(1));
|
|
177
|
+
if (!Number.isFinite(ccn)) continue; // header row
|
|
178
|
+
|
|
179
|
+
functions.push({
|
|
180
|
+
file: toRepoRelative(clean(6)),
|
|
181
|
+
name: clean(7),
|
|
182
|
+
ccn,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return functions;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
computeCrap,
|
|
190
|
+
joinCoverage,
|
|
191
|
+
buildCrapReport,
|
|
192
|
+
isMirrorPath,
|
|
193
|
+
readFileCoverage,
|
|
194
|
+
parseLizardCsv,
|
|
195
|
+
MIRROR_PREFIXES,
|
|
196
|
+
};
|
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.
|