@nathapp/nax 0.75.6 → 0.76.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Commit messages for the flow's `commit_<phase>` checkpoints.
3
+ *
4
+ * These commits are shipped history — they land on the feature branch and a
5
+ * human reviews them in the PR. Every one of them used to read
6
+ * `fix(<feature>): nax-finish <phase> fixes` with an empty body, so a reviewer
7
+ * looking at six such commits could not tell which one re-enabled a disabled
8
+ * market gate and which one renamed a variable. The reviewer already produced
9
+ * exactly the material needed to say so — severity, title, problem, fix — and
10
+ * it was being discarded at the one moment it could have been recorded.
11
+ *
12
+ * Subject lines follow the repo's conventional-commit rule and the 72-column
13
+ * git summary convention; the findings go in the body, one bullet each.
14
+ */
15
+ import type { Finding, FinishPhase } from "./types";
16
+
17
+ /** Git's conventional soft cap for a commit summary line. */
18
+ const MAX_SUBJECT_LEN = 72;
19
+
20
+ /** Worst-first, so the subject of a mixed batch reports the severity that matters. */
21
+ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
22
+
23
+ /** How much gate output to quote in the body before it stops being a commit message. */
24
+ const MAX_GATE_OUTPUT_LINES = 20;
25
+
26
+ interface MessageCtx {
27
+ outputs: Record<string, unknown>;
28
+ }
29
+
30
+ interface PhaseOutputs {
31
+ findings?: Finding[];
32
+ failing?: string[];
33
+ output?: string;
34
+ }
35
+
36
+ function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
37
+ return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
38
+ }
39
+
40
+ function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
41
+ const raw = outputsFor(ctx, `review_${phase}`).findings;
42
+ return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
43
+ }
44
+
45
+ function worstSeverity(findings: Finding[]): string {
46
+ const present = new Set(findings.map((f) => f.severity));
47
+ return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
48
+ }
49
+
50
+ /**
51
+ * Lowercase a finding title's leading word for the subject line.
52
+ *
53
+ * Reviewers write titles as sentences ("Market gate skip branch is
54
+ * unreachable"); conventional-commit subjects read better in lower case. Only
55
+ * the first character is touched — an all-caps leading token is an acronym
56
+ * (`SSRF guard …`) and must survive intact.
57
+ */
58
+ function subjectCase(title: string): string {
59
+ const [first = "", ...rest] = title.split(" ");
60
+ const isAcronym = first.length > 1 && first === first.toUpperCase();
61
+ return isAcronym
62
+ ? title
63
+ : `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
64
+ }
65
+
66
+ function truncate(s: string): string {
67
+ return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
68
+ }
69
+
70
+ /** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
71
+ function humanList(items: string[]): string {
72
+ if (items.length <= 1) return items[0] ?? "";
73
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
74
+ }
75
+
76
+ function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
77
+ if (findings.length === 1) return subjectCase(findings[0].title);
78
+ return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
79
+ }
80
+
81
+ function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
82
+ if (phase === "gate") {
83
+ const failing = outputsFor(ctx, "quality_gates").failing ?? [];
84
+ return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
85
+ }
86
+ if (phase === "acceptance") return "repair failing acceptance tests";
87
+ const findings = findingsFor(ctx, phase);
88
+ return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
89
+ }
90
+
91
+ function bodyFor(phase: FinishPhase, ctx: MessageCtx): string[] {
92
+ if (phase === "gate") {
93
+ const gate = outputsFor(ctx, "quality_gates");
94
+ const failing = gate.failing ?? [];
95
+ const tail = (gate.output ?? "").trim().split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
96
+ return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(tail ? [tail] : [])];
97
+ }
98
+ if (phase === "acceptance") {
99
+ const tail = (outputsFor(ctx, "acceptance").output ?? "")
100
+ .trim()
101
+ .split("\n")
102
+ .slice(-MAX_GATE_OUTPUT_LINES)
103
+ .join("\n");
104
+ return tail ? [tail] : [];
105
+ }
106
+ const findings = findingsFor(ctx, phase);
107
+ if (findings.length === 0) return [];
108
+ return [
109
+ findings
110
+ .map((f) =>
111
+ [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
112
+ .filter(Boolean)
113
+ .join("\n"),
114
+ )
115
+ .join("\n"),
116
+ ];
117
+ }
118
+
119
+ /** Human-readable phase label for the attribution trailer. */
120
+ function phaseLabel(phase: FinishPhase): string {
121
+ return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
122
+ }
123
+
124
+ /**
125
+ * Build the commit message for a `commit_<phase>` checkpoint.
126
+ *
127
+ * Never throws and never returns an empty subject: a missing or malformed
128
+ * reviewer output degrades to the phase label. A commit that cannot be
129
+ * described is still a commit that must happen — failing here would strand the
130
+ * fix uncommitted and reintroduce the stale-diff bug (#1397).
131
+ */
132
+ export function buildFixCommitMessage(phase: FinishPhase, feature: string, ctx: MessageCtx): string {
133
+ const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
134
+ const body = bodyFor(phase, ctx);
135
+ return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
136
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Readers over an acpx `FlowNodeContext` — the flow graph's view of its own
3
+ * state.
4
+ *
5
+ * Split out of `nax-finish.flow.ts` (600-line source cap). These are all pure
6
+ * functions of `ctx.input` / `ctx.outputs` / `ctx.state.steps`; anything that
7
+ * shells out lives under `./steps/`.
8
+ *
9
+ * Two views of the same run, and the difference matters in a graph whose whole
10
+ * shape is loops:
11
+ *
12
+ * - `ctx.outputs` is a map keyed by node id, so it holds only each node's
13
+ * **latest** output. A node re-entering a loop cannot see its own previous
14
+ * round there.
15
+ * - `ctx.state.steps` is the ordered history and carries every step's `output`,
16
+ * so an earlier round IS recoverable from it. A step is appended on its
17
+ * *outcome*, so the currently-executing node is never in this list — which is
18
+ * what lets `incrementalSince` find the *previous* review rather than itself.
19
+ */
20
+ import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
21
+
22
+ /** Minimal shapes so each reader takes only the part of the context it reads. */
23
+ export interface StepsCtx {
24
+ state: { steps: { nodeId: string; output?: unknown }[] };
25
+ }
26
+ export interface OutputsCtx {
27
+ outputs: unknown;
28
+ }
29
+
30
+ export const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
31
+
32
+ /** What `load_ctx` resolves once, for every downstream node to read. */
33
+ export interface LoadCtxOutput {
34
+ base?: string;
35
+ specPath?: string;
36
+ groups?: AcceptanceGroup[];
37
+ /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
38
+ acceptanceStatus?: string;
39
+ /** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
40
+ testFileRegex?: string[];
41
+ route?: string;
42
+ }
43
+
44
+ export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
45
+ return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
46
+ }
47
+
48
+ export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
49
+ return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
50
+ }
51
+
52
+ export function gateOutputs(ctx: OutputsCtx): { failing?: string[] } {
53
+ return ((ctx.outputs as Record<string, { failing?: string[] } | undefined>).quality_gates ?? {}) as {
54
+ failing?: string[];
55
+ };
56
+ }
57
+
58
+ /** The findings the `fix_<phase>` node was asked to resolve; empty for non-review phases. */
59
+ export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
60
+ if (phase !== "spec" && phase !== "quality") return [];
61
+ return (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`]?.findings ?? [];
62
+ }
63
+
64
+ /**
65
+ * The ref a re-review should diff from, or null to review the whole branch.
66
+ *
67
+ * A reviewer node re-reads the spec in full and the entire `git diff
68
+ * base...HEAD` on every round. Reviews were 58% of the wall clock on
69
+ * rs-stock/pipeline-run-outcome (7 calls, 1306s of 2232s), and round 3 re-read
70
+ * everything rounds 1-2 had already cleared.
71
+ *
72
+ * The scoping ref is the `shaBefore` of the **first** `commit_*` step after this
73
+ * phase's last review — that commit's parent is, by construction, the tree the
74
+ * previous verdict passed on, since only `commit_*` nodes commit. Taking the
75
+ * first (not the last) is what makes the window complete when more than one
76
+ * commit landed in it, which happens when the acceptance loop commits between a
77
+ * spec fix and its re-review: `firstCommit.shaBefore..HEAD` spans both.
78
+ *
79
+ * Read from `ctx.state.steps[].output`, not `ctx.outputs` — the latter keeps
80
+ * only each node's newest output, which for two commit steps of the same node id
81
+ * would have discarded the earlier `shaBefore` and silently under-scoped the
82
+ * review.
83
+ *
84
+ * Returns null — a full review — when there is no prior review of this phase
85
+ * (round 1), no commit since it (nothing new to look at), or the commit step
86
+ * recorded no `shaBefore`.
87
+ */
88
+ export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
89
+ const steps = ctx.state.steps ?? [];
90
+ const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
91
+ if (lastReview < 0) return null;
92
+ const firstCommit = steps.slice(lastReview + 1).find((s) => s.nodeId.startsWith("commit_"));
93
+ if (!firstCommit) return null;
94
+ return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
95
+ }
@@ -31,17 +31,31 @@
31
31
  * the `acceptance` node last passed, and the repo-root `test` command does
