Files
gstack/lib/pregate/checks/literals.ts
Garry Tanandcapy-ai[bot] 568cd5aa56 v1.91.254.0 pregate: starter policy, benign-finding allowlist, environment vs diff failures, per-run scratch (#3480)
* pregate: starter policy, benign-finding allowlist, environment failures, per-run scratch

A repository with no .gstack/pregate.json now gets a warn (PREGATE_NO_POLICY)
with a starter policy printed, instead of PREGATE_NO_LANE on every touched
file; gstack-pregate init remains the explicit write. The policy gains an
allowlist for benign secrets/pii findings (env.value and secret never
allowlistable). Checks that cannot run because of the machine report
PREGATE_ENVIRONMENT (incomplete, with kind and evidence) apart from diff
failures. Every run writes its scratch under the state root, per run id.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* pregate: rename run-scratch module (strays rule) and map starter.ts to its test

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* pregate literals: match a module as a path fragment, require the constant name for a one-character value

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* pregate: count a module's full file name without a directory as a consumer

consumersOf anchored both the stem and the full basename on a path
separator, so run('install.sh') or bash install.sh without a directory
stopped counting for a multi-character old value. Only the bare stem
needs the separator; the full file name is specific on its own.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* pregate: a diff cannot clear itself as an environment failure; a taken --run id gets its own directory

Adversarial review of the environment classifier and the scratch layout:

- exit 127 and 'command not found' are missing-binary only when the
  command's own binary is off PATH (runner, guard and remote gate alike);
  a script the diff controls that exits 127 is the diff's failure
- a local lane with no test summary is classified from its output only
  when the runner was not seen starting a test file; a remote gate only
  from its first 40 lines, before any test output could stream
- environment.proof records launch (certain) or output (inferred); an
  output-inferred cause never says 'the diff is not at fault', its line
  says '(inferred from output)', and verify names such checks
- a runner killed by a signal is PREGATE_INCOMPLETE, not missing-binary
- a --run id whose directory exists takes <id>-2 .. -9 instead of
  sharing (and truncating) a live run's logs
- engine.* findings are never allowlistable; a pattern entry the scanner
  gave no position for is reported as not tested instead of silently inert
- docs: synopsis --run, no-policy lanes wording, verify's code list,
  guard exit 127 carve-out, PREGATE_SECRETS allowlist pointer, scratch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): PREGATE_INCOMPLETE names a signal-killed runner; CHANGELOG lists every doc edit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 20:17:41 +00:00

113 lines
6.5 KiB
TypeScript

