@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
15
|
-
"lint:json": "bun x biome check src/ bin/
|
|
16
|
-
"lint:fix": "bun x biome check --write src/ bin/
|
|
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
|
-
}
|
package/flows/nax-finish/exec.ts
DELETED
|
@@ -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
|
-
}
|