@nathapp/nax 0.77.3 → 0.79.0

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.
@@ -0,0 +1,183 @@
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,6 +1,7 @@
1
1
  export * from "./context";
2
2
  export * from "./acceptance";
3
3
  export * from "./quality";
4
+ export * from "./gates";
4
5
  export * from "./escalate";
5
6
  export * from "./forge";
6
7
  export * from "./git";
@@ -80,16 +80,17 @@ export type FinishRoundOutcome =
80
80
  */
81
81
  | "no-reviewer"
82
82
  /**
83
- * A re-review was owed and deliberately skipped — today only a `gate` fix
84
- * that touched test files exclusively (`gateCommitRoute` → `tests-only`).
83
+ * A re-review was owed and deliberately skipped.
85
84
  *
86
- * Distinct from `no-reviewer`, which the same node writes when the fix *is*
87
- * routed on to `review_quality`. Without the distinction both wrote
88
- * `no-reviewer`, so the audit could not tell a gate fix that was re-reviewed
89
- * from one whose re-review was skipped by policy — the #1507 failure mode
90
- * surviving on the one path where the omission is intentional, and the path
91
- * where a reader most needs to know. Recording it is also what makes "how
92
- * often does this fire?" answerable before anyone decides to close the hole.
85
+ * **No longer emitted.** It described the `gate` `tests-only` route, which
86
+ * skipped `review_quality` as a cost tradeoff; #1510 closed that hole, so
87
+ * every committed gate fix is now re-reviewed and nothing writes this.
88
+ *
89
+ * Retained because the audit trail is read, not just written: a project that
90
+ * ran an earlier nax can hold rounds carrying this outcome, and dropping it
91
+ * from the union would make those unrenderable. Do not reuse the name for a
92
+ * new meaning — a reader hitting it in an old artifact must still be told
93
+ * what it meant when it was written.
93
94
  */
94
95
  | "review-skipped";
95
96
 
@@ -106,6 +107,19 @@ export interface FinishRound {
106
107
  findings: Finding[];
107
108
  /** Gate commands that were red this round (gate phase). */
108
109
  failing?: string[];
110
+ /**
111
+ * The successor this round's commit routed to — `changed` / `tests-only` /
112
+ * `unchanged` for `gate`, `changed` / `unchanged` elsewhere.
113
+ *
114
+ * Recorded because `outcome` stopped carrying it. Until #1510 a tests-only
115
+ * gate fix was the only round writing `review-skipped`, so the outcome
116
+ * doubled as the classification; now every committed gate fix is reviewed
117
+ * and writes `no-reviewer`, which would leave "what did this fix touch?"
118
+ * unanswerable from the trail. That question is the input to deciding
119
+ * whether the re-review ever needs a cheaper, test-scoped form, so it has to
120
+ * survive the round it was computed in.
121
+ */
122
+ route?: string;
109
123
  /**
110
124
  * `HEAD` SHA after this round's commit (set only when `committed`); absent
111
125
  * on no-op rounds so a reader can distinguish "no commit" from "record lost".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.77.3",
3
+ "version": "0.79.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,13 +39,22 @@
39
39
  "test:e2e": "timeout -k 5s 180s bun test test/e2e/ --timeout=60000",
40
40
  "test:coverage": "bun run scripts/check-coverage.ts",
41
41
  "test:coverage:report": "bun run scripts/check-coverage.ts --report",
42
- "check-test-overlap": "bun run scripts/check-test-overlap.ts",
43
- "check-dead-tests": "bun run scripts/check-dead-tests.ts",
44
- "check:test-sizes": "bun run scripts/check-test-sizes.ts",
42
+ "report:test-overlap": "bun run scripts/report-test-overlap.ts",
43
+ "report:dead-tests": "bun run scripts/report-dead-tests.ts",
45
44
  "check:test-mocks": "bun scripts/check-inline-test-mocks.ts --strict",
46
45
  "check:process-cwd": "bash scripts/check-process-cwd.sh",
47
46
  "check:no-adapter-wrap": "bash scripts/check-no-adapter-wrap.sh",
48
47
  "check:dispatch-context": "bash scripts/check-dispatch-context.sh",
48
+ "check:naxconfig-cast": "bash scripts/check-no-silent-naxconfig-cast.sh",
49
+ "check:runtime-cleanup": "bash scripts/check-runtime-cleanup.sh",
50
+ "check:scripts": "bash scripts/check-scripts.sh",
51
+ "check:adapter-no-config-import": "bash scripts/check-adapter-no-config-import.sh",
52
+ "check:test-typecheck": "bun run scripts/check-test-typecheck.ts",
53
+ "check:test-typecheck:update": "bun run scripts/check-test-typecheck.ts --update-baseline",
54
+ "check:test-as-unknown-as": "bun run scripts/check-test-as-unknown-as.ts",
55
+ "check:test-as-unknown-as:update": "bun run scripts/check-test-as-unknown-as.ts --update-baseline",
56
+ "check:gate-reachability": "bun run scripts/check-gate-reachability.ts",
57
+ "check:all": "bun run lint && bun run check:test-mocks && bun run check:process-cwd && bun run check:no-adapter-wrap && bun run check:scripts && bun run check:dispatch-context && bun run check:naxconfig-cast && bun run check:runtime-cleanup && bun run check:adapter-no-config-import && bun run check:test-typecheck && bun run check:test-as-unknown-as && bun run check:gate-reachability",
49
58
  "prepublishOnly": "bun run build",
50
59
  "test:full": "FULL=1 NAX_PRECHECK=1 bun test test/ --timeout=60000"
51
60
  },