/**
* pregate check `literals` (tier 1, preflight): for every exported constant,
* timeout, retry or delay literal the branch changed, grep the whole test
* tree, nightly-only and Heavy-only files included, for the old value next to
* the constant's name or its module. Incident p0-4-heavy-old-constant: a
* nightly-only test still asserted the old number and went red a week after
* the merge.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { baseInputs, sha256 } from '../context';
import { isTestFile, listRepoFiles, scansLiterals } from '../import-graph';
import type { CheckDef, CheckResult, PregateContext } from '../types';
export interface ChangedLiteral { name: string; old: string; new: string; file: string }
const CONST_PATTERNS: RegExp[] = [
/^\s*export\s+(?:const|let|var)\s+([A-Z][A-Z0-9_]{1,})\s*(?::\s*[^=]+?)?\s*=\s*(.+?);?\s*$/,
/^\s*(?:module\.)?exports\.([A-Z][A-Z0-9_]{1,})\s*=\s*(.+?);?\s*$/,
/^\s*(?:export\s+)?(?:static\s+)?readonly\s+([A-Z][A-Z0-9_]{1,})\s*(?::\s*[^=]+?)?\s*=\s*(.+?);?\s*$/,
/^([A-Z][A-Z0-9_]{2,})\s*=\s*(.+?)\s*$/,
/^(?:export\s+|readonly\s+)?([A-Z][A-Z0-9_]{2,})=(\S+)\s*$/,
];
const CALL_PATTERN = /\b(timeout(?:Ms|_ms)?|retries|maxRetries|max_retries|attempts|maxAttempts|delay(?:Ms)?|interval(?:Ms)?|budget|cap|limit)\s*[:=]\s*(\d+(?:_\d+)*)\b/g;
const LITERAL = /^(?:-?\d+(?:_\d+)*(?:\.\d+)?|'[^']*'|"[^"]*"|`[^`$]*`|true|false|null|\[[^\]]*\])$/;
/** `NAME → literal` for one source text; only literal right-hand sides count. */
export function constantsIn(text: string): Map<string, string> {
const out = new Map<string, string>();
for (const line of text.split('\n')) {
for (const re of CONST_PATTERNS) {
const m = re.exec(line);
if (!m) continue;
const value = m[2]!.trim();
if (LITERAL.test(value)) out.set(m[1]!, value);
break;
}
}
return out;
}
/** Named numeric call sites (`timeout: 5000`) keyed by name; the last value per name wins, which is enough to spot a change. */
export function callLiteralsIn(text: string): Map<string, string> {
const out = new Map<string, string>();
for (const m of text.matchAll(CALL_PATTERN)) out.set(m[1]!, m[2]!);
return out;
}
export function changedLiterals(file: string, oldText: string, newText: string): ChangedLiteral[] {
const out: ChangedLiteral[] = [];
const oldC = constantsIn(oldText); const newC = constantsIn(newText);
for (const [name, old] of oldC) { const now = newC.get(name); if (now !== undefined && now !== old) out.push({ name, old, new: now, file }); }
const oldCall = callLiteralsIn(oldText); const newCall = callLiteralsIn(newText);
for (const [name, old] of oldCall) { const now = newCall.get(name); if (now !== undefined && now !== old && !oldC.has(name)) out.push({ name, old, new: now, file }); }
return out;
}
function escape(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) : v; }
/**
* Test files that still carry the old literal beside the constant's name or
* its module. The module counts as its full file name (`install.sh`) or as a
* path fragment (`/retry.js`, `/retry'`): a bare stem such as `install` or `retry` is an
* English word in half the test tree, and a one-character old value (`2`)
* sits in every test, so that value needs the constant's name (a wrapper
* version 2 → 3 flagged 575 consumers through the stem alone).
*/
export function consumersOf(repoRoot: string, testFiles: readonly string[], change: ChangedLiteral): string[] {
const value = unquote(change.old);
const valueRe = /^-?\d/.test(value) ? new RegExp(`(?<![\\w.])${escape(value)}(?![\\w.])`) : new RegExp(`['"\`]${escape(value)}['"\`]`);
const base = path.basename(change.file);
const stem = base.replace(/\.[^.]+$/, '');
const moduleRe = new RegExp(`(?:(?:^|[/\\\\])${escape(stem)}|(?<![\\w-])${escape(base)})(?![\\w-])`, 'm');
const nameRe = new RegExp(`\\b${escape(change.name)}\\b`);
const nameRequired = value.length < 2;
const hits: string[] = [];
for (const rel of testFiles) {
let text: string;
try { text = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
if (!(nameRe.test(text) || (!nameRequired && moduleRe.test(text)))) continue;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!)) { hits.push(`${rel}:${i + 1}`); break; }
}
return hits;
}
function run(ctx: PregateContext): CheckResult {
const candidates = ctx.touched.filter(t => (t.status === 'M' || t.status === 'R') && !isTestFile(t.path) && /\.(?:[cm]?[jt]sx?|py|rb|sh|bash)$/.test(t.path));
const changes: ChangedLiteral[] = [];
for (const t of candidates) {
const old = spawnSync('git', ['show', `${ctx.mergeBase}:${t.renamedFrom ?? t.path}`], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, env: ctx.env });
if (old.status !== 0) continue;
let now = '';
try { now = fs.readFileSync(path.join(ctx.repoRoot, t.path), 'utf8'); } catch { continue; }
changes.push(...changedLiterals(t.path, old.stdout, now));
}
const inputs = baseInputs(ctx, { changes: sha256(changes.map(c => `${c.file}:${c.name}:${c.old}→${c.new}`).join('\n')) });
if (!changes.length) return { id: 'literals', stage: 'preflight', status: 'pass', inputs, detail: `no exported constant or named timeout literal changed in ${candidates.length} modified source file(s)` };
const testFiles = listRepoFiles(ctx.repoRoot).filter(f => scansLiterals(f) && !f.startsWith('bin/'));
const lines: string[] = [];
for (const c of changes) {
const hits = consumersOf(ctx.repoRoot, testFiles, c);
ctx.explain.push(`literals: ${c.file} ${c.name} ${c.old} → ${c.new}: ${hits.length} test file(s) still carry ${c.old}`);
for (const h of hits) lines.push(`${h}: ${c.name} still ${c.old} (now ${c.new} in ${c.file})`);
}
if (lines.length) return { id: 'literals', stage: 'preflight', status: 'fail', inputs, lines, code: 'PREGATE_LITERALS', detail: `${lines.length} test location(s) still carry an old literal`, fix: 'update each listed test to the new value (nightly- and Heavy-only files included), or keep the old constant' };
return { id: 'literals', stage: 'preflight', status: 'pass', inputs, detail: `${changes.length} changed literal(s), no test still carries an old value (${testFiles.length} test-tree files scanned)` };
}
export const literalsCheck: CheckDef = { id: 'literals', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: false, run };