@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,150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Readers over an acpx `FlowNodeContext` — the flow graph's view of its own
|
|
3
|
-
* state.
|
|
4
|
-
*
|
|
5
|
-
* Split out of `nax-finish.flow.ts` (600-line source cap). These are all pure
|
|
6
|
-
* functions of `ctx.input` / `ctx.outputs` / `ctx.state.steps`; anything that
|
|
7
|
-
* shells out lives under `./steps/`.
|
|
8
|
-
*
|
|
9
|
-
* Two views of the same run, and the difference matters in a graph whose whole
|
|
10
|
-
* shape is loops:
|
|
11
|
-
*
|
|
12
|
-
* - `ctx.outputs` is a map keyed by node id, so it holds only each node's
|
|
13
|
-
* **latest** output. A node re-entering a loop cannot see its own previous
|
|
14
|
-
* round there.
|
|
15
|
-
* - `ctx.state.steps` is the ordered history and carries every step's `output`,
|
|
16
|
-
* so an earlier round IS recoverable from it. A step is appended on its
|
|
17
|
-
* *outcome*, so the currently-executing node is never in this list — which is
|
|
18
|
-
* what lets `incrementalSince` find the *previous* review rather than itself.
|
|
19
|
-
*/
|
|
20
|
-
import type { AcceptanceStatus } from "./steps/context";
|
|
21
|
-
import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
|
|
22
|
-
|
|
23
|
-
/** Minimal shapes so each reader takes only the part of the context it reads. */
|
|
24
|
-
export interface StepsCtx {
|
|
25
|
-
state: { steps: { nodeId: string; output?: unknown }[] };
|
|
26
|
-
}
|
|
27
|
-
export interface OutputsCtx {
|
|
28
|
-
outputs: unknown;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
32
|
-
|
|
33
|
-
/** What `load_ctx` resolves once, for every downstream node to read. */
|
|
34
|
-
export interface LoadCtxOutput {
|
|
35
|
-
base?: string;
|
|
36
|
-
specPath?: string;
|
|
37
|
-
groups?: AcceptanceGroup[];
|
|
38
|
-
/** `nax features resolve`'s acceptance status, narrowed at `resolveFeature`. */
|
|
39
|
-
acceptanceStatus?: AcceptanceStatus;
|
|
40
|
-
/** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
|
|
41
|
-
testFileRegex?: string[];
|
|
42
|
-
route?: string;
|
|
43
|
-
/** Set only when `route` is `escalate` — see `preflight`. */
|
|
44
|
-
reason?: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
|
|
48
|
-
return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
|
|
52
|
-
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* The narrative node's parsed prose.
|
|
56
|
-
*
|
|
57
|
-
* Absent when the node was skipped by config, died, or produced only
|
|
58
|
-
* whitespace — `amend_body` treats all three identically, so there is one
|
|
59
|
-
* branch downstream rather than three.
|
|
60
|
-
*
|
|
61
|
-
* Accepts the bare string the node used to return as well as the
|
|
62
|
-
* `{ narrative, title }` it returns now: a flow resumed from a run recorded
|
|
63
|
-
* before the title landed replays the old shape from its journal.
|
|
64
|
-
*/
|
|
65
|
-
export function narrativeOf(ctx: OutputsCtx): string | undefined {
|
|
66
|
-
const out = (ctx.outputs as Record<string, unknown>).narrative;
|
|
67
|
-
const prose = typeof out === "string" ? out : (out as { narrative?: unknown } | undefined)?.narrative;
|
|
68
|
-
return typeof prose === "string" && prose.trim().length > 0 ? prose : undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The narrative node's parsed PR title, already sanitised by `parseTitle`.
|
|
73
|
-
*
|
|
74
|
-
* Absent whenever the node is — `resolveTitle` then falls back to
|
|
75
|
-
* `feat: <feature>`, which is what shipped before and what auto-PR opens with.
|
|
76
|
-
*/
|
|
77
|
-
export function prTitleOf(ctx: OutputsCtx): string | undefined {
|
|
78
|
-
const out = (ctx.outputs as Record<string, unknown>).narrative;
|
|
79
|
-
if (typeof out !== "object" || out === null) return undefined;
|
|
80
|
-
const title = (out as { title?: unknown }).title;
|
|
81
|
-
return typeof title === "string" && title.trim().length > 0 ? title : undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function gateOutputs(ctx: OutputsCtx): { failing?: string[]; ran?: string[] } {
|
|
85
|
-
return ((ctx.outputs as Record<string, { failing?: string[]; ran?: string[] } | undefined>).quality_gates ?? {}) as {
|
|
86
|
-
failing?: string[];
|
|
87
|
-
ran?: string[];
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The findings the `fix_<phase>` node was asked to resolve; empty for non-review phases. */
|
|
92
|
-
export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
|
|
93
|
-
if (phase !== "spec" && phase !== "quality") return [];
|
|
94
|
-
return (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`]?.findings ?? [];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* The ref a re-review should diff from, or null to review the whole branch.
|
|
99
|
-
*
|
|
100
|
-
* A reviewer node re-reads the spec in full and the entire `git diff
|
|
101
|
-
* base...HEAD` on every round. Reviews were 58% of the wall clock on
|
|
102
|
-
* rs-stock/pipeline-run-outcome (7 calls, 1306s of 2232s), and round 3 re-read
|
|
103
|
-
* everything rounds 1-2 had already cleared.
|
|
104
|
-
*
|
|
105
|
-
* The scoping ref is the `shaBefore` of the **first** `commit_*` step after this
|
|
106
|
-
* phase's last review — that commit's parent is, by construction, the tree the
|
|
107
|
-
* previous verdict passed on, since only `commit_*` nodes commit. Taking the
|
|
108
|
-
* first (not the last) is what makes the window complete when more than one
|
|
109
|
-
* commit landed in it, which happens when the acceptance loop commits between a
|
|
110
|
-
* spec fix and its re-review: `firstCommit.shaBefore..HEAD` spans both.
|
|
111
|
-
*
|
|
112
|
-
* Read from `ctx.state.steps[].output`, not `ctx.outputs` — the latter keeps
|
|
113
|
-
* only each node's newest output, which for two commit steps of the same node id
|
|
114
|
-
* would have discarded the earlier `shaBefore` and silently under-scoped the
|
|
115
|
-
* review.
|
|
116
|
-
*
|
|
117
|
-
* Only commit steps that actually **committed** anchor the window. A fix node
|
|
118
|
-
* that edited nothing still records a `commit_*` step, and its `shaBefore` is
|
|
119
|
-
* the current HEAD — so scoping to it asks the reviewer for `HEAD..HEAD`, an
|
|
120
|
-
* empty diff, while the prompt tells it the prior findings "have since been
|
|
121
|
-
* fixed and committed". It returns clean, `route_*` sends the flow onward, and
|
|
122
|
-
* the findings ship unfixed. That leaves the loop through the green door, so
|
|
123
|
-
* `MAX_FIX_ATTEMPTS` never catches it. A no-op round therefore either yields
|
|
124
|
-
* the window to a later real commit, or falls back to a full review.
|
|
125
|
-
*
|
|
126
|
-
* Rounds journalled before `committed` existed carry no such field; `!== false`
|
|
127
|
-
* keeps replaying them on the previous behaviour rather than widening every
|
|
128
|
-
* resumed review to the whole branch.
|
|
129
|
-
*
|
|
130
|
-
* Returns null — a full review — when there is no prior review of this phase
|
|
131
|
-
* (round 1), no commit landed since it (nothing new to look at), or the commit
|
|
132
|
-
* step recorded no `shaBefore`.
|
|
133
|
-
*/
|
|
134
|
-
export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
|
|
135
|
-
const steps = ctx.state.steps ?? [];
|
|
136
|
-
const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
|
|
137
|
-
if (lastReview < 0) return null;
|
|
138
|
-
const firstCommit = steps
|
|
139
|
-
.slice(lastReview + 1)
|
|
140
|
-
.find(
|
|
141
|
-
(s) => s.nodeId.startsWith("commit_") && (s.output as { committed?: boolean } | undefined)?.committed !== false,
|
|
142
|
-
);
|
|
143
|
-
if (!firstCommit) return null;
|
|
144
|
-
return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Why this phase's last review was sent back, so the retry is told what was missing. */
|
|
148
|
-
export function reviewGapsOf(ctx: OutputsCtx, phase: "spec" | "quality"): string[] {
|
|
149
|
-
return ((ctx.outputs as Record<string, { gaps?: string[] } | undefined>)[`route_${phase}`]?.gaps ?? []) as string[];
|
|
150
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The PR body's "What changed" section — prompt, parse, and the chain that
|
|
3
|
-
* decides what text (if any) the section carries.
|
|
4
|
-
*
|
|
5
|
-
* Prompt building lives here rather than in `src/prompts/builders/` because
|
|
6
|
-
* `flows/` is loaded by acpx in its own Node process and imports nothing from
|
|
7
|
-
* `src/`. `review-prompts.ts` sits beside this file for the same reason.
|
|
8
|
-
*
|
|
9
|
-
* `resolveNarrative` is a standalone pure function, not flow wiring, because
|
|
10
|
-
* the acp node that produces the model text cannot be executed in tests. The
|
|
11
|
-
* degradation chain is the part that must never break, so it lives where a
|
|
12
|
-
* test can reach it.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { TITLE_CLOSE_TAG, TITLE_MAX_CHARS, TITLE_OPEN_TAG, parseTitle } from "./pr-title";
|
|
16
|
-
|
|
17
|
-
/** Longest narrative rendered into a PR body, in characters, including the ellipsis. */
|
|
18
|
-
export const NARRATIVE_MAX_CHARS = 4000;
|
|
19
|
-
|
|
20
|
-
const TRUNCATION_SUFFIX = "…";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Sentinel wrapping the prose, so `parseNarrative` has an explicit anchor
|
|
24
|
-
* rather than an inferred one.
|
|
25
|
-
*
|
|
26
|
-
* acpx hands `parse` the concatenation of *every* agent message chunk in the
|
|
27
|
-
* turn — `chunks.join("")` in its `createQuietCaptureOutput`. This node reads
|
|
28
|
-
* the diff with tools, so the agent's between-tool-call narration ("Now I have
|
|
29
|
-
* a clear picture. Let me check…") is structurally part of that string. A
|
|
30
|
-
* prompt asking for "no preamble" cannot prevent it; only a delimiter can.
|
|
31
|
-
*
|
|
32
|
-
* A sentinel rather than the JSON contract the sibling nodes use: this payload
|
|
33
|
-
* is multi-paragraph prose about code, full of backticks, quotes and newlines.
|
|
34
|
-
* Literal newlines inside a JSON string are invalid JSON, so a JSON contract
|
|
35
|
-
* would fail on exactly the inputs this node exists to carry.
|
|
36
|
-
*/
|
|
37
|
-
const OPEN_TAG = "<narrative>";
|
|
38
|
-
const CLOSE_TAG = "</narrative>";
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Headings the agent emits despite being told not to. Anchors the fallback
|
|
42
|
-
* strip when the sentinel is absent: everything up to and including the
|
|
43
|
-
* heading is preamble.
|
|
44
|
-
*/
|
|
45
|
-
const HEADING_RE = /^[\s\S]*?(?:\*\*What changed\*\*|##+\s*What changed)\s*/i;
|
|
46
|
-
|
|
47
|
-
/** A `<title>…</title>` block, closed or not — removed wholesale from the prose. */
|
|
48
|
-
const TITLE_BLOCK_RE = new RegExp(`${TITLE_OPEN_TAG}[\\s\\S]*?(?:${TITLE_CLOSE_TAG}|$)`, "gi");
|
|
49
|
-
|
|
50
|
-
/** Headings a spec uses for its lead paragraph, in priority order. */
|
|
51
|
-
const SUMMARY_HEADINGS = ["summary", "overview"] as const;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Prompt for the narrative node.
|
|
55
|
-
*
|
|
56
|
-
* Two jobs: point the agent at the real diff (never the spec, which describes
|
|
57
|
-
* intent rather than what shipped), and forbid restating the sections the body
|
|
58
|
-
* already renders deterministically.
|
|
59
|
-
*/
|
|
60
|
-
/**
|
|
61
|
-
* `prompt` for the `narrative` flow node. Lives here rather than inline in
|
|
62
|
-
* `nax-finish.flow.ts` to keep that file under its 600-line cap — the node
|
|
63
|
-
* just needs `ctx.outputs.load_ctx.base`, which is all this wrapper reads.
|
|
64
|
-
*/
|
|
65
|
-
export function narrativePrompt(ctx: { outputs: unknown }): string {
|
|
66
|
-
const base = (ctx.outputs as { load_ctx?: { base?: string } }).load_ctx?.base ?? "origin/main";
|
|
67
|
-
return buildNarrativePrompt({ base });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function buildNarrativePrompt(args: { base: string }): string {
|
|
71
|
-
return [
|
|
72
|
-
'Write the "What changed" section of a pull request body.',
|
|
73
|
-
"",
|
|
74
|
-
`Read the branch diff yourself: \`git diff ${args.base}...HEAD\`.`,
|
|
75
|
-
"Read whatever source files you need to understand it.",
|
|
76
|
-
"",
|
|
77
|
-
"The PR body ALREADY renders these deterministically, from run artifacts:",
|
|
78
|
-
"- a Stories table (story id, title, acceptance-criteria count)",
|
|
79
|
-
"- a Verification block (acceptance status, regression status, gates run, diffstat)",
|
|
80
|
-
"- a Review rounds block (every finding, with its severity)",
|
|
81
|
-
"- an Out of scope list",
|
|
82
|
-
"",
|
|
83
|
-
"Do NOT restate, summarise, or refer to any of them. Repeating them is how the",
|
|
84
|
-
"written and the generated halves of this body drift apart.",
|
|
85
|
-
"",
|
|
86
|
-
"Describe what the change actually does, in prose: the shape of the change, and",
|
|
87
|
-
"anything a reviewer would otherwise have to reconstruct from the diff by hand.",
|
|
88
|
-
`Hard limit: ${NARRATIVE_MAX_CHARS} characters.`,
|
|
89
|
-
"Do not write a heading — the heading is added for you.",
|
|
90
|
-
"",
|
|
91
|
-
"Then write the pull request title: a conventional-commit subject describing",
|
|
92
|
-
`the change (\`fix: …\`, \`feat: …\`, \`refactor: …\`), at most ${TITLE_MAX_CHARS} characters.`,
|
|
93
|
-
"Describe what the change does — not the feature's name, which the reader",
|
|
94
|
-
"can already see on the branch.",
|
|
95
|
-
"",
|
|
96
|
-
"Reply with exactly these two blocks, and write nothing after the last one:",
|
|
97
|
-
`${TITLE_OPEN_TAG}conventional-commit subject${TITLE_CLOSE_TAG}`,
|
|
98
|
-
`${OPEN_TAG}the prose${CLOSE_TAG}`,
|
|
99
|
-
"",
|
|
100
|
-
"Everything outside those tags is discarded, so anything you say while working",
|
|
101
|
-
"through the diff is safe to leave where it falls.",
|
|
102
|
-
].join("\n");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* `parse` for the narrative acp node.
|
|
107
|
-
*
|
|
108
|
-
* Never throws. A throw inside `parse` fails the node, and acpx has no error
|
|
109
|
-
* edge — see `verdict.ts`. Here that would mean the flow dying *after* the PR
|
|
110
|
-
* was already opened, so every branch below degrades instead of rejecting.
|
|
111
|
-
*
|
|
112
|
-
* Three tiers, strongest anchor first:
|
|
113
|
-
* 1. Sentinel — the contract the prompt asks for.
|
|
114
|
-
* 2. Heading — the agent ignored the sentinel but still wrote
|
|
115
|
-
* `**What changed**`, which marks where its preamble stopped.
|
|
116
|
-
* 3. Bare trim — no anchor available; better a narrative with preamble than
|
|
117
|
-
* no narrative at all.
|
|
118
|
-
*/
|
|
119
|
-
export function parseNarrative(text: string): string {
|
|
120
|
-
if (typeof text !== "string") return "";
|
|
121
|
-
|
|
122
|
-
// Last opening tag, not the first: if the agent narrates the tag before
|
|
123
|
-
// emitting it for real ("I'll wrap this in <narrative>"), the real one wins.
|
|
124
|
-
const open = text.lastIndexOf(OPEN_TAG);
|
|
125
|
-
if (open !== -1) {
|
|
126
|
-
const from = open + OPEN_TAG.length;
|
|
127
|
-
const close = text.indexOf(CLOSE_TAG, from);
|
|
128
|
-
const inner = (close === -1 ? text.slice(from) : text.slice(from, close)).trim();
|
|
129
|
-
if (inner) return inner;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Strip tag markers before the heading pass: an empty or malformed sentinel
|
|
133
|
-
// falls through to here, and leftover `<narrative>` markup in a PR body is
|
|
134
|
-
// worse than the preamble this function exists to remove. The title block
|
|
135
|
-
// goes entirely — tags and content — since it is not part of the prose.
|
|
136
|
-
const untagged = text.replace(TITLE_BLOCK_RE, "").split(OPEN_TAG).join("").split(CLOSE_TAG).join("");
|
|
137
|
-
return untagged.replace(HEADING_RE, "").trim();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** What the `narrative` acp node returns: the prose, and the title to rename the PR to. */
|
|
141
|
-
export interface NarrativeNodeResult {
|
|
142
|
-
narrative: string;
|
|
143
|
-
title?: string;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* `parse` for the narrative acp node.
|
|
148
|
-
*
|
|
149
|
-
* Both halves are optional to the flow: a missing title leaves the PR on
|
|
150
|
-
* `feat: <feature>`, and missing prose leaves the body's mechanical sections
|
|
151
|
-
* alone. Never throws, for the reason `parseNarrative` documents.
|
|
152
|
-
*/
|
|
153
|
-
export function parseNarrativeNode(text: string): NarrativeNodeResult {
|
|
154
|
-
return { narrative: parseNarrative(text), title: parseTitle(text) };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function truncate(text: string): string {
|
|
158
|
-
if (text.length <= NARRATIVE_MAX_CHARS) return text;
|
|
159
|
-
return text.slice(0, NARRATIVE_MAX_CHARS - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Pick the narrative text, best source first.
|
|
164
|
-
*
|
|
165
|
-
* The spec summary is the fallback rather than the primary source because a
|
|
166
|
-
* spec describes intent: when an implementation deviates and the deviation is
|
|
167
|
-
* accepted, a spec-derived narrative confidently describes code that does not
|
|
168
|
-
* exist.
|
|
169
|
-
*
|
|
170
|
-
* `undefined` means "render no section at all" — never an empty heading.
|
|
171
|
-
*/
|
|
172
|
-
export function resolveNarrative(agentText: string | undefined, specSummary: string | null): string | undefined {
|
|
173
|
-
const fromAgent = agentText?.trim();
|
|
174
|
-
if (fromAgent) return truncate(fromAgent);
|
|
175
|
-
const fromSpec = specSummary?.trim();
|
|
176
|
-
if (fromSpec) return truncate(fromSpec);
|
|
177
|
-
return undefined;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function sectionBody(lines: string[], heading: string): string | null {
|
|
181
|
-
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`);
|
|
182
|
-
if (start === -1) return null;
|
|
183
|
-
const rest = lines.slice(start + 1);
|
|
184
|
-
const end = rest.findIndex((line) => line.startsWith("## "));
|
|
185
|
-
const body = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
|
|
186
|
-
return body.length > 0 ? body : null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* First `## Summary` or `## Overview` block in the spec, or `null`.
|
|
191
|
-
*
|
|
192
|
-
* Both headings are accepted because both occur in this repository's real
|
|
193
|
-
* specs — five of six use `## Summary`, the older `plugin-001` uses
|
|
194
|
-
* `## Overview`. Fail-open on every read error: a missing or unreadable spec
|
|
195
|
-
* costs the section, never the PR.
|
|
196
|
-
*/
|
|
197
|
-
export async function readSpecSummary(
|
|
198
|
-
specPath: string | undefined,
|
|
199
|
-
readText: (path: string) => Promise<string | null>,
|
|
200
|
-
): Promise<string | null> {
|
|
201
|
-
if (!specPath) return null;
|
|
202
|
-
let text: string | null;
|
|
203
|
-
try {
|
|
204
|
-
text = await readText(specPath);
|
|
205
|
-
} catch {
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
if (text === null) return null;
|
|
209
|
-
const lines = text.split(/\r?\n/);
|
|
210
|
-
for (const heading of SUMMARY_HEADINGS) {
|
|
211
|
-
const body = sectionBody(lines, heading);
|
|
212
|
-
if (body !== null) return body;
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|