@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.80.1",
3
+ "version": "0.81.0-canary.2",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,10 +11,9 @@
11
11
  "dev": "bun run bin/nax.ts",
12
12
  "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p tsconfig.contracts.json",
14
- "lint": "bun x biome check src/ bin/ flows/ && bun run check:flows-no-bun && bun run check:no-real-global-nax && bun run check:feature-dir-ssot && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes && bun run check:no-control-bytes",
15
- "lint:json": "bun x biome check src/ bin/ flows/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
- "lint:fix": "bun x biome check --write src/ bin/ flows/",
17
- "check:flows-no-bun": "bun run scripts/check-flows-no-bun.ts",
14
+ "lint": "bun x biome check src/ bin/ && bun run check:no-real-global-nax && bun run check:feature-dir-ssot && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes && bun run check:no-control-bytes && bun run check:review-prompts",
15
+ "lint:json": "bun x biome check src/ bin/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
+ "lint:fix": "bun x biome check --write src/ bin/",
18
17
  "check:no-real-global-nax": "bun run scripts/check-no-real-global-nax.ts",
19
18
  "check:feature-dir-ssot": "bun run scripts/check-feature-dir-ssot.ts",
20
19
  "check:alias-internals": "bun run scripts/check-alias-internals.ts",
@@ -28,6 +27,8 @@
28
27
  "check:file-sizes": "bun run scripts/check-file-sizes.ts",
29
28
  "check:file-sizes:update": "bun run scripts/check-file-sizes.ts --update-baseline",
30
29
  "check:no-control-bytes": "bun run scripts/check-no-control-bytes.ts",
30
+ "check:review-prompts": "bun run scripts/check-review-prompts-generated.ts",
31
+ "gen:review-prompts": "bun run scripts/generate-review-prompts.ts",
31
32
  "release": "bun scripts/release.ts",
32
33
  "test": "AGENT=1 bun run scripts/run-tests.ts",
33
34
  "test:verbose": "bun run scripts/run-tests.ts",
@@ -88,7 +89,6 @@
88
89
  ],
89
90
  "files": [
90
91
  "dist/",
91
- "flows/",
92
92
  "README.md",
93
93
  "CHANGELOG.md"
94
94
  ],
