@yagni-app/code-staging 0.0.0 → 0.1.0-staging.1002.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/LICENSE.md +55 -0
- package/README.md +166 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/claudeCompat.d.ts +78 -0
- package/dist/claudeCompat.js +198 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +326 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +28 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +75 -0
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.js +45 -0
- package/dist/doctor.d.ts +94 -0
- package/dist/doctor.js +343 -0
- package/dist/extension/advisor.d.ts +84 -0
- package/dist/extension/advisor.js +101 -0
- package/dist/extension/askAdvisorTool.d.ts +83 -0
- package/dist/extension/askAdvisorTool.js +188 -0
- package/dist/extension/askYagniTool.d.ts +39 -0
- package/dist/extension/askYagniTool.js +63 -0
- package/dist/extension/bless.d.ts +45 -0
- package/dist/extension/bless.js +74 -0
- package/dist/extension/branding.d.ts +48 -0
- package/dist/extension/branding.js +119 -0
- package/dist/extension/chipEditor.d.ts +117 -0
- package/dist/extension/chipEditor.js +369 -0
- package/dist/extension/config.d.ts +112 -0
- package/dist/extension/config.js +108 -0
- package/dist/extension/costHud.d.ts +73 -0
- package/dist/extension/costHud.js +123 -0
- package/dist/extension/decisionCapture.d.ts +52 -0
- package/dist/extension/decisionCapture.js +66 -0
- package/dist/extension/decisions.d.ts +83 -0
- package/dist/extension/decisions.js +200 -0
- package/dist/extension/diagnostics.d.ts +41 -0
- package/dist/extension/diagnostics.js +110 -0
- package/dist/extension/index.d.ts +116 -0
- package/dist/extension/index.js +388 -0
- package/dist/extension/initDone.d.ts +28 -0
- package/dist/extension/initDone.js +66 -0
- package/dist/extension/initPass.d.ts +152 -0
- package/dist/extension/initPass.js +394 -0
- package/dist/extension/nextWorkTool.d.ts +51 -0
- package/dist/extension/nextWorkTool.js +80 -0
- package/dist/extension/permission.d.ts +91 -0
- package/dist/extension/permission.js +236 -0
- package/dist/extension/pipeline/activity.d.ts +37 -0
- package/dist/extension/pipeline/activity.js +151 -0
- package/dist/extension/pipeline/activityFeed.d.ts +59 -0
- package/dist/extension/pipeline/activityFeed.js +175 -0
- package/dist/extension/pipeline/budget.d.ts +48 -0
- package/dist/extension/pipeline/budget.js +68 -0
- package/dist/extension/pipeline/checkpoint.d.ts +31 -0
- package/dist/extension/pipeline/checkpoint.js +176 -0
- package/dist/extension/pipeline/eval.d.ts +168 -0
- package/dist/extension/pipeline/eval.js +182 -0
- package/dist/extension/pipeline/events.d.ts +56 -0
- package/dist/extension/pipeline/events.js +147 -0
- package/dist/extension/pipeline/findings.d.ts +42 -0
- package/dist/extension/pipeline/findings.js +144 -0
- package/dist/extension/pipeline/finish.d.ts +128 -0
- package/dist/extension/pipeline/finish.js +307 -0
- package/dist/extension/pipeline/goCommand.d.ts +128 -0
- package/dist/extension/pipeline/goCommand.js +972 -0
- package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
- package/dist/extension/pipeline/goCompareCommand.js +184 -0
- package/dist/extension/pipeline/goFlags.d.ts +40 -0
- package/dist/extension/pipeline/goFlags.js +46 -0
- package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
- package/dist/extension/pipeline/goStatusCommands.js +188 -0
- package/dist/extension/pipeline/invocation.d.ts +45 -0
- package/dist/extension/pipeline/invocation.js +64 -0
- package/dist/extension/pipeline/orchestrator.d.ts +131 -0
- package/dist/extension/pipeline/orchestrator.js +636 -0
- package/dist/extension/pipeline/personas.d.ts +44 -0
- package/dist/extension/pipeline/personas.js +248 -0
- package/dist/extension/pipeline/resilience.d.ts +85 -0
- package/dist/extension/pipeline/resilience.js +166 -0
- package/dist/extension/pipeline/resume.d.ts +18 -0
- package/dist/extension/pipeline/resume.js +106 -0
- package/dist/extension/pipeline/runRegistry.d.ts +112 -0
- package/dist/extension/pipeline/runRegistry.js +202 -0
- package/dist/extension/pipeline/runSession.d.ts +152 -0
- package/dist/extension/pipeline/runSession.js +167 -0
- package/dist/extension/pipeline/runState.d.ts +158 -0
- package/dist/extension/pipeline/runState.js +264 -0
- package/dist/extension/pipeline/runner.d.ts +79 -0
- package/dist/extension/pipeline/runner.js +298 -0
- package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
- package/dist/extension/pipeline/scrubSecrets.js +32 -0
- package/dist/extension/pipeline/stages.d.ts +51 -0
- package/dist/extension/pipeline/stages.js +113 -0
- package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
- package/dist/extension/pipeline/ticketResolution.js +75 -0
- package/dist/extension/pipeline/types.d.ts +436 -0
- package/dist/extension/pipeline/types.js +48 -0
- package/dist/extension/pipeline/verify.d.ts +277 -0
- package/dist/extension/pipeline/verify.js +758 -0
- package/dist/extension/pipeline/workspace.d.ts +32 -0
- package/dist/extension/pipeline/workspace.js +73 -0
- package/dist/extension/pipeline/worktree.d.ts +107 -0
- package/dist/extension/pipeline/worktree.js +200 -0
- package/dist/extension/provider.d.ts +11 -0
- package/dist/extension/provider.js +26 -0
- package/dist/extension/recall.d.ts +93 -0
- package/dist/extension/recall.js +187 -0
- package/dist/extension/recordContextTool.d.ts +38 -0
- package/dist/extension/recordContextTool.js +85 -0
- package/dist/extension/recordDecisionTool.d.ts +52 -0
- package/dist/extension/recordDecisionTool.js +102 -0
- package/dist/extension/repoDocs.d.ts +81 -0
- package/dist/extension/repoDocs.js +260 -0
- package/dist/extension/resilientFetch.d.ts +60 -0
- package/dist/extension/resilientFetch.js +133 -0
- package/dist/extension/reviewTool.d.ts +34 -0
- package/dist/extension/reviewTool.js +81 -0
- package/dist/extension/spool.d.ts +92 -0
- package/dist/extension/spool.js +266 -0
- package/dist/extension/stateHome.d.ts +2 -0
- package/dist/extension/stateHome.js +6 -0
- package/dist/extension/subagents.d.ts +135 -0
- package/dist/extension/subagents.js +281 -0
- package/dist/extension/surface.d.ts +10 -0
- package/dist/extension/surface.js +12 -0
- package/dist/extension/todos.d.ts +110 -0
- package/dist/extension/todos.js +217 -0
- package/dist/extension/tokenProvider.d.ts +93 -0
- package/dist/extension/tokenProvider.js +234 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.js +111 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +87 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +222 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/dist/upgrade.d.ts +97 -0
- package/dist/upgrade.js +284 -0
- package/package.json +39 -3
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P4 - the deterministic, non-LLM verify gate so "clean" means "builds".
|
|
3
|
+
*
|
|
4
|
+
* The /go review half is judgment (LLM lenses). Judgment can like a change that
|
|
5
|
+
* does not compile: the recorded false-clean defect. This module runs each changed
|
|
6
|
+
* package's own secret-independent build check (`typecheck`/`lint`) via `execFile`
|
|
7
|
+
* and turns a non-zero exit into `critical` Findings that flow through the SAME
|
|
8
|
+
* review -> fix loop, so the loop cannot stop `clean` while the build is broken and
|
|
9
|
+
* the fix stage gets the real errors.
|
|
10
|
+
*
|
|
11
|
+
* `detectVerifyCommands`, `detectVerifyTestCommands`, `parseVerifyFailures`,
|
|
12
|
+
* `classifyTestFailure`, `isFannedOutScript`, `parseChangedPaths` and
|
|
13
|
+
* `resolvePackageDirs` are PURE (unit-tested directly). `makeRunVerify` is the
|
|
14
|
+
* thin impure runner; it shells out (pi's ExtensionContext exposes no exec,
|
|
15
|
+
* mirroring workspace.ts) and is wrapped with R1's timeout composition
|
|
16
|
+
* (`composeAbortSignal`) so a slow or flaky suite is bounded.
|
|
17
|
+
*
|
|
18
|
+
* Scoped-by-default: in a monorepo the gate runs EVERY changed package's own
|
|
19
|
+
* check in that package's dir, never the repo-root recursive runner (`pnpm -r
|
|
20
|
+
* test`, `turbo`, an e2e suite). The changed packages are found by reusing
|
|
21
|
+
* workspace.ts's git seam MINUS the tree's pre-run dirt, so judgment stays
|
|
22
|
+
* scoped to THIS run's real diff and an unrelated dirty package never retargets
|
|
23
|
+
* the gate.
|
|
24
|
+
*
|
|
25
|
+
* TWO HALVES (spec §3d). The TYPECHECK half runs each changed package's
|
|
26
|
+
* secret-independent build check (`typecheck`/`lint`, never `test`) under the
|
|
27
|
+
* scrubbed {@link buildVerifyEnv}; its behavior is frozen (invariant §0.2). The
|
|
28
|
+
* TEST half then runs each changed package's own `test` script under the user's
|
|
29
|
+
* real environment MINUS every `YAGNI_*` var and NODE_OPTIONS
|
|
30
|
+
* ({@link buildTestEnv}) — tests legitimately need the user's DB / local infra,
|
|
31
|
+
* but repo-defined commands never see YAGNI tokens (§0.9). Test output is
|
|
32
|
+
* classified by the pure {@link classifyTestFailure}: parseable test failures
|
|
33
|
+
* become `critical` findings through the same review->fix loop; infra/env
|
|
34
|
+
* failures (connection refused, missing env, DB/auth, command not found) and
|
|
35
|
+
* anything UNCERTAIN fail OPEN with an honest note, never a never-clearable
|
|
36
|
+
* critical.
|
|
37
|
+
*
|
|
38
|
+
* Safe-by-default and FAIL-OPEN: if no scoped, non-fanned-out command is
|
|
39
|
+
* detectable, or the command cannot be spawned, or it times out, the gate
|
|
40
|
+
* produces no findings and records an honest note rather than blocking a real
|
|
41
|
+
* change on its own inability to run. Repo-local `.yagni-code/verify` overrides
|
|
42
|
+
* are opt-in only and, when active, ARE the whole gate (they cover the test half
|
|
43
|
+
* too — no detected `test` script runs alongside an override). All captured
|
|
44
|
+
* command output passes through {@link scrubSecrets} before it can enter
|
|
45
|
+
* findings, handoffs, or run records (§0.9).
|
|
46
|
+
*/
|
|
47
|
+
import { execFile } from "node:child_process";
|
|
48
|
+
import { readFileSync } from "node:fs";
|
|
49
|
+
import { basename, dirname, join } from "node:path";
|
|
50
|
+
import { composeAbortSignal } from "./resilience.js";
|
|
51
|
+
import { scrubSecrets } from "./scrubSecrets.js";
|
|
52
|
+
import { snapshotWorkspace } from "./workspace.js";
|
|
53
|
+
/** Total wall-clock budget for a single verify run before it is abandoned (fail-open). */
|
|
54
|
+
export const VERIFY_TIMEOUT_MS = 10 * 60_000;
|
|
55
|
+
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. */
|
|
56
|
+
export const VERIFY_TEST_TIMEOUT_MS = 10 * 60_000;
|
|
57
|
+
/** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
|
|
58
|
+
const MAX_LOCATED_FINDINGS = 25;
|
|
59
|
+
const MAX_TAIL_CHARS = 1000;
|
|
60
|
+
/** The verify lens reuses `does_it_hold` (does the change build/test) to avoid widening ReviewLens. */
|
|
61
|
+
const VERIFY_LENS = "does_it_hold";
|
|
62
|
+
/**
|
|
63
|
+
* The priority order of package.json scripts we treat as a verify command.
|
|
64
|
+
* Deliberately a SECRET-INDEPENDENT build check (`typecheck`/`lint`), NEVER the
|
|
65
|
+
* package `test`. The verify env is scrubbed of secrets (see {@link buildVerifyEnv}),
|
|
66
|
+
* so a package whose tests need infra (a DB, an e2e secret) can never exit 0 under
|
|
67
|
+
* it: selecting `test` would reintroduce the deterministic, never-clearable
|
|
68
|
+
* `critical` that forces round_cap on every run (the exact defect this gate exists
|
|
69
|
+
* to avoid). The gate's job is "the change compiles"; the worker stages and CI run
|
|
70
|
+
* the tests. `build` is also excluded (it writes dist/.next/ artifacts into the
|
|
71
|
+
* tree). A repo that wants a different check opts in via `.yagni-code/verify`.
|
|
72
|
+
*/
|
|
73
|
+
const SCRIPT_PRIORITY = ["verify", "typecheck", "lint"];
|
|
74
|
+
function tryRead(readFile, path) {
|
|
75
|
+
try {
|
|
76
|
+
return readFile(path);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Detect the package manager from a lockfile (default npm). Exported for the
|
|
84
|
+
* worktree bootstrap (spec §3b), which must install with the same pm the verify
|
|
85
|
+
* gate will later run scripts through.
|
|
86
|
+
*/
|
|
87
|
+
export function detectPackageManager(cwd, readFile) {
|
|
88
|
+
if (tryRead(readFile, join(cwd, "pnpm-lock.yaml")) != null)
|
|
89
|
+
return "pnpm";
|
|
90
|
+
if (tryRead(readFile, join(cwd, "yarn.lock")) != null)
|
|
91
|
+
return "yarn";
|
|
92
|
+
return "npm";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* True for a script body that fans out across packages or shells a secret-gated
|
|
96
|
+
* e2e runner: `pnpm` with `-r`/`--recursive`/`--filter`, or `turbo` / `lerna` /
|
|
97
|
+
* `nx run-many` / `playwright`. Such a script can never exit 0 from a
|
|
98
|
+
* single-package change under the scrubbed verify env (it drags in unrelated
|
|
99
|
+
* packages and missing secrets), so it must NEVER be chosen as the gate command:
|
|
100
|
+
* choosing it is the deterministic false-`critical` this gate exists to avoid.
|
|
101
|
+
* Applied to BOTH the scoped package script and the repo-root fallback. The `-r`
|
|
102
|
+
* flag is matched at a flag boundary so it never hits an innocent substring.
|
|
103
|
+
*/
|
|
104
|
+
export function isFannedOutScript(body) {
|
|
105
|
+
if (/\bpnpm\b/.test(body) && /(?:^|\s)(?:-r|--recursive|--filter)(?:[\s=]|$)/.test(body))
|
|
106
|
+
return true;
|
|
107
|
+
if (/\b(?:turbo|lerna)\b/.test(body))
|
|
108
|
+
return true;
|
|
109
|
+
if (/\bnx\s+run-many\b/.test(body))
|
|
110
|
+
return true;
|
|
111
|
+
if (/\bplaywright\b/.test(body))
|
|
112
|
+
return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse `git status --porcelain` into repo-relative changed paths. Each line is
|
|
117
|
+
* `XY <path>`; we drop the 2-char XY status + its separating space, and for a
|
|
118
|
+
* rename/copy (`R old -> new`) take the destination. Pure; the impure git
|
|
119
|
+
* shell-out is {@link snapshotWorkspace}.
|
|
120
|
+
*/
|
|
121
|
+
export function parseChangedPaths(status) {
|
|
122
|
+
const paths = [];
|
|
123
|
+
for (const raw of status.split("\n")) {
|
|
124
|
+
if (raw.length < 4)
|
|
125
|
+
continue; // need at least "XY p"
|
|
126
|
+
const entry = raw.slice(3); // drop the 2-char XY status + its separating space
|
|
127
|
+
const arrow = entry.indexOf(" -> ");
|
|
128
|
+
const path = (arrow >= 0 ? entry.slice(arrow + 4) : entry).trim();
|
|
129
|
+
if (path)
|
|
130
|
+
paths.push(path);
|
|
131
|
+
}
|
|
132
|
+
return paths;
|
|
133
|
+
}
|
|
134
|
+
/** Walk up from a changed path's dir to repoRoot; return the nearest dir holding a package.json (or null). */
|
|
135
|
+
function nearestPackageDir(repoRoot, rel, readFile) {
|
|
136
|
+
let dir = dirname(join(repoRoot, rel));
|
|
137
|
+
while (true) {
|
|
138
|
+
if (tryRead(readFile, join(dir, "package.json")) != null)
|
|
139
|
+
return dir;
|
|
140
|
+
if (dir === repoRoot)
|
|
141
|
+
return null;
|
|
142
|
+
const parent = dirname(dir);
|
|
143
|
+
if (parent === dir)
|
|
144
|
+
return null; // reached the filesystem root without a package.json
|
|
145
|
+
dir = parent;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Map each repo-relative changed path to its nearest enclosing package dir (the
|
|
150
|
+
* one with a package.json, repoRoot inclusive), deduped + deterministically
|
|
151
|
+
* sorted. `readFile` is injected so this stays pure. Used to SCOPE the verify gate
|
|
152
|
+
* to the package(s) a change actually touched instead of the whole monorepo.
|
|
153
|
+
*/
|
|
154
|
+
export function resolvePackageDirs(repoRoot, relPaths, readFile) {
|
|
155
|
+
const dirs = new Set();
|
|
156
|
+
for (const rel of relPaths) {
|
|
157
|
+
const dir = nearestPackageDir(repoRoot, rel, readFile);
|
|
158
|
+
if (dir)
|
|
159
|
+
dirs.add(dir);
|
|
160
|
+
}
|
|
161
|
+
return [...dirs].sort();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* This run's OWN changed package dirs: the porcelain `status`'s packages MINUS the
|
|
165
|
+
* packages touched only by `baselinePaths` (the tree's pre-run dirt). A package with
|
|
166
|
+
* a NEW change stays in scope even if it also had pre-run dirt; a package whose every
|
|
167
|
+
* changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
|
|
168
|
+
* an unrelated dirty package can never retarget the gate off this run's real diff.
|
|
169
|
+
*/
|
|
170
|
+
export function ownChangedDirs(repoRoot, status, baselinePaths, readFile) {
|
|
171
|
+
const baseline = new Set(baselinePaths);
|
|
172
|
+
const own = parseChangedPaths(status).filter((p) => !baseline.has(p));
|
|
173
|
+
return resolvePackageDirs(repoRoot, own, readFile);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The highest-priority package.json script at `pkgDir` that is a string AND not a
|
|
177
|
+
* fanned-out/e2e runner, as a {@link VerifyCommand} that runs IN `pkgDir`. `pm` is
|
|
178
|
+
* the repo's package manager (detected once at the repo root, since a monorepo's
|
|
179
|
+
* lockfile lives there, not in each package). Null when the dir has no usable
|
|
180
|
+
* script, so the caller tries the next scope or fails open.
|
|
181
|
+
*/
|
|
182
|
+
function pickScript(pkgDir, readFile, pm) {
|
|
183
|
+
const pkgRaw = tryRead(readFile, join(pkgDir, "package.json"));
|
|
184
|
+
if (!pkgRaw)
|
|
185
|
+
return null;
|
|
186
|
+
let scripts = {};
|
|
187
|
+
try {
|
|
188
|
+
scripts = JSON.parse(pkgRaw).scripts ?? {};
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
scripts = {};
|
|
192
|
+
}
|
|
193
|
+
for (const name of SCRIPT_PRIORITY) {
|
|
194
|
+
const body = scripts[name];
|
|
195
|
+
if (typeof body === "string" && !isFannedOutScript(body)) {
|
|
196
|
+
return { argv: [pm, "run", name], label: name, source: "script", cwd: pkgDir };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve the verify command(s) for a repo. An explicitly allowed `.yagni-code/verify`
|
|
203
|
+
* override wins (first non-comment, non-empty line, whitespace-split into argv; run
|
|
204
|
+
* at the repo root) as a single command. Otherwise the gate is SCOPED to the change:
|
|
205
|
+
* EVERY changed package's highest-priority, non-fanned-out script is returned (each
|
|
206
|
+
* run in its own package), falling back to the repo-root script (still rejecting
|
|
207
|
+
* fanned-out runners). Covering all changed packages (not just the first) stops the
|
|
208
|
+
* gate giving false confidence by checking the wrong package. Returns [] when nothing
|
|
209
|
+
* is usable, so the caller fails open rather than running the repo-root recursive
|
|
210
|
+
* runner and emitting a false `critical`.
|
|
211
|
+
*/
|
|
212
|
+
export function detectVerifyCommands(cwd, opts = {}) {
|
|
213
|
+
const readFile = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
214
|
+
const override = opts.allowRepoOverride ? tryRead(readFile, join(cwd, ".yagni-code", "verify")) : null;
|
|
215
|
+
if (override) {
|
|
216
|
+
for (const raw of override.split("\n")) {
|
|
217
|
+
const line = raw.trim();
|
|
218
|
+
if (!line || line.startsWith("#"))
|
|
219
|
+
continue;
|
|
220
|
+
const argv = line.split(/\s+/);
|
|
221
|
+
if (argv.length > 0)
|
|
222
|
+
return [{ argv, label: line, source: "override", cwd }];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Detect the package manager ONCE at the repo root: a monorepo keeps a single
|
|
226
|
+
// lockfile there, so detecting it inside a changed package dir would mis-fall to npm.
|
|
227
|
+
const pm = detectPackageManager(cwd, readFile);
|
|
228
|
+
// Scope to the change: run the build check of EVERY changed package (deterministic
|
|
229
|
+
// order from resolvePackageDirs), each in its own dir. This stays off the repo-root
|
|
230
|
+
// recursive runner AND never silently checks only the first (lexicographically
|
|
231
|
+
// smallest) package while leaving the package the run actually targeted unverified.
|
|
232
|
+
const commands = [];
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
for (const dir of opts.changedDirs ?? []) {
|
|
235
|
+
if (seen.has(dir))
|
|
236
|
+
continue;
|
|
237
|
+
seen.add(dir);
|
|
238
|
+
const scoped = pickScript(dir, readFile, pm);
|
|
239
|
+
if (scoped)
|
|
240
|
+
commands.push(scoped);
|
|
241
|
+
}
|
|
242
|
+
if (commands.length > 0)
|
|
243
|
+
return commands;
|
|
244
|
+
// Fallback: the repo-root script, still rejecting fanned-out runners so a
|
|
245
|
+
// recursive `pnpm -r test` is never selected anywhere. Empty => fail open.
|
|
246
|
+
const root = pickScript(cwd, readFile, pm);
|
|
247
|
+
return root ? [root] : [];
|
|
248
|
+
}
|
|
249
|
+
/** Back-compat singular: the first detected verify command, or null. */
|
|
250
|
+
export function detectVerifyCommand(cwd, opts = {}) {
|
|
251
|
+
return detectVerifyCommands(cwd, opts)[0] ?? null;
|
|
252
|
+
}
|
|
253
|
+
/** The package.json `test` script at `pkgDir` as a VerifyCommand, when usable. */
|
|
254
|
+
function pickTestScript(pkgDir, readFile, pm) {
|
|
255
|
+
const pkgRaw = tryRead(readFile, join(pkgDir, "package.json"));
|
|
256
|
+
if (!pkgRaw)
|
|
257
|
+
return null;
|
|
258
|
+
let scripts = {};
|
|
259
|
+
try {
|
|
260
|
+
scripts = JSON.parse(pkgRaw).scripts ?? {};
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
scripts = {};
|
|
264
|
+
}
|
|
265
|
+
const body = scripts.test;
|
|
266
|
+
if (typeof body === "string" && !isFannedOutScript(body)) {
|
|
267
|
+
return { argv: [pm, "run", "test"], label: "test", source: "script", cwd: pkgDir };
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the TEST-half command(s) (spec §3d): every changed package's own
|
|
273
|
+
* `test` script (each run in its own dir), falling back to the repo-root `test`
|
|
274
|
+
* script; a fanned-out script (`pnpm -r test`, turbo, playwright) is rejected
|
|
275
|
+
* everywhere (the same false-critical guard as the typecheck half). Returns []
|
|
276
|
+
* when nothing is usable so the caller fails open. The `.yagni-code/verify`
|
|
277
|
+
* override is deliberately NOT consulted here: an active override IS the whole
|
|
278
|
+
* gate and the caller skips the test half entirely.
|
|
279
|
+
*/
|
|
280
|
+
export function detectVerifyTestCommands(cwd, opts = {}) {
|
|
281
|
+
const readFile = opts.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
282
|
+
const pm = detectPackageManager(cwd, readFile);
|
|
283
|
+
const commands = [];
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
for (const dir of opts.changedDirs ?? []) {
|
|
286
|
+
if (seen.has(dir))
|
|
287
|
+
continue;
|
|
288
|
+
seen.add(dir);
|
|
289
|
+
const scoped = pickTestScript(dir, readFile, pm);
|
|
290
|
+
if (scoped)
|
|
291
|
+
commands.push(scoped);
|
|
292
|
+
}
|
|
293
|
+
if (commands.length > 0)
|
|
294
|
+
return commands;
|
|
295
|
+
const root = pickTestScript(cwd, readFile, pm);
|
|
296
|
+
return root ? [root] : [];
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Infra/environment failure classification for the typecheck half (spec §3b,
|
|
300
|
+
* invariant §0.2). A fresh run worktree has no node_modules until the bootstrap
|
|
301
|
+
* finishes (or when it fail-opened), so a verify command can exit non-zero not
|
|
302
|
+
* because the CHANGE is broken but because the ENVIRONMENT is: `tsc: command
|
|
303
|
+
* not found` (exit 127), a Node loader `ERR_MODULE_NOT_FOUND`, or a spawn-shaped
|
|
304
|
+
* ENOENT surfaced through the runner's own output. Those must become an honest
|
|
305
|
+
* fail-open `verifyNote` ("verify could not run"), NEVER critical findings — a
|
|
306
|
+
* finding the fix stage can't clear would force round_cap on every run.
|
|
307
|
+
*
|
|
308
|
+
* Deliberately conservative (when uncertain, treat it as a REAL failure and let
|
|
309
|
+
* {@link parseVerifyFailures} report it): tsc's own `error TS2307: Cannot find
|
|
310
|
+
* module` is a legitimate compile diagnostic a change can cause, so a bare
|
|
311
|
+
* "Cannot find module" only classifies as infra when it carries the Node
|
|
312
|
+
* loader's `Require stack`/`ERR_MODULE_NOT_FOUND` signature, not the TS code.
|
|
313
|
+
* Returns a short human cause for the note, or null when it looks like a real
|
|
314
|
+
* verify failure.
|
|
315
|
+
*/
|
|
316
|
+
export function classifyVerifyInfraFailure(output, exitCode) {
|
|
317
|
+
if (exitCode === 127)
|
|
318
|
+
return "command not found (exit 127; missing node_modules?)";
|
|
319
|
+
if (/command not found/i.test(output))
|
|
320
|
+
return "command not found (missing node_modules?)";
|
|
321
|
+
if (/not recognized as an internal or external command/i.test(output)) {
|
|
322
|
+
return "command not found (missing node_modules?)";
|
|
323
|
+
}
|
|
324
|
+
if (/\bERR_MODULE_NOT_FOUND\b/.test(output))
|
|
325
|
+
return "module resolution failed (ERR_MODULE_NOT_FOUND)";
|
|
326
|
+
if (/Cannot find module/.test(output) && /Require stack:/.test(output)) {
|
|
327
|
+
return "module resolution failed (Cannot find module)";
|
|
328
|
+
}
|
|
329
|
+
if (/\bspawn(?:Sync)?\b.*\bENOENT\b/.test(output))
|
|
330
|
+
return "spawn failed (ENOENT)";
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Test-half infra/environment patterns (spec §3d): failure shapes that mean the
|
|
335
|
+
* ENVIRONMENT could not run the suite — a DB that is not up, a missing env var,
|
|
336
|
+
* auth against local infra, a connect timeout — not that the CHANGE broke a
|
|
337
|
+
* test. Each is deliberately specific (a bare "timed out" or "401" would eat
|
|
338
|
+
* real assertion failures); when none match, classification falls through to
|
|
339
|
+
* the failure parsers and, past those, to fail-open UNCERTAIN.
|
|
340
|
+
*/
|
|
341
|
+
const TEST_INFRA_PATTERNS = [
|
|
342
|
+
[/\bECONNREFUSED\b|\bconnection refused\b/i, "connection refused (is the test's DB/service running?)"],
|
|
343
|
+
[/\bECONNRESET\b/, "connection reset by the test's DB/service"],
|
|
344
|
+
[/\bENOTFOUND\b|\bEAI_AGAIN\b/, "DNS/host resolution failed for a test dependency"],
|
|
345
|
+
[/\bEADDRINUSE\b/, "a port the tests need is already in use"],
|
|
346
|
+
[/\bETIMEDOUT\b|\bconnect(?:ion)? timed out\b/i, "connection to a test dependency timed out"],
|
|
347
|
+
[/\bmissing (?:required )?env(?:ironment)? (?:var(?:iable)?s?)\b/i, "missing environment variable"],
|
|
348
|
+
[/\benvironment variable\b[^\n]{0,80}\b(?:is )?(?:not set|missing|required|must be set)\b/i, "missing environment variable"],
|
|
349
|
+
[/\bprocess\.env\.[A-Z0-9_]+ is (?:undefined|not set)\b/, "missing environment variable"],
|
|
350
|
+
[/\bSASL\b|\bSCRAM-SERVER\b/, "database auth failed (missing DB credentials?)"],
|
|
351
|
+
[/\bpassword authentication failed\b/i, "database auth failed"],
|
|
352
|
+
[/\brole "[^"]*" does not exist\b/i, "database role does not exist"],
|
|
353
|
+
[/\bdatabase "[^"]*" does not exist\b/i, "database does not exist"],
|
|
354
|
+
[/\brelation "[^"]*" does not exist\b/i, "database schema missing (migrations not run?)"],
|
|
355
|
+
[/\bauthentication (?:failed|error)\b/i, "authentication against a test dependency failed"],
|
|
356
|
+
[/\b401 Unauthorized\b|\b403 Forbidden\b/i, "authorization against a test dependency failed"],
|
|
357
|
+
];
|
|
358
|
+
/** Strip a trailing `(4 ms)` / `(0.82ms)` duration off a failing-test name. */
|
|
359
|
+
function stripDuration(name) {
|
|
360
|
+
return name.replace(/\s*\(\d+(?:\.\d+)?\s*m?s\)\s*$/, "").trim();
|
|
361
|
+
}
|
|
362
|
+
/** A failing-test bullet: jest `✕ name`, vitest `×/✗ name`, node spec `✖ name`. */
|
|
363
|
+
const FAIL_BULLET = /^[✕✖✗×]\s+(.+)$/;
|
|
364
|
+
/** node:test spec-reporter location line printed right above the footer's ✖ line. */
|
|
365
|
+
const NODE_TEST_AT = /^test at (\S+?):(\d+)(?::\d+)?$/;
|
|
366
|
+
/** TAP failing line (node:test tap reporter): `not ok 1 - name`. */
|
|
367
|
+
const TAP_NOT_OK = /^not ok\s+\d+\s+-\s+(.+)$/;
|
|
368
|
+
/** vitest per-test failure header: ` FAIL file > suite > name` (jest's has no `>`). */
|
|
369
|
+
const RUNNER_FAIL = /^FAIL\s+(\S+)(?:\s+>\s+(.+))?/;
|
|
370
|
+
/** Evidence that a test RUN happened and failed, even when no per-test line parsed. */
|
|
371
|
+
const FAILURE_SUMMARY = [
|
|
372
|
+
/(?:^|\n)\s*# fail\s+[1-9]\d*\b/, // node:test TAP footer
|
|
373
|
+
/\bTests:\s+(?:\d+\s+\w+,\s*)*[1-9]\d*\s+failed\b/, // jest: "Tests: 1 failed, 3 passed, 4 total"
|
|
374
|
+
/\bTests\s+[1-9]\d*\s+failed\b/, // vitest: "Tests 2 failed | 30 passed (32)"
|
|
375
|
+
/\b[1-9]\d*\s+failing\b/, // mocha / node:test "N failing"
|
|
376
|
+
/[✕✖✗×]\s+failing tests:/u, // node:test spec footer header
|
|
377
|
+
];
|
|
378
|
+
/**
|
|
379
|
+
* PURE classification of one test-script run (spec §3d, invariant §0.2).
|
|
380
|
+
* Deliberately asymmetric with {@link parseVerifyFailures} (which always emits
|
|
381
|
+
* a finding on non-zero exit): a test suite fails for environmental reasons far
|
|
382
|
+
* more often than a typecheck does, and a finding the fix stage cannot clear
|
|
383
|
+
* forces round_cap on every run. So:
|
|
384
|
+
*
|
|
385
|
+
* - exit 0 → `clean`.
|
|
386
|
+
* - infra/env shapes (command not found, module resolution, connection
|
|
387
|
+
* refused, missing env var, DB/auth, connect timeout) → `infra` with a short
|
|
388
|
+
* cause; checked FIRST so a suite whose assertions failed only because its
|
|
389
|
+
* DB was down fails open rather than blocking.
|
|
390
|
+
* - recognisable failing-test lines (pnpm-wrapped vitest / jest / node:test
|
|
391
|
+
* TAP + spec reporters) → `failures` with located `critical` findings
|
|
392
|
+
* (capped at 25, deduped, locations attached when the runner printed them).
|
|
393
|
+
* - a failure summary with no parseable per-test lines → one capped tail
|
|
394
|
+
* finding (still real evidence tests ran and failed).
|
|
395
|
+
* - anything else → `uncertain`, and the CALLER FAILS OPEN (an honest note,
|
|
396
|
+
* never a guessed finding).
|
|
397
|
+
*
|
|
398
|
+
* Callers scrub the output through {@link scrubSecrets} BEFORE classification
|
|
399
|
+
* so findings/notes never carry secrets (§0.9).
|
|
400
|
+
*/
|
|
401
|
+
export function classifyTestFailure(output, exitCode) {
|
|
402
|
+
if (exitCode === 0)
|
|
403
|
+
return { kind: "clean" };
|
|
404
|
+
const spawnInfra = classifyVerifyInfraFailure(output, exitCode);
|
|
405
|
+
if (spawnInfra)
|
|
406
|
+
return { kind: "infra", reason: spawnInfra };
|
|
407
|
+
for (const [re, reason] of TEST_INFRA_PATTERNS) {
|
|
408
|
+
if (re.test(output))
|
|
409
|
+
return { kind: "infra", reason };
|
|
410
|
+
}
|
|
411
|
+
// Per-test failure lines, deduped by test name; a later duplicate that DOES
|
|
412
|
+
// carry a location upgrades the located-less first sighting (node:test prints
|
|
413
|
+
// the body ✖ first and the located footer ✖ later). `pendingLocation` is the
|
|
414
|
+
// one-shot `test at file:line` node:test prints directly above a footer ✖;
|
|
415
|
+
// `currentFile` is jest's sticky `FAIL <file>` header covering its ✕ bullets.
|
|
416
|
+
const byMessage = new Map();
|
|
417
|
+
let pendingLocation;
|
|
418
|
+
let currentFile;
|
|
419
|
+
for (const raw of output.split("\n")) {
|
|
420
|
+
const line = raw.trim();
|
|
421
|
+
if (!line)
|
|
422
|
+
continue;
|
|
423
|
+
const at = line.match(NODE_TEST_AT);
|
|
424
|
+
if (at) {
|
|
425
|
+
pendingLocation = { file: at[1], line: Number(at[2]) };
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
let parsed;
|
|
429
|
+
const bullet = line.match(FAIL_BULLET) ?? line.match(TAP_NOT_OK);
|
|
430
|
+
if (bullet) {
|
|
431
|
+
const name = stripDuration(bullet[1]);
|
|
432
|
+
if (name && name.length <= 200 && !/^failing tests:?$/i.test(name)) {
|
|
433
|
+
parsed = { message: name };
|
|
434
|
+
if (pendingLocation) {
|
|
435
|
+
parsed.file = pendingLocation.file;
|
|
436
|
+
parsed.line = pendingLocation.line;
|
|
437
|
+
}
|
|
438
|
+
else if (currentFile) {
|
|
439
|
+
parsed.file = currentFile;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const fail = line.match(RUNNER_FAIL);
|
|
445
|
+
if (fail && /\.[cm]?[jt]sx?$/.test(fail[1])) {
|
|
446
|
+
// vitest's `FAIL file > suite > name` is a test failure in itself; jest's
|
|
447
|
+
// bare `FAIL file` only names the file — its ✕ bullets that follow are
|
|
448
|
+
// located against it via currentFile.
|
|
449
|
+
if (fail[2])
|
|
450
|
+
parsed = { message: stripDuration(fail[2]), file: fail[1] };
|
|
451
|
+
else
|
|
452
|
+
currentFile = fail[1];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!parsed)
|
|
456
|
+
continue;
|
|
457
|
+
pendingLocation = undefined;
|
|
458
|
+
const existing = byMessage.get(parsed.message);
|
|
459
|
+
if (!existing)
|
|
460
|
+
byMessage.set(parsed.message, parsed);
|
|
461
|
+
else if (!existing.file && parsed.file)
|
|
462
|
+
byMessage.set(parsed.message, parsed);
|
|
463
|
+
}
|
|
464
|
+
const failures = [...byMessage.values()].slice(0, MAX_LOCATED_FINDINGS);
|
|
465
|
+
if (failures.length > 0) {
|
|
466
|
+
return {
|
|
467
|
+
kind: "failures",
|
|
468
|
+
findings: failures.map((f) => {
|
|
469
|
+
const finding = { severity: "critical", lens: VERIFY_LENS, message: `verify: test failed: ${f.message}` };
|
|
470
|
+
if (f.file)
|
|
471
|
+
finding.file = f.file;
|
|
472
|
+
if (f.line)
|
|
473
|
+
finding.line = f.line;
|
|
474
|
+
return finding;
|
|
475
|
+
}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (FAILURE_SUMMARY.some((re) => re.test(output))) {
|
|
479
|
+
const tail = output.length > MAX_TAIL_CHARS ? output.slice(output.length - MAX_TAIL_CHARS) : output;
|
|
480
|
+
return {
|
|
481
|
+
kind: "failures",
|
|
482
|
+
findings: [
|
|
483
|
+
{
|
|
484
|
+
severity: "critical",
|
|
485
|
+
lens: VERIFY_LENS,
|
|
486
|
+
message: `verify (test) failed (exit ${exitCode}). Output tail:\n${tail.trim()}`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return { kind: "uncertain" };
|
|
492
|
+
}
|
|
493
|
+
/** Match a TypeScript diagnostic line: `path(line,col): error TSxxxx: message`. */
|
|
494
|
+
const TS_DIAGNOSTIC = /^(.+?)\((\d+),\d+\):\s*error\s+TS\d+:\s*(.+)$/;
|
|
495
|
+
/** Match a generic `path:line:col: error message` (eslint/tsc-pretty-ish). */
|
|
496
|
+
const GENERIC_DIAGNOSTIC = /^(.+?):(\d+):\d+:?\s*(?:error\b[:\s]*)?(.+)$/;
|
|
497
|
+
/**
|
|
498
|
+
* Turn a failing verify run into `critical` Findings. A clean (exit 0) run yields
|
|
499
|
+
* none. Located diagnostics (TypeScript first, then a generic file:line form) are
|
|
500
|
+
* parsed into per-error findings; if none parse, a single capped finding carries
|
|
501
|
+
* the tail of the output so the fix stage still gets actionable signal. Never
|
|
502
|
+
* throws; always emits at least one finding on a non-zero exit.
|
|
503
|
+
*/
|
|
504
|
+
export function parseVerifyFailures(output, exitCode, label) {
|
|
505
|
+
if (exitCode === 0)
|
|
506
|
+
return [];
|
|
507
|
+
const located = [];
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
for (const raw of output.split("\n")) {
|
|
510
|
+
const line = raw.trim();
|
|
511
|
+
if (!line)
|
|
512
|
+
continue;
|
|
513
|
+
const m = line.match(TS_DIAGNOSTIC) ?? line.match(GENERIC_DIAGNOSTIC);
|
|
514
|
+
if (!m)
|
|
515
|
+
continue;
|
|
516
|
+
const file = m[1].trim();
|
|
517
|
+
const lineNo = Number(m[2]);
|
|
518
|
+
const message = m[3].trim();
|
|
519
|
+
// Guard the generic matcher against noise: require a plausible file token.
|
|
520
|
+
if (!file || /\s/.test(file) || !Number.isFinite(lineNo))
|
|
521
|
+
continue;
|
|
522
|
+
const key = `${file}:${lineNo}:${message}`;
|
|
523
|
+
if (seen.has(key))
|
|
524
|
+
continue;
|
|
525
|
+
seen.add(key);
|
|
526
|
+
located.push({ severity: "critical", lens: VERIFY_LENS, message: `verify: ${message}`, file, line: lineNo });
|
|
527
|
+
if (located.length >= MAX_LOCATED_FINDINGS)
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (located.length > 0)
|
|
531
|
+
return located;
|
|
532
|
+
const tail = output.length > MAX_TAIL_CHARS ? output.slice(output.length - MAX_TAIL_CHARS) : output;
|
|
533
|
+
return [
|
|
534
|
+
{
|
|
535
|
+
severity: "critical",
|
|
536
|
+
lens: VERIFY_LENS,
|
|
537
|
+
message: `verify (${label}) failed (exit ${exitCode}). Output tail:\n${tail.trim()}`,
|
|
538
|
+
},
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
const SAFE_ENV_KEYS = [
|
|
542
|
+
"PATH",
|
|
543
|
+
"HOME",
|
|
544
|
+
"USER",
|
|
545
|
+
"USERNAME",
|
|
546
|
+
"SHELL",
|
|
547
|
+
"TMPDIR",
|
|
548
|
+
"TMP",
|
|
549
|
+
"TEMP",
|
|
550
|
+
"SystemRoot",
|
|
551
|
+
"WINDIR",
|
|
552
|
+
"PNPM_HOME",
|
|
553
|
+
"COREPACK_HOME",
|
|
554
|
+
];
|
|
555
|
+
/** Build the environment used for repo verify commands, intentionally excluding secrets and Node injection hooks. */
|
|
556
|
+
export function buildVerifyEnv(source = process.env) {
|
|
557
|
+
const env = { CI: "true" };
|
|
558
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
559
|
+
const value = source[key];
|
|
560
|
+
if (value !== undefined)
|
|
561
|
+
env[key] = value;
|
|
562
|
+
}
|
|
563
|
+
return env;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The TEST-half environment (spec §3d): the user's FULL environment — tests
|
|
567
|
+
* legitimately need the local DB url, service creds, PATH extensions — MINUS
|
|
568
|
+
* every `YAGNI_*` var (repo-defined commands never inherit YAGNI tokens, §0.9)
|
|
569
|
+
* and MINUS NODE_OPTIONS (the same injection-hook stripping as the typecheck
|
|
570
|
+
* half). CI is forced on so a bare `vitest`/`jest --watch` test script runs
|
|
571
|
+
* once instead of parking in watch mode until the 10-min cap kills it.
|
|
572
|
+
*/
|
|
573
|
+
export function buildTestEnv(source = process.env) {
|
|
574
|
+
const env = {};
|
|
575
|
+
for (const [key, value] of Object.entries(source)) {
|
|
576
|
+
if (value === undefined)
|
|
577
|
+
continue;
|
|
578
|
+
if (key.startsWith("YAGNI_"))
|
|
579
|
+
continue;
|
|
580
|
+
if (key === "NODE_OPTIONS")
|
|
581
|
+
continue;
|
|
582
|
+
env[key] = value;
|
|
583
|
+
}
|
|
584
|
+
env.CI = "true";
|
|
585
|
+
return env;
|
|
586
|
+
}
|
|
587
|
+
// `signal` is deliberately NOT passed into execFile: Node's built-in abort
|
|
588
|
+
// handling kills via the child's pid, and when the spawn itself failed (missing
|
|
589
|
+
// command, missing cwd) an abort landing in the same tick resolves that to pid 0
|
|
590
|
+
// — signalling the WHOLE process group and killing the parent. Abort is wired
|
|
591
|
+
// manually with an explicit pid guard; a killed child reaches the callback with
|
|
592
|
+
// a code-less error, which stays the same fail-open rejection as before.
|
|
593
|
+
const defaultExec = (argv, cwd, signal, env) => new Promise((resolve, reject) => {
|
|
594
|
+
if (signal.aborted)
|
|
595
|
+
return reject(new Error("verify aborted before start"));
|
|
596
|
+
const child = execFile(argv[0], argv.slice(1), { cwd, env: env ?? buildVerifyEnv(), maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
597
|
+
signal.removeEventListener("abort", onAbort);
|
|
598
|
+
const output = `${stdout ?? ""}${stderr ?? ""}`;
|
|
599
|
+
if (err) {
|
|
600
|
+
// A non-zero EXIT is a verify failure (carries a numeric code); a spawn
|
|
601
|
+
// error (ENOENT) or an abort has no numeric code and is a fail-open.
|
|
602
|
+
const code = err.code;
|
|
603
|
+
if (typeof code === "number")
|
|
604
|
+
return resolve({ code, output });
|
|
605
|
+
return reject(err);
|
|
606
|
+
}
|
|
607
|
+
resolve({ code: 0, output });
|
|
608
|
+
});
|
|
609
|
+
const onAbort = () => {
|
|
610
|
+
try {
|
|
611
|
+
if (child.pid)
|
|
612
|
+
child.kill("SIGTERM");
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
/* ignore */
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
619
|
+
});
|
|
620
|
+
/**
|
|
621
|
+
* Build the runVerify function injected into the orchestrator. It discovers the
|
|
622
|
+
* changed package(s) (scoped to this run's diff via `baselinePaths`), detects a
|
|
623
|
+
* build check for EACH, runs them (in their package dirs) under a composed (caller +
|
|
624
|
+
* timeout) abort signal, then runs each changed package's own `test` script (the
|
|
625
|
+
* §3d test half), and unions everything onto a single {@link VerifyOutcome}.
|
|
626
|
+
* Fail-open on every path it cannot turn into a clean verdict, so the gate never
|
|
627
|
+
* blocks a real change on its own failure.
|
|
628
|
+
*/
|
|
629
|
+
export function makeRunVerify(opts = {}) {
|
|
630
|
+
const detect = opts.detect ??
|
|
631
|
+
((cwd, changedDirs) => detectVerifyCommands(cwd, {
|
|
632
|
+
allowRepoOverride: process.env.YAGNI_CODE_ALLOW_VERIFY_OVERRIDE === "1",
|
|
633
|
+
changedDirs,
|
|
634
|
+
}));
|
|
635
|
+
const detectTests = opts.detectTests ?? ((cwd, changedDirs) => detectVerifyTestCommands(cwd, { changedDirs }));
|
|
636
|
+
const changedDirs = opts.changedDirs ??
|
|
637
|
+
(async (cwd, signal, baselinePaths) => {
|
|
638
|
+
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
639
|
+
if (!snapshot.tracked)
|
|
640
|
+
return [];
|
|
641
|
+
// This run's OWN changed packages = the current diff minus the pre-run dirt, so
|
|
642
|
+
// a stray pre-existing file (or an unrelated dirty package) never pulls its dir
|
|
643
|
+
// into scope and the gate verifies what THIS run actually touched.
|
|
644
|
+
return ownChangedDirs(cwd, snapshot.status, baselinePaths ?? [], (p) => readFileSync(p, "utf8"));
|
|
645
|
+
});
|
|
646
|
+
const exec = opts.exec ?? defaultExec;
|
|
647
|
+
const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
648
|
+
const testTimeoutMs = opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS;
|
|
649
|
+
return async function runVerify(cwd, signal, baselinePaths) {
|
|
650
|
+
const dirs = await changedDirs(cwd, signal, baselinePaths);
|
|
651
|
+
const commands = detect(cwd, dirs);
|
|
652
|
+
// An explicitly-allowed .yagni-code/verify override IS the whole gate (spec
|
|
653
|
+
// §3d: it "covers the test half too"), so no detected test script runs
|
|
654
|
+
// alongside it — the repo already told us exactly how to verify.
|
|
655
|
+
const overridden = commands.some((c) => c.source === "override");
|
|
656
|
+
const testCommands = overridden ? [] : detectTests(cwd, dirs);
|
|
657
|
+
if (commands.length === 0 && testCommands.length === 0) {
|
|
658
|
+
return { ran: false, ok: true, reason: "verify skipped: no verify command detected", findings: [] };
|
|
659
|
+
}
|
|
660
|
+
const findings = [];
|
|
661
|
+
const reasons = [];
|
|
662
|
+
let anyRan = false;
|
|
663
|
+
// TYPECHECK HALF (frozen behavior, §0.2): run every changed package's build
|
|
664
|
+
// check; union the findings. One package that cannot spawn fails OPEN (a
|
|
665
|
+
// recorded reason, no finding) without sinking the verdict for the packages
|
|
666
|
+
// that did run. Output is scrubbed before it can enter a finding (§0.9).
|
|
667
|
+
const typecheckLabels = [];
|
|
668
|
+
for (const command of commands) {
|
|
669
|
+
const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
|
|
670
|
+
try {
|
|
671
|
+
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed);
|
|
672
|
+
const output = scrubSecrets(rawOutput);
|
|
673
|
+
if (code !== 0) {
|
|
674
|
+
// Infra/env failure (command not found in a fresh worktree, Node loader
|
|
675
|
+
// resolution): the ENVIRONMENT could not run the check, so it is the same
|
|
676
|
+
// fail-open as a spawn error — an honest reason, never a critical finding
|
|
677
|
+
// the fix stage can't clear (invariant §0.2).
|
|
678
|
+
const infra = classifyVerifyInfraFailure(output, code);
|
|
679
|
+
if (infra) {
|
|
680
|
+
reasons.push(`verify (${command.label}) could not run: ${infra}`);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
anyRan = true;
|
|
685
|
+
typecheckLabels.push(command.label);
|
|
686
|
+
if (code !== 0)
|
|
687
|
+
findings.push(...parseVerifyFailures(output, code, command.label));
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
691
|
+
reasons.push(`verify (${command.label}) could not run: ${scrubSecrets(msg)}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// TEST HALF (spec §3d): each changed package's own `test` script, run AFTER
|
|
695
|
+
// the typecheck half under the user's env minus YAGNI_*/NODE_OPTIONS. When
|
|
696
|
+
// the typecheck half already produced findings the tests are skipped with an
|
|
697
|
+
// honest note — a tree that doesn't compile fails its suite for the same
|
|
698
|
+
// root cause, and the loop must fix the compile errors first anyway; the
|
|
699
|
+
// round where typecheck goes green runs the tests, so `clean` is never
|
|
700
|
+
// reached without the tests having run (or their absence honestly noted).
|
|
701
|
+
const testLabels = [];
|
|
702
|
+
if (testCommands.length > 0) {
|
|
703
|
+
if (findings.length > 0) {
|
|
704
|
+
reasons.push("tests skipped: the typecheck half failed; 'clean' still requires a later round's tests");
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
const testEnv = opts.testEnv ?? buildTestEnv();
|
|
708
|
+
let testFindingCount = 0;
|
|
709
|
+
for (const command of testCommands) {
|
|
710
|
+
const where = basename(command.cwd) || command.cwd;
|
|
711
|
+
const composed = composeAbortSignal(signal, AbortSignal.timeout(testTimeoutMs));
|
|
712
|
+
try {
|
|
713
|
+
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
|
|
714
|
+
const output = scrubSecrets(rawOutput);
|
|
715
|
+
const cls = classifyTestFailure(output, code);
|
|
716
|
+
if (cls.kind === "infra") {
|
|
717
|
+
reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (cls.kind === "uncertain") {
|
|
721
|
+
// Failing OPEN on an uninterpretable failure (§3d): an honest note,
|
|
722
|
+
// never a guessed finding the fix stage cannot clear.
|
|
723
|
+
reasons.push(`tests (${where}) exited ${code} but the output was not interpretable as test failures; failing open. 'clean' reflects typecheck + review only`);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
anyRan = true;
|
|
727
|
+
testLabels.push(command.label);
|
|
728
|
+
if (cls.kind === "failures") {
|
|
729
|
+
const room = Math.max(0, MAX_LOCATED_FINDINGS - testFindingCount);
|
|
730
|
+
const take = cls.findings.slice(0, room);
|
|
731
|
+
testFindingCount += take.length;
|
|
732
|
+
findings.push(...take);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
737
|
+
reasons.push(`tests (${where}) could not run: ${scrubSecrets(msg)}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// The command labels list only what produced a verdict — on BOTH the
|
|
743
|
+
// typecheck and test halves — so the FINISH trailer's `passed (<cmds>)`
|
|
744
|
+
// never claims a check that was infra-skipped or threw (invariant §0.1).
|
|
745
|
+
const command = [...new Set(typecheckLabels), ...new Set(testLabels)].join(", ");
|
|
746
|
+
if (!anyRan) {
|
|
747
|
+
return { ran: false, ok: true, command, reason: reasons.join("; ") || "verify skipped", findings: [] };
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
ran: true,
|
|
751
|
+
ok: findings.length === 0,
|
|
752
|
+
command,
|
|
753
|
+
findings,
|
|
754
|
+
...(reasons.length ? { reason: reasons.join("; ") } : {}),
|
|
755
|
+
};
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
//# sourceMappingURL=verify.js.map
|