mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
101 lines
6.2 KiB
TypeScript
101 lines
6.2 KiB
TypeScript
/**
|
|
* Lane D (GSTA-112, E6b): every run gets its own scratch directory under the
|
|
* state root (`<state root>/projects/<slug>/pregate/runs/<run id>/`) for the
|
|
* secrets scan files, lane and guard logs and the remote gate's files, instead
|
|
* of os.tmpdir(): two pregates in parallel never share a path, the logs a
|
|
* `fix:` line names survive a Capy wake that wipes /tmp (gbrain 16), and the
|
|
* record names the directory. Old runs are pruned best-effort.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { pruneScratch, scratchRunId } from '../lib/pregate/run-scratch';
|
|
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
|
|
|
const repos: FixtureRepo[] = [];
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
const SLUG = 'scratchslug';
|
|
const pregate = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', ...args], { GSTACK_PROJECT_SLUG: SLUG });
|
|
const readOut = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
|
const runsDir = (repo: FixtureRepo) => path.join(repo.home, 'projects', SLUG, 'pregate', 'runs');
|
|
|
|
describe('per-run scratch under the state root', () => {
|
|
// Value: protects=lane and guard logs and the record's scratch path sit under the state root's per-run directory, named by
|
|
// --run, and two runs never share one; fails_when=logs land under os.tmpdir() (the behaviour before this change) or two runs
|
|
// collide; why_new=the scratch layout is new; seam=none; no_claim=none
|
|
test('logs and the record name the run directory; two --run ids get two directories', () => {
|
|
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, guards: [{ id: 'echo', when: ['lib/**'], command: 'node -e "console.log(1)"' }] } });
|
|
repos.push(repo);
|
|
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
|
const a = pregate(repo, ['--run', 'run-a', '--only', 'lanes,guards']);
|
|
expect(a.status).toBe(EXIT.ok);
|
|
const dirA = path.join(runsDir(repo), 'run-a');
|
|
expect(readOut(repo).scratch).toBe(dirA);
|
|
expect(a.out).toContain(`scratch: ${dirA}`);
|
|
const logLines = a.out.split('\n').filter(l => /\blog \S+|\(log \S+\)/.test(l));
|
|
expect(logLines.length).toBeGreaterThan(0);
|
|
for (const l of logLines) { expect(l).toContain(dirA); expect(l).not.toMatch(/gstack-pregate-(?:lanes|guards|secrets|remote)-/); }
|
|
expect(fs.existsSync(path.join(dirA, 'lanes', 'lanes.log'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dirA, 'guards', 'echo.log'))).toBe(true);
|
|
expect((fs.statSync(dirA).mode & 0o777)).toBe(0o700);
|
|
pregate(repo, ['--run', 'run-b', '--only', 'lanes']);
|
|
expect(readOut(repo).scratch).toBe(path.join(runsDir(repo), 'run-b'));
|
|
expect(fs.existsSync(path.join(dirA, 'lanes', 'lanes.log'))).toBe(true);
|
|
// A taken --run id is never shared (a second run would truncate the first's logs): it takes a numbered sibling and the record names it.
|
|
const again = pregate(repo, ['--run', 'run-a', '--only', 'lanes']);
|
|
expect(readOut(repo).scratch).toBe(`${dirA}-2`);
|
|
expect(again.out).toContain(`scratch: ${dirA}-2`);
|
|
expect(fs.existsSync(path.join(dirA, 'guards', 'echo.log'))).toBe(true);
|
|
});
|
|
|
|
// Value: protects=without --run the id is unique per process and the secrets scan files are gone after the scan; fails_when=two
|
|
// runs in one second share an id, or added-line copies linger under the state root; why_new=new; seam=none; no_claim=none
|
|
test('an unnamed run gets a unique id, and the secrets scan leaves no added-line copies behind', () => {
|
|
const repo = makeFixtureRepo();
|
|
repos.push(repo);
|
|
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
|
pregate(repo, ['--only', 'secrets']);
|
|
pregate(repo, ['--only', 'secrets']);
|
|
const runs = fs.readdirSync(runsDir(repo));
|
|
expect(runs.length).toBe(2);
|
|
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
|
|
// run or the current run is deleted, or an old one is kept; why_new=new; seam=none; no_claim=none
|
|
test('prune drops runs older than 7 days and keeps fresh ones and the current run', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-prune-'));
|
|
try {
|
|
const old = path.join(root, 'old'); const fresh = path.join(root, 'fresh'); const current = path.join(root, 'current');
|
|
for (const d of [old, fresh, current]) fs.mkdirSync(d);
|
|
const eightDays = (Date.now() - 8 * 24 * 3600 * 1000) / 1000;
|
|
fs.utimesSync(old, eightDays, eightDays);
|
|
fs.utimesSync(current, eightDays, eightDays);
|
|
pruneScratch(root, 'current');
|
|
expect(fs.readdirSync(root).sort()).toEqual(['current', 'fresh']);
|
|
} 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');
|
|
});
|
|
});
|