@nathapp/nax 0.75.6 → 0.77.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 +6292 -3809
- package/flows/nax-finish/commit-message.ts +136 -0
- package/flows/nax-finish/flow-ctx.ts +107 -0
- package/flows/nax-finish/narrative.ts +133 -0
- package/flows/nax-finish/nax-finish.flow.ts +209 -86
- package/flows/nax-finish/pr-template.ts +56 -0
- package/flows/nax-finish/review-prompts.ts +62 -4
- package/flows/nax-finish/steps/context.ts +38 -0
- package/flows/nax-finish/steps/git.ts +26 -3
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +345 -0
- package/flows/nax-finish/steps/pr-narrative.ts +43 -0
- package/flows/nax-finish/steps/pr.ts +48 -3
- package/flows/nax-finish/steps/result.ts +99 -10
- package/flows/nax-finish/types.ts +61 -4
- package/flows/nax-finish/verdict.ts +159 -0
- package/package.json +1 -1
|
@@ -31,17 +31,35 @@
|
|
|
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.
|
|
38
|
+
* - Every `commit_*` node appends its round to the finish-audit trail as it
|
|
39
|
+
* happens, rather than a terminal node reconstructing them from
|
|
40
|
+
* `ctx.state.steps`. Appending live is what makes the trail survive a flow
|
|
41
|
+
* that is killed or times out — no terminal node runs on that path, and a
|
|
42
|
+
* crashed finish is exactly when the record of what it changed matters most.
|
|
34
43
|
*/
|
|
35
|
-
import { defineFlow
|
|
44
|
+
import { defineFlow } from "acpx/flows";
|
|
45
|
+
import { buildFixCommitMessage } from "./commit-message";
|
|
46
|
+
import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
|
|
47
|
+
import { narrativePrompt, parseNarrative } from "./narrative";
|
|
36
48
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
37
49
|
import {
|
|
38
50
|
_contextDeps,
|
|
51
|
+
amendPrBodyNode,
|
|
52
|
+
appendRound,
|
|
39
53
|
buildEscalationComment,
|
|
40
54
|
commitAndPush,
|
|
41
55
|
commitFixes,
|
|
42
56
|
detectBaseBranch,
|
|
57
|
+
detectForge,
|
|
58
|
+
filesInCommit,
|
|
59
|
+
loadFinishPrContext,
|
|
43
60
|
loadQualityCommands,
|
|
44
61
|
openOrPromotePr,
|
|
62
|
+
partitionTestFiles,
|
|
45
63
|
postEscalation,
|
|
46
64
|
preflight,
|
|
47
65
|
resolveFeature,
|
|
@@ -49,34 +67,28 @@ import {
|
|
|
49
67
|
runQualityGates,
|
|
50
68
|
writeResult,
|
|
51
69
|
} from "./steps";
|
|
52
|
-
import type {
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
import type { Forge } from "./steps/forge";
|
|
71
|
+
import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
|
|
72
|
+
import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
73
|
+
import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount, routeReview } from "./verdict";
|
|
55
74
|
|
|
56
75
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
|
|
76
|
+
* Disabled only on an explicit "0". An unset variable means enabled, so a flow
|
|
77
|
+
* invoked directly by `acpx flow run` — outside the plugin that sets the env —
|
|
78
|
+
* still writes the narrative.
|
|
61
79
|
*/
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
interface LoadCtxOutput {
|
|
65
|
-
base?: string;
|
|
66
|
-
specPath?: string;
|
|
67
|
-
groups?: AcceptanceGroup[];
|
|
68
|
-
/** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
|
|
69
|
-
acceptanceStatus?: string;
|
|
70
|
-
route?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function fixAttemptCount(ctx: { state: { steps: { nodeId: string }[] } }, fixNodeId: string): number {
|
|
74
|
-
return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
|
|
75
|
-
}
|
|
80
|
+
const NARRATIVE_ENABLED = process.env.NAX_FINISH_NARRATIVE !== "0";
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Injectable seam for the `open_pr` node's title/body assembly — tests stub
|
|
84
|
+
* these to control the fallback-vs-built-metadata paths without a real PRD or
|
|
85
|
+
* git checkout.
|
|
86
|
+
*/
|
|
87
|
+
export const _openPrDeps = {
|
|
88
|
+
loadFinishPrContext,
|
|
89
|
+
buildFinishTitle,
|
|
90
|
+
buildFinishBody,
|
|
91
|
+
};
|
|
80
92
|
|
|
81
93
|
/**
|
|
82
94
|
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
@@ -132,35 +144,34 @@ async function acceptanceGateNode(ctx: {
|
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
/**
|
|
135
|
-
*
|
|
147
|
+
* Route for `commit_gate`, whose successor depends on what the fix touched.
|
|
136
148
|
*
|
|
137
|
-
* `
|
|
138
|
-
*
|
|
139
|
-
*
|
|
149
|
+
* - `unchanged` — nothing committed; no new diff, so nothing to review.
|
|
150
|
+
* - `tests-only` — every touched path matched the repo's test-file patterns.
|
|
151
|
+
* Skipped by explicit choice: the re-review is the flow's most expensive node
|
|
152
|
+
* and a gate fix is usually a mechanical test repair. **This is a real hole.**
|
|
153
|
+
* The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
|
|
154
|
+
* test-only — 8 copy-pasted stubs across 3 test files — so this route would
|
|
155
|
+
* not have caught it. Widen it here if test-quality regressions start
|
|
156
|
+
* shipping.
|
|
157
|
+
* - `changed` — production code was touched, or the paths could not be
|
|
158
|
+
* classified at all. "Cannot classify" reviews rather than skips.
|
|
140
159
|
*/
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
157
|
-
return {
|
|
158
|
-
route: "escalate",
|
|
159
|
-
escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
|
|
160
|
-
findings,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
return { route: "fix", findings };
|
|
160
|
+
async function gateCommitRoute(
|
|
161
|
+
i: FinishInput,
|
|
162
|
+
committed: boolean,
|
|
163
|
+
shaAfter: string | null,
|
|
164
|
+
testFileRegex: string[],
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
if (!committed) return "unchanged";
|
|
167
|
+
// Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
|
|
168
|
+
// it must be reviewed. Folding this into the `!committed` branch would skip
|
|
169
|
+
// the review for a change that actually landed — the one direction this
|
|
170
|
+
// function must never fail in.
|
|
171
|
+
if (!shaAfter) return "changed";
|
|
172
|
+
const files = await filesInCommit(i.workdir, shaAfter);
|
|
173
|
+
if (files.length === 0) return "changed";
|
|
174
|
+
return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
/**
|
|
@@ -169,28 +180,57 @@ function routeReview(
|
|
|
169
180
|
* One node per phase rather than a single shared one because each returns to a
|
|
170
181
|
* different successor, and acpx routes on the node id — a shared node would
|
|
171
182
|
* need a switch reconstructing which fix ran from the step history.
|
|
183
|
+
*
|
|
184
|
+
* Also the audit seam: this is the only point in the graph where a round's
|
|
185
|
+
* findings and its commit are both known. `ctx.outputs` keeps only the latest
|
|
186
|
+
* output per node, so a round not recorded here is a round no terminal node
|
|
187
|
+
* can reconstruct. `shaBefore` is recorded for the same reason — it is what the
|
|
188
|
+
* next review of this phase diffs from (see `incrementalSince`).
|
|
172
189
|
*/
|
|
173
|
-
function commitFixNode(phase:
|
|
190
|
+
function commitFixNode(phase: FinishPhase) {
|
|
174
191
|
return {
|
|
175
192
|
nodeType: "action" as const,
|
|
176
|
-
async run(ctx: {
|
|
193
|
+
async run(ctx: {
|
|
194
|
+
input: unknown;
|
|
195
|
+
outputs: unknown;
|
|
196
|
+
state: { steps: { nodeId: string }[] };
|
|
197
|
+
}): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
|
|
177
198
|
const i = inputOf(ctx);
|
|
199
|
+
const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
|
|
178
200
|
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
179
201
|
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
180
202
|
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
181
|
-
|
|
203
|
+
const { committed, shaBefore, shaAfter } = await commitFixes(
|
|
204
|
+
i.workdir,
|
|
205
|
+
buildFixCommitMessage(phase, i.feature, messageCtx),
|
|
206
|
+
{ skipHooks: true },
|
|
207
|
+
);
|
|
208
|
+
await appendRound(i, {
|
|
209
|
+
ts: new Date().toISOString(),
|
|
210
|
+
phase,
|
|
211
|
+
attempt: fixAttemptCount(ctx, `fix_${phase}`),
|
|
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.
|
|
223
|
+
const route =
|
|
224
|
+
phase === "gate"
|
|
225
|
+
? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
|
|
226
|
+
: committed
|
|
227
|
+
? "changed"
|
|
228
|
+
: "unchanged";
|
|
229
|
+
return { committed, route, shaBefore, shaAfter };
|
|
182
230
|
},
|
|
183
231
|
};
|
|
184
232
|
}
|
|
185
233
|
|
|
186
|
-
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
187
|
-
function parseVerdict(text: string): ReviewVerdict {
|
|
188
|
-
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
189
|
-
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
190
|
-
const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
|
|
191
|
-
return { route, findings, escalationReason: raw.escalationReason };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
234
|
export default defineFlow({
|
|
195
235
|
name: "nax-finish",
|
|
196
236
|
permissions: {
|
|
@@ -212,6 +252,7 @@ export default defineFlow({
|
|
|
212
252
|
specPath: resolution.specPath,
|
|
213
253
|
acceptanceStatus: resolution.acceptanceStatus,
|
|
214
254
|
groups: resolution.groups,
|
|
255
|
+
testFileRegex: resolution.testFileRegex,
|
|
215
256
|
commitsAhead: pf.commitsAhead,
|
|
216
257
|
route: pf.route,
|
|
217
258
|
};
|
|
@@ -224,7 +265,7 @@ export default defineFlow({
|
|
|
224
265
|
fix_acceptance: {
|
|
225
266
|
nodeType: "acp",
|
|
226
267
|
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
227
|
-
parse:
|
|
268
|
+
parse: parseFixVerdict,
|
|
228
269
|
},
|
|
229
270
|
commit_acceptance: commitFixNode("acceptance"),
|
|
230
271
|
review_spec: {
|
|
@@ -233,9 +274,15 @@ export default defineFlow({
|
|
|
233
274
|
profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
|
|
234
275
|
prompt(ctx) {
|
|
235
276
|
const outs = loadCtxOf(ctx);
|
|
236
|
-
return buildReviewPrompt("spec", {
|
|
277
|
+
return buildReviewPrompt("spec", {
|
|
278
|
+
base: outs.base ?? "origin/main",
|
|
279
|
+
specPath: outs.specPath ?? "",
|
|
280
|
+
since: incrementalSince(ctx, "spec"),
|
|
281
|
+
priorFindings: findingsOf(ctx, "spec"),
|
|
282
|
+
retry: repromptCount(ctx, "spec") > 0,
|
|
283
|
+
});
|
|
237
284
|
},
|
|
238
|
-
parse:
|
|
285
|
+
parse: parseReviewVerdict,
|
|
239
286
|
},
|
|
240
287
|
route_spec: {
|
|
241
288
|
nodeType: "compute",
|
|
@@ -244,7 +291,7 @@ export default defineFlow({
|
|
|
244
291
|
fix_spec: {
|
|
245
292
|
nodeType: "acp",
|
|
246
293
|
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
247
|
-
parse:
|
|
294
|
+
parse: parseFixVerdict,
|
|
248
295
|
},
|
|
249
296
|
commit_spec: commitFixNode("spec"),
|
|
250
297
|
review_quality: {
|
|
@@ -253,9 +300,15 @@ export default defineFlow({
|
|
|
253
300
|
profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
|
|
254
301
|
prompt(ctx) {
|
|
255
302
|
const outs = loadCtxOf(ctx);
|
|
256
|
-
return buildReviewPrompt("quality", {
|
|
303
|
+
return buildReviewPrompt("quality", {
|
|
304
|
+
base: outs.base ?? "origin/main",
|
|
305
|
+
specPath: outs.specPath ?? "",
|
|
306
|
+
since: incrementalSince(ctx, "quality"),
|
|
307
|
+
priorFindings: findingsOf(ctx, "quality"),
|
|
308
|
+
retry: repromptCount(ctx, "quality") > 0,
|
|
309
|
+
});
|
|
257
310
|
},
|
|
258
|
-
parse:
|
|
311
|
+
parse: parseReviewVerdict,
|
|
259
312
|
},
|
|
260
313
|
route_quality: {
|
|
261
314
|
nodeType: "compute",
|
|
@@ -264,13 +317,13 @@ export default defineFlow({
|
|
|
264
317
|
fix_quality: {
|
|
265
318
|
nodeType: "acp",
|
|
266
319
|
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
267
|
-
parse:
|
|
320
|
+
parse: parseFixVerdict,
|
|
268
321
|
},
|
|
269
322
|
commit_quality: commitFixNode("quality"),
|
|
270
323
|
fix_gate: {
|
|
271
324
|
nodeType: "acp",
|
|
272
325
|
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
273
|
-
parse:
|
|
326
|
+
parse: parseFixVerdict,
|
|
274
327
|
},
|
|
275
328
|
commit_gate: commitFixNode("gate"),
|
|
276
329
|
quality_gates: {
|
|
@@ -349,23 +402,65 @@ export default defineFlow({
|
|
|
349
402
|
nodeType: "action",
|
|
350
403
|
async run(ctx) {
|
|
351
404
|
const i = inputOf(ctx);
|
|
352
|
-
|
|
353
|
-
|
|
405
|
+
const loadCtx = loadCtxOf(ctx);
|
|
406
|
+
if (loadCtx.route === "nothing-to-finish") {
|
|
407
|
+
await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
|
|
354
408
|
return { route: "done", status: "nothing-to-finish" };
|
|
355
409
|
}
|
|
356
410
|
// Every fix node edited the working tree; without this the PR would be
|
|
357
411
|
// opened from a remote branch missing all of them.
|
|
358
412
|
const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
413
|
+
|
|
414
|
+
const fallbackTitle = `nax-finish: ${i.feature}`;
|
|
415
|
+
const fallbackBody = `Automated finish of \`${i.feature}\`.`;
|
|
416
|
+
let title = fallbackTitle;
|
|
417
|
+
let body = fallbackBody;
|
|
418
|
+
// Detected once, here, and handed to both the body builder (which needs
|
|
419
|
+
// it for the repo template) and the opener. Detecting in both would let
|
|
420
|
+
// them disagree. On a throw it stays undefined and `openOrPromotePr`
|
|
421
|
+
// detects for itself, exactly as it did before.
|
|
422
|
+
let forge: Forge | undefined;
|
|
423
|
+
try {
|
|
424
|
+
forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
|
|
425
|
+
const prCtx = await _openPrDeps.loadFinishPrContext(i, {
|
|
426
|
+
base: loadCtx.base ?? "",
|
|
427
|
+
gatesRan: gateOutputs(ctx).ran ?? [],
|
|
428
|
+
forge,
|
|
429
|
+
specPath: loadCtx.specPath,
|
|
430
|
+
});
|
|
431
|
+
title = _openPrDeps.buildFinishTitle(prCtx);
|
|
432
|
+
body = _openPrDeps.buildFinishBody(prCtx);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
_prBodyDeps.warn("[finish-pr] Falling back to default PR title/body", { path: i.prdPath, error });
|
|
435
|
+
title = fallbackTitle;
|
|
436
|
+
body = fallbackBody;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
|
|
440
|
+
await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
|
|
441
|
+
// The PR now exists with the mechanical narrative already in place.
|
|
442
|
+
// Anything the narrative node does from here is an improvement on a
|
|
443
|
+
// body that is already correct.
|
|
444
|
+
return { route: NARRATIVE_ENABLED ? "narrate" : "done", committed: sync.committed, ...r };
|
|
367
445
|
},
|
|
368
446
|
},
|
|
447
|
+
narrative: {
|
|
448
|
+
nodeType: "acp",
|
|
449
|
+
session: { isolated: true },
|
|
450
|
+
profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
|
|
451
|
+
prompt: narrativePrompt,
|
|
452
|
+
parse: parseNarrative,
|
|
453
|
+
},
|
|
454
|
+
amend_body: {
|
|
455
|
+
nodeType: "action",
|
|
456
|
+
run: amendPrBodyNode,
|
|
457
|
+
},
|
|
458
|
+
// Inert terminal. acpx switch cases must name a real node, so the `done`
|
|
459
|
+
// route out of open_pr needs somewhere to land.
|
|
460
|
+
finish_done: {
|
|
461
|
+
nodeType: "compute",
|
|
462
|
+
run: () => ({ route: "done" }),
|
|
463
|
+
},
|
|
369
464
|
escalate: {
|
|
370
465
|
nodeType: "action",
|
|
371
466
|
async run(ctx) {
|
|
@@ -409,7 +504,7 @@ export default defineFlow({
|
|
|
409
504
|
escalationReason: reason,
|
|
410
505
|
findings: verdict?.findings ?? [],
|
|
411
506
|
};
|
|
412
|
-
await writeResult(i
|
|
507
|
+
await writeResult(i, result);
|
|
413
508
|
|
|
414
509
|
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
415
510
|
let url: string | undefined;
|
|
@@ -424,7 +519,7 @@ export default defineFlow({
|
|
|
424
519
|
} catch (err) {
|
|
425
520
|
deliveryError = String(err);
|
|
426
521
|
}
|
|
427
|
-
await writeResult(i
|
|
522
|
+
await writeResult(i, { ...result, url, deliveryError });
|
|
428
523
|
|
|
429
524
|
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
430
525
|
},
|
|
@@ -444,7 +539,10 @@ export default defineFlow({
|
|
|
444
539
|
{ from: "review_spec", to: "route_spec" },
|
|
445
540
|
{
|
|
446
541
|
from: "route_spec",
|
|
447
|
-
switch: {
|
|
542
|
+
switch: {
|
|
543
|
+
on: "$.route",
|
|
544
|
+
cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate", reprompt: "review_spec" },
|
|
545
|
+
},
|
|
448
546
|
},
|
|
449
547
|
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
450
548
|
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
@@ -453,7 +551,10 @@ export default defineFlow({
|
|
|
453
551
|
{ from: "review_quality", to: "route_quality" },
|
|
454
552
|
{
|
|
455
553
|
from: "route_quality",
|
|
456
|
-
switch: {
|
|
554
|
+
switch: {
|
|
555
|
+
on: "$.route",
|
|
556
|
+
cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate", reprompt: "review_quality" },
|
|
557
|
+
},
|
|
457
558
|
},
|
|
458
559
|
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
459
560
|
// follow catch anything the fix broke mechanically.
|
|
@@ -464,6 +565,28 @@ export default defineFlow({
|
|
|
464
565
|
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
465
566
|
},
|
|
466
567
|
{ from: "fix_gate", to: "commit_gate" },
|
|
467
|
-
|
|
568
|
+
// A gate fix that changed code goes back through the quality reviewer, not
|
|
569
|
+
// straight to the gates. The gate loop is the last one to edit the tree and
|
|
570
|
+
// was the only one whose edits nothing reviewed: `quality_gates` proves the
|
|
571
|
+
// repo's commands are green, which a bad fix can satisfy. Observed on
|
|
572
|
+
// rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
|
|
573
|
+
// copy-pasting an identical 4-line stub into each, and it shipped, because
|
|
574
|
+
// no reviewer ran after it. Re-entry costs one review per gate round; both
|
|
575
|
+
// loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
|
|
576
|
+
//
|
|
577
|
+
// `unchanged` skips it: with nothing committed there is no new diff to
|
|
578
|
+
// review, and re-running the reviewer on an identical tree would burn a
|
|
579
|
+
// turn to re-report what route_quality already called clean.
|
|
580
|
+
{
|
|
581
|
+
from: "commit_gate",
|
|
582
|
+
switch: {
|
|
583
|
+
on: "$.route",
|
|
584
|
+
cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
// The narrative runs only once the PR exists. acpx has no error edge, so an
|
|
588
|
+
// acp node before `open_pr` would be able to fail the flow and cost the PR.
|
|
589
|
+
{ from: "open_pr", switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done" } } },
|
|
590
|
+
{ from: "narrative", to: "amend_body" },
|
|
468
591
|
],
|
|
469
592
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository PR/MR template discovery, ported from
|
|
3
|
+
* `src/plugins/builtin/auto-pr/template.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Ported rather than imported: `flows/` is loaded by acpx in its own Node
|
|
6
|
+
* process, where nax's `src/` and its `@/*` alias do not exist. This matches
|
|
7
|
+
* the convention already in this directory — `errors.ts`, `exec.ts`, `types.ts`
|
|
8
|
+
* and the PR body builder are all flow-local re-implementations.
|
|
9
|
+
*
|
|
10
|
+
* The duplication is stable: these candidate paths are an external convention
|
|
11
|
+
* set by GitHub and GitLab, not internal logic that drifts with the codebase.
|
|
12
|
+
*
|
|
13
|
+
* Why preserve-not-fill: passing `--body` / `--description` to `gh` / `glab`
|
|
14
|
+
* suppresses the repo's default template, so it must be read and re-embedded.
|
|
15
|
+
*/
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { Forge } from "./steps/forge";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Candidate template paths for GitHub, in priority order.
|
|
21
|
+
* Multi-template directories (`PULL_REQUEST_TEMPLATE/`) are intentionally
|
|
22
|
+
* skipped because they are ambiguous unattended.
|
|
23
|
+
*/
|
|
24
|
+
const GITHUB_TEMPLATE_PATHS: readonly string[] = [
|
|
25
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
26
|
+
".github/pull_request_template.md",
|
|
27
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
28
|
+
"docs/PULL_REQUEST_TEMPLATE.md",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/** Preferred single-template location for GitLab. */
|
|
32
|
+
const GITLAB_DEFAULT_TEMPLATE_PATH = ".gitlab/merge_request_templates/Default.md";
|
|
33
|
+
|
|
34
|
+
/** Only `readText` is consulted, so any caller with a file reader can supply it. */
|
|
35
|
+
export interface TemplateDeps {
|
|
36
|
+
readText: (path: string) => Promise<string | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function firstExisting(workdir: string, deps: TemplateDeps, paths: readonly string[]): Promise<string | null> {
|
|
40
|
+
for (const relPath of paths) {
|
|
41
|
+
const content = await deps.readText(join(workdir, relPath));
|
|
42
|
+
if (content !== null) return content;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Locate the PR/MR template for the current repository.
|
|
49
|
+
*
|
|
50
|
+
* @returns Template text verbatim, or `null` when none resolves — which is the
|
|
51
|
+
* common case and never an error.
|
|
52
|
+
*/
|
|
53
|
+
export async function findPrTemplate(workdir: string, forge: Forge, deps: TemplateDeps): Promise<string | null> {
|
|
54
|
+
if (forge === "github") return firstExisting(workdir, deps, GITHUB_TEMPLATE_PATHS);
|
|
55
|
+
return firstExisting(workdir, deps, [GITLAB_DEFAULT_TEMPLATE_PATH]);
|
|
56
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Finding } from "./types";
|
|
2
|
+
|
|
1
3
|
export const SPEC_REVIEW_DIMENSIONS = `# Spec-relative review dimensions
|
|
2
4
|
|
|
3
5
|
Reference for the post-impl-review **spec-relative** pass: Compliance, Drift,
|
|
@@ -310,12 +312,68 @@ const JSON_CONTRACT = [
|
|
|
310
312
|
"}",
|
|
311
313
|
].join("\n");
|
|
312
314
|
|
|
313
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Prepended when a previous attempt at this review returned something that was
|
|
317
|
+
* not JSON. Lead position, not appended: the failure mode is a model that
|
|
318
|
+
* narrates its findings and forgets the contract at the end of a long turn.
|
|
319
|
+
*/
|
|
320
|
+
const RETRY_NOTICE = [
|
|
321
|
+
"IMPORTANT — your previous reply could not be parsed as JSON, so it was discarded entirely.",
|
|
322
|
+
"Do not narrate your findings in prose. Do not describe what you reported.",
|
|
323
|
+
"Your entire reply must be the JSON object described at the end of this prompt: first char `{`, last char `}`.",
|
|
324
|
+
].join("\n");
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build the reviewer prompt.
|
|
328
|
+
*
|
|
329
|
+
* With `since` set this is a **re-review**: the same reviewer already read the
|
|
330
|
+
* whole branch and raised `priorFindings`, a fix was applied and committed, and
|
|
331
|
+
* the only new material is `since..HEAD`. Re-reading the full branch diff every
|
|
332
|
+
* round made reviews 58% of the flow's wall clock, most of it re-reading code an
|
|
333
|
+
* earlier round had already cleared. The narrowed round still has the full repo
|
|
334
|
+
* available — it is told to open whatever the fix touches — it just is not asked
|
|
335
|
+
* to re-derive a verdict on unchanged code.
|
|
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.
|
|
340
|
+
*/
|
|
341
|
+
export function buildReviewPrompt(
|
|
342
|
+
phase: "spec" | "quality",
|
|
343
|
+
args: {
|
|
344
|
+
base: string;
|
|
345
|
+
specPath: string;
|
|
346
|
+
since?: string | null;
|
|
347
|
+
priorFindings?: Finding[];
|
|
348
|
+
retry?: boolean;
|
|
349
|
+
},
|
|
350
|
+
): string {
|
|
314
351
|
const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
|
|
352
|
+
const lead = args.retry ? [RETRY_NOTICE] : [];
|
|
353
|
+
if (!args.since) {
|
|
354
|
+
return [
|
|
355
|
+
...lead,
|
|
356
|
+
`You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
|
|
357
|
+
`The spec/requirements source is: ${args.specPath}. Read it in full.`,
|
|
358
|
+
`Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
|
|
359
|
+
WORKER_PROTOCOL,
|
|
360
|
+
dims,
|
|
361
|
+
CLASSIFIER,
|
|
362
|
+
JSON_CONTRACT,
|
|
363
|
+
].join("\n\n");
|
|
364
|
+
}
|
|
315
365
|
return [
|
|
316
|
-
|
|
317
|
-
`
|
|
318
|
-
`
|
|
366
|
+
...lead,
|
|
367
|
+
`You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
|
|
368
|
+
`On your previous pass over \`git diff ${args.base}...HEAD\` you raised the findings below, and they have since been fixed and committed. Everything else in that diff you already judged acceptable — do not re-derive a verdict on it.`,
|
|
369
|
+
`Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
|
|
370
|
+
`The fix is \`git diff ${args.since}..HEAD\` — this is the only code that has changed since your last verdict. Review it, and only it, for two questions:`,
|
|
371
|
+
[
|
|
372
|
+
"1. **Resolved?** Does the fix actually resolve each finding above? A finding that was papered over (assertion weakened, test deleted, check disabled) is NOT resolved — re-raise it.",
|
|
373
|
+
"2. **Broken?** Did the fix introduce a new problem, in the changed lines or in the unchanged code they now call into?",
|
|
374
|
+
"",
|
|
375
|
+
`Read whatever files you need — the spec is at ${args.specPath} and the whole repo is available. Scope means *what you judge*, not *what you may read*.`,
|
|
376
|
+
].join("\n"),
|
|
319
377
|
WORKER_PROTOCOL,
|
|
320
378
|
dims,
|
|
321
379
|
CLASSIFIER,
|
|
@@ -17,6 +17,13 @@ export interface FeatureResolution {
|
|
|
17
17
|
specKind: "markdown" | "prd";
|
|
18
18
|
acceptanceStatus: string;
|
|
19
19
|
groups: AcceptanceGroup[];
|
|
20
|
+
/**
|
|
21
|
+
* Test-file classification regexes, as sources, from `nax features resolve`
|
|
22
|
+
* (the ADR-009 SSOT). Empty when the CLI is older than the field or could not
|
|
23
|
+
* resolve them — callers must treat empty as "cannot classify", never as
|
|
24
|
+
* "nothing is a test file".
|
|
25
|
+
*/
|
|
26
|
+
testFileRegex: string[];
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -30,6 +37,7 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
|
|
|
30
37
|
let parsed: {
|
|
31
38
|
specSource?: { kind: "markdown" | "prd"; path: string };
|
|
32
39
|
acceptance?: { status?: string; groups?: AcceptanceGroup[] };
|
|
40
|
+
testPatterns?: { regex?: string[] };
|
|
33
41
|
};
|
|
34
42
|
try {
|
|
35
43
|
parsed = JSON.parse(res.stdout);
|
|
@@ -51,9 +59,39 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
|
|
|
51
59
|
specKind: parsed.specSource.kind,
|
|
52
60
|
acceptanceStatus: parsed.acceptance?.status ?? "no-prd",
|
|
53
61
|
groups: parsed.acceptance?.groups ?? [],
|
|
62
|
+
testFileRegex: parsed.testPatterns?.regex ?? [],
|
|
54
63
|
};
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Split paths into test and non-test, using the regexes `nax features resolve`
|
|
68
|
+
* reported.
|
|
69
|
+
*
|
|
70
|
+
* With no patterns (older nax, or a config the resolver choked on) every path is
|
|
71
|
+
* reported as non-test. That is the safe direction for the one caller: the gate
|
|
72
|
+
* loop skips its re-review only for a test-only change, so "cannot classify"
|
|
73
|
+
* must mean "review it", never "skip it".
|
|
74
|
+
*
|
|
75
|
+
* An unparseable regex source is skipped rather than thrown — a bad pattern in
|
|
76
|
+
* one config entry must not take the flow down mid-loop.
|
|
77
|
+
*/
|
|
78
|
+
export function partitionTestFiles(paths: string[], regexSources: string[]): { test: string[]; nonTest: string[] } {
|
|
79
|
+
const matchers: RegExp[] = [];
|
|
80
|
+
for (const src of regexSources) {
|
|
81
|
+
try {
|
|
82
|
+
matchers.push(new RegExp(src));
|
|
83
|
+
} catch {
|
|
84
|
+
// Skip — see the doc comment above.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const test: string[] = [];
|
|
88
|
+
const nonTest: string[] = [];
|
|
89
|
+
for (const p of paths) {
|
|
90
|
+
(matchers.some((re) => re.test(p)) ? test : nonTest).push(p);
|
|
91
|
+
}
|
|
92
|
+
return { test, nonTest };
|
|
93
|
+
}
|
|
94
|
+
|
|
57
95
|
export async function preflight(
|
|
58
96
|
workdir: string,
|
|
59
97
|
base: string,
|