Files
gstack/lib/pregate/checks/lanes.ts
669e59d65b v1.91.206.0 feat(pregate): user-owned policy file and local lanes (port of #3286) (#3398)
* feat(pregate): user-owned policy file and local lanes

A repo that keeps .gstack/ out of git can hold its pregate policy at
<GSTACK_HOME>/projects/<slug>/pregate.json. gstack-pregate run reads it only
under --policy-from base when the base carries no .gstack/pregate.json, so a
reviewed base copy always wins and a PR still cannot reach the policy. A
missing file falls back; any other read error, and a non-null sealed or
price_file (read only from the git copies), refuse with PREGATE_CONFIG_INVALID
and a fix that names the file.

A new `local` key lists <workflow.yml>/<job> lanes a local run satisfies: a
platform lane named there runs here (tier unit) instead of becoming a
requires-remote obligation. Lane ids share one LANE_ID check with selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(pregate): the user-owned policy file and local lanes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): policy fixes name the user-owned file when it is the active policy

With no .gstack/pregate.json on the base, an edit to that path is never read,
so the no-lane, runner, stall_timeout_s, remote_gate and strays fixes now name
ctx.configFile: the user-owned path when it is the active policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pregate): the remote_gate, strays and stall fixes name the active policy file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): pre-landing review fixes for the user-owned policy

Ignore a user policy path that is relative or inside the checkout, fingerprint
the active user file into check inputs, name the user file in its JSON errors
and the refusal lead, add --explain lines for an absent user file and an
unknown local id, and document the state-root location and slug lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): containment edges and explain wording for the user policy

A state root under an in-repo directory named ..something passed the
inside-the-repo check, and realpath kept the caller's casing on macOS.
The unknown-local explain line now says workflow lane, and the state-root
chain in explain and docs names every source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): a base copy git cannot read, or a dangling user symlink, refuses

Any git show failure read as no base policy, which now hands the run to
the unreviewed user file; it is absent only when cat-file says so, and a
spawn error (stdout cut at the buffer limit) counts as a failure. A user
pregate.json that is a dangling symlink refuses instead of reading as
absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): decide a missing base policy from the tree, and catch dangling parent links

cat-file -e checks the blob, so a missing or corrupt object still read as
no base copy; ls-tree reads the tree entry, and any failure refuses. The
dangling-symlink check now covers every path component, not just the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(result-codes): POLICY_SOURCE_UNAVAILABLE names an unreadable committed pin file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(pregate): the active policy may be the user-owned file; new refusals

Guards, remote_gate, stall timeouts and lane fixes name the user-owned file
when it is the active policy; the trust boundary says run alone reads it;
the dangling-symlink and unreadable-base-copy refusals are documented, and
state-root lists the bucket's pregate.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): a checkout's .env cannot pick the user policy; verify re-checks it

Bun loads .env from the working directory, so a branch could set
GSTACK_PROJECT_SLUG or a state root and choose which pregate.json governs
it. The shebang now passes --no-env-file, scriptInvocation carries that
flag over when it launches the bin as bun <script>, and a bare bun launch
beside any dotenv file refuses (POLICY_SOURCE_UNAVAILABLE).

A recorded pass also outlived its policy: pregate.json now records the
user file's digest (or, under the defaults, the path that was absent), and
verify and the ship receipt treat an edited, deleted or newly present user
file as PREGATE_STALE, reading both paths from the record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): keep the POLICY_SOURCE_UNAVAILABLE summary's pin-files wording

The added '(or .gstack/pregate.json)' broke describeRefusal's rewrite to
'the reviewed pin files cannot be read', which pregate-wtree-refusal pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pregate): pick the host-foreign lane in the user-policy local-lane test

On a Windows host the windows lane is native, so it never became
requires-remote and the test failed on the Windows shards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): Rust crate lanes and nested JS package runners

lanes reported PREGATE_NO_LANE for every touched .rs file and ran a nested
pnpm+vitest package's tests as `bun test` from the repo root.

- Rust: a .rs file of a crate (nearest Cargo.toml with [package]) is a test
  under <crate>/tests/ or when it holds a #[cfg(test)]; every .rs file of a
  crate and its Cargo.toml select the crate's tests and those of every crate
  depending on it (lib/pregate/rust.ts). Selected tests run per crate as
  `cargo test -p <name>` from the workspace root, judged on the summed
  `test result:` lines (doc-tests included); zero tests stays ZERO-RUN and a
  missing cargo is incomplete, like a missing Dart SDK.
- JS/TS: a test owned by a nested package.json that declares vitest or jest
  runs from that package with package-relative paths, through the package
  manager its packageManager field or nearest lockfile names (pnpm exec,
  yarn, bun x, else npx). Root detection and a declared runner are unchanged.
- init notes that Rust tests run per crate; docs/pregate.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pregate): parse Cargo manifests as TOML; follow workspace membership

