diff --git a/.gstack/pregate.json b/.gstack/pregate.json index f145aef13..d659c715e 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,7 +20,7 @@ "free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list" }, "run_profiles": { - "ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux", "skippable": { "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration"], "gate": "docs-scope", "scripts": [".github/actions/docs-scope/action.yml", "bin/gstack-docs-only", "lib/docs-only.ts", "lib/review-depth.ts", "lib/ship-policy.ts"] } }, + "ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux", "skippable": { "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration"], "gate": "docs-scope", "scripts": [".github/actions/docs-scope/action.yml", "bin/gstack-docs-only", "lib/docs-only.ts", "lib/review-depth.ts", "lib/ship-policy.ts", "lib/ship-policy-docs-only.ts"] } }, "ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" } }, "remote_gate_runs": { diff --git a/bin/gstack-ship-policy b/bin/gstack-ship-policy index e4a67962a..41d56846f 100755 --- a/bin/gstack-ship-policy +++ b/bin/gstack-ship-policy @@ -66,6 +66,7 @@ function load(root: string, args: string[], defaultFrom: 'base' | 'head'): Loade function printErrors(loaded: LoadedPolicy): void { for (const e of loaded.errors) process.stderr.write(resultLine('gstack-ship-policy', e.code, `${e.message} at ${e.path || SHIP_POLICY_PATH}`) + '\n'); + for (const w of loaded.warnings) process.stderr.write(`gstack-ship-policy: WARN ${w.path}: ${w.message}\n`); } function main(argv: string[]): number { @@ -93,10 +94,10 @@ function main(argv: string[]): number { const floor: ReturnType = cmd === 'validate' && loaded.present ? floorAgainstStable(loaded.policy.min_gstack_version, newestStableRelease()) : {}; if (floor.error) loaded.errors.push(floor.error); const set = POLICY_KEYS.filter(k => loaded.values[k] && loaded.values[k] !== 'default'); - const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : POLICY_KEY_INTRODUCED[k]])); + const introduced = Object.fromEntries(set.map(k => [k, k === 'bump' && loaded.policy.bump === 'never' ? BUMP_NEVER_KEY_VERSION : k === 'land' ? minimumReader({ land: loaded.policy.land }) : POLICY_KEY_INTRODUCED[k]])); const minReader = set.length ? minimumReader(Object.fromEntries(set.map(k => [k, loaded.policy[k]]))) : null; if (json) { - process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, ...(cmd === 'validate' ? { stable: floor.stable ?? null, warnings: floor.warning ? [floor.warning] : [] } : {}), errors: loaded.errors }) + '\n'); + process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, policy: loaded.policy, values: loaded.values, introduced, min_reader: minReader, ...(cmd === 'validate' ? { stable: floor.stable ?? null, warnings: [...(floor.warning ? [floor.warning] : []), ...loaded.warnings.map(w => `${w.path}: ${w.message}`)] } : {}), errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; } printErrors(loaded); diff --git a/changelog.d/repo-docs-only-paths-gst-1974.md b/changelog.d/repo-docs-only-paths-gst-1974.md new file mode 100644 index 000000000..9ffd0e7fe --- /dev/null +++ b/changelog.d/repo-docs-only-paths-gst-1974.md @@ -0,0 +1 @@ +- **A repository can name its own human-docs folders, so prose there skips the test suites too.** Docs that live outside `docs/` (gstack-evals keeps posts in `wiki/board/`) used to run every suite, and /ship ran them again locally. Add `"docs_only": { "allow": ["wiki/board/**.md"], "tests": ["tests/wiki.test.ts"] }` to `.gstack/ship-policy.json` on the base branch. gstack reads it only from the base, so a pull request can't add itself, and a pull request that edits the policy always runs full CI. Patterns must be narrow Markdown globs: `**`, `*`, `*.md` and anything aimed at agent files are refused with an example of a good one. gstack's own deny list (agent instructions, templates, prompts, preferences, tool configuration, pinned results, generated pages) always wins. A script that reads an allowed folder keeps the class off unless one of the listed tests covers it, and those tests run in the docs check. A listed test missing from the branch keeps the class off too, and land keeps its checks when the base gained a reader of the folder. Unknown keys inside `docs_only` and `land` are now warnings instead of errors. `land.docs_only_skip` names its own release, so `gstack-ship-policy validate` says which `min_gstack_version` turns it on. (GST-1974) diff --git a/docs/pregate.md b/docs/pregate.md index f7a1e5fe1..99fe43682 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1445,12 +1445,15 @@ pregate, `gstack-ci-carry`, /ship's lanes and land. A path is docs only when eve these holds: - It is a plain file (mode 100644). Executables, symlinks, submodules and type changes refuse. -- It is on the allowlist, matched case-sensitively: root `README.md`, `docs/**/*.md`, `changelog.d/*.md`. +- It is on the allowlist, matched case-sensitively: root `README.md`, `docs/**/*.md`, `changelog.d/*.md`, plus the + repository's own globs from the base branch's `docs_only.allow` ([below](#repo-docs-paths)). - review-depth's `classifyPath` calls it docs, so agent prose, skill directories and policy docs stay code. - It matches no denylist entry. The denylist covers agent-instruction names matched case-insensitively (`SKILL.md`, `AGENTS.md`, `CLAUDE.md`, `ETHOS.md`, `CREW.md`, `captain.md`, `factory.md`, `pregate.md`, - `unattended.md` and others), `*-policy.md`, preferences, `sections/`, `templates/`, `*.tmpl`, pinned - results (`docs/evals/`, `docs/inventory/`), preregistrations, and every output in `.gstack/generated.json`. + `unattended.md` and others), `*-policy.md`, preferences, `sections/`, `templates/`, `skills/`, `prompts/` and + `*prompt.md`, `*.tmpl`, hidden directories (`.github/`, `.gstack/`), pinned results (any `evals/` or `inventory/` + directory), preregistrations, every output in `.gstack/generated.json`, and the repository's `docs_only.deny`. + The list lives in `lib/ship-policy-docs-only.ts`, and no repository setting can override it. - No tracked file outside human prose names its path. A script, test, workflow or skill that reads a doc pins it. `CHANGELOG.md`, `TODOS.md` and other allowlisted docs don't pin. The match is a substring match, so it errs toward pinning. @@ -1485,9 +1488,57 @@ also calls the head docs-only. Without that check, the run is never evidence (`a **Land.** `land.docs_only_skip` in `.gstack/ship-policy.json` names the `merged_checks` commands land skips for a docs-only pin, such as the test list, keeping typecheck and the freshness check. Land keeps every -check when a non-docs file on the base tip names a changed doc, because the pin merges onto that tip. A gstack -release older than this change refuses the key as unknown, so main's policy adds it only with `min_gstack_version` -raised to the first release that reads it; until then land runs every merged check on a docs-only pin. +check when a non-docs file on the base tip names a changed doc, or reads the directory of a repo-admitted one +([below](#repo-docs-paths)) where the pin had no such reader, because the pin merges onto that tip. Releases up +to 1.98.0.0 refuse the key as unknown, so `validate` refuses it until `min_gstack_version` reaches +`LAND_DOCS_ONLY_SKIP_KEY_VERSION` (lib/ship-policy-keys.ts). To turn it on (GST-1968), use one pull request, after a +stable release carries the key: add `docs_only_skip`, raise `min_gstack_version` to that release, and check that +`gstack-ship-policy validate` prints `POLICY_VALID` and a `MIN_READER` no newer than the stable release. Until +then land runs every merged check on a docs-only pin. gstack's own policy leaves the key off. + + +### Your repository's own docs paths (`docs_only`) + +A repository whose human docs live outside `docs/` names them in `.gstack/ship-policy.json`: + +```json +"docs_only": { "allow": ["wiki/board/**.md"], "deny": ["wiki/board/drafts/**"], "tests": ["tests/wiki.test.ts"] } +``` + +Every check above still applies to a repo-allowed path: its mode, the built-in deny list, review-depth's +`classifyPath` (a top-level directory holding a `SKILL.md` stays agent prose), generated outputs, renames on both sides, and +the reference scan (a script or test naming the path pins it; a repo-allowed doc naming another does not). + +- **Base only.** The classifier reads the block with git from the base branch tip (CI: the merge commit's first + parent; pregate and `gstack-ci-carry`: `origin/`; land: the tip it merges onto), while the diff still starts + at the merge base, so an allow the base revoked after a branch forked no longer applies. It never reads the head or + the working tree, and a pull request that edits `.gstack/ship-policy.json` is never docs-only, + so a branch can't allowlist itself. +- **Narrow patterns.** Each `allow` entry ends in `.md` (not `.mdx`, which can import code), starts with a + literal directory or file name, uses only `*` and `**` as wildcards, and passes through no hidden directory and no `skills`, `templates`, + `sections`, `specialists`, `prompts`, `test`, `tests`, `spec`, `fixtures`, `lib`, `bin`, `scripts`, `src`, + `vendor`, `dist` or `build` segment. `**`, `*`, `*.md`, `**/*.md` and a pattern naming an agent file + (`wiki/SKILL.md`, `wiki/**/templates/**.md`) are `POLICY_INVALID` when the policy is read, with an example of a + narrow pattern. A path the pattern admits that still passes through one of those directories (`wiki/lib/x.md`) + refuses too. `deny` entries only remove paths, built-in ones included. +- **Fail closed.** A base policy that is not valid JSON, or a `docs_only` block that fails those rules, makes every + change not docs-only, with the error as the refusal. An unknown key inside `docs_only` is a warning to + `gstack-ship-policy validate`, and the classifier then ignores `allow` (keeping `deny`), so a gstack that + doesn't understand a newer key never widens on it. +- **Readers by directory.** A script that lists a directory reads every post in it, which a literal-path scan can't + see. So for a path only `allow` admits, any non-prose tracked file that names one of its directories (`wiki/`, + `'wiki'`, `"wiki/board"`, `'./wiki'`, `'../wiki'`, `root + '/wiki'`, `` `${root}/wiki` ``) refuses the change unless it is tied to `tests`: it is one of them, names one, or one + names it (with or without its extension). `tests` lists up to 16 test files (`*.test.ts` and the like, never a glob); + the docs lane runs them, in pregate and in CI's `tests` output, whenever a repo-admitted path changed. A listed test missing from the classified tree refuses + the change, so the docs lane can't pass without it. A prose file + that names a directory only describes it; one that names a path still pins that path. + +**Turning it on.** Releases up to 1.98.0.0 refuse `docs_only` as an unknown key, and `validate` refuses the block +until `min_gstack_version` reaches `DOCS_ONLY_KEY_VERSION`. After a stable release carries it, add the block and +raise `min_gstack_version` to that release in the same pull request; that pull request edits the policy, so it runs +the full suites. Pushes to the base branch always run the full suites. A repository whose CI runs an older pinned +gstack, or has no `docs-scope` job, gets the class in pregate, /ship and land only: bump the pin first, then add the +`docs-scope` job from [CI](#docs-only) (never gating its own docs checks), then add the block. ## Owner-granted flaky exception diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 76a0205e1..e57c1b280 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -28,9 +28,14 @@ code from [troubleshooting.md](troubleshooting.md#queue-aware-ship-policy-restam `gstack-ship-policy init` writes every key at its default; `init --explain` prints this table. JSON carries no comments, so examples live in -`.gstack/ship-policy.example.json`. Unknown keys are errors, and there is no -extension mechanism: a key from a newer gstack, or a misspelled one, fails -closed (`POLICY_INVALID`) instead of being ignored. Every key has an +`.gstack/ship-policy.example.json`. Unknown top-level keys are errors: a key +from a newer gstack, or a misspelled one, fails closed (`POLICY_INVALID`) +instead of being ignored. The one extension point is a key inside `land` or +`docs_only`: this gstack and later ones ignore it with a `WARN` line +(`validate --json` lists it under `warnings`), so a later release can add a +nested key without breaking readers that carry this rule. A nested key that +must be enforced still names its own introducing version, as +`land.docs_only_skip` does, so `validate` asks for `min_gstack_version`. Every key has an introducing version (`init --explain` and `validate` print it). A key newer than 1.91.98.0 set to a non-default value requires `min_gstack_version` at least at its introducing version, and a gstack older than `min_gstack_version` @@ -75,7 +80,8 @@ branch that changes the policy. | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | | `merge` | `train` \| `direct` | `train` | `direct` lands each READY PR at its own green head with [`land --pr --pin `](#land-direct): no train and no stamp commit, and `train build` refuses. A PR then leaves VERSION, the `package.json` version and the CHANGELOG at its merge base, and the release sets them. Needs `changelog_fragments`, and the stamping `land --pr ` refuses under it. `train` keeps merge trains. Read from the base revision only. Needs `min_gstack_version`. | -| `land` | `{ merged_checks, docs_only_skip? }` or `null` | `null` | Under `merge: direct`, the shell commands (1 to 16, one line of at most 512 characters each, 15 minutes each) [`land --pin`](#land-direct) runs on the pin merged onto the base tip when the base moved past the pin's merge base; it lands only when every one exits 0. They are repo commands, so land runs them only with `--allow-repo-commands`, never under `--dry-run`. `null` lands a stale base untested with a `STALE_BASE: warn` line. `docs_only_skip` lists `merged_checks` commands to skip for a docs-only pin ([pregate.md](pregate.md#docs-only)). Read from the base revision only. Needs `min_gstack_version`. | +| `land` | `{ merged_checks, docs_only_skip? }` or `null` | `null` | Under `merge: direct`, the shell commands (1 to 16, one line of at most 512 characters each, 15 minutes each) [`land --pin`](#land-direct) runs on the pin merged onto the base tip when the base moved past the pin's merge base; it lands only when every one exits 0. They are repo commands, so land runs them only with `--allow-repo-commands`, never under `--dry-run`. `null` lands a stale base untested with a `STALE_BASE: warn` line. `docs_only_skip` lists `merged_checks` commands to skip for a docs-only pin ([pregate.md](pregate.md#docs-only)) and needs `min_gstack_version` at its own release (`LAND_DOCS_ONLY_SKIP_KEY_VERSION`), because older releases refuse it. Any other key under `land` is a warning, ignored. Read from the base revision only. Needs `min_gstack_version`. | +| `docs_only` | `{ allow, deny?, tests? }` or `null` | `null` | Human-docs paths beyond `README.md`, `docs/**.md` and `changelog.d/*.md` for the [docs-only class](pregate.md#repo-docs-paths) (`{ "allow": ["wiki/board/**.md"] }`). `allow` (1 to 16) takes narrow Markdown globs: each ends in `.md`, starts with a literal directory or file name, and passes through no hidden, code, test or agent directory; `**`, `*`, `*.md` and patterns naming agent files are `POLICY_INVALID` with an example of a narrow one. `deny` (up to 64 globs) removes paths, built-in ones included. `tests` (up to 16 test files) are the repository's checks for those directories: the docs lane runs them, and a non-prose file that reads an allowed directory refuses the class unless it is tied to one; a listed test missing from the branch refuses it too. The built-in deny list always wins. Any other key under `docs_only` is a warning, and the classifier then ignores `allow`. Read from the base revision only; a pull request that edits this file is never docs-only. Needs `min_gstack_version`. | | `release_gate` | `{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd }` or `null` | `null` | One full paid gate on main every `cadence_hours` (8 to 168) instead of the per-push main backstop, feeding the stable release; see [release_gate](#release-gate). Every field is required: `on_red` is `hold-release`, `bisect_spend_cap_usd` 0 to 500 (0 skips the bisect). `null` keeps the per-push backstop. Needs `min_gstack_version`. | | `bookkeeping` | glob list | `[]` | Root-anchored globs of bookkeeping tables a verify or ratchet step rewrites (size baselines, serial lists, limits), each ending in `.tsv`, `.csv` or `.txt` and never under `.gstack/` or `.github/` (`["scripts/*-baseline.tsv"]`). A matching file changed at most 20 lines after review binds, first touched or not, takes finish's [one scoped reader](review-policy.md#bounded-post-review-edits) instead of Steps 5–11.5; the reader still reads every changed row. Read from the base revision only. Needs `min_gstack_version`. | | `mechanical_retake` | `[{ path, template, fields, regen }]` | `[]` | A version or rules constant a branch may have to retake after a base merge, next to `bookkeeping`'s bounded kind: `path` is one literal file, `template` the whole line with one `{n}` (`const RULE_NUMBERS := {n}`), `fields` the `{ glob, field }` fixture fields that carry the same value, and `regen` the [`.gstack/generated.json`](pregate.md#generated) entry ids whose command regenerates its goldens. A post-review edit finish proves to be only that retake routes as `mechanical=`, with no reader and no review round; see [mechanical retakes](#mechanical-retake). At most 8 constants and 16 fields or ids each; no glob, `..`, `.gstack/` or `.github/` path. Read from the base revision only. Needs `min_gstack_version`. | diff --git a/lib/docs-only.ts b/lib/docs-only.ts index 044286c6f..a93b75bd2 100644 --- a/lib/docs-only.ts +++ b/lib/docs-only.ts @@ -7,37 +7,35 @@ * * In gstack most Markdown is agent instructions, so the rule is an allowlist with overrides, never a "*.md" glob. A path * is docs only when all of these hold: its mode is a plain file (100644; executables, symlinks, submodules and type - * changes refuse); it is on the allowlist (root README.md, docs/**.md, changelog.d/*.md, case-sensitive); review-depth's - * classifyPath calls it docs (agent prose, skill directories, policy and rule docs are code there); it matches no - * denylist entry (agent-prose names case-insensitively, pinned results, preregistrations, generated outputs); and no - * tracked file outside human prose names its path (a script, test, workflow or skill that reads it pins it). A rename - * needs both sides to pass and a deletion needs the old path to pass. Unsure, or any git failure, is never docs-only. + * changes refuse); it is on the allowlist (root README.md, docs/**.md, changelog.d/*.md, case-sensitive, plus the + * base revision's .gstack/ship-policy.json docs_only.allow, never the head's); review-depth's classifyPath calls it docs + * (agent prose, skill directories, policy and rule docs are code there); it matches no built-in denylist entry + * (lib/ship-policy-docs-only.ts: agent-prose names case-insensitively, prompts, tool configuration, pinned results, + * preregistrations), no generated output and no docs_only.deny glob; and no tracked file outside human prose names its + * path (a script, test, workflow or skill that reads it pins it). A rename needs both sides to pass and a deletion needs + * the old path to pass. Unsure, an unreadable base policy, or any git failure, is never docs-only. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { classifyPath } from './review-depth'; -import { matchesAny } from './ship-policy'; +import { matchesAny, SHIP_POLICY_PATH } from './ship-policy'; +import { CODE_DIRS, DOCS_ONLY_DENY, DOCS_ONLY_KEYS, docsOnlyPolicyErrors } from './ship-policy-docs-only'; export const DOCS_ONLY_ERROR = 'DOCS_ONLY_ERROR'; export const DOCS_ONLY_ANCHOR = 'docs/troubleshooting.md#docs-only-error'; const ALLOW = [/^README\.md$/, /^docs\/(?:[^/]+\/)*[^/]+\.md$/, /^changelog\.d\/[^/]+\.md$/]; -const DENY: readonly [RegExp, string][] = [ - [/(?:^|\/)(?:skill|agents|claude|gemini|ethos|crew|captain|factory|driver|checklist|unattended|pregate|lane-card|notes|digest|soul)\.md$/i, 'agent instructions'], - [/-policy\.md$/i, 'policy doc'], - [/(?:^|\/)preferences[^/]*$/i, 'preferences'], - [/(?:^|\/)(?:sections|templates|specialists|skills)\//i, 'agent instructions'], - [/\.tmpl$/i, 'template'], - [/^docs\/(?:evals|inventory)\//, 'pinned results'], - [/prereg/i, 'preregistration'], - [/^changelog\.d\/README\.md$/i, 'fragment instructions'], -]; const PROSE_REFERENCE = /^(?:CHANGELOG|TODOS)\.md$/; +const PROSE = /\.(?:md|mdx|markdown|rst|txt|adoc)$/i; export interface DiffEntry { status: string; path: string; from?: string; mode: string; oldMode: string } export interface Refusal { path: string; reason: string } export interface DocsOnlyResult { docsOnly: boolean; files: string[]; refusals: Refusal[]; tests: string[] } +/** The base revision's docs_only block (`label` names where it came from); `ignored` names unknown keys that dropped its allow list. */ +export interface RepoDocs { allow: readonly string[]; deny: readonly string[]; tests: readonly string[]; label: string; ignored?: string } +const NO_REPO_DOCS: RepoDocs = { allow: [], deny: [], tests: [], label: '' }; +const builtIn = (p: string) => ALLOW.some(r => r.test(p)); /** * Tests that read docs by directory or every tracked file, so a literal-path reference scan cannot see them. The docs @@ -109,34 +107,89 @@ function generatedOutputs(root: string): string[] { } catch { return []; } } +/** + * The base revision's docs_only block (docs/pregate.md#repo-docs-paths), read with git from `base`, never the working + * tree or the head. No policy file or no block adds nothing; invalid JSON or an invalid block is `{ error }`, which fails + * the whole change closed; an unknown key keeps deny and drops allow. Throws DocsOnlyError when git cannot read `base`. + */ +export function readRepoDocs(root: string, base: string): RepoDocs | { error: string } { + const label = `${base}:${SHIP_POLICY_PATH}`; + if (!git(root, ['ls-tree', '--name-only', base, '--', SHIP_POLICY_PATH]).trim()) return NO_REPO_DOCS; + const text = git(root, ['show', `${base}:${SHIP_POLICY_PATH}`]); + let raw: unknown; + try { raw = JSON.parse(text); } catch (e) { return { error: `${label} is not valid JSON (${(e as Error).message}), so its docs_only block cannot be read` }; } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: `${label} is not a JSON object` }; + const block = (raw as Record).docs_only; + if (block === undefined || block === null) return NO_REPO_DOCS; + const errors = docsOnlyPolicyErrors(block); + if (errors.length) return { error: `${label} has an invalid docs_only block: ${errors.map(e => `${e.path}: ${e.message}`).join('; ')}` }; + const d = block as { allow: string[]; deny?: string[]; tests?: string[] }; + const unknown = Object.keys(d).filter(k => !DOCS_ONLY_KEYS.includes(k)); + if (unknown.length) return { allow: [], deny: d.deny ?? [], tests: [], label, ignored: unknown.map(k => `docs_only.${k}`).join(', ') }; + return { allow: d.allow, deny: d.deny ?? [], tests: d.tests ?? [], label }; +} + /** Why one path is not human docs, or null when its name and place qualify. */ -export function pathRefusal(p: string, root: string, generated: readonly string[] = generatedOutputs(root)): string | null { - if (!ALLOW.some(r => r.test(p))) return 'not on the docs allowlist (README.md, docs/**.md, changelog.d/*.md)'; - for (const [r, why] of DENY) if (r.test(p)) return why; +export function pathRefusal(p: string, root: string, generated: readonly string[] = generatedOutputs(root), repo: RepoDocs = NO_REPO_DOCS): string | null { + const repoOnly = !builtIn(p); + if (repoOnly && !(repo.allow.length && matchesAny(repo.allow, p))) { + const extra = repo.allow.length ? `, ${repo.allow.join(', ')} from ${repo.label}` : repo.ignored ? `; ${repo.label}'s allow is ignored while it has unknown key ${repo.ignored}` : ''; + return `not on the docs allowlist (README.md, docs/**.md, changelog.d/*.md${extra})`; + } + for (const [r, why] of DOCS_ONLY_DENY) if (r.test(p)) return why; + if (repo.deny.length && matchesAny(repo.deny, p)) return `matches docs_only.deny in ${repo.label}`; + const codeDir = repoOnly ? p.split('/').slice(0, -1).find(s => CODE_DIRS.has(s.toLowerCase())) : undefined; + if (codeDir) return `passes through ${codeDir}/, which holds code, tests or agent instructions`; if (classifyPath(p, root) !== 'docs') return 'agent-read prose (review-depth classifyPath)'; if (generated.length && matchesAny(generated, p)) return 'generated output'; return null; } -/** Tracked files outside human prose that name `p` (the caller excludes changed docs). */ -export function referencesTo(root: string, p: string, tree?: string): string[] { - const out = git(root, ['grep', '-l', '-z', '-F', '-I', '-e', p, ...(tree ? [tree] : []), '--'], [0, 1]); +/** Tracked files (at `tree`, else the working tree), limited to `within` when given, that contain any of `needles`. */ +function grepFiles(root: string, needles: readonly string[], tree?: string, within: readonly string[] = []): string[] { + const out = git(root, ['grep', '-l', '-z', '-F', '-I', ...needles.flatMap(n => ['-e', n]), ...(tree ? [tree] : []), '--', ...within], [0, 1]); const prefix = tree ? `${tree}:` : ''; + return out.split('\0').filter(Boolean).map(f => f.startsWith(prefix) ? f.slice(prefix.length) : f); +} + +/** Tracked files outside human prose that name `p` (the caller excludes changed docs). */ +export function referencesTo(root: string, p: string, tree?: string, repo: RepoDocs = NO_REPO_DOCS): string[] { const gen = generatedOutputs(root); - return out.split('\0').filter(Boolean).map(f => f.startsWith(prefix) ? f.slice(prefix.length) : f) - .filter(f => f !== p && !PROSE_REFERENCE.test(f) && pathRefusal(f, root, gen) !== null); + return grepFiles(root, [p], tree).filter(f => f !== p && !PROSE_REFERENCE.test(f) && pathRefusal(f, root, gen, repo) !== null); +} + +/** + * Non-docs tracked files that name a directory holding the repo-admitted path `p` ("wiki/", 'wiki', './wiki', root + '/wiki') and are tied to + * none of the repository's docs_only.tests: a script that lists a directory reads every post in it, which the literal + * scan in referencesTo cannot see. A reader is tied when it is a declared test, names one, or one names it (with or + * without its extension). Prose files are not readers here: one that names a path pins it through referencesTo, and one + * that names a directory describes it. Built-in paths return none; their directory readers are DOCS_READER_TESTS. + */ +export function directoryReaders(root: string, p: string, repo: RepoDocs, tree?: string): string[] { + if (builtIn(p) || !repo.allow.length) return []; + const segs = p.split('/').slice(0, -1); + if (!segs.length) return []; + const dirs = segs.map((_, i) => segs.slice(0, i + 1).join('/')); + const gen = generatedOutputs(root); + const readers = grepFiles(root, dirs.flatMap(d => [`${d}/`, ...[`'`, `"`, '`'].flatMap(q => [`${q}${d}${q}`, `./${d}${q}`, `${q}/${d}${q}`, `}/${d}${q}`])]), tree) + .filter(f => f !== SHIP_POLICY_PATH && !PROSE.test(f) && !repo.tests.includes(f) && pathRefusal(f, root, gen, repo) !== null); + if (!readers.length || !repo.tests.length) return readers; + return readers.filter(f => !grepFiles(root, repo.tests, tree, [f]).length && !grepFiles(root, [f, f.replace(/\.[^./]+$/, '')], tree, repo.tests).length); } /** Classify a parsed diff. `refs` lists the non-docs files naming a path. */ -export function classifyEntries(entries: readonly DiffEntry[], root: string, refs: (p: string) => string[]): DocsOnlyResult { +export function classifyEntries(entries: readonly DiffEntry[], root: string, refs: (p: string) => string[], repo: RepoDocs = NO_REPO_DOCS, readers: (p: string) => string[] = () => []): DocsOnlyResult { const refusals: Refusal[] = []; const files: string[] = []; const gen = generatedOutputs(root); const check = (p: string) => { - const why = pathRefusal(p, root, gen); + const why = pathRefusal(p, root, gen, repo); if (why) return why; + const list = (xs: string[]) => `${xs.slice(0, 3).join(', ')}${xs.length > 3 ? ', …' : ''}`; const by = refs(p); - return by.length ? `named by ${by.slice(0, 3).join(', ')}${by.length > 3 ? ', …' : ''}` : null; + if (by.length) return `named by ${list(by)}`; + const rd = readers(p); + return rd.length ? `its directory is read by ${list(rd)}; list a test that covers it in docs_only.tests at ${repo.label}` : null; }; for (const e of entries) { files.push(e.path); @@ -150,13 +203,25 @@ export function classifyEntries(entries: readonly DiffEntry[], root: string, ref } if (!entries.length) refusals.push({ path: '-', reason: 'no changed paths' }); const docsOnly = refusals.length === 0; - const tests = docsOnly ? [...new Set(DOCS_READER_TESTS.filter(t => files.some(f => t.tree.test(f))).flatMap(t => t.tests))] : []; + const repoTests = entries.some(e => !builtIn(e.path) || (e.from !== undefined && !builtIn(e.from))) ? repo.tests : []; + const tests = docsOnly ? [...new Set([...DOCS_READER_TESTS.filter(t => files.some(f => t.tree.test(f))).flatMap(t => t.tests), ...repoTests])] : []; return { docsOnly, files, refusals, tests }; } -/** The whole decision for base..head (commits) or base to the working tree. Throws DocsOnlyError on any git failure. */ -export function classifyDocsOnly(o: { root: string; base: string; head?: string }): DocsOnlyResult { +/** + * The whole decision for base..head (commits) or base to the working tree. The docs_only block is read at `policyRef`, + * the base tip every consumer lands on (default `base`; ci-carry and land diff from the merge base but read the tip). + * A docs_only.tests file the classified tree lacks refuses the change: the docs lane would otherwise pass without it. + * Throws DocsOnlyError on any git failure. + */ +export function classifyDocsOnly(o: { root: string; base: string; head?: string; policyRef?: string }): DocsOnlyResult { const entries = readDiff(o.root, o.base, o.head); + const policy = readRepoDocs(o.root, o.policyRef ?? o.base); + const repo = 'error' in policy ? NO_REPO_DOCS : policy; const changed = new Set(entries.flatMap(e => [e.path, ...(e.from ? [e.from] : [])])); - return classifyEntries(entries, o.root, p => referencesTo(o.root, p, o.head).filter(f => !changed.has(f) || pathRefusal(f, o.root) !== null)); + const outside = (f: string) => !changed.has(f) || pathRefusal(f, o.root, undefined, repo) !== null; + const r = classifyEntries(entries, o.root, p => referencesTo(o.root, p, o.head, repo).filter(outside), repo, p => directoryReaders(o.root, p, repo, o.head).filter(outside)); + if ('error' in policy) return { ...r, docsOnly: false, tests: [], refusals: [{ path: SHIP_POLICY_PATH, reason: policy.error }, ...r.refusals] }; + const missing = r.tests.filter(t => repo.tests.includes(t) && (o.head ? !git(o.root, ['ls-tree', '--name-only', o.head, '--', t]).trim() : !fs.existsSync(path.join(o.root, t)))); + return missing.length ? { ...r, docsOnly: false, tests: [], refusals: missing.map(t => ({ path: t, reason: `listed in docs_only.tests at ${repo.label} but missing from this tree; restore it or correct the list` })) } : r; } diff --git a/lib/land-docs-only.ts b/lib/land-docs-only.ts index e39e5253f..80439b03b 100644 --- a/lib/land-docs-only.ts +++ b/lib/land-docs-only.ts @@ -3,13 +3,13 @@ * lib/ship-policy.ts, which lib/docs-only.ts imports, so the classifier cannot be imported there. */ import { spawnSync } from 'node:child_process'; -import { classifyDocsOnly, readDiff, referencesTo } from './docs-only'; +import { classifyDocsOnly, directoryReaders, readDiff, readRepoDocs, referencesTo } from './docs-only'; /** * The merged checks for `pin`: land.merged_checks without the commands land.docs_only_skip names when lib/docs-only.ts * calls the pin's change since its merge base with `base` human docs only (R81; docs/pregate.md#docs-only), and no - * non-docs file at `base` names a changed path: land merges onto `base`, so a reader the base gained after the pin - * branched keeps the checks. Any git or classifier failure keeps every check. + * non-docs file at `base` names a changed path or reads its directory without doing so at the pin: land merges onto + * `base`, so a reader the base gained after the pin branched keeps the checks. Any git or classifier failure keeps every check. */ export function mergedChecksFor(root: string, land: { merged_checks?: string[]; docs_only_skip?: string[] } | null | undefined, base: string, pin: string): string[] { const checks = land?.merged_checks ?? []; @@ -17,9 +17,13 @@ export function mergedChecksFor(root: string, land: { merged_checks?: string[]; if (!skip.length) return checks; const mb = spawnSync('git', ['merge-base', base, pin], { cwd: root, encoding: 'utf8', timeout: 30_000 }); try { - if (mb.status !== 0 || !classifyDocsOnly({ root, base: mb.stdout.trim(), head: pin }).docsOnly) return checks; + if (mb.status !== 0 || !classifyDocsOnly({ root, base: mb.stdout.trim(), head: pin, policyRef: base }).docsOnly) return checks; const changed = readDiff(root, mb.stdout.trim(), pin).flatMap(e => [e.path, ...(e.from ? [e.from] : [])]); - if (changed.some(p => referencesTo(root, p, base).length)) return checks; + const repo = readRepoDocs(root, base); + if ('error' in repo) return checks; + const untied = { ...repo, tests: [] }; + const atPin = new Set(changed.flatMap(p => directoryReaders(root, p, untied, pin))); + if (changed.some(p => referencesTo(root, p, base, repo).length || directoryReaders(root, p, untied, base).some(f => !atPin.has(f)))) return checks; } catch { return checks; } return checks.filter(c => !skip.includes(c)); } diff --git a/lib/land-merged-checks.ts b/lib/land-merged-checks.ts index 078b56026..3d0421612 100644 --- a/lib/land-merged-checks.ts +++ b/lib/land-merged-checks.ts @@ -40,12 +40,15 @@ const KEPT_ENV = ['PATH', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR']; /** No hook or fsmonitor from the repository's config or the merged tree runs in the git calls around the checks. */ const NO_HOOKS = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false']; -export const LAND_EXPLANATION = '{ merged_checks } or null, read from the base revision only: under merge: direct, when the base moved past a pin\'s merge base, `land --pin` builds the pin merged onto the base tip in a throwaway worktree (the checkout\'s node_modules linked in) and runs each merged_checks command there (at most 16, 512 characters each, 15 minutes each); it lands only when every one exits 0, and refuses on a conflict (QUEUE_LAND_NOT_CLEAN) or a failure (QUEUE_LAND_MERGED_CHECKS). They are repo commands: land runs them only with --allow-repo-commands (else REPO_COMMANDS_NOT_ALLOWED), never under --dry-run, with PATH and locale as the only environment and a throwaway HOME. null lands a stale base unchecked with a STALE_BASE: warn line.'; +export const LAND_EXPLANATION = '{ merged_checks, docs_only_skip? } or null, read from the base revision only: under merge: direct, when the base moved past a pin\'s merge base, `land --pin` builds the pin merged onto the base tip in a throwaway worktree (the checkout\'s node_modules linked in) and runs each merged_checks command there (at most 16, 512 characters each, 15 minutes each); it lands only when every one exits 0, and refuses on a conflict (QUEUE_LAND_NOT_CLEAN) or a failure (QUEUE_LAND_MERGED_CHECKS). They are repo commands: land runs them only with --allow-repo-commands (else REPO_COMMANDS_NOT_ALLOWED), never under --dry-run, with PATH and locale as the only environment and a throwaway HOME. null lands a stale base unchecked with a STALE_BASE: warn line. docs_only_skip names merged_checks commands land skips for a pin lib/docs-only.ts calls human docs only (docs/pregate.md#docs-only) and needs min_gstack_version at its own introducing release. An unknown key under land is a warning, ignored.'; + +/** The keys this gstack reads under land; any other is a warning (lib/ship-policy.ts policyWarnings), never an error. */ +export const LAND_KEYS: readonly string[] = ['merged_checks', 'docs_only_skip']; export function landPolicyErrors(raw: unknown): PolicyError[] { const l = raw as Record | null; if (!l || typeof l !== 'object' || Array.isArray(l)) return [{ code: 'POLICY_INVALID', path: 'land', message: 'must be { merged_checks } or null' }]; - const out: PolicyError[] = Object.keys(l).filter(k => k !== 'merged_checks' && k !== 'docs_only_skip').map(k => ({ code: 'POLICY_INVALID', path: `land.${k}`, message: 'unknown key' })); + const out: PolicyError[] = []; const c = l.merged_checks; if (!Array.isArray(c) || !c.length || c.length > MAX_CHECKS) return [...out, { code: 'POLICY_INVALID', path: 'land.merged_checks', message: `must list 1 to ${MAX_CHECKS} commands` }]; const skip = l.docs_only_skip; diff --git a/lib/pregate/run-check.ts b/lib/pregate/run-check.ts index 75b9ac1dc..38466ec02 100644 --- a/lib/pregate/run-check.ts +++ b/lib/pregate/run-check.ts @@ -74,7 +74,7 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman unchanged: p => gateInputsChanged(root, base, p), docsOnly: head => { const mb = spawnSync('git', ['merge-base', `origin/${base}`, head], { cwd: root, encoding: 'utf8', timeout: 30_000 }); - try { return mb.status === 0 && classifyDocsOnly({ root, base: mb.stdout.trim(), head }).docsOnly; } catch { return false; } + try { return mb.status === 0 && classifyDocsOnly({ root, base: mb.stdout.trim(), head, policyRef: `origin/${base}` }).docsOnly; } catch { return false; } }, inventory: (command, tree, selected) => { if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' }; diff --git a/lib/ship-policy-docs-only.ts b/lib/ship-policy-docs-only.ts new file mode 100644 index 000000000..f5201e717 --- /dev/null +++ b/lib/ship-policy-docs-only.ts @@ -0,0 +1,82 @@ +/** + * docs_only — the repository's own human-docs paths in .gstack/ship-policy.json (GST-1974; docs/pregate.md#repo-docs-paths), + * and the built-in deny list lib/docs-only.ts applies to every path, built-in or repo-allowed. Its own module so the + * policy reader (lib/ship-policy.ts) and the classifier share one deny list without the reader importing the classifier. + * + * The block is `{ allow: [globs], deny?: [globs], tests?: [test files] }`. Allow entries widen the docs allowlist, so they are narrow by + * construction: each ends in `.md`, starts with a literal path segment, names no hidden, code or agent directory and no + * built-in deny entry. Deny entries only remove paths, so any contained glob is accepted. Tests are the repository's own + * checks for its docs directories: the docs lane runs them, and a non-docs file that reads an allowed directory must be + * tied to one of them (lib/docs-only.ts directory readers). Unknown keys are warnings to the + * reader; the classifier then ignores the allow list, so a gstack that does not understand a key never widens on it. + */ +import type { PolicyError } from './ship-policy'; +import { globError } from './ship-policy-bookkeeping'; + +export interface DocsOnlyPolicy { allow: string[]; deny?: string[]; tests?: string[] } + +export const DOCS_ONLY_KEYS: readonly string[] = ['allow', 'deny', 'tests']; +const MAX_ALLOW = 16, MAX_DENY = 64, MAX_TESTS = 16; +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/; + +/** Agent-loaded Markdown, tool configuration, pinned results, preregistrations and fragment instructions: never docs, whatever a repo allows. */ +export const DOCS_ONLY_DENY: readonly [RegExp, string][] = [ + [/(?:^|\/)(?:skill|agents|claude|gemini|ethos|crew|captain|factory|driver|checklist|unattended|pregate|lane-card|notes|digest|soul)\.md$/i, 'agent instructions'], + [/-policy\.md$/i, 'policy doc'], + [/(?:^|\/)preferences[^/]*$/i, 'preferences'], + [/(?:^|\/)(?:sections|templates|specialists|skills|prompts?)\//i, 'agent instructions'], + [/(?:^|\/|[-_.])prompts?\.md$/i, 'prompt file'], + [/\.tmpl$/i, 'template'], + [/(?:^|\/)\.[^/]+\//, 'tool configuration directory'], + [/(?:^|\/)(?:evals|inventory)\//, 'pinned results'], + [/prereg/i, 'preregistration'], + [/^changelog\.d\/README\.md$/i, 'fragment instructions'], +]; + +/** Directory names that hold code, tests or agent instructions; neither an allow pattern nor a path it admits may pass through one. */ +export const CODE_DIRS = new Set(['skills', 'templates', 'sections', 'specialists', 'prompts', 'prompt', 'test', 'tests', 'spec', '__tests__', 'fixtures', 'lib', 'bin', 'scripts', 'src', 'node_modules', 'vendor', 'dist', 'build']); +const NARROW = 'allow takes narrow Markdown globs under one literal directory, such as "wiki/board/**.md"'; + +function allowError(p: string): string | null { + const contained = globError(p); + if (contained) return contained; + if (!p.endsWith('.md') || p.endsWith('/.md')) return 'must end in .md: only Markdown prose can skip the test suites'; + const segs = p.split('/'); + if (/[?[\]{}]/.test(p)) return 'must use only * and ** as wildcards'; + if (segs[0]!.includes('*')) return 'must start with a literal directory or file name, never a wildcard'; + if (segs[0]!.startsWith('.')) return 'must not name a hidden directory (.github, .gstack and the like hold configuration)'; + const dir = segs.find(s => CODE_DIRS.has(s.toLowerCase())); + if (dir) return `must not pass through ${dir}/, which holds code, tests or agent instructions`; + const deny = DOCS_ONLY_DENY.find(([r]) => r.test(p)); + if (deny) return `names ${deny[1]}, which the built-in deny list always refuses`; + return null; +} + +/** Shape and pattern errors in a `docs_only` value (not null). Unknown keys are not errors (unknownKeyWarnings). */ +export function docsOnlyPolicyErrors(raw: unknown): PolicyError[] { + const d = raw as Record | null; + if (!d || typeof d !== 'object' || Array.isArray(d)) return [{ code: 'POLICY_INVALID', path: 'docs_only', message: 'must be { allow, deny?, tests? } or null' }]; + const out: PolicyError[] = []; + const list = (key: 'allow' | 'deny' | 'tests', min: number, max: number, check: (p: string) => string | null, hint: string) => { + const v = d[key]; + if (key !== 'allow' && v === undefined) return; + if (!Array.isArray(v) || v.length < min || v.length > max) { out.push({ code: 'POLICY_INVALID', path: `docs_only.${key}`, message: `must list ${min} to ${max} entries${hint}` }); return; } + v.forEach((p, i) => { + const why = typeof p === 'string' ? check(p) : 'must be a string'; + if (why) out.push({ code: 'POLICY_INVALID', path: `docs_only.${key}[${i}]`, message: `${JSON.stringify(p)} ${why}${hint}` }); + }); + }; + list('allow', 1, MAX_ALLOW, allowError, `; ${NARROW}`); + list('deny', 0, MAX_DENY, globError, ''); + list('tests', 0, MAX_TESTS, t => globError(t) ?? (/[*?[\]{}]/.test(t) || !TEST_FILE.test(t) || /^\.(?:gstack|github)\//.test(t) ? 'must be one test file path (*.test.ts and the like), not a glob or a path under .gstack/ or .github/' : null), ''); + return out; +} + +export interface PolicyWarning { path: string; message: string } + +/** Keys a newer gstack may add under `at`: ignored here with a warning instead of refusing the whole policy. */ +export function unknownKeyWarnings(at: string, raw: unknown, known: readonly string[]): PolicyWarning[] { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []; + return Object.keys(raw).filter(k => !known.includes(k)).map(k => ({ path: `${at}.${k}`, message: `unknown key, ignored (a newer gstack may read it; check the spelling)${at === 'docs_only' ? '; the docs-only classifier ignores this block\'s allow list while it is present' : ''}` })); +} + diff --git a/lib/ship-policy-keys.ts b/lib/ship-policy-keys.ts index 5ef4322e4..c10bd9282 100644 --- a/lib/ship-policy-keys.ts +++ b/lib/ship-policy-keys.ts @@ -9,8 +9,9 @@ import type { PolicyKey } from './ship-policy'; * The gstack release that introduced each key. Keys newer than * POLICY_COMPAT_FLOOR need `min_gstack_version` before they take a * non-default value, so a crew on mixed gstack versions sees one named - * refusal instead of a policy each version reads differently. Unknown keys - * always fail closed: there is no extension mechanism. + * refusal instead of a policy each version reads differently. Unknown top-level + * keys always fail closed; unknown keys under land and docs_only are warnings + * this gstack ignores (docs/ship-policy.md#schema-v1). */ export const POLICY_COMPAT_FLOOR = '1.91.98.0'; export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0'; @@ -50,6 +51,9 @@ export const MECHANICAL_RETAKE_KEY_VERSION = '1.97.0.0'; export const TEST_CODE_KEY_VERSION = '1.97.0.0'; export const CI_RECORD_TIMEOUT_KEY_VERSION = '1.97.0.0'; export const PLAN_RECORD_KEY_VERSION = '1.98.0.0'; +export const DOCS_ONLY_KEY_VERSION = '1.99.0.0'; +/** `land.docs_only_skip` is a field of the older land key, so compatErrors and minimumReader check it by value, as for bump: never. */ +export const LAND_DOCS_ONLY_SKIP_KEY_VERSION = '1.99.0.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', @@ -60,5 +64,5 @@ export const POLICY_KEY_INTRODUCED: Record = { base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, review: REVIEW_POLICY_KEY_VERSION, release_gate: RELEASE_GATE_KEY_VERSION, stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, merge: MERGE_KEY_VERSION, land: LAND_KEY_VERSION, readers: READERS_KEY_VERSION, bookkeeping: BOOKKEEPING_KEY_VERSION, mechanical_retake: MECHANICAL_RETAKE_KEY_VERSION, test_code: TEST_CODE_KEY_VERSION, ci_record_timeout_s: CI_RECORD_TIMEOUT_KEY_VERSION, - plan_record: PLAN_RECORD_KEY_VERSION, + plan_record: PLAN_RECORD_KEY_VERSION, docs_only: DOCS_ONLY_KEY_VERSION, }; diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index abc4f1844..aa9eec53d 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -17,12 +17,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { RELEASE_GATE_EXPLANATION, releaseGateErrors, type ReleaseGatePolicy } from './release-gate-policy'; -import { LAND_EXPLANATION, landPolicyErrors, type LandPolicy } from './land-merged-checks'; +import { LAND_EXPLANATION, LAND_KEYS, landPolicyErrors, type LandPolicy } from './land-merged-checks'; +import { DOCS_ONLY_KEYS, docsOnlyPolicyErrors, unknownKeyWarnings, type DocsOnlyPolicy, type PolicyWarning } from './ship-policy-docs-only'; import { RESULT_CODES, type ResultCodeName } from './result-codes'; import { reviewErrors, type ReviewPolicy } from './ship-policy-review'; import { READERS_EXPLANATION, readersErrors, type ReadersPolicy } from './ship-policy-readers'; import { cmpVersion, parseVersion } from './version-source'; -import { POLICY_COMPAT_FLOOR, BUMP_NEVER_KEY_VERSION, TRAIN_GATES_KEY_VERSION, POLICY_KEY_INTRODUCED } from './ship-policy-keys'; +import { POLICY_COMPAT_FLOOR, BUMP_NEVER_KEY_VERSION, TRAIN_GATES_KEY_VERSION, POLICY_KEY_INTRODUCED, LAND_DOCS_ONLY_SKIP_KEY_VERSION } from './ship-policy-keys'; import { placeholderErrors, templateErrors } from './ship-policy-stamps'; import { bookkeepingGlobError, globError } from './ship-policy-bookkeeping'; import { MECHANICAL_EXPLANATION, mechanicalErrors, type MechanicalConstant } from './ship-policy-mechanical'; @@ -102,6 +103,7 @@ export interface ShipPolicy { /** What `land --pin` runs on the pin merged onto a base that moved past its merge base (lib/land-merged-checks.ts); null lands a stale base untested with a STALE_BASE: warn line. */ land: LandPolicy | null; /** Seconds accept gives each `gstack-evidence record-ci` (docs/ship-policy.md#ci-record-timeout), clamped to 3600; read from the base only. */ ci_record_timeout_s: number; /** Whether ready and land check the PR body's Plan line (lib/plan-record.ts, docs/ship-policy.md#plan-record); read from the base only. */ plan_record: 'off' | 'warn' | 'require'; + /** Human-docs globs beyond README.md, docs/ and changelog.d/ for the docs-only class (lib/docs-only.ts, docs/pregate.md#repo-docs-paths); read from the base only. */ docs_only: DocsOnlyPolicy | null; } /** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */ @@ -194,7 +196,7 @@ export const DEFAULT_POLICY: ShipPolicy = { docs: null, base_red: null, merge: 'train', land: null, bookkeeping: [], mechanical_retake: [], test_code: [], release_gate: null, readers: { forbid_fetch: [], forbid_paths: [], brief_note: null }, ci_record_timeout_s: 600, - plan_record: 'off', + plan_record: 'off', docs_only: null, }; export type PolicyKey = Exclude; @@ -236,6 +238,7 @@ export const POLICY_EXPLANATIONS: Record = { test_code: 'Root-anchored globs of test code the built-in test names miss, never under .gstack/ or .github/, for example ["tests/**/*.gd"] for GDScript checks. /ship finish routes a matching file changed after review as test code (one scoped reader of its test hunks) instead of behavior, beside the built-in globs; shared helpers and fixtures stay behavior. Read from the base branch only. Empty (default) declares none.', ci_record_timeout_s: 'Seconds `gstack-ship-release accept` lets one `gstack-evidence record-ci` run before it stops that lane, an integer of at least 60 (default 600). A value over 3600 is clamped to 3600, and accept prints the bound it used (`CI_RECORD_TIMEOUT:`). Raise it when record-ci runs long, for example a per_file remainder of CI-skipped files. Read from the base branch only.', plan_record: 'off (default) | warn | require. Whether `gstack-ship-queue ready` and `land` check the PR body\'s `Plan:` line (approved: | exempt: ) against the approval store (docs/ship-policy.md#plan-record). warn prints the PLAN: line and continues; require refuses a missing, unresolved or agent-approved plan. This key is read from the base branch only; with it off, GSTACK_PLAN_RECORD, then plan_record in /config.yaml (gstack-config set plan_record warn, for a repo that commits no .gstack/), then warn in a Capy session decide. On a public repo render --plan writes Plan: approved (record r-) instead of the run id.', + docs_only: '{ allow, deny?, tests? } or null, read from the base revision only (docs/pregate.md#repo-docs-paths). allow (1 to 16) adds human-docs globs to the docs-only allowlist (README.md, docs/**.md, changelog.d/*.md): each ends in .md, starts with a literal directory or file name and passes through no hidden, code, test or agent directory ("wiki/board/**.md"). deny (up to 64 globs) removes paths, built-in ones included. tests (up to 16 test file paths) are the repository\'s checks for those directories: the docs lane runs them, and a non-docs file that names an allowed directory ("wiki/", \'wiki\') refuses the change unless it is one of them, names one, or one names it. The built-in deny list (agent instructions, templates, prompts, preferences, tool configuration, pinned results, preregistrations, generated outputs) always wins, a path a script or test names stays pinned, and a pull request that edits this file is never docs-only. An unknown key under docs_only is a warning, and the classifier then ignores allow. Needs min_gstack_version.', merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr --pin ` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).', min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).', }; @@ -264,10 +267,16 @@ export function runningGstackVersion(): string { /** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */ export function minimumReader(raw: Record): string { return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey)) - .map(k => (k === 'bump' && raw.bump === 'never' ? BUMP_NEVER_KEY_VERSION : k === 'train' && genericTrainKeys(raw.train) ? TRAIN_GATES_KEY_VERSION : POLICY_KEY_INTRODUCED[k])).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump); + .map(k => (k === 'bump' && raw.bump === 'never' ? BUMP_NEVER_KEY_VERSION : k === 'land' && landSkips(raw.land) ? LAND_DOCS_ONLY_SKIP_KEY_VERSION : k === 'train' && genericTrainKeys(raw.train) ? TRAIN_GATES_KEY_VERSION : POLICY_KEY_INTRODUCED[k])).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump); } export interface PolicyError { code: ResultCodeName; path: string; message: string } +const landSkips = (land: unknown): boolean => !!land && typeof land === 'object' && 'docs_only_skip' in land; + +/** Unknown keys under land and docs_only: ignored with a warning so a newer gstack can add one (docs/ship-policy.md#schema-v1). */ +export function policyWarnings(raw: Record): PolicyWarning[] { + return [...unknownKeyWarnings('land', raw.land, LAND_KEYS), ...unknownKeyWarnings('docs_only', raw.docs_only, DOCS_ONLY_KEYS)]; +} const MAX_LIST = 64, MAX_PATTERN = 256, MAX_REGEX = 512, MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; @@ -437,9 +446,10 @@ function trainErrors(value: unknown, obj: Record, out: PolicyEr } /** - * Schema and containment check of a parsed policy object. Unknown keys are - * errors (a typo must not silently mean "default", and a key from a newer - * gstack must not be ignored by an older one). + * Schema and containment check of a parsed policy object. Unknown top-level + * keys are errors (a typo must not silently mean "default", and a key from a + * newer gstack must not be ignored by an older one); unknown keys under land + * and docs_only are policyWarnings, ignored. */ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = {}): PolicyError[] { const errors: PolicyError[] = []; @@ -498,6 +508,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = if ('base_red' in obj && obj.base_red !== null) baseRedErrors(obj.base_red, errors); if ('release_gate' in obj && obj.release_gate !== null) errors.push(...releaseGateErrors(obj.release_gate)); if ('land' in obj && obj.land !== null) errors.push(...landPolicyErrors(obj.land)); + if ('docs_only' in obj && obj.docs_only !== null) errors.push(...docsOnlyPolicyErrors(obj.docs_only)); if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError); for (const key of ['changelog', 'mirror'] as const) { if (!(key in obj) || obj[key] === null) continue; @@ -546,6 +557,9 @@ function compatErrors(obj: Record, running: string, errors: Pol if (genericTrainKeys(obj.train) && !(typeof min === 'string' && !newer(TRAIN_GATES_KEY_VERSION, min))) { errors.push({ code: 'POLICY_INVALID', path: 'train', message: `gates and land were introduced in gstack ${TRAIN_GATES_KEY_VERSION}; set min_gstack_version to ${TRAIN_GATES_KEY_VERSION} or later so an older gstack refuses this policy by name` }); } + if (landSkips(obj.land) && !(typeof min === 'string' && !newer(LAND_DOCS_ONLY_SKIP_KEY_VERSION, min))) { + errors.push({ code: 'POLICY_INVALID', path: 'land.docs_only_skip', message: `introduced in gstack ${LAND_DOCS_ONLY_SKIP_KEY_VERSION}; set min_gstack_version to ${LAND_DOCS_ONLY_SKIP_KEY_VERSION} or later so an older gstack refuses this policy by name` }); + } if (obj.bump === 'never' && !(typeof min === 'string' && !newer(BUMP_NEVER_KEY_VERSION, min))) { errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never was introduced in gstack ${BUMP_NEVER_KEY_VERSION}; set min_gstack_version to ${BUMP_NEVER_KEY_VERSION} or later so an older gstack refuses this policy by name` }); } @@ -628,6 +642,8 @@ export interface LoadedPolicy { present: boolean; source: PolicySource; errors: PolicyError[]; + /** Unknown keys under land and docs_only (policyWarnings): ignored, never errors. */ + warnings: PolicyWarning[]; values: Record; } export type PolicyLoad = LoadedPolicy | { unavailable: true; base: string; detail: string }; @@ -650,13 +666,14 @@ export function resolveBaseBranch(repoRoot: string, explicit?: string): string { } export function finish(rawText: string | null, source: PolicySource): LoadedPolicy { - if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], values: mergePolicy(null, source.label).values }; + if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], warnings: [], values: mergePolicy(null, source.label).values }; let raw: unknown; try { raw = JSON.parse(rawText); } - catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], values: {} }; } + catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], warnings: [], values: {} }; } const errors = validatePolicy(raw); const merged = mergePolicy(errors.length ? null : (raw as Record), source.label); - return { policy: merged.policy, present: true, source, errors, values: merged.values }; + const warnings = raw && typeof raw === 'object' && !Array.isArray(raw) ? policyWarnings(raw as Record) : []; + return { policy: merged.policy, present: true, source, errors, warnings, values: merged.values }; } /** diff --git a/test/docs-only-policy.test.ts b/test/docs-only-policy.test.ts new file mode 100644 index 000000000..09f6b4e4c --- /dev/null +++ b/test/docs-only-policy.test.ts @@ -0,0 +1,360 @@ +/** + * Per-repo docs-only paths (GST-1974; docs/pregate.md#repo-docs-paths). A repository may name extra human-docs paths in + * the base branch's .gstack/ship-policy.json `docs_only` block. The bypass corpus comes first: a pull request must never + * allowlist itself, a pattern must never reach agent-loaded Markdown or code, and an unreadable policy fails closed. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { findCarry } from '../lib/ci-carry'; +import { classifyDocsOnly } from '../lib/docs-only'; +import { mergedChecksFor } from '../lib/land-docs-only'; +import { docsOnlyLane } from '../lib/pregate/docs-only-lane'; +import { buildRunCheck } from '../lib/pregate/run-check'; +import type { CheckResult, PregateContext } from '../lib/pregate/types'; +import type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence'; +import { finish, minimumReader, validatePolicy } from '../lib/ship-policy'; +import { docsOnlyPolicyErrors } from '../lib/ship-policy-docs-only'; +import { DOCS_ONLY_KEY_VERSION, LAND_DOCS_ONLY_SKIP_KEY_VERSION, LAND_KEY_VERSION } from '../lib/ship-policy-keys'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const ENV = { ...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test', GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test' }; +const POLICY = '.gstack/ship-policy.json'; +const TESTS = ['test/wiki.test.ts', 'test/x.test.ts']; +const WIKI = { docs_only: { allow: ['wiki/board/**.md'], tests: TESTS } }; +let repo = ''; + +function git(...args: string[]): string { + const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: ENV, timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} +function put(rel: string, text = `# ${rel}\n\nprose\n`) { + fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true }); + fs.writeFileSync(path.join(repo, rel), text); +} +/** A base commit on top of `plain` whose policy is `policy` (an object, or raw text), tagged `tag`. */ +function base(tag: string, policy: unknown) { + git('checkout', '-q', '--detach', 'plain'); + put(POLICY, typeof policy === 'string' ? policy : JSON.stringify(policy)); + git('add', '-A'); + git('commit', '-qm', tag); + git('tag', tag); +} +/** Commit `change` on top of `from` and classify from..HEAD (or from to the working tree); resets afterwards. */ +function classify(from: string, change: () => void, worktree = false) { + git('checkout', '-q', '--detach', from); + try { + change(); + if (worktree) return classifyDocsOnly({ root: repo, base: from }); + git('add', '-A'); + git('commit', '-q', '--allow-empty', '-m', 'change'); + return classifyDocsOnly({ root: repo, base: from, head: 'HEAD' }); + } finally { + git('reset', '-q', '--hard', from); + git('clean', '-qfdx'); + } +} +const reasons = (r: ReturnType) => r.refusals.map(x => `${x.path}: ${x.reason}`).join('; '); + +beforeAll(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-policy-')); + git('init', '-q', '-b', 'main'); + for (const f of ['README.md', 'docs/guide.md', 'docs/internal/notes-x.md', 'wiki/board/old.md', 'wiki/board/pinned.md', 'wiki/board/tested.md', 'wiki/other.md']) put(f); + put('lib/code.ts', 'export const x = 1;\n'); + put('scripts/post.ts', "export const post = 'wiki/board/pinned.md';\n"); + put('scripts/wiki.ts', "import { readdirSync } from 'node:fs';\nexport const posts = () => readdirSync('wiki');\n"); + put('test/wiki.test.ts', "import { test } from 'bun:test';\nimport '../scripts/wiki';\nimport '../scripts/post';\ntest('wiki', () => {});\n"); + put('test/x.test.ts', "import { test } from 'bun:test';\nexport const doc = 'wiki/board/tested.md';\ntest('x', () => {});\n"); + put(POLICY, JSON.stringify({ bump: 'patch' })); + git('add', '-A'); + git('commit', '-q', '-m', 'plain'); + git('tag', 'plain'); + base('allowed', WIKI); + base('broad', { docs_only: { allow: ['wiki/**.md'] } }); + base('denied', { docs_only: { allow: ['wiki/board/**.md'], deny: ['docs/internal/**', 'README.md'], tests: TESTS } }); + base('unlinked', { docs_only: { allow: ['wiki/board/**.md'] } }); + base('halflinked', { docs_only: { allow: ['wiki/board/**.md'], tests: ['test/x.test.ts'] } }); + base('invalid', { docs_only: { allow: ['**'] } }); + base('broken', '{ "docs_only": { "allow": ["wiki/board/**.md"] '); + base('future', { docs_only: { allow: ['wiki/board/**.md'], deny: ['README.md'], audience: 'humans' } }); + base('ghost', { docs_only: { allow: ['wiki/board/**.md'], tests: [...TESTS, 'test/missing.test.ts'] } }); +}); +afterAll(() => { if (repo) fs.rmSync(repo, { recursive: true, force: true }); }); + +describe('bypasses refuse', () => { + test('a head-side policy edit never allowlists its own change, in commit or working-tree mode', () => { + const edit = () => { put(POLICY, JSON.stringify(WIKI)); put('wiki/board/post.md'); }; + for (const worktree of [false, true]) { + const r = classify('plain', edit, worktree); + expect(r.docsOnly).toBe(false); + expect(reasons(r)).toContain(`${POLICY}: `); + expect(reasons(r)).toContain('wiki/board/post.md: not on the docs allowlist'); + } + }); + + test('an edit to the policy under an allowing base is still never docs-only', () => { + const r = classify('allowed', () => { put(POLICY, JSON.stringify({ docs_only: { allow: ['wiki/**.md'] } })); put('wiki/board/post.md'); }); + expect(r.docsOnly).toBe(false); + expect(r.refusals.map(x => x.path)).toEqual([POLICY]); + }); + + const parse = (p: unknown) => docsOnlyPolicyErrors({ allow: [p] }).map(e => `${e.path}: ${e.message}`).join('; '); + test.each([ + 'SKILL.md', 'wiki/SKILL.md', 'wiki/**/SKILL.md', 'AGENTS.md', 'wiki/AGENTS.md', 'CLAUDE.md', 'wiki/CLAUDE.md', 'CREW.md', 'wiki/board/CREW.md', + 'wiki/x.tmpl', 'wiki/x.md.tmpl', 'skills/**.md', 'skills/review/notes.md', 'templates/**.md', 'wiki/templates/**.md', 'wiki/**/templates/**.md', + 'wiki/skills/**.md', 'wiki/sections/a.md', 'wiki/prompts/**.md', 'wiki/review-prompt.md', 'wiki/preferences.md', 'wiki/factory.md', 'wiki/captain.md', + '.github/**.md', '.gstack/x.md', 'lib/**.md', 'scripts/notes.md', 'test/**.md', 'wiki/?kills/x.md', 'wiki/[s]kills/x.md', + ])('an allow pattern aimed at agent prose or code is refused at parse time: %s', p => { + expect(parse(p)).toMatch(/^docs_only\.allow\[0\]: /); + }); + + test.each(['**', '*', '**.md', '*.md', '**/*.md', '**/README.md', '*/board/*.md', 'wiki/**', 'wiki/*', 'wiki/*.mdx', 'wiki/*.ts', 'wiki/board/**.MD', 'wiki/board/**.md/', '../wiki/*.md', '/wiki/*.md', ''])( + 'a broad or non-Markdown allow pattern is refused at parse time with an example of a narrow one: %p', p => { + expect(parse(p)).toContain('docs_only.allow[0]: '); + if (p !== '') expect(parse(p)).toContain('wiki/board/**.md'); + }); + + test('the block shape is checked: allow is required, lists are capped, deny takes any contained glob, unknown keys are not errors', () => { + const paths = (raw: unknown) => docsOnlyPolicyErrors(raw).map(e => e.path); + expect(paths({ allow: ['wiki/board/**.md'] })).toEqual([]); + expect(paths({ allow: ['wiki/board/**.md'], deny: ['**', 'wiki/board/drafts/**'] })).toEqual([]); + expect(paths({ allow: ['wiki/board/**.md'], audience: 'humans' })).toEqual([]); + expect(paths({})).toEqual(['docs_only.allow']); + expect(paths([])).toEqual(['docs_only']); + expect(paths({ allow: 'wiki/board/**.md' })).toEqual(['docs_only.allow']); + expect(paths({ allow: Array.from({ length: 17 }, (_, i) => `wiki/b${i}/**.md`) })).toEqual(['docs_only.allow']); + expect(paths({ allow: ['wiki/board/**.md'], deny: [3, '../x'] })).toEqual(['docs_only.deny[0]', 'docs_only.deny[1]']); + expect(paths({ allow: ['wiki/board/**.md'], tests: ['tests/*.test.ts', 'scripts/wiki.ts', '.github/x.test.ts', '../x.test.ts', 'tests/wiki.test.ts'] })).toEqual(['docs_only.tests[0]', 'docs_only.tests[1]', 'docs_only.tests[2]', 'docs_only.tests[3]']); + }); + + test.each(['wiki/SKILL.md', 'wiki/AGENTS.md', 'wiki/CLAUDE.md', 'wiki/CREW.md', 'wiki/board/crew.md', 'wiki/templates/a.md', 'wiki/skills/x/a.md', 'wiki/sections/a.md', + 'wiki/prompts/a.md', 'wiki/review-prompt.md', 'wiki/preferences.md', 'wiki/board/factory.md', 'wiki/board/run-prereg.md', 'wiki/board/x-policy.md', + 'wiki/lib/x.md', 'wiki/scripts/x.md', 'wiki/node_modules/x.md', 'wiki/.github/x.md', 'wiki/board/evals/x.md'])( + 'the built-in deny wins over a legal repo pattern: %s', f => { + const r = classify('broad', () => put(f)); + expect(r.docsOnly).toBe(false); + expect(reasons(r)).not.toContain('not on the docs allowlist'); + }); + + test('a directory holding a SKILL.md stays agent prose under a repo pattern', () => { + const r = classify('broad', () => { put('wiki/SKILL.md'); put('wiki/board/post.md'); }); + expect(reasons(r)).toContain('wiki/board/post.md: agent-read prose'); + }); + + test('a repo-allowed path that a script or a test names is pinned', () => { + expect(reasons(classify('allowed', () => put('wiki/board/pinned.md', 'edited\n')))).toContain('named by scripts/post.ts'); + expect(reasons(classify('allowed', () => put('wiki/board/tested.md', 'edited\n')))).toContain('named by test/x.test.ts'); + }); + + test('a script that lists an allowed directory refuses the change unless a declared docs test is tied to it', () => { + const unlinked = reasons(classify('unlinked', () => put('wiki/board/post.md'))); + expect(unlinked).toContain('its directory is read by'); + expect(unlinked).toContain('scripts/wiki.ts'); + const half = reasons(classify('halflinked', () => put('wiki/board/post.md'))); + expect(half).toContain('scripts/wiki.ts'); + expect(half).not.toContain('test/x.test.ts'); + expect(classify('allowed', () => put('wiki/board/post.md')).docsOnly).toBe(true); + }); + + test('a script that builds the directory path relatively or by concatenation is a directory reader too', () => { + const forms = ["'./wiki'", "path.join(__dirname, '../wiki/board')", "root + '/wiki'", '`${root}/wiki`']; + for (const [i, form] of forms.entries()) { + git('checkout', '-q', '--detach', 'allowed'); + put('scripts/rel.ts', `export const dir = (root: string) => ${form};\n`); + git('add', '-A'); + git('commit', '-qm', `relative reader ${i}`); + git('tag', `rel-${i}`); + expect([form, reasons(classify(`rel-${i}`, () => put('wiki/board/post.md')))]).toEqual([form, expect.stringContaining('its directory is read by scripts/rel.ts')]); + } + }); + + test('a declared docs test missing from the tree refuses a repo-allowed change instead of passing without it', () => { + for (const worktree of [false, true]) { + const r = classify('ghost', () => put('wiki/board/post.md'), worktree); + expect(r.docsOnly).toBe(false); + expect(reasons(r)).toContain('test/missing.test.ts'); + } + expect(classify('ghost', () => put('docs/guide.md', 'edited\n')).docsOnly).toBe(true); + }); + + test('a rename from code into an allowed path refuses, and so does an executable or a symlink there', () => { + const r = classify('allowed', () => git('mv', 'lib/code.ts', 'wiki/board/code.md')); + expect(r.docsOnly).toBe(false); + expect(reasons(r)).toContain('wiki/board/code.md: not on the docs allowlist'); + expect(classify('allowed', () => { put('wiki/board/run.md'); fs.chmodSync(path.join(repo, 'wiki/board/run.md'), 0o755); }).docsOnly).toBe(false); + expect(classify('allowed', () => { fs.mkdirSync(path.join(repo, 'wiki/board'), { recursive: true }); fs.symlinkSync('../../lib/code.ts', path.join(repo, 'wiki/board/link.md')); }).docsOnly).toBe(false); + }); + + test('a base policy that fails to parse, or an invalid docs_only block, fails closed for every change', () => { + for (const tag of ['broken', 'invalid']) { + for (const f of ['wiki/board/post.md', 'README.md']) { + const r = classify(tag, () => put(f, 'edited\n')); + expect(r.docsOnly).toBe(false); + expect(reasons(r)).toContain(POLICY); + } + } + expect(reasons(classify('invalid', () => put('README.md', 'edited\n')))).toContain('docs_only.allow[0]'); + }); + + test('an unknown key in the base block drops the repo allow list but keeps its deny list', () => { + expect(classify('future', () => put('wiki/board/post.md')).docsOnly).toBe(false); + expect(reasons(classify('future', () => put('wiki/board/post.md')))).toContain('docs_only.audience'); + expect(classify('future', () => put('README.md', 'edited\n')).docsOnly).toBe(false); + expect(classify('future', () => put('docs/guide.md', 'edited\n')).docsOnly).toBe(true); + }); + + test('repo deny entries only restrict, built-in paths included', () => { + expect(reasons(classify('denied', () => put('docs/internal/notes-x.md', 'edited\n')))).toContain('docs_only.deny'); + expect(classify('denied', () => put('README.md', 'edited\n')).docsOnly).toBe(false); + expect(classify('denied', () => put('docs/guide.md', 'edited\n')).docsOnly).toBe(true); + expect(classify('denied', () => put('wiki/board/post.md')).docsOnly).toBe(true); + }); + + test('without a docs_only block the fixed allowlist is unchanged', () => { + expect(classify('plain', () => put('wiki/board/post.md')).docsOnly).toBe(false); + expect(classify('plain', () => put('docs/guide.md', 'edited\n')).docsOnly).toBe(true); + }); +}); + +describe('a wiki/board post under a base-side allow is docs-only end to end', () => { + test('the classifier, in commit and working-tree mode, for a new, edited and renamed post', () => { + for (const worktree of [false, true]) { + const r = classify('allowed', () => put('wiki/board/post.md'), worktree); + expect([r.docsOnly, reasons(r)]).toEqual([true, '']); + expect(r.tests).toEqual(expect.arrayContaining(TESTS)); + } + expect(classify('allowed', () => put('docs/guide.md', 'edited\n')).tests).not.toContain('test/wiki.test.ts'); + expect(classify('allowed', () => put('wiki/board/old.md', 'edited\n')).docsOnly).toBe(true); + expect(classify('allowed', () => { fs.mkdirSync(path.join(repo, 'wiki/board/2026')); git('mv', 'wiki/board/old.md', 'wiki/board/2026/old.md'); }).docsOnly).toBe(true); + expect(classify('allowed', () => { put('wiki/board/post.md', 'See wiki/board/old.md and docs/guide.md.\n'); put('docs/guide.md', 'edited\n'); }).docsOnly).toBe(true); + expect(classify('allowed', () => put('wiki/other.md', 'edited\n')).docsOnly).toBe(false); + const moved = classify('allowed', () => git('mv', 'wiki/board/old.md', 'docs/old.md')); + expect([moved.docsOnly, moved.tests]).toEqual([true, expect.arrayContaining(TESTS)]); + }); + + test('land keeps the skipped merged checks when the base tip gained a reader of the changed directory', () => { + const land = { merged_checks: ['typecheck', 'tests'], docs_only_skip: ['tests'] }; + const tip = (tag: string, change: () => void) => { git('checkout', '-q', '--detach', 'allowed'); change(); git('add', '-A'); git('commit', '-qm', tag); git('tag', tag); }; + tip('pin', () => put('wiki/board/post.md')); + const pin = git('rev-parse', 'pin').trim(); + tip('tip-reader', () => put('test/list.test.ts', "import { readdirSync } from 'node:fs';\nimport { test } from 'bun:test';\ntest('posts', () => { readdirSync('./wiki/board'); });\n")); + tip('tip-code', () => put('lib/other.ts', 'export const y = 2;\n')); + tip('tip-index', () => put('wiki/board/index.md', 'See wiki/board/post.md.\n')); + expect(mergedChecksFor(repo, land, 'tip-code', pin)).toEqual(['typecheck']); + expect(mergedChecksFor(repo, land, 'tip-index', pin)).toEqual(['typecheck']); + expect(mergedChecksFor(repo, land, 'tip-reader', pin)).toEqual(['typecheck', 'tests']); + }); + + test('the CI output: docs_only=true on a pull_request run, false on a push to main', () => { + git('checkout', '-q', '--detach', 'allowed'); + put('wiki/board/post.md'); + git('add', '-A'); + git('commit', '-qm', 'post'); + try { + for (const [event, code, value] of [['pull_request', 0, 'true'], ['push', 1, 'false']] as const) { + const out = path.join(repo, '..', `${path.basename(repo)}-${event}.out`); + const r = spawnSync(path.join(ROOT, 'bin/gstack-docs-only'), ['--repo', repo, '--base', 'HEAD^1', '--head', 'HEAD', '--github-output', out], { encoding: 'utf8', env: { ...ENV, GITHUB_EVENT_NAME: event }, timeout: 30_000 }); + const lines = fs.readFileSync(out, 'utf8').split('\n'); + expect([r.status, lines[0]]).toEqual([code, `docs_only=${value}`]); + if (value === 'true') expect(lines[1]).toContain('test/wiki.test.ts'); + fs.rmSync(out, { force: true }); + } + } finally { git('reset', '-q', '--hard', 'allowed'); } + }); + + test('pregate\'s lanes check runs the docs lane and owes no remote gate', () => { + git('checkout', '-q', '--detach', 'allowed'); + git('update-ref', 'refs/remotes/origin/main', 'allowed'); + const ctx = { repoRoot: repo, base: 'main', explain: [] as string[], allowRepoCommands: true } as unknown as PregateContext; + const lanes = { id: 'lanes', stage: 'tests', status: 'pass', inputs: {}, lines: [], remote: [{ lane: 'x' }] } as unknown as CheckResult; + try { + put('wiki/board/post.md', 'See [the old post](old.md).\n'); + const ok = docsOnlyLane(ctx, lanes)!; + expect([ok.status, ok.remote]).toEqual(['pass', []]); + expect(ok.detail).toContain('docs-only: 1 file(s)'); + expect(ok.detail).toContain('2 docs reader test file(s) passed'); + git('update-ref', 'refs/remotes/origin/main', 'plain'); + expect(docsOnlyLane(ctx, lanes)).toBeNull(); + } finally { + git('reset', '-q', '--hard', 'allowed'); + git('clean', '-qfdx'); + git('update-ref', '-d', 'refs/remotes/origin/main'); + } + }); + + test('gstack-ci-carry counts the skipped-suite run as carried through pregate\'s own docs-only callback', () => { + git('checkout', '-q', '--detach', 'allowed'); + put('wiki/board/post.md'); + git('add', '-A'); + git('commit', '-qm', 'post'); + const head = git('rev-parse', 'HEAD').trim(); + const tree = git('rev-parse', 'HEAD^{tree}').trim(); + git('remote', 'add', 'origin', 'https://github.com/fixture/docs-policy.git'); + try { + const suites = ['free-plan', 'typecheck']; + const profile: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request'], jobs: [...suites, 'free-tests'], artifact: 'tested-tree', os: 'Linux', skippable: { jobs: suites, gate: 'docs-scope', scripts: ['lib/docs-only.ts'] } }; + const run = { id: 9, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'pull_request', head_sha: head, path: '.github/workflows/free-tests.yml', created_at: '2026-11-08T11:00:00Z', updated_at: '2026-11-08T11:10:00Z', repository: { full_name: 'fixture/docs-policy' }, head_repository: { full_name: 'fixture/docs-policy' } } as ApiRun; + const job = (name: string, conclusion: string) => ({ name, conclusion, completed_at: '2026-11-08T11:10:00Z' }) as ApiJob; + const deps: RunDeps = { run: () => run, runsForHead: (_wf, sha) => (sha === head ? [run] : []), jobs: () => [job('docs-scope', 'success'), ...suites.map(n => job(n, 'skipped')), job('free-tests', 'success')], artifact: () => { throw new Error('no artifact'); }, commitTree: sha => (sha === head ? tree : null) }; + const carry = (tip: string) => { + git('update-ref', 'refs/remotes/origin/main', tip); + const rc = buildRunCheck(repo, null, false, 'main'); + if ('error' in rc) throw new Error(rc.error); + return findCarry({ repo: 'fixture/docs-policy', deps, profiles: { 'ci-reuse/free-tests': profile }, now: new Date('2026-11-08T12:00:00Z'), unchanged: () => null, docsOnly: rc.docsOnly }, { profiles: ['ci-reuse/free-tests'], head, tree, wtree: () => tree }); + }; + const v = carry('allowed'); + expect(v.ok).toBe(true); + if (v.ok) expect(v.receipt).toContain('not_owed=docs-only'); + expect(carry('plain').ok).toBe(false); + git('checkout', '-q', '--detach', 'allowed'); + put(POLICY, JSON.stringify({ bump: 'patch' })); + git('commit', '-qam', 'revoke the allow on the base tip'); + const revoked = git('rev-parse', 'HEAD').trim(); + git('checkout', '-q', '--detach', head); + expect(carry(revoked).ok).toBe(false); + } finally { + git('remote', 'remove', 'origin'); + git('reset', '-q', '--hard', 'allowed'); + } + }); +}); + +describe('the policy reader', () => { + const at = { gstackVersion: DOCS_ONLY_KEY_VERSION }; + const codes = (raw: Record) => validatePolicy(raw, at).map(e => `${e.code}:${e.path}`); + + test('docs_only needs min_gstack_version at its introducing release and validates its patterns', () => { + expect(codes({ ...WIKI, min_gstack_version: DOCS_ONLY_KEY_VERSION })).toEqual([]); + expect(codes({ docs_only: null })).toEqual([]); + expect(codes(WIKI)).toEqual(['POLICY_INVALID:docs_only']); + expect(codes({ docs_only: { allow: ['**'] }, min_gstack_version: DOCS_ONLY_KEY_VERSION })).toEqual(['POLICY_INVALID:docs_only.allow[0]']); + expect(minimumReader(WIKI)).toBe(DOCS_ONLY_KEY_VERSION); + }); + + test('land.docs_only_skip names its own floor, by value, as bump: never does', () => { + const land = { merged_checks: ['typecheck', 'tests'], docs_only_skip: ['tests'] }; + expect(codes({ land, min_gstack_version: LAND_DOCS_ONLY_SKIP_KEY_VERSION })).toEqual([]); + expect(codes({ land, min_gstack_version: LAND_KEY_VERSION })).toEqual(['POLICY_INVALID:land.docs_only_skip']); + expect(codes({ land: { merged_checks: ['typecheck'] }, min_gstack_version: LAND_KEY_VERSION })).toEqual([]); + expect(minimumReader({ land })).toBe(LAND_DOCS_ONLY_SKIP_KEY_VERSION); + }); + + test('unknown keys under land and docs_only are warnings, not errors; elsewhere they stay errors', () => { + const raw = { land: { merged_checks: ['typecheck'], retries: 2 }, docs_only: { allow: ['wiki/board/**.md'], audience: 'humans' }, min_gstack_version: DOCS_ONLY_KEY_VERSION }; + expect(codes(raw)).toEqual([]); + const loaded = finish(JSON.stringify(raw), { kind: 'base', ref: 'origin/main', sha: 'a'.repeat(40), label: 'fixture' }); + expect(loaded.errors.map(e => e.path)).not.toContain('land.retries'); + expect(loaded.warnings.map(w => w.path)).toEqual(['land.retries', 'docs_only.audience']); + expect(loaded.warnings[0]!.message).toContain('ignored'); + expect(codes({ pr_count: { default_pr_count: 1, split_requires_owner: true, extra: 1 } })).toEqual(['POLICY_INVALID:pr_count.extra']); + }); + + test('gstack\'s own policy turns on neither key', () => { + const own = JSON.parse(fs.readFileSync(path.join(ROOT, POLICY), 'utf8')); + expect(own.docs_only).toBeUndefined(); + expect(own.land?.docs_only_skip).toBeUndefined(); + }); +}); diff --git a/test/ship-queue-land-direct.test.ts b/test/ship-queue-land-direct.test.ts index ab217c1ca..f91ff4ab4 100644 --- a/test/ship-queue-land-direct.test.ts +++ b/test/ship-queue-land-direct.test.ts @@ -774,7 +774,7 @@ describe('merge: direct | train', () => { expect(errs({ land: { merged_checks: ['bun run typecheck'] } })).toContain('introduced in gstack ' + LAND_KEY_VERSION); const bad = (land: unknown) => errs({ land, min_gstack_version: LAND_KEY_VERSION }); expect(bad({ merged_checks: [] })).toContain('land.merged_checks: must list 1 to 16 commands'); - expect(bad({ merged_checks: ['a'], timeout: 5 })).toContain('land.timeout: unknown key'); + expect(bad({ merged_checks: ['a'], timeout: 5 })).toBe(''); expect(bad({ merged_checks: ['a\nb', ' '] })).toBe('land.merged_checks[0]: must be one line of at most 512 characters\nland.merged_checks[1]: must be a non-empty command'); expect(bad(['a'])).toContain('land: must be { merged_checks } or null'); });