@nathapp/nax 0.75.2 → 0.75.4
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/dist/nax.js +1861 -556
- package/flows/nax-finish/exec.ts +78 -29
- package/flows/nax-finish/nax-finish.flow.ts +155 -16
- package/flows/nax-finish/steps/acceptance.ts +28 -5
- package/flows/nax-finish/steps/forge.ts +55 -6
- package/flows/nax-finish/steps/git.ts +61 -27
- package/flows/nax-finish/steps/quality.ts +11 -2
- package/flows/nax-finish/steps/result.ts +9 -1
- package/flows/nax-finish/types.ts +14 -0
- package/package.json +3 -2
package/flows/nax-finish/exec.ts
CHANGED
|
@@ -14,7 +14,20 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Both cap wall-clock time: an unbounded gate would hang `acpx flow run`, and
|
|
16
16
|
* the post-run plugin awaits that subprocess.
|
|
17
|
+
*
|
|
18
|
+
* ## Why `node:child_process` and not `Bun.spawn`
|
|
19
|
+
*
|
|
20
|
+
* The rest of nax is Bun-native (see `.claude/rules/project-conventions.md`),
|
|
21
|
+
* but this module is **not** loaded by nax. `acpx flow run` loads it, in acpx's
|
|
22
|
+
* own process, and the published `acpx` binary is a Node program
|
|
23
|
+
* (`#!/usr/bin/env node`). Under Node the `Bun` global does not exist, so
|
|
24
|
+
* `Bun.spawn` threw `ReferenceError: Bun is not defined` on the flow's very
|
|
25
|
+
* first git call — aborting the flow before any node completed and before the
|
|
26
|
+
* result file was written. Everything under `flows/` must therefore stay on
|
|
27
|
+
* Node built-ins; `Bun.*` is banned here and only here, enforced by
|
|
28
|
+
* `scripts/check-flows-no-bun.ts`.
|
|
17
29
|
*/
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
18
31
|
import type { RunResult } from "./types";
|
|
19
32
|
|
|
20
33
|
/** Fallbacks used when the plugin passes no explicit budget in the flow input. */
|
|
@@ -28,35 +41,71 @@ export interface ExecOptions {
|
|
|
28
41
|
timeoutMs?: number;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
44
|
+
/** Exit code reported when the wall-clock cap kills the process, matching `timeout(1)`. */
|
|
45
|
+
const TIMEOUT_EXIT_CODE = 124;
|
|
46
|
+
/** Exit code reported when the binary is missing, matching a shell's "command not found". */
|
|
47
|
+
const NOT_FOUND_EXIT_CODE = 127;
|
|
48
|
+
|
|
49
|
+
function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
|
|
50
|
+
return new Promise<RunResult>((resolve) => {
|
|
51
|
+
const [file, ...args] = cmd;
|
|
52
|
+
const proc = spawn(file as string, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
53
|
+
let stdout = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
let settled = false;
|
|
57
|
+
|
|
58
|
+
proc.stdout.setEncoding("utf8");
|
|
59
|
+
proc.stderr.setEncoding("utf8");
|
|
60
|
+
proc.stdout.on("data", (chunk: string) => {
|
|
61
|
+
stdout += chunk;
|
|
62
|
+
});
|
|
63
|
+
proc.stderr.on("data", (chunk: string) => {
|
|
64
|
+
stderr += chunk;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// setTimeout (not a sleep) because the handle must be cancellable the moment
|
|
68
|
+
// the process exits — the documented exception in forbidden-patterns.md.
|
|
69
|
+
const timer =
|
|
70
|
+
opts.timeoutMs && opts.timeoutMs > 0
|
|
71
|
+
? setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
proc.kill();
|
|
74
|
+
}, opts.timeoutMs)
|
|
75
|
+
: undefined;
|
|
76
|
+
|
|
77
|
+
const settle = (result: RunResult): void => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
if (timer) clearTimeout(timer);
|
|
81
|
+
resolve(result);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// A missing binary (`gh`/`glab` not installed) surfaces as an `error` event
|
|
85
|
+
// under Node, where `Bun.spawn` used to throw. Resolving with 127 instead of
|
|
86
|
+
// rejecting keeps it a readable gate failure the flow can route on, rather
|
|
87
|
+
// than an exception that kills `acpx flow run` with no result file.
|
|
88
|
+
proc.on("error", (err: Error) => {
|
|
89
|
+
settle({ exitCode: NOT_FOUND_EXIT_CODE, stdout, stderr: `${stderr}${err.message}`, timedOut });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// `close` (not `exit`) so both pipes are fully drained before we read them.
|
|
93
|
+
// `code` is null when the process died from a signal — including our own
|
|
94
|
+
// timeout kill — so it maps to a non-zero code rather than a false green.
|
|
95
|
+
proc.on("close", (code: number | null) => {
|
|
96
|
+
const exitCode = code ?? (timedOut ? TIMEOUT_EXIT_CODE : 1);
|
|
97
|
+
settle(
|
|
98
|
+
timedOut
|
|
99
|
+
? {
|
|
100
|
+
exitCode: exitCode === 0 ? TIMEOUT_EXIT_CODE : exitCode,
|
|
101
|
+
stdout,
|
|
102
|
+
stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
|
|
103
|
+
timedOut: true,
|
|
104
|
+
}
|
|
105
|
+
: { exitCode, stdout, stderr },
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
60
109
|
}
|
|
61
110
|
|
|
62
111
|
/** Spawn an argv array directly — no shell. For flow-constructed commands. */
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
* - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
|
|
18
18
|
* resolve` once, and its output feeds both the review prompts (specPath) and
|
|
19
19
|
* the acceptance gate (groups), so nothing resolves the feature twice.
|
|
20
|
-
* - Review fixes loop: `review_* → route_* → fix_* → (re-run
|
|
21
|
-
* re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
-
* trips. A single-shot fix left the fixed diff unverified.
|
|
20
|
+
* - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
|
|
21
|
+
* acceptance | re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
+
* the fix cap trips. A single-shot fix left the fixed diff unverified.
|
|
23
|
+
* - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
|
|
24
|
+
* `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
|
|
25
|
+
* re-review: the loop re-reported findings that were already fixed and always
|
|
26
|
+
* escalated at the cap (issue #1397).
|
|
23
27
|
* - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
|
|
24
28
|
* enforced deterministically rather than trusting the model's own route.
|
|
29
|
+
* - `quality_gates` re-runs the feature's acceptance tests before the repo's
|
|
30
|
+
* own commands. The quality-review and gate fix loops both edit code after
|
|
31
|
+
* the `acceptance` node last passed, and the repo-root `test` command does
|
|
32
|
+
* not cover per-feature acceptance tests — so without this a fix could break
|
|
33
|
+
* the contract the first gate proved and still ship.
|
|
25
34
|
*/
|
|
26
35
|
import { defineFlow, extractJsonObject } from "acpx/flows";
|
|
27
36
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
_contextDeps,
|
|
30
39
|
buildEscalationComment,
|
|
31
40
|
commitAndPush,
|
|
41
|
+
commitFixes,
|
|
32
42
|
detectBaseBranch,
|
|
33
43
|
loadQualityCommands,
|
|
34
44
|
openOrPromotePr,
|
|
@@ -39,7 +49,7 @@ import {
|
|
|
39
49
|
runQualityGates,
|
|
40
50
|
writeResult,
|
|
41
51
|
} from "./steps";
|
|
42
|
-
import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
|
|
52
|
+
import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
|
|
43
53
|
|
|
44
54
|
const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
45
55
|
|
|
@@ -55,6 +65,8 @@ interface LoadCtxOutput {
|
|
|
55
65
|
base?: string;
|
|
56
66
|
specPath?: string;
|
|
57
67
|
groups?: AcceptanceGroup[];
|
|
68
|
+
/** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
|
|
69
|
+
acceptanceStatus?: string;
|
|
58
70
|
route?: string;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -66,16 +78,48 @@ function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
|
|
|
66
78
|
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
83
|
+
*
|
|
84
|
+
* "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
|
|
85
|
+
* unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
|
|
86
|
+
* `no-prd` and `disabled`, and reports `exists: false` for a group whose test
|
|
87
|
+
* was expected at its canonical path but never generated. Treating all of those
|
|
88
|
+
* as green let the flow open a ready PR having verified nothing about the
|
|
89
|
+
* feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
|
|
90
|
+
* — skips cleanly.
|
|
91
|
+
*/
|
|
70
92
|
async function acceptanceGateNode(ctx: {
|
|
71
93
|
input: unknown;
|
|
72
94
|
outputs: unknown;
|
|
73
95
|
state: { steps: { nodeId: string }[] };
|
|
74
96
|
}): Promise<{ route: string; reason?: string; output: string }> {
|
|
75
97
|
const i = inputOf(ctx);
|
|
76
|
-
const groups = loadCtxOf(ctx)
|
|
98
|
+
const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
|
|
99
|
+
if (acceptanceStatus === "disabled") {
|
|
100
|
+
return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
|
|
101
|
+
}
|
|
102
|
+
if (acceptanceStatus === "no-prd") {
|
|
103
|
+
return {
|
|
104
|
+
route: "escalate",
|
|
105
|
+
reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
|
|
106
|
+
output: "[acceptance] no prd.json resolved — acceptance targets unknown",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
78
|
-
if (r.passed)
|
|
111
|
+
if (r.passed) {
|
|
112
|
+
// A real failure below routes to the fix loop, which is more actionable;
|
|
113
|
+
// the coverage hole is only reported once the runnable groups are green.
|
|
114
|
+
if (r.missing.length > 0) {
|
|
115
|
+
return {
|
|
116
|
+
route: "escalate",
|
|
117
|
+
reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
|
|
118
|
+
output: r.output,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { route: "proceed", output: r.output };
|
|
122
|
+
}
|
|
79
123
|
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
80
124
|
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
81
125
|
return {
|
|
@@ -119,6 +163,26 @@ function routeReview(
|
|
|
119
163
|
return { route: "fix", findings };
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
168
|
+
*
|
|
169
|
+
* One node per phase rather than a single shared one because each returns to a
|
|
170
|
+
* different successor, and acpx routes on the node id — a shared node would
|
|
171
|
+
* need a switch reconstructing which fix ran from the step history.
|
|
172
|
+
*/
|
|
173
|
+
function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
|
|
174
|
+
return {
|
|
175
|
+
nodeType: "action" as const,
|
|
176
|
+
async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
|
|
177
|
+
const i = inputOf(ctx);
|
|
178
|
+
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
179
|
+
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
180
|
+
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
181
|
+
return commitFixes(i.workdir, `fix(${i.feature}): nax-finish ${phase} fixes`, { skipHooks: true });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
123
187
|
function parseVerdict(text: string): ReviewVerdict {
|
|
124
188
|
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
@@ -162,6 +226,7 @@ export default defineFlow({
|
|
|
162
226
|
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
163
227
|
parse: parseVerdict,
|
|
164
228
|
},
|
|
229
|
+
commit_acceptance: commitFixNode("acceptance"),
|
|
165
230
|
review_spec: {
|
|
166
231
|
nodeType: "acp",
|
|
167
232
|
session: { isolated: true },
|
|
@@ -181,6 +246,7 @@ export default defineFlow({
|
|
|
181
246
|
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
182
247
|
parse: parseVerdict,
|
|
183
248
|
},
|
|
249
|
+
commit_spec: commitFixNode("spec"),
|
|
184
250
|
review_quality: {
|
|
185
251
|
nodeType: "acp",
|
|
186
252
|
session: { isolated: true },
|
|
@@ -200,15 +266,57 @@ export default defineFlow({
|
|
|
200
266
|
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
201
267
|
parse: parseVerdict,
|
|
202
268
|
},
|
|
269
|
+
commit_quality: commitFixNode("quality"),
|
|
203
270
|
fix_gate: {
|
|
204
271
|
nodeType: "acp",
|
|
205
272
|
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
206
273
|
parse: parseVerdict,
|
|
207
274
|
},
|
|
275
|
+
commit_gate: commitFixNode("gate"),
|
|
208
276
|
quality_gates: {
|
|
209
277
|
nodeType: "action",
|
|
210
278
|
async run(ctx) {
|
|
211
279
|
const i = inputOf(ctx);
|
|
280
|
+
|
|
281
|
+
// Acceptance is gate zero here, not just at the `acceptance` node.
|
|
282
|
+
// Both fix loops that run after it — quality review and this gate —
|
|
283
|
+
// edit code, and the repo-root `test` command does not cover the
|
|
284
|
+
// feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
|
|
285
|
+
// and usually need their own runner config. Re-running them here is what
|
|
286
|
+
// makes "nothing reaches open_pr without the feature's own contract
|
|
287
|
+
// passing against the tree as it will ship" true on every path (#1398).
|
|
288
|
+
//
|
|
289
|
+
// Unconditional, though the common green path re-runs a gate that
|
|
290
|
+
// already passed: acceptance is the cheapest gate in the pipeline, and a
|
|
291
|
+
// conditional skip derived from step history would be a check that can
|
|
292
|
+
// be *wrong* — a silent false green, the failure mode this exists to
|
|
293
|
+
// prevent.
|
|
294
|
+
//
|
|
295
|
+
// `missing` is deliberately ignored: groups are resolved once at
|
|
296
|
+
// load_ctx, so a coverage hole was already escalated by the acceptance
|
|
297
|
+
// node and cannot appear here.
|
|
298
|
+
const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
|
|
299
|
+
timeoutMs: i.timeouts?.acceptanceMs,
|
|
300
|
+
});
|
|
301
|
+
if (!acc.passed) {
|
|
302
|
+
// Short-circuit: the repo gates are re-run next round anyway, and
|
|
303
|
+
// skipping them keeps this out of the "nothing configured" branch
|
|
304
|
+
// below, which would otherwise misreport configured-but-skipped
|
|
305
|
+
// commands as absent.
|
|
306
|
+
const accAttempts = fixAttemptCount(ctx, "fix_gate");
|
|
307
|
+
const failing = ["acceptance"];
|
|
308
|
+
if (accAttempts >= MAX_FIX_ATTEMPTS) {
|
|
309
|
+
return {
|
|
310
|
+
route: "escalate",
|
|
311
|
+
reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
|
|
312
|
+
ran: [],
|
|
313
|
+
failing,
|
|
314
|
+
output: acc.output,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { route: "fix", ran: [], failing, output: acc.output };
|
|
318
|
+
}
|
|
319
|
+
|
|
212
320
|
const cmds = await loadQualityCommands(i.workdir);
|
|
213
321
|
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
214
322
|
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
@@ -289,12 +397,36 @@ export default defineFlow({
|
|
|
289
397
|
syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
|
|
290
398
|
}
|
|
291
399
|
|
|
400
|
+
// Write the result BEFORE attempting delivery. Delivery touches the
|
|
401
|
+
// network and the forge — a rate limit, an expired token, a locked PR
|
|
402
|
+
// or an unrecognised remote used to throw here, killing the node before
|
|
403
|
+
// any result existed. The plugin then had nothing to report and, on the
|
|
404
|
+
// Telegram channel, nothing to notify from: the one path whose job is
|
|
405
|
+
// to say "a human is needed" was the one path with no fallback (#1399).
|
|
406
|
+
const result: FinishResult = {
|
|
407
|
+
feature: i.feature,
|
|
408
|
+
status: "escalated",
|
|
409
|
+
escalationReason: reason,
|
|
410
|
+
findings: verdict?.findings ?? [],
|
|
411
|
+
};
|
|
412
|
+
await writeResult(i.workdir, result);
|
|
413
|
+
|
|
292
414
|
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
415
|
+
let url: string | undefined;
|
|
416
|
+
let channel: string | undefined;
|
|
417
|
+
let deliveryError: string | undefined;
|
|
418
|
+
try {
|
|
419
|
+
const posted = await postEscalation(i.workdir, i.branch, comment, {
|
|
420
|
+
preferTelegram: i.escalateTelegram,
|
|
421
|
+
});
|
|
422
|
+
url = posted.url;
|
|
423
|
+
channel = posted.channel;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
deliveryError = String(err);
|
|
426
|
+
}
|
|
427
|
+
await writeResult(i.workdir, { ...result, url, deliveryError });
|
|
428
|
+
|
|
429
|
+
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
298
430
|
},
|
|
299
431
|
},
|
|
300
432
|
},
|
|
@@ -304,7 +436,11 @@ export default defineFlow({
|
|
|
304
436
|
from: "acceptance",
|
|
305
437
|
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
306
438
|
},
|
|
307
|
-
|
|
439
|
+
// Each fix commits before anything re-reads the diff: the reviewers see
|
|
440
|
+
// `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
|
|
441
|
+
// verbatim until the cap escalated it (#1397).
|
|
442
|
+
{ from: "fix_acceptance", to: "commit_acceptance" },
|
|
443
|
+
{ from: "commit_acceptance", to: "acceptance" },
|
|
308
444
|
{ from: "review_spec", to: "route_spec" },
|
|
309
445
|
{
|
|
310
446
|
from: "route_spec",
|
|
@@ -312,7 +448,8 @@ export default defineFlow({
|
|
|
312
448
|
},
|
|
313
449
|
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
314
450
|
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
315
|
-
{ from: "fix_spec", to: "
|
|
451
|
+
{ from: "fix_spec", to: "commit_spec" },
|
|
452
|
+
{ from: "commit_spec", to: "acceptance" },
|
|
316
453
|
{ from: "review_quality", to: "route_quality" },
|
|
317
454
|
{
|
|
318
455
|
from: "route_quality",
|
|
@@ -320,11 +457,13 @@ export default defineFlow({
|
|
|
320
457
|
},
|
|
321
458
|
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
322
459
|
// follow catch anything the fix broke mechanically.
|
|
323
|
-
{ from: "fix_quality", to: "
|
|
460
|
+
{ from: "fix_quality", to: "commit_quality" },
|
|
461
|
+
{ from: "commit_quality", to: "review_quality" },
|
|
324
462
|
{
|
|
325
463
|
from: "quality_gates",
|
|
326
464
|
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
327
465
|
},
|
|
328
|
-
{ from: "fix_gate", to: "
|
|
466
|
+
{ from: "fix_gate", to: "commit_gate" },
|
|
467
|
+
{ from: "commit_gate", to: "quality_gates" },
|
|
329
468
|
],
|
|
330
469
|
});
|
|
@@ -21,24 +21,47 @@ export function buildAcceptanceCommand(repoRoot: string, group: AcceptanceGroup)
|
|
|
21
21
|
return template.replace(/\{\{FILE\}\}|\{\{file\}\}|\{\{files\}\}/g, absFile);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface AcceptanceGateOutcome {
|
|
25
|
+
/** Every group that ran exited 0. Says nothing about groups that could not run. */
|
|
26
|
+
passed: boolean;
|
|
27
|
+
ran: number;
|
|
28
|
+
/**
|
|
29
|
+
* Package names whose acceptance test the resolver expected at its canonical
|
|
30
|
+
* path but which is absent on disk — never generated, or generation failed.
|
|
31
|
+
* The caller must treat a non-empty list as a coverage hole rather than a
|
|
32
|
+
* pass: skipping these silently is how a feature reached a "ready" PR with
|
|
33
|
+
* nothing verified.
|
|
34
|
+
*/
|
|
35
|
+
missing: string[];
|
|
36
|
+
output: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export async function runAcceptanceGate(
|
|
25
40
|
repoRoot: string,
|
|
26
41
|
groups: AcceptanceGroup[],
|
|
27
42
|
opts: { timeoutMs?: number } = {},
|
|
28
|
-
): Promise<
|
|
43
|
+
): Promise<AcceptanceGateOutcome> {
|
|
29
44
|
const chunks: string[] = [];
|
|
30
45
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS;
|
|
46
|
+
const missing: string[] = [];
|
|
31
47
|
let ran = 0;
|
|
32
48
|
for (const g of groups) {
|
|
33
|
-
|
|
49
|
+
const name = g.packageDir || "root";
|
|
50
|
+
if (!g.exists) {
|
|
51
|
+
missing.push(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
34
54
|
const cwd = g.packageDir ? `${repoRoot}/${g.packageDir}` : repoRoot;
|
|
35
55
|
ran += 1;
|
|
36
56
|
const res = await _acceptanceDeps.runShell(buildAcceptanceCommand(repoRoot, g), { cwd, timeoutMs });
|
|
37
|
-
chunks.push(`[${
|
|
38
|
-
if (res.exitCode !== 0) return { passed: false, ran, output: chunks.join("\n\n") };
|
|
57
|
+
chunks.push(`[${name}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
|
|
58
|
+
if (res.exitCode !== 0) return { passed: false, ran, missing, output: chunks.join("\n\n") };
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
chunks.push(`[acceptance] no acceptance test file on disk for: ${missing.join(", ")}`);
|
|
39
62
|
}
|
|
40
63
|
if (ran === 0) chunks.push("[acceptance] no acceptance test files present — nothing to run");
|
|
41
|
-
return { passed: true, ran, output: chunks.join("\n\n") };
|
|
64
|
+
return { passed: true, ran, missing, output: chunks.join("\n\n") };
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function languageRunner(language: string): string {
|
|
@@ -12,15 +12,64 @@ const URL_REGEX = /https?:\/\/\S+/;
|
|
|
12
12
|
|
|
13
13
|
export type Forge = "github" | "gitlab";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Host of a git remote, for both URL forms git accepts:
|
|
17
|
+
* `git@host:path` (scp-like) and `scheme://[user@]host[:port]/path`.
|
|
18
|
+
*/
|
|
19
|
+
export function remoteHost(remoteUrl: string): string {
|
|
20
|
+
const scp = remoteUrl.match(/^[^/]*@([^:/]+):/);
|
|
21
|
+
if (scp?.[1]) return scp[1].toLowerCase();
|
|
22
|
+
const url = remoteUrl.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/]+)/i);
|
|
23
|
+
return url?.[1]?.toLowerCase() ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify by host name.
|
|
28
|
+
*
|
|
29
|
+
* Matching the host (not a substring of the whole URL) is what makes
|
|
30
|
+
* self-hosted instances work: `"gitlab.mycorp.com".includes("gitlab.com")` is
|
|
31
|
+
* false, so the previous check rejected every self-hosted forge. GitHub is
|
|
32
|
+
* tested first purely for determinism on an absurd host naming both.
|
|
33
|
+
*/
|
|
34
|
+
function forgeFromHost(host: string): Forge | null {
|
|
35
|
+
if (host.includes("github")) return "github";
|
|
36
|
+
if (host.includes("gitlab")) return "gitlab";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Last resort for an enterprise host that names neither forge (`git.corp.com`):
|
|
42
|
+
* ask which CLI is installed. Only decisive when exactly one is — with both or
|
|
43
|
+
* neither present a guess would send `gh` at a GitLab remote.
|
|
44
|
+
*/
|
|
45
|
+
async function forgeFromCli(run: RunFn, repoRoot: string): Promise<Forge | null> {
|
|
46
|
+
const [gh, glab] = await Promise.all([
|
|
47
|
+
run(["gh", "--version"], { cwd: repoRoot }),
|
|
48
|
+
run(["glab", "--version"], { cwd: repoRoot }),
|
|
49
|
+
]);
|
|
50
|
+
const hasGh = gh.exitCode === 0;
|
|
51
|
+
const hasGlab = glab.exitCode === 0;
|
|
52
|
+
if (hasGh && !hasGlab) return "github";
|
|
53
|
+
if (hasGlab && !hasGh) return "gitlab";
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
|
|
16
58
|
const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
|
|
17
59
|
const remoteUrl = remote.stdout.trim();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
60
|
+
const host = remoteHost(remoteUrl);
|
|
61
|
+
|
|
62
|
+
const byHost = forgeFromHost(host);
|
|
63
|
+
if (byHost) return byHost;
|
|
64
|
+
|
|
65
|
+
const byCli = await forgeFromCli(run, repoRoot);
|
|
66
|
+
if (byCli) return byCli;
|
|
67
|
+
|
|
68
|
+
throw new FinishError(
|
|
69
|
+
`Unable to determine forge for remote host "${host || remoteUrl}" — its name matches neither github nor gitlab, and the gh/glab probe was inconclusive`,
|
|
70
|
+
"FINISH_UNKNOWN_FORGE",
|
|
71
|
+
{ stage, remoteUrl, host },
|
|
72
|
+
);
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
/** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Branch synchronisation for the nax-finish flow.
|
|
3
3
|
*
|
|
4
|
-
* Every fix node edits the working tree in place
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Every fix node edits the working tree in place, which two different consumers
|
|
5
|
+
* would otherwise miss:
|
|
6
|
+
*
|
|
7
|
+
* - The **reviewers** read `git diff <base>...HEAD` — committed history only.
|
|
8
|
+
* With the fixes uncommitted, every re-review re-read the pre-fix code and
|
|
9
|
+
* re-reported findings the fix node had already resolved, so the loop could
|
|
10
|
+
* never converge and always escalated at the fix cap (issue #1397). Each
|
|
11
|
+
* `commit_*` node calls `commitFixes` for this reason.
|
|
12
|
+
* - The **forge**: `gh pr create --head <branch>` opens a PR from the *remote*
|
|
13
|
+
* branch, and an escalation comment would describe state nobody else can see.
|
|
14
|
+
* Both terminal nodes call `commitAndPush` before touching the forge.
|
|
9
15
|
*/
|
|
10
16
|
import { FinishError } from "../errors";
|
|
11
17
|
import { runArgv } from "../exec";
|
|
@@ -32,34 +38,62 @@ async function isDirty(repoRoot: string): Promise<boolean> {
|
|
|
32
38
|
return status.stdout.trim().length > 0;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Commit the working tree, if it has anything in it, without pushing.
|
|
43
|
+
*
|
|
44
|
+
* Called by the `commit_*` nodes after every fix node so the next reviewer's
|
|
45
|
+
* `git diff <base>...HEAD` contains the fix. `git add -A` (not `-u`) because a
|
|
46
|
+
* fix routinely adds a *new* test file, and an untracked file is invisible to
|
|
47
|
+
* that diff — which is also why committing beats widening the reviewer's diff
|
|
48
|
+
* to include the working tree.
|
|
49
|
+
*
|
|
50
|
+
* `skipHooks` (used by every mid-loop `commit_*` node) adds `--no-verify`. Those
|
|
51
|
+
* commits are internal checkpoints, not shipped history: a repo whose
|
|
52
|
+
* pre-commit hook runs lint or typecheck would otherwise reject an intermediate
|
|
53
|
+
* state — a lint error the gate loop was about to fix — and take the whole flow
|
|
54
|
+
* down with it, with no result file. Nothing is lost by skipping them, because
|
|
55
|
+
* `quality_gates` runs the repo's own build/typecheck/lint/test and no PR opens
|
|
56
|
+
* unless they are green. The terminal `commitAndPush` leaves hooks enabled.
|
|
57
|
+
*
|
|
58
|
+
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
|
+
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
|
+
*/
|
|
61
|
+
export async function commitFixes(
|
|
62
|
+
repoRoot: string,
|
|
63
|
+
message: string,
|
|
64
|
+
opts: { skipHooks?: boolean } = {},
|
|
65
|
+
): Promise<{ committed: boolean }> {
|
|
66
|
+
if (!(await isDirty(repoRoot))) return { committed: false };
|
|
67
|
+
|
|
68
|
+
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
69
|
+
if (add.exitCode !== 0) {
|
|
70
|
+
throw new FinishError(
|
|
71
|
+
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
72
|
+
"FINISH_GIT_ADD_FAILED",
|
|
73
|
+
{ stage: "finish-git", repoRoot },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const commitArgv = ["git", "commit", "-m", message, ...(opts.skipHooks ? ["--no-verify"] : [])];
|
|
77
|
+
const commit = await _gitDeps.run(commitArgv, { cwd: repoRoot });
|
|
78
|
+
if (commit.exitCode !== 0) {
|
|
79
|
+
throw new FinishError(
|
|
80
|
+
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
81
|
+
"FINISH_GIT_COMMIT_FAILED",
|
|
82
|
+
{ stage: "finish-git", repoRoot },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { committed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
/**
|
|
36
89
|
* Commit any outstanding fixes and push the branch to `origin`.
|
|
37
90
|
*
|
|
38
91
|
* The push is unconditional — even with nothing new to commit the local branch
|
|
39
|
-
* may be ahead of its remote (nax's own run commits, or
|
|
40
|
-
*
|
|
92
|
+
* may be ahead of its remote (nax's own run commits, or the `commit_*` nodes'
|
|
93
|
+
* per-round commits), and the PR must reflect HEAD.
|
|
41
94
|
*/
|
|
42
95
|
export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
|
|
43
|
-
|
|
44
|
-
if (await isDirty(repoRoot)) {
|
|
45
|
-
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
46
|
-
if (add.exitCode !== 0) {
|
|
47
|
-
throw new FinishError(
|
|
48
|
-
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
49
|
-
"FINISH_GIT_ADD_FAILED",
|
|
50
|
-
{ stage: "finish-git", repoRoot },
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const commit = await _gitDeps.run(["git", "commit", "-m", message], { cwd: repoRoot });
|
|
54
|
-
if (commit.exitCode !== 0) {
|
|
55
|
-
throw new FinishError(
|
|
56
|
-
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
57
|
-
"FINISH_GIT_COMMIT_FAILED",
|
|
58
|
-
{ stage: "finish-git", repoRoot, branch },
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
committed = true;
|
|
62
|
-
}
|
|
96
|
+
const { committed } = await commitFixes(repoRoot, message);
|
|
63
97
|
|
|
64
98
|
const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
|
|
65
99
|
if (push.exitCode !== 0) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import { FinishError } from "../errors";
|
|
2
3
|
import { DEFAULT_GATE_TIMEOUT_MS, runShell } from "../exec";
|
|
3
4
|
import type { ShellRunFn } from "../types";
|
|
@@ -12,9 +13,17 @@ export interface QualityCommands {
|
|
|
12
13
|
|
|
13
14
|
export const _qualityDeps: { runShell: ShellRunFn; readText: (path: string) => Promise<string | null> } = {
|
|
14
15
|
runShell,
|
|
16
|
+
// node:fs, not Bun.file — this module runs inside acpx's Node process, where
|
|
17
|
+
// the `Bun` global does not exist (see the header of `../exec.ts`). A single
|
|
18
|
+
// read that treats ENOENT as "absent" also avoids the exists()-then-read race
|
|
19
|
+
// the Bun version had.
|
|
15
20
|
readText: async (path) => {
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(path, "utf8");
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
18
27
|
},
|
|
19
28
|
};
|
|
20
29
|
|