mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 09:23:50 +01:00
fix(pregate): policy fixes name the user-owned file when it is the active policy
With no .gstack/pregate.json on the base, an edit to that path is never read, so the no-lane, runner, stall_timeout_s, remote_gate and strays fixes now name ctx.configFile: the user-owned path when it is the active policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47173fccfd
commit
580a76f40b
5 files changed
+24
-8
No files matched your search
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { baseInputs, sha256 } from '../context';
|
||||
import { PREGATE_CONFIG_PATH } from '../config';
|
||||
import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
|
||||
@@ -103,7 +104,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
|
||||
const rest = r.failed ? `; ${r.failed} failed` : '';
|
||||
if (stalled.length) {
|
||||
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` };
|
||||
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
|
||||
}
|
||||
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
|
||||
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' };
|
||||
@@ -163,7 +164,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
|
||||
*/
|
||||
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> {
|
||||
const lines = base.lines ?? [];
|
||||
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' };
|
||||
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` };
|
||||
const obligation = remoteObligation(ctx, sel);
|
||||
const cap = ctx.remote!.spendCapUsd;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-'));
|
||||
@@ -195,7 +196,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
const lines = describe(sel);
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
|
||||
if (sel.noLane.length) {
|
||||
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: 'add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in .gstack/pregate.json' };
|
||||
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}` };
|
||||
}
|
||||
if (sel.tests.length === 0) {
|
||||
const detail = sel.emptyLane.length
|
||||
@@ -212,7 +213,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
|
||||
if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` };
|
||||
const runner = detectRunner(ctx, sel.localTests);
|
||||
if (!runner) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `no test runner detected for ${sel.localTests.length} selected file(s)`, fix: 'declare `runner` (with {files}) in .gstack/pregate.json' };
|
||||
if (!runner) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `no test runner detected for ${sel.localTests.length} selected file(s)`, fix: `declare \`runner\` (with {files}) in ${ctx.configFile}` };
|
||||
const runtime = launchRuntime(ctx.repoRoot, ctx.env);
|
||||
if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] };
|
||||
const r = await runLocal(ctx, runtime.env, runner, sel.localTests);
|
||||
|
||||
@@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult {
|
||||
if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`);
|
||||
}
|
||||
ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`);
|
||||
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' };
|
||||
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' };
|
||||
if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` };
|
||||
if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` };
|
||||
return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type
|
||||
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
|
||||
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
|
||||
import { policyUnavailableLine } from '../ship-policy';
|
||||
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import type { CheckResult, PregateContext, TouchedFile } from './types';
|
||||
import { discoverLanes } from './workflows';
|
||||
import type { RegistryError } from '../regen';
|
||||
@@ -128,7 +128,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
return {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface PregateContext {
|
||||
touched: TouchedFile[];
|
||||
config: PregateConfig;
|
||||
configLabel: string;
|
||||
/** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
|
||||
configFile: string;
|
||||
registry: Registry;
|
||||
registryLabel: string;
|
||||
lanes: Lane[];
|
||||
|
||||
@@ -79,6 +79,19 @@ describe('gstack-pregate with a user-owned policy', () => {
|
||||
expect(all.out).toMatch(/^tests {6}lanes {5}pass /m);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a fix that says where to declare policy names the user file when it is the active policy, since an edit to the
|
||||
// repo's .gstack/pregate.json is never read on a base that carries none; fails_when=the fix hardcodes .gstack/pregate.json;
|
||||
// why_new=the user source is new; seam=none; no_claim=every fix string (strays, runner, remote_gate share ctx.configFile)
|
||||
test('the no-lane fix names the user file when it is the active policy', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: LOCAL_ALL });
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
expect(run().status).not.toBe(EXIT.ok);
|
||||
const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
expect(lanes.code).toBe('PREGATE_NO_LANE');
|
||||
expect(lanes.fix).toContain(`under dependencies — or empty_lanes for other documentation — in ${userPolicy}`);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix
|
||||
// that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted
|
||||
// and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself
|
||||
|
||||
Reference in new issue
Block a user