@nathapp/nax 0.77.3 → 0.79.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
  }
@@ -31,11 +31,17 @@
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. The skip
38
- * records `review-skipped`, so it is visible in the audit.
34
+ * - `commit_gate` re-enters `review_quality` whenever its fix committed the
35
+ * gate loop was otherwise the one editing loop whose output faced only
36
+ * mechanical checks, which a fix that degrades the tests it repairs will
37
+ * satisfy. A test-only fix used to skip the re-review as a cost tradeoff;
38
+ * #1510 closed that hole. See `gateCommitRoute`.
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,72 +98,28 @@ 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
  *
152
104
  * - `unchanged` — nothing committed; no new diff, so nothing to review.
153
105
  * - `tests-only` — every touched path matched the repo's test-file patterns.
154
- * Skipped by explicit choice: the re-review is the flow's most expensive node
155
- * and a gate fix is usually a mechanical test repair. **This is a real hole.**
156
- * The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
157
- * test-only — 8 copy-pasted stubs across 3 test files — so this route would
158
- * not have caught it. Widen it here if test-quality regressions start
159
- * shipping — the audit's `review-skipped` rounds are the evidence.
160
106
  * - `changed` — production code was touched, or the paths could not be
161
107
  * classified at all. "Cannot classify" reviews rather than skips.
