@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.
- package/dist/nax.js +1737 -1054
- package/flows/nax-finish/flow-ctx.ts +25 -5
- package/flows/nax-finish/nax-finish.flow.ts +83 -148
- package/flows/nax-finish/review-prompts.ts +6 -3
- package/flows/nax-finish/steps/commit-round.ts +8 -8
- 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 +1 -0
- package/flows/nax-finish/types.ts +23 -9
- package/package.json +13 -4
|
@@ -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
|
}
|
|
@@ -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`
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
{
|
|
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": "
|
|
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
|
-
{
|
|
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
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
}
|