@nathapp/nax 0.80.0 → 0.81.0-canary.1
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 +45841 -43059
- package/package.json +6 -6
- package/flows/nax-finish/commit-message.ts +0 -215
- package/flows/nax-finish/errors.ts +0 -23
- package/flows/nax-finish/exec.ts +0 -135
- package/flows/nax-finish/flow-ctx.ts +0 -145
- package/flows/nax-finish/narrative.ts +0 -215
- package/flows/nax-finish/nax-finish.flow.ts +0 -532
- 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 -405
- package/flows/nax-finish/steps/acceptance.ts +0 -76
- package/flows/nax-finish/steps/commit-round.ts +0 -64
- 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 -12
- package/flows/nax-finish/steps/pr-body.ts +0 -442
- 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-round.ts +0 -74
- package/flows/nax-finish/types.ts +0 -202
- package/flows/nax-finish/verdict.ts +0 -176
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0-canary.1",
|
|
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,215 +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, 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
|
-
|
|
94
|
-
interface PhaseOutputs {
|
|
95
|
-
findings?: Finding[];
|
|
96
|
-
failing?: string[];
|
|
97
|
-
output?: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
|
|
101
|
-
return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
|
|
105
|
-
const raw = outputsFor(ctx, `review_${phase}`).findings;
|
|
106
|
-
return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function worstSeverity(findings: Finding[]): string {
|
|
110
|
-
const present = new Set(findings.map((f) => f.severity));
|
|
111
|
-
return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Lowercase a finding title's leading word for the subject line.
|
|
116
|
-
*
|
|
117
|
-
* Reviewers write titles as sentences ("Market gate skip branch is
|
|
118
|
-
* unreachable"); conventional-commit subjects read better in lower case. Only
|
|
119
|
-
* the first character is touched — an all-caps leading token is an acronym
|
|
120
|
-
* (`SSRF guard …`) and must survive intact.
|
|
121
|
-
*/
|
|
122
|
-
function subjectCase(title: string): string {
|
|
123
|
-
const [first = "", ...rest] = title.split(" ");
|
|
124
|
-
const isAcronym = first.length > 1 && first === first.toUpperCase();
|
|
125
|
-
return isAcronym
|
|
126
|
-
? title
|
|
127
|
-
: `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function truncate(s: string): string {
|
|
131
|
-
return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
|
|
135
|
-
function humanList(items: string[]): string {
|
|
136
|
-
if (items.length <= 1) return items[0] ?? "";
|
|
137
|
-
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
|
|
141
|
-
if (findings.length === 1) return subjectCase(findings[0].title);
|
|
142
|
-
return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
|
|
146
|
-
if (phase === "gate") {
|
|
147
|
-
const failing = outputsFor(ctx, "quality_gates").failing ?? [];
|
|
148
|
-
return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
|
|
149
|
-
}
|
|
150
|
-
if (phase === "acceptance") return "repair failing acceptance tests";
|
|
151
|
-
const findings = findingsFor(ctx, phase);
|
|
152
|
-
return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* What to quote from a runner's output: the failing test names if they can be
|
|
157
|
-
* identified, otherwise the tail, as before.
|
|
158
|
-
*
|
|
159
|
-
* Never both. Naming the failures *and* pasting the tail reproduces the noise
|
|
160
|
-
* this replaces, and the tail is the weaker signal whenever the names exist.
|
|
161
|
-
*/
|
|
162
|
-
function runnerEvidence(output: string, opts: MessageOptions): string {
|
|
163
|
-
const clean = redactPaths(output, opts.workdir).trim();
|
|
164
|
-
const names = failingTestNames(clean);
|
|
165
|
-
if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
|
|
166
|
-
return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
|
|
170
|
-
if (phase === "gate") {
|
|
171
|
-
const gate = outputsFor(ctx, "quality_gates");
|
|
172
|
-
const failing = gate.failing ?? [];
|
|
173
|
-
const evidence = runnerEvidence(gate.output ?? "", opts);
|
|
174
|
-
return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
|
|
175
|
-
}
|
|
176
|
-
if (phase === "acceptance") {
|
|
177
|
-
const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
|
|
178
|
-
return evidence ? [evidence] : [];
|
|
179
|
-
}
|
|
180
|
-
const findings = findingsFor(ctx, phase);
|
|
181
|
-
if (findings.length === 0) return [];
|
|
182
|
-
return [
|
|
183
|
-
findings
|
|
184
|
-
.map((f) =>
|
|
185
|
-
[`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
|
|
186
|
-
.filter(Boolean)
|
|
187
|
-
.join("\n"),
|
|
188
|
-
)
|
|
189
|
-
.join("\n"),
|
|
190
|
-
];
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Human-readable phase label for the attribution trailer. */
|
|
194
|
-
function phaseLabel(phase: FinishPhase): string {
|
|
195
|
-
return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Build the commit message for a `commit_<phase>` checkpoint.
|
|
200
|
-
*
|
|
201
|
-
* Never throws and never returns an empty subject: a missing or malformed
|
|
202
|
-
* reviewer output degrades to the phase label. A commit that cannot be
|
|
203
|
-
* described is still a commit that must happen — failing here would strand the
|
|
204
|
-
* fix uncommitted and reintroduce the stale-diff bug (#1397).
|
|
205
|
-
*/
|
|
206
|
-
export function buildFixCommitMessage(
|
|
207
|
-
phase: FinishPhase,
|
|
208
|
-
feature: string,
|
|
209
|
-
ctx: MessageCtx,
|
|
210
|
-
opts: MessageOptions = {},
|
|
211
|
-
): string {
|
|
212
|
-
const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
|
|
213
|
-
const body = bodyFor(phase, ctx, opts);
|
|
214
|
-
return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
|
|
215
|
-
}
|
|
@@ -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,145 +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
|
-
}
|