@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,158 +0,0 @@
|
|
|
1
|
-
import { FinishError } from "../errors";
|
|
2
|
-
import { runArgv } from "../exec";
|
|
3
|
-
import type { AcceptanceGroup, RunFn } from "../types";
|
|
4
|
-
|
|
5
|
-
export const _contextDeps: { run: RunFn } = { run: runArgv };
|
|
6
|
-
|
|
7
|
-
export async function detectBaseBranch(workdir: string): Promise<string> {
|
|
8
|
-
const res = await _contextDeps.run(["git", "remote", "show", "origin"], { cwd: workdir });
|
|
9
|
-
const m = res.stdout.match(/HEAD branch:\s*(\S+)/);
|
|
10
|
-
if (m) return `origin/${m[1]}`;
|
|
11
|
-
const main = await _contextDeps.run(["git", "rev-parse", "--verify", "origin/main"], { cwd: workdir });
|
|
12
|
-
return main.exitCode === 0 ? "origin/main" : "origin/master";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The acceptance resolutions this flow knows how to route on, as emitted by
|
|
17
|
-
* `resolveFeatureAcceptance` (`src/cli/features-acceptance.ts`).
|
|
18
|
-
*
|
|
19
|
-
* A closed set, not `string`: `steps/gates.ts` branches on all three by literal
|
|
20
|
-
* — `disabled` decides whether the acceptance gate runs at all — and a typo in
|
|
21
|
-
* any of those comparisons against a `string` compiles cleanly and silently
|
|
22
|
-
* stops a gate from firing.
|
|
23
|
-
*/
|
|
24
|
-
export type AcceptanceStatus = "ok" | "no-prd" | "disabled";
|
|
25
|
-
|
|
26
|
-
const ACCEPTANCE_STATUSES: readonly AcceptanceStatus[] = ["ok", "no-prd", "disabled"];
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Narrow the resolver's status, degrading anything unrecognised to `no-prd`.
|
|
30
|
-
*
|
|
31
|
-
* A status this flow cannot interpret is not a licence to proceed: it is
|
|
32
|
-
* neither the explicit opt-out nor a resolution to trust. `no-prd` routes to
|
|
33
|
-
* `escalate`, which is the honest answer and matches the existing default for
|
|
34
|
-
* a status that is missing entirely.
|
|
35
|
-
*/
|
|
36
|
-
export function toAcceptanceStatus(raw: unknown): AcceptanceStatus {
|
|
37
|
-
return ACCEPTANCE_STATUSES.find((s) => s === raw) ?? "no-prd";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface FeatureResolution {
|
|
41
|
-
specPath: string;
|
|
42
|
-
specKind: "markdown" | "prd";
|
|
43
|
-
acceptanceStatus: AcceptanceStatus;
|
|
44
|
-
groups: AcceptanceGroup[];
|
|
45
|
-
/**
|
|
46
|
-
* Test-file classification regexes, as sources, from `nax features resolve`
|
|
47
|
-
* (the ADR-009 SSOT). Empty when the CLI is older than the field or could not
|
|
48
|
-
* resolve them — callers must treat empty as "cannot classify", never as
|
|
49
|
-
* "nothing is a test file".
|
|
50
|
-
*/
|
|
51
|
-
testFileRegex: string[];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* One `nax features resolve` call for the whole flow — it yields both the spec
|
|
56
|
-
* source (for the review prompts) and the acceptance groups (for the gate), so
|
|
57
|
-
* `load_ctx` resolves once and the acceptance node reads the groups off
|
|
58
|
-
* `ctx.outputs.load_ctx` instead of shelling out a second time.
|
|
59
|
-
*/
|
|
60
|
-
export async function resolveFeature(feature: string, workdir: string): Promise<FeatureResolution> {
|
|
61
|
-
const res = await _contextDeps.run(["nax", "features", "resolve", feature, "--json"], { cwd: workdir });
|
|
62
|
-
let parsed: {
|
|
63
|
-
specSource?: { kind: "markdown" | "prd"; path: string };
|
|
64
|
-
acceptance?: { status?: string; groups?: AcceptanceGroup[] };
|
|
65
|
-
testPatterns?: { regex?: string[] };
|
|
66
|
-
};
|
|
67
|
-
try {
|
|
68
|
-
parsed = JSON.parse(res.stdout);
|
|
69
|
-
} catch (cause) {
|
|
70
|
-
throw new FinishError(
|
|
71
|
-
`nax features resolve returned unparseable JSON for "${feature}"`,
|
|
72
|
-
"FINISH_RESOLVE_UNPARSEABLE",
|
|
73
|
-
{ stage: "finish-context", feature, exitCode: res.exitCode, cause },
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
if (!parsed.specSource) {
|
|
77
|
-
throw new FinishError(`nax features resolve returned no specSource for "${feature}"`, "FINISH_SPEC_NOT_FOUND", {
|
|
78
|
-
stage: "finish-context",
|
|
79
|
-
feature,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
specPath: parsed.specSource.path,
|
|
84
|
-
specKind: parsed.specSource.kind,
|
|
85
|
-
acceptanceStatus: toAcceptanceStatus(parsed.acceptance?.status),
|
|
86
|
-
groups: parsed.acceptance?.groups ?? [],
|
|
87
|
-
testFileRegex: parsed.testPatterns?.regex ?? [],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Split paths into test and non-test, using the regexes `nax features resolve`
|
|
93
|
-
* reported.
|
|
94
|
-
*
|
|
95
|
-
* With no patterns (older nax, or a config the resolver choked on) every path is
|
|
96
|
-
* reported as non-test. That is the safe direction for the one caller: the gate
|
|
97
|
-
* loop skips its re-review only for a test-only change, so "cannot classify"
|
|
98
|
-
* must mean "review it", never "skip it".
|
|
99
|
-
*
|
|
100
|
-
* An unparseable regex source is skipped rather than thrown — a bad pattern in
|
|
101
|
-
* one config entry must not take the flow down mid-loop.
|
|
102
|
-
*/
|
|
103
|
-
export function partitionTestFiles(paths: string[], regexSources: string[]): { test: string[]; nonTest: string[] } {
|
|
104
|
-
const matchers: RegExp[] = [];
|
|
105
|
-
for (const src of regexSources) {
|
|
106
|
-
try {
|
|
107
|
-
matchers.push(new RegExp(src));
|
|
108
|
-
} catch {
|
|
109
|
-
// Skip — see the doc comment above.
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const test: string[] = [];
|
|
113
|
-
const nonTest: string[] = [];
|
|
114
|
-
for (const p of paths) {
|
|
115
|
-
(matchers.some((re) => re.test(p)) ? test : nonTest).push(p);
|
|
116
|
-
}
|
|
117
|
-
return { test, nonTest };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export interface PreflightOutcome {
|
|
121
|
-
commitsAhead: number;
|
|
122
|
-
route: "proceed" | "nothing-to-finish" | "escalate";
|
|
123
|
-
/** Set only on `escalate` — why the count could not be trusted. */
|
|
124
|
-
reason?: string;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* How far ahead of the base branch this branch is.
|
|
129
|
-
*
|
|
130
|
-
* A failed count must never be reported as zero. `base` reaches here from
|
|
131
|
-
* `detectBaseBranch`, whose last-resort `origin/master` is returned without
|
|
132
|
-
* being verified — so a repo whose base ref is not fetched locally makes
|
|
133
|
-
* `rev-list` exit non-zero with empty stdout. `Number.parseInt("") || 0` turned
|
|
134
|
-
* that into `0`, indistinguishable from "this branch has no new commits", and
|
|
135
|
-
* the flow reported `nothing-to-finish` having reviewed, verified and pushed
|
|
136
|
-
* nothing. Both the non-zero exit and unreadable output escalate instead: a
|
|
137
|
-
* human can fetch the base, and no fix node can.
|
|
138
|
-
*/
|
|
139
|
-
export async function preflight(workdir: string, base: string): Promise<PreflightOutcome> {
|
|
140
|
-
const res = await _contextDeps.run(["git", "rev-list", "--count", `${base}..HEAD`], { cwd: workdir });
|
|
141
|
-
if (res.exitCode !== 0) {
|
|
142
|
-
const detail = res.stderr.trim() || res.stdout.trim() || `exit ${res.exitCode}`;
|
|
143
|
-
return {
|
|
144
|
-
commitsAhead: 0,
|
|
145
|
-
route: "escalate",
|
|
146
|
-
reason: `Could not count commits against "${base}" — git rev-list failed: ${detail}. The base branch may not exist locally; nax-finish will not treat that as "nothing to finish".`,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
const commitsAhead = Number.parseInt(res.stdout.trim(), 10);
|
|
150
|
-
if (!Number.isFinite(commitsAhead)) {
|
|
151
|
-
return {
|
|
152
|
-
commitsAhead: 0,
|
|
153
|
-
route: "escalate",
|
|
154
|
-
reason: `git rev-list --count ${base}..HEAD exited 0 but printed no readable count: "${res.stdout.trim()}".`,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
return { commitsAhead, route: commitsAhead > 0 ? "proceed" : "nothing-to-finish" };
|
|
158
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { FinishError } from "../errors";
|
|
2
|
-
import { runArgv } from "../exec";
|
|
3
|
-
import type { Finding, RunFn } from "../types";
|
|
4
|
-
import { detectForge, extractUrl, viewArgv } from "./forge";
|
|
5
|
-
|
|
6
|
-
export const _escalateDeps: { run: RunFn } = { run: runArgv };
|
|
7
|
-
|
|
8
|
-
export function buildEscalationComment(feature: string, escalationReason: string, findings: Finding[]): string {
|
|
9
|
-
const lines = [
|
|
10
|
-
`## nax-finish escalation — \`${feature}\``,
|
|
11
|
-
"",
|
|
12
|
-
"This feature needs human judgment before it can ship. nax-finish stopped rather than guess.",
|
|
13
|
-
"",
|
|
14
|
-
`**Needs judgment:** ${escalationReason}`,
|
|
15
|
-
"",
|
|
16
|
-
"### Findings",
|
|
17
|
-
...findings.map((f) => `- **[${f.severity}] ${f.title}** — ${f.problem}\n - Suggested: ${f.fix}`),
|
|
18
|
-
];
|
|
19
|
-
return lines.join("\n");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface EscalationOutcome {
|
|
23
|
-
url?: string;
|
|
24
|
-
/** Where the "needs judgment" message was delivered. */
|
|
25
|
-
channel: "telegram" | "pr-comment";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Deliver an escalation.
|
|
30
|
-
*
|
|
31
|
-
* `preferTelegram` (set by the plugin only when Telegram is both enabled and
|
|
32
|
-
* credentialed) makes Telegram the sole channel: the flow posts no comment and
|
|
33
|
-
* — critically — opens no draft PR to hold one, matching the design's
|
|
34
|
-
* "prefer Telegram when configured, else fall back to a PR/MR comment". The
|
|
35
|
-
* plugin sends the message itself after reading the result file. It still reads
|
|
36
|
-
* any existing PR/MR so the notification can carry a link, which is a read-only
|
|
37
|
-
* lookup with no side effect.
|
|
38
|
-
*/
|
|
39
|
-
export async function postEscalation(
|
|
40
|
-
repoRoot: string,
|
|
41
|
-
branch: string,
|
|
42
|
-
comment: string,
|
|
43
|
-
opts: { preferTelegram?: boolean } = {},
|
|
44
|
-
): Promise<EscalationOutcome> {
|
|
45
|
-
const forge = await detectForge(_escalateDeps.run, repoRoot, "finish-escalate");
|
|
46
|
-
const view = await _escalateDeps.run(viewArgv(forge, branch, "url"), { cwd: repoRoot });
|
|
47
|
-
const existingUrl = view.exitCode === 0 ? extractUrl(view.stdout) : undefined;
|
|
48
|
-
|
|
49
|
-
if (opts.preferTelegram) {
|
|
50
|
-
return { url: existingUrl, channel: "telegram" };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (view.exitCode === 0) {
|
|
54
|
-
const commentCmd =
|
|
55
|
-
forge === "github"
|
|
56
|
-
? ["gh", "pr", "comment", branch, "--body", comment]
|
|
57
|
-
: ["glab", "mr", "note", branch, "--message", comment];
|
|
58
|
-
const commented = await _escalateDeps.run(commentCmd, { cwd: repoRoot });
|
|
59
|
-
if (commented.exitCode !== 0) {
|
|
60
|
-
throw new FinishError(
|
|
61
|
-
`Failed to post escalation comment on "${branch}": ${commented.stderr.trim() || `exit ${commented.exitCode}`}`,
|
|
62
|
-
"FINISH_ESCALATION_COMMENT_FAILED",
|
|
63
|
-
{ stage: "finish-escalate", branch },
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
return { url: existingUrl, channel: "pr-comment" };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const createCmd =
|
|
70
|
-
forge === "github"
|
|
71
|
-
? ["gh", "pr", "create", "--draft", "--title", `nax-finish: ${branch}`, "--body", comment, "--head", branch]
|
|
72
|
-
: [
|
|
73
|
-
"glab",
|
|
74
|
-
"mr",
|
|
75
|
-
"create",
|
|
76
|
-
"--draft",
|
|
77
|
-
"--title",
|
|
78
|
-
`nax-finish: ${branch}`,
|
|
79
|
-
"--description",
|
|
80
|
-
comment,
|
|
81
|
-
"--source-branch",
|
|
82
|
-
branch,
|
|
83
|
-
];
|
|
84
|
-
const create = await _escalateDeps.run(createCmd, { cwd: repoRoot });
|
|
85
|
-
if (create.exitCode !== 0) {
|
|
86
|
-
throw new FinishError(
|
|
87
|
-
`Failed to open a draft to hold the escalation for "${branch}": ${create.stderr.trim() || `exit ${create.exitCode}`}`,
|
|
88
|
-
"FINISH_ESCALATION_DRAFT_FAILED",
|
|
89
|
-
{ stage: "finish-escalate", branch },
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return { url: extractUrl(create.stdout), channel: "pr-comment" };
|
|
93
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared forge (GitHub / GitLab) helpers for the nax-finish flow.
|
|
3
|
-
*
|
|
4
|
-
* `run` is passed in by the caller rather than held here so each step keeps a
|
|
5
|
-
* single injectable `_deps` object for tests.
|
|
6
|
-
*/
|
|
7
|
-
import { FinishError } from "../errors";
|
|
8
|
-
import type { RunFn } from "../types";
|
|
9
|
-
|
|
10
|
-
/** Matches the first http(s) URL on a line — `gh`/`glab` print the URL on stdout. */
|
|
11
|
-
const URL_REGEX = /https?:\/\/\S+/;
|
|
12
|
-
|
|
13
|
-
export type Forge = "github" | "gitlab";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Host of a git remote, for both URL forms git accepts:
|
|
17
|
-
* `git@host:path` (scp-like) and `scheme://[user@]host[:port]/path`.
|
|
18
|
-
*/
|
|
19
|
-
export function remoteHost(remoteUrl: string): string {
|
|
20
|
-
const scp = remoteUrl.match(/^[^/]*@([^:/]+):/);
|
|
21
|
-
if (scp?.[1]) return scp[1].toLowerCase();
|
|
22
|
-
const url = remoteUrl.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/]+)/i);
|
|
23
|
-
return url?.[1]?.toLowerCase() ?? "";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Classify by host name.
|
|
28
|
-
*
|
|
29
|
-
* Matching the host (not a substring of the whole URL) is what makes
|
|
30
|
-
* self-hosted instances work: `"gitlab.mycorp.com".includes("gitlab.com")` is
|
|
31
|
-
* false, so the previous check rejected every self-hosted forge. GitHub is
|
|
32
|
-
* tested first purely for determinism on an absurd host naming both.
|
|
33
|
-
*/
|
|
34
|
-
function forgeFromHost(host: string): Forge | null {
|
|
35
|
-
if (host.includes("github")) return "github";
|
|
36
|
-
if (host.includes("gitlab")) return "gitlab";
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Last resort for an enterprise host that names neither forge (`git.corp.com`):
|
|
42
|
-
* ask which CLI is installed. Only decisive when exactly one is — with both or
|
|
43
|
-
* neither present a guess would send `gh` at a GitLab remote.
|
|
44
|
-
*/
|
|
45
|
-
async function forgeFromCli(run: RunFn, repoRoot: string): Promise<Forge | null> {
|
|
46
|
-
const [gh, glab] = await Promise.all([
|
|
47
|
-
run(["gh", "--version"], { cwd: repoRoot }),
|
|
48
|
-
run(["glab", "--version"], { cwd: repoRoot }),
|
|
49
|
-
]);
|
|
50
|
-
const hasGh = gh.exitCode === 0;
|
|
51
|
-
const hasGlab = glab.exitCode === 0;
|
|
52
|
-
if (hasGh && !hasGlab) return "github";
|
|
53
|
-
if (hasGlab && !hasGh) return "gitlab";
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
|
|
58
|
-
const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
|
|
59
|
-
const remoteUrl = remote.stdout.trim();
|
|
60
|
-
const host = remoteHost(remoteUrl);
|
|
61
|
-
|
|
62
|
-
const byHost = forgeFromHost(host);
|
|
63
|
-
if (byHost) return byHost;
|
|
64
|
-
|
|
65
|
-
const byCli = await forgeFromCli(run, repoRoot);
|
|
66
|
-
if (byCli) return byCli;
|
|
67
|
-
|
|
68
|
-
throw new FinishError(
|
|
69
|
-
`Unable to determine forge for remote host "${host || remoteUrl}" — its name matches neither github nor gitlab, and the gh/glab probe was inconclusive`,
|
|
70
|
-
"FINISH_UNKNOWN_FORGE",
|
|
71
|
-
{ stage, remoteUrl, host },
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
|
|
76
|
-
export function extractUrl(stdout: string): string | undefined {
|
|
77
|
-
try {
|
|
78
|
-
const parsed = JSON.parse(stdout) as { url?: string; web_url?: string };
|
|
79
|
-
if (parsed.url) return parsed.url;
|
|
80
|
-
if (parsed.web_url) return parsed.web_url;
|
|
81
|
-
} catch {
|
|
82
|
-
// fall through to regex extraction
|
|
83
|
-
}
|
|
84
|
-
const match = stdout.match(URL_REGEX);
|
|
85
|
-
return match ? match[0] : undefined;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Argv for reading the branch's existing PR/MR as JSON. */
|
|
89
|
-
export function viewArgv(forge: Forge, branch: string, githubFields: string): string[] {
|
|
90
|
-
return forge === "github"
|
|
91
|
-
? ["gh", "pr", "view", branch, "--json", githubFields]
|
|
92
|
-
: ["glab", "mr", "view", branch, "--output", "json"];
|
|
93
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The two gate nodes — `acceptance` and `quality_gates`.
|
|
3
|
-
*
|
|
4
|
-
* Split out of `nax-finish.flow.ts`, which sits against the 600-line source
|
|
5
|
-
* cap. They belong together: both answer the same question ("did anything
|
|
6
|
-
* actually verify this tree?"), both enforce the same rule that **nothing ran
|
|
7
|
-
* is not a pass**, and both route on the shared `MAX_FIX_ATTEMPTS` cap.
|
|
8
|
-
*
|
|
9
|
-
* Keeping them out of the flow file also makes them callable in tests without
|
|
10
|
-
* reaching through `flow.nodes.*`.
|
|
11
|
-
*/
|
|
12
|
-
import { fixAttemptCount, inputOf, loadCtxOf } from "../flow-ctx";
|
|
13
|
-
import { MAX_FIX_ATTEMPTS } from "../verdict";
|
|
14
|
-
import { runAcceptanceGate } from "./acceptance";
|
|
15
|
-
import { type QualityCommands, loadQualityCommands, runQualityGates } from "./quality";
|
|
16
|
-
|
|
17
|
-
/** The slice of an acpx `FlowNodeContext` these nodes read. */
|
|
18
|
-
export interface GateNodeCtx {
|
|
19
|
-
input: unknown;
|
|
20
|
-
outputs: unknown;
|
|
21
|
-
state: { steps: { nodeId: string }[] };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface AcceptanceNodeOutput {
|
|
25
|
-
route: string;
|
|
26
|
-
reason?: string;
|
|
27
|
-
output: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface QualityGatesNodeOutput {
|
|
31
|
-
route: string;
|
|
32
|
-
reason?: string;
|
|
33
|
-
ran: string[];
|
|
34
|
-
failing: string[];
|
|
35
|
-
output: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The repo's explicit opt-out, as reported by `nax features resolve`. */
|
|
39
|
-
function acceptanceDisabled(ctx: GateNodeCtx): boolean {
|
|
40
|
-
return loadCtxOf(ctx).acceptanceStatus === "disabled";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
45
|
-
*
|
|
46
|
-
* "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
|
|
47
|
-
* unconfigured repo. `nax features resolve` reports `groups: []` for `no-prd`,
|
|
48
|
-
* for `disabled`, **and** for an `ok` resolution whose PRD grouped to no
|
|
49
|
-
* package at all; it reports `exists: false` for a group whose test was
|
|
50
|
-
* expected at its canonical path but never generated. Treating any of those as
|
|
51
|
-
* green let the flow open a ready PR having verified nothing about the
|
|
52
|
-
* feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
|
|
53
|
-
* — skips cleanly.
|
|
54
|
-
*/
|
|
55
|
-
export async function acceptanceGateNode(ctx: GateNodeCtx): Promise<AcceptanceNodeOutput> {
|
|
56
|
-
const i = inputOf(ctx);
|
|
57
|
-
const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
|
|
58
|
-
if (acceptanceStatus === "disabled") {
|
|
59
|
-
return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
|
|
60
|
-
}
|
|
61
|
-
if (acceptanceStatus === "no-prd") {
|
|
62
|
-
return {
|
|
63
|
-
route: "escalate",
|
|
64
|
-
reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
|
|
65
|
-
output: "[acceptance] no prd.json resolved — acceptance targets unknown",
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
70
|
-
if (r.passed) {
|
|
71
|
-
// A real failure below routes to the fix loop, which is more actionable;
|
|
72
|
-
// the coverage hole is only reported once the runnable groups are green.
|
|
73
|
-
if (r.missing.length > 0) {
|
|
74
|
-
return {
|
|
75
|
-
route: "escalate",
|
|
76
|
-
reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
|
|
77
|
-
output: r.output,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
// Passed, nothing missing, and nothing ran: the resolver produced no group
|
|
81
|
-
// to run at all. `status: "ok"` does NOT imply a target exists — it means
|
|
82
|
-
// the PRD loaded — so this is the one remaining way an empty gate reports
|
|
83
|
-
// green. Escalating matches what `runQualityGates` does for a repo with no
|
|
84
|
-
// configured commands: an LLM fix node cannot invent the missing target.
|
|
85
|
-
if (r.ran === 0) {
|
|
86
|
-
return {
|
|
87
|
-
route: "escalate",
|
|
88
|
-
reason: `No acceptance test target resolved for "${i.feature}" (status: ${acceptanceStatus ?? "unknown"}) — nothing verified its contract.`,
|
|
89
|
-
output: r.output,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return { route: "proceed", output: r.output };
|
|
93
|
-
}
|
|
94
|
-
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
95
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
96
|
-
return {
|
|
97
|
-
route: "escalate",
|
|
98
|
-
reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
|
|
99
|
-
output: r.output,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
return { route: "fix", output: r.output };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Acceptance is gate zero here, not just at the `acceptance` node.
|
|
107
|
-
*
|
|
108
|
-
* Both fix loops that run after it — quality review and this gate — edit code,
|
|
109
|
-
* and the repo-root `test` command does not cover the feature's acceptance
|
|
110
|
-
* tests: they live under `<pkg>/.nax/features/<f>/` and usually need their own
|
|
111
|
-
* runner config. Re-running them here is what makes "nothing reaches open_pr
|
|
112
|
-
* without the feature's own contract passing against the tree as it will ship"
|
|
113
|
-
* true on every path (#1398).
|
|
114
|
-
*
|
|
115
|
-
* Unconditional apart from the repo's own opt-out, though the common green path
|
|
116
|
-
* re-runs a gate that already passed: acceptance is the cheapest gate in the
|
|
117
|
-
* pipeline, and a conditional skip derived from step history would be a check
|
|
118
|
-
* that can be *wrong* — a silent false green, the failure mode this exists to
|
|
119
|
-
* prevent. The `disabled` skip is not such a derivation: it is the same
|
|
120
|
-
* resolver field the `acceptance` node already honours, and the two nodes
|
|
121
|
-
* disagreeing about who owns the opt-out is its own bug.
|
|
122
|
-
*
|
|
123
|
-
* `missing` is deliberately ignored: groups are resolved once at load_ctx, so a
|
|
124
|
-
* coverage hole was already escalated by the acceptance node and cannot appear
|
|
125
|
-
* here.
|
|
126
|
-
*/
|
|
127
|
-
async function reverifyAcceptance(ctx: GateNodeCtx): Promise<QualityGatesNodeOutput | null> {
|
|
128
|
-
if (acceptanceDisabled(ctx)) return null;
|
|
129
|
-
const i = inputOf(ctx);
|
|
130
|
-
const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
|
|
131
|
-
timeoutMs: i.timeouts?.acceptanceMs,
|
|
132
|
-
});
|
|
133
|
-
if (acc.passed) return null;
|
|
134
|
-
// Short-circuit: the repo gates are re-run next round anyway, and skipping
|
|
135
|
-
// them keeps this out of the "nothing configured" branch below, which would
|
|
136
|
-
// otherwise misreport configured-but-skipped commands as absent.
|
|
137
|
-
const attempts = fixAttemptCount(ctx, "fix_gate");
|
|
138
|
-
const failing = ["acceptance"];
|
|
139
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
140
|
-
return {
|
|
141
|
-
route: "escalate",
|
|
142
|
-
reason: `A later fix broke the feature's own contract: acceptance still failing after ${attempts} fix attempts.`,
|
|
143
|
-
ran: [],
|
|
144
|
-
failing,
|
|
145
|
-
output: acc.output,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return { route: "fix", ran: [], failing, output: acc.output };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export async function qualityGatesNode(ctx: GateNodeCtx): Promise<QualityGatesNodeOutput> {
|
|
152
|
-
const i = inputOf(ctx);
|
|
153
|
-
|
|
154
|
-
const accFailure = await reverifyAcceptance(ctx);
|
|
155
|
-
if (accFailure) return accFailure;
|
|
156
|
-
|
|
157
|
-
const cmds: QualityCommands = await loadQualityCommands(i.workdir);
|
|
158
|
-
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
159
|
-
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
160
|
-
// Nothing configured is not a pass — escalate immediately rather than open a
|
|
161
|
-
// "ready" PR having verified nothing. An LLM fix node cannot invent the
|
|
162
|
-
// repo's build/test commands.
|
|
163
|
-
if (r.ran.length === 0) {
|
|
164
|
-
return {
|
|
165
|
-
route: "escalate",
|
|
166
|
-
reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
|
|
167
|
-
ran: r.ran,
|
|
168
|
-
failing: r.failing,
|
|
169
|
-
output: r.output,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const attempts = fixAttemptCount(ctx, "fix_gate");
|
|
173
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
174
|
-
return {
|
|
175
|
-
route: "escalate",
|
|
176
|
-
reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
|
|
177
|
-
ran: r.ran,
|
|
178
|
-
failing: r.failing,
|
|
179
|
-
output: r.output,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
|
|
183
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Branch synchronisation for the nax-finish flow.
|
|
3
|
-
*
|
|
4
|
-
* Every fix node edits the working tree in place, which two different consumers
|
|
5
|
-
* would otherwise miss:
|
|
6
|
-
*
|
|
7
|
-
* - The **reviewers** read `git diff <base>...HEAD` — committed history only.
|
|
8
|
-
* With the fixes uncommitted, every re-review re-read the pre-fix code and
|
|
9
|
-
* re-reported findings the fix node had already resolved, so the loop could
|
|
10
|
-
* never converge and always escalated at the fix cap (issue #1397). Each
|
|
11
|
-
* `commit_*` node calls `commitFixes` for this reason.
|
|
12
|
-
* - The **forge**: `gh pr create --head <branch>` opens a PR from the *remote*
|
|
13
|
-
* branch, and an escalation comment would describe state nobody else can see.
|
|
14
|
-
* Both terminal nodes call `commitAndPush` before touching the forge.
|
|
15
|
-
*/
|
|
16
|
-
import { FinishError } from "../errors";
|
|
17
|
-
import { runArgv } from "../exec";
|
|
18
|
-
import type { RunFn } from "../types";
|
|
19
|
-
|
|
20
|
-
export const _gitDeps: { run: RunFn } = { run: runArgv };
|
|
21
|
-
|
|
22
|
-
export interface SyncOutcome {
|
|
23
|
-
/** True when the flow's fixes produced a new commit. */
|
|
24
|
-
committed: boolean;
|
|
25
|
-
/** True when the branch was pushed (always attempted, so the forge sees HEAD). */
|
|
26
|
-
pushed: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function isDirty(repoRoot: string): Promise<boolean> {
|
|
30
|
-
const status = await _gitDeps.run(["git", "status", "--porcelain"], { cwd: repoRoot });
|
|
31
|
-
if (status.exitCode !== 0) {
|
|
32
|
-
throw new FinishError(
|
|
33
|
-
`git status failed in "${repoRoot}": ${status.stderr.trim() || `exit ${status.exitCode}`}`,
|
|
34
|
-
"FINISH_GIT_STATUS_FAILED",
|
|
35
|
-
{ stage: "finish-git", repoRoot },
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
return status.stdout.trim().length > 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Commit the working tree, if it has anything in it, without pushing.
|
|
43
|
-
*
|
|
44
|
-
* Called by the `commit_*` nodes after every fix node so the next reviewer's
|
|
45
|
-
* `git diff <base>...HEAD` contains the fix. `git add -A` (not `-u`) because a
|
|
46
|
-
* fix routinely adds a *new* test file, and an untracked file is invisible to
|
|
47
|
-
* that diff — which is also why committing beats widening the reviewer's diff
|
|
48
|
-
* to include the working tree.
|
|
49
|
-
*
|
|
50
|
-
* `skipHooks` (used by every mid-loop `commit_*` node) adds `--no-verify`. Those
|
|
51
|
-
* commits are internal checkpoints, not shipped history: a repo whose
|
|
52
|
-
* pre-commit hook runs lint or typecheck would otherwise reject an intermediate
|
|
53
|
-
* state — a lint error the gate loop was about to fix — and take the whole flow
|
|
54
|
-
* down with it, with no result file. Nothing is lost by skipping them, because
|
|
55
|
-
* `quality_gates` runs the repo's own build/typecheck/lint/test and no PR opens
|
|
56
|
-
* unless they are green. The terminal `commitAndPush` leaves hooks enabled.
|
|
57
|
-
*
|
|
58
|
-
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
|
-
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
|
-
*/
|
|
61
|
-
/** Current HEAD sha, or null outside a repo / on an unborn branch. */
|
|
62
|
-
async function headSha(repoRoot: string): Promise<string | null> {
|
|
63
|
-
const res = await _gitDeps.run(["git", "rev-parse", "HEAD"], { cwd: repoRoot });
|
|
64
|
-
return res.exitCode === 0 ? res.stdout.trim() || null : null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Repo-root-relative paths touched by a commit.
|
|
69
|
-
*
|
|
70
|
-
* `--format=` suppresses the header so the output is just the file list.
|
|
71
|
-
* Failure yields `[]`, which the gate loop reads as "cannot tell what changed"
|
|
72
|
-
* and therefore reviews — see `partitionTestFiles`.
|
|
73
|
-
*/
|
|
74
|
-
export async function filesInCommit(repoRoot: string, sha: string): Promise<string[]> {
|
|
75
|
-
const res = await _gitDeps.run(["git", "show", "--name-only", "--format=", sha], { cwd: repoRoot });
|
|
76
|
-
if (res.exitCode !== 0) return [];
|
|
77
|
-
return res.stdout
|
|
78
|
-
.split("\n")
|
|
79
|
-
.map((l) => l.trim())
|
|
80
|
-
.filter((l) => l.length > 0);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function commitFixes(
|
|
84
|
-
repoRoot: string,
|
|
85
|
-
message: string,
|
|
86
|
-
opts: { skipHooks?: boolean } = {},
|
|
87
|
-
): Promise<{ committed: boolean; shaBefore: string | null; shaAfter: string | null }> {
|
|
88
|
-
const shaBefore = await headSha(repoRoot);
|
|
89
|
-
if (!(await isDirty(repoRoot))) return { committed: false, shaBefore, shaAfter: shaBefore };
|
|
90
|
-
|
|
91
|
-
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
92
|
-
if (add.exitCode !== 0) {
|
|
93
|
-
throw new FinishError(
|
|
94
|
-
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
95
|
-
"FINISH_GIT_ADD_FAILED",
|
|
96
|
-
{ stage: "finish-git", repoRoot },
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
const commitArgv = ["git", "commit", "-m", message, ...(opts.skipHooks ? ["--no-verify"] : [])];
|
|
100
|
-
const commit = await _gitDeps.run(commitArgv, { cwd: repoRoot });
|
|
101
|
-
if (commit.exitCode !== 0) {
|
|
102
|
-
throw new FinishError(
|
|
103
|
-
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
104
|
-
"FINISH_GIT_COMMIT_FAILED",
|
|
105
|
-
{ stage: "finish-git", repoRoot },
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
return { committed: true, shaBefore, shaAfter: await headSha(repoRoot) };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Commit any outstanding fixes and push the branch to `origin`.
|
|
113
|
-
*
|
|
114
|
-
* The push is unconditional — even with nothing new to commit the local branch
|
|
115
|
-
* may be ahead of its remote (nax's own run commits, or the `commit_*` nodes'
|
|
116
|
-
* per-round commits), and the PR must reflect HEAD.
|
|
117
|
-
*/
|
|
118
|
-
export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
|
|
119
|
-
const { committed } = await commitFixes(repoRoot, message);
|
|
120
|
-
|
|
121
|
-
const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
|
|
122
|
-
if (push.exitCode !== 0) {
|
|
123
|
-
throw new FinishError(
|
|
124
|
-
`git push of "${branch}" failed: ${push.stderr.trim() || `exit ${push.exitCode}`}`,
|
|
125
|
-
"FINISH_GIT_PUSH_FAILED",
|
|
126
|
-
{ stage: "finish-git", repoRoot, branch, committed },
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return { committed, pushed: true };
|
|
130
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export * from "./context";
|
|
2
|
-
export * from "./acceptance";
|
|
3
|
-
export * from "./quality";
|
|
4
|
-
export * from "./gates";
|
|
5
|
-
export * from "./escalate";
|
|
6
|
-
export * from "./forge";
|
|
7
|
-
export * from "./git";
|
|
8
|
-
export * from "./pr";
|
|
9
|
-
export * from "./pr-narrative";
|
|
10
|
-
export * from "./commit-round";
|
|
11
|
-
export * from "./result";
|
|
12
|
-
export * from "./review-audit";
|
|
13
|
-
export * from "./review-round";
|