@yagni-app/code-staging 1.2.6-staging.1951.1 → 1.2.6-staging.1952.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.
@@ -45,7 +45,8 @@
45
45
  * - Always, as the last line: the result object
46
46
  * `{type:"pipeline_result", ok, stopReason, rounds, findings, stages:[{stage,
47
47
  * tier, usage}], …}` — or `{type:"pipeline_error", …}` when a build stage
48
- * threw.
48
+ * threw. `preexistingFailures` (absent at 0) counts the gate's own
49
+ * typecheck, lint and test failures the clean tree already had (YAG-1003).
49
50
  * - Exit codes: 0 ONLY on a verified candidate (a `clean` stop), 1 for any
50
51
  * other outcome or a thrown pipeline error, 2 for a usage/input problem.
51
52
  *
@@ -60,7 +61,7 @@
60
61
  */
61
62
  import { type TreeSnapshotDeps } from "./treeSnapshot.js";
62
63
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
63
- import { makeRunVerify } from "./verify.js";
64
+ import { makeRunVerify, type VerifyBaselineResult } from "./verify.js";
64
65
  import { type RunBudget } from "./budget.js";
65
66
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
66
67
  /** One-line usage copy, shared by every argument error. */
@@ -129,6 +130,11 @@ export interface HeadlessGoResult {
129
130
  /** Honest note when the deterministic verify gate gave no verdict. */
130
131
  verifyNote?: string;
131
132
  verifyCommand?: string;
133
+ /**
134
+ * Failures the gate's own typecheck, lint and tests already had on the clean
135
+ * tree, in its last verdict (YAG-1003): a note, never findings. Absent at 0.
136
+ */
137
+ preexistingFailures?: number;
132
138
  }
133
139
  export interface HeadlessGoOutcome {
134
140
  exitCode: number;
@@ -160,6 +166,8 @@ export interface HeadlessGoDeps {
160
166
  * there is none). Defaults to the real gate; injected in tests.
161
167
  */
162
168
  makeRunVerify?: typeof makeRunVerify;
169
+ /** The verify gate's clean-tree baseline run (YAG-1003). Defaults to the real one; injected in tests. */
170
+ takeVerifyBaseline?: (cwd: string, dirs: string[], signal?: AbortSignal, budgetMs?: number) => Promise<VerifyBaselineResult>;
163
171
  /** stdout sink; one call per NDJSON line, newline added by the caller. */
164
172
  write?: (line: string) => void;
165
173
  /** stderr sink for human-readable problems. */
@@ -45,7 +45,8 @@
45
45
  * - Always, as the last line: the result object
46
46
  * `{type:"pipeline_result", ok, stopReason, rounds, findings, stages:[{stage,
47
47
  * tier, usage}], …}` — or `{type:"pipeline_error", …}` when a build stage
48
- * threw.
48
+ * threw. `preexistingFailures` (absent at 0) counts the gate's own
49
+ * typecheck, lint and test failures the clean tree already had (YAG-1003).
49
50
  * - Exit codes: 0 ONLY on a verified candidate (a `clean` stop), 1 for any
50
51
  * other outcome or a thrown pipeline error, 2 for a usage/input problem.
51
52
  *
@@ -69,7 +70,7 @@ import { normalizeMission } from "./mission.js";
69
70
  import { MISSION_CHECKS_FILE_NAME, MISSION_CI_RESULTS_FILE_NAME, readMissionChecksFile, renderCiResults, } from "./missionChecks.js";
70
71
  import { startTreeSnapshots } from "./treeSnapshot.js";
71
72
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
72
- import { makeRunVerify } from "./verify.js";
73
+ import { baselineDirs, makeRunVerify, makeTakeVerifyBaseline, makeWorkstreamCheck, planPaths, } from "./verify.js";
73
74
  import { loadGroundingEnabled } from "../grounding.js";
74
75
  import { codeStateHome } from "../stateHome.js";
75
76
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
@@ -82,6 +83,9 @@ export const HEADLESS_GO_USAGE = "Usage: yagni go --headless --ticket-file <path
82
83
  * can never read as success to a script, a smoke gate, or CI.
83
84
  */
84
85
  export const HEADLESS_GO_EXIT = { verified: 0, unverified: 1, usage: 2 };
86
+ /** The verify baseline's share of a deadline run's time left, and the least it is worth starting with. */
87
+ const BASELINE_SHARE_OF_TIME_LEFT = 6;
88
+ const MIN_BASELINE_BUDGET_MS = 60_000;
85
89
  const VALUE_FLAGS = {
86
90
  "--ticket-file": "ticketFile",
87
91
  "--plan-file": "planFile",
@@ -204,6 +208,7 @@ function buildResult(result, ctx) {
204
208
  ...(finish?.prUrl ? { prUrl: finish.prUrl } : {}),
205
209
  ...(result.verifyNote ? { verifyNote: result.verifyNote } : {}),
206
210
  ...(result.verifyCommand ? { verifyCommand: result.verifyCommand } : {}),
211
+ ...(ctx.preexistingFailures ? { preexistingFailures: ctx.preexistingFailures } : {}),
207
212
  };
208
213
  }
209
214
  /** Attribution rider for a streamed child event (never overwrites pi's own keys). */
@@ -327,7 +332,7 @@ export async function runHeadlessGo(argv, deps = {}) {
327
332
  // A mission's approved checks and its pull request's CI jobs, written by the
328
333
  // host beside the ticket: the verify gate runs them inside the loop. No file
329
334
  // (a host that predates it) leaves the gate as it was.
330
- let runVerify;
335
+ let gateOpts;
331
336
  if (mission && args.ticketFile) {
332
337
  const checksPath = join(dirname(args.ticketFile), MISSION_CHECKS_FILE_NAME);
333
338
  const read = readMissionChecksFile(checksPath, readFile);
@@ -349,7 +354,7 @@ export async function runHeadlessGo(argv, deps = {}) {
349
354
  writeErr(`Could not write ${resultsPath}: ${err instanceof Error ? err.message : String(err)}`);
350
355
  }
351
356
  };
352
- runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks, onFailedChecks });
357
+ gateOpts = { repoChecks: () => checks, onFailedChecks };
353
358
  }
