@nathapp/nax 0.77.2 → 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.
- package/dist/nax.js +1823 -1024
- package/flows/nax-finish/commit-message.ts +90 -11
- package/flows/nax-finish/flow-ctx.ts +25 -5
- package/flows/nax-finish/nax-finish.flow.ts +87 -156
- package/flows/nax-finish/pr-template-merge.ts +253 -0
- package/flows/nax-finish/review-prompts.ts +6 -3
- package/flows/nax-finish/steps/commit-round.ts +64 -0
- package/flows/nax-finish/steps/context.ts +63 -7
- package/flows/nax-finish/steps/gates.ts +183 -0
- package/flows/nax-finish/steps/index.ts +3 -0
- package/flows/nax-finish/steps/pr-body.ts +70 -38
- package/flows/nax-finish/steps/review-round.ts +74 -0
- package/flows/nax-finish/types.ts +58 -0
- package/flows/nax-finish/verdict.ts +20 -3
- package/package.json +9 -5
|
@@ -23,10 +23,74 @@ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
|
|
|
23
23
|
/** How much gate output to quote in the body before it stops being a commit message. */
|
|
24
24
|
const MAX_GATE_OUTPUT_LINES = 20;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Markers a test runner uses to introduce a failing case, worst-supported-first.
|
|
28
|
+
*
|
|
29
|
+
* A heuristic, deliberately: nax orchestrates polyglot repos, so this cannot be
|
|
30
|
+
* one runner's format. Each entry is the literal token that precedes the test's
|
|
31
|
+
* name — bun/jest `(fail)`, go `--- FAIL:`, pytest `FAILED`, and the tick-style
|
|
32
|
+
* reporters. Nothing downstream depends on a match; a miss just falls back to
|
|
33
|
+
* the output tail, which is what shipped before.
|
|
34
|
+
*/
|
|
35
|
+
const FAILURE_MARKERS = ["(fail)", "--- FAIL:", "FAILED ", "FAIL ", "✗ ", "× "];
|
|
36
|
+
|
|
37
|
+
/** How many failing test names to name before the message stops being a commit message. */
|
|
38
|
+
const MAX_NAMED_FAILURES = 10;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strip machine-local filesystem layout out of text bound for shipped history.
|
|
42
|
+
*
|
|
43
|
+
* Two passes, because the two cases differ: a path under the repo is meaningful
|
|
44
|
+
* once made relative, while a path outside it is noise no reader of the commit
|
|
45
|
+
* can act on. The home-directory pattern catches what remains — runner output
|
|
46
|
+
* routinely quotes absolute paths from outside the repo (caches, toolchains).
|
|
47
|
+
*/
|
|
48
|
+
function redactPaths(text: string, workdir?: string): string {
|
|
49
|
+
const withoutRepo = workdir ? text.split(`${workdir}/`).join("") : text;
|
|
50
|
+
return withoutRepo.replace(/(?:\/Users\/|\/home\/)[^/\s)]+\//g, "~/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The names of the tests that actually failed, in output order.
|
|
55
|
+
*
|
|
56
|
+
* This is the whole point of the change: the body used to be the last 20 lines
|
|
57
|
+
* of runner stdout, and a suite whose *passing* tests write to stderr pushes the
|
|
58
|
+
* real failure out of that window — so the commit named a stack trace from a
|
|
59
|
+
* test that passed (#1506).
|
|
60
|
+
*/
|
|
61
|
+
function failingTestNames(output: string): string[] {
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
for (const line of output.split("\n")) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
const marker = FAILURE_MARKERS.find((m) => trimmed.startsWith(m));
|
|
66
|
+
if (!marker) continue;
|
|
67
|
+
// Drop bun's trailing `[0.12ms]` timing — it is noise in a commit message
|
|
68
|
+
// and makes otherwise-identical messages differ between runs.
|
|
69
|
+
const name = trimmed
|
|
70
|
+
.slice(marker.length)
|
|
71
|
+
.replace(/\s*\[[\d.]+m?s\]$/, "")
|
|
72
|
+
.trim();
|
|
73
|
+
if (name) names.push(name);
|
|
74
|
+
}
|
|
75
|
+
// Say so when the list is cut short. A bare list of ten reads as "ten tests
|
|
76
|
+
// failed", and a reader who acts on that count is acting on a truncation.
|
|
77
|
+
if (names.length > MAX_NAMED_FAILURES) {
|
|
78
|
+
const dropped = names.length - MAX_NAMED_FAILURES;
|
|
79
|
+
return [...names.slice(0, MAX_NAMED_FAILURES), `...and ${dropped} more failing test(s)`];
|
|
80
|
+
}
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
|
|
26
84
|
interface MessageCtx {
|
|
27
85
|
outputs: Record<string, unknown>;
|
|
28
86
|
}
|
|
29
87
|
|
|
88
|
+
/** Options carrying what the message builder cannot read off `ctx.outputs`. */
|
|
89
|
+
interface MessageOptions {
|
|
90
|
+
/** Absolute repo root, used to rewrite quoted paths as repo-relative. */
|
|
91
|
+
workdir?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
interface PhaseOutputs {
|
|
31
95
|
findings?: Finding[];
|
|
32
96
|
failing?: string[];
|
|
@@ -88,20 +152,30 @@ function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
|
|
|
88
152
|
return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
|
|
89
153
|
}
|
|
90
154
|
|
|
91
|
-
|
|
155
|
+
/**
|
|
156
|
+
* What to quote from a runner's output: the failing test names if they can be
|
|
157
|
+
* identified, otherwise the tail, as before.
|
|
158
|
+
*
|
|
159
|
+
* Never both. Naming the failures *and* pasting the tail reproduces the noise
|
|
160
|
+
* this replaces, and the tail is the weaker signal whenever the names exist.
|
|
161
|
+
*/
|
|
162
|
+
function runnerEvidence(output: string, opts: MessageOptions): string {
|
|
163
|
+
const clean = redactPaths(output, opts.workdir).trim();
|
|
164
|
+
const names = failingTestNames(clean);
|
|
165
|
+
if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
|
|
166
|
+
return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
|
|
92
170
|
if (phase === "gate") {
|
|
93
171
|
const gate = outputsFor(ctx, "quality_gates");
|
|
94
172
|
const failing = gate.failing ?? [];
|
|
95
|
-
const
|
|
96
|
-
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(
|
|
173
|
+
const evidence = runnerEvidence(gate.output ?? "", opts);
|
|
174
|
+
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
|
|
97
175
|
}
|
|
98
176
|
if (phase === "acceptance") {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
.split("\n")
|
|
102
|
-
.slice(-MAX_GATE_OUTPUT_LINES)
|
|
103
|
-
.join("\n");
|
|
104
|
-
return tail ? [tail] : [];
|
|
177
|
+
const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
|
|
178
|
+
return evidence ? [evidence] : [];
|
|
105
179
|
}
|
|
106
180
|
const findings = findingsFor(ctx, phase);
|
|
107
181
|
if (findings.length === 0) return [];
|
|
@@ -129,8 +203,13 @@ function phaseLabel(phase: FinishPhase): string {
|
|
|
129
203
|
* described is still a commit that must happen — failing here would strand the
|
|
130
204
|
* fix uncommitted and reintroduce the stale-diff bug (#1397).
|
|
131
205
|
*/
|
|
132
|
-
export function buildFixCommitMessage(
|
|
206
|
+
export function buildFixCommitMessage(
|
|
207
|
+
phase: FinishPhase,
|
|
208
|
+
feature: string,
|
|
209
|
+
ctx: MessageCtx,
|
|
210
|
+
opts: MessageOptions = {},
|
|
211
|
+
): string {
|
|
133
212
|
const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
|
|
134
|
-
const body = bodyFor(phase, ctx);
|
|
213
|
+
const body = bodyFor(phase, ctx, opts);
|
|
135
214
|
return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
|
|
136
215
|
}
|
|
@@ -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
|
|
38
|
-
acceptanceStatus?:
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -34,7 +34,14 @@
|
|
|
34
34
|
* - `commit_gate` re-enters `review_quality` when its fix touched non-test code
|
|
35
35
|
* — the gate loop was previously the one editing loop whose output only ever
|
|
36
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.
|
|
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.
|
|
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.
|
|
38
45
|
* - Every `commit_*` node appends its round to the finish-audit trail as it
|
|
39
46
|
* happens, rather than a terminal node reconstructing them from
|
|
40
47
|
* `ctx.state.steps`. Appending live is what makes the trail survive a flow
|
|
@@ -48,8 +55,10 @@ import { narrativePrompt, parseNarrativeNode } from "./narrative";
|
|
|
48
55
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
49
56
|
import {
|
|
50
57
|
_contextDeps,
|
|
58
|
+
acceptanceGateNode,
|
|
51
59
|
amendPrBodyNode,
|
|
52
60
|
appendRound,
|
|
61
|
+
buildCommitRound,
|
|
53
62
|
buildEscalationComment,
|
|
54
63
|
commitAndPush,
|
|
55
64
|
commitFixes,
|
|
@@ -57,20 +66,19 @@ import {
|
|
|
57
66
|
detectForge,
|
|
58
67
|
filesInCommit,
|
|
59
68
|
loadFinishPrContext,
|
|
60
|
-
loadQualityCommands,
|
|
61
69
|
openOrPromotePr,
|
|
62
70
|
partitionTestFiles,
|
|
63
71
|
postEscalation,
|
|
64
72
|
preflight,
|
|
73
|
+
qualityGatesNode,
|
|
65
74
|
resolveFeature,
|
|
66
|
-
|
|
67
|
-
runQualityGates,
|
|
75
|
+
routeReviewAndRecord,
|
|
68
76
|
writeResult,
|
|
69
77
|
} from "./steps";
|
|
70
78
|
import type { Forge } from "./steps/forge";
|
|
71
79
|
import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
|
|
72
80
|
import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
73
|
-
import {
|
|
81
|
+
import { parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
84
|
* Disabled only on an explicit "0". An unset variable means enabled, so a flow
|
|
@@ -90,59 +98,6 @@ export const _openPrDeps = {
|
|
|
90
98
|
buildFinishBody,
|
|
91
99
|
};
|
|
92
100
|
|
|
93
|
-
/**
|
|
94
|
-
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
95
|
-
*
|
|
96
|
-
* "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
|
|
97
|
-
* unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
|
|
98
|
-
* `no-prd` and `disabled`, and reports `exists: false` for a group whose test
|
|
99
|
-
* was expected at its canonical path but never generated. Treating all of those
|
|
100
|
-
* as green let the flow open a ready PR having verified nothing about the
|
|
101
|
-
* feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
|
|
102
|
-
* — skips cleanly.
|
|
103
|
-
*/
|
|
104
|
-
async function acceptanceGateNode(ctx: {
|
|
105
|
-
input: unknown;
|
|
106
|
-
outputs: unknown;
|
|
107
|
-
state: { steps: { nodeId: string }[] };
|
|
108
|
-
}): Promise<{ route: string; reason?: string; output: string }> {
|
|
109
|
-
const i = inputOf(ctx);
|
|
110
|
-
const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
|
|
111
|
-
if (acceptanceStatus === "disabled") {
|
|
112
|
-
return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
|
|
113
|
-
}
|
|
114
|
-
if (acceptanceStatus === "no-prd") {
|
|
115
|
-
return {
|
|
116
|
-
route: "escalate",
|
|
117
|
-
reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
|
|
118
|
-
output: "[acceptance] no prd.json resolved — acceptance targets unknown",
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
123
|
-
if (r.passed) {
|
|
124
|
-
// A real failure below routes to the fix loop, which is more actionable;
|
|
125
|
-
// the coverage hole is only reported once the runnable groups are green.
|
|
126
|
-
if (r.missing.length > 0) {
|
|
127
|
-
return {
|
|
128
|
-
route: "escalate",
|
|
129
|
-
reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
|
|
130
|
-
output: r.output,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return { route: "proceed", output: r.output };
|
|
134
|
-
}
|
|
135
|
-
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
136
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
137
|
-
return {
|
|
138
|
-
route: "escalate",
|
|
139
|
-
reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
|
|
140
|
-
output: r.output,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
return { route: "fix", output: r.output };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
101
|
/**
|
|
147
102
|
* Route for `commit_gate`, whose successor depends on what the fix touched.
|
|
148
103
|
*
|
|
@@ -153,7 +108,7 @@ async function acceptanceGateNode(ctx: {
|
|
|
153
108
|
* The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
|
|
154
109
|
* test-only — 8 copy-pasted stubs across 3 test files — so this route would
|
|
155
110
|
* not have caught it. Widen it here if test-quality regressions start
|
|
156
|
-
* shipping.
|
|
111
|
+
* shipping — the audit's `review-skipped` rounds are the evidence.
|
|
157
112
|
* - `changed` — production code was touched, or the paths could not be
|
|
158
113
|
* classified at all. "Cannot classify" reviews rather than skips.
|
|
159
114
|
*/
|
|
@@ -202,30 +157,32 @@ function commitFixNode(phase: FinishPhase) {
|
|
|
202
157
|
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
203
158
|
const { committed, shaBefore, shaAfter } = await commitFixes(
|
|
204
159
|
i.workdir,
|
|
205
|
-
buildFixCommitMessage(phase, i.feature, messageCtx),
|
|
160
|
+
buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir }),
|
|
206
161
|
{ skipHooks: true },
|
|
207
162
|
);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
committed,
|
|
213
|
-
findings: findingsOf(ctx, phase),
|
|
214
|
-
...(phase === "gate" ? { failing: gateOutputs(ctx).failing ?? [] } : {}),
|
|
215
|
-
// Carry `shaAfter` onto committed rounds only: a no-op round has no
|
|
216
|
-
// commit, so no SHA to record — keeping the field absent (rather than
|
|
217
|
-
// null/undefined) lets the result-file reader distinguish "no commit"
|
|
218
|
-
// from "record lost".
|
|
219
|
-
...(committed && shaAfter ? { sha: shaAfter } : {}),
|
|
220
|
-
});
|
|
221
|
-
// Only `commit_gate` routes on this; the other phases have unconditional
|
|
222
|
-
// edges and ignore it.
|
|
163
|
+
// Routed BEFORE the round is recorded: `buildCommitRound` needs the
|
|
164
|
+
// successor to tell an owed-but-skipped re-review from a phase that never
|
|
165
|
+
// had a reviewer. Only `commit_gate` routes on this; the other phases have
|
|
166
|
+
// unconditional edges and ignore it.
|
|
223
167
|
const route =
|
|
224
168
|
phase === "gate"
|
|
225
169
|
? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
|
|
226
170
|
: committed
|
|
227
171
|
? "changed"
|
|
228
172
|
: "unchanged";
|
|
173
|
+
await appendRound(
|
|
174
|
+
i,
|
|
175
|
+
buildCommitRound({
|
|
176
|
+
phase,
|
|
177
|
+
attempt: fixAttemptCount(ctx, `fix_${phase}`),
|
|
178
|
+
committed,
|
|
179
|
+
route,
|
|
180
|
+
findings: findingsOf(ctx, phase),
|
|
181
|
+
failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
|
|
182
|
+
shaAfter,
|
|
183
|
+
now: new Date().toISOString(),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
229
186
|
return { committed, route, shaBefore, shaAfter };
|
|
230
187
|
},
|
|
231
188
|
};
|
|
@@ -255,6 +212,7 @@ export default defineFlow({
|
|
|
255
212
|
testFileRegex: resolution.testFileRegex,
|
|
256
213
|
commitsAhead: pf.commitsAhead,
|
|
257
214
|
route: pf.route,
|
|
215
|
+
...(pf.reason ? { reason: pf.reason } : {}),
|
|
258
216
|
};
|
|
259
217
|
},
|
|
260
218
|
},
|
|
@@ -286,7 +244,7 @@ export default defineFlow({
|
|
|
286
244
|
},
|
|
287
245
|
route_spec: {
|
|
288
246
|
nodeType: "compute",
|
|
289
|
-
run: (ctx) =>
|
|
247
|
+
run: (ctx) => routeReviewAndRecord(ctx, "spec"),
|
|
290
248
|
},
|
|
291
249
|
fix_spec: {
|
|
292
250
|
nodeType: "acp",
|
|
@@ -312,7 +270,7 @@ export default defineFlow({
|
|
|
312
270
|
},
|
|
313
271
|
route_quality: {
|
|
314
272
|
nodeType: "compute",
|
|
315
|
-
run: (ctx) =>
|
|
273
|
+
run: (ctx) => routeReviewAndRecord(ctx, "quality"),
|
|
316
274
|
},
|
|
317
275
|
fix_quality: {
|
|
318
276
|
nodeType: "acp",
|
|
@@ -328,75 +286,7 @@ export default defineFlow({
|
|
|
328
286
|
commit_gate: commitFixNode("gate"),
|
|
329
287
|
quality_gates: {
|
|
330
288
|
nodeType: "action",
|
|
331
|
-
|
|
332
|
-
const i = inputOf(ctx);
|
|
333
|
-
|
|
334
|
-
// Acceptance is gate zero here, not just at the `acceptance` node.
|
|
335
|
-
// Both fix loops that run after it — quality review and this gate —
|
|
336
|
-
// edit code, and the repo-root `test` command does not cover the
|
|
337
|
-
// feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
|
|
338
|
-
// and usually need their own runner config. Re-running them here is what
|
|
339
|
-
// makes "nothing reaches open_pr without the feature's own contract
|
|
340
|
-
// passing against the tree as it will ship" true on every path (#1398).
|
|
341
|
-
//
|
|
342
|
-
// Unconditional, though the common green path re-runs a gate that
|
|
343
|
-
// already passed: acceptance is the cheapest gate in the pipeline, and a
|
|
344
|
-
// conditional skip derived from step history would be a check that can
|
|
345
|
-
// be *wrong* — a silent false green, the failure mode this exists to
|
|
346
|
-
// prevent.
|
|
347
|
-
//
|
|
348
|
-
// `missing` is deliberately ignored: groups are resolved once at
|
|
349
|
-
// load_ctx, so a coverage hole was already escalated by the acceptance
|
|
350
|
-
// node and cannot appear here.
|
|
351
|
-
const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
|
|
352
|
-
timeoutMs: i.timeouts?.acceptanceMs,
|
|
353
|
-
});
|
|
354
|
-
if (!acc.passed) {
|
|
355
|
-
// Short-circuit: the repo gates are re-run next round anyway, and
|
|
356
|
-
// skipping them keeps this out of the "nothing configured" branch
|
|
357
|
-
// below, which would otherwise misreport configured-but-skipped
|
|
358
|
-
// commands as absent.
|
|
359
|
-
const accAttempts = fixAttemptCount(ctx, "fix_gate");
|
|
360
|
-
const failing = ["acceptance"];
|
|
361
|
-
if (accAttempts >= MAX_FIX_ATTEMPTS) {
|
|
362
|
-
return {
|
|
363
|
-
route: "escalate",
|
|
364
|
-
reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
|
|
365
|
-
ran: [],
|
|
366
|
-
failing,
|
|
367
|
-
output: acc.output,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
return { route: "fix", ran: [], failing, output: acc.output };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const cmds = await loadQualityCommands(i.workdir);
|
|
374
|
-
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
375
|
-
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
376
|
-
// Nothing configured is not a pass — escalate immediately rather than
|
|
377
|
-
// open a "ready" PR having verified nothing. An LLM fix node cannot
|
|
378
|
-
// invent the repo's build/test commands.
|
|
379
|
-
if (r.ran.length === 0) {
|
|
380
|
-
return {
|
|
381
|
-
route: "escalate",
|
|
382
|
-
reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
|
|
383
|
-
ran: r.ran,
|
|
384
|
-
failing: r.failing,
|
|
385
|
-
output: r.output,
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
const attempts = fixAttemptCount(ctx, "fix_gate");
|
|
389
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
390
|
-
return {
|
|
391
|
-
route: "escalate",
|
|
392
|
-
reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
|
|
393
|
-
ran: r.ran,
|
|
394
|
-
failing: r.failing,
|
|
395
|
-
output: r.output,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
|
|
399
|
-
},
|
|
289
|
+
run: qualityGatesNode,
|
|
400
290
|
},
|
|
401
291
|
open_pr: {
|
|
402
292
|
nodeType: "action",
|
|
@@ -409,7 +299,24 @@ export default defineFlow({
|
|
|
409
299
|
}
|
|
410
300
|
// Every fix node edited the working tree; without this the PR would be
|
|
411
301
|
// opened from a remote branch missing all of them.
|
|
412
|
-
|
|
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
|
+
}
|
|
413
320
|
|
|
414
321
|
const fallbackTitle = `nax-finish: ${i.feature}`;
|
|
415
322
|
const fallbackBody = `Automated finish of \`${i.feature}\`.`;
|
|
@@ -436,7 +343,18 @@ export default defineFlow({
|
|
|
436
343
|
body = fallbackBody;
|
|
437
344
|
}
|
|
438
345
|
|
|
439
|
-
|
|
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
|
+
}
|
|
440
358
|
await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
|
|
441
359
|
// The PR now exists with the mechanical narrative already in place.
|
|
442
360
|
// Anything the narrative node does from here is an improvement on a
|
|
@@ -473,12 +391,14 @@ export default defineFlow({
|
|
|
473
391
|
: routed.route_quality?.route === "escalate"
|
|
474
392
|
? routed.route_quality
|
|
475
393
|
: undefined;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
+
);
|
|
482
402
|
const reason =
|
|
483
403
|
verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
|
|
484
404
|
|
|
@@ -526,7 +446,15 @@ export default defineFlow({
|
|
|
526
446
|
},
|
|
527
447
|
},
|
|
528
448
|
edges: [
|
|
529
|
-
{
|
|
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
|
+
},
|
|
530
458
|
{
|
|
531
459
|
from: "acceptance",
|
|
532
460
|
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
@@ -586,7 +514,10 @@ export default defineFlow({
|
|
|
586
514
|
},
|
|
587
515
|
// The narrative runs only once the PR exists. acpx has no error edge, so an
|
|
588
516
|
// acp node before `open_pr` would be able to fail the flow and cost the PR.
|
|
589
|
-
{
|
|
517
|
+
{
|
|
518
|
+
from: "open_pr",
|
|
519
|
+
switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
|
|
520
|
+
},
|
|
590
521
|
{ from: "narrative", to: "amend_body" },
|
|
591
522
|
],
|
|
592
523
|
});
|