32
32
  * not cover per-feature acceptance tests — so without this a fix could break
33
33
  * the contract the first gate proved and still ship.
34
+ * - `commit_gate` re-enters `review_quality` when its fix touched non-test code
35
+ * — the gate loop was previously the one editing loop whose output only ever
36
+ * faced mechanical checks. A test-only fix skips the re-review by explicit
37
+ * cost tradeoff; see `gateCommitRoute` for why that is a known hole.
38
+ * - Every `commit_*` node appends its round to the finish-audit trail as it
39
+ * happens, rather than a terminal node reconstructing them from
40
+ * `ctx.state.steps`. Appending live is what makes the trail survive a flow
41
+ * that is killed or times out — no terminal node runs on that path, and a
42
+ * crashed finish is exactly when the record of what it changed matters most.
34
43
  */
35
44
  import { defineFlow, extractJsonObject } from "acpx/flows";
45
+ import { buildFixCommitMessage } from "./commit-message";
46
+ import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
36
47
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
37
48
  import {
38
49
  _contextDeps,
50
+ appendRound,
39
51
  buildEscalationComment,
40
52
  commitAndPush,
41
53
  commitFixes,
42
54
  detectBaseBranch,
55
+ filesInCommit,
43
56
  loadQualityCommands,
44
57
  openOrPromotePr,
58
+ partitionTestFiles,
45
59
  postEscalation,
46
60
  preflight,
47
61
  resolveFeature,
@@ -49,9 +63,7 @@ import {
49
63
  runQualityGates,
50
64
  writeResult,
51
65
  } from "./steps";
52
- import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
53
-
54
- const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
66
+ import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
55
67
 
56
68
  /**
57
69
  * Cap on fix-and-reverify iterations, per phase, before escalating instead of
@@ -61,23 +73,6 @@ const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
61
73
  */
62
74
  const MAX_FIX_ATTEMPTS = 3;
63
75
 
64
- interface LoadCtxOutput {
65
- base?: string;
66
- specPath?: string;
67
- groups?: AcceptanceGroup[];
68
- /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
69
- acceptanceStatus?: string;
70
- route?: string;
71
- }
72
-
73
- function fixAttemptCount(ctx: { state: { steps: { nodeId: string }[] } }, fixNodeId: string): number {
74
- return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
75
- }
76
-
77
- function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
78
- return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
79
- }
80
-
81
76
  /**
82
77
  * Re-run the acceptance gate, routing on the shared fix-cap rules.
83
78
  *
@@ -169,16 +164,91 @@ function routeReview(
169
164
  * One node per phase rather than a single shared one because each returns to a
170
165
  * different successor, and acpx routes on the node id — a shared node would
171
166
  * need a switch reconstructing which fix ran from the step history.
167
+ *
168
+ * Also the audit seam: this is the only point in the graph where a round's
169
+ * findings and its commit are both known. `ctx.outputs` keeps only the latest
170
+ * output per node, so a round not recorded here is a round no terminal node
171
+ * can reconstruct.
172
+ */
173
+ /**
174
+ * Route for `commit_gate`, whose successor depends on what the fix touched.
175
+ *
176
+ * - `unchanged` — nothing committed; no new diff, so nothing to review.
177
+ * - `tests-only` — every touched path matched the repo's test-file patterns.
178
+ * Skipped by explicit choice: the re-review is the flow's most expensive node
179
+ * and a gate fix is usually a mechanical test repair. **This is a real hole.**
180
+ * The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
181
+ * test-only — 8 copy-pasted stubs across 3 test files — so this route would
182
+ * not have caught it. Widen it here if test-quality regressions start
183
+ * shipping.
184
+ * - `changed` — production code was touched, or the paths could not be
185
+ * classified at all. "Cannot classify" reviews rather than skips.
186
+ */
187
+ async function gateCommitRoute(
188
+ i: FinishInput,
189
+ committed: boolean,
190
+ shaAfter: string | null,
191
+ testFileRegex: string[],
192
+ ): Promise<string> {
193
+ if (!committed) return "unchanged";
194
+ // Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
195
+ // it must be reviewed. Folding this into the `!committed` branch would skip
196
+ // the review for a change that actually landed — the one direction this
197
+ // function must never fail in.
198
+ if (!shaAfter) return "changed";
199
+ const files = await filesInCommit(i.workdir, shaAfter);
200
+ if (files.length === 0) return "changed";
201
+ return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
202
+ }
203
+
204
+ /**
205
+ * Build the `commit_<phase>` node that follows `fix_<phase>`.
206
+ *
207
+ * One node per phase rather than a single shared one because each returns to a
208
+ * different successor, and acpx routes on the node id — a shared node would
209
+ * need a switch reconstructing which fix ran from the step history.
210
+ *
211
+ * Also the audit seam: this is the only point in the graph where a round's
212
+ * findings and its commit are both known. `ctx.outputs` keeps only the latest
213
+ * output per node, so a round not recorded here is a round no terminal node
214
+ * can reconstruct. `shaBefore` is recorded for the same reason — it is what the
215
+ * next review of this phase diffs from (see `incrementalSince`).
172
216
  */