@@ -1,239 +0,0 @@
1
- /**
2
- * Commit messages for the flow's `commit_<phase>` checkpoints.
3
- *
4
- * These commits are shipped history — they land on the feature branch and a
5
- * human reviews them in the PR. Every one of them used to read
6
- * `fix(<feature>): nax-finish <phase> fixes` with an empty body, so a reviewer
7
- * looking at six such commits could not tell which one re-enabled a disabled
8
- * market gate and which one renamed a variable. The reviewer already produced
9
- * exactly the material needed to say so — severity, title, problem, fix — and
10
- * it was being discarded at the one moment it could have been recorded.
11
- *
12
- * Subject lines follow the repo's conventional-commit rule and the 72-column
13
- * git summary convention; the findings go in the body, one bullet each.
14
- */
15
- import type { Finding, FindingDisposition, FinishPhase } from "./types";
16
-
17
- /** Git's conventional soft cap for a commit summary line. */
18
- const MAX_SUBJECT_LEN = 72;
19
-
20
- /** Worst-first, so the subject of a mixed batch reports the severity that matters. */
21
- const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
22
-
23
- /** How much gate output to quote in the body before it stops being a commit message. */
24
- const MAX_GATE_OUTPUT_LINES = 20;
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
-
84
- interface MessageCtx {
85
- outputs: Record<string, unknown>;
86
- }
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
- * What `fix_<phase>` did with each finding it was handed, by 1-based index.
94
- *
95
- * Only `commit_<phase>` callers have this — `gate`/`acceptance` commits, and
96
- * any caller that has not run a fix node yet, pass nothing, and every finding
97
- * renders as before (`Fix: <text>`).
98
- */
99
- dispositions?: FindingDisposition[];
100
- }
101
-
102
- interface PhaseOutputs {
103
- findings?: Finding[];
104
- failing?: string[];
105
- output?: string;
106
- }
107
-
108
- function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
109
- return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
110
- }
111
-
112
- function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
113
- const raw = outputsFor(ctx, `review_${phase}`).findings;
114
- return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
115
- }
116
-
117
- function worstSeverity(findings: Finding[]): string {
118
- const present = new Set(findings.map((f) => f.severity));
119
- return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
120
- }
121
-
122
- /**
123
- * Lowercase a finding title's leading word for the subject line.
124
- *
125
- * Reviewers write titles as sentences ("Market gate skip branch is
126
- * unreachable"); conventional-commit subjects read better in lower case. Only
127
- * the first character is touched — an all-caps leading token is an acronym
128
- * (`SSRF guard …`) and must survive intact.
129
- */
130
- function subjectCase(title: string): string {
131
- const [first = "", ...rest] = title.split(" ");
132
- const isAcronym = first.length > 1 && first === first.toUpperCase();
133
- return isAcronym
134
- ? title
135
- : `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
136
- }
137
-
138
- function truncate(s: string): string {
139
- return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
140
- }
141
-
142
- /** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
143
- function humanList(items: string[]): string {
144
- if (items.length <= 1) return items[0] ?? "";
145
- return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
146
- }
147
-
148
- function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
149
- if (findings.length === 1) return subjectCase(findings[0].title);
150
- return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
151
- }
152
-
153
- function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
154
- if (phase === "gate") {
155
- const failing = outputsFor(ctx, "quality_gates").failing ?? [];
156
- return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
157
- }
158
- if (phase === "acceptance") return "repair failing acceptance tests";
159
- const findings = findingsFor(ctx, phase);
160
- return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
161
- }
162
-
163
- /**
164
- * What to quote from a runner's output: the failing test names if they can be
165
- * identified, otherwise the tail, as before.
166
- *
167
- * Never both. Naming the failures *and* pasting the tail reproduces the noise
168
- * this replaces, and the tail is the weaker signal whenever the names exist.
169
- */
170
- function runnerEvidence(output: string, opts: MessageOptions): string {
171
- const clean = redactPaths(output, opts.workdir).trim();
172
- const names = failingTestNames(clean);
173
- if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
174
- return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
175
- }
176
-
177
- function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
178
- if (phase === "gate") {
179
- const gate = outputsFor(ctx, "quality_gates");
180
- const failing = gate.failing ?? [];
181
- const evidence = runnerEvidence(gate.output ?? "", opts);
182
- return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
183
- }
184
- if (phase === "acceptance") {
185
- const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
186
- return evidence ? [evidence] : [];
187
- }
188
- const findings = findingsFor(ctx, phase);
189
- if (findings.length === 0) return [];
190
- const dispositions = opts.dispositions ?? [];
191
- return [findings.map((f, i) => findingBody(f, i, dispositions)).join("\n")];
192
- }
193
-
194
- /**
195
- * Render one finding's line(s) in the commit body, honouring `fix_<phase>`'s
196
- * disposition when one exists.
197
- *
198
- * A rejected finding must not read `Fix: <the fix text>` — nothing was applied
199
- * — so it renders the same way `pr-body.ts`'s `renderRejected` does: as
200
- * rejected, with its evidence citation, so shipped git history and the PR body
201
- * agree on what happened to the finding.
202
- */
203
- function findingBody(f: Finding, index: number, dispositions: FindingDisposition[]): string {
204
- const d = dispositions.find((x) => x.index === index + 1);
205
- if (d?.disposition === "rejected") {
206
- const evidence = d.evidence ? `\`${d.evidence}\`` : "no evidence cited";
207
- const caveat = d.evidenceMissing ? " (evidence path not found)" : "";
208
- return [`- [${f.severity}] ${f.title} — rejected: ${evidence}${caveat}`, f.problem ? ` ${f.problem}` : ""]
209
- .filter(Boolean)
210
- .join("\n");
211
- }
212
- return [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
213
- .filter(Boolean)
214
- .join("\n");
215
- }
216
-
217
- /** Human-readable phase label for the attribution trailer. */
218
- function phaseLabel(phase: FinishPhase): string {
219
- return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
220
- }
221
-
222
- /**
223
- * Build the commit message for a `commit_<phase>` checkpoint.
224
- *
225
- * Never throws and never returns an empty subject: a missing or malformed
226
- * reviewer output degrades to the phase label. A commit that cannot be
227
- * described is still a commit that must happen — failing here would strand the
228
- * fix uncommitted and reintroduce the stale-diff bug (#1397).
229
- */
230
- export function buildFixCommitMessage(
231
- phase: FinishPhase,
232
- feature: string,
233
- ctx: MessageCtx,
234
- opts: MessageOptions = {},
235
- ): string {
236
- const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
237
- const body = bodyFor(phase, ctx, opts);
238
- return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
239
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * Self-contained error type for the nax-finish flow.
3
- *
4
- * The flow module is loaded by `acpx flow run` from wherever `flows/` happens
5
- * to be installed, in acpx's own process, with the *user's* repo as cwd. It
6
- * therefore cannot import from nax's `src/` — neither via the `@/*` path alias
7
- * (which only resolves through nax's own tsconfig) nor via a relative path
8
- * (only `flows/` is published, not `src/`). `FinishError` mirrors `NaxError`'s
9
- * shape (message + machine-readable code + structured context) so escalation
10
- * output and logs stay consistent with the rest of nax.
11
- */
12
- export class FinishError extends Error {
13
- readonly code: string;
14
- readonly context: Record<string, unknown>;
15
-
16
- constructor(message: string, code: string, context: Record<string, unknown> = {}) {
17
- const { cause, ...rest } = context;
18
- super(message, cause === undefined ? undefined : { cause });
19
- this.name = "FinishError";
20
- this.code = code;
21
- this.context = rest;
22
- }
23
- }
@@ -1,135 +0,0 @@
1
- /**
2
- * Subprocess execution for the nax-finish flow.
3
- *
4
- * Two distinct entry points, deliberately:
5
- *
6
- * - `runArgv` — for commands *this flow* constructs (`git`, `gh`, `glab`). An
7
- * argv array is spawned directly: no shell, so no quoting or injection
8
- * surface for branch names and review text.
9
- * - `runShell` — for command *strings the user configured* (`quality.commands`,
10
- * `acceptance.command`). These are run through `/bin/sh -c`, matching
11
- * `src/quality/runner.ts`, which is the only way to preserve `&&`, quoting,
12
- * globs and env prefixes. Splitting such a string on whitespace and spawning
13
- * argv (the previous behaviour) silently mis-ran every non-trivial command.
14
- *
15
- * Both cap wall-clock time: an unbounded gate would hang `acpx flow run`, and
16
- * the post-run plugin awaits that subprocess.
17
- *
18
- * ## Why `node:child_process` and not `Bun.spawn`
19
- *
20
- * The rest of nax is Bun-native (see `.claude/rules/project-conventions.md`),
21
- * but this module is **not** loaded by nax. `acpx flow run` loads it, in acpx's
22
- * own process, and the published `acpx` binary is a Node program
23
- * (`#!/usr/bin/env node`). Under Node the `Bun` global does not exist, so
24
- * `Bun.spawn` threw `ReferenceError: Bun is not defined` on the flow's very
25
- * first git call — aborting the flow before any node completed and before the
26
- * result file was written. Everything under `flows/` must therefore stay on
27
- * Node built-ins; `Bun.*` is banned here and only here, enforced by
28
- * `scripts/check-flows-no-bun.ts`.
29
- */
30
- import { spawn } from "node:child_process";
31
- import type { RunResult } from "./types";
32
-
33
- /** Fallbacks used when the plugin passes no explicit budget in the flow input. */
34
- export const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 600_000;
35
- export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
36
- /** Short budget for the flow's own git/forge plumbing — these are never long-running. */
37
- export const DEFAULT_ARGV_TIMEOUT_MS = 120_000;
38
-
39
- export interface ExecOptions {
40
- cwd: string;
41
- timeoutMs?: number;
42
- }
43
-
44
- /** Exit code reported when the wall-clock cap kills the process, matching `timeout(1)`. */
45
- const TIMEOUT_EXIT_CODE = 124;
46
- /** Exit code reported when the binary is missing, matching a shell's "command not found". */
47
- const NOT_FOUND_EXIT_CODE = 127;
48
-
49
- function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
50
- return new Promise<RunResult>((resolve) => {
51
- const [file, ...args] = cmd;
52
- // `detached: true` puts the child in its own process group so we can
53
- // SIGKILL the whole tree when the timer fires — otherwise a SIGTERM on
54
- // `sh` only kills the shell, and any inherited pipes from a still-running
55
- // child (e.g. `sleep 30`) keep `close` from firing until the child exits.
56
- const proc = spawn(file as string, args, {
57
- cwd: opts.cwd,
58
- stdio: ["ignore", "pipe", "pipe"],
59
- detached: true,
60
- });
61
- let stdout = "";
62
- let stderr = "";
63
- let timedOut = false;
64
- let settled = false;
65
-
66
- proc.stdout.setEncoding("utf8");
67
- proc.stderr.setEncoding("utf8");
68
- proc.stdout.on("data", (chunk: string) => {
69
- stdout += chunk;
70
- });
71
- proc.stderr.on("data", (chunk: string) => {
72
- stderr += chunk;
73
- });
74
-
75
- // setTimeout (not a sleep) because the handle must be cancellable the moment
76
- // the process exits — the documented exception in forbidden-patterns.md.
77
- const timer =
78
- opts.timeoutMs && opts.timeoutMs > 0
79
- ? setTimeout(() => {
80
- timedOut = true;
81
- if (proc.pid !== undefined) {
82
- // Negative pid = process group. SIGKILL cannot be ignored,
83
- // guarantees the child tree (and inherited pipes) actually close.
84
- try {
85
- process.kill(-proc.pid, "SIGKILL");
86
- } catch {
87
- // Group already gone.
88
- }
89
- }
90
- }, opts.timeoutMs)
91
- : undefined;
92
-
93
- const settle = (result: RunResult): void => {
94
- if (settled) return;
95
- settled = true;
96
- if (timer) clearTimeout(timer);
97
- resolve(result);
98
- };
99
-
100
- // A missing binary (`gh`/`glab` not installed) surfaces as an `error` event
101
- // under Node, where `Bun.spawn` used to throw. Resolving with 127 instead of
102
- // rejecting keeps it a readable gate failure the flow can route on, rather
103
- // than an exception that kills `acpx flow run` with no result file.
104
- proc.on("error", (err: Error) => {
105
- settle({ exitCode: NOT_FOUND_EXIT_CODE, stdout, stderr: `${stderr}${err.message}`, timedOut });
106
- });
107
-
108
- // `close` (not `exit`) so both pipes are fully drained before we read them.
109
- // `code` is null when the process died from a signal — including our own
110
- // timeout kill — so it maps to a non-zero code rather than a false green.
111
- proc.on("close", (code: number | null) => {
112
- const exitCode = code ?? (timedOut ? TIMEOUT_EXIT_CODE : 1);
113
- settle(
114
- timedOut
115
- ? {
116
- exitCode: exitCode === 0 ? TIMEOUT_EXIT_CODE : exitCode,
117
- stdout,
118
- stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
119
- timedOut: true,
120
- }
121
- : { exitCode, stdout, stderr },
122
- );
123
- });
124
- });
125
- }
126
-
127
- /** Spawn an argv array directly — no shell. For flow-constructed commands. */
128
- export function runArgv(cmd: string[], opts: ExecOptions): Promise<RunResult> {
129
- return spawnCapture(cmd, { ...opts, timeoutMs: opts.timeoutMs ?? DEFAULT_ARGV_TIMEOUT_MS });
130
- }
131
-
132
- /** Run a configured command string through `/bin/sh -c`, preserving shell semantics. */
133
- export function runShell(command: string, opts: ExecOptions): Promise<RunResult> {
134
- return spawnCapture(["/bin/sh", "-c", command], opts);
135
- }
@@ -1,150 +0,0 @@
1
- /**
2
- * Turning a reviewer's free-form reply into structured findings.
3
- *
4
- * The reviewer's contract is text, not JSON, for two reasons. The dimension
5
- * references both hinge on an enumeration — a per-AC walk and a per-changed-
6
- * function walk — and a reply constrained to one JSON object has nowhere to put
7
- * one. And a text reply has no cliff: a malformed line costs that line, where an
8
- * unparseable JSON object used to cost the entire review (#1614).
9
- *
10
- * Every function here is pure and non-throwing. `verdict.ts` documents why that
11
- * is load-bearing: a throw inside an acpx `parse` fails the whole flow.
12
- */
13
- import type { Finding, FindingDisposition, ReviewReport, Severity, Touchpoint } from "./types";
14
-
15
- type Section = "touchpoints" | "walk" | "findings";
16
-
17
- /** Headings are matched loosely — any level, any case, optional trailing colon. */
18
- const HEADING = /^\s*#{1,6}\s*(TOUCHPOINTS|WALK|FINDINGS|DISPOSITIONS)\s*:?\s*$/i;
19
- const BLOCK = /^\s*\[(CRITICAL|HIGH|MEDIUM|LOW)\]\s+(.+?)\s*$/;
20
- const FIELD = /^\s*(Problem|Fix|Judgment)\s*:\s*(.*)$/i;
21
- const NO_FINDINGS = /^\s*no findings\.?\s*$/i;
22
- const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
23
- const DISPOSITION = /^\s*\[?(\d+)\]?\s*[.:)]?\s*(fixed|rejected)\b\s*(.*)$/i;
24
- const EVIDENCE = /evidence\s*:\s*(\S+)/i;
25
-
26
- /** `- path/to/file.ts:symbol — why`, tolerant of backticks and of `-` for `—`. */
27
- function parseTouchpoint(text: string): Touchpoint | null {
28
- const m = /^(\S+)\s*(?:[—–-]\s*)?(.*)$/.exec(text);
29
- if (!m) return null;
30
- const locator = m[1].replace(/[`,]/g, "");
31
- const note = m[2].trim();
32
- if (/^none$/i.test(locator)) return { path: "none", note };
33
- const cut = locator.lastIndexOf(":");
34
- return cut > 0 ? { path: locator.slice(0, cut), symbol: locator.slice(cut + 1), note } : { path: locator, note };
35
- }
36
-
37
- function parseJudgment(value: string): { judgment: boolean; judgmentReason?: string } {
38
- const m = /^\s*(yes|true)\b\s*(?:[—–-]\s*)?(.*)$/i.exec(value);
39
- if (!m) return { judgment: false };
40
- const reason = m[2].trim();
41
- return reason ? { judgment: true, judgmentReason: reason } : { judgment: true };
42
- }
43
-
44
- /**
45
- * Parse a reviewer reply.
46
- *
47
- * Section state starts at `findings`, not "none": a reviewer that emits blocks
48
- * and no headings at all is a partial failure of the contract, and its findings
49
- * are still worth keeping — losing them is the exact failure this replaces. The
50
- * `saw*Section` flags stay false in that case, which is what the audit gate in
51
- * `steps/review-audit.ts` keys off.
52
- */
53
- export function parseReviewReport(text: string): ReviewReport {
54
- const report: ReviewReport = {
55
- findings: [],
56
- touchpoints: [],
57
- walk: [],
58
- sawNoFindings: false,
59
- sawTouchpointsSection: false,
60
- sawWalkSection: false,
61
- };
62
- let section: Section = "findings";
63
- let current: Finding | null = null;
64
- let lastField: "problem" | "fix" | null = null;
65
-
66
- const flush = () => {
67
- if (current) report.findings.push(current);
68
- current = null;
69
- lastField = null;
70
- };
71
-
72
- for (const line of text.split("\n")) {
73
- const heading = HEADING.exec(line);
74
- if (heading) {
75
- flush();
76
- const name = heading[1].toLowerCase();
77
- if (name === "touchpoints") {
78
- section = "touchpoints";
79
- report.sawTouchpointsSection = true;
80
- } else if (name === "walk") {
81
- section = "walk";
82
- report.sawWalkSection = true;
83
- } else {
84
- section = "findings";
85
- }
86
- continue;
87
- }
88
-
89
- if (section === "touchpoints") {
90
- const bullet = BULLET.exec(line);
91
- const tp = bullet ? parseTouchpoint(bullet[1]) : null;
92
- if (tp) report.touchpoints.push(tp);
93
- continue;
94
- }
95
- if (section === "walk") {
96
- if (line.trim().length > 0) report.walk.push(line.trim());
97
- continue;
98
- }
99
-
100
- const block = BLOCK.exec(line);
101
- if (block) {
102
- flush();
103
- current = { severity: block[1] as Severity, title: block[2], problem: "", fix: "" };
104
- continue;
105
- }
106
- if (NO_FINDINGS.test(line)) {
107
- report.sawNoFindings = true;
108
- continue;
109
- }
110
- if (!current) continue;
111
- const field = FIELD.exec(line);
112
- if (field) {
113
- const key = field[1].toLowerCase();
114
- if (key === "problem") {
115
- current.problem = field[2].trim();
116
- lastField = "problem";
117
- } else if (key === "fix") {
118
- current.fix = field[2].trim();
119
- lastField = "fix";
120
- } else {
121
- Object.assign(current, parseJudgment(field[2]));
122
- lastField = null;
123
- }
124
- continue;
125
- }
126
- // A continuation line for the field above it — the reviewer wraps prose, and
127
- // a wrapped Problem read as nothing is how detail silently disappears.
128
- if (lastField && line.trim().length > 0) {
129
- current[lastField] = `${current[lastField]} ${line.trim()}`.trim();
130
- }
131
- }
132
- flush();
133
- return report;
134
- }
135
-
136
- /** Parse the `## DISPOSITIONS` section of a fix node's reply. */
137
- export function parseDispositions(text: string): FindingDisposition[] {
138
- const out: FindingDisposition[] = [];
139
- for (const line of text.split("\n")) {
140
- const m = DISPOSITION.exec(line);
141
- if (!m) continue;
142
- const evidence = EVIDENCE.exec(m[3])?.[1];
143
- out.push({
144
- index: Number(m[1]),
145
- disposition: m[2].toLowerCase() as "fixed" | "rejected",
146
- ...(evidence ? { evidence } : {}),
147
- });
148
- }
149
- return out;
150
- }