@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.
- package/dist/nax.js +44737 -42009
- package/package.json +6 -6
- package/flows/nax-finish/commit-message.ts +0 -239
- package/flows/nax-finish/errors.ts +0 -23
- package/flows/nax-finish/exec.ts +0 -135
- package/flows/nax-finish/findings-parse.ts +0 -150
- package/flows/nax-finish/flow-ctx.ts +0 -150
- package/flows/nax-finish/narrative.ts +0 -215
- package/flows/nax-finish/nax-finish.flow.ts +0 -566
- package/flows/nax-finish/pr-template-merge.ts +0 -253
- package/flows/nax-finish/pr-template.ts +0 -56
- package/flows/nax-finish/pr-title.ts +0 -140
- package/flows/nax-finish/review-prompts.ts +0 -468
- package/flows/nax-finish/steps/acceptance.ts +0 -76
- package/flows/nax-finish/steps/commit-round.ts +0 -67
- package/flows/nax-finish/steps/context.ts +0 -158
- package/flows/nax-finish/steps/escalate.ts +0 -93
- package/flows/nax-finish/steps/forge.ts +0 -93
- package/flows/nax-finish/steps/gates.ts +0 -183
- package/flows/nax-finish/steps/git.ts +0 -130
- package/flows/nax-finish/steps/index.ts +0 -13
- package/flows/nax-finish/steps/pr-body.ts +0 -462
- package/flows/nax-finish/steps/pr-narrative.ts +0 -46
- package/flows/nax-finish/steps/pr.ts +0 -116
- package/flows/nax-finish/steps/quality.ts +0 -102
- package/flows/nax-finish/steps/result.ts +0 -112
- package/flows/nax-finish/steps/review-audit.ts +0 -91
- package/flows/nax-finish/steps/review-round.ts +0 -109
- package/flows/nax-finish/types.ts +0 -260
- package/flows/nax-finish/verdict.ts +0 -210
|
@@ -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
|
-
}
|