173
- function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
217
+ function commitFixNode(phase: FinishPhase) {
174
218
  return {
175
219
  nodeType: "action" as const,
176
- async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
220
+ async run(ctx: {
221
+ input: unknown;
222
+ outputs: unknown;
223
+ state: { steps: { nodeId: string }[] };
224
+ }): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
177
225
  const i = inputOf(ctx);
226
+ const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
178
227
  // skipHooks: an intermediate checkpoint must not be rejected by a repo's
179
228
  // pre-commit hook — quality_gates runs the repo's real gates before any
180
229
  // 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 });
230
+ const { committed, shaBefore, shaAfter } = await commitFixes(
231
+ i.workdir,
232
+ buildFixCommitMessage(phase, i.feature, messageCtx),
233
+ { skipHooks: true },
234
+ );
235
+ await appendRound(i, {
236
+ ts: new Date().toISOString(),
237
+ phase,
238
+ attempt: fixAttemptCount(ctx, `fix_${phase}`),
239
+ committed,
240
+ findings: findingsOf(ctx, phase),
241
+ ...(phase === "gate" ? { failing: gateOutputs(ctx).failing ?? [] } : {}),
242
+ });
243
+ // Only `commit_gate` routes on this; the other phases have unconditional
244
+ // edges and ignore it.
245
+ const route =
246
+ phase === "gate"
247
+ ? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
248
+ : committed
249
+ ? "changed"
250
+ : "unchanged";
251
+ return { committed, route, shaBefore, shaAfter };
182
252
  },
183
253
  };
