From e8793e59e8f41679f0d7bd121bd954853f1f2efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 02:44:48 +0000 Subject: [PATCH] fix(pregate): treat planning and evidence markdown as an empty lane A docs-only diff with no .gstack/pregate.json failed lanes as PREGATE_NO_LANE. Planning and evidence Markdown is now a docs/no-tests empty lane. Unmapped source files still fail closed. Co-authored-by: Nick --- docs/pregate.md | 10 ++- docs/troubleshooting.md | 13 ++-- lib/pregate/checks/lanes.ts | 12 +++- lib/pregate/config.ts | 18 +++++- lib/pregate/docs-lane.ts | 63 +++++++++++++++++++ lib/pregate/lane-selection.ts | 18 +++++- lib/result-codes.ts | 4 +- test/fixtures/module-size-ratchet.json | 1 + test/pregate-selection.test.ts | 84 ++++++++++++++++++++++++++ 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 lib/pregate/docs-lane.ts diff --git a/docs/pregate.md b/docs/pregate.md index fd6708b6a..c49465f01 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -59,6 +59,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). |---|---| | `runner` | the local test command with `{files}` (gstack: `bun test {files}`); without it `lanes` is `incomplete` | | `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. Planning and evidence Markdown already qualifies with no pin file (a `docs/` / `doc/` / `documentation/` tree, a `.work` plan, `plans/` / `evidence/` / `redproof/`, and markdown named plan, planning, redproof or evidence, plus README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS, CLAUDE). Source code and files under a test directory never qualify. A test that names 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 | @@ -118,8 +119,13 @@ Two stages with separate clocks, both printed: (static imports ∪ repo-path string literals ∪ the `bin/` → `test/*.test.ts` convention ∪ `dependencies`), transitively through non-test files; deleted and renamed files select the tests that named them. - A touched file with no lane is `FAIL lanes — has no test lane`, never - a pass. Lanes and platform tiers come from `.github/workflows/*.yml` + A touched source file with no lane is `FAIL lanes — has no test lane`, + never a pass. Planning and evidence Markdown is a docs/no-tests empty lane + instead (`docs/no-tests: N documentation file(s)`), including when the base + branch has no `.gstack/pregate.json`. `empty_lanes` adds globs for other + documentation; it cannot clear a source file or a file under a test + directory. A test that names a Markdown file still selects that test. + Lanes and platform tiers come from `.github/workflows/*.yml` (`gstack-pregate lanes` prints them): 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 45cdeb562..5f1dee56f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2741,12 +2741,17 @@ and Heavy-only files included. ### `PREGATE_NO_LANE` -**Meaning.** A touched file maps to no test through static imports, repo-path -string literals, the `bin/` → `test/*.test.ts` convention or a -`dependencies` declaration. That is a failure, not a pass. +**Meaning.** A touched source file maps to no test through static imports, +repo-path string literals, the `bin/` → `test/*.test.ts` convention +or a `dependencies` declaration. That is a failure, not a pass. Planning and +evidence Markdown is a docs/no-tests empty lane and is not this error: a `docs/` +tree, a `.work` plan, a redproof or evidence note, and README/CHANGELOG/LICENSE. +A test that names one of those files still selects that test. An unmapped +source or code file still fails. **Fix.** Add a test that names the file, or declare its lane (an explicit `[]` -for files with no behaviour, such as docs) in `.gstack/pregate.json`. `--explain` +for files with no behaviour) under `dependencies` in `.gstack/pregate.json`. +Other 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 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 diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 4d76453c1..94ebbaa22 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -5,6 +5,8 @@ * (zero tests ran is a failure, exit 0 or not); platform lanes this machine * cannot run become persisted `requires-remote` obligations. A touched file * with no lane is `FAIL lanes — has no test lane`, never a pass. + * Planning and evidence Markdown is the docs/no-tests empty lane instead + * (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does. * Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green), * p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun). */ @@ -99,6 +101,7 @@ function unfinished(ctx: PregateContext, r: Awaited> 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(' ')}`); 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; @@ -182,9 +185,14 @@ async function run(ctx: PregateContext): Promise { 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 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: 'add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in .gstack/pregate.json' }; + } + if (sel.tests.length === 0) { + const detail = sel.emptyLane.length + ? `docs/no-tests: ${sel.emptyLane.length} documentation file(s); nothing touched selects a test` + : ctx.touched.length ? 'nothing touched selects a test' : 'no touched files'; + return { ...base, detail }; } - if (sel.tests.length === 0) return { ...base, detail: ctx.touched.length ? 'nothing touched selects a test' : 'no touched files' }; if (ctx.remote && sel.localTests.length) return runRemote(ctx, sel, base); const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local'); if (smallMachine) { diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 5e202ab7d..bc599301b 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -8,6 +8,9 @@ * dependencies glob → test files for subprocess targets, runtime reads and * generated inputs the import graph cannot see; `[]` declares * "no lane needed" for docs and the like + * empty_lanes extra globs for the docs/no-tests empty lane (planning and + * evidence Markdown already qualifies without a pin file; + * source code and test trees never do) * selection `/` → the runner-owned command that lists * the lane's test files when the matrix is planner-computed * warn check ids downgraded to warn (never `secrets`; `lanes` keeps @@ -44,6 +47,8 @@ export interface PregateConfig { $schema?: string; runner: string | null; dependencies: Record; + /** Extra docs/no-tests globs. Built-in planning and evidence Markdown always qualifies. */ + empty_lanes: string[]; selection: Record; warn: string[]; strays: { allow: string[] }; @@ -54,12 +59,12 @@ export interface PregateConfig { remote_gate: string | null; } export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number } -export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; +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; const MAX_COMMAND = 512; -const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; +const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; const MAX_GUARDS = 32; const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s']; @@ -125,6 +130,11 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] if (!Array.isArray(list) || list.some(g => typeof g !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'serial_patterns', message: 'must be an array of globs' }); else (list as string[]).forEach((g, i) => { const why = globError(g); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `serial_patterns[${i}]`, message: `${JSON.stringify(g)} ${why}` }); }); } + if ('empty_lanes' in obj) { + const list = obj.empty_lanes; + if (!Array.isArray(list) || list.some(g => typeof g !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'empty_lanes', message: 'must be an array of globs (extra docs/no-tests paths; source files and test trees never qualify)' }); + else (list as string[]).forEach((g, i) => { const why = globError(g); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `empty_lanes[${i}]`, message: `${JSON.stringify(g)} ${why}` }); }); + } if ('dependencies' in obj) { const deps = obj.dependencies; if (!deps || typeof deps !== 'object' || Array.isArray(deps)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'dependencies', message: 'must be an object of glob → test file list' }); @@ -176,7 +186,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []): const errors = validateConfig(raw, knownChecks); if (errors.length) return { config: DEFAULT_CONFIG, errors }; const o = raw as Partial; - return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; + return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; } export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string }; @@ -210,6 +220,7 @@ export function exampleConfigJson(): string { $schema: PREGATE_CONFIG_SCHEMA, runner: null, dependencies: { 'bin/example-tool': ['test/example-tool.test.ts'], 'docs/**': [] }, + empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, @@ -221,6 +232,7 @@ 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: a docs/doc/documentation tree, a .work plan, plans/evidence/redproof directories, markdown whose name says plan, planning, redproof or evidence, and README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE. Source code and files under a test directory never qualify. A test that names the file still selects that test', 'selection: / → the runner-owned command that lists that lane’s test files when its matrix is planner-computed', `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`, 'strays.allow: root files or new top-level directories that are intentional', diff --git a/lib/pregate/docs-lane.ts b/lib/pregate/docs-lane.ts new file mode 100644 index 000000000..2c9374100 --- /dev/null +++ b/lib/pregate/docs-lane.ts @@ -0,0 +1,63 @@ +/** + * pregate docs/no-tests — planning and evidence Markdown is an empty lane. + * + * A repo with no `.gstack/pregate.json` used to fail `lanes` on a tracked + * `.work` plan or a redproof note (`PREGATE_NO_LANE`). Those files document + * a change; they are not test subjects. `isDocsEmptyLane` is that class. + * `lane-selection` consults it only when nothing else maps the file, so a + * test that names the file 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`. + */ +import { SOURCE_EXT } from './import-graph'; +import { matchesAny } from '../ship-policy'; + +const TEST_TREE = /(?:^|\/)(?:test|tests|spec|__tests__)(?:\/|$)/i; +const MARKDOWN = /\.(?:md|markdown)$/i; +/** Root-level prose names, with or without `.md`. */ +const PROSE_NAME = /^(?:README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE|ETHOS|LICENSE|LICENCE|COPYING|NOTICE)(?:\.md)?$/i; +/** A documentation tree. `doc` does not match `docstring`; `.work` is the plan workspace. */ +const DOC_DIR = /(?:^|\/)(?:\.work|docs|doc|documentation|plans|evidence|redproof)(?:\/|$)/i; +/** + * A planning or evidence basename: `plan.md`, `implementation-plan.md`, + * `planning.md`, `redproof.md`, `red-proof.md`, `evidence.md`. + * `planet.md` and `explanation.md` do not match. + */ +const EVIDENCE_NAME = /(?:^|[^a-z0-9])(?:planning|plans|plan|red-?proofs?|evidence)(?:[^a-z0-9]|$)/i; + +function isMarkdown(rel: string): boolean { + return MARKDOWN.test(rel); +} + +function isTestTree(rel: string): boolean { + return TEST_TREE.test(rel); +} + +/** Code the empty-lane class must not swallow. Extensionless `bin/` entrypoints count; `bin/README.md` does not. */ +function isSourceCode(rel: string): boolean { + if (SOURCE_EXT.test(rel)) return true; + const base = rel.split('/').pop() ?? rel; + if (rel.startsWith('bin/') && !isMarkdown(rel) && !PROSE_NAME.test(base)) return true; + return false; +} + +function isBuiltinDocsPath(rel: string): boolean { + const base = rel.split('/').pop() ?? rel; + if (PROSE_NAME.test(base)) return true; + if (!isMarkdown(rel)) return false; + if (DOC_DIR.test(rel)) return true; + return EVIDENCE_NAME.test(base); +} + +/** + * True when `rel` is documentation or evidence that needs no test lane. + * `extraGlobs` are the repo's `empty_lanes` (`.gstack/pregate.json` on the + * base branch). They add paths; they cannot classify source code or a file + * under a test directory. + */ +export function isDocsEmptyLane(rel: string, extraGlobs: readonly string[] = []): boolean { + const norm = rel.replace(/\\/g, '/').replace(/^\.\//, ''); + if (!norm || isTestTree(norm) || isSourceCode(norm)) return false; + if (extraGlobs.length > 0 && matchesAny(extraGlobs, norm)) return true; + return isBuiltinDocsPath(norm); +} diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 6c2a463ec..170aca70f 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -11,6 +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 { buildGraph, isTestFile, listRepoFiles, testsFor, type Graph } from './import-graph'; import type { PregateContext, RemoteObligation } from './types'; import type { Lane } from './workflows'; @@ -18,6 +19,8 @@ import type { Lane } from './workflows'; export interface Selection { /** Touched files with no lane and no `[]` declaration. */ noLane: string[]; + /** Documentation and evidence files with no mapped test: the docs/no-tests empty lane. */ + emptyLane: string[]; /** Every test the touched files select, sorted. */ tests: string[]; /** Tests that run on this machine. */ @@ -76,6 +79,7 @@ export function selectLanes(ctx: PregateContext): Selection { const provenance: string[] = []; const tests = new Set(); const noLane: string[] = []; + const emptyLane: string[] = []; const seeds = new Map(); 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) { @@ -86,7 +90,12 @@ export function selectLanes(ctx: PregateContext): Selection { const m = testsFor(graph, file, ctx.config.dependencies); for (const t of m.tests) tests.add(t); provenance.push(...m.provenance.map(p => `${p} [${why}]`)); - if (m.tests.length || m.declaredNone || why.startsWith('generated')) continue; + if (m.tests.length || why.startsWith('generated')) continue; + // An explicit `[]` already cleared the file. Label it docs/no-tests only when it is that class. + if (m.declaredNone) { + if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file); + continue; + } // A renamed file keeps the lane its old name had: those tests run (and fail loudly if they still import the old path). const renamedFrom = touched.find(t => t.path === file && t.renamedFrom)?.renamedFrom; if (renamedFrom && testsFor(graph, renamedFrom, ctx.config.dependencies).tests.length) { provenance.push(`${file}: lane via its old name ${renamedFrom}`); continue; } @@ -95,6 +104,11 @@ export function selectLanes(ctx: PregateContext): Selection { if (generator) { provenance.push(`${file}: covered by regen ${generator.id} (registry input)`); continue; } // The pin files are validated by pregate itself (validateConfig / validateRegistry) on every run. if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; } + if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) { + emptyLane.push(file); + provenance.push(`${file}: docs/no-tests (empty lane)`); + continue; + } noLane.push(file); } const laneSets: Record = {}; @@ -117,7 +131,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(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance }; + return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance }; } export function isTest(file: string): boolean { return isTestFile(file); } diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 3e4201c7d..12216d914 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -381,8 +381,8 @@ export const RESULT_CODES = { }, PREGATE_NO_LANE: { anchor: 'pregate-no-lane', - summary: 'a touched file maps to no test lane through imports, repo-path literals, the bin/ convention or a declaration', - fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json', + summary: 'a touched source file maps to no test lane through imports, repo-path literals, the bin/ convention or a declaration', + fix: 'add a test that names the file, or declare its lane (or an explicit empty lane) under `dependencies` in .gstack/pregate.json. Planning and evidence Markdown is already a docs/no-tests empty lane; other documentation goes in `empty_lanes`', }, PREGATE_LANE_FAILED: { anchor: 'pregate-lane-failed', diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index e97822032..dcd31f0ab 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -102,6 +102,7 @@ { "file": "lib/pregate/import-graph.ts", "movedFrom": "(new: imports ∪ repo-path literals ∪ bin convention ∪ declarations, C8)" }, { "file": "lib/pregate/context.ts", "movedFrom": "(new: touched files, base inputs and before/after fingerprinting, C8)" }, { "file": "lib/pregate/lane-selection.ts", "movedFrom": "(new: touched files to lanes, local vs requires-remote, C8)" }, + { "file": "lib/pregate/docs-lane.ts", "movedFrom": "(new: docs/no-tests empty lane for planning and evidence markdown, #3210)" }, { "file": "lib/pregate/index.ts", "movedFrom": "(new: the check registry, two-stage runner, pregate.json writer and publication verifier, C8)" }, { "file": "lib/pregate/checks/regen.ts", "movedFrom": "(new: pre-gate check, C8)" }, { "file": "lib/pregate/checks/secrets.ts", "movedFrom": "(new: pre-gate check, C8)" }, diff --git a/test/pregate-selection.test.ts b/test/pregate-selection.test.ts index 8df3c62e8..7757bbbc0 100644 --- a/test/pregate-selection.test.ts +++ b/test/pregate-selection.test.ts @@ -16,6 +16,7 @@ import { EXIT } from '../lib/headless-artifacts'; import { changedLiterals, constantsIn } from '../lib/pregate/checks/literals'; import { parseSummary } from '../lib/pregate/checks/lanes'; import { validateConfig } from '../lib/pregate/config'; +import { isDocsEmptyLane } from '../lib/pregate/docs-lane'; import { buildGraph, testsFor } from '../lib/pregate/import-graph'; import { CHECK_IDS } from '../lib/pregate/index'; import { parseWorkflow } from '../lib/pregate/workflows'; @@ -88,11 +89,40 @@ describe('literals (pure)', () => { expect(codes({ warn: ['bogus'] })).toEqual(['PREGATE_CONFIG_INVALID:warn']); expect(codes({ runner: 'bun test' })).toEqual(['PREGATE_CONFIG_INVALID:runner']); expect(codes({ dependencies: { '../x': [] } })).toEqual(['PREGATE_CONFIG_INVALID:dependencies["../x"]']); + expect(codes({ empty_lanes: 'docs/**' })).toEqual(['PREGATE_CONFIG_INVALID:empty_lanes']); + expect(codes({ empty_lanes: ['../x'] })).toEqual(['PREGATE_CONFIG_INVALID:empty_lanes[0]']); + expect(codes({ empty_lanes: ['notes/**/*.md'] })).toEqual([]); expect(codes({ selection: { 'ci/job': 'x' } })).toEqual(['PREGATE_CONFIG_INVALID:selection["ci/job"]']); expect(codes({ extra: 1 })).toEqual(['PREGATE_CONFIG_INVALID:extra']); }); }); +describe('docs/no-tests empty lane', () => { + const docs = [ + '.work/round-3-plan.md', 'pkg/.work/notes.md', 'notes/redproof.md', 'notes/REDPROOF.md', + 'notes/round-3.red-proof.md', 'docs/evidence.md', 'docs/guide.md', 'Docs/Guide.MD', + 'documentation/index.md', 'plans/2026-10-16.md', 'README.md', 'lib/README.md', + 'CHANGELOG.md', 'LICENSE', 'notes/implementation-plan.md', 'notes/planning.md', 'src/plan.md', + ]; + const blocked = [ + 'lib/orphan.js', 'src/app.ts', 'bin/tool', 'scripts/gen.sh', 'prompts/system.md', 'src/notes.md', + 'src/explanation.md', 'planet.md', 'docs/example.ts', 'test/fixtures/plan.md', 'test/plans/plan.md', + '__tests__/redproof.md', 'my-docs/guide.md', + ]; + test('built-in planning and evidence markdown qualifies; source, test trees and lookalikes do not', () => { + for (const file of docs) expect(isDocsEmptyLane(file)).toBe(true); + for (const file of blocked) expect(isDocsEmptyLane(file)).toBe(false); + }); + test('empty_lanes adds documentation globs and cannot clear source code or a test-tree file', () => { + expect(isDocsEmptyLane('notes/scratch.md', ['notes/**/*.md'])).toBe(true); + expect(isDocsEmptyLane('prompts/system.md', ['prompts/**'])).toBe(true); + expect(isDocsEmptyLane('lib/orphan.js', ['**', 'lib/**', '**/*.js'])).toBe(false); + expect(isDocsEmptyLane('docs/example.ts', ['docs/**'])).toBe(false); + expect(isDocsEmptyLane('bin/tool', ['bin/**'])).toBe(false); + expect(isDocsEmptyLane('test/fixtures/plan.md', ['**/*.md', 'test/**'])).toBe(false); + }); +}); + describe('gstack-pregate (bin)', () => { test('--help prints the exit table and both stages; unknown commands and checks are usage (2)', () => { const repo = fixture(); @@ -187,6 +217,60 @@ describe('gstack-pregate (bin)', () => { expect(pregate(ok, ['--stage', 'preflight', '--only', 'secrets']).status).toBe(EXIT.ok); }); + test('lanes: tracked planning and evidence markdown is docs/no-tests, and an unmapped source file still fails closed', () => { + // No .gstack/pregate.json on the base: documentation cannot be declared as []. + const docs = fixture({ pregate: null }); + commit(docs, { + '.work/round-3-plan.md': '# plan\n\nImplementation notes, not a test subject.\n', + 'notes/redproof.md': '# redproof\n\nThe lane failed, then this note recorded the green run.\n', + 'docs/evidence.md': '# evidence\n', + }); + const ok = pregate(docs, ['--only', 'lanes', '--explain']); + expect(ok.status).toBe(EXIT.ok); + expect(ok.err).not.toContain('PREGATE_NO_LANE'); + expect(ok.out).toMatch(/docs\/no-tests: 3 documentation file\(s\)/); + expect(ok.out).toContain('.work/round-3-plan.md: docs/no-tests (empty lane)'); + expect(ok.out).toContain('notes/redproof.md: docs/no-tests (empty lane)'); + expect(ok.out).toContain('docs/evidence.md: docs/no-tests (empty lane)'); + expect(ok.out).not.toContain('has no test lane'); + + const mixed = fixture({ pregate: null }); + commit(mixed, { + '.work/round-3-plan.md': '# plan\n', + 'lib/orphan.js': 'exports.lonely = () => 2;\n', + }); + const bad = pregate(mixed, ['--only', 'lanes']); + expect(bad.status).toBe(EXIT.fail); + expect(bad.err).toContain('(PREGATE_NO_LANE)'); + expect(bad.out).toContain('lanes: FAIL lanes — lib/orphan.js has no test lane'); + expect(bad.out).toContain('docs/no-tests (empty lane): .work/round-3-plan.md'); + expect(bad.out).not.toContain('.work/round-3-plan.md has no test lane'); + + const extra = fixture({ pregate: { empty_lanes: ['notes/**/*.md'], runner: 'node --test {files}' } }); + commit(extra, { 'notes/scratch.md': '# a planning note outside the built-in names\n' }); + const declared = pregate(extra, ['--only', 'lanes']); + expect(declared.status).toBe(EXIT.ok); + expect(declared.err).not.toContain('PREGATE_NO_LANE'); + expect(declared.out).toContain('docs/no-tests (empty lane): notes/scratch.md'); + }); + + test('lanes: a test that names a markdown file still runs that test', () => { + const repo = fixture({ pregate: null }); + repo.git('checkout', '-q', 'main'); + write(repo.work, 'test/readme-doc.test.js', "const test = require('node:test');\nconst fs = require('node:fs');\ntest('reads the readme', () => { fs.readFileSync('docs/README.md', 'utf8'); });\n"); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'name the doc'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('reset', '--hard', 'main'); + commit(repo, { 'docs/README.md': '# docs\n\nupdated evidence\n' }); + const r = pregate(repo, ['--only', 'lanes', '--explain']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('test/readme-doc.test.js ← docs/README.md'); + expect(r.err).not.toContain('PREGATE_NO_LANE'); + expect(r.out).not.toContain('docs/no-tests'); + }); + test('lanes: a touched file with no test lane is FAIL lanes — has no test lane; a declaration or an explicit [] clears it', () => { const repo = fixture(); commit(repo, { 'lib/orphan.js': "exports.lonely = () => 2;\n", 'docs/guide.md': '# guide\n' });