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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 15:19:01 +00:00
1 parent e9f9c217d9
commit 31732c7dbf
3 files changed
+38 -4

No files matched your search

+3
View File
@@ -10,6 +10,9 @@
- **Environment failures reported apart from diff failures** (`lib/pregate/environment.ts`; new code `PREGATE_ENVIRONMENT` with the troubleshooting anchor `pregate-environment`). A runner or guard binary missing from PATH (exit 127, `command not found`, a spawn `ENOENT`, a tool `not on PATH`), a network error or a quota refusal before any test was counted, a full disk, an unverified runtime or gstack's own scanner not finishing is `incomplete` with `cause: "environment"` and `environment: { kind, evidence }` in `pregate.json`, an `environment: <kind> — <evidence>` table line and a fix that says the diff is not at fault; `PREGATE_LANE_FAILED`, `PREGATE_GUARD_FAILED` and `PREGATE_SECRETS` carry `cause: "diff"`. The classifier is conservative: a local lane that exited non-zero is read from its output only when its runner reported no test summary; a counted failure is the diff's whatever its log says; a remote gate is environment only when it printed no `PREGATE_REMOTE:` line; stalls and timeouts keep their codes. `gstack-pregate verify` prints `PREGATE_ENVIRONMENT: <n> check(s) blocked by the environment (<kinds>), not by the diff` when every incomplete check is environment-caused and names the kinds beside `PREGATE_INCOMPLETE` otherwise, so a lane waives by cause, never by test name.
- **One scratch directory per run under the state root** (`lib/pregate/run-scratch.ts`): `<state root>/projects/<slug>/pregate/runs/<run id>/` (0700; `--run <id>` names it, else `<UTC stamp>-<pid>-<4 hex>`) holds the secrets scan's added-line copies (deleted after the scan), `lanes/lanes.log`, `guards/<id>.log` and the remote gate's log and file list, never `os.tmpdir()`. `pregate.json` records it as `scratch`, the table prints `scratch: <dir>`, `--explain` names it; runs older than 7 days under the same project are pruned when a new run starts; an unwritable state root falls back to a private per-run directory under `os.tmpdir()` and says so.
### Fixed
- **`literals` no longer floods on a common module stem or a one-digit value** (`lib/pregate/checks/literals.ts` `consumersOf`). A test file counted as a consumer of a changed constant when it merely contained the source file's stem as a bare word (`install`, `retry`), so `GSTACK_CAPY_WRAPPER=2 → 3` in `capy/skill/gstack/scripts/install.sh` flagged 575 test files (`PREGATE_LITERALS`; GSTA-114, #3475). The module now counts only as a path fragment (`/install.sh`, `/retry.js`, `/retry'`), and an old value shorter than two characters needs the constant's name in the file. The same edit now flags the 5 files that name `GSTACK_CAPY_WRAPPER`. Regression: `test/pregate-selection.test.ts` "consumersOf: a module stem is a path fragment…".
### Changed
- `docs/pregate.md`: the `allowlist` row, the "No policy yet" paragraph, and three sections (allowlist, environment failures, scratch). `docs/troubleshooting.md`: `PREGATE_NO_POLICY` and `PREGATE_ENVIRONMENT` anchors; `PREGATE_NO_LANE` and `PREGATE_INCOMPLETE` point at them. `lib/headless-artifacts.ts`: the pregate schema accepts `cause`, `environment` and `scratch` (additive). `gstack-pregate --help` documents `--run`.
- Existing pregate tests whose fixtures had no policy and asserted the hard `PREGATE_NO_LANE` (`test/pregate-selection.test.ts`, `pregate-docs-lane-review.test.ts`, `pregate-non-js.test.ts`, `pregate-rust.test.ts`) now assert the no-policy warning and that the file is still named as unmapped; the Dart missing-SDK cases in `test/pregate-dart.test.ts` assert `PREGATE_ENVIRONMENT` and the new fix text.
+13 -3
View File
@@ -59,17 +59,27 @@ export function changedLiterals(file: string, oldText: string, newText: string):
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 stem. */
/**
* Test files that still carry the old literal beside the constant's name or
* its module. The module counts only as a path fragment (`/install.sh`,
* `/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 stem = path.basename(change.file).replace(/\.[^.]+$/, '');
const base = path.basename(change.file);
const stem = base.replace(/\.[^.]+$/, '');
const moduleRe = new RegExp(`(?:^|[/\\\\])(?:${escape(base)}|${escape(stem)})(?![\\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) || text.includes(stem))) 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; }
}
+22 -1
View File
@@ -11,9 +11,10 @@
*/
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 { changedLiterals, constantsIn } from '../lib/pregate/checks/literals';
import { changedLiterals, constantsIn, consumersOf } from '../lib/pregate/checks/literals';
import { parseSummary, planRuns } from '../lib/pregate/checks/lanes';
import { DEFAULT_CONFIG, validateConfig } from '../lib/pregate/config';
import { isDocsEmptyLane } from '../lib/pregate/docs-lane';
@@ -200,6 +201,26 @@ describe('literals (pure)', () => {
{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' },
]);
});
test('consumersOf: a module stem is a path fragment, never a bare word, and a one-character old value needs the constant name (GSTA-114 flood: install.sh 2 -> 3 flagged 575 consumers)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-'));
const nl = String.fromCharCode(10);
const files: Record<string, string> = {
'test/wrapper.test.ts': ["import { x } from '../capy/skill/gstack/scripts/install.sh';", 'expect(GSTACK_CAPY_WRAPPER).toBe(2);', ''].join(nl),
'test/by-path.test.ts': ["const out = run('scripts/install.sh');", 'expect(out.wrapper).toBe(2);', ''].join(nl),
'test/prose.test.ts': ['// run install first, then check 2 things', 'expect(count).toBe(2);', ''].join(nl),
'test/other.test.ts': ['expect(install.steps.length).toBe(2);', ''].join(nl),
'test/retry.test.ts': ["expect(require('../lib/retry.js').TIMEOUT_MS).toBe(5000);", ''].join(nl),
'test/retry-path.test.ts': ["const mod = require('../lib/retry.js');", 'expect(mod.wait()).toBe(5000);', ''].join(nl),
'test/retry-words.test.ts': ['// the retry budget was 5000 once', 'expect(elapsed).toBeLessThan(5000);', ''].join(nl),
};
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
const all = Object.keys(files);
// A one-character value: only the file that names the constant counts; the path fragment alone is not enough for "2".
expect(consumersOf(root, all, { name: 'GSTACK_CAPY_WRAPPER', old: '2', new: '3', file: 'capy/skill/gstack/scripts/install.sh' })).toEqual(['test/wrapper.test.ts:2']);
// A longer value: the constant's name or the module as a path fragment; the bare word "retry" in prose does not count.
expect(consumersOf(root, all, { name: 'TIMEOUT_MS', old: '5000', new: '9000', file: 'lib/retry.js' })).toEqual(['test/retry.test.ts:1', 'test/retry-path.test.ts:2']);
fs.rmSync(root, { recursive: true, force: true });
});
test('runner summaries: bun, node --test, vitest, jest, pytest, zero-run', () => {
expect(parseSummary(' 12 pass\n 1 fail\nRan 13 tests across 2 files.')).toEqual({ ran: 13, failed: 1 });
expect(parseSummary('# tests 4\n# pass 4\n# fail 0')).toEqual({ ran: 4, failed: 0 });