184
254
  }
@@ -212,6 +282,7 @@ export default defineFlow({
212
282
  specPath: resolution.specPath,
213
283
  acceptanceStatus: resolution.acceptanceStatus,
214
284
  groups: resolution.groups,
285
+ testFileRegex: resolution.testFileRegex,
215
286
  commitsAhead: pf.commitsAhead,
216
287
  route: pf.route,
217
288
  };
@@ -233,7 +304,12 @@ export default defineFlow({
233
304
  profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
234
305
  prompt(ctx) {
235
306
  const outs = loadCtxOf(ctx);
236
- return buildReviewPrompt("spec", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
307
+ return buildReviewPrompt("spec", {
308
+ base: outs.base ?? "origin/main",
309
+ specPath: outs.specPath ?? "",
310
+ since: incrementalSince(ctx, "spec"),
311
+ priorFindings: findingsOf(ctx, "spec"),
312
+ });
237
313
  },
238
314
  parse: parseVerdict,
239
315
  },
@@ -253,7 +329,12 @@ export default defineFlow({
253
329
  profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
254
330
  prompt(ctx) {
255
331
  const outs = loadCtxOf(ctx);
256
- return buildReviewPrompt("quality", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
332
+ return buildReviewPrompt("quality", {
333
+ base: outs.base ?? "origin/main",
334
+ specPath: outs.specPath ?? "",
335
+ since: incrementalSince(ctx, "quality"),
336
+ priorFindings: findingsOf(ctx, "quality"),
337
+ });
257
338
  },
258
339
  parse: parseVerdict,
259
340
  },
@@ -350,7 +431,7 @@ export default defineFlow({
350
431
  async run(ctx) {
351
432
  const i = inputOf(ctx);
352
433
  if (loadCtxOf(ctx).route === "nothing-to-finish") {
353
- await writeResult(i.workdir, { feature: i.feature, status: "nothing-to-finish" });
434
+ await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
354
435
  return { route: "done", status: "nothing-to-finish" };
355
436
  }
356
437
  // Every fix node edited the working tree; without this the PR would be
@@ -362,7 +443,7 @@ export default defineFlow({
362
443
  `nax-finish: ${i.feature}`,
363
444
  `Automated finish of \`${i.feature}\`.`,
364
445
  );
365
- await writeResult(i.workdir, { feature: i.feature, status: r.status, url: r.url });
446
+ await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
366
447
  return { route: "done", committed: sync.committed, ...r };
367
448
  },
368
449
  },
@@ -409,7 +490,7 @@ export default defineFlow({
409
490
  escalationReason: reason,
410
491
  findings: verdict?.findings ?? [],
411
492
  };
412
- await writeResult(i.workdir, result);
493
+ await writeResult(i, result);
413
494
 
414
495
  const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
415
496
  let url: string | undefined;
@@ -424,7 +505,7 @@ export default defineFlow({
424
505
  } catch (err) {
425
506
  deliveryError = String(err);
426
507
  }
427
- await writeResult(i.workdir, { ...result, url, deliveryError });
508
+ await writeResult(i, { ...result, url, deliveryError });
428
509
 
429
510
  return { route: "done", url, channel, deliveryError, escalationReason: reason };
430
511
  },
@@ -464,6 +545,24 @@ export default defineFlow({
464
545
  switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
465
546
  },
466
547
  { from: "fix_gate", to: "commit_gate" },
467
- { from: "commit_gate", to: "quality_gates" },
548
+ // A gate fix that changed code goes back through the quality reviewer, not
549
+ // straight to the gates. The gate loop is the last one to edit the tree and
550
+ // was the only one whose edits nothing reviewed: `quality_gates` proves the
551
+ // repo's commands are green, which a bad fix can satisfy. Observed on
552
+ // rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
553
+ // copy-pasting an identical 4-line stub into each, and it shipped, because
554
+ // no reviewer ran after it. Re-entry costs one review per gate round; both
555
+ // loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
556
+ //
557
+ // `unchanged` skips it: with nothing committed there is no new diff to
558
+ // review, and re-running the reviewer on an identical tree would burn a
559
+ // turn to re-report what route_quality already called clean.
560
+ {
561
+ from: "commit_gate",
562
+ switch: {
563
+ on: "$.route",
564
+ cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
565
+ },
566
+ },
468
567
  ],
469
568
  });
@@ -1,3 +1,5 @@
1
+ import type { Finding } from "./types";
2
+
1
3
  export const SPEC_REVIEW_DIMENSIONS = `# Spec-relative review dimensions
2
4
 
3
5
  Reference for the post-impl-review **spec-relative** pass: Compliance, Drift,
@@ -310,12 +312,48 @@ const JSON_CONTRACT = [
310
312
  "}",
311
313
  ].join("\n");
312
314
 
313
- export function buildReviewPrompt(phase: "spec" | "quality", args: { base: string; specPath: string }): string {
315
+ /**
316
+ * Build the reviewer prompt.
317
+ *
318
+ * With `since` set this is a **re-review**: the same reviewer already read the
319
+ * whole branch and raised `priorFindings`, a fix was applied and committed, and
320
+ * the only new material is `since..HEAD`. Re-reading the full branch diff every
321
+ * round made reviews 58% of the flow's wall clock, most of it re-reading code an
322
+ * earlier round had already cleared. The narrowed round still has the full repo
323
+ * available — it is told to open whatever the fix touches — it just is not asked
324
+ * to re-derive a verdict on unchanged code.
325
+ *
326
+ * `since` is only ever supplied when exactly one commit separates the two
327
+ * reviews (see `incrementalSince`), so `since..HEAD` provably contains every
328
+ * change made since the previous verdict.
329
+ */
330
+ export function buildReviewPrompt(
331
+ phase: "spec" | "quality",
332
+ args: { base: string; specPath: string; since?: string | null; priorFindings?: Finding[] },
333
+ ): string {
314
334
  const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
335
+ if (!args.since) {
336
+ return [
337
+ `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
338
+ `The spec/requirements source is: ${args.specPath}. Read it in full.`,
339
+ `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
340
+ WORKER_PROTOCOL,
341
+ dims,
342
+ CLASSIFIER,
343
+ JSON_CONTRACT,
344
+ ].join("\n\n");
345
+ }
315
346
  return [
316
- `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
317
- `The spec/requirements source is: ${args.specPath}. Read it in full.`,
318
- `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
347
+ `You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
348
+ `On your previous pass over \`git diff ${args.base}...HEAD\` you raised the findings below, and they have since been fixed and committed. Everything else in that diff you already judged acceptable — do not re-derive a verdict on it.`,
349
+ `Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
350
+ `The fix is \`git diff ${args.since}..HEAD\` — this is the only code that has changed since your last verdict. Review it, and only it, for two questions:`,
351
+ [
352
+ "1. **Resolved?** Does the fix actually resolve each finding above? A finding that was papered over (assertion weakened, test deleted, check disabled) is NOT resolved — re-raise it.",
353
+ "2. **Broken?** Did the fix introduce a new problem, in the changed lines or in the unchanged code they now call into?",
354
+ "",
355
+ `Read whatever files you need — the spec is at ${args.specPath} and the whole repo is available. Scope means *what you judge*, not *what you may read*.`,
356
+ ].join("\n"),
319
357
  WORKER_PROTOCOL,
320
358
  dims,
321
359
  CLASSIFIER,
@@ -17,6 +17,13 @@ export interface FeatureResolution {
17
17
  specKind: "markdown" | "prd";
18
18
  acceptanceStatus: string;
19
19
  groups: AcceptanceGroup[];
20
+ /**
21
+ * Test-file classification regexes, as sources, from `nax features resolve`
22
+ * (the ADR-009 SSOT). Empty when the CLI is older than the field or could not
23
+ * resolve them — callers must treat empty as "cannot classify", never as
24
+ * "nothing is a test file".
25
+ */
26
+ testFileRegex: string[];
20
27
  }
21
28
 
22
29
  /**
@@ -30,6 +37,7 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
30
37
  let parsed: {
31
38
  specSource?: { kind: "markdown" | "prd"; path: string };
32
39
  acceptance?: { status?: string; groups?: AcceptanceGroup[] };
40
+ testPatterns?: { regex?: string[] };
33
41
  };
34
42
  try {
35
43
  parsed = JSON.parse(res.stdout);
@@ -51,9 +59,39 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
51
59
  specKind: parsed.specSource.kind,
52
60
  acceptanceStatus: parsed.acceptance?.status ?? "no-prd",
53
61
  groups: parsed.acceptance?.groups ?? [],
62
+ testFileRegex: parsed.testPatterns?.regex ?? [],
54
63
  };
55
64
  }
56
65
 
66
+ /**
67
+ * Split paths into test and non-test, using the regexes `nax features resolve`
68
+ * reported.
69
+ *
70
+ * With no patterns (older nax, or a config the resolver choked on) every path is
71
+ * reported as non-test. That is the safe direction for the one caller: the gate
72
+ * loop skips its re-review only for a test-only change, so "cannot classify"
73
+ * must mean "review it", never "skip it".
74
+ *
75
+ * An unparseable regex source is skipped rather than thrown — a bad pattern in
76
+ * one config entry must not take the flow down mid-loop.
77
+ */
78
+ export function partitionTestFiles(paths: string[], regexSources: string[]): { test: string[]; nonTest: string[] } {
79
+ const matchers: RegExp[] = [];
80
+ for (const src of regexSources) {
81
+ try {
82
+ matchers.push(new RegExp(src));
83
+ } catch {
84
+ // Skip — see the doc comment above.
85
+ }
86
+ }
87
+ const test: string[] = [];
88
+ const nonTest: string[] = [];
89
+ for (const p of paths) {
90
+ (matchers.some((re) => re.test(p)) ? test : nonTest).push(p);
91
+ }
92
+ return { test, nonTest };
93
+ }
94
+
57
95
  export async function preflight(
58
96
  workdir: string,
59
97
  base: string,
@@ -58,12 +58,35 @@ async function isDirty(repoRoot: string): Promise<boolean> {
58
58
  * A failing commit still throws: the fix is then unreviewable, and continuing
59
59
  * would silently reproduce the stale-diff bug this exists to fix.
60
60
  */
61
+ /** Current HEAD sha, or null outside a repo / on an unborn branch. */
62
+ async function headSha(repoRoot: string): Promise<string | null> {
63
+ const res = await _gitDeps.run(["git", "rev-parse", "HEAD"], { cwd: repoRoot });
64
+ return res.exitCode === 0 ? res.stdout.trim() || null : null;
65
+ }
66
+
67
+ /**
68
+ * Repo-root-relative paths touched by a commit.
69
+ *
70
+ * `--format=` suppresses the header so the output is just the file list.
71
+ * Failure yields `[]`, which the gate loop reads as "cannot tell what changed"
72
+ * and therefore reviews — see `partitionTestFiles`.
73
+ */
74
+ export async function filesInCommit(repoRoot: string, sha: string): Promise<string[]> {
75
+ const res = await _gitDeps.run(["git", "show", "--name-only", "--format=", sha], { cwd: repoRoot });
76
+ if (res.exitCode !== 0) return [];
77
+ return res.stdout
78
+ .split("\n")
79
+ .map((l) => l.trim())
80
+ .filter((l) => l.length > 0);
81
+ }
82
+
61
83
  export async function commitFixes(
62
84
  repoRoot: string,
63
85
  message: string,
64
86
  opts: { skipHooks?: boolean } = {},
65
- ): Promise<{ committed: boolean }> {
66
- if (!(await isDirty(repoRoot))) return { committed: false };
87
+ ): Promise<{ committed: boolean; shaBefore: string | null; shaAfter: string | null }> {
88
+ const shaBefore = await headSha(repoRoot);
89
+ if (!(await isDirty(repoRoot))) return { committed: false, shaBefore, shaAfter: shaBefore };
67
90
 
68
91
  const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
69
92
  if (add.exitCode !== 0) {
@@ -82,7 +105,7 @@ export async function commitFixes(
82
105
  { stage: "finish-git", repoRoot },
83
106
  );
84
107
  }
85
- return { committed: true };
108
+ return { committed: true, shaBefore, shaAfter: await headSha(repoRoot) };
86
109
  }
87
110
 
88
111
  /**