The line scan of Cargo.toml missed dependents (a false green) and picked the
wrong workspace:
- an inherited renamed dependency (`base = { workspace = true }` with
  `[workspace.dependencies] base = { package = "alpha", path = ... }`);
- dotted keys (`alpha.path = "../a"`), `[dependencies.<name>]` tables and
  target-specific tables, matched by key name only;
- a crate the workspace excludes ran `cargo test -p` from the workspace root.

Manifests are now parsed with Bun.TOML. A dependency resolves by path
(relative to the manifest declaring it, or to the workspace root when
inherited), else by package name. A crate runs from the workspace it is a
member of under Cargo's rules (package.workspace, the nearest [workspace],
members globs, exclude, literal members, path dependencies of members),
else from its own directory. Nothing runs cargo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(pregate): the Rust fixture's cargo shim wins PATH on Windows and beside a real cargo

windows-free-shard (5) failed 'a touched src file runs its crate...' and
'zero tests is ZERO-RUN...' deterministically: the shim PATH was joined with
':' so Git Bash never saw the shim dir, and the runner's real cargo ran
against the fixture (exit 1, no test result lines). On POSIX hosts where
Bun and cargo share a directory (/usr/local/bin on Capy's image), the lane
puts Bun's directory first and the real cargo wins the same way.

Join with path.delimiter, add cargo.cmd for Bun.which on Windows, and on
POSIX put the shim first again in the lane's own bash through BASH_ENV.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): quote the crate name in cargoRunner

The crate name is read from the repo's Cargo.toml and was interpolated into
'cargo test -p <name>', which the lane runs through bash. A name like
'evil;touch pwned' ran the second command. Quote it like every file and cwd
the lane already passes; a new end-to-end case checks the name reaches cargo
as one argument and runs nothing.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): the nested-package pnpm shim wins PATH beside a real pnpm

'a touched file in a nested pnpm+vitest package runs vitest there' ran the
host's real pnpm wherever Bun and pnpm share a directory (/usr/local/bin on
Capy's image): the lane puts Bun's directory first, so pnpm installed vitest
from the network, dirtied the fixture tree and the check came back
incomplete. Put the shim first again in the lane's bash through BASH_ENV,
as the Rust fixture does.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): pregate Rust crate lanes and nested package runners (#3344)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): a crate name Cargo would reject never reaches the lane's shell

Port review (outside voice): quoting alone was not enough, because the lane
expands {files} after the runner string is built, so a crate named
'evil{files}' spliced the quoted file list into the quoted name and a file
name like tests/$(cmd).rs then ran. A name outside Cargo's grammar
(letters, digits, _ and -) now gets no runner: its files are unclaimed and
the lane is incomplete, as Cargo itself would refuse the manifest. A valid
name, Unicode included, still runs quoted.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): match Cargo members globs per path component, without a backtracking regex

Port review (outside voice): members globs went through ship-policy's
matchesAny, which escapes [...] classes (crates/[ab] matched nothing, so the
crate was no member, lost its inherited [workspace.dependencies] path and its
dependents' tests were never selected: a false green) and backtracks
exponentially on a hostile glob such as '*a*a...*ab' (over 5 s for one crate,
before any runner deadline). A small dynamic-programming matcher handles *,
?, [..] with negation and ranges, and ** across components, in
O(pattern x path).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): a plain #[test] in src is a Rust test; bound the cfg attribute scan

Port review (outside voice): only #[cfg(test)] modules and tests/ files
counted, so a dependent crate whose tests are plain #[test] or #[tokio::test]
functions in src never ran when its dependency changed (a false green). The
cfg scan also went quadratic on a run of unclosed '#[cfg(' (about 3 s on
120 KB). Test attributes now count, and the cfg predicate and its strings are
length-bounded so the scan stays linear.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): Rust test attributes, workspace membership and rejected crate names; changelog for the port review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): match members globs by code point in one pass, and admit every crate past a size cap

Second port review round (outside voice): the matcher counted UTF-16 units, so
'crates/?' missed a crate directory named with a supplementary character and
dropped its inherited dependencies (a false green); and each unmatched '['
rescanned the rest of the glob (5 s for one crate with a million of them).
Tokenize by code point with a precomputed next-']' table, and past 4096
characters of members globs admit every crate under the workspace: a wrong
member fails loudly in cargo, a dropped one is silent. The new supervised
probe kills a super-linear matcher at 30 s instead of hanging the file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): scan cfg attributes once without truncating them; an unclosable one counts as a test

Second port review round (outside voice): the length bound from the first
round dropped a valid cfg(any(test, feature = "<257 chars>")), so its crate's
tests were no longer selected (a false green). Read each attribute once to
its closing ']' and resume after it, which keeps the scan linear without a
bound; an attribute the scan cannot close (an unterminated string, no ']')
counts as a test, which selects more, never fewer.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): second port review round (code-point member globs, unbounded linear cfg scan)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): past the members size cap, a crate's run root is undecided and its tests unclaimed

