@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.
- package/dist/nax.js +950 -608
- package/flows/nax-finish/flow-ctx.ts +25 -5
- package/flows/nax-finish/nax-finish.flow.ts +62 -136
- package/flows/nax-finish/review-prompts.ts +6 -3
- 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/package.json +8 -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
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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",
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|