mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
fix(pregate): a lane-less policy keeps no-policy verdicts; warn lanes runs the tests; [] keeps removed-test suites (GST-775)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f739756389
commit
a9ccb3e411
13 files changed
+294
-51
No files matched your search
+19
-5
@@ -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 <name>` 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.
|
||||
|
||||
<a id="lane-requirements"></a>
|
||||
**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 — <file> 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
|
||||
|
||||
+10
-5
@@ -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 `<state root>/projects/<slug>/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 `<state root>/projects/<slug>/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`.
|
||||
|
||||
<a id="pregate-environment"></a>
|
||||
### `PREGATE_ENVIRONMENT`
|
||||
|
||||
+38
-12
@@ -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 — <file> 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 — <file> 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<ReturnType<typeof runLocal>>
|
||||
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<CheckResult> {
|
||||
@@ -510,16 +534,18 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-8
@@ -13,8 +13,9 @@
|
||||
* depends on it; 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
|
||||
* 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 `<workflow.yml>/<job>` 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
|
||||
* `<state root>/projects/<slug>/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<PregateConfig>;
|
||||
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: <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`,
|
||||
`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: <workflow.yml>/<job> 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',
|
||||
|
||||
@@ -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`)),
|
||||
|
||||
@@ -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(', ')}`),
|
||||
};
|
||||
}
|
||||
@@ -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})` };
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet<string
|
||||
const uncovered = namedBy(ctx, file).filter(f => !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<string, string>();
|
||||
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);
|
||||
|
||||
@@ -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<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy' | 'configKind' | 'configLabel' | 'configFile'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort();
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
const todo: string[] = [];
|
||||
@@ -43,7 +44,9 @@ export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | '
|
||||
const dirs = [...dirCounts].sort((a, b) => 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)'}`,
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof makeFixtureRepo>[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<string, string> = {}) {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
|
||||
Reference in new issue
Block a user