Let a repository name its own docs-only paths in its ship policy (GST-1974) (#4185)

* Add failing tests for repo-declared docs-only paths (GST-1974)

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

* Let a repository name its own docs-only paths in its base ship policy (GST-1974)

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

* Catch relative directory readers, missing docs tests and readers the base gained in the docs-only class

Review-Finding: review/specialist-batched#1 reader=security
Review-Finding: review/red-team#1 reader=red-team
Review-Finding: review/red-team#2 reader=red-team
Review-Finding: voices/adversarial-native#1 reader=adversarial-native
Review-Finding: voices/adversarial-native#3 reader=adversarial-native
Review-Finding: voices/adversarial-native#5 reader=adversarial-native
Review-Finding: voices/outside-voice#1 reader=outside-voice
Review-Finding: voices/outside-voice#2 reader=outside-voice
Review-Finding: voices/outside-voice#3 reader=outside-voice
Review-Finding: voices/outside-voice#4 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog that a missing docs test or a new base reader keeps the full checks

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

* Describe the new directory-reader forms, the missing docs test refusal and land's base-reader check

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-09 04:14:37 +00:00
1 parent cc6cc83573
commit 10fc9b1eab
14 files changed
+660 -66

No files matched your search

+1 -1
View File
@@ -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": {
+3 -2
View File
@@ -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<typeof floorAgainstStable> = 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);
@@ -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)
+57 -6
View File
@@ -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.
<a id="repo-docs-paths"></a>
### 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/<base>`; 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.
<a id="flaky-exception"></a>
## Owner-granted flaky exception
+10 -4
View File
@@ -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 <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> 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 <n> --pin <sha>`](#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 <n>` 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 | `[]` | <a id="bookkeeping"></a>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`. |
+96 -31
View File
@@ -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<string, unknown>).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;
}
+9 -5
View File
@@ -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));
}
+5 -2
View File
@@ -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<string, unknown> | 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;
+1 -1
View File
@@ -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' };
+82
View File
@@ -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<string, unknown> | 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' : ''}` }));
}
+7 -3
View File
@@ -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<PolicyKey, string> = {
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<PolicyKey, string> = {
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,
};
+27 -10
View File
@@ -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<keyof ShipPolicy, '$schema'>;
@@ -236,6 +238,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
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: <run id or row id> | exempt: <reason>) 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 <state root>/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-<id>) 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 <n> --pin <sha>` (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, unknown>): 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<string, unknown>): 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<string, unknown>, 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<string, unknown>, 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<string, string>;
}
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<string, unknown>), 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<string, unknown>) : [];
return { policy: merged.policy, present: true, source, errors, warnings, values: merged.values };
}
/**
+360
View File
@@ -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<typeof classifyDocsOnly>) => 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<string, unknown>) => 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();
});
});
+1 -1
View File
@@ -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');
});