diff --git a/docs/pregate.md b/docs/pregate.md index e6d45e4da..707cc6f72 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -75,10 +75,10 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | Key | Meaning | |---|---| | `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p ` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) | -| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) | +| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences). A `[]` that matches a deleted test does not skip it: the deleted test still runs its owning suite, and a file naming it that `[]` covers (a durations list) counts as covered. With this key present, even empty, the policy [declares lanes](#lane-requirements) | | `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test | | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | -| `warn` | check ids downgraded to warn; `secrets` and required lanes never | +| `warn` | check ids downgraded to warn; `secrets` and required lanes never. `"lanes"` downgrades only a touched file with no lane: the selected tests still run, a red lane stays `fail` (`PREGATE_LANE_FAILED`) and an unfinished one `incomplete`; a green run is `warn` `PREGATE_NO_LANE` naming the unmapped files | | `strays.allow` | intentional root files or new top-level directories | | `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | @@ -141,6 +141,19 @@ unmapped file is `PREGATE_NO_LANE` again: the warning is for the first /ship in a new repository (three Captains lost their first pass to it), never a way to hide missing coverage. `--policy-from head` with a head policy is strict too. + +**Lane requirements.** "Any policy" above means a policy that declares lanes: +one with a `dependencies`, `empty_lanes`, `selection` or `local` key, even an +empty one (`gstack-pregate init` writes the first three). A policy with none of +them, such as +`{"timeout_s": 1800}` to give long tests more time, keeps the no-policy lane +verdicts: an unmapped file is the `warn` `PREGATE_NO_POLICY`, the selected +tests run, a red lane is `fail`, and the starter says the active policy +declares no lanes instead of claiming there is none. So raising +`timeout_s` never needs a `"**": []` catch-all. Add `dependencies` when you +want unmapped files to fail. The same rule applies to the user-owned file and to +a `--policy-from head` bootstrap (gbrain-evals #272, GST-775). + `gstack-pregate init` writes a draft built from the repo, not a fixed example. It prints the languages it found and the runner each language's tests would get, then maps every file the import graph cannot place to the tests of the @@ -266,9 +279,10 @@ Two stages with separate clocks, both printed: `.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx` and `index.mjs` inside the directory; the first file that exists wins. A touched source file with no lane is `FAIL lanes — has no test lane` - once any policy exists (the base copy, the user-owned file or `--policy-from - head`); with no policy anywhere it is the `warn` `PREGATE_NO_POLICY` with a - starter printed ([no policy](#no-policy)); never a pass. Markdown that no + once a policy that [declares lanes](#lane-requirements) exists (the base copy, + the user-owned file or `--policy-from head`); with no policy anywhere, or one + that declares no lanes, it is the `warn` `PREGATE_NO_POLICY` with a starter + printed ([no policy](#no-policy)); never a pass. Markdown that no test depends on is a docs/no-tests empty lane instead (`docs/no-tests: N documentation file(s)`), including when the base branch has no `.gstack/pregate.json`. The dependency is the same import graph diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2fb72daf8..575871a04 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3561,9 +3561,12 @@ never runs them: declare `"**/integration_test/**": []` (and them. A `[]` declaration added only on the branch is refused under `--policy-from head` ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)), so land it in its own PR first. -With no policy anywhere (no base copy, no user-owned file) an unmapped file is +With no policy anywhere (no base copy, no user-owned file), or a policy with no +`dependencies`, `empty_lanes`, `selection` or `local` key (a timeout-only file), an unmapped file is the warning [`PREGATE_NO_POLICY`](#pregate-no-policy) with a starter policy -printed, not this error; `gstack-pregate init` writes that draft and lists the +printed, not this error. Under `warn: ["lanes"]` it is a `warn` with this code, +given only after the selected tests ran green (a red lane is still +`PREGATE_LANE_FAILED`); `gstack-pregate init` writes that draft and lists the files it could not place. `--explain` prints every mapping and why. For a file with no lane it also prints the import specifiers in test files that resolved to no repo file (for example a `tsconfig.json` `paths` alias such as `@/lib/x` whose target is missing) and any @@ -3701,8 +3704,9 @@ differing path is a change. ### `PREGATE_NO_POLICY` **Meaning.** The repository has no pregate policy yet (no `.gstack/pregate.json` -on the base branch and no user-owned `/projects//pregate.json`) -and a touched file maps to no test lane. Under no policy that is a `warn`, not +on the base branch and no user-owned `/projects//pregate.json`), +or its policy declares no lanes (no `dependencies`, `empty_lanes`, `selection` or `local` key, a +timeout-only file for example), and a touched file maps to no test lane. Under no policy that is a `warn`, not `PREGATE_NO_LANE`: the selected tests still ran (a red lane is still `fail`), and the table printed a starter policy drafted from the repository (`PREGATE_STARTER:` lines: runner, test directories, CI lanes, the JSON between @@ -3713,7 +3717,8 @@ Details: [docs/pregate.md](pregate.md#no-policy). overwrite an existing file without `--force`), edit each `TODO` (a test that names the file, a `dependencies` entry, or `[]` for a file with no behaviour), run `gstack-pregate validate`, then commit `.gstack/pregate.json` on the base -branch. From then on an unmapped file is `PREGATE_NO_LANE`. +branch. When a policy already exists, add the starter's `dependencies` to it +instead. From then on an unmapped file is `PREGATE_NO_LANE`. ### `PREGATE_ENVIRONMENT` diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index f1206404c..f3368b936 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -4,10 +4,12 @@ * `set -o pipefail` with the exit code and the runner's own summary judged * (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 — has no test lane` once any policy - * exists; with no policy anywhere (no base `.gstack/pregate.json`, no - * user-owned file) it is the `warn` PREGATE_NO_POLICY with a starter printed - * (lib/pregate/starter.ts), never a pass either way. + * with no lane is `FAIL lanes — has no test lane` once the policy + * declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key); with no policy + * anywhere, or one that declares neither (a timeout-only file), it is the + * `warn` PREGATE_NO_POLICY with a starter printed (lib/pregate/starter.ts), + * never a pass either way. `warn: ["lanes"]` downgrades only that condition: + * the selected tests still run and a red lane stays `fail` (GST-775). * Markdown no test depends on is the docs/no-tests empty lane instead * (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does. * A release manifest whose diff against the merge base changes only version @@ -347,8 +349,14 @@ function unfinished(ctx: PregateContext, r: Awaited> return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--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' }; } -function describe(sel: Selection, noPolicy = false): string[] { - const lines = noPolicy ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] : sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`); +/** How a touched file with no lane is judged: the no-policy warning, the `warn: ["lanes"]` downgrade, or the hard PREGATE_NO_LANE. */ +type NoLaneMode = 'no-policy' | 'warned' | 'strict'; + +function describe(ctx: PregateContext, sel: Selection, mode: NoLaneMode): string[] { + const lines = mode === 'warned' ? sel.noLane.map(f => `WARN lanes — ${f} has no test lane (warn: ["lanes"] in ${ctx.configLabel}; the selected tests are still judged)`) + : mode === 'strict' ? sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`) + : ctx.configKind === 'none' ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] + : [`no lane policy: ${sel.noLane.join(' ')} have no test lane (a warning while ${ctx.configLabel} declares no dependencies, empty_lanes, selection or local; the starter below maps them)`]; for (const r of sel.removed) lines.push(`removed (no lane owed): ${r.file} — ${r.note}`); if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`); if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`); @@ -500,9 +508,25 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul */ function withStarter(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult { const lines = [...(result.lines ?? []), ...starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests))]; - const noted = `${sel.noLane.length} touched file(s) with no test lane under no policy (starter printed)`; + const noted = `${sel.noLane.length} touched file(s) with no test lane under ${ctx.configKind === 'none' ? 'no policy' : 'a policy that declares no lanes'} (starter printed)`; if (result.status !== 'pass') return { ...result, lines, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_POLICY' } : {}) }; - return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix: `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` }; + const fix = ctx.configKind === 'none' + ? `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` + : `review the starter above and add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'}, editing each TODO; from then on an unmapped file is PREGATE_NO_LANE`; + return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix }; +} + +/** + * `warn: ["lanes"]` under a policy that declares lanes: the selected tests ran + * and their verdict stands (a red lane is still `fail`, an unfinished one + * `incomplete`); only a green run becomes `warn` PREGATE_NO_LANE naming the + * files with no lane. Before GST-775 the downgraded failure returned before + * any test ran, so a red test beside an unmapped file passed. + */ +function withNoLaneWarning(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult { + const noted = `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')} (downgraded to warn by ${ctx.configLabel})`; + if (result.status !== 'pass') return { ...result, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_LANE' } : {}) }; + return { ...result, status: 'warn', code: 'PREGATE_NO_LANE', detail: `${result.detail}; ${noted}`, fix: noLaneFix(ctx, sel.noLane) }; } async function run(ctx: PregateContext): Promise { @@ -510,16 +534,18 @@ async function run(ctx: PregateContext): Promise { ctx.explain.push(...sel.provenance); for (const [lane, set] of Object.entries(sel.laneSets)) ctx.explain.push(`lane ${lane}: ${set === '?' ? 'file set unknown' : `${set.length} files`}`); const inputs = baseInputs(ctx, { selection: sha256(sel.tests.join('\n')), localTests: sha256(sel.localTests.join('\n')), remote: sha256(sel.remote.map(r => `${r.lane}:${r.tests.join(',')}`).join('\n')) }); - const noPolicy = sel.noLane.length > 0 && ctx.configKind === 'none'; - const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(sel, noPolicy), remote: sel.remote }; + const declared = ctx.configKind !== 'none' && ctx.policyDeclaresLanes !== false; + const mode: NoLaneMode = !declared ? 'no-policy' : ctx.config.warn.includes('lanes') ? 'warned' : 'strict'; + const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(ctx, sel, mode), remote: sel.remote }; const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size; const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : ''; const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r); - if (sel.noLane.length && !noPolicy) { + if (sel.noLane.length && mode === 'strict') { return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) }); } const result = withRemoved(await runSelected(ctx, sel, base)); - return noPolicy ? withStarter(ctx, sel, result) : result; + if (!sel.noLane.length) return result; + return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result); } /** diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index b30ad4c83..ac2a511d1 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -13,8 +13,9 @@ * depends on it; source code and test trees never do) * selection `/` → 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 - * its required-lane and requires-remote verdicts) + * warn check ids downgraded to warn (never `secrets`; `lanes` + * downgrades only a touched file with no lane, after the + * selected tests ran: a red lane stays `fail`) * strays.allow root files or new top-level directories that are intentional * local `/` lanes a local run satisfies (a * platform-agnostic toolchain, or a repo whose CI is off): @@ -49,6 +50,11 @@ * `env.value` (a configured credential's real value) and never * the whole `secret` category * + * A policy declares lanes when it has a `dependencies`, `empty_lanes`, + * `selection` or `local` key (LANE_KEYS), even an empty one; only then is a touched file with no lane + * the hard PREGATE_NO_LANE. A policy without either (a timeout-only file) + * keeps the no-policy lane verdicts (lib/pregate/checks/lanes.ts). + * * A repo that keeps `.gstack/` out of git can hold the same file at * `/projects//pregate.json` (lib/state-root.ts), honoured * only when the base carries none. It must sit outside the repo, so a PR @@ -254,17 +260,20 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] return errors; } -export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[] } { +/** The keys that declare lanes: with either present, a touched file with no lane is PREGATE_NO_LANE. */ +export const LANE_KEYS = ['dependencies', 'empty_lanes', 'selection', 'local'] as const; + +export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean } { let raw: unknown; try { raw = JSON.parse(text); } - catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }] }; } + catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }], declaresLanes: false }; } const errors = validateConfig(raw, knownChecks); - if (errors.length) return { config: DEFAULT_CONFIG, errors }; + if (errors.length) return { config: DEFAULT_CONFIG, errors, declaresLanes: false }; const o = raw as Partial; - 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 ?? [] }, local: o.local ?? [], 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, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] }; + return { declaresLanes: LANE_KEYS.some(k => k in o), 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 ?? [] }, local: o.local ?? [], 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, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] }; } -export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string }; +export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string; /** The policy has a LANE_KEYS key; absent under kind 'none'. */ declaresLanes?: boolean } | { unavailable: true; base: string; detail: string }; function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); @@ -333,7 +342,8 @@ export function explainConfig(): string { '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: any Markdown file the import graph maps to no test (a quoted path, a join() of path segments, an import, or a dependencies entry), plus README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE and licence text under any name; assets (images, fonts, media) and allowlisted tool config take their own empty lane. Source code and files under a test directory never qualify. A test that reads the file still selects that test', 'selection: / → 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`, + `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes downgrades only a touched file with no lane, after the selected tests ran (a red lane stays fail)`, + `lane requirements: a policy with any of ${LANE_KEYS.join(', ')} (even empty) makes a touched file with no lane PREGATE_NO_LANE; a policy with none of them (timeout_s only, say) keeps the no-policy warning (PREGATE_NO_POLICY) and starter`, 'strays.allow: root files or new top-level directories that are intentional', 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 5ed06266f..162db7a7e 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -247,7 +247,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, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + config, configLabel: configLoad.label, configKind: configLoad.kind, policyDeclaresLanes: configLoad.declaresLanes === true, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default', stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000, progress: o.progress ?? (line => process.stderr.write(`${line}\n`)), diff --git a/lib/pregate/head-policy.ts b/lib/pregate/head-policy.ts index 9ef2dd062..0d898cef6 100644 --- a/lib/pregate/head-policy.ts +++ b/lib/pregate/head-policy.ts @@ -66,7 +66,7 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string, for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])]; const n = Object.keys(adds).length; return { - load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` }, + load: { ...trusted, config: { ...trusted.config, dependencies }, declaresLanes: trusted.declaresLanes === true || head.declaresLanes === true, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` }, explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`), }; } diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 81233836c..aea1fc748 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -37,12 +37,12 @@ export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches' export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string } -/** Apply the repo's `warn` downgrades: never `secrets`, and never a lane that could run here and failed. */ +/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */ export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult { if (result.status !== 'fail') return result; if (!def.downgradable || (NON_DOWNGRADABLE as readonly string[]).includes(def.id)) return result; if (!ctx.config.warn.includes(def.id)) return result; - if (def.id === 'lanes' && result.code === 'PREGATE_LANE_FAILED') return { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` }; + if (def.id === 'lanes') return result.code === 'PREGATE_LANE_FAILED' ? { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` } : result; return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` }; } diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 5ca134179..3e0f9bafe 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -165,7 +165,7 @@ function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet !gone.has(f)).filter(n => { const m = testsFor(graph, n, ctx.config.dependencies); for (const t of m.tests) namerSuite.add(t); - return !m.tests.length; + return !m.tests.length && !m.declaredNone; }); if (uncovered.length) return { refused: `removed, but still named by ${shown(uncovered)}, which no test covers` }; const namerNote = namerSuite.size ? `files naming it run: ${shown([...namerSuite].sort())}` : ''; @@ -211,6 +211,11 @@ export function selectLanes(ctx: PregateContext): Selection { const stampOnly: string[] = []; const seeds = new Map(); const touchedByPath = new Map(touched.map(t => [t.path, t])); + const waive = (file: string, r: { removed: Removed; suite: string[] }) => { + removed.push(r.removed); + for (const t of r.suite) tests.add(t); + provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`); + }; 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) { if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue; @@ -221,8 +226,10 @@ export function selectLanes(ctx: PregateContext): Selection { for (const t of m.tests) tests.add(t); provenance.push(...m.provenance.map(p => `${p} [${why}]`)); 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. + // An explicit `[]` already cleared the file. A removed test still runs its owning suite; label the rest docs/no-tests only when it is that class. if (m.declaredNone) { + const r = gone.includes(file) ? removedPath(ctx, graph, goneSet, file) : null; + if (r && 'removed' in r) { waive(file, r); continue; } if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file); continue; } @@ -259,12 +266,7 @@ export function selectLanes(ctx: PregateContext): Selection { } if (gone.includes(file)) { const r = removedPath(ctx, graph, goneSet, file); - if ('removed' in r) { - removed.push(r.removed); - for (const t of r.suite) tests.add(t); - provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`); - continue; - } + if ('removed' in r) { waive(file, r); continue; } provenance.push(`${file}: ${r.refused}; no lane until declared`); } noLane.push(file); diff --git a/lib/pregate/starter.ts b/lib/pregate/starter.ts index 8ed5a3954..2e27af84f 100644 --- a/lib/pregate/starter.ts +++ b/lib/pregate/starter.ts @@ -2,7 +2,8 @@ * pregate/starter — the policy a repository with none would start from * (Lane D, GSTA-112; FOUN #292, harness-evals #81/#82). When no * `.gstack/pregate.json` exists on the base branch and no user-owned file is - * present, the lanes check does not fail a touched file with no lane + * present, or the active policy declares no lanes (no `dependencies` or + * `empty_lanes`, `selection` or `local` key, GST-775), the lanes check does not fail a touched file with no lane * (PREGATE_NO_LANE); it warns (PREGATE_NO_POLICY) and prints this starter: * the detected runner, a `dependencies` entry for each unmapped touched file * (the tests nearest it, else the tests sharing its stem; never `[]`, which @@ -29,7 +30,7 @@ export function nearestTestsFor(tests: readonly string[], file: string): string[ } /** The starter for `noLane` (touched files with no lane) under `runner` (detected by the lanes check; null when none). */ -export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): string[] { +export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): string[] { const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort(); const dependencies: Record = {}; const todo: string[] = []; @@ -43,7 +44,9 @@ export function starterPolicy(ctx: Pick b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_DIRS).map(([d]) => d); const lanes = ctx.lanes.filter(l => l.runsTests && !l.manual).map(l => `${l.id} (${l.platform}${l.matrixExpression && l.testFiles.length === 0 && !ctx.config.selection[l.id] ? ', matrix computed; declare selection' : ''})`); return [ - `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`, + ctx.configKind === 'none' + ? `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)` + : `PREGATE_STARTER: ${ctx.configLabel} declares no lanes (no dependencies, empty_lanes, selection or local key); a starter drafted from this repository follows. Review it, add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'} and edit each TODO (from then on an unmapped file is PREGATE_NO_LANE)`, `PREGATE_STARTER: runner ${runner ?? 'null (none detected; set runner with {files})'}`, `PREGATE_STARTER: test directories: ${dirs.join(', ') || '(no test files found)'}${dirCounts.size > MAX_DIRS ? ` (+${dirCounts.size - MAX_DIRS} more)` : ''}`, `PREGATE_STARTER: CI lanes: ${lanes.join(', ') || '(no test job in .github/workflows)'}`, diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 0a4e8a731..67d68c78d 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -35,6 +35,8 @@ export interface PregateContext { configLabel: string; /** Where the policy came from: the base branch, the unreviewed head, the user-owned file, or nowhere (defaults). */ configKind: 'base' | 'head' | 'user' | 'none'; + /** The active policy has a `dependencies`, `empty_lanes`, `selection` or `local` key (config LANE_KEYS); false keeps the no-policy lane verdicts. Absent counts as declared. */ + policyDeclaresLanes?: boolean; /** This run's scratch directory under the state root (lib/pregate/run-scratch.ts): logs and scan files, never os.tmpdir(). */ scratch: string; /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */ diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 09a36afe4..9dc34904f 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -303,8 +303,8 @@ export const RELEASE_RESULT_CODES = { // Lane D (GSTA-112): the no-policy warning and the environment-cause verdict. PREGATE_NO_POLICY: { anchor: 'pregate-no-policy', - summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file) and a touched file maps to no test lane; a warn, with a starter policy printed', - fix: 'review the printed starter, write it with `gstack-pregate init`, edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE', + summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file), or its policy declares no lanes (no dependencies, empty_lanes, selection or local key), and a touched file maps to no test lane; a warn, with a starter policy printed', + fix: 'review the printed starter, write it with `gstack-pregate init` (or add its dependencies to the existing policy), edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE', }, PREGATE_ENVIRONMENT: { anchor: 'pregate-environment', diff --git a/test/pregate-policy-lane-verdicts.test.ts b/test/pregate-policy-lane-verdicts.test.ts new file mode 100644 index 000000000..f4fdbd42d --- /dev/null +++ b/test/pregate-policy-lane-verdicts.test.ts @@ -0,0 +1,181 @@ +/** + * GST-775 (from GBEV-3, gbrain-evals #272 and GBEV-22): `.gstack/pregate.json` could not raise the test timeout without + * changing lane verdicts. (1) Any policy file, even `{"timeout_s": 1800}`, turned a touched file with no test lane into a + * hard PREGATE_NO_LANE that returned before any test ran; with no file it only warned. (2) `warn: ["lanes"]` failed open: + * the downgraded PREGATE_NO_LANE returned before the selected tests ran, so a red test beside an unmapped file passed. + * (3) A `[]` declaration that matched a deleted test (the `"**": []` workaround) cleared it before the removed-test + * handling, so the deleted test's owning suite no longer ran. A policy that declares lanes (a `dependencies`, + * `empty_lanes`, `selection` or `local` key) keeps the hard PREGATE_NO_LANE. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +const fixture = (opts?: Parameters[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +/** The fixture's planner-computed Windows lane replaced by a lint job, so the selected tests run here instead of becoming a requires-remote obligation. */ +const NO_PLATFORM_LANE = { '.github/workflows/windows.yml': 'name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-24.04\n steps:\n - run: echo lint\n' }; +const SCHEMA = BASE_PREGATE.$schema; +const UNMAPPED = { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }; +const GREEN = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }; +const RED = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => a - b;\n' }; +const UTIL_V2 = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { add } = require('../lib/util.js');\ntest('adds again', () => assert.strictEqual(add(2, 2), 4));\n"; + +function lanes(repo: FixtureRepo, extra: string[] = [], env: Record = {}) { + const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', ...extra], env); + const out = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')); + const row = out.checks.find((c: { id: string }) => c.id === 'lanes'); + const lines: string[] = row.lines ?? []; + return { ...r, row, lines, ran: lines.find(l => l.startsWith('ran: ')) ?? '', removed: lines.filter(l => l.startsWith('removed (no lane owed): ')) }; +} + +describe('a policy that declares no lanes keeps the no-policy lane verdicts (GST-775 bug 1)', () => { + // Value: protects=a timeout-only policy (gbrain-evals #272's intent) leaves an unmapped file a warning with the selected tests run, + // exactly as with no policy; fails_when=any policy file turns the unmapped file into a hard PREGATE_NO_LANE before the tests run; + // why_new=the lane-requirement condition is new; seam=none; no_claim=the 600 s default changes + test.each([ + ['timeout-only', { $schema: SCHEMA, timeout_s: 1800 }], + ['runner, warn and strays', { $schema: SCHEMA, runner: 'node --test {files}', warn: ['strays'], strays: { allow: ['Makefile'] } }], + ])('%s policy: unmapped file warns and the selected test runs, like no policy', (_name, policy) => { + const none = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE }); + commit(none, GREEN); + const n = lanes(none); + const withPolicy = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE }); + commit(withPolicy, GREEN); + const p = lanes(withPolicy); + expect(p.err).not.toContain('PREGATE_NO_LANE'); + expect([p.status, p.row.status, p.row.code]).toEqual([n.status, n.row.status, n.row.code]); + expect([p.status, p.row.status, p.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_POLICY']); + expect(p.ran).toContain('test/util.test.js'); + expect(p.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/); + expect(p.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(true); + }); + + // Value: protects=the warning never hides a red test under a lane-less policy; fails_when=the timeout-only branch returns warn for a + // failing lane; why_new=new branch; seam=none; no_claim=none + test('timeout-only policy: a red selected test beside an unmapped file is PREGATE_LANE_FAILED', () => { + const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE }); + commit(repo, RED); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + expect(r.ran).toContain('test/util.test.js'); + }); + + // Value: protects=the starter names the policy that exists instead of claiming there is none; fails_when=the starter header says + // "no .gstack/pregate.json" while one is on the base; why_new=new wording; seam=none; no_claim=none + test('the starter says the active policy declares no lanes, not that there is no policy', () => { + const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const r = lanes(repo); + const header = r.lines.find(l => l.startsWith('PREGATE_STARTER: ')) ?? ''; + expect(header).toContain('declares no lanes'); + expect(header).not.toContain('no .gstack/pregate.json on'); + }); +}); + +describe('warn: ["lanes"] never lets a red test pass (GST-775 bug 2)', () => { + const warnLanes = { ...BASE_PREGATE, warn: ['strays', 'lanes'] }; + + // Value: protects=the documented escape runs the selected tests and keeps a red lane fail; fails_when=the downgraded PREGATE_NO_LANE + // returns before the tests run (GBEV-22's fail-open); why_new=the warn path is new; seam=none; no_claim=none + test('a red selected test beside an unmapped file fails with PREGATE_LANE_FAILED', () => { + const repo = fixture({ pregate: warnLanes }); + commit(repo, RED); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + expect(r.ran).toContain('test/util.test.js'); + }); + + // Value: protects=only the missing-lane condition becomes the warning, after the tests ran green; fails_when=the warn row carries + // no test run, or the unmapped file is dropped from the row; why_new=new; seam=none; no_claim=none + test('a green run with an unmapped file is warn PREGATE_NO_LANE, naming the file, after the tests ran', () => { + const repo = fixture({ pregate: warnLanes }); + commit(repo, GREEN); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_LANE']); + expect(r.ran).toContain('test/util.test.js'); + expect(r.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/); + expect(r.row.detail).toContain('lib/orphan.js'); + expect(r.row.detail).toContain('downgraded to warn'); + }); +}); + +describe('a [] declaration does not skip a deleted test\'s owning suite (GST-775 bug 3)', () => { + const catchAll = { ...BASE_PREGATE, dependencies: { '**': [] } }; + + // Value: protects=gbrain-evals' "**": [] catch-all still runs a deleted test's owning suite; fails_when=the [] declaration clears + // the deleted test before removedPath(); why_new=the ordering is new; seam=none; no_claim=none + test('deleting a test under "**": [] runs its owning suite', () => { + const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2 } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.row.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).toContain('test/util-v2.test.js'); + }); + + // Value: protects=a file naming the deleted test that [] declares needs no lane (a durations manifest) does not stop the suite; + // fails_when=the namer reads as uncovered and the removal is cleared without its suite; why_new=new; seam=none; no_claim=none + test('a curation file that names the deleted test and is declared [] does not stop the owning suite', () => { + const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2, 'scripts/durations.json': '{"test/util.test.js": 1}\n' } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.row.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).toContain('test/util-v2.test.js'); + }); +}); + +describe('negative controls: a policy that declares lanes still fails hard', () => { + // Value: protects=declaring dependencies, empty_lanes, selection or local, even empty, keeps PREGATE_NO_LANE a hard stop before any test runs; + // fails_when=the lane-less branch fires for every policy; why_new=the boundary is new; seam=none; no_claim=none + test.each([ + ['dependencies', { ...BASE_PREGATE }], + ['an empty dependencies object', { $schema: SCHEMA, timeout_s: 1800, dependencies: {} }], + ['an empty empty_lanes list', { $schema: SCHEMA, timeout_s: 1800, empty_lanes: [] }], + ['an empty selection object', { $schema: SCHEMA, timeout_s: 1800, selection: {} }], + ['an empty local list', { $schema: SCHEMA, timeout_s: 1800, local: [] }], + ])('a base policy with %s keeps PREGATE_NO_LANE', (_name, policy) => { + const repo = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']); + expect(r.ran).toBe(''); + expect(r.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(false); + }); + + // Value: protects=the user-owned policy follows the same boundary; fails_when=the user file is treated differently from the base + // copy; why_new=new; seam=none; no_claim=none + test('a user-owned policy that declares dependencies keeps PREGATE_NO_LANE', () => { + const repo = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const userPolicy = path.join(repo.home, 'projects', 'gst775slug', 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } })); + const r = lanes(repo, [], { GSTACK_PROJECT_SLUG: 'gst775slug' }); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']); + }); + + // Value: protects=without a [] declaration a deleted test still runs its owning suite and an unmapped deleted source still fails; + // fails_when=moving the removed-test handling waives non-test removals; why_new=new; seam=none; no_claim=none + test('a deleted source file under a lane-declaring policy still owes a lane', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null, 'lib/util.js': null }); + const r = lanes(repo); + expect(r.row.code).toBe('PREGATE_NO_LANE'); + expect(r.row.detail).toContain('lib/util.js'); + }); + + // Value: protects=warn: ["lanes"] does not downgrade a red lane even with every file mapped; fails_when=the lanes downgrade starts + // applying to PREGATE_LANE_FAILED; why_new=regression guard; seam=none; no_claim=none + test('warn: ["lanes"] with every file mapped keeps a red lane fail', () => { + const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['strays', 'lanes'] } }); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a - b;\n' }); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + }); +}); diff --git a/test/pregate-starter.test.ts b/test/pregate-starter.test.ts index fd911b866..f21ec3b81 100644 --- a/test/pregate-starter.test.ts +++ b/test/pregate-starter.test.ts @@ -85,8 +85,8 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE expect(r.out).toContain('no policy: lib/orphan.js scripts/report.js have no test lane'); }); - // Value: protects=a repository with a policy keeps the hard PREGATE_NO_LANE (base copy, and the user-owned file); fails_when=the - // warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none + // Value: protects=a repository with a policy that declares lanes keeps the hard PREGATE_NO_LANE (base copy, the user-owned file and a + // head bootstrap; a runner-only policy declares none since GST-775); fails_when=the warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none test('a base policy or a user-owned policy keeps PREGATE_NO_LANE as a hard failure', () => { const withBase = fixture(); commit(withBase, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); @@ -98,14 +98,14 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE commit(withUser, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); const userPolicy = path.join(withUser.home, 'projects', 'starterslug', 'pregate.json'); fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); - fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}' })); + fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } })); const u = lanes(withUser, [], { GSTACK_PROJECT_SLUG: 'starterslug' }); expect(u.status).toBe(EXIT.fail); expect(u.err).toContain('(PREGATE_NO_LANE)'); expect(u.out).not.toContain('PREGATE_STARTER'); // --policy-from head with a head policy (no base copy) is strict too. const withHead = fixture({ pregate: null }); - commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}' }) + '\n' }); + commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } }) + '\n' }); const h = lanes(withHead, ['--policy-from', 'head']); expect(h.status).toBe(EXIT.fail); expect(h.err).toContain('(PREGATE_NO_LANE)');