@yagni-app/code-staging 1.2.7-staging.2027.1 → 1.2.7-staging.2031.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.
@@ -496,8 +496,9 @@ export async function runHeadlessGo(argv, deps = {}) {
496
496
  }
497
497
  }
498
498
  }
499
- // The approved plan's checks the host has not seen pass on the unchanged
500
- // code (`measureOnBase`), each run once there before the build, on the same
499
+ // The approved checks the host has not seen pass on the unchanged code
500
+ // (`measureOnBase`: the plan's, and CI's when the repository's record has
501
+ // none of its own), each run once there before the build, on the same
501
502
  // fresh tree the baseline above reads: a check that already failed there is
502
503
  // not this change's to fix. A continuation or a fix round carries a change,
503
504
  // so it measures nothing; its host hands it what an earlier session found.
@@ -23,7 +23,8 @@ export interface MissionCheck {
23
23
  cause?: string;
24
24
  /**
25
25
  * The host has not seen it pass on the unchanged code (an approved plan's
26
- * check): the session runs it there once before the build, and what that
26
+ * check, or one CI runs that has no baseline): the session runs it there
27
+ * once before the build, and what that
27
28
  * measures stands over `status` (`makeMeasureChecksOnBase` in verify.ts).
28
29
  */
29
30
  measureOnBase?: boolean;
@@ -37,7 +37,10 @@
37
37
  * anything UNCERTAIN fail OPEN with an honest note, never a never-clearable
38
38
  * critical. A cloud session's gate (`unreadTestFailureIsFinding`) makes an
39
39
  * UNCERTAIN failure a finding carrying the output tail, unless the clean tree
40
- * failed the same command.
40
+ * failed the same command. On a build that named the repository's toolchain
41
+ * (`YAGNI_TOOLCHAIN_FILE`, a sandbox) both halves run on it, as the model's
42
+ * shell and the approved checks do ({@link buildGateVerifyEnv},
43
+ * {@link buildGateTestEnv}).
41
44
  *
42
45
  * Safe-by-default and FAIL-OPEN: if no scoped, non-fanned-out command is
43
46
  * detectable, or the command cannot be spawned, or it times out, the gate
@@ -258,15 +261,36 @@ export declare function detectVerifyTestCommands(cwd: string, opts?: DetectOpts)
258
261
  * verify failure.
259
262
  */
260
263
  export declare function classifyVerifyInfraFailure(output: string, exitCode: number): string | null;
264
+ /**
265
+ * What a run could not start without, when its output says so in a way the
266
+ * gate recognises: a credential or variable the environment lacks, or a
267
+ * prerequisite the sandbox has not installed. Null for anything else, which
268
+ * stays a real failure. Pure.
269
+ *
270
+ * Narrow on purpose, since a match makes the run "could not run" rather than
271
+ * the change's failure:
272
+ * - a named variable counts only when `env` (the environment the command ran
273
+ * under) does not have it set: one it has is not why the run failed, and
274
+ * no unnamed phrase then stands in for it;
275
+ * - a line that quotes a test's expectation or a diff never counts, nor
276
+ * one a logger wrote below error level;
277
+ * - a repository's own "`A_B` is missing" counts only on an error's line.
278
+ *
279
+ * Without `env`, a named variable is taken as unset.
280
+ */
281
+ export declare function classifyMissingPrerequisite(output: string, env?: NodeJS.ProcessEnv): string | null;
261
282
  /** How the pure test classifier read one test run (spec §3d). */
262
283
  export type TestFailureClassification = {
263
284
  kind: "clean";
264
285
  } | {
265
286
  kind: "failures";
266
287
  findings: Finding[];
267
- } | {
288
+ }
289
+ /** `needs`: the credential or prerequisite the run lacked ({@link classifyMissingPrerequisite}). */
290
+ | {
268
291
  kind: "infra";
269
292
  reason: string;
293
+ needs?: string;
270
294
  } | {
271
295
  kind: "uncertain";
272
296
  };
@@ -281,7 +305,9 @@ export type TestFailureClassification = {
281
305
  * - infra/env shapes (command not found, module resolution, connection
282
306
  * refused, missing env var, DB/auth, connect timeout) → `infra` with a short
283
307
  * cause; checked FIRST so a suite whose assertions failed only because its
284
- * DB was down fails open rather than blocking.
308
+ * DB was down fails open rather than blocking. A missing credential or
309
+ * prerequisite ({@link classifyMissingPrerequisite}, given the `env` the
310
+ * command ran under) is `infra` with `needs` and the cause "needs <it>".
285
311
  * - recognisable failing-test lines (pnpm-wrapped vitest / jest / node:test
286
312
  * TAP + spec reporters) → `failures` with located `critical` findings
287
313
  * (capped at 25, deduped, locations attached when the runner printed them).
@@ -293,7 +319,7 @@ export type TestFailureClassification = {
293
319
  * Callers scrub the output through {@link scrubSecrets} BEFORE classification
294
320
  * so findings/notes never carry secrets (§0.9).
295
321
  */
296
- export declare function classifyTestFailure(output: string, exitCode: number, limit?: number): TestFailureClassification;
322
+ export declare function classifyTestFailure(output: string, exitCode: number, limit?: number, env?: NodeJS.ProcessEnv): TestFailureClassification;
297
323
  /**
298
324
  * Turn a failing verify run into `critical` Findings. A clean (exit 0) run yields
299
325
  * none. Located diagnostics (TypeScript first, then a generic file:line form, then
@@ -394,6 +420,22 @@ export declare function buildTestEnv(source?: NodeJS.ProcessEnv): NodeJS.Process
394
420
  * own literal variables.
395
421
  */
396
422
  export declare function buildRepoCheckEnv(source?: NodeJS.ProcessEnv, toolchain?: ToolchainFile | null, stepEnv?: Record<string, string>): NodeJS.ProcessEnv;
423
+ /**
424
+ * The environment the gate's own detected test script runs under. On a build
425
+ * that named a toolchain (a sandbox), an approved check's
426
+ * ({@link buildRepoCheckEnv}), so the suite runs on the repository's Node,
427
+ * Ruby or Go as the model's shell does, never the image's, and gives the same
428
+ * verdict it gives as an approved check. Without one (a developer's terminal),
429
+ * the user's own ({@link buildTestEnv}), as before.
430
+ */
431
+ export declare function buildGateTestEnv(source?: NodeJS.ProcessEnv, toolchain?: ToolchainFile | null): NodeJS.ProcessEnv;
432
+ /**
433
+ * The environment the gate's own detected typecheck or lint script runs
434
+ * under: the scrubbed {@link buildVerifyEnv} on the build's toolchain, when it
435
+ * named one. Undefined without one, so the exec seam's default applies, as
436
+ * before.
437
+ */
438
+ export declare function buildGateVerifyEnv(source?: NodeJS.ProcessEnv, toolchain?: ToolchainFile | null): NodeJS.ProcessEnv | undefined;
397
439
  /** One porcelain line's path, and whether it is untracked. */
398
440
  interface TreeEntry {
399
441
  path: string;
@@ -431,7 +473,7 @@ export interface MakeRunVerifyOpts {
431
473
  detectTests?: (cwd: string, changedDirs: string[]) => VerifyCommand[];
432
474
  /** Per-package wall-clock cap for the test half (default VERIFY_TEST_TIMEOUT_MS). */
433
475
  testTimeoutMs?: number;
434
- /** Test-half env override for determinism in tests (default: buildTestEnv() per run). */
476
+ /** Test-half env override for determinism in tests (default: buildGateTestEnv() per run). */
435
477
  testEnv?: NodeJS.ProcessEnv;
436
478
  /**
437
479
  * The mission's `checks.json` (the approved checks and the pull request's CI
@@ -451,7 +493,11 @@ export interface MakeRunVerifyOpts {
451
493
  changedPaths?: (cwd: string, signal?: AbortSignal, baselinePaths?: string[]) => Promise<string[]>;
452
494
  /** Repository-checks env override for determinism in tests (default: buildRepoCheckEnv() per run). */
453
495
  repoChecksEnv?: NodeJS.ProcessEnv;
454
- /** The toolchain the repository checks run on (default: `YAGNI_TOOLCHAIN_FILE`, when it reads). */
496
+ /**
497
+ * The toolchain every command the gate runs is on: the repository checks,
498
+ * and its own detected typecheck and test scripts (default:
499
+ * `YAGNI_TOOLCHAIN_FILE`, when it reads).
500
+ */
455
501
  toolchain?: () => ToolchainFile | null;
456
502
  repoChecksBuildBudgetMs?: number;
457
503
  repoChecksCallBudgetMs?: number;
@@ -529,6 +575,11 @@ export interface MakeWorkstreamCheckOpts {
529
575
  timeoutMs?: number;
530
576
  /** What the clean tree already failed on (YAG-1003): those failures are not a workstream's. */
531
577
  baseline?: VerifyBaseline | null;
578
+ /**
579
+ * The toolchain the checks run on, as the full gate's do (default:
580
+ * `YAGNI_TOOLCHAIN_FILE`, when it reads).
581
+ */
582
+ toolchain?: () => ToolchainFile | null;
532
583
  }
533
584
  /**
534
585
  * The per-workstream scoped check the implement diamond runs right after the fan
@@ -575,6 +626,11 @@ export interface MakeTakeVerifyBaselineOpts {
575
626
  timeoutMs?: number;
576
627
  testTimeoutMs?: number;
577
628
  testEnv?: NodeJS.ProcessEnv;
629
+ /**
630
+ * The toolchain the commands run on, as the gate's do (default:
631
+ * `YAGNI_TOOLCHAIN_FILE`, when it reads).
632
+ */
633
+ toolchain?: () => ToolchainFile | null;
578
634
  budgetMs?: number;
579
635
  now?: () => number;
580
636
  treeStatus?: (cwd: string, signal?: AbortSignal) => Promise<string>;
@@ -625,7 +681,10 @@ export interface ChecksOnBaseResult extends ChecksOnBase {
625
681
  */
626
682
  treeUndone: boolean;
627
683
  }
628
- /** The whole measurement's cap, before the build starts: the approved plan's own checks, once each. */
684
+ /**
685
+ * The whole measurement's cap, before the build starts: each check marked to
686
+ * measure, once, the plan's first.
687
+ */
629
688
  export declare const CHECKS_ON_BASE_BUDGET_MS: number;
630
689
  export interface MakeMeasureChecksOnBaseOpts {
631
690
  exec?: VerifyExec;
@@ -643,8 +702,9 @@ export interface MakeMeasureChecksOnBaseOpts {
643
702
  * Run each approved check `checks.json` marks `measureOnBase` once on the
644
703
  * unchanged code, before the build changes anything: a plan's check the
645
704
  * host has not seen pass there (none of the record's, or a red one the
646
- * record may hold from long ago). A check the plan wrote for the change
647
- * often fails there by design (its test file is not written yet), and a
705
+ * record may hold from long ago), and a command the repository's CI runs
706
+ * when the record names no check of its own. A check the plan wrote for the
707
+ * change often fails there by design (its test file is not written yet), and a
648
708
  * failure that says only that (a file not found, no tests to run, output no
649
709
  * parser reads) is no verdict: its failures all count after the build, as
650
710
  * before. A failure the gate reads (failing tests, located diagnostics, a
@@ -37,7 +37,10 @@
37
37
  * anything UNCERTAIN fail OPEN with an honest note, never a never-clearable
38
38
  * critical. A cloud session's gate (`unreadTestFailureIsFinding`) makes an
39
39
  * UNCERTAIN failure a finding carrying the output tail, unless the clean tree
40
- * failed the same command.
40
+ * failed the same command. On a build that named the repository's toolchain
41
+ * (`YAGNI_TOOLCHAIN_FILE`, a sandbox) both halves run on it, as the model's
42
+ * shell and the approved checks do ({@link buildGateVerifyEnv},
43
+ * {@link buildGateTestEnv}).
41
44
  *
42
45
  * Safe-by-default and FAIL-OPEN: if no scoped, non-fanned-out command is
43
46
  * detectable, or the command cannot be spawned, or it times out, the gate
@@ -509,18 +512,6 @@ const TEST_INFRA_PATTERNS = [
509
512
  [/\bENOTFOUND\b|\bEAI_AGAIN\b/, "DNS/host resolution failed for a test dependency"],
510
513
  [/\bEADDRINUSE\b/, "a port the tests need is already in use"],
511
514
  [/\bETIMEDOUT\b|\bconnect(?:ion)? timed out\b/i, "connection to a test dependency timed out"],
512
- [
513
- /\bmissing (?:required )?env(?:ironment)? (?:var(?:iable)?s?)\b/i,
514
- "missing environment variable",
515
- ],
516
- [
517
- /\benvironment variable\b[^\n]{0,80}\b(?:is )?(?:not set|missing|required|must be set)\b/i,
518
- "missing environment variable",
519
- ],
520
- [/\bprocess\.env\.[A-Z0-9_]+ is (?:undefined|not set)\b/, "missing environment variable"],
521
- // Yarn Berry reading a `${TOKEN}` in .yarnrc.yml that the environment lacks
522
- // (staging bench 2026-10-23: GITHUB_PACKAGES_TOKEN): the suite never started.
523
- [/\bEnvironment variable not found \([A-Za-z0-9_]+\)/, "missing environment variable"],
524
515
  [/\bSASL\b|\bSCRAM-SERVER\b/, "database auth failed (missing DB credentials?)"],
525
516
  [/\bpassword authentication failed\b/i, "database auth failed"],
526
517
  [/\brole "[^"]*" does not exist\b/i, "database role does not exist"],
@@ -537,6 +528,126 @@ const TEST_INFRA_PATTERNS = [
537
528
  [/\bCannot connect to the Docker daemon\b/i, "Docker is not running"],
538
529
  [/\bno space left on device\b/i, "the disk is full"],
539
530
  ];
531
+ /**
532
+ * A variable's name as an output names it: `SUPABASE_SECRET_KEY`, `PORT`.
533
+ * Two characters at least and upper case only, on purpose: a single letter or
534
+ * a lower-case word beside "is missing" is prose, and reading it as a
535
+ * variable would make a real failure "could not run".
536
+ */
537
+ const VAR_NAME = "[A-Z][A-Z0-9_]*[A-Z0-9]";
538
+ /** Words a missing variable is said with after its name. */
539
+ const UNSET_WORDS = "(?:is |was )?(?:not set|not defined|missing|required|undefined|empty|must be set)";
540
+ const ENV_VARIABLE = "[Ee]nv(?:ironment)?[ _-]?[Vv]ar(?:iable)?";
541
+ /**
542
+ * The ways a run's output says it lacks an environment variable, each with the
543
+ * name in its first group. Each names the variable as one: a phrase about an
544
+ * environment variable around it, or `process.env.` before it.
545
+ */
546
+ const NAMED_MISSING_VARIABLE = [
547
+ // "Missing required environment variable: SUPABASE_SECRET_KEY", "missing env var FOO"
548
+ new RegExp(`\\b[Mm]issing (?:[Rr]equired )?${ENV_VARIABLE}s?\\b[\\s:=\`'"([{]*(${VAR_NAME})\\b`, "g"),
549
+ // "Environment variable SUPABASE_URL is not set"
550
+ new RegExp(`\\b${ENV_VARIABLE} [\`'"]?(${VAR_NAME})[\`'"]? ${UNSET_WORDS}\\b`, "g"),
551
+ // "SUPABASE_URL environment variable is not set"
552
+ new RegExp(`\\b(${VAR_NAME}) ${ENV_VARIABLE} ${UNSET_WORDS}\\b`, "g"),
553
+ // "process.env.FOO is undefined"
554
+ new RegExp(`\\bprocess\\.env\\.(${VAR_NAME}) ${UNSET_WORDS}\\b`, "g"),
555
+ // Yarn Berry reading a `${TOKEN}` in .yarnrc.yml that the environment lacks
556
+ // (staging bench 2026-10-23: GITHUB_PACKAGES_TOKEN): the suite never started.
557
+ /\bEnvironment variable not found \(([A-Za-z_][A-Za-z0-9_]*)\)/g,
558
+ // django-environ: "ImproperlyConfigured: Set the SECRET_KEY environment variable"
559
+ new RegExp(`\\bSet the (${VAR_NAME}) environment variable\\b`, "g"),
560
+ ];
561
+ /**
562
+ * A repository's own check of its environment, on a line that reads as the
563
+ * error the run stopped on (staging 2026-10-24, NearMyTown's e2e:
564
+ * "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY is missing"). The name must look like
565
+ * a variable's (`A_B`), and the line an error's: a log line that a variable is
566
+ * missing is not why a run failed. Never "is not defined" or "is undefined":
567
+ * that is JavaScript's word for a name the code never declared.
568
+ */
569
+ const BARE_MISSING_VARIABLE = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+) (?:is |was )?(?:missing|not set|must be set)\b/g;
570
+ const ERROR_LINE = /(?:Error|Exception)\b|^\s*(?:error|ERROR|fatal|FATAL)\b/;
571
+ /** At most this many missing variables are named in one cause. */
572
+ const MAX_NAMED_VARIABLES = 3;
573
+ /**
574
+ * A line that quotes what a test expected or received, or a diff of it: a
575
+ * test of an environment check, never the run's own missing variable.
576
+ */
577
+ const QUOTED_LINE = /^\s*[-+]\s|\b(?:[Ee]xpected|[Rr]eceived|toThrow|[Aa]ssert)/;
578
+ /**
579
+ * A line a logger wrote below error level (`warn:`, `[info]`, `DEBUG`,
580
+ * Next.js's `⚠`): a variable it says is missing is one the run went on
581
+ * without, never why it failed.
582
+ */
583
+ const LOG_LINE = /^\s*(?:⚠|[[(]?(?:warn(?:ing)?|info|debug|trace|notice)\b)/i;
584
+ /** Frameworks that say which variable they lack only by what it is for. */
585
+ const MISSING_FRAMEWORK_KEY = [
586
+ // Rails credentials: "Missing encryption key to decrypt file with. ... ENV["RAILS_MASTER_KEY"]"
587
+ [/\bMissing encryption key to decrypt file with\b/, "RAILS_MASTER_KEY"],
588
+ // Laravel's MissingAppKeyException
589
+ [/\bNo application encryption key has been specified\b/, "APP_KEY"],
590
+ ];
591
+ /** Phrases that say a variable is missing without naming it. */
592
+ const UNNAMED_MISSING_VARIABLE = [
593
+ /\bmissing (?:required )?env(?:ironment)? (?:var(?:iable)?s?)\b/i,
594
+ /\benvironment variable\b[^\n]{0,80}\b(?:is )?(?:not set|missing|required|must be set)\b/i,
595
+ ];
596
+ /**
597
+ * What a run could not start without, when its output says so in a way the
598
+ * gate recognises: a credential or variable the environment lacks, or a
599
+ * prerequisite the sandbox has not installed. Null for anything else, which
600
+ * stays a real failure. Pure.
601
+ *
602
+ * Narrow on purpose, since a match makes the run "could not run" rather than
603
+ * the change's failure:
604
+ * - a named variable counts only when `env` (the environment the command ran
605
+ * under) does not have it set: one it has is not why the run failed, and
606
+ * no unnamed phrase then stands in for it;
607
+ * - a line that quotes a test's expectation or a diff never counts, nor
608
+ * one a logger wrote below error level;
609
+ * - a repository's own "`A_B` is missing" counts only on an error's line.
610
+ *
611
+ * Without `env`, a named variable is taken as unset.
612
+ */
613
+ export function classifyMissingPrerequisite(output, env) {
614
+ const unset = (name) => !env?.[name];
615
+ const lines = output
616
+ .split("\n")
617
+ .filter((line) => !QUOTED_LINE.test(line) && !LOG_LINE.test(line));
618
+ const named = new Set(lines.flatMap((line) => [
619
+ ...NAMED_MISSING_VARIABLE.flatMap((re) => [...line.matchAll(re)]),
620
+ ...(ERROR_LINE.test(line) ? [...line.matchAll(BARE_MISSING_VARIABLE)] : []),
621
+ ].map((match) => match[1])));
622
+ const missing = [...named].filter(unset);
623
+ if (missing.length > 0) {
624
+ const more = missing.length - MAX_NAMED_VARIABLES;
625
+ return `${missing.slice(0, MAX_NAMED_VARIABLES).join(", ")}${more > 0 ? ` and ${more} more` : ""}`;
626
+ }
627
+ const text = lines.join("\n");
628
+ for (const [re, name] of MISSING_FRAMEWORK_KEY) {
629
+ if (re.test(text) && unset(name))
630
+ return name;
631
+ }
632
+ // supabase-js `createClient` with no URL or key: the variable it was read from is unset.
633
+ const supabase = text.match(/\bsupabase(Url|Key) is required\b/);
634
+ if (supabase) {
635
+ return `a Supabase ${supabase[1] === "Url" ? "URL" : "key"} (supabase${supabase[1]} is required)`;
636
+ }
637
+ // Playwright with no browser of its own version (a prepare installs one; #2758).
638
+ if (/\bExecutable doesn't exist at /.test(text) &&
639
+ /\bplaywright install\b|\bbrowserType\.launch\b|\bms-playwright\b/.test(text)) {
640
+ return "Playwright's browsers (npx playwright install)";
641
+ }
642
+ // A variable the output names that the environment has is not missing, and
643
+ // no unnamed phrase stands in for it. A log line is not read at all, so one
644
+ // that names a variable never stops an error's own unnamed phrase from
645
+ // counting: that error says the run lacked a variable, only not which.
646
+ if (named.size === 0 && UNNAMED_MISSING_VARIABLE.some((re) => re.test(text))) {
647
+ return "an environment variable the output does not name";
648
+ }
649
+ return null;
650
+ }
540
651
  /** Strip a trailing `(4 ms)` / `(0.82ms)` duration off a failing-test name. */
541
652
  function stripDuration(name) {
542
653
  return name.replace(/\s*\(\d+(?:\.\d+)?\s*m?s\)\s*$/, "").trim();
@@ -568,7 +679,9 @@ const FAILURE_SUMMARY = [
568
679
  * - infra/env shapes (command not found, module resolution, connection
569
680
  * refused, missing env var, DB/auth, connect timeout) → `infra` with a short
570
681
  * cause; checked FIRST so a suite whose assertions failed only because its
571
- * DB was down fails open rather than blocking.
682
+ * DB was down fails open rather than blocking. A missing credential or
683
+ * prerequisite ({@link classifyMissingPrerequisite}, given the `env` the
684
+ * command ran under) is `infra` with `needs` and the cause "needs <it>".
572
685
  * - recognisable failing-test lines (pnpm-wrapped vitest / jest / node:test
573
686
  * TAP + spec reporters) → `failures` with located `critical` findings
574
687
  * (capped at 25, deduped, locations attached when the runner printed them).
@@ -580,12 +693,15 @@ const FAILURE_SUMMARY = [
580
693
  * Callers scrub the output through {@link scrubSecrets} BEFORE classification
581
694
  * so findings/notes never carry secrets (§0.9).
582
695
  */
583
- export function classifyTestFailure(output, exitCode, limit = MAX_LOCATED_FINDINGS) {
696
+ export function classifyTestFailure(output, exitCode, limit = MAX_LOCATED_FINDINGS, env) {
584
697
  if (exitCode === 0)
585
698
  return { kind: "clean" };
586
699
  const spawnInfra = classifyVerifyInfraFailure(output, exitCode);
587
700
  if (spawnInfra)
588
701
  return { kind: "infra", reason: spawnInfra };
702
+ const needs = classifyMissingPrerequisite(output, env);
703
+ if (needs)
704
+ return { kind: "infra", reason: `needs ${needs}`, needs };
589
705
  for (const [re, reason] of TEST_INFRA_PATTERNS) {
590
706
  if (re.test(output))
591
707
  return { kind: "infra", reason };
@@ -951,8 +1067,40 @@ export function buildRepoCheckEnv(source = process.env, toolchain = null, stepEn
951
1067
  const env = buildTestEnv(source);
952
1068
  for (const key of REPO_CHECK_SECRET_KEYS)
953
1069
  delete env[key];
954
- const withToolchain = toolchain ? { ...toolchain.env, ...env, PATH: toolchain.path } : env;
955
- return { ...withToolchain, ...stepEnv, CI: "true" };
1070
+ return { ...onToolchain(env, toolchain), ...stepEnv, CI: "true" };
1071
+ }
1072
+ /**
1073
+ * `env` on the build's toolchain, as the model's shell gets it
1074
+ * (`makeToolchainSpawnHook`): the toolchain's PATH, and its other variables
1075
+ * beneath `env`'s own. No toolchain: `env` as it is.
1076
+ */
1077
+ function onToolchain(env, toolchain) {
1078
+ return toolchain ? { ...toolchain.env, ...env, PATH: toolchain.path } : env;
1079
+ }
1080
+ /**
1081
+ * The environment the gate's own detected test script runs under. On a build
1082
+ * that named a toolchain (a sandbox), an approved check's
1083
+ * ({@link buildRepoCheckEnv}), so the suite runs on the repository's Node,
1084
+ * Ruby or Go as the model's shell does, never the image's, and gives the same
1085
+ * verdict it gives as an approved check. Without one (a developer's terminal),
1086
+ * the user's own ({@link buildTestEnv}), as before.
1087
+ */
1088
+ export function buildGateTestEnv(source = process.env, toolchain = null) {
1089
+ return toolchain ? buildRepoCheckEnv(source, toolchain) : buildTestEnv(source);
1090
+ }
1091
+ /**
1092
+ * The environment the gate's own detected typecheck or lint script runs
1093
+ * under: the scrubbed {@link buildVerifyEnv} on the build's toolchain, when it
1094
+ * named one. Undefined without one, so the exec seam's default applies, as
1095
+ * before.
1096
+ */
1097
+ export function buildGateVerifyEnv(source = process.env, toolchain = null) {
1098
+ return toolchain ? onToolchain(buildVerifyEnv(source), toolchain) : undefined;
1099
+ }
1100
+ /** The toolchain the session's harness named (`YAGNI_TOOLCHAIN_FILE`), when it reads. */
1101
+ function sessionToolchain() {
1102
+ const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
1103
+ return path ? readToolchainFile(path) : null;
956
1104
  }
957
1105
  /** One repository command as the gate runs it: through bash, as Actions runs a `run:` step. */
958
1106
  function repoCheckArgv(run) {
@@ -1111,6 +1259,7 @@ export function makeWorkstreamCheck(opts = {}) {
1111
1259
  // command, untagged, by whichever target reaches the command first.
1112
1260
  const uncoveredEmitted = new Set();
1113
1261
  const results = [];
1262
+ const env = buildGateVerifyEnv(process.env, (opts.toolchain ?? sessionToolchain)());
1114
1263
  for (const target of targets) {
1115
1264
  const dirs = claimDirs(cwd, target.files);
1116
1265
  const commands = dirs.length > 0 ? detect(cwd, dirs).filter((c) => dirs.includes(c.cwd)) : [];
@@ -1134,7 +1283,7 @@ export function makeWorkstreamCheck(opts = {}) {
1134
1283
  if (!run) {
1135
1284
  const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
1136
1285
  try {
1137
- const { code, output } = await exec(command.argv, command.cwd, composed);
1286
+ const { code, output } = await exec(command.argv, command.cwd, composed, env);
1138
1287
  run = { code, output: scrubSecrets(output) };
1139
1288
  }
1140
1289
  catch (err) {
@@ -1185,7 +1334,7 @@ export function makeWorkstreamCheck(opts = {}) {
1185
1334
  */
1186
1335
  function repoCommandFindings(failure, at) {
1187
1336
  const limit = at.limit ?? MAX_LOCATED_FINDINGS;
1188
- const cls = classifyTestFailure(failure.output, failure.code, limit);
1337
+ const cls = classifyTestFailure(failure.output, failure.code, limit, failure.env);
1189
1338
  const parsed = cls.kind === "failures"
1190
1339
  ? cls.findings
1191
1340
  : parseVerifyFailures(failure.output, failure.code, at.label, limit);
@@ -1243,11 +1392,7 @@ export function makeRunVerify(opts = {}) {
1243
1392
  const baseline = new Set(baselinePaths ?? []);
1244
1393
  return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
1245
1394
  });
1246
- const toolchain = opts.toolchain ??
1247
- (() => {
1248
- const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
1249
- return path ? readToolchainFile(path) : null;
1250
- });
1395
+ const toolchain = opts.toolchain ?? sessionToolchain;
1251
1396
  const treeStatus = opts.treeStatus ??
1252
1397
  (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
1253
1398
  const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
@@ -1346,7 +1491,7 @@ export function makeRunVerify(opts = {}) {
1346
1491
  cwd,
1347
1492
  signal,
1348
1493
  left: () => callBudget - (now() - halfStartedAt),
1349
- toolchainFile: toolchain(),
1494
+ toolchainFile: input.toolchainFile,
1350
1495
  findings,
1351
1496
  reasons,
1352
1497
  labels,
@@ -1389,7 +1534,15 @@ export function makeRunVerify(opts = {}) {
1389
1534
  }
1390
1535
  return { findings, reasons, labels, checks: [...unrunnable, ...run.checks] };
1391
1536
  }
1392
- /** One repository command's verdict: passed, a note (it could not run), or a failure with its output. */
1537
+ /**
1538
+ * One repository command's verdict: passed, a note (it could not run), or a
1539
+ * failure with its output. A check that passed on the unchanged code
1540
+ * (`passedOnBase`, measured in this environment) and now says it lacks a
1541
+ * credential failed: the change made it need one, and that is the change's
1542
+ * (`needs` says what). Only the session that measured knows it passed: a
1543
+ * resumed session's host carries forward what failed on the base, never
1544
+ * what passed, so there the same check is could not run, failing open.
1545
+ */
1393
1546
  async function runRepoCommand(run, command, dir, stepEnv, check) {
1394
1547
  const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
1395
1548
  const composed = composeAbortSignal(run.signal, AbortSignal.timeout(Math.max(1, capMs)));
@@ -1417,13 +1570,20 @@ export function makeRunVerify(opts = {}) {
1417
1570
  return { kind: "passed" };
1418
1571
  }
1419
1572
  const output = scrubSecrets(rawOutput);
1420
- const cls = classifyTestFailure(output, code);
1421
- if (cls.kind === "infra") {
1573
+ const cls = classifyTestFailure(output, code, MAX_LOCATED_FINDINGS, env);
1574
+ if (cls.kind === "infra" && !(cls.needs && check.passedOnBase)) {
1422
1575
  const reason = `could not run: ${cls.reason}`;
1423
1576
  record("cannot_run", `${reason}; ${exitSummary(check.name, code, output)}`);
1424
1577
  return { kind: "note", reason };
1425
1578
  }
1426
- return { kind: "failed", output, code, record: record(check.baseline) };
1579
+ return {
1580
+ kind: "failed",
1581
+ output,
1582
+ code,
1583
+ env,
1584
+ record: record(check.baseline),
1585
+ ...(cls.kind === "infra" && cls.needs ? { needs: cls.needs } : {}),
1586
+ };
1427
1587
  }
1428
1588
  catch (err) {
1429
1589
  run.checks.push({
@@ -1470,9 +1630,13 @@ export function makeRunVerify(opts = {}) {
1470
1630
  const verdict = await runRepoCommand(run, check.command, run.cwd, undefined, {
1471
1631
  name: label,
1472
1632
  baseline: status,
1633
+ passedOnBase: opts.checksOnBase?.passed.has(check.command.trim()) ?? false,
1473
1634
  });
1474
1635
  if (verdict.kind === "failed")
1475
1636
  run.approvedFailed.add(canonicalCheckCommand(check.command));
1637
+ if (verdict.kind === "failed" && verdict.needs) {
1638
+ run.reasons.push(`approved check \`${label}\` passed on the unchanged code and now needs ${verdict.needs}: the change's failure`);
1639
+ }
1476
1640
  if (verdict.kind === "note") {
1477
1641
  run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
1478
1642
  }
@@ -1612,6 +1776,8 @@ export function makeRunVerify(opts = {}) {
1612
1776
  const findings = [];
1613
1777
  const reasons = [];
1614
1778
  let anyRan = false;
1779
+ // Every half runs on the build's toolchain, as the model's shell does.
1780
+ const toolchainFile = toolchain();
1615
1781
  // Failures the clean tree already had, by command label (YAG-1003).
1616
1782
  const preexisting = new Map();
1617
1783
  const countPreexisting = (label, n) => {
@@ -1638,11 +1804,12 @@ export function makeRunVerify(opts = {}) {
1638
1804
  // recorded reason, no finding) without sinking the verdict for the packages
1639
1805
  // that did run. Output is scrubbed before it can enter a finding (§0.9).
1640
1806
  const typecheckLabels = [];
1807
+ const typecheckEnv = buildGateVerifyEnv(process.env, toolchainFile);
1641
1808
  for (const command of commands) {
1642
1809
  const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
1643
1810
  const startedAt = now();
1644
1811
  try {
1645
- const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed);
1812
+ const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, typecheckEnv);
1646
1813
  const output = scrubSecrets(rawOutput);
1647
1814
  if (code !== 0) {
1648
1815
  // Infra/env failure (command not found in a fresh worktree, Node loader
@@ -1686,7 +1853,7 @@ export function makeRunVerify(opts = {}) {
1686
1853
  reasons.push("tests skipped: the typecheck half failed; 'clean' still requires a later round's tests");
1687
1854
  }
1688
1855
  else {
1689
- const testEnv = opts.testEnv ?? buildTestEnv();
1856
+ const testEnv = opts.testEnv ?? buildGateTestEnv(process.env, toolchainFile);
1690
1857
  let testFindingCount = 0;
1691
1858
  // A root test script an approved check runs too (`yarn run test`
1692
1859
  // beside `yarn test`) runs once, as that check, on the repository's
@@ -1707,7 +1874,7 @@ export function makeRunVerify(opts = {}) {
1707
1874
  try {
1708
1875
  const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, testEnv);
1709
1876
  const output = scrubSecrets(rawOutput);
1710
- const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
1877
+ const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY, testEnv);
1711
1878
  if (cls.kind === "infra") {
1712
1879
  recordCommand(command, null, startedAt, "cannot_run", `could not run: ${cls.reason}; ${exitSummary(command.label, code, output)}`);
1713
1880
  reasons.push(`tests (${where}) could not run: ${cls.reason}; 'clean' reflects typecheck + review only`);
@@ -1772,6 +1939,7 @@ export function makeRunVerify(opts = {}) {
1772
1939
  signal,
1773
1940
  baselinePaths,
1774
1941
  callStartedAt,
1942
+ toolchainFile,
1775
1943
  });
1776
1944
  reasons.push(...repo.reasons);
1777
1945
  repoLabels.push(...repo.labels);
@@ -1879,6 +2047,10 @@ export function makeTakeVerifyBaseline(opts = {}) {
1879
2047
  ...commands.map((command) => ({ command, test: false })),
1880
2048
  ...tests.map((command) => ({ command, test: true })),
1881
2049
  ];
2050
+ // The gate's environments, so a failure keys the same on the clean tree and the build's.
2051
+ const toolchainFile = (opts.toolchain ?? sessionToolchain)();
2052
+ const verifyEnv = buildGateVerifyEnv(process.env, toolchainFile);
2053
+ const testEnv = opts.testEnv ?? buildGateTestEnv(process.env, toolchainFile);
1882
2054
  try {
1883
2055
  for (const { command, test } of runs) {
1884
2056
  if (signal?.aborted)
@@ -1893,13 +2065,11 @@ export function makeTakeVerifyBaseline(opts = {}) {
1893
2065
  : (opts.timeoutMs ?? VERIFY_TIMEOUT_MS), left);
1894
2066
  const composed = composeAbortSignal(signal, AbortSignal.timeout(cap));
1895
2067
  try {
1896
- const { code, output: rawOutput } = test
1897
- ? await exec(command.argv, command.cwd, composed, opts.testEnv ?? buildTestEnv())
1898
- : await exec(command.argv, command.cwd, composed);
2068
+ const { code, output: rawOutput } = await exec(command.argv, command.cwd, composed, test ? testEnv : verifyEnv);
1899
2069
  const output = scrubSecrets(rawOutput);
1900
2070
  let failed;
1901
2071
  if (test) {
1902
- const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
2072
+ const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY, testEnv);
1903
2073
  if (cls.kind === "infra" || cls.kind === "uncertain") {
1904
2074
  result.notes.push(`${command.label} (${command.cwd}): ${cls.kind === "infra" ? cls.reason : "output not interpretable"}`);
1905
2075
  if (cls.kind === "uncertain") {
@@ -1945,14 +2115,18 @@ export function makeTakeVerifyBaseline(opts = {}) {
1945
2115
  return result;
1946
2116
  };
1947
2117
  }
1948
- /** The whole measurement's cap, before the build starts: the approved plan's own checks, once each. */
2118
+ /**
2119
+ * The whole measurement's cap, before the build starts: each check marked to
2120
+ * measure, once, the plan's first.
2121
+ */
1949
2122
  export const CHECKS_ON_BASE_BUDGET_MS = 10 * 60_000;
1950
2123
  /**
1951
2124
  * Run each approved check `checks.json` marks `measureOnBase` once on the
1952
2125
  * unchanged code, before the build changes anything: a plan's check the
1953
2126
  * host has not seen pass there (none of the record's, or a red one the
1954
- * record may hold from long ago). A check the plan wrote for the change
1955
- * often fails there by design (its test file is not written yet), and a
2127
+ * record may hold from long ago), and a command the repository's CI runs
2128
+ * when the record names no check of its own. A check the plan wrote for the
2129
+ * change often fails there by design (its test file is not written yet), and a
1956
2130
  * failure that says only that (a file not found, no tests to run, output no
1957
2131
  * parser reads) is no verdict: its failures all count after the build, as
1958
2132
  * before. A failure the gate reads (failing tests, located diagnostics, a
@@ -1967,11 +2141,7 @@ export const CHECKS_ON_BASE_BUDGET_MS = 10 * 60_000;
1967
2141
  export function makeMeasureChecksOnBase(opts = {}) {
1968
2142
  const exec = opts.exec ?? defaultExec;
1969
2143
  const now = opts.now ?? Date.now;
1970
- const toolchain = opts.toolchain ??
1971
- (() => {
1972
- const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
1973
- return path ? readToolchainFile(path) : null;
1974
- });
2144
+ const toolchain = opts.toolchain ?? sessionToolchain;
1975
2145
  const treeStatus = opts.treeStatus ??
1976
2146
  (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
1977
2147
  const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
@@ -2013,12 +2183,12 @@ export function makeMeasureChecksOnBase(opts = {}) {
2013
2183
  continue;
2014
2184
  }
2015
2185
  const output = scrubSecrets(rawOutput);
2016
- const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY);
2186
+ const cls = classifyTestFailure(output, code, Number.POSITIVE_INFINITY, env);
2017
2187
  if (cls.kind === "infra") {
2018
2188
  result.notes.push(`\`${label}\`: could not run: ${cls.reason}`);
2019
2189
  continue;
2020
2190
  }
2021
- const found = repoCommandFindings({ output, code }, {
2191
+ const found = repoCommandFindings({ output, code, env }, {
2022
2192
  label,
2023
2193
  prefix: approvedPrefix(label),
2024
2194
  repoRoot: cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.7-staging.2027.1",
3
+ "version": "1.2.7-staging.2031.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": "1dd7310c6a9ff69891182d936aa792a4d375970b"
61
+ "yagniSourceSha": "38e1c8e1dced29e7f90bc2502b9aaef9fa7415db"
62
62
  }