mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
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:
1 parent
31732c7dbf
commit
213ffe9d2e
13 files changed
+102
-44
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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` };
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user