@nathapp/nax 0.77.2 → 0.77.3
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 +876 -419
- package/flows/nax-finish/commit-message.ts +90 -11
- package/flows/nax-finish/nax-finish.flow.ts +26 -21
- package/flows/nax-finish/pr-template-merge.ts +253 -0
- package/flows/nax-finish/steps/commit-round.ts +64 -0
- package/flows/nax-finish/steps/index.ts +2 -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 +2 -2
|
@@ -23,10 +23,74 @@ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
|
|
|
23
23
|
/** How much gate output to quote in the body before it stops being a commit message. */
|
|
24
24
|
const MAX_GATE_OUTPUT_LINES = 20;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Markers a test runner uses to introduce a failing case, worst-supported-first.
|
|
28
|
+
*
|
|
29
|
+
* A heuristic, deliberately: nax orchestrates polyglot repos, so this cannot be
|
|
30
|
+
* one runner's format. Each entry is the literal token that precedes the test's
|
|
31
|
+
* name — bun/jest `(fail)`, go `--- FAIL:`, pytest `FAILED`, and the tick-style
|
|
32
|
+
* reporters. Nothing downstream depends on a match; a miss just falls back to
|
|
33
|
+
* the output tail, which is what shipped before.
|
|
34
|
+
*/
|
|
35
|
+
const FAILURE_MARKERS = ["(fail)", "--- FAIL:", "FAILED ", "FAIL ", "✗ ", "× "];
|
|
36
|
+
|
|
37
|
+
/** How many failing test names to name before the message stops being a commit message. */
|
|
38
|
+
const MAX_NAMED_FAILURES = 10;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strip machine-local filesystem layout out of text bound for shipped history.
|
|
42
|
+
*
|
|
43
|
+
* Two passes, because the two cases differ: a path under the repo is meaningful
|
|
44
|
+
* once made relative, while a path outside it is noise no reader of the commit
|
|
45
|
+
* can act on. The home-directory pattern catches what remains — runner output
|
|
46
|
+
* routinely quotes absolute paths from outside the repo (caches, toolchains).
|
|
47
|
+
*/
|
|
48
|
+
function redactPaths(text: string, workdir?: string): string {
|
|
49
|
+
const withoutRepo = workdir ? text.split(`${workdir}/`).join("") : text;
|
|
50
|
+
return withoutRepo.replace(/(?:\/Users\/|\/home\/)[^/\s)]+\//g, "~/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The names of the tests that actually failed, in output order.
|
|
55
|
+
*
|
|
56
|
+
* This is the whole point of the change: the body used to be the last 20 lines
|
|
57
|
+
* of runner stdout, and a suite whose *passing* tests write to stderr pushes the
|
|
58
|
+
* real failure out of that window — so the commit named a stack trace from a
|
|
59
|
+
* test that passed (#1506).
|
|
60
|
+
*/
|
|
61
|
+
function failingTestNames(output: string): string[] {
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
for (const line of output.split("\n")) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
const marker = FAILURE_MARKERS.find((m) => trimmed.startsWith(m));
|
|
66
|
+
if (!marker) continue;
|
|
67
|
+
// Drop bun's trailing `[0.12ms]` timing — it is noise in a commit message
|
|
68
|
+
// and makes otherwise-identical messages differ between runs.
|
|
69
|
+
const name = trimmed
|
|
70
|
+
.slice(marker.length)
|
|
71
|
+
.replace(/\s*\[[\d.]+m?s\]$/, "")
|
|
72
|
+
.trim();
|
|
73
|
+
if (name) names.push(name);
|
|
74
|
+
}
|
|
75
|
+
// Say so when the list is cut short. A bare list of ten reads as "ten tests
|
|
76
|
+
// failed", and a reader who acts on that count is acting on a truncation.
|
|
77
|
+
if (names.length > MAX_NAMED_FAILURES) {
|
|
78
|
+
const dropped = names.length - MAX_NAMED_FAILURES;
|
|
79
|
+
return [...names.slice(0, MAX_NAMED_FAILURES), `...and ${dropped} more failing test(s)`];
|
|
80
|
+
}
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
|
|
26
84
|
interface MessageCtx {
|
|
27
85
|
outputs: Record<string, unknown>;
|
|
28
86
|
}
|
|
29
87
|
|
|
88
|
+
/** Options carrying what the message builder cannot read off `ctx.outputs`. */
|
|
89
|
+
interface MessageOptions {
|
|
90
|
+
/** Absolute repo root, used to rewrite quoted paths as repo-relative. */
|
|
91
|
+
workdir?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
interface PhaseOutputs {
|
|
31
95
|
findings?: Finding[];
|
|
32
96
|
failing?: string[];
|
|
@@ -88,20 +152,30 @@ function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
|
|
|
88
152
|
return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
|
|
89
153
|
}
|
|
90
154
|
|
|
91
|
-
|
|
155
|
+
/**
|
|
156
|
+
* What to quote from a runner's output: the failing test names if they can be
|
|
157
|
+
* identified, otherwise the tail, as before.
|
|
158
|
+
*
|
|
159
|
+
* Never both. Naming the failures *and* pasting the tail reproduces the noise
|
|
160
|
+
* this replaces, and the tail is the weaker signal whenever the names exist.
|
|
161
|
+
*/
|
|
162
|
+
function runnerEvidence(output: string, opts: MessageOptions): string {
|
|
163
|
+
const clean = redactPaths(output, opts.workdir).trim();
|
|
164
|
+
const names = failingTestNames(clean);
|
|
165
|
+
if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
|
|
166
|
+
return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
|
|
92
170
|
if (phase === "gate") {
|
|
93
171
|
const gate = outputsFor(ctx, "quality_gates");
|
|
94
172
|
const failing = gate.failing ?? [];
|
|
95
|
-
const
|
|
96
|
-
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(
|
|
173
|
+
const evidence = runnerEvidence(gate.output ?? "", opts);
|
|
174
|
+
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
|
|
97
175
|
}
|
|
98
176
|
if (phase === "acceptance") {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
.split("\n")
|
|
102
|
-
.slice(-MAX_GATE_OUTPUT_LINES)
|
|
103
|
-
.join("\n");
|
|
104
|
-
return tail ? [tail] : [];
|
|
177
|
+
const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
|
|
178
|
+
return evidence ? [evidence] : [];
|
|
105
179
|
}
|
|
106
180
|
const findings = findingsFor(ctx, phase);
|
|
107
181
|
if (findings.length === 0) return [];
|
|
@@ -129,8 +203,13 @@ function phaseLabel(phase: FinishPhase): string {
|
|
|
129
203
|
* described is still a commit that must happen — failing here would strand the
|
|
130
204
|
* fix uncommitted and reintroduce the stale-diff bug (#1397).
|
|
131
205
|
*/
|
|
132
|
-
export function buildFixCommitMessage(
|
|
206
|
+
export function buildFixCommitMessage(
|
|
207
|
+
phase: FinishPhase,
|
|
208
|
+
feature: string,
|
|
209
|
+
ctx: MessageCtx,
|
|
210
|
+
opts: MessageOptions = {},
|
|
211
|
+
): string {
|
|
133
212
|
const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
|
|
134
|
-
const body = bodyFor(phase, ctx);
|
|
213
|
+
const body = bodyFor(phase, ctx, opts);
|
|
135
214
|
return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
|
|
136
215
|
}
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* - `commit_gate` re-enters `review_quality` when its fix touched non-test code
|
|
35
35
|
* — the gate loop was previously the one editing loop whose output only ever
|
|
36
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.
|
|
37
|
+
* cost tradeoff; see `gateCommitRoute` for why that is a known hole. The skip
|
|
38
|
+
* records `review-skipped`, so it is visible in the audit.
|
|
38
39
|
* - Every `commit_*` node appends its round to the finish-audit trail as it
|
|
39
40
|
* happens, rather than a terminal node reconstructing them from
|
|
40
41
|
* `ctx.state.steps`. Appending live is what makes the trail survive a flow
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
_contextDeps,
|
|
51
52
|
amendPrBodyNode,
|
|
52
53
|
appendRound,
|
|
54
|
+
buildCommitRound,
|
|
53
55
|
buildEscalationComment,
|
|
54
56
|
commitAndPush,
|
|
55
57
|
commitFixes,
|
|
@@ -63,6 +65,7 @@ import {
|
|
|
63
65
|
postEscalation,
|
|
64
66
|
preflight,
|
|
65
67
|
resolveFeature,
|
|
68
|
+
routeReviewAndRecord,
|
|
66
69
|
runAcceptanceGate,
|
|
67
70
|
runQualityGates,
|
|
68
71
|
writeResult,
|
|
@@ -70,7 +73,7 @@ import {
|
|
|
70
73
|
import type { Forge } from "./steps/forge";
|
|
71
74
|
import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
|
|
72
75
|
import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
73
|
-
import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount
|
|
76
|
+
import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* Disabled only on an explicit "0". An unset variable means enabled, so a flow
|
|
@@ -153,7 +156,7 @@ async function acceptanceGateNode(ctx: {
|
|
|
153
156
|
* The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
|
|
154
157
|
* test-only — 8 copy-pasted stubs across 3 test files — so this route would
|
|
155
158
|
* not have caught it. Widen it here if test-quality regressions start
|
|
156
|
-
* shipping.
|
|
159
|
+
* shipping — the audit's `review-skipped` rounds are the evidence.
|
|
157
160
|
* - `changed` — production code was touched, or the paths could not be
|
|
158
161
|
* classified at all. "Cannot classify" reviews rather than skips.
|
|
159
162
|
*/
|
|
@@ -202,30 +205,32 @@ function commitFixNode(phase: FinishPhase) {
|
|
|
202
205
|
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
203
206
|
const { committed, shaBefore, shaAfter } = await commitFixes(
|
|
204
207
|
i.workdir,
|
|
205
|
-
buildFixCommitMessage(phase, i.feature, messageCtx),
|
|
208
|
+
buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir }),
|
|
206
209
|
{ skipHooks: true },
|
|
207
210
|
);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
211
|
+
// Routed BEFORE the round is recorded: `buildCommitRound` needs the
|
|
212
|
+
// successor to tell an owed-but-skipped re-review from a phase that never
|
|
213
|
+
// had a reviewer. Only `commit_gate` routes on this; the other phases have
|
|
214
|
+
// unconditional edges and ignore it.
|
|
223
215
|
const route =
|
|
224
216
|
phase === "gate"
|
|
225
217
|
? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
|
|
226
218
|
: committed
|
|
227
219
|
? "changed"
|
|
228
220
|
: "unchanged";
|
|
221
|
+
await appendRound(
|
|
222
|
+
i,
|
|
223
|
+
buildCommitRound({
|
|
224
|
+
phase,
|
|
225
|
+
attempt: fixAttemptCount(ctx, `fix_${phase}`),
|
|
226
|
+
committed,
|
|
227
|
+
route,
|
|
228
|
+
findings: findingsOf(ctx, phase),
|
|
229
|
+
failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
|
|
230
|
+
shaAfter,
|
|
231
|
+
now: new Date().toISOString(),
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
229
234
|
return { committed, route, shaBefore, shaAfter };
|
|
230
235
|
},
|
|
231
236
|
};
|
|
@@ -286,7 +291,7 @@ export default defineFlow({
|
|
|
286
291
|
},
|
|
287
292
|
route_spec: {
|
|
288
293
|
nodeType: "compute",
|
|
289
|
-
run: (ctx) =>
|
|
294
|
+
run: (ctx) => routeReviewAndRecord(ctx, "spec"),
|
|
290
295
|
},
|
|
291
296
|
fix_spec: {
|
|
292
297
|
nodeType: "acp",
|
|
@@ -312,7 +317,7 @@ export default defineFlow({
|
|
|
312
317
|
},
|
|
313
318
|
route_quality: {
|
|
314
319
|
nodeType: "compute",
|
|
315
|
-
run: (ctx) =>
|
|
320
|
+
run: (ctx) => routeReviewAndRecord(ctx, "quality"),
|
|
316
321
|
},
|
|
317
322
|
fix_quality: {
|
|
318
323
|
nodeType: "acp",
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|