fix: pre-landing review fixes

guards: an exited guard is the machine's only on exit 127, never from its output;
secrets: clear an interrupted run's added-line copies before scanning;
environment: one fix table per kind, one ANSI strip, hoisted not-found vocabulary;
starter returns its lines; allowlistDigest reuses sha256; stale headers and the
PREGATE_INCOMPLETE fix clause updated; tests for the mixed verify branch, disk
precedence, allowlist boundaries, a positionless finding, head-policy strictness,
run-id edges and the unwritable state root.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 15:23:06 +00:00
1 parent 31732c7dbf
commit 213ffe9d2e
13 files changed
+102 -44

No files matched your search

+4 -4
View File
@@ -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.
<a id="pregate-stalled"></a>
### `PREGATE_STALLED`
+2 -4
View File
@@ -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<F extends AllowlistFinding>(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 {
+3 -2
View File
@@ -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 };
}
+7 -5
View File
@@ -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 — <file> has no test lane`, never a pass.
* with no lane is `FAIL lanes — <file> 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<ReturnType<typeof runLocal>>, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix' | 'cause' | 'environment'> {
function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix' | 'environment'> {
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<ReturnType<typeof runLocal>>
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` };
+7 -2
View File
@@ -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
+21 -16
View File
@@ -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<EnvironmentKind, string> = {
'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}`;
+3 -6
View File
@@ -17,7 +17,6 @@ import type { PregateContext } from './types';
const MAX_DIRS = 8;
export interface Starter { json: string; config: Record<string, unknown>; 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<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy'>, noLane: readonly string[], runner: string | null): Starter {
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy'>, noLane: readonly string[], runner: string | null): string[] {
const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort();
const dependencies: Record<string, string[]> = {};
const todo: string[] = [];
@@ -38,13 +37,12 @@ export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | '
const suggested = nearestTestsFor(tests, file);
if (suggested.length) dependencies[file] = suggested; else todo.push(file);
}
const config: Record<string, unknown> = { $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<string, number>();
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<PregateContext, 'repoRoot' | 'lanes' | '
'PREGATE_STARTER: end',
...todo.map(f => `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 };
}
+1 -3
View File
@@ -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;
}
+8
View File
@@ -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');
});
});
+20 -2
View File
@@ -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');
});
});
+18
View File
@@ -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');
});
});
+1
View File
@@ -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)');
+7
View File
@@ -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