@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.
Files changed (30) hide show
  1. package/dist/extension/branding.d.ts +20 -0
  2. package/dist/extension/branding.js +49 -8
  3. package/dist/extension/index.js +5 -0
  4. package/dist/extension/pipeline/changeScan.d.ts +62 -0
  5. package/dist/extension/pipeline/changeScan.js +307 -0
  6. package/dist/extension/pipeline/cloud.d.ts +205 -0
  7. package/dist/extension/pipeline/cloud.js +382 -0
  8. package/dist/extension/pipeline/continuation.d.ts +7 -3
  9. package/dist/extension/pipeline/continuation.js +29 -13
  10. package/dist/extension/pipeline/findings.d.ts +9 -4
  11. package/dist/extension/pipeline/findings.js +25 -10
  12. package/dist/extension/pipeline/finish.d.ts +11 -1
  13. package/dist/extension/pipeline/finish.js +51 -5
  14. package/dist/extension/pipeline/headlessGo.d.ts +30 -3
  15. package/dist/extension/pipeline/headlessGo.js +253 -29
  16. package/dist/extension/pipeline/orchestrator.d.ts +8 -2
  17. package/dist/extension/pipeline/orchestrator.js +249 -68
  18. package/dist/extension/pipeline/personas.d.ts +8 -3
  19. package/dist/extension/pipeline/personas.js +42 -7
  20. package/dist/extension/pipeline/planOutput.d.ts +42 -0
  21. package/dist/extension/pipeline/planOutput.js +94 -0
  22. package/dist/extension/pipeline/stages.d.ts +34 -5
  23. package/dist/extension/pipeline/stages.js +136 -7
  24. package/dist/extension/pipeline/tierCap.d.ts +22 -1
  25. package/dist/extension/pipeline/tierCap.js +48 -1
  26. package/dist/extension/pipeline/types.d.ts +31 -0
  27. package/dist/extension/pipeline/types.js +2 -0
  28. package/dist/extension/pipeline/verify.d.ts +128 -4
  29. package/dist/extension/pipeline/verify.js +356 -31
  30. 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, MAX_LOCATED_FINDINGS);
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) are
564
- * parsed into per-error findings; if none parse, a single capped finding carries
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
- if (!m)
579
- continue;
580
- const file = m[1].trim();
581
- const lineNo = Number(m[2]);
582
- const message = m[3].trim();
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 >= MAX_LOCATED_FINDINGS)
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
- ((cwd, changedDirs) => detectVerifyCommands(cwd, {
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
- if (code === 0)
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
- findings.push(...parseVerifyFailures(output, code, command.label).map((f) => liftFinding(f, cwd, command.cwd)));
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 take = cls.findings.slice(0, room).map((f) => liftFinding(f, cwd, command.cwd));
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.1951.1",
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": "0985f34f33f3c59e0fb7ed50af520abbcbb2466c"
61
+ "yagniSourceSha": "bb058b7c375b03862aa2899c11e3c00b40d0b4f4"
62
62
  }