@nathapp/nax 0.77.2 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nax.js +1823 -1024
- package/flows/nax-finish/commit-message.ts +90 -11
- package/flows/nax-finish/flow-ctx.ts +25 -5
- package/flows/nax-finish/nax-finish.flow.ts +87 -156
- package/flows/nax-finish/pr-template-merge.ts +253 -0
- package/flows/nax-finish/review-prompts.ts +6 -3
- package/flows/nax-finish/steps/commit-round.ts +64 -0
- package/flows/nax-finish/steps/context.ts +63 -7
- package/flows/nax-finish/steps/gates.ts +183 -0
- package/flows/nax-finish/steps/index.ts +3 -0
- package/flows/nax-finish/steps/pr-body.ts +70 -38
- package/flows/nax-finish/steps/review-round.ts +74 -0
- package/flows/nax-finish/types.ts +58 -0
- package/flows/nax-finish/verdict.ts +20 -3
- package/package.json +9 -5
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge nax's generated PR/MR body into the repository's own PR template.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `gh pr create --body` / `glab mr create --description` suppress the repo's
|
|
7
|
+
* template, so a generated body has to account for it. The first attempt
|
|
8
|
+
* appended the template verbatim after the generated content, which shipped a
|
|
9
|
+
* *blank form* below a filled one: placeholder comments, a dangling `Closes #`,
|
|
10
|
+
* duplicated headings, and an unchecked "`bun test` passes" box sitting under a
|
|
11
|
+
* Verification section that already said the gates were green (nax#1504).
|
|
12
|
+
*
|
|
13
|
+
* A PR template is an input form, not decoration. So the template is treated as
|
|
14
|
+
* **shape** and nax's content as **fill**:
|
|
15
|
+
*
|
|
16
|
+
* - a template heading nax can fill → keep the heading, replace its body
|
|
17
|
+
* - a template heading nax cannot → drop it (`merge`) or empty it (`strict`)
|
|
18
|
+
* - nax content with no home → append under nax's own heading
|
|
19
|
+
*
|
|
20
|
+
* The governing invariant is **never emit a field that was not filled**. That
|
|
21
|
+
* is the same rule `buildFinishBody` already followed for its own sections
|
|
22
|
+
* (nax#1477 forbids a bare heading with nothing under it); this module extends
|
|
23
|
+
* it to template-derived text. `strict` mode is the one deliberate exception,
|
|
24
|
+
* for repos whose CI asserts a set of headings exists.
|
|
25
|
+
*
|
|
26
|
+
* ## Why deterministic
|
|
27
|
+
*
|
|
28
|
+
* Placement is decided by a heading-alias table, not by a model. The facts in
|
|
29
|
+
* the body — gate results, story counts, diffstat, review rounds — stay a pure
|
|
30
|
+
* string join, which is what keeps a finish body greppable in PR history. A
|
|
31
|
+
* repo whose headings the table does not know loses nothing: its sections are
|
|
32
|
+
* dropped and nax's own headings are used instead, and `sectionMap` pins the
|
|
33
|
+
* mapping explicitly when a team wants its headings honoured.
|
|
34
|
+
*
|
|
35
|
+
* Lives under `flows/` (and is imported from `src/` via `@flows/*`, not
|
|
36
|
+
* re-implemented) because `flows/` is the more constrained runtime — acpx runs
|
|
37
|
+
* it in its own Node process where `Bun` and the `@/*` alias do not exist. Code
|
|
38
|
+
* that satisfies that constraint runs in both places; the reverse is not true.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** One nax-authored section of the body. */
|
|
42
|
+
export interface BodySection {
|
|
43
|
+
/**
|
|
44
|
+
* Stable id matched against the alias table. Independent of `heading` so
|
|
45
|
+
* renaming nax's own heading does not silently break template matching.
|
|
46
|
+
*/
|
|
47
|
+
key: string;
|
|
48
|
+
/**
|
|
49
|
+
* nax's H2 text, used when the section is appended rather than merged.
|
|
50
|
+
* Empty means headingless (the run footer) — such a section is rendered as
|
|
51
|
+
* bare text and is never matched to a template heading, so a stray alias
|
|
52
|
+
* cannot bury the footer under someone's `## Notes`.
|
|
53
|
+
*/
|
|
54
|
+
heading: string;
|
|
55
|
+
/** Markdown body without its heading line. Callers omit empty sections. */
|
|
56
|
+
body: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* - `merge` — template headings nax cannot fill are dropped. Default.
|
|
61
|
+
* - `strict` — they are kept, empty, for repos with heading-checking CI.
|
|
62
|
+
* - `ignore` — the template is not consulted at all.
|
|
63
|
+
*/
|
|
64
|
+
export type TemplateMode = "merge" | "strict" | "ignore";
|
|
65
|
+
|
|
66
|
+
export interface MergeOptions {
|
|
67
|
+
mode?: TemplateMode;
|
|
68
|
+
/**
|
|
69
|
+
* Normalised template heading → `BodySection.key`, layered over
|
|
70
|
+
* `DEFAULT_SECTION_ALIASES`. An empty value suppresses a default alias,
|
|
71
|
+
* which is how a repo says "do not put anything under this heading".
|
|
72
|
+
*/
|
|
73
|
+
sectionMap?: Record<string, string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalised heading → section key.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately partial. `why`, `notes`, `screenshots` and friends are absent
|
|
80
|
+
* because nax has nothing truthful to put under them — an alias that mapped
|
|
81
|
+
* them to some loosely-related section would reintroduce exactly the
|
|
82
|
+
* unfilled-field problem this module exists to remove.
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_SECTION_ALIASES: Record<string, string> = {
|
|
85
|
+
// → narrative
|
|
86
|
+
what: "narrative",
|
|
87
|
+
"what changed": "narrative",
|
|
88
|
+
"whats changed": "narrative",
|
|
89
|
+
summary: "narrative",
|
|
90
|
+
description: "narrative",
|
|
91
|
+
overview: "narrative",
|
|
92
|
+
changes: "narrative",
|
|
93
|
+
"what does this do": "narrative",
|
|
94
|
+
"what does this mr do and why": "narrative",
|
|
95
|
+
"what does this pr do": "narrative",
|
|
96
|
+
// → stories
|
|
97
|
+
how: "stories",
|
|
98
|
+
implementation: "stories",
|
|
99
|
+
"implementation details": "stories",
|
|
100
|
+
"changes made": "stories",
|
|
101
|
+
approach: "stories",
|
|
102
|
+
design: "stories",
|
|
103
|
+
// → verification
|
|
104
|
+
testing: "verification",
|
|
105
|
+
tests: "verification",
|
|
106
|
+
"test plan": "verification",
|
|
107
|
+
verification: "verification",
|
|
108
|
+
qa: "verification",
|
|
109
|
+
validation: "verification",
|
|
110
|
+
"how to test": "verification",
|
|
111
|
+
"how has this been tested": "verification",
|
|
112
|
+
"how to set up and validate locally": "verification",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const HEADING_RE = /^##[ \t]+(.+?)[ \t]*$/;
|
|
116
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
117
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
118
|
+
/** `Closes #`, `Fixes # (issue)` — an issue reference with no issue. */
|
|
119
|
+
const DANGLING_ISSUE_RE = /^[ \t]*(?:closes?|fixe?s?|resolves?)[ \t]*:?[ \t]*#[ \t]*(?:\([^)]*\))?[ \t]*$/i;
|
|
120
|
+
/** An unticked task-list item — an unfilled field wherever it appears. */
|
|
121
|
+
const UNCHECKED_BOX_RE = /^[ \t]*[-*+][ \t]+\[[ \t]\]/;
|
|
122
|
+
|
|
123
|
+
interface TemplateSection {
|
|
124
|
+
heading: string;
|
|
125
|
+
body: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ParsedTemplate {
|
|
129
|
+
frontmatter: string;
|
|
130
|
+
preamble: string;
|
|
131
|
+
sections: TemplateSection[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Lowercase, drop punctuation, collapse whitespace — so `## Testing:` matches `testing`. */
|
|
135
|
+
function normalizeHeading(heading: string): string {
|
|
136
|
+
return heading
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
139
|
+
.replace(/\s+/g, " ")
|
|
140
|
+
.trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Strip placeholders from template-derived prose.
|
|
145
|
+
*
|
|
146
|
+
* Only ever applied to the preamble — the one template region that survives
|
|
147
|
+
* into the body. Text under a matched heading is replaced wholesale and text
|
|
148
|
+
* under an unmatched one is discarded, so a checklist or a stale `Closes #`
|
|
149
|
+
* *inside a section* never reaches this function, which is why there is no
|
|
150
|
+
* checkbox-versus-gate reconciliation anywhere in this module.
|
|
151
|
+
*
|
|
152
|
+
* The preamble is the exception, because a template may open with a
|
|
153
|
+
* contributor checklist before its first heading. An unticked box there is an
|
|
154
|
+
* unfilled field like any other, so it is dropped while the prose around it is
|
|
155
|
+
* kept.
|
|
156
|
+
*/
|
|
157
|
+
function cleanTemplateText(text: string): string {
|
|
158
|
+
return text
|
|
159
|
+
.replace(HTML_COMMENT_RE, "")
|
|
160
|
+
.split("\n")
|
|
161
|
+
.filter((line) => !DANGLING_ISSUE_RE.test(line) && !UNCHECKED_BOX_RE.test(line))
|
|
162
|
+
.map((line) => line.trimEnd())
|
|
163
|
+
.join("\n")
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseTemplate(rawText: string): ParsedTemplate {
|
|
168
|
+
// Normalised up front so a CRLF template (anything authored on Windows, or
|
|
169
|
+
// fetched through a forge web editor) cannot leak a stray carriage return
|
|
170
|
+
// into a heading this module re-emits.
|
|
171
|
+
const text = rawText.replace(/\r\n/g, "\n");
|
|
172
|
+
const frontmatterMatch = FRONTMATTER_RE.exec(text);
|
|
173
|
+
const frontmatter = frontmatterMatch ? frontmatterMatch[0].trimEnd() : "";
|
|
174
|
+
const rest = frontmatterMatch ? text.slice(frontmatterMatch[0].length) : text;
|
|
175
|
+
|
|
176
|
+
const preambleLines: string[] = [];
|
|
177
|
+
const sections: TemplateSection[] = [];
|
|
178
|
+
let current: { heading: string; lines: string[] } | null = null;
|
|
179
|
+
|
|
180
|
+
for (const line of rest.split("\n")) {
|
|
181
|
+
const heading = HEADING_RE.exec(line);
|
|
182
|
+
if (heading) {
|
|
183
|
+
if (current) sections.push({ heading: current.heading, body: current.lines.join("\n") });
|
|
184
|
+
current = { heading: heading[1], lines: [] };
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (current) current.lines.push(line);
|
|
188
|
+
else preambleLines.push(line);
|
|
189
|
+
}
|
|
190
|
+
if (current) sections.push({ heading: current.heading, body: current.lines.join("\n") });
|
|
191
|
+
|
|
192
|
+
return { frontmatter, preamble: preambleLines.join("\n"), sections };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderSection(heading: string, body: string): string {
|
|
196
|
+
if (heading.length === 0) return body;
|
|
197
|
+
return body.length === 0 ? `## ${heading}` : `## ${heading}\n\n${body}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Nax-only body: every section under its own heading, in the order given. */
|
|
201
|
+
function renderSections(sections: BodySection[]): string {
|
|
202
|
+
return sections
|
|
203
|
+
.filter((s) => s.body.trim().length > 0)
|
|
204
|
+
.map((s) => renderSection(s.heading, s.body.trim()))
|
|
205
|
+
.join("\n\n")
|
|
206
|
+
.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function mergeTemplate(
|
|
210
|
+
template: string | null | undefined,
|
|
211
|
+
sections: BodySection[],
|
|
212
|
+
opts: MergeOptions = {},
|
|
213
|
+
): string {
|
|
214
|
+
const mode = opts.mode ?? "merge";
|
|
215
|
+
if (mode === "ignore" || !template || template.trim().length === 0) return renderSections(sections);
|
|
216
|
+
|
|
217
|
+
const parsed = parseTemplate(template);
|
|
218
|
+
// No H2 anywhere: the template is prose, or nests everything under H1/H3.
|
|
219
|
+
// There is no shape to merge into, and appending it unparsed is the defect
|
|
220
|
+
// this module removes — so fall back to the body nax would have written.
|
|
221
|
+
if (parsed.sections.length === 0) return renderSections(sections);
|
|
222
|
+
|
|
223
|
+
// Override keys go through the same normalisation as the template headings
|
|
224
|
+
// they are matched against, so a repo pins a heading by pasting it —
|
|
225
|
+
// `"What does this MR do and why?"` — not by hand-normalising it first.
|
|
226
|
+
const aliases = { ...DEFAULT_SECTION_ALIASES };
|
|
227
|
+
for (const [heading, key] of Object.entries(opts.sectionMap ?? {})) aliases[normalizeHeading(heading)] = key;
|
|
228
|
+
const fillable = sections.filter((s) => s.heading.length > 0 && s.body.trim().length > 0);
|
|
229
|
+
const consumed = new Set<string>();
|
|
230
|
+
const parts: string[] = [];
|
|
231
|
+
|
|
232
|
+
if (parsed.frontmatter.length > 0) parts.push(parsed.frontmatter);
|
|
233
|
+
const preamble = cleanTemplateText(parsed.preamble);
|
|
234
|
+
if (preamble.length > 0) parts.push(preamble);
|
|
235
|
+
|
|
236
|
+
for (const templateSection of parsed.sections) {
|
|
237
|
+
const key = aliases[normalizeHeading(templateSection.heading)];
|
|
238
|
+
const match = key ? fillable.find((s) => s.key === key && !consumed.has(s.key)) : undefined;
|
|
239
|
+
if (match) {
|
|
240
|
+
consumed.add(match.key);
|
|
241
|
+
parts.push(renderSection(templateSection.heading, match.body.trim()));
|
|
242
|
+
} else if (mode === "strict") {
|
|
243
|
+
parts.push(renderSection(templateSection.heading, ""));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const section of sections) {
|
|
248
|
+
if (consumed.has(section.key) || section.body.trim().length === 0) continue;
|
|
249
|
+
parts.push(renderSection(section.heading, section.body.trim()));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return parts.join("\n\n").trim();
|
|
253
|
+
}
|
|
@@ -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",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembling the audit round a `commit_<phase>` node records.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `nax-finish.flow.ts` for room — that file sits against the
|
|
5
|
+
* 600-line source cap — but the split earns its keep independently: the round's
|
|
6
|
+
* shape is a data decision with four conditional fields, and it is now unit
|
|
7
|
+
* testable without driving a flow node through a git mock.
|
|
8
|
+
*
|
|
9
|
+
* Pairs with `./review-round`, which records the rounds that produce no commit.
|
|
10
|
+
*/
|
|
11
|
+
import type { Finding, FinishPhase, FinishRound, FinishRoundOutcome } from "../types";
|
|
12
|
+
|
|
13
|
+
/** Phases that own a reviewer node; every other phase's round has nobody behind it. */
|
|
14
|
+
const REVIEWED_PHASES: FinishPhase[] = ["spec", "quality"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What produced this round, given the successor the commit routed to.
|
|
18
|
+
*
|
|
19
|
+
* The `route` argument is why this is computed after the commit rather than
|
|
20
|
+
* alongside it: `tests-only` is only known once the committed paths have been
|
|
21
|
+
* classified, and it is the difference between "no reviewer exists for this
|
|
22
|
+
* phase" and "a reviewer exists, was owed a look, and was skipped".
|
|
23
|
+
*/
|
|
24
|
+
export function commitRoundOutcome(phase: FinishPhase, route: string): FinishRoundOutcome {
|
|
25
|
+
if (REVIEWED_PHASES.includes(phase)) return "fixed";
|
|
26
|
+
// Only `gate` can skip an owed re-review; `acceptance` has no reviewer to
|
|
27
|
+
// skip, so its `tests-only`-shaped routes (it has none today) stay honest.
|
|
28
|
+
if (phase === "gate" && route === "tests-only") return "review-skipped";
|
|
29
|
+
return "no-reviewer";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommitRoundInput {
|
|
33
|
+
phase: FinishPhase;
|
|
34
|
+
attempt: number;
|
|
35
|
+
committed: boolean;
|
|
36
|
+
/** The successor this commit routed to — see `commitRoundOutcome`. */
|
|
37
|
+
route: string;
|
|
38
|
+
findings: Finding[];
|
|
39
|
+
/** Gate commands that were red this round; omitted for non-gate phases. */
|
|
40
|
+
failing?: string[];
|
|
41
|
+
/** Post-commit HEAD, when there was a commit. */
|
|
42
|
+
shaAfter?: string | null;
|
|
43
|
+
now: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the round record for a commit checkpoint.
|
|
48
|
+
*
|
|
49
|
+
* `sha` and `failing` are omitted rather than set to null/undefined: a reader of
|
|
50
|
+
* the JSONL distinguishes "no commit" from "record lost" by the key's absence,
|
|
51
|
+
* and that only works if absence is never used to mean anything else.
|
|
52
|
+
*/
|
|
53
|
+
export function buildCommitRound(i: CommitRoundInput): FinishRound {
|
|
54
|
+
return {
|
|
55
|
+
ts: i.now,
|
|
56
|
+
phase: i.phase,
|
|
57
|
+
attempt: i.attempt,
|
|
58
|
+
committed: i.committed,
|
|
59
|
+
outcome: commitRoundOutcome(i.phase, i.route),
|
|
60
|
+
findings: i.findings,
|
|
61
|
+
...(i.failing ? { failing: i.failing } : {}),
|
|
62
|
+
...(i.committed && i.shaAfter ? { sha: i.shaAfter } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export * from "./context";
|
|
2
2
|
export * from "./acceptance";
|
|
3
3
|
export * from "./quality";
|
|
4
|
+
export * from "./gates";
|
|
4
5
|
export * from "./escalate";
|
|
5
6
|
export * from "./forge";
|
|
6
7
|
export * from "./git";
|
|
7
8
|
export * from "./pr";
|
|
8
9
|
export * from "./pr-narrative";
|
|
10
|
+
export * from "./commit-round";
|
|
9
11
|
export * from "./result";
|
|
12
|
+
export * from "./review-round";
|