mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
fix(pregate): treat planning and evidence markdown as an empty lane
A docs-only diff with no .gstack/pregate.json failed lanes as
PREGATE_NO_LANE. Planning and evidence Markdown is now a docs/no-tests
empty lane. Unmapped source files still fail closed.
Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit e8793e59e8)
This commit is contained in:
1 parent
83d9c9717f
commit
801195f4e3
9 files changed
+208
-15
No files matched your search
@@ -5,6 +5,8 @@
|
||||
* (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.
|
||||
* Planning and evidence Markdown is the docs/no-tests empty lane instead
|
||||
* (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does.
|
||||
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
|
||||
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
|
||||
*/
|
||||
@@ -99,6 +101,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
|
||||
function describe(sel: Selection): string[] {
|
||||
const lines = sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
|
||||
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
|
||||
for (const r of sel.remote) lines.push(`requires-remote ${r.lane} (${r.platform}): ${r.tests.length} test(s) — ${r.tests.slice(0, 8).join(' ')}${r.tests.length > 8 ? ' …' : ''}`);
|
||||
lines.push(`Tiers ran: ${sel.tiersRan.join(' ') || '(none)'}; requires-remote: ${[...new Set(sel.remote.map(r => r.platform))].join(' ') || 'none'}`);
|
||||
return lines;
|
||||
@@ -182,9 +185,14 @@ 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 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 .gstack/pregate.json' };
|
||||
}
|
||||
if (sel.tests.length === 0) {
|
||||
const detail = sel.emptyLane.length
|
||||
? `docs/no-tests: ${sel.emptyLane.length} documentation file(s); nothing touched selects a test`
|
||||
: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files';
|
||||
return { ...base, detail };
|
||||
}
|
||||
if (sel.tests.length === 0) return { ...base, detail: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files' };
|
||||
if (ctx.remote && sel.localTests.length) return runRemote(ctx, sel, base);
|
||||
const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local');
|
||||
if (smallMachine) {
|
||||
|
||||
+15
-3
@@ -8,6 +8,9 @@
|
||||
* dependencies glob → test files for subprocess targets, runtime reads and
|
||||
* generated inputs the import graph cannot see; `[]` declares
|
||||
* "no lane needed" for docs and the like
|
||||
* empty_lanes extra globs for the docs/no-tests empty lane (planning and
|
||||
* evidence Markdown already qualifies without a pin file;
|
||||
* source code and test trees never do)
|
||||
* selection `<workflow.yml>/<job>` → the runner-owned command that lists
|
||||
* the lane's test files when the matrix is planner-computed
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
|
||||
@@ -44,6 +47,8 @@ export interface PregateConfig {
|
||||
$schema?: string;
|
||||
runner: string | null;
|
||||
dependencies: Record<string, string[]>;
|
||||
/** Extra docs/no-tests globs. Built-in planning and evidence Markdown always qualifies. */
|
||||
empty_lanes: string[];
|
||||
selection: Record<string, string>;
|
||||
warn: string[];
|
||||
strays: { allow: string[] };
|
||||
@@ -54,12 +59,12 @@ export interface PregateConfig {
|
||||
remote_gate: string | null;
|
||||
}
|
||||
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
|
||||
export interface ConfigError { code: ResultCodeName; path: string; message: string }
|
||||
|
||||
const MAX_LIST = 256;
|
||||
const MAX_COMMAND = 512;
|
||||
const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
|
||||
const MAX_GUARDS = 32;
|
||||
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
|
||||
|
||||
@@ -125,6 +130,11 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
if (!Array.isArray(list) || list.some(g => typeof g !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'serial_patterns', message: 'must be an array of globs' });
|
||||
else (list as string[]).forEach((g, i) => { const why = globError(g); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `serial_patterns[${i}]`, message: `${JSON.stringify(g)} ${why}` }); });
|
||||
}
|
||||
if ('empty_lanes' in obj) {
|
||||
const list = obj.empty_lanes;
|
||||
if (!Array.isArray(list) || list.some(g => typeof g !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'empty_lanes', message: 'must be an array of globs (extra docs/no-tests paths; source files and test trees never qualify)' });
|
||||
else (list as string[]).forEach((g, i) => { const why = globError(g); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `empty_lanes[${i}]`, message: `${JSON.stringify(g)} ${why}` }); });
|
||||
}
|
||||
if ('dependencies' in obj) {
|
||||
const deps = obj.dependencies;
|
||||
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'dependencies', message: 'must be an object of glob → test file list' });
|
||||
@@ -176,7 +186,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
|
||||
const errors = validateConfig(raw, knownChecks);
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors };
|
||||
const o = raw as Partial<PregateConfig>;
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
|
||||
@@ -210,6 +220,7 @@ export function exampleConfigJson(): string {
|
||||
$schema: PREGATE_CONFIG_SCHEMA,
|
||||
runner: null,
|
||||
dependencies: { 'bin/example-tool': ['test/example-tool.test.ts'], 'docs/**': [] },
|
||||
empty_lanes: [],
|
||||
selection: {},
|
||||
warn: [],
|
||||
strays: { allow: [] },
|
||||
@@ -221,6 +232,7 @@ export function explainConfig(): string {
|
||||
return [
|
||||
'runner: the test command with a {files} placeholder; null detects bun test / vitest / jest / pytest from the repo',
|
||||
'dependencies: glob → test files the import graph cannot see (subprocess targets, runtime reads, generated inputs); [] declares no lane needed',
|
||||
'empty_lanes: extra globs treated as docs/no-tests when nothing else maps them. Built-in, with or without this file: a docs/doc/documentation tree, a .work plan, plans/evidence/redproof directories, markdown whose name says plan, planning, redproof or evidence, and README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE. Source code and files under a test directory never qualify. A test that names the file still selects that test',
|
||||
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
|
||||
'strays.allow: root files or new top-level directories that are intentional',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* pregate docs/no-tests — planning and evidence Markdown is an empty lane.
|
||||
*
|
||||
* A repo with no `.gstack/pregate.json` used to fail `lanes` on a tracked
|
||||
* `.work` plan or a redproof note (`PREGATE_NO_LANE`). Those files document
|
||||
* a change; they are not test subjects. `isDocsEmptyLane` is that class.
|
||||
* `lane-selection` consults it only when nothing else maps the file, so a
|
||||
* test that names the file still runs. Source code and anything under a
|
||||
* test directory never qualify, including when `empty_lanes` globs match
|
||||
* them. Unmapped code stays `PREGATE_NO_LANE`.
|
||||
*/
|
||||
import { SOURCE_EXT } from './import-graph';
|
||||
import { matchesAny } from '../ship-policy';
|
||||
|
||||
const TEST_TREE = /(?:^|\/)(?:test|tests|spec|__tests__)(?:\/|$)/i;
|
||||
const MARKDOWN = /\.(?:md|markdown)$/i;
|
||||
/** Root-level prose names, with or without `.md`. */
|
||||
const PROSE_NAME = /^(?:README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE|ETHOS|LICENSE|LICENCE|COPYING|NOTICE)(?:\.md)?$/i;
|
||||
/** A documentation tree. `doc` does not match `docstring`; `.work` is the plan workspace. */
|
||||
const DOC_DIR = /(?:^|\/)(?:\.work|docs|doc|documentation|plans|evidence|redproof)(?:\/|$)/i;
|
||||
/**
|
||||
* A planning or evidence basename: `plan.md`, `implementation-plan.md`,
|
||||
* `planning.md`, `redproof.md`, `red-proof.md`, `evidence.md`.
|
||||
* `planet.md` and `explanation.md` do not match.
|
||||
*/
|
||||
const EVIDENCE_NAME = /(?:^|[^a-z0-9])(?:planning|plans|plan|red-?proofs?|evidence)(?:[^a-z0-9]|$)/i;
|
||||
|
||||
function isMarkdown(rel: string): boolean {
|
||||
return MARKDOWN.test(rel);
|
||||
}
|
||||
|
||||
function isTestTree(rel: string): boolean {
|
||||
return TEST_TREE.test(rel);
|
||||
}
|
||||
|
||||
/** Code the empty-lane class must not swallow. Extensionless `bin/` entrypoints count; `bin/README.md` does not. */
|
||||
function isSourceCode(rel: string): boolean {
|
||||
if (SOURCE_EXT.test(rel)) return true;
|
||||
const base = rel.split('/').pop() ?? rel;
|
||||
if (rel.startsWith('bin/') && !isMarkdown(rel) && !PROSE_NAME.test(base)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBuiltinDocsPath(rel: string): boolean {
|
||||
const base = rel.split('/').pop() ?? rel;
|
||||
if (PROSE_NAME.test(base)) return true;
|
||||
if (!isMarkdown(rel)) return false;
|
||||
if (DOC_DIR.test(rel)) return true;
|
||||
return EVIDENCE_NAME.test(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `rel` is documentation or evidence that needs no test lane.
|
||||
* `extraGlobs` are the repo's `empty_lanes` (`.gstack/pregate.json` on the
|
||||
* base branch). They add paths; they cannot classify source code or a file
|
||||
* under a test directory.
|
||||
*/
|
||||
export function isDocsEmptyLane(rel: string, extraGlobs: readonly string[] = []): boolean {
|
||||
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
if (!norm || isTestTree(norm) || isSourceCode(norm)) return false;
|
||||
if (extraGlobs.length > 0 && matchesAny(extraGlobs, norm)) return true;
|
||||
return isBuiltinDocsPath(norm);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { GENERATED_PATH } from '../regen';
|
||||
import { runBashSync } from '../script-invocation';
|
||||
import { matchesAny } from '../ship-policy';
|
||||
import { PREGATE_CONFIG_PATH } from './config';
|
||||
import { isDocsEmptyLane } from './docs-lane';
|
||||
import { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph';
|
||||
import type { PregateContext, RemoteObligation } from './types';
|
||||
import type { Lane } from './workflows';
|
||||
@@ -18,6 +19,8 @@ import type { Lane } from './workflows';
|
||||
export interface Selection {
|
||||
/** Touched files with no lane and no `[]` declaration. */
|
||||
noLane: string[];
|
||||
/** Documentation and evidence files with no mapped test: the docs/no-tests empty lane. */
|
||||
emptyLane: string[];
|
||||
/** Every test the touched files select, sorted. */
|
||||
tests: string[];
|
||||
/** Tests that run on this machine. */
|
||||
@@ -76,6 +79,7 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
const provenance: string[] = [];
|
||||
const tests = new Set<string>();
|
||||
const noLane: string[] = [];
|
||||
const emptyLane: string[] = [];
|
||||
const seeds = new Map<string, string>();
|
||||
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
|
||||
for (const e of ctx.registry.entries) {
|
||||
@@ -86,7 +90,12 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
const m = testsFor(graph, file, ctx.config.dependencies);
|
||||
for (const t of m.tests) tests.add(t);
|
||||
provenance.push(...m.provenance.map(p => `${p} [${why}]`));
|
||||
if (m.tests.length || m.declaredNone || why.startsWith('generated')) continue;
|
||||
if (m.tests.length || why.startsWith('generated')) continue;
|
||||
// An explicit `[]` already cleared the file. Label it docs/no-tests only when it is that class.
|
||||
if (m.declaredNone) {
|
||||
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file);
|
||||
continue;
|
||||
}
|
||||
// A renamed file keeps the lane its old name had: those tests run (and fail loudly if they still import the old path).
|
||||
const renamedFrom = touched.find(t => t.path === file && t.renamedFrom)?.renamedFrom;
|
||||
if (renamedFrom && testsFor(graph, renamedFrom, ctx.config.dependencies).tests.length) { provenance.push(`${file}: lane via its old name ${renamedFrom}`); continue; }
|
||||
@@ -95,6 +104,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
if (generator) { provenance.push(`${file}: covered by regen ${generator.id} (registry input)`); continue; }
|
||||
// The pin files are validated by pregate itself (validateConfig / validateRegistry) on every run.
|
||||
if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; }
|
||||
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) {
|
||||
emptyLane.push(file);
|
||||
provenance.push(`${file}: docs/no-tests (empty lane)`);
|
||||
continue;
|
||||
}
|
||||
noLane.push(file);
|
||||
}
|
||||
const laneSets: Record<string, string[] | '?'> = {};
|
||||
@@ -117,7 +131,7 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
|
||||
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
|
||||
if (local.size && tiers.size === 0) tiers.add('unit');
|
||||
return { noLane: noLane.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
|
||||
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
|
||||
}
|
||||
|
||||
export function isTest(file: string): boolean { return isTestFile(file); }
|
||||
+2
-2
@@ -381,8 +381,8 @@ export const RESULT_CODES = {
|
||||
},
|
||||
PREGATE_NO_LANE: {
|
||||
anchor: 'pregate-no-lane',
|
||||
summary: 'a touched file maps to no test lane through imports, repo-path literals, the bin/<name> convention or a declaration',
|
||||
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json',
|
||||
summary: 'a touched source file maps to no test lane through imports, repo-path literals, the bin/<name> convention or a declaration',
|
||||
fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Planning and evidence Markdown is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`',
|
||||
},
|
||||
PREGATE_LANE_FAILED: {
|
||||
anchor: 'pregate-lane-failed',
|
||||
|
||||
Reference in new issue
Block a user