@yagni-app/code-staging 1.2.7-staging.2035.1 → 1.2.7-staging.2059.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/changeScan.d.ts +10 -3
- package/dist/extension/pipeline/changeScan.js +7 -5
- package/dist/extension/pipeline/cloud.d.ts +4 -2
- package/dist/extension/pipeline/cloud.js +11 -5
- package/dist/extension/pipeline/finish.d.ts +9 -1
- package/dist/extension/pipeline/finish.js +45 -15
- package/dist/extension/pipeline/headlessGo.js +24 -11
- package/dist/extension/pipeline/verify.d.ts +65 -8
- package/dist/extension/pipeline/verify.js +236 -50
- package/dist/extension/pipeline/workspace.d.ts +6 -0
- package/dist/extension/pipeline/workspace.js +12 -0
- package/package.json +2 -2
|
@@ -52,11 +52,18 @@ export interface ChangeScanOpts {
|
|
|
52
52
|
/** This session's own credentials, matched by value. */
|
|
53
53
|
sessionSecrets?: readonly string[];
|
|
54
54
|
readFile?: (path: string) => Buffer;
|
|
55
|
+
/**
|
|
56
|
+
* The commit the run started from: what it committed past it is scanned
|
|
57
|
+
* too, since a model that commits leaves nothing on top of HEAD (YAG-1039).
|
|
58
|
+
* Absent: HEAD.
|
|
59
|
+
*/
|
|
60
|
+
base?: string;
|
|
55
61
|
}
|
|
56
62
|
/**
|
|
57
|
-
* Scan what this run added on top of
|
|
58
|
-
* files whole, minus `skip` (the tree's
|
|
59
|
-
* not say what changed: the gate then
|
|
63
|
+
* Scan what this run added on top of its base (HEAD, unless `opts.base`):
|
|
64
|
+
* tracked files' added lines, new files whole, minus `skip` (the tree's
|
|
65
|
+
* pre-run dirt). Null when git could not say what changed: the gate then
|
|
66
|
+
* notes the scan did not run.
|
|
60
67
|
*/
|
|
61
68
|
export declare function scanChange(cwd: string, opts: ChangeScanOpts, skip?: readonly string[], signal?: AbortSignal): Promise<Finding[] | null>;
|
|
62
69
|
//# sourceMappingURL=changeScan.d.ts.map
|
|
@@ -253,9 +253,10 @@ function bytesAreBinary(bytes) {
|
|
|
253
253
|
/** How much of one new file is scanned: a credential sits in source, not past the first megabyte. */
|
|
254
254
|
const MAX_SCANNED_FILE_BYTES = 1024 * 1024;
|
|
255
255
|
/**
|
|
256
|
-
* Scan what this run added on top of
|
|
257
|
-
* files whole, minus `skip` (the tree's
|
|
258
|
-
* not say what changed: the gate then
|
|
256
|
+
* Scan what this run added on top of its base (HEAD, unless `opts.base`):
|
|
257
|
+
* tracked files' added lines, new files whole, minus `skip` (the tree's
|
|
258
|
+
* pre-run dirt). Null when git could not say what changed: the gate then
|
|
259
|
+
* notes the scan did not run.
|
|
259
260
|
*/
|
|
260
261
|
export async function scanChange(cwd, opts, skip = [], signal) {
|
|
261
262
|
// `git status --porcelain` lists an untracked folder as `dir/`, so a skip
|
|
@@ -267,10 +268,11 @@ export async function scanChange(cwd, opts, skip = [], signal) {
|
|
|
267
268
|
has: (path) => skipFiles.has(path) || skipDirs.some((dir) => path.startsWith(dir)),
|
|
268
269
|
};
|
|
269
270
|
const git = (args) => opts.exec(["git", "-c", "core.quotepath=off", ...args], cwd, signal);
|
|
271
|
+
const base = opts.base ?? "HEAD";
|
|
270
272
|
// A deleted binary is no binary in the change: the host lets it through.
|
|
271
273
|
const [numstat, diff, others] = await Promise.all([
|
|
272
|
-
git(["diff",
|
|
273
|
-
git(["diff",
|
|
274
|
+
git(["diff", base, "--numstat", "--no-renames", "--no-ext-diff", "--diff-filter=d"]),
|
|
275
|
+
git(["diff", base, "-U0", "--no-color", "--no-renames", "--no-ext-diff"]),
|
|
274
276
|
git(["ls-files", "--others", "--exclude-standard"]),
|
|
275
277
|
]);
|
|
276
278
|
if (numstat.code !== 0 || diff.code !== 0 || others.code !== 0)
|
|
@@ -190,8 +190,10 @@ export interface CloudCompletion {
|
|
|
190
190
|
* commits its work locally on the branch the host checked out, so nothing is
|
|
191
191
|
* lost when the sandbox goes. `result.json` is written last; the caller mirrors
|
|
192
192
|
* it on the stream. `ok` is a written plan, or a clean candidate whose branch
|
|
193
|
-
* carries this attempt's committed work
|
|
194
|
-
*
|
|
193
|
+
* carries this attempt's committed work: a commit past the execution's base,
|
|
194
|
+
* whoever wrote it (a model that ran `git commit` too, YAG-1039), or an
|
|
195
|
+
* earlier execution's. A clean stop with nothing committed at all delivered
|
|
196
|
+
* nothing.
|
|
195
197
|
*/
|
|
196
198
|
export declare function completeCloudSession(c: CloudCompletion, ran: {
|
|
197
199
|
result: PipelineResult;
|
|
@@ -247,8 +247,10 @@ function findingKey(finding) {
|
|
|
247
247
|
* commits its work locally on the branch the host checked out, so nothing is
|
|
248
248
|
* lost when the sandbox goes. `result.json` is written last; the caller mirrors
|
|
249
249
|
* it on the stream. `ok` is a written plan, or a clean candidate whose branch
|
|
250
|
-
* carries this attempt's committed work
|
|
251
|
-
*
|
|
250
|
+
* carries this attempt's committed work: a commit past the execution's base,
|
|
251
|
+
* whoever wrote it (a model that ran `git commit` too, YAG-1039), or an
|
|
252
|
+
* earlier execution's. A clean stop with nothing committed at all delivered
|
|
253
|
+
* nothing.
|
|
252
254
|
*/
|
|
253
255
|
export async function completeCloudSession(c, ran) {
|
|
254
256
|
const notes = "error" in ran ? [ran.error] : [];
|
|
@@ -334,21 +336,25 @@ export async function completeCloudSession(c, ran) {
|
|
|
334
336
|
rounds: result?.rounds.length ?? 0,
|
|
335
337
|
openPr: false,
|
|
336
338
|
...(c.preRunDirt.length > 0 ? { baselinePaths: c.preRunDirt } : {}),
|
|
339
|
+
...(c.baseSha ? { baseSha: c.baseSha } : {}),
|
|
337
340
|
exec: c.exec,
|
|
338
341
|
});
|
|
339
342
|
committed = finish.committed;
|
|
340
343
|
notes.push(...finish.notes);
|
|
341
344
|
}
|
|
345
|
+
const facts = await gitFacts(c.cwd, c.exec, c.baseSha);
|
|
346
|
+
notes.push(...facts.notes);
|
|
342
347
|
let delivered = outputsWritten;
|
|
343
348
|
if (c.stage !== "plan") {
|
|
344
|
-
|
|
349
|
+
// A commit past the base is this execution's work, whoever wrote it.
|
|
350
|
+
const onBranch = committed ||
|
|
351
|
+
facts.commits.length > 0 ||
|
|
352
|
+
(await attemptCommitted(c.cwd, c.exec, c.identity.attemptId));
|
|
345
353
|
if (onBranch === null) {
|
|
346
354
|
notes.push("git log failed, so the result cannot say whether the branch carries this attempt's work.");
|
|
347
355
|
}
|
|
348
356
|
delivered = onBranch === true;
|
|
349
357
|
}
|
|
350
|
-
const facts = await gitFacts(c.cwd, c.exec, c.baseSha);
|
|
351
|
-
notes.push(...facts.notes);
|
|
352
358
|
// What the final scan and checks found beside the last round's findings; a
|
|
353
359
|
// finding both report is listed once.
|
|
354
360
|
const seen = new Set((result?.findings ?? []).map(findingKey));
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* branch (the tree started clean, so everything is the run's own). In --here
|
|
8
8
|
* mode it commits ONLY when the pre-run baseline was verifiably clean;
|
|
9
9
|
* otherwise it skips with an honest note (never mixing user dirt into an
|
|
10
|
-
* agent commit).
|
|
10
|
+
* agent commit). A run given its base commit (the cloud session) whose work
|
|
11
|
+
* is already committed past it gets the trailer on an empty commit instead.
|
|
11
12
|
* - Commit message: `<type>(<scope>): <title>` + a 2-3 sentence plan summary +
|
|
12
13
|
* the exact provenance trailer (YAGNI-Run / YAGNI-Ticket / YAGNI-Verify /
|
|
13
14
|
* YAGNI-Decisions / YAGNI-Rounds / Co-Authored-By: YAGNI Code).
|
|
@@ -72,6 +73,13 @@ export interface RunFinishOpts extends TrailerInputs {
|
|
|
72
73
|
* lands in the run's provenance commit. Absent ⇒ the whole diff is the run's.
|
|
73
74
|
*/
|
|
74
75
|
baselinePaths?: string[];
|
|
76
|
+
/**
|
|
77
|
+
* The commit the run started from (a cloud session's). When the tree holds
|
|
78
|
+
* nothing of the run's own because the run already committed its work past
|
|
79
|
+
* it (the model ran `git commit`), FINISH puts the trailer on an empty
|
|
80
|
+
* commit on top, so the branch still carries the run's `YAGNI-Run` line.
|
|
81
|
+
*/
|
|
82
|
+
baseSha?: string;
|
|
75
83
|
signal?: AbortSignal;
|
|
76
84
|
exec?: WorktreeExec;
|
|
77
85
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* branch (the tree started clean, so everything is the run's own). In --here
|
|
8
8
|
* mode it commits ONLY when the pre-run baseline was verifiably clean;
|
|
9
9
|
* otherwise it skips with an honest note (never mixing user dirt into an
|
|
10
|
-
* agent commit).
|
|
10
|
+
* agent commit). A run given its base commit (the cloud session) whose work
|
|
11
|
+
* is already committed past it gets the trailer on an empty commit instead.
|
|
11
12
|
* - Commit message: `<type>(<scope>): <title>` + a 2-3 sentence plan summary +
|
|
12
13
|
* the exact provenance trailer (YAGNI-Run / YAGNI-Ticket / YAGNI-Verify /
|
|
13
14
|
* YAGNI-Decisions / YAGNI-Rounds / Co-Authored-By: YAGNI Code).
|
|
@@ -238,6 +239,27 @@ function tail(output) {
|
|
|
238
239
|
.trim();
|
|
239
240
|
return t;
|
|
240
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* The paths the run's commits past `baseSha` changed: empty with no base, no
|
|
244
|
+
* commit past it, or a change that nets to nothing; `failed` when git could
|
|
245
|
+
* not say, so a note never reads that as nothing committed.
|
|
246
|
+
*/
|
|
247
|
+
async function committedSince(run, baseSha) {
|
|
248
|
+
if (!baseSha)
|
|
249
|
+
return [];
|
|
250
|
+
const commits = await run(["git", "rev-list", `${baseSha}..HEAD`]);
|
|
251
|
+
if (commits.code !== 0)
|
|
252
|
+
return { failed: tail(commits.output) || "git rev-list failed" };
|
|
253
|
+
if (!commits.output.trim())
|
|
254
|
+
return [];
|
|
255
|
+
const diff = await run(["git", "diff", "--name-only", baseSha, "HEAD"]);
|
|
256
|
+
if (diff.code !== 0)
|
|
257
|
+
return { failed: tail(diff.output) || "git diff failed" };
|
|
258
|
+
return diff.output
|
|
259
|
+
.split("\n")
|
|
260
|
+
.map((line) => line.trim())
|
|
261
|
+
.filter(Boolean);
|
|
262
|
+
}
|
|
241
263
|
/**
|
|
242
264
|
* Run the FINISH stage. NEVER throws; every failure path returns an honest
|
|
243
265
|
* {@link FinishResult} with notes. See the module docblock for the contract.
|
|
@@ -273,33 +295,42 @@ export async function runFinish(opts) {
|
|
|
273
295
|
if (status.code !== 0) {
|
|
274
296
|
return skipped(`FINISH could not read the git status (${tail(status.output) || "git failed"}); nothing was committed.`);
|
|
275
297
|
}
|
|
276
|
-
if (!status.output.trim()) {
|
|
277
|
-
return skipped("FINISH found nothing to commit (the tree is already clean).");
|
|
278
|
-
}
|
|
279
298
|
// The run's OWN paths = everything changed now, minus what was already dirty
|
|
280
299
|
// at pipeline start (bootstrap lockfile churn, or a pre-existing edit). We
|
|
281
300
|
// commit only these, so the provenance commit never folds in non-run work.
|
|
282
301
|
const changed = parseChangedPaths(status.output);
|
|
283
302
|
const baseline = new Set(opts.baselinePaths ?? []);
|
|
284
303
|
const runOwned = baseline.size === 0 ? changed : changed.filter((p) => !baseline.has(p));
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
// Nothing of the run's own left in the tree, but its work may already be
|
|
305
|
+
// committed past its base: the trailer then goes on an empty commit.
|
|
306
|
+
const committed = runOwned.length === 0 ? await committedSince(run, opts.baseSha) : [];
|
|
307
|
+
if (!Array.isArray(committed)) {
|
|
308
|
+
return skipped(`FINISH could not read what the run committed past its base (${committed.failed}); nothing was committed.`);
|
|
309
|
+
}
|
|
310
|
+
if (runOwned.length === 0 && committed.length === 0) {
|
|
311
|
+
return skipped(status.output.trim()
|
|
312
|
+
? "FINISH found nothing of the run's own to commit (the only changes were already present before the run)."
|
|
313
|
+
: "FINISH found nothing to commit (the tree is already clean).");
|
|
287
314
|
}
|
|
315
|
+
const trailerOnly = runOwned.length === 0;
|
|
288
316
|
// Message derivation off the run's real diff.
|
|
289
|
-
const subject = commitSubject(commitType(opts.ticket, opts.ticketBrief), deriveCommitScope(runOwned), commitTitle(opts.ticket, opts.ticketBrief));
|
|
317
|
+
const subject = commitSubject(commitType(opts.ticket, opts.ticketBrief), deriveCommitScope(trailerOnly ? committed : runOwned), commitTitle(opts.ticket, opts.ticketBrief));
|
|
290
318
|
const summary = summarizePlan(opts.planText, opts.ticket);
|
|
291
319
|
const trailer = provenanceTrailer(opts);
|
|
292
320
|
const message = buildCommitMessage(subject, summary, trailer);
|
|
293
321
|
// No baseline dirt ⇒ everything is the run's own: `git add -A` (preserves
|
|
294
322
|
// rename detection). With a baseline, stage only the run-owned paths so
|
|
295
323
|
// bootstrap/pre-existing dirt never lands (`--` guards flag-like paths).
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
324
|
+
if (!trailerOnly) {
|
|
325
|
+
const add = baseline.size === 0
|
|
326
|
+
? await run(["git", "add", "-A"])
|
|
327
|
+
: await run(["git", "add", "--", ...runOwned]);
|
|
328
|
+
if (add.code !== 0) {
|
|
329
|
+
return skipped(`FINISH could not stage the changes (git add failed: ${tail(add.output)}); nothing was committed.`);
|
|
330
|
+
}
|
|
301
331
|
}
|
|
302
|
-
|
|
332
|
+
const commitArgs = [...(trailerOnly ? ["--allow-empty"] : []), "-m", message];
|
|
333
|
+
let commit = await run(["git", "commit", ...commitArgs]);
|
|
303
334
|
if (commit.code !== 0) {
|
|
304
335
|
// Most likely a missing git identity in a fresh environment (mirrors
|
|
305
336
|
// commitWip): retry once with an explicit YAGNI Code identity.
|
|
@@ -310,8 +341,7 @@ export async function runFinish(opts) {
|
|
|
310
341
|
"-c",
|
|
311
342
|
"user.email=code@yagni.app",
|
|
312
343
|
"commit",
|
|
313
|
-
|
|
314
|
-
message,
|
|
344
|
+
...commitArgs,
|
|
315
345
|
]);
|
|
316
346
|
}
|
|
317
347
|
if (commit.code !== 0) {
|
|
@@ -456,6 +456,13 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
456
456
|
if (stream)
|
|
457
457
|
emit({ type: "pipeline_log", event, data });
|
|
458
458
|
};
|
|
459
|
+
// Each command the gate runs, as it starts and ends: a check runs for
|
|
460
|
+
// minutes with no turn of the model's, and the host's ticket went quiet
|
|
461
|
+
// through it (YAG-1031). The host shows the command and its verdict.
|
|
462
|
+
const onCheck = stream
|
|
463
|
+
? (event) => logPipeline("verify_check", event)
|
|
464
|
+
: undefined;
|
|
465
|
+
const heard = onCheck ? { onCheck } : {};
|
|
459
466
|
// A measurement before the build (the baseline, the plan's checks on the
|
|
460
467
|
// base) gets at most a sixth of the run's time left when it starts, and
|
|
461
468
|
// none below a minute: the build itself must still fit. No deadline, no cap.
|
|
@@ -480,7 +487,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
480
487
|
}
|
|
481
488
|
else if (dirs.length > 0) {
|
|
482
489
|
try {
|
|
483
|
-
const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline())(cwd, dirs, deps.signal, share.shareMs);
|
|
490
|
+
const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
|
|
484
491
|
logPipeline("verify_baseline", {
|
|
485
492
|
ran: taken.ran,
|
|
486
493
|
failures: taken.failures,
|
|
@@ -518,7 +525,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
518
525
|
}
|
|
519
526
|
else {
|
|
520
527
|
try {
|
|
521
|
-
const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase())(cwd, measureFile, deps.signal, share.shareMs);
|
|
528
|
+
const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, measureFile, deps.signal, share.shareMs);
|
|
522
529
|
logPipeline("verify_checks_on_base", {
|
|
523
530
|
ran: measured.ran,
|
|
524
531
|
passed: [...measured.passed],
|
|
@@ -542,6 +549,14 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
542
549
|
// A cloud session reports the checks of the tree it commits: the gate's last
|
|
543
550
|
// verdict, with the tree it judged.
|
|
544
551
|
const snapshot = deps.snapshotWorkspace ?? snapshotWorkspace;
|
|
552
|
+
// A cloud session's start: the commit it builds on (its result's `baseSha`,
|
|
553
|
+
// which its gate and scan read its change against, since a model may commit
|
|
554
|
+
// its own work) and the tree's pre-run dirt (an install's lockfile churn),
|
|
555
|
+
// which never lands in its commit.
|
|
556
|
+
const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
|
|
557
|
+
const baseSha = startTree?.head ?? "";
|
|
558
|
+
const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
|
|
559
|
+
const onBase = baseSha ? { base: baseSha } : {};
|
|
545
560
|
let gate;
|
|
546
561
|
let lastVerify;
|
|
547
562
|
// A cloud session's gate also runs the host's secret and binary scans on
|
|
@@ -549,7 +564,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
549
564
|
const exec = deps.exec ?? finishExec;
|
|
550
565
|
const sessionSecrets = [env.YAGNI_TOKEN, env.GH_ENTERPRISE_TOKEN].filter((value) => Boolean(value));
|
|
551
566
|
const scan = cloud
|
|
552
|
-
? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets }, skip, signal)
|
|
567
|
+
? (scanCwd, signal, skip) => scanChange(scanCwd, { exec, sessionSecrets, ...onBase }, skip, signal)
|
|
553
568
|
: undefined;
|
|
554
569
|
if (gateOpts || baseline || cloud) {
|
|
555
570
|
const made = (deps.makeRunVerify ?? makeRunVerify)({
|
|
@@ -557,7 +572,11 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
557
572
|
...(baseline ? { baseline } : {}),
|
|
558
573
|
...(checksOnBase ? { checksOnBase } : {}),
|
|
559
574
|
...(scan ? { scanChange: scan } : {}),
|
|
560
|
-
|
|
575
|
+
// The one-attempt lane's host runs no checks after the build.
|
|
576
|
+
...(cloud
|
|
577
|
+
? { unreadTestFailureIsFinding: true, hostRechecks: false, ...(baseSha ? { baseSha } : {}) }
|
|
578
|
+
: {}),
|
|
579
|
+
...heard,
|
|
561
580
|
});
|
|
562
581
|
gate = made;
|
|
563
582
|
runVerify = async (verifyCwd, signal, baselinePaths) => {
|
|
@@ -626,12 +645,6 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
626
645
|
emit({ type: "pipeline_log", event, ...(data === undefined ? {} : { data }) });
|
|
627
646
|
},
|
|
628
647
|
};
|
|
629
|
-
// A cloud session's start: the commit it builds on (its result's `baseSha`)
|
|
630
|
-
// and the tree's pre-run dirt (an install's lockfile churn), which never
|
|
631
|
-
// lands in its commit.
|
|
632
|
-
const startTree = cloud ? await snapshot(cwd, deps.signal) : undefined;
|
|
633
|
-
const baseSha = startTree?.head ?? "";
|
|
634
|
-
const preRunDirt = startTree ? parseChangedPaths(startTree.status) : [];
|
|
635
648
|
/** The cloud session's last act (cloud.ts `completeCloudSession`), mirrored on the stream. */
|
|
636
649
|
const completeCloud = async (session, ran) => {
|
|
637
650
|
const verifyGate = gate;
|
|
@@ -660,7 +673,7 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
660
673
|
},
|
|
661
674
|
}
|
|
662
675
|
: {}),
|
|
663
|
-
scan: () => scanChange(cwd, { exec, sessionSecrets }, preRunDirt),
|
|
676
|
+
scan: () => scanChange(cwd, { exec, sessionSecrets, ...onBase }, preRunDirt),
|
|
664
677
|
planPaths: (planText) => baselineDirs(cwd, planPaths(planText), readFile).map((dir) => relative(cwd, dir) || "."),
|
|
665
678
|
exec,
|
|
666
679
|
writeFile: deps.writeFile ?? writeFileSync,
|
|
@@ -158,6 +158,38 @@ export interface VerifyCheckRun {
|
|
|
158
158
|
*/
|
|
159
159
|
note?: string;
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* How the note of an approved check this gate run did not run starts (its
|
|
163
|
+
* budget was spent, it was not reached, the build stopped first, the
|
|
164
|
+
* typecheck half failed first). The host's pull request body reads these
|
|
165
|
+
* words (`CHECK_NOT_RUN_NOTE` in @yagni/shared's attempts.ts); a change here
|
|
166
|
+
* is a change there.
|
|
167
|
+
*/
|
|
168
|
+
export declare const CHECK_NOT_RUN_NOTE = "not run on this tree";
|
|
169
|
+
/**
|
|
170
|
+
* One command the gate starts or ends, for the session's stream (headlessGo's
|
|
171
|
+
* `verify_check` line), so the ticket says what runs instead of going quiet
|
|
172
|
+
* for minutes (YAG-1031). `base` is a run on the unchanged code before the
|
|
173
|
+
* build. An end that did not pass carries the last few hundred characters
|
|
174
|
+
* of its output, scrubbed: the host logs them and never shows them.
|
|
175
|
+
*/
|
|
176
|
+
export type VerifyCheckEvent = {
|
|
177
|
+
phase: "start";
|
|
178
|
+
name: string;
|
|
179
|
+
command: string;
|
|
180
|
+
cwd: string;
|
|
181
|
+
base?: true;
|
|
182
|
+
} | {
|
|
183
|
+
phase: "end";
|
|
184
|
+
name: string;
|
|
185
|
+
command: string;
|
|
186
|
+
cwd: string;
|
|
187
|
+
exitCode: number | null;
|
|
188
|
+
durationMs: number;
|
|
189
|
+
baseline?: VerifyCheckRun["baseline"];
|
|
190
|
+
base?: true;
|
|
191
|
+
tail?: string;
|
|
192
|
+
};
|
|
161
193
|
/**
|
|
162
194
|
* Why a command threw instead of exiting, as its check record says it. `run`
|
|
163
195
|
* is the run's own signal (a /stop or the run's end); `capped` is the one
|
|
@@ -212,13 +244,19 @@ export declare function parseChangedPaths(status: string): string[];
|
|
|
212
244
|
*/
|
|
213
245
|
export declare function resolvePackageDirs(repoRoot: string, relPaths: string[], readFile: (path: string) => string): string[];
|
|
214
246
|
/**
|
|
215
|
-
* This run's OWN changed
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
|
|
247
|
+
* This run's OWN changed paths: the porcelain `status`'s, plus `committed` (what
|
|
248
|
+
* the run already committed past its base, YAG-1039), MINUS `baselinePaths` (the
|
|
249
|
+
* tree's pre-run dirt). Pure, deduped.
|
|
250
|
+
*/
|
|
251
|
+
export declare function ownChangedPaths(status: string, baselinePaths: string[], committed?: string[]): string[];
|
|
252
|
+
/**
|
|
253
|
+
* This run's OWN changed package dirs: the packages of {@link ownChangedPaths}, so a
|
|
254
|
+
* package touched only by `baselinePaths` (the tree's pre-run dirt) drops out. A
|
|
255
|
+
* package with a NEW change stays in scope even if it also had pre-run dirt. Pure
|
|
256
|
+
* (the `readFile` seam keeps it so), so an unrelated dirty package can never
|
|
257
|
+
* retarget the gate off this run's real diff.
|
|
220
258
|
*/
|
|
221
|
-
export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string): string[];
|
|
259
|
+
export declare function ownChangedDirs(repoRoot: string, status: string, baselinePaths: string[], readFile: (path: string) => string, committed?: string[]): string[];
|
|
222
260
|
/**
|
|
223
261
|
* Resolve the verify command(s) for a repo. An explicitly allowed `.yagni-code/verify`
|
|
224
262
|
* override wins (first non-comment, non-empty line, whitespace-split into argv; run
|
|
@@ -463,6 +501,19 @@ export interface MakeRunVerifyOpts {
|
|
|
463
501
|
* Recomputed each round so the scope follows the growing diff.
|
|
464
502
|
*/
|
|
465
503
|
changedDirs?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
|
|
504
|
+
/**
|
|
505
|
+
* The commit the run started from (the cloud session's): what the run
|
|
506
|
+
* committed past it counts as changed beside the uncommitted tree, in the
|
|
507
|
+
* default `changedDirs` and `changedPaths`, so a model that commits its own
|
|
508
|
+
* work keeps the gate's scope (YAG-1039). Absent: the tree alone.
|
|
509
|
+
*/
|
|
510
|
+
baseSha?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Whether the host runs the approved checks again after the build, which the
|
|
513
|
+
* gate's note of checks it left unrun says. Absent: it does (the mission
|
|
514
|
+
* lane). The one-attempt lane's host does not, so its session passes false.
|
|
515
|
+
*/
|
|
516
|
+
hostRechecks?: boolean;
|
|
466
517
|
exec?: VerifyExec;
|
|
467
518
|
timeoutMs?: number;
|
|
468
519
|
/**
|
|
@@ -488,8 +539,8 @@ export interface MakeRunVerifyOpts {
|
|
|
488
539
|
onFailedChecks?: (results: FailedCheckResult[]) => void;
|
|
489
540
|
/**
|
|
490
541
|
* This run's own changed paths (repo-relative), which scope the CI jobs by
|
|
491
|
-
* their `paths` filters. Default: the same `git status`
|
|
492
|
-
* minus the pre-run dirt.
|
|
542
|
+
* their `paths` filters. Default: the same `git status` (and commits past
|
|
543
|
+
* `baseSha`) the scope reads, minus the pre-run dirt.
|
|
493
544
|
*/
|
|
494
545
|
changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
|
|
495
546
|
/** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
|
|
@@ -535,6 +586,8 @@ export interface MakeRunVerifyOpts {
|
|
|
535
586
|
* removes. Absent: no scan half, as before (the cloud session sets it).
|
|
536
587
|
*/
|
|
537
588
|
scanChange?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<Finding[] | null>;
|
|
589
|
+
/** Told as each command starts and ends (the session's stream). Absent: nobody hears it. */
|
|
590
|
+
onCheck?: (event: VerifyCheckEvent) => void;
|
|
538
591
|
}
|
|
539
592
|
/**
|
|
540
593
|
* One workstream as the scoped checker sees it: the name its findings are
|
|
@@ -636,6 +689,8 @@ export interface MakeTakeVerifyBaselineOpts {
|
|
|
636
689
|
now?: () => number;
|
|
637
690
|
treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
638
691
|
undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
|
|
692
|
+
/** Told as each command starts and ends on the clean tree (the session's stream). */
|
|
693
|
+
onCheck?: (event: VerifyCheckEvent) => void;
|
|
639
694
|
}
|
|
640
695
|
/** What the baseline run found, for the gate and for the run's log. */
|
|
641
696
|
export interface VerifyBaselineResult {
|
|
@@ -698,6 +753,8 @@ export interface MakeMeasureChecksOnBaseOpts {
|
|
|
698
753
|
now?: () => number;
|
|
699
754
|
treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
700
755
|
undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
|
|
756
|
+
/** Told as each check starts and ends on the unchanged code (the session's stream). */
|
|
757
|
+
onCheck?: (event: VerifyCheckEvent) => void;
|
|
701
758
|
}
|
|
702
759
|
/**
|
|
703
760
|
* Run each approved check `checks.json` marks `measureOnBase` once on the
|
|
@@ -65,7 +65,7 @@ import { claimCovers } from "./fanout.js";
|
|
|
65
65
|
import { ciJobInScope } from "./missionChecks.js";
|
|
66
66
|
import { composeAbortSignal } from "./resilience.js";
|
|
67
67
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
68
|
-
import { snapshotWorkspace } from "./workspace.js";
|
|
68
|
+
import { committedPaths, snapshotWorkspace } from "./workspace.js";
|
|
69
69
|
/**
|
|
70
70
|
* Total wall-clock budget for a single verify run before it is abandoned
|
|
71
71
|
* (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
|
|
@@ -195,6 +195,44 @@ export function changesGitState(command) {
|
|
|
195
195
|
}
|
|
196
196
|
return false;
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* How the note of an approved check this gate run did not run starts (its
|
|
200
|
+
* budget was spent, it was not reached, the build stopped first, the
|
|
201
|
+
* typecheck half failed first). The host's pull request body reads these
|
|
202
|
+
* words (`CHECK_NOT_RUN_NOTE` in @yagni/shared's attempts.ts); a change here
|
|
203
|
+
* is a change there.
|
|
204
|
+
*/
|
|
205
|
+
export const CHECK_NOT_RUN_NOTE = "not run on this tree";
|
|
206
|
+
/** How much of a failed command's output its end carries, for the host's own log. */
|
|
207
|
+
const CHECK_TAIL_CHARS = 400;
|
|
208
|
+
/** How a check went the last time it ran, after its not-run note; nothing when it never ran. */
|
|
209
|
+
function earlierRunWords(last) {
|
|
210
|
+
if (last === undefined)
|
|
211
|
+
return "";
|
|
212
|
+
if (last.exitCode === null)
|
|
213
|
+
return "; it gave no verdict on an earlier tree";
|
|
214
|
+
if (last.exitCode === 0)
|
|
215
|
+
return "; it passed on an earlier tree";
|
|
216
|
+
return `; it failed on an earlier tree (exit ${last.exitCode})`;
|
|
217
|
+
}
|
|
218
|
+
/** The end of a command's output, scrubbed, as a check's end carries it. */
|
|
219
|
+
function checkTail(output) {
|
|
220
|
+
return scrubSecrets(output).trimEnd().slice(-CHECK_TAIL_CHARS);
|
|
221
|
+
}
|
|
222
|
+
/** A recorded run's end, as the stream hears it: the output's tail only when it did not pass. */
|
|
223
|
+
function checkEnded(record, output) {
|
|
224
|
+
const tail = output && record.exitCode !== 0 ? checkTail(output) : "";
|
|
225
|
+
return {
|
|
226
|
+
phase: "end",
|
|
227
|
+
name: record.name,
|
|
228
|
+
command: record.command,
|
|
229
|
+
cwd: record.cwd,
|
|
230
|
+
exitCode: record.exitCode,
|
|
231
|
+
durationMs: record.durationMs,
|
|
232
|
+
baseline: record.baseline,
|
|
233
|
+
...(tail ? { tail } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
198
236
|
/**
|
|
199
237
|
* Why a command threw instead of exiting, as its check record says it. `run`
|
|
200
238
|
* is the run's own signal (a /stop or the run's end); `capped` is the one
|
|
@@ -317,16 +355,23 @@ export function resolvePackageDirs(repoRoot, relPaths, readFile) {
|
|
|
317
355
|
return [...dirs].sort();
|
|
318
356
|
}
|
|
319
357
|
/**
|
|
320
|
-
* This run's OWN changed
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* changed path predates the run drops out. Pure (the `readFile` seam keeps it so), so
|
|
324
|
-
* an unrelated dirty package can never retarget the gate off this run's real diff.
|
|
358
|
+
* This run's OWN changed paths: the porcelain `status`'s, plus `committed` (what
|
|
359
|
+
* the run already committed past its base, YAG-1039), MINUS `baselinePaths` (the
|
|
360
|
+
* tree's pre-run dirt). Pure, deduped.
|
|
325
361
|
*/
|
|
326
|
-
export function
|
|
362
|
+
export function ownChangedPaths(status, baselinePaths, committed = []) {
|
|
327
363
|
const baseline = new Set(baselinePaths);
|
|
328
|
-
|
|
329
|
-
|
|
364
|
+
return [...new Set([...parseChangedPaths(status), ...committed])].filter((p) => !baseline.has(p));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* This run's OWN changed package dirs: the packages of {@link ownChangedPaths}, so a
|
|
368
|
+
* package touched only by `baselinePaths` (the tree's pre-run dirt) drops out. A
|
|
369
|
+
* package with a NEW change stays in scope even if it also had pre-run dirt. Pure
|
|
370
|
+
* (the `readFile` seam keeps it so), so an unrelated dirty package can never
|
|
371
|
+
* retarget the gate off this run's real diff.
|
|
372
|
+
*/
|
|
373
|
+
export function ownChangedDirs(repoRoot, status, baselinePaths, readFile, committed = []) {
|
|
374
|
+
return resolvePackageDirs(repoRoot, ownChangedPaths(status, baselinePaths, committed), readFile);
|
|
330
375
|
}
|
|
331
376
|
/**
|
|
332
377
|
* The highest-priority package.json script at `pkgDir` that is a string AND not a
|
|
@@ -1371,6 +1416,10 @@ function measuredBaseline(onBase, check) {
|
|
|
1371
1416
|
export function makeRunVerify(opts = {}) {
|
|
1372
1417
|
const detect = opts.detect ?? defaultDetect;
|
|
1373
1418
|
const detectTests = opts.detectTests ?? defaultDetectTests;
|
|
1419
|
+
// What the run committed past its base: a model that commits leaves the tree clean.
|
|
1420
|
+
const committedOf = (cwd, head, signal) => opts.baseSha && head !== opts.baseSha
|
|
1421
|
+
? committedPaths(cwd, opts.baseSha, signal)
|
|
1422
|
+
: Promise.resolve([]);
|
|
1374
1423
|
const changedDirs = opts.changedDirs ??
|
|
1375
1424
|
(async (cwd, signal, baselinePaths) => {
|
|
1376
1425
|
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
@@ -1379,7 +1428,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1379
1428
|
// This run's OWN changed packages = the current diff minus the pre-run dirt, so
|
|
1380
1429
|
// a stray pre-existing file (or an unrelated dirty package) never pulls its dir
|
|
1381
1430
|
// into scope and the gate verifies what THIS run actually touched.
|
|
1382
|
-
return ownChangedDirs(cwd, snapshot.status, baselinePaths ?? [], (p) => readFileSync(p, "utf8"));
|
|
1431
|
+
return ownChangedDirs(cwd, snapshot.status, baselinePaths ?? [], (p) => readFileSync(p, "utf8"), await committedOf(cwd, snapshot.head, signal));
|
|
1383
1432
|
});
|
|
1384
1433
|
const exec = opts.exec ?? defaultExec;
|
|
1385
1434
|
const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
@@ -1390,8 +1439,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1390
1439
|
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
1391
1440
|
if (!snapshot.tracked)
|
|
1392
1441
|
return [];
|
|
1393
|
-
|
|
1394
|
-
return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
|
|
1442
|
+
return ownChangedPaths(snapshot.status, baselinePaths ?? [], await committedOf(cwd, snapshot.head, signal));
|
|
1395
1443
|
});
|
|
1396
1444
|
const toolchain = opts.toolchain ?? sessionToolchain;
|
|
1397
1445
|
const treeStatus = opts.treeStatus ??
|
|
@@ -1401,6 +1449,55 @@ export function makeRunVerify(opts = {}) {
|
|
|
1401
1449
|
// keys from the last call it failed in.
|
|
1402
1450
|
let repoChecksSpentMs = 0;
|
|
1403
1451
|
const lastCiFailure = new Map();
|
|
1452
|
+
const hostRechecks = opts.hostRechecks === false ? "" : "; the host still runs the approved checks after the build";
|
|
1453
|
+
// Each approved check's last run in this build, by command (trimmed).
|
|
1454
|
+
const lastApproved = new Map();
|
|
1455
|
+
/**
|
|
1456
|
+
* The approved checks as one call reports them, in `checks.json`'s order:
|
|
1457
|
+
* one the sandbox cannot run with no verdict (#2694), one this call ran
|
|
1458
|
+
* with its record, and any other with no verdict on this tree, why, and how
|
|
1459
|
+
* it went the last time it ran. The cloud session's report is the last
|
|
1460
|
+
* call's: one that ran none of them (the build's check budget spent, the
|
|
1461
|
+
* typecheck half failing first) used to drop every one, and a pull request
|
|
1462
|
+
* whose plan named six checks said the session reported none (YAG-1031,
|
|
1463
|
+
* production 2026-10-25). A check that changes the checkout's git state is
|
|
1464
|
+
* never run and never listed.
|
|
1465
|
+
*/
|
|
1466
|
+
function approvedRecords(file, ran, why) {
|
|
1467
|
+
return file.checks.flatMap((check) => {
|
|
1468
|
+
if (check.status === "cannot_run") {
|
|
1469
|
+
return [
|
|
1470
|
+
{
|
|
1471
|
+
name: checkLabel(check.command),
|
|
1472
|
+
command: check.command,
|
|
1473
|
+
cwd: ".",
|
|
1474
|
+
exitCode: null,
|
|
1475
|
+
durationMs: 0,
|
|
1476
|
+
baseline: "cannot_run",
|
|
1477
|
+
note: `could not run in the sandbox${check.cause ? `: ${check.cause}` : ""}`,
|
|
1478
|
+
},
|
|
1479
|
+
];
|
|
1480
|
+
}
|
|
1481
|
+
if (changesGitState(check.command))
|
|
1482
|
+
return [];
|
|
1483
|
+
const key = check.command.trim();
|
|
1484
|
+
const record = ran.get(key);
|
|
1485
|
+
if (record)
|
|
1486
|
+
return [record];
|
|
1487
|
+
const last = lastApproved.get(key);
|
|
1488
|
+
return [
|
|
1489
|
+
{
|
|
1490
|
+
name: checkLabel(check.command),
|
|
1491
|
+
command: check.command,
|
|
1492
|
+
cwd: ".",
|
|
1493
|
+
exitCode: null,
|
|
1494
|
+
durationMs: 0,
|
|
1495
|
+
baseline: last?.baseline ?? measuredBaseline(opts.checksOnBase, check),
|
|
1496
|
+
note: `${CHECK_NOT_RUN_NOTE}: ${why}${earlierRunWords(last)}`,
|
|
1497
|
+
},
|
|
1498
|
+
];
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1404
1501
|
/**
|
|
1405
1502
|
* The repository-checks half: the plan's approved checks, then the pull
|
|
1406
1503
|
* request's CI steps its changed paths trigger. A failure becomes `critical`
|
|
@@ -1417,22 +1514,12 @@ export function makeRunVerify(opts = {}) {
|
|
|
1417
1514
|
const labels = [];
|
|
1418
1515
|
const cannotRun = [];
|
|
1419
1516
|
// An approved check the sandbox cannot run is on the report as one with no
|
|
1420
|
-
// verdict, so a session that never ran it is never
|
|
1421
|
-
// (#2694): the host leaves its pull request a draft.
|
|
1422
|
-
const unrunnable = [];
|
|
1517
|
+
// verdict (`approvedRecords`), so a session that never ran it is never
|
|
1518
|
+
// read as all green (#2694): the host leaves its pull request a draft.
|
|
1423
1519
|
for (const check of file.checks) {
|
|
1424
1520
|
if (check.status !== "cannot_run")
|
|
1425
1521
|
continue;
|
|
1426
1522
|
cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
|
|
1427
|
-
unrunnable.push({
|
|
1428
|
-
name: checkLabel(check.command),
|
|
1429
|
-
command: check.command,
|
|
1430
|
-
cwd: ".",
|
|
1431
|
-
exitCode: null,
|
|
1432
|
-
durationMs: 0,
|
|
1433
|
-
baseline: "cannot_run",
|
|
1434
|
-
note: `could not run in the sandbox${check.cause ? `: ${check.cause}` : ""}`,
|
|
1435
|
-
});
|
|
1436
1523
|
}
|
|
1437
1524
|
const paths = await changedPaths(cwd, signal, input.baselinePaths);
|
|
1438
1525
|
// A job the pull request's CI reported failed runs whatever its paths say.
|
|
@@ -1479,12 +1566,23 @@ export function makeRunVerify(opts = {}) {
|
|
|
1479
1566
|
const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
|
|
1480
1567
|
if (approved.length === 0 && steps.length === 0) {
|
|
1481
1568
|
report();
|
|
1482
|
-
|
|
1569
|
+
// Every approved check here is one the sandbox cannot run: none is "not run".
|
|
1570
|
+
return {
|
|
1571
|
+
findings,
|
|
1572
|
+
reasons,
|
|
1573
|
+
labels,
|
|
1574
|
+
checks: approvedRecords(file, new Map(), "the sandbox cannot run it"),
|
|
1575
|
+
};
|
|
1483
1576
|
}
|
|
1484
1577
|
const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
|
|
1485
1578
|
if (buildLeft < REPO_CHECK_MIN_START_MS) {
|
|
1486
|
-
reasons.push(
|
|
1487
|
-
return {
|
|
1579
|
+
reasons.push(`approved checks and CI steps not run again: the build's check budget is spent${hostRechecks}`);
|
|
1580
|
+
return {
|
|
1581
|
+
findings,
|
|
1582
|
+
reasons,
|
|
1583
|
+
labels,
|
|
1584
|
+
checks: approvedRecords(file, new Map(), "the build's check budget is spent"),
|
|
1585
|
+
};
|
|
1488
1586
|
}
|
|
1489
1587
|
const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
|
|
1490
1588
|
const halfStartedAt = now();
|
|
@@ -1500,6 +1598,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1500
1598
|
failedJobs,
|
|
1501
1599
|
approvedFailed: new Set(),
|
|
1502
1600
|
checks: [],
|
|
1601
|
+
approvedRuns: new Map(),
|
|
1503
1602
|
};
|
|
1504
1603
|
const before = await treeStatus(cwd, signal).catch((err) => {
|
|
1505
1604
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1531,9 +1630,21 @@ export function makeRunVerify(opts = {}) {
|
|
|
1531
1630
|
}
|
|
1532
1631
|
}
|
|
1533
1632
|
if (run.notReached > 0) {
|
|
1534
|
-
reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget
|
|
1633
|
+
reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget${hostRechecks}`);
|
|
1535
1634
|
}
|
|
1536
|
-
|
|
1635
|
+
const approvedRan = new Set(run.approvedRuns.values());
|
|
1636
|
+
const whyNotRun = signal?.aborted
|
|
1637
|
+
? "the build stopped first"
|
|
1638
|
+
: "not reached in the gate's time budget";
|
|
1639
|
+
return {
|
|
1640
|
+
findings,
|
|
1641
|
+
reasons,
|
|
1642
|
+
labels,
|
|
1643
|
+
checks: [
|
|
1644
|
+
...approvedRecords(file, run.approvedRuns, whyNotRun),
|
|
1645
|
+
...run.checks.filter((record) => !approvedRan.has(record)),
|
|
1646
|
+
],
|
|
1647
|
+
};
|
|
1537
1648
|
}
|
|
1538
1649
|
/**
|
|
1539
1650
|
* One repository command's verdict: passed, a note (it could not run), or a
|
|
@@ -1543,6 +1654,8 @@ export function makeRunVerify(opts = {}) {
|
|
|
1543
1654
|
* (`needs` says what). Only the session that measured knows it passed: a
|
|
1544
1655
|
* resumed session's host carries forward what failed on the base, never
|
|
1545
1656
|
* what passed, so there the same check is could not run, failing open.
|
|
1657
|
+
* The stream hears the command start, and its end here unless it failed:
|
|
1658
|
+
* a failure's caller tells its end once it decided whose the failure is.
|
|
1546
1659
|
*/
|
|
1547
1660
|
async function runRepoCommand(run, command, dir, stepEnv, check) {
|
|
1548
1661
|
const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
|
|
@@ -1550,6 +1663,8 @@ export function makeRunVerify(opts = {}) {
|
|
|
1550
1663
|
const env = opts.repoChecksEnv
|
|
1551
1664
|
? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
|
|
1552
1665
|
: buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
|
|
1666
|
+
const cwd = relative(run.cwd, dir) || ".";
|
|
1667
|
+
opts.onCheck?.({ phase: "start", name: check.name, command, cwd });
|
|
1553
1668
|
const startedAt = now();
|
|
1554
1669
|
try {
|
|
1555
1670
|
const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
|
|
@@ -1557,7 +1672,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1557
1672
|
const entry = {
|
|
1558
1673
|
name: check.name,
|
|
1559
1674
|
command,
|
|
1560
|
-
cwd
|
|
1675
|
+
cwd,
|
|
1561
1676
|
exitCode: note ? null : code,
|
|
1562
1677
|
durationMs: now() - startedAt,
|
|
1563
1678
|
baseline,
|
|
@@ -1567,14 +1682,16 @@ export function makeRunVerify(opts = {}) {
|
|
|
1567
1682
|
return entry;
|
|
1568
1683
|
};
|
|
1569
1684
|
if (code === 0) {
|
|
1570
|
-
record(check.baseline);
|
|
1685
|
+
const entry = record(check.baseline);
|
|
1686
|
+
opts.onCheck?.(checkEnded(entry));
|
|
1571
1687
|
return { kind: "passed" };
|
|
1572
1688
|
}
|
|
1573
1689
|
const output = scrubSecrets(rawOutput);
|
|
1574
1690
|
const cls = classifyTestFailure(output, code, MAX_LOCATED_FINDINGS, env);
|
|
1575
1691
|
if (cls.kind === "infra" && !(cls.needs && check.passedOnBase)) {
|
|
1576
1692
|
const reason = `could not run: ${cls.reason}`;
|
|
1577
|
-
record("cannot_run", `${reason}; ${exitSummary(check.name, code, output)}`);
|
|
1693
|
+
const entry = record("cannot_run", `${reason}; ${exitSummary(check.name, code, output)}`);
|
|
1694
|
+
opts.onCheck?.(checkEnded(entry, output));
|
|
1578
1695
|
return { kind: "note", reason };
|
|
1579
1696
|
}
|
|
1580
1697
|
return {
|
|
@@ -1587,15 +1704,17 @@ export function makeRunVerify(opts = {}) {
|
|
|
1587
1704
|
};
|
|
1588
1705
|
}
|
|
1589
1706
|
catch (err) {
|
|
1590
|
-
|
|
1707
|
+
const entry = {
|
|
1591
1708
|
name: check.name,
|
|
1592
1709
|
command,
|
|
1593
|
-
cwd
|
|
1710
|
+
cwd,
|
|
1594
1711
|
exitCode: null,
|
|
1595
1712
|
durationMs: now() - startedAt,
|
|
1596
1713
|
baseline: check.baseline,
|
|
1597
1714
|
note: unexitedNote(err, run.signal, composed),
|
|
1598
|
-
}
|
|
1715
|
+
};
|
|
1716
|
+
run.checks.push(entry);
|
|
1717
|
+
opts.onCheck?.(checkEnded(entry));
|
|
1599
1718
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1600
1719
|
return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
|
|
1601
1720
|
}
|
|
@@ -1628,11 +1747,18 @@ export function makeRunVerify(opts = {}) {
|
|
|
1628
1747
|
// The failures it had there, when the session measured it failing.
|
|
1629
1748
|
const failedOnBase = opts.checksOnBase?.failed.get(check.command.trim());
|
|
1630
1749
|
const status = measuredBaseline(opts.checksOnBase, check);
|
|
1750
|
+
const recorded = run.checks.length;
|
|
1631
1751
|
const verdict = await runRepoCommand(run, check.command, run.cwd, undefined, {
|
|
1632
1752
|
name: label,
|
|
1633
1753
|
baseline: status,
|
|
1634
1754
|
passedOnBase: opts.checksOnBase?.passed.has(check.command.trim()) ?? false,
|
|
1635
1755
|
});
|
|
1756
|
+
// `runRepoCommand` records every run once, whatever its verdict.
|
|
1757
|
+
const ran = run.checks[recorded];
|
|
1758
|
+
if (ran) {
|
|
1759
|
+
run.approvedRuns.set(check.command.trim(), ran);
|
|
1760
|
+
lastApproved.set(check.command.trim(), ran);
|
|
1761
|
+
}
|
|
1636
1762
|
if (verdict.kind === "failed")
|
|
1637
1763
|
run.approvedFailed.add(canonicalCheckCommand(check.command));
|
|
1638
1764
|
if (verdict.kind === "failed" && verdict.needs) {
|
|
@@ -1668,6 +1794,8 @@ export function makeRunVerify(opts = {}) {
|
|
|
1668
1794
|
run.findings.push(...found.slice(0, findingRoom(run)));
|
|
1669
1795
|
}
|
|
1670
1796
|
}
|
|
1797
|
+
if (verdict.kind === "failed")
|
|
1798
|
+
opts.onCheck?.(checkEnded(verdict.record, verdict.output));
|
|
1671
1799
|
}
|
|
1672
1800
|
}
|
|
1673
1801
|
/**
|
|
@@ -1742,6 +1870,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1742
1870
|
continue;
|
|
1743
1871
|
}
|
|
1744
1872
|
settle(job, "failed");
|
|
1873
|
+
opts.onCheck?.(checkEnded(verdict.record, verdict.output));
|
|
1745
1874
|
const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
|
|
1746
1875
|
const keys = new Set(stepFindings.map((f) => f.message));
|
|
1747
1876
|
const previous = lastCiFailure.get(key);
|
|
@@ -1791,15 +1920,27 @@ export function makeRunVerify(opts = {}) {
|
|
|
1791
1920
|
const counts = opts.baseline?.get(verifyCommandKey(command));
|
|
1792
1921
|
return counts && [...counts.values()].some((n) => n > 0) ? "fails_on_base" : "runnable";
|
|
1793
1922
|
};
|
|
1794
|
-
|
|
1923
|
+
/** A detected command starting, as the stream hears it. */
|
|
1924
|
+
const commandStarted = (command) => opts.onCheck?.({
|
|
1925
|
+
phase: "start",
|
|
1795
1926
|
name: command.label,
|
|
1796
1927
|
command: command.argv.join(" "),
|
|
1797
1928
|
cwd: relative(cwd, command.cwd) || ".",
|
|
1798
|
-
exitCode: code,
|
|
1799
|
-
durationMs: now() - startedAt,
|
|
1800
|
-
baseline,
|
|
1801
|
-
...(note ? { note } : {}),
|
|
1802
1929
|
});
|
|
1930
|
+
/** A detected command's record, its end told to the stream with its output's tail. */
|
|
1931
|
+
const recordCommand = (command, code, startedAt, baseline, more = {}) => {
|
|
1932
|
+
const entry = {
|
|
1933
|
+
name: command.label,
|
|
1934
|
+
command: command.argv.join(" "),
|
|
1935
|
+
cwd: relative(cwd, command.cwd) || ".",
|
|
1936
|
+
exitCode: code,
|
|
1937
|
+
durationMs: now() - startedAt,
|
|
1938
|
+
baseline,
|
|
1939
|
+
...(more.note ? { note: more.note } : {}),
|
|
1940
|
+
};
|
|
1941
|
+
checks.push(entry);
|
|
1942
|
+
opts.onCheck?.(checkEnded(entry, more.output));
|
|
1943
|
+
};
|
|
1803
1944
|
// TYPECHECK HALF (frozen behavior, §0.2): run every changed package's build
|
|
1804
1945
|
// check; union the findings. One package that cannot spawn fails OPEN (a
|
|
1805
1946
|
// recorded reason, no finding) without sinking the verdict for the packages
|
|
@@ -1808,6 +1949,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1808
1949
|
const typecheckEnv = buildGateVerifyEnv(process.env, toolchainFile);
|
|
1809
1950
|
for (const command of commands) {
|
|
1810
1951
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
|
|
1952
|
+
commandStarted(command);
|
|
1811
1953
|
const startedAt = now();
|
|
1812
1954
|
try {
|
|
1813
1955
|
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, typecheckEnv);
|
|
@@ -1820,11 +1962,14 @@ export function makeRunVerify(opts = {}) {
|
|
|
1820
1962
|
const infra = classifyVerifyInfraFailure(output, code);
|
|
1821
1963
|
if (infra) {
|
|
1822
1964
|
reasons.push(`verify (${command.label}) could not run: ${infra}`);
|
|
1823
|
-
recordCommand(command, null, startedAt, "cannot_run",
|
|
1965
|
+
recordCommand(command, null, startedAt, "cannot_run", {
|
|
1966
|
+
note: `could not run: ${infra}; ${exitSummary(command.label, code, output)}`,
|
|
1967
|
+
output,
|
|
1968
|
+
});
|
|
1824
1969
|
continue;
|
|
1825
1970
|
}
|
|
1826
1971
|
}
|
|
1827
|
-
recordCommand(command, code, startedAt, standing(command));
|
|
1972
|
+
recordCommand(command, code, startedAt, standing(command), { output });
|
|
1828
1973
|
anyRan = true;
|
|
1829
1974
|
typecheckLabels.push(command.label);
|
|
1830
1975
|
if (code !== 0) {
|
|
@@ -1835,7 +1980,9 @@ export function makeRunVerify(opts = {}) {
|
|
|
1835
1980
|
}
|
|
1836
1981
|
}
|
|
1837
1982
|
catch (err) {
|
|
1838
|
-
recordCommand(command, null, startedAt, standing(command),
|
|
1983
|
+
recordCommand(command, null, startedAt, standing(command), {
|
|
1984
|
+
note: unexitedNote(err, signal, composed),
|
|
1985
|
+
});
|
|
1839
1986
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1840
1987
|
reasons.push(`verify (${command.label}) could not run: ${scrubSecrets(msg)}`);
|
|
1841
1988
|
}
|
|
@@ -1871,13 +2018,17 @@ export function makeRunVerify(opts = {}) {
|
|
|
1871
2018
|
continue;
|
|
1872
2019
|
}
|
|
1873
2020
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(testTimeoutMs));
|
|
2021
|
+
commandStarted(command);
|
|
1874
2022
|
const startedAt = now();
|
|
1875
2023
|
try {
|
|
1876
2024
|
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
|
|
1877
2025
|
const output = scrubSecrets(rawOutput);
|
|
1878
2026
|
const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY, testEnv);
|
|
1879
2027
|
if (cls.kind === "infra") {
|
|
1880
|
-
recordCommand(command, null, startedAt, "cannot_run",
|
|
2028
|
+
recordCommand(command, null, startedAt, "cannot_run", {
|
|
2029
|
+
note: `could not run: ${cls.reason}; ${exitSummary(command.label, code, output)}`,
|
|
2030
|
+
output,
|
|
2031
|
+
});
|
|
1881
2032
|
reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
|
|
1882
2033
|
continue;
|
|
1883
2034
|
}
|
|
@@ -1887,19 +2038,22 @@ export function makeRunVerify(opts = {}) {
|
|
|
1887
2038
|
testLabels.push(command.label);
|
|
1888
2039
|
if (base === "fails_on_base") {
|
|
1889
2040
|
const summary = exitSummary(command.label, code, output);
|
|
1890
|
-
recordCommand(command, code, startedAt, base,
|
|
2041
|
+
recordCommand(command, code, startedAt, base, {
|
|
2042
|
+
note: `failed on the unchanged code too: ${summary}`,
|
|
2043
|
+
output,
|
|
2044
|
+
});
|
|
1891
2045
|
reasons.push(`tests (${where}) failed on the unchanged code too, so not a finding: ${summary}`);
|
|
1892
2046
|
}
|
|
1893
2047
|
else {
|
|
1894
2048
|
// One finding per command, past the cap too: it is the only
|
|
1895
2049
|
// word of this command's failure.
|
|
1896
|
-
recordCommand(command, code, startedAt, base);
|
|
2050
|
+
recordCommand(command, code, startedAt, base, { output });
|
|
1897
2051
|
testFindingCount += 1;
|
|
1898
2052
|
findings.push(tailFinding(command.label, code, output));
|
|
1899
2053
|
}
|
|
1900
2054
|
continue;
|
|
1901
2055
|
}
|
|
1902
|
-
recordCommand(command, code, startedAt, base);
|
|
2056
|
+
recordCommand(command, code, startedAt, base, { output });
|
|
1903
2057
|
if (cls.kind === "uncertain") {
|
|
1904
2058
|
// Failing OPEN on an uninterpretable failure (§3d): an honest note,
|
|
1905
2059
|
// never a guessed finding the fix stage cannot clear.
|
|
@@ -1918,7 +2072,9 @@ export function makeRunVerify(opts = {}) {
|
|
|
1918
2072
|
}
|
|
1919
2073
|
}
|
|
1920
2074
|
catch (err) {
|
|
1921
|
-
recordCommand(command, null, startedAt, standing(command),
|
|
2075
|
+
recordCommand(command, null, startedAt, standing(command), {
|
|
2076
|
+
note: unexitedNote(err, signal, composed),
|
|
2077
|
+
});
|
|
1922
2078
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1923
2079
|
reasons.push(`tests (${where}) could not run: ${scrubSecrets(msg)}`);
|
|
1924
2080
|
}
|
|
@@ -1932,6 +2088,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1932
2088
|
if (repoFile) {
|
|
1933
2089
|
if (typecheckFailed) {
|
|
1934
2090
|
reasons.push("approved checks and CI steps skipped: the typecheck half failed; a later round runs them");
|
|
2091
|
+
checks.push(...approvedRecords(repoFile, new Map(), "the typecheck half failed first"));
|
|
1935
2092
|
}
|
|
1936
2093
|
else {
|
|
1937
2094
|
const repo = await runRepoChecks({
|
|
@@ -2056,7 +2213,8 @@ export function makeTakeVerifyBaseline(opts = {}) {
|
|
|
2056
2213
|
for (const { command, test } of runs) {
|
|
2057
2214
|
if (signal?.aborted)
|
|
2058
2215
|
break;
|
|
2059
|
-
const
|
|
2216
|
+
const commandStartedAt = now();
|
|
2217
|
+
const left = budgetMs - (commandStartedAt - startedAt);
|
|
2060
2218
|
if (left < REPO_CHECK_MIN_START_MS) {
|
|
2061
2219
|
result.notes.push(`${command.label} (${command.cwd}): not reached in the baseline budget`);
|
|
2062
2220
|
continue;
|
|
@@ -2065,8 +2223,23 @@ export function makeTakeVerifyBaseline(opts = {}) {
|
|
|
2065
2223
|
? (opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS)
|
|
2066
2224
|
: (opts.timeoutMs ?? VERIFY_TIMEOUT_MS), left);
|
|
2067
2225
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(cap));
|
|
2226
|
+
const heard = {
|
|
2227
|
+
name: command.label,
|
|
2228
|
+
command: command.argv.join(" "),
|
|
2229
|
+
cwd: relative(cwd, command.cwd) || ".",
|
|
2230
|
+
base: true,
|
|
2231
|
+
};
|
|
2232
|
+
opts.onCheck?.({ phase: "start", ...heard });
|
|
2233
|
+
const ended = (exitCode, output) => opts.onCheck?.({
|
|
2234
|
+
phase: "end",
|
|
2235
|
+
...heard,
|
|
2236
|
+
exitCode,
|
|
2237
|
+
durationMs: now() - commandStartedAt,
|
|
2238
|
+
...(output && exitCode !== 0 ? { tail: checkTail(output) } : {}),
|
|
2239
|
+
});
|
|
2068
2240
|
try {
|
|
2069
2241
|
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, test ? testEnv : verifyEnv);
|
|
2242
|
+
ended(code, rawOutput);
|
|
2070
2243
|
const output = scrubSecrets(rawOutput);
|
|
2071
2244
|
let failed;
|
|
2072
2245
|
if (test) {
|
|
@@ -2095,6 +2268,7 @@ export function makeTakeVerifyBaseline(opts = {}) {
|
|
|
2095
2268
|
result.failures += n;
|
|
2096
2269
|
}
|
|
2097
2270
|
catch (err) {
|
|
2271
|
+
ended(null);
|
|
2098
2272
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2099
2273
|
result.notes.push(`${command.label} (${command.cwd}): could not run: ${scrubSecrets(msg)}`);
|
|
2100
2274
|
}
|
|
@@ -2170,14 +2344,25 @@ export function makeMeasureChecksOnBase(opts = {}) {
|
|
|
2170
2344
|
break;
|
|
2171
2345
|
const command = check.command.trim();
|
|
2172
2346
|
const label = checkLabel(check.command);
|
|
2173
|
-
const
|
|
2347
|
+
const checkStartedAt = now();
|
|
2348
|
+
const left = budgetMs - (checkStartedAt - startedAt);
|
|
2174
2349
|
if (left < REPO_CHECK_MIN_START_MS) {
|
|
2175
2350
|
result.notes.push(`\`${label}\`: not reached in the budget`);
|
|
2176
2351
|
continue;
|
|
2177
2352
|
}
|
|
2178
2353
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(Math.min(opts.timeoutMs ?? REPO_CHECK_TIMEOUT_MS, left)));
|
|
2354
|
+
const heard = { name: label, command: check.command, cwd: ".", base: true };
|
|
2355
|
+
opts.onCheck?.({ phase: "start", ...heard });
|
|
2356
|
+
const ended = (exitCode, output) => opts.onCheck?.({
|
|
2357
|
+
phase: "end",
|
|
2358
|
+
...heard,
|
|
2359
|
+
exitCode,
|
|
2360
|
+
durationMs: now() - checkStartedAt,
|
|
2361
|
+
...(output && exitCode !== 0 ? { tail: checkTail(output) } : {}),
|
|
2362
|
+
});
|
|
2179
2363
|
try {
|
|
2180
2364
|
const { code, output: rawOutput } = await exec(repoCheckArgv(check.command), cwd, composed, env);
|
|
2365
|
+
ended(code, rawOutput);
|
|
2181
2366
|
if (code === 0) {
|
|
2182
2367
|
passed.add(command);
|
|
2183
2368
|
result.ran.push(label);
|
|
@@ -2206,6 +2391,7 @@ export function makeMeasureChecksOnBase(opts = {}) {
|
|
|
2206
2391
|
result.ran.push(label);
|
|
2207
2392
|
}
|
|
2208
2393
|
catch (err) {
|
|
2394
|
+
ended(null);
|
|
2209
2395
|
result.notes.push(`\`${label}\`: ${unexitedNote(err, signal, composed)}`);
|
|
2210
2396
|
}
|
|
2211
2397
|
}
|
|
@@ -29,4 +29,10 @@ export declare function workspaceChanged(before: WorkspaceSnapshot, after: Works
|
|
|
29
29
|
* report `tracked: false` and skip the second call.
|
|
30
30
|
*/
|
|
31
31
|
export declare function snapshotWorkspace(cwd: string, signal?: AbortSignal): Promise<WorkspaceSnapshot>;
|
|
32
|
+
/**
|
|
33
|
+
* The repo-relative paths committed between `baseSha` and HEAD: what a run
|
|
34
|
+
* committed past its base. Empty on any git error, so a caller reads the
|
|
35
|
+
* uncommitted tree alone, as it did before.
|
|
36
|
+
*/
|
|
37
|
+
export declare function committedPaths(cwd: string, baseSha: string, signal?: AbortSignal): Promise<string[]>;
|
|
32
38
|
//# sourceMappingURL=workspace.d.ts.map
|
|
@@ -70,4 +70,16 @@ export async function snapshotWorkspace(cwd, signal) {
|
|
|
70
70
|
const status = await runGit(["status", "--porcelain"], cwd, signal);
|
|
71
71
|
return { tracked: true, head, status };
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The repo-relative paths committed between `baseSha` and HEAD: what a run
|
|
75
|
+
* committed past its base. Empty on any git error, so a caller reads the
|
|
76
|
+
* uncommitted tree alone, as it did before.
|
|
77
|
+
*/
|
|
78
|
+
export async function committedPaths(cwd, baseSha, signal) {
|
|
79
|
+
const names = await runGit(["diff", "--name-only", baseSha, "HEAD"], cwd, signal);
|
|
80
|
+
return names
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((line) => line.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
}
|
|
73
85
|
//# sourceMappingURL=workspace.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.7-staging.
|
|
3
|
+
"version": "1.2.7-staging.2059.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": "6e9cbb06e7ae4d5b15b14fecc749fb5fbab2e93b"
|
|
62
62
|
}
|