@yagni-app/code-staging 1.2.4-staging.1809.1 → 1.2.4-staging.1821.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension/pipeline/headlessGo.d.ts +6 -0
- package/dist/extension/pipeline/headlessGo.js +20 -0
- package/dist/extension/pipeline/missionChecks.d.ts +66 -0
- package/dist/extension/pipeline/missionChecks.js +197 -0
- package/dist/extension/pipeline/verify.d.ts +60 -1
- package/dist/extension/pipeline/verify.js +320 -9
- package/package.json +2 -2
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
*/
|
|
61
61
|
import { type TreeSnapshotDeps } from "./treeSnapshot.js";
|
|
62
62
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
63
|
+
import { makeRunVerify } from "./verify.js";
|
|
63
64
|
import { type RunBudget } from "./budget.js";
|
|
64
65
|
import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
|
|
65
66
|
/** One-line usage copy, shared by every argument error. */
|
|
@@ -152,6 +153,11 @@ export interface HeadlessGoDeps {
|
|
|
152
153
|
now?: () => number;
|
|
153
154
|
runPipeline?: typeof defaultRunPipeline;
|
|
154
155
|
readFile?: (path: string) => string;
|
|
156
|
+
/**
|
|
157
|
+
* The verify gate a mission run uses, given its `checks.json` (null when
|
|
158
|
+
* there is none). Defaults to the real gate; injected in tests.
|
|
159
|
+
*/
|
|
160
|
+
makeRunVerify?: typeof makeRunVerify;
|
|
155
161
|
/** stdout sink; one call per NDJSON line, newline added by the caller. */
|
|
156
162
|
write?: (line: string) => void;
|
|
157
163
|
/** stderr sink for human-readable problems. */
|
|
@@ -59,12 +59,15 @@
|
|
|
59
59
|
* ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
|
|
60
60
|
*/
|
|
61
61
|
import { readFileSync } from "node:fs";
|
|
62
|
+
import { dirname, join } from "node:path";
|
|
62
63
|
import { continuationResumePlan, parseContinuation } from "./continuation.js";
|
|
63
64
|
import { resolveFanoutMode } from "./fanout.js";
|
|
64
65
|
import { makeFanoutBeats } from "./fanoutBeats.js";
|
|
65
66
|
import { normalizeMission } from "./mission.js";
|
|
67
|
+
import { MISSION_CHECKS_FILE_NAME, readMissionChecksFile } from "./missionChecks.js";
|
|
66
68
|
import { startTreeSnapshots } from "./treeSnapshot.js";
|
|
67
69
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
70
|
+
import { makeRunVerify } from "./verify.js";
|
|
68
71
|
import { loadGroundingEnabled } from "../grounding.js";
|
|
69
72
|
import { codeStateHome } from "../stateHome.js";
|
|
70
73
|
import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
|
|
@@ -319,6 +322,22 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
319
322
|
// NDJSON stream as `pipeline_fanout` records, and the mission collector
|
|
320
323
|
// folds them onto the Run.
|
|
321
324
|
const fanoutBeats = makeFanoutBeats();
|
|
325
|
+
// A mission's approved checks and its pull request's CI jobs, written by the
|
|
326
|
+
// host beside the ticket: the verify gate runs them inside the loop. No file
|
|
327
|
+
// (a host that predates it) leaves the gate as it was.
|
|
328
|
+
let runVerify;
|
|
329
|
+
if (mission && args.ticketFile) {
|
|
330
|
+
const checksPath = join(dirname(args.ticketFile), MISSION_CHECKS_FILE_NAME);
|
|
331
|
+
const read = readMissionChecksFile(checksPath, readFile);
|
|
332
|
+
if (read.file) {
|
|
333
|
+
const checks = read.file;
|
|
334
|
+
runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks });
|
|
335
|
+
}
|
|
336
|
+
else if (read.problem !== "missing") {
|
|
337
|
+
const why = read.problem === "malformed" ? "not a version 1 checks file" : "it could not be read";
|
|
338
|
+
writeErr(`Ignoring ${checksPath}: ${why}. The verify gate runs its own checks only.`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
322
341
|
const pipelineDeps = {
|
|
323
342
|
cwd,
|
|
324
343
|
childEnv,
|
|
@@ -337,6 +356,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
337
356
|
...(deps.stages ? { stages: deps.stages } : {}),
|
|
338
357
|
grounded: deps.grounded ?? grounding,
|
|
339
358
|
...(mission ? { mission } : {}),
|
|
359
|
+
...(runVerify ? { runVerify } : {}),
|
|
340
360
|
...(continuation ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
|
|
341
361
|
...(resumeFrom ? { resumeFrom } : {}),
|
|
342
362
|
onEvent: (ev, tag) => {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `checks.json`, the mission file that hands the build's verify gate what to
|
|
3
|
+
* run besides its own detected scripts: the plan's approved checks (each with
|
|
4
|
+
* what the host already knows: it fails on the unchanged code, or the sandbox
|
|
5
|
+
* cannot start it) and the pull request's CI jobs, read from the repository's
|
|
6
|
+
* workflows at build time.
|
|
7
|
+
*
|
|
8
|
+
* The format's producer is `renderMissionChecksJson` in
|
|
9
|
+
* `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
|
|
10
|
+
* not depend on the backend, so it reads the same version 1 with its own
|
|
11
|
+
* parser. A change there is a change here. A file that is missing or does not
|
|
12
|
+
* read as version 1 means no repository checks, never a stopped build.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MISSION_CHECKS_FILE_NAME = "checks.json";
|
|
15
|
+
export type MissionCheckStatus = "runnable" | "fails_on_base" | "cannot_run";
|
|
16
|
+
export interface MissionCheck {
|
|
17
|
+
command: string;
|
|
18
|
+
status: MissionCheckStatus;
|
|
19
|
+
cause?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MissionCiStep {
|
|
22
|
+
name: string;
|
|
23
|
+
run: string;
|
|
24
|
+
workingDirectory?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
export interface MissionCiJob {
|
|
28
|
+
workflow: string;
|
|
29
|
+
job: string;
|
|
30
|
+
name: string;
|
|
31
|
+
paths?: string[];
|
|
32
|
+
pathsIgnore?: string[];
|
|
33
|
+
steps: MissionCiStep[];
|
|
34
|
+
cannotRun: Array<{
|
|
35
|
+
step: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export interface MissionChecksFile {
|
|
40
|
+
version: 1;
|
|
41
|
+
checks: MissionCheck[];
|
|
42
|
+
ciJobs: MissionCiJob[];
|
|
43
|
+
}
|
|
44
|
+
/** The file, or null when the text is not a well-formed version 1 file. */
|
|
45
|
+
export declare function parseMissionChecksFile(text: string): MissionChecksFile | null;
|
|
46
|
+
/**
|
|
47
|
+
* Read the file. `missing` (no such file: a host that predates it, or a run
|
|
48
|
+
* with no mission) is silent; `unreadable` and `malformed` are the caller's to
|
|
49
|
+
* report once.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readMissionChecksFile(path: string, readFile?: (path: string) => string): {
|
|
52
|
+
file: MissionChecksFile;
|
|
53
|
+
} | {
|
|
54
|
+
file: null;
|
|
55
|
+
problem: "missing" | "unreadable" | "malformed";
|
|
56
|
+
};
|
|
57
|
+
/** GitHub's filter glob: `*` within one path segment, `**` across segments, `?` one character. */
|
|
58
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
59
|
+
/**
|
|
60
|
+
* Whether a change to `changedPaths` runs this job on its pull request, by
|
|
61
|
+
* GitHub's rules: with `paths`, when any changed path matches; with
|
|
62
|
+
* `paths-ignore`, unless every changed path is ignored (a new folder is never
|
|
63
|
+
* taken as ignored). No filter: always. No changed paths: never.
|
|
64
|
+
*/
|
|
65
|
+
export declare function ciJobInScope(job: Pick<MissionCiJob, "paths" | "pathsIgnore">, changedPaths: readonly string[]): boolean;
|
|
66
|
+
//# sourceMappingURL=missionChecks.d.ts.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `checks.json`, the mission file that hands the build's verify gate what to
|
|
3
|
+
* run besides its own detected scripts: the plan's approved checks (each with
|
|
4
|
+
* what the host already knows: it fails on the unchanged code, or the sandbox
|
|
5
|
+
* cannot start it) and the pull request's CI jobs, read from the repository's
|
|
6
|
+
* workflows at build time.
|
|
7
|
+
*
|
|
8
|
+
* The format's producer is `renderMissionChecksJson` in
|
|
9
|
+
* `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
|
|
10
|
+
* not depend on the backend, so it reads the same version 1 with its own
|
|
11
|
+
* parser. A change there is a change here. A file that is missing or does not
|
|
12
|
+
* read as version 1 means no repository checks, never a stopped build.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
export const MISSION_CHECKS_FILE_NAME = "checks.json";
|
|
16
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
const isString = (value) => typeof value === "string";
|
|
18
|
+
function stringList(value) {
|
|
19
|
+
return Array.isArray(value) && value.every(isString) ? value : null;
|
|
20
|
+
}
|
|
21
|
+
function stringRecord(value) {
|
|
22
|
+
if (!isRecord(value))
|
|
23
|
+
return null;
|
|
24
|
+
return Object.values(value).every(isString) ? value : null;
|
|
25
|
+
}
|
|
26
|
+
function parseStep(value) {
|
|
27
|
+
if (!isRecord(value) || !isString(value.name) || !isString(value.run))
|
|
28
|
+
return null;
|
|
29
|
+
const step = { name: value.name, run: value.run };
|
|
30
|
+
if (value.workingDirectory !== undefined) {
|
|
31
|
+
if (!isString(value.workingDirectory))
|
|
32
|
+
return null;
|
|
33
|
+
step.workingDirectory = value.workingDirectory;
|
|
34
|
+
}
|
|
35
|
+
if (value.env !== undefined) {
|
|
36
|
+
const env = stringRecord(value.env);
|
|
37
|
+
if (!env)
|
|
38
|
+
return null;
|
|
39
|
+
step.env = env;
|
|
40
|
+
}
|
|
41
|
+
return step;
|
|
42
|
+
}
|
|
43
|
+
function parseJob(value) {
|
|
44
|
+
if (!isRecord(value) ||
|
|
45
|
+
!isString(value.workflow) ||
|
|
46
|
+
!isString(value.job) ||
|
|
47
|
+
!isString(value.name))
|
|
48
|
+
return null;
|
|
49
|
+
if (!Array.isArray(value.steps) || !Array.isArray(value.cannotRun))
|
|
50
|
+
return null;
|
|
51
|
+
const steps = [];
|
|
52
|
+
for (const raw of value.steps) {
|
|
53
|
+
const step = parseStep(raw);
|
|
54
|
+
if (!step)
|
|
55
|
+
return null;
|
|
56
|
+
steps.push(step);
|
|
57
|
+
}
|
|
58
|
+
const cannotRun = [];
|
|
59
|
+
for (const raw of value.cannotRun) {
|
|
60
|
+
if (!isRecord(raw) || !isString(raw.step) || !isString(raw.reason))
|
|
61
|
+
return null;
|
|
62
|
+
cannotRun.push({ step: raw.step, reason: raw.reason });
|
|
63
|
+
}
|
|
64
|
+
const job = {
|
|
65
|
+
workflow: value.workflow,
|
|
66
|
+
job: value.job,
|
|
67
|
+
name: value.name,
|
|
68
|
+
steps,
|
|
69
|
+
cannotRun,
|
|
70
|
+
};
|
|
71
|
+
for (const key of ["paths", "pathsIgnore"]) {
|
|
72
|
+
if (value[key] === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const list = stringList(value[key]);
|
|
75
|
+
if (!list)
|
|
76
|
+
return null;
|
|
77
|
+
job[key] = list;
|
|
78
|
+
}
|
|
79
|
+
return job;
|
|
80
|
+
}
|
|
81
|
+
/** The file, or null when the text is not a well-formed version 1 file. */
|
|
82
|
+
export function parseMissionChecksFile(text) {
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Not JSON: the caller reports the null.
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!isRecord(parsed) ||
|
|
92
|
+
parsed.version !== 1 ||
|
|
93
|
+
!Array.isArray(parsed.checks) ||
|
|
94
|
+
!Array.isArray(parsed.ciJobs)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const checks = [];
|
|
98
|
+
for (const raw of parsed.checks) {
|
|
99
|
+
if (!isRecord(raw) || !isString(raw.command))
|
|
100
|
+
return null;
|
|
101
|
+
if (raw.status !== "runnable" && raw.status !== "fails_on_base" && raw.status !== "cannot_run")
|
|
102
|
+
return null;
|
|
103
|
+
const check = { command: raw.command, status: raw.status };
|
|
104
|
+
if (isString(raw.cause))
|
|
105
|
+
check.cause = raw.cause;
|
|
106
|
+
checks.push(check);
|
|
107
|
+
}
|
|
108
|
+
const ciJobs = [];
|
|
109
|
+
for (const raw of parsed.ciJobs) {
|
|
110
|
+
const job = parseJob(raw);
|
|
111
|
+
if (!job)
|
|
112
|
+
return null;
|
|
113
|
+
ciJobs.push(job);
|
|
114
|
+
}
|
|
115
|
+
return { version: 1, checks, ciJobs };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read the file. `missing` (no such file: a host that predates it, or a run
|
|
119
|
+
* with no mission) is silent; `unreadable` and `malformed` are the caller's to
|
|
120
|
+
* report once.
|
|
121
|
+
*/
|
|
122
|
+
export function readMissionChecksFile(path, readFile = (p) => readFileSync(p, "utf8")) {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = readFile(path);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const code = isRecord(err) && typeof err.code === "string" ? err.code : null;
|
|
129
|
+
return { file: null, problem: code === "ENOENT" ? "missing" : "unreadable" };
|
|
130
|
+
}
|
|
131
|
+
const file = parseMissionChecksFile(text);
|
|
132
|
+
return file ? { file } : { file: null, problem: "malformed" };
|
|
133
|
+
}
|
|
134
|
+
/** GitHub's filter glob: `*` within one path segment, `**` across segments, `?` one character. */
|
|
135
|
+
export function globToRegExp(pattern) {
|
|
136
|
+
let source = "";
|
|
137
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
138
|
+
const char = pattern[index];
|
|
139
|
+
if (char === "*" && pattern[index + 1] === "*") {
|
|
140
|
+
source += ".*";
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
else if (char === "*") {
|
|
144
|
+
source += "[^/]*";
|
|
145
|
+
}
|
|
146
|
+
else if (char === "?") {
|
|
147
|
+
source += "[^/]";
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return new RegExp(`^${source}$`);
|
|
154
|
+
}
|
|
155
|
+
/** The part of a pattern before its first glob character. */
|
|
156
|
+
function literalPrefix(pattern) {
|
|
157
|
+
const at = pattern.search(/[*?[]/);
|
|
158
|
+
return at < 0 ? pattern : pattern.slice(0, at);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Whether one changed path matches a pattern. Porcelain reports a new
|
|
162
|
+
* untracked folder as `dir/`, not its files: it matches when the pattern's
|
|
163
|
+
* literal start and the folder overlap, so a doubt runs the job.
|
|
164
|
+
*/
|
|
165
|
+
function pathMatches(pattern, path) {
|
|
166
|
+
if (!path.endsWith("/"))
|
|
167
|
+
return globToRegExp(pattern).test(path);
|
|
168
|
+
const prefix = literalPrefix(pattern);
|
|
169
|
+
return prefix.startsWith(path) || path.startsWith(prefix);
|
|
170
|
+
}
|
|
171
|
+
/** An ordered list with `!` exclusions: the last pattern that matches decides. */
|
|
172
|
+
function listMatches(patterns, path) {
|
|
173
|
+
let matched = false;
|
|
174
|
+
for (const raw of patterns) {
|
|
175
|
+
const negated = raw.startsWith("!");
|
|
176
|
+
if (pathMatches(negated ? raw.slice(1) : raw, path))
|
|
177
|
+
matched = !negated;
|
|
178
|
+
}
|
|
179
|
+
return matched;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether a change to `changedPaths` runs this job on its pull request, by
|
|
183
|
+
* GitHub's rules: with `paths`, when any changed path matches; with
|
|
184
|
+
* `paths-ignore`, unless every changed path is ignored (a new folder is never
|
|
185
|
+
* taken as ignored). No filter: always. No changed paths: never.
|
|
186
|
+
*/
|
|
187
|
+
export function ciJobInScope(job, changedPaths) {
|
|
188
|
+
if (changedPaths.length === 0)
|
|
189
|
+
return false;
|
|
190
|
+
if (job.paths)
|
|
191
|
+
return changedPaths.some((path) => listMatches(job.paths, path));
|
|
192
|
+
if (job.pathsIgnore) {
|
|
193
|
+
return changedPaths.some((path) => path.endsWith("/") || !listMatches(job.pathsIgnore, path));
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=missionChecks.js.map
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
* command output passes through {@link scrubSecrets} before it can enter
|
|
47
47
|
* findings, handoffs, or run records (§0.9).
|
|
48
48
|
*/
|
|
49
|
+
import { type ToolchainFile } from "../toolchain/spawnHook.js";
|
|
50
|
+
import { type MissionChecksFile } from "./missionChecks.js";
|
|
49
51
|
import type { Finding } from "./types.js";
|
|
50
52
|
/**
|
|
51
53
|
* Total wall-clock budget for a single verify run before it is abandoned
|
|
@@ -55,6 +57,17 @@ import type { Finding } from "./types.js";
|
|
|
55
57
|
export declare const VERIFY_TIMEOUT_MS: number;
|
|
56
58
|
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
|
|
57
59
|
export declare const VERIFY_TEST_TIMEOUT_MS: number;
|
|
60
|
+
/**
|
|
61
|
+
* The repository-checks half (the plan's approved checks and the pull
|
|
62
|
+
* request's CI steps) across a whole build: the gate runs after implement,
|
|
63
|
+
* after each fix turn and every review round, so a cap per call alone could
|
|
64
|
+
* add an hour to a build.
|
|
65
|
+
*/
|
|
66
|
+
export declare const REPO_CHECKS_BUILD_BUDGET_MS: number;
|
|
67
|
+
/** The same half's cap in one gate call, inside the run budget's verify allowance. */
|
|
68
|
+
export declare const REPO_CHECKS_CALL_BUDGET_MS: number;
|
|
69
|
+
/** One approved check's or CI step's cap. */
|
|
70
|
+
export declare const REPO_CHECK_TIMEOUT_MS: number;
|
|
58
71
|
/** A resolved verify command plus how it was found. */
|
|
59
72
|
export interface VerifyCommand {
|
|
60
73
|
/** Shell-free command + args, e.g. ["pnpm", "run", "typecheck"]. */
|
|
@@ -116,7 +129,10 @@ export declare function isFannedOutScript(body: string): boolean;
|
|
|
116
129
|
* Parse `git status --porcelain` into repo-relative changed paths. Each line is
|
|
117
130
|
* `XY <path>`; we drop the 2-char XY status + its separating space, and for a
|
|
118
131
|
* rename/copy (`R old -> new`) take the destination. Pure; the impure git
|
|
119
|
-
* shell-out is {@link snapshotWorkspace}
|
|
132
|
+
* shell-out is {@link snapshotWorkspace}, whose status is trimmed as a whole:
|
|
133
|
+
* a first line ` M path` arrives as `M path`. A whole line always has a space
|
|
134
|
+
* third, so a line without one lost its leading space and its path starts one
|
|
135
|
+
* column earlier.
|
|
120
136
|
*/
|
|
121
137
|
export declare function parseChangedPaths(status: string): string[];
|
|
122
138
|
/**
|
|
@@ -261,6 +277,25 @@ export declare function buildVerifyEnv(source?: NodeJS.ProcessEnv): NodeJS.Proce
|
|
|
261
277
|
* once instead of parking in watch mode until the 10-min cap kills it.
|
|
262
278
|
*/
|
|
263
279
|
export declare function buildTestEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
280
|
+
/**
|
|
281
|
+
* The environment an approved check or CI step runs under: the test half's
|
|
282
|
+
* (no `YAGNI_*`, no NODE_OPTIONS, CI on) minus the secrets the host strips
|
|
283
|
+
* from its own check runs, on the repository's toolchain when the build named
|
|
284
|
+
* one (the same PATH and variables the model's shell gets), then the step's
|
|
285
|
+
* own literal variables.
|
|
286
|
+
*/
|
|
287
|
+
export declare function buildRepoCheckEnv(source?: NodeJS.ProcessEnv, toolchain?: ToolchainFile | null, stepEnv?: Record<string, string>): NodeJS.ProcessEnv;
|
|
288
|
+
/** One porcelain line's path, and whether it is untracked. */
|
|
289
|
+
interface TreeEntry {
|
|
290
|
+
path: string;
|
|
291
|
+
untracked: boolean;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The porcelain entries in `after` that `before` did not have: what the
|
|
295
|
+
* repository checks wrote into the tree. A path that was already dirty is the
|
|
296
|
+
* build's, and is not listed. Pure.
|
|
297
|
+
*/
|
|
298
|
+
export declare function newTreeEntries(before: string, after: string): TreeEntry[];
|
|
264
299
|
export declare function makeDefaultExec(opts?: {
|
|
265
300
|
graceMs?: number;
|
|
266
301
|
killTree?: (pid: number) => void;
|
|
@@ -289,6 +324,29 @@ export interface MakeRunVerifyOpts {
|
|
|
289
324
|
testTimeoutMs?: number;
|
|
290
325
|
/** Test-half env override for determinism in tests (default: buildTestEnv() per run). */
|
|
291
326
|
testEnv?: NodeJS.ProcessEnv;
|
|
327
|
+
/**
|
|
328
|
+
* The mission's `checks.json` (the approved checks and the pull request's CI
|
|
329
|
+
* jobs). Absent or null: no repository-checks half, as before.
|
|
330
|
+
*/
|
|
331
|
+
repoChecks?: () => MissionChecksFile | null;
|
|
332
|
+
/**
|
|
333
|
+
* This run's own changed paths (repo-relative), which scope the CI jobs by
|
|
334
|
+
* their `paths` filters. Default: the same `git status` the scope reads,
|
|
335
|
+
* minus the pre-run dirt.
|
|
336
|
+
*/
|
|
337
|
+
changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
|
|
338
|
+
/** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
|
|
339
|
+
repoChecksEnv?: NodeJS.ProcessEnv;
|
|
340
|
+
/** The toolchain the repository checks run on (default: `YAGNI_TOOLCHAIN_FILE`, when it reads). */
|
|
341
|
+
toolchain?: () => ToolchainFile | null;
|
|
342
|
+
repoChecksBuildBudgetMs?: number;
|
|
343
|
+
repoChecksCallBudgetMs?: number;
|
|
344
|
+
repoCheckTimeoutMs?: number;
|
|
345
|
+
now?: () => number;
|
|
346
|
+
/** The tree's porcelain status before and after the half (default: `git status`). */
|
|
347
|
+
treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
348
|
+
/** Undo what the half wrote into the tree (default: `git clean` / `git checkout` of exactly those paths). */
|
|
349
|
+
undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
|
|
292
350
|
}
|
|
293
351
|
/**
|
|
294
352
|
* One workstream as the scoped checker sees it: the name its findings are
|
|
@@ -365,4 +423,5 @@ export declare function makeWorkstreamCheck(opts?: MakeWorkstreamCheckOpts): (cw
|
|
|
365
423
|
* blocks a real change on its own failure.
|
|
366
424
|
*/
|
|
367
425
|
export declare function makeRunVerify(opts?: MakeRunVerifyOpts): (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<VerifyOutcome>;
|
|
426
|
+
export {};
|
|
368
427
|
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -54,8 +54,10 @@ import { readFileSync } from "node:fs";
|
|
|
54
54
|
// backslashed strings that no longer compare equal to the git-derived repoRoot
|
|
55
55
|
// and would break the walk-up termination check.
|
|
56
56
|
import { basename, dirname, join } from "node:path/posix";
|
|
57
|
+
import { readToolchainFile } from "../toolchain/spawnHook.js";
|
|
57
58
|
import { killTreeSync, trackChild } from "./childRegistry.js";
|
|
58
59
|
import { claimCovers } from "./fanout.js";
|
|
60
|
+
import { ciJobInScope } from "./missionChecks.js";
|
|
59
61
|
import { composeAbortSignal } from "./resilience.js";
|
|
60
62
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
61
63
|
import { snapshotWorkspace } from "./workspace.js";
|
|
@@ -67,6 +69,21 @@ import { snapshotWorkspace } from "./workspace.js";
|
|
|
67
69
|
export const VERIFY_TIMEOUT_MS = 20 * 60_000;
|
|
68
70
|
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
|
|
69
71
|
export const VERIFY_TEST_TIMEOUT_MS = 20 * 60_000;
|
|
72
|
+
/**
|
|
73
|
+
* The repository-checks half (the plan's approved checks and the pull
|
|
74
|
+
* request's CI steps) across a whole build: the gate runs after implement,
|
|
75
|
+
* after each fix turn and every review round, so a cap per call alone could
|
|
76
|
+
* add an hour to a build.
|
|
77
|
+
*/
|
|
78
|
+
export const REPO_CHECKS_BUILD_BUDGET_MS = 30 * 60_000;
|
|
79
|
+
/** The same half's cap in one gate call, inside the run budget's verify allowance. */
|
|
80
|
+
export const REPO_CHECKS_CALL_BUDGET_MS = 15 * 60_000;
|
|
81
|
+
/** One approved check's or CI step's cap. */
|
|
82
|
+
export const REPO_CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
83
|
+
/** Below this much budget left, the next repository check is not started. */
|
|
84
|
+
const REPO_CHECK_MIN_START_MS = 5_000;
|
|
85
|
+
/** Cannot-run entries named in the note before "and N more". */
|
|
86
|
+
const MAX_NAMED_CANNOT_RUN = 10;
|
|
70
87
|
/** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
|
|
71
88
|
const MAX_LOCATED_FINDINGS = 25;
|
|
72
89
|
const MAX_TAIL_CHARS = 1000;
|
|
@@ -129,14 +146,17 @@ export function isFannedOutScript(body) {
|
|
|
129
146
|
* Parse `git status --porcelain` into repo-relative changed paths. Each line is
|
|
130
147
|
* `XY <path>`; we drop the 2-char XY status + its separating space, and for a
|
|
131
148
|
* rename/copy (`R old -> new`) take the destination. Pure; the impure git
|
|
132
|
-
* shell-out is {@link snapshotWorkspace}
|
|
149
|
+
* shell-out is {@link snapshotWorkspace}, whose status is trimmed as a whole:
|
|
150
|
+
* a first line ` M path` arrives as `M path`. A whole line always has a space
|
|
151
|
+
* third, so a line without one lost its leading space and its path starts one
|
|
152
|
+
* column earlier.
|
|
133
153
|
*/
|
|
134
154
|
export function parseChangedPaths(status) {
|
|
135
155
|
const paths = [];
|
|
136
156
|
for (const raw of status.split("\n")) {
|
|
137
|
-
if (raw.length <
|
|
138
|
-
continue; // need at least "
|
|
139
|
-
const entry = raw.slice(3)
|
|
157
|
+
if (raw.length < 3)
|
|
158
|
+
continue; // need at least "X p"
|
|
159
|
+
const entry = raw[2] === " " ? raw.slice(3) : raw.slice(2);
|
|
140
160
|
const arrow = entry.indexOf(" -> ");
|
|
141
161
|
const path = (arrow >= 0 ? entry.slice(arrow + 4) : entry).trim();
|
|
142
162
|
if (path)
|
|
@@ -640,6 +660,70 @@ export function buildTestEnv(source = process.env) {
|
|
|
640
660
|
env.CI = "true";
|
|
641
661
|
return env;
|
|
642
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* The secrets a repository check never sees: a copy of the host's
|
|
665
|
+
* `VERIFICATION_SECRET_ENV_VARS` in
|
|
666
|
+
* `packages/backend/src/integrations/e2b/supervisedCandidateSession.ts`
|
|
667
|
+
* (`YAGNI_*` goes with the test env). This package cannot import the backend:
|
|
668
|
+
* a change to either list is a change to both.
|
|
669
|
+
*/
|
|
670
|
+
const REPO_CHECK_SECRET_KEYS = [
|
|
671
|
+
"OPENAI_API_KEY",
|
|
672
|
+
"ANTHROPIC_API_KEY",
|
|
673
|
+
"FIREWORKS_API_KEY",
|
|
674
|
+
"BASETEN_API_KEY",
|
|
675
|
+
"GITHUB_TOKEN",
|
|
676
|
+
"GH_TOKEN",
|
|
677
|
+
];
|
|
678
|
+
/**
|
|
679
|
+
* The environment an approved check or CI step runs under: the test half's
|
|
680
|
+
* (no `YAGNI_*`, no NODE_OPTIONS, CI on) minus the secrets the host strips
|
|
681
|
+
* from its own check runs, on the repository's toolchain when the build named
|
|
682
|
+
* one (the same PATH and variables the model's shell gets), then the step's
|
|
683
|
+
* own literal variables.
|
|
684
|
+
*/
|
|
685
|
+
export function buildRepoCheckEnv(source = process.env, toolchain = null, stepEnv = {}) {
|
|
686
|
+
const env = buildTestEnv(source);
|
|
687
|
+
for (const key of REPO_CHECK_SECRET_KEYS)
|
|
688
|
+
delete env[key];
|
|
689
|
+
const withToolchain = toolchain ? { ...toolchain.env, ...env, PATH: toolchain.path } : env;
|
|
690
|
+
return { ...withToolchain, ...stepEnv, CI: "true" };
|
|
691
|
+
}
|
|
692
|
+
/** One repository command as the gate runs it: through bash, as Actions runs a `run:` step. */
|
|
693
|
+
function repoCheckArgv(run) {
|
|
694
|
+
return ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", run];
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* The porcelain entries in `after` that `before` did not have: what the
|
|
698
|
+
* repository checks wrote into the tree. A path that was already dirty is the
|
|
699
|
+
* build's, and is not listed. Pure.
|
|
700
|
+
*/
|
|
701
|
+
export function newTreeEntries(before, after) {
|
|
702
|
+
const known = new Set(parseChangedPaths(before));
|
|
703
|
+
return after
|
|
704
|
+
.split("\n")
|
|
705
|
+
.filter((line) => line.trim().length >= 3)
|
|
706
|
+
.flatMap((line) => {
|
|
707
|
+
const [path] = parseChangedPaths(line);
|
|
708
|
+
if (!path || known.has(path))
|
|
709
|
+
return [];
|
|
710
|
+
return [{ path, untracked: line.trimStart().startsWith("??") }];
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
function runGitQuiet(args, cwd) {
|
|
714
|
+
return new Promise((resolve, reject) => {
|
|
715
|
+
execFile("git", args, { cwd, maxBuffer: 1024 * 1024 }, (err) => (err ? reject(err) : resolve()));
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/** Undo what the repository checks wrote: an untracked path is removed, a tracked one restored. */
|
|
719
|
+
async function defaultUndoTreeWrites(cwd, entries) {
|
|
720
|
+
const untracked = entries.filter((entry) => entry.untracked).map((entry) => entry.path);
|
|
721
|
+
const tracked = entries.filter((entry) => !entry.untracked).map((entry) => entry.path);
|
|
722
|
+
if (untracked.length > 0)
|
|
723
|
+
await runGitQuiet(["clean", "-fdq", "--", ...untracked], cwd);
|
|
724
|
+
if (tracked.length > 0)
|
|
725
|
+
await runGitQuiet(["checkout", "-q", "--", ...tracked], cwd);
|
|
726
|
+
}
|
|
643
727
|
// `signal` is deliberately NOT passed into execFile: Node's built-in abort
|
|
644
728
|
// handling kills via the child's pid, and when the spawn itself failed (missing
|
|
645
729
|
// command, missing cwd) an abort landing in the same tick resolves that to pid 0
|
|
@@ -845,7 +929,213 @@ export function makeRunVerify(opts = {}) {
|
|
|
845
929
|
const exec = opts.exec ?? defaultExec;
|
|
846
930
|
const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
847
931
|
const testTimeoutMs = opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS;
|
|
932
|
+
const now = opts.now ?? Date.now;
|
|
933
|
+
const changedPaths = opts.changedPaths ??
|
|
934
|
+
(async (cwd, signal, baselinePaths) => {
|
|
935
|
+
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
936
|
+
if (!snapshot.tracked)
|
|
937
|
+
return [];
|
|
938
|
+
const baseline = new Set(baselinePaths ?? []);
|
|
939
|
+
return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
|
|
940
|
+
});
|
|
941
|
+
const toolchain = opts.toolchain ??
|
|
942
|
+
(() => {
|
|
943
|
+
const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
|
|
944
|
+
return path ? readToolchainFile(path) : null;
|
|
945
|
+
});
|
|
946
|
+
const treeStatus = opts.treeStatus ?? (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
|
|
947
|
+
const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
|
|
948
|
+
// Across the whole build: the half's time spent, and each CI step's failure
|
|
949
|
+
// keys from the last call it failed in.
|
|
950
|
+
let repoChecksSpentMs = 0;
|
|
951
|
+
const lastCiFailure = new Map();
|
|
952
|
+
/**
|
|
953
|
+
* The repository-checks half: the plan's approved checks, then the pull
|
|
954
|
+
* request's CI steps its changed paths trigger. A failure becomes `critical`
|
|
955
|
+
* findings the fix turn works on, except where it can never clear: a check
|
|
956
|
+
* the sandbox cannot start, an infra failure, an approved check that already
|
|
957
|
+
* failed on the unchanged code (the host compares it after the build), and a
|
|
958
|
+
* CI step that fails exactly as it did before a fix turn (it may fail on the
|
|
959
|
+
* base too, and the pull request's CI shows it). Those are notes.
|
|
960
|
+
*/
|
|
961
|
+
async function runRepoChecks(input) {
|
|
962
|
+
const { file, cwd, signal } = input;
|
|
963
|
+
const findings = [];
|
|
964
|
+
const reasons = [];
|
|
965
|
+
const labels = [];
|
|
966
|
+
const cannotRun = [];
|
|
967
|
+
for (const check of file.checks) {
|
|
968
|
+
if (check.status === "cannot_run")
|
|
969
|
+
cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
|
|
970
|
+
}
|
|
971
|
+
const paths = await changedPaths(cwd, signal, input.baselinePaths);
|
|
972
|
+
const jobs = file.ciJobs.filter((job) => ciJobInScope(job, paths));
|
|
973
|
+
for (const job of jobs) {
|
|
974
|
+
for (const entry of job.cannotRun)
|
|
975
|
+
cannotRun.push(`CI ${job.name}, "${entry.step}" (${entry.reason})`);
|
|
976
|
+
}
|
|
977
|
+
if (cannotRun.length > 0) {
|
|
978
|
+
const named = cannotRun.slice(0, MAX_NAMED_CANNOT_RUN).join("; ");
|
|
979
|
+
const more = cannotRun.length > MAX_NAMED_CANNOT_RUN ? `; and ${cannotRun.length - MAX_NAMED_CANNOT_RUN} more` : "";
|
|
980
|
+
reasons.push(`could not run in the sandbox: ${named}${more}`);
|
|
981
|
+
}
|
|
982
|
+
const approved = file.checks.filter((check) => check.status !== "cannot_run");
|
|
983
|
+
const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
|
|
984
|
+
if (approved.length === 0 && steps.length === 0)
|
|
985
|
+
return { findings, reasons, labels };
|
|
986
|
+
const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
|
|
987
|
+
if (buildLeft < REPO_CHECK_MIN_START_MS) {
|
|
988
|
+
reasons.push("approved checks and CI steps not run again: the build's check budget is spent; the host still runs the approved checks after the build");
|
|
989
|
+
return { findings, reasons, labels };
|
|
990
|
+
}
|
|
991
|
+
const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
|
|
992
|
+
const halfStartedAt = now();
|
|
993
|
+
const run = {
|
|
994
|
+
cwd,
|
|
995
|
+
signal,
|
|
996
|
+
left: () => callBudget - (now() - halfStartedAt),
|
|
997
|
+
toolchainFile: toolchain(),
|
|
998
|
+
findings,
|
|
999
|
+
reasons,
|
|
1000
|
+
labels,
|
|
1001
|
+
notReached: 0,
|
|
1002
|
+
};
|
|
1003
|
+
const before = await treeStatus(cwd, signal).catch((err) => {
|
|
1004
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
reasons.push(`could not read the tree before the checks, so what they write is not undone: ${scrubSecrets(msg)}`);
|
|
1006
|
+
return null;
|
|
1007
|
+
});
|
|
1008
|
+
try {
|
|
1009
|
+
await runApprovedChecks(run, approved);
|
|
1010
|
+
await runCiSteps(run, steps, new Set(file.checks.map((check) => check.command.trim())));
|
|
1011
|
+
}
|
|
1012
|
+
finally {
|
|
1013
|
+
repoChecksSpentMs += now() - halfStartedAt;
|
|
1014
|
+
// What the checks wrote into the tree (a coverage folder, an updated
|
|
1015
|
+
// snapshot, a build output) is not the build's change: undone, so the
|
|
1016
|
+
// candidate never carries it.
|
|
1017
|
+
if (before !== null) {
|
|
1018
|
+
try {
|
|
1019
|
+
const written = newTreeEntries(before, await treeStatus(cwd));
|
|
1020
|
+
if (written.length > 0)
|
|
1021
|
+
await undoTreeWrites(cwd, written);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1025
|
+
reasons.push(`could not undo what the checks wrote into the tree: ${scrubSecrets(msg)}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (run.notReached > 0) {
|
|
1030
|
+
reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget; the host still runs the approved checks after the build`);
|
|
1031
|
+
}
|
|
1032
|
+
return { findings, reasons, labels };
|
|
1033
|
+
}
|
|
1034
|
+
/** One repository command's verdict: passed, a note (it could not run), or a failure with its output. */
|
|
1035
|
+
async function runRepoCommand(run, command, dir, stepEnv) {
|
|
1036
|
+
const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
|
|
1037
|
+
const composed = composeAbortSignal(run.signal, AbortSignal.timeout(Math.max(1, capMs)));
|
|
1038
|
+
const env = opts.repoChecksEnv
|
|
1039
|
+
? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
|
|
1040
|
+
: buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
|
|
1041
|
+
try {
|
|
1042
|
+
const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
|
|
1043
|
+
if (code === 0)
|
|
1044
|
+
return { kind: "passed" };
|
|
1045
|
+
const output = scrubSecrets(rawOutput);
|
|
1046
|
+
const cls = classifyTestFailure(output, code);
|
|
1047
|
+
if (cls.kind === "infra")
|
|
1048
|
+
return { kind: "note", reason: `could not run: ${cls.reason}` };
|
|
1049
|
+
return { kind: "failed", output, code };
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1053
|
+
return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** A failure's findings, located from the folder the command ran in and prefixed with what ran. */
|
|
1057
|
+
function repoFailureFindings(run, failure, label, prefix, dir) {
|
|
1058
|
+
const cls = classifyTestFailure(failure.output, failure.code);
|
|
1059
|
+
const parsed = cls.kind === "failures" ? cls.findings : parseVerifyFailures(failure.output, failure.code, label);
|
|
1060
|
+
return parsed.map((f) => ({ ...liftFinding(f, run.cwd, dir), message: `${prefix}: ${f.message}` }));
|
|
1061
|
+
}
|
|
1062
|
+
const findingRoom = (run) => Math.max(0, MAX_LOCATED_FINDINGS - run.findings.length);
|
|
1063
|
+
/**
|
|
1064
|
+
* The plan's approved checks, at the repository root as the host runs them.
|
|
1065
|
+
* One that already failed on the unchanged code is a note: the host compares
|
|
1066
|
+
* the two after the build.
|
|
1067
|
+
*/
|
|
1068
|
+
async function runApprovedChecks(run, approved) {
|
|
1069
|
+
for (const check of approved) {
|
|
1070
|
+
if (run.signal?.aborted)
|
|
1071
|
+
return;
|
|
1072
|
+
if (run.left() < REPO_CHECK_MIN_START_MS) {
|
|
1073
|
+
run.notReached += 1;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
|
|
1077
|
+
const verdict = await runRepoCommand(run, check.command, run.cwd, undefined);
|
|
1078
|
+
if (verdict.kind === "note") {
|
|
1079
|
+
run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
|
|
1080
|
+
}
|
|
1081
|
+
else if (verdict.kind === "failed" && check.status === "fails_on_base") {
|
|
1082
|
+
run.reasons.push(`approved check \`${label}\` failed, and it already failed on the unchanged code; the host compares the two after the build`);
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
run.labels.push(label);
|
|
1086
|
+
if (verdict.kind === "failed") {
|
|
1087
|
+
const found = repoFailureFindings(run, verdict, label, `approved check \`${label}\``, run.cwd);
|
|
1088
|
+
run.findings.push(...found.slice(0, findingRoom(run)));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The pull request's CI steps, job by job. A job stops at its first failing
|
|
1095
|
+
* step, as it does on the runner; a step that is one of the approved checks
|
|
1096
|
+
* already ran. A step that fails with the same findings it failed with on
|
|
1097
|
+
* the previous call (so after a fix turn) is a note: it may fail on the base
|
|
1098
|
+
* too, and the pull request's CI shows it.
|
|
1099
|
+
*/
|
|
1100
|
+
async function runCiSteps(run, steps, approvedCommands) {
|
|
1101
|
+
const stoppedJobs = new Set();
|
|
1102
|
+
for (const { job, step } of steps) {
|
|
1103
|
+
if (run.signal?.aborted)
|
|
1104
|
+
return;
|
|
1105
|
+
if (stoppedJobs.has(job) || approvedCommands.has(step.run.trim()))
|
|
1106
|
+
continue;
|
|
1107
|
+
if (run.left() < REPO_CHECK_MIN_START_MS) {
|
|
1108
|
+
run.notReached += 1;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
const where = `CI ${job.name}, step "${step.name}"`;
|
|
1112
|
+
const key = `${job.workflow}\u0000${job.job}\u0000${step.name}\u0000${step.run}`;
|
|
1113
|
+
const dir = step.workingDirectory ? join(run.cwd, step.workingDirectory.replace(/^\.\/+/, "")) : run.cwd;
|
|
1114
|
+
const verdict = await runRepoCommand(run, step.run, dir, step.env);
|
|
1115
|
+
if (verdict.kind === "passed") {
|
|
1116
|
+
lastCiFailure.delete(key);
|
|
1117
|
+
run.labels.push(`CI ${job.name}: ${step.name}`);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
stoppedJobs.add(job);
|
|
1121
|
+
if (verdict.kind === "note") {
|
|
1122
|
+
run.reasons.push(`${where} ${verdict.reason}`);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
|
|
1126
|
+
const keys = new Set(stepFindings.map((f) => f.message));
|
|
1127
|
+
const previous = lastCiFailure.get(key);
|
|
1128
|
+
lastCiFailure.set(key, keys);
|
|
1129
|
+
if (previous && keys.size > 0 && [...keys].every((k) => previous.has(k))) {
|
|
1130
|
+
run.reasons.push(`${where} still fails the same way after a fix turn; it may fail on the base too, and the pull request's CI shows it`);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
run.labels.push(`CI ${job.name}: ${step.name}`);
|
|
1134
|
+
run.findings.push(...stepFindings.slice(0, findingRoom(run)));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
848
1137
|
return async function runVerify(cwd, signal, baselinePaths) {
|
|
1138
|
+
const callStartedAt = now();
|
|
849
1139
|
const dirs = await changedDirs(cwd, signal, baselinePaths);
|
|
850
1140
|
const commands = detect(cwd, dirs);
|
|
851
1141
|
// An explicitly-allowed .yagni-code/verify override IS the whole gate (spec
|
|
@@ -853,7 +1143,10 @@ export function makeRunVerify(opts = {}) {
|
|
|
853
1143
|
// alongside it — the repo already told us exactly how to verify.
|
|
854
1144
|
const overridden = commands.some((c) => c.source === "override");
|
|
855
1145
|
const testCommands = overridden ? [] : detectTests(cwd, dirs);
|
|
856
|
-
|
|
1146
|
+
// The approved checks carry the plan's approval, not the repository's own
|
|
1147
|
+
// choice of how to verify, so an override never takes them out of the loop.
|
|
1148
|
+
const repoFile = opts.repoChecks?.() ?? null;
|
|
1149
|
+
if (commands.length === 0 && testCommands.length === 0 && !repoFile) {
|
|
857
1150
|
return { ran: false, ok: true, reason: "verify skipped: no verify command detected", findings: [] };
|
|
858
1151
|
}
|
|
859
1152
|
const findings = [];
|
|
@@ -898,6 +1191,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
898
1191
|
// root cause, and the loop must fix the compile errors first anyway; the
|
|
899
1192
|
// round where typecheck goes green runs the tests, so `clean` is never
|
|
900
1193
|
// reached without the tests having run (or their absence honestly noted).
|
|
1194
|
+
const typecheckFailed = findings.length > 0;
|
|
901
1195
|
const testLabels = [];
|
|
902
1196
|
if (testCommands.length > 0) {
|
|
903
1197
|
if (findings.length > 0) {
|
|
@@ -939,10 +1233,27 @@ export function makeRunVerify(opts = {}) {
|
|
|
939
1233
|
}
|
|
940
1234
|
}
|
|
941
1235
|
}
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
const
|
|
1236
|
+
// REPOSITORY-CHECKS HALF: the plan's approved checks and the pull
|
|
1237
|
+
// request's CI steps (`checks.json`), after the test half, under the same
|
|
1238
|
+
// rule: a tree that does not compile fails them for the same root cause.
|
|
1239
|
+
const repoLabels = [];
|
|
1240
|
+
if (repoFile) {
|
|
1241
|
+
if (typecheckFailed) {
|
|
1242
|
+
reasons.push("approved checks and CI steps skipped: the typecheck half failed; a later round runs them");
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
const repo = await runRepoChecks({ file: repoFile, cwd, signal, baselinePaths, callStartedAt });
|
|
1246
|
+
reasons.push(...repo.reasons);
|
|
1247
|
+
repoLabels.push(...repo.labels);
|
|
1248
|
+
if (repo.labels.length > 0)
|
|
1249
|
+
anyRan = true;
|
|
1250
|
+
findings.push(...repo.findings);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// The command labels list only what produced a verdict — on every half —
|
|
1254
|
+
// so the FINISH trailer's `passed (<cmds>)` never claims a check that was
|
|
1255
|
+
// infra-skipped or threw (invariant §0.1).
|
|
1256
|
+
const command = [...new Set(typecheckLabels), ...new Set(testLabels), ...new Set(repoLabels)].join(", ");
|
|
946
1257
|
if (!anyRan) {
|
|
947
1258
|
return { ran: false, ok: true, command, reason: reasons.join("; ") || "verify skipped", findings: [] };
|
|
948
1259
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.4-staging.
|
|
3
|
+
"version": "1.2.4-staging.1821.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "60f51163d9b1184bea0c2dca85cf2b7d07d6d275"
|
|
62
62
|
}
|