108
+ *
109
+ * `tests-only` and `changed` both re-enter `review_quality` (#1510). They used
110
+ * to diverge: `tests-only` skipped the re-review as a cost tradeoff, on the
111
+ * reasoning that a gate fix is usually a mechanical test repair. That was a
112
+ * real hole — the defect that motivated the re-entry was itself test-only, 8
113
+ * copy-pasted stubs across 3 test files, so the skip would not have caught the
114
+ * very thing it was built for. A gate fix can turn a red suite green by
115
+ * degrading the tests it repairs, and `quality_gates` is satisfied by exactly
116
+ * that; nothing else read that diff. The audit settled the cost side: across
117
+ * every finish recorded, exactly one `gate` round has ever fired, so the skip
118
+ * was saving a review that almost never runs.
119
+ *
120
+ * The classification is kept even though both routes now review. It is what a
121
+ * cheaper test-quality-scoped reviewer would key off if gate rounds ever become
122
+ * frequent enough for the full re-review to hurt.
162
123
  */
163
124
  async function gateCommitRoute(
164
125
  i: FinishInput,
@@ -260,6 +221,7 @@ export default defineFlow({
260
221
  testFileRegex: resolution.testFileRegex,
261
222
  commitsAhead: pf.commitsAhead,
262
223
  route: pf.route,
224
+ ...(pf.reason ? { reason: pf.reason } : {}),
263
225
  };
264
226
  },
265
227
  },
@@ -333,75 +295,7 @@ export default defineFlow({
333
295
  commit_gate: commitFixNode("gate"),
334
296
  quality_gates: {
335
297
  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
- },
298
+ run: qualityGatesNode,
405
299
  },
406
300
  open_pr: {
407
301
  nodeType: "action",
@@ -414,7 +308,24 @@ export default defineFlow({
414
308
  }
415
309
  // Every fix node edited the working tree; without this the PR would be
416
310
  // 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`);
311
+ //
312
+ // Routed, not thrown. acpx has no error edge, so a throw here kills the
313
+ // flow — and this is the last node on the happy path, reached only once
314
+ // every gate is green and every fix has landed. It died before
315
+ // `writeResult`, so the plugin found no result file and notified
316
+ // nobody: the #1399 failure mode the `escalate` node was hardened
317
+ // against and this one was not. A protected branch, an expired token or
318
+ // a non-fast-forward push is exactly the kind of dead end `escalate`
319
+ // exists to report.
320
+ let sync: { committed: boolean };
321
+ try {
322
+ sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
323
+ } catch (error) {
324
+ return {
325
+ route: "escalate",
326
+ reason: `nax-finish could not push "${i.branch}", so no PR was opened: ${String(error)}`,
327
+ };
328
+ }
418
329
 
419
330
  const fallbackTitle = `nax-finish: ${i.feature}`;
420
331
  const fallbackBody = `Automated finish of \`${i.feature}\`.`;
@@ -441,7 +352,18 @@ export default defineFlow({
441
352
  body = fallbackBody;
442
353
  }
443
354
 
444
- const r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
355
+ // Same reasoning as the push above: a forge that refuses to create or
356
+ // promote (rate limit, revoked token, unrecognised remote) must reach a
357
+ // human through `escalate`, not take the flow down silently.
358
+ let r: { status: "opened" | "promoted" | "already-ready"; url?: string };
359
+ try {
360
+ r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
361
+ } catch (error) {
362
+ return {
363
+ route: "escalate",
364
+ reason: `nax-finish could not open or promote the PR for "${i.branch}": ${String(error)}`,
365
+ };
366
+ }
445
367
  await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
446
368
  // The PR now exists with the mechanical narrative already in place.
447
369
  // Anything the narrative node does from here is an improvement on a
@@ -478,12 +400,14 @@ export default defineFlow({
478
400
  : routed.route_quality?.route === "escalate"
479
401
  ? routed.route_quality
480
402
  : undefined;
481
- const loopExhausted =
482
- outs.acceptance?.route === "escalate"
483
- ? outs.acceptance
484
- : outs.quality_gates?.route === "escalate"
485
- ? outs.quality_gates
486
- : undefined;
403
+ // Ordered by how far down the graph the node sits, so the *last* thing
404
+ // that gave up names the reason. `load_ctx` and `open_pr` are here
405
+ // because both can now route here rather than throw — a base ref that
406
+ // does not resolve, and a push or forge call that failed after every
407
+ // gate was green.
408
+ const loopExhausted = [outs.open_pr, outs.quality_gates, outs.acceptance, outs.load_ctx].find(
409
+ (o) => o?.route === "escalate",
410
+ );
487
411
  const reason =
488
412
  verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
489
413
 
@@ -531,7 +455,15 @@ export default defineFlow({
531
455
  },
532
456
  },
533
457
  edges: [
534
- { from: "load_ctx", switch: { on: "$.route", cases: { proceed: "acceptance", "nothing-to-finish": "open_pr" } } },
458
+ {
459
+ from: "load_ctx",
460
+ switch: {
461
+ on: "$.route",
462
+ // `escalate`: the branch could not be measured against its base at all,
463
+ // so neither "proceed" nor "nothing-to-finish" would be a true claim.
464
+ cases: { proceed: "acceptance", "nothing-to-finish": "open_pr", escalate: "escalate" },
465
+ },
466
+ },
535
467
  {
536
468
  from: "acceptance",
537
469
  switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
@@ -586,12 +518,15 @@ export default defineFlow({
586
518
  from: "commit_gate",
587
519
  switch: {
588
520
  on: "$.route",
589
- cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
521
+ cases: { changed: "review_quality", "tests-only": "review_quality", unchanged: "quality_gates" },
590
522
  },
591
523
  },
592
524
  // The narrative runs only once the PR exists. acpx has no error edge, so an
593
525
  // 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" } } },
526
+ {
527
+ from: "open_pr",
528
+ switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
529
+ },
595
530
  { from: "narrative", to: "amend_body" },
596
531
  ],
597
532
  });
@@ -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",
@@ -16,16 +16,15 @@ const REVIEWED_PHASES: FinishPhase[] = ["spec", "quality"];
16
16
  /**
17
17
  * What produced this round, given the successor the commit routed to.
18
18
  *
19
- * The `route` argument is why this is computed after the commit rather than
20
- * alongside it: `tests-only` is only known once the committed paths have been
21
- * classified, and it is the difference between "no reviewer exists for this
22
- * phase" and "a reviewer exists, was owed a look, and was skipped".
19
+ * `route` no longer changes the answer, and that is the point: since #1510
20
+ * every committed gate fix re-enters `review_quality`, so no route can skip an
21
+ * owed re-review. The parameter stays because the round is still keyed on the
22
+ * successor conceptually, and a future route that *does* bypass a reviewer
23
+ * would need to be reflected here rather than silently inheriting
24
+ * `no-reviewer`.
23
25
  */
24
- export function commitRoundOutcome(phase: FinishPhase, route: string): FinishRoundOutcome {
26
+ export function commitRoundOutcome(phase: FinishPhase, _route: string): FinishRoundOutcome {
25
27
  if (REVIEWED_PHASES.includes(phase)) return "fixed";
26
- // Only `gate` can skip an owed re-review; `acceptance` has no reviewer to
27
- // skip, so its `tests-only`-shaped routes (it has none today) stay honest.
28
- if (phase === "gate" && route === "tests-only") return "review-skipped";
29
28
  return "no-reviewer";
30
29
  }
31
30
 
@@ -58,6 +57,7 @@ export function buildCommitRound(i: CommitRoundInput): FinishRound {
58
57
  committed: i.committed,
59
58
  outcome: commitRoundOutcome(i.phase, i.route),
60
59
  findings: i.findings,
60
+ route: i.route,
61
61
  ...(i.failing ? { failing: i.failing } : {}),
62
62
  ...(i.committed && i.shaAfter ? { sha: i.shaAfter } : {}),
63
63
  };
@@ -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
  }