@nathapp/nax 0.77.3 → 0.78.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.
@@ -17,6 +17,7 @@
17
17
  * *outcome*, so the currently-executing node is never in this list — which is
18
18
  * what lets `incrementalSince` find the *previous* review rather than itself.
19
19
  */
20
+ import type { AcceptanceStatus } from "./steps/context";
20
21
  import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
21
22
 
22
23
  /** Minimal shapes so each reader takes only the part of the context it reads. */
@@ -34,11 +35,13 @@ export interface LoadCtxOutput {
34
35
  base?: string;
35
36
  specPath?: string;
36
37
  groups?: AcceptanceGroup[];
37
- /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
38
- acceptanceStatus?: string;
38
+ /** `nax features resolve`'s acceptance status, narrowed at `resolveFeature`. */
39
+ acceptanceStatus?: AcceptanceStatus;
39
40
  /** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
40
41
  testFileRegex?: string[];
41
42
  route?: string;
43
+ /** Set only when `route` is `escalate` — see `preflight`. */
44
+ reason?: string;
42
45
  }
43
46
 
44
47
  export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
@@ -111,15 +114,32 @@ export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
111
114
  * would have discarded the earlier `shaBefore` and silently under-scoped the
112
115
  * review.
113
116
  *
117
+ * Only commit steps that actually **committed** anchor the window. A fix node
118
+ * that edited nothing still records a `commit_*` step, and its `shaBefore` is
119
+ * the current HEAD — so scoping to it asks the reviewer for `HEAD..HEAD`, an
120
+ * empty diff, while the prompt tells it the prior findings "have since been
121
+ * fixed and committed". It returns clean, `route_*` sends the flow onward, and
122
+ * the findings ship unfixed. That leaves the loop through the green door, so
123
+ * `MAX_FIX_ATTEMPTS` never catches it. A no-op round therefore either yields
124
+ * the window to a later real commit, or falls back to a full review.
125
+ *
126
+ * Rounds journalled before `committed` existed carry no such field; `!== false`
127
+ * keeps replaying them on the previous behaviour rather than widening every
128
+ * resumed review to the whole branch.
129
+ *
114
130
  * Returns null — a full review — when there is no prior review of this phase
115
- * (round 1), no commit since it (nothing new to look at), or the commit step
116
- * recorded no `shaBefore`.
131
+ * (round 1), no commit landed since it (nothing new to look at), or the commit
132
+ * step recorded no `shaBefore`.
117
133
  */
118
134
  export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
119
135
  const steps = ctx.state.steps ?? [];
120
136
  const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
121
137
  if (lastReview < 0) return null;
122
- const firstCommit = steps.slice(lastReview + 1).find((s) => s.nodeId.startsWith("commit_"));
138
+ const firstCommit = steps
139
+ .slice(lastReview + 1)
140
+ .find(
141
+ (s) => s.nodeId.startsWith("commit_") && (s.output as { committed?: boolean } | undefined)?.committed !== false,
142
+ );
123
143
  if (!firstCommit) return null;
124
144
  return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
125
145
  }
@@ -36,6 +36,12 @@
36
36
  * faced mechanical checks. A test-only fix skips the re-review by explicit
37
37
  * cost tradeoff; see `gateCommitRoute` for why that is a known hole. The skip
38
38
  * records `review-skipped`, so it is visible in the audit.
