From 77f27fa0831bb1a1de14ad52ea32030048ddd89a Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 06:11:39 +0000 Subject: [PATCH] fix(ci-carry): a file whose every test skipped on CI is not carried; tree.json records skipped_files (GST-1190) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta258-l6-guard-carry.md | 3 +++ docs/pregate.md | 17 ++++++++++++-- lib/ci-carry.ts | 5 ++-- lib/pregate/checks/lanes.ts | 7 +++--- lib/run-evidence.ts | 20 ++++++++++++---- scripts/ci-tree-reuse.ts | 14 ++++++++---- test/ci-tree-reuse.test.ts | 13 +++++++++++ test/ship-ci-carry.test.ts | 33 ++++++++++++++++++++++++++- 8 files changed, 95 insertions(+), 17 deletions(-) diff --git a/changelog.d/gsta258-l6-guard-carry.md b/changelog.d/gsta258-l6-guard-carry.md index 0fbaa0102..934efe46b 100644 --- a/changelog.d/gsta258-l6-guard-carry.md +++ b/changelog.d/gsta258-l6-guard-carry.md @@ -9,7 +9,10 @@ - An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= ; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses. - docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count. +- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). `tree.json` now lists such files in `skipped_files`, written by gstack's own Free Tests from the per-file JUnit counts; the pregate tests stage runs them here, and a /ship lane or guard whose inventory names one runs here. A partly skipped file still counts. An older `tree.json` without the list is read as before. + ### Tests - `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry. - `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change. - `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change. +- `test/ci-tree-reuse.test.ts` and `test/ship-ci-carry.test.ts` (GST-1190 skipped files): the writer lists only fully skipped files and leaves the list out without complete counts; a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. All three fail without the change. diff --git a/docs/pregate.md b/docs/pregate.md index ca1421775..63a537434 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -737,6 +737,17 @@ once: record (a reuse is never evidence). Its tree equals the tree git computes for the run's head commit. `files` is a list of paths and `tests` a non-negative integer when present; without `tests` the file count stands in. + `skipped_files`, when present, lists the files whose every test skipped on + the runner (a Docker image, a drive path or an installed gstack it lacks; + GST-1190). A carry treats them as uncovered: the pregate tests stage runs + them here, and a /ship lane or guard whose inventory names one runs here + whole. A partly skipped file ran tests and stays covered. gstack's own writer + (`scripts/ci-tree-reuse.ts record`) derives the list from the shards' per-file + JUnit counts and leaves it out when those counts are incomplete. A `tree.json` + without the key (older writers, other repos) is read as before, with every + listed file counted as run; add the list to your writer to close that gap. + The remote gate's clearance does not use it: that gate runs on a Linux runner + like CI, where the same files skip. - No run of the same workflow on that head, or on pregate.json's own head, whose newest attempt started at or after the second this run was created failed, was cancelled or is still running (`run_started_at`, so a red re-run @@ -895,7 +906,8 @@ whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests` on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no stamp-only substitution). Then: -- The selected local test files the run's `tree.json` lists count as passed. +- The selected local test files the run's `tree.json` lists count as passed, + except those in its `skipped_files` ([above](#skipped-files)), which run here. The lanes check records them under `from_run` with the run receipt (`run tree=… tests=… runs=/ profiles=… heads=… verified=…`) and prints `REUSE: accepted of selected local test file(s) from ; @@ -933,7 +945,8 @@ it accepts a run only when all of these hold: tree. A `pull_request` run counts only when the pull request already contained its base. - Every run executed tests, and with an inventory command, the runs' - `tree.json` files cover every file it prints. A lane mapped by `jobs` + `tree.json` files cover every file it prints, none of them a file whose every + test skipped there (`skipped_files`). A lane mapped by `jobs` instead needs no test count: each named job succeeded in the run's newest attempt. Each run is read once more afterward (`run_changed`). diff --git a/lib/ci-carry.ts b/lib/ci-carry.ts index 0315dfca3..66dbf1de0 100644 --- a/lib/ci-carry.ts +++ b/lib/ci-carry.ts @@ -86,8 +86,9 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string; const listed = o.inventory(); if ('error' in listed) return no('inventory_failed', listed.error); if (!listed.length) return no('inventory_empty', 'the inventory command printed no files'); - const missing = uncoveredFiles(listed, runs); - if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`); + const missing = uncoveredFiles(listed, runs, 'uncovered'); + const skippedThere = missing.filter(f => runs.some(e => e.skipped?.includes(normalPath(f)))).length; + if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json${skippedThere ? ` or skipped every test there (${skippedThere} skipped_files)` : ''}: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`); covered = new Set(listed.map(normalPath)).size; } for (const e of runs) { diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index a16f4b0ed..a0ff1f85a 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection'; import { launchRuntime } from '../runtime'; import { cargoModel, type CargoModel } from '../rust'; import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise'; -import { verifyFromRun, type RunProfile } from '../../run-evidence'; +import { ranFiles, verifyFromRun, type RunProfile } from '../../run-evidence'; import { matchesAny } from '../../ship-policy'; import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types'; @@ -643,9 +643,10 @@ function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: s const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles); const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head); if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] }; - const ran = new Set(v.evidence.files); + const ran = new Set(ranFiles(v.evidence)); const passed = sel.localTests.filter(t => ran.has(t)); - return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt }; + const skipped = sel.localTests.filter(t => v.evidence.skipped?.includes(t)).length; + return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run${skipped ? ` (${skipped} skipped every test in the run)` : ''}`, passed, receipt: v.receipt }; } /** diff --git a/lib/run-evidence.ts b/lib/run-evidence.ts index 74c523983..cb28023f4 100644 --- a/lib/run-evidence.ts +++ b/lib/run-evidence.ts @@ -50,7 +50,7 @@ export interface ApiRun { repository?: { full_name: string } | null; head_repository?: { full_name: string } | null; } export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null } -export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown } +export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown } /** Everything verifyRun reads, injectable so every refusal has a fixture. */ export interface RunDeps { @@ -64,7 +64,8 @@ export interface RunDeps { export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | 'workflow_mismatch' | 'event_mismatch' | 'run_in_progress' | 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch' | 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error'; -export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null } +/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */ +export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[] } export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string }; const SHA = /^[0-9a-f]{40}$/; @@ -187,11 +188,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`); if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`); if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`); + if (rec.skipped_files !== undefined && (!Array.isArray(rec.skipped_files) || rec.skipped_files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} skipped_files is not a list of paths`); const files = ((rec.files ?? []) as string[]).map(normalPath); const tests = rec.tests === undefined ? null : (rec.tests as number); const changed = runChanged(deps, id, run.run_attempt); if (changed) return fail('run_changed', changed); - return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests } }; + const skipped = rec.skipped_files === undefined ? {} : { skipped: (rec.skipped_files as string[]).map(normalPath) }; + return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests, ...skipped } }; } catch (e: any) { return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`); } @@ -211,8 +214,15 @@ export function runInventory(command: string, cwd: string, env?: Record e.files.map(normalPath))); +/** A run's files minus the ones whose every test skipped there (GST-1190): only those ran anything. Older tree.json: all files. */ +export const ranFiles = (e: RunEvidence): string[] => { const skipped = new Set(e.skipped ?? []); return e.files.map(normalPath).filter(f => !skipped.has(f)); }; + +/** + * The inventory files no run covers. `skipped: 'uncovered'` (a carry, which drops tests on a machine where they might run) + * also counts a file every test of which skipped in the run; `'covered'` (the remote gate, a Linux runner like CI) does not. + */ +export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], skipped: 'uncovered' | 'covered' = 'covered'): string[] { + const ran = new Set(evidence.flatMap(e => (skipped === 'uncovered' ? ranFiles(e) : e.files.map(normalPath)))); return inventory.filter(f => !ran.has(normalPath(f))); } diff --git a/scripts/ci-tree-reuse.ts b/scripts/ci-tree-reuse.ts index 9107a0a0a..a7a3d65f2 100755 --- a/scripts/ci-tree-reuse.ts +++ b/scripts/ci-tree-reuse.ts @@ -6,7 +6,8 @@ * The producer. A Free Tests or Windows Free Tests aggregate runs it only after its strict verification * passed, and uploads the file as the `tested-tree` artifact: run_id, run_attempt, event, sha (GITHUB_SHA), * head_sha (the pull request head for pull_request, else GITHUB_SHA), tree (git's tree of GITHUB_SHA), - * workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan) and tests. + * workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan), tests and, from complete + * per-file counts, skipped_files (the planned files whose every test skipped on the runner; GST-1190). * * bun run scripts/ci-tree-reuse.ts find --workflow --profile * The main-push consumer (the `reuse` job). A push to main whose tree a pull request already tested green @@ -72,7 +73,8 @@ export function findReuse(deps: ReuseDeps, o: { repo: string; base: string; push } /** tree.json for the `tested-tree` artifact; throws on any missing or malformed input, so a broken record fails the aggregate instead of uploading. */ -export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number } } }>; os: string[]; tree: string }) { +type FileCounts = { tests: number; pass: number; fail: number }; +export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number }; counts?: { complete?: boolean; files?: Record } } }>; os: string[]; tree: string }) { const int = (name: string) => { const v = Number(env[name]); if (!Number.isSafeInteger(v) || v < 1) throw new Error(`${name} is not a positive integer`); return v; }; const sha = env.GITHUB_SHA ?? ''; const event = env.GITHUB_EVENT_NAME ?? ''; @@ -87,7 +89,11 @@ export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ if (!files.length) throw new Error('the plan lists no test files'); const counts = o.results.map(r => r.outcome?.summary?.testsRan); const tests = o.results.length && counts.every(n => Number.isSafeInteger(n) && n! >= 0) ? counts.reduce((a, b) => a! + b!, 0)! : files.length; - return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests }; + // skipped_files (GST-1190): planned files whose every test skipped on this runner, so a carry runs them where they might not + // skip. A partly skipped file ran tests and stays covered. Only from complete per-file counts; otherwise left out (unknown). + const counted = o.results.length > 0 && o.results.every(r => r.outcome?.counts?.complete === true && r.outcome.counts.files); + const skipped_files = counted ? files.filter(f => o.results.some(r => { const c = r.outcome!.counts!.files![f]; return !!c && c.tests > 0 && c.pass === 0 && c.fail === 0; })) : undefined; + return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests, ...(skipped_files ? { skipped_files } : {}) }; } function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } @@ -102,7 +108,7 @@ function record(args: string[]): number { const out = need(args, '--out'); fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); fs.writeFileSync(out, JSON.stringify(rec, null, 2) + '\n'); - console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} os=${rec.os.join(',')}`); + console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} skipped_files=${rec.skipped_files?.length ?? 'unknown'} os=${rec.os.join(',')}`); return 0; } diff --git a/test/ci-tree-reuse.test.ts b/test/ci-tree-reuse.test.ts index 1fc8cc913..235c562c6 100644 --- a/test/ci-tree-reuse.test.ts +++ b/test/ci-tree-reuse.test.ts @@ -129,6 +129,19 @@ describe('treeRecord: the tested-tree producer', () => { expect(treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE, PR_HEAD_SHA: HEAD }), { plan, results, os: ['Windows'], tree: TREE })).toMatchObject({ event: 'pull_request', sha: MERGE, head_sha: HEAD }); expect(treeRecord(env(), { plan, results: [{ outcome: {} }], os: ['Linux'], tree: TREE }).tests).toBe(3); }); + // Value: protects=a carry knows which files ran nothing on CI (GST-1190, harness-evals: 75 tests skipped on the runner); fails_when=a fully + // skipped file is not listed, a partly skipped one is, or a run without complete counts claims a list + test('skipped_files lists only files whose every test skipped; a partly skipped file stays covered; incomplete counts leave it out', () => { + const n = (tests: number, pass: number, skip: number) => ({ tests, pass, fail: 0, skip, todo: 0, expects: pass }); + const counted = [ + { outcome: { summary: { testsRan: 7 }, counts: { complete: true, files: { 'test/a.test.ts': n(3, 0, 3), 'test/b.test.ts': n(4, 2, 2) } } } }, + { outcome: { summary: { testsRan: 5 }, counts: { complete: true, files: { 'test/c.test.ts': n(5, 5, 0) } } } }, + ]; + expect(treeRecord(env(), { plan, results: counted, os: ['Linux'], tree: TREE }).skipped_files).toEqual(['test/a.test.ts']); + expect(treeRecord(env(), { plan, results, os: ['Linux'], tree: TREE })).not.toHaveProperty('skipped_files'); + const partial = [counted[0]!, { outcome: { ...counted[1]!.outcome, counts: { complete: false, files: {} } } }]; + expect(treeRecord(env(), { plan, results: partial, os: ['Linux'], tree: TREE })).not.toHaveProperty('skipped_files'); + }); test('missing or malformed inputs throw, so the aggregate fails instead of uploading a record', () => { expect(() => treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/PR_HEAD_SHA/); expect(() => treeRecord(env({ GITHUB_RUN_ID: '' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_RUN_ID/); diff --git a/test/ship-ci-carry.test.ts b/test/ship-ci-carry.test.ts index ac527c5d3..dff5dcb4e 100644 --- a/test/ship-ci-carry.test.ts +++ b/test/ship-ci-carry.test.ts @@ -335,7 +335,7 @@ describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a map const TSC = 'bunx tsc --noEmit'; const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } }, guards: [{ id: 'typecheck', when: ['src/**'], command: TSC, fix: 'bunx tsc --noEmit' }] }) + '\n' }); fixtures.push(f); world(f, { jobs: ALL, tree: { files: [], tests: 0 } }); - const g = f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'guards', '--allow-repo-commands', '--base', 'main']); + const g = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']); expect([g.status, g.stdout]).toEqual([0, expect.stringContaining('GUARD: typecheck carried runs=7/1')]); const row = JSON.parse(fs.readFileSync(ledger(f)[0]!, 'utf8').trim().split('\n').at(-1)!); expect([row.tests_ran, row.jobs]).toEqual(['n/a', ['typecheck']]); @@ -380,3 +380,34 @@ describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a map expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]); }); }); + +describe('GST-1190: a file whose every test skipped on CI is not carried', () => { + // Value: protects=a lane never carries a test file that ran nothing on the CI runner (Docker images, drive paths, an installed gstack), so + // those tests run here instead of silently dropping; a partly skipped file and an older tree.json without skipped_files carry as before; + // fails_when=harness-evals' 75 runner-skipped tests carry as passed; why_new=HARN-7 (harness-evals #240); seam=fake gh + test('a fully skipped inventory file runs the lane here; a partly skipped one (not listed) and an older tree.json still carry', () => { + const f = fx(); + world(f, { tree: { skipped_files: ['test/b.test.ts'] } }); + const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]); + expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_uncovered: 1 of 2 inventory file\(s\) are in no run's tree\.json or skipped every test there \(1 skipped_files\): test\/b\.test\.ts$/m)]); + const out = preflight(f, [`free=${FREE}`]); + expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_uncovered'); + expect(runs(f, 'free-runs')).toBe(1); + world(f, { tree: { skipped_files: [] } }); + expect(f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]).status).toBe(0); + world(f, { tree: { skipped_files: 'test/b.test.ts' } }); + expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']).stdout).toContain('artifact_mismatch'); + }); + + test('the tests stage runs a selected file the run skipped entirely, and accepts the rest', () => { + const conf = JSON.stringify({ runner: 'bun test {files}', dependencies: { 'src/**': ['test/a.test.ts', 'test/b.test.ts'] }, run_profiles: { 'ci/free': PROFILE } }) + '\n'; + const t = "import { test, expect } from 'bun:test';\ntest('x', () => expect(1).toBe(1));\n"; + const f = shipFixture({ ...BASE, '.gstack/pregate.json': conf, 'test/a.test.ts': t, 'test/b.test.ts': t }); fixtures.push(f); + const stage = () => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main']); + world(f, { tree: { skipped_files: ['test/b.test.ts'] } }); + const skipped = stage(); + expect(skipped.stdout + skipped.stderr).toMatch(/REUSE: accepted 1 of 2 selected local test file\(s\) from run .*; 1 still run \(1 skipped every test in the run\)/); + world(f); + expect(stage().stdout + stage().stderr).toMatch(/REUSE: accepted 2 of 2 selected local test file\(s\) from run .*; 0 still run/); + }); +});