Third port review round (outside voice): admitting every crate past the cap
made a non-member run as 'cargo test -p <name>' from the workspace root,
where a same-named member answered and its passing tests stood in for the
orphan's failing one (a false green). Keep the conservative membership for
dependency edges, but leave such a crate's workspace undecided (null):
planRuns leaves its files unclaimed and the lane incomplete.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): the Rust end-to-end runs put the cargo shim under Windows' own Path key

windows-free-shard (5) still failed both end-to-end cases at fe99b200 with
the same symptom (the runner's real cargo, exit 1, no test result lines).
runBin merges process.env, whose Windows key is 'Path', with the shim's
'PATH', so the child got two path variables and read the original. Write the
shim under the key process.env actually uses, as test/helpers/unreachable-gbrain.ts
and sync-gbrain-readiness-fixture.ts do.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): run the cargo stand-in cases on POSIX only

windows-free-shard (5) still failed both end-to-end cargo-lane cases after the
delimiter, cargo.cmd and Path-key repairs: the lane's Git Bash exits 1 with an
empty log, and that log is not visible from CI. Skip the two cases that run the
bash stand-in on win32 (test.skipIf, as other POSIX-shim pregate fixtures are
handled) and drop the unproven cargo.cmd and Path-key changes. Selection,
planning and the hostile-name case still run on Windows.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): TODO(#3380) on the two win32-skipped cargo-lane cases

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): refuse a user policy whose link chain passes through the checkout

userConfigRejection checked the lexical path and the final realpath only. A
state root linked into the checkout, or a link hop inside it, let a branch pick
which existing out-of-repo policy governs it. Every hop of the resolution chain
is now checked. Found by the security review of #3286.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): verify goes stale when a link on the way to the absent user policy dangles

A fresh run refuses a user policy path with a dangling parent link, but verify
looked only at the leaf, so the parent's ENOENT read as continued absence and
an old defaults pass stayed publishable. The loader and verify now share one
absence check. Found by the security review of #3286.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): CHANGELOG entry for #3286 and the link-chain and dangling-parent rules

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.206.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: arlophoenix <arlo.phoenix@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vilano93 <244097767+vilano93@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-25 10:50:18 +00:00

