builder-assistant-engineer 0.2.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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/analyst/prompt.js +19 -0
- package/dist/analyst/retry.js +38 -0
- package/dist/artifacts/diff.js +47 -0
- package/dist/artifacts/lessons.js +32 -0
- package/dist/artifacts/merge.js +70 -0
- package/dist/artifacts/write.js +44 -0
- package/dist/backends/agent-cli.js +174 -0
- package/dist/backends/api.js +171 -0
- package/dist/backends/index.js +10 -0
- package/dist/backends/manual.js +52 -0
- package/dist/backends/sse.js +24 -0
- package/dist/backends/types.js +1 -0
- package/dist/bin.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/commands/context.js +12 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/next.js +103 -0
- package/dist/commands/plan.js +71 -0
- package/dist/commands/replan.js +37 -0
- package/dist/commands/review.js +37 -0
- package/dist/commands/shared.js +40 -0
- package/dist/commands/status.js +95 -0
- package/dist/config/commands.js +85 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/settings.js +8 -0
- package/dist/config/store.js +38 -0
- package/dist/core/bash.js +105 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/fs.js +35 -0
- package/dist/core/git.js +104 -0
- package/dist/core/gitignore.js +14 -0
- package/dist/core/json.js +13 -0
- package/dist/core/paths.js +14 -0
- package/dist/core/process.js +56 -0
- package/dist/core/prompt-loader.js +27 -0
- package/dist/core/state.js +27 -0
- package/dist/core/which.js +24 -0
- package/dist/detect/agents.js +17 -0
- package/dist/detect/mode.js +11 -0
- package/dist/digest/baseline.js +192 -0
- package/dist/digest/budget.js +31 -0
- package/dist/digest/files.js +167 -0
- package/dist/digest/format.js +18 -0
- package/dist/digest/git.js +19 -0
- package/dist/digest/index.js +100 -0
- package/dist/digest/manifests.js +133 -0
- package/dist/digest/read.js +28 -0
- package/dist/digest/redact.js +37 -0
- package/dist/digest/render.js +138 -0
- package/dist/digest/select.js +116 -0
- package/dist/digest/todos.js +16 -0
- package/dist/digest/tree.js +55 -0
- package/dist/digest/walk.js +72 -0
- package/dist/gates/capture.js +145 -0
- package/dist/gates/contract.js +339 -0
- package/dist/gates/enforce.js +143 -0
- package/dist/gates/findings.js +17 -0
- package/dist/gates/gate.js +315 -0
- package/dist/gates/ignore-rules.js +104 -0
- package/dist/gates/protected-files.js +311 -0
- package/dist/gates/regression.js +219 -0
- package/dist/gates/reports.js +349 -0
- package/dist/gates/results.js +295 -0
- package/dist/gates/runners.js +189 -0
- package/dist/gates/state-guard.js +56 -0
- package/dist/i18n/en.js +288 -0
- package/dist/i18n/es.js +288 -0
- package/dist/i18n/index.js +18 -0
- package/dist/interview/adaptive.js +69 -0
- package/dist/interview/brief.js +27 -0
- package/dist/interview/questions.js +18 -0
- package/dist/interview/render.js +19 -0
- package/dist/interview/reply.js +34 -0
- package/dist/next/attempts.js +151 -0
- package/dist/next/start.js +179 -0
- package/dist/plan/continuation.js +40 -0
- package/dist/plan/evidence.js +239 -0
- package/dist/plan/filter.js +20 -0
- package/dist/plan/parser.js +283 -0
- package/dist/plan/prior.js +32 -0
- package/dist/plan/questions.js +48 -0
- package/dist/plan/repair.js +33 -0
- package/dist/plan/report.js +37 -0
- package/dist/plan/run.js +191 -0
- package/dist/prompts/analyst.md +101 -0
- package/dist/prompts/continue.md +11 -0
- package/dist/prompts/fix-format.md +13 -0
- package/dist/prompts/fix-paths.md +23 -0
- package/dist/prompts/lesson.md +18 -0
- package/dist/prompts/retry.md +9 -0
- package/dist/prompts/review.md +36 -0
- package/dist/prompts/task.md +9 -0
- package/dist/review/changes.js +177 -0
- package/dist/review/diff.js +115 -0
- package/dist/review/integrity.js +150 -0
- package/dist/review/mechanical.js +62 -0
- package/dist/review/parse.js +40 -0
- package/dist/review/reviewer.js +23 -0
- package/dist/review/run.js +134 -0
- package/dist/review/scope.js +55 -0
- package/dist/review/secrets.js +163 -0
- package/dist/review/snapshot.js +38 -0
- package/dist/review/tests.js +140 -0
- package/dist/tasks/attempts.js +71 -0
- package/dist/tasks/checks.js +181 -0
- package/dist/tasks/frontmatter.js +58 -0
- package/dist/tasks/graph.js +27 -0
- package/dist/tasks/handoff.js +62 -0
- package/dist/tasks/learn.js +128 -0
- package/dist/tasks/load.js +37 -0
- package/dist/tasks/metrics.js +40 -0
- package/dist/tasks/runs.js +12 -0
- package/dist/tasks/schema.js +96 -0
- package/dist/tasks/select.js +15 -0
- package/dist/tasks/shell-words.js +176 -0
- package/dist/tasks/status.js +13 -0
- package/dist/tasks/verify.js +181 -0
- package/dist/ui/clack.js +46 -0
- package/dist/ui/prompter.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { directRunner, expandCommand, testRunners } from "./runners.js";
|
|
5
|
+
export const REPORT_SOURCES = [
|
|
6
|
+
"vitest-json",
|
|
7
|
+
"jest-json",
|
|
8
|
+
"pytest-junit",
|
|
9
|
+
"go-json",
|
|
10
|
+
"dotnet-trx",
|
|
11
|
+
"node-junit",
|
|
12
|
+
];
|
|
13
|
+
const PASSED = new Set(["passed"]);
|
|
14
|
+
const FAILED = new Set(["failed"]);
|
|
15
|
+
const TRX_FAILED = new Set(["Failed", "Error", "Timeout", "Aborted"]);
|
|
16
|
+
const ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
|
|
17
|
+
export async function probeFor(cwd, command, sources) {
|
|
18
|
+
const direct = directRunner(command, sources);
|
|
19
|
+
if (direct && direct.runner !== "cargo" && direct.runner !== "pytest") {
|
|
20
|
+
return argumentProbe(cwd, command, direct);
|
|
21
|
+
}
|
|
22
|
+
const runners = testRunners(command, sources);
|
|
23
|
+
if (runners.length > 0 && runners.every((name) => name === "pytest")) {
|
|
24
|
+
return pytestProbe(command);
|
|
25
|
+
}
|
|
26
|
+
const text = expandCommand(command, sources);
|
|
27
|
+
if (runners.length > 0 &&
|
|
28
|
+
runners.every((name) => name === "node") &&
|
|
29
|
+
!/--test-reporter/.test(text)) {
|
|
30
|
+
return nodeProbe(cwd, command);
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
async function argumentProbe(cwd, command, direct) {
|
|
35
|
+
const { runner, args } = direct;
|
|
36
|
+
if (runner === "go") {
|
|
37
|
+
if (args.includes("-args"))
|
|
38
|
+
return undefined;
|
|
39
|
+
const json = args.includes("-json") || args.includes("--json");
|
|
40
|
+
const quiet = command
|
|
41
|
+
.replace(/(^|\s)-v(=true)?(?=\s|$)/g, "$1")
|
|
42
|
+
.replace(/\s+/g, " ")
|
|
43
|
+
.trim();
|
|
44
|
+
return {
|
|
45
|
+
command: json ? command : extend(quiet, direct.suffix(["-json"])),
|
|
46
|
+
env: {},
|
|
47
|
+
collect: async (stdout) => goReport(stdout),
|
|
48
|
+
view: goView,
|
|
49
|
+
dispose: async () => { },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (runner === "vitest" && args.some((arg) => arg.startsWith("--outputFile")))
|
|
53
|
+
return undefined;
|
|
54
|
+
if (runner === "jest" && args.some((arg) => arg === "--json" || arg.startsWith("--outputFile"))) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (runner === "dotnet" && args.some((arg) => arg === "--" || arg === "--results-directory")) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), "bae-report-"));
|
|
61
|
+
const file = slashed(join(dir, "report.json"));
|
|
62
|
+
const extra = extraArgs(runner, slashed(dir), file);
|
|
63
|
+
if (!extra) {
|
|
64
|
+
await rm(dir, { recursive: true, force: true });
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const roots = await rootsOf(cwd);
|
|
68
|
+
return {
|
|
69
|
+
command: extend(command, direct.suffix(extra)),
|
|
70
|
+
env: {},
|
|
71
|
+
collect: async () => runner === "dotnet"
|
|
72
|
+
? trxReport(await readReports(dir, ".trx"))
|
|
73
|
+
: jestReport(await readText(file), roots, runner === "vitest" ? "vitest-json" : "jest-json"),
|
|
74
|
+
dispose: () => rm(dir, { recursive: true, force: true }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function extraArgs(runner, dir, file) {
|
|
78
|
+
if (runner === "vitest")
|
|
79
|
+
return ["--reporter=default", "--reporter=json", `--outputFile.json=${file}`];
|
|
80
|
+
if (runner === "jest")
|
|
81
|
+
return ["--json", `--outputFile=${file}`];
|
|
82
|
+
if (runner === "dotnet")
|
|
83
|
+
return ["--logger", "trx", "--results-directory", dir];
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
async function nodeProbe(cwd, command) {
|
|
87
|
+
const dir = await mkdtemp(join(tmpdir(), "bae-report-"));
|
|
88
|
+
const file = slashed(join(dir, "junit.xml"));
|
|
89
|
+
const existing = process.env.NODE_OPTIONS ?? "";
|
|
90
|
+
const reporters = `--test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination="${file}"`;
|
|
91
|
+
const roots = await rootsOf(cwd);
|
|
92
|
+
return {
|
|
93
|
+
command,
|
|
94
|
+
env: { NODE_OPTIONS: `${existing} ${reporters}`.trim() },
|
|
95
|
+
collect: async () => junitReport(await readText(file), "node-junit", roots),
|
|
96
|
+
dispose: () => rm(dir, { recursive: true, force: true }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function pytestProbe(command) {
|
|
100
|
+
const dir = await mkdtemp(join(tmpdir(), "bae-report-"));
|
|
101
|
+
const file = slashed(join(dir, "junit.xml"));
|
|
102
|
+
const existing = process.env.PYTEST_ADDOPTS ?? "";
|
|
103
|
+
return {
|
|
104
|
+
command,
|
|
105
|
+
env: { PYTEST_ADDOPTS: `${existing} --junitxml="${file}"`.trim() },
|
|
106
|
+
collect: async () => junitReport(await readText(file)),
|
|
107
|
+
dispose: () => rm(dir, { recursive: true, force: true }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function jestReport(text, roots, source) {
|
|
111
|
+
const data = parseJson(text);
|
|
112
|
+
const files = Array.isArray(data?.testResults) ? data.testResults : undefined;
|
|
113
|
+
if (!files)
|
|
114
|
+
return undefined;
|
|
115
|
+
const tally = { passed: 0, failed: 0, skipped: 0, failing: [], units: [] };
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const name = relativeTo(String(file.name ?? ""), roots);
|
|
118
|
+
tally.units.push(name);
|
|
119
|
+
const cases = Array.isArray(file.assertionResults)
|
|
120
|
+
? file.assertionResults
|
|
121
|
+
: [];
|
|
122
|
+
let failedHere = 0;
|
|
123
|
+
for (const item of cases) {
|
|
124
|
+
const status = String(item.status ?? "");
|
|
125
|
+
if (PASSED.has(status))
|
|
126
|
+
tally.passed++;
|
|
127
|
+
else if (FAILED.has(status)) {
|
|
128
|
+
failedHere++;
|
|
129
|
+
tally.failed++;
|
|
130
|
+
tally.failing.push(caseName(item));
|
|
131
|
+
}
|
|
132
|
+
else
|
|
133
|
+
tally.skipped++;
|
|
134
|
+
}
|
|
135
|
+
if (file.status === "failed" && failedHere === 0) {
|
|
136
|
+
tally.failed++;
|
|
137
|
+
tally.failing.push(name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return report(tally, source);
|
|
141
|
+
}
|
|
142
|
+
export function junitReport(text, source = "pytest-junit", roots = []) {
|
|
143
|
+
if (!text || !/<testsuites?\b/.test(text))
|
|
144
|
+
return undefined;
|
|
145
|
+
const tally = { passed: 0, failed: 0, skipped: 0, failing: [], units: [] };
|
|
146
|
+
for (const { attributes, body } of elements(text, "testcase")) {
|
|
147
|
+
const name = [attributes.classname, attributes.name].filter(Boolean).join("::");
|
|
148
|
+
const file = attributes.file ? relativeTo(attributes.file, roots) : undefined;
|
|
149
|
+
tally.units.push(source === "node-junit" ? (file ?? "") : (attributes.file ?? attributes.classname ?? ""));
|
|
150
|
+
const title = attributes.name ?? "";
|
|
151
|
+
const silent = source === "node-junit" &&
|
|
152
|
+
/\.[cm]?[jt]sx?$/.test(title) &&
|
|
153
|
+
(/[\\/]/.test(title) || slashed(attributes.file ?? "").endsWith(`/${title}`));
|
|
154
|
+
if (silent) {
|
|
155
|
+
const path = relativeTo(attributes.name ?? "", roots);
|
|
156
|
+
tally.units.push(path);
|
|
157
|
+
tally.failed++;
|
|
158
|
+
tally.failing.push(path);
|
|
159
|
+
}
|
|
160
|
+
else if (/<(failure|error)\b/.test(body)) {
|
|
161
|
+
tally.failed++;
|
|
162
|
+
tally.failing.push(name);
|
|
163
|
+
}
|
|
164
|
+
else if (/<skipped\b/.test(body))
|
|
165
|
+
tally.skipped++;
|
|
166
|
+
else
|
|
167
|
+
tally.passed++;
|
|
168
|
+
}
|
|
169
|
+
return report(tally, source);
|
|
170
|
+
}
|
|
171
|
+
export function goReport(stdout) {
|
|
172
|
+
const events = stdout.split(/\r?\n/).flatMap((line) => {
|
|
173
|
+
const event = line.startsWith("{") ? parseJson(line) : undefined;
|
|
174
|
+
return event && typeof event.Action === "string" ? [event] : [];
|
|
175
|
+
});
|
|
176
|
+
if (events.length === 0)
|
|
177
|
+
return undefined;
|
|
178
|
+
const started = new Set();
|
|
179
|
+
const results = new Map();
|
|
180
|
+
const packages = new Map();
|
|
181
|
+
for (const event of events) {
|
|
182
|
+
const pkg = String(event.Package ?? "");
|
|
183
|
+
const action = String(event.Action);
|
|
184
|
+
if (typeof event.Test !== "string") {
|
|
185
|
+
if (pkg && ["pass", "fail", "skip"].includes(action))
|
|
186
|
+
packages.set(pkg, action);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const key = `${pkg}::${event.Test}`;
|
|
190
|
+
if (action === "run")
|
|
191
|
+
started.add(key);
|
|
192
|
+
else if (["pass", "fail", "skip"].includes(action) && started.has(key)) {
|
|
193
|
+
results.set(key, action);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const tally = { passed: 0, failed: 0, skipped: 0, failing: [], units: [] };
|
|
197
|
+
for (const [key, action] of results) {
|
|
198
|
+
if (action === "pass")
|
|
199
|
+
tally.passed++;
|
|
200
|
+
else if (action === "skip")
|
|
201
|
+
tally.skipped++;
|
|
202
|
+
else {
|
|
203
|
+
tally.failed++;
|
|
204
|
+
tally.failing.push(key);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
tally.units.push(...packages.keys());
|
|
208
|
+
for (const [pkg, action] of packages) {
|
|
209
|
+
const failedTests = tally.failing.some((name) => name.startsWith(`${pkg}::`));
|
|
210
|
+
if (action === "fail" && !failedTests) {
|
|
211
|
+
tally.failed++;
|
|
212
|
+
tally.failing.push(pkg);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return report(tally, "go-json");
|
|
216
|
+
}
|
|
217
|
+
export function trxReport(texts) {
|
|
218
|
+
if (texts.length === 0)
|
|
219
|
+
return undefined;
|
|
220
|
+
const tally = { passed: 0, failed: 0, skipped: 0, failing: [], units: [] };
|
|
221
|
+
for (const text of texts) {
|
|
222
|
+
for (const { attributes } of elements(text, "UnitTest")) {
|
|
223
|
+
tally.units.push(slashed(attributes.storage ?? "")
|
|
224
|
+
.split("/")
|
|
225
|
+
.at(-1) ?? "");
|
|
226
|
+
}
|
|
227
|
+
for (const { attributes } of elements(text, "UnitTestResult")) {
|
|
228
|
+
const outcome = attributes.outcome ?? "";
|
|
229
|
+
if (outcome === "Passed")
|
|
230
|
+
tally.passed++;
|
|
231
|
+
else if (TRX_FAILED.has(outcome)) {
|
|
232
|
+
tally.failed++;
|
|
233
|
+
tally.failing.push(attributes.testName ?? "");
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
tally.skipped++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return report(tally, "dotnet-trx");
|
|
240
|
+
}
|
|
241
|
+
export function goText(stdout) {
|
|
242
|
+
const view = goView();
|
|
243
|
+
return view.push(stdout) + view.end();
|
|
244
|
+
}
|
|
245
|
+
export function goView() {
|
|
246
|
+
let pending = "";
|
|
247
|
+
const render = (line) => {
|
|
248
|
+
const event = line.startsWith("{") ? parseJson(line) : undefined;
|
|
249
|
+
if (!event || typeof event.Action !== "string")
|
|
250
|
+
return `${line}\n`;
|
|
251
|
+
return typeof event.Output === "string" ? event.Output : "";
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
push: (chunk) => {
|
|
255
|
+
const lines = (pending + chunk).split("\n");
|
|
256
|
+
pending = lines.pop() ?? "";
|
|
257
|
+
return lines.map((line) => render(line.replace(/\r$/, ""))).join("");
|
|
258
|
+
},
|
|
259
|
+
end: () => {
|
|
260
|
+
const rest = pending ? render(pending) : "";
|
|
261
|
+
pending = "";
|
|
262
|
+
return rest;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function elements(text, tag) {
|
|
267
|
+
const clean = text.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
|
|
268
|
+
const pattern = new RegExp(`<${tag}((?:\\s+[\\w:.-]+\\s*=\\s*(?:"[^"]*"|'[^']*'))*)\\s*(/?)>`, "g");
|
|
269
|
+
const found = [];
|
|
270
|
+
for (const match of clean.matchAll(pattern)) {
|
|
271
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
272
|
+
const end = match[2] ? start : clean.indexOf(`</${tag}>`, start);
|
|
273
|
+
found.push({
|
|
274
|
+
attributes: attributesOf(match[1] ?? ""),
|
|
275
|
+
body: end === -1 ? clean.slice(start) : clean.slice(start, end),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return found;
|
|
279
|
+
}
|
|
280
|
+
function attributesOf(text) {
|
|
281
|
+
const attributes = {};
|
|
282
|
+
for (const match of text.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
283
|
+
attributes[match[1] ?? ""] = decode(match[2] ?? match[3] ?? "");
|
|
284
|
+
}
|
|
285
|
+
return attributes;
|
|
286
|
+
}
|
|
287
|
+
function decode(value) {
|
|
288
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (whole, entity) => {
|
|
289
|
+
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
|
290
|
+
return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
|
291
|
+
}
|
|
292
|
+
if (entity.startsWith("#"))
|
|
293
|
+
return String.fromCodePoint(Number(entity.slice(1)));
|
|
294
|
+
return ENTITIES[entity] ?? whole;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function caseName(item) {
|
|
298
|
+
const ancestors = Array.isArray(item.ancestorTitles) ? item.ancestorTitles.map(String) : [];
|
|
299
|
+
return [...ancestors, String(item.title ?? item.fullName ?? "")].join(" > ");
|
|
300
|
+
}
|
|
301
|
+
function report(tally, source) {
|
|
302
|
+
const { passed, failed, skipped } = tally;
|
|
303
|
+
const unique = (items) => [...new Set(items.filter(Boolean))].sort();
|
|
304
|
+
return {
|
|
305
|
+
counts: { passed, failed, skipped },
|
|
306
|
+
failing: unique(tally.failing),
|
|
307
|
+
source,
|
|
308
|
+
units: unique(tally.units),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function relativeTo(path, roots) {
|
|
312
|
+
const normal = slashed(path);
|
|
313
|
+
const root = roots.find((candidate) => normal.startsWith(`${candidate}/`));
|
|
314
|
+
return root ? normal.slice(root.length + 1) : normal;
|
|
315
|
+
}
|
|
316
|
+
async function rootsOf(cwd) {
|
|
317
|
+
const real = await realpath(cwd).catch(() => cwd);
|
|
318
|
+
return [...new Set([slashed(cwd), slashed(real)])];
|
|
319
|
+
}
|
|
320
|
+
function extend(command, extra) {
|
|
321
|
+
return extra.length > 0 ? `${command} ${extra.map(quote).join(" ")}` : command;
|
|
322
|
+
}
|
|
323
|
+
function quote(word) {
|
|
324
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, "'\\''")}'`;
|
|
325
|
+
}
|
|
326
|
+
function slashed(path) {
|
|
327
|
+
return path.replace(/\\/g, "/");
|
|
328
|
+
}
|
|
329
|
+
async function readText(path) {
|
|
330
|
+
return readFile(path, "utf8").catch(() => undefined);
|
|
331
|
+
}
|
|
332
|
+
async function readReports(dir, extension) {
|
|
333
|
+
const names = await readdir(dir).catch(() => []);
|
|
334
|
+
const texts = await Promise.all(names.filter((name) => name.endsWith(extension)).map((name) => readText(join(dir, name))));
|
|
335
|
+
return texts.filter((text) => text !== undefined);
|
|
336
|
+
}
|
|
337
|
+
function parseJson(text) {
|
|
338
|
+
if (!text)
|
|
339
|
+
return undefined;
|
|
340
|
+
try {
|
|
341
|
+
const value = JSON.parse(text);
|
|
342
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
343
|
+
? value
|
|
344
|
+
: undefined;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const countsSchema = z.object({
|
|
4
|
+
passed: z.number().int().nonnegative(),
|
|
5
|
+
failed: z.number().int().nonnegative(),
|
|
6
|
+
skipped: z.number().int().nonnegative(),
|
|
7
|
+
});
|
|
8
|
+
const PARSERS = {
|
|
9
|
+
vitest,
|
|
10
|
+
jest,
|
|
11
|
+
pytest,
|
|
12
|
+
cargo,
|
|
13
|
+
go: goTest,
|
|
14
|
+
mocha,
|
|
15
|
+
node: nodeTest,
|
|
16
|
+
bun,
|
|
17
|
+
deno,
|
|
18
|
+
unittest,
|
|
19
|
+
rspec,
|
|
20
|
+
minitest,
|
|
21
|
+
phpunit,
|
|
22
|
+
dotnet,
|
|
23
|
+
surefire,
|
|
24
|
+
eslint,
|
|
25
|
+
playwright,
|
|
26
|
+
errors: errorsFound,
|
|
27
|
+
};
|
|
28
|
+
const HINTS = [
|
|
29
|
+
[/\bvitest\b/, ["vitest"]],
|
|
30
|
+
[/\bjest\b|\b(react-scripts|craco)\s+test\b/, ["jest"]],
|
|
31
|
+
[/\bmocha\b/, ["mocha"]],
|
|
32
|
+
[/\bnode\b[^|;&]*--test\b/, ["node"]],
|
|
33
|
+
[/\b(pytest|py\.test)\b/, ["pytest"]],
|
|
34
|
+
[/\bgo\s+test\b/, ["go"]],
|
|
35
|
+
[/\bcargo\s+(test|nextest)\b/, ["cargo"]],
|
|
36
|
+
[/\brspec\b/, ["rspec"]],
|
|
37
|
+
[/\b(rails|rake)\s+test\b|\bminitest\b/, ["minitest"]],
|
|
38
|
+
[/\b(phpunit|pest)\b/, ["phpunit"]],
|
|
39
|
+
[/\bdotnet\s+test\b/, ["dotnet"]],
|
|
40
|
+
[/\b(mvnw?|gradlew?)\b/, ["surefire"]],
|
|
41
|
+
[/\bbun\s+test\b/, ["bun"]],
|
|
42
|
+
[/\bdeno\s+test\b/, ["deno"]],
|
|
43
|
+
[/\bunittest\b/, ["unittest"]],
|
|
44
|
+
[/\beslint\b/, ["eslint"]],
|
|
45
|
+
[/\bplaywright\b/, ["playwright"]],
|
|
46
|
+
[/\b(tsc|vue-tsc|biome|ruff|mypy)\b/, ["errors"]],
|
|
47
|
+
];
|
|
48
|
+
export function runnerHint(command) {
|
|
49
|
+
return [...new Set(HINTS.flatMap(([pattern, names]) => (pattern.test(command) ? names : [])))];
|
|
50
|
+
}
|
|
51
|
+
export function parseSummary(output, hint = []) {
|
|
52
|
+
const text = stripVTControlCharacters(output).replace(/\r\n?/g, "\n");
|
|
53
|
+
const names = hint.length > 0 ? hint : Object.keys(PARSERS);
|
|
54
|
+
const found = names.flatMap((name) => {
|
|
55
|
+
const item = PARSERS[name](text);
|
|
56
|
+
return item ? [{ ...item, source: name }] : [];
|
|
57
|
+
});
|
|
58
|
+
const latest = found.sort((a, b) => b.at - a.at)[0];
|
|
59
|
+
return latest ? { counts: latest.counts, source: latest.source } : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function parseCounts(output, hint = []) {
|
|
62
|
+
return parseSummary(output, hint)?.counts;
|
|
63
|
+
}
|
|
64
|
+
const FAILING = {
|
|
65
|
+
vitest: [/^\s*FAIL\s+(\S.*? > .+?)\s*$/gm, /^\s*FAIL\s+(\S+)\s*(?:\[.*\])?\s*$/gm],
|
|
66
|
+
jest: [/^\s*● (?!Test suite failed to run)(.+?)\s*$/gm, /^FAIL\s+(\S+)/gm],
|
|
67
|
+
pytest: [/^(?:FAILED|ERROR) (\S+::\S+|\S+\.py)/gm],
|
|
68
|
+
go: [/^\s*--- FAIL: (\S+)/gm, /^FAIL\s+(\S+)\s+(?:\[.*\]|[\d.]+s)/gm, /^panic: .*/gm],
|
|
69
|
+
cargo: [/^test (\S+) \.\.\. FAILED$/gm, /^error: test failed, to rerun pass (.+)$/gm],
|
|
70
|
+
rspec: [/^rspec \S+ # (.+)$/gm],
|
|
71
|
+
unittest: [/^(?:FAIL|ERROR): (\S+ \(.+\))$/gm],
|
|
72
|
+
node: [/^not ok \d+ - (.+)$/gm],
|
|
73
|
+
mocha: [/^\s+\d+\) (.+):$/gm],
|
|
74
|
+
phpunit: [/^\d+\) (\S+::\S+)/gm],
|
|
75
|
+
dotnet: [/^\s+Failed (\S+) \[/gm],
|
|
76
|
+
errors: [/^(\S[^(\n]*)\(\d+,\d+\): error (TS\d+)/gm, /^(\S+):\d+:\d+ (lint\/\S+)/gm],
|
|
77
|
+
playwright: [/^\s+\d+\) (\[.+?\] › .+)$/gm],
|
|
78
|
+
};
|
|
79
|
+
const STRICT = ["vitest", "go", "cargo", "unittest"];
|
|
80
|
+
export function parseFailing(output, hint) {
|
|
81
|
+
const text = stripVTControlCharacters(output).replace(/\r\n?/g, "\n");
|
|
82
|
+
const patterns = (hint.length > 0 ? hint : STRICT).flatMap((name) => FAILING[name] ?? []);
|
|
83
|
+
if (patterns.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const names = patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => (match.slice(1).filter(Boolean).join(" ") || match[0] || "").trim()));
|
|
86
|
+
return names.length > 0 ? [...new Set(names)].sort() : undefined;
|
|
87
|
+
}
|
|
88
|
+
export function executed(counts) {
|
|
89
|
+
return counts.passed + counts.failed;
|
|
90
|
+
}
|
|
91
|
+
function counts(passed = 0, failed = 0, skipped = 0) {
|
|
92
|
+
return { passed, failed, skipped };
|
|
93
|
+
}
|
|
94
|
+
function all(text, pattern) {
|
|
95
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
96
|
+
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
97
|
+
}
|
|
98
|
+
function last(text, pattern) {
|
|
99
|
+
return all(text, pattern).at(-1);
|
|
100
|
+
}
|
|
101
|
+
function number(value) {
|
|
102
|
+
return value ? Number(value) : 0;
|
|
103
|
+
}
|
|
104
|
+
function parts(list, separator) {
|
|
105
|
+
const found = new Map();
|
|
106
|
+
for (const part of list.split(separator)) {
|
|
107
|
+
const match = /(\d+)\s+([a-z]+)/i.exec(part.trim());
|
|
108
|
+
if (match)
|
|
109
|
+
found.set((match[2] ?? "").toLowerCase(), number(match[1]));
|
|
110
|
+
}
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
function at(match, value) {
|
|
114
|
+
return match ? { counts: value, at: match.index } : undefined;
|
|
115
|
+
}
|
|
116
|
+
function vitest(text) {
|
|
117
|
+
const match = last(text, /^\s*Tests\s+((?:\d+ [a-z]+(?: [a-z]+)?(?: \| )?)+)\s*\((\d+)\)/m);
|
|
118
|
+
if (!match)
|
|
119
|
+
return undefined;
|
|
120
|
+
const found = parts(match[1] ?? "", /\|/);
|
|
121
|
+
return at(match, counts(found.get("passed"), found.get("failed"), (found.get("skipped") ?? 0) + (found.get("todo") ?? 0)));
|
|
122
|
+
}
|
|
123
|
+
function jest(text) {
|
|
124
|
+
const match = last(text, /^Tests:\s+(.*?)(\d+) total$/m);
|
|
125
|
+
if (!match)
|
|
126
|
+
return undefined;
|
|
127
|
+
const found = parts(match[1] ?? "", /,/);
|
|
128
|
+
return at(match, counts(found.get("passed"), found.get("failed"), (found.get("skipped") ?? 0) + (found.get("todo") ?? 0)));
|
|
129
|
+
}
|
|
130
|
+
function pytest(text) {
|
|
131
|
+
const match = last(text, /^(?:=+ )?((?:\d+ (?:passed|failed|skipped|errors?|xfailed|xpassed|deselected|warnings?)(?:, )?)+) in [\d.]+s/m);
|
|
132
|
+
if (!match)
|
|
133
|
+
return undefined;
|
|
134
|
+
const found = parts(match[1] ?? "", /,/);
|
|
135
|
+
return at(match, counts((found.get("passed") ?? 0) + (found.get("xpassed") ?? 0), (found.get("failed") ?? 0) + (found.get("error") ?? 0) + (found.get("errors") ?? 0), (found.get("skipped") ?? 0) + (found.get("xfailed") ?? 0) + (found.get("deselected") ?? 0)));
|
|
136
|
+
}
|
|
137
|
+
function cargo(output) {
|
|
138
|
+
const text = cargoText(output);
|
|
139
|
+
const matches = all(text, /^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored/m);
|
|
140
|
+
if (matches.length === 0)
|
|
141
|
+
return undefined;
|
|
142
|
+
const sum = matches.reduce((total, match) => counts(total.passed + number(match[1]), total.failed + number(match[2]), total.skipped + number(match[3])), counts());
|
|
143
|
+
return at(matches.at(-1), { ...sum, failed: sum.failed + cargoCutShort(text) });
|
|
144
|
+
}
|
|
145
|
+
function cargoText(output) {
|
|
146
|
+
return output.replace(/^---- .+ std(?:out|err) ----$[\s\S]*?^failures:$/gm, "");
|
|
147
|
+
}
|
|
148
|
+
function cargoCutShort(text) {
|
|
149
|
+
let missing = 0;
|
|
150
|
+
let open;
|
|
151
|
+
for (const line of text.split("\n")) {
|
|
152
|
+
const running = /^running (\d+) tests?$/.exec(line);
|
|
153
|
+
if (running) {
|
|
154
|
+
if (open !== undefined)
|
|
155
|
+
missing += Math.max(open, 1);
|
|
156
|
+
open = number(running[1]);
|
|
157
|
+
}
|
|
158
|
+
else if (/^test result: /.test(line)) {
|
|
159
|
+
open = undefined;
|
|
160
|
+
}
|
|
161
|
+
else if (open !== undefined && /^test \S+ \.\.\. (?:ok|FAILED|ignored)/.test(line)) {
|
|
162
|
+
open = Math.max(open - 1, 0);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return missing + (open === undefined ? 0 : Math.max(open, 1));
|
|
166
|
+
}
|
|
167
|
+
export function cargoUnits(output) {
|
|
168
|
+
return [...cargoText(output).matchAll(/^\s*Running (?:unittests )?(\S+)(?: \(([^)]+)\))?/gm)].map((match) => (match[2] ?? match[1] ?? "").replace(/\\/g, "/").replace(/-[0-9a-f]{16}(\.exe)?$/, ""));
|
|
169
|
+
}
|
|
170
|
+
export function errorLines(output) {
|
|
171
|
+
const text = stripVTControlCharacters(output).replace(/\r\n?/g, "\n");
|
|
172
|
+
const lines = text
|
|
173
|
+
.split("\n")
|
|
174
|
+
.filter((line) => /\berror\b|\S+:\d+(:\d+)?:/i.test(line))
|
|
175
|
+
.map((line) => line
|
|
176
|
+
.replace(/:\d+(:\d+)?/g, "")
|
|
177
|
+
.replace(/\(\d+,\d+\)/g, "")
|
|
178
|
+
.replace(/\b\d+(\.\d+)?m?s\b/g, "")
|
|
179
|
+
.trim())
|
|
180
|
+
.filter(Boolean);
|
|
181
|
+
return [...new Set(lines)].sort();
|
|
182
|
+
}
|
|
183
|
+
function goTest(text) {
|
|
184
|
+
const verbose = all(text, /^\s*--- (PASS|FAIL|SKIP): /m);
|
|
185
|
+
if (verbose.length === 0 || !/^=== RUN /m.test(text))
|
|
186
|
+
return undefined;
|
|
187
|
+
const tally = (kind) => verbose.filter((match) => match[1] === kind).length;
|
|
188
|
+
return at(verbose.at(-1), counts(tally("PASS"), tally("FAIL"), tally("SKIP")));
|
|
189
|
+
}
|
|
190
|
+
function mocha(text) {
|
|
191
|
+
const passing = last(text, /^\s*(\d+) passing\b/m);
|
|
192
|
+
if (!passing)
|
|
193
|
+
return undefined;
|
|
194
|
+
const tail = text.slice(passing.index, passing.index + 300);
|
|
195
|
+
const failing = last(tail, /^\s*(\d+) failing\b/m);
|
|
196
|
+
const pending = last(tail, /^\s*(\d+) pending\b/m);
|
|
197
|
+
return at(passing, counts(number(passing[1]), number(failing?.[1]), number(pending?.[1])));
|
|
198
|
+
}
|
|
199
|
+
function nodeTest(text) {
|
|
200
|
+
const pass = last(text, /^(?:#|ℹ) pass (\d+)$/m);
|
|
201
|
+
const fail = last(text, /^(?:#|ℹ) fail (\d+)$/m);
|
|
202
|
+
if (!pass || !fail)
|
|
203
|
+
return undefined;
|
|
204
|
+
const skipped = last(text, /^(?:#|ℹ) skipped (\d+)$/m);
|
|
205
|
+
const todo = last(text, /^(?:#|ℹ) todo (\d+)$/m);
|
|
206
|
+
return at(fail, counts(number(pass[1]), number(fail[1]), number(skipped?.[1]) + number(todo?.[1])));
|
|
207
|
+
}
|
|
208
|
+
function bun(text) {
|
|
209
|
+
const pass = last(text, /^\s*(\d+) pass$/m);
|
|
210
|
+
const fail = last(text, /^\s*(\d+) fail$/m);
|
|
211
|
+
if (!pass || !fail)
|
|
212
|
+
return undefined;
|
|
213
|
+
const skip = last(text, /^\s*(\d+) skip$/m);
|
|
214
|
+
const todo = last(text, /^\s*(\d+) todo$/m);
|
|
215
|
+
return at(fail, counts(number(pass[1]), number(fail[1]), number(skip?.[1]) + number(todo?.[1])));
|
|
216
|
+
}
|
|
217
|
+
function deno(text) {
|
|
218
|
+
const match = last(text, /^(?:ok|FAILED) \| (\d+) passed(?: \(\d+ steps?\))? \| (\d+) failed(?: \(\d+ steps?\))?(?: \| (\d+) ignored)?/m);
|
|
219
|
+
return match
|
|
220
|
+
? at(match, counts(number(match[1]), number(match[2]), number(match[3])))
|
|
221
|
+
: undefined;
|
|
222
|
+
}
|
|
223
|
+
function unittest(text) {
|
|
224
|
+
const ran = last(text, /^Ran (\d+) tests? in [\d.]+s$/m);
|
|
225
|
+
if (!ran)
|
|
226
|
+
return undefined;
|
|
227
|
+
const status = last(text, /^(OK|FAILED)(?: \((.*)\))?$/m);
|
|
228
|
+
const found = parts((status?.[2] ?? "").replace(/=/g, " ").replace(/(\w+) (\d+)/g, "$2 $1"), /,/);
|
|
229
|
+
const failed = (found.get("failures") ?? 0) + (found.get("errors") ?? 0);
|
|
230
|
+
const skipped = found.get("skipped") ?? 0;
|
|
231
|
+
return at(ran, counts(number(ran[1]) - failed - skipped, failed, skipped));
|
|
232
|
+
}
|
|
233
|
+
function rspec(text) {
|
|
234
|
+
const match = last(text, /^(\d+) examples?, (\d+) failures?(?:, (\d+) pending)?/m);
|
|
235
|
+
if (!match)
|
|
236
|
+
return undefined;
|
|
237
|
+
const failed = number(match[2]);
|
|
238
|
+
const skipped = number(match[3]);
|
|
239
|
+
return at(match, counts(number(match[1]) - failed - skipped, failed, skipped));
|
|
240
|
+
}
|
|
241
|
+
function minitest(text) {
|
|
242
|
+
const match = last(text, /^(\d+) (?:runs|tests), \d+ assertions, (\d+) failures, (\d+) errors, (\d+) skips/m);
|
|
243
|
+
if (!match)
|
|
244
|
+
return undefined;
|
|
245
|
+
const failed = number(match[2]) + number(match[3]);
|
|
246
|
+
const skipped = number(match[4]);
|
|
247
|
+
return at(match, counts(number(match[1]) - failed - skipped, failed, skipped));
|
|
248
|
+
}
|
|
249
|
+
function phpunit(text) {
|
|
250
|
+
const ok = last(text, /^OK \((\d+) tests?, \d+ assertions?\)/m);
|
|
251
|
+
const match = last(text, /^Tests: (\d+), Assertions: \d+(.*)$/m);
|
|
252
|
+
if (ok && (!match || ok.index > match.index))
|
|
253
|
+
return at(ok, counts(number(ok[1])));
|
|
254
|
+
if (!match)
|
|
255
|
+
return undefined;
|
|
256
|
+
const found = parts((match[2] ?? "").replace(/(\w+): (\d+)/g, "$2 $1"), /,/);
|
|
257
|
+
const failed = (found.get("errors") ?? 0) + (found.get("failures") ?? 0);
|
|
258
|
+
const skipped = (found.get("skipped") ?? 0) + (found.get("incomplete") ?? 0);
|
|
259
|
+
return at(match, counts(number(match[1]) - failed - skipped, failed, skipped));
|
|
260
|
+
}
|
|
261
|
+
function dotnet(text) {
|
|
262
|
+
const matches = all(text, /(?:Passed|Failed)!\s+-\s+Failed:\s+(\d+),\s+Passed:\s+(\d+),\s+Skipped:\s+(\d+),\s+Total:\s+\d+/);
|
|
263
|
+
if (matches.length === 0)
|
|
264
|
+
return undefined;
|
|
265
|
+
const sum = matches.reduce((total, match) => counts(total.passed + number(match[2]), total.failed + number(match[1]), total.skipped + number(match[3])), counts());
|
|
266
|
+
return at(matches.at(-1), sum);
|
|
267
|
+
}
|
|
268
|
+
function surefire(text) {
|
|
269
|
+
const match = last(text, /Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)/);
|
|
270
|
+
if (!match)
|
|
271
|
+
return undefined;
|
|
272
|
+
const failed = number(match[2]) + number(match[3]);
|
|
273
|
+
const skipped = number(match[4]);
|
|
274
|
+
return at(match, counts(number(match[1]) - failed - skipped, failed, skipped));
|
|
275
|
+
}
|
|
276
|
+
function playwright(text) {
|
|
277
|
+
const passed = last(text, /^\s*(\d+) passed \([\d.]+m?s\)$/m);
|
|
278
|
+
if (!passed)
|
|
279
|
+
return undefined;
|
|
280
|
+
const failed = last(text, /^\s*(\d+) failed$/m);
|
|
281
|
+
const flaky = last(text, /^\s*(\d+) flaky$/m);
|
|
282
|
+
const skipped = last(text, /^\s*(\d+) skipped$/m);
|
|
283
|
+
return at(passed, counts(number(passed[1]) + number(flaky?.[1]), number(failed?.[1]), number(skipped?.[1])));
|
|
284
|
+
}
|
|
285
|
+
function eslint(text) {
|
|
286
|
+
const match = last(text, /✖ \d+ problems? \((\d+) errors?, \d+ warnings?\)/);
|
|
287
|
+
return match ? at(match, counts(0, number(match[1]))) : undefined;
|
|
288
|
+
}
|
|
289
|
+
function errorsFound(text) {
|
|
290
|
+
const match = last(text, /^Found (\d+) errors?\b/m);
|
|
291
|
+
if (match)
|
|
292
|
+
return at(match, counts(0, number(match[1])));
|
|
293
|
+
const lines = all(text, /^\S.*\(\d+,\d+\): error TS\d+:/m);
|
|
294
|
+
return lines.length > 0 ? at(lines.at(-1), counts(0, lines.length)) : undefined;
|
|
295
|
+
}
|