@nathapp/nax 0.75.6 → 0.76.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 +966 -312
- package/flows/nax-finish/commit-message.ts +136 -0
- package/flows/nax-finish/flow-ctx.ts +95 -0
- package/flows/nax-finish/nax-finish.flow.ts +129 -30
- package/flows/nax-finish/review-prompts.ts +42 -4
- package/flows/nax-finish/steps/context.ts +38 -0
- package/flows/nax-finish/steps/git.ts +26 -3
- package/flows/nax-finish/steps/result.ts +99 -10
- package/flows/nax-finish/types.ts +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit messages for the flow's `commit_<phase>` checkpoints.
|
|
3
|
+
*
|
|
4
|
+
* These commits are shipped history — they land on the feature branch and a
|
|
5
|
+
* human reviews them in the PR. Every one of them used to read
|
|
6
|
+
* `fix(<feature>): nax-finish <phase> fixes` with an empty body, so a reviewer
|
|
7
|
+
* looking at six such commits could not tell which one re-enabled a disabled
|
|
8
|
+
* market gate and which one renamed a variable. The reviewer already produced
|
|
9
|
+
* exactly the material needed to say so — severity, title, problem, fix — and
|
|
10
|
+
* it was being discarded at the one moment it could have been recorded.
|
|
11
|
+
*
|
|
12
|
+
* Subject lines follow the repo's conventional-commit rule and the 72-column
|
|
13
|
+
* git summary convention; the findings go in the body, one bullet each.
|
|
14
|
+
*/
|
|
15
|
+
import type { Finding, FinishPhase } from "./types";
|
|
16
|
+
|
|
17
|
+
/** Git's conventional soft cap for a commit summary line. */
|
|
18
|
+
const MAX_SUBJECT_LEN = 72;
|
|
19
|
+
|
|
20
|
+
/** Worst-first, so the subject of a mixed batch reports the severity that matters. */
|
|
21
|
+
const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
|
|
22
|
+
|
|
23
|
+
/** How much gate output to quote in the body before it stops being a commit message. */
|
|
24
|
+
const MAX_GATE_OUTPUT_LINES = 20;
|
|
25
|
+
|
|
26
|
+
interface MessageCtx {
|
|
27
|
+
outputs: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PhaseOutputs {
|
|
31
|
+
findings?: Finding[];
|
|
32
|
+
failing?: string[];
|
|
33
|
+
output?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
|
|
37
|
+
return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
|
|
41
|
+
const raw = outputsFor(ctx, `review_${phase}`).findings;
|
|
42
|
+
return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function worstSeverity(findings: Finding[]): string {
|
|
46
|
+
const present = new Set(findings.map((f) => f.severity));
|
|
47
|
+
return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lowercase a finding title's leading word for the subject line.
|
|
52
|
+
*
|
|
53
|
+
* Reviewers write titles as sentences ("Market gate skip branch is
|
|
54
|
+
* unreachable"); conventional-commit subjects read better in lower case. Only
|
|
55
|
+
* the first character is touched — an all-caps leading token is an acronym
|
|
56
|
+
* (`SSRF guard …`) and must survive intact.
|
|
57
|
+
*/
|
|
58
|
+
function subjectCase(title: string): string {
|
|
59
|
+
const [first = "", ...rest] = title.split(" ");
|
|
60
|
+
const isAcronym = first.length > 1 && first === first.toUpperCase();
|
|
61
|
+
return isAcronym
|
|
62
|
+
? title
|
|
63
|
+
: `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(s: string): string {
|
|
67
|
+
return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
|
|
71
|
+
function humanList(items: string[]): string {
|
|
72
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
73
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
|
|
77
|
+
if (findings.length === 1) return subjectCase(findings[0].title);
|
|
78
|
+
return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
|
|
82
|
+
if (phase === "gate") {
|
|
83
|
+
const failing = outputsFor(ctx, "quality_gates").failing ?? [];
|
|
84
|
+
return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
|
|
85
|
+
}
|
|
86
|
+
if (phase === "acceptance") return "repair failing acceptance tests";
|
|
87
|
+
const findings = findingsFor(ctx, phase);
|
|
88
|
+
return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bodyFor(phase: FinishPhase, ctx: MessageCtx): string[] {
|
|
92
|
+
if (phase === "gate") {
|
|
93
|
+
const gate = outputsFor(ctx, "quality_gates");
|
|
94
|
+
const failing = gate.failing ?? [];
|
|
95
|
+
const tail = (gate.output ?? "").trim().split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
|
|
96
|
+
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(tail ? [tail] : [])];
|
|
97
|
+
}
|
|
98
|
+
if (phase === "acceptance") {
|
|
99
|
+
const tail = (outputsFor(ctx, "acceptance").output ?? "")
|
|
100
|
+
.trim()
|
|
101
|
+
.split("\n")
|
|
102
|
+
.slice(-MAX_GATE_OUTPUT_LINES)
|
|
103
|
+
.join("\n");
|
|
104
|
+
return tail ? [tail] : [];
|
|
105
|
+
}
|
|
106
|
+
const findings = findingsFor(ctx, phase);
|
|
107
|
+
if (findings.length === 0) return [];
|
|
108
|
+
return [
|
|
109
|
+
findings
|
|
110
|
+
.map((f) =>
|
|
111
|
+
[`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join("\n"),
|
|
114
|
+
)
|
|
115
|
+
.join("\n"),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Human-readable phase label for the attribution trailer. */
|
|
120
|
+
function phaseLabel(phase: FinishPhase): string {
|
|
121
|
+
return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the commit message for a `commit_<phase>` checkpoint.
|
|
126
|
+
*
|
|
127
|
+
* Never throws and never returns an empty subject: a missing or malformed
|
|
128
|
+
* reviewer output degrades to the phase label. A commit that cannot be
|
|
129
|
+
* described is still a commit that must happen — failing here would strand the
|
|
130
|
+
* fix uncommitted and reintroduce the stale-diff bug (#1397).
|
|
131
|
+
*/
|
|
132
|
+
export function buildFixCommitMessage(phase: FinishPhase, feature: string, ctx: MessageCtx): string {
|
|
133
|
+
const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
|
|
134
|
+
const body = bodyFor(phase, ctx);
|
|
135
|
+
return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
|
|
136
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readers over an acpx `FlowNodeContext` — the flow graph's view of its own
|
|
3
|
+
* state.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `nax-finish.flow.ts` (600-line source cap). These are all pure
|
|
6
|
+
* functions of `ctx.input` / `ctx.outputs` / `ctx.state.steps`; anything that
|
|
7
|
+
* shells out lives under `./steps/`.
|
|
8
|
+
*
|
|
9
|
+
* Two views of the same run, and the difference matters in a graph whose whole
|
|
10
|
+
* shape is loops:
|
|
11
|
+
*
|
|
12
|
+
* - `ctx.outputs` is a map keyed by node id, so it holds only each node's
|
|
13
|
+
* **latest** output. A node re-entering a loop cannot see its own previous
|
|
14
|
+
* round there.
|
|
15
|
+
* - `ctx.state.steps` is the ordered history and carries every step's `output`,
|
|
16
|
+
* so an earlier round IS recoverable from it. A step is appended on its
|
|
17
|
+
* *outcome*, so the currently-executing node is never in this list — which is
|
|
18
|
+
* what lets `incrementalSince` find the *previous* review rather than itself.
|
|
19
|
+
*/
|
|
20
|
+
import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
|
|
21
|
+
|
|
22
|
+
/** Minimal shapes so each reader takes only the part of the context it reads. */
|
|
23
|
+
export interface StepsCtx {
|
|
24
|
+
state: { steps: { nodeId: string; output?: unknown }[] };
|
|
25
|
+
}
|
|
26
|
+
export interface OutputsCtx {
|
|
27
|
+
outputs: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
31
|
+
|
|
32
|
+
/** What `load_ctx` resolves once, for every downstream node to read. */
|
|
33
|
+
export interface LoadCtxOutput {
|
|
34
|
+
base?: string;
|
|
35
|
+
specPath?: string;
|
|
36
|
+
groups?: AcceptanceGroup[];
|
|
37
|
+
/** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
|
|
38
|
+
acceptanceStatus?: string;
|
|
39
|
+
/** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
|
|
40
|
+
testFileRegex?: string[];
|
|
41
|
+
route?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
|
|
45
|
+
return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
|
|
49
|
+
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function gateOutputs(ctx: OutputsCtx): { failing?: string[] } {
|
|
53
|
+
return ((ctx.outputs as Record<string, { failing?: string[] } | undefined>).quality_gates ?? {}) as {
|
|
54
|
+
failing?: string[];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The findings the `fix_<phase>` node was asked to resolve; empty for non-review phases. */
|
|
59
|
+
export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
|
|
60
|
+
if (phase !== "spec" && phase !== "quality") return [];
|
|
61
|
+
return (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`]?.findings ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ref a re-review should diff from, or null to review the whole branch.
|
|
66
|
+
*
|
|
67
|
+
* A reviewer node re-reads the spec in full and the entire `git diff
|
|
68
|
+
* base...HEAD` on every round. Reviews were 58% of the wall clock on
|
|
69
|
+
* rs-stock/pipeline-run-outcome (7 calls, 1306s of 2232s), and round 3 re-read
|
|
70
|
+
* everything rounds 1-2 had already cleared.
|
|
71
|
+
*
|
|
72
|
+
* The scoping ref is the `shaBefore` of the **first** `commit_*` step after this
|
|
73
|
+
* phase's last review — that commit's parent is, by construction, the tree the
|
|
74
|
+
* previous verdict passed on, since only `commit_*` nodes commit. Taking the
|
|
75
|
+
* first (not the last) is what makes the window complete when more than one
|
|
76
|
+
* commit landed in it, which happens when the acceptance loop commits between a
|
|
77
|
+
* spec fix and its re-review: `firstCommit.shaBefore..HEAD` spans both.
|
|
78
|
+
*
|
|
79
|
+
* Read from `ctx.state.steps[].output`, not `ctx.outputs` — the latter keeps
|
|
80
|
+
* only each node's newest output, which for two commit steps of the same node id
|
|
81
|
+
* would have discarded the earlier `shaBefore` and silently under-scoped the
|
|
82
|
+
* review.
|
|
83
|
+
*
|
|
84
|
+
* Returns null — a full review — when there is no prior review of this phase
|
|
85
|
+
* (round 1), no commit since it (nothing new to look at), or the commit step
|
|
86
|
+
* recorded no `shaBefore`.
|
|
87
|
+
*/
|
|
88
|
+
export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
|
|
89
|
+
const steps = ctx.state.steps ?? [];
|
|
90
|
+
const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
|
|
91
|
+
if (lastReview < 0) return null;
|
|
92
|
+
const firstCommit = steps.slice(lastReview + 1).find((s) => s.nodeId.startsWith("commit_"));
|
|
93
|
+
if (!firstCommit) return null;
|
|
94
|
+
return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
|
|
95
|
+
}
|
|
@@ -31,17 +31,31 @@
|
|
|
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
44
|
import { defineFlow, extractJsonObject } from "acpx/flows";
|
|
45
|
+
import { buildFixCommitMessage } from "./commit-message";
|
|
46
|
+
import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
|
|
36
47
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
37
48
|
import {
|
|
38
49
|
_contextDeps,
|
|
50
|
+
appendRound,
|
|
39
51
|
buildEscalationComment,
|
|
40
52
|
commitAndPush,
|
|
41
53
|
commitFixes,
|
|
42
54
|
detectBaseBranch,
|
|
55
|
+
filesInCommit,
|
|
43
56
|
loadQualityCommands,
|
|
44
57
|
openOrPromotePr,
|
|
58
|
+
partitionTestFiles,
|
|
45
59
|
postEscalation,
|
|
46
60
|
preflight,
|
|
47
61
|
resolveFeature,
|
|
@@ -49,9 +63,7 @@ import {
|
|
|
49
63
|
runQualityGates,
|
|
50
64
|
writeResult,
|
|
51
65
|
} from "./steps";
|
|
52
|
-
import type {
|
|
53
|
-
|
|
54
|
-
const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
66
|
+
import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
55
67
|
|
|
56
68
|
/**
|
|
57
69
|
* Cap on fix-and-reverify iterations, per phase, before escalating instead of
|
|
@@ -61,23 +73,6 @@ const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
|
61
73
|
*/
|
|
62
74
|
const MAX_FIX_ATTEMPTS = 3;
|
|
63
75
|
|
|
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
|
-
}
|
|
76
|
-
|
|
77
|
-
function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
|
|
78
|
-
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
76
|
/**
|
|
82
77
|
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
83
78
|
*
|
|
@@ -169,16 +164,91 @@ function routeReview(
|
|
|
169
164
|
* One node per phase rather than a single shared one because each returns to a
|
|
170
165
|
* different successor, and acpx routes on the node id — a shared node would
|
|
171
166
|
* need a switch reconstructing which fix ran from the step history.
|
|
167
|
+
*
|
|
168
|
+
* Also the audit seam: this is the only point in the graph where a round's
|
|
169
|
+
* findings and its commit are both known. `ctx.outputs` keeps only the latest
|
|
170
|
+
* output per node, so a round not recorded here is a round no terminal node
|
|
171
|
+
* can reconstruct.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Route for `commit_gate`, whose successor depends on what the fix touched.
|
|
175
|
+
*
|
|
176
|
+
* - `unchanged` — nothing committed; no new diff, so nothing to review.
|
|
177
|
+
* - `tests-only` — every touched path matched the repo's test-file patterns.
|
|
178
|
+
* Skipped by explicit choice: the re-review is the flow's most expensive node
|
|
179
|
+
* and a gate fix is usually a mechanical test repair. **This is a real hole.**
|
|
180
|
+
* The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
|
|
181
|
+
* test-only — 8 copy-pasted stubs across 3 test files — so this route would
|
|
182
|
+
* not have caught it. Widen it here if test-quality regressions start
|
|
183
|
+
* shipping.
|
|
184
|
+
* - `changed` — production code was touched, or the paths could not be
|
|
185
|
+
* classified at all. "Cannot classify" reviews rather than skips.
|
|
186
|
+
*/
|
|
187
|
+
async function gateCommitRoute(
|
|
188
|
+
i: FinishInput,
|
|
189
|
+
committed: boolean,
|
|
190
|
+
shaAfter: string | null,
|
|
191
|
+
testFileRegex: string[],
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
if (!committed) return "unchanged";
|
|
194
|
+
// Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
|
|
195
|
+
// it must be reviewed. Folding this into the `!committed` branch would skip
|
|
196
|
+
// the review for a change that actually landed — the one direction this
|
|
197
|
+
// function must never fail in.
|
|
198
|
+
if (!shaAfter) return "changed";
|
|
199
|
+
const files = await filesInCommit(i.workdir, shaAfter);
|
|
200
|
+
if (files.length === 0) return "changed";
|
|
201
|
+
return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
206
|
+
*
|
|
207
|
+
* One node per phase rather than a single shared one because each returns to a
|
|
208
|
+
* different successor, and acpx routes on the node id — a shared node would
|
|
209
|
+
* need a switch reconstructing which fix ran from the step history.
|
|
210
|
+
*
|
|
211
|
+
* Also the audit seam: this is the only point in the graph where a round's
|
|
212
|
+
* findings and its commit are both known. `ctx.outputs` keeps only the latest
|
|
213
|
+
* output per node, so a round not recorded here is a round no terminal node
|
|
214
|
+
* can reconstruct. `shaBefore` is recorded for the same reason — it is what the
|
|
215
|
+
* next review of this phase diffs from (see `incrementalSince`).
|
|
172
216
|
*/
|
|
173
|
-
function commitFixNode(phase:
|
|
217
|
+
function commitFixNode(phase: FinishPhase) {
|
|
174
218
|
return {
|
|
175
219
|
nodeType: "action" as const,
|
|
176
|
-
async run(ctx: {
|
|
220
|
+
async run(ctx: {
|
|
221
|
+
input: unknown;
|
|
222
|
+
outputs: unknown;
|
|
223
|
+
state: { steps: { nodeId: string }[] };
|
|
224
|
+
}): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
|
|
177
225
|
const i = inputOf(ctx);
|
|
226
|
+
const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
|
|
178
227
|
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
179
228
|
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
180
229
|
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
181
|
-
|
|
230
|
+
const { committed, shaBefore, shaAfter } = await commitFixes(
|
|
231
|
+
i.workdir,
|
|
232
|
+
buildFixCommitMessage(phase, i.feature, messageCtx),
|
|
233
|
+
{ skipHooks: true },
|
|
234
|
+
);
|
|
235
|
+
await appendRound(i, {
|
|
236
|
+
ts: new Date().toISOString(),
|
|
237
|
+
phase,
|
|
238
|
+
attempt: fixAttemptCount(ctx, `fix_${phase}`),
|
|
239
|
+
committed,
|
|
240
|
+
findings: findingsOf(ctx, phase),
|
|
241
|
+
...(phase === "gate" ? { failing: gateOutputs(ctx).failing ?? [] } : {}),
|
|
242
|
+
});
|
|
243
|
+
// Only `commit_gate` routes on this; the other phases have unconditional
|
|
244
|
+
// edges and ignore it.
|
|
245
|
+
const route =
|
|
246
|
+
phase === "gate"
|
|
247
|
+
? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
|
|
248
|
+
: committed
|
|
249
|
+
? "changed"
|
|
250
|
+
: "unchanged";
|
|
251
|
+
return { committed, route, shaBefore, shaAfter };
|
|
182
252
|
},
|
|
183
253
|
};
|
|
184
254
|
}
|
|
@@ -212,6 +282,7 @@ export default defineFlow({
|
|
|
212
282
|
specPath: resolution.specPath,
|
|
213
283
|
acceptanceStatus: resolution.acceptanceStatus,
|
|
214
284
|
groups: resolution.groups,
|
|
285
|
+
testFileRegex: resolution.testFileRegex,
|
|
215
286
|
commitsAhead: pf.commitsAhead,
|
|
216
287
|
route: pf.route,
|
|
217
288
|
};
|
|
@@ -233,7 +304,12 @@ export default defineFlow({
|
|
|
233
304
|
profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
|
|
234
305
|
prompt(ctx) {
|
|
235
306
|
const outs = loadCtxOf(ctx);
|
|
236
|
-
return buildReviewPrompt("spec", {
|
|
307
|
+
return buildReviewPrompt("spec", {
|
|
308
|
+
base: outs.base ?? "origin/main",
|
|
309
|
+
specPath: outs.specPath ?? "",
|
|
310
|
+
since: incrementalSince(ctx, "spec"),
|
|
311
|
+
priorFindings: findingsOf(ctx, "spec"),
|
|
312
|
+
});
|
|
237
313
|
},
|
|
238
314
|
parse: parseVerdict,
|
|
239
315
|
},
|
|
@@ -253,7 +329,12 @@ export default defineFlow({
|
|
|
253
329
|
profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
|
|
254
330
|
prompt(ctx) {
|
|
255
331
|
const outs = loadCtxOf(ctx);
|
|
256
|
-
return buildReviewPrompt("quality", {
|
|
332
|
+
return buildReviewPrompt("quality", {
|
|
333
|
+
base: outs.base ?? "origin/main",
|
|
334
|
+
specPath: outs.specPath ?? "",
|
|
335
|
+
since: incrementalSince(ctx, "quality"),
|
|
336
|
+
priorFindings: findingsOf(ctx, "quality"),
|
|
337
|
+
});
|
|
257
338
|
},
|
|
258
339
|
parse: parseVerdict,
|
|
259
340
|
},
|
|
@@ -350,7 +431,7 @@ export default defineFlow({
|
|
|
350
431
|
async run(ctx) {
|
|
351
432
|
const i = inputOf(ctx);
|
|
352
433
|
if (loadCtxOf(ctx).route === "nothing-to-finish") {
|
|
353
|
-
await writeResult(i
|
|
434
|
+
await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
|
|
354
435
|
return { route: "done", status: "nothing-to-finish" };
|
|
355
436
|
}
|
|
356
437
|
// Every fix node edited the working tree; without this the PR would be
|
|
@@ -362,7 +443,7 @@ export default defineFlow({
|
|
|
362
443
|
`nax-finish: ${i.feature}`,
|
|
363
444
|
`Automated finish of \`${i.feature}\`.`,
|
|
364
445
|
);
|
|
365
|
-
await writeResult(i
|
|
446
|
+
await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
|
|
366
447
|
return { route: "done", committed: sync.committed, ...r };
|
|
367
448
|
},
|
|
368
449
|
},
|
|
@@ -409,7 +490,7 @@ export default defineFlow({
|
|
|
409
490
|
escalationReason: reason,
|
|
410
491
|
findings: verdict?.findings ?? [],
|
|
411
492
|
};
|
|
412
|
-
await writeResult(i
|
|
493
|
+
await writeResult(i, result);
|
|
413
494
|
|
|
414
495
|
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
415
496
|
let url: string | undefined;
|
|
@@ -424,7 +505,7 @@ export default defineFlow({
|
|
|
424
505
|
} catch (err) {
|
|
425
506
|
deliveryError = String(err);
|
|
426
507
|
}
|
|
427
|
-
await writeResult(i
|
|
508
|
+
await writeResult(i, { ...result, url, deliveryError });
|
|
428
509
|
|
|
429
510
|
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
430
511
|
},
|
|
@@ -464,6 +545,24 @@ export default defineFlow({
|
|
|
464
545
|
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
465
546
|
},
|
|
466
547
|
{ from: "fix_gate", to: "commit_gate" },
|
|
467
|
-
|
|
548
|
+
// A gate fix that changed code goes back through the quality reviewer, not
|
|
549
|
+
// straight to the gates. The gate loop is the last one to edit the tree and
|
|
550
|
+
// was the only one whose edits nothing reviewed: `quality_gates` proves the
|
|
551
|
+
// repo's commands are green, which a bad fix can satisfy. Observed on
|
|
552
|
+
// rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
|
|
553
|
+
// copy-pasting an identical 4-line stub into each, and it shipped, because
|
|
554
|
+
// no reviewer ran after it. Re-entry costs one review per gate round; both
|
|
555
|
+
// loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
|
|
556
|
+
//
|
|
557
|
+
// `unchanged` skips it: with nothing committed there is no new diff to
|
|
558
|
+
// review, and re-running the reviewer on an identical tree would burn a
|
|
559
|
+
// turn to re-report what route_quality already called clean.
|
|
560
|
+
{
|
|
561
|
+
from: "commit_gate",
|
|
562
|
+
switch: {
|
|
563
|
+
on: "$.route",
|
|
564
|
+
cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
|
|
565
|
+
},
|
|
566
|
+
},
|
|
468
567
|
],
|
|
469
568
|
});
|
|
@@ -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,48 @@ const JSON_CONTRACT = [
|
|
|
310
312
|
"}",
|
|
311
313
|
].join("\n");
|
|
312
314
|
|
|
313
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Build the reviewer prompt.
|
|
317
|
+
*
|
|
318
|
+
* With `since` set this is a **re-review**: the same reviewer already read the
|
|
319
|
+
* whole branch and raised `priorFindings`, a fix was applied and committed, and
|
|
320
|
+
* the only new material is `since..HEAD`. Re-reading the full branch diff every
|
|
321
|
+
* round made reviews 58% of the flow's wall clock, most of it re-reading code an
|
|
322
|
+
* earlier round had already cleared. The narrowed round still has the full repo
|
|
323
|
+
* available — it is told to open whatever the fix touches — it just is not asked
|
|
324
|
+
* to re-derive a verdict on unchanged code.
|
|
325
|
+
*
|
|
326
|
+
* `since` is only ever supplied when exactly one commit separates the two
|
|
327
|
+
* reviews (see `incrementalSince`), so `since..HEAD` provably contains every
|
|
328
|
+
* change made since the previous verdict.
|
|
329
|
+
*/
|
|
330
|
+
export function buildReviewPrompt(
|
|
331
|
+
phase: "spec" | "quality",
|
|
332
|
+
args: { base: string; specPath: string; since?: string | null; priorFindings?: Finding[] },
|
|
333
|
+
): string {
|
|
314
334
|
const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
|
|
335
|
+
if (!args.since) {
|
|
336
|
+
return [
|
|
337
|
+
`You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
|
|
338
|
+
`The spec/requirements source is: ${args.specPath}. Read it in full.`,
|
|
339
|
+
`Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
|
|
340
|
+
WORKER_PROTOCOL,
|
|
341
|
+
dims,
|
|
342
|
+
CLASSIFIER,
|
|
343
|
+
JSON_CONTRACT,
|
|
344
|
+
].join("\n\n");
|
|
345
|
+
}
|
|
315
346
|
return [
|
|
316
|
-
`You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
|
|
317
|
-
`
|
|
318
|
-
`
|
|
347
|
+
`You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
|
|
348
|
+
`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.`,
|
|
349
|
+
`Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
|
|
350
|
+
`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:`,
|
|
351
|
+
[
|
|
352
|
+
"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.",
|
|
353
|
+
"2. **Broken?** Did the fix introduce a new problem, in the changed lines or in the unchanged code they now call into?",
|
|
354
|
+
"",
|
|
355
|
+
`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*.`,
|
|
356
|
+
].join("\n"),
|
|
319
357
|
WORKER_PROTOCOL,
|
|
320
358
|
dims,
|
|
321
359
|
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,
|
|
@@ -58,12 +58,35 @@ async function isDirty(repoRoot: string): Promise<boolean> {
|
|
|
58
58
|
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
59
|
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
60
|
*/
|
|
61
|
+
/** Current HEAD sha, or null outside a repo / on an unborn branch. */
|
|
62
|
+
async function headSha(repoRoot: string): Promise<string | null> {
|
|
63
|
+
const res = await _gitDeps.run(["git", "rev-parse", "HEAD"], { cwd: repoRoot });
|
|
64
|
+
return res.exitCode === 0 ? res.stdout.trim() || null : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Repo-root-relative paths touched by a commit.
|
|
69
|
+
*
|
|
70
|
+
* `--format=` suppresses the header so the output is just the file list.
|
|
71
|
+
* Failure yields `[]`, which the gate loop reads as "cannot tell what changed"
|
|
72
|
+
* and therefore reviews — see `partitionTestFiles`.
|
|
73
|
+
*/
|
|
74
|
+
export async function filesInCommit(repoRoot: string, sha: string): Promise<string[]> {
|
|
75
|
+
const res = await _gitDeps.run(["git", "show", "--name-only", "--format=", sha], { cwd: repoRoot });
|
|
76
|
+
if (res.exitCode !== 0) return [];
|
|
77
|
+
return res.stdout
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((l) => l.trim())
|
|
80
|
+
.filter((l) => l.length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
export async function commitFixes(
|
|
62
84
|
repoRoot: string,
|
|
63
85
|
message: string,
|
|
64
86
|
opts: { skipHooks?: boolean } = {},
|
|
65
|
-
): Promise<{ committed: boolean }> {
|
|
66
|
-
|
|
87
|
+
): Promise<{ committed: boolean; shaBefore: string | null; shaAfter: string | null }> {
|
|
88
|
+
const shaBefore = await headSha(repoRoot);
|
|
89
|
+
if (!(await isDirty(repoRoot))) return { committed: false, shaBefore, shaAfter: shaBefore };
|
|
67
90
|
|
|
68
91
|
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
69
92
|
if (add.exitCode !== 0) {
|
|
@@ -82,7 +105,7 @@ export async function commitFixes(
|
|
|
82
105
|
{ stage: "finish-git", repoRoot },
|
|
83
106
|
);
|
|
84
107
|
}
|
|
85
|
-
return { committed: true };
|
|
108
|
+
return { committed: true, shaBefore, shaAfter: await headSha(repoRoot) };
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
/**
|