v1.91.161.0 fix(pregate): Django, Dart, asset and tool-config lanes; init drafts from the repo (#3266-#3269) (#3306)

* fix(pregate): Dart import graph and test lanes (#3285)

Port of arlophoenix's #3285. The import graph reads Dart import/export/part
directives and resolves package: URIs through every tracked pubspec.yaml;
_test.dart files under test/ are tests. lanes runs each Dart package's
selected tests from its own root with flutter test or dart test (through fvm
when the package pins it). flutter/dart/melos/very_good CI jobs are lanes,
and run-step test paths resolve against working-directory and literal cd.
A missing Dart SDK or a .dart file outside any package leaves the lane
incomplete with a fix line; device tests (integration_test/, test_driver/)
are not unit lanes.

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

* fix(pregate): a --base that names a commit gets a branch fix, not an impossible fetch (#3269)

gstack-pregate --base <sha> refused with "fix: git fetch origin <sha>
--depth=1". That fetch can never create origin/<sha>: a short SHA fails
with "couldn't find remote ref", and a full SHA only reaches FETCH_HEAD.
A fork clone hits the same wall with --base upstream/main.

When origin/<base> does not resolve but <base> names a commit here and
no local branch (a SHA, a tag, another remote's ref), the refusal now
says --base takes a branch name on origin and suggests origin/HEAD's
branch. A local branch, or a name that resolves nowhere, keeps the
fetch fix: that is the unfetched or shallow checkout it repairs. Same
code, same exit 3, and pin files are still read only from the reviewed
origin/<base>.

Fixes #3269

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

* fix(pregate): Django, asset and tool-config lanes; init drafts from the repo (#3266, #3267, #3268)

- Django: settings modules, settings strings, include(), app migrations,
  admin, templates, static files and project templates get framework edges
  (lib/pregate/django.ts).
- Assets, stylesheets and dotfile tool config take a no-tests empty lane;
  licence text under any name is documentation.
- gstack-pregate init drafts languages, runner notes and nearest-test
  dependencies; never writes [].
- Review fixes on the #3285 port: untracked shell scopes leave a lane
  unknown; Dart directives read to their semicolon; nested comments.

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

* test(pregate): init draft API is exercised directly, so init-draft.ts has a lane

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

* docs(changelog): restore the history a truncated rewrite dropped

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

* fix(pregate): outside-voice review fixes for the non-JS lanes

- Tool config is an allowlist: unknown dotfiles (.rspec, .husky hooks) and
  stylesheets still need a lane; snapshot, golden and fixture trees and
  .gstack/ are never inert.
- Django edges need Django evidence (manage.py or code importing django)
  and settings assignments in code, not strings or comments.
- A cd after ||, &, control flow or braces leaves a lane unknown.
- Dart directives allow metadata and quoted semicolons.
- init drafts at most 256 dependencies (the schema limit); the rest are TODO.

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

* test(windows): pregate-dart and pregate-non-js join the POSIX-run pregate fixtures

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

* chore: release v1.91.161.0

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: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: y$un_ <forrest.sun527@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 01:38:26 +00:00
1 parent 71b007ae7c
commit 5ebda5999f
22 files changed
+1642 -89

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.91.161.0] - 2026-10-19
**`gstack-pregate` works on Django, Dart and Flutter repos, and on branches that only touch assets or tool config.** Before, a Django migration, an `admin.py`, a template, a font, a vendored licence or `.pre-commit-config.yaml` stopped `/ship` with `PREGATE_NO_LANE` in a repo that had never opted in, and every touched `_test.dart` file did the same. `gstack-pregate init` now drafts a policy from the repo instead of writing a fixed example. Contributed by @arlophoenix (#3285) and @szsunyuan (#3290); reported by @dgrant (#3266, #3267, #3268, #3269).
### Fixed
- **Django files loaded by name select tests (#3266).** In a Django project (a manage.py or code importing django, plus Python code that assigns `INSTALLED_APPS` or `ROOT_URLCONF`, or a config file naming `DJANGO_SETTINGS_MODULE`), every Python test depends on the settings module; a dotted string in settings and an `include("app.urls")` argument count as imports; an app's migrations, `admin.py`, `templates/`, `static/`, `templatetags/`, `management/`, `fixtures/` and `locale/` files select that app's tests and the tests that reach its `models.py`; a project-level template selects the suite.
- **Files no test runner loads take an empty lane (#3267).** Images, fonts, media and PDFs (outside snapshot, golden and fixture trees) and allowlisted tool config (`.pre-commit-config.yaml`, `.editorconfig`, lint and format config, `.vscode/`, `.idea/`, `.impeccable/`, GitHub issue templates and CODEOWNERS) print `no-tests (assets and tool config)`, and licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`) is documentation, when no test names the file. Stylesheets, unknown dotfiles (`.rspec`, `.husky/` hooks), CI workflows, `.gstack/`, `.env*` and version pins still need a lane, and so does unmapped code.
- **Dart and Flutter lanes (#3285, @arlophoenix).** The import graph reads `import`/`export`/`part` directives and resolves `package:` URIs through every tracked `pubspec.yaml`. `lanes` runs each package's tests from its own root with `flutter test` or `dart test`, through fvm when the package pins it. flutter, dart, melos and very_good CI jobs are lanes, and a run step's test paths resolve against its `working-directory` and literal `cd`. A missing Dart SDK or a `.dart` file outside any package is `incomplete`, never a pass; device tests are not unit lanes.
- **`--base <sha>` gets a branch suggestion, not an impossible fetch (#3269, #3290, @szsunyuan).**
### Changed
- **`gstack-pregate init` drafts from the repo (#3268).** It prints the languages it found and the runner each language's tests get, maps each file the import graph cannot place to the tests of the nearest directory above it, and lists the rest as `TODO` (as it does past the 256-entry schema limit). It never writes `[]`, which would clear a file without a test.
- **Review fixes on the Dart port.** A `cd` inside a pipeline, an alternative, a background job, shell control flow, a command substitution or a redirected subshell now leaves a CI lane's test set unknown (it may run any test) instead of resolving later paths against the wrong directory, which could drop a platform obligation. Dart directives are read to their `;` whatever the line indentation, metadata before a directive and semicolons inside a URI are handled, and nested block comments are skipped, so an unindented conditional import or a long `show` list no longer loses its edges.
- **Gate integrity:** new free regressions in `test/pregate-non-js.test.ts` (Django, assets and licence, init, and the two review fixes, each with a negative control) and `test/pregate-dart.test.ts`. Each new positive case fails on the tree before this change. No existing test, threshold, trial count or case kind changed; `test/helpers/pregate-fixture.ts` only reports more about a failed git spawn. `test/pregate-dart.test.ts` and `test/pregate-non-js.test.ts` join the other pregate fixture files in `scripts/lib/windows-curation.ts`'s known-incompatible list: the Dart stand-ins are bash scripts on a colon-joined PATH, and the bin cases expect the Linux-host lanes verdict.
## [1.91.160.0] - 2026-10-19
**Dart and Flutter projects get the right reviewers and the right test command.** A Dart change matched no diff-scope category except its test file, so a Flutter widget PR ran only the testing pass and skipped design, performance and security. Contributed by @arlophoenix (#3284).
+1 -1
View File
@@ -1 +1 @@
1.91.160.0
1.91.161.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.160.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.161.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+8 -5
View File
@@ -2,7 +2,7 @@
/**
* gstack-pregate — cheap checks before anything paid or remote (plan C8).
*
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <ref>]
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
@@ -27,7 +27,8 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, exampleConfigJson, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildContext, describeRefusal } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
@@ -60,7 +61,7 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--stage preflight|tests|all default all
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
--base <ref> base branch (default: origin/HEAD, else main)
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
--out <file> pregate.json path (default ${PREGATE_OUT_REL})
@@ -303,9 +304,11 @@ async function main(argv: string[]): Promise<number> {
if (rest.includes('--explain')) { process.stdout.write(explainConfig()); if (rest.length === 1) return EXIT.ok; }
const file = path.join(root, PREGATE_CONFIG_PATH);
if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(resultLine('gstack-pregate init', 'POLICY_EXISTS', file) + '\n'); return EXIT.refused; }
const draft = draftConfig(root);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, exampleConfigJson());
process.stdout.write(`PREGATE_CONFIG_WRITTEN: ${PREGATE_CONFIG_PATH} (edit it, then gstack-pregate validate; commit it on the base branch before /ship reads it)\n`);
fs.writeFileSync(file, draft.json);
process.stdout.write(`PREGATE_CONFIG_WRITTEN: ${PREGATE_CONFIG_PATH} (a draft from this repo: review it, then gstack-pregate validate; commit it on the base branch before /ship reads it)\n`);
for (const line of describeDraft(draft)) process.stdout.write(`${line}\n`);
return EXIT.ok;
}
process.stderr.write(`gstack-pregate: unknown command ${cmd}\n${HELP}\n`);
+42 -5
View File
@@ -59,9 +59,9 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| Key | Meaning |
|---|---|
| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); without it `lanes` is `incomplete` |
| `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); nothing detected, a Dart SDK missing from PATH, or a `.dart` file with no `pubspec.yaml` above it 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) |
| `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). README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`. Source code and files under a test directory never qualify. A test that reads the file still selects that test |
| `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 |
| `strays.allow` | intentional root files or new top-level directories |
@@ -75,6 +75,15 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
Only the `origin/<base>` copy is honoured, so a downgrade is reviewed before it
takes effect. `gstack-pregate validate` / `init` cover the working-tree file.
`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
nearest directory above it that holds at most 25 of them, up to the
256-entry schema limit. The draft never
writes `[]`, which would clear a file without a test. A file with no nearby
test, and a lane whose matrix a planner computes, is printed as a `TODO`.
Review each entry before committing the file on the base branch.
## `gstack-pregate`
```
@@ -119,7 +128,14 @@ Two stages with separate clocks, both printed:
- `lanes`: touched files map to tests through `lib/pregate/import-graph.ts`
(static imports ∪ repo-path string literals ∪ the `bin/<name>` →
`test/<name>*.test.ts` convention ∪ `dependencies`), transitively through
`test/<name>*.test.ts` convention ∪ `dependencies`; Dart `import`/`export`/`part`
directives resolve `package:<name>/x` through every tracked `pubspec.yaml`, and
`*_test.dart` files under a `test/` directory are tests, except under `integration_test/` and
`test_driver/`: they need a device, so pregate never runs them, and a touched
one has no lane until you declare `"**/integration_test/**": []` (and
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
does not), transitively through
non-test files; deleted and renamed files select the tests that named them.
A touched source file with no lane is `FAIL lanes — <file> has no test lane`,
never a pass. Markdown that no test depends on is a docs/no-tests empty lane
@@ -128,8 +144,29 @@ Two stages with separate clocks, both printed:
as every other file, so a test that reads the Markdown (a fixture or a doc)
still selects that test. `empty_lanes` adds globs for other documentation;
it cannot clear a source file or a file under a test directory.
Files no test runner loads take a second empty lane by the same rule
(`no-tests (assets and tool config): …`): images, fonts, media and PDFs
outside snapshot, golden and fixture directories, plus an allowlist of tool
configuration (`.editorconfig`, `.gitignore`, `.pre-commit-config.yaml`,
lint and format config, `.vscode/`, `.idea/`, `.impeccable/`, and GitHub
issue templates, CODEOWNERS and dependabot config). Stylesheets (they import
each other), any other dotfile (`.rspec`, `.husky/` hooks, `.env*`, version
pins), CI workflows and actions, and `.gstack/` still need a lane.
In a Django project (a manage.py or code importing django, plus Python
code, not a string or comment, that assigns `INSTALLED_APPS` or
`ROOT_URLCONF`, or a config file names `DJANGO_SETTINGS_MODULE`), the files
Django loads by name get `framework` edges: every Python test depends on
each settings module; a dotted string in a settings module and an
`include("app.urls")` argument count as imports; an app's migrations,
`admin.py`, `templates/`, `static/`, `templatetags/`, `management/`,
`fixtures/` and `locale/` files select that app's tests and the tests that
reach its `models.py` (the settings module, so the suite, when the app has
no tests of its own); a template under a project-level `templates/`
directory selects the suite through the settings module.
Lanes and platform tiers come from `.github/workflows/*.yml`
(`gstack-pregate lanes` prints them): local ones run through the runner under
(`gstack-pregate lanes` prints them; a test path a step names resolves against
that step's `working-directory`, else the job's or workflow's
`defaults.run.working-directory`): local ones run through the runner under
`set -o pipefail`, judged on the runner's summary line (zero tests ran is a
failure, a `| tail`-hidden exit is still an exit); a platform lane this
machine cannot run becomes a `requires-remote` obligation naming
@@ -193,7 +230,7 @@ test-tier command:
Per-file lines come from a progress adapter where the runner's reporter
provides them: `bun test` (its `<file>:` headers) is the only adapter today;
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest) get heartbeats
runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
- is killed as **stalled** when its log does not grow for the stall timeout
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
+21 -5
View File
@@ -2368,6 +2368,12 @@ working-tree file silently.
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
the receipt then says `policy: head (unreviewed)`.
`--base` names a branch on `origin`. A commit SHA, a tag or another remote's
ref (`upstream/main` in a fork clone) never resolves as `origin/<base>`, and
fetching it cannot create that ref, so pass a branch that exists on `origin`
(`git branch -r --list 'origin/*'` lists them). `gstack-pregate` says so in
its fix instead of suggesting the fetch.
<a id="policy-invalid"></a>
### `POLICY_INVALID`
@@ -2850,17 +2856,25 @@ and Heavy-only files included.
**Meaning.** A touched source file maps to no test through static imports,
repo-path string literals, the `bin/<name>` → `test/<name>*.test.ts` convention
or a `dependencies` declaration. That is a failure, not a pass. Markdown no
or a `dependencies` declaration (in a Django project, also the files Django
loads by name: settings strings, `include()`, app migrations, admin, templates
and static files). That is a failure, not a pass. Markdown no
test depends on is a docs/no-tests empty lane and is not this error, whether
or not the name says plan, evidence or redproof. A test that reads one of
those files (a quoted path or a `join()` of path segments) still selects that
test. An unmapped source or code file still fails. A file under a test
directory is not this empty lane.
directory is not this empty lane. Images, fonts and media, and
allowlisted tool config no test names, are the assets-and-tool-config empty lane,
not this error.
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
for files with no behaviour) under `dependencies` in `.gstack/pregate.json`.
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
`--explain` prints every mapping and why. For a file with no lane it also prints the import
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
never runs them: declare `"**/integration_test/**": []` (and
`"**/test_driver/**": []`) under `dependencies` and let CI run them.
With no policy yet, `gstack-pregate init` drafts the `dependencies` entries
from the repo 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
`extends` hop that was not followed (only relative `extends`, at most 4 hops). If
@@ -2883,8 +2897,10 @@ should have found it, report the specifier as a resolver gap.
allowed, or the working tree changed while the check ran. Incomplete is never
pass and blocks publication.
**Fix.** Pass `--allow-repo-commands`, install the tool, raise `--timeout`, or
keep the tree still, then rerun.
**Fix.** Pass `--allow-repo-commands`, install the tool (for a Dart package the
message names `not on PATH: dart` or `flutter`; fvm also works when the repo pins
it), commit the `pubspec.yaml` of a Dart package the message says has none (or
declare `runner`), raise `--timeout`, or keep the tree still, then rerun.
<a id="pregate-stalled"></a>
### `PREGATE_STALLED`
+162 -30
View File
@@ -15,6 +15,8 @@ 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, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
@@ -22,7 +24,7 @@ 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: PregateContext, files: readonly string[]): string | null {
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> } = {};
@@ -37,6 +39,104 @@ export function detectRunner(ctx: PregateContext, files: readonly string[]): str
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 a pubspec.yaml, repo-relative; null outside any Dart package. `seen` memoises per directory. */
function dartRoot(repoRoot: string, file: 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, 'pubspec.yaml'))) { found = rel; break; }
if (d === '.' || d === '/' || d === '') break;
}
for (const w of walked) seen.set(w, found);
return found;
}
/** 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); });
}
/**
* `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 onPath = (bin: string) => !!Bun.which(bin, { PATH: env.PATH ?? '' });
const fvm = fvmPinned(repoRoot, root) && onPath('fvm');
if (!fvm && !onPath(tool)) return { missing: tool };
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
}
/** The run plan: the groups that can run, the files no runner claims, Dart SDKs 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), and the rest go to the detected runner. Files no
* runner claims (a Dart SDK missing from PATH, a `.dart` file outside any
* package, 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 seen = new Map<string, string | null>();
const rest: string[] = [];
const noPubspec: string[] = [];
for (const f of files) {
if (!f.endsWith('.dart')) { rest.push(f); continue; }
const root = dartRoot(ctx.repoRoot, f, seen);
if (root === null) { noPubspec.push(f); continue; }
let list = dart.get(root);
if (!list) dart.set(root, (list = []));
list.push(root ? f.slice(root.length + 1) : f);
}
const unclaimed: string[] = [];
const missing = new Set<string>();
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 });
}
if (rest.length) {
const runner = detectRunner(ctx, rest);
if (runner) groups.push({ runner, cwd: '', files: rest });
else unclaimed.push(...rest);
}
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 .gstack/pregate.json'
: 'declare `runner` (with {files}) in .gstack/pregate.json';
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`. */
@@ -47,54 +147,72 @@ function parseBats(output: string): RunnerSummary | null {
return null;
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats. A bats runner's summary is tried first; for any other runner bats output is a last resort. `ran: null` when not recognised. */
export function parseSummary(output: string, runner = ''): RunnerSummary {
/** 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;
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test. A bats or dart/flutter runner's summary is tried first; for any other runner those 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-])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/.test(output) || /No tests found/i.test(output)) return { ran: 0, failed: 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 parseBats(output) ?? { ran: null, failed: null };
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, runner: string, files: string[], log: string, counter: Counter): Promise<LocalRun> {
const command = runner.replace('{files}', files.map(quote).join(' '));
const r = await supervise({ command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${files.length === 1 ? files[0] : `${files.length} files`})`, files, adapter: adapterFor(runner), counter, progress: ctx.progress });
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, runner);
return { exit: r.exit, outcome: r.outcome, ...summary, tail: output.trim().split('\n').slice(-25), command, files, lastFile: r.lastFile, ms: r.ms };
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 };
}
/**
* 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
* 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.
* 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, runner: string, files: string[]) {
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 serial = files.filter(f => matchesAny(ctx.config.serial_patterns, f));
const unit = files.filter(f => !serial.includes(f));
const counter: Counter = { done: 0, total: files.length };
const counter: Counter = { done: 0, total: groups.reduce((n, g) => n + g.files.length, 0) };
const runs: LocalRun[] = [];
for (const group of [...(unit.length ? [unit] : []), ...serial.map(f => [f])]) {
runs.push(await runOnce(ctx, env, runner, group, log, counter));
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));
return { runs, bad, exit: bad ? bad.exit : 0, ran: sum('ran'), failed: sum('failed'), log, tail: (bad ?? runs[runs.length - 1]!).tail, command: runs.map(r => r.command).join(' && '), serial: serial.length };
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. */
@@ -112,6 +230,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
function describe(sel: Selection): string[] {
const lines = sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
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;
@@ -195,11 +314,13 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
const lines = describe(sel);
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote };
if (sel.noLane.length) {
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: 'add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in .gstack/pregate.json' };
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(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); nothing touched selects a test`
? `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 };
}
@@ -211,18 +332,29 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
}
if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` };
const runner = detectRunner(ctx, sel.localTests);
if (!runner) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `no test runner detected for ${sel.localTests.length} selected file(s)`, fix: 'declare `runner` (with {files}) in .gstack/pregate.json' };
// 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, runner, sel.localTests);
const runLines = [...lines, `runtime: ${runtime.label}`, `progress: ${adapterFor(runner) ?? '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 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, sel.localTests.length) };
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; ${sel.localTests.length} 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 for ${sel.localTests.length} 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 ${sel.localTests.length} file(s)${r.serial ? ` (${r.serial} serial)` : ''}, exit 0${sel.remote.length ? `; ${sel.remote.length} requires-remote obligation(s)` : ''}`;
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(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 ${PREGATE_CONFIG_PATH}${device}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
+1 -1
View File
@@ -11,7 +11,7 @@ import { baseInputs } from '../context';
import type { CheckDef, CheckResult, PregateContext } from '../types';
/** Conventional root files a repo adds without a declaration. */
const ROOT_OK = /^(?:README|LICENSE|LICENCE|NOTICE|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY|AUTHORS|VERSION|Makefile|Dockerfile|Procfile|Gemfile|Rakefile|Cargo\.(?:toml|lock)|go\.(?:mod|sum)|package(?:-lock)?\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|tsconfig[\w.-]*\.json|bunfig\.toml|pyproject\.toml|requirements[\w.-]*\.txt|setup\.(?:py|cfg)|\.[\w.-]+|[\w.-]+\.config\.[cm]?[jt]s|[\w.-]+\.md)$/i;
export const ROOT_OK = /^(?:README|LICENSE|LICENCE|NOTICE|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY|AUTHORS|VERSION|Makefile|Dockerfile|Procfile|Gemfile|Rakefile|Cargo\.(?:toml|lock)|go\.(?:mod|sum)|package(?:-lock)?\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|tsconfig[\w.-]*\.json|bunfig\.toml|pyproject\.toml|pubspec\.(?:yaml|lock)|analysis_options\.yaml|dart_test\.yaml|requirements[\w.-]*\.txt|setup\.(?:py|cfg)|\.[\w.-]+|[\w.-]+\.config\.[cm]?[jt]s|[\w.-]+\.md)$/i;
export const STRAY_NAME = /^(?:zz-|tmp|temp|scratch|debug-|probe)|\.(?:orig|rej|swp|swo|bak|tmp)$|~$|^\.DS_Store$|^Thumbs\.db$/i;
function run(ctx: PregateContext): CheckResult {
+2 -15
View File
@@ -62,7 +62,7 @@ export interface Guard { id: string; when: string[]; command: string; fix?: stri
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 256;
export const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
const MAX_GUARDS = 32;
@@ -215,24 +215,11 @@ export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: tr
return (load as any).unavailable === true;
}
export function exampleConfigJson(): string {
const example = {
$schema: PREGATE_CONFIG_SCHEMA,
runner: null,
dependencies: { 'bin/example-tool': ['test/example-tool.test.ts'], 'docs/**': [] },
empty_lanes: [],
selection: {},
warn: [],
strays: { allow: [] },
};
return JSON.stringify(example, null, 2) + '\n';
}
export function explainConfig(): string {
return [
'runner: the test command with a {files} placeholder; null detects bun test / vitest / jest / pytest from the repo',
'dependencies: glob → test files the import graph cannot see (subprocess targets, runtime reads, generated inputs); [] declares no lane needed',
'empty_lanes: extra globs treated as docs/no-tests when nothing else maps them. Built-in, with or without this file: 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. Source code and files under a test directory never qualify. A test that reads the file still selects that test',
'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`,
'strays.allow: root files or new top-level directories that are intentional',
+26 -6
View File
@@ -14,7 +14,7 @@ import * as path from 'node:path';
import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine } from '../ship-policy';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config';
import type { CheckResult, PregateContext, TouchedFile } from './types';
import { discoverLanes } from './workflows';
@@ -40,7 +40,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
export interface ContextRefusal {
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
message: string;
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair). */
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
fix?: string;
errors?: Array<ConfigError | RegistryError>;
}
@@ -62,16 +62,36 @@ export function wtreeRefusal(fp: Extract<WtreeResult, { error: string }>): Conte
return { code: 'WTREE_UNAVAILABLE', message: fp.error, fix: 'run bin/gstack-wtree in the repository to see why it fails (it exits 1 outside a git repository, in one with no commits, or when a write fails), repair that, then rerun gstack-pregate' };
}
/**
* `origin/<base>` does not resolve, yet `<base>` names a commit here and no local
* branch: a SHA, a tag or another remote's ref (`upstream/main` in a fork clone).
* `git fetch origin <base>` never creates `origin/<base>` for those (#3269), so the
* fix names an origin branch instead. A local branch, or a name that resolves
* nowhere, keeps the code's fetch fix: that is the shallow or never-fetched
* checkout it repairs.
*/
function notAnOriginBranch(repoRoot: string, base: string): ContextRefusal | null {
if (git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${base}`]).ok) return null;
if (!git(repoRoot, ['rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return null;
const fallback = resolveBaseBranch(repoRoot);
const example = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${fallback}^{commit}`]).ok ? `, such as --base ${fallback}` : '';
return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `origin/${base} is not a resolvable commit; ${base} names a commit here, but --base takes a branch name on origin`, fix: `pass a branch that exists on origin${example} (git branch -r --list 'origin/*' lists them)` };
}
export interface RefusalReport { code: ContextRefusal['code']; detail: string; fix: string; docs: string; line: string }
/** One context refusal as its stderr line and as the fields of the `--json` envelope. */
/**
* One context refusal as its stderr line and as the fields of the `--json` envelope.
* A cause-specific `fix` replaces the code's own. The shared POLICY_SOURCE_UNAVAILABLE
* summary names the ship policy; pregate's line says "pin files".
*/
export function describeRefusal(refused: ContextRefusal, base: string, context = 'gstack-pregate'): RefusalReport {
const { code, message } = refused;
const row = RESULT_CODES[code];
const docs = `${TROUBLESHOOTING_DOC}#${row.anchor}`;
if (code === 'POLICY_SOURCE_UNAVAILABLE') return { code, detail: message, fix: row.fix.replace('<base>', base), docs, line: policyUnavailableLine(context, base, message).replace('.gstack/ship-policy.json', 'pin files') };
if (code === 'POLICY_SOURCE_UNAVAILABLE' && !refused.fix) return { code, detail: message, fix: row.fix.replace('<base>', base), docs, line: policyUnavailableLine(context, base, message).replace('.gstack/ship-policy.json', 'pin files') };
if (!refused.fix) return { code, detail: message, fix: row.fix, docs, line: resultLine(context, code, message) };
return { code, detail: message, fix: refused.fix, docs, line: `${context}: ${row.summary} (${message}); fix: ${refused.fix} (${code})` };
return { code, detail: message, fix: refused.fix, docs, line: `${context}: ${row.summary.replace('.gstack/ship-policy.json', 'pin files')} (${message}); fix: ${refused.fix} (${code})` };
}
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string } {
@@ -107,7 +127,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
const base = o.base.replace(/^origin\//, '');
const baseRef = `origin/${base}`;
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
if (!baseSha.ok || !baseSha.out) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
+111
View File
@@ -0,0 +1,111 @@
/**
* pregate/django — the edges Django creates by name rather than by import
* (#3266). The import graph follows Python imports, but Django loads much of
* a project from settings strings and directory conventions, so without these
* edges a migration, an admin module or a template has no test lane.
*
* A repo is a Django project when it carries Django evidence (a manage.py, or
* Python code that imports django) and a settings module: Python code (strings
* and comments excluded) that assigns INSTALLED_APPS or ROOT_URLCONF, or the
* module a config file names in DJANGO_SETTINGS_MODULE. Then:
* - every Python test depends on each settings module (the test runner
* loads it), so a settings change selects the suite;
* - a dotted string in a settings module (`"stores.backends.StoreOwnerBackend"`,
* ROOT_URLCONF) and an `include("app.urls")` argument anywhere count as
* imports of the module they name;
* - an app (a directory holding apps.py, models.py or migrations/) maps its
* migrations, admin, templates, static files, template tags, management
* commands, fixtures and locale files to that app's tests and to the tests
* that reach its models, as an edit to models.py does; an app with no tests
* of its own falls back to the settings module (the suite);
* - a template under a project-level templates/ directory (settings DIRS)
* maps to the settings module: conservatively the whole suite.
* Uncertainty widens the selection; nothing here removes an edge.
*/
import * as path from 'node:path';
export interface DjangoInput {
files: readonly string[];
tests: ReadonlySet<string>;
read: (rel: string) => string | null;
/** Python source with strings blanked and comments dropped. */
code: (source: string) => string;
/** A dotted Python name → the repo files of its longest module prefix ([] outside the repo). */
resolveDotted: (dotted: string) => string[];
}
/** One edge: `dependent` depends on `dependency`. */
export interface DjangoEdge { dependency: string; dependent: string }
const SETTINGS_ASSIGN = /^(?:INSTALLED_APPS|ROOT_URLCONF)[ \t]*(?::[^=\n]*)?[+]?=/m;
const IMPORTS_DJANGO = /^[ \t]*(?:from[ \t]+django[\w.]*[ \t]+import|import[ \t]+django\b)/m;
const SETTINGS_ENV = /DJANGO_SETTINGS_MODULE['"]?\s*[,=:]\s*['"]?([A-Za-z_][\w.]*)/g;
const SETTINGS_ENV_FILES = /(?:^|\/)(?:manage\.py|pytest\.ini|pyproject\.toml|setup\.cfg|tox\.ini|conftest\.py|\.env\.example)$/;
const DOTTED_STRING = /['"]([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)['"]/g;
const INCLUDE = /\binclude\(\s*['"]([A-Za-z_][\w.]*)['"]/g;
/** Paths inside an app that Django loads by convention, relative to the app directory. */
const SOURCE_LIKE = /\.(?:py|[cm]?[jt]sx?)$/;
const APP_LOADED = /^(?:migrations\/.+\.py|admin\.py|admin\/.+|templates\/.+|jinja2\/.+|static\/.+|templatetags\/.+|management\/.+|fixtures\/.+|locale\/.+)$/;
function settingsModules(input: DjangoInput): string[] {
const out = new Set<string>();
let evidence = input.files.some(f => /(?:^|\/)manage\.py$/.test(f));
for (const f of input.files) {
if (f.endsWith('.py')) {
const text = input.read(f);
const code = text ? input.code(text) : '';
if (SETTINGS_ASSIGN.test(code)) out.add(f);
if (!evidence && IMPORTS_DJANGO.test(code)) evidence = true;
}
if (!SETTINGS_ENV_FILES.test(f)) continue;
const text = input.read(f) ?? '';
for (const m of text.matchAll(SETTINGS_ENV)) for (const t of input.resolveDotted(m[1]!)) if (t.endsWith('.py') && !t.endsWith('/__init__.py')) out.add(t);
}
return evidence ? [...out].sort() : [];
}
/** App directories: a directory holding apps.py, models.py, models/__init__.py or migrations/__init__.py. */
function appDirs(files: readonly string[]): string[] {
const out = new Set<string>();
for (const f of files) {
const m = /^(?:(.*)\/)?(?:apps\.py|models\.py|models\/__init__\.py|migrations\/__init__\.py)$/.exec(f);
if (m && m[1] !== undefined && !/(?:^|\/)(?:migrations|models)$/.test(m[1])) out.add(m[1]);
}
return [...out].sort((a, b) => b.length - a.length);
}
export function djangoEdges(input: DjangoInput): DjangoEdge[] {
const settings = settingsModules(input);
if (!settings.length) return [];
const edges: DjangoEdge[] = [];
const has = new Set(input.files);
const pyTests = [...input.tests].filter(t => t.endsWith('.py'));
for (const s of settings) {
for (const t of pyTests) edges.push({ dependency: s, dependent: t });
for (const m of (input.read(s) ?? '').matchAll(DOTTED_STRING)) for (const t of input.resolveDotted(m[1]!)) edges.push({ dependency: t, dependent: s });
}
for (const f of input.files) {
if (!f.endsWith('.py') || input.tests.has(f)) continue;
const text = input.read(f);
if (!text || !text.includes('include(')) continue;
for (const m of text.matchAll(INCLUDE)) for (const t of input.resolveDotted(m[1]!)) edges.push({ dependency: t, dependent: f });
}
const apps = appDirs(input.files);
const appOf = (f: string) => apps.find(a => f.startsWith(`${a}/`));
const appTests = new Map(apps.map(a => [a, pyTests.filter(t => appOf(t) === a)]));
for (const f of input.files) {
if (input.tests.has(f)) continue;
const app = appOf(f);
const rel = app === undefined ? null : f.slice(app.length + 1);
if (app !== undefined && rel !== null && APP_LOADED.test(rel)) {
const anchor = [`${app}/models.py`, `${app}/models/__init__.py`].find(a => has.has(a));
if (anchor && anchor !== f) edges.push({ dependency: f, dependent: anchor });
const own = appTests.get(app)!;
for (const t of own) edges.push({ dependency: f, dependent: t });
if (!own.length) for (const s of settings) edges.push({ dependency: f, dependent: s });
continue;
}
if (/(?:^|\/)templates\/./.test(f) && !SOURCE_LIKE.test(path.posix.basename(f))) for (const s of settings) edges.push({ dependency: f, dependent: s });
}
return edges;
}
+38 -2
View File
@@ -10,14 +10,32 @@
* fixture or a doc — still runs. Source code and anything under a test
* directory never qualify, including when `empty_lanes` globs match them.
* Unmapped code stays `PREGATE_NO_LANE`.
*
* `isInertNoTests` is the second empty-lane class (#3267): files no test
* runner loads — binary and media assets, and a named allowlist of editor,
* lint, format and repository-metadata configuration. It applies under the
* same rule (only when nothing maps the file to a test) and never to code,
* the test tree, snapshot or golden baselines, CI workflows, the pin files,
* stylesheets (they import each other), or any dotfile not on the list: an
* unknown dotfile may be read by a runner (`.rspec`, `.taprc`) or executed
* (`.husky/pre-commit`), so it still needs a lane.
*/
import { SOURCE_EXT } from './import-graph';
import { matchesAny } from '../ship-policy';
const TEST_TREE = /(?:^|\/)(?:test|tests|spec|__tests__)(?:\/|$)/i;
const MARKDOWN = /\.(?:md|markdown)$/i;
const MARKDOWN = /\.(?:md|markdown|rst|adoc|asciidoc)$/i;
/** Prose names, with or without `.md` (LICENSE has no extension). */
const PROSE_NAME = /^(?:README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE|ETHOS|LICENSE|LICENCE|COPYING|NOTICE)(?:\.md)?$/i;
/** Licence text under any name a vendored asset ships it with: `roboto-LICENSE.txt`, `LICENSE-MIT`, `COPYING.LESSER`, `licenses/apache.txt`. */
const LICENCE_TEXT = /(?:^|[-_.])(?:LICEN[CS]E|COPYING|COPYRIGHT|NOTICE)(?:[-_.][\w-]*)?(?:\.(?:txt|md))?$|^(?:licen[cs]es?|LICENSES)\/(?:.*\/)?[^/]+\.(?:txt|md)$/i;
const ASSET = /\.(?:png|jpe?g|gif|webp|avif|bmp|ico|icns|tiff?|svg|heic|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|flac|pdf|psd)$/i;
/** Directories whose files a test compares against or that pregate and CI read: never inert, assets included. */
const PROTECTED_TREE = /(?:^|\/)(?:__snapshots__|__image_snapshots__|[^/]*-snapshots|snapshots?|goldens?|baselines?|fixtures?)\/|^\.(?:github\/(?:workflows|actions)|gstack)\//i;
/** Editor, lint, format, hook-manager and repository-metadata configuration no test runner reads. */
const INERT_DOTFILE = /^\.(?:editorconfig|gitignore|gitattributes|gitmessage|mailmap|dockerignore|npmignore|prettierignore|eslintignore|pre-commit-config\.ya?ml|pre-commit-hooks\.ya?ml|prettierrc(?:\.(?:json|ya?ml|toml))?|eslintrc(?:\.(?:json|ya?ml))?|stylelintrc(?:\.(?:json|ya?ml))?|markdownlint(?:\.(?:json|jsonc|ya?ml))?|markdownlintignore|yamllint(?:\.ya?ml)?|flake8|pylintrc|isort\.cfg|rubocop\.yml|shellcheckrc|hadolint\.ya?ml|codespellrc|clang-format)$/i;
/** Dot-directories of editors and design tools, and GitHub's repository metadata (not workflows or actions). */
const INERT_DOT_DIR = /^\.(?:vscode|idea|fleet|zed|cursor|impeccable)\/|^\.github\/(?:ISSUE_TEMPLATE\/|DISCUSSION_TEMPLATE\/|PULL_REQUEST_TEMPLATE|pull_request_template|CODEOWNERS$|FUNDING\.ya?ml$|dependabot\.ya?ml$|labeler\.ya?ml$|release\.ya?ml$)/;
function isMarkdown(rel: string): boolean {
return MARKDOWN.test(rel);
@@ -37,7 +55,7 @@ function isSourceCode(rel: string): boolean {
function isBuiltinDocsPath(rel: string): boolean {
const base = rel.split('/').pop() ?? rel;
if (PROSE_NAME.test(base)) return true;
if (PROSE_NAME.test(base) || LICENCE_TEXT.test(base) || LICENCE_TEXT.test(rel)) return true;
// Any Markdown file. Name and directory do not matter: `planet.md` and
// `prompts/system.md` qualify the same way a `.work` plan does. A test
// that reads the file is handled by the caller, before this check.
@@ -57,3 +75,21 @@ export function isDocsEmptyLane(rel: string, extraGlobs: readonly string[] = [])
if (extraGlobs.length > 0 && matchesAny(extraGlobs, norm)) return true;
return isBuiltinDocsPath(norm);
}
/** Tool configuration on the allowlist: a named dotfile at any depth, or a file under an editor or design-tool dot-directory. */
function isToolConfig(rel: string): boolean {
return INERT_DOTFILE.test(rel.split('/').pop() ?? rel) || INERT_DOT_DIR.test(rel);
}
/**
* True when `rel` is a file no test runner loads (#3267): a binary or media
* asset outside any snapshot, golden or fixture tree, or allowlisted tool
* configuration. Callers apply this only
* after the import graph found no test, so an asset a test imports or names
* still selects that test.
*/
export function isInertNoTests(rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
if (!norm || isTestTree(norm) || isSourceCode(norm) || PROTECTED_TREE.test(norm)) return false;
return ASSET.test(norm) || isToolConfig(norm);
}
+120 -8
View File
@@ -11,7 +11,10 @@
* Python, dotted `mock.patch` targets (`'pkg.mod.Class.method'`);
* 3. the `bin/<name>` → `test/<name>*.test.*` convention (with and without
* the `gstack-` prefix);
* 4. explicit `.gstack/pregate.json` `dependencies` declarations.
* 4. explicit `.gstack/pregate.json` `dependencies` declarations;
* 5. in a Django project, the files Django loads by name (lib/pregate/django.ts).
* Dart directives (`import`/`export`/`part`, conditional URIs included) resolve
* `package:<name>/x` through every tracked pubspec.yaml to `<root>/lib/x`.
* Uncertainty selects more tests, never fewer: a dependency on a directory
* selects every test under it; a touched file with no path to a test is the
* caller's `no lane` failure, not a pass. test/helpers/touchfile-closure.ts
@@ -21,11 +24,12 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { matchesAny } from '../ship-policy';
import { djangoEdges } from './django';
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs)$/;
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$/;
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs|dart)$/;
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$|(?:^|\/)test\/(?:.*\/)?[^/]*_test\.dart$/;
export interface Edge { from: string; via: 'import' | 'literal' | 'convention' | 'declared' | 'generated' }
export interface Edge { from: string; via: 'import' | 'literal' | 'convention' | 'declared' | 'generated' | 'framework' }
export interface Graph {
files: Set<string>;
tests: Set<string>;
@@ -49,7 +53,107 @@ const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/in
const PY_IMPORT = /(?:^|[;:])[ \t\f]*(?:from[ \t]+(\.+(?:\w[\w.]*)?|\w[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[^\n;]+)|import[ \t]+([^\n;]+))/gm;
const PY_DOTTED = /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/;
export function isTestFile(rel: string): boolean { return TEST_FILE_RE.test(rel); }
/** Flutter's device-only directories: their Dart files need a device, so they are never tests here (a touched one has no lane until declared `[]`). */
const DEVICE_TEST_DIR = /(?:^|\/)(?:integration_test|test_driver)\//;
export function isDeviceDart(rel: string): boolean { return rel.endsWith('.dart') && DEVICE_TEST_DIR.test(rel); }
export function isTestFile(rel: string): boolean { return TEST_FILE_RE.test(rel) && !isDeviceDart(rel); }
/**
* A repo file's text, or null when it is not a regular file, is a symlink
* resolving outside the repo, is over `maxBytes` (default 1 MiB), or is
* unreadable. A committed symlink to a device or FIFO, or a directory named
* like the file, is skipped rather than read (a read could hang or exhaust memory).
*/
export function readRepoFile(repoRoot: string, rel: string, maxBytes = 1024 * 1024): string | null {
const abs = path.join(repoRoot, rel);
try {
if (fs.lstatSync(abs).isSymbolicLink()) {
const real = fs.realpathSync(abs);
const top = fs.realpathSync(repoRoot);
if (real !== top && !real.startsWith(top + path.sep)) return null;
}
const st = fs.statSync(abs);
if (!st.isFile() || st.size > maxBytes) return null;
return fs.readFileSync(abs, 'utf8');
} catch { return null; }
}
/** Dart package name → its roots (`''` for the repository root), from every tracked pubspec.yaml; a name two pubspecs share keeps both, in ls-files order. */
export function dartPackages(repoRoot: string, files: Iterable<string>): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const f of files) {
if (path.posix.basename(f) !== 'pubspec.yaml') continue;
const text = readRepoFile(repoRoot, f);
if (text === null) continue;
const name = /^name:\s*['"]?([A-Za-z_][\w]*)/m.exec(text.replace(/^\uFEFF/, ''))?.[1];
const root = path.posix.dirname(f);
if (name) out.set(name, [...(out.get(name) ?? []), root === '.' ? '' : root]);
}
return out;
}
/** The repo files one Dart directive URI names: `package:<name>/x` → the tracked `<root>/lib/x` of the importer's own package (the deepest pubspec root of any name containing `fromFile`) when that package is `<name>` and holds it, otherwise every tracked `<root>/lib/x` of `<name>` (an ambiguous name selects more, never fewer); a relative URI from `fromFile`; `dart:` and external packages name none. */
function resolveDartUri(files: Set<string>, packages: Map<string, string[]>, fromFile: string, uri: string): string[] {
const pkg = /^package:([\w]+)\/(.+)$/.exec(uri);
if (pkg) {
const roots = packages.get(pkg[1]!) ?? [];
const targetIn = (root: string) => normalize(path.posix.join(root, 'lib', pkg[2]!));
const own = [...packages.values()].flat().filter(r => r === '' || fromFile.startsWith(`${r}/`)).sort((a, b) => b.length - a.length)[0];
if (own !== undefined && roots.includes(own) && files.has(targetIn(own))) return [targetIn(own)];
return roots.map(targetIn).filter(t => files.has(t));
}
if (/^[a-z][\w+.-]*:/.test(uri)) return [];
const target = normalize(path.posix.join(path.posix.dirname(fromFile), uri));
return files.has(target) ? [target] : [];
}
/**
* The URIs of every `import`/`export`/`part` directive (never `part of`), comments blanked. A directive runs from its keyword at
* a line start or after a `;`, metadata (`@deprecated`) allowed before it, to the next unquoted `;`, across any number of lines whatever their indentation (a conditional import's
* `if (…) 'uri'` clause may start a line). The scan resumes after each directive, so it is one linear pass; a directive missing its
* `;` reads to the end of the file, which can only add edges.
*/
export function dartDirectiveUris(source: string): string[] {
const code = blankDartComments(source.replace(/^\uFEFF/, ''));
const start = /(?:^|;)[ \t]*(?:@[\w.$]+(?:\([^;]*?\))?\s+)*(?:import|export|part)\b(?!\s+of\b)/gm;
const uris: string[] = [];
for (let m = start.exec(code); m; m = start.exec(code)) {
const from = m.index + m[0].length;
let end = from;
for (let quote = ''; end < code.length && (quote || code[end] !== ';'); end++) {
const c = code[end]!;
if (quote) { if (c === '\\') end++; else if (c === quote || c === '\n') quote = ''; } else if (c === "'" || c === '"') quote = c;
}
for (const u of code.slice(from, end).matchAll(/'([^'\n]+)'|"([^"\n]+)"/g)) uris.push((u[1] ?? u[2])!);
start.lastIndex = Math.max(end, from);
}
return uris;
}
/** Dart source with line and (nested) block comments blanked to spaces (newlines kept, so line structure holds), quote-aware, in one linear pass. */
export function blankDartComments(source: string): string {
let out = '';
let quote = '';
for (let i = 0; i < source.length; i++) {
const c = source[i]!;
if (quote) { out += c; if (c === '\\') { out += source[i + 1] ?? ''; i++; } else if (c === quote || c === '\n') quote = ''; continue; }
if (c === "'" || c === '"') { quote = c; out += c; continue; }
const line = source.startsWith('//', i);
if (!line && !source.startsWith('/*', i)) { out += c; continue; }
let stop: number;
if (line) { const e = source.indexOf('\n', i); stop = e < 0 ? source.length : e; }
else {
// Dart block comments nest.
let depth = 0;
for (stop = i; stop < source.length;) {
if (source.startsWith('/*', stop)) { depth++; stop += 2; } else if (source.startsWith('*/', stop)) { depth--; stop += 2; if (!depth) break; } else stop++;
}
}
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop - 1;
}
return out;
}
export function listRepoFiles(repoRoot: string): string[] {
const r = spawnSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
@@ -224,7 +328,7 @@ function pyFromBase(rel: string, spec: string): string | null {
* stay inert. Backslash escapes are honoured; an unterminated string runs to
* its line end, or to the end of the file when triple-quoted.
*/
function pythonCode(source: string): string {
export function pythonCode(source: string): string {
const parts: string[] = [];
let from = 0;
for (let i = 0; i < source.length;) {
@@ -292,8 +396,11 @@ function addEdge(graph: Graph, dependency: string, dependent: string, via: Edge[
* repo paths and join() segment runs. Import specifiers that resolve nowhere
* and look like repo paths go to `unresolved` when given.
*/
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string, config: PathAliases = NO_ALIASES, unresolved?: string[]): Array<{ target: string; via: Edge['via'] }> {
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string, config: PathAliases = NO_ALIASES, unresolved?: string[], packages: Map<string, string[]> = new Map()): Array<{ target: string; via: Edge['via'] }> {
const out: Array<{ target: string; via: Edge['via'] }> = [];
if (rel.endsWith('.dart')) {
for (const uri of dartDirectiveUris(source)) for (const t of resolveDartUri(files, packages, rel, uri)) out.push({ target: t, via: 'import' });
}
const importOf = (spec: string) => {
const targets = resolveRefs(files, dirs, rel, spec, false, config);
for (const target of targets) out.push({ target, via: 'import' });
@@ -347,14 +454,19 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
const config = readPathAliases(repoRoot);
const graph: Graph = { files, tests: new Set(all.filter(isTestFile)), reverse: new Map(), unresolved: new Map(), aliasNotes: config.notes };
const maxBytes = opts.maxBytes ?? 2 * 1024 * 1024;
const packages = dartPackages(repoRoot, all);
const pySources = new Map<string, string>();
for (const rel of all) {
if (!SOURCE_EXT.test(rel) && !rel.startsWith('bin/')) continue;
let source: string;
try { const st = fs.statSync(path.join(repoRoot, rel)); if (!st.isFile() || st.size > maxBytes) continue; source = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
if (rel.endsWith('.py')) pySources.set(rel, source);
const unresolved: string[] = [];
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined)) addEdge(graph, target, rel, via);
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined, packages)) addEdge(graph, target, rel, via);
if (unresolved.length) graph.unresolved!.set(rel, unresolved);
}
const django = djangoEdges({ files: [...files], tests: graph.tests, read: rel => pySources.get(rel) ?? readRepoFile(repoRoot, rel, maxBytes), code: pythonCode, resolveDotted: dotted => pythonPatchTarget(files, dotted) });
for (const e of django) addEdge(graph, e.dependency, e.dependent, 'framework');
for (const test of graph.tests) {
const m = /^(?:[\w.-]+\/)*?test\/([\w-]+)\.(?:test|spec)\./.exec(test);
if (!m) continue;
+100
View File
@@ -0,0 +1,100 @@
/**
* pregate/init-draft — `gstack-pregate init` writes a policy drafted from the
* repo (#3268) instead of a fixed example: the languages it holds, what the
* lanes check would run, and a `dependencies` entry for each file the import
* graph maps to no test.
*
* A drafted entry only ever adds tests: it maps an unmapped file to the tests
* of the nearest directory above it that has any. An empty list (`[]`) would
* clear the file without a test, so the draft never writes one; a file with no
* nearby test, or past the schema's entry limit, is printed as a TODO for the
* owner to decide. Documentation,
* assets and tool config already clear without a policy (docs-lane.ts), and
* Dart packages run per package root, so `runner` stays null unless no
* runner can be detected.
*/
import * as path from 'node:path';
import { detectRunner } from './checks/lanes';
import { DEFAULT_CONFIG, MAX_LIST, PREGATE_CONFIG_PATH, PREGATE_CONFIG_SCHEMA } from './config';
import { isDocsEmptyLane, isInertNoTests } from './docs-lane';
import { buildGraph, listRepoFiles, testsFor } from './import-graph';
import { GENERATED_PATH, isRegistryUnavailable, loadRegistryFromHead } from '../regen';
import { matchesAny } from '../ship-policy';
import { discoverLanes } from './workflows';
const LANGUAGES: Array<[string, RegExp]> = [
['typescript', /\.[cm]?tsx?$/], ['javascript', /\.[cm]?jsx?$/], ['python', /\.py$/], ['dart', /\.dart$/],
['go', /\.go$/], ['ruby', /\.rb$/], ['rust', /\.rs$/], ['shell', /\.(?:sh|bash|bats)$/],
];
/** Above this many tests a directory's tests are no longer a useful suggestion. */
const MAX_SUGGESTED = 25;
const MAX_TODO_LINES = 50;
export interface InitDraft {
json: string;
languages: Array<{ language: string; files: number }>;
runnerNote: string;
drafted: number;
todo: string[];
selectionTodo: string[];
}
/** The tests under the nearest ancestor directory of `file` (not the repo root) that has 1..MAX_SUGGESTED tests. */
function nearestTests(tests: readonly string[], file: string): string[] {
for (let dir = path.posix.dirname(file); dir !== '.' && dir !== ''; dir = path.posix.dirname(dir)) {
const under = tests.filter(t => t.startsWith(`${dir}/`));
if (under.length > MAX_SUGGESTED) return [];
if (under.length) return under;
}
return [];
}
export function draftConfig(repoRoot: string): InitDraft {
const files = listRepoFiles(repoRoot);
const graph = buildGraph(repoRoot);
const tests = [...graph.tests].sort();
const languages = LANGUAGES.map(([language, re]) => ({ language, files: files.filter(f => re.test(f)).length })).filter(l => l.files > 0).sort((a, b) => b.files - a.files);
const dependencies: Record<string, string[]> = {};
const todo: string[] = [];
let drafted = 0;
const registry = loadRegistryFromHead(repoRoot);
const generated = isRegistryUnavailable(registry) ? [] : registry.registry.entries.flatMap(e => [...e.inputs, ...e.outputs]);
for (const file of files) {
if (matchesAny(generated, file)) continue;
if (graph.tests.has(file) || file === PREGATE_CONFIG_PATH || file === GENERATED_PATH) continue;
if (isDocsEmptyLane(file) || isInertNoTests(file)) continue;
if (testsFor(graph, file).tests.length) continue;
const suggested = nearestTests(tests, file);
if (!suggested.length || drafted >= MAX_LIST) { todo.push(file); continue; }
dependencies[file] = suggested;
drafted++;
}
const groups = new Map<string, string[]>();
for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); }
const notes = [...groups].map(([lang, list]) => {
if (lang === 'dart') return `${list.length} Dart test file(s) run per package with flutter test or dart test`;
const runner = detectRunner({ repoRoot, config: DEFAULT_CONFIG }, list);
return runner ? `${lang} tests detected at run time: ${runner}` : `no runner detected for ${list.length} ${lang} test file(s); set runner (with {files})`;
});
const mixed = [...groups.keys()].filter(l => l !== 'dart').length > 1 ? '; a selection mixing these languages needs runner set' : '';
const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found';
const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id);
const config = { $schema: PREGATE_CONFIG_SCHEMA, runner: null, dependencies, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] } };
return { json: JSON.stringify(config, null, 2) + '\n', languages, runnerNote, drafted, todo, selectionTodo };
}
/** The lines `init` prints after writing the draft. */
export function describeDraft(d: InitDraft): string[] {
const lines = [
`PREGATE_INIT: languages ${d.languages.map(l => `${l.language} ${l.files}`).join(', ') || '(none recognised)'}`,
`PREGATE_INIT: runner null (${d.runnerNote})`,
`PREGATE_INIT: ${d.drafted} file(s) with no test lane mapped to the tests nearest them under dependencies; review each entry`,
];
for (const id of d.selectionTodo) lines.push(`TODO selection: ${id} computes its matrix; declare the command that lists its test files under selection`);
for (const f of d.todo.slice(0, MAX_TODO_LINES)) lines.push(`TODO ${f}: no test lane and no nearby test; add a test, map it under dependencies, or declare [] if no test can cover it`);
if (d.todo.length > MAX_TODO_LINES) lines.push(`TODO: +${d.todo.length - MAX_TODO_LINES} more file(s) with no test lane`);
return lines;
}
+10 -2
View File
@@ -11,7 +11,7 @@ import { GENERATED_PATH } from '../regen';
import { runBashSync } from '../script-invocation';
import { matchesAny } from '../ship-policy';
import { PREGATE_CONFIG_PATH } from './config';
import { isDocsEmptyLane } from './docs-lane';
import { isDocsEmptyLane, isInertNoTests } from './docs-lane';
import { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph';
import type { PregateContext, RemoteObligation } from './types';
import type { Lane } from './workflows';
@@ -21,6 +21,8 @@ export interface Selection {
noLane: string[];
/** Markdown (and `empty_lanes` paths) with no import-graph test: the docs/no-tests empty lane. */
emptyLane: string[];
/** Assets and dotfile tool config with no import-graph test: files no runner loads, also an empty lane. */
inert: string[];
/** Every test the touched files select, sorted. */
tests: string[];
/** Tests that run on this machine. */
@@ -80,6 +82,7 @@ export function selectLanes(ctx: PregateContext): Selection {
const tests = new Set<string>();
const noLane: string[] = [];
const emptyLane: string[] = [];
const inert: string[] = [];
const seeds = new Map<string, string>();
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
for (const e of ctx.registry.entries) {
@@ -110,6 +113,11 @@ export function selectLanes(ctx: PregateContext): Selection {
provenance.push(`${file}: docs/no-tests (empty lane)`);
continue;
}
if (isInertNoTests(file)) {
inert.push(file);
provenance.push(`${file}: no-tests (asset or tool config no runner loads)`);
continue;
}
noLane.push(file);
}
const laneSets: Record<string, string[] | '?'> = {};
@@ -132,7 +140,7 @@ export function selectLanes(ctx: PregateContext): Selection {
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
if (local.size && tiers.size === 0) tiers.add('unit');
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance };
}
export function isTest(file: string): boolean { return isTestFile(file); }
+77 -6
View File
@@ -25,7 +25,7 @@ export interface Lane {
platform: Platform;
/** Every `run:` step text, in order. */
commands: string[];
/** Test file paths named literally in the run steps. */
/** Test file paths named literally in the run steps, repo-relative: resolved against the step's working-directory and any literal `cd`/`pushd` before them; empty when one is named from an unresolvable directory (the lane may run any test). */
testFiles: string[];
/** The `matrix:` value when it is a workflow expression, else null. */
matrixExpression: string | null;
@@ -37,8 +37,8 @@ export interface Lane {
source: string;
}
const TEST_COMMAND = /\b(?:bun test|bun run test\b|bun run test:|npm test|npm run test\b|pnpm test|yarn test|pytest|go test|cargo test|rspec|bin\/test-lane|node --test|vitest|jest|test-(?:free|paid)-shards\.ts(?![^\n]*--(?:ci-verify|ci-plan|list)\b))\b/;
const TEST_FILE = /(?<![\w/])((?:[\w.-]+\/)*(?:test|tests|spec|__tests__)\/[\w./-]*\.(?:test|spec)\.[a-z]+|(?:[\w.-]+\/)*[\w.-]+\.(?:test|spec)\.[cm]?[jt]sx?)(?![\w/])/g;
const TEST_COMMAND = /\b(?:bun test|bun run test\b|bun run test:|npm test|npm run test\b|pnpm test|yarn test|pytest|go test|cargo test|flutter test|dart test|dart run test|melos (?:run )?test|very_good test|rspec|bin\/test-lane|node --test|vitest|jest|test-(?:free|paid)-shards\.ts(?![^\n]*--(?:ci-verify|ci-plan|list)\b))\b/;
const TEST_FILE = /(?<![\w/])((?:[\w.-]+\/)*(?:test|tests|spec|__tests__)\/[\w./-]*\.(?:test|spec)\.[a-z]+|(?:[\w.-]+\/)*[\w.-]+\.(?:test|spec)\.[cm]?[jt]sx?|(?:[\w.-]+\/)*test\/(?:[\w.-]+\/)*[\w.-]+_test\.dart)(?![\w/])/g;
function indentOf(line: string): number { return line.length - line.trimStart().length; }
@@ -54,12 +54,14 @@ function outline(text: string): Block[] {
if (!raw.trim() || raw.trim().startsWith('#')) continue;
const indent = indentOf(raw);
const body = raw.trim().replace(/^-\s+/, '');
// A list item's key sits past its `- `: a block scalar ends at a line not indented past that key (a sibling step key).
const keyColumn = indent + raw.trim().length - body.length;
const m = /^([^:#]+?):(?:\s+(.*))?$/.exec(body);
const key = m ? m[1]!.trim() : body;
let value = m ? (m[2] ?? '').trim() : '';
if (/^[|>][-+]?$/.test(value)) {
const parts: string[] = [];
while (i + 1 < lines.length && (!lines[i + 1]!.trim() || indentOf(lines[i + 1]!) > indent)) { i++; parts.push(lines[i]!.trim()); }
while (i + 1 < lines.length && (!lines[i + 1]!.trim() || indentOf(lines[i + 1]!) > keyColumn)) { i++; parts.push(lines[i]!.trim()); }
value = parts.filter(Boolean).join('\n');
}
while (stack.length > 1 && stack[stack.length - 1]!.indent >= indent) stack.pop();
@@ -71,6 +73,67 @@ function outline(text: string): Block[] {
}
function find(blocks: Block[], key: string): Block | undefined { return blocks.find(b => b.key === key); }
/** `defaults: run: working-directory:` under `parent`, or null. */
function defaultDir(parent: Block[]): string | null {
return find(find(find(parent, 'defaults')?.children ?? [], 'run')?.children ?? [], 'working-directory')?.value ?? null;
}
/** A step's working directory, repo-relative (`''` for the root), or null when it cannot be resolved: an expression, absolute, or escaping `..`. */
function resolveDir(dir: string | null): string | null {
const d = (dir ?? '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
if (!d) return '';
if (/\$\{\{/.test(d) || path.posix.isAbsolute(d)) return null;
const n = path.posix.normalize(d).replace(/\/$/, '');
return n === '.' ? '' : n === '..' || n.startsWith('../') ? null : n;
}
/** `cur` after a `cd`/`pushd` segment: its first word (a redirect or comment after it is ignored), when a literal relative path, joins it; a non-literal, absolute, bare or repo-escaping target (or an unknown `cur`) is null. */
function changeDir(cur: string | null, target: string | undefined): string | null {
const t = (/^\s*(?:(['"])(.*?)\1|([^\s'"]+))(?=\s|$)/.exec(target ?? '') ?? []).slice(2).find(x => x !== undefined) ?? '';
if (cur === null || !t || /[$`~*?[\]{}'"\\\s]/.test(t) || t.startsWith('-') || path.posix.isAbsolute(t)) return null;
const n = path.posix.normalize(path.posix.join(cur, t)).replace(/\/$/, '');
return n === '.' ? '' : n === '..' || n.startsWith('../') ? null : n;
}
/**
* The repo-relative test paths one run command names, walking its shell segments (split on `&&`, `||`, `;`, `|`, newlines) from
* `start` (null: unknown). `cd`/`pushd <literal>` moves the directory; any other directory change (`popd`, `cd "$X"`, a `cd` not
* leading its segment) makes it unknown for the rest of the command. A segment's paths resolve against the directory current at
* it; a path named while it is unknown, or escaping the repo, returns null (the lane may run any test). The package.json script a
* segment runs (one level deep, as expandScripts) is walked from the root when the segment runs there, else from an unknown directory (npm runs the nearest package.json's script). A `( … )` subshell's directory changes end at its `)`.
*/
function commandTestFiles(command: string, start: string | null, scripts: Record<string, string> | null): string[] | null {
let cur = start;
const out: string[] = [];
const saved: Array<string | null> = [];
const segments = command.split(/&&|\|\||[;|\n]/);
// A pipeline element, a `||` alternative, a background job, a command substitution, shell control flow, or a `)` with text after
// it (a redirect) runs a `cd` in a scope or order this walk cannot track, so in such a command any directory change makes the
// directory unknown (the lane may run any test).
const untracked = /\||(?<!&)&(?!&)|`|\$\(|[{}]|\b(?:if|then|else|elif|case|while|until|for|select|function)\b/.test(command) || segments.some(seg => /\)/.test(seg.replace(/\)+\s*$/, '')));
for (const raw of segments) {
// A `( … )` subshell's cds end at its `)`.
const opens = /^\s*(\(+)/.exec(raw)?.[1]!.length ?? 0;
for (let i = 0; i < opens; i++) saved.push(cur);
const seg = raw.replace(/^\s*\(+/, '');
for (const m of seg.matchAll(TEST_FILE)) {
const joined = cur === null ? null : path.posix.normalize(path.posix.join(cur, m[1]!)).replace(/^\.\//, '');
if (joined === null || joined.startsWith('../')) return null;
out.push(joined);
}
if (scripts) {
for (const m of seg.matchAll(/\b(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?([A-Za-z0-9:_.-]+)/g)) {
if (!scripts[m[1]!]) continue;
// npm runs the nearest package.json's script; only from the root is that known to be the root's.
const inner = commandTestFiles(scripts[m[1]!]!, cur === '' ? '' : null, null);
if (inner === null) return null;
out.push(...inner);
}
}
const cd = /^\s*(?:cd|pushd)(?:\s+(.*))?$/.exec(seg);
if (cd) cur = untracked ? null : changeDir(cur, cd[1]);
else if (/(?:^|[\s(){}])(?:cd|pushd|popd)(?=\s|$)/.test(seg)) cur = null;
for (let closes = (/(\)+)\s*$/.exec(seg)?.[1]!.length ?? 0); closes > 0 && saved.length; closes--) { const outer = saved.pop()!; if (!untracked) cur = outer; }
}
return out;
}
function deepValues(blocks: Block[], key: string, out: string[] = []): string[] {
for (const b of blocks) { if (b.key === key && b.value) out.push(b.value); deepValues(b.children, key, out); }
return out;
@@ -100,13 +163,21 @@ export function parseWorkflow(file: string, text: string, platform: NodeJS.Platf
const maxParallel = find(find(job.children, 'strategy')?.children ?? [], 'max-parallel')?.value;
const jobPlatform = laneOf(runsOn);
const jobName = find(job.children, 'name')?.value ?? job.key;
const testFiles = [...new Set(commands.flatMap(c => [...c.matchAll(TEST_FILE)].map(m => m[1]!)))];
// A step's paths resolve against its working-directory, else the job's defaults, else the workflow's.
const jobDir = defaultDir(job.children) ?? defaultDir(top);
// Each run command is walked from the step's directory through its `cd`s (commandTestFiles). A test path named from an
// unresolvable directory leaves the lane's set unknown (empty: laneFileSet's may-run-anything path).
let unresolved = false;
const testFiles = [...new Set((find(job.children, 'steps')?.children ?? []).flatMap(step => {
const dir = resolveDir(find([step, ...step.children], 'working-directory')?.value ?? jobDir);
return deepValues([step], 'run').flatMap(c => commandTestFiles(c, dir, scripts) ?? (unresolved = true, []));
}))];
let tier: LaneTier = 'unit';
if (jobPlatform !== 'any' && jobPlatform !== here) tier = 'platform';
else if (/\b(e2e|evals?)\b/i.test(`${name} ${job.key} ${jobName}`)) tier = 'e2e';
else if (maxParallel === '1' || /serial/i.test(`${job.key} ${jobName}`)) tier = 'serial';
return {
id: `${file}/${job.key}`, workflow: file, workflowName: name, job: job.key, jobName, runsOn, platform: jobPlatform, commands, testFiles,
id: `${file}/${job.key}`, workflow: file, workflowName: name, job: job.key, jobName, runsOn, platform: jobPlatform, commands, testFiles: unresolved ? [] : testFiles,
matrixExpression, tier, runsTests: commands.some(c => TEST_COMMAND.test(c)), manual, source: `.github/workflows/${file}:${job.line}`,
};
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.160",
"version": "1.91.161",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+2
View File
@@ -65,6 +65,8 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
{ file: 'test/pregate-selection.test.ts', reason: 'its gstack-pregate (bin) cases expect the Linux-host lanes verdict (a local lane run); on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-bats-nested.test.ts', reason: 'expects the bats lane to run locally, which it does on a Linux host; on Windows the fixture\'s Linux lane is a requires-remote obligation' },
{ file: 'test/pregate-docs-lane-review.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess); the docs-lane selection rules it drives are POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-dart.test.ts', reason: 'its dart, flutter and fvm stand-ins are bash scripts on a colon-joined PATH, which Bun.which and CreateProcess cannot use on Windows, and its containment case needs a POSIX symlink; the Dart lane code it drives is POSIX-run like the other pregate fixtures' },
{ file: 'test/pregate-non-js.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin and expects the Linux-host lanes verdict, like test/pregate-docs-lane-review.test.ts and test/pregate-selection.test.ts' },
{ file: 'test/pregate-remote-gbrain.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and runs the gbrain-shaped ci:ubicloud package script through the POSIX lane runner, like test/pregate-remote.test.ts' },
// C-pregate: these drive bin/gstack-issue-links and bin/gstack-ship-receipt through
// test/helpers/pregate-fixture.ts runBin.
+1 -1
View File
@@ -91,7 +91,7 @@ export function makeFixtureRepo(opts: { registry?: Record<string, unknown> | nul
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const git = (...args: string[]): string => {
const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`);
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: status=${r.status} signal=${r.signal} error=${r.error?.message ?? ''} stderr=${r.stderr?.toString() ?? ''}`);
return r.stdout?.toString().trim() ?? '';
};
spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } });
+629
View File
@@ -0,0 +1,629 @@
/**
* C8 `lanes` for a Dart/Flutter repo: `package:` directives resolve through
* every tracked pubspec.yaml, `_test.dart` files under `test/` are tests,
* each package's tests run from its own root, and the runner's `+N -M:`
* verdict is judged. The end-to-end fixture commits its policy on the base
* and carries no workflows, so every selected test is a local lane on any
* host platform.
*/
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 { dartRunner, parseSummary, planRuns } from '../lib/pregate/checks/lanes';
import { laneFileSet } from '../lib/pregate/lane-selection';
import type { Lane } from '../lib/pregate/workflows';
import { ROOT_OK } from '../lib/pregate/checks/strays';
import { DEFAULT_CONFIG } from '../lib/pregate/config';
import { blankDartComments, buildGraph, dartPackages, isTestFile, readRepoFile, testsFor } from '../lib/pregate/import-graph';
import type { PregateContext } from '../lib/pregate/types';
import { parseWorkflow } from '../lib/pregate/workflows';
import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const DART_FILES: Record<string, string> = {
'pubspec.yaml': 'name: app\ndependencies:\n core:\n path: packages/core\n',
'lib/theme.dart': "const double gap = 8;\n",
'lib/widgets/drawer.dart': "import 'package:app/theme.dart';\nimport 'package:core/core.dart' if (dart.library.io) 'package:core/core_io.dart';\nimport 'dart:math';\npart 'drawer_parts.dart';\n",
'lib/widgets/drawer_parts.dart': "part of 'drawer.dart';\n",
'lib/widgets/widgets.dart': "export 'drawer.dart';\n",
'test/ui/drawer_widget_test.dart': "import 'package:app/widgets/widgets.dart';\nimport 'package:flutter_test/flutter_test.dart';\nvoid main() {}\n",
'test/helpers/pump.dart': "import '../../lib/theme.dart';\n",
'packages/core/pubspec.yaml': 'name: core\n',
'packages/core/lib/core.dart': "int one() => 1;\n",
'packages/core/lib/core_io.dart': "int one() => 1;\n",
'packages/core/test/core_test.dart': "import 'package:core/core.dart';\nvoid main() {}\n",
};
/** A base policy with no declared runner, so Dart files are planned per package. */
const DART_PREGATE = { $schema: 'https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1', dependencies: { 'docs/**': [], 'README.md': [] } };
/**
* The fixture plus DART_FILES on main. `policy` is committed as the base's
* .gstack/pregate.json; the fixture's workflows are removed so no lane is
* platform-tier on any host (every selected test runs locally).
*/
function dartRepo(policy: Record<string, unknown> = DART_PREGATE): FixtureRepo {
const repo = makeFixtureRepo({ pregate: policy });
repos.push(repo);
commit(repo, { ...DART_FILES, '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'dart');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
return repo;
}
function sleepCommand(sleepS: number | Record<string, number>): string {
if (typeof sleepS === 'number') return `sleep ${sleepS}`;
const { '*': other = 0, ...named } = sleepS;
return `case "$(basename "$PWD")" in ${Object.entries(named).map(([dir, n]) => `${dir}) sleep ${n};;`).join(' ')} *) sleep ${other};; esac`;
}
/** A `dart` and `flutter` on PATH that report its cwd and passes every file it is given, after `sleepS` seconds (per package directory name when an object). */
function dartShim(repo: FixtureRepo, sleepS: number | Record<string, number> = 0): { PATH: string } {
const dir = path.join(repo.root, 'shim');
fs.mkdirSync(dir, { recursive: true });
for (const tool of ['dart', 'flutter']) fs.writeFileSync(path.join(dir, tool), `#!/usr/bin/env bash\nshift\n${sleepCommand(sleepS)}\necho "ran in $(basename "$PWD"): $*"\necho "00:01 +$#: All tests passed!"\n`, { mode: 0o755 });
return { PATH: `${dir}:${process.env.PATH ?? ''}` };
}
/** The shim dir, filled with links to every host PATH tool except fvm/dart/flutter: pregate and git still run, no real Dart tool is reachable. */
function pathWithoutDartTools(repo: FixtureRepo): string {
const bin = path.join(repo.root, 'shim');
fs.mkdirSync(bin, { recursive: true });
for (const dir of (process.env.PATH ?? '').split(':')) {
let names: string[] = [];
try { names = fs.readdirSync(dir); } catch { continue; }
for (const name of names) if (!['fvm', 'dart', 'flutter'].includes(name) && !fs.lstatSync(path.join(bin, name), { throwIfNoEntry: false })) fs.symlinkSync(path.join(dir, name), path.join(bin, name));
}
return bin;
}
describe('Dart import graph', () => {
test('_test.dart is a test; package:, relative, conditional, export and part directives all map; dart: and unknown packages do not', () => {
const repo = dartRepo();
const g = buildGraph(repo.work);
expect(isTestFile('test/ui/drawer_widget_test.dart')).toBe(true);
expect(isTestFile('lib/widgets/drawer.dart')).toBe(false);
// Value: protects=device-only Flutter tests (integration_test/, test_driver/) are never auto-selected for a local `flutter test`;
// fails_when=the exclusion is dropped and touching lib/ launches the app or fails with no device; why_new=no integration test file was classified;
// seam=none; no_claim=a device run
for (const f of ['integration_test/app_test.dart', 'packages/core/integration_test/core_test.dart', 'test_driver/app_test.dart', 'test_driver/integration_test.dart']) expect(isTestFile(f)).toBe(false);
// Value: protects=dart test only runs files under test/, so a production lib/src/ab_test.dart is never run as a test;
// fails_when=the _test.dart arm loses its test/ anchor (false PREGATE_LANE_FAILED on an app file); why_new=only device dirs were excluded;
// seam=none; no_claim=none beyond protects
expect(isTestFile('lib/src/ab_test.dart')).toBe(false);
expect(isTestFile('packages/core/test/unit/core_test.dart')).toBe(true);
expect(testsFor(g, 'lib/theme.dart').tests).toEqual(['test/ui/drawer_widget_test.dart']);
expect(testsFor(g, 'lib/widgets/drawer_parts.dart').tests).toEqual(['test/ui/drawer_widget_test.dart']);
expect(testsFor(g, 'packages/core/lib/core.dart').tests).toEqual(['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart']);
expect(testsFor(g, 'packages/core/lib/core_io.dart').tests).toEqual(['test/ui/drawer_widget_test.dart']);
expect(testsFor(g, 'test/ui/drawer_widget_test.dart').tests).toEqual(['test/ui/drawer_widget_test.dart']);
});
// Value: protects=several directives on one line all map, while a directive inside a line comment still does not;
// fails_when=the directive regex is anchored to line start again (only the first import of `import 'a'; import 'b';` counts) or
// loosened to match after any `;`; why_new=every fixture put one directive per line; seam=none; no_claim=a Dart parser
test('directives sharing a line all map; a commented-out directive does not', () => {
const repo = dartRepo();
commit(repo, {
'lib/multi.dart': 'int two() => 2;\n',
'test/multi_test.dart': "import 'package:test/test.dart'; import 'package:app/multi.dart';\nvoid main() {}\n",
'test/commented_test.dart': "void main() {} // see a; import 'package:app/multi.dart';\n",
'lib/cond.dart': 'int c() => 1;\n',
'lib/cond_io.dart': 'int c() => 2;\n',
'test/cond_test.dart': "import 'package:app/cond.dart'\n if (dart.library.io) 'package:app/cond_io.dart'\n show c;\nvoid main() {}\n",
});
const g = buildGraph(repo.work);
expect(testsFor(g, 'lib/multi.dart').tests).toEqual(['test/multi_test.dart']);
// Value: protects=a directive dart format splits over indented lines (a conditional import) still maps both URIs once the run regex
// is bounded for linear time; fails_when=the body stops at the first newline or the continuation rule rejects indented lines;
// why_new=every directive fit on one line; seam=none; no_claim=the linear-time bound itself (measured, not asserted)
expect(testsFor(g, 'lib/cond.dart').tests).toEqual(['test/cond_test.dart']);
expect(testsFor(g, 'lib/cond_io.dart').tests).toEqual(['test/cond_test.dart']);
// Value: protects=a `;` inside a comment within a directive does not end it, so the conditional URI after the comment still maps,
// while a `//` inside a string is kept; fails_when=comments are not blanked before the directive scan (the comment's `;`
// truncates the import and native.dart loses its dependent, failing open); why_new=no fixture put a comment inside a directive;
// seam=none; no_claim=nested block comments or triple-quoted strings
commit(repo, {
'lib/base.dart': 'int b() => 1;\n',
'lib/native.dart': 'int b() => 2;\n',
'lib/web.dart': 'int b() => 3;\n',
'test/platform_test.dart': "import 'package:app/base.dart' // fallback; platform-specific below\n if (dart.library.io) 'package:app/native.dart' /* io; */\n if (dart.library.js_interop) 'package:app/web.dart';\nvoid main() {}\n",
});
const h = buildGraph(repo.work);
for (const f of ['lib/base.dart', 'lib/native.dart', 'lib/web.dart']) expect(testsFor(h, f).tests).toEqual(['test/platform_test.dart']);
// Value: protects=a comment on its own line inside a multi-line conditional import (whitespace-only once blanked) does not end
// the directive, so both URIs map; fails_when=the continuation only accepts an indented non-blank line and the whole directive
// drops (base.dart and native.dart lose their dependent, failing open); why_new=the comment fixture kept comments at line ends;
// seam=none; no_claim=the linear-time bound itself (measured, not asserted)
commit(repo, {
'lib/own_base.dart': 'int b() => 1;\n',
'lib/own_native.dart': 'int b() => 2;\n',
'test/own_line_test.dart': "import 'package:app/own_base.dart'\n // Native implementation.\n\n if (dart.library.io) 'package:app/own_native.dart';\nvoid main() {}\n",
});
const k = buildGraph(repo.work);
for (const f of ['lib/own_base.dart', 'lib/own_native.dart']) expect(testsFor(k, f).tests).toEqual(['test/own_line_test.dart']);
expect(blankDartComments("var u = 'http://x'; // c;\n")).toBe("var u = 'http://x'; \n");
});
// Value: protects=two tracked pubspecs sharing a `name:` (a fixture copying the app's) keep both roots, so package: imports still
// reach the real package; fails_when=the first root in ls-files order wins, so a fixture that mirrors lib/ steals the real package's edges (no lane);
// why_new=every fixture package name was unique; seam=none; no_claim=pub's own resolution (the importer's own root first, then the rest; tracked file wins)
test('a package name two pubspecs share resolves to whichever root holds the file', () => {
const repo = dartRepo();
commit(repo, {
'a_fixture/pubspec.yaml': 'name: app\n',
'lib/x.dart': 'int x() => 1;\n',
'test/x_test.dart': "import 'package:app/x.dart';\nvoid main() {}\n",
});
expect(dartPackages(repo.work, ['a_fixture/pubspec.yaml', 'pubspec.yaml']).get('app')).toEqual(['a_fixture', '']);
expect(testsFor(buildGraph(repo.work), 'lib/x.dart').tests).toEqual(['test/x_test.dart']);
// The fixture mirrors the file: the importer's own package (the deepest root containing it) wins over ls-files order.
commit(repo, { 'a_fixture/lib/x.dart': 'int x() => 2;\n', 'a_fixture/test/x_test.dart': "import 'package:app/x.dart';\nvoid main() {}\n" });
const g = buildGraph(repo.work);
expect(testsFor(g, 'lib/x.dart').tests).toEqual(['test/x_test.dart']);
expect(testsFor(g, 'a_fixture/lib/x.dart').tests).toEqual(['a_fixture/test/x_test.dart']);
});
// Value: protects=an importer inside a third package (apps/app) imports a name the repo root and packages/shared both declare, and
// both copies select it; fails_when=the importer's own package is taken as the deepest same-name root containing it (the root ''
// contains everything), so the root copy steals the import and packages/shared/lib/a.dart has no lane (fail open);
// why_new=no fixture nested an importer under a same-name ancestor it does not belong to; seam=none; no_claim=pub's own resolution
test('an ancestor same-name package does not steal an import from a package that is not its own', () => {
const repo = dartRepo();
commit(repo, {
'pubspec.yaml': 'name: shared\n',
'lib/a.dart': 'int a() => 1;\n',
'packages/shared/pubspec.yaml': 'name: shared\n',
'packages/shared/lib/a.dart': 'int a() => 2;\n',
'apps/app/pubspec.yaml': 'name: app\n',
'apps/app/test/x_test.dart': "import 'package:shared/a.dart';\nvoid main() {}\n",
'test/root_test.dart': "import 'package:shared/a.dart';\nvoid main() {}\n",
});
const g = buildGraph(repo.work);
expect(testsFor(g, 'packages/shared/lib/a.dart').tests).toEqual(['apps/app/test/x_test.dart']);
// The root package's own test still resolves to its own copy only.
expect(testsFor(g, 'lib/a.dart').tests).toEqual(['apps/app/test/x_test.dart', 'test/root_test.dart']);
});
// Value: protects=a package name two other pubspecs share, imported from outside both, selects the importer for either copy, and a
// leading BOM does not hide the file's directives; fails_when=the first root in ls-files order wins (the other copy loses its
// dependent) or the BOM stops the line-start match (no edges, fail open); why_new=duplicate names resolved to one root and every
// fixture file started at a directive; seam=none; no_claim=pub's own dependency resolution
test('an ambiguous package: import selects every same-name copy, and a BOM does not hide directives', () => {
const repo = dartRepo();
commit(repo, {
'a_fixture/pubspec.yaml': 'name: shared\n',
'a_fixture/lib/y.dart': 'int y() => 1;\n',
'b_fixture/pubspec.yaml': 'name: shared\n',
'b_fixture/lib/y.dart': 'int y() => 2;\n',
'test/y_test.dart': "\uFEFFimport 'package:shared/y.dart';\nvoid main() {}\n",
});
const g = buildGraph(repo.work);
expect(testsFor(g, 'a_fixture/lib/y.dart').tests).toEqual(['test/y_test.dart']);
expect(testsFor(g, 'b_fixture/lib/y.dart').tests).toEqual(['test/y_test.dart']);
});
// Value: protects=a pubspec.yaml that is a symlink out of the repo, or not a regular file, is skipped without throwing (a link to a
// device or FIFO could hang the read); fails_when=the guard is dropped and the outside file names a package or the read throws;
// why_new=every fixture pubspec was a regular in-repo file; seam=none; no_claim=a real /dev/zero or FIFO (a directory and an
// outside file stand in)
test('a pubspec.yaml symlinked outside the repo, or a directory of that name, is skipped', () => {
const repo = dartRepo();
fs.mkdirSync(path.join(repo.root, 'outside'), { recursive: true });
fs.writeFileSync(path.join(repo.root, 'outside', 'pubspec.yaml'), 'name: outside\ndependencies:\n flutter:\n sdk: flutter\n');
fs.mkdirSync(path.join(repo.work, 'packages', 'ext'), { recursive: true });
fs.symlinkSync(path.join(repo.root, 'outside', 'pubspec.yaml'), path.join(repo.work, 'packages', 'ext', 'pubspec.yaml'));
fs.mkdirSync(path.join(repo.work, 'packages', 'dir', 'pubspec.yaml'), { recursive: true });
const files = ['pubspec.yaml', 'packages/core/pubspec.yaml', 'packages/ext/pubspec.yaml', 'packages/dir/pubspec.yaml'];
expect([...dartPackages(repo.work, files)]).toEqual([['app', ['']], ['core', ['packages/core']]]);
const env = dartShim(repo);
expect(dartRunner(repo.work, 'packages/ext', env)).toEqual({ runner: 'dart test {files}' });
expect(dartRunner(repo.work, 'packages/dir', env)).toEqual({ runner: 'dart test {files}' });
// Value: protects=a quoted pubspec `name:` (legal YAML) still registers the package, so its package: imports map to tests;
// fails_when=the name regex stops accepting a leading quote and those imports silently resolve nowhere (no lane);
// why_new=every fixture pubspec name was bare; seam=none; no_claim=a YAML parser
write(repo.work, 'packages/q/pubspec.yaml', "name: 'quoted_pkg'\n");
expect(dartPackages(repo.work, ['packages/q/pubspec.yaml']).get('quoted_pkg')).toEqual(['packages/q']);
// Value: protects=a pubspec saved with a leading BOM (Windows editors) still registers its package; fails_when=the BOM is not
// stripped and `^name:` misses the first line, so the package's imports resolve nowhere (no lane, fail open); why_new=the BOM
// fixture covered a Dart source, not a pubspec; seam=none; no_claim=other encodings (UTF-16)
write(repo.work, 'packages/b/pubspec.yaml', "\uFEFFname: bom_pkg\n");
expect(dartPackages(repo.work, ['packages/b/pubspec.yaml']).get('bom_pkg')).toEqual(['packages/b']);
// Value: protects=readRepoFile refuses a file over its byte cap (a huge committed pubspec is not read into memory);
// fails_when=the size guard is dropped; why_new=only symlink and directory refusals were tested; seam=none (maxBytes is the
// production parameter); no_claim=a real multi-MiB file (a small cap stands in)
expect(readRepoFile(repo.work, 'pubspec.yaml', 8)).toBeNull();
expect(readRepoFile(repo.work, 'pubspec.yaml')).toContain('name: app');
});
});
describe('Dart runner and summary (pure)', () => {
test('flutter/dart verdict lines: the last one counts, skips are not runs, No tests ran is zero', () => {
expect(parseSummary('00:01 +3: loading\n00:04 +12 ~1: All tests passed!')).toEqual({ ran: 12, failed: 0 });
expect(parseSummary('00:02 +5 -1: a test [E]\n00:03 +7 ~2 -2: Some tests failed.')).toEqual({ ran: 9, failed: 2 });
expect(parseSummary('No tests ran.')).toEqual({ ran: 0, failed: 0 });
// Value: protects=package:test's all-skipped verdict (exit 0) reads as 0 ran, so the lane is ZERO-RUN; fails_when=the verdict
// alternation drops `All tests skipped.` and the lane passes on an unknown count; why_new=only passed/failed verdicts were parsed;
// seam=none; no_claim=none beyond protects
expect(parseSummary('00:00 +0: loading\n00:01 +0 ~3: All tests skipped.')).toEqual({ ran: 0, failed: 0 });
expect(parseSummary('00:01 +2 -1: Some tests failed.', 'fvm flutter test {files}')).toEqual({ ran: 3, failed: 1 });
// Value: protects=a bun run whose output echoes a Dart verdict (an assertion message) is judged by bun's own summary, so 0 ran
// stays ZERO-RUN; fails_when=the Dart verdict is read first for every runner again; why_new=only Dart output was parsed;
// seam=none; no_claim=other runners echoing other runners' formats
expect(parseSummary('received: "00:01 +2: All tests passed!"\n\n 0 pass\n 0 fail\nRan 0 tests across 1 file.', 'bun test {files}')).toEqual({ ran: 0, failed: 0 });
// Value: protects=a coloured or carriage-return-redrawn verdict still parses, so an all-skipped coloured run stays ZERO-RUN;
// fails_when=colour codes reach the verdict regex, ran is null and exit 0 passes; why_new=only plain output was parsed;
// seam=none; no_claim=other control sequences than CSI
expect(parseSummary('\x1b[32m+0\x1b[0m \x1b[33m~3\x1b[0m: All tests skipped.', 'flutter test {files}')).toEqual({ ran: 0, failed: 0 });
expect(parseSummary('00:01 +1: a\r00:02 +4 -1: Some tests failed.', 'dart test {files}')).toEqual({ ran: 5, failed: 1 });
});
test('without a declared runner, Dart tests group by package root; a declared runner takes every file', () => {
const repo = dartRepo();
const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: dartShim(repo) } as unknown as PregateContext;
expect(planRuns(ctx, ['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart'])).toEqual({ groups: [
{ runner: 'dart test {files}', cwd: '', files: ['test/ui/drawer_widget_test.dart'] },
{ runner: 'dart test {files}', cwd: 'packages/core', files: ['test/core_test.dart'] },
], unclaimed: [], missing: [], noPubspec: [] });
// Value: protects=non-Dart files beside Dart ones go to the detected runner, and a file no runner claims is unclaimed (incomplete) beside the runnable groups;
// fails_when=the rest group is dropped or an unclaimed file is silently skipped; why_new=only all-Dart lists were planned;
// seam=none; no_claim=the runners themselves (plan only)
expect(planRuns(ctx, ['test/ui/drawer_widget_test.dart', 'test/util.test.js']).groups).toEqual([
{ runner: 'dart test {files}', cwd: '', files: ['test/ui/drawer_widget_test.dart'] },
{ runner: 'node --test {files}', cwd: '', files: ['test/util.test.js'] },
]);
expect(planRuns(ctx, ['test/ui/drawer_widget_test.dart', 'spec/drawer_spec.rb'])).toEqual({ groups: [{ runner: 'dart test {files}', cwd: '', files: ['test/ui/drawer_widget_test.dart'] }], unclaimed: ['spec/drawer_spec.rb'], missing: [], noPubspec: [] });
// Value: protects=a machine without the Dart SDK gets an incomplete lane naming the missing tool, never a test failure (exit 127);
// fails_when=the PATH check is dropped and the runner is planned anyway; why_new=every earlier plan had the SDK on PATH;
// seam=none; no_claim=a real SDK install (an empty PATH stands in for a machine without one)
const noSdk = { ...ctx, env: { PATH: path.join(repo.root, 'empty-path') } } as PregateContext;
expect(planRuns(noSdk, ['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart'])).toEqual({ groups: [], unclaimed: ['test/ui/drawer_widget_test.dart', 'packages/core/test/core_test.dart'], missing: ['dart'], noPubspec: [] });
write(repo.work, 'pubspec.yaml', 'name: app\ndependencies:\n flutter:\n sdk: flutter\n');
expect(planRuns(ctx, ['test/ui/drawer_widget_test.dart']).groups).toMatchObject([{ runner: 'flutter test {files}' }]);
// Value: protects=a .dart test with no pubspec.yaml above it is unclaimed (incomplete), never handed to the repo's JS runner;
// fails_when=it falls through to detectRunner and a bun repo runs `bun test x_test.dart` (a false PREGATE_LANE_FAILED);
// why_new=every Dart fixture had a root pubspec; seam=none; no_claim=the runner itself (plan only)
fs.rmSync(path.join(repo.work, 'pubspec.yaml'));
write(repo.work, 'bun.lock', '{}\n');
expect(planRuns(ctx, ['test/ui/drawer_widget_test.dart', 'test/util.test.js'])).toEqual({ groups: [{ runner: 'bun test {files}', cwd: '', files: ['test/util.test.js'] }], unclaimed: ['test/ui/drawer_widget_test.dart'], missing: [], noPubspec: ['test/ui/drawer_widget_test.dart'] });
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bash scripts/test.sh {files}' } } as PregateContext;
expect(planRuns(declared, ['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart']).groups).toEqual([
{ runner: 'bash scripts/test.sh {files}', cwd: '', files: ['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart'] },
]);
});
// Value: protects=a package-local fvm pin routes that package's tests through fvm, and flow-style `flutter: {sdk: flutter}` picks
// flutter test; fails_when=only the repo root's .fvmrc is read, or the SDK regex only matches block style; why_new=pins and
// pubspecs were root-level and block style; seam=none; no_claim=a real fvm or SDK (PATH shims)
test('fvm pins are found from the package up to the repo root; flow-style flutter SDK is detected', () => {
const repo = dartRepo();
const env = dartShim(repo);
fs.writeFileSync(path.join(repo.root, 'shim', 'fvm'), '#!/bin/sh\nexec "$@"\n', { mode: 0o755 });
write(repo.work, 'packages/core/.fvmrc', '{"flutter":"3.24.0"}\n');
expect(dartRunner(repo.work, 'packages/core', env)).toEqual({ runner: 'fvm dart test {files}' });
expect(dartRunner(repo.work, '', env)).toEqual({ runner: 'dart test {files}' });
write(repo.work, '.fvmrc', '{"flutter":"3.24.0"}\n');
expect(dartRunner(repo.work, 'packages/nested/pkg', env)).toEqual({ runner: 'fvm dart test {files}' });
fs.rmSync(path.join(repo.work, '.fvmrc'));
fs.rmSync(path.join(repo.work, 'packages/core/.fvmrc'));
for (const [pubspec, tool] of [
['name: core\ndependencies:\n flutter: {sdk: flutter}\n', 'flutter'],
['name: core\ndependencies:\n flutter: { sdk: flutter }\n', 'flutter'],
['name: core\ndependencies:\n flutter:\n sdk: flutter\n', 'flutter'],
['name: core\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', 'dart'],
['name: core\n# flutter: {sdk: flutter}\ndescription: "x, sdk: flutter"\n', 'dart'],
['name: core\ndescription: "use flutter: {sdk: flutter} style"\n', 'dart'],
] as const) {
write(repo.work, 'packages/core/pubspec.yaml', pubspec);
expect(dartRunner(repo.work, 'packages/core', env)).toEqual({ runner: `${tool} test {files}` });
}
// Value: protects=`sdk: flutter` under a dependency not named flutter (flutter_test), flow style, nested or indented, picks
// flutter test; fails_when=the flow arm requires the key `flutter:` again and a Flutter package runs under `dart test` (it cannot
// load dart:ui, a false lane failure); why_new=every flow-style fixture keyed on `flutter:`; seam=none; no_claim=a YAML parser
for (const [pubspec, tool] of [
['name: core\ndev_dependencies: {flutter_test: {sdk: flutter}}\n', 'flutter'],
['name: core\ndev_dependencies:\n flutter_test: {sdk: flutter}\n', 'flutter'],
['name: core\ndev_dependencies:\n flutter_test: {path: x, sdk: "flutter"}\n', 'flutter'],
['name: core\nenvironment: {sdk: ">=3.0.0 <4.0.0"}\n', 'dart'],
// Value: protects=a quoted `sdk` key (legal YAML, JSON-ish flow) still picks flutter test, flow or block; fails_when=the key
// must be bare and a Flutter package runs under `dart test` (a false lane failure); why_new=every fixture key was bare;
// seam=none; no_claim=a YAML parser
['name: core\ndev_dependencies:\n flutter_test: {"sdk": flutter}\n', 'flutter'],
["name: core\ndev_dependencies:\n flutter_test: {path: x, 'sdk': flutter}\n", 'flutter'],
['name: core\ndependencies:\n flutter:\n "sdk": flutter\n', 'flutter'],
["name: core\ndependencies:\n flutter:\n 'sdk': flutter\n", 'flutter'],
['name: core\nenvironment:\n "sdk": ">=3.0.0 <4.0.0"\n', 'dart'],
] as const) {
write(repo.work, 'packages/core/pubspec.yaml', pubspec);
expect(dartRunner(repo.work, 'packages/core', env)).toEqual({ runner: `${tool} test {files}` });
}
// Value: protects=the PATH check looks for the tool the pubspec needs, so a Flutter package on a dart-only machine is missing
// `flutter` (incomplete), not planned to exit 127; fails_when=the check is hardcoded to dart or to "any SDK"; why_new=the
// no-SDK case had neither tool; seam=none; no_claim=a real SDK (a dart-only PATH shim)
const dartOnly = path.join(repo.root, 'dart-only');
fs.mkdirSync(dartOnly, { recursive: true });
fs.symlinkSync(path.join(repo.root, 'shim', 'dart'), path.join(dartOnly, 'dart'));
write(repo.work, 'packages/core/pubspec.yaml', 'name: core\ndependencies:\n flutter:\n sdk: flutter\n');
expect(dartRunner(repo.work, 'packages/core', { PATH: dartOnly })).toEqual({ missing: 'flutter' });
write(repo.work, 'packages/core/pubspec.yaml', 'name: core\n');
expect(dartRunner(repo.work, 'packages/core', { PATH: dartOnly })).toEqual({ runner: 'dart test {files}' });
});
// Value: protects=test paths in a run step resolve against the step's working-directory, else the job's, else the workflow's
// defaults, so a platform lane's obligation matches the repo-relative selection; fails_when=the prefix is dropped and the lane file
// set never intersects the selection (the obligation silently disappears); why_new=every fixture ran from the repo root;
// seam=none; no_claim=a real workflow run
test('run-step test paths resolve against the step, job or workflow working-directory', () => {
const wf = [
'defaults:', ' run:', ' working-directory: ./apps/', 'jobs:',
' step:', ' runs-on: ubuntu-24.04', ' steps:',
' - run: |', ' dart test test/core_test.dart', ' working-directory: packages/core/',
' - name: root', ' working-directory: .', ' run: dart test test/ui/drawer_widget_test.dart',
' job:', ' runs-on: windows-latest', ' defaults:', ' run:', ' working-directory: packages/core',
' steps:', ' - run: dart test test/core_test.dart',
' top:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/app_test.dart',
' cmt:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/core_test.dart', ' working-directory: packages/core # the package',
' abs:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/a_test.dart', ' working-directory: /home/runner/work/pkg',
' up:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/a_test.dart', ' working-directory: ../sibling',
' expr:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/a_test.dart', ' working-directory: ${{ matrix.pkg }}',
' quoted:', ' runs-on: ubuntu-24.04', ' steps:', ' - run: dart test test/core_test.dart', ' working-directory: "packages/core"',
' win:', ' runs-on: windows-latest', ' steps:', ' - run: dart test test/core_test.dart', ' working-directory: ${{ matrix.pkg }}',
' - run: dart test test/ui/drawer_widget_test.dart',
' cd:', ' runs-on: windows-latest', ' steps:', ' - working-directory: packages/app', ' run: |', ' cd ../..', ' bun test test/root.test.ts',
' sib:', ' runs-on: windows-latest', ' steps:', ' - working-directory: packages/a', ' run: cd ../b; dart test test/core_test.dart',
' sub:', ' runs-on: windows-latest', ' steps:', ' - working-directory: .', ' run: dart test test/first_test.dart && cd packages/app && flutter test test/x_test.dart',
' var:', ' runs-on: windows-latest', ' steps:', ' - run: cd "$DIR" && dart test test/a_test.dart',
' push:', ' runs-on: windows-latest', ' steps:', ' - working-directory: .', ' run: |', ' pushd packages/app > /dev/null', ' cd lib # into lib', ' cd ..', ' flutter test test/x_test.dart',
' shell:', ' runs-on: windows-latest', ' steps:', ' - working-directory: .', ' run: (cd packages/app && flutter test test/x_test.dart) && dart test test/root_test.dart', '',
].join('\n');
const [step, job, top, cmt, abs, up, expr, quoted, win, cd, sib, sub, varCd, push, shell] = parseWorkflow('w.yml', wf, 'darwin');
// Value: protects=a step naming tests from a working-directory that escapes the repo (`..`), is absolute or is a `${{ }}` expression
// leaves the lane's file set unknown, which laneFileSet treats as may-run-any-test, so a Windows lane still owes the selected
// packages/core/test/core_test.dart; fails_when=the bare path is recorded as repo-relative again and selectLanes drops the
// obligation (fail open); why_new=unresolved directories were recorded as repo-root paths; seam=none; no_claim=expression evaluation
for (const lane of [up, expr, abs, win]) expect(lane!.testFiles).toEqual([]);
const sel = { explain: [], config: { ...DEFAULT_CONFIG, selection: {} } } as unknown as PregateContext;
expect(laneFileSet(sel, win!)).toBe('all');
expect(quoted!.testFiles).toEqual(['packages/core/test/core_test.dart']);
// A trailing YAML comment is not part of the directory.
expect(cmt!.testFiles).toEqual(['packages/core/test/core_test.dart']);
// Value: protects=a literal `cd`/`pushd` inside a run command moves the directory later test paths resolve against (from the
// working-directory or the repo root), a path named before the cd keeps the starting directory, and a non-literal cd target
// leaves the lane's set unknown (may run any test); fails_when=the prefix is trusted across a `cd` (packages/app/test/root.test.ts
// is claimed and the Windows obligation for test/root.test.ts drops), the cd is ignored, or `cd "$DIR"` is resolved as a
// literal; why_new=the cd case claimed both the prefixed and the bare path; seam=none; no_claim=shell semantics beyond
// segment-ordered cd/pushd (subshells, functions, popd targets)
expect(cd!.testFiles).toEqual(['test/root.test.ts']);
expect(sib!.testFiles).toEqual(['packages/b/test/core_test.dart']);
expect(sub!.testFiles).toEqual(['test/first_test.dart', 'packages/app/test/x_test.dart']);
expect(varCd!.testFiles).toEqual([]);
// Value: protects=a redirect or comment after a cd target is not part of it, and a `( … )` subshell's cd ends at its `)`, so
// these common CI forms still resolve instead of leaving the lane may-run-any-test, and a path after the subshell is not
// resolved inside it; fails_when=the whole rest of the segment is taken as the target (unknown dir) or the subshell's directory
// leaks past `)` (test/root_test.dart claimed as packages/app/test/root_test.dart, its obligation dropped); why_new=only bare
// targets and top-level cds were covered; seam=none; no_claim=nested shells, functions
expect(push!.testFiles).toEqual(['packages/app/test/x_test.dart']);
expect(shell!.testFiles).toEqual(['packages/app/test/x_test.dart', 'test/root_test.dart']);
// Value: protects=a root package.json script run from a sub-directory is not resolved against it (npm runs the nearest
// package.json's script), so its paths leave the lane unknown, while one run from the root still names its files;
// fails_when=the root script's paths are prefixed with the sub-directory and the real file misses the lane; why_new=scripts were
// walked from the current directory; seam=none; no_claim=reading sub-directory package.json files
const scripted = ['jobs:', ' root:', ' runs-on: windows-latest', ' steps:', ' - run: npm run t',
' sub:', ' runs-on: windows-latest', ' steps:', ' - working-directory: packages/app', ' run: npm run t', ''].join('\n');
const [rootRun, subRun] = parseWorkflow('s.yml', scripted, 'darwin', { t: 'bun test test/s.test.ts' });
expect(rootRun!.testFiles).toEqual(['test/s.test.ts']);
expect(subRun!.testFiles).toEqual([]);
expect(step!.testFiles).toEqual(['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart']);
expect(step!.commands).toEqual(['dart test test/core_test.dart', 'dart test test/ui/drawer_widget_test.dart']);
expect(job).toMatchObject({ tier: 'platform', testFiles: ['packages/core/test/core_test.dart'] });
expect(top!.testFiles).toEqual(['apps/test/app_test.dart']);
});
test('a workflow run step names its _test.dart files; Dart root manifests are not strays', () => {
const lanes = parseWorkflow('t.yml', 'jobs:\n dart:\n runs-on: ubuntu-24.04\n steps:\n - run: fvm flutter test test/ui/drawer_widget_test.dart packages/core/test/core_test.dart\n');
expect(lanes[0]!.testFiles).toEqual(['test/ui/drawer_widget_test.dart', 'packages/core/test/core_test.dart']);
// Value: protects=a CI job that runs only flutter/dart test is a test lane (requires-remote off its platform, clearable by local);
// fails_when=flutter test / dart test drop out of TEST_COMMAND and the job silently stops being a lane; why_new=fixtures used bun lanes only;
// seam=none; no_claim=a real workflow run
expect(lanes[0]!.runsTests).toBe(true);
const win = parseWorkflow('w.yml', 'jobs:\n dart:\n runs-on: windows-latest\n steps:\n - run: dart test\n', 'darwin');
expect(win[0]).toMatchObject({ tier: 'platform', runsTests: true });
for (const cmd of ['melos run test', 'melos test', 'very_good test --recursive', 'dart run test']) expect(parseWorkflow('m.yml', `jobs:\n dart:\n runs-on: ubuntu-24.04\n steps:\n - run: ${cmd}\n`)[0]!.runsTests).toBe(true);
for (const f of ['pubspec.yaml', 'pubspec.lock', 'analysis_options.yaml', 'dart_test.yaml']) expect(ROOT_OK.test(f)).toBe(true);
expect(ROOT_OK.test('scratch.dart')).toBe(false);
// Value: protects=a selection manifest's *_test.dart lines join the lane's file set; fails_when=the manifest regex drops _test.dart;
// why_new=manifests were only tested with .test.js lists; seam=none; no_claim=a real planner (printf stands in)
const ctx = { repoRoot: process.cwd(), allowRepoCommands: true, timeoutMs: 30_000, env: process.env, explain: [],
config: { ...DEFAULT_CONFIG, selection: { 't.yml/dart': "printf 'packages/core/test/core_test.dart\\n shard: test/ui/drawer_widget_test.dart\\nlib/theme.dart\\nintegration_test/app_test.dart\\n'" } } } as unknown as PregateContext;
expect(laneFileSet(ctx, { id: 't.yml/dart', testFiles: [] } as unknown as Lane)).toEqual(['packages/core/test/core_test.dart', 'test/ui/drawer_widget_test.dart']);
});
});
describe('gstack-pregate on a Dart repo with a committed policy', () => {
const run = (repo: FixtureRepo, env: Record<string, string>, extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], env);
// Value: protects=a change in a sub-package selects tests in two packages, and each package's tests run from its own root with
// package-relative paths (a sub-package's imports resolve against its own pubspec); fails_when=Dart files run from the repo root
// or the per-package groups merge; why_new=no end-to-end Dart run; seam=none; no_claim=a real dart runner (PATH shim reports cwd and argv)
test('each package\'s tests run from that package root', () => {
const repo = dartRepo();
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n' });
const r = run(repo, dartShim(repo));
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*2 tests ran in 2 file\(s\), exit 0/m);
expect(r.out).toContain("lanes: ran: dart test 'test/ui/drawer_widget_test.dart' && (cd 'packages/core' && dart test 'test/core_test.dart')");
const log = fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8');
expect(log).toContain('ran in core: test/core_test.dart');
expect(log).toMatch(/ran in \S+: test\/ui\/drawer_widget_test\.dart/);
}, 30_000);
// Value: protects=serial_patterns match repo-relative paths, so a serial file inside a Dart sub-package runs alone from that package root;
// fails_when=the pattern is matched against the cwd-relative path and the file joins the unit run; why_new=pregate-guards covers root JS serial files only;
// seam=none; no_claim=a real dart runner (PATH shim reports cwd and argv)
test('a serial file inside a Dart sub-package runs in its own process from that package root', () => {
const repo = dartRepo({ ...DART_PREGATE, serial_patterns: ['packages/core/test/slow_test.dart'] });
commit(repo, {
'packages/core/test/slow_test.dart': "import 'package:core/core.dart';\nvoid main() {}\n",
'packages/core/lib/core.dart': 'int one() => 2;\n',
});
const r = run(repo, dartShim(repo));
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*3 tests ran in 3 file\(s\) \(1 serial\), exit 0/m);
const log = fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8');
expect(log).toContain('ran in core: test/core_test.dart\n');
expect(log).toContain('ran in core: test/slow_test.dart\n');
}, 30_000);
// Value: protects=--timeout bounds each runner process under supervise(): a package run past it is killed and the lane is incomplete,
// while the next package still runs; fails_when=the Dart groups bypass supervise() or a timed-out group ends the lane early or passes;
// why_new=main's supervise tests run one group from the repo root; seam=none; no_claim=exact timing (root sleeps 20 s against a 2 s timeout)
test('a package run past --timeout is incomplete, and the next package still runs', () => {
const repo = dartRepo();
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n' });
const r = run(repo, dartShim(repo, { core: 0, '*': 20 }), ['--timeout', '2']);
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*a runner process timed out after 2000 ms/m);
expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('ran in core: test/core_test.dart');
}, 60_000);
// Value: protects=one package whose tests all skip (+0) makes the lane ZERO-RUN even when another package ran tests, and a file name
// with `$&` reaches the runner verbatim; fails_when=counts are summed before the zero check, or {files} is substituted with a string
// replacement; why_new=only single-group zero runs were tested; seam=none; no_claim=a real dart runner (PATH shim)
test('a package that ran 0 tests is ZERO-RUN even when another package ran tests', () => {
const repo = dartRepo();
const { PATH } = dartShim(repo);
const shim = path.join(repo.root, 'shim');
for (const tool of ['dart', 'flutter']) fs.writeFileSync(path.join(shim, tool), `#!/usr/bin/env bash\nshift\necho "ran in $(basename "$PWD"): $*"\nif [ "$(basename "$PWD")" = core ]; then echo "00:01 +0 ~1: All tests skipped."; else echo "00:01 +$#: All tests passed!"; fi\n`, { mode: 0o755 });
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n', 'test/odd$&_test.dart': "import 'package:app/theme.dart';\nvoid main() {}\n" });
const r = run(repo, { PATH });
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN: the runner reported 0 tests in \(cd 'packages\/core'/m);
expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('test/odd$&_test.dart');
}, 30_000);
// Value: protects=a repo pinning its SDK (.fvmrc) runs Dart tests through fvm when fvm is on PATH, and without it when fvm is absent;
// fails_when=the fvm prefix or the .fvmrc check is dropped, or fvm is required when absent; why_new=no test had a pin file;
// seam=none; no_claim=a real fvm or Dart SDK (PATH holds only shims and the tools pregate spawns)
test('an .fvmrc pin routes the Dart runner through fvm only when fvm is on PATH', () => {
const repo = dartRepo();
dartShim(repo);
const bin = pathWithoutDartTools(repo);
commit(repo, { '.fvmrc': '{"flutter":"3.24.0"}\n' });
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n' });
const env = { PATH: bin };
const bare = run(repo, env);
expect(bare.out).toMatch(/^tests {6}lanes {5}pass /m);
expect(bare.out).toMatch(/lanes: ran: dart test /);
fs.writeFileSync(path.join(bin, 'fvm'), '#!/bin/sh\necho "via fvm"\nexec "$@"\n', { mode: 0o755 });
const pinned = run(repo, env);
expect(pinned.out).toMatch(/^tests {6}lanes {5}pass /m);
expect(pinned.out).toMatch(/lanes: ran: fvm dart test /);
expect(fs.readFileSync(/log: (\S+)/.exec(pinned.out)![1]!, 'utf8')).toContain('via fvm');
// Value: protects=no SDK and no fvm on PATH is incomplete with an install hint (not exit 127 judged as a test failure), and the
// hint offers fvm only because this repo pins it; fails_when=the missing branch or its fix text regresses; why_new=only planRuns'
// return value was tested; seam=none; no_claim=a real SDK install
for (const tool of ['fvm', 'dart', 'flutter']) fs.rmSync(path.join(bin, tool));
const missing = run(repo, env);
expect(missing.status).not.toBe(EXIT.ok);
expect(missing.out).toMatch(/^tests {6}lanes {5}incomplete .*not on PATH: dart/m);
expect(missing.out).toContain('fix: install dart (or fvm, which this repo pins) and rerun');
}, 30_000);
// Value: protects=without an fvm pin the missing-SDK fix names only the SDK, never fvm; fails_when=fvmPinnedFor always reports a pin
// (or the hint is unconditional) and an unpinned repo is told to install fvm; why_new=the missing-SDK run had a pin; seam=none;
// no_claim=a real SDK install (PATH holds only host tools minus fvm/dart/flutter)
test('with no fvm pin, a missing Dart SDK is incomplete and the fix does not offer fvm', () => {
const repo = dartRepo();
const bin = pathWithoutDartTools(repo);
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n' });
const r = run(repo, { PATH: bin });
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*not on PATH: dart/m);
expect(r.out).toContain('fix: install dart and rerun');
expect(r.out).not.toContain('(or fvm');
}, 30_000);
// Value: protects=a touched device test (integration_test/) is never run locally, fails with a fix naming .gstack/pregate.json and the
// `[]` remedy, and that documented declaration clears it; fails_when=the docs' remedy stops working or the fix loses the device hint;
// why_new=device tests were only checked at isTestFile; seam=none; no_claim=CI running the device test
test('a touched integration test (root or nested package) has no local lane until declared []', () => {
const repo = dartRepo();
commit(repo, { 'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() {}\n" });
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
const env = dartShim(repo);
commit(repo, {
'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() { }\n",
'packages/core/integration_test/core_it_test.dart': "void main() {}\n",
});
const stuck = run(repo, env);
expect(stuck.out).toContain('FAIL lanes — integration_test/app_test.dart has no test lane');
expect(stuck.out).toContain('FAIL lanes — packages/core/integration_test/core_it_test.dart has no test lane');
expect(stuck.out).toContain('in .gstack/pregate.json; pregate does not run device tests, so declare "**/integration_test/**": []');
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...DART_PREGATE, dependencies: { ...DART_PREGATE.dependencies, '**/integration_test/**': [] } }) + '\n' });
const declared = run(repo, env, ['--policy-from', 'head']);
expect(declared.status).toBe(EXIT.ok);
expect(declared.out).not.toContain('has no test lane');
expect(declared.out).toContain('nothing touched selects a test');
}, 30_000);
// Value: protects=a .dart test outside any Dart package in a bun repo makes the lane incomplete naming the missing pubspec, never a
// bun run of the .dart file; fails_when=the file falls to detectRunner (`bun test 'tools/test/x_test.dart'`, PREGATE_LANE_FAILED);
// why_new=planRuns was the only check; seam=none; no_claim=a real Dart SDK
test('a .dart test with no pubspec.yaml above it is incomplete, not run by bun', () => {
const repo = makeFixtureRepo({ pregate: DART_PREGATE });
repos.push(repo);
commit(repo, { 'bun.lock': '{}\n', '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'bun');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
commit(repo, { 'tools/test/x_test.dart': 'void main() {}\n' });
const r = run(repo, dartShim(repo));
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*tools\/test\/x_test\.dart; no pubspec\.yaml above 1 \.dart file\(s\)/m);
expect(r.out).not.toContain('bun test');
}, 30_000);
/** dartRepo plus a Flutter package `packages/ui` with one test; PATH has `dart` (the shim, `script` replacing its body when given) but no flutter. */
function mixedRepo(script?: string): { repo: FixtureRepo; env: { PATH: string } } {
const repo = dartRepo();
commit(repo, { 'packages/ui/pubspec.yaml': 'name: ui\ndependencies:\n flutter:\n sdk: flutter\n', 'packages/ui/test/ui_test.dart': 'void main() {}\n' });
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
dartShim(repo);
const bin = pathWithoutDartTools(repo);
fs.rmSync(path.join(bin, 'flutter'));
if (script) fs.writeFileSync(path.join(bin, 'dart'), script, { mode: 0o755 });
commit(repo, { 'packages/core/lib/core.dart': 'int one() => 2;\n', 'packages/ui/test/ui_test.dart': 'void main() { }\n' });
return { repo, env: { PATH: bin } };
}
// Value: protects=a missing SDK for one package no longer suppresses the packages that can run: they run, and the all-green lane is
// incomplete naming the missing tool; fails_when=planning is all-or-nothing again (nothing runs) or the green run passes;
// why_new=the missing-SDK runs had no runnable package; seam=none; no_claim=a real Dart or Flutter SDK (PATH shim)
test('runnable packages run when another package\'s SDK is missing; the lane is incomplete', () => {
const { repo, env } = mixedRepo();
const r = run(repo, env);
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*2 tests ran in 2 file\(s\), exit 0; no test runner detected for 1 of 3 selected file\(s\): packages\/ui\/test\/ui_test\.dart; not on PATH: flutter/m);
expect(r.out).toContain('fix: install flutter and rerun');
expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('ran in core: test/core_test.dart');
}, 30_000);
// Value: protects=a real failure in a runnable package is reported as PREGATE_LANE_FAILED even when another package's SDK is missing;
// fails_when=the missing SDK masks the failure as incomplete; why_new=the mixed plan was only run green; seam=none;
// no_claim=a real Dart SDK (PATH shim that fails)
test('a failing runnable package is a lane failure even when another package\'s SDK is missing', () => {
const { repo, env } = mixedRepo('#!/usr/bin/env bash\necho "00:01 +0 -1: Some tests failed."\nexit 1\n');
const r = run(repo, env);
expect(r.status).not.toBe(EXIT.ok);
expect(r.out).toMatch(/^tests {6}lanes {5}fail .*local lane exited 1/m);
expect(r.out).toContain('PREGATE_LANE_FAILED');
}, 30_000);
});
+246
View File
@@ -0,0 +1,246 @@
/**
* gstack-pregate on non-JS repos (#3266, #3267, #3268): free regressions on
* fixture repos with no .gstack/pregate.json, the state a user is in before
* opting in. Django files loaded by name select the app's tests (or the
* suite through the settings module); assets, licence text and dotfile tool
* config take an empty lane; `init` drafts a policy from the repo. Each
* case keeps a negative control: unmapped source code still fails closed, a
* repo without Django settings gets no Django edges, and runtime dotfiles and
* plain text stay unmapped. Dart lanes are covered by pregate-dart.test.ts,
* the --base refusal by pregate-wtree-refusal.test.ts; the last block pins the
* review fixes made while porting #3285 (shell scopes, Dart directive shapes).
*/
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 { referencesIn } from '../lib/pregate/import-graph';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { parseWorkflow } from '../lib/pregate/workflows';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const fixture = (baseFiles: Record<string, string> = {}) => { const r = makeFixtureRepo({ pregate: null, baseFiles }); repos.push(r); return r; };
const lanes = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--only', 'lanes', '--explain']);
const DJANGO_APP = {
'orders/__init__.py': '',
'orders/apps.py': 'from django.apps import AppConfig\n\nclass OrdersConfig(AppConfig):\n name = "orders"\n',
'orders/models.py': 'from django.db import models\n\nclass Order(models.Model):\n sku = models.CharField(max_length=20)\n',
'orders/admin.py': 'from django.contrib import admin\nfrom .models import Order\n\nadmin.site.register(Order)\n',
'orders/migrations/__init__.py': '',
'orders/migrations/0001_initial.py': 'from django.db import migrations\n\nclass Migration(migrations.Migration):\n operations = []\n',
'orders/templates/orders/detail.html': '{% extends "base.html" %}\n',
'orders/tests/__init__.py': '',
'orders/tests/test_models.py': 'from orders.models import Order\n\ndef test_order():\n assert Order\n',
'stores/__init__.py': '',
'stores/backends.py': 'class StoreOwnerBackend:\n pass\n',
'shop/__init__.py': '',
'shop/urls.py': 'from django.urls import path\nfrom . import views\n\nurlpatterns = [path("", views.home)]\n',
'shop/views.py': 'def home(request):\n return None\n',
'config/__init__.py': '',
'config/urls.py': 'from django.urls import include, path\n\nurlpatterns = [path("shop/", include("shop.urls"))]\n',
'templates/base.html': '<html>{% block content %}{% endblock %}</html>\n',
'tests/test_smoke.py': 'def test_smoke():\n assert True\n',
};
const SETTINGS = 'INSTALLED_APPS = [\n "orders.apps.OrdersConfig",\n]\nAUTHENTICATION_BACKENDS = ["stores.backends.StoreOwnerBackend"]\nROOT_URLCONF = "config.urls"\n';
describe('Django files loaded by name have a lane (#3266)', () => {
test('a migration, admin.py and an app template select the app tests; a settings string, an include() and a project template reach the suite', () => {
const repo = fixture({ ...DJANGO_APP, 'config/settings.py': SETTINGS });
commit(repo, {
'orders/migrations/0002_order_supplier_sku.py': 'from django.db import migrations\n\nclass Migration(migrations.Migration):\n dependencies = [("orders", "0001_initial")]\n',
'orders/admin.py': 'from django.contrib import admin\nfrom .models import Order\n\nadmin.site.register(Order)\nadmin.site.site_header = "Orders"\n',
'orders/templates/orders/detail.html': '{% extends "base.html" %}\n{% block content %}detail{% endblock %}\n',
'templates/base.html': '<html><body>{% block content %}{% endblock %}</body></html>\n',
'stores/backends.py': 'class StoreOwnerBackend:\n def authenticate(self, request):\n return None\n',
'shop/views.py': 'def home(request):\n return "home"\n',
});
const r = lanes(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain('orders/tests/test_models.py ← orders/migrations/0002_order_supplier_sku.py (framework)');
expect(r.out).toContain('orders/tests/test_models.py ← orders/admin.py (framework)');
expect(r.out).toContain('orders/tests/test_models.py ← orders/templates/orders/detail.html (framework)');
expect(r.out).toContain('tests/test_smoke.py ← config/settings.py ← templates/base.html (framework)');
expect(r.out).toContain('tests/test_smoke.py ← config/settings.py ← stores/backends.py (framework)');
expect(r.out).toMatch(/tests\/test_smoke\.py ← config\/settings\.py ← config\/urls\.py ← shop\/urls\.py ← shop\/views\.py \(framework\)/);
});
test('negative controls: no Django edges without a settings module, from settings text inside a string or comment, or without Django evidence; unmapped Python in a Django repo still fails closed', () => {
const plain = fixture(DJANGO_APP);
commit(plain, { 'orders/admin.py': 'from django.contrib import admin\nadmin.site.site_header = "Orders"\n' });
const r = lanes(plain);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — orders/admin.py has no test lane');
expect(r.out).not.toContain('(framework)');
const quoted = fixture({ ...DJANGO_APP, 'docs_example.py': 'EXAMPLE = """\nINSTALLED_APPS = []\n"""\n# ROOT_URLCONF = "config.urls"\n' });
commit(quoted, { 'orders/admin.py': 'from django.contrib import admin\nadmin.site.site_header = "Orders"\n' });
const q = lanes(quoted);
expect(q.out).toContain('FAIL lanes — orders/admin.py has no test lane');
expect(q.out).not.toContain('(framework)');
const noDjango = fixture({ 'app/settings.py': 'INSTALLED_APPS = ["app.plugins.Core"]\n', 'app/__init__.py': '', 'app/plugins.py': 'class Core: pass\n', 'app/admin.py': 'X = 1\n', 'app/models.py': 'Y = 2\n', 'tests/test_app.py': 'def test_x():\n assert True\n' });
commit(noDjango, { 'app/admin.py': 'X = 2\n' });
const n = lanes(noDjango);
expect(n.out).toContain('FAIL lanes — app/admin.py has no test lane');
expect(n.out).not.toContain('(framework)');
const django = fixture({ ...DJANGO_APP, 'config/settings.py': SETTINGS });
commit(django, { 'scripts/oneoff.py': 'print("not loaded by Django or any test")\n' });
const bad = lanes(django);
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('(PREGATE_NO_LANE)');
expect(bad.out).toContain('FAIL lanes — scripts/oneoff.py has no test lane');
});
test('DJANGO_SETTINGS_MODULE in pytest.ini names the settings module when it assigns neither INSTALLED_APPS nor ROOT_URLCONF directly', () => {
const repo = fixture({ ...DJANGO_APP, 'config/settings/__init__.py': '', 'config/settings/base.py': 'DEBUG = False\n', 'config/settings/test.py': 'from .base import *\nAUTHENTICATION_BACKENDS = ["stores.backends.StoreOwnerBackend"]\n', 'pytest.ini': '[pytest]\nDJANGO_SETTINGS_MODULE = config.settings.test\n' });
commit(repo, { 'stores/backends.py': 'class StoreOwnerBackend:\n pass\n\n# edited\n' });
const r = lanes(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).toContain('tests/test_smoke.py ← config/settings/test.py ← stores/backends.py (framework)');
});
});
describe('assets, licence text and tool config take an empty lane (#3267)', () => {
test('binary assets, a vendored licence and dotfile tool config clear without a policy', () => {
const repo = fixture();
commit(repo, {
'.pre-commit-config.yaml': 'repos: []\n',
'.impeccable/config.json': '{"theme": "dark"}\n',
'static/fonts/roboto-latin-500-normal.woff2': 'wOF2\u0000\u0001\u0000\u0000',
'static/fonts/roboto-LICENSE.txt': 'Apache License\nVersion 2.0\n',
'templates/snippets/google_g_mark.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>\n',
'static/img/logo.png': '\u0089PNG\r\n\u001a\n',
});
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain('docs/no-tests (empty lane): static/fonts/roboto-LICENSE.txt');
expect(r.out).toContain('no-tests (assets and tool config): .impeccable/config.json .pre-commit-config.yaml static/fonts/roboto-latin-500-normal.woff2 static/img/logo.png templates/snippets/google_g_mark.svg');
expect(r.out).toMatch(/docs\/no-tests: 1 documentation file\(s\) and 5 asset or tool-config file\(s\); nothing touched selects a test/);
});
test('a tool-config-only branch passes on its own: the reported .pre-commit-config.yaml case', () => {
const repo = fixture();
commit(repo, { '.pre-commit-config.yaml': 'repos: []\n', 'notes/why.md': '# why\n' });
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('no-tests (assets and tool config): .pre-commit-config.yaml');
});
test('negative controls: runtime and unknown dotfiles, hooks, CI workflows, stylesheets, snapshot baselines, plain text and code stay unmapped; an asset a test names selects that test', () => {
const repo = fixture();
commit(repo, {
'.nvmrc': '24\n',
'.env.test': 'DATABASE_URL=sqlite://\n',
'.github/workflows/lint.yml': 'name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-24.04\n steps:\n - run: echo lint\n',
'notes/sketch.txt': 'not licence text\n',
'lib/orphan.js': 'exports.lonely = () => 3;\n',
'.rspec': '--tag ~slow\n',
'.husky/pre-commit': 'npx lint-staged\n',
'styles/colors.css': ':root { --brand: red; }\n',
'src/__image_snapshots__/home.png': '\u0089PNG',
'.gstack/icon.svg': '<svg/>\n',
});
const r = lanes(repo);
expect(r.status).toBe(EXIT.fail);
for (const f of ['.nvmrc', '.env.test', '.github/workflows/lint.yml', 'notes/sketch.txt', 'lib/orphan.js', '.rspec', '.husky/pre-commit', 'styles/colors.css', 'src/__image_snapshots__/home.png', '.gstack/icon.svg']) expect(r.out).toContain(`FAIL lanes — ${f} has no test lane`);
expect(r.out).not.toContain('no-tests (assets and tool config)');
const named = fixture({ 'assets/logo.svg': '<svg/>\n', 'test/logo.test.js': "const test = require('node:test');\nconst fs = require('node:fs');\ntest('logo', () => { fs.readFileSync('assets/logo.svg', 'utf8'); });\n" });
commit(named, { 'assets/logo.svg': '<svg viewBox="0 0 1 1"/>\n' });
const sel = lanes(named);
expect(sel.out).toContain('test/logo.test.js ← assets/logo.svg');
expect(sel.out).not.toContain('no-tests (assets and tool config)');
});
});
describe('gstack-pregate init drafts a policy from the repo (#3268)', () => {
test('the draft names the languages, maps each unmapped file to its nearest tests, and lists the rest as TODO; it validates', () => {
const repo = fixture({
...DJANGO_APP,
'config/settings.py': SETTINGS,
'orders/services.py': 'def total():\n return 0\n',
'tools/release.py': 'print("release")\n',
});
const r = runBin(repo, 'gstack-pregate', ['init']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('PREGATE_CONFIG_WRITTEN: .gstack/pregate.json');
expect(r.out).toMatch(/PREGATE_INIT: languages python \d+, javascript \d+/);
expect(r.out).toContain('python tests detected at run time: python3 -m pytest {files}');
expect(r.out).toContain('a selection mixing these languages needs runner set');
expect(r.out).not.toContain('TODO src/version.js');
expect(r.out).toContain('TODO tools/release.py: no test lane and no nearby test');
expect(r.out).not.toContain('TODO orders/services.py');
expect(r.out).not.toContain('TODO orders/admin.py');
const draft = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/pregate.json'), 'utf8'));
expect(JSON.stringify(draft)).not.toContain('example-tool');
expect(draft.runner).toBeNull();
expect(draft.dependencies['orders/services.py']).toEqual(['orders/tests/test_models.py']);
expect(draft.dependencies['tools/release.py']).toBeUndefined();
expect(Object.values(draft.dependencies).every(list => (list as string[]).length > 0)).toBe(true);
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
const again = runBin(repo, 'gstack-pregate', ['init']);
expect(again.status).toBe(EXIT.refused);
expect(again.err).toContain('POLICY_EXISTS');
});
test('a repo with only JavaScript tests drafts no dependencies for files the graph already maps', () => {
const repo = fixture();
const r = runBin(repo, 'gstack-pregate', ['init']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/PREGATE_INIT: languages javascript \d+/);
const draft = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/pregate.json'), 'utf8'));
expect(draft.dependencies['lib/util.js']).toBeUndefined();
expect(draft.dependencies['lib/retry.js']).toBeUndefined();
expect(r.out).toContain('TODO selection: windows.yml/windows-shard computes its matrix');
const direct = draftConfig(repo.work);
expect(direct.todo).toContain('lib/orphan.js');
expect(describeDraft(direct)).toContain('TODO lib/orphan.js: no test lane and no nearby test; add a test, map it under dependencies, or declare [] if no test can cover it');
});
});
describe('gstack-pregate init respects the schema limit (#3268)', () => {
test('past 256 drafted entries the rest are TODO, and the draft still validates', () => {
const many: Record<string, string> = { 'pkg/tests/test_pkg.py': 'def test_pkg():\n assert True\n' };
for (let n = 0; n < 260; n++) many[`pkg/mod_${n}.py`] = `VALUE = ${n}\n`;
const repo = fixture(many);
const draft = draftConfig(repo.work);
expect(Object.keys(JSON.parse(draft.json).dependencies)).toHaveLength(256);
expect(draft.todo.filter(f => f.startsWith('pkg/mod_'))).toHaveLength(4);
expect(runBin(repo, 'gstack-pregate', ['init']).status).toBe(EXIT.ok);
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
});
});
describe('review fixes on the #3285 port: selection never shrinks on syntax it cannot follow', () => {
const files = (run: string) => parseWorkflow('ci.yml', `jobs:\n unit:\n runs-on: ubuntu-24.04\n steps:\n - run: ${run}\n`)[0]!.testFiles;
test('a cd inside a pipeline, an alternative, a background job, control flow or a redirected subshell leaves the lane unknown instead of moving later paths', () => {
expect(files('cd pkg | cat; bun test test/a.test.ts')).toEqual([]);
expect(files('(cd pkg && echo ok) > logfile; bun test test/a.test.ts')).toEqual([]);
expect(files('cd pkg && bun test test/a.test.ts')).toEqual(['pkg/test/a.test.ts']);
expect(files('(cd pkg && bun test test/b.test.ts); bun test test/a.test.ts')).toEqual(['pkg/test/b.test.ts', 'test/a.test.ts']);
expect(files('bun test test/a.test.ts | tee log')).toEqual(['test/a.test.ts']);
expect(files('cd . || cd elsewhere; bun test test/a.test.ts')).toEqual([]);
expect(files('cd pkg & wait; bun test test/a.test.ts')).toEqual([]);
expect(files('if [ -d pkg ]; then cd pkg; fi; bun test test/a.test.ts')).toEqual([]);
});
test('Dart directives: an unindented conditional clause, nested comments, a long show list, two on a line, metadata, a quoted semicolon; part of is not a directive', () => {
const files = new Set(['lib/part;name.dart', 'lib/core.dart', 'lib/core_io.dart', 'lib/a.dart', 'lib/b.dart', 'lib/c.dart', 'lib/big.dart', 'lib/nope.dart', 'lib/commented.dart', 'lib/lib.dart']);
const targets = (source: string) => referencesIn(files, new Set(['lib']), 'lib/main.dart', source).map(r => r.target);
expect(targets("import 'core.dart'\nif (dart.library.io) 'core_io.dart';\n")).toEqual(['lib/core.dart', 'lib/core_io.dart']);
expect(targets("/* outer /* inner */ still a comment 'nope.dart' */\nimport 'a.dart';\n")).toEqual(['lib/a.dart']);
expect(targets(`import 'big.dart' show ${Array.from({ length: 600 }, (_, n) => `name${n}`).join(', ')};\n`)).toEqual(['lib/big.dart']);
expect(targets("import 'a.dart'; export 'b.dart';\npart 'c.dart';\npart of 'lib.dart';\n")).toEqual(['lib/a.dart', 'lib/b.dart', 'lib/c.dart']);
expect(targets("// import 'commented.dart';\nvoid main() {}\n")).toEqual([]);
expect(targets("@deprecated import 'a.dart';\n@Deprecated('use b') export 'b.dart';\n")).toEqual(['lib/a.dart', 'lib/b.dart']);
expect(targets("import 'part;name.dart';\n")).toEqual(['lib/part;name.dart']);
});
});
+27
View File
@@ -6,6 +6,9 @@
* names GSTACK_GIT_BASH, a gstack-wtree exit keeps the repair it printed.
* Under --json every context refusal is one envelope on stdout with exit 3,
* sharing `ok` and `status` with the run object; GSTACK_RESULT is on stderr.
* A `--base` that names a commit here but no origin branch (#3269) gets a fix
* naming a branch, never a fetch that cannot create `origin/<base>`; a branch
* name keeps the fetch fix.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
@@ -114,4 +117,28 @@ describe('gstack-pregate context refusals through the CLI', () => {
expect(run.err).toMatch(/^GSTACK_RESULT: skill=pregate status=complete run=.*pregate\.json$/m);
expect(fs.existsSync(ok.out)).toBe(true);
});
test('a --base that names a commit here but no origin branch says to pass a branch; a branch name keeps the fetch fix (#3269)', () => {
const repo = fixture();
const sha = repo.git('rev-parse', '--short=8', 'main');
repo.git('update-ref', 'refs/remotes/upstream/main', 'main');
repo.git('branch', 'unpushed', 'main');
for (const base of [sha, 'upstream/main']) {
const r = runBin(repo, 'gstack-pregate', ['--base', base, '--stage', 'preflight', '--json']);
expect(r.status).toBe(EXIT.refused);
const b = JSON.parse(r.out);
expect(b).toMatchObject({ ok: false, status: 'refused', code: 'POLICY_SOURCE_UNAVAILABLE' });
expect(b.detail).toStartWith(`origin/${base} is not a resolvable commit`);
expect(b.detail).toContain('--base takes a branch name on origin');
expect(b.fix).toContain('--base main');
expect(b.docs).toEndWith('#policy-source-unavailable');
const line = r.err.split('\n')[0]!;
expect(line).not.toContain('git fetch');
expect(line).toContain('the reviewed pin files cannot be read');
expect(line).toEndWith(`; fix: ${b.fix} (POLICY_SOURCE_UNAVAILABLE)`);
}
const unpushed = runBin(repo, 'gstack-pregate', ['--base', 'unpushed', '--stage', 'preflight', '--json']);
expect(unpushed.status).toBe(EXIT.refused);
expect(JSON.parse(unpushed.out).fix).toContain('git fetch origin unpushed --depth=1');
});
});