@yagni-app/code-staging 1.2.6-staging.1951.1 → 1.2.6-staging.1956.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/branding.d.ts +20 -0
- package/dist/extension/branding.js +49 -8
- package/dist/extension/index.js +5 -0
- package/dist/extension/pipeline/changeScan.d.ts +62 -0
- package/dist/extension/pipeline/changeScan.js +307 -0
- package/dist/extension/pipeline/cloud.d.ts +205 -0
- package/dist/extension/pipeline/cloud.js +382 -0
- package/dist/extension/pipeline/continuation.d.ts +7 -3
- package/dist/extension/pipeline/continuation.js +29 -13
- package/dist/extension/pipeline/findings.d.ts +9 -4
- package/dist/extension/pipeline/findings.js +25 -10
- package/dist/extension/pipeline/finish.d.ts +11 -1
- package/dist/extension/pipeline/finish.js +51 -5
- package/dist/extension/pipeline/headlessGo.d.ts +30 -3
- package/dist/extension/pipeline/headlessGo.js +253 -29
- package/dist/extension/pipeline/orchestrator.d.ts +8 -2
- package/dist/extension/pipeline/orchestrator.js +249 -68
- package/dist/extension/pipeline/personas.d.ts +8 -3
- package/dist/extension/pipeline/personas.js +42 -7
- package/dist/extension/pipeline/planOutput.d.ts +42 -0
- package/dist/extension/pipeline/planOutput.js +94 -0
- package/dist/extension/pipeline/stages.d.ts +34 -5
- package/dist/extension/pipeline/stages.js +136 -7
- package/dist/extension/pipeline/tierCap.d.ts +22 -1
- package/dist/extension/pipeline/tierCap.js +48 -1
- package/dist/extension/pipeline/types.d.ts +31 -0
- package/dist/extension/pipeline/types.js +2 -0
- package/dist/extension/pipeline/verify.d.ts +128 -4
- package/dist/extension/pipeline/verify.js +356 -31
- package/package.json +2 -2
|
@@ -53,7 +53,7 @@ import { readFileSync } from "node:fs";
|
|
|
53
53
|
// APIs accept forward-slash paths — while win32 join/dirname would emit
|
|
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
|
-
import { basename, dirname, join } from "node:path/posix";
|
|
56
|
+
import { basename, dirname, join, relative } from "node:path/posix";
|
|
57
57
|
import { readToolchainFile } from "../toolchain/spawnHook.js";
|
|
58
58
|
import { killTreeSync, trackChild } from "./childRegistry.js";
|
|
59
59
|
import { claimCovers } from "./fanout.js";
|
|
@@ -89,6 +89,23 @@ const MAX_LOCATED_FINDINGS = 25;
|
|
|
89
89
|
const MAX_TAIL_CHARS = 1000;
|
|
90
90
|
/** The verify lens reuses `does_it_hold` (does the change build/test) to avoid widening ReviewLens. */
|
|
91
91
|
const VERIFY_LENS = "does_it_hold";
|
|
92
|
+
/**
|
|
93
|
+
* Why a command threw instead of exiting, as its check record says it. `run`
|
|
94
|
+
* is the run's own signal (a /stop or the run's end); `capped` is the one
|
|
95
|
+
* the command ran under, which also carries its time cap.
|
|
96
|
+
*/
|
|
97
|
+
export function unexitedNote(err, run, capped) {
|
|
98
|
+
if (run?.aborted)
|
|
99
|
+
return "aborted";
|
|
100
|
+
if (capped?.aborted)
|
|
101
|
+
return "timed out";
|
|
102
|
+
const shape = err;
|
|
103
|
+
if (shape?.code === "ENOENT")
|
|
104
|
+
return "not found";
|
|
105
|
+
if (shape?.name === "AbortError" || shape?.name === "TimeoutError")
|
|
106
|
+
return "timed out";
|
|
107
|
+
return "could not run";
|
|
108
|
+
}
|
|
92
109
|
/**
|
|
93
110
|
* The priority order of package.json scripts we treat as a verify command.
|
|
94
111
|
* Deliberately a SECRET-INDEPENDENT build check (`typecheck`/`lint`), NEVER the
|
|
@@ -458,7 +475,7 @@ const FAILURE_SUMMARY = [
|
|
|
458
475
|
* Callers scrub the output through {@link scrubSecrets} BEFORE classification
|
|
459
476
|
* so findings/notes never carry secrets (§0.9).
|
|
460
477
|
*/
|
|
461
|
-
export function classifyTestFailure(output, exitCode) {
|
|
478
|
+
export function classifyTestFailure(output, exitCode, limit = MAX_LOCATED_FINDINGS) {
|
|
462
479
|
if (exitCode === 0)
|
|
463
480
|
return { kind: "clean" };
|
|
464
481
|
const spawnInfra = classifyVerifyInfraFailure(output, exitCode);
|
|
@@ -521,7 +538,7 @@ export function classifyTestFailure(output, exitCode) {
|
|
|
521
538
|
else if (!existing.file && parsed.file)
|
|
522
539
|
byMessage.set(parsed.message, parsed);
|
|
523
540
|
}
|
|
524
|
-
const failures = [...byMessage.values()].slice(0,
|
|
541
|
+
const failures = [...byMessage.values()].slice(0, limit);
|
|
525
542
|
if (failures.length > 0) {
|
|
526
543
|
return {
|
|
527
544
|
kind: "failures",
|
|
@@ -558,28 +575,45 @@ export function classifyTestFailure(output, exitCode) {
|
|
|
558
575
|
const TS_DIAGNOSTIC = /^(.+?)\((\d+),\d+\):\s*error\s+TS\d+:\s*(.+)$/;
|
|
559
576
|
/** Match a generic `path:line:col: error message` (eslint/tsc-pretty-ish). */
|
|
560
577
|
const GENERIC_DIAGNOSTIC = /^(.+?):(\d+):\d+:?\s*(?:error\b[:\s]*)?(.+)$/;
|
|
578
|
+
/** An ESLint stylish file header: one path with an extension, nothing else on the line. */
|
|
579
|
+
const STYLISH_FILE = /^(?:\.{0,2}\/)?[^\s:]+\.[A-Za-z0-9]+$/;
|
|
580
|
+
/** An ESLint stylish error row under that header: `12:5 error message rule`. */
|
|
581
|
+
const STYLISH_ERROR = /^(\d+):\d+\s+(?:error|Error:)\s+(.+)$/;
|
|
561
582
|
/**
|
|
562
583
|
* Turn a failing verify run into `critical` Findings. A clean (exit 0) run yields
|
|
563
|
-
* none. Located diagnostics (TypeScript first, then a generic file:line form
|
|
564
|
-
*
|
|
584
|
+
* none. Located diagnostics (TypeScript first, then a generic file:line form, then
|
|
585
|
+
* ESLint's stylish error rows under their file header) are parsed into per-error
|
|
586
|
+
* findings, at most `limit` of them; if none parse, a single capped finding carries
|
|
565
587
|
* the tail of the output so the fix stage still gets actionable signal. Never
|
|
566
588
|
* throws; always emits at least one finding on a non-zero exit.
|
|
567
589
|
*/
|
|
568
|
-
export function parseVerifyFailures(output, exitCode, label) {
|
|
590
|
+
export function parseVerifyFailures(output, exitCode, label, limit = MAX_LOCATED_FINDINGS) {
|
|
569
591
|
if (exitCode === 0)
|
|
570
592
|
return [];
|
|
571
593
|
const located = [];
|
|
572
594
|
const seen = new Set();
|
|
595
|
+
// ESLint's default (stylish) and `next lint` output name the file once, on
|
|
596
|
+
// its own line, above that file's `line:col error message rule` rows.
|
|
597
|
+
let stylishFile;
|
|
573
598
|
for (const raw of output.split("\n")) {
|
|
574
599
|
const line = raw.trim();
|
|
575
600
|
if (!line)
|
|
576
601
|
continue;
|
|
577
602
|
const m = line.match(TS_DIAGNOSTIC) ?? line.match(GENERIC_DIAGNOSTIC);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
603
|
+
let parts = m ? [m[1], m[2], m[3]] : null;
|
|
604
|
+
if (!parts) {
|
|
605
|
+
if (STYLISH_FILE.test(line)) {
|
|
606
|
+
stylishFile = line;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const row = stylishFile ? line.match(STYLISH_ERROR) : null;
|
|
610
|
+
if (!row || !stylishFile)
|
|
611
|
+
continue;
|
|
612
|
+
parts = [stylishFile, row[1], row[2].replace(/\s{2,}/g, " ")];
|
|
613
|
+
}
|
|
614
|
+
const file = parts[0].trim();
|
|
615
|
+
const lineNo = Number(parts[1]);
|
|
616
|
+
const message = parts[2].trim();
|
|
583
617
|
// Guard the generic matcher against noise: require a plausible file token.
|
|
584
618
|
if (!file || /\s/.test(file) || !Number.isFinite(lineNo))
|
|
585
619
|
continue;
|
|
@@ -594,7 +628,7 @@ export function parseVerifyFailures(output, exitCode, label) {
|
|
|
594
628
|
file,
|
|
595
629
|
line: lineNo,
|
|
596
630
|
});
|
|
597
|
-
if (located.length >=
|
|
631
|
+
if (located.length >= limit)
|
|
598
632
|
break;
|
|
599
633
|
}
|
|
600
634
|
if (located.length > 0)
|
|
@@ -651,6 +685,98 @@ function liftFinding(finding, repoRoot, commandCwd) {
|
|
|
651
685
|
const file = toRepoRelativePath(finding.file, repoRoot, commandCwd);
|
|
652
686
|
return file === finding.file ? finding : { ...finding, file };
|
|
653
687
|
}
|
|
688
|
+
/** One command's identity across the baseline run and the build's runs: where it runs and what it runs. */
|
|
689
|
+
export function verifyCommandKey(command) {
|
|
690
|
+
return `${command.cwd}\u0000${command.argv.join(" ")}`;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* One failure's key, the shape #2601's `failureKeyOf` gives a CI failure: the
|
|
694
|
+
* repo-relative file and the message, never the line, since a change above a
|
|
695
|
+
* failure moves it. A failure that is only the output's tail names nothing
|
|
696
|
+
* stable, so it has no key and is never excused. Pure.
|
|
697
|
+
*/
|
|
698
|
+
export function verifyFailureKey(finding) {
|
|
699
|
+
if (finding.message.includes("Output tail:"))
|
|
700
|
+
return null;
|
|
701
|
+
return `${finding.file ?? ""}\u0000${finding.message}`;
|
|
702
|
+
}
|
|
703
|
+
/** How many times each failure key occurs. Pure. */
|
|
704
|
+
export function failureKeyCounts(findings) {
|
|
705
|
+
const counts = new Map();
|
|
706
|
+
for (const finding of findings) {
|
|
707
|
+
const key = verifyFailureKey(finding);
|
|
708
|
+
if (key !== null)
|
|
709
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
710
|
+
}
|
|
711
|
+
return counts;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The failures this change caused: each failure key is excused at most as many
|
|
715
|
+
* times as the clean tree had it, so a second copy of an old error is new. A
|
|
716
|
+
* failure in a file the build did not touch still counts when the baseline did
|
|
717
|
+
* not have it, since a change can break another file's types. No baseline for
|
|
718
|
+
* the command leaves every failure in place. Pure.
|
|
719
|
+
*/
|
|
720
|
+
export function subtractBaseline(findings, baseline) {
|
|
721
|
+
if (!baseline)
|
|
722
|
+
return { findings: [...findings], excused: 0 };
|
|
723
|
+
const left = new Map(baseline);
|
|
724
|
+
const fresh = [];
|
|
725
|
+
let excused = 0;
|
|
726
|
+
for (const finding of findings) {
|
|
727
|
+
const key = verifyFailureKey(finding);
|
|
728
|
+
const count = key === null ? 0 : (left.get(key) ?? 0);
|
|
729
|
+
if (key !== null && count > 0) {
|
|
730
|
+
left.set(key, count - 1);
|
|
731
|
+
excused += 1;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
fresh.push(finding);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return { findings: fresh, excused };
|
|
738
|
+
}
|
|
739
|
+
/** The note for failures the clean tree already had, by command label. Pure. */
|
|
740
|
+
export function preexistingNote(byLabel) {
|
|
741
|
+
const total = [...byLabel.values()].reduce((sum, n) => sum + n, 0);
|
|
742
|
+
if (total === 0)
|
|
743
|
+
return null;
|
|
744
|
+
const parts = [...byLabel].filter(([, n]) => n > 0).map(([label, n]) => `${label} ${n}`);
|
|
745
|
+
return `already failed before this change: ${total} (${parts.join(", ")}); not findings`;
|
|
746
|
+
}
|
|
747
|
+
/** Cap on the paths read off a plan, and on the packages the baseline runs in. */
|
|
748
|
+
const MAX_PLAN_PATHS = 200;
|
|
749
|
+
const MAX_BASELINE_PACKAGES = 10;
|
|
750
|
+
/** A path-shaped token in plan prose: at least one `/`, no scheme. */
|
|
751
|
+
const PLAN_PATH = /(?:^|[\s`'"(<[])(\.?\/?[\w@.+-]+(?:\/[\w@.+[\]-]+)+\/?)/g;
|
|
752
|
+
/**
|
|
753
|
+
* The repo-relative paths an approved plan names (`src/flows/a.tsx`,
|
|
754
|
+
* `packages/web/`), which say which packages the build will change before it
|
|
755
|
+
* has changed anything. URLs and paths that leave the tree are dropped. Pure.
|
|
756
|
+
*/
|
|
757
|
+
export function planPaths(plan) {
|
|
758
|
+
const paths = new Set();
|
|
759
|
+
for (const match of plan.matchAll(PLAN_PATH)) {
|
|
760
|
+
const raw = match[1].replace(/[.,:;]+$/, "").replace(/^\.\//, "");
|
|
761
|
+
if (!raw || raw.startsWith("/") || raw.startsWith("..") || raw.includes("://"))
|
|
762
|
+
continue;
|
|
763
|
+
paths.add(raw);
|
|
764
|
+
if (paths.size >= MAX_PLAN_PATHS)
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
return [...paths];
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* The package dirs the baseline runs in: those the plan's paths fall in. The
|
|
771
|
+
* repo root drops out when a package below it was named, since its check is
|
|
772
|
+
* not one the build's gate would run then. Pure (the `readFile` seam).
|
|
773
|
+
*/
|
|
774
|
+
export function baselineDirs(repoRoot, paths, readFile) {
|
|
775
|
+
const dirs = resolveClaimDirs(repoRoot, paths, readFile);
|
|
776
|
+
const root = normDir(repoRoot);
|
|
777
|
+
const below = dirs.filter((dir) => normDir(dir) !== root);
|
|
778
|
+
return (below.length > 0 ? below : dirs).slice(0, MAX_BASELINE_PACKAGES);
|
|
779
|
+
}
|
|
654
780
|
const SAFE_ENV_KEYS = [
|
|
655
781
|
"PATH",
|
|
656
782
|
"HOME",
|
|
@@ -815,6 +941,13 @@ export function makeDefaultExec(opts = {}) {
|
|
|
815
941
|
});
|
|
816
942
|
}
|
|
817
943
|
const defaultExec = makeDefaultExec();
|
|
944
|
+
/** The gate's typecheck-half detection, shared by the build's runs and the baseline run. */
|
|
945
|
+
const defaultDetect = (cwd, changedDirs) => detectVerifyCommands(cwd, {
|
|
946
|
+
allowRepoOverride: process.env.YAGNI_CODE_ALLOW_VERIFY_OVERRIDE === "1",
|
|
947
|
+
changedDirs,
|
|
948
|
+
});
|
|
949
|
+
/** The gate's test-half detection, shared the same way. */
|
|
950
|
+
const defaultDetectTests = (cwd, changedDirs) => detectVerifyTestCommands(cwd, { changedDirs });
|
|
818
951
|
/**
|
|
819
952
|
* A workstream's claims resolved to package dirs. Claims are PREFIXES and a claim
|
|
820
953
|
* is very often a directory (`packages/backend/src/routes`), while
|
|
@@ -921,7 +1054,7 @@ export function makeWorkstreamCheck(opts = {}) {
|
|
|
921
1054
|
anyRan = true;
|
|
922
1055
|
labels.push(command.label);
|
|
923
1056
|
if (run.code !== 0) {
|
|
924
|
-
const parsed = parseVerifyFailures(run.output, run.code, command.label).map((f) => liftFinding(f, cwd, command.cwd));
|
|
1057
|
+
const parsed = subtractBaseline(parseVerifyFailures(run.output, run.code, command.label, Number.POSITIVE_INFINITY).map((f) => liftFinding(f, cwd, command.cwd)), opts.baseline?.get(verifyCommandKey(command))).findings.slice(0, MAX_LOCATED_FINDINGS);
|
|
925
1058
|
const covered = parsed.filter((f) => f.file != null && target.files.some((claim) => claimCovers(claim, f.file)));
|
|
926
1059
|
findings.push(...covered.map((f) => tagFinding(f, target.name)));
|
|
927
1060
|
if (!uncoveredEmitted.has(memoKey)) {
|
|
@@ -952,13 +1085,8 @@ export function makeWorkstreamCheck(opts = {}) {
|
|
|
952
1085
|
* blocks a real change on its own failure.
|
|
953
1086
|
*/
|
|
954
1087
|
export function makeRunVerify(opts = {}) {
|
|
955
|
-
const detect = opts.detect ??
|
|
956
|
-
|
|
957
|
-
allowRepoOverride: process.env.YAGNI_CODE_ALLOW_VERIFY_OVERRIDE === "1",
|
|
958
|
-
changedDirs,
|
|
959
|
-
}));
|
|
960
|
-
const detectTests = opts.detectTests ??
|
|
961
|
-
((cwd, changedDirs) => detectVerifyTestCommands(cwd, { changedDirs }));
|
|
1088
|
+
const detect = opts.detect ?? defaultDetect;
|
|
1089
|
+
const detectTests = opts.detectTests ?? defaultDetectTests;
|
|
962
1090
|
const changedDirs = opts.changedDirs ??
|
|
963
1091
|
(async (cwd, signal, baselinePaths) => {
|
|
964
1092
|
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
@@ -1057,12 +1185,12 @@ export function makeRunVerify(opts = {}) {
|
|
|
1057
1185
|
const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
|
|
1058
1186
|
if (approved.length === 0 && steps.length === 0) {
|
|
1059
1187
|
report();
|
|
1060
|
-
return { findings, reasons, labels };
|
|
1188
|
+
return { findings, reasons, labels, checks: [] };
|
|
1061
1189
|
}
|
|
1062
1190
|
const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
|
|
1063
1191
|
if (buildLeft < REPO_CHECK_MIN_START_MS) {
|
|
1064
1192
|
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");
|
|
1065
|
-
return { findings, reasons, labels };
|
|
1193
|
+
return { findings, reasons, labels, checks: [] };
|
|
1066
1194
|
}
|
|
1067
1195
|
const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
|
|
1068
1196
|
const halfStartedAt = now();
|
|
@@ -1077,6 +1205,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1077
1205
|
notReached: 0,
|
|
1078
1206
|
failedJobs,
|
|
1079
1207
|
approvedFailed: new Set(),
|
|
1208
|
+
checks: [],
|
|
1080
1209
|
};
|
|
1081
1210
|
const before = await treeStatus(cwd, signal).catch((err) => {
|
|
1082
1211
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1110,26 +1239,47 @@ export function makeRunVerify(opts = {}) {
|
|
|
1110
1239
|
if (run.notReached > 0) {
|
|
1111
1240
|
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`);
|
|
1112
1241
|
}
|
|
1113
|
-
return { findings, reasons, labels };
|
|
1242
|
+
return { findings, reasons, labels, checks: run.checks };
|
|
1114
1243
|
}
|
|
1115
1244
|
/** One repository command's verdict: passed, a note (it could not run), or a failure with its output. */
|
|
1116
|
-
async function runRepoCommand(run, command, dir, stepEnv) {
|
|
1245
|
+
async function runRepoCommand(run, command, dir, stepEnv, check) {
|
|
1117
1246
|
const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
|
|
1118
1247
|
const composed = composeAbortSignal(run.signal, AbortSignal.timeout(Math.max(1, capMs)));
|
|
1119
1248
|
const env = opts.repoChecksEnv
|
|
1120
1249
|
? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
|
|
1121
1250
|
: buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
|
|
1251
|
+
const startedAt = now();
|
|
1122
1252
|
try {
|
|
1123
1253
|
const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
|
|
1124
|
-
|
|
1254
|
+
const record = (baseline) => run.checks.push({
|
|
1255
|
+
name: check.name,
|
|
1256
|
+
command,
|
|
1257
|
+
cwd: relative(run.cwd, dir) || ".",
|
|
1258
|
+
exitCode: code,
|
|
1259
|
+
durationMs: now() - startedAt,
|
|
1260
|
+
baseline,
|
|
1261
|
+
});
|
|
1262
|
+
if (code === 0) {
|
|
1263
|
+
record(check.baseline);
|
|
1125
1264
|
return { kind: "passed" };
|
|
1265
|
+
}
|
|
1126
1266
|
const output = scrubSecrets(rawOutput);
|
|
1127
1267
|
const cls = classifyTestFailure(output, code);
|
|
1268
|
+
record(cls.kind === "infra" ? "cannot_run" : check.baseline);
|
|
1128
1269
|
if (cls.kind === "infra")
|
|
1129
1270
|
return { kind: "note", reason: `could not run: ${cls.reason}` };
|
|
1130
1271
|
return { kind: "failed", output, code };
|
|
1131
1272
|
}
|
|
1132
1273
|
catch (err) {
|
|
1274
|
+
run.checks.push({
|
|
1275
|
+
name: check.name,
|
|
1276
|
+
command,
|
|
1277
|
+
cwd: relative(run.cwd, dir) || ".",
|
|
1278
|
+
exitCode: null,
|
|
1279
|
+
durationMs: now() - startedAt,
|
|
1280
|
+
baseline: check.baseline,
|
|
1281
|
+
note: unexitedNote(err, run.signal, composed),
|
|
1282
|
+
});
|
|
1133
1283
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1134
1284
|
return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
|
|
1135
1285
|
}
|
|
@@ -1160,7 +1310,10 @@ export function makeRunVerify(opts = {}) {
|
|
|
1160
1310
|
continue;
|
|
1161
1311
|
}
|
|
1162
1312
|
const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
|
|
1163
|
-
const verdict = await runRepoCommand(run, check.command, run.cwd, undefined
|
|
1313
|
+
const verdict = await runRepoCommand(run, check.command, run.cwd, undefined, {
|
|
1314
|
+
name: label,
|
|
1315
|
+
baseline: check.status,
|
|
1316
|
+
});
|
|
1164
1317
|
if (verdict.kind === "failed")
|
|
1165
1318
|
run.approvedFailed.add(check.command.trim());
|
|
1166
1319
|
if (verdict.kind === "note") {
|
|
@@ -1225,7 +1378,10 @@ export function makeRunVerify(opts = {}) {
|
|
|
1225
1378
|
const dir = step.workingDirectory
|
|
1226
1379
|
? join(run.cwd, step.workingDirectory.replace(/^\.\/+/, ""))
|
|
1227
1380
|
: run.cwd;
|
|
1228
|
-
const verdict = await runRepoCommand(run, step.run, dir, step.env
|
|
1381
|
+
const verdict = await runRepoCommand(run, step.run, dir, step.env, {
|
|
1382
|
+
name: `CI ${job.name}: ${step.name}`,
|
|
1383
|
+
baseline: "runnable",
|
|
1384
|
+
});
|
|
1229
1385
|
if (verdict.kind === "passed") {
|
|
1230
1386
|
lastCiFailure.delete(key);
|
|
1231
1387
|
run.labels.push(`CI ${job.name}: ${step.name}`);
|
|
@@ -1263,7 +1419,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1263
1419
|
// The approved checks carry the plan's approval, not the repository's own
|
|
1264
1420
|
// choice of how to verify, so an override never takes them out of the loop.
|
|
1265
1421
|
const repoFile = opts.repoChecks?.() ?? null;
|
|
1266
|
-
if (commands.length === 0 && testCommands.length === 0 && !repoFile) {
|
|
1422
|
+
if (commands.length === 0 && testCommands.length === 0 && !repoFile && !opts.scanChange) {
|
|
1267
1423
|
return {
|
|
1268
1424
|
ran: false,
|
|
1269
1425
|
ok: true,
|
|
@@ -1274,6 +1430,27 @@ export function makeRunVerify(opts = {}) {
|
|
|
1274
1430
|
const findings = [];
|
|
1275
1431
|
const reasons = [];
|
|
1276
1432
|
let anyRan = false;
|
|
1433
|
+
// Failures the clean tree already had, by command label (YAG-1003).
|
|
1434
|
+
const preexisting = new Map();
|
|
1435
|
+
const countPreexisting = (label, n) => {
|
|
1436
|
+
if (n > 0)
|
|
1437
|
+
preexisting.set(label, (preexisting.get(label) ?? 0) + n);
|
|
1438
|
+
};
|
|
1439
|
+
const checks = [];
|
|
1440
|
+
// A detected command's standing on the clean tree, from the baseline run.
|
|
1441
|
+
const standing = (command) => {
|
|
1442
|
+
const counts = opts.baseline?.get(verifyCommandKey(command));
|
|
1443
|
+
return counts && [...counts.values()].some((n) => n > 0) ? "fails_on_base" : "runnable";
|
|
1444
|
+
};
|
|
1445
|
+
const recordCommand = (command, code, startedAt, baseline, note) => checks.push({
|
|
1446
|
+
name: command.label,
|
|
1447
|
+
command: command.argv.join(" "),
|
|
1448
|
+
cwd: relative(cwd, command.cwd) || ".",
|
|
1449
|
+
exitCode: code,
|
|
1450
|
+
durationMs: now() - startedAt,
|
|
1451
|
+
baseline,
|
|
1452
|
+
...(note ? { note } : {}),
|
|
1453
|
+
});
|
|
1277
1454
|
// TYPECHECK HALF (frozen behavior, §0.2): run every changed package's build
|
|
1278
1455
|
// check; union the findings. One package that cannot spawn fails OPEN (a
|
|
1279
1456
|
// recorded reason, no finding) without sinking the verdict for the packages
|
|
@@ -1281,6 +1458,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1281
1458
|
const typecheckLabels = [];
|
|
1282
1459
|
for (const command of commands) {
|
|
1283
1460
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
|
|
1461
|
+
const startedAt = now();
|
|
1284
1462
|
try {
|
|
1285
1463
|
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed);
|
|
1286
1464
|
const output = scrubSecrets(rawOutput);
|
|
@@ -1292,16 +1470,22 @@ export function makeRunVerify(opts = {}) {
|
|
|
1292
1470
|
const infra = classifyVerifyInfraFailure(output, code);
|
|
1293
1471
|
if (infra) {
|
|
1294
1472
|
reasons.push(`verify (${command.label}) could not run: ${infra}`);
|
|
1473
|
+
recordCommand(command, code, startedAt, "cannot_run");
|
|
1295
1474
|
continue;
|
|
1296
1475
|
}
|
|
1297
1476
|
}
|
|
1477
|
+
recordCommand(command, code, startedAt, standing(command));
|
|
1298
1478
|
anyRan = true;
|
|
1299
1479
|
typecheckLabels.push(command.label);
|
|
1300
1480
|
if (code !== 0) {
|
|
1301
|
-
|
|
1481
|
+
const parsed = parseVerifyFailures(output, code, command.label, Number.POSITIVE_INFINITY).map((f) => liftFinding(f, cwd, command.cwd));
|
|
1482
|
+
const fresh = subtractBaseline(parsed, opts.baseline?.get(verifyCommandKey(command)));
|
|
1483
|
+
countPreexisting(command.label, fresh.excused);
|
|
1484
|
+
findings.push(...fresh.findings.slice(0, MAX_LOCATED_FINDINGS));
|
|
1302
1485
|
}
|
|
1303
1486
|
}
|
|
1304
1487
|
catch (err) {
|
|
1488
|
+
recordCommand(command, null, startedAt, standing(command), unexitedNote(err, signal, composed));
|
|
1305
1489
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1306
1490
|
reasons.push(`verify (${command.label}) could not run: ${scrubSecrets(msg)}`);
|
|
1307
1491
|
}
|
|
@@ -1325,10 +1509,12 @@ export function makeRunVerify(opts = {}) {
|
|
|
1325
1509
|
for (const command of testCommands) {
|
|
1326
1510
|
const where = basename(command.cwd) || command.cwd;
|
|
1327
1511
|
const composed = composeAbortSignal(signal, AbortSignal.timeout(testTimeoutMs));
|
|
1512
|
+
const startedAt = now();
|
|
1328
1513
|
try {
|
|
1329
1514
|
const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
|
|
1330
1515
|
const output = scrubSecrets(rawOutput);
|
|
1331
|
-
const cls = classifyTestFailure(output, code);
|
|
1516
|
+
const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
|
|
1517
|
+
recordCommand(command, code, startedAt, cls.kind === "infra" ? "cannot_run" : standing(command));
|
|
1332
1518
|
if (cls.kind === "infra") {
|
|
1333
1519
|
reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
|
|
1334
1520
|
continue;
|
|
@@ -1343,12 +1529,15 @@ export function makeRunVerify(opts = {}) {
|
|
|
1343
1529
|
testLabels.push(command.label);
|
|
1344
1530
|
if (cls.kind === "failures") {
|
|
1345
1531
|
const room = Math.max(0, MAX_LOCATED_FINDINGS - testFindingCount);
|
|
1346
|
-
const
|
|
1532
|
+
const fresh = subtractBaseline(cls.findings.map((f) => liftFinding(f, cwd, command.cwd)), opts.baseline?.get(verifyCommandKey(command)));
|
|
1533
|
+
countPreexisting(command.label, fresh.excused);
|
|
1534
|
+
const take = fresh.findings.slice(0, room);
|
|
1347
1535
|
testFindingCount += take.length;
|
|
1348
1536
|
findings.push(...take);
|
|
1349
1537
|
}
|
|
1350
1538
|
}
|
|
1351
1539
|
catch (err) {
|
|
1540
|
+
recordCommand(command, null, startedAt, standing(command), unexitedNote(err, signal, composed));
|
|
1352
1541
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1353
1542
|
reasons.push(`tests (${where}) could not run: ${scrubSecrets(msg)}`);
|
|
1354
1543
|
}
|
|
@@ -1373,11 +1562,37 @@ export function makeRunVerify(opts = {}) {
|
|
|
1373
1562
|
});
|
|
1374
1563
|
reasons.push(...repo.reasons);
|
|
1375
1564
|
repoLabels.push(...repo.labels);
|
|
1565
|
+
checks.push(...repo.checks);
|
|
1376
1566
|
if (repo.labels.length > 0)
|
|
1377
1567
|
anyRan = true;
|
|
1378
1568
|
findings.push(...repo.findings);
|
|
1379
1569
|
}
|
|
1380
1570
|
}
|
|
1571
|
+
// SCAN HALF (YAG-1000): what this run added, against the host's own stop
|
|
1572
|
+
// rules, whatever the other halves found: a credential is never the
|
|
1573
|
+
// compile error's root cause, and it must not reach the host.
|
|
1574
|
+
const scanLabels = [];
|
|
1575
|
+
if (opts.scanChange) {
|
|
1576
|
+
try {
|
|
1577
|
+
const hits = await opts.scanChange(cwd, signal, baselinePaths);
|
|
1578
|
+
if (hits === null) {
|
|
1579
|
+
reasons.push("the secret and binary scan could not read the change; the host still scans it");
|
|
1580
|
+
}
|
|
1581
|
+
else {
|
|
1582
|
+
anyRan = true;
|
|
1583
|
+
scanLabels.push("secret and binary scan");
|
|
1584
|
+
findings.push(...hits.slice(0, MAX_LOCATED_FINDINGS));
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1589
|
+
reasons.push(`the secret and binary scan could not run: ${scrubSecrets(msg)}; the host still scans the change`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const note = preexistingNote(preexisting);
|
|
1593
|
+
if (note)
|
|
1594
|
+
reasons.push(note);
|
|
1595
|
+
const preexistingTotal = [...preexisting.values()].reduce((sum, n) => sum + n, 0);
|
|
1381
1596
|
// The command labels list only what produced a verdict — on every half —
|
|
1382
1597
|
// so the FINISH trailer's `passed (<cmds>)` never claims a check that was
|
|
1383
1598
|
// infra-skipped or threw (invariant §0.1).
|
|
@@ -1385,6 +1600,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1385
1600
|
...new Set(typecheckLabels),
|
|
1386
1601
|
...new Set(testLabels),
|
|
1387
1602
|
...new Set(repoLabels),
|
|
1603
|
+
...scanLabels,
|
|
1388
1604
|
].join(", ");
|
|
1389
1605
|
if (!anyRan) {
|
|
1390
1606
|
return {
|
|
@@ -1393,6 +1609,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
1393
1609
|
command,
|
|
1394
1610
|
reason: reasons.join("; ") || "verify skipped",
|
|
1395
1611
|
findings: [],
|
|
1612
|
+
checks,
|
|
1396
1613
|
};
|
|
1397
1614
|
}
|
|
1398
1615
|
return {
|
|
@@ -1401,7 +1618,115 @@ export function makeRunVerify(opts = {}) {
|
|
|
1401
1618
|
command,
|
|
1402
1619
|
findings,
|
|
1403
1620
|
...(reasons.length ? { reason: reasons.join("; ") } : {}),
|
|
1621
|
+
...(preexistingTotal > 0 ? { preexisting: preexistingTotal } : {}),
|
|
1622
|
+
checks,
|
|
1404
1623
|
};
|
|
1405
1624
|
};
|
|
1406
1625
|
}
|
|
1626
|
+
/** The whole baseline run's cap, before the build starts: it costs minutes at most. */
|
|
1627
|
+
export const VERIFY_BASELINE_BUDGET_MS = 10 * 60_000;
|
|
1628
|
+
/**
|
|
1629
|
+
* Run the gate's own typecheck and test commands ONCE on the clean tree,
|
|
1630
|
+
* before the build changes anything (YAG-1003), in the package dirs the
|
|
1631
|
+
* approved plan names, and keep each command's failure keys. The build's gate
|
|
1632
|
+
* then counts only the failures this change added ({@link subtractBaseline}).
|
|
1633
|
+
*
|
|
1634
|
+
* The same detection, exec seam and environments as {@link makeRunVerify}, so
|
|
1635
|
+
* a command's key and its failures' keys match the build's runs. Fail-open per
|
|
1636
|
+
* command: one that cannot run, times out, or is not reached in the budget
|
|
1637
|
+
* has no baseline, and its failures all count, as before. What the commands
|
|
1638
|
+
* write into the tree is undone, so none of it reaches the candidate.
|
|
1639
|
+
*/
|
|
1640
|
+
export function makeTakeVerifyBaseline(opts = {}) {
|
|
1641
|
+
const detect = opts.detect ?? defaultDetect;
|
|
1642
|
+
const detectTests = opts.detectTests ?? defaultDetectTests;
|
|
1643
|
+
const exec = opts.exec ?? defaultExec;
|
|
1644
|
+
const now = opts.now ?? Date.now;
|
|
1645
|
+
const treeStatus = opts.treeStatus ??
|
|
1646
|
+
(async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
|
|
1647
|
+
const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
|
|
1648
|
+
return async function takeVerifyBaseline(cwd, dirs, signal, budgetOverrideMs) {
|
|
1649
|
+
const baseline = new Map();
|
|
1650
|
+
const result = { baseline, ran: [], failures: 0, notes: [] };
|
|
1651
|
+
if (dirs.length === 0)
|
|
1652
|
+
return result;
|
|
1653
|
+
const commands = detect(cwd, dirs);
|
|
1654
|
+
const overridden = commands.some((c) => c.source === "override");
|
|
1655
|
+
const tests = overridden ? [] : detectTests(cwd, dirs);
|
|
1656
|
+
if (commands.length === 0 && tests.length === 0)
|
|
1657
|
+
return result;
|
|
1658
|
+
const startedAt = now();
|
|
1659
|
+
const budgetMs = Math.min(opts.budgetMs ?? VERIFY_BASELINE_BUDGET_MS, budgetOverrideMs ?? Number.POSITIVE_INFINITY);
|
|
1660
|
+
const before = await treeStatus(cwd, signal).catch((err) => {
|
|
1661
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1662
|
+
result.notes.push(`could not read the tree before the baseline, so what it writes is not undone: ${scrubSecrets(msg)}`);
|
|
1663
|
+
return null;
|
|
1664
|
+
});
|
|
1665
|
+
const runs = [
|
|
1666
|
+
...commands.map((command) => ({ command, test: false })),
|
|
1667
|
+
...tests.map((command) => ({ command, test: true })),
|
|
1668
|
+
];
|
|
1669
|
+
try {
|
|
1670
|
+
for (const { command, test } of runs) {
|
|
1671
|
+
if (signal?.aborted)
|
|
1672
|
+
break;
|
|
1673
|
+
const left = budgetMs - (now() - startedAt);
|
|
1674
|
+
if (left < REPO_CHECK_MIN_START_MS) {
|
|
1675
|
+
result.notes.push(`${command.label} (${command.cwd}): not reached in the baseline budget`);
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const cap = Math.min(test
|
|
1679
|
+
? (opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS)
|
|
1680
|
+
: (opts.timeoutMs ?? VERIFY_TIMEOUT_MS), left);
|
|
1681
|
+
const composed = composeAbortSignal(signal, AbortSignal.timeout(cap));
|
|
1682
|
+
try {
|
|
1683
|
+
const { code, output: rawOutput } = test
|
|
1684
|
+
? await exec(command.argv, command.cwd, composed, opts.testEnv ?? buildTestEnv())
|
|
1685
|
+
: await exec(command.argv, command.cwd, composed);
|
|
1686
|
+
const output = scrubSecrets(rawOutput);
|
|
1687
|
+
let failed;
|
|
1688
|
+
if (test) {
|
|
1689
|
+
const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
|
|
1690
|
+
if (cls.kind === "infra" || cls.kind === "uncertain") {
|
|
1691
|
+
result.notes.push(`${command.label} (${command.cwd}): ${cls.kind === "infra" ? cls.reason : "output not interpretable"}`);
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
failed = cls.kind === "failures" ? cls.findings : [];
|
|
1695
|
+
}
|
|
1696
|
+
else {
|
|
1697
|
+
const infra = code === 0 ? null : classifyVerifyInfraFailure(output, code);
|
|
1698
|
+
if (infra) {
|
|
1699
|
+
result.notes.push(`${command.label} (${command.cwd}): ${infra}`);
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
failed = parseVerifyFailures(output, code, command.label, Number.POSITIVE_INFINITY);
|
|
1703
|
+
}
|
|
1704
|
+
const counts = failureKeyCounts(failed.map((f) => liftFinding(f, cwd, command.cwd)));
|
|
1705
|
+
baseline.set(verifyCommandKey(command), counts);
|
|
1706
|
+
result.ran.push(command.label);
|
|
1707
|
+
for (const n of counts.values())
|
|
1708
|
+
result.failures += n;
|
|
1709
|
+
}
|
|
1710
|
+
catch (err) {
|
|
1711
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1712
|
+
result.notes.push(`${command.label} (${command.cwd}): could not run: ${scrubSecrets(msg)}`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
finally {
|
|
1717
|
+
if (before !== null) {
|
|
1718
|
+
try {
|
|
1719
|
+
const written = newTreeEntries(before, await treeStatus(cwd));
|
|
1720
|
+
if (written.length > 0)
|
|
1721
|
+
await undoTreeWrites(cwd, written);
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1725
|
+
result.notes.push(`could not undo what the baseline wrote into the tree: ${scrubSecrets(msg)}`);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return result;
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1407
1732
|
//# sourceMappingURL=verify.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.6-staging.
|
|
3
|
+
"version": "1.2.6-staging.1956.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": "bb058b7c375b03862aa2899c11e3c00b40d0b4f4"
|
|
62
62
|
}
|