@nathapp/nax 0.80.1 → 0.81.0-canary.2

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.
@@ -1,253 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * Repository PR/MR template discovery, ported from
3
- * `src/plugins/builtin/auto-pr/template.ts`.
4
- *
5
- * Ported rather than imported: `flows/` is loaded by acpx in its own Node
6
- * process, where nax's `src/` and its `@/*` alias do not exist. This matches
7
- * the convention already in this directory — `errors.ts`, `exec.ts`, `types.ts`
8
- * and the PR body builder are all flow-local re-implementations.
9
- *
10
- * The duplication is stable: these candidate paths are an external convention
11
- * set by GitHub and GitLab, not internal logic that drifts with the codebase.
12
- *
13
- * Why preserve-not-fill: passing `--body` / `--description` to `gh` / `glab`
14
- * suppresses the repo's default template, so it must be read and re-embedded.
15
- */
16
- import { join } from "node:path";
17
- import type { Forge } from "./steps/forge";
18
-
19
- /**
20
- * Candidate template paths for GitHub, in priority order.
21
- * Multi-template directories (`PULL_REQUEST_TEMPLATE/`) are intentionally
22
- * skipped because they are ambiguous unattended.
23
- */
24
- const GITHUB_TEMPLATE_PATHS: readonly string[] = [
25
- ".github/PULL_REQUEST_TEMPLATE.md",
26
- ".github/pull_request_template.md",
27
- "PULL_REQUEST_TEMPLATE.md",
28
- "docs/PULL_REQUEST_TEMPLATE.md",
29
- ] as const;
30
-
31
- /** Preferred single-template location for GitLab. */
32
- const GITLAB_DEFAULT_TEMPLATE_PATH = ".gitlab/merge_request_templates/Default.md";
33
-
34
- /** Only `readText` is consulted, so any caller with a file reader can supply it. */
35
- export interface TemplateDeps {
36
- readText: (path: string) => Promise<string | null>;
37
- }
38
-
39
- async function firstExisting(workdir: string, deps: TemplateDeps, paths: readonly string[]): Promise<string | null> {
40
- for (const relPath of paths) {
41
- const content = await deps.readText(join(workdir, relPath));
42
- if (content !== null) return content;
43
- }
44
- return null;
45
- }
46
-
47
- /**
48
- * Locate the PR/MR template for the current repository.
49
- *
50
- * @returns Template text verbatim, or `null` when none resolves — which is the
51
- * common case and never an error.
52
- */
53
- export async function findPrTemplate(workdir: string, forge: Forge, deps: TemplateDeps): Promise<string | null> {
54
- if (forge === "github") return firstExisting(workdir, deps, GITHUB_TEMPLATE_PATHS);
55
- return firstExisting(workdir, deps, [GITLAB_DEFAULT_TEMPLATE_PATH]);
56
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * The PR title — sentinel, sanitiser, and the fallback chain.
3
- *
4
- * `buildFinishTitle` used to return `feat: <feature>` unconditionally, so every
5
- * finish-opened PR was titled with its feature slug: `feat: schema-drift-gate`
6
- * describes the run, not the change. The narrative node has already read the
7
- * whole diff by the time the body is amended, so a real conventional-commit
8
- * subject costs one extra sentinel in a prompt that was being sent anyway.
9
- *
10
- * No deterministic source can replace it. The spec's H1 is the slug in prose
11
- * (`# SPEC: Schema drift gate`), the PRD carries no feature-level title, and
12
- * concatenating story titles reads worse than the slug it replaces — which is
13
- * why this is the one part of the PR metadata that is model-derived, and why
14
- * everything below assumes the model may return junk.
15
- *
16
- * Lives beside `narrative.ts` rather than in `src/prompts/builders/` for the
17
- * same reason that file gives: `flows/` runs in acpx's Node process and imports
18
- * nothing from `src/`.
19
- */
20
-
21
- /** Sentinel wrapping the title. See `narrative.ts` for why a delimiter is required at all. */
22
- export const TITLE_OPEN_TAG = "<title>";
23
- export const TITLE_CLOSE_TAG = "</title>";
24
-
25
- /**
26
- * Longest title rendered onto a PR.
27
- *
28
- * 72 is the conventional-commit subject norm, and GitHub truncates around this
29
- * width in list views.
30
- */
31
- export const TITLE_MAX_CHARS = 72;
32
-
33
- /**
34
- * Conventional-commit prefix, split into the type-with-scope and the subject.
35
- *
36
- * Types mirror the list in `.claude/rules/project-conventions.md`, plus
37
- * `revert`. Captured rather than merely tested so the two halves can be
38
- * rejoined with exactly one space — `feat:no space` and `feat:` both reach
39
- * here, and testing alone let the latter become `feat: feat:`.
40
- *
41
- * A title arriving without any prefix is prefixed rather than rejected: the
42
- * prose is usually right even when the model forgets the ceremony.
43
- */
44
- const CONVENTIONAL_PREFIX_RE =
45
- /^((?:feat|fix|refactor|perf|docs|test|chore|ci|build|style|revert)(?:\([^)]*\))?!?):\s*([\s\S]*)$/i;
46
-
47
- const DEFAULT_TYPE = "feat";
48
-
49
- /** Wrapping quotes/backticks the model adds when it treats the title as a quoted string. */
50
- const WRAPPING_CHARS = new Set(['"', "'", "`", "*", "_"]);
51
-
52
- function stripWrapping(text: string): string {
53
- let out = text;
54
- // Loop: models nest these ("`fix: thing`" arrives quoted *and* fenced).
55
- while (out.length >= 2) {
56
- const first = out[0];
57
- const last = out[out.length - 1];
58
- if (first !== undefined && first === last && WRAPPING_CHARS.has(first)) {
59
- out = out.slice(1, -1).trim();
60
- continue;
61
- }
62
- break;
63
- }
64
- return out;
65
- }
66
-
67
- /**
68
- * Cut to `TITLE_MAX_CHARS` on a word boundary where one is available.
69
- *
70
- * A mid-word cut reads as corruption rather than brevity; falling back to a
71
- * hard slice only matters for a title with no spaces at all.
72
- */
73
- function clamp(text: string): string {
74
- if (text.length <= TITLE_MAX_CHARS) return text;
75
- const cut = text.slice(0, TITLE_MAX_CHARS);
76
- const lastSpace = cut.lastIndexOf(" ");
77
- // Guard against a long type prefix eating the whole budget: only honour a
78
- // word boundary that leaves a meaningful subject behind.
79
- const MIN_KEEP = 20;
80
- return (lastSpace >= MIN_KEEP ? cut.slice(0, lastSpace) : cut).trimEnd();
81
- }
82
-
83
- /**
84
- * Normalise a model-supplied title, or `undefined` if nothing usable survives.
85
- *
86
- * Never throws — this feeds `parse` on an acp node, and the flow's PR is
87
- * already open by the time it runs.
88
- */
89
- export function sanitizeTitle(raw: string | undefined): string | undefined {
90
- if (typeof raw !== "string") return undefined;
91
-
92
- // First non-empty line: a title is single-line by definition, and a model
93
- // that adds a rationale below it must not push that onto the PR.
94
- const firstLine = raw.split(/\r?\n/).find((line) => line.trim().length > 0);
95
- if (firstLine === undefined) return undefined;
96
-
97
- // Collapse internal runs of whitespace before measuring, so the length cap
98
- // reflects what a reader sees.
99
- let title = stripWrapping(firstLine.trim()).replace(/\s+/g, " ");
100
- // Markdown heading marks, for a model that answers the "write a title" ask
101
- // with a heading.
102
- title = title.replace(/^#+\s*/, "").trim();
103
- title = stripWrapping(title);
104
- // Trailing sentence punctuation — conventional-commit subjects carry none.
105
- title = title.replace(/[.\s]+$/, "");
106
- if (!title) return undefined;
107
-
108
- const match = CONVENTIONAL_PREFIX_RE.exec(title);
109
- const type = match?.[1] ?? DEFAULT_TYPE;
110
- const subject = (match?.[2] ?? title).trim();
111
- // A bare `feat:` carries no subject, and a type alone is not a title.
112
- if (!subject) return undefined;
113
-
114
- return clamp(`${type}: ${subject}`);
115
- }
116
-
117
- /**
118
- * Extract the title from the narrative node's reply.
119
- *
120
- * Last opening tag wins, mirroring `parseNarrative` — a model that narrates the
121
- * tag before emitting it must not beat the real one.
122
- */
123
- export function parseTitle(text: string): string | undefined {
124
- if (typeof text !== "string") return undefined;
125
- const open = text.lastIndexOf(TITLE_OPEN_TAG);
126
- if (open === -1) return undefined;
127
- const from = open + TITLE_OPEN_TAG.length;
128
- const close = text.indexOf(TITLE_CLOSE_TAG, from);
129
- return sanitizeTitle(close === -1 ? text.slice(from) : text.slice(from, close));
130
- }
131
-
132
- /**
133
- * The title to render, best source first.
134
- *
135
- * `feat: <feature>` remains the floor: it is what shipped before, it is what
136
- * the auto-PR plugin opens with, and it is always available.
137
- */
138
- export function resolveTitle(agentTitle: string | undefined, feature: string): string {
139
- return sanitizeTitle(agentTitle) ?? `${DEFAULT_TYPE}: ${feature}`;
140
- }