@holmes-lab/holmes-kit 0.1.0
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.planTestRun = planTestRun;
|
|
37
|
+
exports.ecosystemOf = ecosystemOf;
|
|
38
|
+
exports.parseGoTestJson = parseGoTestJson;
|
|
39
|
+
exports.parseExecutedCounts = parseExecutedCounts;
|
|
40
|
+
exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
|
|
41
|
+
exports.parseCargoTest = parseCargoTest;
|
|
42
|
+
exports.runCargo = runCargo;
|
|
43
|
+
exports.runGradle = runGradle;
|
|
44
|
+
exports.runDotnet = runDotnet;
|
|
45
|
+
exports.runGo = runGo;
|
|
46
|
+
exports.runTestScope = runTestScope;
|
|
47
|
+
// @implements A-SPEC-102.1
|
|
48
|
+
const node_child_process_1 = require("node:child_process");
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const os = __importStar(require("node:os"));
|
|
51
|
+
const path = __importStar(require("node:path"));
|
|
52
|
+
function planTestRun(scope) {
|
|
53
|
+
if (scope.tier === 'full') {
|
|
54
|
+
return { mode: 'full', testFiles: [], reason: 'full regression — run the entire suite' };
|
|
55
|
+
}
|
|
56
|
+
if (scope.impactedTestFiles.length === 0) {
|
|
57
|
+
return { mode: 'noop', testFiles: [], reason: `${scope.tier} scope but no impacted test files — nothing to run` };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
mode: 'scoped',
|
|
61
|
+
testFiles: [...scope.impactedTestFiles],
|
|
62
|
+
reason: `${scope.tier} scope — run ${scope.impactedTestFiles.length} impacted test file(s)`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Ecosystem of a test file, by extension/convention. `null` when no runner adapter exists for it. */
|
|
66
|
+
function ecosystemOf(file) {
|
|
67
|
+
if (/\.(ts|mts|cts|tsx|jsx|js|mjs|cjs)$/i.test(file))
|
|
68
|
+
return 'jest';
|
|
69
|
+
if (/\.py$/i.test(file))
|
|
70
|
+
return 'pytest';
|
|
71
|
+
// @implements A-SPEC-137.1 — Go's test convention is a `_test.go` suffix; a plain `.go` file is
|
|
72
|
+
// scanned for symbols/edges but is not a runnable test file.
|
|
73
|
+
if (/_test\.go$/i.test(file))
|
|
74
|
+
return 'go';
|
|
75
|
+
// @implements A-SPEC-137.2 — Rust test files, matching the scanner's isTestFile convention:
|
|
76
|
+
// tests/<x>.rs (integration), <x>_test(s).rs, <x>_unittest.rs, test_<x>.rs. A plain src/x.rs may
|
|
77
|
+
// carry inline #[cfg(test)] unit tests but is a production source, not a selected test file.
|
|
78
|
+
if (/(^|\/)(tests\/[^/]*|[^/]*_(tests?|unittest)|test_[^/]*)\.rs$/i.test(file))
|
|
79
|
+
return 'rust';
|
|
80
|
+
// @implements A-SPEC-137.3 — JUnit/xUnit naming (case-sensitive `Test` so Manifest.java is not a
|
|
81
|
+
// test): Java `…Test(s|Case|Cases).java`, C# `…Test(s).cs`.
|
|
82
|
+
if (/(^|\/)[^/]*(Test|Tests|TestCase|TestCases)\.java$/.test(file))
|
|
83
|
+
return 'java';
|
|
84
|
+
if (/(^|\/)[^/]*(Test|Tests)\.cs$/.test(file))
|
|
85
|
+
return 'dotnet';
|
|
86
|
+
return null; // cpp and non-test files — counted syntactically, not yet executable here
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @implements A-SPEC-137.1
|
|
90
|
+
* Executed-test counts per Go package from `go test -json` output. A test is EXECUTED when a NAMED
|
|
91
|
+
* test (`Test` present) reaches `Action: 'pass'` or `'fail'` — a skip does not count (matching the
|
|
92
|
+
* jest/pytest rule), and a package-level result (no `Test`) is not a test case. Non-JSON lines (go
|
|
93
|
+
* interleaves build output) are ignored. Pure; empty/garbage input yields `{}` rather than throwing.
|
|
94
|
+
*/
|
|
95
|
+
function parseGoTestJson(out) {
|
|
96
|
+
const counts = {};
|
|
97
|
+
for (const line of out.split('\n')) {
|
|
98
|
+
const s = line.trim();
|
|
99
|
+
if (!s.startsWith('{'))
|
|
100
|
+
continue;
|
|
101
|
+
let evt;
|
|
102
|
+
try {
|
|
103
|
+
evt = JSON.parse(s);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!evt.Test || !evt.Package)
|
|
109
|
+
continue; // package-level result is not a test case
|
|
110
|
+
if (evt.Action === 'pass' || evt.Action === 'fail') { // skip is excluded; pass/fail both executed
|
|
111
|
+
counts[evt.Package] = (counts[evt.Package] ?? 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return counts;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Parse jest --json output into per-file EXECUTED case counts. This is the execution-proof upgrade
|
|
118
|
+
* over the syntactic countTestCases lower bound: a case only appears here if jest actually ran it
|
|
119
|
+
* (pending/skipped assertions are excluded), so `if(false){it(…)}`, aliasing, and other syntactic
|
|
120
|
+
* fabrications cannot produce evidence. Returns {} for unparseable output (caller falls back).
|
|
121
|
+
*/
|
|
122
|
+
function parseExecutedCounts(stdout, cwd) {
|
|
123
|
+
const start = stdout.indexOf('{');
|
|
124
|
+
if (start < 0)
|
|
125
|
+
return {};
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(stdout.slice(start));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const tr of data?.testResults ?? []) {
|
|
135
|
+
const abs = tr.name ?? tr.testFilePath ?? '';
|
|
136
|
+
if (!abs)
|
|
137
|
+
continue;
|
|
138
|
+
const rel = abs.startsWith(cwd) ? abs.slice(cwd.length).replace(/^[/\\]/, '') : abs;
|
|
139
|
+
const ran = (tr.assertionResults ?? []).filter((a) => a.status === 'passed' || a.status === 'failed').length;
|
|
140
|
+
out[rel.split('\\').join('/')] = ran;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse pytest's built-in JUnit XML into per-file EXECUTED case counts — the pytest analogue of
|
|
146
|
+
* parseExecutedCounts. `--junit-xml` ships with pytest core, so this needs no plugin, and it is the
|
|
147
|
+
* only pytest output that is both machine-readable and per-file.
|
|
148
|
+
*
|
|
149
|
+
* A `<testcase>` counts as executed unless it carries `<skipped>`, matching the jest rule where
|
|
150
|
+
* pending/skipped assertions are excluded. Errors and failures DO count: they executed.
|
|
151
|
+
*/
|
|
152
|
+
function parseJUnitXmlCounts(xml, ext = 'py') {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const m of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
155
|
+
const attrs = m[1] ?? '';
|
|
156
|
+
const body = m[3] ?? '';
|
|
157
|
+
// `file` (xunit1 / dotnet loggers) is used verbatim when present. Otherwise the `classname`
|
|
158
|
+
// fallback maps to a source path — but the class↔file relationship is language-specific:
|
|
159
|
+
// @implements A-SPEC-137.3
|
|
160
|
+
// py : the MODULE names the file, the class is a nested segment. `tests.test_calc.TestThing`
|
|
161
|
+
// -> `tests/test_calc.py` (drop trailing Capitalized class segments).
|
|
162
|
+
// java/cs : the CLASS names the file. `com.x.FooTest` -> `com/x/FooTest.java` (keep all).
|
|
163
|
+
let file = /\bfile="([^"]*)"/.exec(attrs)?.[1];
|
|
164
|
+
if (!file) {
|
|
165
|
+
const cls = /\bclassname="([^"]*)"/.exec(attrs)?.[1];
|
|
166
|
+
if (!cls)
|
|
167
|
+
continue;
|
|
168
|
+
const parts = cls.split('.').filter(Boolean);
|
|
169
|
+
if (ext === 'py') {
|
|
170
|
+
while (parts.length > 1 && /^[A-Z]/.test(parts[parts.length - 1]))
|
|
171
|
+
parts.pop();
|
|
172
|
+
}
|
|
173
|
+
if (parts.length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
file = `${parts.join('/')}.${ext}`;
|
|
176
|
+
}
|
|
177
|
+
if (/<skipped\b/.test(body))
|
|
178
|
+
continue;
|
|
179
|
+
const key = file.split('\\').join('/');
|
|
180
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Run the jest half of a plan. */
|
|
185
|
+
function runJest(files, mode, cwd) {
|
|
186
|
+
// --runTestsByPath treats args as EXACT files (not testPathPattern REGEXes, which would over/mis-match
|
|
187
|
+
// or throw on a metachar); `--` + a leading-`./` guard so a path can never be read as a flag. NO
|
|
188
|
+
// --passWithNoTests on a scoped run (verification H5): if the resolved paths match ZERO tests
|
|
189
|
+
// (e.g. a cwd≠root mismatch), that must FAIL, not report a false "passed" — a scoped gate that ran
|
|
190
|
+
// nothing has verified nothing. `full` mode runs the whole suite.
|
|
191
|
+
const safeFiles = files.map((p) => (p.startsWith('-') ? `./${p}` : p));
|
|
192
|
+
// --json yields per-file assertion results = EXECUTION evidence (see parseExecutedCounts).
|
|
193
|
+
const args = mode === 'scoped'
|
|
194
|
+
? ['jest', '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
|
|
195
|
+
: ['jest', '--silent', '--json'];
|
|
196
|
+
try {
|
|
197
|
+
const out = (0, node_child_process_1.execFileSync)('npx', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
198
|
+
return { passed: true, tail: tailOf(out), executed: parseExecutedCounts(out, cwd) };
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
const err = e;
|
|
202
|
+
// A failing suite still emits --json on stdout, so execution evidence survives a red run.
|
|
203
|
+
return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: parseExecutedCounts(err.stdout ?? '', cwd) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
|
|
207
|
+
function runPytest(files, mode, cwd) {
|
|
208
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
|
|
209
|
+
const report = path.join(dir, 'report.xml');
|
|
210
|
+
// `python3 -m pytest` rather than a bare `pytest`, so the interpreter on PATH decides which
|
|
211
|
+
// environment runs — a bare `pytest` shim can belong to a different virtualenv than the project.
|
|
212
|
+
// `--` guards a path that begins with a dash. No "exit ok on no tests collected": pytest's exit
|
|
213
|
+
// code 5 (no tests ran) must stay a failure for a scoped run, for the same reason jest gets no
|
|
214
|
+
// --passWithNoTests — a gate that executed nothing has verified nothing.
|
|
215
|
+
// `junit_family=xunit1` is requested explicitly because it is the only family that emits a `file`
|
|
216
|
+
// attribute per testcase. pytest's default since v6 is xunit2, which carries only `classname` —
|
|
217
|
+
// measured: a green 4-passed/1-skipped run yielded ZERO per-file evidence under the default, so
|
|
218
|
+
// the tier gate would have had nothing to verify against.
|
|
219
|
+
const args = ['-m', 'pytest', '-q', '-o', 'junit_family=xunit1', `--junit-xml=${report}`,
|
|
220
|
+
...(mode === 'scoped' ? ['--', ...files] : [])];
|
|
221
|
+
const read = () => {
|
|
222
|
+
try {
|
|
223
|
+
return parseJUnitXmlCounts(fs.readFileSync(report, 'utf8'));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return {};
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const out = (0, node_child_process_1.execFileSync)('python3', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
231
|
+
return { passed: true, tail: tailOf(out), executed: read() };
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
const err = e;
|
|
235
|
+
// A red pytest run still writes the XML, so execution evidence survives — same property as jest.
|
|
236
|
+
return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read() };
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
try {
|
|
240
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
catch { /* best effort */ }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* @implements A-SPEC-137.2
|
|
247
|
+
* Executed-test count from `cargo test` text output. cargo prints one `test <name> ... ok|FAILED|
|
|
248
|
+
* ignored` line per test across all test binaries (unit/integration/doctest); `ok` and `FAILED` are
|
|
249
|
+
* EXECUTED, `ignored` is the skip rule. The `test result:` summary line is not itself counted (the
|
|
250
|
+
* per-test lines are the evidence). Crate-granular — cargo does not name a passing test's source
|
|
251
|
+
* file. Pure; empty input → 0.
|
|
252
|
+
*/
|
|
253
|
+
function parseCargoTest(out) {
|
|
254
|
+
let n = 0;
|
|
255
|
+
for (const line of out.split('\n')) {
|
|
256
|
+
if (/^test .+ \.\.\. (ok|FAILED)$/.test(line.trim()))
|
|
257
|
+
n++;
|
|
258
|
+
}
|
|
259
|
+
return n;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* @implements A-SPEC-137.2
|
|
263
|
+
* Run the rust half of a plan. cargo runs the WHOLE crate (a crate is its smallest test unit), so
|
|
264
|
+
* scoped and full both invoke `cargo test` in the crate root; the crate total is credited to each
|
|
265
|
+
* input test file (crate-granular). A red suite still yields the count (cargo writes the test lines
|
|
266
|
+
* to stdout on failure). Absent toolchain → `unsupported`, never a pass. `opts.cargoPresent`
|
|
267
|
+
* overrides the probe for testing.
|
|
268
|
+
*/
|
|
269
|
+
function runCargo(files, _mode, cwd, opts) {
|
|
270
|
+
const present = opts?.cargoPresent ?? (() => {
|
|
271
|
+
try {
|
|
272
|
+
(0, node_child_process_1.execFileSync)('cargo', ['--version'], { cwd, stdio: 'ignore' });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
if (!present) {
|
|
280
|
+
return { passed: false, tail: 'cargo toolchain not found on PATH — cannot execute Rust tests', executed: {}, unsupported: files };
|
|
281
|
+
}
|
|
282
|
+
let out = '';
|
|
283
|
+
let ok = true;
|
|
284
|
+
try {
|
|
285
|
+
out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
const err = e;
|
|
289
|
+
out = err.stdout ?? '';
|
|
290
|
+
ok = false;
|
|
291
|
+
}
|
|
292
|
+
const count = parseCargoTest(out);
|
|
293
|
+
const executed = {};
|
|
294
|
+
for (const f of files)
|
|
295
|
+
executed[f] = count; // crate-granular
|
|
296
|
+
// Executing nothing is not a pass, mirroring no-passWithNoTests.
|
|
297
|
+
const passed = ok && count > 0;
|
|
298
|
+
return { passed, tail: `[rust] cargo test: ${count} executed`, executed };
|
|
299
|
+
}
|
|
300
|
+
/** Read every JUnit XML report under a directory (recursively) and sum their counts for `ext`. */
|
|
301
|
+
function readJUnitReports(dir, ext) {
|
|
302
|
+
const acc = {};
|
|
303
|
+
const walk = (d) => {
|
|
304
|
+
let entries;
|
|
305
|
+
try {
|
|
306
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const e of entries) {
|
|
312
|
+
const p = path.join(d, e.name);
|
|
313
|
+
if (e.isDirectory())
|
|
314
|
+
walk(p);
|
|
315
|
+
else if (e.name.endsWith('.xml')) {
|
|
316
|
+
try {
|
|
317
|
+
const counts = parseJUnitXmlCounts(fs.readFileSync(p, 'utf8'), ext);
|
|
318
|
+
for (const [k, v] of Object.entries(counts))
|
|
319
|
+
acc[k] = (acc[k] ?? 0) + v;
|
|
320
|
+
}
|
|
321
|
+
catch { /* skip unreadable/non-JUnit xml */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
walk(dir);
|
|
326
|
+
return acc;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* @implements A-SPEC-137.3
|
|
330
|
+
* Run the Java half of a plan via gradle or maven, taking evidence from the JUnit XML they write.
|
|
331
|
+
* The presence probe RUNS `java -version` (a `/usr/bin/java` shim with no JDK fails — PATH is not
|
|
332
|
+
* enough). Absent → `unsupported`, never a pass. On this machine (no JDK) only the degradation is
|
|
333
|
+
* exercised; the run+read path is reviewed but unverified here (see A-SPEC-137.3).
|
|
334
|
+
*/
|
|
335
|
+
function runGradle(files, _mode, cwd, opts) {
|
|
336
|
+
const present = opts?.javaPresent ?? (() => {
|
|
337
|
+
try {
|
|
338
|
+
(0, node_child_process_1.execFileSync)('java', ['-version'], { cwd, stdio: 'ignore' });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
})();
|
|
345
|
+
if (!present) {
|
|
346
|
+
return { passed: false, tail: 'java runtime not found (a PATH shim without a JDK does not count) — cannot execute Java tests', executed: {}, unsupported: files };
|
|
347
|
+
}
|
|
348
|
+
// Detect the build tool. gradle wrapper > gradle > maven.
|
|
349
|
+
const has = (f) => { try {
|
|
350
|
+
return fs.existsSync(path.join(cwd, f));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return false;
|
|
354
|
+
} };
|
|
355
|
+
let cmd;
|
|
356
|
+
let args;
|
|
357
|
+
let reportDir;
|
|
358
|
+
if (has('gradlew')) {
|
|
359
|
+
cmd = './gradlew';
|
|
360
|
+
args = ['test'];
|
|
361
|
+
reportDir = 'build/test-results/test';
|
|
362
|
+
}
|
|
363
|
+
else if (has('build.gradle') || has('build.gradle.kts')) {
|
|
364
|
+
cmd = 'gradle';
|
|
365
|
+
args = ['test'];
|
|
366
|
+
reportDir = 'build/test-results/test';
|
|
367
|
+
}
|
|
368
|
+
else if (has('pom.xml')) {
|
|
369
|
+
cmd = 'mvn';
|
|
370
|
+
args = ['-q', 'test'];
|
|
371
|
+
reportDir = 'target/surefire-reports';
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
return { passed: false, tail: 'no gradle/maven build file found — cannot execute Java tests', executed: {}, unsupported: files };
|
|
375
|
+
}
|
|
376
|
+
let ok = true;
|
|
377
|
+
try {
|
|
378
|
+
(0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
ok = false;
|
|
382
|
+
} // a failing test run still wrote the JUnit XML
|
|
383
|
+
const executed = readJUnitReports(path.join(cwd, reportDir), 'java');
|
|
384
|
+
const total = Object.values(executed).reduce((a, b) => a + b, 0);
|
|
385
|
+
return { passed: ok && total > 0, tail: `[java] ${cmd} ${args.join(' ')}: ${total} executed`, executed };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* @implements A-SPEC-137.3
|
|
389
|
+
* Run the C# half of a plan via `dotnet test` with the JUnit logger. The probe RUNS `dotnet
|
|
390
|
+
* --version`; absent → `unsupported`, never a pass. dotnet is absent on this machine, so only the
|
|
391
|
+
* degradation is exercised; the run+read path is reviewed but unverified here.
|
|
392
|
+
*/
|
|
393
|
+
function runDotnet(files, _mode, cwd, opts) {
|
|
394
|
+
const present = opts?.dotnetPresent ?? (() => {
|
|
395
|
+
try {
|
|
396
|
+
(0, node_child_process_1.execFileSync)('dotnet', ['--version'], { cwd, stdio: 'ignore' });
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
if (!present) {
|
|
404
|
+
return { passed: false, tail: 'dotnet not found on PATH — cannot execute C# tests', executed: {}, unsupported: files };
|
|
405
|
+
}
|
|
406
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-dotnet-'));
|
|
407
|
+
const report = path.join(dir, 'junit.xml');
|
|
408
|
+
let ok = true;
|
|
409
|
+
try {
|
|
410
|
+
(0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
ok = false;
|
|
414
|
+
}
|
|
415
|
+
let executed = {};
|
|
416
|
+
try {
|
|
417
|
+
executed = parseJUnitXmlCounts(fs.readFileSync(report, 'utf8'), 'cs');
|
|
418
|
+
}
|
|
419
|
+
catch { /* no report */ }
|
|
420
|
+
try {
|
|
421
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
catch { /* best effort */ }
|
|
424
|
+
const total = Object.values(executed).reduce((a, b) => a + b, 0);
|
|
425
|
+
return { passed: ok && total > 0, tail: `[dotnet] dotnet test: ${total} executed`, executed };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* @implements A-SPEC-137.1
|
|
429
|
+
* Run the go half of a plan. Evidence is PACKAGE-granular (go test names a passing test's package,
|
|
430
|
+
* not its file), so each package's non-skip count is credited to its input `_test.go` files. A red
|
|
431
|
+
* suite still yields counts (go writes JSON events to stdout on failure too). Absent toolchain →
|
|
432
|
+
* `unsupported`, never a pass. `opts.goPresent` overrides the probe for testing.
|
|
433
|
+
*/
|
|
434
|
+
function runGo(files, mode, cwd, opts) {
|
|
435
|
+
const present = opts?.goPresent ?? (() => {
|
|
436
|
+
try {
|
|
437
|
+
(0, node_child_process_1.execFileSync)('go', ['version'], { cwd, stdio: 'ignore' });
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
444
|
+
if (!present) {
|
|
445
|
+
return { passed: false, tail: 'go toolchain not found on PATH — cannot execute Go tests', executed: {}, unsupported: files };
|
|
446
|
+
}
|
|
447
|
+
// Group the impacted test files by their package directory; run each package once. `full` mode
|
|
448
|
+
// runs the module (`./...`) and credits nothing per-file (package names do not map to file anchors
|
|
449
|
+
// without `go list`) — scoped is the path that produces per-file evidence.
|
|
450
|
+
const executed = {};
|
|
451
|
+
const tails = [];
|
|
452
|
+
let passed = true;
|
|
453
|
+
let anyExecuted = 0;
|
|
454
|
+
const runDir = (dirArg, creditFiles) => {
|
|
455
|
+
let out = '';
|
|
456
|
+
try {
|
|
457
|
+
out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
const err = e;
|
|
461
|
+
out = err.stdout ?? '';
|
|
462
|
+
tails.push(err.stderr ? tailOf(err.stderr, 2) : (err.message ?? 'go test failed'));
|
|
463
|
+
passed = false;
|
|
464
|
+
}
|
|
465
|
+
const perPkg = parseGoTestJson(out);
|
|
466
|
+
const dirCount = Object.values(perPkg).reduce((a, b) => a + b, 0);
|
|
467
|
+
anyExecuted += dirCount;
|
|
468
|
+
for (const f of creditFiles)
|
|
469
|
+
executed[f] = (executed[f] ?? 0) + dirCount; // package-granular
|
|
470
|
+
tails.push(`[go] ${dirArg}: ${dirCount} executed`);
|
|
471
|
+
};
|
|
472
|
+
if (mode === 'full') {
|
|
473
|
+
runDir('./...', []);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
const byDir = new Map();
|
|
477
|
+
for (const f of files) {
|
|
478
|
+
const d = path.dirname(f);
|
|
479
|
+
const arg = d === '.' || d === '' ? '.' : `./${d}`;
|
|
480
|
+
if (!byDir.has(arg))
|
|
481
|
+
byDir.set(arg, []);
|
|
482
|
+
byDir.get(arg).push(f);
|
|
483
|
+
}
|
|
484
|
+
for (const [dirArg, creditFiles] of byDir)
|
|
485
|
+
runDir(dirArg, creditFiles);
|
|
486
|
+
}
|
|
487
|
+
// A run that executed NOTHING is not a pass — the same no-passWithNoTests rule jest/pytest hold.
|
|
488
|
+
if (anyExecuted === 0)
|
|
489
|
+
passed = false;
|
|
490
|
+
return { passed, tail: tails.join('\n'), executed };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Execute a plan (I/O). Deterministic inputs → deterministic command; the pass/fail is the suite's.
|
|
494
|
+
* A no-op plan is `skipped: true, passed: true` (nothing to run is not a failure).
|
|
495
|
+
*
|
|
496
|
+
* Test files are grouped by ecosystem and each group runs under its own adapter, so a mixed repo is
|
|
497
|
+
* covered rather than silently reduced to whichever language happens to sort first. Files whose
|
|
498
|
+
* ecosystem has no adapter are returned in `unsupported` — never dropped quietly, because a scoped
|
|
499
|
+
* gate that ran part of its scope has verified less than it reports.
|
|
500
|
+
*/
|
|
501
|
+
function runTestScope(scope, cwd) {
|
|
502
|
+
const plan = planTestRun(scope);
|
|
503
|
+
if (plan.mode === 'noop') {
|
|
504
|
+
return { tier: scope.tier, mode: 'noop', ranFiles: [], passed: true, skipped: true, tail: plan.reason };
|
|
505
|
+
}
|
|
506
|
+
// planTestRun deliberately empties testFiles for a `full` run (the point is to run everything), so
|
|
507
|
+
// ecosystem detection has to read the SCOPE's impacted files instead. Grouping on plan.testFiles
|
|
508
|
+
// alone made every full-tier run fall back to jest — i.e. a Python project's full regression
|
|
509
|
+
// executed the JavaScript suite and reported on it. Found by review, not by a failing test.
|
|
510
|
+
const detectFrom = plan.mode === 'full' ? scope.impactedTestFiles : plan.testFiles;
|
|
511
|
+
const groups = new Map();
|
|
512
|
+
const unsupported = [];
|
|
513
|
+
for (const f of detectFrom) {
|
|
514
|
+
const eco = ecosystemOf(f);
|
|
515
|
+
if (!eco) {
|
|
516
|
+
unsupported.push(f);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
// In full mode the runners ignore the file list; the group only records which ecosystems exist.
|
|
520
|
+
groups.set(eco, plan.mode === 'full' ? [] : [...(groups.get(eco) ?? []), f]);
|
|
521
|
+
}
|
|
522
|
+
// A full run with no impacted files at all gives no signal about which ecosystems are present.
|
|
523
|
+
// Defaulting to jest keeps the historical behavior; the assumption is stated in the evidence tail
|
|
524
|
+
// rather than left implicit, because "we ran the wrong suite" and "we ran nothing" look identical
|
|
525
|
+
// to a caller that only sees passed=true.
|
|
526
|
+
let assumedJest = false;
|
|
527
|
+
if (plan.mode === 'full' && groups.size === 0) {
|
|
528
|
+
groups.set('jest', []);
|
|
529
|
+
assumedJest = true;
|
|
530
|
+
}
|
|
531
|
+
if (groups.size > 0) {
|
|
532
|
+
const runners = { jest: runJest, pytest: runPytest, go: runGo, rust: runCargo, java: runGradle, dotnet: runDotnet };
|
|
533
|
+
const ran = [];
|
|
534
|
+
let passed = true;
|
|
535
|
+
const tails = [];
|
|
536
|
+
const executedByFile = {};
|
|
537
|
+
for (const eco of [...groups.keys()].sort()) {
|
|
538
|
+
const r = runners[eco](groups.get(eco), plan.mode, cwd);
|
|
539
|
+
ran.push(eco);
|
|
540
|
+
passed = passed && r.passed;
|
|
541
|
+
tails.push(`[${eco}] ${r.tail}`);
|
|
542
|
+
Object.assign(executedByFile, r.executed);
|
|
543
|
+
// @implements A-SPEC-137.1 — an adapter whose toolchain is absent reports its files as
|
|
544
|
+
// unsupported; merge them so a scope that could not run them never reads as fully covered.
|
|
545
|
+
const adapterUnsupported = r.unsupported;
|
|
546
|
+
if (adapterUnsupported && adapterUnsupported.length)
|
|
547
|
+
unsupported.push(...adapterUnsupported);
|
|
548
|
+
}
|
|
549
|
+
if (assumedJest) {
|
|
550
|
+
tails.push('[assumption] full run with no impacted test files — defaulted to the jest suite');
|
|
551
|
+
}
|
|
552
|
+
if (unsupported.length) {
|
|
553
|
+
tails.push(`[unsupported] ${unsupported.length} test file(s) have no runner adapter: ${unsupported.slice(0, 5).join(', ')}`);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
tier: scope.tier, mode: plan.mode, ranFiles: plan.testFiles, passed, skipped: false,
|
|
557
|
+
tail: tails.join('\n'), executedByFile, ranWith: ran,
|
|
558
|
+
...(unsupported.length ? { unsupported } : {}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// Every file in scope belongs to an ecosystem with no adapter. Executing nothing must NOT read as
|
|
562
|
+
// a pass: there is no evidence either way, and reporting success would be the exact false comfort
|
|
563
|
+
// the no-passWithNoTests rule exists to prevent.
|
|
564
|
+
return {
|
|
565
|
+
tier: scope.tier, mode: plan.mode, ranFiles: plan.testFiles, passed: false, skipped: false,
|
|
566
|
+
tail: `no runner adapter for ${unsupported.length} test file(s): ${unsupported.slice(0, 5).join(', ')}`,
|
|
567
|
+
executedByFile: {}, unsupported, ranWith: [],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function tailOf(s, n = 6) {
|
|
571
|
+
return s.trim().split('\n').slice(-n).join('\n');
|
|
572
|
+
}
|