354
359
  else if (read.problem !== "missing") {
355
360
  const why = read.problem === "malformed" ? "not a version 1 checks file" : "it could not be read";
@@ -371,6 +376,51 @@ export async function runHeadlessGo(argv, deps = {}) {
371
376
  writeErr(`Ignoring ${fixPath}: ${why}. The fix round runs the full pipeline.`);
372
377
  }
373
378
  }
379
+ // The gate's own checks, once on the clean tree before the build (YAG-1003),
380
+ // in the packages the approved plan names: what already failed there is a
381
+ // note, never a finding. Only a fresh build's tree is the clean base; a
382
+ // continuation or a fix round already carries a change, so it takes none.
383
+ let baseline;
384
+ const logBaseline = (data) => {
385
+ deps.logger?.("verify_baseline", data);
386
+ if (stream)
387
+ emit({ type: "pipeline_log", event: "verify_baseline", data });
388
+ };
389
+ // With a deadline, the baseline gets at most a sixth of the run's time left,
390
+ // and none below a minute: the build itself must still fit.
391
+ const deadlineLeftMs = budget?.deadlineMs === undefined ? undefined : budget.deadlineMs - (deps.now ?? Date.now)();
392
+ const baselineBudgetMs = deadlineLeftMs === undefined ? undefined : Math.floor(deadlineLeftMs / BASELINE_SHARE_OF_TIME_LEFT);
393
+ if (mission?.plan && !continuation && !fixRound && !deps.signal?.aborted) {
394
+ const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
395
+ if (dirs.length > 0 && baselineBudgetMs !== undefined && baselineBudgetMs < MIN_BASELINE_BUDGET_MS) {
396
+ logBaseline({ skipped: "too little of the run's time left", deadlineLeftMs, baselineBudgetMs });
397
+ }
398
+ else if (dirs.length > 0) {
399
+ try {
400
+ const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline())(cwd, dirs, deps.signal, baselineBudgetMs);
401
+ logBaseline({ ran: taken.ran, failures: taken.failures, notes: taken.notes });
402
+ if (taken.baseline.size > 0)
403
+ baseline = taken.baseline;
404
+ }
405
+ catch (err) {
406
+ const message = err instanceof Error ? err.message : String(err);
407
+ logBaseline({ error: message });
408
+ writeErr(`The verify baseline could not run (every failure counts): ${message}`);
409
+ }
410
+ }
411
+ }
412
+ // The last gate verdict's count of failures the clean tree already had, for the result.
413
+ let preexistingFailures = 0;
414
+ let runVerify;
415
+ if (gateOpts || baseline) {
416
+ const gate = (deps.makeRunVerify ?? makeRunVerify)({ ...gateOpts, ...(baseline ? { baseline } : {}) });
417
+ runVerify = async (verifyCwd, signal, baselinePaths) => {
418
+ const outcome = await gate(verifyCwd, signal, baselinePaths);
419
+ if (outcome.ran)
420
+ preexistingFailures = outcome.preexisting ?? 0;
421
+ return outcome;
422
+ };
423
+ }
374
424
  const pipelineDeps = {
375
425
  cwd,
376
426
  ...(fixRound ? { fixRound } : {}),
@@ -391,6 +441,7 @@ export async function runHeadlessGo(argv, deps = {}) {
391
441
  grounded: deps.grounded ?? grounding,
392
442
  ...(mission ? { mission } : {}),
393
443
  ...(runVerify ? { runVerify } : {}),
444
+ ...(baseline ? { runWorkstreamCheck: makeWorkstreamCheck({ baseline }) } : {}),
394
445
  ...(continuation ? { continuation: { stageAtStop: continuation.stageAtStop } } : {}),
395
446
  ...(resumeFrom ? { resumeFrom } : {}),
396
447
  onEvent: (ev, tag) => {
@@ -437,6 +488,7 @@ export async function runHeadlessGo(argv, deps = {}) {
437
488
  ...(args.runId ? { runId: args.runId } : {}),
438
489
  ...(tierCap ? { tierCap } : {}),
439
490
  ...(mission ? { missionMode: true } : {}),
491
+ preexistingFailures,
440
492
  });
441
493
  emit({ type: "pipeline_result", ...headless });
442
494
  return {
@@ -94,6 +94,8 @@ export interface VerifyOutcome {
94
94
  reason?: string;
95
95
  /** Critical findings parsed from a failing run; empty otherwise. */
96
96
  findings: Finding[];
97
+ /** Failures the clean tree already had (YAG-1003): named in `reason`, never findings. */
98
+ preexisting?: number;
97
99
  }
98
100
  /** Injectable fs seam so detection is unit-testable without a real repo. */
99
101
  export interface DetectOpts {
@@ -228,15 +230,16 @@ export type TestFailureClassification = {
228
230
  * Callers scrub the output through {@link scrubSecrets} BEFORE classification
229
231
  * so findings/notes never carry secrets (§0.9).
230
232
  */
231
- export declare function classifyTestFailure(output: string, exitCode: number): TestFailureClassification;
233
+ export declare function classifyTestFailure(output: string, exitCode: number, limit?: number): TestFailureClassification;
232
234
  /**
233
235
  * Turn a failing verify run into `critical` Findings. A clean (exit 0) run yields
234
- * none. Located diagnostics (TypeScript first, then a generic file:line form) are
235
- * parsed into per-error findings; if none parse, a single capped finding carries
236
+ * none. Located diagnostics (TypeScript first, then a generic file:line form, then
237
+ * ESLint's stylish error rows under their file header) are parsed into per-error
238
+ * findings, at most `limit` of them; if none parse, a single capped finding carries
236
239
  * the tail of the output so the fix stage still gets actionable signal. Never
237
240
  * throws; always emits at least one finding on a non-zero exit.
238
241
  */
239
- export declare function parseVerifyFailures(output: string, exitCode: number, label: string): Finding[];
242
+ export declare function parseVerifyFailures(output: string, exitCode: number, label: string, limit?: number): Finding[];
240
243
  /**
241
244
  * One finding's file as the REPO ROOT sees it.
242
245
  *
@@ -253,6 +256,49 @@ export declare function parseVerifyFailures(output: string, exitCode: number, la
253
256
  * so a finding that passes through twice is not double-prefixed.
254
257
  */
255
258
  export declare function toRepoRelativePath(file: string, repoRoot: string, commandCwd: string): string;
259
+ /**
260
+ * What the gate's own checks already failed on before the build changed
261
+ * anything (YAG-1003): per command, how many times each failure key was seen
262
+ * on the clean tree. A command with no entry was not run there (or could not
263
+ * run), and its failures all count, as before.
264
+ */
265
+ export type VerifyBaseline = ReadonlyMap<string, ReadonlyMap<string, number>>;
266
+ /** One command's identity across the baseline run and the build's runs: where it runs and what it runs. */
267
+ export declare function verifyCommandKey(command: Pick<VerifyCommand, "cwd" | "argv">): string;
268
+ /**
269
+ * One failure's key, the shape #2601's `failureKeyOf` gives a CI failure: the
270
+ * repo-relative file and the message, never the line, since a change above a
271
+ * failure moves it. A failure that is only the output's tail names nothing
272
+ * stable, so it has no key and is never excused. Pure.
273
+ */
274
+ export declare function verifyFailureKey(finding: Finding): string | null;
275
+ /** How many times each failure key occurs. Pure. */
276
+ export declare function failureKeyCounts(findings: readonly Finding[]): Map<string, number>;
277
+ /**
278
+ * The failures this change caused: each failure key is excused at most as many
279
+ * times as the clean tree had it, so a second copy of an old error is new. A
280
+ * failure in a file the build did not touch still counts when the baseline did
281
+ * not have it, since a change can break another file's types. No baseline for
282
+ * the command leaves every failure in place. Pure.
283
+ */
284
+ export declare function subtractBaseline(findings: readonly Finding[], baseline: ReadonlyMap<string, number> | undefined): {
285
+ findings: Finding[];
286
+ excused: number;
287
+ };
288
+ /** The note for failures the clean tree already had, by command label. Pure. */
289
+ export declare function preexistingNote(byLabel: ReadonlyMap<string, number>): string | null;
290
+ /**
291
+ * The repo-relative paths an approved plan names (`src/flows/a.tsx`,
292
+ * `packages/web/`), which say which packages the build will change before it
293
+ * has changed anything. URLs and paths that leave the tree are dropped. Pure.
294
+ */
295
+ export declare function planPaths(plan: string): string[];
296
+ /**
297
+ * The package dirs the baseline runs in: those the plan's paths fall in. The
298
+ * repo root drops out when a package below it was named, since its check is
299
+ * not one the build's gate would run then. Pure (the `readFile` seam).
300
+ */
301
+ export declare function baselineDirs(repoRoot: string, paths: string[], readFile: (path: string) => string): string[];
256
302
  /** Result of actually executing the resolved command. */
257
303
  export interface VerifyExecResult {
258
304
  code: number;
@@ -352,6 +398,12 @@ export interface MakeRunVerifyOpts {
352
398
  treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
353
399
  /** Undo what the half wrote into the tree (default: `git clean` / `git checkout` of exactly those paths). */
354
400
  undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
401
+ /**
402
+ * What the typecheck and test halves already failed on before the build
403
+ * (YAG-1003, {@link makeTakeVerifyBaseline}). Those failures are a note, not
404
+ * findings. Absent: every failure is a finding, as before.
405
+ */
406
+ baseline?: VerifyBaseline | null;
355
407
  }
356
408
  /**
357
409
  * One workstream as the scoped checker sees it: the name its findings are
@@ -391,6 +443,8 @@ export interface MakeWorkstreamCheckOpts {
391
443
  detect?: (cwd: string, claimDirs: string[]) => VerifyCommand[];
392
444
  exec?: VerifyExec;
393
445
  timeoutMs?: number;
446
+ /** What the clean tree already failed on (YAG-1003): those failures are not a workstream's. */
447
+ baseline?: VerifyBaseline | null;
394
448
  }
395
449
  /**
396
450
  * The per-workstream scoped check the implement diamond runs right after the fan
@@ -428,5 +482,42 @@ export declare function makeWorkstreamCheck(opts?: MakeWorkstreamCheckOpts): (cw
428
482
  * blocks a real change on its own failure.
429
483
  */
430
484
  export declare function makeRunVerify(opts?: MakeRunVerifyOpts): (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<VerifyOutcome>;
485
+ /** The whole baseline run's cap, before the build starts: it costs minutes at most. */
486
+ export declare const VERIFY_BASELINE_BUDGET_MS: number;
487
+ export interface MakeTakeVerifyBaselineOpts {
488
+ detect?: (cwd: string, dirs: string[]) => VerifyCommand[];
489
+ detectTests?: (cwd: string, dirs: string[]) => VerifyCommand[];
490
+ exec?: VerifyExec;
491
+ timeoutMs?: number;
492
+ testTimeoutMs?: number;
493
+ testEnv?: NodeJS.ProcessEnv;
494
+ budgetMs?: number;
495
+ now?: () => number;
496
+ treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
497
+ undoTreeWrites?: (cwd: string, entries: TreeEntry[]) => Promise<void>;
498
+ }
499
+ /** What the baseline run found, for the gate and for the run's log. */
500
+ export interface VerifyBaselineResult {
501
+ baseline: VerifyBaseline;
502
+ /** Commands that gave a verdict on the clean tree, by label. */
503
+ ran: string[];
504
+ /** Keyed failures the clean tree already had, across those commands. */
505
+ failures: number;
506
+ /** Commands that gave no verdict, and why: their failures all count later. */
507
+ notes: string[];
508
+ }
509
+ /**
510
+ * Run the gate's own typecheck and test commands ONCE on the clean tree,
511
+ * before the build changes anything (YAG-1003), in the package dirs the
512
+ * approved plan names, and keep each command's failure keys. The build's gate
513
+ * then counts only the failures this change added ({@link subtractBaseline}).
514
+ *
515
+ * The same detection, exec seam and environments as {@link makeRunVerify}, so
516
+ * a command's key and its failures' keys match the build's runs. Fail-open per
517
+ * command: one that cannot run, times out, or is not reached in the budget
518
+ * has no baseline, and its failures all count, as before. What the commands
519
+ * write into the tree is undone, so none of it reaches the candidate.
520
+ */
521
+ export declare function makeTakeVerifyBaseline(opts?: MakeTakeVerifyBaselineOpts): (cwd: string, dirs: string[], signal?: AbortSignal, budgetOverrideMs?: number) => Promise<VerifyBaselineResult>;
431
522
  export {};
432
523
  //# sourceMappingURL=verify.d.ts.map
@@ -458,7 +458,7 @@ const FAILURE_SUMMARY = [
458
458
  * Callers scrub the output through {@link scrubSecrets} BEFORE classification
459
459
  * so findings/notes never carry secrets (§0.9).
460
460
  */
461
- export function classifyTestFailure(output, exitCode) {
461
+ export function classifyTestFailure(output, exitCode, limit = MAX_LOCATED_FINDINGS) {
462
462
  if (exitCode === 0)
463
463
  return { kind: "clean" };
464
464
  const spawnInfra = classifyVerifyInfraFailure(output, exitCode);
@@ -521,7 +521,7 @@ export function classifyTestFailure(output, exitCode) {
521
521
  else if (!existing.file && parsed.file)
522
522
  byMessage.set(parsed.message, parsed);
523
523
  }
524
- const failures = [...byMessage.values()].slice(0, MAX_LOCATED_FINDINGS);
524
+ const failures = [...byMessage.values()].slice(0, limit);
525
525
  if (failures.length > 0) {
526
526
  return {
527
527
  kind: "failures",
@@ -558,28 +558,45 @@ export function classifyTestFailure(output, exitCode) {
558
558
  const TS_DIAGNOSTIC = /^(.+?)\((\d+),\d+\):\s*error\s+TS\d+:\s*(.+)$/;
559
559
  /** Match a generic `path:line:col: error message` (eslint/tsc-pretty-ish). */
560
560
  const GENERIC_DIAGNOSTIC = /^(.+?):(\d+):\d+:?\s*(?:error\b[:\s]*)?(.+)$/;
561
+ /** An ESLint stylish file header: one path with an extension, nothing else on the line. */
562
+ const STYLISH_FILE = /^(?:\.{0,2}\/)?[^\s:]+\.[A-Za-z0-9]+$/;
563
+ /** An ESLint stylish error row under that header: `12:5 error message rule`. */
564
+ const STYLISH_ERROR = /^(\d+):\d+\s+(?:error|Error:)\s+(.+)$/;
561
565
  /**
562
566
  * 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
567
+ * none. Located diagnostics (TypeScript first, then a generic file:line form, then
568
+ * ESLint's stylish error rows under their file header) are parsed into per-error
569
+ * findings, at most `limit` of them; if none parse, a single capped finding carries
565
570
  * the tail of the output so the fix stage still gets actionable signal. Never
566
571
  * throws; always emits at least one finding on a non-zero exit.
567
572
  */
568
- export function parseVerifyFailures(output, exitCode, label) {
573
+ export function parseVerifyFailures(output, exitCode, label, limit = MAX_LOCATED_FINDINGS) {
569
574
  if (exitCode === 0)
570
575
  return [];
571
576
  const located = [];
572
577
  const seen = new Set();
578
+ // ESLint's default (stylish) and `next lint` output name the file once, on
579
+ // its own line, above that file's `line:col error message rule` rows.
580
+ let stylishFile;
573
581
  for (const raw of output.split("\n")) {
574
582
  const line = raw.trim();
575
583
  if (!line)
576
584
  continue;
577
585
  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();
586
+ let parts = m ? [m[1], m[2], m[3]] : null;
587
+ if (!parts) {
588
+ if (STYLISH_FILE.test(line)) {
589
+ stylishFile = line;
590
+ continue;
591
+ }
592
+ const row = stylishFile ? line.match(STYLISH_ERROR) : null;
593
+ if (!row || !stylishFile)
594
+ continue;
595
+ parts = [stylishFile, row[1], row[2].replace(/\s{2,}/g, " ")];
596
+ }
597
+ const file = parts[0].trim();
598
+ const lineNo = Number(parts[1]);
599
+ const message = parts[2].trim();
583
600
  // Guard the generic matcher against noise: require a plausible file token.
584
601
  if (!file || /\s/.test(file) || !Number.isFinite(lineNo))
585
602
  continue;
@@ -594,7 +611,7 @@ export function parseVerifyFailures(output, exitCode, label) {
594
611
  file,
595
612
  line: lineNo,
596
613
  });
597
- if (located.length >= MAX_LOCATED_FINDINGS)
614
+ if (located.length >= limit)
598
615
  break;
599
616
  }
600
617
  if (located.length > 0)
@@ -651,6 +668,98 @@ function liftFinding(finding, repoRoot, commandCwd) {
651
668
  const file = toRepoRelativePath(finding.file, repoRoot, commandCwd);
652
669
  return file === finding.file ? finding : { ...finding, file };
653
670
  }
671
+ /** One command's identity across the baseline run and the build's runs: where it runs and what it runs. */
672
+ export function verifyCommandKey(command) {
673
+ return `${command.cwd}\u0000${command.argv.join(" ")}`;
674
+ }
675
+ /**
676
+ * One failure's key, the shape #2601's `failureKeyOf` gives a CI failure: the
677
+ * repo-relative file and the message, never the line, since a change above a
678
+ * failure moves it. A failure that is only the output's tail names nothing
679
+ * stable, so it has no key and is never excused. Pure.
680
+ */
681
+ export function verifyFailureKey(finding) {
682
+ if (finding.message.includes("Output tail:"))
683
+ return null;
684
+ return `${finding.file ?? ""}\u0000${finding.message}`;
685
+ }
686
+ /** How many times each failure key occurs. Pure. */
687
+ export function failureKeyCounts(findings) {
688
+ const counts = new Map();
689
+ for (const finding of findings) {
690
+ const key = verifyFailureKey(finding);
691
+ if (key !== null)
692
+ counts.set(key, (counts.get(key) ?? 0) + 1);
693
+ }
694
+ return counts;
695
+ }
696
+ /**
697
+ * The failures this change caused: each failure key is excused at most as many
698
+ * times as the clean tree had it, so a second copy of an old error is new. A
699
+ * failure in a file the build did not touch still counts when the baseline did
700
+ * not have it, since a change can break another file's types. No baseline for
701
+ * the command leaves every failure in place. Pure.
702
+ */
703
+ export function subtractBaseline(findings, baseline) {
704
+ if (!baseline)
705
+ return { findings: [...findings], excused: 0 };
706
+ const left = new Map(baseline);
707
+ const fresh = [];
708
+ let excused = 0;
709
+ for (const finding of findings) {
710
+ const key = verifyFailureKey(finding);
711
+ const count = key === null ? 0 : (left.get(key) ?? 0);
712
+ if (key !== null && count > 0) {
713
+ left.set(key, count - 1);
714
+ excused += 1;
715
+ }
716
+ else {
717
+ fresh.push(finding);
718
+ }
719
+ }
720
+ return { findings: fresh, excused };
721
+ }
722
+ /** The note for failures the clean tree already had, by command label. Pure. */
723
+ export function preexistingNote(byLabel) {
724
+ const total = [...byLabel.values()].reduce((sum, n) => sum + n, 0);
725
+ if (total === 0)
726
+ return null;
727
+ const parts = [...byLabel].filter(([, n]) => n > 0).map(([label, n]) => `${label} ${n}`);
728
+ return `already failed before this change: ${total} (${parts.join(", ")}); not findings`;
729
+ }
730
+ /** Cap on the paths read off a plan, and on the packages the baseline runs in. */
731
+ const MAX_PLAN_PATHS = 200;
732
+ const MAX_BASELINE_PACKAGES = 10;
733
+ /** A path-shaped token in plan prose: at least one `/`, no scheme. */
734
+ const PLAN_PATH = /(?:^|[\s`'"(<[])(\.?\/?[\w@.+-]+(?:\/[\w@.+[\]-]+)+\/?)/g;
735
+ /**
736
+ * The repo-relative paths an approved plan names (`src/flows/a.tsx`,
737
+ * `packages/web/`), which say which packages the build will change before it
738
+ * has changed anything. URLs and paths that leave the tree are dropped. Pure.
739
+ */
740
+ export function planPaths(plan) {
741
+ const paths = new Set();
742
+ for (const match of plan.matchAll(PLAN_PATH)) {
743
+ const raw = match[1].replace(/[.,:;]+$/, "").replace(/^\.\//, "");
744
+ if (!raw || raw.startsWith("/") || raw.startsWith("..") || raw.includes("://"))
745
+ continue;
746
+ paths.add(raw);
747
+ if (paths.size >= MAX_PLAN_PATHS)
748
+ break;
749
+ }
750
+ return [...paths];
751
+ }
752
+ /**
753
+ * The package dirs the baseline runs in: those the plan's paths fall in. The
754
+ * repo root drops out when a package below it was named, since its check is
755
+ * not one the build's gate would run then. Pure (the `readFile` seam).
756
+ */
757
+ export function baselineDirs(repoRoot, paths, readFile) {
758
+ const dirs = resolveClaimDirs(repoRoot, paths, readFile);
759
+ const root = normDir(repoRoot);
760
+ const below = dirs.filter((dir) => normDir(dir) !== root);
761
+ return (below.length > 0 ? below : dirs).slice(0, MAX_BASELINE_PACKAGES);
762
+ }
654
763
  const SAFE_ENV_KEYS = [
655
764
  "PATH",
656
765
  "HOME",
@@ -815,6 +924,13 @@ export function makeDefaultExec(opts = {}) {
815
924
  });
816
925
  }
817
926
  const defaultExec = makeDefaultExec();
927
+ /** The gate's typecheck-half detection, shared by the build's runs and the baseline run. */
928
+ const defaultDetect = (cwd, changedDirs) => detectVerifyCommands(cwd, {
929
+ allowRepoOverride: process.env.YAGNI_CODE_ALLOW_VERIFY_OVERRIDE === "1",
930
+ changedDirs,
931
+ });
932
+ /** The gate's test-half detection, shared the same way. */
933
+ const defaultDetectTests = (cwd, changedDirs) => detectVerifyTestCommands(cwd, { changedDirs });
818
934
  /**
819
935
  * A workstream's claims resolved to package dirs. Claims are PREFIXES and a claim
820
936
  * is very often a directory (`packages/backend/src/routes`), while
@@ -921,7 +1037,7 @@ export function makeWorkstreamCheck(opts = {}) {
921
1037
  anyRan = true;
922
1038
  labels.push(command.label);
923
1039
  if (run.code !== 0) {
924
- const parsed = parseVerifyFailures(run.output, run.code, command.label).map((f) => liftFinding(f, cwd, command.cwd));
1040
+ 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
1041
  const covered = parsed.filter((f) => f.file != null && target.files.some((claim) => claimCovers(claim, f.file)));
926
1042
  findings.push(...covered.map((f) => tagFinding(f, target.name)));
927
1043
  if (!uncoveredEmitted.has(memoKey)) {
@@ -952,13 +1068,8 @@ export function makeWorkstreamCheck(opts = {}) {
952
1068
  * blocks a real change on its own failure.
953
1069
  */
954
1070
  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 }));
1071
+ const detect = opts.detect ?? defaultDetect;
1072
+ const detectTests = opts.detectTests ?? defaultDetectTests;
962
1073
  const changedDirs = opts.changedDirs ??
963
1074
  (async (cwd, signal, baselinePaths) => {
964
1075
  const snapshot = await snapshotWorkspace(cwd, signal);
@@ -1274,6 +1385,12 @@ export function makeRunVerify(opts = {}) {
1274
1385
  const findings = [];
1275
1386
  const reasons = [];
1276
1387
  let anyRan = false;
1388
+ // Failures the clean tree already had, by command label (YAG-1003).
1389
+ const preexisting = new Map();
1390
+ const countPreexisting = (label, n) => {
1391
+ if (n > 0)
1392
+ preexisting.set(label, (preexisting.get(label) ?? 0) + n);
1393
+ };
1277
1394
  // TYPECHECK HALF (frozen behavior, §0.2): run every changed package's build
1278
1395
  // check; union the findings. One package that cannot spawn fails OPEN (a
1279
1396
  // recorded reason, no finding) without sinking the verdict for the packages
@@ -1298,7 +1415,10 @@ export function makeRunVerify(opts = {}) {
1298
1415
  anyRan = true;
1299
1416
  typecheckLabels.push(command.label);
1300
1417
  if (code !== 0) {
1301
- findings.push(...parseVerifyFailures(output, code, command.label).map((f) => liftFinding(f, cwd, command.cwd)));
1418
+ const parsed = parseVerifyFailures(output, code, command.label, Number.POSITIVE_INFINITY).map((f) => liftFinding(f, cwd, command.cwd));
1419
+ const fresh = subtractBaseline(parsed, opts.baseline?.get(verifyCommandKey(command)));
1420
+ countPreexisting(command.label, fresh.excused);
1421
+ findings.push(...fresh.findings.slice(0, MAX_LOCATED_FINDINGS));
1302
1422
  }
1303
1423
  }
1304
1424
  catch (err) {
@@ -1328,7 +1448,7 @@ export function makeRunVerify(opts = {}) {
1328
1448
  try {
1329
1449
  const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
1330
1450
  const output = scrubSecrets(rawOutput);
1331
- const cls = classifyTestFailure(output, code);
1451
+ const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
1332
1452
  if (cls.kind === "infra") {
1333
1453
  reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
1334
1454
  continue;
@@ -1343,7 +1463,9 @@ export function makeRunVerify(opts = {}) {
1343
1463
  testLabels.push(command.label);
1344
1464
  if (cls.kind === "failures") {
1345
1465
  const room = Math.max(0, MAX_LOCATED_FINDINGS - testFindingCount);
1346
- const take = cls.findings.slice(0, room).map((f) => liftFinding(f, cwd, command.cwd));
1466
+ const fresh = subtractBaseline(cls.findings.map((f) => liftFinding(f, cwd, command.cwd)), opts.baseline?.get(verifyCommandKey(command)));
1467
+ countPreexisting(command.label, fresh.excused);
1468
+ const take = fresh.findings.slice(0, room);
1347
1469
  testFindingCount += take.length;
1348
1470
  findings.push(...take);
1349
1471
  }
@@ -1378,6 +1500,10 @@ export function makeRunVerify(opts = {}) {
1378
1500
  findings.push(...repo.findings);
1379
1501
  }
1380
1502
  }
1503
+ const note = preexistingNote(preexisting);
1504
+ if (note)
1505
+ reasons.push(note);
1506
+ const preexistingTotal = [...preexisting.values()].reduce((sum, n) => sum + n, 0);
1381
1507
  // The command labels list only what produced a verdict — on every half —
1382
1508
  // so the FINISH trailer's `passed (<cmds>)` never claims a check that was
1383
1509
  // infra-skipped or threw (invariant §0.1).
@@ -1401,7 +1527,114 @@ export function makeRunVerify(opts = {}) {
1401
1527
  command,
1402
1528
  findings,
1403
1529
  ...(reasons.length ? { reason: reasons.join("; ") } : {}),
1530
+ ...(preexistingTotal > 0 ? { preexisting: preexistingTotal } : {}),
1404
1531
  };
1405
1532
  };
1406
1533
  }
1534
+ /** The whole baseline run's cap, before the build starts: it costs minutes at most. */
1535
+ export const VERIFY_BASELINE_BUDGET_MS = 10 * 60_000;
1536
+ /**
1537
+ * Run the gate's own typecheck and test commands ONCE on the clean tree,
1538
+ * before the build changes anything (YAG-1003), in the package dirs the
1539
+ * approved plan names, and keep each command's failure keys. The build's gate
1540
+ * then counts only the failures this change added ({@link subtractBaseline}).
1541
+ *
1542
+ * The same detection, exec seam and environments as {@link makeRunVerify}, so
1543
+ * a command's key and its failures' keys match the build's runs. Fail-open per
1544
+ * command: one that cannot run, times out, or is not reached in the budget
1545
+ * has no baseline, and its failures all count, as before. What the commands
1546
+ * write into the tree is undone, so none of it reaches the candidate.
1547
+ */
1548
+ export function makeTakeVerifyBaseline(opts = {}) {
1549
+ const detect = opts.detect ?? defaultDetect;
1550
+ const detectTests = opts.detectTests ?? defaultDetectTests;
1551
+ const exec = opts.exec ?? defaultExec;
1552
+ const now = opts.now ?? Date.now;
1553
+ const treeStatus = opts.treeStatus ??
1554
+ (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
1555
+ const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
1556
+ return async function takeVerifyBaseline(cwd, dirs, signal, budgetOverrideMs) {
1557
+ const baseline = new Map();
1558
+ const result = { baseline, ran: [], failures: 0, notes: [] };
1559
+ if (dirs.length === 0)
1560
+ return result;
1561
+ const commands = detect(cwd, dirs);
1562
+ const overridden = commands.some((c) => c.source === "override");
1563
+ const tests = overridden ? [] : detectTests(cwd, dirs);
1564
+ if (commands.length === 0 && tests.length === 0)
1565
+ return result;
1566
+ const startedAt = now();
1567
+ const budgetMs = Math.min(opts.budgetMs ?? VERIFY_BASELINE_BUDGET_MS, budgetOverrideMs ?? Number.POSITIVE_INFINITY);
1568
+ const before = await treeStatus(cwd, signal).catch((err) => {
1569
+ const msg = err instanceof Error ? err.message : String(err);
1570
+ result.notes.push(`could not read the tree before the baseline, so what it writes is not undone: ${scrubSecrets(msg)}`);
1571
+ return null;
1572
+ });
1573
+ const runs = [
1574
+ ...commands.map((command) => ({ command, test: false })),
1575
+ ...tests.map((command) => ({ command, test: true })),
1576
+ ];
1577
+ try {
1578
+ for (const { command, test } of runs) {
1579
+ if (signal?.aborted)
1580
+ break;
1581
+ const left = budgetMs - (now() - startedAt);
1582
+ if (left < REPO_CHECK_MIN_START_MS) {
1583
+ result.notes.push(`${command.label} (${command.cwd}): not reached in the baseline budget`);
1584
+ continue;
1585
+ }
1586
+ const cap = Math.min(test
1587
+ ? (opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS)
1588
+ : (opts.timeoutMs ?? VERIFY_TIMEOUT_MS), left);
1589
+ const composed = composeAbortSignal(signal, AbortSignal.timeout(cap));
1590
+ try {
1591
+ const { code, output: rawOutput } = test
1592
+ ? await exec(command.argv, command.cwd, composed, opts.testEnv ?? buildTestEnv())
1593
+ : await exec(command.argv, command.cwd, composed);
1594
+ const output = scrubSecrets(rawOutput);
1595
+ let failed;
1596
+ if (test) {
1597
+ const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
1598
+ if (cls.kind === "infra" || cls.kind === "uncertain") {
1599
+ result.notes.push(`${command.label} (${command.cwd}): ${cls.kind === "infra" ? cls.reason : "output not interpretable"}`);
1600
+ continue;
1601
+ }
1602
+ failed = cls.kind === "failures" ? cls.findings : [];
1603
+ }
1604
+ else {
1605
+ const infra = code === 0 ? null : classifyVerifyInfraFailure(output, code);
1606
+ if (infra) {
1607
+ result.notes.push(`${command.label} (${command.cwd}): ${infra}`);
1608
+ continue;
1609
+ }
1610
+ failed = parseVerifyFailures(output, code, command.label, Number.POSITIVE_INFINITY);
1611
+ }
1612
+ const counts = failureKeyCounts(failed.map((f) => liftFinding(f, cwd, command.cwd)));
1613
+ baseline.set(verifyCommandKey(command), counts);
1614
+ result.ran.push(command.label);
1615
+ for (const n of counts.values())
1616
+ result.failures += n;
1617
+ }
1618
+ catch (err) {
1619
+ const msg = err instanceof Error ? err.message : String(err);
1620
+ result.notes.push(`${command.label} (${command.cwd}): could not run: ${scrubSecrets(msg)}`);
1621
+ }
1622
+ }
1623
+ }
1624
+ finally {
1625
+ if (before !== null) {
1626
+ try {
1627
+ const written = newTreeEntries(before, await treeStatus(cwd));
1628
+ if (written.length > 0)
1629
+ await undoTreeWrites(cwd, written);
1630
+ }
1631
+ catch (err) {
1632
+ const msg = err instanceof Error ? err.message : String(err);
1633
+ result.notes.push(`could not undo what the baseline wrote into the tree: ${scrubSecrets(msg)}`);
1634
+ }
1635
+ }
1636
+ }
1637
+ return result;
1638
+ };
1639
+ }
1407
1640
  //# 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.1952.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": "b25d4f380aa71b29777808fe149c71d8d0e3c4ce"
62
62
  }