39
+ * - `load_ctx` and `open_pr` both route to `escalate` rather than throwing on
40
+ * their own failures. acpx has no error edge, so a throw ends the run with no
41
+ * result file for the plugin to read or notify from — the one outcome that
42
+ * must always be reported is "a human is needed" (#1399). `open_pr` matters
43
+ * most: it is reached only after every gate is green, so a failed push or
44
+ * forge call there discards a completed, verified run.
39
45
  * - Every `commit_*` node appends its round to the finish-audit trail as it
40
46
  * happens, rather than a terminal node reconstructing them from
41
47
  * `ctx.state.steps`. Appending live is what makes the trail survive a flow
@@ -49,6 +55,7 @@ import { narrativePrompt, parseNarrativeNode } from "./narrative";
49
55
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
50
56
  import {
51
57
  _contextDeps,
58
+ acceptanceGateNode,
52
59
  amendPrBodyNode,
53
60
  appendRound,
54
61
  buildCommitRound,
@@ -59,21 +66,19 @@ import {
59
66
  detectForge,
60
67
  filesInCommit,
61
68
  loadFinishPrContext,
62
- loadQualityCommands,
63
69
  openOrPromotePr,
64
70
  partitionTestFiles,
65
71
  postEscalation,
66
72
  preflight,
73
+ qualityGatesNode,
67
74
  resolveFeature,
68
75
  routeReviewAndRecord,
69
- runAcceptanceGate,
70
- runQualityGates,
71
76
  writeResult,
72
77
  } from "./steps";
73
78
  import type { Forge } from "./steps/forge";
74
79
  import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
75
80
  import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
76
- import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
81
+ import { parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
77
82
 
78
83
  /**
79
84
  * Disabled only on an explicit "0". An unset variable means enabled, so a flow
@@ -93,59 +98,6 @@ export const _openPrDeps = {
93
98
  buildFinishBody,
94
99
  };
95
100
 
96
- /**
97
- * Re-run the acceptance gate, routing on the shared fix-cap rules.
98
- *
99
- * "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
100
- * unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
101
- * `no-prd` and `disabled`, and reports `exists: false` for a group whose test
102
- * was expected at its canonical path but never generated. Treating all of those
103
- * as green let the flow open a ready PR having verified nothing about the
104
- * feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
105
- * — skips cleanly.
106
- */
107
- async function acceptanceGateNode(ctx: {
108
- input: unknown;
109
- outputs: unknown;
110
- state: { steps: { nodeId: string }[] };
111
- }): Promise<{ route: string; reason?: string; output: string }> {
112
- const i = inputOf(ctx);
113
- const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
114
- if (acceptanceStatus === "disabled") {
115
- return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
116
- }
117
- if (acceptanceStatus === "no-prd") {
118
- return {
119
- route: "escalate",
120
- reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
121
- output: "[acceptance] no prd.json resolved — acceptance targets unknown",
122
- };
123
- }
124
-
125
- const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
126
- if (r.passed) {
127
- // A real failure below routes to the fix loop, which is more actionable;
128
- // the coverage hole is only reported once the runnable groups are green.
129
- if (r.missing.length > 0) {
130
- return {
131
- route: "escalate",
132
- reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
133
- output: r.output,
134
- };
135
- }
136
- return { route: "proceed", output: r.output };
137
- }
138
- const attempts = fixAttemptCount(ctx, "fix_acceptance");
139
- if (attempts >= MAX_FIX_ATTEMPTS) {
140
- return {
141
- route: "escalate",
142
- reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
143
- output: r.output,
144
- };
145
- }
146
- return { route: "fix", output: r.output };
147
- }
148
-
149
101
  /**
150
102
  * Route for `commit_gate`, whose successor depends on what the fix touched.
151
103
  *
@@ -260,6 +212,7 @@ export default defineFlow({
260
212
  testFileRegex: resolution.testFileRegex,
261
213
  commitsAhead: pf.commitsAhead,
262
214
  route: pf.route,
215
+ ...(pf.reason ? { reason: pf.reason } : {}),
263
216
  };
264
217
  },
265
218
  },
@@ -333,75 +286,7 @@ export default defineFlow({
333
286
  commit_gate: commitFixNode("gate"),
334
287
  quality_gates: {
335
288
  nodeType: "action",
336
- async run(ctx) {
337
- const i = inputOf(ctx);
338
-
339
- // Acceptance is gate zero here, not just at the `acceptance` node.
340
- // Both fix loops that run after it — quality review and this gate —
341
- // edit code, and the repo-root `test` command does not cover the
342
- // feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
343
- // and usually need their own runner config. Re-running them here is what
344
- // makes "nothing reaches open_pr without the feature's own contract
345
- // passing against the tree as it will ship" true on every path (#1398).
346
- //
347
- // Unconditional, though the common green path re-runs a gate that
348
- // already passed: acceptance is the cheapest gate in the pipeline, and a
349
- // conditional skip derived from step history would be a check that can
350
- // be *wrong* — a silent false green, the failure mode this exists to
351
- // prevent.
352
- //
353
- // `missing` is deliberately ignored: groups are resolved once at
354
- // load_ctx, so a coverage hole was already escalated by the acceptance
355
- // node and cannot appear here.
356
- const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
357
- timeoutMs: i.timeouts?.acceptanceMs,
358
- });
359
- if (!acc.passed) {
360
- // Short-circuit: the repo gates are re-run next round anyway, and
361
- // skipping them keeps this out of the "nothing configured" branch
362
- // below, which would otherwise misreport configured-but-skipped
363
- // commands as absent.
364
- const accAttempts = fixAttemptCount(ctx, "fix_gate");
365
- const failing = ["acceptance"];
366
- if (accAttempts >= MAX_FIX_ATTEMPTS) {
367
- return {
368
- route: "escalate",
369
- reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
370
- ran: [],
371
- failing,
372
- output: acc.output,
373
- };
374
- }
375
- return { route: "fix", ran: [], failing, output: acc.output };
376
- }
377
-
378
- const cmds = await loadQualityCommands(i.workdir);
379
- const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
380
- if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
381
- // Nothing configured is not a pass — escalate immediately rather than
382
- // open a "ready" PR having verified nothing. An LLM fix node cannot
383
- // invent the repo's build/test commands.
384
- if (r.ran.length === 0) {
385
- return {
386
- route: "escalate",
387
- reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
388
- ran: r.ran,
389
- failing: r.failing,
390
- output: r.output,
391
- };
392
- }
393
- const attempts = fixAttemptCount(ctx, "fix_gate");
394
- if (attempts >= MAX_FIX_ATTEMPTS) {
395
- return {
396
- route: "escalate",
397
- reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
398
- ran: r.ran,
399
- failing: r.failing,
400
- output: r.output,
401
- };
402
- }
403
- return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
404
- },
289
+ run: qualityGatesNode,
405
290
  },
406
291
  open_pr: {
407
292
  nodeType: "action",
@@ -414,7 +299,24 @@ export default defineFlow({
414
299
  }
415
300
  // Every fix node edited the working tree; without this the PR would be
416
301
  // opened from a remote branch missing all of them.
417
- const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
302
+ //
303
+ // Routed, not thrown. acpx has no error edge, so a throw here kills the
304
+ // flow — and this is the last node on the happy path, reached only once
305
+ // every gate is green and every fix has landed. It died before
306
+ // `writeResult`, so the plugin found no result file and notified
307
+ // nobody: the #1399 failure mode the `escalate` node was hardened
308
+ // against and this one was not. A protected branch, an expired token or
309
+ // a non-fast-forward push is exactly the kind of dead end `escalate`
310
+ // exists to report.
311
+ let sync: { committed: boolean };
312
+ try {
313
+ sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
314
+ } catch (error) {
315
+ return {
316
+ route: "escalate",
317
+ reason: `nax-finish could not push "${i.branch}", so no PR was opened: ${String(error)}`,
318
+ };
319
+ }
418
320
 
419
321
  const fallbackTitle = `nax-finish: ${i.feature}`;
420
322
  const fallbackBody = `Automated finish of \`${i.feature}\`.`;
@@ -441,7 +343,18 @@ export default defineFlow({
441
343
  body = fallbackBody;
442
344
  }
443
345
 
444
- const r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
346
+ // Same reasoning as the push above: a forge that refuses to create or
347
+ // promote (rate limit, revoked token, unrecognised remote) must reach a
348
+ // human through `escalate`, not take the flow down silently.
349
+ let r: { status: "opened" | "promoted" | "already-ready"; url?: string };
350
+ try {
351
+ r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
352
+ } catch (error) {
353
+ return {
354
+ route: "escalate",
355
+ reason: `nax-finish could not open or promote the PR for "${i.branch}": ${String(error)}`,
356
+ };
357
+ }
445
358
  await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
446
359
  // The PR now exists with the mechanical narrative already in place.
447
360
  // Anything the narrative node does from here is an improvement on a
@@ -478,12 +391,14 @@ export default defineFlow({
478
391
  : routed.route_quality?.route === "escalate"
479
392
  ? routed.route_quality
480
393
  : undefined;
481
- const loopExhausted =
482
- outs.acceptance?.route === "escalate"
483
- ? outs.acceptance
484
- : outs.quality_gates?.route === "escalate"
485
- ? outs.quality_gates
486
- : undefined;
394
+ // Ordered by how far down the graph the node sits, so the *last* thing
395
+ // that gave up names the reason. `load_ctx` and `open_pr` are here
396
+ // because both can now route here rather than throw — a base ref that
397
+ // does not resolve, and a push or forge call that failed after every
398
+ // gate was green.
399
+ const loopExhausted = [outs.open_pr, outs.quality_gates, outs.acceptance, outs.load_ctx].find(
400
+ (o) => o?.route === "escalate",
401
+ );
487
402
  const reason =
488
403
  verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
489
404
 
@@ -531,7 +446,15 @@ export default defineFlow({
531
446
  },
532
447
  },
533
448
  edges: [
534
- { from: "load_ctx", switch: { on: "$.route", cases: { proceed: "acceptance", "nothing-to-finish": "open_pr" } } },
449
+ {
450
+ from: "load_ctx",
451
+ switch: {
452
+ on: "$.route",
453
+ // `escalate`: the branch could not be measured against its base at all,
454
+ // so neither "proceed" nor "nothing-to-finish" would be a true claim.
455
+ cases: { proceed: "acceptance", "nothing-to-finish": "open_pr", escalate: "escalate" },
456
+ },
457
+ },
535
458
  {
536
459
  from: "acceptance",
537
460
  switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
@@ -591,7 +514,10 @@ export default defineFlow({
591
514
  },
592
515
  // The narrative runs only once the PR exists. acpx has no error edge, so an
593
516
  // acp node before `open_pr` would be able to fail the flow and cost the PR.
594
- { from: "open_pr", switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done" } } },
517
+ {
518
+ from: "open_pr",
519
+ switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
520
+ },
595
521
  { from: "narrative", to: "amend_body" },
596
522
  ],
597
523
  });
@@ -334,9 +334,12 @@ const RETRY_NOTICE = [
334
334
  * available — it is told to open whatever the fix touches — it just is not asked
335
335
  * to re-derive a verdict on unchanged code.
336
336
  *
337
- * `since` is only ever supplied when exactly one commit separates the two
338
- * reviews (see `incrementalSince`), so `since..HEAD` provably contains every
339
- * change made since the previous verdict.
337
+ * `since` is the parent of the *first* commit that landed after the previous
338
+ * verdict, not of the latest one (see `incrementalSince`) the acceptance loop
339
+ * can commit between a spec fix and its re-review, and the window has to span
340
+ * both. So `since..HEAD` provably contains every change made since that
341
+ * verdict, however many commits that took, and it is never supplied at all when
342
+ * no commit landed.
340
343
  */
341
344
  export function buildReviewPrompt(
342
345
  phase: "spec" | "quality",
@@ -12,10 +12,35 @@ export async function detectBaseBranch(workdir: string): Promise<string> {
12
12
  return main.exitCode === 0 ? "origin/main" : "origin/master";
13
13
  }
14
14
 
15
+ /**
16
+ * The acceptance resolutions this flow knows how to route on, as emitted by
17
+ * `resolveFeatureAcceptance` (`src/cli/features-acceptance.ts`).
18
+ *
19
+ * A closed set, not `string`: `steps/gates.ts` branches on all three by literal
20
+ * — `disabled` decides whether the acceptance gate runs at all — and a typo in
21
+ * any of those comparisons against a `string` compiles cleanly and silently
22
+ * stops a gate from firing.
23
+ */
24
+ export type AcceptanceStatus = "ok" | "no-prd" | "disabled";
25
+
26
+ const ACCEPTANCE_STATUSES: readonly AcceptanceStatus[] = ["ok", "no-prd", "disabled"];
27
+
28
+ /**
29
+ * Narrow the resolver's status, degrading anything unrecognised to `no-prd`.
30
+ *
31
+ * A status this flow cannot interpret is not a licence to proceed: it is
32
+ * neither the explicit opt-out nor a resolution to trust. `no-prd` routes to
33
+ * `escalate`, which is the honest answer and matches the existing default for
34
+ * a status that is missing entirely.
35
+ */
36
+ export function toAcceptanceStatus(raw: unknown): AcceptanceStatus {
37
+ return ACCEPTANCE_STATUSES.find((s) => s === raw) ?? "no-prd";
38
+ }
39
+
15
40
  export interface FeatureResolution {
16
41
  specPath: string;
17
42
  specKind: "markdown" | "prd";
18
- acceptanceStatus: string;
43
+ acceptanceStatus: AcceptanceStatus;
19
44
  groups: AcceptanceGroup[];
20
45
  /**
21
46
  * Test-file classification regexes, as sources, from `nax features resolve`
@@ -57,7 +82,7 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
57
82
  return {
58
83
  specPath: parsed.specSource.path,
59
84
  specKind: parsed.specSource.kind,
60
- acceptanceStatus: parsed.acceptance?.status ?? "no-prd",
85
+ acceptanceStatus: toAcceptanceStatus(parsed.acceptance?.status),
61
86
  groups: parsed.acceptance?.groups ?? [],
62
87
  testFileRegex: parsed.testPatterns?.regex ?? [],
63
88
  };
@@ -92,11 +117,42 @@ export function partitionTestFiles(paths: string[], regexSources: string[]): { t
92
117
  return { test, nonTest };
93
118
  }
94
119
 
95
- export async function preflight(
96
- workdir: string,
97
- base: string,
98
- ): Promise<{ commitsAhead: number; route: "proceed" | "nothing-to-finish" }> {
120
+ export interface PreflightOutcome {
121
+ commitsAhead: number;
122
+ route: "proceed" | "nothing-to-finish" | "escalate";
123
+ /** Set only on `escalate` why the count could not be trusted. */
124
+ reason?: string;
125
+ }
126
+
127
+ /**
128
+ * How far ahead of the base branch this branch is.
129
+ *
130
+ * A failed count must never be reported as zero. `base` reaches here from
131
+ * `detectBaseBranch`, whose last-resort `origin/master` is returned without
132
+ * being verified — so a repo whose base ref is not fetched locally makes
133
+ * `rev-list` exit non-zero with empty stdout. `Number.parseInt("") || 0` turned
134
+ * that into `0`, indistinguishable from "this branch has no new commits", and
135
+ * the flow reported `nothing-to-finish` having reviewed, verified and pushed
136
+ * nothing. Both the non-zero exit and unreadable output escalate instead: a
137
+ * human can fetch the base, and no fix node can.
138
+ */
139
+ export async function preflight(workdir: string, base: string): Promise<PreflightOutcome> {
99
140
  const res = await _contextDeps.run(["git", "rev-list", "--count", `${base}..HEAD`], { cwd: workdir });
100
- const commitsAhead = Number.parseInt(res.stdout.trim(), 10) || 0;
141
+ if (res.exitCode !== 0) {
142
+ const detail = res.stderr.trim() || res.stdout.trim() || `exit ${res.exitCode}`;
143
+ return {
144
+ commitsAhead: 0,
145
+ route: "escalate",
146
+ reason: `Could not count commits against "${base}" — git rev-list failed: ${detail}. The base branch may not exist locally; nax-finish will not treat that as "nothing to finish".`,
147
+ };
148
+ }
149
+ const commitsAhead = Number.parseInt(res.stdout.trim(), 10);
150
+ if (!Number.isFinite(commitsAhead)) {
151
+ return {
152
+ commitsAhead: 0,
153
+ route: "escalate",
154
+ reason: `git rev-list --count ${base}..HEAD exited 0 but printed no readable count: "${res.stdout.trim()}".`,
155
+ };
156
+ }
101
157
  return { commitsAhead, route: commitsAhead > 0 ? "proceed" : "nothing-to-finish" };
102
158
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The two gate nodes — `acceptance` and `quality_gates`.
3
+ *
4
+ * Split out of `nax-finish.flow.ts`, which sits against the 600-line source
5
+ * cap. They belong together: both answer the same question ("did anything
6
+ * actually verify this tree?"), both enforce the same rule that **nothing ran
7
+ * is not a pass**, and both route on the shared `MAX_FIX_ATTEMPTS` cap.
8
+ *
9
+ * Keeping them out of the flow file also makes them callable in tests without
10
+ * reaching through `flow.nodes.*`.
11
+ */
12
+ import { fixAttemptCount, inputOf, loadCtxOf } from "../flow-ctx";
13
+ import { MAX_FIX_ATTEMPTS } from "../verdict";
14
+ import { runAcceptanceGate } from "./acceptance";
15
+ import { type QualityCommands, loadQualityCommands, runQualityGates } from "./quality";
16
+
17
+ /** The slice of an acpx `FlowNodeContext` these nodes read. */
18
+ export interface GateNodeCtx {
19
+ input: unknown;
20
+ outputs: unknown;
21
+ state: { steps: { nodeId: string }[] };
22
+ }
23
+
24
+ export interface AcceptanceNodeOutput {
25
+ route: string;
26
+ reason?: string;
27
+ output: string;
28
+ }
29
+
30
+ export interface QualityGatesNodeOutput {
31
+ route: string;
32
+ reason?: string;
33
+ ran: string[];
34
+ failing: string[];
35
+ output: string;
36
+ }
37
+
38
+ /** The repo's explicit opt-out, as reported by `nax features resolve`. */
39
+ function acceptanceDisabled(ctx: GateNodeCtx): boolean {
40
+ return loadCtxOf(ctx).acceptanceStatus === "disabled";
41
+ }
42
+
43
+ /**
44
+ * Re-run the acceptance gate, routing on the shared fix-cap rules.
45
+ *
46
+ * "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
47
+ * unconfigured repo. `nax features resolve` reports `groups: []` for `no-prd`,
48
+ * for `disabled`, **and** for an `ok` resolution whose PRD grouped to no
49
+ * package at all; it reports `exists: false` for a group whose test was
50
+ * expected at its canonical path but never generated. Treating any of those as
51
+ * green let the flow open a ready PR having verified nothing about the
52
+ * feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
53
+ * — skips cleanly.
54
+ */
55
+ export async function acceptanceGateNode(ctx: GateNodeCtx): Promise<AcceptanceNodeOutput> {
56
+ const i = inputOf(ctx);
57
+ const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
58
+ if (acceptanceStatus === "disabled") {
59
+ return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
60
+ }
61
+ if (acceptanceStatus === "no-prd") {
62
+ return {
63
+ route: "escalate",
64
+ reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
65
+ output: "[acceptance] no prd.json resolved — acceptance targets unknown",
66
+ };
67
+ }
68
+
69
+ const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
70
+ if (r.passed) {
71
+ // A real failure below routes to the fix loop, which is more actionable;
72
+ // the coverage hole is only reported once the runnable groups are green.
73
+ if (r.missing.length > 0) {
74
+ return {
75
+ route: "escalate",
76
+ reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
77
+ output: r.output,
78
+ };
79
+ }
80
+ // Passed, nothing missing, and nothing ran: the resolver produced no group
81
+ // to run at all. `status: "ok"` does NOT imply a target exists — it means
82
+ // the PRD loaded — so this is the one remaining way an empty gate reports
83
+ // green. Escalating matches what `runQualityGates` does for a repo with no
84
+ // configured commands: an LLM fix node cannot invent the missing target.
85
+ if (r.ran === 0) {
86
+ return {
87
+ route: "escalate",
88
+ reason: `No acceptance test target resolved for "${i.feature}" (status: ${acceptanceStatus ?? "unknown"}) — nothing verified its contract.`,
89
+ output: r.output,
90
+ };
91
+ }
92
+ return { route: "proceed", output: r.output };
93
+ }
94
+ const attempts = fixAttemptCount(ctx, "fix_acceptance");
95
+ if (attempts >= MAX_FIX_ATTEMPTS) {
96
+ return {
97
+ route: "escalate",
98
+ reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
99
+ output: r.output,
100
+ };
101
+ }
102
+ return { route: "fix", output: r.output };
103
+ }
104
+
105
+ /**
106
+ * Acceptance is gate zero here, not just at the `acceptance` node.
107
+ *
108
+ * Both fix loops that run after it — quality review and this gate — edit code,
109
+ * and the repo-root `test` command does not cover the feature's acceptance
110
+ * tests: they live under `<pkg>/.nax/features/<f>/` and usually need their own
111
+ * runner config. Re-running them here is what makes "nothing reaches open_pr
112
+ * without the feature's own contract passing against the tree as it will ship"
113
+ * true on every path (#1398).
114
+ *
115
+ * Unconditional apart from the repo's own opt-out, though the common green path
116
+ * re-runs a gate that already passed: acceptance is the cheapest gate in the
117
+ * pipeline, and a conditional skip derived from step history would be a check
118
+ * that can be *wrong* — a silent false green, the failure mode this exists to
119
+ * prevent. The `disabled` skip is not such a derivation: it is the same
120
+ * resolver field the `acceptance` node already honours, and the two nodes
121
+ * disagreeing about who owns the opt-out is its own bug.
122
+ *
123
+ * `missing` is deliberately ignored: groups are resolved once at load_ctx, so a
124
+ * coverage hole was already escalated by the acceptance node and cannot appear
125
+ * here.
126
+ */
127
+ async function reverifyAcceptance(ctx: GateNodeCtx): Promise<QualityGatesNodeOutput | null> {
128
+ if (acceptanceDisabled(ctx)) return null;
129
+ const i = inputOf(ctx);
130
+ const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
131
+ timeoutMs: i.timeouts?.acceptanceMs,
132
+ });
133
+ if (acc.passed) return null;
134
+ // Short-circuit: the repo gates are re-run next round anyway, and skipping
135
+ // them keeps this out of the "nothing configured" branch below, which would
136
+ // otherwise misreport configured-but-skipped commands as absent.
137
+ const attempts = fixAttemptCount(ctx, "fix_gate");
138
+ const failing = ["acceptance"];
139
+ if (attempts >= MAX_FIX_ATTEMPTS) {
140
+ return {
141
+ route: "escalate",
142
+ reason: `A later fix broke the feature's own contract: acceptance still failing after ${attempts} fix attempts.`,
143
+ ran: [],
144
+ failing,
145
+ output: acc.output,
146
+ };
147
+ }
148
+ return { route: "fix", ran: [], failing, output: acc.output };
149
+ }
150
+
151
+ export async function qualityGatesNode(ctx: GateNodeCtx): Promise<QualityGatesNodeOutput> {
152
+ const i = inputOf(ctx);
153
+
154
+ const accFailure = await reverifyAcceptance(ctx);
155
+ if (accFailure) return accFailure;
156
+
157
+ const cmds: QualityCommands = await loadQualityCommands(i.workdir);
158
+ const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
159
+ if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
160
+ // Nothing configured is not a pass — escalate immediately rather than open a
161
+ // "ready" PR having verified nothing. An LLM fix node cannot invent the
162
+ // repo's build/test commands.
163
+ if (r.ran.length === 0) {
164
+ return {
165
+ route: "escalate",
166
+ reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
167
+ ran: r.ran,
168
+ failing: r.failing,
169
+ output: r.output,
170
+ };
171
+ }
172
+ const attempts = fixAttemptCount(ctx, "fix_gate");
173
+ if (attempts >= MAX_FIX_ATTEMPTS) {
174
+ return {
175
+ route: "escalate",
176
+ reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
177
+ ran: r.ran,
178
+ failing: r.failing,
179
+ output: r.output,
180
+ };
181
+ }
182
+ return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
183
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./context";
2
2
  export * from "./acceptance";
3
3
  export * from "./quality";
4
+ export * from "./gates";
4
5
  export * from "./escalate";
5
6
  export * from "./forge";
6
7
  export * from "./git";