@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.
@@ -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
- async function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
32
- const proc = Bun.spawn(cmd, { cwd: opts.cwd, stdout: "pipe", stderr: "pipe" });
33
- let timedOut = false;
34
- // setTimeout (not Bun.sleep) because the handle must be cancellable the moment
35
- // the process exits — the documented exception in forbidden-patterns.md.
36
- const timer =
37
- opts.timeoutMs && opts.timeoutMs > 0
38
- ? setTimeout(() => {
39
- timedOut = true;
40
- proc.kill();
41
- }, opts.timeoutMs)
42
- : undefined;
43
- try {
44
- const [exitCode, stdout, stderr] = await Promise.all([
45
- proc.exited,
46
- new Response(proc.stdout).text(),
47
- new Response(proc.stderr).text(),
48
- ]);
49
- return timedOut
50
- ? {
51
- exitCode: exitCode === 0 ? 124 : exitCode,
52
- stdout,
53
- stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
54
- timedOut: true,
55
- }
56
- : { exitCode, stdout, stderr };
57
- } finally {
58
- if (timer) clearTimeout(timer);
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 acceptance |
21
- * re-review) → review_*` until the reviewer comes back clean or the fix cap
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
- /** Re-run the acceptance gate, routing on the shared fix-cap rules. */
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).groups ?? [];
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) return { route: "proceed", output: r.output };
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
- const { url, channel } = await postEscalation(i.workdir, i.branch, comment, {
294
- preferTelegram: i.escalateTelegram,
295
- });
296
- await writeResult(i.workdir, { feature: i.feature, status: "escalated", url, escalationReason: reason });
297
- return { route: "done", url, channel, escalationReason: reason };
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
- { from: "fix_acceptance", to: "acceptance" },
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: "acceptance" },
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: "review_quality" },
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: "quality_gates" },
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<{ passed: boolean; ran: number; output: string }> {
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
- if (!g.exists) continue;
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(`[${g.packageDir || "root"}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
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
- if (remoteUrl.includes("github.com")) return "github";
19
- if (remoteUrl.includes("gitlab.com")) return "gitlab";
20
- throw new FinishError(`Unable to determine forge from remote URL "${remoteUrl}"`, "FINISH_UNKNOWN_FORGE", {
21
- stage,
22
- remoteUrl,
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. Without this step those edits
5
- * stay local and uncommitted: `gh pr create --head <branch>` then opens a PR
6
- * from the *remote* branch, which is missing every fix the flow just made (and
7
- * an escalation comment would describe state nobody else can see). Both
8
- * terminal nodes call `commitAndPush` before touching the forge.
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 a previous partial
40
- * finish), and the PR must reflect HEAD.
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
- let committed = false;
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
- const file = Bun.file(path);
17
- return (await file.exists()) ? await file.text() : null;
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