diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 72650ccb4..531c1d814 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3081,10 +3081,10 @@ network or quota error before any test ran, or a full disk is [`PREGATE_ENVIRONMENT`](#pregate-environment) instead, so the machine's failure reads apart from the diff's. -**Fix.** Pass `--allow-repo-commands`, install the tool (for a Dart package the -message names `not on PATH: dart` or `flutter`; fvm also works when the repo pins -it), commit the `pubspec.yaml` of a Dart package the message says has none (or -declare `runner`), raise `--timeout`, or keep the tree still, then rerun. +**Fix.** Pass `--allow-repo-commands`, commit the `pubspec.yaml` of a Dart +package the message says has none (or declare `runner`), raise `--timeout`, or +keep the tree still, then rerun. A tool missing from PATH is +[`PREGATE_ENVIRONMENT`](#pregate-environment), with its own fix. ### `PREGATE_STALLED` diff --git a/lib/pregate/allowlist.ts b/lib/pregate/allowlist.ts index 50a6d3a42..d668eb41b 100644 --- a/lib/pregate/allowlist.ts +++ b/lib/pregate/allowlist.ts @@ -9,8 +9,8 @@ * against the list: validation refuses an entry naming it, and this matcher * keeps such a finding whatever the entries say (#3449). */ -import { createHash } from 'node:crypto'; import { matchesAny } from '../ship-policy'; +import { sha256 } from './context'; import { ALLOWLISTABLE_CATEGORIES, NON_ALLOWLISTABLE, type AllowlistEntry } from './config'; export interface AllowlistFinding { @@ -49,9 +49,7 @@ export function applyAllowlist(findings: readonly F[ } /** sha256 of the entries, for the check's `inputs.allowlist`. */ -export function allowlistDigest(entries: readonly AllowlistEntry[]): string { - return createHash('sha256').update(JSON.stringify(entries)).digest('hex'); -} +export const allowlistDigest = (entries: readonly AllowlistEntry[]) => sha256(JSON.stringify(entries)); /** One table line per allowlisted finding: what, where, and which entry (with its reason) covered it. */ export function describeAllowlisted(a: Allowlisted): string { diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts index 4ab35054e..f2b0926c5 100644 --- a/lib/pregate/checks/guards.ts +++ b/lib/pregate/checks/guards.ts @@ -32,8 +32,9 @@ async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEn fs.writeFileSync(log, ''); const r = await supervise({ command: guard.command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + timeoutMs, stallMs: ctx.stallTimeoutMs, label: `guard ${guard.id}`, progress: ctx.progress }); const output = readRange(log, r.start, r.end).trim(); - // A guard that could not run (its binary missing, no network) is the machine's failure, not the diff's; a guard that ran and said no is the diff's. - const environment = r.outcome === 'spawn-failed' ? classifyEnvironment(r.error ?? '', null) ?? { kind: 'missing-binary' as const, evidence: r.error ?? 'the guard could not be started' } : r.outcome === 'exited' && r.exit !== 0 ? classifyEnvironment(output, r.exit) : null; + // A guard that could not start, or whose shell found no such command (exit 127), is the machine's failure; a guard that ran and said no is the diff's, + // whatever its output mentions (a test about rate limits must not read as a quota refusal). + const environment = r.outcome === 'spawn-failed' ? classifyEnvironment(r.error ?? '', null) ?? { kind: 'missing-binary' as const, evidence: r.error ?? 'the guard could not be started' } : r.outcome === 'exited' && r.exit === 127 ? classifyEnvironment(output, r.exit) : null; return { guard, exit: r.exit, timedOut: r.outcome !== 'exited' && r.outcome !== 'spawn-failed', stalled: r.outcome === 'stalled', tail: output ? output.split('\n').slice(-15) : [], log, environment }; } diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 80f406935..7760fff06 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -4,7 +4,10 @@ * `set -o pipefail` with the exit code and the runner's own summary judged * (zero tests ran is a failure, exit 0 or not); platform lanes this machine * cannot run become persisted `requires-remote` obligations. A touched file - * with no lane is `FAIL lanes — has no test lane`, never a pass. + * with no lane is `FAIL lanes — has no test lane` once any policy + * exists; with no policy anywhere (no base `.gstack/pregate.json`, no + * user-owned file) it is the `warn` PREGATE_NO_POLICY with a starter printed + * (lib/pregate/starter.ts), never a pass either way. * Markdown no test depends on is the docs/no-tests empty lane instead * (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does. * A release manifest whose diff against the merge base changes only version @@ -319,7 +322,7 @@ async function runLocal(ctx: PregateContext, env: NodeJS.ProcessEnv, groups: Run } /** Why the local lane did not finish: a stall, a runner process past --timeout or a launch failure, with the last file started. */ -function unfinished(ctx: PregateContext, r: Awaited>, total: number): Pick { +function unfinished(ctx: PregateContext, r: Awaited>, total: number): Pick { const stalled = r.runs.filter(x => x.outcome === 'stalled'); const timedOut = r.runs.filter(x => x.outcome === 'timeout'); const rest = r.failed ? `; ${r.failed} failed` : ''; @@ -329,7 +332,7 @@ function unfinished(ctx: PregateContext, r: Awaited> if (!timedOut.length) { const launch = r.runs.find(x => x.outcome === 'spawn-failed'); const environment = classifyEnvironment(launch?.error ?? '', null) ?? { kind: 'missing-binary' as const, evidence: launch?.error ?? 'the runner could not be started' }; - return { code: 'PREGATE_ENVIRONMENT', cause: 'environment', environment, detail: `the runner could not be launched (${total} files${rest}): ${environment.evidence}`, fix: 'repair the runner command or the shell on this machine, or run the lane remotely; the diff is not at fault' }; + return { environment, detail: `the runner could not be launched (${total} files${rest}): ${environment.evidence}` }; } return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' }; } @@ -471,8 +474,7 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul * blocked before the owner has written a lane policy. */ function withStarter(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult { - const starter = starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests)); - const lines = [...(result.lines ?? []), ...starter.lines]; + const lines = [...(result.lines ?? []), ...starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests))]; const noted = `${sel.noLane.length} touched file(s) with no test lane under no policy (starter printed)`; if (result.status !== 'pass') return { ...result, lines, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_POLICY' } : {}) }; return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix: `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` }; diff --git a/lib/pregate/checks/secrets.ts b/lib/pregate/checks/secrets.ts index 5083002b7..d22d80e72 100644 --- a/lib/pregate/checks/secrets.ts +++ b/lib/pregate/checks/secrets.ts @@ -2,7 +2,10 @@ * pregate check `secrets` (tier 1, preflight, never downgradable): the * branch's added lines (merge-base → working tree, untracked files included) * go through bin/gstack-redact. Exit 0 is clean; 2 (MEDIUM) and 3 (HIGH) - * fail; anything else is incomplete. Runs regardless of lint tools. + * fail unless the policy's `allowlist` names each finding (lib/pregate/allowlist.ts; + * never env.value); a scanner that does not finish or exits outside its + * contract is PREGATE_ENVIRONMENT (kind tool), never a pass. Runs regardless + * of lint tools. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; @@ -54,6 +57,8 @@ function run(ctx: PregateContext): CheckResult { const parts = addedLines(ctx); const inputs = baseInputs(ctx, { added_lines: sha256(JSON.stringify(parts)), ...(ctx.config.allowlist.length ? { allowlist: allowlistDigest(ctx.config.allowlist) } : {}) }); if (!parts.length) return { id: 'secrets', stage: 'preflight', status: 'pass', detail: 'no added lines', inputs }; + // A run killed mid-scan under the same --run id must not keep its added-line copies: clear them before writing new ones. + fs.rmSync(path.join(ctx.scratch, 'secrets'), { recursive: true, force: true }); const tmp = checkScratch(ctx.scratch, 'secrets'); const base: CheckResult = { id: 'secrets', stage: 'preflight', status: 'incomplete', inputs }; try { @@ -77,7 +82,7 @@ function run(ctx: PregateContext): CheckResult { const applied = parsedAll && ctx.config.allowlist.length ? applyAllowlist(findings, ctx.config.allowlist) : { kept: findings, allowlisted: [] }; const kept = applied.kept; if (parsedAll) { high = kept.some(f => f.severity === 'HIGH'); medium = kept.some(f => f.severity === 'MEDIUM'); } - const render = (f: Finding) => `${f.severity} ${f.id ?? 'finding'} [${f.category ?? '?'}]: ${f.description ?? ''}${f.line ? ` (${f.file ? `${f.file}, ` : ''}added line ${f.line})` : ''}`; + const render = (f: Scanned) => `${f.severity} ${f.id} [${f.category}]: ${f.description ?? ''}${f.line ? ` (${f.file ? `${f.file}, ` : ''}added line ${f.line})` : ''}`; const allowLines = applied.allowlisted.map(describeAllowlisted); const allowNote = applied.allowlisted.length ? `; ${applied.allowlisted.length} allowlisted (${[...new Set(applied.allowlisted.map(a => a.finding.id))].join(', ')})` : ''; // HIGH and any MEDIUM credential fail; a MEDIUM hygiene/internal/pii/legal diff --git a/lib/pregate/environment.ts b/lib/pregate/environment.ts index 20898cd26..0c0ff3f8f 100644 --- a/lib/pregate/environment.ts +++ b/lib/pregate/environment.ts @@ -12,18 +12,20 @@ */ import type { CheckResult, EnvironmentCause, EnvironmentKind } from './types'; +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; +/** The shell's own "no such command" text (POSIX and cmd.exe); exit 127 says the same without text. */ +const NOT_FOUND = /command not found|is not recognized as an internal or external command/; const RULES: Array<{ kind: EnvironmentKind; re: RegExp }> = [ - { kind: 'missing-binary', re: /command not found|No such file or directory: ['"]?[\w./-]+|is not recognized as an internal or external command|spawn \S+ ENOENT|\bENOENT\b.*\bspawn\b|not on PATH/ }, + { kind: 'missing-binary', re: new RegExp(`${NOT_FOUND.source}|No such file or directory: ['"]?[\\w./-]+|spawn \\S+ ENOENT|\\bENOENT\\b.*\\bspawn\\b|not on PATH`) }, { kind: 'quota', re: /\bquota\b|\b429 Too Many Requests|status(?: code)?[:= ]+429\b|HTTP(?:\/[\d.]+)? 429\b|\brate.?limit(?:ed)?\b|insufficient_quota/i }, { kind: 'network', re: /\bE(?:NOTFOUND|CONNREFUSED|CONNRESET|TIMEDOUT|HOSTUNREACH|NETUNREACH|AI_AGAIN|PIPE)\b|getaddrinfo|Could not resolve host|Connection refused|Connection reset|Network is unreachable|fetch failed|TLS handshake|certificate verify failed|UNABLE_TO_GET_ISSUER_CERT|Temporary failure in name resolution/ }, { kind: 'disk', re: /\bENOSPC\b|No space left on device|\bEDQUOT\b|Disk quota exceeded/ }, ]; -/** The output line (trimmed, capped) that matched a rule, or the first line when the exit code alone decided. */ -function evidenceLine(output: string, re: RegExp | null): string { - const lines = output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').split(/\r?\n/).map(l => l.trim()).filter(Boolean); - const hit = re ? lines.find(l => re.test(l)) : undefined; - return (hit ?? lines[0] ?? '').replace(/^(?:Error|error|ERROR):\s*/, '').slice(0, 200); +/** The first line (trimmed, capped) of ANSI-free `text` that matches `re`, else its first line. */ +function evidenceLine(text: string, re: RegExp): string { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + return (lines.find(l => re.test(l)) ?? lines[0] ?? '').replace(/^(?:Error|error|ERROR):\s*/, '').slice(0, 200); } /** @@ -33,23 +35,26 @@ function evidenceLine(output: string, re: RegExp | null): string { * disk quota); otherwise the first rule in order whose text appears wins. */ export function classifyEnvironment(output: string, exit: number | null): EnvironmentCause | null { - const text = output.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); - if (exit === 127 || /command not found|is not recognized as an internal or external command/.test(text)) return { kind: 'missing-binary', evidence: evidenceLine(text, /command not found|is not recognized as an internal or external command|No such file or directory/) || `exit 127: the shell found no such command` }; + const text = output.replace(ANSI, ''); + if (exit === 127 || NOT_FOUND.test(text)) return { kind: 'missing-binary', evidence: evidenceLine(text, RULES[0]!.re) || 'exit 127: the shell found no such command' }; const disk = RULES.find(r => r.kind === 'disk')!; if (disk.re.test(text)) return { kind: 'disk', evidence: evidenceLine(text, disk.re) }; for (const rule of RULES) if (rule.kind !== 'disk' && rule.re.test(text)) return { kind: rule.kind, evidence: evidenceLine(text, rule.re) }; return null; } +const FIX: Record = { + 'missing-binary': 'install the named tool on this machine (or put it on PATH), or run the lane remotely (--remote, or clear it with a run URL); the diff is not at fault', + network: 'check the network, proxy or registry this machine reaches, then rerun; or run the lane remotely; the diff is not at fault', + quota: 'wait for the quota or raise it, free the backend\'s capacity, then rerun; the diff is not at fault', + disk: 'free disk space (or move the state root) and rerun; the diff is not at fault', + runtime: 'repair the runtime the line names (the install verdict or the pinned Bun), then rerun; the diff is not at fault', + tool: 'repair or reinstall the named gstack tool, then rerun; the diff is not at fault', +}; + /** An `incomplete` result under PREGATE_ENVIRONMENT: cause, kind, evidence, an `environment:` line and the kind's fix. */ -export function environmentResult(base: CheckResult, cause: EnvironmentCause, detail: string, extraLines: string[] = []): CheckResult { - const fix = cause.kind === 'missing-binary' ? 'install the named tool on this machine (or put it on PATH), or run the lane remotely (--remote, or clear it with a run URL); the diff is not at fault' - : cause.kind === 'network' ? 'check the network, proxy or registry this machine reaches, then rerun; or run the lane remotely; the diff is not at fault' - : cause.kind === 'quota' ? 'wait for the quota or raise it, free the backend\'s capacity, then rerun; the diff is not at fault' - : cause.kind === 'disk' ? 'free disk space (or move the state root) and rerun; the diff is not at fault' - : cause.kind === 'runtime' ? 'repair the runtime the line names (the install verdict or the pinned Bun), then rerun; the diff is not at fault' - : 'repair or reinstall the named gstack tool, then rerun; the diff is not at fault'; - return { ...base, status: 'incomplete', code: 'PREGATE_ENVIRONMENT', cause: 'environment', environment: cause, detail, fix, lines: [...(base.lines ?? []), ...extraLines, environmentLine(cause)] }; +export function environmentResult(base: CheckResult, cause: EnvironmentCause, detail: string): CheckResult { + return { ...base, status: 'incomplete', code: 'PREGATE_ENVIRONMENT', cause: 'environment', environment: cause, detail, fix: FIX[cause.kind], lines: [...(base.lines ?? []), environmentLine(cause)] }; } export const environmentLine = (cause: EnvironmentCause) => `environment: ${cause.kind} — ${cause.evidence}`; diff --git a/lib/pregate/starter.ts b/lib/pregate/starter.ts index fa4725c37..8ed5a3954 100644 --- a/lib/pregate/starter.ts +++ b/lib/pregate/starter.ts @@ -17,7 +17,6 @@ import type { PregateContext } from './types'; const MAX_DIRS = 8; -export interface Starter { json: string; config: Record; lines: string[]; todo: string[] } /** `init`'s nearest-directory suggestion, else the tests whose basename starts with the file's stem. */ export function nearestTestsFor(tests: readonly string[], file: string): string[] { @@ -30,7 +29,7 @@ export function nearestTestsFor(tests: readonly string[], file: string): string[ } /** The starter for `noLane` (touched files with no lane) under `runner` (detected by the lanes check; null when none). */ -export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): Starter { +export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): string[] { const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort(); const dependencies: Record = {}; const todo: string[] = []; @@ -38,13 +37,12 @@ export function starterPolicy(ctx: Pick = { $schema: PREGATE_CONFIG_SCHEMA, runner, dependencies }; - const json = JSON.stringify(config, null, 2); + const json = JSON.stringify({ $schema: PREGATE_CONFIG_SCHEMA, runner, dependencies }, null, 2); const dirCounts = new Map(); for (const t of tests) { const d = path.posix.dirname(t); dirCounts.set(d, (dirCounts.get(d) ?? 0) + 1); } const dirs = [...dirCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_DIRS).map(([d]) => d); const lanes = ctx.lanes.filter(l => l.runsTests && !l.manual).map(l => `${l.id} (${l.platform}${l.matrixExpression && l.testFiles.length === 0 && !ctx.config.selection[l.id] ? ', matrix computed; declare selection' : ''})`); - const lines = [ + return [ `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`, `PREGATE_STARTER: runner ${runner ?? 'null (none detected; set runner with {files})'}`, `PREGATE_STARTER: test directories: ${dirs.join(', ') || '(no test files found)'}${dirCounts.size > MAX_DIRS ? ` (+${dirCounts.size - MAX_DIRS} more)` : ''}`, @@ -54,5 +52,4 @@ export function starterPolicy(ctx: Pick `TODO ${f}: no test lane and no nearby test; add a test, map it under dependencies, or declare [] if no test can cover it`), ]; - return { json: json + '\n', config, lines, todo }; } diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 4489bf9ab..df7a3f364 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -90,9 +90,7 @@ export interface CheckResult extends PregateCheck { /** Per-file or per-item lines for the table body. */ lines?: string[]; remote?: RemoteObligation[]; - /** Who is to blame for a non-pass: the branch's diff, or the machine the check ran on (lib/pregate/environment.ts). */ - cause?: 'diff' | 'environment'; - /** With `cause: 'environment'`: the kind and the line that proves it. */ + /** With `cause: 'environment'` (declared on PregateCheck): the kind and the line that proves it. */ environment?: EnvironmentCause; } diff --git a/test/pregate-allowlist.test.ts b/test/pregate-allowlist.test.ts index 64c5b952c..6101ff1a7 100644 --- a/test/pregate-allowlist.test.ts +++ b/test/pregate-allowlist.test.ts @@ -40,6 +40,14 @@ describe('allowlist: policy validation', () => { expect(codes({ allowlist: [{ finding: 'env.kv', pattern: '(', reason: 'x' }] })).toEqual(['PREGATE_CONFIG_INVALID:allowlist[0].pattern']); expect(codes({ allowlist: [{ finding: 'env.kv', paths: ['../x'], reason: 'x' }] })).toEqual(['PREGATE_CONFIG_INVALID:allowlist[0].paths[0]']); expect(codes({ allowlist: { finding: 'env.kv' } })).toEqual(['PREGATE_CONFIG_INVALID:allowlist']); + expect(codes({ allowlist: Array.from({ length: 65 }, () => TRIAL_ENTRY) })).toContain('PREGATE_CONFIG_INVALID:allowlist'); + expect(codes({ allowlist: [{ ...TRIAL_ENTRY, extra: 1 }] })).toEqual(['PREGATE_CONFIG_INVALID:allowlist[0].extra']); + expect(codes({ allowlist: [{ finding: 'env.kv', paths: [], reason: 'x' }] })).toEqual(['PREGATE_CONFIG_INVALID:allowlist[0].paths']); + expect(codes({ allowlist: [{ finding: 'env.kv', pattern: 'a'.repeat(257), reason: 'x' }] })).toEqual(['PREGATE_CONFIG_INVALID:allowlist[0].pattern']); + // A pattern entry never covers a finding the scanner gave no position; a paths-only entry does. + const positionless: AllowlistFinding = { id: 'env.kv', category: 'secret', severity: 'MEDIUM', file: 'evals/x.json' }; + expect(applyAllowlist([positionless], [TRIAL_ENTRY]).kept).toHaveLength(1); + expect(applyAllowlist([positionless], [{ finding: 'env.kv', paths: ['evals/**'], reason: 'r' }]).allowlisted).toHaveLength(1); expect(NON_ALLOWLISTABLE).toContain('env.value'); }); }); diff --git a/test/pregate-environment.test.ts b/test/pregate-environment.test.ts index eb1cf62ca..73a3582c5 100644 --- a/test/pregate-environment.test.ts +++ b/test/pregate-environment.test.ts @@ -13,6 +13,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { classifyEnvironment } from '../lib/pregate/environment'; +import { verifyPregate } from '../lib/pregate/index'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -29,7 +30,8 @@ describe('classifyEnvironment', () => { // seam=none; no_claim=completeness of the vocabulary test('names missing-binary, network, quota and disk, and stays null for an ordinary failure', () => { expect(classifyEnvironment('bash: line 1: vitest: command not found', 127)?.kind).toBe('missing-binary'); - expect(classifyEnvironment('', 127)?.kind).toBe('missing-binary'); + expect(classifyEnvironment('', 127)).toEqual({ kind: 'missing-binary', evidence: 'exit 127: the shell found no such command' }); + expect(classifyEnvironment('write failed: Disk quota exceeded (EDQUOT)', 1)).toEqual({ kind: 'disk', evidence: 'write failed: Disk quota exceeded (EDQUOT)' }); expect(classifyEnvironment("'nope' is not recognized as an internal or external command", 1)?.kind).toBe('missing-binary'); expect(classifyEnvironment('Error: getaddrinfo ENOTFOUND registry.example.invalid', 1)).toEqual({ kind: 'network', evidence: 'getaddrinfo ENOTFOUND registry.example.invalid' }); expect(classifyEnvironment('curl: (7) Failed to connect to host port 443: Connection refused', 7)?.kind).toBe('network'); @@ -96,13 +98,15 @@ describe('environment failures are reported apart from diff failures', () => { // Value: protects=guards classify the same way: a guard whose command is missing is environment, a guard that fails on its // own terms is the diff; fails_when=a missing guard binary is PREGATE_GUARD_FAILED; why_new=new; seam=none; no_claim=none test('a guard whose binary is missing is environment; a guard that fails is the diff', () => { - const repo = fixture({ ...BASE_PREGATE, guards: [{ id: 'absent', when: ['lib/**'], command: 'gstack-fixture-guard-that-does-not-exist --check' }, { id: 'red', when: ['lib/**'], command: 'node -e "process.exit(2)"', fix: 'fix it' }] }); + // The red guard's output names a rate limit and a refused connection: a guard that ran and said no is the diff's whatever it printed. + const repo = fixture({ ...BASE_PREGATE, guards: [{ id: 'absent', when: ['lib/**'], command: 'gstack-fixture-guard-that-does-not-exist --check' }, { id: 'red', when: ['lib/**'], command: 'node -e "console.log(\'FAIL api > retries after a rate limit (ECONNREFUSED fixture)\'); process.exit(2)"', fix: 'fix it' }] }); commit(repo, UTIL_EDIT); const r = pregate(repo, ['--only', 'guards']); expect(r.status).toBe(EXIT.fail); const g = check(repo, 'guards'); expect([g.status, g.code, g.cause]).toEqual(['fail', 'PREGATE_GUARD_FAILED', 'diff']); expect(g.lines.join('\n')).toContain('environment: missing-binary'); + expect(g.lines.join('\n')).not.toContain('red environment:'); const only = fixture({ ...BASE_PREGATE, guards: [{ id: 'absent', when: ['lib/**'], command: 'gstack-fixture-guard-that-does-not-exist --check' }] }); commit(only, UTIL_EDIT); pregate(only, ['--only', 'guards']); @@ -110,3 +114,17 @@ describe('environment failures are reported apart from diff failures', () => { expect([o.status, o.code, o.cause, o.environment.kind]).toEqual(['incomplete', 'PREGATE_ENVIRONMENT', 'environment', 'missing-binary']); }); }); + +describe('verify: a mixed incomplete record stays PREGATE_INCOMPLETE and names the environment-blocked check', () => { + test('one environment-caused check beside a plain incomplete one is not promoted to PREGATE_ENVIRONMENT', () => { + const file: any = { schema_version: 1, tree: 'T', checks: [ + { id: 'lanes', status: 'incomplete', cause: 'environment', environment: { kind: 'network', evidence: 'ENOTFOUND x' } }, + { id: 'guards', status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED' }, + ] }; + const v = verifyPregate('/nonexistent', file, { tree: 'T' } as any); + expect(v.code).toBe('PREGATE_INCOMPLETE'); + expect(v.message).toContain('(blocked by the environment: lanes network)'); + const all: any = { ...file, checks: [file.checks[0]] }; + expect(verifyPregate('/nonexistent', all, { tree: 'T' } as any).code).toBe('PREGATE_ENVIRONMENT'); + }); +}); diff --git a/test/pregate-scratch.test.ts b/test/pregate-scratch.test.ts index 9d47b568e..a01d4ba39 100644 --- a/test/pregate-scratch.test.ts +++ b/test/pregate-scratch.test.ts @@ -58,6 +58,8 @@ describe('per-run scratch under the state root', () => { for (const run of runs) expect(fs.existsSync(path.join(runsDir(repo), run, 'secrets'))).toBe(false); expect(scratchRunId('weird id/with:chars')).toBe('weird-id-with-chars'); expect(scratchRunId(undefined, 123)).toMatch(/^\d{8}T\d{6}Z-123-[0-9a-f]{4}$/); + expect(scratchRunId('...', 123)).toMatch(/^\d{8}T\d{6}Z-123-[0-9a-f]{4}$/); + expect(scratchRunId('a'.repeat(200))).toHaveLength(96); }); // Value: protects=pruning removes only run directories older than the retention and never the current one; fails_when=a fresh @@ -75,3 +77,19 @@ describe('per-run scratch under the state root', () => { } finally { fs.rmSync(root, { recursive: true, force: true }); } }); }); + +describe('an unwritable state root', () => { + // Value: protects=a state root whose projects dir cannot be created still runs pregate, in a private per-run directory under + // os.tmpdir(), and says so under --explain; fails_when=the mkdir error escapes buildContext or the note is dropped; why_new=the + // fallback is new; seam=none; no_claim=none + test('falls back to a per-run tmpdir and says so', () => { + const repo = makeFixtureRepo(); + repos.push(repo); + fs.writeFileSync(path.join(repo.home, 'projects'), 'not a directory'); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + const r = pregate(repo, ['--only', 'secrets', '--explain']); + expect(r.status).toBe(EXIT.ok); + expect(readOut(repo).scratch.startsWith(os.tmpdir())).toBe(true); + expect(r.out + r.err).toContain('scratch: state root not writable'); + }); +}); diff --git a/test/pregate-selection.test.ts b/test/pregate-selection.test.ts index b3d1aa400..1c64a8262 100644 --- a/test/pregate-selection.test.ts +++ b/test/pregate-selection.test.ts @@ -393,6 +393,7 @@ describe('gstack-pregate (bin)', () => { const ok = pregate(docs, ['--only', 'lanes', '--explain']); expect(ok.status).toBe(EXIT.ok); expect(ok.err).not.toContain('PREGATE_NO_LANE'); + expect(ok.out).not.toContain('PREGATE_NO_POLICY'); expect(ok.out).toMatch(/docs\/no-tests: 3 documentation file\(s\)/); expect(ok.out).toContain('.work/round-3-plan.md: docs/no-tests (empty lane)'); expect(ok.out).toContain('notes/redproof.md: docs/no-tests (empty lane)'); diff --git a/test/pregate-starter.test.ts b/test/pregate-starter.test.ts index a6dad6a11..fd911b866 100644 --- a/test/pregate-starter.test.ts +++ b/test/pregate-starter.test.ts @@ -103,6 +103,13 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE expect(u.status).toBe(EXIT.fail); expect(u.err).toContain('(PREGATE_NO_LANE)'); expect(u.out).not.toContain('PREGATE_STARTER'); + // --policy-from head with a head policy (no base copy) is strict too. + const withHead = fixture({ pregate: null }); + commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}' }) + '\n' }); + const h = lanes(withHead, ['--policy-from', 'head']); + expect(h.status).toBe(EXIT.fail); + expect(h.err).toContain('(PREGATE_NO_LANE)'); + expect(h.out).not.toContain('PREGATE_STARTER'); }); // Value: protects=`run` never writes the policy; `init` is the explicit write and the starter and the init draft agree on the