458 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* pregate check `lanes` (tier 1, tests stage): the touched files map to test
* files (lib/pregate/lane-selection.ts); the local ones run under
* `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`, never a pass.
* 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 test that reads the file still selects that test.
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { baseInputs, sha256 } from '../context';
import { PREGATE_CONFIG_PATH } from '../config';
import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
/** The test command with `{files}`; detected from the repo when the config does not name one. */
export function detectRunner(ctx: Pick<PregateContext, 'config' | 'repoRoot'>, files: readonly string[]): string | null {
if (ctx.config.runner) return ctx.config.runner;
const has = (f: string) => fs.existsSync(path.join(ctx.repoRoot, f));
let pkg: { devDependencies?: Record<string, string>; dependencies?: Record<string, string>; scripts?: Record<string, string> } = {};
try { pkg = JSON.parse(fs.readFileSync(path.join(ctx.repoRoot, 'package.json'), 'utf8')); } catch { /* no package.json */ }
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (has('bunfig.toml') || has('bun.lock') || has('bun.lockb')) return 'bun test {files}';
if (deps.vitest) return 'npx vitest run {files}';
if (deps.jest) return 'npx jest {files}';
if (files.every(f => /\.py$/.test(f)) && files.length) return 'python3 -m pytest {files}';
if (files.every(f => /\.[cm]?js$/.test(f)) && files.length) return 'node --test {files}';
if (files.every(f => /\.[cm]?[jt]sx?$/.test(f)) && files.length && Bun.which('bun')) return 'bun test {files}';
return null;
}
/** One runner invocation: `files` relative to `cwd` (repo-relative, `''` for the root). */
export interface RunGroup { runner: string; cwd: string; files: string[] }
/** The nearest directory at or above `file` holding `marker`, repo-relative; null when none does. `seen` memoises per directory (one map per marker). */
function nearestRoot(repoRoot: string, file: string, marker: string, seen: Map<string, string | null>): string | null {
const walked: string[] = [];
let found: string | null = null;
for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) {
const rel = d === '.' ? '' : d;
if (seen.has(rel)) { found = seen.get(rel)!; break; }
walked.push(rel);
if (fs.existsSync(path.join(repoRoot, rel, marker))) { found = rel; break; }
if (d === '.' || d === '/' || d === '') break;
}
for (const w of walked) seen.set(w, found);
return found;
}
/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. */
function dartRoot(repoRoot: string, file: string, seen: Map<string, string | null>): string | null { return nearestRoot(repoRoot, file, 'pubspec.yaml', seen); }
/** An fvm pin (`.fvmrc` or `.fvm/fvm_config.json`) at the package root `root` or any ancestor up to the repo root. */
function fvmPinned(repoRoot: string, root: string): boolean {
for (let d = root; ; d = path.posix.dirname(d)) {
const dir = path.join(repoRoot, d === '.' ? '' : d);
if (fs.existsSync(path.join(dir, '.fvmrc')) || fs.existsSync(path.join(dir, '.fvm', 'fvm_config.json'))) return true;
if (d === '' || d === '.' || d === '/') return false;
}
}
/** Whether any Dart file among `files` sits in a package that fvm pins (the missing-SDK hint offers fvm only then). */
function fvmPinnedFor(repoRoot: string, files: readonly string[]): boolean {
const seen = new Map<string, string | null>();
return files.some(f => { const root = f.endsWith('.dart') ? dartRoot(repoRoot, f, seen) : null; return root !== null && fvmPinned(repoRoot, root); });
}
/** Whether `bin` is on the PATH of `env` (the PATH the runner will get). */
const onPath = (bin: string, env: NodeJS.ProcessEnv) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
/**
* `flutter test` for a package on the Flutter SDK, else `dart test`; through fvm
* when the package or an ancestor up to the repo root pins one and fvm is on PATH. `missing` names the tool when
* neither fvm nor the SDK is on PATH, so the lane is incomplete rather than a
* test failure (exit 127).
*/
export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } {
const pubspec = readRepoFile(repoRoot, path.posix.join(root, 'pubspec.yaml')) ?? '';
// `sdk: flutter` (the key optionally quoted) under any dependency name (flutter, flutter_test, ...), block style or inside a flow mapping a key opens, comments stripped.
const tool = /^[ \t]*(['"]?)sdk\1:[ \t]*['"]?flutter\b|^[ \t]*[\w-]+:[ \t]*\{(?:[^\n]*[{,])?[ \t]*(['"]?)sdk\2:[ \t]*['"]?flutter\b/m.test(pubspec.replace(/(^|\s)#.*$/gm, '$1')) ? 'flutter' : 'dart';
const fvm = fvmPinned(repoRoot, root) && onPath('fvm', env);
if (!fvm && !onPath(tool, env)) return { missing: tool };
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
}
/**
* `cargo test -p <name>` for a crate (cargo runs a crate's tests, never a file list), or the missing tool when cargo is
* not on PATH. The name comes from the repo's Cargo.toml and reaches a shell and the `{files}` expansion: a name outside
* Cargo's grammar (letters, digits, `_`, `-`) gets no runner (null), and a valid one is still quoted like every file and cwd.
*/
function cargoRunner(name: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
if (!/^[\p{ID_Continue}-]+$/u.test(name)) return null;
return onPath('cargo', env) ? { runner: `cargo test -p ${quote(name)}` } : { missing: 'cargo' };
}
type PackageJson = { packageManager?: string; devDependencies?: Record<string, string>; dependencies?: Record<string, string>; scripts?: Record<string, string> };
function readPackageJson(repoRoot: string, dir: string): PackageJson | null {
try { return JSON.parse(readRepoFile(repoRoot, path.posix.join(dir, 'package.json')) ?? 'null'); } catch { return null; }
}
const LOCKFILES: Array<[string, string]> = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']];
const PM_EXEC: Record<string, string> = { pnpm: 'pnpm exec', yarn: 'yarn', bun: 'bun x', npm: 'npx' };
/** The package manager for a package: the first `packageManager` field or lockfile from its directory up to the repo root; npm when none. */
function packageManager(repoRoot: string, dir: string): string {
for (let d = dir; ; d = path.posix.dirname(d)) {
const rel = d === '.' ? '' : d;
const declared = /^(pnpm|yarn|bun|npm)@/.exec(readPackageJson(repoRoot, rel)?.packageManager ?? '')?.[1];
if (declared) return declared;
const lock = LOCKFILES.find(([f]) => fs.existsSync(path.join(repoRoot, rel, f)));
if (lock) return lock[1];
if (rel === '') return 'npm';
}
}
/**
* The runner a nested package (not the repo root) declares for itself: vitest
* or jest in its dependencies or its `test` script, through the package manager
* its `packageManager` field or nearest lockfile names; null when it declares
* neither, so its files stay with the root runner. `missing` names a package
* manager that is not on PATH.
*/
function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null {
const pkg = readPackageJson(repoRoot, dir);
if (!pkg) return null;
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const script = pkg.scripts?.test ?? '';
const tool = deps.vitest || /\bvitest\b/.test(script) ? 'vitest run' : deps.jest || /\bjest\b/.test(script) ? 'jest' : null;
if (!tool) return null;
const pm = packageManager(repoRoot, dir);
const bin = pm === 'npm' ? 'npx' : pm;
if (!onPath(bin, env)) return { missing: bin };
return { runner: `${PM_EXEC[pm]} ${tool} {files}` };
}
/** The run plan: the groups that can run, the files no runner claims, tools missing from PATH, and `.dart` files with no pubspec.yaml above. */
export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: string[]; noPubspec: string[] }
/**
* The invocations that run `files`. A declared runner takes every file. Otherwise
* Dart tests run from their own package root (a sub-package's imports resolve
* against its own pubspec); Rust tests run per crate as `cargo test -p <name>`
* from the crate's workspace root; JS/TS tests owned by a nested package that
* declares vitest or jest run from that package; the rest go to the detected
* runner from the repo root. Files no runner claims (a tool missing from PATH,
* a `.dart` file outside any package, a `.rs` file outside any crate or in a
* crate whose name Cargo would reject, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass.
*/
export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan {
if (ctx.config.runner) return { groups: [{ runner: ctx.config.runner, cwd: '', files: [...files] }], unclaimed: [], missing: [], noPubspec: [] };
const groups: RunGroup[] = [];
const dart = new Map<string, string[]>();
const cargo = new Map<string, { name: string; cwd: string; files: string[] }>();
const packages = new Map<string, string[]>();
const seen = new Map<string, string | null>();
const seenPackage = new Map<string, string | null>();
let model: CargoModel | null = null;
const pkgRunners = new Map<string, ReturnType<typeof packageRunner>>();
const rest: string[] = [];
const noPubspec: string[] = [];
const unclaimed: string[] = [];
const missing = new Set<string>();
const add = <T>(map: Map<string, T[]>, key: string, value: T) => { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value); };
const under = (root: string, f: string) => (root ? f.slice(root.length + 1) : f);
for (const f of files) {
if (f.endsWith('.rs')) {
model ??= cargoModel(listRepoFiles(ctx.repoRoot), rel => readRepoFile(ctx.repoRoot, rel));
const crate = model.crateOf(f);
const cwd = crate?.workspace ?? null;
if (!crate || cwd === null) { unclaimed.push(f); continue; }
const key = `${cwd}\0${crate.name}`;
if (!cargo.has(key)) cargo.set(key, { name: crate.name, cwd, files: [] });
cargo.get(key)!.files.push(under(cwd, f));
continue;
}
const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null;
if (pkg) {
if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env));
const r = pkgRunners.get(pkg);
if (r && 'missing' in r) { missing.add(r.missing); unclaimed.push(f); continue; }
if (r) { add(packages, pkg, under(pkg, f)); continue; }
}
if (!f.endsWith('.dart')) { rest.push(f); continue; }
const root = dartRoot(ctx.repoRoot, f, seen);
if (root === null) { noPubspec.push(f); continue; }
add(dart, root, under(root, f));
}
for (const [root, list] of [...dart].sort(([a], [b]) => a.localeCompare(b))) {
const r = dartRunner(ctx.repoRoot, root, ctx.env);
if ('missing' in r) { missing.add(r.missing); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); } else groups.push({ runner: r.runner, cwd: root, files: list });
}
for (const [, c] of [...cargo].sort(([a], [b]) => a.localeCompare(b))) {
const r = cargoRunner(c.name, ctx.env);
if (r && 'runner' in r) { groups.push({ runner: r.runner, cwd: c.cwd, files: c.files }); continue; }
if (r) missing.add(r.missing);
unclaimed.push(...c.files.map(f => (c.cwd ? `${c.cwd}/${f}` : f)));
}
if (rest.length) {
const runner = detectRunner(ctx, rest);
if (runner) groups.push({ runner, cwd: '', files: rest });
else unclaimed.push(...rest);
}
for (const [root, list] of [...packages].sort(([a], [b]) => a.localeCompare(b))) groups.push({ runner: (pkgRunners.get(root) as { runner: string }).runner, cwd: root, files: list });
return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec };
}
/** Why `plan` leaves files unrun: their count, the missing SDKs, `.dart` files outside any package, and the fix. */
function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix'> {
const { unclaimed, missing, noPubspec } = plan;
const detail = `no test runner detected for ${unclaimed.length} of ${total} selected file(s): ${unclaimed.slice(0, 8).join(' ')}${unclaimed.length > 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`;
const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun`
: noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}`
: `declare \`runner\` (with {files}) in ${ctx.configFile}`;
return { code: 'PREGATE_INCOMPLETE', detail, fix };
}
export interface RunnerSummary { ran: number | null; failed: number | null }
/** bats: the pretty `N tests, M failures` line or the TAP plan `1..N`. */
function parseBats(output: string): RunnerSummary | null {
let m: RegExpExecArray | null;
if ((m = /^\s*(\d+)\s+tests?,\s+(\d+)\s+failures?/m.exec(output))) return { ran: Number(m[1]), failed: Number(m[2]) };
if ((m = /^1\.\.(\d+)\s*$/m.exec(output))) return { ran: Number(m[1]), failed: (output.match(/^not ok\b/gm) ?? []).length };
return null;
}
/** dart/flutter test: the last `+passed ~skipped -failed: …` verdict line (`All tests skipped.` is +0, so zero-run); progress lines carry no verdict. */
function parseDart(output: string): RunnerSummary | null {
const d = [...output.matchAll(/\+(\d+)(?: ~\d+)?(?: -(\d+))?: (?:All tests passed!|All tests skipped\.|Some tests failed\.)/g)].pop();
return d ? { ran: Number(d[1]) + Number(d[2] ?? 0), failed: Number(d[2] ?? 0) } : null;
}
/** cargo test: one `test result: ok|FAILED. N passed; M failed; …` line per test binary (unit tests, each integration test, doc-tests), summed; ignored and filtered-out tests did not run. */
function parseCargo(output: string): RunnerSummary | null {
const lines = [...output.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed;/gm)];
if (!lines.length) return null;
const failed = lines.reduce((n, m) => n + Number(m[2]), 0);
return { ran: lines.reduce((n, m) => n + Number(m[1]), 0) + failed, failed };
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test. A bats, dart/flutter or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */
export function parseSummary(raw: string, runner = ''): RunnerSummary {
const output = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n');
if (/(?<![\w-])cargo\s+test(?![\w-])/.test(runner)) return parseCargo(output) ?? { ran: null, failed: null };
if (/(?<![\w-])bats(?![\w-])/.test(runner)) { const b = parseBats(output); if (b) return b; }
const dartRun = /(?<![\w-])(?:dart|flutter)(?![\w-])/.test(runner);
if (dartRun) { const d = parseDart(output); if (d) return d; }
let m: RegExpExecArray | null;
if ((m = /^\s*(?:#|ℹ)\s*tests\s+(\d+)/m.exec(output))) return { ran: Number(m[1]), failed: Number(/^\s*(?:#|ℹ)\s*fail\s+(\d+)/m.exec(output)?.[1] ?? 0) };
if ((m = /Ran\s+(\d+)\s+tests?\s+across/m.exec(output))) return { ran: Number(m[1]), failed: Number(/^\s*(\d+)\s+fail\b/m.exec(output)?.[1] ?? 0) };
if ((m = /^\s*(\d+)\s+pass\s*$/m.exec(output))) return { ran: Number(m[1]) + Number(/^\s*(\d+)\s+fail\s*$/m.exec(output)?.[1] ?? 0), failed: Number(/^\s*(\d+)\s+fail\s*$/m.exec(output)?.[1] ?? 0) };
if ((m = /Tests:\s+(?:(\d+)\s+failed,\s+)?(?:(\d+)\s+passed,\s+)?(\d+)\s+total/.exec(output))) return { ran: Number(m[3]), failed: Number(m[1] ?? 0) };
if ((m = /Tests\s+(?:(\d+)\s+failed\s*\|\s*)?(\d+)\s+passed/.exec(output))) return { ran: Number(m[2]) + Number(m[1] ?? 0), failed: Number(m[1] ?? 0) };
if (/no tests ran/i.test(output) || /No tests found/i.test(output)) return { ran: 0, failed: 0 };
if ((m = /(?:(\d+)\s+failed,?\s*)?(\d+)\s+passed/.exec(output))) return { ran: Number(m[2]) + Number(m[1] ?? 0), failed: Number(m[1] ?? 0) };
return (dartRun ? null : parseDart(output)) ?? parseBats(output) ?? { ran: null, failed: null };
}
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; ran: number | null; failed: number | null; tail: string[]; command: string; files: string[]; lastFile: string | null; ms: number }
const quote = (f: string) => `'${f.replace(/'/g, `'\\''`)}'`;
async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, g: RunGroup, files: string[], log: string, counter: Counter): Promise<LocalRun> {
const joined = files.map(quote).join(' ');
const run = g.runner.replace('{files}', () => joined);
const command = g.cwd ? `(cd ${quote(g.cwd)} && ${run})` : run;
const shown = g.cwd ? files.map(f => `${g.cwd}/${f}`) : files;
const r = await supervise({ command: run, cwd: path.join(ctx.repoRoot, g.cwd), env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${shown.length === 1 ? shown[0] : `${shown.length} files`})`, files, adapter: adapterFor(g.runner), counter, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const summary = parseSummary(output, g.runner);
return { exit: r.exit, outcome: r.outcome, ...summary, tail: output.trim().split('\n').slice(-25), command, files: shown, lastFile: r.lastFile && g.cwd ? `${g.cwd}/${r.lastFile}` : r.lastFile, ms: r.ms };
}
/**
* Per run group (a Dart package from its own root, the rest from the repo
* root): unit files in one runner process, then each serial-pattern file in
* its own (a serial file shares process-wide state: a module mock, an env
* mutation). `--timeout` bounds each runner process; the stall timeout
* catches a silent one. A stalled or timed-out group is killed and the next
* one runs. The first unfinished, then the first non-zero run decides the
* exit; counts add up only when every run reported a summary, and any run
* that reported 0 tests makes the whole lane zero-run (another package's
* count must not hide it).
*/
async function runLocal(ctx: PregateContext, env: NodeJS.ProcessEnv, groups: RunGroup[]) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-lanes-'));
const log = path.join(dir, 'lanes.log');
fs.writeFileSync(log, '');
const counter: Counter = { done: 0, total: groups.reduce((n, g) => n + g.files.length, 0) };
const runs: LocalRun[] = [];
let serialCount = 0;
for (const g of groups) {
const serial = g.files.filter(f => matchesAny(ctx.config.serial_patterns, g.cwd ? `${g.cwd}/${f}` : f));
const unit = g.files.filter(f => !serial.includes(f));
serialCount += serial.length;
for (const files of [...(unit.length ? [unit] : []), ...serial.map(f => [f])]) runs.push(await runOnce(ctx, env, g, files, log, counter));
}
const bad = runs.find(r => r.exit === null) ?? runs.find(r => r.exit !== 0);
const sum = (k: 'ran' | 'failed') => (runs.some(r => r[k] === null) ? null : runs.reduce((n, r) => n + (r[k] as number), 0));
const zero = runs.find(r => r.ran === 0);
return { runs, bad, exit: bad ? bad.exit : 0, ran: zero ? 0 : sum('ran'), zero: zero?.command, failed: sum('failed'), log, tail: (bad ?? runs[runs.length - 1]!).tail, command: runs.map(r => r.command).join(' && '), serial: serialCount };
}
/** Why the local lane did not finish: a stall, a runner process past --timeout or a launch failure, with the last file started. */
function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>, total: number): Pick<CheckResult, 'code' | 'detail' | 'fix'> {
const stalled = r.runs.filter(x => x.outcome === 'stalled');
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` };
}
if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' };
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
}
function describe(sel: Selection): string[] {
const lines = sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`);
for (const r of sel.remote) lines.push(`requires-remote ${r.lane} (${r.platform}): ${r.tests.length} test(s) — ${r.tests.slice(0, 8).join(' ')}${r.tests.length > 8 ? ' …' : ''}`);
lines.push(`Tiers ran: ${sel.tiersRan.join(' ') || '(none)'}; requires-remote: ${[...new Set(sel.remote.map(r => r.platform))].join(' ') || 'none'}`);
return lines;
}
export const SMALL_MACHINE_CORES = 8;
const GSTACK_ROOT = path.resolve(import.meta.dir, '..', '..', '..');
/**
* The small-machine obligation: the repo's declared `remote_gate`, else the
* configured test backend running this selection through gstack's Ubicloud
* runner (that command prints no PREGATE_REMOTE line, so only `clear` with a
* run URL discharges it).
*/
function remoteObligation(ctx: PregateContext, sel: Selection): RemoteObligation {
const declared = ctx.config.remote_gate;
const runner = detectRunner(ctx, sel.localTests);
const command = declared ?? `${GSTACK_ROOT}/scripts/ubicloud/ubi-runner.sh run --src ${quote(ctx.repoRoot)} -- ${quote(runner ? runner.replace('{files}', sel.localTests.map(quote).join(' ')) : '<test runner> <selected files>')}`;
const [workflow, job] = declared ? REMOTE_GATE_LANE.split('/') as [string, string] : ['test_backend', ctx.testBackend];
return { lane: `${workflow}/${job}`, platform: 'remote', workflow, job, tests: sel.localTests, receipt: null, command };
}
export const REMOTE_GATE_LANE = 'pregate.json/remote_gate';
/** The `clear` command for an obligation: the remote gate's own output for remote_gate, a run URL otherwise. */
export const clearHint = (lane: string) => `gstack-pregate clear --remote ${lane} ${lane === REMOTE_GATE_LANE ? '--receipt <gate output file>' : '--run-url <url>'}`;
export interface RemoteReport { tree: string; tests: number; failed: number; spent?: string }
/** The last `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]` line in a remote gate's output. */
export function parseRemoteReport(output: string): RemoteReport | null {
const m = [...output.matchAll(/^PREGATE_REMOTE: tree=(\S+) tests=(\d+) failed=(\d+)(?: spent=(\d+(?:\.\d+)?))?\s*$/gm)].pop();
return m ? { tree: m[1]!, tests: Number(m[2]), failed: Number(m[3]), spent: m[4] } : null;
}
/** Why a remote gate report does not clear the obligation for `tree` (`--remote` and `clear --receipt`), or null when it does. */
export function remoteReportProblem(report: RemoteReport, tree: string): string | null {
if (report.tree !== tree) return `the remote gate tested tree ${report.tree.slice(0, 12)}, not ${tree.slice(0, 12)}`;
if (report.tests === 0) return 'the remote gate reported 0 tests';
if (report.failed > 0) return `the remote gate reported ${report.failed} failed of ${report.tests}`;
return null;
}
/**
* `--remote`: run the declared remote gate now, under the spend cap. The
* gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and
* must print `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`;
* the obligation clears only when that tree is the tested tree, tests > 0,
* failed = 0 and the gate exited 0. Anything else leaves it uncleared and
* the check incomplete.
*/
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> {
const lines = base.lines ?? [];
if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` };
const obligation = remoteObligation(ctx, sel);
const cap = ctx.remote!.spendCapUsd;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-'));
const log = path.join(dir, 'remote.log');
const filesFile = path.join(dir, 'files.txt');
fs.writeFileSync(log, '');
fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n');
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const reported = parseRemoteReport(output);
const spent = reported?.spent;
const spend = `spend: spent ${spent ? `$${spent}` : '$0'}, reserved $0, unknown ${spent ? '$0' : `$${cap} (no spent= reported; counted at the cap)`} (cap $${cap})`;
ctx.progress(`PREGATE_REMOTE_SPEND: ${spend.slice('spend: '.length)}`);
const runLines = [...lines, `remote gate: ${obligation.command}`, `log: ${log}`, spend, ...output.trim().split('\n').slice(-15).map(l => ` ${l}`)];
const inputs = { ...base.inputs, command: sha256(obligation.command!) };
const why = r.outcome !== 'exited' ? `the remote gate did not finish (${r.outcome === 'stalled' ? `no output for ${ctx.stallTimeoutMs / 1000} s` : r.outcome})`
: !reported ? `the remote gate exited ${r.exit} without a PREGATE_REMOTE: line`
: remoteReportProblem(reported, ctx.wtree) ?? (r.exit !== 0 ? `the remote gate exited ${r.exit}` : null);
if (why) return { ...base, inputs, lines: runLines, status: 'incomplete', remote: [...sel.remote, obligation], code: r.outcome === 'stalled' ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${why}; requires-remote ${obligation.lane} stays uncleared`, fix: `read ${log}; the obligation clears only on PREGATE_REMOTE: tree=${ctx.wtree.slice(0, 12)}… tests>0 failed=0 with exit 0` };
const cleared = { ...obligation, receipt: `remote-gate tree=${ctx.wtree.slice(0, 12)} tests=${reported!.tests}`, cleared_by: `gstack-pregate --remote ${new Date().toISOString()}` };
return { ...base, inputs, lines: runLines, status: 'requires-remote', remote: [...sel.remote, cleared], detail: `remote gate ${obligation.lane}: ${reported!.tests} tests, 0 failed on tree ${ctx.wtree.slice(0, 12)}; ${spend}` };
}
async function run(ctx: PregateContext): Promise<CheckResult> {
const sel = selectLanes(ctx);
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 lines = describe(sel);
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
if (sel.noLane.length) {
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
const detail = sel.emptyLane.length
? `docs/no-tests: ${sel.emptyLane.length} documentation file(s)${inert ? ` and ${inert}` : ''}; nothing touched selects a test`
: inert ? `no-tests: ${inert}; nothing touched selects a test`
: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files';
return { ...base, detail };
}
if (ctx.remote && sel.localTests.length) return runRemote(ctx, sel, base);
const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local');
if (smallMachine) {
const obligation = remoteObligation(ctx, sel);
return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands` };
}
if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` };
// Runnable groups run even when other files are unclaimed: a real failure is reported as one, and an all-green run is still incomplete.
const plan = planRuns(ctx, sel.localTests);
const gap = plan.unclaimed.length ? planGap(ctx, plan, sel.localTests.length) : null;
if (!plan.groups.length) return { ...base, status: 'incomplete', ...gap };
const { groups } = plan;
const total = groups.reduce((n, g) => n + g.files.length, 0);
const runtime = launchRuntime(ctx.repoRoot, ctx.env);
if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] };
const r = await runLocal(ctx, runtime.env, groups);
const adapters = [...new Set(groups.map(g => adapterFor(g.runner)).filter(Boolean))];
const runLines = [...lines, `runtime: ${runtime.label}`, `progress: ${adapters.join(', ') || 'none (no progress adapter for this runner; serial files and heartbeats only)'}`, `ran: ${r.command.length > 200 ? `${r.command.slice(0, 200)}…` : r.command}`, `log: ${r.log}`, ...r.tail.map(l => ` ${l}`)];
const withRun = { ...base, lines: runLines, inputs: { ...inputs, command: sha256(r.command), runtime: runtime.label } };
if (r.exit === null) return { ...withRun, status: 'incomplete', ...unfinished(ctx, r, total) };
if (r.exit !== 0) return { ...withRun, status: 'fail', code: 'PREGATE_LANE_FAILED', detail: `local lane exited ${r.exit} (${r.failed ?? '?'} failed of ${r.ran ?? '?'} ran; ${total} files; pipefail)`, fix: `read ${r.log}, fix the in-branch failure, rerun gstack-pregate --stage tests` };
if (r.ran === 0) return { ...withRun, status: 'fail', code: 'PREGATE_LANE_FAILED', detail: `ZERO-RUN: the runner reported 0 tests${r.zero ? ` in ${r.zero}` : ''} (${total} selected file(s))`, fix: 'a bad selector, a missing file or a skipped describe; exit 0 is not a pass' };
const detail = `${r.ran ?? 'count unavailable'} tests ran in ${total} file(s)${r.serial ? ` (${r.serial} serial)` : ''}, exit 0${sel.remote.length ? `; ${sel.remote.length} requires-remote obligation(s)` : ''}`;
if (gap) return { ...withRun, status: 'incomplete', ...gap, detail: `${detail}; ${gap.detail}` };
return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail };
}
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };