@gethmy/harness 1.0.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/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
3
|
+
import type { VerificationConfig } from "./exec-types.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
import { spawnRunArgs } from "./pm.js";
|
|
6
|
+
import {
|
|
7
|
+
buildCommand,
|
|
8
|
+
formatFixCommand,
|
|
9
|
+
lintCommand,
|
|
10
|
+
supportsDevServer,
|
|
11
|
+
testCommand,
|
|
12
|
+
} from "./project-type.js";
|
|
13
|
+
import { findDeletedTestFiles } from "./revert-guard.js";
|
|
14
|
+
|
|
15
|
+
const TAG = "verification";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Output cap for the verification steps' `execFileSync` calls. Node defaults to
|
|
19
|
+
* 1 MB, and on overflow `execFileSync` kills the child and throws regardless of
|
|
20
|
+
* exit code (`ENOBUFS`, `status: null`) — so a *passing* suite that merely
|
|
21
|
+
* printed a lot landed in the failure path and its ordinary green lines were
|
|
22
|
+
* parsed as assertion failures (#701). A verbose reporter (`jest --verbose`, a
|
|
23
|
+
* console.log-heavy suite, a CI reporter) clears 1 MB easily.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_OUTPUT_BUFFER = 64 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
export interface VerificationResult {
|
|
28
|
+
passed: boolean;
|
|
29
|
+
buildErrors: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Failures from the repo's own test suite (#688). Non-empty blocks the branch
|
|
32
|
+
* before Review — a run that breaks tests is a failed attempt. Empty when the
|
|
33
|
+
* repo has no resolvable test command or the step is disabled.
|
|
34
|
+
*/
|
|
35
|
+
testFailures: string[];
|
|
36
|
+
lintWarnings: string[];
|
|
37
|
+
reviewFindings: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Branch reverts already-merged work — currently: test/spec files it deletes
|
|
40
|
+
* relative to current main (#408). Non-empty blocks the branch before Review.
|
|
41
|
+
*/
|
|
42
|
+
revertWarnings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============ PUBLIC API ============
|
|
46
|
+
|
|
47
|
+
export async function runVerification(
|
|
48
|
+
worktreePath: string,
|
|
49
|
+
config: VerificationConfig,
|
|
50
|
+
workerId: number,
|
|
51
|
+
): Promise<VerificationResult> {
|
|
52
|
+
const result: VerificationResult = {
|
|
53
|
+
passed: true,
|
|
54
|
+
buildErrors: [],
|
|
55
|
+
testFailures: [],
|
|
56
|
+
lintWarnings: [],
|
|
57
|
+
reviewFindings: [],
|
|
58
|
+
revertWarnings: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (config.verification.revertGuard) {
|
|
62
|
+
log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
|
|
63
|
+
const deletedTests = findDeletedTestFiles(
|
|
64
|
+
worktreePath,
|
|
65
|
+
config.worktree.baseBranch,
|
|
66
|
+
);
|
|
67
|
+
if (deletedTests.length > 0) {
|
|
68
|
+
result.revertWarnings = deletedTests.map(
|
|
69
|
+
(f) =>
|
|
70
|
+
`Branch deletes test file '${f}' relative to current ${config.worktree.baseBranch} — ` +
|
|
71
|
+
"likely an accidental revert of already-merged work. Restore the test or rebase on current main.",
|
|
72
|
+
);
|
|
73
|
+
log.warn(
|
|
74
|
+
TAG,
|
|
75
|
+
`[worker:${workerId}] Revert guard tripped: ${deletedTests.length} deleted test file(s)`,
|
|
76
|
+
);
|
|
77
|
+
// Deleting a bound regression test removes the safety net — block before
|
|
78
|
+
// Review regardless of build/lint outcome (#408).
|
|
79
|
+
result.passed = false;
|
|
80
|
+
} else {
|
|
81
|
+
log.info(TAG, `[worker:${workerId}] Revert guard passed`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (config.verification.build) {
|
|
86
|
+
log.info(TAG, `[worker:${workerId}] Running build...`);
|
|
87
|
+
result.buildErrors = runBuild(worktreePath, config.verification.timeout);
|
|
88
|
+
if (result.buildErrors.length > 0) {
|
|
89
|
+
log.warn(
|
|
90
|
+
TAG,
|
|
91
|
+
`[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`,
|
|
92
|
+
);
|
|
93
|
+
result.passed = false;
|
|
94
|
+
} else {
|
|
95
|
+
log.info(TAG, `[worker:${workerId}] Build passed`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Tests run only against a repo that compiles. A broken build fails the suite
|
|
100
|
+
// for reasons the test output can't explain, and the auto-fix loop re-runs
|
|
101
|
+
// verification from the top anyway — so once the build is fixed the suite
|
|
102
|
+
// still gates the branch.
|
|
103
|
+
if (config.verification.test && result.buildErrors.length === 0) {
|
|
104
|
+
log.info(TAG, `[worker:${workerId}] Running tests...`);
|
|
105
|
+
result.testFailures = runTests(
|
|
106
|
+
worktreePath,
|
|
107
|
+
config.verification.testTimeout,
|
|
108
|
+
);
|
|
109
|
+
if (result.testFailures.length > 0) {
|
|
110
|
+
log.warn(
|
|
111
|
+
TAG,
|
|
112
|
+
`[worker:${workerId}] Tests failed with ${result.testFailures.length} failure(s)`,
|
|
113
|
+
);
|
|
114
|
+
// A broken suite is a broken branch — block before Review (#688).
|
|
115
|
+
result.passed = false;
|
|
116
|
+
} else {
|
|
117
|
+
log.info(TAG, `[worker:${workerId}] Tests passed`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (config.verification.lint) {
|
|
122
|
+
log.info(TAG, `[worker:${workerId}] Running lint...`);
|
|
123
|
+
result.lintWarnings = runLint(worktreePath, config.verification.timeout);
|
|
124
|
+
if (result.lintWarnings.length > 0) {
|
|
125
|
+
log.warn(
|
|
126
|
+
TAG,
|
|
127
|
+
`[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`,
|
|
128
|
+
);
|
|
129
|
+
// Lint warnings alone don't block — only build errors block
|
|
130
|
+
} else {
|
|
131
|
+
log.info(TAG, `[worker:${workerId}] Lint passed`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config.verification.deepReview) {
|
|
136
|
+
log.info(TAG, `[worker:${workerId}] Running deep review...`);
|
|
137
|
+
result.reviewFindings = await runDeepReview(worktreePath, config, workerId);
|
|
138
|
+
if (result.reviewFindings.length > 0) {
|
|
139
|
+
log.warn(
|
|
140
|
+
TAG,
|
|
141
|
+
`[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
log.info(TAG, `[worker:${workerId}] Deep review passed`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
152
|
+
const command = buildCommand(worktreePath);
|
|
153
|
+
if (!command) {
|
|
154
|
+
log.warn(
|
|
155
|
+
TAG,
|
|
156
|
+
`No known build toolchain for ${worktreePath} — skipping build`,
|
|
157
|
+
);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
execFileSync(command.cmd, command.args, {
|
|
162
|
+
cwd: worktreePath,
|
|
163
|
+
timeout,
|
|
164
|
+
stdio: "pipe",
|
|
165
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
166
|
+
});
|
|
167
|
+
return [];
|
|
168
|
+
} catch (err: unknown) {
|
|
169
|
+
return parseErrorOutput(err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the repo's own test suite. Returns the failures (empty = passed, or no
|
|
175
|
+
* suite to run). A non-zero exit ALWAYS yields at least one entry — an
|
|
176
|
+
* unparsable failure must never read as a pass (#688).
|
|
177
|
+
*/
|
|
178
|
+
export function runTests(worktreePath: string, timeout: number): string[] {
|
|
179
|
+
const command = testCommand(worktreePath);
|
|
180
|
+
if (!command) {
|
|
181
|
+
log.warn(
|
|
182
|
+
TAG,
|
|
183
|
+
`No test command for detected toolchain in ${worktreePath} — skipping tests`,
|
|
184
|
+
);
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
execFileSync(command.cmd, command.args, {
|
|
189
|
+
cwd: worktreePath,
|
|
190
|
+
timeout,
|
|
191
|
+
stdio: "pipe",
|
|
192
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
193
|
+
});
|
|
194
|
+
return [];
|
|
195
|
+
} catch (err: unknown) {
|
|
196
|
+
// The suite's own output is the diagnostic — put its tail in the run log,
|
|
197
|
+
// not only the parsed lines that become subtasks.
|
|
198
|
+
const output = combineOutput(err);
|
|
199
|
+
log.warn(
|
|
200
|
+
TAG,
|
|
201
|
+
`Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
|
|
202
|
+
);
|
|
203
|
+
return parseTestFailures(err, timeout);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run the repo's write-mode auto-fixer over the worktree so deterministic
|
|
209
|
+
* formatter drift is fixed in place (#691). The completion pipeline calls this
|
|
210
|
+
* *before* `commitUncommittedChanges` + the pre-verify push, NOT from inside
|
|
211
|
+
* `runVerification` — verification runs after that push, so formatting there
|
|
212
|
+
* would never reach origin (and thus never clear CI's blocking lint gate). The
|
|
213
|
+
* fixed files are folded into the commit by `commitUncommittedChanges` and
|
|
214
|
+
* pushed. Best-effort: a repo with no declared fixer is a no-op, and a fixer
|
|
215
|
+
* that exits non-zero (e.g. it wrote what it could but flagged an unfixable
|
|
216
|
+
* rule) is logged, never thrown. This does NOT change the lint-warn-only
|
|
217
|
+
* policy — the later `runLint` still reports whatever the fixer left behind.
|
|
218
|
+
*/
|
|
219
|
+
export function runFormatFix(
|
|
220
|
+
worktreePath: string,
|
|
221
|
+
timeout: number,
|
|
222
|
+
workerId: number,
|
|
223
|
+
): void {
|
|
224
|
+
const command = formatFixCommand(worktreePath);
|
|
225
|
+
if (!command) return;
|
|
226
|
+
try {
|
|
227
|
+
execFileSync(command.cmd, command.args, {
|
|
228
|
+
cwd: worktreePath,
|
|
229
|
+
timeout,
|
|
230
|
+
stdio: "pipe",
|
|
231
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
232
|
+
});
|
|
233
|
+
log.info(
|
|
234
|
+
TAG,
|
|
235
|
+
`[worker:${workerId}] Auto-formatted worktree before commit/push`,
|
|
236
|
+
);
|
|
237
|
+
} catch (err: unknown) {
|
|
238
|
+
log.warn(
|
|
239
|
+
TAG,
|
|
240
|
+
`[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
|
|
241
|
+
err instanceof Error ? err.message : String(err)
|
|
242
|
+
}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function runLint(worktreePath: string, timeout: number): string[] {
|
|
248
|
+
const command = lintCommand(worktreePath);
|
|
249
|
+
if (!command) {
|
|
250
|
+
log.info(
|
|
251
|
+
TAG,
|
|
252
|
+
`No lint step for detected toolchain in ${worktreePath} — skipping lint`,
|
|
253
|
+
);
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
execFileSync(command.cmd, command.args, {
|
|
258
|
+
cwd: worktreePath,
|
|
259
|
+
timeout,
|
|
260
|
+
stdio: "pipe",
|
|
261
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
262
|
+
});
|
|
263
|
+
return [];
|
|
264
|
+
} catch (err: unknown) {
|
|
265
|
+
return parseErrorOutput(err);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function runDeepReview(
|
|
270
|
+
worktreePath: string,
|
|
271
|
+
config: VerificationConfig,
|
|
272
|
+
workerId: number,
|
|
273
|
+
): Promise<string[]> {
|
|
274
|
+
// Deep review boots a `dev` server and probes it over HTTP — only Node
|
|
275
|
+
// repos support that. Skip for Swift/iOS and unknown toolchains.
|
|
276
|
+
if (!supportsDevServer(worktreePath)) {
|
|
277
|
+
log.info(
|
|
278
|
+
TAG,
|
|
279
|
+
`[worker:${workerId}] Detected non-web toolchain — skipping deep review`,
|
|
280
|
+
);
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const port = config.verification.devServerBasePort + workerId;
|
|
285
|
+
let devServer: ChildProcess | null = null;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// Start dev server in background
|
|
289
|
+
const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
|
|
290
|
+
devServer = spawn(cmd, args, {
|
|
291
|
+
cwd: worktreePath,
|
|
292
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Wait for dev server to be ready, then confirm it answers HTTP.
|
|
296
|
+
try {
|
|
297
|
+
await waitForDevServer(devServer, 30_000);
|
|
298
|
+
await probeDevServer(port);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
log.error(
|
|
301
|
+
TAG,
|
|
302
|
+
`Dev server did not become ready: ${err instanceof Error ? err.message : err}`,
|
|
303
|
+
);
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Get diff for review context
|
|
308
|
+
let diff = "";
|
|
309
|
+
try {
|
|
310
|
+
// Without a raised cap a diff over 1 MB throws and silently degrades to
|
|
311
|
+
// "(unable to retrieve diff)" — reviewing the change with no change (#701).
|
|
312
|
+
diff = execFileSync(
|
|
313
|
+
"git",
|
|
314
|
+
["diff", `origin/${config.worktree.baseBranch}..HEAD`],
|
|
315
|
+
{
|
|
316
|
+
cwd: worktreePath,
|
|
317
|
+
encoding: "utf-8",
|
|
318
|
+
timeout: 30_000,
|
|
319
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
} catch {
|
|
323
|
+
diff = "(unable to retrieve diff)";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Spawn Claude for review
|
|
327
|
+
const reviewPrompt = [
|
|
328
|
+
"You are reviewing code changes for quality and correctness.",
|
|
329
|
+
`A dev server is running at http://localhost:${port}.`,
|
|
330
|
+
"Review the following diff and report any issues found.",
|
|
331
|
+
"Output ONLY a numbered list of findings, one per line.",
|
|
332
|
+
"If no issues, output: No issues found.",
|
|
333
|
+
"",
|
|
334
|
+
"```diff",
|
|
335
|
+
diff.slice(0, 50_000),
|
|
336
|
+
"```",
|
|
337
|
+
].join("\n");
|
|
338
|
+
|
|
339
|
+
const leanSources = config.claude.leanSettingSources;
|
|
340
|
+
const output = execFileSync(
|
|
341
|
+
"claude",
|
|
342
|
+
[
|
|
343
|
+
"--print",
|
|
344
|
+
"--model",
|
|
345
|
+
"sonnet",
|
|
346
|
+
"--max-turns",
|
|
347
|
+
"10",
|
|
348
|
+
// Lean spawn — skip project CLAUDE.md/@-imports (#348).
|
|
349
|
+
...(leanSources ? ["--setting-sources", leanSources] : []),
|
|
350
|
+
"--",
|
|
351
|
+
reviewPrompt,
|
|
352
|
+
],
|
|
353
|
+
{
|
|
354
|
+
cwd: worktreePath,
|
|
355
|
+
encoding: "utf-8",
|
|
356
|
+
timeout: config.verification.timeout,
|
|
357
|
+
stdio: "pipe",
|
|
358
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return parseReviewFindings(output);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
log.error(
|
|
365
|
+
TAG,
|
|
366
|
+
`Deep review failed: ${err instanceof Error ? err.message : err}`,
|
|
367
|
+
);
|
|
368
|
+
return [];
|
|
369
|
+
} finally {
|
|
370
|
+
if (devServer && !devServer.killed) {
|
|
371
|
+
devServer.kill("SIGTERM");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function attemptAutoFix(
|
|
377
|
+
worktreePath: string,
|
|
378
|
+
config: VerificationConfig,
|
|
379
|
+
errors: string[],
|
|
380
|
+
): void {
|
|
381
|
+
const errorSummary = errors.slice(0, 20).join("\n");
|
|
382
|
+
const fixPrompt = [
|
|
383
|
+
"The following build, test, and lint failures were found after implementing a feature.",
|
|
384
|
+
"Fix the source files to resolve them.",
|
|
385
|
+
"Do NOT commit build artifacts or modify files in dist/.",
|
|
386
|
+
"Fix source files only.",
|
|
387
|
+
// Without this the cheapest way to make a failing suite pass is to delete
|
|
388
|
+
// or neuter the test — which ships the regression it caught (#688).
|
|
389
|
+
"For a failing test: fix the code under test. Do NOT delete, skip, or weaken",
|
|
390
|
+
"a test to make it pass — unless the test itself is provably wrong, and then",
|
|
391
|
+
"say so explicitly.",
|
|
392
|
+
"",
|
|
393
|
+
"Failures:",
|
|
394
|
+
"```",
|
|
395
|
+
errorSummary,
|
|
396
|
+
"```",
|
|
397
|
+
].join("\n");
|
|
398
|
+
|
|
399
|
+
const leanSources = config.claude.leanSettingSources;
|
|
400
|
+
const args = [
|
|
401
|
+
"--print",
|
|
402
|
+
"--model",
|
|
403
|
+
config.claude.model,
|
|
404
|
+
"--max-turns",
|
|
405
|
+
"50",
|
|
406
|
+
"--allowedTools",
|
|
407
|
+
"Bash,Read,Write,Edit,Glob,Grep",
|
|
408
|
+
// Lean spawn — concrete build/lint errors, no project docs needed (#348).
|
|
409
|
+
...(leanSources ? ["--setting-sources", leanSources] : []),
|
|
410
|
+
"--",
|
|
411
|
+
fixPrompt,
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
log.info(TAG, "Spawning Claude for auto-fix...");
|
|
415
|
+
// A long fix run prints well past 1 MB; without a raised cap the overflow
|
|
416
|
+
// throws and aborts the auto-fix loop even when the fix itself worked (#701).
|
|
417
|
+
execFileSync("claude", args, {
|
|
418
|
+
cwd: worktreePath,
|
|
419
|
+
timeout: config.verification.timeout,
|
|
420
|
+
stdio: "pipe",
|
|
421
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface RecoveryInfo {
|
|
426
|
+
/** Remote ref where the failed attempt was pushed. */
|
|
427
|
+
branchName: string;
|
|
428
|
+
/** Public URL of the branch (GitHub/GitLab/Bitbucket tree view), if known. */
|
|
429
|
+
branchUrl: string | null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function reportFindings(
|
|
433
|
+
client: HarmonyApiClient,
|
|
434
|
+
cardId: string,
|
|
435
|
+
result: VerificationResult,
|
|
436
|
+
recovery?: RecoveryInfo | null,
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
const items: string[] = [];
|
|
439
|
+
|
|
440
|
+
if (recovery) {
|
|
441
|
+
const cmd = `git fetch && git checkout ${recovery.branchName}`;
|
|
442
|
+
const url = recovery.branchUrl ? ` (${recovery.branchUrl})` : "";
|
|
443
|
+
items.push(`Recovery: \`${cmd}\`${url}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Revert warnings first — a deleted regression test is the most severe
|
|
447
|
+
// finding (it can mask a reintroduced bug), so it leads the subtask list.
|
|
448
|
+
for (const warn of result.revertWarnings) {
|
|
449
|
+
items.push(`Revert: ${warn}`);
|
|
450
|
+
}
|
|
451
|
+
for (const err of result.buildErrors) {
|
|
452
|
+
items.push(`Build: ${err}`);
|
|
453
|
+
}
|
|
454
|
+
for (const err of result.testFailures) {
|
|
455
|
+
items.push(`Test: ${err}`);
|
|
456
|
+
}
|
|
457
|
+
for (const err of result.lintWarnings) {
|
|
458
|
+
items.push(`Lint: ${err}`);
|
|
459
|
+
}
|
|
460
|
+
for (const finding of result.reviewFindings) {
|
|
461
|
+
items.push(`Review: ${finding}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const maxSubtasks = 10;
|
|
465
|
+
const overflow = items.length - maxSubtasks;
|
|
466
|
+
const toCreate = items.slice(0, maxSubtasks);
|
|
467
|
+
|
|
468
|
+
await Promise.all(
|
|
469
|
+
toCreate.map(async (item) => {
|
|
470
|
+
const title = item.length > 120 ? `${item.slice(0, 117)}...` : item;
|
|
471
|
+
try {
|
|
472
|
+
await client.createSubtask(cardId, title);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
log.error(
|
|
475
|
+
TAG,
|
|
476
|
+
`Failed to create subtask: ${err instanceof Error ? err.message : err}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
if (overflow > 0) {
|
|
483
|
+
try {
|
|
484
|
+
await client.createSubtask(cardId, `...and ${overflow} more issues`);
|
|
485
|
+
} catch {
|
|
486
|
+
// best-effort
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
log.info(
|
|
491
|
+
TAG,
|
|
492
|
+
`Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ============ HELPERS ============
|
|
497
|
+
|
|
498
|
+
function combineOutput(err: unknown): string {
|
|
499
|
+
const stderr =
|
|
500
|
+
(err as { stderr?: Buffer | string })?.stderr?.toString() ?? "";
|
|
501
|
+
const stdout =
|
|
502
|
+
(err as { stdout?: Buffer | string })?.stdout?.toString() ?? "";
|
|
503
|
+
return `${stderr}\n${stdout}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function parseErrorOutput(err: unknown): string[] {
|
|
507
|
+
const combined = combineOutput(err);
|
|
508
|
+
|
|
509
|
+
const lines = combined
|
|
510
|
+
.split("\n")
|
|
511
|
+
.map((l) => l.trim())
|
|
512
|
+
.filter(
|
|
513
|
+
(l) =>
|
|
514
|
+
l.length > 0 &&
|
|
515
|
+
(l.includes("error") ||
|
|
516
|
+
l.includes("Error") ||
|
|
517
|
+
l.includes("✖") ||
|
|
518
|
+
l.includes("×")),
|
|
519
|
+
)
|
|
520
|
+
.map((l) => (l.length > 200 ? `${l.slice(0, 197)}...` : l));
|
|
521
|
+
|
|
522
|
+
// If we couldn't parse specific error lines, return the whole output truncated
|
|
523
|
+
if (lines.length === 0 && combined.trim().length > 0) {
|
|
524
|
+
return [combined.trim().slice(0, 200)];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return lines;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Lines a test runner uses to mark a failure. Deliberately broader than
|
|
532
|
+
* {@link parseErrorOutput}'s `error`-only filter: vitest/bun/jest report a
|
|
533
|
+
* failing test as `FAIL`, `✗`, `(fail)` or an assertion diff, and none of
|
|
534
|
+
* those contain the word "error".
|
|
535
|
+
*/
|
|
536
|
+
const TEST_FAILURE_LINE =
|
|
537
|
+
/(\bFAIL\b|\(fail\)|✗|✘|×|✖|\bfailed\b|\bfailing\b|AssertionError|\bexpect(ed)?\b|\berror\b)/i;
|
|
538
|
+
|
|
539
|
+
const MAX_TEST_FAILURE_LINES = 20;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Turn a failed test run into reportable failures. Guarantees a non-empty
|
|
543
|
+
* result: the caller only reaches here on a non-zero exit, so returning `[]`
|
|
544
|
+
* would silently promote a broken branch to Review.
|
|
545
|
+
*/
|
|
546
|
+
function parseTestFailures(err: unknown, timeout: number): string[] {
|
|
547
|
+
const e = err as { code?: string | number };
|
|
548
|
+
|
|
549
|
+
// execFileSync surfaces its own timeout as ETIMEDOUT (SIGTERM'd child, no
|
|
550
|
+
// usable output) — report it as itself, not as a phantom assertion failure,
|
|
551
|
+
// so the operator knows to raise the cap.
|
|
552
|
+
if (e?.code === "ETIMEDOUT") {
|
|
553
|
+
return [
|
|
554
|
+
`Test run exceeded the ${timeout}ms limit and was killed — raise agent.verification.testTimeout or narrow the suite`,
|
|
555
|
+
];
|
|
556
|
+
}
|
|
557
|
+
if (e?.code === "ENOENT") {
|
|
558
|
+
return [
|
|
559
|
+
"Test runner not found — could not execute the repo's test command",
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Output blew past maxBuffer: execFileSync killed the child and threw, so the
|
|
564
|
+
// exit code is gone and the captured output is a truncated prefix. Whether the
|
|
565
|
+
// suite actually passed is unknowable here — report the infrastructure limit,
|
|
566
|
+
// never the green lines that prefix happens to contain (#701).
|
|
567
|
+
if (e?.code === "ENOBUFS") {
|
|
568
|
+
return [
|
|
569
|
+
`Test output exceeded the ${MAX_OUTPUT_BUFFER / (1024 * 1024)}MB capture limit and the run was killed — ` +
|
|
570
|
+
"the suite's real result is unknown. Quieten the reporter or raise the limit.",
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const combined = combineOutput(err);
|
|
575
|
+
const lines = combined
|
|
576
|
+
.split("\n")
|
|
577
|
+
.map((l) => l.trim())
|
|
578
|
+
.filter((l) => l.length > 0 && TEST_FAILURE_LINE.test(l))
|
|
579
|
+
.map((l) => (l.length > 200 ? `${l.slice(0, 197)}...` : l));
|
|
580
|
+
|
|
581
|
+
const unique = [...new Set(lines)].slice(0, MAX_TEST_FAILURE_LINES);
|
|
582
|
+
if (unique.length > 0) return unique;
|
|
583
|
+
|
|
584
|
+
// Exited non-zero but nothing matched — fall back to the raw tail rather
|
|
585
|
+
// than reporting a pass.
|
|
586
|
+
const tail = combined.trim().slice(-200);
|
|
587
|
+
return [tail.length > 0 ? tail : "Tests failed (no output captured)"];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parseReviewFindings(output: string): string[] {
|
|
591
|
+
if (output.toLowerCase().includes("no issues found")) {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return output
|
|
596
|
+
.split("\n")
|
|
597
|
+
.map((l) => l.trim())
|
|
598
|
+
.filter((l) => /^\d+[.)]/.test(l))
|
|
599
|
+
.map((l) => l.replace(/^\d+[.)]\s*/, ""))
|
|
600
|
+
.filter((l) => l.length > 0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export class DevServerReadinessError extends Error {
|
|
604
|
+
constructor(message: string) {
|
|
605
|
+
super(message);
|
|
606
|
+
this.name = "DevServerReadinessError";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Wait for a dev server to signal readiness on stdout/stderr.
|
|
612
|
+
*
|
|
613
|
+
* Rejects (does NOT resolve) on timeout or process error — callers that
|
|
614
|
+
* need the server to be live for correctness (e.g. the review worker)
|
|
615
|
+
* must not proceed without a confirmed signal. If the server dies before
|
|
616
|
+
* becoming ready, we reject with the exit details.
|
|
617
|
+
*/
|
|
618
|
+
export function waitForDevServer(
|
|
619
|
+
proc: ChildProcess,
|
|
620
|
+
timeout: number,
|
|
621
|
+
): Promise<void> {
|
|
622
|
+
return new Promise((resolve, reject) => {
|
|
623
|
+
let settled = false;
|
|
624
|
+
const cleanup = () => {
|
|
625
|
+
proc.stdout?.off("data", onData);
|
|
626
|
+
proc.stderr?.off("data", onData);
|
|
627
|
+
proc.off("error", onError);
|
|
628
|
+
proc.off("exit", onExit);
|
|
629
|
+
clearTimeout(timer);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const settleResolve = () => {
|
|
633
|
+
if (settled) return;
|
|
634
|
+
settled = true;
|
|
635
|
+
cleanup();
|
|
636
|
+
resolve();
|
|
637
|
+
};
|
|
638
|
+
const settleReject = (err: Error) => {
|
|
639
|
+
if (settled) return;
|
|
640
|
+
settled = true;
|
|
641
|
+
cleanup();
|
|
642
|
+
reject(err);
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const timer = setTimeout(() => {
|
|
646
|
+
settleReject(
|
|
647
|
+
new DevServerReadinessError(
|
|
648
|
+
`dev server did not signal readiness within ${timeout}ms`,
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
}, timeout);
|
|
652
|
+
|
|
653
|
+
const onData = (data: Buffer) => {
|
|
654
|
+
const text = data.toString();
|
|
655
|
+
if (
|
|
656
|
+
text.includes("ready") ||
|
|
657
|
+
text.includes("localhost") ||
|
|
658
|
+
text.includes("Local:")
|
|
659
|
+
) {
|
|
660
|
+
settleResolve();
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const onError = (err: Error) => {
|
|
665
|
+
settleReject(err);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
669
|
+
settleReject(
|
|
670
|
+
new DevServerReadinessError(
|
|
671
|
+
`dev server exited before becoming ready (code=${code ?? "?"}, signal=${signal ?? "?"})`,
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
proc.stdout?.on("data", onData);
|
|
677
|
+
proc.stderr?.on("data", onData);
|
|
678
|
+
proc.on("error", onError);
|
|
679
|
+
proc.on("exit", onExit);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Verify the dev server actually responds to HTTP GET before treating
|
|
685
|
+
* it as ready. Listening alone isn't enough — a framework can bind but
|
|
686
|
+
* still crash on the first request.
|
|
687
|
+
*/
|
|
688
|
+
export async function probeDevServer(
|
|
689
|
+
port: number,
|
|
690
|
+
timeoutMs = 5000,
|
|
691
|
+
): Promise<void> {
|
|
692
|
+
const controller = new AbortController();
|
|
693
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
694
|
+
try {
|
|
695
|
+
const res = await fetch(`http://localhost:${port}/`, {
|
|
696
|
+
signal: controller.signal,
|
|
697
|
+
});
|
|
698
|
+
if (!res.ok && res.status >= 500) {
|
|
699
|
+
throw new DevServerReadinessError(
|
|
700
|
+
`dev server returned ${res.status} on probe`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
} catch (err) {
|
|
704
|
+
if (err instanceof DevServerReadinessError) throw err;
|
|
705
|
+
throw new DevServerReadinessError(
|
|
706
|
+
`dev server probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
707
|
+
);
|
|
708
|
+
} finally {
|
|
709
|
+
clearTimeout(timer);
|
|
710
|
+
}
|
|
711
|
+
}
|