diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9af272b..e5f5b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,30 @@ jobs: - name: Build run: make build + docs: + name: docs are not stale + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # docs/BEHAVIOR.md is generated from the test doc comments, so a change + # to a test that is not regenerated is documentation drift. Regenerate + # and fail if the committed file differs from what the tests produce. + - name: Regenerate docs + run: make docs + + - name: Check docs/BEHAVIOR.md is up to date + run: | + git diff --exit-code docs/BEHAVIOR.md \ + || (echo "docs/BEHAVIOR.md is stale: run 'make docs' and commit the result" && exit 1) + lint: name: lint runs-on: ubuntu-latest diff --git a/README.md b/README.md index 32b2322..951882f 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,24 @@ # codeowners-tool -A single CLI for making **safe, intent-level, verifiable** changes to large -GitHub CODEOWNERS files, plus an auditor that finds rot (dead owners, dead -rules, inert owners). GitHub only (github.com and GHES); GitLab/Bitbucket are -explicit non-goals. +Make safe, provable changes to GitHub CODEOWNERS files — in one repo, or across a +hundred. -**The problem it solves:** CODEOWNERS edits are expressed in *lines*, but what -anyone cares about is *resolved ownership per file path*. The two are related -by non-obvious semantics (last match wins; owner sets don't union; appending -`/x/ @team-2` **replaces** the owners of `/x/`, it doesn't add to them). This -tool makes you express intent over resolved ownership and refuses to apply -anything it can't prove. +**The problem.** CODEOWNERS is written in *lines*, but what anyone cares about is *who +owns which file*. The two are connected by rules that surprise people: the **last** +matching line wins, and owner sets don't combine — appending `/x/ @team-2` **replaces** +the owners of `/x/`, it doesn't add to them. -``` -intent (ops) ──▶ PLAN ──▶ ASSERT ──▶ APPLY ──▶ VALIDATE - │ │ - │ └── gate; refuses on violation - └── resolves ownership before/after over the real git tree -``` +So you say what you want — "`@team-2` should co-own `/services/api/`" — and this tool +works out the lines. Then it checks its own work against every file in your repo, and +**refuses to write anything it can't prove correct.** -## Getting started +It also reads: `codeowners-tool audit` finds owners who've left the company, rules that +match no files, and owners who don't actually have permission to approve. Auditing +never writes anything. -### Install +Works with github.com and GitHub Enterprise Server. + +## Install **Homebrew** (macOS/Linux): @@ -29,26 +26,25 @@ intent (ops) ──▶ PLAN ──▶ ASSERT ──▶ APPLY ──▶ VALIDATE brew install jordonpeterson/tap/codeowners-tool ``` -**Install script** (macOS/Linux) — downloads the right prebuilt binary, -verifies its checksum, and installs it: +**Install script** (macOS/Linux) — downloads the right prebuilt binary, verifies its +checksum, and installs it: ```sh curl -fsSL https://raw.githubusercontent.com/jordonpeterson/codeowners-tool/main/install.sh | sh ``` -Set `VERSION=vX.Y.Z` to pin a release or `BINDIR=~/.local/bin` to change the -install location. +Set `VERSION=vX.Y.Z` to pin a release or `BINDIR=~/.local/bin` to change the install +location. **Direct download**: grab the archive for your platform from the [latest release](https://github.com/jordonpeterson/codeowners-tool/releases/latest), -verify it against `checksums.txt`, extract, and put `codeowners-tool` on your -`PATH`. Every release ships Linux, macOS, and Windows builds for both amd64 and -arm64. +verify it against `checksums.txt`, extract, and put `codeowners-tool` on your `PATH`. +Every release ships Linux, macOS, and Windows builds for both amd64 and arm64. -> **macOS note:** the binaries are not notarized, so a build downloaded through -> a browser is quarantined by Gatekeeper. Clear it with -> `xattr -d com.apple.quarantine ./codeowners-tool`. Homebrew and the install -> script above are unaffected — neither quarantines its downloads. +> **macOS note:** the binaries are not notarized, so a build downloaded through a +> browser is quarantined by Gatekeeper. Clear it with +> `xattr -d com.apple.quarantine ./codeowners-tool`. Homebrew and the install script +> above are unaffected — neither quarantines its downloads. **From source** with Go 1.24+: @@ -56,46 +52,332 @@ arm64. go install github.com/jordonpeterson/codeowners-tool/cmd/codeowners-tool@latest ``` -### Make your first change +## Change one repo + +Say your `.github/CODEOWNERS` looks like this: + +``` +* @org/everyone +/services/api/ @org/api-team +``` + +You want `@org/team-1` to co-own the API directory. Run this inside the repo: + +```console +$ codeowners-tool sync --op 'add_owner(/services/api/, @org/team-1)' +1 line changed, 12 paths change owners, 64 → 78 bytes +``` + +and the file is now: + +``` +* @org/everyone +/services/api/ @org/api-team @org/team-1 +``` + +That's the whole thing. Note what *didn't* happen: `@org/api-team` is still there. +Adding that line by hand as `/services/api/ @org/team-1` would have silently replaced +them — which is the mistake this tool exists to make impossible. + +Run it again and nothing happens; it's already true. Add `--dry-run` to see the change +without making it. If the repo has no CODEOWNERS at all, add `--create` and one is +written at `.github/CODEOWNERS`. + +## Roll a policy out across your org -Run inside a git repository that contains a CODEOWNERS file (searched in -`.github/`, then the repo root, then `docs/`): +Put the ops in a file once: + +```json +{ + "version": 1, + "ops": [ + "add_owner(/services/api/, @org/api-team)", + "add_owner(/.github/workflows/, @org/ci)" + ] +} +``` + +Each of those lines is an **op** — one intent, same syntax as `--op` above. + +The tool works on one repo at a time and doesn't clone anything, so you write the loop: + +```bash +while read -r repo; do + gh repo clone "$repo" "work/$repo" -- --depth 1 -q + codeowners-tool sync --repo "work/$repo" --policy policy.json --create +done < repos.txt # one "org/name" per line +``` + +That's the whole idea, and it is genuinely all you need for a first pass. Don't use it +for a real 100-repo rollout, though: it stops dead the first time a clone fails or a +repo needs a human. [Fleet scripting](#fleet-scripting) below is the version that +survives both, records what happened, and can be resumed. + +Your 100 repos aren't identical, though, so an op can say what to do when nothing in +that repo matches it. Write it as a plain string until it needs to say something extra, +then swap in an object — both forms can sit in the same list: + +```json +{ + "version": 1, + "ops": [ + "add_owner(/services/api/, @org/api-team)", + { "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip" }, + { "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare" } + ] +} +``` + +| `on_zero_match` | What happens when nothing in the repo matches | +|---|---| +| `require` *(default)* | Treat it as a problem. This repo gets no changes and exits 2; your script records it and carries on to the next one. Use it for paths every repo really does have. | +| `skip` | Move on. "*If* this repo has Terraform, `@org/infra` owns it." | +| `declare` | Write the rule anyway, at the end of the file, ready for files added later. | + +`declare` is how you get an identical baseline into every repo without editing it again +each time someone adds a file — at the cost of a weaker guarantee, explained +[below](#what-declare-costs). + +Check a policy before you run it anywhere: ```sh -# Add @org/team-1 as a co-owner of everything under /services/api/, keeping any -# existing owners. This only writes a reviewable plan — nothing is changed yet. -codeowners-tool plan --op 'add_owner(/services/api/, @org/team-1)' --out plan.json +codeowners-tool check --policy policy.json +``` -# plan.json shows the resolved ownership rows and the exact line diff. -# Apply only what the planner proved safe: -codeowners-tool apply --plan plan.json +`check` reads no repo and writes nothing. It catches the problems that would fail +identically on all 100 repos, so you find them once instead of a hundred times. + +## When it can't do what you asked + +It refuses, tells you which rule was in the way, and **writes nothing**: + +```console +$ codeowners-tool sync --op 'add_owner(/services/api/, @org/team-1)' +error: refusing: rule "*" also governs paths outside scope "/services/api/", and no +sound narrowing pattern is derivable — amending would violate INV-2, appending would +violate INV-1 +$ echo $? +2 +``` + +`INV-1` is "the files you named end up owned the way you asked"; `INV-2` is "every +other file in the repo ends up exactly as it was." In English, then: a later `*` rule +already covers everything, so any line added for `/services/api/` would just get +overridden (breaking INV-1) — and reordering the file to fix that would move files you +never mentioned (breaking INV-2). There is no line the tool can write that does what +you asked and nothing else, so it writes none. That's a normal, expected outcome for +some repos; the tool *fails closed* and would rather stop than guess. + +Across a fleet that means your script records the handful that stopped and carries on. +`sync` returns exactly three codes, never anything else: + +| Exit | Meaning | In a fleet script | +|---|---|---| +| 0 | Done — changed it, or it was already correct | continue | +| 2 | **This repo** needs a human | record it, continue | +| 3 | **The policy** is broken — it'll fail the same way everywhere | stop the run | + +That split is the whole contract: exit 3 is only ever for problems that have nothing to +do with which repo you're standing in. `check` catches exactly that class, which is why +running it first is worth the two seconds. + +--- + +# Reference + +> Throughout this section, IDs like `R-6`, `S-4`, `INV-2` and `A-9` are numbered +> requirements from the specification. Each is enforced by a named test — see +> [docs/BEHAVIOR.md](docs/BEHAVIOR.md), which is generated from the test suite. You +> never need them to use the tool; they're there so every claim below is traceable to +> something that's actually checked. + +## Fleet scripting + +```bash +#!/usr/bin/env bash +set -euo pipefail + +codeowners-tool check --policy policy.json # fail on repo 0, not 100 times +mkdir -p work bodies +touch done.txt + +while read -r repo; do # repos.txt: one "org/name" per line + grep -qxF "$repo" done.txt && continue # resume: skip what already finished + + # Clone failures are infrastructure, not policy — record and keep going, or one + # rate-limited clone at repo 40 ends the run. + rm -rf "work/$repo" # so a re-run doesn't clone onto itself + if ! gh repo clone "$repo" "work/$repo" -- --depth 1 -q 2>>clone-errors; then + echo "$repo" >> clone-failed + continue + fi + + code=0 + codeowners-tool sync --repo "work/$repo" --policy policy.json --create \ + --format json --summary-out "bodies/${repo//\//__}.md" >> results.jsonl || code=$? + case $code in + 0) ;; # converged + 2) echo "$repo" >> needs-human ;; # this repo, not the policy + *) exit "$code" ;; # policy broken — stop + esac + echo "$repo" >> done.txt +done < repos.txt + +jq -s 'group_by(.status)|map({status:.[0].status, n:length})' results.jsonl +wc -l done.txt needs-human clone-failed 2>/dev/null || true ``` -`plan` re-resolves ownership across your real git tree and refuses anything it -can't prove; `apply` writes only the proven edit and is idempotent, so -re-running the same op is a no-op. To audit a repo for rot or to verify in CI -that a change stayed inside its declared scope, read on. +`check` exits `0` for a valid policy and `3` for a broken one — and never `1`. That +matters under `set -e`: a valid policy lets the script proceed, a broken one stops it +before the first clone, and there's no third case where a fine policy halts you for a +non-error reason. Re-run it whenever you edit the policy; it's the only step that +catches a mistake before it reaches a repo. -## Quick start +Two piles are left at the end. `clone-failed` is infrastructure — re-run the loop +against just that list. `needs-human` is the interesting one: for each repo, run +`sync --dry-run` locally to see the refusal, then either restructure that repo's +CODEOWNERS so the intent becomes expressible (usually: replace the over-broad rule the +error names with narrower ones), or accept that this repo is a legitimate exception and +drop it from `repos.txt`. + +The tool does not clone, commit, branch, or open PRs — that stays your script's job. +`--format json` prints one line per repo so `jq` can aggregate the fleet; +`--summary-out` writes a markdown summary for a PR body (keep it outside the clone, or +`git add -A` will commit it). Add `--dry-run` for a preview of the whole fleet: it +changes no CODEOWNERS file, but still emits the JSON and the summaries so there's +something to review. + +One `jq` habit worth having: project `.ops_skipped` too. A policy with one typo'd path +prefix skips on every repo, and grouping on `.status` alone shows a reassuring wall of +`skipped` rows that you might read as success. + +## `sync` and `check` + +``` +sync (--op 'OP' ... | --policy FILE) [--on-empty error|inherit|unowned] + [--repo DIR] [--branch REF] [--file PATH] + [--create] [--dry-run] + [--format text|json] [--out FILE] [--summary-out FILE] + +check (--op 'OP' ... | --policy FILE) [--format text|json] +``` + +`check` reads no repository and writes nothing. It exits `0` for a valid policy, `3` +for a broken one, and never `1` — so under `set -e` a good policy always lets the +script continue and a bad one always stops it. Syntax errors stop at the first one; +everything else (bad enum values, ops that can't carry the `on_zero_match` you gave +them, a `remove_owner` with no `on_empty`) is reported all at once, because fixing a +generated 40-op policy one error per run is miserable. + +Note that `--on-empty`, `on_zero_match`, and `--policy` all use the word "policy" for +different things: `--policy` is your ops file, while the other two are per-situation +rules the tool follows. The file is always "the policy file". + +| Flag | Meaning | +|---|---| +| `--op` / `--policy` | Where the ops come from. Mutually exclusive; passing both or neither is exit 3. | +| `--repo` | Local git repository. Default `.`. | +| `--branch` | Ref whose tracked tree governs resolution (S-7). Default `HEAD`. | +| `--file` | CODEOWNERS path override, repo-relative. | +| `--on-empty` | Policy when `remove_owner` empties an owner set. Allowed only with `--op`; with `--policy`, set `on_empty` in the file instead. | +| `--create` | Write CODEOWNERS if the repo has none. Off by default; never overwrites an existing file. | +| `--dry-run` | Makes no change to CODEOWNERS. `--out` and `--summary-out` still emit. | +| `--format` | `text` (default) or `json`. Under `json`, stdout is data and stderr is logs. | +| `--out` | Write the JSON record here instead of stdout. | +| `--summary-out` | Markdown rendering, for a PR body. | + +### Policy file fields + +| Field | Where | Required | Meaning | +|---|---|---|---| +| `version` | top | yes | Format version. `1`. | +| `ops` | top | yes | Op strings, or objects. A bare string is shorthand for `{"op": "..."}` with everything else defaulted. | +| `name`, `description` | top | no | Surfaced in `--summary-out`, so PR reviewers know why. | +| `on_empty` | top | if any `remove_owner` | `error` \| `inherit` \| `unowned` | +| `op` | per op | yes | Op string, same syntax as `--op`. | +| `id` | per op | no | Short label used in JSON results and error messages. | +| `on_zero_match` | per op | no | `require` (default) \| `skip` \| `declare` | +| `note` | per op | no | Reaches the PR reviewer via `--summary-out`. | + +Unknown fields are a hard error — a typo'd `on_zero_mtach` that silently fell back to +the default would apply the wrong policy to every repo at once. JSON has no comments, so +keys beginning with `_` (and the key `//`) are always ignored and can hold one. + +`on_zero_match` is rejected on `rename_owner` (its scope comes from current ownership, +not a pattern) and `declare` is rejected on `remove_owner` (there is no rule to write). + +### JSON output + +Real output, abridged only in `changes`: + +```json +{ + "repo": "work/org/foo", + "status": "applied", + "ops": [ + {"op": "add_owner(/services/api/, @org/api-team)", "status": "applied", "proven": "tree"}, + {"id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "status": "skipped", + "reason": "scope \"**/*.tf\" matches zero tracked files and on_zero_match=skip (R-21)"}, + {"id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "status": "applied", + "proven": "structural"} + ], + "ops_applied": 2, "ops_skipped": 1, "paths_changed": 37, + "created": false, "changes": [ ] +} +``` + +`status` is `applied`, `unchanged`, `skipped`, `refused`, or `error`. `proven` is +`tree` when the result was checked against real files, `structural` when it wasn't — +see [below](#what-declare-costs). + +Two things to know before you write `jq` against this. `id` appears only on ops your +policy named, so key on it only where you set it. And **`ops_applied` + `ops_skipped` +doesn't have to equal your op count** — an op that was already satisfied is +`unchanged` and counted by neither. If you want "did this policy actually do anything +anywhere", read `.status`; if you want "is this op reaching any repo at all", count it +out of `.ops[]`: ```sh -go build -o codeowners-tool ./cmd/codeowners-tool +jq -s '[.[] | (.ops // [])[]] | group_by(.op) | map({op: .[0].op, n: length, + applied: (map(select(.status=="applied")) | length)})' results.jsonl +``` -# Add a co-owner to everything under /services/api — existing owners retained: -./codeowners-tool plan --op 'add_owner(/services/api/, @org/team-1)' --out plan.json -# Review plan.json (ownership rows + literal line diff), then: -./codeowners-tool apply --plan plan.json +Note the `// []`. Keys with nothing in them are **omitted entirely** rather than +emitted empty — that applies to `ops`, `warnings` and `changes`. A refused repo has no +`.ops` at all, so the same query without the guard dies with `Cannot iterate over +null` on the first repo that needed a human, which is the one you most wanted to see. -# Audit for rot: -GITHUB_TOKEN=... ./codeowners-tool audit --github-repo org/repo --format json +## `plan` and `apply` + +``` +intent (ops) ──▶ PLAN ──▶ ASSERT ──▶ APPLY ──▶ VALIDATE + │ │ + │ └── gate; refuses on violation + └── resolves ownership before/after over the real git tree +``` + +`sync` runs that whole pipeline in one step. If you want the reviewable artifact in the +middle — a JSON plan showing resolved ownership per path plus the literal line diff — +run the two halves separately: + +```sh +codeowners-tool plan --op 'add_owner(/services/api/, @org/team-1)' --out plan.json +codeowners-tool apply --plan plan.json +``` + +Other commands: + +```sh +# Audit a repo for rot: +GITHUB_TOKEN=... codeowners-tool audit --github-repo org/repo --format json # Prove in CI that a change touched nothing outside its declared scope: -./codeowners-tool snapshot --branch main --out before.json -./codeowners-tool snapshot --branch feature --out after.json -./codeowners-tool verify --before before.json --after after.json --scope /services/api/ +codeowners-tool snapshot --branch main --out before.json +codeowners-tool snapshot --branch feature --out after.json +codeowners-tool verify --before before.json --after after.json --scope /services/api/ ``` -## Operations (Engine A — mutation) +## Operations (mutation) Scope is a directory, file path, or glob — same syntax as CODEOWNERS patterns. @@ -103,41 +385,56 @@ Scope is a directory, file path, or glob — same syntax as CODEOWNERS patterns. |---|---| | `add_owner(scope, owner)` | Owner becomes a **co-owner**; every pre-existing owner of every path in scope is retained. | | `set_owners(scope, [owners])` | Exact owner set for every path in scope, displacing prior owners. `[]` is legal: it deliberately un-owns the scope. | -| `remove_owner(scope, owner)` | Owner stops owning every path in scope. If a rule's owner set would empty, `--on-empty` is **required** (see below). | +| `remove_owner(scope, owner)` | Owner stops owning every path in scope. If a rule's owner set would empty, an `--on-empty` policy is **required** (see below). | | `rename_owner(old, new)` | Global identifier substitution — the only op safe as pure text replacement (it can't change any rule's match set). | ### The two invariants -- **INV-1 (in scope):** after apply, every in-scope path resolves to exactly - what the op requires. -- **INV-2 (out of scope):** after apply, every out-of-scope path resolves to - exactly what it did before. **This is the product.** +- **INV-1 (in scope):** after apply, every in-scope path resolves to exactly what the + op requires. +- **INV-2 (out of scope):** after apply, every out-of-scope path resolves to exactly + what it did before. **This is the product.** + +The planner synthesizes line edits, then *proves* the result by re-resolving every file +git knows about at `--branch` and comparing against an independently computed desired +state. Anything unprovable → refusal, nothing written. Plans are idempotent (re-running +is a no-op) and preserve every untouched byte — comments, blank lines, spacing, +ordering. + +### What `declare` costs + +`declare` is the one place a guarantee weakens: + +- **INV-2 is unaffected.** A pattern that matches nothing in the repo cannot move any + existing file's ownership. Proven exactly as usual. +- **INV-1 weakens.** Your repo has no files matching the pattern, so there is nothing + to check the rule against — the tool cannot prove the rule does what you meant. It + proves the next best thing: that no rule after it can override it, which it + guarantees by putting the rule at the end of the file. When someone later adds a + matching file, this rule takes it. If that wasn't what you wanted, nothing will have + caught it. -The planner synthesizes line edits, then *proves* the result by re-resolving -the entire tracked tree (at `--branch`, default HEAD) and comparing against an -independently computed desired state. Anything unprovable → exit 2, nothing -written. Plans are idempotent (re-running is a no-op) and preserve every -untouched byte — comments, blank lines, spacing, ordering. +Ops that took this path report `"proven": "structural"` in the JSON and are called out +in `--summary-out`, so a reviewer can find them without reading the diff. -### `--on-empty` (R-6) +### `--on-empty` / `on_empty` (R-6) -Removing the sole owner of a rule needs an explicit policy — **there is no -default**, and the documented recommendation is `error`: +Removing the sole owner of a rule needs an explicit policy — **there is no default**, +and the documented recommendation is `error`: - `error` — refuse (recommended: consistent with the tool's fail-closed posture) -- `inherit` — delete the rule; the preceding broader rule takes over - (removal **cascades** if the fallthrough rule also lists the owner) -- `unowned` — keep the pattern with zero owners (GitHub's sanctioned - substitute for `!` negation) +- `inherit` — delete the rule; the preceding broader rule takes over (removal + **cascades** if the fallthrough rule also lists the owner) +- `unowned` — keep the pattern with zero owners (GitHub's sanctioned substitute for `!` + negation) -Under `inherit`/`unowned` the resulting reassignment is shown in the plan's -ownership rows. +Under `inherit`/`unowned` the resulting reassignment is shown in the plan's ownership +rows. -## Audit (Engine B) +## Audit — find rot without writing anything -Read-only. **Never writes** — where a fix is expressible it emits Engine A op -strings for a human to review and run through `plan`/`apply`, the system's -single writer path. +Read-only. **Never writes** — where a fix is expressible it emits op strings for a +human to review and run through `plan`/`apply`, the system's single writer path. | ID | Check | API | Auto-fix | |---|---|---|---| @@ -154,17 +451,36 @@ single writer path. | A-11 | CODEOWNERS file itself unowned | no | report only | | A-12 | File size approaching 3 MB | no | n/a | -**Fail closed (R-12):** a 404 can mean deleted, renamed, invisible to the -token, or rate-limited. The client probes org/repo visibility first; anything -inconclusive is reported `unknown`, exits 5, and **never proposes a removal**. -An expired token quietly stripping owners is the worst failure this tool can -produce, so it can't. Email owners are `unverifiable`, never dead (R-13). -Removing a sole owner is presented as a **reassignment** with before → after -owners per path, never a bare line deletion (R-14). Lookups are cached in -memory per run and optionally on disk (`--cache-dir`, `--cache-ttl`). +**Fail closed (R-12):** a 404 can mean deleted, renamed, invisible to the token, or +rate-limited. The client probes org/repo visibility first; anything inconclusive is +reported `unknown`, exits 5, and **never proposes a removal**. An expired token quietly +stripping owners is the worst failure this tool can produce, so it can't. Email owners +are `unverifiable`, never dead (R-13). Removing a sole owner is presented as a +**reassignment** with before → after owners per path, never a bare line deletion +(R-14). Lookups are cached in memory per run and optionally on disk (`--cache-dir`, +`--cache-ttl`). ## Exit codes +`sync` uses the coarse three-code contract described +[above](#when-it-cant-do-what-you-asked) — its question is "did this repo converge?" +and it returns exactly `0`, `2`, or `3`, never anything else. Every other command uses +the precise taxonomy below. + +**The two tables do not use the same numbers for the same things**, so don't read +across. `sync` maps the precise codes onto its own by asking a single question — *is +this about the policy, or about this repo?* + +| Precise code | Under `sync` | Why | +|---|---|---| +| 1 no-op | **0** | "Already correct" is the common fleet outcome; special-casing it defeats the point | +| 2 refused | **2** | This repo's file has an awkward shape | +| 3 zero-match scope | **2** | Whether a path exists is the most repo-specific fact there is | +| 3 malformed op, bad policy | **3** | Will fail identically on all 100 | +| 6 rolled back | **2** | A rolled-back write is about that one repo, not your policy | + +`sync` makes no network calls, so it never returns 4 or 5. + | Code | Meaning | |---|---| | 0 | Success — applied, or audit found nothing | @@ -175,6 +491,10 @@ memory per run and optionally on disk (`--cache-dir`, `--cache-ttl`). | 5 | Inconclusive — API unavailable, token insufficient, rate limited | | 6 | Validation failed post-write; rolled back | +`sync` deliberately collapses 0 and 1: across a fleet, "already correct" is the common +outcome, and making every caller special-case it defeats the point. It also folds 6 +into 2 — a rolled-back write is a problem with that one repo, not with your policy. + ## GitHub semantics this tool encodes | # | Property | @@ -192,33 +512,38 @@ memory per run and optionally on disk (`--cache-dir`, `--cache-ttl`). ## Design decisions (resolved from the spec's open list) 1. **`--on-empty` recommendation:** `error`. -2. **Performance:** naive full-tree resolve (twice per plan). Fine to ~100k - files; a pattern-level pre-filter is a straightforward later optimization. -3. **GHES:** in, via `--api-url`; endpoint gaps degrade to exit 5, not wrong answers. -4. **Auth:** PAT only for v1 (`--token` / `$GITHUB_TOKEN`). GitHub App is the - right v2 answer for org-wide automation. -5. **Branch handling:** local repo, any ref via `--branch` (git `ls-tree`); - plan/apply read and write the working-tree file, resolve against the ref's tree. +2. **Performance:** naive full-tree resolve (twice per plan). Fine to ~100k files; a + pattern-level pre-filter is a straightforward later optimization. +3. **GitHub Enterprise Server:** in, via `--api-url`; endpoint gaps degrade to exit 5, + not wrong answers. +4. **Auth:** PAT only for v1 (`--token` / `$GITHUB_TOKEN`). GitHub App is the right v2 + answer for org-wide automation. +5. **Branch handling:** local repo, any ref via `--branch` (git `ls-tree`); plan/apply + read and write the working-tree file, resolve against the ref's tree. +6. **Fleet automation:** the loop stays in your script. Cloning, auth, hosts, + parallelism and retries are solved by `gh`/`ghorg`; the tool stays single-repo and + composes with them. ## Tests as documentation -The test suite is the specification. Every test carries a doc comment naming -the spec requirement it enforces; `make docs` regenerates -[docs/BEHAVIOR.md](docs/BEHAVIOR.md) from them via `go/ast`, so the docs -cannot drift from what is verified. Verification layers: +The test suite is the specification. Every test carries a doc comment naming the spec +requirement it enforces; `make docs` regenerates [docs/BEHAVIOR.md](docs/BEHAVIOR.md) +from them via `go/ast`, so the docs cannot drift from what is verified. Verification +layers: - **Vendored corpus** (`testdata/patterns.json`) from - [hmarr/codeowners](https://github.com/hmarr/codeowners) — the actively - maintained reference implementation whose matcher encodes GitHub's observed - divergences from gitignore. -- **Differential fuzz** (`make diff-test`): 500k random pattern/path cases - against hmarr's *unmodified* matcher, vendored verbatim as an oracle. -- **Property tests**: thousands of generated (file, tree, op) cases proving - INV-1/INV-2 and idempotence by independent re-resolution — these caught two - real bugs during development (removal cascade under `inherit`; self-rename - churn). -- **Acceptance tests T-1…T-11** from the spec, end-to-end CLI tests in real - git repos, and mocked-API fail-closed tests. + [hmarr/codeowners](https://github.com/hmarr/codeowners) — the actively maintained + reference implementation whose matcher encodes GitHub's observed divergences from + gitignore. +- **Differential fuzz** (`make diff-test`): 500k random pattern/path cases against + hmarr's *unmodified* matcher, vendored verbatim as an oracle. +- **Property tests**: thousands of generated (file, tree, op) cases proving INV-1/INV-2 + and idempotence by independent re-resolution — these caught two real bugs during + development (removal cascade under `inherit`; self-rename churn). +- **Acceptance tests** from the spec, end-to-end CLI tests in real git repos, and + mocked-API fail-closed tests. + +Build from a checkout with `make build`; `make all` runs vet, tests, build, and docs. ## Prior art this design learned from @@ -226,20 +551,21 @@ cannot drift from what is verified. Verification layers: [mszostok/codeowners-validator](https://github.com/mszostok/codeowners-validator) (check taxonomy; also a cautionary tale — it mixes three glob engines), [snyk/github-codeowners](https://github.com/snyk/github-codeowners) and -[beaugunderson/codeowners](https://github.com/beaugunderson/codeowners) -(both built on the npm `ignore` gitignore engine, whose `!`/`[ ]` support -diverges from GitHub — exactly the trap S-2 warns about), -[toptal/codeowners-checker](https://github.com/toptal/codeowners-checker) -(archived; the only prior mutation tool — it reorders lines, which R-1 forbids). +[beaugunderson/codeowners](https://github.com/beaugunderson/codeowners) (both built on +the npm `ignore` gitignore engine, whose `!`/`[ ]` support diverges from GitHub — +exactly the trap S-2 warns about), +[toptal/codeowners-checker](https://github.com/toptal/codeowners-checker) (archived; +the only prior mutation tool — it reorders lines, which R-1 forbids). ## Non-goals -GitLab/Bitbucket semantics · reordering or reformatting existing files · -auto-deleting rules that match zero files · inventing owners · resolving -conflicting batches by precedence · opening PRs or any git write. +GitLab/Bitbucket semantics · reordering or reformatting existing files · auto-deleting +rules that match zero files · inventing owners · resolving conflicting batches by +precedence · opening PRs or any git write · iterating over repos (that's your script's +job). ## License MIT. The pattern matcher is ported from, and differentially tested against, -[hmarr/codeowners](https://github.com/hmarr/codeowners) (MIT, © Harry Marr) — -see NOTICE. +[hmarr/codeowners](https://github.com/hmarr/codeowners) (MIT, © Harry Marr) — see +NOTICE. diff --git a/docs/BEHAVIOR.md b/docs/BEHAVIOR.md index 80515d8..0ed037b 100644 --- a/docs/BEHAVIOR.md +++ b/docs/BEHAVIOR.md @@ -6,11 +6,17 @@ Every statement below is enforced by a named test. Spec references (S-* semantics, R-* rules, INV-* invariants, T-* acceptance tests, A-* audit checks) map to the specification this tool was built against. +Each package section opens with the file-level prose of its test files — +package doc comments and free-floating commentary — quoted per file in +filename order, then one entry per test in spec-tag order. + ## internal/apply +**`apply_test.go`** + > Package apply_test defines the writer: the ONLY code path in the system > that modifies bytes on disk (R-0). -> +> > SPEC R-10: after apply, the file is validated; a write that produces new > syntax errors is rolled back (exit 6). > The plan pins the input's SHA-256; a drifted file is never written over. @@ -47,8 +53,10 @@ didn't create them and refuses only what it caused. ## internal/audit +**`audit_test.go`** + > Package audit_test defines Engine B: read-only rot detection. -> +> > SPEC R-0: audit NEVER writes. Where a fix is expressible, it emits Engine A > operations for a human to review and run through `plan`/`apply`. > SPEC R-11: A-4 (dead rule) is report-only, permanently. @@ -56,6 +64,10 @@ didn't create them and refuses only what it caused. > SPEC R-13: email owners are unverifiable, never dead. > SPEC R-14: removing a sole owner is a reassignment, shown as one. +> ---------- offline checks ---------- + +> ---------- API-dependent checks ---------- + ### `TestA3_NoWriteAccess` SPEC A-3 (S-5): an owner that exists, is in the org, but lacks EXPLICIT @@ -146,9 +158,123 @@ a bare line deletion. ## internal/cli +**`cli_test.go`** + > Package cli_test exercises the end-to-end contract: real git repo, real > files, real exit codes (R-17). +**`fleet_idempotence_test.go`** + +> R-19 — convergence and idempotence at fleet scale. +> +> The failure this file exists to prevent: a scheduled job pushes one policy +> at 100 repositories every night, and every night each run appends the same +> line again. Nothing errors, every exit code is 0, and resolved ownership is +> correct on every pass — so a test that compares OWNERSHIP passes forever +> while the file grows without bound. It ends at S-4's 3 MB cliff, where +> GitHub silently stops loading CODEOWNERS at all: total ownership failure +> across the whole fleet, produced by a job that reported success every night +> on the way there. +> +> This is not hypothetical. The planner's no-op detector is +> `bytes.Equal(afterBytes, content)` (internal/plan/plan.go), gated on a +> `desired` map keyed by TRACKED path. A `declare` op writes a rule for files +> that do not exist yet, so it moves no tracked path's resolution and the +> detector structurally cannot see its line. Ownership-level idempotence is +> therefore the wrong assertion. Every claim below compares BYTES. + +> Helpers. All prefixed `idem` — this package is written by several agents at +> once and an unprefixed helper is a compile error for everyone. +> initRepo and runCLI come from cli_test.go and are reused, never redefined. + +> The headline claims. + +> Per-op-kind idempotence. Each kind synthesizes edits differently, so each +> gets its own two-run byte comparison. + +> on_zero_match: one test per value. `declare` is the critical one. + +**`fleet_test.go`** + +> The synthetic fleet. +> +> Every test in this file runs ONE policy across a set of deliberately +> heterogeneous repositories, exactly as the README's fleet script does. The +> repos are the shapes a real 100-repo rollout meets on day one: repos that +> converge, repos that are already correct, repos missing the directory an op +> names, repos with no CODEOWNERS at all, repos whose file shape makes the +> intent inexpressible. The fleet is the unit under test — no single repo's +> outcome may change any other repo's outcome. + +**`schema_test.go`** + +> Schema pins for the sync record (R-24). +> +> A SyncRecord is not a log line, it is the fleet's data plane. `sync +> --format json` appends one object per repo to results.jsonl and the README's +> fleet script aggregates the run with +> `jq -s 'group_by(.status)|map({status:.[0].status, n:length})'`, plus the +> documented habit of projecting `.ops_skipped`. Those key names are the whole +> interface between this tool and a user's shell; nothing in Go's type system +> touches them. +> +> Until these tests existed the shape was pinned by NOTHING. Every other test +> in this package json.Unmarshals INTO cli.SyncRecord, so the struct tags sit +> on both sides of the assertion: renaming `ops_applied` to `opsApplied` keeps +> the entire suite green while silently emptying the field for every jq +> consumer on the planet. A schema that only ever round-trips through itself +> is not pinned at all. +> +> These are characterization tests. They record the CURRENT shape as +> intentional so the next change to it is a deliberate, reviewed one, and they +> mirror internal/plan/schema_test.go, which does the same job for the plan +> document. Like those, they assert the key SET, never the key ORDER — +> encoding/json emits fields in declaration order, but that ordering is not +> part of the contract and pinning it would fail on a harmless reshuffle. + +### `TestCheck_BrokenPolicyExitsThree` + +SPEC R-22: `check` exits 3 on every member of the exit-3 class, and 3 only. +Its definition is exactly that class: the failures that will repeat +identically on all 100 repos. + +### `TestCheck_NeverReturnsOtherExitCodes` + +SPEC R-22: check never returns anything but 0 and 3 — in particular not 1, +2, 4, 5 or 6. The fleet script's `*)` catch-all exits on anything else. + +### `TestCheck_OpStringIsSyntaxChecked` + +SPEC R-22: `check --op` syntax-checks an op string too, so the escalation +path has no gap — the user who has not yet written a policy file can still +validate before touching 100 repos. + +### `TestCheck_ReadsNoRepository` + +SPEC R-22: `check` reads no repository. As a verb it carries no repo flags +at all, so the shape enforces the contract — it must succeed from a +directory that is not a git repo, which is where the fleet script runs it +before the first clone exists. + +### `TestCheck_SyntaxFailsFastSemanticsAccumulate` + +SPEC R-22: syntax errors fail fast at the FIRST problem; semantic errors +accumulate and all print. Fixing a generated 40-op policy one error per run +is miserable — but a file that is not JSON has no second error to report, +only guesses. + +### `TestCheck_ValidPolicyExitsZeroNeverOne` + +SPEC R-22: `check` exits 0 on a valid policy — never 1. It is the first +line of every fleet script under `set -e`; a clean policy returning the +no-op code would abort the run before the loop starts. + +### `TestCheck_WritesNothing` + +SPEC R-22: `check` writes nothing. It sits one token away from --dry-run, +where the failure mode is exit 0 across all 100 repos having read and +written nothing — silent success, the worst outcome this design produces. + ### `TestEndToEnd_PlanApplyVerify` The full loop: plan → apply → snapshot/verify. The verify step checks @@ -183,11 +309,636 @@ SPEC R-17: exit 1 no-op, exit 3 invalid input. Review finding: a typo'd --scope must be a loud exit-3 error, not silently dropped (which turned every change into an inexplicable violation). +### `TestFleet_BrokenPolicyHaltsOnTheFirstRepo` + +SPEC R-20/R-22: a broken policy is exit 3 on the FIRST repo, before a single +byte is read from it or written to it. That is what makes the README's "fail +on repo 0, not 100 times" true: the same policy would fail identically +everywhere, so the run halts instead of producing 100 identical errors — and +`check` catches the same class with no repo at all. + +### `TestFleet_DeclareLandsAtEOFUnderCatchAll` + +SPEC INV-6 (R-21 declare): a repo whose CODEOWNERS opens with a `*` +catch-all is the case that makes `declare` non-trivial. Reusing the +unowned-path insert point would put the declared rule BEFORE the catch-all, +where last-match-wins hands every future workflow file back to `@org/eng` — +100 PRs that do nothing. The rule must be appended at EOF, and it must +actually govern its scope when a matching file finally appears. + +### `TestFleet_DryRunChangesNothingButStillEmits` + +SPEC R-19/R-24: --dry-run is the fleet preview — the only granularity at +which reviewing a 100-repo change is possible. It must change no CODEOWNERS +anywhere while still emitting the complete results.jsonl and every +--summary-out file, because those artifacts ARE the review. + +### `TestFleet_MissingCodeownersNeedsCreate` + +SPEC R-23: a repo with no CODEOWNERS is exit 2 without --create — a per-repo +fact, recorded and skipped past, never a halt. With --create the file is +written at .github/CODEOWNERS, which is the path the README promises. + +### `TestFleet_NothingMatchesAnywhereIsSkippedNotUnchanged` + +SPEC R-24: `skipped` is a status of its own. A policy with a typo'd path +prefix matches nothing in any repo; if those runs reported `unchanged`, the +jq recipe in the README would show a wall of "already correct" and the +operator would read a no-op rollout as a success. + +### `TestFleet_OnePolicyAcrossHeterogeneousRepos` + +SPEC R-19/R-20/R-24: one policy, one pass, seven deliberately different +repositories. This is the whole product claim in a single test — that a +standardized policy can be pushed at a heterogeneous fleet, that each repo +gets the outcome its own shape earns, and that the aggregate is readable +afterwards. If any single repo's failure can change another repo's outcome, +the fleet script in the README is a lie. + +### `TestFleet_ReadmeScriptContract` + +SPEC R-19/R-22/R-24: the facts the README's copy-paste fleet script depends +on, pinned one by one. Every assertion here corresponds to a line of that +script; if this test fails, someone who pasted the script gets a halted +rollout, a silent no-op, or an empty PR body. + +### `TestFleet_RecordRepoIsTheArgumentVerbatim` + +SPEC R-24: the record's `.repo` is the `--repo` argument BYTE-FOR-BYTE — +never absolutized, never symlink-resolved. + +Six tests in this file join records back to repos through fleetByRepo, which +keys on exactly that field; so does the README's fleet script, which pairs +`needs-human` entries with the paths it passed in. Deriving `.repo` from the +repository instead of from the argument breaks every one of them, and it +breaks them on developer laptops only: on macOS `t.TempDir()` hands out +`/var/folders/...` while `git rev-parse --show-toplevel` reports the same +directory as `/private/var/folders/...`, because `/var` is a symlink to +`/private/var`. The two strings name one directory and compare unequal, so +every lookup misses, every record looks orphaned, and CI on Linux — where no +such symlink exists — stays green the whole time. + +### `TestFleet_RefusalIsRecoverableAndIsolated` + +SPEC R-19: a refusal is per-repo and RECOVERABLE. The repo that cannot +express the intent exits 2 with byte-identical bytes, and the repos after it +in the loop converge exactly as if it had never been there. + +### `TestFleet_ZeroMatchUnderRequireIsTwoNotThree` + +SPEC R-21/R-19: an op whose scope matches zero tracked files under the +DEFAULT on_zero_match=require is exit 2, not 3. Whether a path exists is the +most repo-specific fact there is; revision 1 mapped it to 3 and a policy +naming /services/api/ killed the fleet on the first repo that lacked it. + +### `TestHardening_DryRunRecordSaysSoAndSummaryAgrees` + +Review finding: a --dry-run record was byte-identical to a record from a real +rollout, and the markdown summary contradicted itself. + +Nothing in results.jsonl said "this was a preview", so an operator (or a +script) holding a file of records could not tell a modelled fleet from a +changed one — and the two are collected by the same `>> results.jsonl` line. +The PR body was worse than ambiguous: it claimed "a new CODEOWNERS file was +written" and then, three lines down, "nothing was written". + +### `TestHardening_FileArgumentIsContainedByTheRepo` + +Review finding (F2, security): `--file` was not contained by `--repo`, so +`sync --create` wrote a CODEOWNERS OUTSIDE the repository. + +`--file ../ESCAPED/CODEOWNERS --create` exited 0 having created the +directories and the file next to the clone; an absolute `--file /tmp/x/ABS` +was not rejected but reinterpreted as repo-relative, building a garbage tree +inside the clone and reporting success. Both hurt the operator running the +fleet loop over 100 checkouts: one typo writes 100 files into whatever sits +beside them (or 100 junk trees inside them), each one reported applied. The +verdict depends on nothing but the argument, so it is exit 3 — the class that +halts at repo 0 rather than working through the fleet. + +### `TestHardening_NonHeadBranchMayNotWrite` + +Review finding (F4): `--branch REF` proved the change against REF's tree and +then wrote the working tree of whatever was checked out. + +On a clone standing on main, `sync --branch old` resolved every scope against +old's tree, proved INV-2 there, and wrote main's file — exit 0, "applied", +carrying a rule that is dead where it landed and justified by a tree nobody +wrote to. sync.go already refused --create for exactly this reason; the +argument never depended on the file being new. Writing is refused (exit 2) +rather than silently downgraded to a dry run: an implied dry run would exit 0 +having written nothing, which under this contract reads as "converged", and a +fleet of 100 silently unchanged repos with 100 green rows is worse than the +bug. --dry-run still previews, and `plan` still targets another ref. + +### `TestHardening_RefusedWriteRecordCountsNothing` + +Review finding (F5): a refused WRITE left an applied-looking record. + +When the write itself failed, sync.go set status/error and cleared `created` +but left ops, ops_applied, paths_changed and the changes array exactly as the +planner produced them. The row then said a repo whose CODEOWNERS is +byte-for-byte unchanged had applied N ops and changed M paths, so the +README's `jq '[.[].ops_applied] | add'` overcounted the rollout by precisely +the repos where nothing was written — the operator reads a total that +includes the failures and believes more of the fleet moved than did. + +### `TestHardening_RepoMustBeTheRepositoryRoot` + +Review finding (F3): `--repo` pointing BELOW a repository root wrote a +CODEOWNERS that git will never read, and called it applied. + +gittree.ListTracked runs `git -C `, and git walks UP to the enclosing +repository rather than refusing: pointed at rK/sub it answers with rK's tree +minus the `sub/` prefix. Nothing then looks wrong from inside — the scopes +match, the plan is proven, the write succeeds — and the run reports +created:true, status "applied", exit 0. What it produced is +rK/sub/.github/CODEOWNERS, and GitHub loads only the CODEOWNERS at the +repository ROOT (or its .github/ or docs/), so the file governs nothing; +worse, the rules inside it are anchored at that root, where `/services/api/` +names a directory that does not exist. The repository's real CODEOWNERS, if +it has one, is never even read, because discovery looked below it. + +A fleet whose clone layout carries one extra directory level +(…/clones//checkout is a common one) writes 100 dead files and reports +100 successes: the exact "reported applied, dead on arrival" outcome this +verb exists to prevent. Repo-specific, so exit 2 — recorded, and the loop +moves on to the next clone. + +### `TestHardening_SinkFailureNeverReportsAConvergedRepoAsFailed` + +Review finding (F1): a CODEOWNERS write that LANDED was reported as a failed +repo, with no record emitted at all. + +--out was written before stdout and the first sink error became the exit +code, so `--out` pointing into a directory that does not exist turned a +converged repo into exit 2 with an empty stdout. The fleet script's +`>> results.jsonl` collected nothing for that repo — so the row an operator +would use to see the change is missing precisely where a change happened — +and the `2)` arm filed an already-correct repo under `needs-human`. At 100 +repos with a mistyped `--out` directory that is 100 real edits reported as +100 failures, with no record of any of them. + +The record on stdout is the durable trace and goes first, unconditionally; a +sink that cannot be written is a warning on stderr and nothing more. + +### `TestR19_AddOwnerTwiceByteIdentical` + +SPEC R-19 (INV-4): add_owner run twice is byte-identical. A second append of +the same owner would be invisible in resolution — `@a @b @b` resolves to the +same set as `@a @b` — and would grow the line on every nightly run. + +### `TestR19_ConvergesFromPartiallyAppliedState` + +SPEC R-19: convergence from a partially-applied state. A file that already +satisfies SOME of the policy — the normal state of a fleet mid-rollout, or +of a repo a human edited by hand — must converge in ONE pass, leave the +already-satisfied lines byte-untouched (INV-5), and be a no-op on the next. +A tool that only converges after N passes has no fixed point a scheduled job +can ever reach. + +### `TestR19_DryRunRepeatedIsInert` + +SPEC R-19: repeated --dry-run runs change nothing and say the same thing +every time. --dry-run is the fleet preview — the only review step that +exists at 100 repos — so a preview that differs run to run, or that writes +while previewing, makes the review meaningless. + +### `TestR19_FleetFivePassesNeverGrow` + +SPEC R-19: the same fleet, five passes. Two passes is not enough evidence — +a bug that appends only on odd passes, or one that flips a file between two +spellings (line endings, owner order, a re-sorted rule), is byte-identical +every other run and survives a two-run test forever. Five passes catch both: +every pass from the second on must be byte-identical to the second, and no +file may ever be larger than it was after pass 1. + +### `TestR19_FleetSizeStableAfterFivePasses` + +SPEC R-19 (S-4): the fleet's TOTAL CODEOWNERS size after five passes must +equal its total after one. Size is the number the failure is measured in: +past 3 MB GitHub silently stops loading a CODEOWNERS file entirely, so an +unbounded appender does not degrade ownership, it deletes it — with no +error, on every repo the job ever touched. + +### `TestR19_FleetTwoPassesByteIdentical` + +SPEC R-19: one policy, eight unalike repos, run twice. After the second pass +every repo must report a status that claims nothing was written, exit 0, and +hold a BYTE-IDENTICAL CODEOWNERS. Comparing resolved ownership instead would +pass while each file grew by a line a night: a `declare` line matches no +tracked path, so it moves no path's resolution and the planner's +`bytes.Equal(afterBytes, content)` no-op detector never sees it. + +### `TestR19_MixedPolicyDeclareLinesDoNotReorder` + +SPEC R-19: a realistic policy — three declared rules interleaved with two +ordinary ops — is byte-identical on a second run AND keeps its declared +lines in the same relative order. Re-ordering is a silent correctness change: +the last matching rule wins in CODEOWNERS, so two runs that shuffle appended +rules produce different ownership from the same policy, and every nightly run +churns a diff for reviewers. + +### `TestR19_RemoveOwnerTwiceEachOnEmptyPolicy` + +SPEC R-19 (R-6): remove_owner run twice is byte-identical under every +--on-empty policy. `inherit` deletes a rule and `unowned` writes a +zero-owner one — two different files that resolve identically, which is +exactly the pair a resolution-level idempotence check cannot tell apart. The +second run, where the owner is already gone, must write nothing at all. + +### `TestR19_RenameOwnerSelfRenameNeverWrites` + +SPEC R-19/R-20: `rename_owner(@a, @a)` is exit 3, on every run, and writes +nothing. + +This exit is not a toss-up between 0 and 3. ops.Parse rejects a rename whose +old and new names are equal, and it reaches that verdict from the op string +alone — before --repo is opened, before the tree is listed, before a byte of +CODEOWNERS is read. A verdict that consults no repository is by construction +the same verdict on all 100 of them, and that is precisely sync's definition +of the exit-3 class: halt at repo 0 rather than grind out 100 identical +errors. Exit 0 would be wrong for the same reason — "nothing to do, carry on" +sends a scheduled job through every clone in the org to rediscover a fact the +first repo already proved, and hides a typo'd rename inside a green run. + +The self-rename is also the churn case it is named for: a no-difference +rewrite that a nightly job would otherwise apply, commit and PR forever. So +the bytes are checked on both runs as well. Per D8 an exit-3 run emits NO +record, which is why this reads the raw streams instead of decoding one. + +### `TestR19_RenameOwnerTwiceByteIdentical` + +SPEC R-19: rename_owner run twice is byte-identical. + +What a regression here costs: the nightly job opens a pull request against +every repository in the org, every night, forever — each one a rewrite of a +CODEOWNERS file whose content did not actually change. The reviewers are the +code owners the file names, so they are the people paged for it. They learn +within a week that anything from this job is a no-op, stop reading it, and +the night it proposes a real ownership change it is rubber-stamped along with +the noise. Nothing ever errors and no exit code is ever non-zero. + +rename_owner reaches that failure first, because it derives its scope from +CURRENT ownership: after pass 1 the old name owns nothing, so pass 2 runs +with an empty scope — and an empty scope that synthesizes an edit anyway +rewrites the file with byte-different, semantically identical content. + +### `TestR19_SequentialSyncsReadPreviousOutput` + +SPEC R-19: the actual scheduled-job shape — sync, merge the result, and let +the NEXT run read what the last one wrote. This is the loop that turns a +one-line-per-run bug into a 3 MB file, and it is the only test here where +the second run's input is genuinely the first run's committed output rather +than a working-tree edit. + +### `TestR19_SetOwnersTwiceByteIdentical` + +SPEC R-19: set_owners run twice is byte-identical, including the S-9 +zero-owner form `set_owners(scope, [])`. The empty list is the dangerous +one: it writes a rule with no owners, so "did this already apply?" cannot be +answered by asking who owns the path — both states answer "nobody". + +### `TestR19_ZeroMatchDeclareWritesOnceThenNever` + +SPEC R-19/R-21/INV-6: `on_zero_match: declare` writes its rule exactly ONCE +and never again. This is the case the existing no-op detector cannot see: a +declared rule matches no tracked file, so it changes no tracked path's +resolution, and `bytes.Equal(afterBytes, content)` in plan.Build compares a +file the declare line was already appended to. Four passes, one line: the +difference between a policy that converges and a nightly job that adds a +line to 100 CODEOWNERS files until GitHub stops loading them (S-4). + +### `TestR19_ZeroMatchRequireStableAcrossRuns` + +SPEC R-19/R-21: `on_zero_match: require` is stable across runs. A scope that +matches nothing fails THIS repo (exit 2 — whether a path exists is the most +repo-specific fact there is) and must leave the file untouched, identically, +every night. A refusal that half-wrote something would be worse than the +growth bug. + +### `TestR19_ZeroMatchSkipIsNoOpBothRuns` + +SPEC R-19/R-21: `on_zero_match: skip` is a no-op on every run — exit 0, +status "skipped" (never "unchanged": a policy that matches nothing anywhere +must not read as "100 repos already correct"), and the file untouched. Twice, +because a skip that quietly wrote a rule anyway would be growth with a +reassuring status attached. + +### `TestR24_ForwardCompatMissingFieldIsZero` + +SPEC R-24 (forward compatibility, missing field): a record written before a +field existed unmarshals with that field at its zero value and everything +else intact. The counts are the ones that matter — a pre-`paths_changed` +line must read as 0 rather than failing the parse, because the alternative +is a resumed fleet run aborting on its own earlier output. + +Note what this test does NOT claim: that 0 and "absent" are +distinguishable. They are not, which is exactly why the always-present +guarantee in TestR24_StatusAndCountsAreAlwaysPresent is load-bearing — it is +the only thing keeping "this run changed nothing" apart from "this field did +not exist yet". + +### `TestR24_ForwardCompatUnknownFieldIsIgnored` + +SPEC R-24 (forward compatibility, unknown field): a record containing a +field this binary does not know still unmarshals, with the known fields +untouched. results.jsonl outlives the binary that wrote it — a fleet run is +resumed days later, often after an upgrade — so an unknown key must be +ignored rather than rejected. This is Go's default; the reader must never +opt into DisallowUnknownFields, and this test is what notices if it ever +does. + +### `TestR24_JSONLLinesParseIndividually` + +SPEC R-24 (JSONL shape): several records marshalled one per line each parse +individually, and no single record contains a raw newline. This is what +makes the README's `>> results.jsonl` plus `jq -s` work at all: the shell +appends whole lines and jq slurps them line by line, so one embedded newline +inside one record splits it into two unparseable fragments and takes the +whole fleet report down with it — after the run, when the repos are already +cloned and mutated. + +The hazard is real, not theoretical: `error` and `warnings` carry free text +composed from refusal messages, and `changes` carries CODEOWNERS line +content. encoding/json escapes newlines to \n, which is precisely the +property being pinned; a future switch to an encoder that does not (or one +left in indent mode) breaks line-oriented parsing without breaking a single +unmarshal-based test. + +### `TestR24_OmitEmptyKeysDisappear` + +SPEC R-24 (omitempty): which keys DISAPPEAR from a minimal record, pinned +explicitly, and which are unconditional. The split is not cosmetic. The +optional four (ops, warnings, changes, error) are absent when there is +nothing to say, so a consumer reads their absence as "empty", and a clean +run's line stays short enough to eyeball. The unconditional six are the +aggregation keys: absence there is a malformed record, not an empty result. + +### `TestR24_PerOpResultsRenderUnderOps` + +SPEC R-24 (nested per-op results): the per-op array renders under the key +`ops`, and each element carries plan.OpResult's own names. + +Note the DELIBERATE difference between the two documents: plan.Plan renders +this exact same []plan.OpResult under `op_results`, because Plan.Ops already +owns `ops` there as the list of raw op strings (R-16) and must keep it. The +sync record has no such collision, so it uses the shorter, documented name — +`ops` is what the README's JSON output section shows and what fleet scripts +select. This is not an inconsistency to be tidied up: renaming either one to +match the other breaks a published contract. The test pins both names at +once so a well-meant "fix" fails here instead of in a user's pipeline. + +### `TestR24_RoundTripPreservesEveryField` + +SPEC R-24 (round trip): a fully-populated record marshalled and unmarshalled +is semantically identical. `--out FILE` writes a record that a later step +reads back, and the fleet script's results.jsonl is read by whatever the +operator points at it, possibly a newer build of this same tool. A field +that does not survive its own round trip is a field the document claims to +carry and does not. + +### `TestR24_StatusAndCountsAreAlwaysPresent` + +SPEC R-24 (always-present keys): `status` and the three counts are emitted +even at their zero values, on every record, unconditionally. This is the +single most load-bearing property of the whole document. The README's fleet +aggregation is `jq -s 'group_by(.status)'`; on a record missing `.status` +that expression does not error, it groups the repo under null — a silent +fleet-wide misreport, which is exactly the failure this schema exists to +prevent. Same for the counts: `ops_skipped,omitempty` would hide 0 and make +the documented "project .ops_skipped too" habit read null on precisely the +repos that were fine. + +The zero-valued record below is not hypothetical: a refused repo emits +status "refused" with all three counts at 0, and that line still has to +aggregate. + +### `TestR24_StatusValuesAreExactlyFive` + +SPEC R-24 (status vocabulary): there are exactly five legal `status` values +— applied, unchanged, skipped, refused, error — and they are pinned both to +the exported constants and to the complete set of Status* constants declared +in the package source. The source scan is the point: referencing the five +constants catches a value being CHANGED, but only enumerating the +declarations catches a sixth being ADDED. A new status shipped without a +README update means fleet operators have a bucket in their `group_by` +output that no documentation explains, and every `case` statement written +against the documented five silently falls through. + +### `TestR24_SyncRecordFieldTypes` + +SPEC R-24 (field types): every field's JSON name mapped to its JSON type. +The key-set test catches renames and additions; this catches a type change +under an unchanged name. `ops_applied` going from number to string is the +concrete case — `jq 'map(.ops_applied)|add'` on strings is a runtime error +in the middle of a 100-repo rollout, and nothing in Go would have complained +at the moment the field's type changed. `created` being a bool matters for +the same reason: `select(.created)` is true for the string "false". + +### `TestR24_SyncRecordTopLevelKeys` + +SPEC R-24 (sync record, top level): the set of keys a marshalled SyncRecord +emits is pinned to a literal list. This is deliberately an ENUMERATION and +not a spot-check: the failures it guards against are a field being RENAMED +(every jq selector on it starts returning null) and a field being ADDED +without anyone noticing that `sync --format json` now emits something new, +and a spot-checking test can see neither. Every other cli test unmarshals +into SyncRecord, using the same tags on both sides of its assertions, so +this is the only place the wire names are compared to anything external. + +### `TestSync_AlreadyCorrectIsZeroNotNoOp` + +SPEC R-19 (R-17 remap): "already correct" is exit 0 under sync even though +`plan` calls the same situation a no-op and exits 1. If sync inherited the +1, every fleet runner would abort on the most common outcome there is. + +### `TestSync_AlreadyCorrectStillCarriesPerOpResults` + +SPEC R-24 (D1): an already-correct repo STILL reports one per-op result per +policy op, each `unchanged`. + +This pins the no-op path specifically, and it is a real hole today: +plan.Build returns `nil, &NoOpError{}` when the file already says what the +policy wants, so a caller that renders the record from the returned *Plan +has nothing to render and emits `"ops": []`. `unchanged` is the MODAL +outcome of a mature fleet — most repos are already correct most nights — so +the empty case is the one an operator sees on 90 of 100 rows. Without it the +record cannot answer "is op `tf` actually in place here, or did the policy +never mention this repo?", and the whole per-op array degrades to detail that +appears only when something changed, which is exactly when you least need it. + +Three fleet tests (fleet_test.go's "per-op results" and the idempotence +file's repeat passes) read per-op detail out of records produced by this +path; none of them pins the path itself. + +### `TestSync_BrokenPolicyExitsThree` + +SPEC R-19/R-20: a broken policy is exit 3 — it fails identically on every +repo, so the fleet script halts instead of grinding through 100 clones. + +### `TestSync_ConvergesThenIsIdempotent` + +SPEC R-19: sync is convergent and idempotent. The first run applies and +exits 0; the identical second run changes nothing and STILL exits 0. The +collapse of "applied" and "already correct" onto 0 is the entire point — +under `set -e` at 100 repos, the common outcome must not read as failure. + +### `TestSync_CreateHonorsFileAndRejectsNonHeadBranch` + +SPEC R-23: --create honors --file when given, and hard-errors with a +non-HEAD --branch — there is nothing to create a file "at" on a ref you are +not standing on, and silently writing to the working tree instead would be +the wrong file in the wrong place. + +### `TestSync_CreateNeverOverwrites` + +SPEC R-23: --create NEVER overwrites. Creating a file is the one action +with no prior artifact to prove INV-2 against; clobbering an existing one +would destroy ownership the tool exists to protect. + +### `TestSync_CreateWritesWhenAbsent` + +SPEC R-23: --create writes .github/CODEOWNERS when the repo has none, and +reports created:true so the fleet aggregation can tell a new file from an +edit. + +### `TestSync_DryRunWritesNoCodeownersButStillEmits` + +SPEC R-19/R-24: --dry-run makes NO change to CODEOWNERS but STILL emits +--out and --summary-out. That combination is what makes a fleet preview +useful — at 100 repos, the aggregated preview is the only review possible. + +### `TestSync_DuplicateOpIDsExitThree` + +SPEC R-20 (D3): two ops sharing one `id` is exit 3. + +Results are KEYED by id — syncOpResult here, fleetOpResult in fleet_test.go, +opResultByID in the planner's tests, and every `jq` recipe an operator writes +over results.jsonl. Every one of them returns the FIRST match. So a policy +with a duplicated id does not fail: it silently reports the first op's +outcome as if it were the second's, across the whole fleet, and the operator +reading "tf: applied" cannot tell that the other `tf` refused. Nothing about +this depends on any repo, so it belongs in the class that halts at repo 0 — +and `check` must catch it before the first clone. + +### `TestSync_FormatJSONSeparatesDataFromLogs` + +SPEC R-24: under --format json, stdout is data and stderr is logs. A log +line on stdout breaks `jq -s` over results.jsonl for the whole fleet. + +### `TestSync_HelpExitsZero` + +SPEC R-19: `sync -h` and `sync --help` exit 0. Under a fleet contract a +help request reading as "the policy is broken, halt" is wrong. + +### `TestSync_JSONRecordPerOpResults` + +SPEC R-24: the record's per-op results carry id/op/status/proven, and the +counts agree with the array. "ops_skipped: 1" alone cannot answer the +question that motivates `skip` — WHICH repos lack Terraform. + +### `TestSync_NeverReturnsOtherExitCodes` + +SPEC R-19: sync returns ONLY 0, 2, 3. Revision 1's "3+ means halt" was +unsafe three separate ways; reserving nothing is the fix. 1 (no-op), 4 +(findings), 5 (inconclusive) and 6 (rolled back) must all be unreachable. + +### `TestSync_NoCodeownersWithoutCreateExitsTwo` + +SPEC R-23: no CODEOWNERS and no --create is exit 2, not 3. --create is off +by default, so treating "this repo has no file" as a policy error halted +revision 1's fleet run at roughly repo 3. + +### `TestSync_NonRepoDirectoryIsAnErrorRecord` + +SPEC R-24: `--repo` pointing at a directory that is not a git repository is a +RECORDED per-repo failure — exit 2, one record, status "error". + +This is the only producer of cli.StatusError, and it is a different animal +from "refused": refused means the tool understood this repo and declined to +touch it, error means it never got that far. Both need a human, so both are +exit 2 and both must appear in results.jsonl; grouping on .status is how an +operator separates "12 repos have awkward CODEOWNERS files" from "12 clones +failed and were never actually synced". It must NOT be exit 3: a failed or +half-finished clone is the most repo-specific fact there is, and halting the +whole rollout on it strands the other 99 — the same mistake revision 1 made +with zero-match scopes. + +### `TestSync_OnEmptyWithPolicyExitsThree` + +SPEC R-20: --on-empty is allowed only with --op. With --policy it is a hard +error, because a flag that silently beats a policy field means the file in +git is not the complete statement of what ran — and `check` would then +validate something other than what executes. + +### `TestSync_OpAndPolicyMisuseExitsThree` + +SPEC R-20: --op and --policy are mutually exclusive, and --policy twice is +an error, never a silent last-wins. Silent last-wins means the artifact in +git is not the policy that ran. + +### `TestSync_OutWritesRecordToFile` + +SPEC R-24: the two flags are independent. `--out FILE` ALWAYS writes the JSON +record to FILE, whatever `--format` says; `--format` governs stdout and only +stdout. + +The alternative — `--out` emitting whatever `--format` names — quietly +destroys the artifact the flag exists for. `sync --out records/$repo.json` +with the default text format would leave a directory of human prose, and the +`jq -s` aggregation the README builds over it fails on the first file with a +parse error, after the whole rollout has already run and the CODEOWNERS +writes are done. Making it depend on a flag nobody passed is worse than +making it wrong: it works for the operator who happened to type `--format +json` and fails for the one who did not. + +The converse guarantee matters as much: `--out` must not go on to SUPPRESS +stdout. Someone piping `sync --format json ... | tee` while also archiving +with `--out` gets both, byte-identical, and the fleet script's +`>> results.jsonl` keeps working when a per-repo `--out` is added to it. + +### `TestSync_RefusalExitsTwo` + +SPEC R-19 (exit contract): a refusal is exit 2 — this repo's file has an +awkward shape and needs a human. The fleet script records it and keeps +going; it must never be confused with a broken policy. + +### `TestSync_RefusalRecordCarriesError` + +SPEC R-24: a refusal still produces a parseable record carrying the reason. +The fleet script appends every run to results.jsonl; a refused repo that +emits nothing (or emits prose) is a hole in the aggregation exactly where +the operator needs to look. + +### `TestSync_StatusSkippedWhenNothingApplied` + +SPEC R-24: `skipped` is a DISTINCT status, used when at least one op +skipped and none applied. Without it a policy with one typo'd path prefix +skips everywhere and reports 100 × `unchanged` — the operator groups on +.status, reads "already correct", and ships a no-op rollout. + +### `TestSync_SummaryOutNamesPolicyAndStructuralOps` + +SPEC R-24/INV-6: --summary-out renders markdown for a PR body. It must name +the policy (that is why `name` and `description` exist) and call out every +op proven only structurally, so a reviewer finds the weakened INV-1 cases +without reading the diff. + +### `TestSync_ZeroMatchUnderRequireExitsTwo` + +SPEC R-19/R-21: a scope matching zero tracked files under the default +`require` is exit 2, NOT 3. `plan` calls it invalid input; sync remaps it +because whether a path exists is the most repo-specific fact there is. +Getting this backwards halts a 100-repo run on repo 3. + ## internal/file +**`file_test.go`** + > Package file_test defines the CODEOWNERS file model: a byte-preserving > parse that can always be serialized back to the exact input. -> +> > SPEC INV-5: comments, blank lines, inline spacing, and line ordering outside > of directly modified lines are preserved exactly. > SPEC S-9: a rule may legally have zero owners (GitHub's sanctioned @@ -270,8 +1021,10 @@ InsertRule adds a new line without disturbing any existing line (R-1). ## internal/ghapi +**`ghapi_test.go`** + > Package ghapi_test defines the GitHub API client's fail-closed contract. -> +> > SPEC R-12: a lookup that cannot be answered definitively (bad token, > missing scope, rate limit, network failure) is INCONCLUSIVE — never a > negative. The worst failure this tool can produce is an expired token @@ -340,8 +1093,10 @@ A 404 on /users/{login} with a working token is a definitive negative. ## internal/gittree +**`gittree_test.go`** + > Package gittree_test defines how the tool obtains the tracked file tree. -> +> > SPEC S-7 / INV-3: resolution runs against the tracked tree of a specific > ref (default HEAD) of a local repository — never the working directory > listing, never the pattern set. @@ -372,6 +1127,8 @@ an error condition for the caller, never a merge. ## internal/ops +**`ops_test.go`** + > Package ops_test defines the intent language: operations are expressed > over resolved ownership, never over lines (§1 of the spec). @@ -418,13 +1175,15 @@ spells such patterns with escaped spaces, and so must ops. ## internal/pattern +**`pattern_test.go`** + > Package pattern_test defines the CODEOWNERS pattern-matching semantics. -> +> > SPEC S-2: patterns are gitignore-LIKE, but with no negation (`!`) and no > character ranges (`[a-z]`). GitHub's docs list three explicit exceptions to > gitignore syntax; everything else follows gitignore matching rules. > SPEC S-6: matching is case-sensitive regardless of local filesystem. -> +> > The authoritative corpus in testdata/patterns.json is vendored from > hmarr/codeowners (MIT), the reference implementation this matcher is > differentially tested against. @@ -476,28 +1235,103 @@ An unsound answer here lets the planner amend a rule in place and silently hand an owner every future file that rule will ever match — the defect this whole mechanism exists to prevent. +### `TestContainsIsSoundOverGlobstarFamily` + +The soundness rule TestContainsIsSound states, generalized over the "**" +family so the next member of it cannot slip through by simply not being +listed in containsCorpus. Whenever Contains(outer, inner) is true, NO +concrete path may match inner without also matching outer; a violation means +the planner would amend a rule in place and silently widen an owner's reach. + ### `TestContainsKnownPairs` The containments the planner relies on to amend a rule in place, and the near-miss shapes it must NOT accept. +### `TestContainsRejectsAdjacentGlobstars` + +Contains(`**/`, `*`) is the witness that broke soundness: `**/` normalizes to +the segments ["**", "**"], and buildPatternRegex compiles that to +`\A(?:.+/)?/.*\z` — the leading globstar consumes the separator, then the +trailing globstar re-emits one, so no repo-relative path can ever match it. +The token model read the same pattern as [many, any1, many] and called it +universal, so Contains claimed `**/` contained every pattern in the language. +That is the exact shape that would let the planner amend a dead rule in place +and hand its owner every file the inner rule will ever match. + ### `TestContainsRejectsInvalid` Contains must not blow up or report true on patterns Compile rejects. ## internal/plan +**`plan_test.go`** + > Package plan_test encodes the spec's acceptance tests for Engine A. -> +> > The planner takes intent-level ops, computes the resolved ownership > before/after over the real tree, synthesizes line edits, and GATES the > result on two invariants: -> +> > INV-1: every path in scope resolves to exactly what the op requires. > INV-2: every path outside scope resolves to exactly what it did before. -> +> > A plan that cannot be proven is refused (exit 2), never guessed at. +**`property_test.go`** + +> Property tests for the planner (T-4, T-5): thousands of generated +> (file, tree, op) cases, all checked against independently computed +> expectations. Deterministic seeds — failures reproduce exactly. + +**`schema_test.go`** + +> Schema pins for the plan document (R-16). +> +> The plan JSON is not an output format, it is a CONTRACT. `plan` writes it, +> `apply` reads it, and nothing else validates it in between: apply unmarshals +> the document straight into plan.Plan and writes bytes to disk from +> after_content, gated only on sha256_before. Every invariant the planner +> proved is carried across that boundary by the document alone. +> +> The document is also a CI artifact: `plan --out plan.json` in one job, +> `apply --plan plan.json` in a later job, possibly with a newer binary. That +> makes both directions of compatibility load-bearing — a newer binary must +> read an older plan, and an older reader must not choke on a field it does +> not know. +> +> Until these tests existed the shape was pinned by NOTHING. No test in the +> suite marshalled a Plan and compared the result to anything, so a field +> added to Plan silently changed `plan --out`'s output and the suite stayed +> green. Plan.OpResults was added exactly that way. These tests are +> characterization tests: they record the CURRENT shape as intentional, so the +> next change to it is a deliberate, reviewed one. +> +> They assert the key SET, never the key ORDER. Go's marshaller emits fields +> in declaration order, but that ordering is not part of the contract and +> pinning it would fail on a harmless field reshuffle. + +**`settle_internal_test.go`** + +> White-box test for the remove_owner settling logic (second-review +> regression): divergence between the pure transform desired∖{owner} and the +> file's actual resolution is accepted ONLY under --on-empty=inherit, where +> rule deletion legitimately resurrects owners from surviving rules. Under +> any other policy no rule is deleted, so divergence means a synthesis bug +> (or a bad earlier batched edit) and must REFUSE — accepting it would +> launder the error past the gate. + +**`zeromatch_test.go`** + +> These tests cover R-21 (on_zero_match: require | skip | declare), R-22 +> (what `declare` actually writes) and INV-6 (a declare op is proven +> STRUCTURALLY, because there is no tracked file to prove it against). +> +> The whole safety argument for fleet automation lives here. A declare op is +> the one place the tool writes a rule it cannot check against the repo, and +> it is executed unattended across 100 repositories on a schedule — so every +> defect in it is multiplied by 100 and nobody is watching. + ### `TestAddOwner_CoversUnownedPaths` add_owner covering previously-UNOWNED paths: a scope rule is inserted @@ -530,6 +1364,96 @@ TestShape4_TreeConfirmationRefusesInexactDerivation. INV-5 property: parse→serialize round-trips any generated file (plus junk mutations) byte-identically. +### `TestINV6_DeclareDoesNotWeakenINV2` + +TRAP 3 — SPEC INV-6 / INV-2: declare weakens INV-1 and NOTHING else. INV-2 +is still proven over the whole tree exactly as today. + +This is the promise the feature is sold on ("a pattern matching nothing +tracked cannot move any existing path's resolution"). If a declare op could +move even one tracked path, the fleet rollout would be silently +reassigning ownership in 100 repositories under cover of "declaring" paths +that do not exist. + +### `TestINV6_PartialOverlapBetweenSameBatchDeclaresIsAllowedAndDisclosed` + +SPEC INV-6 (third obligation): a scope this same policy declares LATER may +overlap an earlier declared scope PARTIALLY — allowed, and disclosed. + +The canonical fleet baseline is "CI owns workflows everywhere, infra owns +Terraform where it exists". Those scopes meet on .github/workflows/deploy.tf, +and CODEOWNERS gives a path exactly one owner set, so one of them must lose +there. Refusing the pair made that baseline inexpressible in EVERY repo, +forever, and reported a policy-level conflict as an identical per-repo exit 2 +on all 100 repos — the misclassification the exit-2/exit-3 split exists to +prevent. The author wrote both in one document; its order is the precedence, +exactly as in a hand-written file. R-7 sets the precedent: disclose the +shadowing, do not refuse it. + +The plan suite never covered this before — TestR22_MultipleDeclaresStackAtEOF +InPolicyOrder and TestR22_CommutingDeclareBatchIsAccepted both stack +anchored, wildcard-free scopes, which are provably disjoint — so the +overlapping-but-satisfiable case stayed invisible until the CLI wired it up. + +### `TestINV6_PartialOverlapWithAPreexistingLaterRuleIsStillRefused` + +SPEC INV-6 / R-1: a partial overlap with a PRE-EXISTING later rule is still a +refusal. Same geometry as the allowed case above — "/.github/workflows/" +against a later "**/*.tf" — and the opposite answer, because the difference is +authority, not shape: R-1 forbids reordering lines this run did not write, so +unlike a same-policy overlap there is no order the planner is entitled to +choose. Accepting it would let the tool silently ratify whatever precedence +the existing file happens to encode. + +### `TestINV6_ProvenIsStructuralOnlyForZeroMatchDeclares` + +TRAP 3 — SPEC INV-6: `proven` distinguishes an op checked against real +files from one that could only be argued structurally. + +The gate iterates the TREE. For an op whose scope set is empty it iterates +nothing, so INV-1 is vacuously true — the plan is "proven" without a single +statement having been made about the line just written. That is precisely +the case where a reviewer most needs to be told. `proven: "structural"` is +the disclosure that a rule went in unverified against the repo; without it, +a fleet operator reading 100 JSON records cannot tell a checked rollout +from an unchecked one. + +### `TestINV6_RefusesADeclareWhoseEmittedLineDoesNotRoundTrip` + +TRAP 3 — SPEC INV-6: the structural proof must be a real proof. Since the +gate's tree loop is vacuous for a zero-match op, the ONLY thing standing +between the user and a wrong line is the structural check, and it must +re-read the emitted bytes exactly as the gate does for tracked paths. + +Here the scope contains unescaped whitespace, so the line written for it — +`docs x@y.zz @a` — re-parses as a DIFFERENT rule (pattern `docs`). With a +tracked match this is caught today (TestGate_ProvesSerializedBytesNotModel). +With zero matches nothing looks, and the tool would hand @a every `docs` +directory in the repo while reporting that it declared ownership of a path +with a space in it. Must refuse (exit 2). + +### `TestINV6_TotalShadowingBetweenSameBatchDeclaresIsRefused` + +SPEC INV-6 (third obligation): TOTAL capture by a same-batch declare is still +a refusal. The relaxation above is only for overlaps that leave the declared +rule the last word SOMEWHERE. A rule no path can ever reach is dead on +arrival: the tool would report it applied with proven=structural, the diff +would look right in review, and the declaration would never take effect — +which is the entire failure mode this file exists to prevent. + +### `TestINV6_TrailingStarStarIsNotADirectoryPrefix` + +SPEC INV-6 / R-8 (regression, adversarial audit of Wave 1): the disjointness +proof must not treat "/src**" as the directory "/src/". + +anchoredDirPrefix stripped the trailing "**" BEFORE testing for wildcards, so +"/src**" normalized to "/src/" and patternsProvablyDisjoint("/src**", +"/srcx/") answered TRUE. It is false: "/src**" compiles to +`\Asrc[^/]*[^/]*(?:/.*)?\z` and matches srcx/a.go, which "/srcx/" matches too. +A false disjointness proof is the one failure mode this package is built to +exclude — it is a WRONG WRITE, not a missed one, and both reproducers below +were accepted at exit 0 before the fix. + ### `TestR2_ContainmentIsSemanticNotTextual` Containment must be semantic, not textual. Each of these rules is genuinely @@ -724,11 +1648,292 @@ E2E-testing finding: add_owner amend records recorded the POST-op owner set as old_owners (OwnersCopy taken after SetOwners mutated the aliased rule). The change record must show the true before/after. +### `TestR16_ChangeKeys` + +SPEC R-16 (change records): a Change's JSON keys, enumerated. Each change is +one line edit plus the reason it was chosen; a reviewer reading a plan in CI +reads these fields, so renaming one silently breaks every consumer that is +not the Go binary. + +### `TestR16_FieldTypes` + +SPEC R-16 (field types): every field's JSON name mapped to its JSON type. +The key-set tests catch renames and additions; this catches a type change +under an unchanged name — a string becoming an int, a scalar becoming a +list — which is the change most likely to pass review and then break a +consumer at runtime. + +### `TestR16_ForwardCompatMissingFieldIsZero` + +SPEC R-16 (forward compatibility, missing field): a plan document written +before a field existed unmarshals with that field at its zero value, and +every other field intact. op_results is the concrete case — it was added on +this branch, so every plan.json already sitting in a CI artifact lacks it, +and this is what lets a newer binary still apply one. + +### `TestR16_ForwardCompatUnknownFieldIsIgnored` + +SPEC R-16 (forward compatibility, unknown field): a plan document containing +a field this binary does not know still unmarshals. Plans are written to CI +artifacts and read back by a possibly-newer or possibly-older binary, so an +unknown key must be ignored rather than rejected. This is Go's default — +apply must never opt into DisallowUnknownFields, and this test is what +notices if it ever does. + +### `TestR16_HashBeforeSurvivesRoundTrip` + +SPEC R-16 (drift gate): sha256_before is the pin that makes apply REFUSE a +CODEOWNERS file that changed since the plan was computed — the plan's +invariants were proven against those exact bytes and are worthless against +any others. It must be the hash of the planned-over content and it must +survive the round trip byte for byte; a plan whose hash is lost or mangled +either fails safe (refuses forever) or, worse, would need the check +disabled. + +### `TestR16_OmitEmptyKeysDisappear` + +SPEC R-16 (omitempty): which keys DISAPPEAR from a minimal document, pinned +explicitly. omitempty is not cosmetic here — it decides whether a consumer +can distinguish "this plan produced no warnings" from "this plan is from a +binary that predates warnings". The unconditional keys (ops, changes, +ownership_rows, diff, after_content) are always present, so a reader can +treat their absence as a malformed document rather than as an empty result. + +### `TestR16_OpResultKeys` + +SPEC R-24 (op results): an OpResult's JSON keys, enumerated. This is the +newest member of the plan document and the one whose addition this suite +failed to notice. + +### `TestR16_OwnershipRowsDistinguishNullFromEmpty` + +SPEC R-16/S-9 (null vs []): ownership_rows must distinguish JSON null — +"no rule matches this path", i.e. genuinely unowned — from [] — "a rule +matches and deliberately lists zero owners" (--on-empty=unowned). They are +different states of the repository and internal/verify treats a transition +between them as a real change (see internal/verify/verify_test.go, which +pins the same distinction at the snapshot layer). + +This is why Row carries no omitempty: `owners_before,omitempty` would erase +nil and [] into the same absent key, collapsing the two states into one on +the way through the plan document. + +### `TestR16_OwnershipRowsNullVsEmptyFromRealPlan` + +SPEC R-16/S-9 (null vs [], end to end): the same distinction as produced by +the real planner, not by a hand-built struct. remove_owner under +--on-empty=unowned keeps the pattern with zero owners, and the row for that +path must serialize owners_after as [], not null — a reviewer reading the +plan in CI is being told "deliberately un-owned", which is a different (and +legal, S-9) outcome from "no rule matches". + +### `TestR16_PlanFileEnvelopeInlinesPlan` + +SPEC R-16 (envelope): the CLI wraps a Plan in planFile, adding repo, ref and +codeowners_path. Because Plan is EMBEDDED without a json tag, encoding/json +inlines its fields — the document has no nested "Plan" object, and apply +unmarshalling the file gets both the envelope and the plan in one pass. +Pinning that inlining matters: giving the embedded field a name would nest +every plan key one level deeper and break every existing plan.json, with no +compile error anywhere. + ### `TestR16_PlanIsMachineReadable` SPEC R-16: the plan carries both views — ownership rows AND the literal line diff — plus byte sizes and per-change reasons. +### `TestR16_PlanTopLevelKeys` + +SPEC R-16 (plan document, top level): the set of keys a marshalled Plan +emits is pinned to a literal list. This is deliberately an ENUMERATION and +not a spot-check: the failure this guards against is a field being ADDED +without anyone noticing that `plan --out`'s output changed, and a +spot-checking test cannot see an addition. op_results is included because +it was added without a test noticing; listing it here records the current +shape as intentional. + +### `TestR16_RoundTripPreservesRealPlan` + +SPEC R-16 (round trip, real plan): the same round trip over a plan produced +by the planner rather than by hand, including the two fields apply actually +consumes — after_content (the bytes written to disk) and sha256_before (the +drift gate). + +### `TestR16_RoundTripThroughApplyPath` + +SPEC R-16 (round trip): a plan marshalled and unmarshalled through the exact +path `apply` uses — json.MarshalIndent of the planFile envelope, then +json.Unmarshal back into it — is semantically identical. This is the actual +apply contract: everything the planner proved reaches the writer through +this round trip and nothing else, so any field that does not survive it is a +proof that silently does not reach `apply`. + +### `TestR16_RowKeys` + +SPEC R-16 (ownership rows): a Row's JSON keys, enumerated. Note the Go field +names (Before/After) differ from the JSON names (owners_before/owners_after) +— exactly the kind of mapping a refactor of the struct can break without any +compile error. + +### `TestR21_AllOpsSkippedIsAWholePlanNoOp` + +SPEC R-21 (`skip`): when EVERY op skips, the plan as a whole is a no-op +(exit 1) — there is nothing to write. + +The planner must not emit a plan whose AfterContent equals its input: +`apply` would then rewrite the file with identical bytes and the fleet +would open a PR per repo containing no diff. + +### `TestR21_RequireAndZeroValuePreserveR5` + +SPEC R-21 (compatibility): `require` and its zero value "" preserve R-5 +exactly — a scope matching zero tracked files is invalid input (exit 3). + +This is the guarantee that lets `on_zero_match` be added at all: ops parsed +from `--op` never set the field, so every pre-existing test and every +existing caller must keep the behavior TestT7_ZeroMatchScopeRejected pins. +If this regresses, adding the field silently changed what one-repo `plan` +does — the tool's oldest documented refusal. + +### `TestR21_SkipIsANoOpForThatOpOnly` + +SPEC R-21 (`skip`): a skipped op changes nothing, is reported as skipped +with a reason, and does not stop the rest of the batch from applying. + +This is the opportunistic-op case — "if this repo has Terraform, @org/infra +owns it". If a skip silently aborted the batch, the baseline ops that DO +apply here would never land; if it silently applied something, `skip` would +mean nothing. + +### `TestR22_CommutingDeclareBatchIsAccepted` + +TRAP 4, the other half — SPEC R-22: the order-dependence guard must not +become "reject any batch containing a declare op". A fleet policy is mostly +declare ops; a guard that refused all of them would make the feature +unusable and push operators back to hand-editing 100 files. + +### `TestR22_DeclareAmendsAnExistingRuleWithDifferentOwners` + +TRAP 1, second face — SPEC R-22: when the declared rule is already present +but with DIFFERENT owners, the op must amend that rule in place, not append +a second copy of the pattern. + +Appending instead would leave two rules for the same pattern; last-match +wins, so `add_owner` would silently DROP the owners on the shadowed line +(the exact R-4/R-7 failure the amend-preferred rule exists to prevent), and +the file would gain a line on every ownership change forever. + +### `TestR22_DeclareAppendsAtEOFBelowACatchAll` + +TRAP 2 — SPEC R-22: a declare op appends at EOF. It must NOT reuse +synthAdd's unowned-path insert point, which inserts at firstRuleIndex, +i.e. BEFORE every existing rule. + +CODEOWNERS is last-match-wins, so a rule written above a trailing +`* @org/everyone` catch-all — the single most common line in real +CODEOWNERS files — is shadowed for every path it was meant to govern. The +tool would report "applied", the diff would look right in review, and the +declared ownership would never take effect: 100 pull requests that do +nothing, discovered months later when the first workflow file lands and +pings the wrong team. + +The tracked tree cannot show any of this (the scope matches nothing), so +the assertion resolves a path that does not exist yet against the emitted +bytes. + +### `TestR22_DeclareAppendsAtEOFInACommentOnlyFile` + +TRAP 2, EOF edge: a file containing only comments has no rules at all, so +"after the last rule" and "before the first rule" are the same index. The +declared rule still goes at the end, and every comment line stays +byte-identical (INV-5) — a header explaining the file must not be pushed +below the rules it introduces. + +### `TestR22_DeclareAppendsAtEOFWithoutATrailingNewline` + +TRAP 2, EOF edge: the file's final line has NO trailing newline. Appending +must put the declared rule on its own line — concatenating onto the last +line would rewrite an existing rule into something else entirely (pattern +plus a stray owner list), which is a silent ownership change, not a +declaration. + +### `TestR22_DeclareIsIdempotent` + +TRAP 1 — SPEC R-22 (INV-4 for declare): a declare op must be IDEMPOTENT. + +This is the highest-cost defect in the feature. The planner's only no-op +detector is `bytes.Equal(afterBytes, content)`, and the desired-state map +is keyed by TRACKED path — a declare op has none. So a declare resolves +identically before and after, and nothing in the current design can notice +that the line it is about to append is already the line at the bottom of +the file. + +Unfixed, the failure is not subtle and not local: a scheduled fleet run +appends the same rule to all 100 CODEOWNERS files, every run, forever. That +is R-4's unbounded growth, once per repo per night, ending at S-4's 3 MB +cap where GitHub stops loading the file and the repo silently has NO +ownership at all. The second run must be a no-op (exit 1). + +### `TestR22_DeclareRespectsTheSizeCap` + +SPEC R-22 (S-4): the size cap applies to declared lines too. A rule for +files that do not exist yet is still bytes GitHub has to load, and past +3 MB GitHub silently ignores the whole file — every path in the repo loses +its owners at once. A declare op is exactly the kind of op a fleet script +runs on a schedule, so it is the likeliest way to cross the line. + +### `TestR22_DeclareSeesAnExistingRuleThroughSpacing` + +TRAP 1, third face — SPEC R-22: "already declared" is a question about +RESOLUTION, not about bytes. A rule written with a tab, extra spaces, or an +inline comment already grants the declared owners, so re-declaring it is a +no-op — and must not rewrite the line, which would churn a diff into 100 +pull requests that change only whitespace. + +### `TestR22_DeclareSetOwners` + +SPEC R-22: `declare` on set_owners, including the empty owner list. An +empty list is a legal, deliberate un-owning (S-9): the rule keeps its +pattern with zero owners, so a future matching file is explicitly NOT +governed by whatever broader rule sits above it — the only way to say "this +path is deliberately unowned" for paths that do not exist yet. + +### `TestR22_DeclareWithMatchesIsTheOrdinaryPath` + +SPEC R-22: a declare op whose scope matches SOME tracked files is not a +zero-match at all. It takes the ordinary path — same edits, same bytes, +proven over the tree. + +`on_zero_match` describes what to do when the scope matches NOTHING. If +setting it to `declare` changed how an op that does match is written, then +adding the field to a policy would quietly alter the behavior of every op +in it, and reviewers of the policy file would be reading the wrong thing. + +### `TestR22_MultipleDeclaresStackAtEOFInPolicyOrder` + +SPEC R-22: several declare ops stack at EOF in POLICY order. + +Order is not cosmetic in a last-match-wins file: the order the lines land +in is the order that decides overlaps for every file added later. It must +be the order the policy author wrote and a reviewer read — not map order, +which would differ run to run and make two repos with identical inputs +produce different files. + +### `TestR22_ZeroMatchBatchIsNotVacuouslyCommuting` + +TRAP 4 — SPEC R-22 (R-8 for declare ops): the batch commutativity check +intersects TREE path sets. Two zero-match scopes intersect on nothing, so +an order-dependent batch of declare ops sails through the R-8 check as +"commuting" and is written in input order. + +The pair below is genuinely contradictory: for a future `terraform/main.tf` +the first op asks for @org/infra among the owners and the second asks for +exactly {@org/tf}. Last-match-wins means whichever line is written second +decides — the two orderings produce different CODEOWNERS files and both are +accepted today. The refusal must cite R-8; a refusal citing R-5 means +`declare` was never implemented and this test is passing for the wrong +reason. + ### `TestRenameOwner_Global` rename_owner replaces the identifier everywhere; it cannot change any @@ -884,11 +2089,342 @@ create them. SPEC T-10 (R-9, S-4): refuse to produce a file over 3 MB — GitHub would silently not load it, which is total ownership failure. +### `TestZeroMatch_DeclareByteIdenticalToAnExistingRule` + +SPEC R-22: a declare op whose rule would be byte-identical to a rule +already in the file. + +Two cases with opposite answers, and the difference is precedence, not +bytes. When the identical rule is already the last word, there is nothing +to do (exit 1). When a later catch-all shadows it, the declaration is NOT +satisfied — reporting "already correct" there is the silent no-op rollout +that `skip` vs `declare` exists to distinguish, and it would read as 100 +converged repos in the fleet summary. + +### `TestZeroMatch_OpResultsAreInOpOrder` + +SPEC R-22 (R-24): OpResults is one entry per op, in op order, carrying the +op's id and text. + +The fleet record is the only artifact of an unattended run. "Which repos +lack Terraform" is answerable only if each op reports itself, by id, in the +order the policy lists them — a bare count cannot answer it, and a reordered +list attributes the wrong outcome to the wrong op. + +## internal/policy + +**`policy_test.go`** + +> Package policy_test encodes the acceptance tests for the policy file. +> +> The policy file is the unit of review for a fleet rollout (R-20): one +> artifact in git that states what ran across N repositories. That makes it +> the one place where a silent default is unaffordable — a typo'd +> `on_zero_mtach` that fell back to the default would apply the WRONG policy +> to every repo at once, and nothing downstream would notice. +> +> So the contract is: +> +> Unknown fields, bad enum values, and duplicate JSON keys are HARD errors. +> Syntax errors fail fast; semantic errors accumulate into *MultiError. +> Every error carries file:line:col and names the op by index and id. +> Go's own decoder messages never reach the operator. +> +> Per-op zero-match (R-21) is validated here too, because whether an op may +> carry `on_zero_match` at all depends only on the op kind — a repo-independent +> fact, and therefore a policy error, caught on repo 0 rather than repo 47. + +### `TestR20_AllOptionalFieldsArePopulated` + +SPEC R-20: every optional field round-trips. name/description/note exist so +the PR reviewer on repo 63 learns WHY the change is in front of them; if they +are silently dropped at parse, --summary-out has nothing to render. + +### `TestR20_BadOnEmptyValueRejected` + +SPEC R-20: on_empty is validated lazily today — plan.go only reaches "unknown +--on-empty policy" when a removal actually empties an owner set. A policy +saying "inhrit" would pass, work on 46 repos, and blow up on repo 47. +Validate the enum at load; that is precisely what `check` exists to prevent. + +### `TestR20_BareStringIsExactlyObjectShorthand` + +SPEC R-20: a bare string is SHORTHAND for {"op": ""} with every other +field at its default — not a second form with its own code path. If the two +ever diverge, a reviewer reading a mixed ops array cannot tell what runs. + +"Every other field at its default" includes the id, which stays EMPTY. The +`ops[0]` form that appears in error messages and results is a display label +the renderer computes from the position; storing it in the field instead +would make an unnamed op indistinguishable from one a policy author +deliberately named "ops[0]", and would key that op's fleet-wide results by a +name that shifts the moment somebody inserts an op above it. + +### `TestR20_DuplicateKeyInsideOpRejected` + +SPEC R-20: the same, one level down. Per-element decoders are built fresh for +each op, so it is easy to implement duplicate detection at the top level and +forget it inside the ops array — where the values that steer behavior live. +Every value here is a scalar, so the message has no excuse: it must name the +key and show both of the values that were in conflict. + +### `TestR20_DuplicateOpIDRejected` + +SPEC R-20: two ops in one policy may not share an id. Results are keyed by +id — the summary a reviewer reads, and whatever a fleet script pipes into +jq — so a repeated id silently overwrites one op's outcome with another's. +The reviewer then sees a policy that ran N ops reporting N-1 results, with +no indication which repo the missing one applied to. + +An op with NO id is not a duplicate of another op with no id: the empty id +is the absence of a name, and unnamed ops are referred to by position. + +### `TestR20_DuplicateTopLevelKeyRejected` + +SPEC R-20: encoding/json silently takes the LAST duplicate key. A generator +concatenating fragments produces on_empty twice and `inherit` wins without a +word — the same failure class as the typo, and invisible in review because +both lines are individually correct. +The message must show BOTH values, because the whole failure is that one of +them vanished without trace. Naming the key alone leaves the reviewer to +work out which of their two lines the tool would have obeyed. + +### `TestR20_EmptyOpsArrayRejected` + +SPEC R-20: an EMPTY ops array is the same silent success, and is what a +generator emits when its input query returned nothing. + +### `TestR20_ErrorCarriesAPlausibleLineNumber` + +SPEC R-20 (error quality): a real multi-line policy must report the line the +mistake is actually on. Line 0 — the value you get by not converting the byte +offset — is the whole feature failing quietly. + +### `TestR20_ErrorFormatsFileLineColAndOp` + +SPEC R-20 (error quality): the format is file:line:col, then the op by index +and id, then the message. A byte offset is what encoding/json gives and what +nobody can use; this string is what a person greps out of a 100-repo log at +2am. + +### `TestR20_ErrorsIdentifyTheOpByIndexAndID` + +SPEC R-20 (error quality): the op is identified by index AND id. The index +alone makes a reviewer count array elements; the id alone is optional and may +be absent. Both, always. + +When the op has no id the index still stands alone, and no id is invented to +fill the gap: `ops[1]` is how the renderer refers to an unnamed op, and an +error claiming an op is named "ops[1]" sends a reviewer searching the policy +file for a string that is not in it. + +### `TestR20_GoUnmarshalMessagesNeverEscape` + +SPEC R-20 (error quality, rule 5): `json: cannot unmarshal object into Go +value of type string` names Go types and points at the wrong concept. It must +never reach an operator, for ANY malformed input. + +### `TestR20_LoadOfMissingPathFails` + +SPEC R-20: a missing policy file is a policy error, not a crash and not a +silent empty policy — the fleet script's first line is `check --policy`, and +a typo'd path must halt there rather than run zero ops against 100 repos. + +### `TestR20_LoadReadsAPolicyFromDisk` + +SPEC R-20: Load is Parse over a file on disk — same policy, same result. The +filename in the errors is the path the operator passed, so they can open it. + +### `TestR20_MalformedOpStringIsAPolicyError` + +SPEC R-20: an op string that ops.Parse rejects is a POLICY error. Letting the +raw ops.Parse text out loses the file, line, and op index — the operator gets +`unknown op "add_ownr"` with no idea which of 40 ops in which of 100 repos. + +### `TestR20_MalformedPolicyIsCaughtWithoutARepo` + +SPEC R-20: the policy is the unit of review, and reviewing it touches NO +repository. Every error above is reachable with nothing on disk but the file +itself — no git, no tree, no CODEOWNERS — which is what lets `check` run once +before the loop instead of failing on repo 100. + +### `TestR20_MinimalPolicyParses` + +SPEC R-20: the smallest policy anyone would write by hand — a version and a +list of op strings — is accepted exactly as written, and every field the +author did not mention stays at its default rather than acquiring one. + +This is step two of the escalation the README promises: run one operation +with --op, decide you want to keep it, paste it into a file. If the minimal +file needs a name, a description, or a per-op object before the tool will +take it, that promise breaks at the moment an operator first tries to save +their work — and a policy file nobody can write by hand is a policy file +nobody reviews. + +### `TestR20_MissingOpsRejected` + +SPEC R-20: a policy with no ops does nothing on 100 repos and exits 0 on all +of them — the silent-success failure this design exists to make impossible. + +### `TestR20_MissingVersionRejected` + +SPEC R-20: version is required, not optional-defaulting-to-1. A strict format +read by pinned binaries across a fleet, with no version marker, is a corner +with no way out. + +### `TestR20_NearMissTypoNamesTheOffendingField` + +SPEC R-20: the motivating case. `on_zero_mtach` differs from the real field by +two transposed letters; under a permissive decoder it applies the DEFAULT +zero-match policy to 100 repos while the file in git says "skip". The error +must quote the offending key and point at the one that was meant. + +### `TestR20_NewerVersionDistinguishedFromGarbage` + +SPEC R-20: "your binary is too old" and "this file is nonsense" are two +different jobs for the operator — upgrade the tool, or go fix whatever +generated the file. A pinned fleet binary that meets a version it does not +implement has to say which one it is, or the operator picks wrong and +spends the outage on the other. + +The message carries that verdict in the two NUMBERS it names: the version +the file asks for and the version this binary implements. A malformed +version has no such pair — there is nothing to compare — so it names the +field and shows what it actually found, and must never send anyone off to +upgrade over a stray quote mark. + +### `TestR20_OpObjectWithoutOpKeyRejected` + +SPEC R-20: `op` is the one required field of the object form. An object +carrying only id and note describes nothing; accepting it would put a +phantom entry in the per-op results of every repo. + +### `TestR20_OpsEntryOfWrongJSONTypeRejected` + +SPEC R-20: dispatch is on the first non-space byte — `"` is the string form, +`{` the object form, and ANYTHING ELSE is a typed error. A number or null +slipping through as a zero-value op would run an empty op against the fleet. + +### `TestR20_OpsItselfMustBeAnArray` + +SPEC R-20: "ops" itself must be an array. A single string is the plausible +generator mistake, and reading it as one op would be a helpful guess — the +exact behavior a strict format forbids. + +### `TestR20_RemoveOwnerRequiresTopLevelOnEmpty` + +SPEC R-20: on_empty is REQUIRED when any op is a remove_owner, validated +statically. Otherwise the R-6 question ("what happens when a removal empties +an owner set?") is answered lazily on whichever repo first hits it — a repo-47 +surprise, turned here into a repo-0 one. + +### `TestR20_SemanticErrorsAccumulate` + +SPEC R-20: SEMANTIC errors accumulate. Fixing a generated 40-op policy one +error per run is miserable and is how an operator gives up and stops running +`check` at all. + +### `TestR20_ShorthandAndObjectFormsMix` + +SPEC R-20: both forms are legal in the same ops array, in any order, and +order is preserved (R-8 conflict detection downstream depends on it). + +### `TestR20_SyntaxErrorsFailFast` + +SPEC R-20: SYNTAX errors fail fast. Once the token stream is broken every +subsequent "error" is invented, and a wall of phantom problems is worse than +the one real one. + +### `TestR20_UnderscoreAndSlashKeysAreIgnored` + +SPEC R-20: JSON has no comments and unknown fields are fatal, so without an +escape hatch the universal "_comment" convention would be ILLEGAL. Keys +starting with _ and the key // are ignored at EVERY level. This does not +weaken typo detection — on_zero_mtach does not start with an underscore. + +### `TestR20_UnknownFieldInsideOpRejected` + +SPEC R-20: unknown fields inside an OP object are the dangerous case — the +natural string-or-object implementation loses DisallowUnknownFields the +moment a custom unmarshaler takes over, so this is the test that catches the +regression the UX doc calls out by name. + +### `TestR20_UnknownTopLevelFieldRejected` + +SPEC R-20: an unknown top-level field is a hard error. Silently ignoring +"opps" means the policy that ran is not the policy that was reviewed. + +### `TestR20_VersionZeroRejected` + +SPEC R-20: version 0 is what an absent field decodes to. It must be rejected +for the same reason absence is — otherwise "version": 0 and "no version at +all" become indistinguishable and the marker stops carrying information. + +### `TestR21_AllZeroMatchValuesLegalOnAddOwnerAndSetOwners` + +SPEC R-21: all three values are legal on add_owner and set_owners, including +set_owners with an empty list (the zero-owner rule, S-9) under declare. +The legality table is the contract; a value legal in the docs and rejected by +the parser halts a fleet on repo 0 for no reason. + +### `TestR21_BadOnZeroMatchValueRejected` + +SPEC R-21: the enum is exactly require|skip|declare, case-sensitively. "write" +is the OLD name from revision 1 — accepting it would silently give a fleet +the default behavior under a spelling whose author meant something else, and +the rename would never actually have happened. + +### `TestR21_DeclareRejectedOnRemoveOwner` + +SPEC R-21: `declare` means "write the rule anyway, for files that do not +exist yet". A remove_owner has nothing to write — there is no rule to declare +the absence of an owner on paths that are not there. Reject at parse. + +### `TestR21_ExplicitEmptyOnZeroMatchIsNotTheSameAsAbsent` + +SPEC R-21: an ABSENT on_zero_match means require. An on_zero_match that is +PRESENT and empty is an error. These are two different states of the file and +the parser must be able to tell them apart. + +The distinction is easy to lose: a plain Go struct field decodes both to the +empty string, so an implementation that reads the field and checks its value +cannot see the difference and will accept `"on_zero_match": ""` as a default. +Detecting which keys the file actually contained is therefore part of the +contract, not an implementation preference. + +What it buys: a generator that emits an empty string where it meant to emit +a decision has produced a file that reads, to a human reviewer, as if a +choice was made. Accepting it applies the default across the fleet under a +spelling that says otherwise — the same silent-default failure as the typo, +arriving through the correctly-spelled field. + +### `TestR21_OnZeroMatchAndIDComeFromTheFile` + +SPEC R-21: on_zero_match and id ride on ops.Op, because plan.Build's signature +does not change — the planner learns per-op zero-match behavior only from the +op it is handed. A policy that parsed but left these zero would silently run +the default everywhere. + +### `TestR21_OnZeroMatchRejectedOnRenameOwner` + +SPEC R-21: rename_owner's scope is derived from current ownership, not a +pattern — plan.go exempts it from R-5 entirely, so on_zero_match can never +fire on it. Accepting the field and ignoring it is the same class of failure +as the typo the strictness rule exists to catch. + +### `TestR21_RequireAndSkipAreLegalOnRemoveOwner` + +SPEC R-21: require and skip ARE meaningful on remove_owner — "this repo must +have had @a here" versus "clean it up where it exists". Rejecting them would +make the only fleet-safe removal impossible. + ## internal/resolve +**`resolve_test.go`** + > Package resolve_test defines ownership resolution: mapping every tracked > path to its owner set via the LAST matching rule. -> +> > SPEC S-1: last matching rule wins; owner sets do not union. > SPEC S-9: matching a zero-owner rule yields an explicitly-empty owner set, > distinct from "no rule matched". @@ -924,6 +2460,8 @@ SPEC S-9: GitHub's official example — a zero-owner rule un-owns a subtree. ## internal/verify +**`verify_test.go`** + > Package verify_test defines snapshot comparison (R-18): the invariant can > be checked in CI from two ownership snapshots WITHOUT trusting the tool > that produced the change. @@ -952,4 +2490,4 @@ DIFFERENT states; transitioning between them is a real ownership change. --- -144 documented test cases across 11 packages. +293 documented test cases across 12 packages. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d561b7d..1775959 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,5 +1,5 @@ -// Package cli wires the four commands and owns the exit-code contract -// (R-17). Distinct, documented, scriptable: +// Package cli wires the commands and owns the exit-code contract (R-17). +// Distinct, documented, scriptable: // // 0 success — applied, or audit found nothing // 1 no-op — nothing to change @@ -8,6 +8,10 @@ // 4 audit findings present // 5 inconclusive — API unavailable, token insufficient, rate limited (R-12) // 6 validation failed post-write; rolled back +// +// `sync` and `check` run under a coarser three-code contract of their own +// (R-19: only 0, 2 and 3), because their question is "did this repo converge?" +// rather than "what exactly happened?". See sync.go. package cli import ( @@ -44,6 +48,51 @@ const ( ExitValidation = 6 ) +// flagParseCode maps a flag-parsing failure to an exit code. +// +// `--help` is a request, not a broken invocation. Returning ExitInvalid for it +// made every ` -h` exit 3, which under the fleet contract in sync.go reads +// as "the policy is broken, halt the run" — so all verbs answer it here rather +// than each deciding for itself. +func flagParseCode(err error) int { + if errors.Is(err, flag.ErrHelp) { + return ExitOK + } + return ExitInvalid +} + +// SyncRecord is one `sync` run, rendered as one JSON object (R-24). One line +// per repo is what lets `jq -s` aggregate a fleet without parsing stderr. +type SyncRecord struct { + Repo string `json:"repo"` + Status string `json:"status"` // applied|unchanged|skipped|refused|error + Ops []plan.OpResult `json:"ops,omitempty"` + OpsApplied int `json:"ops_applied"` + OpsSkipped int `json:"ops_skipped"` + PathsChanged int `json:"paths_changed"` + Created bool `json:"created"` + // DryRun marks a record produced under --dry-run. Without it a preview row + // and a row from a real rollout are byte-identical, so an operator holding + // results.jsonl cannot tell whether the fleet was changed or only modelled — + // and neither can the script that reads it. omitempty keeps the common case + // (a real run) at the same six unconditional keys as before. + DryRun bool `json:"dry_run,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Changes []plan.Change `json:"changes,omitempty"` + Error string `json:"error,omitempty"` +} + +// Sync statuses (R-24). "skipped" is distinct from "unchanged" so a policy +// that matches nothing anywhere cannot read as "already correct" across a +// whole fleet. +const ( + StatusApplied = "applied" + StatusUnchanged = "unchanged" + StatusSkipped = "skipped" + StatusRefused = "refused" + StatusError = "error" +) + // planFile is Plan plus the apply-time context the CLI adds. type planFile struct { plan.Plan @@ -59,6 +108,10 @@ func Run(argv []string, stdout, stderr io.Writer) int { return ExitInvalid } switch argv[0] { + case "sync": + return cmdSync(argv[1:], stdout, stderr) + case "check": + return cmdCheck(argv[1:], stdout, stderr) case "plan": return cmdPlan(argv[1:], stdout, stderr) case "apply": @@ -82,6 +135,10 @@ func Run(argv []string, stdout, stderr io.Writer) int { func usage(w io.Writer) { fmt.Fprint(w, `codeowners-tool — safe, intent-level, verifiable CODEOWNERS changes + sync (--op 'OP' ... | --policy FILE) [--on-empty error|inherit|unowned] + [--repo DIR] [--branch REF] [--file PATH] [--create] [--dry-run] + [--format text|json] [--out FILE] [--summary-out FILE] + check (--op 'OP' ... | --policy FILE) [--format text|json] plan --op 'add_owner(/services/api, @org/team-1)' [--op ...] [--on-empty error|inherit|unowned] [--repo DIR] [--branch REF] [--file PATH] [--out plan.json] apply --plan plan.json [--repo DIR] @@ -93,6 +150,8 @@ func usage(w io.Writer) { Exit codes: 0 ok · 1 no-op · 2 refused (invariant/size) · 3 invalid input 4 audit findings · 5 inconclusive (fail-closed) · 6 rolled back +sync/check use a coarser contract and return only: + 0 converged · 2 this repo needs a human · 3 the policy is broken `) } @@ -125,6 +184,15 @@ func locate(repoDir, ref, filePath string) (tree []string, path string, all []st } all = gittree.FindCodeownersPaths(tree) if filePath != "" { + // Same containment guard as `sync` (see containedRelPath): --file is + // documented as repo-relative and is joined onto --repo everywhere, so a + // path that is absolute or climbs out with .. names a file this + // repository does not own. These verbs only ever READ through this path, + // so today the escape merely fails late and obscurely; the guard makes it + // fail at the argument, in the same exit-3 class it already lands in. + if err := containedRelPath(filePath); err != nil { + return nil, "", nil, &plan.InvalidError{Msg: err.Error()} + } return tree, filePath, all, nil } if len(all) == 0 { @@ -133,6 +201,37 @@ func locate(repoDir, ref, filePath string) (tree []string, path string, all []st return tree, all[0], all, nil } +// containedRelPath rejects a --file that names anything outside --repo. +// +// Every caller joins --file onto --repo, so the flag is only meaningful as a +// repo-relative path. Two spellings break that, and both used to be accepted +// silently: +// +// - `--file ../ESCAPED/CODEOWNERS` addresses a sibling of the clone. Under +// `sync --create` that is not a read that fails but a WRITE: os.MkdirAll +// builds the tree and a CODEOWNERS lands outside the repository, reported as +// applied at exit 0. A fleet loop pointed at 100 clones writes 100 files +// into whatever happens to sit next to them. +// - `--file /tmp/x/ABS.txt` is not rejected but REINTERPRETED: filepath.Join +// makes it repo/tmp/x/ABS.txt, so the operator who typed an absolute path +// gets a lookalike tree inside the clone and a success record. +// +// Both are decidable from the argument alone — no repository is opened to know +// them — so they belong to the exit-3 class in sync.go's terms. +func containedRelPath(p string) error { + if p == "" { + return nil + } + if filepath.IsAbs(p) || strings.HasPrefix(p, "/") || filepath.VolumeName(p) != "" { + return fmt.Errorf("--file %q must be repo-relative: an absolute path is not silently reinterpreted, because joining it onto --repo would build a lookalike tree inside the repository and report success", p) + } + clean := filepath.Clean(filepath.FromSlash(p)) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("--file %q escapes the repository: it resolves to %q, outside --repo — with --create the tool would create the directories and write a CODEOWNERS there", p, filepath.ToSlash(clean)) + } + return nil +} + type multiFlag []string func (m *multiFlag) String() string { return strings.Join(*m, ",") } @@ -151,7 +250,7 @@ func cmdPlan(args []string, stdout, stderr io.Writer) int { maxSize := fs.Int("max-size", 3_000_000, "hard size cap in bytes (S-4)") warnSize := fs.Int("warn-size", 2_500_000, "warn threshold in bytes (R-9)") if err := fs.Parse(args); err != nil { - return ExitInvalid + return flagParseCode(err) } if len(opSpecs) == 0 { fmt.Fprintln(stderr, "error: at least one --op is required") @@ -198,7 +297,7 @@ func cmdApply(args []string, stdout, stderr io.Writer) int { planPath := fs.String("plan", "", "plan JSON produced by `plan`") repo := fs.String("repo", "", "path to local git repository (default: plan's repo)") if err := fs.Parse(args); err != nil { - return ExitInvalid + return flagParseCode(err) } if *planPath == "" { fmt.Fprintln(stderr, "error: --plan is required") @@ -232,7 +331,7 @@ func cmdSnapshot(args []string, stdout, stderr io.Writer) int { filePath := fs.String("file", "", "CODEOWNERS path override") out := fs.String("out", "", "write snapshot JSON here (default stdout)") if err := fs.Parse(args); err != nil { - return ExitInvalid + return flagParseCode(err) } tree, coPath, _, err := locate(*repo, *branch, *filePath) if err != nil { @@ -269,7 +368,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { var scopes multiFlag fs.Var(&scopes, "scope", "pattern where change is allowed (repeatable; none = assert no change)") if err := fs.Parse(args); err != nil { - return ExitInvalid + return flagParseCode(err) } if *beforePath == "" || *afterPath == "" { fmt.Fprintln(stderr, "error: --before and --after are required") @@ -315,7 +414,7 @@ func cmdAudit(args []string, stdout, stderr io.Writer) int { cacheDir := fs.String("cache-dir", "", "disk cache directory (R-15); empty = memory only") cacheTTL := fs.Duration("cache-ttl", 24*time.Hour, "disk cache TTL") if err := fs.Parse(args); err != nil { - return ExitInvalid + return flagParseCode(err) } tree, coPath, all, err := locate(*repo, *branch, *filePath) diff --git a/internal/cli/fleet_idempotence_test.go b/internal/cli/fleet_idempotence_test.go new file mode 100644 index 0000000..5b1f209 --- /dev/null +++ b/internal/cli/fleet_idempotence_test.go @@ -0,0 +1,835 @@ +// R-19 — convergence and idempotence at fleet scale. +// +// The failure this file exists to prevent: a scheduled job pushes one policy +// at 100 repositories every night, and every night each run appends the same +// line again. Nothing errors, every exit code is 0, and resolved ownership is +// correct on every pass — so a test that compares OWNERSHIP passes forever +// while the file grows without bound. It ends at S-4's 3 MB cliff, where +// GitHub silently stops loading CODEOWNERS at all: total ownership failure +// across the whole fleet, produced by a job that reported success every night +// on the way there. +// +// This is not hypothetical. The planner's no-op detector is +// `bytes.Equal(afterBytes, content)` (internal/plan/plan.go), gated on a +// `desired` map keyed by TRACKED path. A `declare` op writes a rule for files +// that do not exist yet, so it moves no tracked path's resolution and the +// detector structurally cannot see its line. Ownership-level idempotence is +// therefore the wrong assertion. Every claim below compares BYTES. + +package cli_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/cli" +) + +// --------------------------------------------------------------------------- +// Helpers. All prefixed `idem` — this package is written by several agents at +// once and an unprefixed helper is a compile error for everyone. +// initRepo and runCLI come from cli_test.go and are reused, never redefined. +// --------------------------------------------------------------------------- + +// idemRepo is one fleet member: a name for failure messages and its directory. +type idemRepo struct { + name string + dir string +} + +func idemGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +// idemCommit commits whatever the last sync wrote. That is the real shape of +// the scheduled job: last night's PR is merged, and tonight's run reads the +// merged result. Committing also makes a --create'd file tracked, so the +// second pass can find it at all. +func idemCommit(t *testing.T, dir string) { + t.Helper() + idemGit(t, dir, "add", "-A") + idemGit(t, dir, "commit", "-q", "--allow-empty", "-m", "sync") +} + +// idemOwnersRel returns the repo-relative CODEOWNERS path in S-8 precedence +// order. +func idemOwnersRel(t *testing.T, repo string) string { + t.Helper() + for _, cand := range []string{".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"} { + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(cand))); err == nil { + return cand + } + } + t.Fatalf("no CODEOWNERS file in %s", repo) + return "" +} + +// idemBytes reads the governing CODEOWNERS verbatim. Every idempotence claim +// in this file is made against these bytes, never against resolution. +func idemBytes(t *testing.T, repo string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(idemOwnersRel(t, repo)))) + if err != nil { + t.Fatalf("read CODEOWNERS in %s: %v", repo, err) + } + return b +} + +func idemPolicy(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "policy.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// idemSyncRaw runs `sync --format json` and returns the untouched streams, for +// the cases where no record is expected (a broken policy is exit 3 and emits +// no repo row). +func idemSyncRaw(t *testing.T, repo string, args ...string) (int, string, string) { + t.Helper() + argv := append([]string{"sync", "--repo", repo, "--format", "json"}, args...) + return runCLI(t, argv...) +} + +// idemSync runs one sync and decodes the single JSON record (R-24). +func idemSync(t *testing.T, repo string, args ...string) (int, cli.SyncRecord, string) { + t.Helper() + code, out, errOut := idemSyncRaw(t, repo, args...) + var rec cli.SyncRecord + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rec); err != nil { + t.Fatalf("sync --format json must write exactly one SyncRecord to stdout (exit %d): %v\nstdout: %q\nstderr: %q", + code, err, out, errOut) + } + return code, rec, errOut +} + +// idemPass is one nightly pass: sync, then commit what it wrote. +func idemPass(t *testing.T, repo string, args ...string) (int, cli.SyncRecord) { + t.Helper() + code, rec, _ := idemSync(t, repo, args...) + idemCommit(t, repo) + return code, rec +} + +// idemConverged asserts a repeat pass converged: exit 0 and a status that +// claims nothing was written. "applied" on a repeat pass is the growth bug. +func idemConverged(t *testing.T, name string, pass int, code int, rec cli.SyncRecord) { + t.Helper() + if code != cli.ExitOK { + t.Errorf("%s pass %d: exit %d, want 0 — a converged repo must not fail (status %q, error %q)", + name, pass, code, rec.Status, rec.Error) + } + if rec.Status != cli.StatusUnchanged && rec.Status != cli.StatusSkipped { + t.Errorf("%s pass %d: status %q, want %q or %q — re-running the same policy must not re-apply it", + name, pass, rec.Status, cli.StatusUnchanged, cli.StatusSkipped) + } +} + +// idemSameBytes is the assertion this whole file is built around. +func idemSameBytes(t *testing.T, label string, want, got []byte) { + t.Helper() + if bytes.Equal(want, got) { + return + } + t.Errorf("%s: CODEOWNERS changed on a repeat run (%d → %d bytes)\nbefore:\n%s\nafter:\n%s", + label, len(want), len(got), want, got) +} + +// idemFleetPolicyJSON is one standardized policy pushed at heterogeneous +// repos: one op that must be declared where the directory does not exist yet, +// two that are opportunistic. This is the shape that makes a fleet policy +// applicable everywhere, and the shape that hides unbounded growth. +const idemFleetPolicyJSON = `{ + "version": 1, + "name": "org baseline ownership", + "description": "R-19 fleet convergence fixture", + "ops": [ + { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare" }, + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip" }, + { "id": "docs", "op": "add_owner(/docs/, @org/docs)", "on_zero_match": "skip" } + ] +}` + +// idemFleet builds eight deliberately unalike repos: different CODEOWNERS +// locations (S-8), different line endings, a catch-all rule, a repo the policy +// already satisfies, and repos where each op variously applies, skips, or must +// be declared. A fleet is heterogeneous or it is not a fleet. +func idemFleet(t *testing.T) []idemRepo { + t.Helper() + specs := []struct { + name string + files map[string]string + }{ + // Everything applies: workflows, terraform and docs all present. + {"root-full", map[string]string{ + "CODEOWNERS": "* @org/base\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "resource {}\n", + "docs/guide.md": "# guide\n", + }}, + // Governed by .github/CODEOWNERS, not the root file. + {"dotgithub", map[string]string{ + ".github/CODEOWNERS": "/src/ @org/app\n", + ".github/workflows/ci.yml": "on: push\n", + "src/a.go": "package a\n", + }}, + // Governed by docs/CODEOWNERS, which the docs op also matches. + {"docsdir", map[string]string{ + "docs/CODEOWNERS": "# owners\n", + "docs/guide.md": "# guide\n", + "a.go": "package a\n", + }}, + // Nothing matches: one declare, two skips. + {"bare", map[string]string{ + "CODEOWNERS": "/lib/ @org/lib\n", + "lib/x.go": "package lib\n", + }}, + // CRLF throughout — a line ending re-normalized on every pass is + // growth's quieter sibling. + {"crlf", map[string]string{ + "CODEOWNERS": "# crlf\r\n/api/ @org/api\r\n", + "api/a.go": "package api\n", + "infra/b.tf": "resource {}\n", + "api/README.md": "# api\n", + }}, + // Comments, blank lines, tabs, trailing whitespace (INV-5 surface). + {"messy", map[string]string{ + "CODEOWNERS": "# header\n\n/x/\t@a # inline\n \n", + "x/a.go": "package x\n", + ".github/workflows/w.yml": "on: push\n", + }}, + // Already correct on pass 1 — the common fleet outcome. + {"converged", map[string]string{ + "CODEOWNERS": "/.github/workflows/ @org/ci\n", + ".github/workflows/w.yml": "on: push\n", + "README.md": "# r\n", + }}, + // A catch-all rule: a declared line must land AFTER it, or it is dead. + {"catchall", map[string]string{ + "CODEOWNERS": "* @org/everyone\n", + "main.go": "package main\n", + }}, + } + fleet := make([]idemRepo, 0, len(specs)) + for _, s := range specs { + fleet = append(fleet, idemRepo{name: s.name, dir: initRepo(t, s.files)}) + } + return fleet +} + +// idemTotalSize is the fleet-wide byte total — the number that must not creep. +func idemTotalSize(t *testing.T, fleet []idemRepo) int { + t.Helper() + total := 0 + for _, r := range fleet { + total += len(idemBytes(t, r.dir)) + } + return total +} + +// idemLineIndex returns the index of the first line containing sub, or -1. +func idemLineIndex(content, sub string) int { + for i, line := range strings.Split(content, "\n") { + if strings.Contains(line, sub) { + return i + } + } + return -1 +} + +// idemCountLines counts lines containing sub — one is convergence, two is the +// bug this file is named after. +func idemCountLines(content, sub string) int { + n := 0 + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, sub) { + n++ + } + } + return n +} + +// --------------------------------------------------------------------------- +// The headline claims. +// --------------------------------------------------------------------------- + +// SPEC R-19: one policy, eight unalike repos, run twice. After the second pass +// every repo must report a status that claims nothing was written, exit 0, and +// hold a BYTE-IDENTICAL CODEOWNERS. Comparing resolved ownership instead would +// pass while each file grew by a line a night: a `declare` line matches no +// tracked path, so it moves no path's resolution and the planner's +// `bytes.Equal(afterBytes, content)` no-op detector never sees it. +func TestR19_FleetTwoPassesByteIdentical(t *testing.T) { + policy := idemPolicy(t, idemFleetPolicyJSON) + fleet := idemFleet(t) + + after1 := map[string][]byte{} + for _, r := range fleet { + code, rec := idemPass(t, r.dir, "--policy", policy) + if code != cli.ExitOK { + t.Fatalf("%s pass 1: exit %d, want 0 (status %q, error %q) — every op is skip or declare, so no repo may refuse", + r.name, code, rec.Status, rec.Error) + } + after1[r.name] = idemBytes(t, r.dir) + } + + for _, r := range fleet { + code, rec := idemPass(t, r.dir, "--policy", policy) + idemConverged(t, r.name, 2, code, rec) + idemSameBytes(t, r.name+" pass 2", after1[r.name], idemBytes(t, r.dir)) + if rec.PathsChanged != 0 { + t.Errorf("%s pass 2: paths_changed = %d, want 0", r.name, rec.PathsChanged) + } + } +} + +// SPEC R-19: the same fleet, five passes. Two passes is not enough evidence — +// a bug that appends only on odd passes, or one that flips a file between two +// spellings (line endings, owner order, a re-sorted rule), is byte-identical +// every other run and survives a two-run test forever. Five passes catch both: +// every pass from the second on must be byte-identical to the second, and no +// file may ever be larger than it was after pass 1. +func TestR19_FleetFivePassesNeverGrow(t *testing.T) { + policy := idemPolicy(t, idemFleetPolicyJSON) + fleet := idemFleet(t) + + snaps := map[string][][]byte{} + for pass := 1; pass <= 5; pass++ { + for _, r := range fleet { + code, rec := idemPass(t, r.dir, "--policy", policy) + if pass == 1 { + if code != cli.ExitOK { + t.Fatalf("%s pass 1: exit %d (status %q, error %q)", r.name, code, rec.Status, rec.Error) + } + } else { + idemConverged(t, r.name, pass, code, rec) + } + snaps[r.name] = append(snaps[r.name], idemBytes(t, r.dir)) + } + } + + for _, r := range fleet { + s := snaps[r.name] + for i := 2; i < len(s); i++ { + idemSameBytes(t, fmt.Sprintf("%s pass %d vs pass 2", r.name, i+1), s[1], s[i]) + } + for i := 1; i < len(s); i++ { + if len(s[i]) > len(s[0]) { + t.Errorf("%s: pass %d is %d bytes, pass 1 was %d — the file grows every run; at a line a night this reaches S-4's 3 MB cliff and GitHub stops loading it", + r.name, i+1, len(s[i]), len(s[0])) + } + } + } +} + +// SPEC R-19 (S-4): the fleet's TOTAL CODEOWNERS size after five passes must +// equal its total after one. Size is the number the failure is measured in: +// past 3 MB GitHub silently stops loading a CODEOWNERS file entirely, so an +// unbounded appender does not degrade ownership, it deletes it — with no +// error, on every repo the job ever touched. +func TestR19_FleetSizeStableAfterFivePasses(t *testing.T) { + policy := idemPolicy(t, idemFleetPolicyJSON) + fleet := idemFleet(t) + + for _, r := range fleet { + if code, rec := idemPass(t, r.dir, "--policy", policy); code != cli.ExitOK { + t.Fatalf("%s pass 1: exit %d (status %q, error %q)", r.name, code, rec.Status, rec.Error) + } + } + size1 := idemTotalSize(t, fleet) + + for pass := 2; pass <= 5; pass++ { + for _, r := range fleet { + code, rec := idemPass(t, r.dir, "--policy", policy) + idemConverged(t, r.name, pass, code, rec) + } + } + if size5 := idemTotalSize(t, fleet); size5 != size1 { + t.Errorf("fleet total CODEOWNERS size: %d bytes after pass 1, %d after pass 5 (+%d) — unbounded growth ends at S-4's 3 MB cliff, where GitHub stops loading the file at all", + size1, size5, size5-size1) + } +} + +// --------------------------------------------------------------------------- +// Per-op-kind idempotence. Each kind synthesizes edits differently, so each +// gets its own two-run byte comparison. +// --------------------------------------------------------------------------- + +// SPEC R-19 (INV-4): add_owner run twice is byte-identical. A second append of +// the same owner would be invisible in resolution — `@a @b @b` resolves to the +// same set as `@a @b` — and would grow the line on every nightly run. +func TestR19_AddOwnerTwiceByteIdentical(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + code, rec := idemPass(t, repo, "--op", "add_owner(/x/, @b)") + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + + code, rec = idemPass(t, repo, "--op", "add_owner(/x/, @b)") + idemConverged(t, "add_owner", 2, code, rec) + idemSameBytes(t, "add_owner", first, idemBytes(t, repo)) + if n := idemCountLines(string(first), "@b"); n != 1 { + t.Errorf("@b appears on %d lines, want 1", n) + } +} + +// SPEC R-19: set_owners run twice is byte-identical, including the S-9 +// zero-owner form `set_owners(scope, [])`. The empty list is the dangerous +// one: it writes a rule with no owners, so "did this already apply?" cannot be +// answered by asking who owns the path — both states answer "nobody". +func TestR19_SetOwnersTwiceByteIdentical(t *testing.T) { + t.Run("named_owners", func(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n*.tf @infra\n", + "x/main.tf": "resource {}\n", + "x/app.go": "package x\n", + "other.tf": "resource {}\n", + }) + code, rec := idemPass(t, repo, "--op", "set_owners(/x/, [@b, @c])") + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + + code, rec = idemPass(t, repo, "--op", "set_owners(/x/, [@b, @c])") + idemConverged(t, "set_owners", 2, code, rec) + idemSameBytes(t, "set_owners", first, idemBytes(t, repo)) + }) + + t.Run("empty_list_S9", func(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + code, rec := idemPass(t, repo, "--op", "set_owners(/x/, [])") + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + + code, rec = idemPass(t, repo, "--op", "set_owners(/x/, [])") + idemConverged(t, "set_owners([])", 2, code, rec) + idemSameBytes(t, "set_owners([])", first, idemBytes(t, repo)) + }) +} + +// SPEC R-19 (R-6): remove_owner run twice is byte-identical under every +// --on-empty policy. `inherit` deletes a rule and `unowned` writes a +// zero-owner one — two different files that resolve identically, which is +// exactly the pair a resolution-level idempotence check cannot tell apart. The +// second run, where the owner is already gone, must write nothing at all. +func TestR19_RemoveOwnerTwiceEachOnEmptyPolicy(t *testing.T) { + cases := []struct { + policy string + files map[string]string + op string + }{ + // Removal does not empty the set, so the policy never fires. + {"error", map[string]string{ + "CODEOWNERS": "/x/ @a @b\n", + "x/a.go": "package x\n", + }, "remove_owner(/x/, @b)"}, + // Removal empties the set: the rule goes away and @base is inherited. + {"inherit", map[string]string{ + "CODEOWNERS": "* @base\n/x/ @a\n", + "x/a.go": "package x\n", + "y.txt": "y\n", + }, "remove_owner(/x/, @a)"}, + // Removal empties the set: the path becomes explicitly unowned. + {"unowned", map[string]string{ + "CODEOWNERS": "* @base\n/x/ @a\n", + "x/a.go": "package x\n", + "y.txt": "y\n", + }, "remove_owner(/x/, @a)"}, + } + for _, c := range cases { + t.Run(c.policy, func(t *testing.T) { + repo := initRepo(t, c.files) + code, rec := idemPass(t, repo, "--op", c.op, "--on-empty", c.policy) + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + + code, rec = idemPass(t, repo, "--op", c.op, "--on-empty", c.policy) + idemConverged(t, "remove_owner --on-empty="+c.policy, 2, code, rec) + idemSameBytes(t, "remove_owner --on-empty="+c.policy, first, idemBytes(t, repo)) + }) + } +} + +// SPEC R-19: rename_owner run twice is byte-identical. +// +// What a regression here costs: the nightly job opens a pull request against +// every repository in the org, every night, forever — each one a rewrite of a +// CODEOWNERS file whose content did not actually change. The reviewers are the +// code owners the file names, so they are the people paged for it. They learn +// within a week that anything from this job is a no-op, stop reading it, and +// the night it proposes a real ownership change it is rubber-stamped along with +// the noise. Nothing ever errors and no exit code is ever non-zero. +// +// rename_owner reaches that failure first, because it derives its scope from +// CURRENT ownership: after pass 1 the old name owns nothing, so pass 2 runs +// with an empty scope — and an empty scope that synthesizes an edit anyway +// rewrites the file with byte-different, semantically identical content. +func TestR19_RenameOwnerTwiceByteIdentical(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @old\n/y/ @old @other\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + code, rec := idemPass(t, repo, "--op", "rename_owner(@old, @new)") + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + if strings.Contains(string(first), "@old") { + t.Fatalf("pass 1 left @old behind:\n%s", first) + } + + code, rec = idemPass(t, repo, "--op", "rename_owner(@old, @new)") + idemConverged(t, "rename_owner", 2, code, rec) + idemSameBytes(t, "rename_owner re-run", first, idemBytes(t, repo)) + if n := idemCountLines(string(first), "@new"); n != 2 { + t.Errorf("@new appears on %d lines, want 2 (one per renamed rule)", n) + } +} + +// SPEC R-19/R-20: `rename_owner(@a, @a)` is exit 3, on every run, and writes +// nothing. +// +// This exit is not a toss-up between 0 and 3. ops.Parse rejects a rename whose +// old and new names are equal, and it reaches that verdict from the op string +// alone — before --repo is opened, before the tree is listed, before a byte of +// CODEOWNERS is read. A verdict that consults no repository is by construction +// the same verdict on all 100 of them, and that is precisely sync's definition +// of the exit-3 class: halt at repo 0 rather than grind out 100 identical +// errors. Exit 0 would be wrong for the same reason — "nothing to do, carry on" +// sends a scheduled job through every clone in the org to rediscover a fact the +// first repo already proved, and hides a typo'd rename inside a green run. +// +// The self-rename is also the churn case it is named for: a no-difference +// rewrite that a nightly job would otherwise apply, commit and PR forever. So +// the bytes are checked on both runs as well. Per D8 an exit-3 run emits NO +// record, which is why this reads the raw streams instead of decoding one. +func TestR19_RenameOwnerSelfRenameNeverWrites(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + original := idemBytes(t, repo) + + for run := 1; run <= 2; run++ { + code, stdout, errOut := idemSyncRaw(t, repo, "--op", "rename_owner(@a, @a)") + if code != cli.ExitInvalid { + t.Errorf("self-rename run %d: exit %d, want 3 — ops.Parse rejects it without reading the repo, so it fails identically everywhere\nstderr: %s", + run, code, errOut) + } + if strings.TrimSpace(stdout) != "" { + t.Errorf("self-rename run %d: exit 3 must emit no record — a {\"status\":...} line reports a phantom repo to `jq -s`\ngot: %q", + run, stdout) + } + if strings.TrimSpace(errOut) == "" { + t.Errorf("self-rename run %d: exit 3 must explain itself on stderr, or the halted fleet run says nothing about why", run) + } + idemSameBytes(t, fmt.Sprintf("self-rename run %d", run), original, idemBytes(t, repo)) + } +} + +// --------------------------------------------------------------------------- +// on_zero_match: one test per value. `declare` is the critical one. +// --------------------------------------------------------------------------- + +// SPEC R-19/R-21: `on_zero_match: require` is stable across runs. A scope that +// matches nothing fails THIS repo (exit 2 — whether a path exists is the most +// repo-specific fact there is) and must leave the file untouched, identically, +// every night. A refusal that half-wrote something would be worse than the +// growth bug. +func TestR19_ZeroMatchRequireStableAcrossRuns(t *testing.T) { + policy := idemPolicy(t, `{ + "version": 1, + "ops": [ { "id": "ghost", "op": "add_owner(/ghost/, @org/ghost)", "on_zero_match": "require" } ] +}`) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + original := idemBytes(t, repo) + + for pass := 1; pass <= 2; pass++ { + code, rec, _ := idemSync(t, repo, "--policy", policy) + if code != cli.ExitRefused { + t.Errorf("pass %d: exit %d, want 2 — a zero-match under `require` is this repo's problem, not the policy's (status %q)", + pass, code, rec.Status) + } + if rec.Status != cli.StatusRefused { + t.Errorf("pass %d: status %q, want %q", pass, rec.Status, cli.StatusRefused) + } + idemSameBytes(t, fmt.Sprintf("require pass %d", pass), original, idemBytes(t, repo)) + } +} + +// SPEC R-19/R-21: `on_zero_match: skip` is a no-op on every run — exit 0, +// status "skipped" (never "unchanged": a policy that matches nothing anywhere +// must not read as "100 repos already correct"), and the file untouched. Twice, +// because a skip that quietly wrote a rule anyway would be growth with a +// reassuring status attached. +func TestR19_ZeroMatchSkipIsNoOpBothRuns(t *testing.T) { + policy := idemPolicy(t, `{ + "version": 1, + "ops": [ { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip" } ] +}`) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + original := idemBytes(t, repo) + + for pass := 1; pass <= 2; pass++ { + code, rec := idemPass(t, repo, "--policy", policy) + if code != cli.ExitOK { + t.Errorf("pass %d: exit %d, want 0 (status %q, error %q)", pass, code, rec.Status, rec.Error) + } + if rec.Status != cli.StatusSkipped { + t.Errorf("pass %d: status %q, want %q", pass, rec.Status, cli.StatusSkipped) + } + if rec.OpsSkipped != 1 || rec.OpsApplied != 0 { + t.Errorf("pass %d: ops_applied=%d ops_skipped=%d, want 0 and 1", pass, rec.OpsApplied, rec.OpsSkipped) + } + idemSameBytes(t, fmt.Sprintf("skip pass %d", pass), original, idemBytes(t, repo)) + } +} + +// SPEC R-19/R-21/INV-6: `on_zero_match: declare` writes its rule exactly ONCE +// and never again. This is the case the existing no-op detector cannot see: a +// declared rule matches no tracked file, so it changes no tracked path's +// resolution, and `bytes.Equal(afterBytes, content)` in plan.Build compares a +// file the declare line was already appended to. Four passes, one line: the +// difference between a policy that converges and a nightly job that adds a +// line to 100 CODEOWNERS files until GitHub stops loading them (S-4). +func TestR19_ZeroMatchDeclareWritesOnceThenNever(t *testing.T) { + policy := idemPolicy(t, `{ + "version": 1, + "ops": [ { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare" } ] +}`) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "/x/ @a\n", + "x/a.go": "package x\n", + }) + original := idemBytes(t, repo) + + code, rec := idemPass(t, repo, "--policy", policy) + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + if rec.Status != cli.StatusApplied { + t.Errorf("pass 1: status %q, want %q — declare writes the rule", rec.Status, cli.StatusApplied) + } + if len(rec.Ops) != 1 || rec.Ops[0].Proven != "structural" { + t.Errorf("pass 1: ops = %+v, want one op proven %q (INV-6: nothing in the tree to prove it against)", + rec.Ops, "structural") + } + first := idemBytes(t, repo) + if bytes.Equal(first, original) { + t.Fatalf("pass 1 wrote nothing; declare must write the rule for files that do not exist yet") + } + + for pass := 2; pass <= 4; pass++ { + code, rec := idemPass(t, repo, "--policy", policy) + idemConverged(t, "declare", pass, code, rec) + idemSameBytes(t, fmt.Sprintf("declare pass %d", pass), first, idemBytes(t, repo)) + } + if n := idemCountLines(string(idemBytes(t, repo)), "@org/ci"); n != 1 { + t.Errorf("@org/ci appears on %d lines after four passes, want exactly 1 — this is the unbounded-growth defect", n) + } +} + +// SPEC R-19: a realistic policy — three declared rules interleaved with two +// ordinary ops — is byte-identical on a second run AND keeps its declared +// lines in the same relative order. Re-ordering is a silent correctness change: +// the last matching rule wins in CODEOWNERS, so two runs that shuffle appended +// rules produce different ownership from the same policy, and every nightly run +// churns a diff for reviewers. +func TestR19_MixedPolicyDeclareLinesDoNotReorder(t *testing.T) { + policy := idemPolicy(t, `{ + "version": 1, + "name": "mixed", + "ops": [ + { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare" }, + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "declare" }, + { "id": "charts", "op": "add_owner(/charts/, @org/k8s)", "on_zero_match": "declare" }, + "add_owner(/src/, @org/app)", + "add_owner(/lib/, @org/lib)" + ] +}`) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "* @org/base\n", + "src/a.go": "package a\n", + "lib/b.go": "package lib\n", + }) + + code, rec := idemPass(t, repo, "--policy", policy) + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := idemBytes(t, repo) + + declared := []string{"@org/ci", "@org/infra", "@org/k8s"} + order1 := make([]int, len(declared)) + for i, owner := range declared { + order1[i] = idemLineIndex(string(first), owner) + if order1[i] < 0 { + t.Fatalf("pass 1 did not declare %s:\n%s", owner, first) + } + if n := idemCountLines(string(first), owner); n != 1 { + t.Errorf("pass 1: %s on %d lines, want 1", owner, n) + } + } + for i := 1; i < len(order1); i++ { + if order1[i] <= order1[i-1] { + t.Errorf("declared rules are out of policy order after pass 1: %s at line %d, %s at line %d\n%s", + declared[i-1], order1[i-1], declared[i], order1[i], first) + } + } + + code, rec = idemPass(t, repo, "--policy", policy) + idemConverged(t, "mixed policy", 2, code, rec) + second := idemBytes(t, repo) + idemSameBytes(t, "mixed policy", first, second) + for i, owner := range declared { + if got := idemLineIndex(string(second), owner); got != order1[i] { + t.Errorf("%s moved from line %d to line %d between identical passes — declared rules must not reorder, the last matching rule wins", + owner, order1[i], got) + } + } +} + +// SPEC R-19: convergence from a partially-applied state. A file that already +// satisfies SOME of the policy — the normal state of a fleet mid-rollout, or +// of a repo a human edited by hand — must converge in ONE pass, leave the +// already-satisfied lines byte-untouched (INV-5), and be a no-op on the next. +// A tool that only converges after N passes has no fixed point a scheduled job +// can ever reach. +func TestR19_ConvergesFromPartiallyAppliedState(t *testing.T) { + policy := idemPolicy(t, `{ + "version": 1, + "ops": [ + "add_owner(/x/, @b)", + "add_owner(/y/, @d)" + ] +}`) + repo := initRepo(t, map[string]string{ + // /x/ already satisfies op 1; /y/ does not satisfy op 2. + "CODEOWNERS": "# owners\n/x/ @a @b\n/y/ @c\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + + code, rec := idemPass(t, repo, "--policy", policy) + if code != cli.ExitOK { + t.Fatalf("pass 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + first := string(idemBytes(t, repo)) + lines := strings.Split(first, "\n") + if len(lines) < 3 || lines[0] != "# owners" || lines[1] != "/x/ @a @b" { + t.Errorf("pass 1 rewrote lines it did not need to touch (INV-5):\n%s", first) + } + if idemLineIndex(first, "@d") < 0 { + t.Errorf("pass 1 did not converge the unsatisfied op — @d is absent:\n%s", first) + } + if n := idemCountLines(first, "@b"); n != 1 { + t.Errorf("@b on %d lines after pass 1, want 1 (it was already there)", n) + } + + code, rec = idemPass(t, repo, "--policy", policy) + idemConverged(t, "partially-applied", 2, code, rec) + idemSameBytes(t, "partially-applied", []byte(first), idemBytes(t, repo)) +} + +// SPEC R-19: repeated --dry-run runs change nothing and say the same thing +// every time. --dry-run is the fleet preview — the only review step that +// exists at 100 repos — so a preview that differs run to run, or that writes +// while previewing, makes the review meaningless. +func TestR19_DryRunRepeatedIsInert(t *testing.T) { + policy := idemPolicy(t, idemFleetPolicyJSON) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "* @org/base\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "resource {}\n", + }) + original := idemBytes(t, repo) + + var firstRecord string + for run := 1; run <= 3; run++ { + code, rec, _ := idemSync(t, repo, "--policy", policy, "--dry-run") + if code != cli.ExitOK { + t.Errorf("dry run %d: exit %d, want 0 (status %q, error %q)", run, code, rec.Status, rec.Error) + } + idemSameBytes(t, fmt.Sprintf("dry run %d", run), original, idemBytes(t, repo)) + b, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + if run == 1 { + firstRecord = string(b) + continue + } + if string(b) != firstRecord { + t.Errorf("dry run %d reported differently from dry run 1:\n%s\n%s", run, firstRecord, b) + } + } +} + +// SPEC R-19: the actual scheduled-job shape — sync, merge the result, and let +// the NEXT run read what the last one wrote. This is the loop that turns a +// one-line-per-run bug into a 3 MB file, and it is the only test here where +// the second run's input is genuinely the first run's committed output rather +// than a working-tree edit. +func TestR19_SequentialSyncsReadPreviousOutput(t *testing.T) { + policy := idemPolicy(t, idemFleetPolicyJSON) + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "# baseline\n* @org/base\n", + ".github/workflows/ci.yml": "on: push\n", + "docs/guide.md": "# guide\n", + }) + + code, rec := idemPass(t, repo, "--policy", policy) + if code != cli.ExitOK { + t.Fatalf("run 1: exit %d (status %q, error %q)", code, rec.Status, rec.Error) + } + rel := idemOwnersRel(t, repo) + committed := idemGit(t, repo, "show", "HEAD:"+rel) + if committed != string(idemBytes(t, repo)) { + t.Fatalf("run 1's output was not committed intact; the second run would not read it") + } + + code, rec = idemPass(t, repo, "--policy", policy) + idemConverged(t, "scheduled job", 2, code, rec) + if after := idemGit(t, repo, "show", "HEAD:"+rel); after != committed { + t.Errorf("run 2 rewrote the committed file (%d → %d bytes) — every night this opens another no-op PR and grows the file\nbefore:\n%s\nafter:\n%s", + len(committed), len(after), committed, after) + } + if len(rec.Changes) != 0 { + t.Errorf("run 2 reported %d line change(s), want 0", len(rec.Changes)) + } +} diff --git a/internal/cli/fleet_test.go b/internal/cli/fleet_test.go new file mode 100644 index 0000000..91aa24f --- /dev/null +++ b/internal/cli/fleet_test.go @@ -0,0 +1,998 @@ +package cli_test + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/cli" + "github.com/jordonpeterson/codeowners-tool/internal/plan" +) + +// --------------------------------------------------------------------------- +// The synthetic fleet. +// +// Every test in this file runs ONE policy across a set of deliberately +// heterogeneous repositories, exactly as the README's fleet script does. The +// repos are the shapes a real 100-repo rollout meets on day one: repos that +// converge, repos that are already correct, repos missing the directory an op +// names, repos with no CODEOWNERS at all, repos whose file shape makes the +// intent inexpressible. The fleet is the unit under test — no single repo's +// outcome may change any other repo's outcome. +// --------------------------------------------------------------------------- + +// fleetPolicySrc is the worked example from the UX spec: one mandatory op that +// every repo is expected to satisfy, one opportunistic op that skips where the +// files do not exist, one baseline op that is declared for files a repo does +// not have yet. Every op carries an explicit id, because the per-repo records +// are keyed by it. +const fleetPolicySrc = `{ + "version": 1, + "name": "org baseline ownership", + "description": "CI owns workflows everywhere; infra owns Terraform where it exists.", + "ops": [ + { "id": "api", "op": "add_owner(/services/api/, @org/api-team)", + "note": "Every service repo has one." }, + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip", + "note": "Opportunistic — only repos that actually have Terraform." }, + { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare", + "note": "Baseline; also covers workflows added later." } + ] +}` + +// fleetTypoPolicySrc is the failure the `skipped` status exists to make +// visible: a policy whose path prefixes are misspelled matches nothing in any +// repo. Both ops use the bare-string-in-an-object form with no id, so this +// also exercises the shorthand alongside fleetPolicySrc's fully-specified ops. +const fleetTypoPolicySrc = `{ + "version": 1, + "name": "typo'd prefixes", + "ops": [ + { "op": "add_owner(/servces/api/, @org/api-team)", "on_zero_match": "skip" }, + { "op": "add_owner(/.githbu/workflows/, @org/ci)", "on_zero_match": "skip" } + ] +}` + +// fleetBrokenPolicySrc is the exit-3 class in its most dangerous form: a typo +// in a field name. Silently defaulting `on_zero_mtach` would apply the wrong +// policy to all 100 repos at once, which is the whole reason unknown fields +// are fatal. +const fleetBrokenPolicySrc = `{ + "version": 1, + "ops": [ + { "id": "api", "op": "add_owner(/services/api/, @org/api-team)", "on_zero_mtach": "skip" } + ] +}` + +// fleetShape is one repository in the synthetic fleet, plus what the fleet +// policy must do to it. +type fleetShape struct { + name string + files map[string]string + // wantStatus is the record's status under fleetPolicySrc with --create. + wantStatus string + // wantExit is sync's exit code for this repo under the same run. + wantExit int +} + +// fleetShapes is the fleet. Each entry is a shape a standardized policy meets +// across a real org; together they cover every branch of the exit contract. +func fleetShapes() []fleetShape { + return []fleetShape{ + { + // Plain: has a CODEOWNERS, has every path the policy names. + // All three ops apply against real tracked files. + name: "plain", + files: map[string]string{ + ".github/CODEOWNERS": "# owners\n/services/ @org/platform\n", + "services/api/main.go": "package api\n", + "services/web/app.js": "//\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "# tf\n", + }, + wantStatus: cli.StatusApplied, + wantExit: cli.ExitOK, + }, + { + // Already correct: every op is already true. The common fleet + // outcome — it must be exit 0 and must not rewrite the file. + name: "correct", + files: map[string]string{ + ".github/CODEOWNERS": "/services/api/ @org/api-team\n**/*.tf @org/infra\n/.github/workflows/ @org/ci\n", + "services/api/main.go": "package api\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "# tf\n", + }, + wantStatus: cli.StatusUnchanged, + wantExit: cli.ExitOK, + }, + { + // No Terraform: the `tf` op (on_zero_match=skip) matches nothing. + // That op skips; the other two still apply. + name: "no-tf", + files: map[string]string{ + ".github/CODEOWNERS": "# owners\n/services/ @org/platform\n", + "services/api/main.go": "package api\n", + ".github/workflows/ci.yml": "on: push\n", + }, + wantStatus: cli.StatusApplied, + wantExit: cli.ExitOK, + }, + { + // No CODEOWNERS at all. Exit 2 without --create; the fleet run + // passes --create, so here it converges and the file is created. + name: "no-file", + files: map[string]string{ + "services/api/main.go": "package api\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "# tf\n", + }, + wantStatus: cli.StatusApplied, + wantExit: cli.ExitOK, + }, + { + // A `*` catch-all and no workflows directory: the `ci` op is + // declared for files that do not exist yet, and must land at EOF + // where the catch-all cannot shadow it (INV-6). + name: "catchall", + files: map[string]string{ + ".github/CODEOWNERS": "* @org/eng\n", + "services/api/main.go": "package api\n", + "infra/main.tf": "# tf\n", + }, + wantStatus: cli.StatusApplied, + wantExit: cli.ExitOK, + }, + { + // Inexpressible: the unanchored rule "infra/" governs paths both + // inside and outside `**/*.tf`, and no sound narrowing pattern is + // derivable — amending breaks INV-2, appending breaks INV-1. + name: "refuse", + files: map[string]string{ + ".github/CODEOWNERS": "infra/ @infra-legacy\n", + "services/api/main.go": "package api\n", + "infra/main.tf": "# tf\n", + "infra/README.md": "# infra\n", + ".github/workflows/ci.yml": "on: push\n", + }, + wantStatus: cli.StatusRefused, + wantExit: cli.ExitRefused, + }, + { + // No /services/api/ at all: the `api` op carries the default + // on_zero_match=require, so this repo needs a human — exit 2, and + // emphatically NOT exit 3, which would halt the whole fleet. + name: "zero-require", + files: map[string]string{ + ".github/CODEOWNERS": "# owners\n/lib/ @org/eng\n", + "lib/util.go": "package lib\n", + ".github/workflows/ci.yml": "on: push\n", + "infra/main.tf": "# tf\n", + }, + wantStatus: cli.StatusRefused, + wantExit: cli.ExitRefused, + }, + } +} + +// fleetBuild materializes the named shapes as real git repositories and +// returns name -> repo directory, plus the processing order. +func fleetBuild(t *testing.T, names ...string) (map[string]string, []string) { + t.Helper() + want := map[string]bool{} + for _, n := range names { + want[n] = true + } + dirs := map[string]string{} + var order []string + for _, s := range fleetShapes() { + if len(names) > 0 && !want[s.name] { + continue + } + dirs[s.name] = initRepo(t, s.files) + order = append(order, s.name) + } + if len(names) > 0 && len(order) != len(names) { + t.Fatalf("fleetBuild: asked for %v, built %v — unknown shape name", names, order) + } + return dirs, order +} + +// fleetPolicyFile writes a policy OUTSIDE every clone — the README is explicit +// that anything written inside one is a `git add -A` away from being +// committed. +func fleetPolicyFile(t *testing.T, src string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "policy.json") + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// fleetCodeowners returns a repo's CODEOWNERS content, or ok=false when the +// repo has none. It looks in every location the tool itself considers. +func fleetCodeowners(t *testing.T, dir string) (string, bool) { + t.Helper() + for _, rel := range []string{".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"} { + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + if err == nil { + return string(b), true + } + } + return "", false +} + +// fleetSnapshot records every repo's CODEOWNERS bytes so a test can prove a +// whole fleet run wrote nothing where it must not. +func fleetSnapshot(t *testing.T, dirs map[string]string) map[string]string { + t.Helper() + snap := map[string]string{} + for name, dir := range dirs { + content, ok := fleetCodeowners(t, dir) + if !ok { + snap[name] = "\x00absent" + continue + } + snap[name] = content + } + return snap +} + +// fleetOutcome is one pass of the README's loop over the fleet. A code of -1 +// means the loop never reached that repo. +type fleetOutcome struct { + codes map[string]int + stderr map[string]string + processed []string // repos the loop actually reached, in order + halted string // repo whose exit code aborted the loop; "" if none + jsonl string // the concatenated records, i.e. results.jsonl +} + +// fleetRunPolicy walks the fleet exactly as the README's script does: run +// sync, append stdout to results.jsonl, continue on 0 and 2, and abort the +// whole run on anything else. The abort is the contract under test — a fleet +// script cannot be more forgiving than the tool's exit codes let it be. +func fleetRunPolicy(t *testing.T, dirs map[string]string, order []string, policy string, extra ...string) fleetOutcome { + t.Helper() + out := fleetOutcome{codes: map[string]int{}, stderr: map[string]string{}} + for _, name := range order { + // -1 until the loop reaches it, so an unprocessed repo can never be + // mistaken for one that exited 0. + out.codes[name] = -1 + } + for _, name := range order { + args := append([]string{"sync", "--repo", dirs[name], "--policy", policy, "--format", "json"}, extra...) + code, stdout, errOut := runCLI(t, args...) + out.jsonl += stdout + out.codes[name] = code + out.stderr[name] = errOut + out.processed = append(out.processed, name) + if code != cli.ExitOK && code != cli.ExitRefused { + out.halted = name + return out + } + } + return out +} + +// fleetRecords parses results.jsonl the way `jq -s` does: one object per line, +// nothing trailing on any line. A stray log line or a pretty-printed record +// breaks every fleet consumer, so the parse itself is an assertion. +func fleetRecords(t *testing.T, jsonl string) []cli.SyncRecord { + t.Helper() + var recs []cli.SyncRecord + if jsonl == "" { + return recs + } + if !strings.HasSuffix(jsonl, "\n") { + t.Errorf("results.jsonl must end with a newline, or `>>` concatenates two records into one line:\n%q", jsonl) + } + for i, line := range strings.Split(strings.TrimSuffix(jsonl, "\n"), "\n") { + dec := json.NewDecoder(strings.NewReader(line)) + var rec cli.SyncRecord + if err := dec.Decode(&rec); err != nil { + t.Fatalf("results.jsonl line %d is not valid JSON: %v\nraw: %s", i+1, err, line) + } + var trailing json.RawMessage + if err := dec.Decode(&trailing); err != io.EOF { + t.Fatalf("results.jsonl line %d must hold exactly one object, got trailing %s\nraw: %s", i+1, trailing, line) + } + recs = append(recs, rec) + } + return recs +} + +// fleetByRepo keys the records by the --repo value each run was given. The +// record must name its repo, or an aggregation over 100 lines cannot say +// WHICH repo needs a human. +func fleetByRepo(t *testing.T, recs []cli.SyncRecord, dirs map[string]string) map[string]cli.SyncRecord { + t.Helper() + byPath := map[string]cli.SyncRecord{} + for _, r := range recs { + byPath[r.Repo] = r + } + out := map[string]cli.SyncRecord{} + for name, dir := range dirs { + rec, ok := byPath[dir] + if !ok { + t.Errorf("no record whose .repo is %q (repo %s); records name %v", dir, name, byPath) + continue + } + out[name] = rec + } + return out +} + +// fleetGroupByStatus mirrors `jq -s 'group_by(.status)|map({status, n:length})'` +// — the last line of the README's script, and the only view an operator of a +// 100-repo rollout actually reads. +func fleetGroupByStatus(recs []cli.SyncRecord) map[string]int { + counts := map[string]int{} + for _, r := range recs { + counts[r.Status]++ + } + return counts +} + +// fleetOpResult finds one per-op result by policy id. +func fleetOpResult(t *testing.T, rec cli.SyncRecord, id string) plan.OpResult { + t.Helper() + for _, r := range rec.Ops { + if r.ID == id { + return r + } + } + t.Fatalf("record for %q has no op result with id %q: %+v", rec.Repo, id, rec.Ops) + return plan.OpResult{} +} + +// fleetRuleLines returns the CODEOWNERS rule lines (comments and blanks +// dropped) in file order — enough to reason about last-match-wins. +func fleetRuleLines(content string) []string { + var out []string + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + out = append(out, trimmed) + } + return out +} + +// SPEC R-19/R-20/R-24: one policy, one pass, seven deliberately different +// repositories. This is the whole product claim in a single test — that a +// standardized policy can be pushed at a heterogeneous fleet, that each repo +// gets the outcome its own shape earns, and that the aggregate is readable +// afterwards. If any single repo's failure can change another repo's outcome, +// the fleet script in the README is a lie. +func TestFleet_OnePolicyAcrossHeterogeneousRepos(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t) + policy := fleetPolicyFile(t, fleetPolicySrc) + before := fleetSnapshot(t, dirs) + + out := fleetRunPolicy(t, dirs, order, policy, "--create") + + // The run COMPLETES. A refusal at repo 6 must not cost you repo 7. + t.Run("run completes", func(t *testing.T) { + if out.halted != "" { + t.Fatalf("fleet halted at %q (exit %d): no per-repo outcome may abort the run\nstderr: %s", + out.halted, out.codes[out.halted], out.stderr[out.halted]) + } + if !reflect.DeepEqual(out.processed, order) { + t.Fatalf("processed %v, want every repo in order %v", out.processed, order) + } + }) + + // Exactly the expected status and exit code per repo. + t.Run("per-repo statuses", func(t *testing.T) { + recs := fleetRecords(t, out.jsonl) + byRepo := fleetByRepo(t, recs, dirs) + for _, s := range fleetShapes() { + if got := out.codes[s.name]; got != s.wantExit { + t.Errorf("%s: exit %d, want %d\nstderr: %s", s.name, got, s.wantExit, out.stderr[s.name]) + } + rec, ok := byRepo[s.name] + if !ok { + continue + } + if rec.Status != s.wantStatus { + t.Errorf("%s: status %q, want %q", s.name, rec.Status, s.wantStatus) + } + } + }) + + // Per-op detail: the reason `skip` and `declare` exist at all is that a + // bare count cannot say WHICH repos lack Terraform. + t.Run("per-op results", func(t *testing.T) { + recs := fleetRecords(t, out.jsonl) + byRepo := fleetByRepo(t, recs, dirs) + + plain := byRepo["plain"] + for _, id := range []string{"api", "tf", "ci"} { + r := fleetOpResult(t, plain, id) + if r.Status != "applied" { + t.Errorf("plain op %s: status %q, want applied", id, r.Status) + } + if r.Proven != "tree" { + t.Errorf("plain op %s: proven %q, want tree — every op here matched real tracked files", id, r.Proven) + } + } + if plain.OpsApplied != 3 || plain.OpsSkipped != 0 { + t.Errorf("plain: ops_applied=%d ops_skipped=%d, want 3/0", plain.OpsApplied, plain.OpsSkipped) + } + + noTF := byRepo["no-tf"] + if r := fleetOpResult(t, noTF, "tf"); r.Status != "skipped" { + t.Errorf("no-tf op tf: status %q, want skipped (on_zero_match=skip)", r.Status) + } else if r.Reason == "" { + t.Error("no-tf op tf: a skipped op must carry a reason — otherwise the record cannot say why") + } + for _, id := range []string{"api", "ci"} { + if r := fleetOpResult(t, noTF, id); r.Status != "applied" { + t.Errorf("no-tf op %s: status %q, want applied — one skipped op must not suppress the others", id, r.Status) + } + } + if noTF.OpsApplied != 2 || noTF.OpsSkipped != 1 { + t.Errorf("no-tf: ops_applied=%d ops_skipped=%d, want 2/1", noTF.OpsApplied, noTF.OpsSkipped) + } + + correct := byRepo["correct"] + for _, id := range []string{"api", "tf", "ci"} { + if r := fleetOpResult(t, correct, id); r.Status != "unchanged" { + t.Errorf("correct op %s: status %q, want unchanged", id, r.Status) + } + } + + if got := byRepo["no-file"].Created; !got { + t.Error("no-file: created must be true — the record is how the fleet script learns a file was written from nothing") + } + for _, name := range []string{"plain", "correct", "no-tf", "catchall"} { + if byRepo[name].Created { + t.Errorf("%s: created must be false — the repo already had a CODEOWNERS", name) + } + } + }) + + // Nothing partially written. A refusal is a promise about the bytes. + t.Run("exit 2 leaves the file byte-identical", func(t *testing.T) { + // The refusing SET is pinned before any byte is compared. Skipping + // every repo whose code is not 2 and then comparing bytes is a test + // that reports success when sync refuses nothing at all — or refuses + // everything — because the loop body simply never runs. The promise + // under test ("a refusal writes no bytes") is only meaningful once we + // know which repos refused, so that comes first. + var refused []string + for _, name := range order { + if out.codes[name] == cli.ExitRefused { + refused = append(refused, name) + } + } + // `order` is fleetShapes() order, so this is deterministic: the only + // two shapes that cannot be converged are the inexpressible one and + // the one missing the directory a `require` op names. + wantRefused := []string{"refuse", "zero-require"} + if !reflect.DeepEqual(refused, wantRefused) { + t.Fatalf("repos that exited 2 = %v, want exactly %v (all codes %v)", refused, wantRefused, out.codes) + } + after := fleetSnapshot(t, dirs) + for _, name := range refused { + if after[name] != before[name] { + t.Errorf("%s exited 2 but its CODEOWNERS changed:\nbefore %q\nafter %q", name, before[name], after[name]) + } + } + // And the converse: "unchanged" means unchanged, to the byte. + if after["correct"] != before["correct"] { + t.Errorf("correct: status unchanged must mean byte-identical:\nbefore %q\nafter %q", before["correct"], after["correct"]) + } + }) + + // The aggregate the operator actually reads. + t.Run("aggregate jsonl groups by status", func(t *testing.T) { + recs := fleetRecords(t, out.jsonl) + if len(recs) != len(order) { + t.Fatalf("results.jsonl has %d records for %d repos — every repo must emit exactly one line, including the ones that refused", len(recs), len(order)) + } + want := map[string]int{} + for _, s := range fleetShapes() { + want[s.wantStatus]++ + } + if got := fleetGroupByStatus(recs); !reflect.DeepEqual(got, want) { + t.Errorf("group_by(.status) = %v, want %v", got, want) + } + }) +} + +// SPEC INV-6 (R-21 declare): a repo whose CODEOWNERS opens with a `*` +// catch-all is the case that makes `declare` non-trivial. Reusing the +// unowned-path insert point would put the declared rule BEFORE the catch-all, +// where last-match-wins hands every future workflow file back to `@org/eng` — +// 100 PRs that do nothing. The rule must be appended at EOF, and it must +// actually govern its scope when a matching file finally appears. +func TestFleet_DeclareLandsAtEOFUnderCatchAll(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t, "catchall") + policy := fleetPolicyFile(t, fleetPolicySrc) + + out := fleetRunPolicy(t, dirs, order, policy) + if out.codes["catchall"] != cli.ExitOK { + t.Fatalf("catchall: exit %d, want 0\nstderr: %s", out.codes["catchall"], out.stderr["catchall"]) + } + + rec := fleetByRepo(t, fleetRecords(t, out.jsonl), dirs)["catchall"] + ci := fleetOpResult(t, rec, "ci") + if ci.Status != "applied" { + t.Errorf("op ci: status %q, want applied — declare writes the rule even with nothing to match", ci.Status) + } + if ci.Proven != "structural" { + t.Errorf("op ci: proven %q, want structural — there is nothing tracked to prove it against (INV-6)", ci.Proven) + } + + content, ok := fleetCodeowners(t, dirs["catchall"]) + if !ok { + t.Fatal("catchall lost its CODEOWNERS") + } + rules := fleetRuleLines(content) + if len(rules) == 0 { + t.Fatalf("no rules left in:\n%s", content) + } + last := rules[len(rules)-1] + if !strings.HasPrefix(last, "/.github/workflows/") { + t.Errorf("last rule is %q, want the declared /.github/workflows/ rule at EOF — nothing after it can override it\nfile:\n%s", last, content) + } + if rules[0] != "* @org/eng" { + t.Errorf("the pre-existing catch-all must survive untouched (INV-2), got %q\nfile:\n%s", rules[0], content) + } + + // The point of declare: when the file eventually exists, this rule takes + // it. Resolve a hypothetical future path against the written bytes. + future := ".github/workflows/ci.yml" + res := plan.ResolveContent(content, []string{future})[future] + if !res.Matched { + t.Fatalf("%s matches no rule after declare\nfile:\n%s", future, content) + } + var hasCI bool + for _, o := range res.Owners { + if o == "@org/ci" { + hasCI = true + } + } + if !hasCI { + t.Errorf("%s resolves to %v — the declared rule is shadowed by the catch-all, which is the exact failure declare exists to avoid\nfile:\n%s", + future, res.Owners, content) + } +} + +// SPEC R-23: a repo with no CODEOWNERS is exit 2 without --create — a per-repo +// fact, recorded and skipped past, never a halt. With --create the file is +// written at .github/CODEOWNERS, which is the path the README promises. +func TestFleet_MissingCodeownersNeedsCreate(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t, "no-file") + policy := fleetPolicyFile(t, fleetPolicySrc) + dir := dirs["no-file"] + + out := fleetRunPolicy(t, dirs, order, policy) + if got := out.codes["no-file"]; got != cli.ExitRefused { + t.Fatalf("no CODEOWNERS and no --create: exit %d, want 2 — exit 3 would halt a fleet on roughly repo 3\nstderr: %s", + got, out.stderr["no-file"]) + } + if _, ok := fleetCodeowners(t, dir); ok { + t.Error("exit 2 must write nothing at all — a file appeared without --create") + } + // An exit-2 run DOES emit its record: `needs-human` is a list of repos + // someone has to triage, and it is built from results.jsonl. Tolerating a + // count other than 1 here would let a refusal emit nothing — the exact + // hole this assertion exists to close — and still report a pass. + recs := fleetRecords(t, out.jsonl) + if len(recs) != 1 { + t.Fatalf("exit 2 emitted %d records for 1 repo, want exactly 1 — a refused repo missing from results.jsonl is a hole in the aggregation precisely where the operator has to look\nraw: %q", + len(recs), out.jsonl) + } + if recs[0].Created { + t.Error("created must be false when nothing was created") + } + + out = fleetRunPolicy(t, dirs, order, policy, "--create") + if got := out.codes["no-file"]; got != cli.ExitOK { + t.Fatalf("--create: exit %d, want 0\nstderr: %s", got, out.stderr["no-file"]) + } + created := filepath.Join(dir, ".github", "CODEOWNERS") + b, err := os.ReadFile(created) + if err != nil { + t.Fatalf("--create must write .github/CODEOWNERS: %v", err) + } + if len(b) == 0 { + t.Error("created CODEOWNERS is empty") + } + rec := fleetByRepo(t, fleetRecords(t, out.jsonl), dirs)["no-file"] + if !rec.Created { + t.Error("created must be true in the record") + } + if rec.Status != cli.StatusApplied { + t.Errorf("status %q, want applied", rec.Status) + } +} + +// SPEC R-21/R-19: an op whose scope matches zero tracked files under the +// DEFAULT on_zero_match=require is exit 2, not 3. Whether a path exists is the +// most repo-specific fact there is; revision 1 mapped it to 3 and a policy +// naming /services/api/ killed the fleet on the first repo that lacked it. +func TestFleet_ZeroMatchUnderRequireIsTwoNotThree(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t, "zero-require") + policy := fleetPolicyFile(t, fleetPolicySrc) + before := fleetSnapshot(t, dirs) + + out := fleetRunPolicy(t, dirs, order, policy, "--create") + got := out.codes["zero-require"] + if got == cli.ExitInvalid { + t.Fatalf("zero-match under require exited 3 — that halts the whole fleet for a fact about one repo\nstderr: %s", out.stderr["zero-require"]) + } + if got != cli.ExitRefused { + t.Fatalf("zero-match under require: exit %d, want 2\nstderr: %s", got, out.stderr["zero-require"]) + } + if after := fleetSnapshot(t, dirs); after["zero-require"] != before["zero-require"] { + t.Errorf("exit 2 wrote to the file:\nbefore %q\nafter %q", before["zero-require"], after["zero-require"]) + } + rec := fleetByRepo(t, fleetRecords(t, out.jsonl), dirs)["zero-require"] + if rec.Status != cli.StatusRefused { + t.Errorf("status %q, want refused", rec.Status) + } + if rec.Error == "" { + t.Error("a refused record must carry the reason — `needs-human` is a list of repos someone has to triage") + } +} + +// SPEC R-19: a refusal is per-repo and RECOVERABLE. The repo that cannot +// express the intent exits 2 with byte-identical bytes, and the repos after it +// in the loop converge exactly as if it had never been there. +func TestFleet_RefusalIsRecoverableAndIsolated(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t, "refuse", "plain") + policy := fleetPolicyFile(t, fleetPolicySrc) + before := fleetSnapshot(t, dirs) + + out := fleetRunPolicy(t, dirs, order, policy, "--create") + if out.halted != "" { + t.Fatalf("the loop stopped at %q; a refusal must be recorded and stepped over", out.halted) + } + if got := out.codes["refuse"]; got != cli.ExitRefused { + t.Fatalf("refuse: exit %d, want 2\nstderr: %s", got, out.stderr["refuse"]) + } + if got := out.codes["plain"]; got != cli.ExitOK { + t.Fatalf("plain (after a refusal): exit %d, want 0 — one repo's refusal must not touch the next\nstderr: %s", + got, out.stderr["plain"]) + } + after := fleetSnapshot(t, dirs) + if after["refuse"] != before["refuse"] { + t.Errorf("refused repo was written to:\nbefore %q\nafter %q", before["refuse"], after["refuse"]) + } + if after["plain"] == before["plain"] { + t.Error("plain must have converged after the refusal") + } + byRepo := fleetByRepo(t, fleetRecords(t, out.jsonl), dirs) + if byRepo["refuse"].Status != cli.StatusRefused { + t.Errorf("refuse: status %q, want refused", byRepo["refuse"].Status) + } + if byRepo["refuse"].Error == "" { + t.Error("refused record must name the rule that was in the way") + } + if byRepo["plain"].Status != cli.StatusApplied { + t.Errorf("plain: status %q, want applied", byRepo["plain"].Status) + } +} + +// SPEC R-24: the record's `.repo` is the `--repo` argument BYTE-FOR-BYTE — +// never absolutized, never symlink-resolved. +// +// Six tests in this file join records back to repos through fleetByRepo, which +// keys on exactly that field; so does the README's fleet script, which pairs +// `needs-human` entries with the paths it passed in. Deriving `.repo` from the +// repository instead of from the argument breaks every one of them, and it +// breaks them on developer laptops only: on macOS `t.TempDir()` hands out +// `/var/folders/...` while `git rev-parse --show-toplevel` reports the same +// directory as `/private/var/folders/...`, because `/var` is a symlink to +// `/private/var`. The two strings name one directory and compare unequal, so +// every lookup misses, every record looks orphaned, and CI on Linux — where no +// such symlink exists — stays green the whole time. +func TestFleet_RecordRepoIsTheArgumentVerbatim(t *testing.T) { + t.Parallel() + // One repo that converges and one that refuses: the refused row is the one + // an operator has to find again by path, so exit 2 must carry it too. + dirs, order := fleetBuild(t, "plain", "refuse") + policy := fleetPolicyFile(t, fleetPolicySrc) + + out := fleetRunPolicy(t, dirs, order, policy, "--create") + recs := fleetRecords(t, out.jsonl) + if len(recs) != len(order) { + t.Fatalf("%d records for %d repos", len(recs), len(order)) + } + byArg := map[string]bool{} + for _, r := range recs { + byArg[r.Repo] = true + } + for _, name := range order { + dir := dirs[name] + if !byArg[dir] { + t.Errorf("%s: no record whose .repo is the --repo argument %q; records name %v", name, dir, byArg) + } + // Name the specific wrong answer, so a failure reads as a diagnosis + // rather than a mystery. + if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { + if byArg[resolved] { + t.Errorf("%s: .repo is the symlink-RESOLVED path %q, but --repo was given %q — fleet aggregation keys on the argument and finds nothing", + name, resolved, dir) + } + } + } +} + +// SPEC R-24: `skipped` is a status of its own. A policy with a typo'd path +// prefix matches nothing in any repo; if those runs reported `unchanged`, the +// jq recipe in the README would show a wall of "already correct" and the +// operator would read a no-op rollout as a success. +func TestFleet_NothingMatchesAnywhereIsSkippedNotUnchanged(t *testing.T) { + t.Parallel() + // Only repos that already have a CODEOWNERS: whether a create-nothing run + // creates a file is a different question, asked elsewhere. + dirs, order := fleetBuild(t, "plain", "correct", "no-tf") + policy := fleetPolicyFile(t, fleetTypoPolicySrc) + before := fleetSnapshot(t, dirs) + + out := fleetRunPolicy(t, dirs, order, policy) + for _, name := range order { + if got := out.codes[name]; got != cli.ExitOK { + t.Errorf("%s: exit %d, want 0 — a skip is a deliberate no-op, not a repo that needs a human\nstderr: %s", + name, got, out.stderr[name]) + } + } + recs := fleetRecords(t, out.jsonl) + if len(recs) != len(order) { + t.Fatalf("%d records for %d repos", len(recs), len(order)) + } + byRepo := fleetByRepo(t, recs, dirs) + for _, name := range order { + rec := byRepo[name] + if rec.Status != cli.StatusSkipped { + t.Errorf("%s: status %q, want %q — every op skipped and none applied", name, rec.Status, cli.StatusSkipped) + } + if rec.OpsApplied != 0 || rec.OpsSkipped != 2 { + t.Errorf("%s: ops_applied=%d ops_skipped=%d, want 0/2", name, rec.OpsApplied, rec.OpsSkipped) + } + // The length is asserted before the loop: ranging over an empty Ops + // slice makes every per-op claim below vacuously true, so a record + // that dropped its per-op detail entirely would read as a pass. + if len(rec.Ops) != 2 { + t.Errorf("%s: %d per-op results, want one per policy op (2) — without them the record cannot say WHICH op skipped", name, len(rec.Ops)) + } + for _, r := range rec.Ops { + if r.Status != "skipped" { + t.Errorf("%s: op %q status %q, want skipped", name, r.Op, r.Status) + } + } + } + if got := fleetGroupByStatus(recs); !reflect.DeepEqual(got, map[string]int{cli.StatusSkipped: len(order)}) { + t.Errorf("group_by(.status) = %v, want all %d skipped", got, len(order)) + } + if after := fleetSnapshot(t, dirs); !reflect.DeepEqual(after, before) { + t.Error("a policy that matched nothing must have written nothing") + } +} + +// SPEC R-20/R-22: a broken policy is exit 3 on the FIRST repo, before a single +// byte is read from it or written to it. That is what makes the README's "fail +// on repo 0, not 100 times" true: the same policy would fail identically +// everywhere, so the run halts instead of producing 100 identical errors — and +// `check` catches the same class with no repo at all. +func TestFleet_BrokenPolicyHaltsOnTheFirstRepo(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t) + policy := fleetPolicyFile(t, fleetBrokenPolicySrc) + before := fleetSnapshot(t, dirs) + + // The first line of the fleet script: this is where it should die. + if code, _, errOut := runCLI(t, "check", "--policy", policy); code != cli.ExitInvalid { + t.Errorf("check on a broken policy: exit %d, want 3\nstderr: %s", code, errOut) + } else if !strings.Contains(errOut, "on_zero_mtach") { + t.Errorf("the error must quote the offending key, or nobody can find it in a 40-op policy:\n%s", errOut) + } + + out := fleetRunPolicy(t, dirs, order, policy, "--create") + if out.halted != order[0] { + t.Fatalf("halted at %q, want the FIRST repo %q (codes %v)", out.halted, order[0], out.codes) + } + if got := out.codes[order[0]]; got != cli.ExitInvalid { + t.Fatalf("broken policy: exit %d, want 3", got) + } + if len(out.processed) != 1 { + t.Errorf("processed %v — a broken policy must not reach a second repo", out.processed) + } + if out.jsonl != "" { + t.Errorf("a policy error must emit NO record: a {\"status\":...} line in results.jsonl reports a phantom repo to the aggregation\ngot: %q", out.jsonl) + } + if out.stderr[order[0]] == "" { + t.Error("exit 3 must explain itself on stderr") + } + if after := fleetSnapshot(t, dirs); !reflect.DeepEqual(after, before) { + t.Error("a broken policy must touch no file in any repo") + } +} + +// SPEC R-19/R-24: --dry-run is the fleet preview — the only granularity at +// which reviewing a 100-repo change is possible. It must change no CODEOWNERS +// anywhere while still emitting the complete results.jsonl and every +// --summary-out file, because those artifacts ARE the review. +func TestFleet_DryRunChangesNothingButStillEmits(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t) + policy := fleetPolicyFile(t, fleetPolicySrc) + before := fleetSnapshot(t, dirs) + bodies := t.TempDir() // outside every clone, per the README + + out := fleetOutcome{codes: map[string]int{}, stderr: map[string]string{}} + summaries := map[string]string{} + for _, name := range order { + summaries[name] = filepath.Join(bodies, name+".md") + code, stdout, errOut := runCLI(t, "sync", + "--repo", dirs[name], "--policy", policy, "--create", "--dry-run", + "--format", "json", "--summary-out", summaries[name]) + out.jsonl += stdout + out.codes[name] = code + out.stderr[name] = errOut + out.processed = append(out.processed, name) + } + + if after := fleetSnapshot(t, dirs); !reflect.DeepEqual(after, before) { + for name := range dirs { + if after[name] != before[name] { + t.Errorf("--dry-run wrote to %s:\nbefore %q\nafter %q", name, before[name], after[name]) + } + } + } + + recs := fleetRecords(t, out.jsonl) + if len(recs) != len(order) { + t.Fatalf("--dry-run produced %d records for %d repos — the preview must be complete", len(recs), len(order)) + } + byRepo := fleetByRepo(t, recs, dirs) + for _, s := range fleetShapes() { + if got := out.codes[s.name]; got != s.wantExit { + t.Errorf("%s: dry-run exit %d, want %d (same verdict as the real run)\nstderr: %s", + s.name, got, s.wantExit, out.stderr[s.name]) + } + if rec := byRepo[s.name]; rec.Status != s.wantStatus { + t.Errorf("%s: dry-run status %q, want %q", s.name, rec.Status, s.wantStatus) + } + } + + // Every repo that would converge must leave a PR body to review. + for _, name := range order { + if out.codes[name] != cli.ExitOK { + continue + } + b, err := os.ReadFile(summaries[name]) + if err != nil { + t.Errorf("%s: --summary-out wrote nothing under --dry-run: %v", name, err) + continue + } + if len(strings.TrimSpace(string(b))) == 0 { + t.Errorf("%s: --summary-out is empty", name) + } + } +} + +// SPEC R-19/R-22/R-24: the facts the README's copy-paste fleet script depends +// on, pinned one by one. Every assertion here corresponds to a line of that +// script; if this test fails, someone who pasted the script gets a halted +// rollout, a silent no-op, or an empty PR body. +func TestFleet_ReadmeScriptContract(t *testing.T) { + t.Parallel() + dirs, order := fleetBuild(t, "plain", "correct", "no-file", "refuse") + policy := fleetPolicyFile(t, fleetPolicySrc) + + // Line 1: `codeowners-tool check --policy policy.json` under `set -euo + // pipefail`. A valid policy MUST exit 0 — a "nothing to do" 1 would abort + // the script before the first clone. + t.Run("check exits 0 on a good policy and never 1", func(t *testing.T) { + code, _, errOut := runCLI(t, "check", "--policy", policy) + if code == cli.ExitNoOp { + t.Fatal("check returned 1 on a valid policy: under `set -e` that halts the run before repo 0") + } + if code != cli.ExitOK { + t.Fatalf("check: exit %d, want 0\nstderr: %s", code, errOut) + } + }) + + t.Run("check exits 3 on a broken policy", func(t *testing.T) { + broken := fleetPolicyFile(t, fleetBrokenPolicySrc) + if code, _, _ := runCLI(t, "check", "--policy", broken); code != cli.ExitInvalid { + t.Fatalf("check on a broken policy: exit %d, want 3", code) + } + }) + + // `case $code in 0) ;; 2) ... ;; *) exit "$code" ;; esac` — the catch-all + // arm halts the rollout, so anything sync can return that is not 0 or 2 + // must genuinely be a reason to stop. + out := fleetRunPolicy(t, dirs, order, policy, "--create") + t.Run("sync returns only 0, 2 or 3", func(t *testing.T) { + for _, name := range order { + switch out.codes[name] { + case cli.ExitOK, cli.ExitRefused, cli.ExitInvalid: + case -1: + t.Errorf("%s: the loop never reached it (halted at %q)", name, out.halted) + default: + t.Errorf("%s: sync returned %d — the script's `*)` arm would halt the whole rollout\nstderr: %s", + name, out.codes[name], out.stderr[name]) + } + } + if got := out.codes["correct"]; got != cli.ExitOK { + t.Errorf("already-correct repo: exit %d, want 0 — never 1, which is the tax this contract removes", got) + } + }) + + // `2) echo "$repo" >> needs-human` — recoverable, and the loop keeps going. + t.Run("exit 2 is recoverable and the loop continues", func(t *testing.T) { + if out.halted != "" { + t.Fatalf("loop halted at %q", out.halted) + } + if !reflect.DeepEqual(out.processed, order) { + t.Fatalf("processed %v, want %v", out.processed, order) + } + var needsHuman []string + for _, name := range order { + if out.codes[name] == cli.ExitRefused { + needsHuman = append(needsHuman, name) + } + } + if !reflect.DeepEqual(needsHuman, []string{"refuse"}) { + t.Errorf("needs-human = %v, want exactly [refuse]", needsHuman) + } + }) + + // `>> results.jsonl` then `jq -s` at the end: one line per repo, refusals + // included, or the counts do not add up to the fleet. + t.Run("results.jsonl is one object per repo", func(t *testing.T) { + recs := fleetRecords(t, out.jsonl) + if len(recs) != len(order) { + t.Fatalf("%d records for %d repos", len(recs), len(order)) + } + total := 0 + for _, n := range fleetGroupByStatus(recs) { + total += n + } + if total != len(order) { + t.Errorf("group_by(.status) totals %d, want %d", total, len(order)) + } + }) + + // `--summary-out "bodies/${repo//\//__}.md"` — an absolute path outside + // the clone, written even under --dry-run, which is the fleet preview. + t.Run("summary-out honors its path under dry-run", func(t *testing.T) { + bodies := t.TempDir() + path := filepath.Join(bodies, "org__plain.md") + code, _, errOut := runCLI(t, "sync", "--repo", dirs["plain"], "--policy", policy, + "--create", "--dry-run", "--format", "json", "--summary-out", path) + if code != cli.ExitOK { + t.Fatalf("dry-run sync: exit %d\nstderr: %s", code, errOut) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("--summary-out must write exactly the path it was given: %v", err) + } + if len(strings.TrimSpace(string(b))) == 0 { + t.Fatal("--summary-out wrote an empty file: there is nothing to review") + } + // And nothing landed inside the clone, where `git add -A` would + // commit it. + if _, err := os.Stat(filepath.Join(dirs["plain"], filepath.Base(path))); err == nil { + t.Error("summary must not be written inside the clone") + } + }) +} diff --git a/internal/cli/hardening_test.go b/internal/cli/hardening_test.go new file mode 100644 index 0000000..263140d --- /dev/null +++ b/internal/cli/hardening_test.go @@ -0,0 +1,344 @@ +package cli_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/cli" +) + +// hardeningGit runs a git command inside dir, for the cases that need a +// repository shaped differently from initRepo's single-commit default. +func hardeningGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// Review finding (F1): a CODEOWNERS write that LANDED was reported as a failed +// repo, with no record emitted at all. +// +// --out was written before stdout and the first sink error became the exit +// code, so `--out` pointing into a directory that does not exist turned a +// converged repo into exit 2 with an empty stdout. The fleet script's +// `>> results.jsonl` collected nothing for that repo — so the row an operator +// would use to see the change is missing precisely where a change happened — +// and the `2)` arm filed an already-correct repo under `needs-human`. At 100 +// repos with a mistyped `--out` directory that is 100 real edits reported as +// 100 failures, with no record of any of them. +// +// The record on stdout is the durable trace and goes first, unconditionally; a +// sink that cannot be written is a warning on stderr and nothing more. +func TestHardening_SinkFailureNeverReportsAConvergedRepoAsFailed(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "# owners\n/services/api/ @org/a\n", + "services/api/main.go": "package main\n", + }) + bad := filepath.Join(t.TempDir(), "no-such-dir", "rec.json") + + code, out, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/services/api/, @org/c)", "--out", bad, "--format", "json") + + if code != cli.ExitOK { + t.Errorf("exit %d, want 0: the CODEOWNERS write succeeded — an unwritable --out path is not a fact about this repository\nstderr: %s", code, errOut) + } + if strings.TrimSpace(out) == "" { + t.Fatalf("stdout is empty: the record is the fleet's only durable trace of this repo, and it must survive a failed file sink\nstderr: %s", errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusApplied { + t.Errorf("status = %q, want %q — the file on disk was changed", rec.Status, cli.StatusApplied) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); !strings.Contains(got, "@org/c") { + t.Errorf("CODEOWNERS = %q, want the new owner: this test is only meaningful when the write landed", got) + } + if !strings.Contains(errOut, "--out") { + t.Errorf("the failed sink must still be reported on stderr, or it is silently lost:\n%s", errOut) + } +} + +// Review finding (F2, security): `--file` was not contained by `--repo`, so +// `sync --create` wrote a CODEOWNERS OUTSIDE the repository. +// +// `--file ../ESCAPED/CODEOWNERS --create` exited 0 having created the +// directories and the file next to the clone; an absolute `--file /tmp/x/ABS` +// was not rejected but reinterpreted as repo-relative, building a garbage tree +// inside the clone and reporting success. Both hurt the operator running the +// fleet loop over 100 checkouts: one typo writes 100 files into whatever sits +// beside them (or 100 junk trees inside them), each one reported applied. The +// verdict depends on nothing but the argument, so it is exit 3 — the class that +// halts at repo 0 rather than working through the fleet. +func TestHardening_FileArgumentIsContainedByTheRepo(t *testing.T) { + repo := initRepo(t, map[string]string{"services/api/main.go": "package main\n"}) + outside := filepath.Join(repo, "..", "ESCAPED") + + code, out, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/services/api/, @org/b)", "--file", "../ESCAPED/CODEOWNERS", "--create") + if code != cli.ExitInvalid { + t.Errorf("--file ../ESCAPED/CODEOWNERS: exit %d, want 3\nstderr: %s", code, errOut) + } + if _, err := os.Stat(outside); err == nil { + t.Errorf("%s exists: --create wrote outside the repository", outside) + } + if strings.TrimSpace(out) != "" { + t.Errorf("an argument-class rejection emits no record (D8), got: %q", out) + } + + // An absolute path must be REFUSED, never quietly re-rooted inside the repo. + abs := filepath.Join(t.TempDir(), "x", "ABS.txt") + code2, _, errOut2 := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/services/api/, @org/b)", "--file", abs, "--create") + if code2 != cli.ExitInvalid { + t.Errorf("--file %s: exit %d, want 3\nstderr: %s", abs, code2, errOut2) + } + if _, err := os.Stat(abs); err == nil { + t.Errorf("%s exists: --create wrote outside the repository", abs) + } + if _, err := os.Stat(filepath.Join(repo, abs)); err == nil { + t.Errorf("%s exists: an absolute --file was reinterpreted as repo-relative and built a lookalike tree inside the clone", filepath.Join(repo, abs)) + } + + // The guard must not cost the legitimate spellings anything. + if code, _, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/services/api/, @org/b)", "--file", "docs/CODEOWNERS", "--create"); code != cli.ExitOK { + t.Fatalf("--file docs/CODEOWNERS: exit %d, want 0 — the containment guard must not reject repo-relative paths\nstderr: %s", code, errOut) + } + if got := syncReadFile(t, filepath.Join(repo, "docs", "CODEOWNERS")); !strings.Contains(got, "@org/b") { + t.Errorf("docs/CODEOWNERS = %q", got) + } +} + +// Review finding (F3): `--repo` pointing BELOW a repository root wrote a +// CODEOWNERS that git will never read, and called it applied. +// +// gittree.ListTracked runs `git -C `, and git walks UP to the enclosing +// repository rather than refusing: pointed at rK/sub it answers with rK's tree +// minus the `sub/` prefix. Nothing then looks wrong from inside — the scopes +// match, the plan is proven, the write succeeds — and the run reports +// created:true, status "applied", exit 0. What it produced is +// rK/sub/.github/CODEOWNERS, and GitHub loads only the CODEOWNERS at the +// repository ROOT (or its .github/ or docs/), so the file governs nothing; +// worse, the rules inside it are anchored at that root, where `/services/api/` +// names a directory that does not exist. The repository's real CODEOWNERS, if +// it has one, is never even read, because discovery looked below it. +// +// A fleet whose clone layout carries one extra directory level +// (…/clones//checkout is a common one) writes 100 dead files and reports +// 100 successes: the exact "reported applied, dead on arrival" outcome this +// verb exists to prevent. Repo-specific, so exit 2 — recorded, and the loop +// moves on to the next clone. +func TestHardening_RepoMustBeTheRepositoryRoot(t *testing.T) { + root := initRepo(t, map[string]string{ + "sub/services/api/main.go": "package main\n", + "sub/README.md": "x\n", + }) + sub := filepath.Join(root, "sub") + + // The scope a fleet policy would carry for a clone rooted here: it resolves + // against the prefix-stripped tree git hands back, which is what makes the + // run look successful. + code, out, errOut := runCLI(t, "sync", "--repo", sub, + "--op", "add_owner(/services/api/, @org/api)", "--create", "--format", "json") + if code != cli.ExitRefused { + t.Errorf("--repo below the root: exit %d, want 2\nstderr: %s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusRefused { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusRefused) + } + if strings.TrimSpace(rec.Error) == "" { + t.Error("the refusal must carry a reason, or the operator cannot tell a layout mistake from a broken clone") + } + if rec.Repo != sub { + t.Errorf("record .repo = %q, want the --repo argument %q verbatim (D6)", rec.Repo, sub) + } + if _, err := os.Stat(filepath.Join(sub, ".github", "CODEOWNERS")); err == nil { + t.Errorf("%s was written: git never reads it, and its existence makes every later run think this tree has a CODEOWNERS", filepath.Join(sub, ".github", "CODEOWNERS")) + } + if _, err := os.Stat(filepath.Join(root, ".github", "CODEOWNERS")); err == nil { + t.Error("a refused run must write nothing at all, including at the real root") + } + + // The root itself still works, so the guard is about the layout and not + // about the repository. + if code, _, errOut := runCLI(t, "sync", "--repo", root, + "--op", "add_owner(/sub/services/api/, @org/api)", "--create"); code != cli.ExitOK { + t.Fatalf("--repo at the root: exit %d, want 0\nstderr: %s", code, errOut) + } + if got := syncReadFile(t, filepath.Join(root, ".github", "CODEOWNERS")); !strings.Contains(got, "@org/api") { + t.Errorf("root .github/CODEOWNERS = %q", got) + } +} + +// Review finding (F4): `--branch REF` proved the change against REF's tree and +// then wrote the working tree of whatever was checked out. +// +// On a clone standing on main, `sync --branch old` resolved every scope against +// old's tree, proved INV-2 there, and wrote main's file — exit 0, "applied", +// carrying a rule that is dead where it landed and justified by a tree nobody +// wrote to. sync.go already refused --create for exactly this reason; the +// argument never depended on the file being new. Writing is refused (exit 2) +// rather than silently downgraded to a dry run: an implied dry run would exit 0 +// having written nothing, which under this contract reads as "converged", and a +// fleet of 100 silently unchanged repos with 100 green rows is worse than the +// bug. --dry-run still previews, and `plan` still targets another ref. +func TestHardening_NonHeadBranchMayNotWrite(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "# owners\n/legacy/ @org/a\n", + "legacy/x.go": "package legacy\n", + }) + hardeningGit(t, repo, "branch", "old") + // Move main on, so `old` is genuinely a different commit. + if err := os.WriteFile(filepath.Join(repo, "legacy", "y.go"), []byte("package legacy\n"), 0o644); err != nil { + t.Fatal(err) + } + hardeningGit(t, repo, "add", ".") + hardeningGit(t, repo, "commit", "-qm", "second") + before := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")) + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--branch", "old", + "--op", "add_owner(/legacy/, @org/legacy)", "--format", "json") + if code != cli.ExitRefused { + t.Errorf("--branch old (writing): exit %d, want 2\nstderr: %s", code, errOut) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != before { + t.Errorf("the working tree was written while proving against another ref: %q → %q", before, got) + } + if rec := syncDecodeRecord(t, out); rec.Status != cli.StatusRefused { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusRefused) + } + + // --dry-run is the supported way to ask about another ref: it writes no + // CODEOWNERS, so there is no tree to disagree with. + code2, _, errOut2 := runCLI(t, "sync", "--repo", repo, "--branch", "old", + "--op", "add_owner(/legacy/, @org/legacy)", "--dry-run") + if code2 != cli.ExitOK { + t.Errorf("--branch old --dry-run: exit %d, want 0 — previewing another ref writes nothing and is always safe\nstderr: %s", code2, errOut2) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != before { + t.Errorf("--dry-run wrote to CODEOWNERS: %q → %q", before, got) + } + + // Naming the checked-out branch explicitly is the ordinary fleet + // invocation and still writes: the refs are compared by resolved commit, + // not by the literal string "HEAD". + code3, _, errOut3 := runCLI(t, "sync", "--repo", repo, "--branch", "main", + "--op", "add_owner(/legacy/, @org/legacy)") + if code3 != cli.ExitOK { + t.Fatalf("--branch main on a clone checked out at main: exit %d, want 0\nstderr: %s", code3, errOut3) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); !strings.Contains(got, "@org/legacy") { + t.Errorf("CODEOWNERS = %q, want the new owner", got) + } +} + +// Review finding (F5): a refused WRITE left an applied-looking record. +// +// When the write itself failed, sync.go set status/error and cleared `created` +// but left ops, ops_applied, paths_changed and the changes array exactly as the +// planner produced them. The row then said a repo whose CODEOWNERS is +// byte-for-byte unchanged had applied N ops and changed M paths, so the +// README's `jq '[.[].ops_applied] | add'` overcounted the rollout by precisely +// the repos where nothing was written — the operator reads a total that +// includes the failures and believes more of the fleet moved than did. +func TestHardening_RefusedWriteRecordCountsNothing(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: a read-only directory does not stop the write") + } + repo := initRepo(t, map[string]string{ + ".github/CODEOWNERS": "# owners\n/x/ @a\n", + "x/a.go": "package x\n", + }) + dir := filepath.Join(repo, ".github") + before := syncReadFile(t, filepath.Join(dir, "CODEOWNERS")) + // apply writes atomically via a temp file in the target's directory, so a + // read-only directory fails the write while leaving the file readable. + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--format", "json") + if code != cli.ExitRefused { + t.Fatalf("unwritable CODEOWNERS directory: exit %d, want 2\nstderr: %s", code, errOut) + } + if got := syncReadFile(t, filepath.Join(dir, "CODEOWNERS")); got != before { + t.Fatalf("the file changed after a failed write: %q → %q", before, got) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusRefused { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusRefused) + } + if rec.OpsApplied != 0 { + t.Errorf("ops_applied = %d, want 0 — nothing reached disk, and `jq '[.[].ops_applied]|add'` sums this field across the fleet", rec.OpsApplied) + } + if rec.PathsChanged != 0 { + t.Errorf("paths_changed = %d, want 0 — no path changed owners", rec.PathsChanged) + } + if len(rec.Changes) != 0 { + t.Errorf("changes carries %d entry(s) for a file that was never written: %+v", len(rec.Changes), rec.Changes) + } + if rec.Created { + t.Error("created must be false when the write failed") + } + for _, o := range rec.Ops { + if o.Status == "applied" { + t.Errorf("op %q reports status %q on a run that wrote nothing", o.ID, o.Status) + } + } +} + +// Review finding: a --dry-run record was byte-identical to a record from a real +// rollout, and the markdown summary contradicted itself. +// +// Nothing in results.jsonl said "this was a preview", so an operator (or a +// script) holding a file of records could not tell a modelled fleet from a +// changed one — and the two are collected by the same `>> results.jsonl` line. +// The PR body was worse than ambiguous: it claimed "a new CODEOWNERS file was +// written" and then, three lines down, "nothing was written". +func TestHardening_DryRunRecordSaysSoAndSummaryAgrees(t *testing.T) { + repo := initRepo(t, map[string]string{"services/api/main.go": "package main\n"}) + sumPath := filepath.Join(t.TempDir(), "body.md") + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/services/api/, @org/api)", + "--create", "--dry-run", "--format", "json", "--summary-out", sumPath) + if code != cli.ExitOK { + t.Fatalf("dry run: exit %d, want 0\nstderr: %s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if !rec.DryRun { + t.Error("dry_run is absent or false on a --dry-run record: the row is then indistinguishable from a real rollout's") + } + if _, err := os.Stat(filepath.Join(repo, ".github", "CODEOWNERS")); err == nil { + t.Error("--dry-run created a file") + } + summary := syncReadFile(t, sumPath) + if strings.Contains(summary, "file was written") { + t.Errorf("the preview's PR body claims a file was written, then says nothing was:\n%s", summary) + } + if !strings.Contains(summary, "nothing was written") { + t.Errorf("a preview body must say it is a preview:\n%s", summary) + } + + // The real run is the control: no dry_run key, and the summary says what + // actually happened. + sumPath2 := filepath.Join(t.TempDir(), "body2.md") + code2, out2, errOut2 := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/services/api/, @org/api)", + "--create", "--format", "json", "--summary-out", sumPath2) + if code2 != cli.ExitOK { + t.Fatalf("real run: exit %d, want 0\nstderr: %s", code2, errOut2) + } + if rec2 := syncDecodeRecord(t, out2); rec2.DryRun { + t.Error("dry_run is true on a run that actually wrote") + } + if summary2 := syncReadFile(t, sumPath2); !strings.Contains(summary2, "file was written") { + t.Errorf("a real run's body must report the file it wrote:\n%s", summary2) + } +} diff --git a/internal/cli/schema_test.go b/internal/cli/schema_test.go new file mode 100644 index 0000000..db67f68 --- /dev/null +++ b/internal/cli/schema_test.go @@ -0,0 +1,618 @@ +package cli_test + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/cli" + "github.com/jordonpeterson/codeowners-tool/internal/plan" +) + +// --------------------------------------------------------------------------- +// Schema pins for the sync record (R-24). +// +// A SyncRecord is not a log line, it is the fleet's data plane. `sync +// --format json` appends one object per repo to results.jsonl and the README's +// fleet script aggregates the run with +// `jq -s 'group_by(.status)|map({status:.[0].status, n:length})'`, plus the +// documented habit of projecting `.ops_skipped`. Those key names are the whole +// interface between this tool and a user's shell; nothing in Go's type system +// touches them. +// +// Until these tests existed the shape was pinned by NOTHING. Every other test +// in this package json.Unmarshals INTO cli.SyncRecord, so the struct tags sit +// on both sides of the assertion: renaming `ops_applied` to `opsApplied` keeps +// the entire suite green while silently emptying the field for every jq +// consumer on the planet. A schema that only ever round-trips through itself +// is not pinned at all. +// +// These are characterization tests. They record the CURRENT shape as +// intentional so the next change to it is a deliberate, reviewed one, and they +// mirror internal/plan/schema_test.go, which does the same job for the plan +// document. Like those, they assert the key SET, never the key ORDER — +// encoding/json emits fields in declaration order, but that ordering is not +// part of the contract and pinning it would fail on a harmless reshuffle. +// --------------------------------------------------------------------------- + +// schemaTopLevelKeys marshals v and returns its top-level JSON object keys, +// sorted so comparisons are order-independent. +func schemaTopLevelKeys(t *testing.T, v any) []string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal into key map: %v", err) + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// schemaKindOf names the JSON type of a decoded value. Arrays report their +// element type too, so an array of objects turning into an array of strings is +// caught, not just a rename. +func schemaKindOf(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + return "bool" + case float64: + return "number" + case string: + return "string" + case map[string]any: + return "object" + case []any: + if len(x) == 0 { + return "array" + } + return "array<" + schemaKindOf(x[0]) + ">" + } + return "unknown" +} + +// schemaFieldKinds marshals v and reports every top-level key's JSON type. +func schemaFieldKinds(t *testing.T, v any) map[string]string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal into key map: %v", err) + } + out := make(map[string]string, len(m)) + for k, raw := range m { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + out[k] = schemaKindOf(v) + } + return out +} + +func schemaWantKeys(t *testing.T, what string, got, want []string) { + t.Helper() + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s top-level JSON keys changed.\n got: %v\nwant: %v\n"+ + "If this change is intentional, update the literal in this test AND the README's\n"+ + "JSON output section — these key names are what users' jq scripts select on.", + what, got, want) + } +} + +// schemaFullOpResult is an OpResult with every field populated, so no +// omitempty tag can hide a field from the key enumeration. +func schemaFullOpResult() plan.OpResult { + return plan.OpResult{ID: "tf", Op: "add_owner(**/*.tf, @org/infra)", Status: "skipped", Proven: "structural", Reason: "scope matched zero tracked files"} +} + +func schemaFullChange() plan.Change { + return plan.Change{ + Action: "amend", + Line: 7, + Pattern: "/services/api/", + OldOwners: []string{"@org/api-team"}, + NewOwners: []string{"@org/api-team", "@org/infra"}, + OldLine: "/services/api/ @org/api-team", + NewLine: "/services/api/ @org/api-team @org/infra", + Reason: "add_owner", + } +} + +// schemaFullRecord is a SyncRecord with every field populated, including the +// omitempty ones, so the key enumeration sees the maximal document. +func schemaFullRecord() cli.SyncRecord { + return cli.SyncRecord{ + Repo: "work/org/foo", + Status: cli.StatusApplied, + Ops: []plan.OpResult{schemaFullOpResult()}, + OpsApplied: 2, + OpsSkipped: 1, + PathsChanged: 37, + Created: true, + DryRun: true, + Warnings: []string{"file is 2.6 MB"}, + Changes: []plan.Change{schemaFullChange()}, + Error: "would violate INV-2 at /docs", + } +} + +// schemaStatusConstsFromSource parses this package's non-test sources and +// returns every exported `Status*` string constant it declares, name to value. +// Reading the source rather than the compiled identifiers is what lets the +// test notice a SIXTH status being added: a Go test can reference constants it +// knows about, but it cannot enumerate ones it does not. +func schemaStatusConstsFromSource(t *testing.T) map[string]string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + out := map[string]string{} + fset := token.NewFileSet() + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, id := range vs.Names { + if !strings.HasPrefix(id.Name, "Status") || i >= len(vs.Values) { + continue + } + lit, ok := vs.Values[i].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + v, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquote %s: %v", lit.Value, err) + } + out[id.Name] = v + } + } + } + } + if len(out) == 0 { + t.Fatal("found no Status* constants in the package source — the scan is broken, not the schema") + } + return out +} + +// SPEC R-24 (sync record, top level): the set of keys a marshalled SyncRecord +// emits is pinned to a literal list. This is deliberately an ENUMERATION and +// not a spot-check: the failures it guards against are a field being RENAMED +// (every jq selector on it starts returning null) and a field being ADDED +// without anyone noticing that `sync --format json` now emits something new, +// and a spot-checking test can see neither. Every other cli test unmarshals +// into SyncRecord, using the same tags on both sides of its assertions, so +// this is the only place the wire names are compared to anything external. +func TestR24_SyncRecordTopLevelKeys(t *testing.T) { + schemaWantKeys(t, "cli.SyncRecord", schemaTopLevelKeys(t, schemaFullRecord()), []string{ + "repo", + "status", + "ops", + "ops_applied", + "ops_skipped", + "paths_changed", + "created", + "dry_run", + "warnings", + "changes", + "error", + }) +} + +// SPEC R-24 (field types): every field's JSON name mapped to its JSON type. +// The key-set test catches renames and additions; this catches a type change +// under an unchanged name. `ops_applied` going from number to string is the +// concrete case — `jq 'map(.ops_applied)|add'` on strings is a runtime error +// in the middle of a 100-repo rollout, and nothing in Go would have complained +// at the moment the field's type changed. `created` being a bool matters for +// the same reason: `select(.created)` is true for the string "false". +func TestR24_SyncRecordFieldTypes(t *testing.T) { + got := schemaFieldKinds(t, schemaFullRecord()) + want := map[string]string{ + "repo": "string", + "status": "string", + "ops": "array", + "ops_applied": "number", + "ops_skipped": "number", + "paths_changed": "number", + "created": "bool", + "dry_run": "bool", + "warnings": "array", + "changes": "array", + "error": "string", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("cli.SyncRecord field types changed.\n got: %v\nwant: %v", got, want) + } +} + +// SPEC R-24 (omitempty): which keys DISAPPEAR from a minimal record, pinned +// explicitly, and which are unconditional. The split is not cosmetic. The +// optional four (ops, warnings, changes, error) are absent when there is +// nothing to say, so a consumer reads their absence as "empty", and a clean +// run's line stays short enough to eyeball. The unconditional six are the +// aggregation keys: absence there is a malformed record, not an empty result. +func TestR24_OmitEmptyKeysDisappear(t *testing.T) { + got := schemaTopLevelKeys(t, cli.SyncRecord{}) + schemaWantKeys(t, "minimal cli.SyncRecord", got, []string{ + "repo", "status", "ops_applied", "ops_skipped", "paths_changed", "created", + }) + present := map[string]bool{} + for _, k := range got { + present[k] = true + } + for _, k := range []string{"ops", "warnings", "changes", "error"} { + if present[k] { + t.Errorf("minimal record: key %q should be omitted when empty but was emitted", k) + } + } +} + +// SPEC R-24 (always-present keys): `status` and the three counts are emitted +// even at their zero values, on every record, unconditionally. This is the +// single most load-bearing property of the whole document. The README's fleet +// aggregation is `jq -s 'group_by(.status)'`; on a record missing `.status` +// that expression does not error, it groups the repo under null — a silent +// fleet-wide misreport, which is exactly the failure this schema exists to +// prevent. Same for the counts: `ops_skipped,omitempty` would hide 0 and make +// the documented "project .ops_skipped too" habit read null on precisely the +// repos that were fine. +// +// The zero-valued record below is not hypothetical: a refused repo emits +// status "refused" with all three counts at 0, and that line still has to +// aggregate. +func TestR24_StatusAndCountsAreAlwaysPresent(t *testing.T) { + records := []cli.SyncRecord{ + {}, + {Status: cli.StatusRefused, Repo: "work/org/foo"}, + {Status: cli.StatusUnchanged, Repo: "work/org/bar", OpsApplied: 0, OpsSkipped: 0, PathsChanged: 0}, + schemaFullRecord(), + } + required := []string{"status", "ops_applied", "ops_skipped", "paths_changed"} + for i, rec := range records { + b, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + for _, k := range required { + if _, ok := m[k]; !ok { + t.Errorf("record %d (%s): key %q is missing; `jq -s 'group_by(.status)'` buckets such a record under null", + i, b, k) + } + } + } +} + +// SPEC R-24 (nested per-op results): the per-op array renders under the key +// `ops`, and each element carries plan.OpResult's own names. +// +// Note the DELIBERATE difference between the two documents: plan.Plan renders +// this exact same []plan.OpResult under `op_results`, because Plan.Ops already +// owns `ops` there as the list of raw op strings (R-16) and must keep it. The +// sync record has no such collision, so it uses the shorter, documented name — +// `ops` is what the README's JSON output section shows and what fleet scripts +// select. This is not an inconsistency to be tidied up: renaming either one to +// match the other breaks a published contract. The test pins both names at +// once so a well-meant "fix" fails here instead of in a user's pipeline. +func TestR24_PerOpResultsRenderUnderOps(t *testing.T) { + b, err := json.Marshal(schemaFullRecord()) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["ops"]; !ok { + t.Fatalf("sync record must render per-op results under %q; got keys %v", "ops", schemaTopLevelKeys(t, schemaFullRecord())) + } + if _, ok := m["op_results"]; ok { + t.Error("sync record must NOT use `op_results` — that is the plan document's name for the same type") + } + + // The two documents deliberately differ. Pin the other side too. + planKeys := schemaTopLevelKeys(t, plan.Plan{OpResults: []plan.OpResult{schemaFullOpResult()}}) + hasOpResults := false + for _, k := range planKeys { + if k == "op_results" { + hasOpResults = true + } + } + if !hasOpResults { + t.Errorf("plan.Plan must keep rendering op results under `op_results` (its `ops` is the raw op strings); keys: %v", planKeys) + } + + // Each element's own key set, so a rename inside OpResult is caught here + // too — the README documents id/status/proven/reason by name. + var elems []json.RawMessage + if err := json.Unmarshal(m["ops"], &elems); err != nil { + t.Fatalf("ops is not an array: %v", err) + } + if len(elems) != 1 { + t.Fatalf("got %d op results, want 1", len(elems)) + } + schemaWantKeys(t, "sync record ops[0]", schemaTopLevelKeys(t, schemaFullOpResult()), []string{ + "id", "op", "status", "proven", "reason", + }) + got := schemaFieldKinds(t, schemaFullOpResult()) + want := map[string]string{"id": "string", "op": "string", "status": "string", "proven": "string", "reason": "string"} + if !reflect.DeepEqual(got, want) { + t.Errorf("op result field types changed.\n got: %v\nwant: %v", got, want) + } +} + +// SPEC R-24 (status vocabulary): there are exactly five legal `status` values +// — applied, unchanged, skipped, refused, error — and they are pinned both to +// the exported constants and to the complete set of Status* constants declared +// in the package source. The source scan is the point: referencing the five +// constants catches a value being CHANGED, but only enumerating the +// declarations catches a sixth being ADDED. A new status shipped without a +// README update means fleet operators have a bucket in their `group_by` +// output that no documentation explains, and every `case` statement written +// against the documented five silently falls through. +func TestR24_StatusValuesAreExactlyFive(t *testing.T) { + want := map[string]string{ + "StatusApplied": "applied", + "StatusUnchanged": "unchanged", + "StatusSkipped": "skipped", + "StatusRefused": "refused", + "StatusError": "error", + } + // The constants as the compiler sees them: catches a changed value. + live := map[string]string{ + "StatusApplied": cli.StatusApplied, + "StatusUnchanged": cli.StatusUnchanged, + "StatusSkipped": cli.StatusSkipped, + "StatusRefused": cli.StatusRefused, + "StatusError": cli.StatusError, + } + if !reflect.DeepEqual(live, want) { + t.Errorf("status constant values changed.\n got: %v\nwant: %v", live, want) + } + // The constants as declared: catches a sixth being added. + declared := schemaStatusConstsFromSource(t) + if !reflect.DeepEqual(declared, want) { + t.Errorf("the set of Status* constants changed.\n got: %v\nwant: %v\n"+ + "A new sync status must be added to the README's JSON output section and to this\n"+ + "test together — fleet scripts switch on these five by name.", declared, want) + } + // And they survive the wire as themselves. + for name, v := range want { + b, err := json.Marshal(cli.SyncRecord{Status: v}) + if err != nil { + t.Fatal(err) + } + var doc struct { + Status string `json:"status"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if doc.Status != v { + t.Errorf("%s: status round-tripped as %q, want %q", name, doc.Status, v) + } + } +} + +// SPEC R-24 (round trip): a fully-populated record marshalled and unmarshalled +// is semantically identical. `--out FILE` writes a record that a later step +// reads back, and the fleet script's results.jsonl is read by whatever the +// operator points at it, possibly a newer build of this same tool. A field +// that does not survive its own round trip is a field the document claims to +// carry and does not. +func TestR24_RoundTripPreservesEveryField(t *testing.T) { + orig := schemaFullRecord() + b, err := json.Marshal(orig) + if err != nil { + t.Fatal(err) + } + var back cli.SyncRecord + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(orig, back) { + t.Errorf("record did not survive the round trip.\n got: %#v\nwant: %#v", back, orig) + } +} + +// SPEC R-24 (JSONL shape): several records marshalled one per line each parse +// individually, and no single record contains a raw newline. This is what +// makes the README's `>> results.jsonl` plus `jq -s` work at all: the shell +// appends whole lines and jq slurps them line by line, so one embedded newline +// inside one record splits it into two unparseable fragments and takes the +// whole fleet report down with it — after the run, when the repos are already +// cloned and mutated. +// +// The hazard is real, not theoretical: `error` and `warnings` carry free text +// composed from refusal messages, and `changes` carries CODEOWNERS line +// content. encoding/json escapes newlines to \n, which is precisely the +// property being pinned; a future switch to an encoder that does not (or one +// left in indent mode) breaks line-oriented parsing without breaking a single +// unmarshal-based test. +func TestR24_JSONLLinesParseIndividually(t *testing.T) { + records := []cli.SyncRecord{ + {Repo: "work/org/a", Status: cli.StatusApplied, OpsApplied: 3, PathsChanged: 12}, + {Repo: "work/org/b", Status: cli.StatusUnchanged}, + {Repo: "work/org/c", Status: cli.StatusSkipped, OpsSkipped: 2}, + { + Repo: "work/org/d", + Status: cli.StatusRefused, + Error: "refused: INV-2 violated\n /docs/x.md {@a} → {@b}", + Warnings: []string{"multi\nline\nwarning"}, + Changes: []plan.Change{{Action: "amend", Line: 1, Pattern: "/x/", NewLine: "/x/ @a\n"}}, + }, + {Repo: "work/org/e", Status: cli.StatusError, Error: "clone missing"}, + } + + var lines []string + for _, rec := range records { + b, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + if i := strings.IndexAny(string(b), "\n\r"); i >= 0 { + t.Fatalf("record for %s contains a raw newline at byte %d; a JSONL line must be one line: %s", rec.Repo, i, b) + } + lines = append(lines, string(b)) + } + + // Reassemble the file exactly as `>> results.jsonl` would, then parse it + // back the way `jq -s` does: line by line, each independently. + file := strings.Join(lines, "\n") + "\n" + var slurped []cli.SyncRecord + for i, line := range strings.Split(strings.TrimRight(file, "\n"), "\n") { + var rec cli.SyncRecord + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("line %d failed to parse on its own: %v\nline: %s", i+1, err, line) + } + slurped = append(slurped, rec) + } + if !reflect.DeepEqual(slurped, records) { + t.Errorf("records did not survive the JSONL round trip.\n got: %#v\nwant: %#v", slurped, records) + } + + // And the aggregation the README actually prints: group_by(.status). + counts := map[string]int{} + for _, rec := range slurped { + counts[rec.Status]++ + } + want := map[string]int{ + cli.StatusApplied: 1, + cli.StatusUnchanged: 1, + cli.StatusSkipped: 1, + cli.StatusRefused: 1, + cli.StatusError: 1, + } + if !reflect.DeepEqual(counts, want) { + t.Errorf("group_by(.status) over the JSONL gave %v, want %v", counts, want) + } + if _, grouped := counts[""]; grouped { + t.Error("a record aggregated under the empty status — that is the null bucket jq would report") + } +} + +// SPEC R-24 (forward compatibility, unknown field): a record containing a +// field this binary does not know still unmarshals, with the known fields +// untouched. results.jsonl outlives the binary that wrote it — a fleet run is +// resumed days later, often after an upgrade — so an unknown key must be +// ignored rather than rejected. This is Go's default; the reader must never +// opt into DisallowUnknownFields, and this test is what notices if it ever +// does. +func TestR24_ForwardCompatUnknownFieldIsIgnored(t *testing.T) { + b, err := json.Marshal(schemaFullRecord()) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + doc["field_from_a_newer_binary"] = map[string]any{"nested": []any{1, 2, 3}} + withExtra, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + + var back cli.SyncRecord + if err := json.Unmarshal(withExtra, &back); err != nil { + t.Fatalf("record with an unknown field failed to unmarshal: %v", err) + } + if !reflect.DeepEqual(back, schemaFullRecord()) { + t.Errorf("unknown field disturbed the known ones.\n got: %#v\nwant: %#v", back, schemaFullRecord()) + } +} + +// SPEC R-24 (forward compatibility, missing field): a record written before a +// field existed unmarshals with that field at its zero value and everything +// else intact. The counts are the ones that matter — a pre-`paths_changed` +// line must read as 0 rather than failing the parse, because the alternative +// is a resumed fleet run aborting on its own earlier output. +// +// Note what this test does NOT claim: that 0 and "absent" are +// distinguishable. They are not, which is exactly why the always-present +// guarantee in TestR24_StatusAndCountsAreAlwaysPresent is load-bearing — it is +// the only thing keeping "this run changed nothing" apart from "this field did +// not exist yet". +func TestR24_ForwardCompatMissingFieldIsZero(t *testing.T) { + b, err := json.Marshal(schemaFullRecord()) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + // Simulate a line written by a binary that predates these fields. + for _, k := range []string{"paths_changed", "created", "ops", "warnings"} { + delete(doc, k) + } + older, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + + var back cli.SyncRecord + if err := json.Unmarshal(older, &back); err != nil { + t.Fatalf("older record failed to unmarshal: %v", err) + } + if back.PathsChanged != 0 { + t.Errorf("paths_changed = %d, want 0 for a record that predates the field", back.PathsChanged) + } + if back.Created { + t.Error("created = true, want false for a record that predates the field") + } + if back.Ops != nil { + t.Errorf("ops = %#v, want nil for a record that omits it", back.Ops) + } + if back.Warnings != nil { + t.Errorf("warnings = %#v, want nil for a record that omits it", back.Warnings) + } + // The aggregation keys that DID exist must be untouched by the omission. + full := schemaFullRecord() + if back.Status != full.Status || back.Repo != full.Repo { + t.Errorf("repo/status = %q/%q, want %q/%q", back.Repo, back.Status, full.Repo, full.Status) + } + if back.OpsApplied != full.OpsApplied || back.OpsSkipped != full.OpsSkipped { + t.Errorf("counts = %d/%d, want %d/%d", back.OpsApplied, back.OpsSkipped, full.OpsApplied, full.OpsSkipped) + } +} diff --git a/internal/cli/sync.go b/internal/cli/sync.go new file mode 100644 index 0000000..cc62846 --- /dev/null +++ b/internal/cli/sync.go @@ -0,0 +1,734 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/jordonpeterson/codeowners-tool/internal/apply" + "github.com/jordonpeterson/codeowners-tool/internal/gittree" + "github.com/jordonpeterson/codeowners-tool/internal/ops" + "github.com/jordonpeterson/codeowners-tool/internal/pattern" + "github.com/jordonpeterson/codeowners-tool/internal/plan" + "github.com/jordonpeterson/codeowners-tool/internal/policy" +) + +// The fleet verbs (R-19…R-24). +// +// One rule orders everything in this file: exit 3 is reserved for failures that +// depend on nothing but the policy, and every failure that depends on which repo +// you are standing in is exit 2. Revision 1 had it the other way round for +// zero-match scopes and for "no CODEOWNERS", which halted a 100-repo rollout on +// roughly repo 3 — so each exit-3 verdict below is reached before the repository +// is opened at all, and everything after that point is exit 0 or 2. + +// policyGuidance is UX rule 4, rendered here rather than by the parser: +// policy.MultiError deliberately carries only the problems, so that a caller +// printing one problem per line does not get advice interleaved between them. +// Without this line, the operator whose fleet run halted at repo 0 has no reason +// not to retry the same policy against the other 99 clones. +const policyGuidance = "this is a policy error — it will fail identically on every repo; fix the policy, do not retry" + +// exit3 reports a member of the exit-3 class and returns its code. +func exit3(stderr io.Writer, err error) int { + var multi *policy.MultiError + if errors.As(err, &multi) { + // Every accumulated problem prints in one run (R-22): fixing a generated + // 40-op policy one error per invocation is miserable. + for _, e := range multi.Errs { + fmt.Fprintln(stderr, "error:", e) + } + } else { + fmt.Fprintln(stderr, "error:", err) + } + fmt.Fprintln(stderr, policyGuidance) + return ExitInvalid +} + +// opSource resolves where the ops come from. Everything it can reject is +// decidable from the arguments alone, with no repository open — which is exactly +// what makes these verdicts identical on all 100 repos. +func opSource(opSpecs, policyPaths []string) (*policy.Policy, []ops.Op, error) { + switch { + case len(opSpecs) > 0 && len(policyPaths) > 0: + return nil, nil, errors.New("--op and --policy are mutually exclusive (R-20): the policy file is the complete statement of what ran, and an op appended at one call site is invisible to the people reviewing that file") + case len(policyPaths) > 1: + // Never a silent last-wins: the artifact in git would not be the policy + // that ran, and `check` would have validated something else. + return nil, nil, fmt.Errorf("--policy given %d times (%s); it takes exactly one file (R-20)", + len(policyPaths), strings.Join(policyPaths, ", ")) + case len(policyPaths) == 1: + p, err := policy.Load(policyPaths[0]) + if err != nil { + return nil, nil, err + } + return p, p.Ops, nil + case len(opSpecs) > 0: + list, err := ops.ParseAll(opSpecs) + if err != nil { + return nil, nil, err + } + return nil, list, nil + default: + return nil, nil, errors.New("no ops given: pass --op 'add_owner(/x/, @a)' (repeatable) or --policy policy.json (R-20)") + } +} + +// validateScopes rejects a scope the matcher cannot compile — a property of the op +// string alone, so it belongs to the exit-3 class and is settled before any repo +// is opened. Draining it here is what lets everything plan.Build can still call +// InvalidError (zero match, an R-8 overlap in this tree, a removal that empties +// an owner set) map to exit 2 without a second guess about which class it is in. +func validateScopes(list []ops.Op) error { + for i, op := range list { + if op.Kind == ops.RenameOwner { + // A rename's scope comes from current ownership, not a pattern. + continue + } + if _, err := pattern.Compile(op.Scope); err != nil { + return fmt.Errorf("%s: invalid scope %q: %v", policy.OpLabel(op.ID, i), op.Scope, err) + } + } + return nil +} + +// syncRun is one repo's sync, after every repo-independent verdict has been made. +type syncRun struct { + repoArg string // D6: the --repo argument VERBATIM, never absolutized + branch string + filePath string + create bool + dryRun bool + onEmpty string + ops []ops.Op + policy *policy.Policy // nil under --op +} + +func cmdSync(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("sync", flag.ContinueOnError) + fs.SetOutput(stderr) + var opSpecs, policyPaths multiFlag + fs.Var(&opSpecs, "op", "operation (repeatable); mutually exclusive with --policy") + fs.Var(&policyPaths, "policy", "policy file (R-20); mutually exclusive with --op") + repo := fs.String("repo", ".", "path to local git repository") + branch := fs.String("branch", "HEAD", "ref whose tracked tree governs resolution (S-7)") + filePath := fs.String("file", "", "CODEOWNERS path override (repo-relative)") + onEmpty := fs.String("on-empty", "", "R-6 policy when remove_owner empties a set: error|inherit|unowned (only with --op)") + create := fs.Bool("create", false, "write .github/CODEOWNERS when the repo has none; never overwrites (R-23)") + dryRun := fs.Bool("dry-run", false, "change no CODEOWNERS; --out and --summary-out still emit") + format := fs.String("format", "text", "text|json — governs stdout only") + out := fs.String("out", "", "write the JSON record here (always JSON, whatever --format says)") + summaryOut := fs.String("summary-out", "", "write a markdown PR body here") + if err := fs.Parse(args); err != nil { + return flagParseCode(err) + } + + if *format != "text" && *format != "json" { + // Never a silent fallback to text: the fleet script's `>> results.jsonl` + // would then collect human prose that `jq -s` cannot read, after the + // whole rollout has already written its CODEOWNERS files. + return exit3(stderr, fmt.Errorf("unknown --format %q; want text or json", *format)) + } + if *onEmpty != "" && len(policyPaths) > 0 { + return exit3(stderr, errors.New("--on-empty is not allowed with --policy: set \"on_empty\" in the policy file instead, or the artifact in git is not the policy that ran (R-20)")) + } + // A non-HEAD --branch may not write — checkBranchIsWritable enforces that + // for creates and edits alike, by comparing RESOLVED COMMITS rather than + // the literal string "HEAD". Comparing strings rejected `--branch main` on + // a clone already standing on main: a completely ordinary fleet + // invocation, and being argument-shaped it exited 3, halting the whole + // rollout at repo 0 over an argument that was never wrong (R-23). + // --file is joined onto --repo by everything below, so it is only meaningful + // as a repo-relative path. Argument-only, repo-independent, hence exit 3 — + // and it is checked BEFORE the repository is opened, because with --create + // the write happens outside the repository the moment we get that far. + if err := containedRelPath(*filePath); err != nil { + return exit3(stderr, err) + } + pol, opList, err := opSource(opSpecs, policyPaths) + if err != nil { + return exit3(stderr, err) + } + if err := validateScopes(opList); err != nil { + return exit3(stderr, err) + } + + run := &syncRun{ + repoArg: *repo, + branch: *branch, + filePath: *filePath, + create: *create, + dryRun: *dryRun, + onEmpty: *onEmpty, + ops: opList, + policy: pol, + } + if pol != nil { + run.onEmpty = pol.OnEmpty + } + + rec, code := run.execute() + if rec.Error != "" { + fmt.Fprintln(stderr, "error:", rec.Error) + } + for _, w := range rec.Warnings { + fmt.Fprintln(stderr, "warning:", w) + } + emitRecord(rec, run, *format, *out, *summaryOut, stdout, stderr) + return code +} + +// execute reads the repository and converges it. From here on the only codes are +// 0 and 2 — every remaining failure is a fact about THIS repo, and a fleet script +// records it and steps to the next clone. +func (r *syncRun) execute() (SyncRecord, int) { + rec := SyncRecord{Repo: r.repoArg, DryRun: r.dryRun} + + tree, err := gittree.ListTracked(r.repoArg, r.branch) + if err != nil { + // StatusError, not StatusRefused: the tool never got far enough to read + // this repo, let alone decline to touch it. Grouping on .status is how an + // operator separates "12 awkward CODEOWNERS files" from "12 clones that + // failed and were never synced". + rec.Status = StatusError + rec.Error = err.Error() + return rec, ExitRefused + } + + // Both guards below are refusals, not errors: the repository was read + // successfully and the tool is declining to write into it. Both are also + // facts about THIS clone — the next one may be laid out differently, or be + // checked out on the ref that was asked for — so both are exit 2, and a + // fleet loop records them and steps to the next repo. + if err := r.checkRepoRoot(); err != nil { + rec.Status = StatusRefused + rec.Error = err.Error() + return rec, ExitRefused + } + if err := r.checkBranchIsWritable(); err != nil { + rec.Status = StatusRefused + rec.Error = err.Error() + return rec, ExitRefused + } + + rel, content, creating, err := r.governing(tree) + if err != nil { + rec.Status = statusForReadFailure(err) + rec.Error = err.Error() + return rec, ExitRefused + } + + p, buildErr := plan.Build(content, tree, r.ops, plan.Options{OnEmpty: r.onEmpty}) + var noop *plan.NoOpError + converged := errors.As(buildErr, &noop) + // D1 says the no-op path returns a POPULATED plan. The nil guard is not + // dead code insurance for a hypothetical: dereferencing nil here would + // panic, and a panic in an unattended fleet run loses the record for this + // repo and every subsequent line the loop would have appended. + if p == nil && converged { + buildErr, converged = errors.New("planner reported a no-op without a plan"), false + } + if buildErr != nil && !converged { + // Refusal, zero-match under `require`, an R-8 overlap in this tree, the + // size cap: all repo-dependent, all exit 2. Mapping any of them to 3 is + // what strands the other 99 repos. + rec.Status = StatusRefused + rec.Error = buildErr.Error() + return rec, ExitRefused + } + + rec.Ops = p.OpResults + for _, o := range p.OpResults { + switch o.Status { + case "applied": + rec.OpsApplied++ + case "skipped": + rec.OpsSkipped++ + } + } + rec.PathsChanged = len(p.Rows) + rec.Warnings = p.Warnings + rec.Changes = p.Changes + rec.Status = syncStatus(rec) + if converged { + // A run that wrote nothing must not report line changes. plan can + // synthesize an edit that renders byte-identical text; a `changes` array + // under "status":"unchanged" would make every converged repo look like a + // pending diff in the fleet preview. + rec.Changes = nil + } + + if !converged && !r.dryRun { + if err := r.write(rel, p, creating); err != nil { + rec.Status = StatusRefused + rec.Error = err.Error() + rec.Created = false + // Nothing reached disk, so the record must not read like a run that + // changed something. Leaving ops_applied, paths_changed and the + // changes array populated made `jq '[.[].ops_applied] | add'` + // overcount the fleet by exactly the repos where the write FAILED — + // the rollout summary would claim ownership moved on repos whose + // CODEOWNERS is byte-for-byte what it was. Ops are rewritten the same + // way the already-converged path rewrites them (plan.Build), so the + // per-op array and the counts still agree with each other: an op that + // was skipped at planning time is still reported skipped, and no op is + // left claiming an edit that does not exist. + for i := range rec.Ops { + if rec.Ops[i].Status == "applied" { + rec.Ops[i].Status = "unchanged" + } + } + rec.OpsApplied = 0 + rec.PathsChanged = 0 + rec.Changes = nil + return rec, ExitRefused + } + } + // `created` reports what this run did, or under --dry-run what it would have + // done — a converged repo needs no file written, so nothing is created for it + // even with --create. + rec.Created = creating && !converged + return rec, ExitOK +} + +// checkRepoRoot refuses a --repo that points BELOW a repository's root. +// +// gittree.ListTracked runs `git -C `, and git walks UP to the enclosing +// repository rather than refusing: pointed at rK/sub it answers with rK's tree +// minus the `sub/` prefix. Nothing looks wrong from inside — the scopes match +// that tree, the plan is proven against it, the write succeeds — and the run +// reports "applied", exit 0. What it produced is rK/sub/.github/CODEOWNERS, and +// GitHub loads only the CODEOWNERS at the repository ROOT, so the file governs +// nothing; the rules in it are anchored at that root, where the paths they name +// do not exist; and rK's real CODEOWNERS was never read, because discovery +// looked below it. A fleet whose clone layout carries one extra directory level +// (…/clones//checkout is a common one) writes 100 dead files and reports +// 100 successes. That is precisely the "reported applied, dead on arrival" +// outcome this whole verb exists to prevent, so it is refused. +// +// The comparison resolves symlinks on BOTH sides and never touches +// SyncRecord.Repo: on macOS `t.TempDir()` hands out /var/folders/... while git +// reports /private/var/folders/..., one directory under two names. Comparing +// the raw strings would refuse every repo on a developer laptop while CI on +// Linux stayed green; deriving .repo from the resolved path instead would break +// every fleet lookup that keys on the argument (D6). +func (r *syncRun) checkRepoRoot() error { + root, err := gitLine(r.repoArg, "rev-parse", "--show-toplevel") + if err != nil { + return err + } + same, err := sameDir(r.repoArg, root) + if err != nil { + return err + } + if !same { + return fmt.Errorf("--repo %s is inside the repository rooted at %s, not that root: git resolves the tracked tree against the root, so the CODEOWNERS this run would write is at a path GitHub never reads, while the file that does govern (%s) stays untouched; re-run with --repo %s", + r.repoArg, root, filepath.ToSlash(filepath.Join(root, gittree.CodeownersLocations[0])), root) + } + return nil +} + +// checkBranchIsWritable refuses to WRITE while proving against another ref. +// +// --branch names the ref whose tracked tree governs resolution (S-7), and every +// invariant this tool proves is proven against that tree. The bytes, though, +// come from the working tree, and the working tree is whatever is checked out — +// so `sync --branch old` on a clone standing on main proved INV-2 against old's +// tree and then wrote main's file, exit 0, "applied": a rule that is dead where +// it landed, justified by a tree nobody wrote to. +// +// Refusing is chosen over implying --dry-run. An implied dry-run exits 0 having +// written nothing, which under the fleet contract in this file reads as "this +// repo converged" — 100 repos silently unchanged and 100 green rows is a worse +// failure than the one being fixed, and it is invisible until someone opens a +// PR that is not there. --dry-run remains fully available; it just has to be +// asked for. `plan` is unaffected: it emits an artifact and writes no +// CODEOWNERS, so proving against another ref is exactly its job. +// +// Refs are compared by resolved commit, not by name, so the ordinary fleet +// invocation `--branch main` on a clone checked out at main writes as it always +// did — as does a tag or a second branch pointing at the same commit, where the +// tree is the same tree. +func (r *syncRun) checkBranchIsWritable() error { + if r.branch == "HEAD" || r.dryRun { + return nil + } + head, err := gitLine(r.repoArg, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return err + } + want, err := gitLine(r.repoArg, "rev-parse", "--verify", r.branch+"^{commit}") + if err != nil { + return err + } + if head != want { + return fmt.Errorf("--branch %s is not what this clone has checked out (HEAD is %s): sync proves the change against %s's tree but writes the working tree, so the rule would be justified by one tree and land in another; re-run with --dry-run to preview it, check out %s first, or use `plan` to produce an artifact for that ref (S-7)", + r.branch, head[:min(len(head), 12)], r.branch, r.branch) + } + return nil +} + +// gitLine runs a git command that answers with a single line. +func gitLine(repoDir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repoDir}, args...)...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return strings.TrimSpace(string(out)), nil +} + +// sameDir reports whether two paths name one directory, symlinks resolved. +func sameDir(a, b string) (bool, error) { + ra, err := resolveDir(a) + if err != nil { + return false, err + } + rb, err := resolveDir(b) + if err != nil { + return false, err + } + return ra == rb, nil +} + +func resolveDir(p string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + // EvalSymlinks failing is not fatal here: the absolutized path is still a + // usable answer, and the only cost is a refusal that reads as a mismatch + // rather than as an I/O error. + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(resolved), nil + } + return filepath.Clean(abs), nil +} + +// statusForReadFailure separates the two ways reading a repo can fail. A missing +// CODEOWNERS is a considered refusal — the tool read the repo and declined, +// because --create is off by default; an I/O failure is one it never got to read. +func statusForReadFailure(err error) string { + var missing *noCodeownersError + if errors.As(err, &missing) { + return StatusRefused + } + return StatusError +} + +// noCodeownersError is R-23: this repo has no CODEOWNERS and --create was not +// given. Exit 2, never 3 — --create is off by default, so treating it as a policy +// error halted revision 1's fleet run at roughly repo 3. +type noCodeownersError struct{ ref string } + +func (e *noCodeownersError) Error() string { + return "no CODEOWNERS file found in .github/, root, or docs/ at " + e.ref + + "; re-run with --create to write one at .github/CODEOWNERS, or --file to name a path (R-23)" +} + +// governing finds the CODEOWNERS this run edits and reads its current bytes. +// +// Discovery falls back to the WORKING TREE (D5). FindCodeownersPaths runs over +// `git ls-tree`, so a file created by pass 1 and not yet committed is invisible +// to pass 2: the tool would see "no CODEOWNERS" again, --create never overwrites, +// and there is no third outcome — a nightly job could never converge. Bytes come +// from the working tree for the same reason `plan` reads them there: that is what +// apply mutates. +func (r *syncRun) governing(tree []string) (rel string, content []byte, creating bool, err error) { + switch { + case r.filePath != "": + rel = r.filePath + default: + if all := gittree.FindCodeownersPaths(tree); len(all) > 0 { + rel = all[0] + } else if onDisk := r.findOnDisk(); onDisk != "" { + rel = onDisk + } else { + rel = gittree.CodeownersLocations[0] + } + } + + b, readErr := os.ReadFile(filepath.Join(r.repoArg, filepath.FromSlash(rel))) + switch { + case readErr == nil: + return rel, b, false, nil + case !errors.Is(readErr, os.ErrNotExist): + return "", nil, false, readErr + case !r.create: + return "", nil, false, &noCodeownersError{ref: r.branch} + default: + // Creating from nothing: the "before" state is an empty file, which is + // what INV-2 is proven against. + return rel, nil, true, nil + } +} + +func (r *syncRun) findOnDisk() string { + for _, cand := range gittree.CodeownersLocations { + if _, err := os.Stat(filepath.Join(r.repoArg, filepath.FromSlash(cand))); err == nil { + return cand + } + } + return "" +} + +// syncStatus derives R-24's verdict from the per-op results. `skipped` is not +// cosmetic: without it, a policy with one typo'd path prefix skips on every repo +// and reports 100 × `unchanged`, and the operator grouping on .status reads +// "already correct" and ships a no-op rollout. +func syncStatus(rec SyncRecord) string { + switch { + case rec.OpsApplied > 0: + return StatusApplied + case rec.OpsSkipped > 0: + return StatusSkipped + default: + return StatusUnchanged + } +} + +// write converges the file on disk. Creation is seeded with an empty file rather +// than bypassing apply: creating a CODEOWNERS is the one write with no prior +// artifact to prove INV-2 against, so it gets the hash pin, the syntax validation +// and the atomic rename (R-10), not fewer of them. O_EXCL makes "--create never +// overwrites" a property of the syscall rather than of the discovery logic above. +func (r *syncRun) write(rel string, p *plan.Plan, creating bool) error { + target := filepath.Join(r.repoArg, filepath.FromSlash(rel)) + if creating { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + } + if err := apply.Apply(p, target); err != nil { + if creating { + // Never leave the empty seed behind: a zero-byte CODEOWNERS is a file + // that governs nothing while making every later run think one exists. + _ = os.Remove(target) + } + return err + } + return nil +} + +// emitRecord writes the record to every sink the operator asked for. +// +// --out ALWAYS writes the JSON record, whatever --format says, and does not +// suppress stdout. The alternative — --out emitting whatever --format names — +// destroys the artifact the flag exists for: `--out records/$repo.json` under the +// default text format would leave a directory of prose, and the aggregation over +// it fails on the first file, after the rollout has already written every +// CODEOWNERS. +// +// STDOUT GOES FIRST, AND UNCONDITIONALLY. The fleet script's `>> results.jsonl` +// is fed from stdout and is the only durable trace of what happened to a repo; +// it must not be lost because some other sink was unwritable. Writing the file +// sinks first, returning on the first error and mapping that to ExitRefused, +// cost exactly that: `--out /nonexistent-dir/rec.json` on a repo whose +// CODEOWNERS had ALREADY been rewritten produced exit 2 and an empty stdout, so +// the script filed a converged repo under `needs-human` and left no record of +// the change it had just made. That is a reporting failure being reported as a +// repository failure, which is the one lie this record exists to prevent. +// +// A sink failure is therefore a warning and never an exit code. The verdict +// belongs to the repository: once the file on disk has converged, no unwritable +// --out path makes that false, and inventing a refusal sends a human to inspect +// a repo that is already correct. The warning goes to stderr immediately and +// into rec.Warnings, so the sinks still downstream (the summary) carry it too. +func emitRecord(rec SyncRecord, r *syncRun, format, outPath, summaryPath string, stdout, stderr io.Writer) { + b, err := json.Marshal(rec) + if err != nil { + // Unreachable in practice; degrade to the human render rather than + // emitting nothing at all. + fmt.Fprintln(stderr, "warning: could not render the JSON record:", err) + } + line := append(b, '\n') + + switch { + case err != nil && format == "json": + // Nothing valid to write; the warning above is the whole report. + case format == "json": + if _, werr := stdout.Write(line); werr != nil { + fmt.Fprintln(stderr, "warning: could not write the record to stdout:", werr) + } + default: + renderRecordText(stdout, rec) + } + if err != nil { + return + } + + if outPath != "" { + if werr := os.WriteFile(outPath, line, 0o644); werr != nil { + w := fmt.Sprintf("write --out %s: %v (the record above is on stdout; this repo's outcome is unaffected)", outPath, werr) + fmt.Fprintln(stderr, "warning:", w) + rec.Warnings = append(rec.Warnings, w) + } + } + if summaryPath != "" { + if werr := os.WriteFile(summaryPath, []byte(renderSummary(rec, r)), 0o644); werr != nil { + fmt.Fprintf(stderr, "warning: write --summary-out %s: %v (this repo's outcome is unaffected)\n", summaryPath, werr) + } + } +} + +// renderRecordText is the human render. It is stdout's content under --format text and +// deliberately not JSON: a consumer that piped text into `jq` should fail loudly +// rather than parse a lookalike. +func renderRecordText(w io.Writer, rec SyncRecord) { + fmt.Fprintf(w, "%s: %d op(s) applied, %d skipped; %d line change(s), %d path(s) change owners\n", + rec.Status, rec.OpsApplied, rec.OpsSkipped, len(rec.Changes), rec.PathsChanged) + for i, o := range rec.Ops { + label := policy.OpLabel(o.ID, i) + switch { + case o.Reason != "": + fmt.Fprintf(w, " %s %s: %s\n", label, o.Status, o.Reason) + case o.Proven != "": + fmt.Fprintf(w, " %s %s (proven: %s)\n", label, o.Status, o.Proven) + default: + fmt.Fprintf(w, " %s %s\n", label, o.Status) + } + } + if rec.Created { + fmt.Fprintln(w, " created a new CODEOWNERS file") + } + if rec.Error != "" { + fmt.Fprintln(w, " "+rec.Error) + } +} + +// renderSummary is the PR body (R-24). It names the policy — that is what `name` +// and `description` are for — and calls out every op proven only structurally, so +// a reviewer finds the weakened INV-1 cases (INV-6) without reading the diff. +func renderSummary(rec SyncRecord, r *syncRun) string { + var b strings.Builder + title := "CODEOWNERS sync" + if r.policy != nil && r.policy.Name != "" { + title += " — " + r.policy.Name + } + fmt.Fprintf(&b, "# %s\n\n", title) + if r.policy != nil && r.policy.Description != "" { + fmt.Fprintf(&b, "%s\n\n", r.policy.Description) + } + fmt.Fprintf(&b, "- repo: `%s`\n", rec.Repo) + fmt.Fprintf(&b, "- status: `%s`\n", rec.Status) + fmt.Fprintf(&b, "- ops applied: %d, skipped: %d\n", rec.OpsApplied, rec.OpsSkipped) + fmt.Fprintf(&b, "- paths whose owners change: %d\n", rec.PathsChanged) + // Under --dry-run `created` reports what the run WOULD do, so the past tense + // here contradicted the --dry-run bullet three lines below in the same PR + // body ("a new CODEOWNERS file was written" … "nothing was written"). A + // reviewer reading a preview cannot be left to guess which sentence is true. + if rec.Created && !r.dryRun { + fmt.Fprintf(&b, "- a new CODEOWNERS file was written (`--create`)\n") + } + if rec.Created && r.dryRun { + fmt.Fprintf(&b, "- a new CODEOWNERS file WOULD be created (`--create`)\n") + } + if r.dryRun { + fmt.Fprintf(&b, "- `--dry-run`: nothing was written; this is what the run would do\n") + } + if rec.Error != "" { + fmt.Fprintf(&b, "\n## Not applied\n\n%s\n", rec.Error) + } + + if len(rec.Ops) > 0 { + b.WriteString("\n## Ops\n\n| id | op | status | proven | note |\n|---|---|---|---|---|\n") + for i, o := range rec.Ops { + id := policy.OpLabel(o.ID, i) + note := "" + if r.policy != nil { + note = r.policy.Notes[id] + } + detail := o.Proven + if o.Reason != "" { + detail = o.Reason + } + fmt.Fprintf(&b, "| `%s` | `%s` | %s | %s | %s |\n", id, o.Op, o.Status, detail, note) + } + } + + var structural []string + for i, o := range rec.Ops { + if o.Proven == "structural" { + structural = append(structural, fmt.Sprintf("- `%s` — `%s`", policy.OpLabel(o.ID, i), o.Op)) + } + } + if len(structural) > 0 { + b.WriteString("\n## Proven structurally, not against the tree (INV-6)\n\n") + b.WriteString("Nothing tracked in this repository matches these scopes, so the rule was appended\n" + + "at EOF where no later rule can override it, and that ordering is the whole proof —\n" + + "the tool cannot show the rule does what you meant. Read these lines in the diff.\n\n") + b.WriteString(strings.Join(structural, "\n") + "\n") + } + return b.String() +} + +func cmdCheck(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("check", flag.ContinueOnError) + fs.SetOutput(stderr) + var opSpecs, policyPaths multiFlag + fs.Var(&opSpecs, "op", "operation to syntax-check (repeatable); mutually exclusive with --policy") + fs.Var(&policyPaths, "policy", "policy file to validate; mutually exclusive with --op") + format := fs.String("format", "text", "text|json") + // No --repo, --branch, --file, --create, --dry-run or --summary-out: check + // reads no repository, and the shape of the verb is what enforces that (R-22). + // An unknown flag is a parse error, which is exit 3 below. + if err := fs.Parse(args); err != nil { + return flagParseCode(err) + } + if *format != "text" && *format != "json" { + return exit3(stderr, fmt.Errorf("unknown --format %q; want text or json", *format)) + } + pol, opList, err := opSource(opSpecs, policyPaths) + if err != nil { + return exit3(stderr, err) + } + if err := validateScopes(opList); err != nil { + return exit3(stderr, err) + } + + // Exit 0, never 1. `check` is the first line of every fleet script under + // `set -e`; a clean policy returning the no-op code would abort the run + // before the loop starts. + if *format == "json" { + doc := struct { + Valid bool `json:"valid"` + Policy string `json:"policy,omitempty"` + Name string `json:"name,omitempty"` + Ops int `json:"ops"` + }{Valid: true, Ops: len(opList)} + if len(policyPaths) == 1 { + doc.Policy = policyPaths[0] + } + if pol != nil { + doc.Name = pol.Name + } + b, err := json.Marshal(doc) + if err != nil { + return exit3(stderr, err) + } + fmt.Fprintln(stdout, string(b)) + return ExitOK + } + what := "ops" + if len(policyPaths) == 1 { + what = policyPaths[0] + } + fmt.Fprintf(stdout, "ok: %s — %d op(s), no policy errors\n", what, len(opList)) + return ExitOK +} diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go new file mode 100644 index 0000000..a7ee82e --- /dev/null +++ b/internal/cli/sync_test.go @@ -0,0 +1,1091 @@ +package cli_test + +import ( + "encoding/json" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/cli" +) + +// syncWritePolicy writes a policy file to its own temp dir — deliberately +// OUTSIDE any repo, because a policy that lives in the clone is one +// `git add -A` from being committed by the fleet script. +func syncWritePolicy(t *testing.T, src string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "policy.json") + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// syncDecodeRecord parses one sync record and asserts it is exactly ONE JSON +// object with nothing trailing. `jq -s` over results.jsonl aggregates a whole +// fleet; a second object or a stray log line on stdout breaks every consumer. +func syncDecodeRecord(t *testing.T, s string) cli.SyncRecord { + t.Helper() + dec := json.NewDecoder(strings.NewReader(s)) + var rec cli.SyncRecord + if err := dec.Decode(&rec); err != nil { + t.Fatalf("record is not valid JSON: %v\nraw:\n%s", err, s) + } + var trailing json.RawMessage + if err := dec.Decode(&trailing); err != io.EOF { + t.Fatalf("want exactly one JSON object, got trailing %s (err %v)\nraw:\n%s", trailing, err, s) + } + return rec +} + +func syncReadFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +// syncOpResult finds a per-op result by policy id. +func syncOpResult(t *testing.T, rec cli.SyncRecord, id string) (int, bool) { + t.Helper() + for i, r := range rec.Ops { + if r.ID == id { + return i, true + } + } + return 0, false +} + +// checkDirSnapshot records every file under dir by content, so a test can +// prove a command wrote nothing at all. +func checkDirSnapshot(t *testing.T, dir string) map[string]string { + t.Helper() + snap := map[string]string{} + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + snap[filepath.ToSlash(rel)] = string(b) + return nil + }) + if err != nil { + t.Fatal(err) + } + return snap +} + +// syncFleetPolicy is the worked example from the UX spec: one op that applies +// against the tree, one opportunistic op that skips, one baseline op that is +// declared for files this repo does not have yet. +const syncFleetPolicy = `{ + "version": 1, + "name": "org baseline ownership", + "description": "CI owns workflows everywhere; infra owns Terraform where it exists.", + "ops": [ + { "id": "api", "op": "add_owner(/x/, @org/api-team)" }, + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip", + "note": "Opportunistic — only repos that actually have Terraform." }, + { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare", + "note": "Baseline; also covers workflows added later." } + ] +}` + +// syncBrokenPolicies is the exit-3 class: every one of these is a property of +// the POLICY, not of any repo, so it fails identically on all 100 and must +// halt the fleet run rather than being recorded and skipped past. +var syncBrokenPolicies = []struct { + name string + src string +}{ + {"unknown top-level field", `{"version":1,"ops":["add_owner(/x/, @b)"],"on_zero_match":"skip"}`}, + {"unknown per-op field", `{"version":1,"ops":[{"op":"add_owner(/x/, @b)","on_zero_mtach":"skip"}]}`}, + {"missing version", `{"ops":["add_owner(/x/, @b)"]}`}, + {"unsupported version", `{"version":2,"ops":["add_owner(/x/, @b)"]}`}, + {"empty ops", `{"version":1,"ops":[]}`}, + {"missing ops", `{"version":1,"name":"n"}`}, + {"bad on_empty enum", `{"version":1,"on_empty":"inhrit","ops":["add_owner(/x/, @b)"]}`}, + {"bad on_zero_match enum", `{"version":1,"ops":[{"op":"add_owner(/x/, @b)","on_zero_match":"maybe"}]}`}, + {"duplicate key", `{"version":1,"on_empty":"error","on_empty":"inherit","ops":["remove_owner(/x/, @a)"]}`}, + {"malformed op string", `{"version":1,"ops":["frob(/x/, @b)"]}`}, + {"on_zero_match on rename_owner", `{"version":1,"ops":[{"op":"rename_owner(@a, @b)","on_zero_match":"skip"}]}`}, + {"declare on remove_owner", `{"version":1,"on_empty":"error","ops":[{"op":"remove_owner(/x/, @a)","on_zero_match":"declare"}]}`}, + {"remove_owner without on_empty", `{"version":1,"ops":["remove_owner(/x/, @a)"]}`}, + {"op is neither string nor object", `{"version":1,"ops":[7]}`}, +} + +// syncSmallRepo is the fixture most exit-code tests stand in: one owned +// directory, one CODEOWNERS at the root. +func syncSmallRepo(t *testing.T) string { + t.Helper() + return initRepo(t, map[string]string{ + "CODEOWNERS": "# owners\n/x/ @a\n", + "x/a.go": "package x\n", + }) +} + +// SPEC R-19: sync is convergent and idempotent. The first run applies and +// exits 0; the identical second run changes nothing and STILL exits 0. The +// collapse of "applied" and "already correct" onto 0 is the entire point — +// under `set -e` at 100 repos, the common outcome must not read as failure. +func TestSync_ConvergesThenIsIdempotent(t *testing.T) { + repo := syncSmallRepo(t) + coPath := filepath.Join(repo, "CODEOWNERS") + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--format", "json") + if code != cli.ExitOK { + t.Fatalf("first sync: want exit 0, got %d\n%s", code, errOut) + } + first := syncDecodeRecord(t, out) + if first.Status != cli.StatusApplied { + t.Errorf("first sync status = %q, want %q", first.Status, cli.StatusApplied) + } + applied := syncReadFile(t, coPath) + if applied != "# owners\n/x/ @a @b\n" { + t.Fatalf("CODEOWNERS after first sync = %q", applied) + } + + code2, out2, errOut2 := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--format", "json") + if code2 != cli.ExitOK { + t.Fatalf("second sync: want exit 0 (converged), got %d\n%s", code2, errOut2) + } + second := syncDecodeRecord(t, out2) + if second.Status != cli.StatusUnchanged { + t.Errorf("second sync status = %q, want %q", second.Status, cli.StatusUnchanged) + } + if second.OpsApplied != 0 || second.PathsChanged != 0 { + t.Errorf("second sync must change nothing: %+v", second) + } + if got := syncReadFile(t, coPath); got != applied { + t.Errorf("second sync rewrote the file: %q → %q", applied, got) + } +} + +// SPEC R-19 (R-17 remap): "already correct" is exit 0 under sync even though +// `plan` calls the same situation a no-op and exits 1. If sync inherited the +// 1, every fleet runner would abort on the most common outcome there is. +func TestSync_AlreadyCorrectIsZeroNotNoOp(t *testing.T) { + repo := syncSmallRepo(t) + if code, _, _ := runCLI(t, "plan", "--repo", repo, "--op", "add_owner(/x/, @a)"); code != cli.ExitNoOp { + t.Fatalf("precondition: plan must still report exit 1 for a no-op, got %d", code) + } + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @a)", "--format", "json") + if code != cli.ExitOK { + t.Fatalf("no-op sync: want exit 0, got %d\n%s", code, errOut) + } + if rec := syncDecodeRecord(t, out); rec.Status != cli.StatusUnchanged { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusUnchanged) + } +} + +// SPEC R-19 (exit contract): a refusal is exit 2 — this repo's file has an +// awkward shape and needs a human. The fleet script records it and keeps +// going; it must never be confused with a broken policy. +func TestSync_RefusalExitsTwo(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "* @default\n/x/ @solo\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + // R-6: removing the only owner under --on-empty=error is a refusal. + code, _, _ := runCLI(t, "sync", "--repo", repo, "--op", "remove_owner(/x/, @solo)", "--on-empty", "error") + if code != cli.ExitRefused { + t.Errorf("on-empty=error refusal: want exit 2, got %d", code) + } + + // INV-1/INV-2 unprovable: no sound narrowing pattern is derivable. + repo2 := initRepo(t, map[string]string{ + "CODEOWNERS": "*.md @docs\n", + "a/x/doc.md": "x\n", + "a/x/code.go": "package x\n", + "other/doc.md": "x\n", + "other/keep.go": "package other\n", + }) + if code, _, _ := runCLI(t, "sync", "--repo", repo2, "--op", "add_owner(x/, @b)"); code != cli.ExitRefused { + t.Errorf("inexpressible intent: want exit 2, got %d", code) + } +} + +// SPEC R-19/R-21: a scope matching zero tracked files under the default +// `require` is exit 2, NOT 3. `plan` calls it invalid input; sync remaps it +// because whether a path exists is the most repo-specific fact there is. +// Getting this backwards halts a 100-repo run on repo 3. +func TestSync_ZeroMatchUnderRequireExitsTwo(t *testing.T) { + repo := syncSmallRepo(t) + if code, _, _ := runCLI(t, "plan", "--repo", repo, "--op", "add_owner(/ghost/, @b)"); code != cli.ExitInvalid { + t.Fatalf("precondition: plan must still report exit 3 for zero-match, got %d", code) + } + code, _, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/ghost/, @b)") + if code != cli.ExitRefused { + t.Errorf("zero-match under require: want exit 2 (this repo), got %d\n%s", code, errOut) + } +} + +// SPEC R-23: no CODEOWNERS and no --create is exit 2, not 3. --create is off +// by default, so treating "this repo has no file" as a policy error halted +// revision 1's fleet run at roughly repo 3. +func TestSync_NoCodeownersWithoutCreateExitsTwo(t *testing.T) { + repo := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + if code, _, _ := runCLI(t, "plan", "--repo", repo, "--op", "add_owner(/svc/api/, @org/api)"); code != cli.ExitInvalid { + t.Fatalf("precondition: plan must still report exit 3 for a missing file, got %d", code) + } + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/svc/api/, @org/api)", "--format", "json") + if code != cli.ExitRefused { + t.Fatalf("missing CODEOWNERS without --create: want exit 2, got %d\n%s", code, errOut) + } + if rec := syncDecodeRecord(t, out); rec.Created { + t.Error("created must be false when nothing was created") + } + if _, err := os.Stat(filepath.Join(repo, ".github", "CODEOWNERS")); err == nil { + t.Error("--create is off by default: no file may be written") + } +} + +// SPEC R-19/R-20: a broken policy is exit 3 — it fails identically on every +// repo, so the fleet script halts instead of grinding through 100 clones. +func TestSync_BrokenPolicyExitsThree(t *testing.T) { + repo := syncSmallRepo(t) + for _, tc := range syncBrokenPolicies { + t.Run(tc.name, func(t *testing.T) { + p := syncWritePolicy(t, tc.src) + code, _, _ := runCLI(t, "sync", "--repo", repo, "--policy", p) + if code != cli.ExitInvalid { + t.Errorf("%s: want exit 3 (halt the fleet), got %d", tc.name, code) + } + }) + } + // A malformed --op string is the same class. + if code, _, _ := runCLI(t, "sync", "--repo", repo, "--op", "frob(/x/, @b)"); code != cli.ExitInvalid { + t.Error("malformed --op: want exit 3") + } + // So is a policy file that is not there at all. + if code, _, _ := runCLI(t, "sync", "--repo", repo, "--policy", filepath.Join(t.TempDir(), "nope.json")); code != cli.ExitInvalid { + t.Error("missing policy file: want exit 3") + } +} + +// SPEC R-20: --op and --policy are mutually exclusive, and --policy twice is +// an error, never a silent last-wins. Silent last-wins means the artifact in +// git is not the policy that ran. +func TestSync_OpAndPolicyMisuseExitsThree(t *testing.T) { + repo := syncSmallRepo(t) + p1 := syncWritePolicy(t, `{"version":1,"ops":["add_owner(/x/, @b)"]}`) + p2 := syncWritePolicy(t, `{"version":1,"ops":["add_owner(/x/, @c)"]}`) + + // A slice, not a map, and one t.Run per case: a map ranges in random order + // and, without subtests, all three cases report under this test's single + // name. A failure then names a case that may not be the one that failed on + // the next run, and `go test -run` cannot re-run just the broken one. + cases := []struct { + name string + args []string + }{ + {"both", []string{"sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--policy", p1}}, + {"neither", []string{"sync", "--repo", repo}}, + {"policy twice", []string{"sync", "--repo", repo, "--policy", p1, "--policy", p2}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + code, _, _ := runCLI(t, c.args...) + if code != cli.ExitInvalid { + t.Errorf("%s: want exit 3, got %d", c.name, code) + } + }) + } + // Last-wins would have silently applied p2 and left the file changed. + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != "# owners\n/x/ @a\n" { + t.Errorf("a rejected invocation must write nothing, file = %q", got) + } +} + +// SPEC R-20: --on-empty is allowed only with --op. With --policy it is a hard +// error, because a flag that silently beats a policy field means the file in +// git is not the complete statement of what ran — and `check` would then +// validate something other than what executes. +func TestSync_OnEmptyWithPolicyExitsThree(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "* @default\n/x/ @solo\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + p := syncWritePolicy(t, `{"version":1,"on_empty":"inherit","ops":["remove_owner(/x/, @solo)"]}`) + if code, _, _ := runCLI(t, "sync", "--repo", repo, "--policy", p, "--on-empty", "unowned"); code != cli.ExitInvalid { + t.Error("--on-empty with --policy: want exit 3") + } + // The same flag with --op is exactly how R-6 is expressed there. + code, _, errOut := runCLI(t, "sync", "--repo", repo, "--op", "remove_owner(/x/, @solo)", "--on-empty", "inherit") + if code != cli.ExitOK { + t.Errorf("--on-empty with --op: want exit 0, got %d\n%s", code, errOut) + } +} + +// SPEC R-19: sync returns ONLY 0, 2, 3. Revision 1's "3+ means halt" was +// unsafe three separate ways; reserving nothing is the fix. 1 (no-op), 4 +// (findings), 5 (inconclusive) and 6 (rolled back) must all be unreachable. +func TestSync_NeverReturnsOtherExitCodes(t *testing.T) { + ok := syncSmallRepo(t) + empty := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + removal := initRepo(t, map[string]string{ + "CODEOWNERS": "* @default\n/x/ @solo\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + broken := syncWritePolicy(t, `{"version":1,"ops":[{"op":"add_owner(/x/, @b)","on_zero_mtach":"skip"}]}`) + fleet := syncWritePolicy(t, syncFleetPolicy) + + invocations := [][]string{ + {"sync", "--repo", ok, "--op", "add_owner(/x/, @b)"}, + {"sync", "--repo", ok, "--op", "add_owner(/x/, @a)"}, + {"sync", "--repo", ok, "--op", "add_owner(/ghost/, @b)"}, + {"sync", "--repo", ok, "--policy", fleet}, + {"sync", "--repo", ok, "--policy", broken}, + {"sync", "--repo", empty, "--op", "add_owner(/svc/api/, @org/api)"}, + {"sync", "--repo", empty, "--op", "add_owner(/svc/api/, @org/api)", "--create"}, + {"sync", "--repo", removal, "--op", "remove_owner(/x/, @solo)", "--on-empty", "error"}, + {"sync", "--repo", removal, "--op", "remove_owner(/x/, @solo)"}, + {"sync", "--repo", filepath.Join(t.TempDir(), "not-a-repo"), "--op", "add_owner(/x/, @b)"}, + {"sync", "--repo", ok, "--op", "add_owner(/x/, @b)", "--format", "json", "--dry-run"}, + } + for _, args := range invocations { + code, _, _ := runCLI(t, args...) + switch code { + case cli.ExitOK, cli.ExitRefused, cli.ExitInvalid: + default: + t.Errorf("sync %v: exit %d — sync may return only 0, 2, 3", args[1:], code) + } + } +} + +// SPEC R-19: `sync -h` and `sync --help` exit 0. Under a fleet contract a +// help request reading as "the policy is broken, halt" is wrong. +func TestSync_HelpExitsZero(t *testing.T) { + for _, flagSpelling := range []string{"-h", "--help"} { + if code, _, _ := runCLI(t, "sync", flagSpelling); code != cli.ExitOK { + t.Errorf("sync %s: want exit 0, got %d", flagSpelling, code) + } + if code, _, _ := runCLI(t, "check", flagSpelling); code != cli.ExitOK { + t.Errorf("check %s: want exit 0, got %d", flagSpelling, code) + } + } +} + +// SPEC R-23: --create writes .github/CODEOWNERS when the repo has none, and +// reports created:true so the fleet aggregation can tell a new file from an +// edit. +func TestSync_CreateWritesWhenAbsent(t *testing.T) { + repo := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + code, out, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/svc/api/, @org/api)", "--create", "--format", "json") + if code != cli.ExitOK { + t.Fatalf("sync --create: want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if !rec.Created { + t.Error("created must be true when the file was written from nothing") + } + got := syncReadFile(t, filepath.Join(repo, ".github", "CODEOWNERS")) + if !strings.Contains(got, "@org/api") { + t.Errorf(".github/CODEOWNERS = %q, want the new owner", got) + } +} + +// SPEC R-23: --create NEVER overwrites. Creating a file is the one action +// with no prior artifact to prove INV-2 against; clobbering an existing one +// would destroy ownership the tool exists to protect. +func TestSync_CreateNeverOverwrites(t *testing.T) { + repo := syncSmallRepo(t) + coPath := filepath.Join(repo, "CODEOWNERS") + before := syncReadFile(t, coPath) + + // An op that is already satisfied: the run converges without touching + // a byte, and --create must not manufacture a second file. + code, out, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/x/, @a)", "--create", "--format", "json") + if code != cli.ExitOK { + t.Fatalf("want exit 0, got %d\n%s", code, errOut) + } + if got := syncReadFile(t, coPath); got != before { + t.Errorf("existing CODEOWNERS was rewritten: %q → %q", before, got) + } + if _, err := os.Stat(filepath.Join(repo, ".github", "CODEOWNERS")); err == nil { + t.Error("--create must not create a second file when one already governs") + } + if rec := syncDecodeRecord(t, out); rec.Created { + t.Error("created must be false when a file already existed") + } +} + +// SPEC R-23: --create honors --file when given, and hard-errors with a +// non-HEAD --branch — there is nothing to create a file "at" on a ref you are +// not standing on, and silently writing to the working tree instead would be +// the wrong file in the wrong place. +func TestSync_CreateHonorsFileAndRejectsNonHeadBranch(t *testing.T) { + repo := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + code, _, errOut := runCLI(t, "sync", "--repo", repo, + "--op", "add_owner(/svc/api/, @org/api)", "--create", "--file", "docs/CODEOWNERS") + if code != cli.ExitOK { + t.Fatalf("--create --file: want exit 0, got %d\n%s", code, errOut) + } + if got := syncReadFile(t, filepath.Join(repo, "docs", "CODEOWNERS")); !strings.Contains(got, "@org/api") { + t.Errorf("docs/CODEOWNERS = %q", got) + } + if _, err := os.Stat(filepath.Join(repo, ".github", "CODEOWNERS")); err == nil { + t.Error("--file was given: the default location must not also be written") + } + + // A GENUINELY non-HEAD branch. initRepo runs `git init -b main`, so the + // earlier revision of this test passed "main" — the branch the repo is + // already standing on — and therefore asserted that naming your own + // checked-out branch is an error. That is an ordinary fleet invocation + // (`--branch main` in a script), and rejecting it halted the whole rollout + // at repo 0. The intent below was always right; the fixture did not + // exercise it. Refusal is exit 2, not 3: whether a ref is the one checked + // out is a fact about this repo, not about the arguments. + repo2 := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + syncGit(t, repo2, "branch", "other") + syncGit(t, repo2, "commit", "-q", "--allow-empty", "-m", "move main ahead of other") + if code, _, _ := runCLI(t, "sync", "--repo", repo2, + "--op", "add_owner(/svc/api/, @org/api)", "--create", "--branch", "other"); code != cli.ExitRefused { + t.Errorf("--create with a genuinely non-HEAD --branch: want exit 2, got %d", code) + } + // Naming the checked-out branch explicitly must still work. + repo3 := initRepo(t, map[string]string{"svc/api/main.go": "package main\n"}) + if code, _, errOut := runCLI(t, "sync", "--repo", repo3, + "--op", "add_owner(/svc/api/, @org/api)", "--create", "--branch", "main"); code != cli.ExitOK { + t.Errorf("--branch main on a repo standing on main: want exit 0, got %d\n%s", code, errOut) + } +} + +// syncGit runs a git command in dir, for tests that need a second branch. +func syncGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// SPEC R-19/R-24: --dry-run makes NO change to CODEOWNERS but STILL emits +// --out and --summary-out. That combination is what makes a fleet preview +// useful — at 100 repos, the aggregated preview is the only review possible. +func TestSync_DryRunWritesNoCodeownersButStillEmits(t *testing.T) { + repo := syncSmallRepo(t) + coPath := filepath.Join(repo, "CODEOWNERS") + before := syncReadFile(t, coPath) + outDir := t.TempDir() + recPath := filepath.Join(outDir, "record.json") + sumPath := filepath.Join(outDir, "body.md") + + code, _, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", + "--dry-run", "--out", recPath, "--summary-out", sumPath) + if code != cli.ExitOK { + t.Fatalf("dry run: want exit 0, got %d\n%s", code, errOut) + } + if got := syncReadFile(t, coPath); got != before { + t.Fatalf("--dry-run wrote to CODEOWNERS: %q → %q", before, got) + } + rec := syncDecodeRecord(t, syncReadFile(t, recPath)) + if rec.Status != cli.StatusApplied { + t.Errorf("dry-run status = %q, want %q — the preview must show what WOULD happen", rec.Status, cli.StatusApplied) + } + if len(rec.Changes) == 0 { + t.Error("a preview with no changes tells the operator nothing") + } + if s := syncReadFile(t, sumPath); strings.TrimSpace(s) == "" { + t.Error("--summary-out must still be written under --dry-run") + } +} + +// SPEC R-24: under --format json, stdout is data and stderr is logs. A log +// line on stdout breaks `jq -s` over results.jsonl for the whole fleet. +func TestSync_FormatJSONSeparatesDataFromLogs(t *testing.T) { + repo := syncSmallRepo(t) + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--format", "json") + if code != cli.ExitOK { + t.Fatalf("want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Repo == "" { + t.Error("record must name the repo — a fleet row without it is unattributable") + } + if strings.Contains(errOut, `"status"`) { + t.Errorf("the record must not also be written to stderr:\n%s", errOut) + } + + // text is the default, and it is not JSON. + repo2 := syncSmallRepo(t) + code2, out2, _ := runCLI(t, "sync", "--repo", repo2, "--op", "add_owner(/x/, @b)") + if code2 != cli.ExitOK { + t.Fatalf("default format: want exit 0, got %d", code2) + } + if strings.HasPrefix(strings.TrimSpace(out2), "{") { + t.Errorf("--format text is the default, got JSON:\n%s", out2) + } + + // An unrecognized format is a policy-class error, not a silent fallback. + if code, _, _ := runCLI(t, "sync", "--repo", syncSmallRepo(t), "--op", "add_owner(/x/, @b)", "--format", "yaml"); code != cli.ExitInvalid { + t.Error("--format yaml: want exit 3, never a silent fallback to text") + } +} + +// SPEC R-24: the two flags are independent. `--out FILE` ALWAYS writes the JSON +// record to FILE, whatever `--format` says; `--format` governs stdout and only +// stdout. +// +// The alternative — `--out` emitting whatever `--format` names — quietly +// destroys the artifact the flag exists for. `sync --out records/$repo.json` +// with the default text format would leave a directory of human prose, and the +// `jq -s` aggregation the README builds over it fails on the first file with a +// parse error, after the whole rollout has already run and the CODEOWNERS +// writes are done. Making it depend on a flag nobody passed is worse than +// making it wrong: it works for the operator who happened to type `--format +// json` and fails for the one who did not. +// +// The converse guarantee matters as much: `--out` must not go on to SUPPRESS +// stdout. Someone piping `sync --format json ... | tee` while also archiving +// with `--out` gets both, byte-identical, and the fleet script's +// `>> results.jsonl` keeps working when a per-repo `--out` is added to it. +func TestSync_OutWritesRecordToFile(t *testing.T) { + // Default format is text: the FILE is still the JSON record, stdout is + // still the human render. + repo := syncSmallRepo(t) + recPath := filepath.Join(t.TempDir(), "record.json") + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--op", "add_owner(/x/, @b)", "--out", recPath) + if code != cli.ExitOK { + t.Fatalf("want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, syncReadFile(t, recPath)) + if rec.Status != cli.StatusApplied { + t.Errorf("record status = %q, want %q", rec.Status, cli.StatusApplied) + } + if strings.HasPrefix(strings.TrimSpace(out), "{") { + t.Errorf("--format defaults to text: stdout must carry the human render, not the record:\n%s", out) + } + if strings.TrimSpace(out) == "" { + t.Error("--out must not silence stdout: --format governs stdout, --out governs the file, and neither implies the other") + } + + // --format json --out FILE: both sinks, same record, no interaction. + repo2 := syncSmallRepo(t) + recPath2 := filepath.Join(t.TempDir(), "record2.json") + code2, out2, errOut2 := runCLI(t, "sync", "--repo", repo2, "--op", "add_owner(/x/, @b)", + "--format", "json", "--out", recPath2) + if code2 != cli.ExitOK { + t.Fatalf("--format json --out: want exit 0, got %d\n%s", code2, errOut2) + } + fromFile := syncDecodeRecord(t, syncReadFile(t, recPath2)) + fromStdout := syncDecodeRecord(t, out2) + if fromFile.Status != cli.StatusApplied { + t.Errorf("--format json --out: file record status = %q, want %q", fromFile.Status, cli.StatusApplied) + } + if fromStdout.Status != fromFile.Status || fromStdout.Repo != fromFile.Repo { + t.Errorf("--format json --out: stdout and file disagree:\nstdout %+v\nfile %+v", fromStdout, fromFile) + } + if strings.TrimSpace(out2) != strings.TrimSpace(syncReadFile(t, recPath2)) { + t.Errorf("--format json --out: the two sinks must be byte-identical\nstdout: %q\nfile: %q", + out2, syncReadFile(t, recPath2)) + } +} + +// SPEC R-24/INV-6: --summary-out renders markdown for a PR body. It must name +// the policy (that is why `name` and `description` exist) and call out every +// op proven only structurally, so a reviewer finds the weakened INV-1 cases +// without reading the diff. +func TestSync_SummaryOutNamesPolicyAndStructuralOps(t *testing.T) { + repo := syncSmallRepo(t) + p := syncWritePolicy(t, syncFleetPolicy) + sumPath := filepath.Join(t.TempDir(), "body.md") + + code, _, errOut := runCLI(t, "sync", "--repo", repo, "--policy", p, "--summary-out", sumPath) + if code != cli.ExitOK { + t.Fatalf("want exit 0, got %d\n%s", code, errOut) + } + summary := syncReadFile(t, sumPath) + for _, want := range []string{"org baseline ownership", "CI owns workflows everywhere"} { + if !strings.Contains(summary, want) { + t.Errorf("summary must carry the policy %q:\n%s", want, summary) + } + } + // The `ci` op matched nothing tracked and was proven structurally + // (INV-6). A reviewer who cannot see that cannot review it. + if !strings.Contains(summary, "ci") { + t.Errorf("summary must name the structurally-proven op by id:\n%s", summary) + } + if !strings.Contains(strings.ToLower(summary), "structural") { + t.Errorf("summary must call out structural proof (INV-6):\n%s", summary) + } +} + +// SPEC R-24: the record's per-op results carry id/op/status/proven, and the +// counts agree with the array. "ops_skipped: 1" alone cannot answer the +// question that motivates `skip` — WHICH repos lack Terraform. +func TestSync_JSONRecordPerOpResults(t *testing.T) { + repo := syncSmallRepo(t) + p := syncWritePolicy(t, syncFleetPolicy) + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--policy", p, "--format", "json") + if code != cli.ExitOK { + t.Fatalf("want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusApplied { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusApplied) + } + if len(rec.Ops) != 3 { + t.Fatalf("want one result per policy op, got %d: %+v", len(rec.Ops), rec.Ops) + } + var applied, skipped int + for _, r := range rec.Ops { + if r.ID == "" || r.Op == "" || r.Status == "" { + t.Errorf("per-op result missing id/op/status: %+v", r) + } + switch r.Status { + case "applied": + applied++ + if r.Proven != "tree" && r.Proven != "structural" { + t.Errorf("applied op %q proven = %q, want tree|structural", r.ID, r.Proven) + } + case "skipped": + skipped++ + if r.Reason == "" { + t.Errorf("skipped op %q must say why", r.ID) + } + case "unchanged": + default: + t.Errorf("op %q status = %q, want applied|skipped|unchanged", r.ID, r.Status) + } + } + if rec.OpsApplied != applied || rec.OpsSkipped != skipped { + t.Errorf("counts disagree with the array: ops_applied=%d ops_skipped=%d vs %d/%d", + rec.OpsApplied, rec.OpsSkipped, applied, skipped) + } + i, ok := syncOpResult(t, rec, "tf") + if !ok { + t.Fatalf("no result for the `tf` op: %+v", rec.Ops) + } + if rec.Ops[i].Status != "skipped" { + t.Errorf("tf op (no Terraform in this repo, on_zero_match=skip) = %q, want skipped", rec.Ops[i].Status) + } + j, ok := syncOpResult(t, rec, "ci") + if !ok { + t.Fatalf("no result for the `ci` op: %+v", rec.Ops) + } + if rec.Ops[j].Proven != "structural" { + t.Errorf("declare op with zero matches must be proven %q (INV-6), got %q", "structural", rec.Ops[j].Proven) + } + if rec.PathsChanged < 1 { + t.Errorf("paths_changed = %d, want the /x/ paths the api op took", rec.PathsChanged) + } + if rec.Created { + t.Error("created must be false — the repo already had a CODEOWNERS") + } + if len(rec.Warnings) != 0 { + t.Errorf("clean run must carry no warnings, got %v", rec.Warnings) + } +} + +// SPEC R-24 (D1): an already-correct repo STILL reports one per-op result per +// policy op, each `unchanged`. +// +// This pins the no-op path specifically, and it is a real hole today: +// plan.Build returns `nil, &NoOpError{}` when the file already says what the +// policy wants, so a caller that renders the record from the returned *Plan +// has nothing to render and emits `"ops": []`. `unchanged` is the MODAL +// outcome of a mature fleet — most repos are already correct most nights — so +// the empty case is the one an operator sees on 90 of 100 rows. Without it the +// record cannot answer "is op `tf` actually in place here, or did the policy +// never mention this repo?", and the whole per-op array degrades to detail that +// appears only when something changed, which is exactly when you least need it. +// +// Three fleet tests (fleet_test.go's "per-op results" and the idempotence +// file's repeat passes) read per-op detail out of records produced by this +// path; none of them pins the path itself. +func TestSync_AlreadyCorrectStillCarriesPerOpResults(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "# owners\n/x/ @a @b\n/y/ @c\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + p := syncWritePolicy(t, `{ + "version": 1, + "ops": [ + {"id":"x","op":"add_owner(/x/, @b)"}, + {"id":"y","op":"add_owner(/y/, @c)"} + ] + }`) + before := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")) + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--policy", p, "--format", "json") + if code != cli.ExitOK { + t.Fatalf("already-correct repo: want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusUnchanged { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusUnchanged) + } + if len(rec.Ops) != 2 { + t.Fatalf("no-op run carried %d per-op results, want one per policy op (2): %+v — the no-op path must report as fully as the applying one", len(rec.Ops), rec.Ops) + } + for _, r := range rec.Ops { + if r.Status != "unchanged" { + t.Errorf("op %q: status %q, want unchanged", r.ID, r.Status) + } + if r.ID == "" { + t.Errorf("op result lost its policy id: %+v — results are keyed by id", r) + } + if r.Op == "" { + t.Errorf("op result lost its op text: %+v (D7: Op is the raw op string)", r) + } + } + if _, ok := syncOpResult(t, rec, "x"); !ok { + t.Errorf("no result for op `x`: %+v", rec.Ops) + } + if _, ok := syncOpResult(t, rec, "y"); !ok { + t.Errorf("no result for op `y`: %+v", rec.Ops) + } + if rec.OpsApplied != 0 { + t.Errorf("ops_applied = %d, want 0 — nothing was applied", rec.OpsApplied) + } + if rec.OpsSkipped != 0 { + t.Errorf("ops_skipped = %d, want 0 — `unchanged` is not `skipped`", rec.OpsSkipped) + } + if rec.PathsChanged != 0 { + t.Errorf("paths_changed = %d, want 0", rec.PathsChanged) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != before { + t.Errorf("an already-correct repo must not be rewritten: %q → %q", before, got) + } +} + +// SPEC R-20 (D3): two ops sharing one `id` is exit 3. +// +// Results are KEYED by id — syncOpResult here, fleetOpResult in fleet_test.go, +// opResultByID in the planner's tests, and every `jq` recipe an operator writes +// over results.jsonl. Every one of them returns the FIRST match. So a policy +// with a duplicated id does not fail: it silently reports the first op's +// outcome as if it were the second's, across the whole fleet, and the operator +// reading "tf: applied" cannot tell that the other `tf` refused. Nothing about +// this depends on any repo, so it belongs in the class that halts at repo 0 — +// and `check` must catch it before the first clone. +func TestSync_DuplicateOpIDsExitThree(t *testing.T) { + repo := syncSmallRepo(t) + dup := syncWritePolicy(t, `{ + "version": 1, + "ops": [ + {"id":"tf","op":"add_owner(/x/, @b)"}, + {"id":"tf","op":"add_owner(/x/, @c)"} + ] + }`) + before := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")) + + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--policy", dup, "--format", "json") + if code != cli.ExitInvalid { + t.Errorf("duplicate op id: want exit 3, got %d — a shadowed id makes every per-op result unreliable\nstderr: %s", code, errOut) + } + if !strings.Contains(errOut, "tf") { + t.Errorf("the error must quote the duplicated id, or nobody can find it in a 40-op policy:\n%s", errOut) + } + if strings.TrimSpace(out) != "" { + t.Errorf("exit 3 emits no record (D8), got: %q", out) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != before { + t.Errorf("a rejected policy writes nothing, file = %q", got) + } + // `check` is the first line of the fleet script; this must never get past it. + if code, _, _ := runCLI(t, "check", "--policy", dup); code != cli.ExitInvalid { + t.Errorf("check on a duplicate-id policy: want exit 3, got %d", code) + } +} + +// SPEC R-24: `--repo` pointing at a directory that is not a git repository is a +// RECORDED per-repo failure — exit 2, one record, status "error". +// +// This is the only producer of cli.StatusError, and it is a different animal +// from "refused": refused means the tool understood this repo and declined to +// touch it, error means it never got that far. Both need a human, so both are +// exit 2 and both must appear in results.jsonl; grouping on .status is how an +// operator separates "12 repos have awkward CODEOWNERS files" from "12 clones +// failed and were never actually synced". It must NOT be exit 3: a failed or +// half-finished clone is the most repo-specific fact there is, and halting the +// whole rollout on it strands the other 99 — the same mistake revision 1 made +// with zero-match scopes. +func TestSync_NonRepoDirectoryIsAnErrorRecord(t *testing.T) { + dir := t.TempDir() // exists, is readable, has no .git + code, out, errOut := runCLI(t, "sync", "--repo", dir, "--op", "add_owner(/x/, @b)", "--format", "json") + if code != cli.ExitRefused { + t.Fatalf("--repo is not a git repo: exit %d, want 2 — exit 3 would halt the fleet for one bad clone\nstderr: %s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusError { + t.Errorf("status = %q, want %q — %q would claim the tool read this repo and declined, which it did not", + rec.Status, cli.StatusError, cli.StatusRefused) + } + if strings.TrimSpace(rec.Error) == "" { + t.Error("an error record must carry the reason text, or the row says only that something went wrong") + } + if rec.Repo != dir { + t.Errorf("record .repo = %q, want the --repo argument %q verbatim (D6)", rec.Repo, dir) + } + if rec.Created { + t.Error("created must be false: nothing was written") + } + if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 { + t.Errorf("a repo that could not be read must not be written to: %v (err %v)", entries, err) + } +} + +// SPEC R-24: `skipped` is a DISTINCT status, used when at least one op +// skipped and none applied. Without it a policy with one typo'd path prefix +// skips everywhere and reports 100 × `unchanged` — the operator groups on +// .status, reads "already correct", and ships a no-op rollout. +func TestSync_StatusSkippedWhenNothingApplied(t *testing.T) { + repo := syncSmallRepo(t) + p := syncWritePolicy(t, `{ + "version": 1, + "ops": [ + {"id":"tf","op":"add_owner(**/*.tf, @org/infra)","on_zero_match":"skip"}, + {"id":"typo","op":"add_owner(/servcies/api/, @org/api)","on_zero_match":"skip"} + ] + }`) + code, out, errOut := runCLI(t, "sync", "--repo", repo, "--policy", p, "--format", "json") + if code != cli.ExitOK { + t.Fatalf("an all-skipped run is graceful: want exit 0, got %d\n%s", code, errOut) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusSkipped { + t.Errorf("status = %q, want %q — %q here is how a no-op rollout ships unnoticed", + rec.Status, cli.StatusSkipped, cli.StatusUnchanged) + } + if rec.OpsApplied != 0 || rec.OpsSkipped != 2 { + t.Errorf("ops_applied=%d ops_skipped=%d, want 0/2", rec.OpsApplied, rec.OpsSkipped) + } + if rec.PathsChanged != 0 { + t.Errorf("paths_changed = %d, want 0", rec.PathsChanged) + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != "# owners\n/x/ @a\n" { + t.Errorf("an all-skipped run must not touch the file, got %q", got) + } +} + +// SPEC R-24: a refusal still produces a parseable record carrying the reason. +// The fleet script appends every run to results.jsonl; a refused repo that +// emits nothing (or emits prose) is a hole in the aggregation exactly where +// the operator needs to look. +func TestSync_RefusalRecordCarriesError(t *testing.T) { + repo := initRepo(t, map[string]string{ + "CODEOWNERS": "* @default\n/x/ @solo\n", + "x/a.go": "package x\n", + "y/b.go": "package y\n", + }) + code, out, _ := runCLI(t, "sync", "--repo", repo, + "--op", "remove_owner(/x/, @solo)", "--on-empty", "error", "--format", "json") + if code != cli.ExitRefused { + t.Fatalf("want exit 2, got %d", code) + } + rec := syncDecodeRecord(t, out) + if rec.Status != cli.StatusRefused { + t.Errorf("status = %q, want %q", rec.Status, cli.StatusRefused) + } + if strings.TrimSpace(rec.Error) == "" { + t.Error("a refusal record must carry the reason text") + } + if got := syncReadFile(t, filepath.Join(repo, "CODEOWNERS")); got != "* @default\n/x/ @solo\n" { + t.Errorf("a refusal writes nothing, file = %q", got) + } +} + +// SPEC R-22: `check` exits 0 on a valid policy — never 1. It is the first +// line of every fleet script under `set -e`; a clean policy returning the +// no-op code would abort the run before the loop starts. +func TestCheck_ValidPolicyExitsZeroNeverOne(t *testing.T) { + p := syncWritePolicy(t, syncFleetPolicy) + code, out, errOut := runCLI(t, "check", "--policy", p) + if code != cli.ExitOK { + t.Fatalf("valid policy: want exit 0, got %d\n%s%s", code, out, errOut) + } + if code == cli.ExitNoOp { + t.Fatal("check must never return exit 1") + } + code, out, errOut = runCLI(t, "check", "--policy", p, "--format", "json") + if code != cli.ExitOK { + t.Fatalf("valid policy --format json: want exit 0, got %d\n%s", code, errOut) + } + var obj map[string]any + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Errorf("check --format json stdout is not one JSON object: %v\n%s", err, out) + } +} + +// SPEC R-22: `check` exits 3 on every member of the exit-3 class, and 3 only. +// Its definition is exactly that class: the failures that will repeat +// identically on all 100 repos. +func TestCheck_BrokenPolicyExitsThree(t *testing.T) { + for _, tc := range syncBrokenPolicies { + t.Run(tc.name, func(t *testing.T) { + p := syncWritePolicy(t, tc.src) + code, _, _ := runCLI(t, "check", "--policy", p) + if code != cli.ExitInvalid { + t.Errorf("%s: want exit 3, got %d", tc.name, code) + } + }) + } +} + +// SPEC R-22: `check --op` syntax-checks an op string too, so the escalation +// path has no gap — the user who has not yet written a policy file can still +// validate before touching 100 repos. +func TestCheck_OpStringIsSyntaxChecked(t *testing.T) { + if code, _, _ := runCLI(t, "check", "--op", "add_owner(/x/, @a)"); code != cli.ExitOK { + t.Error("valid --op: want exit 0") + } + if code, _, _ := runCLI(t, "check", "--op", "frob(/x/, @b)"); code != cli.ExitInvalid { + t.Error("malformed --op: want exit 3") + } + if code, _, _ := runCLI(t, "check", "--op", "rename_owner(@a, @a)"); code != cli.ExitInvalid { + t.Error("rename_owner to itself: want exit 3") + } + // Same mutual exclusion as sync, and for the same reason. + p := syncWritePolicy(t, `{"version":1,"ops":["add_owner(/x/, @b)"]}`) + if code, _, _ := runCLI(t, "check", "--op", "add_owner(/x/, @b)", "--policy", p); code != cli.ExitInvalid { + t.Error("check with both --op and --policy: want exit 3") + } + if code, _, _ := runCLI(t, "check"); code != cli.ExitInvalid { + t.Error("check with neither --op nor --policy: want exit 3") + } +} + +// SPEC R-22: `check` reads no repository. As a verb it carries no repo flags +// at all, so the shape enforces the contract — it must succeed from a +// directory that is not a git repo, which is where the fleet script runs it +// before the first clone exists. +func TestCheck_ReadsNoRepository(t *testing.T) { + p := syncWritePolicy(t, syncFleetPolicy) + notARepo := t.TempDir() + // t.Chdir mutates PROCESS-WIDE state. This test must never gain + // t.Parallel(), and neither may any test that reaches the filesystem + // through a relative path — fleet_test.go already runs t.Parallel() + // throughout, and it is only safe because every path it touches is + // absolute. Adding t.Parallel() here would move this chdir into that + // window and make those tests fail nondeterministically, in whichever + // order the scheduler happened to interleave them. + t.Chdir(notARepo) + + code, _, errOut := runCLI(t, "check", "--policy", p) + if code != cli.ExitOK { + t.Fatalf("check outside any git repo: want exit 0, got %d\n%s", code, errOut) + } + // The repo-scoped flags do not exist on this verb. + for _, args := range [][]string{ + {"check", "--policy", p, "--repo", notARepo}, + {"check", "--policy", p, "--create"}, + {"check", "--policy", p, "--dry-run"}, + } { + if code, _, _ := runCLI(t, args...); code != cli.ExitInvalid { + t.Errorf("check %v: repo-scoped flags must not be accepted, got %d", args[1:], code) + } + } +} + +// SPEC R-22: `check` writes nothing. It sits one token away from --dry-run, +// where the failure mode is exit 0 across all 100 repos having read and +// written nothing — silent success, the worst outcome this design produces. +func TestCheck_WritesNothing(t *testing.T) { + policyDir := t.TempDir() + policyPath := filepath.Join(policyDir, "policy.json") + if err := os.WriteFile(policyPath, []byte(syncFleetPolicy), 0o644); err != nil { + t.Fatal(err) + } + work := t.TempDir() + // Same rule as TestCheck_ReadsNoRepository: t.Chdir is process-wide, so + // this test must NOT become parallel. Here the chdir is load-bearing — + // the assertion below is "check wrote nothing into the current working + // directory" — so under t.Parallel() a concurrent test's write would be + // attributed to `check`, and worse, the parallel tests in fleet_test.go + // would be running against a cwd this test moved out from under them. + t.Chdir(work) + + before := checkDirSnapshot(t, policyDir) + if code, _, _ := runCLI(t, "check", "--policy", policyPath); code != cli.ExitOK { + t.Fatalf("valid policy: want exit 0") + } + if got := checkDirSnapshot(t, policyDir); len(got) != len(before) || got["policy.json"] != before["policy.json"] { + t.Errorf("check modified the policy directory: %v → %v", before, got) + } + if entries, err := os.ReadDir(work); err != nil || len(entries) != 0 { + t.Errorf("check wrote into the working directory: %v (err %v)", entries, err) + } +} + +// SPEC R-22: syntax errors fail fast at the FIRST problem; semantic errors +// accumulate and all print. Fixing a generated 40-op policy one error per run +// is miserable — but a file that is not JSON has no second error to report, +// only guesses. +func TestCheck_SyntaxFailsFastSemanticsAccumulate(t *testing.T) { + // Trailing comma: not JSON. The semantic problems downstream of it + // (`inhrit`, `frob`) are unreachable and must not be claimed. + syntax := syncWritePolicy(t, `{"version":1,"on_empty":"inhrit","ops":["frob(/x/, @b)"],}`) + code, out, errOut := runCLI(t, "check", "--policy", syntax) + if code != cli.ExitInvalid { + t.Fatalf("syntax error: want exit 3, got %d", code) + } + all := out + errOut + if strings.Contains(all, "inhrit") || strings.Contains(all, "frob") { + t.Errorf("syntax errors stop at the first; downstream semantics must not be reported:\n%s", all) + } + + // Three independent semantic problems: all three must print in one run. + semantic := syncWritePolicy(t, `{ + "version": 1, + "on_empty": "inhrit", + "ops": [ + {"id":"one","op":"add_owner(/x/, @b)","on_zero_match":"maybe"}, + {"id":"two","op":"frob(/y/, @c)"} + ] + }`) + code, out, errOut = runCLI(t, "check", "--policy", semantic) + if code != cli.ExitInvalid { + t.Fatalf("semantic errors: want exit 3, got %d", code) + } + all = out + errOut + for _, want := range []string{"inhrit", "maybe", "frob"} { + if !strings.Contains(all, want) { + t.Errorf("every semantic error must print in one run; %q missing:\n%s", want, all) + } + } +} + +// SPEC R-22: check never returns anything but 0 and 3 — in particular not 1, +// 2, 4, 5 or 6. The fleet script's `*)` catch-all exits on anything else. +func TestCheck_NeverReturnsOtherExitCodes(t *testing.T) { + valid := syncWritePolicy(t, syncFleetPolicy) + invocations := [][]string{ + {"check", "--policy", valid}, + {"check", "--op", "add_owner(/x/, @a)"}, + {"check", "--op", "frob(/x/, @b)"}, + {"check", "--policy", filepath.Join(t.TempDir(), "absent.json")}, + {"check"}, + } + for _, tc := range syncBrokenPolicies { + invocations = append(invocations, []string{"check", "--policy", syncWritePolicy(t, tc.src)}) + } + for _, args := range invocations { + code, _, _ := runCLI(t, args...) + if code != cli.ExitOK && code != cli.ExitInvalid { + t.Errorf("check %v: exit %d — check may return only 0 or 3", args[1:], code) + } + } +} diff --git a/internal/gittree/gittree.go b/internal/gittree/gittree.go index a878eb5..67660bf 100644 --- a/internal/gittree/gittree.go +++ b/internal/gittree/gittree.go @@ -32,8 +32,14 @@ func ReadFileAtRef(repoDir, ref, path string) ([]byte, error) { return gitOutput(repoDir, "cat-file", "blob", ref+":"+path) } -// codeownersLocations is GitHub's search order: first found wins (S-8). -var codeownersLocations = []string{".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"} +// CodeownersLocations is GitHub's search order: first found wins (S-8). +// +// Exported because the same order governs two different lookups — "what governs +// at this ref", over the tracked tree below, and "what is on disk right now", +// which `sync` needs for its working-tree fallback. The questions differ; the +// list is one fact about GitHub, and a second copy of it is a place for the two +// to disagree the day GitHub adds a location. +var CodeownersLocations = []string{".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"} // FindCodeownersPaths returns every CODEOWNERS file present in the tree, in // precedence order. More than one entry is an error condition for callers @@ -44,7 +50,7 @@ func FindCodeownersPaths(tree []string) []string { present[p] = true } var found []string - for _, loc := range codeownersLocations { + for _, loc := range CodeownersLocations { if present[loc] { found = append(found, loc) } diff --git a/internal/ops/ops.go b/internal/ops/ops.go index b04a0e7..aaf1832 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -29,8 +29,23 @@ type Op struct { NewOwner string `json:"new_owner,omitempty"` // rename new name Owners []string `json:"owners,omitempty"` // set_owners exact set (non-nil, may be empty) Raw string `json:"raw"` + + // OnZeroMatch selects behavior when Scope matches zero tracked files: + // "" (== "require") | "require" | "skip" | "declare". The zero value + // preserves R-5 exactly, which is why adding this changes nothing for + // ops built by Parse. + OnZeroMatch string `json:"on_zero_match,omitempty"` + // ID is a policy-file label used in results and errors; "" from --op. + ID string `json:"id,omitempty"` } +// Zero-match policies (R-21). +const ( + ZeroMatchRequire = "require" + ZeroMatchSkip = "skip" + ZeroMatchDeclare = "declare" +) + func (o Op) String() string { return o.Raw } // Parse parses one op of the form kind(arg1, arg2). diff --git a/internal/pattern/contains.go b/internal/pattern/contains.go index b4fba08..f61e3f4 100644 --- a/internal/pattern/contains.go +++ b/internal/pattern/contains.go @@ -99,13 +99,30 @@ type token struct { } // tokenize converts a pattern into its token sequence, mirroring the regex -// buildPatternRegex emits for each normalized segment. +// buildPatternRegex emits for each normalized segment. It reports false for any +// pattern it cannot model faithfully; callers then answer "unproven", which is +// always safe. func tokenize(pat string) ([]token, bool) { if pat == "" || pat[0] == '!' || strings.Contains(pat, "***") { return nil, false } segs := normalizeSegs(pat) last := len(segs) - 1 + // Refuse patterns with two adjacent "**" segments. buildPatternRegex tracks + // whether the separator has already been consumed (its needSlash flag), and + // the "**" arms do not honour it: a leading "**" compiles to "(?:.+/)?" and + // clears needSlash, then the next "**" emits the separator again anyway. So + // "**/" — segments ["**", "**"] — compiles to `\A(?:.+/)?/.*\z`, which no + // repo-relative path can match. Modeling those segments as tokens loses that + // bookkeeping entirely and reads the pattern as universal, which is UNSOUND: + // Contains("**/", "*") would be true while "a.go" matches "*" and not "**/". + // These spellings match nothing (or, like "*/**/**", are simply degenerate), + // so declining to reason about them costs a refusal and nothing real. + for i := 1; i <= last; i++ { + if segs[i] == "**" && segs[i-1] == "**" { + return nil, false + } + } var out []token for i, seg := range segs { switch seg { diff --git a/internal/pattern/contains_test.go b/internal/pattern/contains_test.go index 3815f4b..13fa89f 100644 --- a/internal/pattern/contains_test.go +++ b/internal/pattern/contains_test.go @@ -150,3 +150,123 @@ func TestContainsRejectsInvalid(t *testing.T) { } } } + +// containsGlobstarCorpus is the "**" family: every spelling that puts two +// globstar segments next to each other, plus the well-formed globstar patterns +// they are easily confused with. Adjacent globstars are the shapes the token +// model got wrong — buildPatternRegex's needSlash bookkeeping makes them match +// NOTHING, while a naive token model reads them as universal. +var containsGlobstarCorpus = []string{ + "**", "/**", "**/*", "*/**", "**/x", "x/**", "/**/x", "**/x/**", "x/**/y", + "**/", "/**/", "**/**", "/**/**", "**/**/", "**/**/x", "/**/**/y", + "**/**/**", "a/**/**", "/a/**/**/b", "**/**/*", "*/**/**", "**/*/**", +} + +// Contains(`**/`, `*`) is the witness that broke soundness: `**/` normalizes to +// the segments ["**", "**"], and buildPatternRegex compiles that to +// `\A(?:.+/)?/.*\z` — the leading globstar consumes the separator, then the +// trailing globstar re-emits one, so no repo-relative path can ever match it. +// The token model read the same pattern as [many, any1, many] and called it +// universal, so Contains claimed `**/` contained every pattern in the language. +// That is the exact shape that would let the planner amend a dead rule in place +// and hand its owner every file the inner rule will ever match. +func TestContainsRejectsAdjacentGlobstars(t *testing.T) { + dead, err := pattern.Compile("**/") + if err != nil { + t.Fatalf(`Compile("**/"): %v`, err) + } + star, err := pattern.Compile("*") + if err != nil { + t.Fatalf(`Compile("*"): %v`, err) + } + // Premise: the witness path separates the two languages. + if dead.Match("a.go") { + t.Fatalf(`Compile("**/").Match("a.go") = true, want false — "**/" matches nothing`) + } + if !star.Match("a.go") { + t.Fatalf(`Compile("*").Match("a.go") = false, want true`) + } + // Conclusion: so "**/" cannot contain "*". + if pattern.Contains("**/", "*") { + t.Errorf(`Contains("**/", "*") = true, want false — "a.go" matches "*" and not "**/"`) + } + // The same reasoning for the rest of the family. Not every adjacent-globstar + // spelling is dead ("*/**/**" matches "x/y.go"), so emptiness is established + // against the matcher rather than assumed: a pattern that matches none of + // the probe paths contains only the empty language, and therefore cannot + // contain any pattern that matches one of them. + probes := []string{"a.go", "x/a.go", "docs/readme.md", "a/b/c/d.go", "x", "docs"} + live := []string{"*", "**", "x", "/x/y.go", "docs/"} + for _, outer := range containsGlobstarCorpus { + oc, err := pattern.Compile(outer) + if err != nil { + t.Fatalf("Compile(%q): %v", outer, err) + } + if matchesAny(oc, probes) { + continue + } + for _, inner := range live { + ic, err := pattern.Compile(inner) + if err != nil { + t.Fatalf("Compile(%q): %v", inner, err) + } + if !matchesAny(ic, probes) { + continue + } + if pattern.Contains(outer, inner) { + t.Errorf("Contains(%q, %q) = true, want false — %q matches nothing, %q does", + outer, inner, outer, inner) + } + } + } +} + +// matchesAny reports whether the compiled pattern matches at least one path. +func matchesAny(p *pattern.Pattern, paths []string) bool { + for _, s := range paths { + if p.Match(s) { + return true + } + } + return false +} + +// The soundness rule TestContainsIsSound states, generalized over the "**" +// family so the next member of it cannot slip through by simply not being +// listed in containsCorpus. Whenever Contains(outer, inner) is true, NO +// concrete path may match inner without also matching outer; a violation means +// the planner would amend a rule in place and silently widen an owner's reach. +func TestContainsIsSoundOverGlobstarFamily(t *testing.T) { + corpus := append(append([]string{}, containsCorpus...), containsGlobstarCorpus...) + paths := append(append([]string{}, containsPaths...), "a.go", "x/a.go", "a/b/c/d.go") + + compiled := map[string]*pattern.Pattern{} + for _, p := range corpus { + c, err := pattern.Compile(p) + if err != nil { + t.Fatalf("Compile(%q): %v", p, err) + } + compiled[p] = c + } + unsound := 0 + for _, outer := range corpus { + for _, inner := range corpus { + if !pattern.Contains(outer, inner) { + continue + } + for _, p := range paths { + if compiled[inner].Match(p) && !compiled[outer].Match(p) { + unsound++ + if unsound <= 20 { + t.Errorf("Contains(%q, %q) = true, but %q matches %q and not %q", + outer, inner, p, inner, outer) + } + break + } + } + } + } + if unsound > 0 { + t.Errorf("%d unsound pair(s)", unsound) + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 70a2284..9ba8e0b 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -85,6 +85,17 @@ type Row struct { After []string `json:"owners_after"` } +// OpResult is one op's outcome (R-24). Proven distinguishes an op checked +// against real tracked files ("tree") from a declare op that matched none +// and could only be proven structurally ("structural", INV-6). +type OpResult struct { + ID string `json:"id,omitempty"` + Op string `json:"op"` + Status string `json:"status"` // applied | skipped | unchanged + Proven string `json:"proven,omitempty"` // tree | structural + Reason string `json:"reason,omitempty"` +} + // Plan is the machine-readable output of `plan` and the sole input of // `apply` (R-16). type Plan struct { @@ -97,6 +108,11 @@ type Plan struct { Rows []Row `json:"ownership_rows"` Diff string `json:"diff"` AfterContent string `json:"after_content"` + + // Named op_results, not ops: Ops above already owns the "ops" tag as the + // raw op strings (R-16) and must keep it. The sync record renders these + // as "ops" in ITS document, where there is no collision. + OpResults []OpResult `json:"op_results,omitempty"` } // ResolveContent parses content and resolves the whole tree — the primitive @@ -119,11 +135,18 @@ func Build(content []byte, tree []string, opList []ops.Op, opts Options) (*Plan, beforeOwners[p] = r.Owners // nil if unmatched } - // Per-op scope path sets (R-5: empty scope is invalid input). + // Per-op scope path sets (R-5: empty scope is invalid input, unless a + // policy op opts out per R-21). scopeSets := make([]map[string]bool, len(opList)) + skipped := make([]bool, len(opList)) + declared := make([]bool, len(opList)) for i, op := range opList { set := map[string]bool{} if op.Kind == ops.RenameOwner { + // R-21 never reaches a rename: its scope comes from current + // ownership, not from a pattern, so there is no zero-match branch + // to take here. Rejecting on_zero_match on a rename is a static + // property of the policy and belongs to internal/policy. for p, own := range beforeOwners { if contains(own, op.Owner) { set[p] = true @@ -140,7 +163,29 @@ func Build(content []byte, tree []string, opList []ops.Op, opts Options) (*Plan, } } if len(set) == 0 { - return nil, &InvalidError{Msg: fmt.Sprintf("scope %q matches zero tracked files (R-5: refusing to create a dead rule)", op.Scope)} + switch op.OnZeroMatch { + case ops.ZeroMatchSkip: + // The op stays in opList with an empty scope set rather + // than being filtered out. Filtering would drop its raw + // string from Plan.Ops, which is R-16's record of what was + // REQUESTED, and an all-skip batch would fall into "no + // operations supplied" (exit 3) where R-21 requires a + // per-repo no-op (exit 1). + skipped[i] = true + case ops.ZeroMatchDeclare: + if op.Kind == ops.RemoveOwner { + return nil, &InvalidError{Msg: fmt.Sprintf( + "on_zero_match=declare is meaningless on %s: there is no rule to remove an owner from (R-21)", op.Raw)} + } + declared[i] = true + default: + // "" and "require" are the same state, and this arm — not a + // comparison against "require" — is what makes that true: + // every op parsed from --op carries "", and R-21's + // compatibility guarantee is that those keep hitting R-5 + // exactly as before the field existed. + return nil, &InvalidError{Msg: fmt.Sprintf("scope %q matches zero tracked files (R-5: refusing to create a dead rule)", op.Scope)} + } } } scopeSets[i] = set @@ -213,6 +258,30 @@ func Build(content []byte, tree []string, opList []ops.Op, opts Options) (*Plan, } } + // R-8 for zero-match ops. The loop above intersects TREE path sets, and a + // declared scope owns none — so two contradictory declares meet on the + // empty set, are waved through as "commuting", and get written in input + // order with last-match-wins silently picking the winner. There is no path + // to test, so decide these pairs over the patterns and the transforms + // instead. Skipped ops write nothing and commute with everything; pairs of + // ordinary ops are left entirely to the check above, so nothing that + // planned before this existed can start refusing now. + for i := 0; i < len(opList); i++ { + for j := i + 1; j < len(opList); j++ { + if skipped[i] || skipped[j] || (!declared[i] && !declared[j]) { + continue + } + if patternsProvablyDisjoint(opList[i].Scope, opList[j].Scope) { + continue + } + if !commuteOnEveryOwnerSet(opList[i], opList[j]) { + return nil, &InvalidError{Msg: fmt.Sprintf( + "ops %q and %q can both govern a path that does not exist yet and do not commute (R-8: refusing order-dependent batch)", + opList[i].Raw, opList[j].Raw)} + } + } + } + // Desired final ownership, computed independently of edit synthesis. desired := make(map[string][]string, len(tree)) for p, own := range beforeOwners { @@ -224,22 +293,49 @@ func Build(content []byte, tree []string, opList []ops.Op, opts Options) (*Plan, pl.Ops = append(pl.Ops, op.Raw) } - // Synthesize edits op by op on the evolving file. + // Synthesize edits op by op on the evolving file. A declare op runs against + // the SAME evolving file, which is what lets two declares on one scope + // merge into a single rule instead of stacking two lines where the second + // shadows the first. + // + // The scopes this batch declares are collected UP FRONT: INV-6's third + // obligation is relaxed for overlaps between them (see + // classifyDeclareShadow), and an op must be able to see a scope declared + // later in the policy than itself, on the pass that writes the lines and on + // every pass after it. + var batchDeclares []string for i, op := range opList { + if declared[i] { + batchDeclares = append(batchDeclares, op.Scope) + } + } + batch := newDeclareBatch(batchDeclares) + var declares []*declareCheck + for i, op := range opList { + mark := len(pl.Changes) var err error - switch op.Kind { - case ops.AddOwner: - err = synthAdd(f, tree, op, scopeSets[i], desired, pl) - case ops.SetOwners: - err = synthSet(f, tree, op, scopeSets[i], desired, pl) - case ops.RemoveOwner: - err = synthRemove(f, tree, op, scopeSets[i], desired, opts.OnEmpty, pl) - case ops.RenameOwner: - err = synthRename(f, op, desired, pl) + switch { + case skipped[i]: + // R-21: a skipped op changes nothing and does not stop the rest of + // the batch from applying. + case declared[i]: + err = synthDeclare(f, op, batch, &declares, pl) + default: + switch op.Kind { + case ops.AddOwner: + err = synthAdd(f, tree, op, scopeSets[i], desired, pl) + case ops.SetOwners: + err = synthSet(f, tree, op, scopeSets[i], desired, pl) + case ops.RemoveOwner: + err = synthRemove(f, tree, op, scopeSets[i], desired, opts.OnEmpty, pl) + case ops.RenameOwner: + err = synthRename(f, op, desired, pl) + } } if err != nil { return nil, err } + pl.OpResults = append(pl.OpResults, opResultFor(op, skipped[i], declared[i], len(pl.Changes) > mark)) } // ASSERT: the gate. Serialize, RE-PARSE, and re-resolve over the real @@ -274,8 +370,29 @@ func Build(content []byte, tree []string, opList []ops.Op, opts Options) (*Plan, return nil, &RefusalError{Msg: "refusing: synthesized edits do not satisfy the invariants", Details: violations} } + // INV-6. The loop above ranged the tree; for a declared scope it ranged + // nothing, so INV-1 came out true without a single statement having been + // made about the line just written — precisely the case a reviewer most + // needs told. Prove it structurally instead, or refuse. + if err := proveDeclares(file.Parse(afterBytes), declares, batch, pl); err != nil { + return nil, err + } + if bytes.Equal(afterBytes, content) { - return nil, &NoOpError{Msg: "nothing to change: file already satisfies the requested ops"} + // A populated plan, not nil: "already correct" is the modal outcome of + // a scheduled fleet run and must still report one result per op, or the + // sync record cannot say which repos are converged. Callers that only + // check err are unaffected. Nothing moved, so nothing was applied — + // an op whose synthesized edit rendered byte-identical text is reported + // as unchanged rather than left claiming a change nobody can see. + for k := range pl.OpResults { + if pl.OpResults[k].Status == "applied" { + pl.OpResults[k].Status = "unchanged" + } + } + pl.AfterContent = string(afterBytes) + pl.SizeAfter = len(afterBytes) + return pl, &NoOpError{Msg: "nothing to change: file already satisfies the requested ops"} } pl.AfterContent = string(afterBytes) @@ -1014,19 +1131,39 @@ func ruleDirPrefix(pat string) (string, bool) { return "/" + body + "/", true } -// anchoredDirPrefix normalizes "/x/", "/x/**", "/x" to the prefix "/x/". +// anchoredDirPrefix normalizes an anchored, WILDCARD-FREE directory scope +// ("/x/", "/x/**", "/x") to the prefix "/x/", and rejects everything else. The +// rejection is what keeps patternsProvablyDisjoint honest: the prefix is used +// as a stand-in for the pattern's whole language, which is only true when the +// pattern has no wildcard to spill outside that prefix. +// +// The wildcard check therefore runs on the ORIGINAL spelling, BEFORE any suffix +// is trimmed, and "**" is stripped only where a "/" precedes it. Trimming first +// normalized "/src**" to "/src/", so patternsProvablyDisjoint("/src**", +// "/srcx/") answered true — but "/src**" compiles to +// `\Asrc[^/]*[^/]*(?:/.*)?\z`, which matches srcx/a.go, and so does "/srcx/". +// That is a WRONG WRITE, not a missed proof: it let a declare be amended under +// a later rule capturing its entire scope (reported applied, dead on arrival) +// and let R-8 wave through an order-dependent declare batch whose result +// depended on op order (adversarial audit of Wave 1). func anchoredDirPrefix(scope string) (string, bool) { if !strings.HasPrefix(scope, "/") { return "", false } - s := strings.TrimSuffix(scope, "**") - if !strings.HasSuffix(s, "/") { - s += "/" + if strings.ContainsAny(scope, "*?[]\\") { + // "/x/**" is the one wildcard spelling that is still exactly a directory + // subtree, and only with the slash intact: in "/src**" the "**" binds to + // the "src" segment and reaches siblings like srcx/. + body, ok := strings.CutSuffix(scope, "/**") + if !ok || strings.ContainsAny(body, "*?[]\\") { + return "", false + } + scope = body } - if strings.ContainsAny(s, "*?[]\\") { - return "", false + if !strings.HasSuffix(scope, "/") { + scope += "/" } - return s, true + return scope, true } // warnShadowedDuplicates reports duplicate patterns intersecting the scope diff --git a/internal/plan/schema_test.go b/internal/plan/schema_test.go new file mode 100644 index 0000000..059010d --- /dev/null +++ b/internal/plan/schema_test.go @@ -0,0 +1,593 @@ +package plan_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" + "sort" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/plan" +) + +// --------------------------------------------------------------------------- +// Schema pins for the plan document (R-16). +// +// The plan JSON is not an output format, it is a CONTRACT. `plan` writes it, +// `apply` reads it, and nothing else validates it in between: apply unmarshals +// the document straight into plan.Plan and writes bytes to disk from +// after_content, gated only on sha256_before. Every invariant the planner +// proved is carried across that boundary by the document alone. +// +// The document is also a CI artifact: `plan --out plan.json` in one job, +// `apply --plan plan.json` in a later job, possibly with a newer binary. That +// makes both directions of compatibility load-bearing — a newer binary must +// read an older plan, and an older reader must not choke on a field it does +// not know. +// +// Until these tests existed the shape was pinned by NOTHING. No test in the +// suite marshalled a Plan and compared the result to anything, so a field +// added to Plan silently changed `plan --out`'s output and the suite stayed +// green. Plan.OpResults was added exactly that way. These tests are +// characterization tests: they record the CURRENT shape as intentional, so the +// next change to it is a deliberate, reviewed one. +// +// They assert the key SET, never the key ORDER. Go's marshaller emits fields +// in declaration order, but that ordering is not part of the contract and +// pinning it would fail on a harmless field reshuffle. +// --------------------------------------------------------------------------- + +// planFileMirror mirrors internal/cli's unexported planFile — Plan plus the +// apply-time context the CLI adds. cli.planFile cannot be referenced from +// here, so this copy stands in for it; TestR16_PlanFileEnvelopeInlinesPlan +// pins the relationship between the two shapes so the copy cannot quietly +// drift into a different contract than the one apply actually reads. +type planFileMirror struct { + plan.Plan + Repo string `json:"repo"` + Ref string `json:"ref"` + CodeownersPath string `json:"codeowners_path"` +} + +// topLevelKeys marshals v and returns its top-level JSON object keys, sorted +// so comparisons are order-independent. +func topLevelKeys(t *testing.T, v any) []string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal into key map: %v", err) + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// jsonKind names the JSON type of a raw value. Arrays report their element +// type too, so a []string turning into a []int is caught, not just a rename. +func jsonKind(t *testing.T, raw json.RawMessage) string { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + return kindOf(v) +} + +func kindOf(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + return "bool" + case float64: + return "number" + case string: + return "string" + case map[string]any: + return "object" + case []any: + if len(x) == 0 { + return "array" + } + return "array<" + kindOf(x[0]) + ">" + } + return "unknown" +} + +// fieldKinds marshals v and reports every top-level key's JSON type. +func fieldKinds(t *testing.T, v any) map[string]string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal into key map: %v", err) + } + out := make(map[string]string, len(m)) + for k, raw := range m { + out[k] = jsonKind(t, raw) + } + return out +} + +func wantKeys(t *testing.T, what string, got, want []string) { + t.Helper() + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s top-level JSON keys changed.\n got: %v\nwant: %v\n"+ + "If this change is intentional, update the literal in this test — the plan\n"+ + "document is `apply`'s sole input and a CI artifact, so its shape is a contract.", + what, got, want) + } +} + +// fullChange is a Change with every field populated, so no omitempty tag can +// hide a field from the key enumeration. +func fullChange() plan.Change { + return plan.Change{ + Action: "amend", + Line: 7, + Pattern: "/x/", + OldOwners: []string{"@a"}, + NewOwners: []string{"@a", "@b"}, + OldLine: "/x/ @a", + NewLine: "/x/ @a @b", + Reason: "because", + } +} + +func fullRow() plan.Row { + return plan.Row{Path: "x/a.go", Before: []string{"@a"}, After: []string{"@a", "@b"}} +} + +func fullOpResult() plan.OpResult { + return plan.OpResult{ID: "op-1", Op: "add_owner(/x/, @b)", Status: "applied", Proven: "tree", Reason: "because"} +} + +func fullPlan() plan.Plan { + return plan.Plan{ + Ops: []string{"add_owner(/x/, @b)"}, + HashBefore: "0f7c1e1d", + SizeBefore: 14, + SizeAfter: 20, + Warnings: []string{"a warning"}, + Changes: []plan.Change{fullChange()}, + Rows: []plan.Row{fullRow()}, + Diff: "@ line 1\n-/x/ @a\n+/x/ @a @b\n", + AfterContent: "/x/ @a @b\n", + OpResults: []plan.OpResult{fullOpResult()}, + } +} + +// SPEC R-16 (plan document, top level): the set of keys a marshalled Plan +// emits is pinned to a literal list. This is deliberately an ENUMERATION and +// not a spot-check: the failure this guards against is a field being ADDED +// without anyone noticing that `plan --out`'s output changed, and a +// spot-checking test cannot see an addition. op_results is included because +// it was added without a test noticing; listing it here records the current +// shape as intentional. +func TestR16_PlanTopLevelKeys(t *testing.T) { + wantKeys(t, "plan.Plan", topLevelKeys(t, fullPlan()), []string{ + "ops", + "sha256_before", + "size_before", + "size_after", + "warnings", + "changes", + "ownership_rows", + "diff", + "after_content", + "op_results", + }) +} + +// SPEC R-16 (change records): a Change's JSON keys, enumerated. Each change is +// one line edit plus the reason it was chosen; a reviewer reading a plan in CI +// reads these fields, so renaming one silently breaks every consumer that is +// not the Go binary. +func TestR16_ChangeKeys(t *testing.T) { + wantKeys(t, "plan.Change", topLevelKeys(t, fullChange()), []string{ + "action", "line", "pattern", + "old_owners", "new_owners", + "old_line", "new_line", + "reason", + }) +} + +// SPEC R-16 (ownership rows): a Row's JSON keys, enumerated. Note the Go field +// names (Before/After) differ from the JSON names (owners_before/owners_after) +// — exactly the kind of mapping a refactor of the struct can break without any +// compile error. +func TestR16_RowKeys(t *testing.T) { + wantKeys(t, "plan.Row", topLevelKeys(t, fullRow()), []string{ + "path", "owners_before", "owners_after", + }) +} + +// SPEC R-24 (op results): an OpResult's JSON keys, enumerated. This is the +// newest member of the plan document and the one whose addition this suite +// failed to notice. +func TestR16_OpResultKeys(t *testing.T) { + wantKeys(t, "plan.OpResult", topLevelKeys(t, fullOpResult()), []string{ + "id", "op", "status", "proven", "reason", + }) +} + +// SPEC R-16 (field types): every field's JSON name mapped to its JSON type. +// The key-set tests catch renames and additions; this catches a type change +// under an unchanged name — a string becoming an int, a scalar becoming a +// list — which is the change most likely to pass review and then break a +// consumer at runtime. +func TestR16_FieldTypes(t *testing.T) { + cases := []struct { + name string + value any + want map[string]string + }{ + {"plan.Plan", fullPlan(), map[string]string{ + "ops": "array", + "sha256_before": "string", + "size_before": "number", + "size_after": "number", + "warnings": "array", + "changes": "array", + "ownership_rows": "array", + "diff": "string", + "after_content": "string", + "op_results": "array", + }}, + {"plan.Change", fullChange(), map[string]string{ + "action": "string", + "line": "number", + "pattern": "string", + "old_owners": "array", + "new_owners": "array", + "old_line": "string", + "new_line": "string", + "reason": "string", + }}, + {"plan.Row", fullRow(), map[string]string{ + "path": "string", + "owners_before": "array", + "owners_after": "array", + }}, + {"plan.OpResult", fullOpResult(), map[string]string{ + "id": "string", + "op": "string", + "status": "string", + "proven": "string", + "reason": "string", + }}, + } + for _, c := range cases { + got := fieldKinds(t, c.value) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s field types changed.\n got: %v\nwant: %v", c.name, got, c.want) + } + } +} + +// SPEC R-16 (omitempty): which keys DISAPPEAR from a minimal document, pinned +// explicitly. omitempty is not cosmetic here — it decides whether a consumer +// can distinguish "this plan produced no warnings" from "this plan is from a +// binary that predates warnings". The unconditional keys (ops, changes, +// ownership_rows, diff, after_content) are always present, so a reader can +// treat their absence as a malformed document rather than as an empty result. +func TestR16_OmitEmptyKeysDisappear(t *testing.T) { + cases := []struct { + name string + value any + want []string + omitted []string + }{ + { + name: "plan.Plan", + value: plan.Plan{}, + want: []string{"ops", "sha256_before", "size_before", "size_after", "changes", "ownership_rows", "diff", "after_content"}, + omitted: []string{"warnings", "op_results"}, + }, + { + name: "plan.Change", + value: plan.Change{}, + want: []string{"action", "line", "pattern", "reason"}, + omitted: []string{"old_owners", "new_owners", "old_line", "new_line"}, + }, + { + // Row has NO omitempty anywhere, and that is the point: see + // TestR16_OwnershipRowsDistinguishNullFromEmpty. + name: "plan.Row", + value: plan.Row{}, + want: []string{"path", "owners_before", "owners_after"}, + omitted: nil, + }, + { + name: "plan.OpResult", + value: plan.OpResult{}, + want: []string{"op", "status"}, + omitted: []string{"id", "proven", "reason"}, + }, + } + for _, c := range cases { + got := topLevelKeys(t, c.value) + wantKeys(t, "minimal "+c.name, got, c.want) + present := map[string]bool{} + for _, k := range got { + present[k] = true + } + for _, k := range c.omitted { + if present[k] { + t.Errorf("minimal %s: key %q should be omitted when empty but was emitted", c.name, k) + } + } + } +} + +// SPEC R-16/S-9 (null vs []): ownership_rows must distinguish JSON null — +// "no rule matches this path", i.e. genuinely unowned — from [] — "a rule +// matches and deliberately lists zero owners" (--on-empty=unowned). They are +// different states of the repository and internal/verify treats a transition +// between them as a real change (see internal/verify/verify_test.go, which +// pins the same distinction at the snapshot layer). +// +// This is why Row carries no omitempty: `owners_before,omitempty` would erase +// nil and [] into the same absent key, collapsing the two states into one on +// the way through the plan document. +func TestR16_OwnershipRowsDistinguishNullFromEmpty(t *testing.T) { + p := plan.Plan{Rows: []plan.Row{ + {Path: "unowned.go", Before: nil, After: []string{"@a"}}, + {Path: "zeroed.go", Before: []string{"@a"}, After: []string{}}, + }} + b, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var doc struct { + Rows []map[string]json.RawMessage `json:"ownership_rows"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Rows) != 2 { + t.Fatalf("got %d rows, want 2", len(doc.Rows)) + } + if got := string(doc.Rows[0]["owners_before"]); got != "null" { + t.Errorf("unowned path: owners_before = %s, want null (no rule matches)", got) + } + if got := string(doc.Rows[1]["owners_after"]); got != "[]" { + t.Errorf("explicitly zero-owned path: owners_after = %s, want [] (rule matches, zero owners)", got) + } + + // And the distinction survives the trip back: nil stays nil, [] stays a + // non-nil empty slice. + var back plan.Plan + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if back.Rows[0].Before != nil { + t.Errorf("null owners_before unmarshalled to %#v, want nil", back.Rows[0].Before) + } + if back.Rows[1].After == nil || len(back.Rows[1].After) != 0 { + t.Errorf("[] owners_after unmarshalled to %#v, want a non-nil empty slice", back.Rows[1].After) + } +} + +// SPEC R-16/S-9 (null vs [], end to end): the same distinction as produced by +// the real planner, not by a hand-built struct. remove_owner under +// --on-empty=unowned keeps the pattern with zero owners, and the row for that +// path must serialize owners_after as [], not null — a reviewer reading the +// plan in CI is being told "deliberately un-owned", which is a different (and +// legal, S-9) outcome from "no rule matches". +func TestR16_OwnershipRowsNullVsEmptyFromRealPlan(t *testing.T) { + tree := []string{"x/a.go", "y/b.go"} + p, err := build(t, "/x/ @a\n/y/ @b\n", tree, plan.Options{OnEmpty: "unowned"}, "remove_owner(/x/, @a)") + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var doc struct { + Rows []map[string]json.RawMessage `json:"ownership_rows"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Rows) != 1 { + t.Fatalf("got %d ownership rows, want 1", len(doc.Rows)) + } + if got := string(doc.Rows[0]["owners_after"]); got != "[]" { + t.Errorf("owners_after = %s, want [] (--on-empty=unowned leaves a matching rule with zero owners, S-9)", got) + } +} + +// SPEC R-16 (envelope): the CLI wraps a Plan in planFile, adding repo, ref and +// codeowners_path. Because Plan is EMBEDDED without a json tag, encoding/json +// inlines its fields — the document has no nested "Plan" object, and apply +// unmarshalling the file gets both the envelope and the plan in one pass. +// Pinning that inlining matters: giving the embedded field a name would nest +// every plan key one level deeper and break every existing plan.json, with no +// compile error anywhere. +func TestR16_PlanFileEnvelopeInlinesPlan(t *testing.T) { + pf := planFileMirror{Plan: fullPlan(), Repo: ".", Ref: "HEAD", CodeownersPath: ".github/CODEOWNERS"} + got := topLevelKeys(t, pf) + want := append(topLevelKeys(t, fullPlan()), "repo", "ref", "codeowners_path") + wantKeys(t, "cli planFile envelope", got, want) + for _, k := range got { + if k == "Plan" || k == "plan" { + t.Error("planFile must inline the embedded Plan, not nest it under a key") + } + } +} + +// SPEC R-16 (round trip): a plan marshalled and unmarshalled through the exact +// path `apply` uses — json.MarshalIndent of the planFile envelope, then +// json.Unmarshal back into it — is semantically identical. This is the actual +// apply contract: everything the planner proved reaches the writer through +// this round trip and nothing else, so any field that does not survive it is a +// proof that silently does not reach `apply`. +func TestR16_RoundTripThroughApplyPath(t *testing.T) { + orig := planFileMirror{ + Plan: fullPlan(), + Repo: "/some/repo", + Ref: "HEAD", + CodeownersPath: ".github/CODEOWNERS", + } + // json.MarshalIndent(pf, "", " ") is precisely what cmdPlan writes. + b, err := json.MarshalIndent(orig, "", " ") + if err != nil { + t.Fatal(err) + } + var back planFileMirror + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(orig, back) { + t.Errorf("plan did not survive the apply round trip.\n got: %#v\nwant: %#v", back, orig) + } +} + +// SPEC R-16 (round trip, real plan): the same round trip over a plan produced +// by the planner rather than by hand, including the two fields apply actually +// consumes — after_content (the bytes written to disk) and sha256_before (the +// drift gate). +func TestR16_RoundTripPreservesRealPlan(t *testing.T) { + tree := []string{"x/a.go", "y/b.go"} + p, err := build(t, "/x/ @a\n/y/ @b\n", tree, plan.Options{}, "add_owner(/x/, @b)") + if err != nil { + t.Fatal(err) + } + b, err := json.MarshalIndent(planFileMirror{Plan: *p, Repo: ".", Ref: "HEAD", CodeownersPath: "CODEOWNERS"}, "", " ") + if err != nil { + t.Fatal(err) + } + var back planFileMirror + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*p, back.Plan) { + t.Errorf("real plan did not survive the round trip.\n got: %#v\nwant: %#v", back.Plan, *p) + } + if back.Plan.AfterContent != p.AfterContent { + t.Errorf("after_content = %q, want %q — apply writes these bytes to disk", back.Plan.AfterContent, p.AfterContent) + } +} + +// SPEC R-16 (drift gate): sha256_before is the pin that makes apply REFUSE a +// CODEOWNERS file that changed since the plan was computed — the plan's +// invariants were proven against those exact bytes and are worthless against +// any others. It must be the hash of the planned-over content and it must +// survive the round trip byte for byte; a plan whose hash is lost or mangled +// either fails safe (refuses forever) or, worse, would need the check +// disabled. +func TestR16_HashBeforeSurvivesRoundTrip(t *testing.T) { + content := "/x/ @a\n/y/ @b\n" + tree := []string{"x/a.go", "y/b.go"} + p, err := build(t, content, tree, plan.Options{}, "add_owner(/x/, @b)") + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(content)) + want := hex.EncodeToString(sum[:]) + if p.HashBefore != want { + t.Fatalf("sha256_before = %q, want the sha256 of the planned-over bytes %q", p.HashBefore, want) + } + b, err := json.MarshalIndent(planFileMirror{Plan: *p, Repo: ".", Ref: "HEAD", CodeownersPath: "CODEOWNERS"}, "", " ") + if err != nil { + t.Fatal(err) + } + var back planFileMirror + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if back.HashBefore != want { + t.Errorf("sha256_before after round trip = %q, want %q — apply compares this against the file on disk", back.HashBefore, want) + } +} + +// SPEC R-16 (forward compatibility, unknown field): a plan document containing +// a field this binary does not know still unmarshals. Plans are written to CI +// artifacts and read back by a possibly-newer or possibly-older binary, so an +// unknown key must be ignored rather than rejected. This is Go's default — +// apply must never opt into DisallowUnknownFields, and this test is what +// notices if it ever does. +func TestR16_ForwardCompatUnknownFieldIsIgnored(t *testing.T) { + b, err := json.Marshal(planFileMirror{Plan: fullPlan(), Repo: ".", Ref: "HEAD", CodeownersPath: "CODEOWNERS"}) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + doc["field_from_a_newer_binary"] = map[string]any{"nested": []any{1, 2, 3}} + withExtra, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + + var back planFileMirror + if err := json.Unmarshal(withExtra, &back); err != nil { + t.Fatalf("plan with an unknown field failed to unmarshal: %v", err) + } + if !reflect.DeepEqual(back.Plan, fullPlan()) { + t.Errorf("unknown field disturbed the known ones.\n got: %#v\nwant: %#v", back.Plan, fullPlan()) + } + if back.CodeownersPath != "CODEOWNERS" { + t.Errorf("codeowners_path = %q, want %q", back.CodeownersPath, "CODEOWNERS") + } +} + +// SPEC R-16 (forward compatibility, missing field): a plan document written +// before a field existed unmarshals with that field at its zero value, and +// every other field intact. op_results is the concrete case — it was added on +// this branch, so every plan.json already sitting in a CI artifact lacks it, +// and this is what lets a newer binary still apply one. +func TestR16_ForwardCompatMissingFieldIsZero(t *testing.T) { + b, err := json.Marshal(planFileMirror{Plan: fullPlan(), Repo: ".", Ref: "HEAD", CodeownersPath: "CODEOWNERS"}) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + // Simulate a plan.json written by a binary that predates these fields. + delete(doc, "op_results") + delete(doc, "warnings") + older, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + + var back planFileMirror + if err := json.Unmarshal(older, &back); err != nil { + t.Fatalf("older plan failed to unmarshal: %v", err) + } + if back.OpResults != nil { + t.Errorf("op_results = %#v, want nil for a plan that predates the field", back.OpResults) + } + if back.Warnings != nil { + t.Errorf("warnings = %#v, want nil for a plan that omits it", back.Warnings) + } + // The fields apply actually depends on must be untouched by the omission. + if back.HashBefore != fullPlan().HashBefore { + t.Errorf("sha256_before = %q, want %q", back.HashBefore, fullPlan().HashBefore) + } + if back.AfterContent != fullPlan().AfterContent { + t.Errorf("after_content = %q, want %q", back.AfterContent, fullPlan().AfterContent) + } + if !reflect.DeepEqual(back.Changes, fullPlan().Changes) { + t.Errorf("changes = %#v, want %#v", back.Changes, fullPlan().Changes) + } +} diff --git a/internal/plan/zeromatch.go b/internal/plan/zeromatch.go new file mode 100644 index 0000000..ab9d58b --- /dev/null +++ b/internal/plan/zeromatch.go @@ -0,0 +1,583 @@ +package plan + +import ( + "fmt" + "strings" + + "github.com/jordonpeterson/codeowners-tool/internal/file" + "github.com/jordonpeterson/codeowners-tool/internal/ops" + "github.com/jordonpeterson/codeowners-tool/internal/pattern" + "github.com/jordonpeterson/codeowners-tool/internal/resolve" +) + +// Zero-match semantics (R-21/R-22/INV-6). +// +// Every other op in this package is checked against files that exist. A +// `declare` op is the one place the tool writes a rule with nothing in the repo +// to check it against, and it runs unattended across a fleet on a schedule — so +// each defect here is multiplied by the fleet and nobody is watching. Two +// properties carry the whole safety argument, and both are established without +// consulting the tree, which by definition says nothing about the declared +// scope: +// +// - IDEMPOTENCE. The planner's only no-op detector is a byte comparison, and +// its desired-state map is keyed by tracked path. A declare op has neither, +// so nothing already here can notice that the line it is about to append is +// already the last line of the file. Unfixed, a nightly fleet run appends +// the same rule to every repo forever, until the file crosses the 3 MB cliff +// where GitHub stops loading it and every path loses its owners at once +// (R-4/S-4). lastRuleForScope is the fix: "already declared" is a question +// about resolution, not about bytes. +// +// - THE STRUCTURAL PROOF (INV-6). The gate iterates the tree; with an empty +// scope set it iterates nothing, so INV-1 is vacuously true for a declare — +// "proven" without one statement having been made about the line written. +// proveDeclares replaces it: over the RE-PARSED bytes, the rule is there, it +// says what was asked, and no rule after it can capture the scope. + +// declareCheck is one declared scope's structural obligation, carried from +// synthesis to the gate. Keyed by pattern LANGUAGE, not by op: two declares on +// the same scope collapse into one rule, and checking the first op's owner set +// against the rule the second one amended would refuse a batch that is correct. +type declareCheck struct { + scope string + want []string +} + +// synthDeclare writes a rule for a scope that matches nothing tracked (R-22). +// +// It deliberately does NOT reuse synthAdd's unowned-path branch, which inserts +// at firstRuleIndex — i.e. BEFORE every existing rule. CODEOWNERS is +// last-match-wins, so a rule written above a trailing `* @org/everyone` +// catch-all is shadowed for every path it was meant to govern: the tool would +// report "applied", the diff would look right in review, and the declaration +// would never take effect. A declaration appends at EOF, where nothing can +// recapture it. +func synthDeclare(f *file.File, op ops.Op, batch *declareBatch, checks *[]*declareCheck, pl *Plan) error { + warnDuplicateDeclaredPatterns(f, op.Scope, pl) + rule, effective := lastRuleForScope(f, op.Scope, batch) + + // A rule for this pattern is already here, but a later rule can capture the + // scope, so it does not grant what it appears to grant. Reporting a no-op + // would read as a converged repo in the fleet summary while the rollout did + // nothing; the only way to make it effective is a second copy of the + // pattern, and R-7 treats a duplicate pattern as a defect to report rather + // than to author. Refuse and say so. + if rule != nil && !effective { + return &RefusalError{Msg: fmt.Sprintf( + "refusing: rule %q on line %d already declares scope %q but a later rule can recapture it; "+ + "declaring it again would need a duplicate pattern, which is the defect R-7 exists to report — fix the ordering first", + rule.PatternText, rule.LineIndex+1, op.Scope)} + } + + var base []string + if rule != nil { + base = rule.OwnersCopy() + } + want := declaredOwners(op, base) + + switch { + case rule == nil: + at := len(f.Lines) + f.InsertRule(at, op.Scope, want) + pl.Changes = append(pl.Changes, Change{ + Action: "insert", Line: at + 1, Pattern: op.Scope, + NewOwners: want, NewLine: f.LineText(at), + Reason: fmt.Sprintf( + "scope %q matches no tracked file; on_zero_match=declare appends the rule at EOF so no existing rule can shadow it (R-22, last-match-wins S-1)", op.Scope), + }) + case !sameRuleOwners(rule.Owners, want): + // Amend, never append a second line for the same pattern: two rules for + // one pattern means last-match-wins silently DROPS the owners on the + // shadowed line (R-4/R-7), and the file gains a line on every ownership + // change forever. + l := rule.LineIndex + old := f.LineText(l) + oldOwners := rule.OwnersCopy() + f.SetOwners(l, want) + pl.Changes = append(pl.Changes, Change{ + Action: "amend", Line: l + 1, Pattern: rule.PatternText, + OldOwners: oldOwners, NewOwners: want, + OldLine: old, NewLine: f.LineText(l), + Reason: fmt.Sprintf( + "rule %q already declares scope %q and nothing later recaptures it; amended in place rather than appending a duplicate pattern (R-22/R-7)", rule.PatternText, op.Scope), + }) + default: + // Already declared and already effective: edit nothing. This is the + // only thing standing between a scheduled fleet and R-4's unbounded + // growth, and it must survive spacing — the rule may be written with a + // tab and an inline comment and still grant exactly these owners. + } + + recordDeclareCheck(checks, op.Scope, want) + return nil +} + +// declaredOwners is the owner set the declared rule must end up with. base is +// the owner set of the rule the declaration lands on, if any. +func declaredOwners(op ops.Op, base []string) []string { + if op.Kind == ops.SetOwners { + // An empty list is a legal, deliberate un-owning (S-9): the rule keeps + // its pattern with zero owners, so a future matching path is explicitly + // NOT governed by whatever broader rule sits above it. + return append([]string{}, op.Owners...) + } + out := append([]string{}, base...) + if !contains(out, op.Owner) { + out = append(out, op.Owner) + } + return out +} + +// lastRuleForScope finds the rule a declaration lands on: the last rule whose +// pattern is the SAME LANGUAGE as the scope. Language, not bytes — a rule +// written with a tab, extra spaces or an inline comment already grants the +// declared owners, and rewriting it would churn a whitespace-only diff into one +// pull request per repo. +// +// It also reports whether that rule is still EFFECTIVE: under last-match-wins a +// rule any later rule can recapture does not grant what it appears to grant, so +// a declaration sitting above a catch-all is not satisfied by it. A PARTIAL +// overlap with another scope this same policy declares does not make it +// ineffective — see declareShadow; this must agree with proveDeclares or the +// second pass of a converged fleet repo would refuse the file the first pass +// wrote (R-19). +func lastRuleForScope(f *file.File, scope string, batch *declareBatch) (*file.Rule, bool) { + rules := f.Rules() + idx := lastRuleIndexForScope(rules, scope) + if idx < 0 { + return nil, false + } + for _, later := range rules[idx+1:] { + if kind, _ := classifyDeclareShadow(scope, later.PatternText, batch); kind == shadowFatal { + return rules[idx], false + } + } + return rules[idx], true +} + +// lastRuleIndexForScope is the LAST rule whose pattern is the same language as +// scope, or -1. Last, not first: under last-match-wins that is the only rule +// that governs the scope, so it is the one a declaration lands on and the one +// INV-6 must prove. synthDeclare and proveDeclares must agree on it exactly, or +// the pass that writes a line and the pass that proves it are talking about +// different rules. +func lastRuleIndexForScope(rules []*file.Rule, scope string) int { + idx := -1 + for i, r := range rules { + if samePatternLanguage(scope, r.PatternText) { + idx = i + } + } + return idx +} + +// proveDeclares is INV-6's structural proof, run over the RE-PARSED bytes for +// exactly the reason the tree gate re-parses: a rule that serializes into +// something that reads back as a DIFFERENT rule is a silent ownership change, +// and for a declared scope there is no tracked path whose resolution would +// betray it. Three obligations, all structural: +// +// 1. the emitted line round-trips as a rule for the declared scope; +// 2. it carries exactly the owner set the op asked for; +// 3. no rule after it can capture the scope, so it is the last word for every +// path added later — the guarantee that replaces INV-1 here. +// +// Anything unproven is a refusal, never a warning: this is the only check the +// line gets. Obligation 3 has exactly one relaxation, and only for scopes THIS +// SAME policy declares — see classifyDeclareShadow; a partial overlap there is +// disclosed as a warning, total capture is still refused. +func proveDeclares(after *file.File, checks []*declareCheck, batch *declareBatch, pl *Plan) error { + if len(checks) == 0 { + return nil + } + rules := after.Rules() + for _, c := range checks { + idx := lastRuleIndexForScope(rules, c.scope) + if idx < 0 { + return &RefusalError{Msg: fmt.Sprintf( + "refusing: the line written for scope %q does not read back as a rule for that pattern — "+ + "nothing tracked matches the scope, so this round-trip is the only proof the rule says what was asked (INV-6)", c.scope)} + } + if !sameRuleOwners(rules[idx].Owners, c.want) { + return &RefusalError{Msg: fmt.Sprintf( + "refusing: the declared rule for %q reads back as %s, not %s (INV-6)", + c.scope, fmtOwners(rules[idx].Owners), fmtOwners(c.want))} + } + for _, later := range rules[idx+1:] { + kind, witness := classifyDeclareShadow(c.scope, later.PatternText, batch) + switch kind { + case shadowNone: + // Provably disjoint: the later rule cannot touch the scope. + case shadowPartial: + pl.addWarning(declareOverlapWarning(rules[idx], later, c.scope, witness)) + default: + if batch.declares(later.PatternText) { + return &RefusalError{Msg: fmt.Sprintf( + "refusing: rule %q on line %d is declared by this same policy, comes after the rule declared for %q, "+ + "and no path can be shown to be governed by %q that %q does not also capture — the declaration would be "+ + "dead on arrival (INV-6); order the policy so the narrower scope is declared last", + later.PatternText, later.LineIndex+1, c.scope, c.scope, later.PatternText)} + } + return &RefusalError{Msg: fmt.Sprintf( + "refusing: rule %q on line %d comes after the rule declared for %q and cannot be shown disjoint from it; "+ + "a later rule that can recapture the scope makes the declaration dead on arrival (INV-6)", + later.PatternText, later.LineIndex+1, c.scope)} + } + } + } + return nil +} + +// declareShadow classifies a rule that sits AFTER the rule written for a +// declared scope — INV-6's third obligation. +type declareShadow int + +const ( + // shadowNone: provably disjoint languages, so the later rule can never + // govern a path the declared scope governs. + shadowNone declareShadow = iota + // shadowPartial: the later rule is another scope THIS SAME policy declares, + // and a concrete path exists that the declared scope governs and the later + // rule does not — so the declaration still wins somewhere. + shadowPartial + // shadowFatal: total capture, or an overlap that could not be shown partial, + // or a rule this policy did not write. The declaration is (or cannot be + // shown not to be) dead on arrival. + shadowFatal +) + +// classifyDeclareShadow decides INV-6's third obligation for one (declared +// scope, later rule) pair. +// +// The original rule was "not provably disjoint ⇒ refuse", which is logically +// correct — CODEOWNERS gives a path exactly one owner set, so on +// .github/workflows/deploy.tf either the workflows rule or the Terraform rule +// wins, never both. But it made the canonical fleet baseline ("CI owns +// workflows everywhere, infra owns Terraform where it exists") inexpressible in +// EVERY repo, forever, and surfaced a policy-level conflict as an identical +// per-repo exit 2 on all 100 repos — exactly the misclassification the exit-2 / +// exit-3 split exists to prevent. +// +// So the obligation is relaxed in one direction only: +// +// - TOTAL capture stays a refusal. A declared rule no path can ever reach is +// a dead line reported as applied — the defect this whole file exists to +// prevent. +// - PARTIAL overlap with a scope the SAME policy declares is allowed and +// disclosed. The author asked for both in one document; their order in it +// is the precedence, exactly as in a hand-written CODEOWNERS. This mirrors +// R-7, which reports shadowed duplicates rather than refusing. +// - PARTIAL overlap with a PRE-EXISTING later rule stays a refusal: R-1 +// forbids reordering existing lines, so the planner has no move to make. +// +// "Partial" must be PROVEN, by exhibiting a path the declared scope governs and +// the later rule does not. No witness means unproven, and unproven is fatal — +// the incompleteness costs a refusal, never a dead write. +// +// The witness is returned alongside the verdict so the disclosure can name a +// concrete path the declared rule still governs. +func classifyDeclareShadow(scope, later string, batch *declareBatch) (declareShadow, string) { + if patternsProvablyDisjoint(scope, later) { + return shadowNone, "" + } + if !batch.declares(later) { + return shadowFatal, "" + } + witness := pathOutside(scope, later) + if witness == "" { + return shadowFatal, "" + } + return shadowPartial, witness +} + +// declareBatch is the set of scopes one policy declares, with a memo over the +// rule patterns it has been asked about. +// +// INV-6's third obligation asks "did this same policy declare that later rule?" +// once per (declared scope, later rule) pair, and the answer does not depend on +// which scope is asking. Each answer costs two pattern.Contains walks — tens of +// microseconds — so recomputing it per scope makes the check quadratic in the +// number of declares and linear in the size of the CODEOWNERS on top of that, +// which a 40-declare policy over a large file feels directly. +type declareBatch struct { + scopes []string + memo map[string]bool +} + +func newDeclareBatch(scopes []string) *declareBatch { + return &declareBatch{scopes: scopes, memo: make(map[string]bool, len(scopes))} +} + +// declares reports whether a rule pattern is one of the scopes this batch +// declares. Matching by pattern LANGUAGE, not by line identity, is what makes +// the answer the same on the pass that writes the line and on every pass after +// it — a line-identity test would refuse on pass 2 the file pass 1 wrote, and a +// fleet job with no fixed point is worse than one that refuses (R-19). +func (b *declareBatch) declares(pat string) bool { + if v, done := b.memo[pat]; done { + return v + } + v := false + for _, s := range b.scopes { + if samePatternLanguage(s, pat) { + v = true + break + } + } + b.memo[pat] = v + return v +} + +// declareOverlapWarning is the disclosure that makes the relaxation above +// honest: it names both scopes, says which one wins where they meet, and shows +// a path the declared rule still governs. +func declareOverlapWarning(declared, later *file.Rule, scope, witness string) string { + return fmt.Sprintf( + "line %d: declared scope %q overlaps scope %q, declared by the same policy on line %d; CODEOWNERS gives a path exactly "+ + "one owner set, so %q wins on every path both match and %q governs only the rest (a path like %q still resolves to "+ + "it) — the order the policy lists them in is the precedence, exactly as in a hand-written file (R-22/INV-6); a later "+ + "scope capturing %q entirely would still be refused as dead on arrival", + declared.LineIndex+1, scope, later.PatternText, later.LineIndex+1, + later.PatternText, scope, witness, scope) +} + +// pathOutside returns a concrete path the scope governs and the later pattern +// does NOT, or "" when it cannot find one. A witness is a proof that the +// overlap is partial: the declared rule is still the last word for that path, +// so it is not dead on arrival. +// +// Candidates are generated by instantiating the scope's wildcards, then CHECKED +// against the compiled patterns — the proof rests on pattern.Match, not on the +// generator, so an over-eager instantiation costs a missed witness (a refusal), +// never a false one. +func pathOutside(scope, later string) string { + sp, err := pattern.Compile(scope) + if err != nil { + return "" + } + lp, err := pattern.Compile(later) + if err != nil { + return "" + } + for _, p := range declareWitnesses(scope) { + if sp.Match(p) && !lp.Match(p) { + return p + } + } + return "" +} + +// Filler segments for witness paths. They are deliberately unlikely to be +// spelled literally in a real rule: a filler that collided with a literal +// segment of the later pattern would only lose a witness, but a stable, odd +// name also keeps the warning text reproducible across runs and repos, which +// the fleet record depends on. +const ( + witnessFillerA = "zzz-witness-a" + witnessFillerB = "zzz-witness-b" +) + +// declareWitnesses enumerates concrete paths a scope pattern plausibly governs. +// Deterministic and bounded: the same scope yields the same list in the same +// order on every repo in the fleet, so a warning naming a witness reads the +// same everywhere. +func declareWitnesses(scope string) []string { + segs := strings.Split(strings.TrimPrefix(scope, "/"), "/") + variants := [][]string{{}} + for _, seg := range segs { + var next [][]string + for _, v := range variants { + for _, alt := range segmentInstances(seg) { + next = append(next, append(append([]string{}, v...), alt...)) + } + } + if len(next) > 64 { + next = next[:64] + } + variants = next + } + // A directory scope governs its whole subtree, so probe below each variant + // too: a later rule may govern only direct children ("/x/*"), or only one + // extension, and the witness has to be able to dodge both. + tails := [][]string{nil, {witnessFillerA}, {witnessFillerA + ".zzz"}, {witnessFillerA, witnessFillerB}} + var out []string + seen := map[string]bool{} + for _, v := range variants { + for _, tail := range tails { + p := strings.Trim(strings.Join(append(append([]string{}, v...), tail...), "/"), "/") + if p == "" || seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + } + return out +} + +// segmentInstances instantiates ONE pattern segment into the path segments it +// can stand for. "**" spans any number of segments, so it is tried at zero, one +// and two — zero is what finds the root-level witness for "**/*.tf". +func segmentInstances(seg string) [][]string { + switch seg { + case "": + return [][]string{nil} + case "**": + return [][]string{nil, {witnessFillerA}, {witnessFillerA, witnessFillerB}} + } + a, b := fillSegment(seg, witnessFillerA), fillSegment(seg, witnessFillerB) + if a == b { + return [][]string{{a}} + } + return [][]string{{a}, {b}} +} + +// fillSegment substitutes a literal for each wildcard in one segment. Two +// different fillers are tried by the caller so a witness can dodge a later +// pattern that happens to spell one of them. +func fillSegment(seg, filler string) string { + var b strings.Builder + escaped := false + for _, r := range seg { + switch { + case escaped: + b.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '*': + b.WriteString(filler) + case r == '?': + b.WriteByte('z') + default: + b.WriteRune(r) + } + } + return b.String() +} + +func recordDeclareCheck(checks *[]*declareCheck, scope string, want []string) { + for _, c := range *checks { + if samePatternLanguage(c.scope, scope) { + c.want = want + return + } + } + *checks = append(*checks, &declareCheck{scope: scope, want: want}) +} + +// patternsProvablyDisjoint reports whether NO path can ever match both +// patterns. It is SOUND and deliberately incomplete, in the same shape as +// pattern.Contains: false means "could not prove disjoint", never "they +// overlap". Every caller treats an unproven pair as overlapping, so +// incompleteness costs a refusal or a stricter R-8 — never a wrong write. +// +// The shape it proves: two anchored, wildcard-free directory scopes. Such a +// pattern matches exactly its own path plus everything beneath it, so the two +// languages can meet only if one prefix contains the other. anchoredDirPrefix +// supplies the normalization and rejects anything with a wildcard or an escape +// in it, which is what keeps the claim honest. +func patternsProvablyDisjoint(a, b string) bool { + pa, ok := anchoredDirPrefix(a) + if !ok { + return false + } + pb, ok := anchoredDirPrefix(b) + if !ok { + return false + } + return !strings.HasPrefix(pa, pb) && !strings.HasPrefix(pb, pa) +} + +// commuteOnEveryOwnerSet decides R-8 for a pair of ops that meet on paths which +// do not exist yet. +// +// The tree-based R-8 check intersects path SETS, and a declared scope owns +// none — so two contradictory declares meet on the empty set and sail through +// as "commuting", and the batch is written in input order with last-match-wins +// silently picking the winner. There is no path to test, so test the transforms +// instead: each op's owner-set transform depends on its input only through +// membership of the identifiers it names (set_owners ignores the input +// entirely), so probing every subset of those identifiers, plus one identifier +// neither op names, decides commutation exactly. +func commuteOnEveryOwnerSet(a, b ops.Op) bool { + var probe []string + for _, o := range []string{a.Owner, a.NewOwner, b.Owner, b.NewOwner} { + if o != "" && !contains(probe, o) { + probe = append(probe, o) + } + } + // Stands in for whatever an existing broader rule already granted the + // future path; without it, transforms that differ only on foreign owners + // (remove vs. set, say) would look equal on the empty set alone. + probe = append(probe, "@zz/owner-not-named-by-either-op") + + states := [][]string{nil} + for mask := 0; mask < 1< 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return out +} + +func lastEmittedLine(t *testing.T, s string) string { + t.Helper() + ls := emittedLines(s) + if len(ls) == 0 { + t.Fatalf("planned content is empty") + } + return ls[len(ls)-1] +} + +// futureResolution resolves a path that does NOT exist in the repo against +// the planned content. It is the only way to observe what a declare op +// actually declared: the tracked tree, by definition, says nothing about it. +func futureResolution(content string, tree []string, path string) resolve.Resolution { + full := append(append([]string{}, tree...), path) + return plan.ResolveContent(content, full)[path] +} + +func opResultByID(t *testing.T, p *plan.Plan, id string) plan.OpResult { + t.Helper() + for _, r := range p.OpResults { + if r.ID == id { + return r + } + } + t.Fatalf("no op result with id %q; results = %+v", id, p.OpResults) + return plan.OpResult{} +} + +// SPEC R-21 (compatibility): `require` and its zero value "" preserve R-5 +// exactly — a scope matching zero tracked files is invalid input (exit 3). +// +// This is the guarantee that lets `on_zero_match` be added at all: ops parsed +// from `--op` never set the field, so every pre-existing test and every +// existing caller must keep the behavior TestT7_ZeroMatchScopeRejected pins. +// If this regresses, adding the field silently changed what one-repo `plan` +// does — the tool's oldest documented refusal. +func TestR21_RequireAndZeroValuePreserveR5(t *testing.T) { + tree := []string{"real.txt"} + for _, mode := range []string{"", ops.ZeroMatchRequire} { + for _, spec := range []string{ + "add_owner(/ghost/, @b)", + "set_owners(/ghost/, [@b])", + "set_owners(/ghost/, [])", + "remove_owner(/ghost/, @b)", + } { + t.Run(mode+"/"+spec, func(t *testing.T) { + _, err := buildZM(t, "* @a\n", tree, plan.Options{}, zmOp{spec: spec, mode: mode}) + var inv *plan.InvalidError + if !errors.As(err, &inv) { + t.Fatalf("zero-match scope under on_zero_match=%q must stay InvalidError (exit 3), got %v", mode, err) + } + if !strings.Contains(err.Error(), "R-5") { + t.Errorf("refusal must still cite R-5, got %q", err.Error()) + } + }) + } + } +} + +// SPEC R-21 (`skip`): a skipped op changes nothing, is reported as skipped +// with a reason, and does not stop the rest of the batch from applying. +// +// This is the opportunistic-op case — "if this repo has Terraform, @org/infra +// owns it". If a skip silently aborted the batch, the baseline ops that DO +// apply here would never land; if it silently applied something, `skip` would +// mean nothing. +func TestR21_SkipIsANoOpForThatOpOnly(t *testing.T) { + content := "# baseline\n* @org/everyone\n" + tree := []string{"services/api/main.go", "README.md"} + + api := zmOp{spec: "add_owner(/services/api/, @org/api)", id: "api"} + tf := zmOp{spec: "add_owner(/terraform/, @org/infra)", mode: ops.ZeroMatchSkip, id: "tf"} + + alone, err := buildZM(t, content, tree, plan.Options{}, api) + if err != nil { + t.Fatalf("reference plan (api op alone) must succeed: %v", err) + } + p, err := buildZM(t, content, tree, plan.Options{}, tf, api) + if err != nil { + t.Fatalf("a skipped op must not fail the batch: %v", err) + } + if p.AfterContent != alone.AfterContent { + t.Errorf("skipped op changed bytes:\n got %q\nwant %q (identical to the batch without it)", p.AfterContent, alone.AfterContent) + } + for _, c := range p.Changes { + if strings.Contains(c.Pattern, "terraform") { + t.Errorf("skipped op emitted a change: %+v", c) + } + } + if len(p.OpResults) != 2 { + t.Fatalf("op_results = %+v, want one per op", p.OpResults) + } + got := opResultByID(t, p, "tf") + if got.Status != "skipped" { + t.Errorf("tf status = %q, want %q", got.Status, "skipped") + } + if got.Reason == "" { + t.Error("a skipped op must carry a reason — without it the fleet operator cannot tell a skipped repo from a converged one") + } + if got.Proven != "" { + t.Errorf("tf proven = %q, want empty: a skipped op proves nothing", got.Proven) + } + if got := opResultByID(t, p, "api"); got.Status != "applied" || got.Proven != "tree" { + t.Errorf("api result = %+v, want applied/tree", got) + } +} + +// SPEC R-21 (`skip`): when EVERY op skips, the plan as a whole is a no-op +// (exit 1) — there is nothing to write. +// +// The planner must not emit a plan whose AfterContent equals its input: +// `apply` would then rewrite the file with identical bytes and the fleet +// would open a PR per repo containing no diff. +func TestR21_AllOpsSkippedIsAWholePlanNoOp(t *testing.T) { + _, err := buildZM(t, "* @a\n", []string{"README.md"}, plan.Options{}, + skipZMOp("add_owner(/terraform/, @org/infra)"), + skipZMOp("set_owners(/vendor/, [@org/vendor])")) + var noop *plan.NoOpError + if !errors.As(err, &noop) { + t.Fatalf("a batch in which every op skips must be a no-op (exit 1), got %v", err) + } +} + +// TRAP 1 — SPEC R-22 (INV-4 for declare): a declare op must be IDEMPOTENT. +// +// This is the highest-cost defect in the feature. The planner's only no-op +// detector is `bytes.Equal(afterBytes, content)`, and the desired-state map +// is keyed by TRACKED path — a declare op has none. So a declare resolves +// identically before and after, and nothing in the current design can notice +// that the line it is about to append is already the line at the bottom of +// the file. +// +// Unfixed, the failure is not subtle and not local: a scheduled fleet run +// appends the same rule to all 100 CODEOWNERS files, every run, forever. That +// is R-4's unbounded growth, once per repo per night, ending at S-4's 3 MB +// cap where GitHub stops loading the file and the repo silently has NO +// ownership at all. The second run must be a no-op (exit 1). +func TestR22_DeclareIsIdempotent(t *testing.T) { + content := "# baseline\n* @org/everyone\n" + tree := []string{"README.md", "src/main.go"} + op := declareOp("add_owner(/.github/workflows/, @org/ci)") + + p, err := buildZM(t, content, tree, plan.Options{}, op) + if err != nil { + t.Fatalf("declare on a zero-match scope must plan: %v", err) + } + want := "# baseline\n* @org/everyone\n/.github/workflows/ @org/ci\n" + if p.AfterContent != want { + t.Fatalf("after = %q, want %q", p.AfterContent, want) + } + + // Re-run the SAME op against the OUTPUT, three times: this is literally + // what tomorrow's, and the next day's, scheduled run does. + for run := 2; run <= 4; run++ { + _, err := buildZM(t, p.AfterContent, tree, plan.Options{}, op) + var noop *plan.NoOpError + if !errors.As(err, &noop) { + t.Fatalf("run %d of the same declare must be a no-op (exit 1), got %v — this is the unbounded-growth failure (R-4)", run, err) + } + } + if n := strings.Count(p.AfterContent, "/.github/workflows/"); n != 1 { + t.Errorf("declared pattern appears %d times, want exactly 1", n) + } +} + +// TRAP 1, second face — SPEC R-22: when the declared rule is already present +// but with DIFFERENT owners, the op must amend that rule in place, not append +// a second copy of the pattern. +// +// Appending instead would leave two rules for the same pattern; last-match +// wins, so `add_owner` would silently DROP the owners on the shadowed line +// (the exact R-4/R-7 failure the amend-preferred rule exists to prevent), and +// the file would gain a line on every ownership change forever. +func TestR22_DeclareAmendsAnExistingRuleWithDifferentOwners(t *testing.T) { + tree := []string{"README.md"} + cases := []struct { + name, content, spec, want string + }{ + {"add_owner joins the existing owners", + "# baseline\n* @org/everyone\n/.github/workflows/ @org/old-ci\n", + "add_owner(/.github/workflows/, @org/ci)", + "# baseline\n* @org/everyone\n/.github/workflows/ @org/old-ci @org/ci\n"}, + {"set_owners replaces them", + "# baseline\n* @org/everyone\n/.github/workflows/ @org/old-ci\n", + "set_owners(/.github/workflows/, [@org/ci])", + "# baseline\n* @org/everyone\n/.github/workflows/ @org/ci\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, err := buildZM(t, c.content, tree, plan.Options{}, declareOp(c.spec)) + if err != nil { + t.Fatalf("declare over an existing rule must plan: %v", err) + } + if p.AfterContent != c.want { + t.Errorf("after = %q, want %q", p.AfterContent, c.want) + } + if n := strings.Count(p.AfterContent, "/.github/workflows/"); n != 1 { + t.Errorf("declared pattern appears %d times, want exactly 1 — a duplicate pattern shadows the other (R-7)", n) + } + }) + } +} + +// TRAP 1, third face — SPEC R-22: "already declared" is a question about +// RESOLUTION, not about bytes. A rule written with a tab, extra spaces, or an +// inline comment already grants the declared owners, so re-declaring it is a +// no-op — and must not rewrite the line, which would churn a diff into 100 +// pull requests that change only whitespace. +func TestR22_DeclareSeesAnExistingRuleThroughSpacing(t *testing.T) { + tree := []string{"README.md"} + content := "# baseline\n* @org/everyone\n/.github/workflows/\t @org/ci # owned by CI\n" + for _, spec := range []string{ + "add_owner(/.github/workflows/, @org/ci)", + "set_owners(/.github/workflows/, [@org/ci])", + } { + t.Run(spec, func(t *testing.T) { + _, err := buildZM(t, content, tree, plan.Options{}, declareOp(spec)) + var noop *plan.NoOpError + if !errors.As(err, &noop) { + t.Fatalf("an already-satisfied declare must be a no-op (exit 1) regardless of spacing, got %v", err) + } + }) + } +} + +// TRAP 2 — SPEC R-22: a declare op appends at EOF. It must NOT reuse +// synthAdd's unowned-path insert point, which inserts at firstRuleIndex, +// i.e. BEFORE every existing rule. +// +// CODEOWNERS is last-match-wins, so a rule written above a trailing +// `* @org/everyone` catch-all — the single most common line in real +// CODEOWNERS files — is shadowed for every path it was meant to govern. The +// tool would report "applied", the diff would look right in review, and the +// declared ownership would never take effect: 100 pull requests that do +// nothing, discovered months later when the first workflow file lands and +// pings the wrong team. +// +// The tracked tree cannot show any of this (the scope matches nothing), so +// the assertion resolves a path that does not exist yet against the emitted +// bytes. +func TestR22_DeclareAppendsAtEOFBelowACatchAll(t *testing.T) { + content := "# CODEOWNERS\n/services/api/ @org/api\n* @org/everyone\n" + tree := []string{"services/api/main.go", "README.md"} + + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci)")) + if err != nil { + t.Fatalf("declare must plan: %v", err) + } + if got, want := lastEmittedLine(t, p.AfterContent), "/.github/workflows/ @org/ci"; got != want { + t.Errorf("last line = %q, want %q — a declared rule written above the catch-all is dead on arrival", got, want) + } + // The point of appending at EOF: a matching file added LATER resolves to + // the declared owners, not to the catch-all's. + future := futureResolution(p.AfterContent, tree, ".github/workflows/ci.yml") + if !reflect.DeepEqual(future.Owners, []string{"@org/ci"}) { + t.Errorf("future .github/workflows/ci.yml = %v, want {@org/ci} — the declared rule is shadowed", future.Owners) + } + // INV-2 is unaffected: nothing tracked moves. Note both tracked paths + // resolve to the catch-all BEFORE any op runs — the trailing `*` shadows + // `/services/api/` under last-match-wins (S-1), which is the whole point of + // the fixture. Asserting {@org/api} here would demand the declare op MOVE a + // tracked path, i.e. the exact INV-2 violation this block exists to rule + // out. (An earlier revision did assert that and was unsatisfiable by any + // correct implementation.) + before := plan.ResolveContent(content, tree) + wantOwners(t, before, map[string][]string{ + "services/api/main.go": {"@org/everyone"}, + "README.md": {"@org/everyone"}, + }) + after := plan.ResolveContent(p.AfterContent, tree) + wantOwners(t, after, map[string][]string{ + "services/api/main.go": {"@org/everyone"}, + "README.md": {"@org/everyone"}, + }) +} + +// TRAP 2, EOF edge: the file's final line has NO trailing newline. Appending +// must put the declared rule on its own line — concatenating onto the last +// line would rewrite an existing rule into something else entirely (pattern +// plus a stray owner list), which is a silent ownership change, not a +// declaration. +func TestR22_DeclareAppendsAtEOFWithoutATrailingNewline(t *testing.T) { + content := "# CODEOWNERS\n* @org/everyone" + tree := []string{"README.md"} + + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci)")) + if err != nil { + t.Fatalf("declare must plan: %v", err) + } + want := "# CODEOWNERS\n* @org/everyone\n/.github/workflows/ @org/ci\n" + if p.AfterContent != want { + t.Errorf("after = %q, want %q", p.AfterContent, want) + } + if got := plan.ResolveContent(p.AfterContent, tree)["README.md"].Owners; !reflect.DeepEqual(got, []string{"@org/everyone"}) { + t.Errorf("README.md = %v, want {@org/everyone} (INV-2)", got) + } +} + +// TRAP 2, EOF edge: a file containing only comments has no rules at all, so +// "after the last rule" and "before the first rule" are the same index. The +// declared rule still goes at the end, and every comment line stays +// byte-identical (INV-5) — a header explaining the file must not be pushed +// below the rules it introduces. +func TestR22_DeclareAppendsAtEOFInACommentOnlyFile(t *testing.T) { + content := "# CODEOWNERS\n# nothing is owned yet — see the ownership policy\n" + tree := []string{"README.md"} + + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci)")) + if err != nil { + t.Fatalf("declare must plan: %v", err) + } + want := content + "/.github/workflows/ @org/ci\n" + if p.AfterContent != want { + t.Errorf("after = %q, want %q", p.AfterContent, want) + } + future := futureResolution(p.AfterContent, tree, ".github/workflows/ci.yml") + if !reflect.DeepEqual(future.Owners, []string{"@org/ci"}) { + t.Errorf("future .github/workflows/ci.yml = %v, want {@org/ci}", future.Owners) + } +} + +// TRAP 3 — SPEC INV-6: `proven` distinguishes an op checked against real +// files from one that could only be argued structurally. +// +// The gate iterates the TREE. For an op whose scope set is empty it iterates +// nothing, so INV-1 is vacuously true — the plan is "proven" without a single +// statement having been made about the line just written. That is precisely +// the case where a reviewer most needs to be told. `proven: "structural"` is +// the disclosure that a rule went in unverified against the repo; without it, +// a fleet operator reading 100 JSON records cannot tell a checked rollout +// from an unchecked one. +func TestINV6_ProvenIsStructuralOnlyForZeroMatchDeclares(t *testing.T) { + content := "* @org/everyone\n" + tree := []string{"services/api/main.go", "README.md"} + + p, err := buildZM(t, content, tree, plan.Options{}, + zmOp{spec: "add_owner(/services/api/, @org/api)", mode: ops.ZeroMatchDeclare, id: "api"}, + zmOp{spec: "add_owner(/.github/workflows/, @org/ci)", mode: ops.ZeroMatchDeclare, id: "ci"}) + if err != nil { + t.Fatalf("declare batch must plan: %v", err) + } + if got := opResultByID(t, p, "api"); got.Status != "applied" || got.Proven != "tree" { + t.Errorf("api result = %+v, want applied/tree — this op matched real files and IS provable over the tree", got) + } + if got := opResultByID(t, p, "ci"); got.Status != "applied" || got.Proven != "structural" { + t.Errorf("ci result = %+v, want applied/structural — nothing tracked matched it (INV-6)", got) + } +} + +// TRAP 3 — SPEC INV-6: the structural proof must be a real proof. Since the +// gate's tree loop is vacuous for a zero-match op, the ONLY thing standing +// between the user and a wrong line is the structural check, and it must +// re-read the emitted bytes exactly as the gate does for tracked paths. +// +// Here the scope contains unescaped whitespace, so the line written for it — +// `docs x@y.zz @a` — re-parses as a DIFFERENT rule (pattern `docs`). With a +// tracked match this is caught today (TestGate_ProvesSerializedBytesNotModel). +// With zero matches nothing looks, and the tool would hand @a every `docs` +// directory in the repo while reporting that it declared ownership of a path +// with a space in it. Must refuse (exit 2). +func TestINV6_RefusesADeclareWhoseEmittedLineDoesNotRoundTrip(t *testing.T) { + tree := []string{"README", "docs/guide.md"} + op := ops.Op{Kind: ops.AddOwner, Scope: "docs x@y.zz", Owner: "@a", + Raw: "add_owner(docs x@y.zz, @a)", OnZeroMatch: ops.ZeroMatchDeclare, ID: "bad"} + + _, err := plan.Build([]byte("README @keep\n"), tree, []ops.Op{op}, plan.Options{}) + var ref *plan.RefusalError + if !errors.As(err, &ref) { + t.Fatalf("a declared line that re-parses as a different rule must be refused (exit 2), got %v", err) + } +} + +// TRAP 3 — SPEC INV-6 / INV-2: declare weakens INV-1 and NOTHING else. INV-2 +// is still proven over the whole tree exactly as today. +// +// This is the promise the feature is sold on ("a pattern matching nothing +// tracked cannot move any existing path's resolution"). If a declare op could +// move even one tracked path, the fleet rollout would be silently +// reassigning ownership in 100 repositories under cover of "declaring" paths +// that do not exist. +func TestINV6_DeclareDoesNotWeakenINV2(t *testing.T) { + content := "# ownership\n* @org/everyone\n/services/ @org/svc\n/services/api/ @org/api\n*.md @org/docs\n" + tree := []string{ + "README.md", "src/util.go", + "services/api/main.go", "services/api/README.md", "services/web/app.js", + } + before := plan.ResolveContent(content, tree) + + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/terraform/, @org/infra)")) + if err != nil { + t.Fatalf("declare must plan: %v", err) + } + after := plan.ResolveContent(p.AfterContent, tree) + for _, path := range tree { + b, a := before[path], after[path] + if b.Matched != a.Matched || !resolve.OwnersEqual(b.Owners, a.Owners) { + t.Errorf("INV-2: %s changed from %v (matched=%v) to %v (matched=%v)", + path, b.Owners, b.Matched, a.Owners, a.Matched) + } + } + if len(p.Rows) != 0 { + t.Errorf("ownership rows = %+v, want none: a declare op moves no tracked path", p.Rows) + } + if len(p.Changes) != 1 || p.Changes[0].Action != "insert" || p.Changes[0].Pattern != "/terraform/" { + t.Errorf("changes = %+v, want exactly one insert of /terraform/", p.Changes) + } +} + +// TRAP 4 — SPEC R-22 (R-8 for declare ops): the batch commutativity check +// intersects TREE path sets. Two zero-match scopes intersect on nothing, so +// an order-dependent batch of declare ops sails through the R-8 check as +// "commuting" and is written in input order. +// +// The pair below is genuinely contradictory: for a future `terraform/main.tf` +// the first op asks for @org/infra among the owners and the second asks for +// exactly {@org/tf}. Last-match-wins means whichever line is written second +// decides — the two orderings produce different CODEOWNERS files and both are +// accepted today. The refusal must cite R-8; a refusal citing R-5 means +// `declare` was never implemented and this test is passing for the wrong +// reason. +func TestR22_ZeroMatchBatchIsNotVacuouslyCommuting(t *testing.T) { + cases := []struct { + name string + tree []string + in []zmOp + }{ + {"two declare ops on overlapping future paths", + []string{"README.md"}, + []zmOp{ + declareOp("add_owner(/terraform/, @org/infra)"), + declareOp("set_owners(/terraform/*.tf, [@org/tf])"), + }}, + {"declare op vs. an op that applies here", + []string{"README.md", "src/util.go"}, + []zmOp{ + declareOp("set_owners(**/*.tf, [@org/infra])"), + {spec: "set_owners(/src/, [@org/team])"}, + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := buildZM(t, "# CODEOWNERS\n", c.tree, plan.Options{}, c.in...) + var inv *plan.InvalidError + if !errors.As(err, &inv) { + t.Fatalf("order-dependent batch must be invalid input (R-8), got %v", err) + } + if !strings.Contains(err.Error(), "R-8") { + t.Fatalf("refusal must cite R-8 (order dependence), got %q", err.Error()) + } + }) + } +} + +// TRAP 4, the other half — SPEC R-22: the order-dependence guard must not +// become "reject any batch containing a declare op". A fleet policy is mostly +// declare ops; a guard that refused all of them would make the feature +// unusable and push operators back to hand-editing 100 files. +func TestR22_CommutingDeclareBatchIsAccepted(t *testing.T) { + tree := []string{"README.md"} + + t.Run("disjoint scopes", func(t *testing.T) { + p, err := buildZM(t, "* @org/everyone\n", tree, plan.Options{}, + declareOp("add_owner(/terraform/, @org/infra)"), + declareOp("add_owner(/.github/workflows/, @org/ci)")) + if err != nil { + t.Fatalf("two declare ops on disjoint scopes must be accepted: %v", err) + } + if got := futureResolution(p.AfterContent, tree, "terraform/main.tf").Owners; !reflect.DeepEqual(got, []string{"@org/infra"}) { + t.Errorf("future terraform/main.tf = %v, want {@org/infra}", got) + } + if got := futureResolution(p.AfterContent, tree, ".github/workflows/ci.yml").Owners; !reflect.DeepEqual(got, []string{"@org/ci"}) { + t.Errorf("future .github/workflows/ci.yml = %v, want {@org/ci}", got) + } + }) + + // Two adds on the SAME scope commute as owner-set operations, so they must + // be accepted — but they cannot be written as two stacked lines: the second + // would shadow the first and the future path would resolve to one owner, + // not both. + t.Run("same scope, both owners survive", func(t *testing.T) { + p, err := buildZM(t, "* @org/everyone\n", tree, plan.Options{}, + declareOp("add_owner(/terraform/, @org/infra)"), + declareOp("add_owner(/terraform/, @org/sre)")) + if err != nil { + t.Fatalf("two commuting add_owner declares must be accepted: %v", err) + } + got := append([]string{}, futureResolution(p.AfterContent, tree, "terraform/main.tf").Owners...) + sort.Strings(got) + if !reflect.DeepEqual(got, []string{"@org/infra", "@org/sre"}) { + t.Errorf("future terraform/main.tf = %v, want {@org/infra, @org/sre} — a second stacked rule shadows the first", got) + } + }) +} + +// SPEC R-22: `declare` on set_owners, including the empty owner list. An +// empty list is a legal, deliberate un-owning (S-9): the rule keeps its +// pattern with zero owners, so a future matching file is explicitly NOT +// governed by whatever broader rule sits above it — the only way to say "this +// path is deliberately unowned" for paths that do not exist yet. +func TestR22_DeclareSetOwners(t *testing.T) { + content := "# CODEOWNERS\n* @org/everyone\n" + tree := []string{"README.md"} + + t.Run("explicit owner set", func(t *testing.T) { + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("set_owners(/terraform/, [@org/infra, @org/sre])")) + if err != nil { + t.Fatalf("declare set_owners must plan: %v", err) + } + if got, want := lastEmittedLine(t, p.AfterContent), "/terraform/ @org/infra @org/sre"; got != want { + t.Errorf("last line = %q, want %q", got, want) + } + got := append([]string{}, futureResolution(p.AfterContent, tree, "terraform/main.tf").Owners...) + sort.Strings(got) + if !reflect.DeepEqual(got, []string{"@org/infra", "@org/sre"}) { + t.Errorf("future terraform/main.tf = %v, want exactly {@org/infra, @org/sre}", got) + } + }) + + t.Run("empty owner set is a zero-owner rule (S-9)", func(t *testing.T) { + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("set_owners(/generated/, [])")) + if err != nil { + t.Fatalf("declare set_owners with an empty list must plan: %v", err) + } + if got, want := lastEmittedLine(t, p.AfterContent), "/generated/"; got != want { + t.Errorf("last line = %q, want %q — a pattern with no owners", got, want) + } + r := futureResolution(p.AfterContent, tree, "generated/api.pb.go") + if !r.Matched || len(r.Owners) != 0 { + t.Errorf("future generated/api.pb.go = %+v, want matched with zero owners — not a fallthrough to the catch-all", r) + } + }) +} + +// SPEC R-22: a declare op whose scope matches SOME tracked files is not a +// zero-match at all. It takes the ordinary path — same edits, same bytes, +// proven over the tree. +// +// `on_zero_match` describes what to do when the scope matches NOTHING. If +// setting it to `declare` changed how an op that does match is written, then +// adding the field to a policy would quietly alter the behavior of every op +// in it, and reviewers of the policy file would be reading the wrong thing. +func TestR22_DeclareWithMatchesIsTheOrdinaryPath(t *testing.T) { + content := "* @org/everyone\n/services/ @org/svc\n" + tree := []string{"services/api/main.go", "services/web/app.js", "README.md"} + spec := "add_owner(/services/api/, @org/api)" + + def, err := buildZM(t, content, tree, plan.Options{}, zmOp{spec: spec, id: "api"}) + if err != nil { + t.Fatalf("default-mode plan must succeed: %v", err) + } + dec, err := buildZM(t, content, tree, plan.Options{}, zmOp{spec: spec, mode: ops.ZeroMatchDeclare, id: "api"}) + if err != nil { + t.Fatalf("declare-mode plan must succeed: %v", err) + } + if dec.AfterContent != def.AfterContent { + t.Errorf("declare changed the bytes of an op that matches files:\n got %q\nwant %q", dec.AfterContent, def.AfterContent) + } + if !reflect.DeepEqual(dec.Changes, def.Changes) { + t.Errorf("declare changed the edits of an op that matches files:\n got %+v\nwant %+v", dec.Changes, def.Changes) + } + if got := opResultByID(t, dec, "api"); got.Status != "applied" || got.Proven != "tree" { + t.Errorf("api result = %+v, want applied/tree (INV-6 applies only to zero-match ops)", got) + } +} + +// SPEC R-22 (R-24): OpResults is one entry per op, in op order, carrying the +// op's id and text. +// +// The fleet record is the only artifact of an unattended run. "Which repos +// lack Terraform" is answerable only if each op reports itself, by id, in the +// order the policy lists them — a bare count cannot answer it, and a reordered +// list attributes the wrong outcome to the wrong op. +func TestZeroMatch_OpResultsAreInOpOrder(t *testing.T) { + content := "* @org/everyone\n" + tree := []string{"services/api/main.go", "docs/guide.md", "README.md"} + in := []zmOp{ + {spec: "add_owner(/services/api/, @org/api)", id: "api"}, + {spec: "add_owner(/terraform/, @org/infra)", mode: ops.ZeroMatchSkip, id: "tf"}, + {spec: "add_owner(/.github/workflows/, @org/ci)", mode: ops.ZeroMatchDeclare, id: "ci"}, + {spec: "add_owner(/docs/, @org/everyone)", id: "docs"}, + } + parsed := mkOps(t, in...) + p, err := plan.Build([]byte(content), tree, parsed, plan.Options{}) + if err != nil { + t.Fatalf("mixed batch must plan: %v", err) + } + want := []struct{ id, status string }{ + {"api", "applied"}, + {"tf", "skipped"}, + {"ci", "applied"}, + {"docs", "unchanged"}, // @org/everyone already owns /docs/ via the catch-all + } + if len(p.OpResults) != len(want) { + t.Fatalf("op_results = %+v, want %d entries in op order", p.OpResults, len(want)) + } + for i, w := range want { + got := p.OpResults[i] + if got.ID != w.id || got.Status != w.status { + t.Errorf("op_results[%d] = %+v, want id %q status %q", i, got, w.id, w.status) + } + if got.Op != parsed[i].Raw { + t.Errorf("op_results[%d].Op = %q, want the op text %q", i, got.Op, parsed[i].Raw) + } + } +} + +// SPEC R-22: several declare ops stack at EOF in POLICY order. +// +// Order is not cosmetic in a last-match-wins file: the order the lines land +// in is the order that decides overlaps for every file added later. It must +// be the order the policy author wrote and a reviewer read — not map order, +// which would differ run to run and make two repos with identical inputs +// produce different files. +func TestR22_MultipleDeclaresStackAtEOFInPolicyOrder(t *testing.T) { + content := "# CODEOWNERS\n* @org/everyone\n" + tree := []string{"README.md"} + + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci)"), + declareOp("add_owner(/terraform/, @org/infra)"), + declareOp("add_owner(/docs/, @org/docs)")) + if err != nil { + t.Fatalf("declare batch must plan: %v", err) + } + want := "# CODEOWNERS\n* @org/everyone\n" + + "/.github/workflows/ @org/ci\n/terraform/ @org/infra\n/docs/ @org/docs\n" + if p.AfterContent != want { + t.Errorf("after = %q, want %q", p.AfterContent, want) + } +} + +// SPEC R-22: a declare op whose rule would be byte-identical to a rule +// already in the file. +// +// Two cases with opposite answers, and the difference is precedence, not +// bytes. When the identical rule is already the last word, there is nothing +// to do (exit 1). When a later catch-all shadows it, the declaration is NOT +// satisfied — reporting "already correct" there is the silent no-op rollout +// that `skip` vs `declare` exists to distinguish, and it would read as 100 +// converged repos in the fleet summary. +func TestZeroMatch_DeclareByteIdenticalToAnExistingRule(t *testing.T) { + tree := []string{"README.md"} + spec := "add_owner(/terraform/, @org/infra)" + + t.Run("identical rule is already last", func(t *testing.T) { + _, err := buildZM(t, "* @org/everyone\n/terraform/ @org/infra\n", tree, + plan.Options{}, declareOp(spec)) + var noop *plan.NoOpError + if !errors.As(err, &noop) { + t.Fatalf("an already-declared rule must be a no-op (exit 1), got %v", err) + } + }) + + t.Run("identical rule is shadowed by a later catch-all", func(t *testing.T) { + p, err := buildZM(t, "/terraform/ @org/infra\n* @org/everyone\n", tree, + plan.Options{}, declareOp(spec)) + var noop *plan.NoOpError + var inv *plan.InvalidError + switch { + case errors.As(err, &noop): + t.Fatalf("a shadowed declaration is not satisfied; reporting a no-op hides a rollout that did nothing") + case errors.As(err, &inv): + t.Fatalf("a shadowed declaration is not invalid input, got %v", err) + case err != nil: + // A refusal (exit 2) is an acceptable answer: the only fix is a + // duplicate pattern, which R-7 treats as a defect to report. + default: + got := futureResolution(p.AfterContent, tree, "terraform/main.tf").Owners + if !reflect.DeepEqual(got, []string{"@org/infra"}) { + t.Errorf("future terraform/main.tf = %v, want {@org/infra} — the declared rule is still shadowed", got) + } + } + }) +} + +// SPEC R-22 (S-4): the size cap applies to declared lines too. A rule for +// files that do not exist yet is still bytes GitHub has to load, and past +// 3 MB GitHub silently ignores the whole file — every path in the repo loses +// its owners at once. A declare op is exactly the kind of op a fleet script +// runs on a schedule, so it is the likeliest way to cross the line. +func TestR22_DeclareRespectsTheSizeCap(t *testing.T) { + _, err := buildZM(t, "* @a\n", []string{"README.md"}, + plan.Options{MaxSize: 20}, + declareOp("add_owner(/.github/workflows/, @org/ci)")) + var ref *plan.RefusalError + if !errors.As(err, &ref) { + t.Fatalf("a declare pushing the file over the size cap must be refused (exit 2), got %v", err) + } +} + +// SPEC INV-6 / R-8 (regression, adversarial audit of Wave 1): the disjointness +// proof must not treat "/src**" as the directory "/src/". +// +// anchoredDirPrefix stripped the trailing "**" BEFORE testing for wildcards, so +// "/src**" normalized to "/src/" and patternsProvablyDisjoint("/src**", +// "/srcx/") answered TRUE. It is false: "/src**" compiles to +// `\Asrc[^/]*[^/]*(?:/.*)?\z` and matches srcx/a.go, which "/srcx/" matches too. +// A false disjointness proof is the one failure mode this package is built to +// exclude — it is a WRONG WRITE, not a missed one, and both reproducers below +// were accepted at exit 0 before the fix. +func TestINV6_TrailingStarStarIsNotADirectoryPrefix(t *testing.T) { + // Reproducer (a): the declaration is amended into a rule that a later + // "/src**" recaptures entirely, so a future srcx/a.go resolves to + // @org/legacy — while the tool reports the op applied and proven=structural. + t.Run("amend under a later /src** is refused", func(t *testing.T) { + content := "/srcx/ @org/old\n/src** @org/legacy\n" + tree := []string{"src/main.go"} + p, err := buildZM(t, content, tree, plan.Options{}, + declareOp("add_owner(/srcx/, @org/new)")) + var ref *plan.RefusalError + if !errors.As(err, &ref) { + after := "" + if p != nil { + after = p.AfterContent + } + t.Fatalf("declaring /srcx/ under a later /src** must be refused (exit 2): "+ + "/src** captures the whole declared scope, so future srcx/a.go resolves to @org/legacy. got err=%v, after=%q", + err, after) + } + }) + + // Reproducer (b): R-8's zero-match guard skipped the pair as "provably + // disjoint", so BOTH orderings were accepted and a future srcx/a.go resolved + // to whichever declare was listed second. + t.Run("order-dependent declare batch is refused in both orders", func(t *testing.T) { + srcx := declareOp("set_owners(/srcx/, [@org/a])") + starstar := declareOp("set_owners(/src**, [@org/b])") + for _, c := range []struct { + name string + in []zmOp + }{ + {"/srcx/ first", []zmOp{srcx, starstar}}, + {"/src** first", []zmOp{starstar, srcx}}, + } { + t.Run(c.name, func(t *testing.T) { + p, err := buildZM(t, "# empty\n", []string{"README.md"}, plan.Options{}, c.in...) + var inv *plan.InvalidError + if !errors.As(err, &inv) { + after := "" + if p != nil { + after = p.AfterContent + } + t.Fatalf("both declares can govern srcx/a.go and set_owners does not commute — "+ + "the batch is order-dependent and must be refused (R-8). got err=%v, after=%q", err, after) + } + if !strings.Contains(err.Error(), "R-8") { + t.Errorf("refusal must cite R-8 (order dependence), got %q", err.Error()) + } + }) + } + }) +} + +// SPEC INV-6 (third obligation): a scope this same policy declares LATER may +// overlap an earlier declared scope PARTIALLY — allowed, and disclosed. +// +// The canonical fleet baseline is "CI owns workflows everywhere, infra owns +// Terraform where it exists". Those scopes meet on .github/workflows/deploy.tf, +// and CODEOWNERS gives a path exactly one owner set, so one of them must lose +// there. Refusing the pair made that baseline inexpressible in EVERY repo, +// forever, and reported a policy-level conflict as an identical per-repo exit 2 +// on all 100 repos — the misclassification the exit-2/exit-3 split exists to +// prevent. The author wrote both in one document; its order is the precedence, +// exactly as in a hand-written file. R-7 sets the precedent: disclose the +// shadowing, do not refuse it. +// +// The plan suite never covered this before — TestR22_MultipleDeclaresStackAtEOF +// InPolicyOrder and TestR22_CommutingDeclareBatchIsAccepted both stack +// anchored, wildcard-free scopes, which are provably disjoint — so the +// overlapping-but-satisfiable case stayed invisible until the CLI wired it up. +func TestINV6_PartialOverlapBetweenSameBatchDeclaresIsAllowedAndDisclosed(t *testing.T) { + tree := []string{"README.md"} + p, err := buildZM(t, "* @org/base\n", tree, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci)"), + declareOp("add_owner(**/*.tf, @org/infra)"), + declareOp("add_owner(/charts/, @org/k8s)")) + if err != nil { + t.Fatalf("partially overlapping declares from one policy must be accepted: %v", err) + } + + want := "* @org/base\n/.github/workflows/ @org/ci\n**/*.tf @org/infra\n/charts/ @org/k8s\n" + if p.AfterContent != want { + t.Fatalf("after = %q, want %q — declares stack at EOF in policy order", p.AfterContent, want) + } + + // Every scope still governs something: none of these declarations is dead. + for _, c := range []struct { + path string + want []string + }{ + {".github/workflows/ci.yml", []string{"@org/ci"}}, + {"src/main.tf", []string{"@org/infra"}}, + {"charts/values.yaml", []string{"@org/k8s"}}, + // The intersections, decided by policy order: the later rule wins. + {".github/workflows/deploy.tf", []string{"@org/infra"}}, + {"charts/main.tf", []string{"@org/k8s"}}, + } { + if got := futureResolution(p.AfterContent, tree, c.path).Owners; !reflect.DeepEqual(got, c.want) { + t.Errorf("future %s = %v, want %v", c.path, got, c.want) + } + } + + // The disclosure is the whole price of the relaxation: silently writing an + // overlap a reviewer cannot see in the diff is what R-7 refuses to do. + found := "" + for _, w := range p.Warnings { + if strings.Contains(w, `"/.github/workflows/"`) && strings.Contains(w, `"**/*.tf"`) { + found = w + } + } + if found == "" { + t.Fatalf("overlapping declares must warn, naming both scopes; warnings = %#v", p.Warnings) + } + if !strings.Contains(found, "wins on every path both match") { + t.Errorf("the warning must say which scope wins on the intersection, got %q", found) + } + if !strings.Contains(found, "INV-6") { + t.Errorf("the warning must cite the rule it relaxes (INV-6), got %q", found) + } +} + +// SPEC INV-6 (third obligation): TOTAL capture by a same-batch declare is still +// a refusal. The relaxation above is only for overlaps that leave the declared +// rule the last word SOMEWHERE. A rule no path can ever reach is dead on +// arrival: the tool would report it applied with proven=structural, the diff +// would look right in review, and the declaration would never take effect — +// which is the entire failure mode this file exists to prevent. +func TestINV6_TotalShadowingBetweenSameBatchDeclaresIsRefused(t *testing.T) { + p, err := buildZM(t, "* @org/base\n", []string{"README.md"}, plan.Options{}, + declareOp("add_owner(/src/api/, @org/api)"), + declareOp("add_owner(/src/, @org/app)")) + var ref *plan.RefusalError + if !errors.As(err, &ref) { + after := "" + if p != nil { + after = p.AfterContent + } + t.Fatalf("/src/ captures /src/api/ entirely, so the /src/api/ declaration can never win for any path "+ + "and must be refused (INV-6). got err=%v, after=%q", err, after) + } + if !strings.Contains(err.Error(), "INV-6") { + t.Errorf("refusal must cite INV-6, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "/src/api/") || !strings.Contains(err.Error(), "/src/") { + t.Errorf("refusal must name both scopes so the author can reorder the policy, got %q", err.Error()) + } +} + +// SPEC INV-6 / R-1: a partial overlap with a PRE-EXISTING later rule is still a +// refusal. Same geometry as the allowed case above — "/.github/workflows/" +// against a later "**/*.tf" — and the opposite answer, because the difference is +// authority, not shape: R-1 forbids reordering lines this run did not write, so +// unlike a same-policy overlap there is no order the planner is entitled to +// choose. Accepting it would let the tool silently ratify whatever precedence +// the existing file happens to encode. +func TestINV6_PartialOverlapWithAPreexistingLaterRuleIsStillRefused(t *testing.T) { + content := "/.github/workflows/ @org/ci\n**/*.tf @org/legacy\n" + p, err := buildZM(t, content, []string{"README.md"}, plan.Options{}, + declareOp("add_owner(/.github/workflows/, @org/ci2)")) + var ref *plan.RefusalError + if !errors.As(err, &ref) { + after := "" + if p != nil { + after = p.AfterContent + } + t.Fatalf("the overlapping **/*.tf is not this policy's line to reorder (R-1), so the declare must be refused: "+ + "got err=%v, after=%q", err, after) + } + if !strings.Contains(err.Error(), "/.github/workflows/") { + t.Errorf("refusal must name the declared scope, got %q", err.Error()) + } +} diff --git a/internal/policy/jsonsrc.go b/internal/policy/jsonsrc.go new file mode 100644 index 0000000..c2307e7 --- /dev/null +++ b/internal/policy/jsonsrc.go @@ -0,0 +1,321 @@ +package policy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +// This file turns policy bytes into a located value tree: every value and every +// object key remembers the byte offset it came from. +// +// Why not `json.Unmarshal` into structs with DisallowUnknownFields, which is the +// obvious way to get strictness for free? Three independent reasons, each fatal +// on its own: +// +// 1. DisallowUnknownFields is a property of encoding/json's STRUCT decode path. +// The moment a type implements json.Unmarshaler — which the string-or-object +// `ops` element must, in that design — the decoder hands it raw bytes and the +// flag stops propagating. `on_zero_mtach` then sails through and applies the +// default zero-match policy to every repo in the fleet, which is the one +// failure the strictness rule exists to prevent (R-20). +// 2. It cannot coexist with the comment convention. Keys beginning with `_` and +// the key `//` are ignored at every level, because JSON has no comments and +// unknown fields are fatal; DisallowUnknownFields would reject `"_note"`. +// Ignoring some unknown keys and rejecting others is a per-level decision the +// decoder has no way to express. +// 3. It reports byte offsets, and only for some errors. Operators grep +// `policy.json:14:22` out of a 100-repo log at 2am; nobody can use a byte +// offset. +// +// Duplicate-key detection (below) needs a token-level pass regardless, since +// encoding/json silently keeps the LAST of two identical keys. Given that pass +// is mandatory, building the located tree in the same walk costs almost nothing +// and lets every later check report an exact position. + +// value kinds, spelled as the byte that opens them in the source where there is +// one. 'z' is null, which has no distinguishing punctuation. +const ( + kObject = '{' + kArray = '[' + kString = 's' + kNumber = 'n' + kBool = 'b' + kNull = 'z' +) + +// jsonValue is one JSON value plus where in the file it was written. +type jsonValue struct { + kind byte + off int // byte offset of the value's first byte + raw string // exact source text of the value, for echoing it back verbatim + str string // kString + num json.Number + // members is in source order. Duplicates are rejected before anything reads + // this, so first-wins here is never observable. + members []*member + elems []*jsonValue +} + +// member is one key/value pair, with the key's own offset — errors about a +// field name must point at the name, not at its value. +type member struct { + key string + keyOff int + val *jsonValue +} + +// field returns the member with the given key, or nil. +func (v *jsonValue) field(name string) *member { + if v == nil { + return nil + } + for _, m := range v.members { + if m.key == name { + return m + } + } + return nil +} + +// describe names a value the way a policy author would say it out loud. Scalars +// are echoed literally because seeing your own typo is what makes the message +// land; composites are named by type, because quoting a whole array back at +// someone is not a readable error. +func (v *jsonValue) describe() string { + switch v.kind { + case kString: + return fmt.Sprintf("the string %q", v.str) + case kNumber: + return "the number " + v.num.String() + case kBool: + return "the boolean " + v.raw + case kNull: + return "null" + case kArray: + return "an array" + case kObject: + return "an object" + } + return v.raw +} + +// show is describe's compact form, used where the surrounding sentence already +// supplies the grammar. +func (v *jsonValue) show() string { + switch v.kind { + case kArray: + return "an array" + case kObject: + return "an object" + } + return v.raw +} + +// scanner walks the token stream once, building the located tree and collecting +// duplicate keys as it goes. +type scanner struct { + src []byte + file string + dec *json.Decoder + dups []error +} + +// scanSource parses src into a located tree. +// +// A syntax error is returned alone and immediately: once the token stream is +// broken every subsequent problem is invented, and a wall of phantom errors is +// worse for the operator than the one real one. Duplicate keys come back +// separately because they are not syntax — the document parses — but they are +// structural enough that validating a tree with ambiguous values would report +// problems about whichever value happened to win. +func scanSource(src []byte, file string) (root *jsonValue, dups []error, syntax *Error) { + s := &scanner{src: src, file: file} + s.dec = json.NewDecoder(bytes.NewReader(src)) + // Without UseNumber, `1.5` and `1` both arrive as float64 and a fractional + // version becomes indistinguishable from a whole one. + s.dec.UseNumber() + + if len(bytes.TrimSpace(src)) == 0 { + return nil, nil, s.errAt(0, "the policy file is empty; a policy is at minimum {\"version\": 1, \"ops\": [...]}") + } + v, err := s.value() + if err != nil { + return nil, nil, s.syntaxError(err) + } + // Trailing content means two documents in one file — usually a generator that + // concatenated fragments without merging them. Silently reading the first + // would run half the policy. + if _, err := s.dec.Token(); !errors.Is(err, io.EOF) { + if err != nil { + return nil, nil, s.syntaxError(err) + } + return nil, nil, s.errAt(int(s.dec.InputOffset()), "unexpected content after the end of the policy object; a policy file holds exactly one JSON object") + } + if v.kind != kObject { + return nil, nil, s.errAt(v.off, "the top-level value must be a JSON object, got %s; a policy file starts with `{` and sets \"version\" and \"ops\"", v.describe()) + } + return v, s.dups, nil +} + +// value consumes exactly one JSON value, recording where it started. +func (s *scanner) value() (*jsonValue, error) { + start := s.valueStart() + tok, err := s.dec.Token() + if err != nil { + return nil, err + } + v := &jsonValue{off: start} + switch t := tok.(type) { + case json.Delim: + switch t { + case '{': + v.kind = kObject + err = s.object(v) + case '[': + v.kind = kArray + err = s.array(v) + default: + // A closing delimiter can only arrive here if object/array below + // mis-tracked its own extent, which would be a bug in this file. + err = fmt.Errorf("unexpected %q", t) + } + if err != nil { + return nil, err + } + case string: + v.kind, v.str = kString, t + case json.Number: + v.kind, v.num = kNumber, t + case bool: + // The value itself is never read: describe echoes booleans from raw, + // and no policy field is a boolean, so the kind alone carries the + // rejection. + v.kind = kBool + case nil: + v.kind = kNull + } + v.raw = string(s.src[start:min(int(s.dec.InputOffset()), len(s.src))]) + return v, nil +} + +func (s *scanner) object(v *jsonValue) error { + seen := make(map[string]*member) + for s.dec.More() { + keyOff := s.valueStart() + tok, err := s.dec.Token() + if err != nil { + return err + } + key, ok := tok.(string) + if !ok { + return fmt.Errorf("object key is not a string") + } + val, err := s.value() + if err != nil { + return err + } + m := &member{key: key, keyOff: keyOff, val: val} + if prev, dup := seen[key]; dup { + // encoding/json would keep the LAST of these without a word. A + // generator concatenating fragments produces exactly this, and it is + // invisible in review because both lines are individually correct — + // so the message has to show BOTH values, not just name the key. + s.dups = append(s.dups, s.errAt(keyOff, + "duplicate key %q: this object sets it twice, first to %s and then to %s; one of the two would be discarded silently and the file gives no sign which", + key, prev.val.show(), val.show())) + continue + } + seen[key] = m + v.members = append(v.members, m) + } + _, err := s.dec.Token() // the closing '}' + return err +} + +func (s *scanner) array(v *jsonValue) error { + for s.dec.More() { + e, err := s.value() + if err != nil { + return err + } + v.elems = append(v.elems, e) + } + _, err := s.dec.Token() // the closing ']' + return err +} + +// valueStart finds the first byte of the value (or key) that comes next. +// +// InputOffset marks the END of the token just returned, so the bytes between it +// and the next value are structural: whitespace, the `:` after a key, the `,` +// between elements. No JSON value can begin with any of them, so skipping them +// lands exactly on the first byte the author typed. +func (s *scanner) valueStart() int { + i := int(s.dec.InputOffset()) + for i < len(s.src) { + switch s.src[i] { + case ' ', '\t', '\r', '\n', ':', ',': + i++ + default: + return i + } + } + return i +} + +// syntaxError converts a decoder failure into a located policy error. +// +// encoding/json's syntax messages ("invalid character ']' looking for beginning +// of value") describe the input rather than Go's types, so they survive UX rule +// 5 and are genuinely the most precise thing available. What does not survive is +// the byte offset, which is why every branch here resolves one. +func (s *scanner) syntaxError(err error) *Error { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return s.errAt(len(s.src), "the policy file ends in the middle of a value; a bracket or brace is unclosed") + } + var se *json.SyntaxError + if errors.As(err, &se) { + // SyntaxError.Offset counts bytes CONSUMED, so the offending byte is the + // one before it. + return s.errAt(int(se.Offset)-1, "invalid JSON: %s", se.Error()) + } + return s.errAt(int(s.dec.InputOffset()), "invalid JSON: %s", err.Error()) +} + +func (s *scanner) errAt(off int, format string, args ...any) *Error { + line, col := lineCol(s.src, off) + return &Error{File: s.file, Line: line, Col: col, OpIndex: -1, Msg: fmt.Sprintf(format, args...)} +} + +// lineCol converts a byte offset into the 1-based line and column an operator +// can act on. encoding/json deals only in offsets; leaving them unconverted is +// the whole file:line:col feature failing quietly, and it shows up as `Line: 0`. +func lineCol(src []byte, off int) (line, col int) { + if off < 0 { + off = 0 + } + if off > len(src) { + off = len(src) + } + line, lastNL := 1, -1 + for i := 0; i < off; i++ { + if src[i] == '\n' { + line++ + lastNL = i + } + } + return line, off - lastNL +} + +// ignoredKey reports whether a key is a comment rather than a field. +// +// JSON has no comments and unknown fields are fatal, so without this escape +// hatch the universal `"_comment"` convention would be ILLEGAL in a policy file. +// It does not weaken typo detection: `on_zero_mtach` does not start with `_`. +func ignoredKey(k string) bool { + return k == "//" || strings.HasPrefix(k, "_") +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..fc434fd --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,555 @@ +// Package policy parses the JSON policy file: the unit of review for a fleet +// rollout (R-20). +// +// A policy is the complete, version-controlled statement of what ran across N +// repositories, so it is validated strictly. Unknown fields, bad enum values, +// and duplicate keys are hard errors — a typo'd `on_zero_mtach` that silently +// fell back to the default would apply the wrong policy to every repo at once. +// +// Syntax errors fail fast; semantic errors accumulate, because fixing a +// generated 40-op policy one error per run is miserable. +package policy + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/jordonpeterson/codeowners-tool/internal/ops" +) + +// Version is the only policy format version this binary understands. +const Version = 1 + +// The field sets are PER LEVEL, not one merged bag. `description` is legal at +// the top and meaningless on an op; accepting it in both places would let a +// generator put the policy's description on op 17 and nothing would notice. +var ( + topFields = []string{"version", "name", "description", "on_empty", "ops"} + opFields = []string{"op", "id", "on_zero_match", "note"} +) + +// The two enums, in the order the docs present them. Alphabetizing a legal set +// in an error message quietly re-ranks it; `require` is the default and belongs +// first. +var ( + onEmptyValues = []string{"error", "inherit", "unowned"} + zeroMatchValues = []string{ops.ZeroMatchRequire, ops.ZeroMatchSkip, ops.ZeroMatchDeclare} +) + +// Names from revision 1 of the design. A policy generated against the older doc +// fails on a name that no longer exists anywhere, so the message has to carry +// the rename itself — otherwise the operator's only route to the answer is the +// changelog. +var ( + renamedOpFields = map[string]string{"when_absent": "on_zero_match"} + renamedZeroMatch = map[string]string{ + "error": ops.ZeroMatchRequire, + "write": ops.ZeroMatchDeclare, + } +) + +// Policy is a parsed, validated policy file. +type Policy struct { + Version int + Name string + Description string + OnEmpty string // "" | error | inherit | unowned + Ops []ops.Op + Notes map[string]string // op ID -> note +} + +// Error is one located problem in a policy file. +type Error struct { + File string + Line int // 1-based; 0 when not locatable + Col int // 1-based; 0 when not locatable + OpIndex int // -1 when not op-scoped + OpID string + Msg string +} + +func (e *Error) Error() string { + var b strings.Builder + if e.File != "" { + b.WriteString(e.File) + if e.Line > 0 { + fmt.Fprintf(&b, ":%d", e.Line) + if e.Col > 0 { + fmt.Fprintf(&b, ":%d", e.Col) + } + } + b.WriteString(": ") + } + if e.OpIndex >= 0 { + fmt.Fprintf(&b, "ops[%d]", e.OpIndex) + if e.OpID != "" { + fmt.Fprintf(&b, " (id %q)", e.OpID) + } + b.WriteString(": ") + } + b.WriteString(e.Msg) + return b.String() +} + +// MultiError carries every semantic problem found in one pass. +// +// It prints the problems and nothing else — no header, no "fix the policy, do +// not retry" advice. That advice belongs to whichever command is reporting, not +// to the parse result, and a caller that wants to render one problem per line +// needs Error() to be exactly that. +type MultiError struct{ Errs []error } + +func (m *MultiError) Error() string { + parts := make([]string, len(m.Errs)) + for i, e := range m.Errs { + parts[i] = e.Error() + } + return strings.Join(parts, "\n") +} + +// Parse decodes and fully validates src. filename appears in error messages. +// +// Syntax and duplicate-key problems return immediately: once the token stream is +// broken, or a key's value is ambiguous, every later finding is about a document +// nobody wrote. Everything after that accumulates, so one run of `check` reports +// every problem in a generated policy rather than the first. +func Parse(src []byte, filename string) (*Policy, error) { + root, dups, syntax := scanSource(src, filename) + if syntax != nil { + return nil, syntax + } + if len(dups) > 0 { + return nil, &MultiError{Errs: dups} + } + v := &validator{file: filename, src: src} + p := v.policy(root) + if len(v.errs) > 0 { + // Report in file order. The checks run in passes — every op individually, + // then the cross-op ones — so ops[4]'s problem can otherwise print before + // ops[2]'s. An operator fixing a 40-op policy works down the file, and a + // list that jumps around costs them a second pass over it. + sort.SliceStable(v.errs, func(i, j int) bool { + a, b := v.errs[i], v.errs[j] + if a.Line != b.Line { + return a.Line < b.Line + } + return a.Col < b.Col + }) + errs := make([]error, len(v.errs)) + for i, e := range v.errs { + errs[i] = e + } + return nil, &MultiError{Errs: errs} + } + return p, nil +} + +// Load is Parse over a file on disk. +// +// The OS error stays wrapped so callers can errors.Is it: the fleet script's +// first line is `check --policy`, and a typo'd path must be distinguishable from +// a malformed file — one is a shell mistake, the other sends someone to a +// generator. +func Load(path string) (*Policy, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read policy file: %w", err) + } + return Parse(src, path) +} + +// validator accumulates located problems over one document. +type validator struct { + file string + src []byte + errs []*Error +} + +// at records one problem. opIndex is -1 when the problem is not op-scoped. +func (v *validator) at(off, opIndex int, opID, format string, args ...any) { + line, col := lineCol(v.src, off) + v.errs = append(v.errs, &Error{ + File: v.file, + Line: line, + Col: col, + OpIndex: opIndex, + OpID: opID, + Msg: fmt.Sprintf(format, args...), + }) +} + +// opInfo is what the cross-op checks need after each op has been validated +// individually. It is kept separate from Policy.Ops because an op whose string +// failed to parse still has an index and an id that later errors must name. +type opInfo struct { + index int + id string + idOff int + off int + kind ops.Kind + parsed bool +} + +func (v *validator) policy(root *jsonValue) *Policy { + p := &Policy{} + + // Unknown keys are reported where they appear; known ones are collected and + // then validated in a fixed order, so the error list reads the same way twice + // for the same file. Duplicates were already rejected, so one member per key. + fields := make(map[string]*member, len(root.members)) + for _, m := range root.members { + if ignoredKey(m.key) { + continue + } + if !contains(topFields, m.key) { + v.unknownTopField(m) + continue + } + fields[m.key] = m + } + + v.version(p, fields["version"], root.off) + p.Name = v.optString(fields["name"], "name", -1, "") + p.Description = v.optString(fields["description"], "description", -1, "") + v.onEmpty(p, fields["on_empty"]) + infos := v.opsArray(p, fields["ops"], root.off) + v.checkDuplicateIDs(infos) + v.checkOnEmptyRequired(fields["on_empty"] != nil, infos) + return p +} + +// version is required rather than optional-defaulting-to-1: a strict format read +// by pinned binaries across a fleet, with no version marker, is a corner with no +// way out. Absence and `0` are therefore the same rejection — if `"version": 0` +// were accepted it would be indistinguishable from a file that never had one. +// +// A version this binary does not implement and a version that is nonsense are +// two different jobs for the operator: upgrade the tool, or go fix the +// generator. The verdict rides on the two NUMBERS the message names; a +// malformed version has no such pair, so it must never send anyone to upgrade +// over a stray quote mark. +func (v *validator) version(p *Policy, m *member, rootOff int) { + if m == nil { + v.at(rootOff, -1, "", `missing required field "version"; add "version": %d — the marker is required, not defaulted, so a pinned binary always knows which format it is reading`, Version) + return + } + val := m.val + if val.kind != kNumber { + v.at(val.off, -1, "", `field "version" must be a number, got %s`, val.describe()) + return + } + n, err := strconv.Atoi(val.num.String()) + switch { + case err != nil: + v.at(val.off, -1, "", `field "version" must be a whole number, got %s`, val.raw) + case n == 0: + v.at(val.off, -1, "", `field "version" must be a positive integer, got 0; 0 is what an absent field decodes to, so it cannot also name a real format version`) + case n < 0: + v.at(val.off, -1, "", `field "version" must be a positive integer, got %s`, val.raw) + case n > Version: + v.at(val.off, -1, "", `policy version %d is newer than this binary understands; this build implements policy version %d, so upgrade codeowners-tool or regenerate the file at version %d`, n, Version, Version) + default: + p.Version = n + } +} + +// optString reads an optional string field, reporting the JSON type it actually +// found rather than letting the decoder name a Go type (UX rule 5). +func (v *validator) optString(m *member, name string, opIndex int, opID string) string { + if m == nil { + return "" + } + if m.val.kind != kString { + v.at(m.val.off, opIndex, opID, `field %q must be a string, got %s`, name, m.val.describe()) + return "" + } + return m.val.str +} + +// onEmpty validates R-6's policy at LOAD time. plan.go only reaches "unknown +// --on-empty policy" when a removal actually empties an owner set, so a policy +// saying "inhrit" passes review, works on 46 repos, and blows up on repo 47 — +// precisely the repo-47 surprise `check` exists to turn into a repo-0 one. +func (v *validator) onEmpty(p *Policy, m *member) { + if m == nil { + return + } + val := m.val + switch { + case val.kind != kString: + v.at(val.off, -1, "", `field "on_empty" must be a string, got %s; legal values are %s`, val.describe(), list(onEmptyValues)) + case val.str == "": + // Present-and-empty is not "unset". An absent on_empty says the policy + // makes no removal that could empty an owner set; "" says nothing at all, + // while reading to a human reviewer as though a choice was made. + v.at(val.off, -1, "", `field "on_empty" is present but empty; an absent on_empty and an empty one are different states of the file, and "" declares nothing — legal values are %s`, list(onEmptyValues)) + case !contains(onEmptyValues, val.str): + v.at(val.off, -1, "", `field "on_empty" has unknown value %q%s; legal values are %s`, val.str, hint(val.str, onEmptyValues), list(onEmptyValues)) + default: + p.OnEmpty = val.str + } +} + +func (v *validator) opsArray(p *Policy, m *member, rootOff int) []opInfo { + if m == nil { + v.at(rootOff, -1, "", `missing required field "ops"; a policy with no ops does nothing on every repo and exits 0 on all of them, which is the silent success this format exists to make impossible`) + return nil + } + val := m.val + switch { + case val.kind != kArray: + // A single op string here is the plausible generator mistake, and reading + // it as one op would be a helpful guess — the exact behavior a strict + // format forbids. + v.at(val.off, -1, "", `field "ops" must be an array, got %s; even one op is written as a one-element array`, val.describe()) + return nil + case len(val.elems) == 0: + v.at(val.off, -1, "", `field "ops" is empty; an empty array is what a generator emits when its query returned nothing, and it would report success on every repo having changed none of them`) + return nil + } + infos := make([]opInfo, 0, len(val.elems)) + for i, e := range val.elems { + infos = append(infos, v.op(p, i, e)) + } + return infos +} + +// op validates one entry of the ops array. +// +// A bare string is SHORTHAND for {"op": ""} with every other field at its +// default — not a second form with its own code path. Both land in the same +// ops.Parse call and the same defaults below; if they ever diverged, a reviewer +// reading a mixed ops array could not tell what runs. +func (v *validator) op(p *Policy, i int, e *jsonValue) opInfo { + info := opInfo{index: i, off: e.off, idOff: e.off} + + spec, specOff := "", e.off + var zeroM *member + note := "" + + switch e.kind { + case kString: + spec = e.str + case kObject: + // `id` is resolved before anything else so every other error about this + // op can name it. An op with no id keeps ID "": `ops[N]` is a display + // label the renderer computes from the position, and storing it would + // make an unnamed op indistinguishable from one deliberately named + // "ops[0]", keyed by a name that shifts the moment somebody inserts an op + // above it. + if m := e.field("id"); m != nil { + info.idOff = m.val.off + info.id = v.optString(m, "id", i, "") + } + var opM *member + for _, m := range e.members { + if ignoredKey(m.key) { + continue + } + switch m.key { + case "op": + opM = m + case "id": + // handled above, before the loop + case "on_zero_match": + zeroM = m + case "note": + note = v.optString(m, "note", i, info.id) + default: + v.unknownOpField(m, i, info.id) + } + } + if opM == nil { + v.at(e.off, i, info.id, `missing required field "op"; an object carrying only id and note describes nothing, and would put a phantom entry in every repo's per-op results`) + return info + } + if opM.val.kind != kString { + v.at(opM.val.off, i, info.id, `field "op" must be a string holding one op, like "add_owner(/x/, @a)", got %s`, opM.val.describe()) + return info + } + spec, specOff = opM.val.str, opM.val.off + default: + // Dispatch is on the JSON type and nothing else. A number or null read as + // a zero-value op would run an empty op against the whole fleet. + v.at(e.off, i, "", `an op must be a string like "add_owner(/x/, @a)" or an object like {"op": "add_owner(/x/, @a)"}, got %s`, e.describe()) + return info + } + + zero, zeroOK := "", true + if zeroM != nil { + zero, zeroOK = v.zeroMatch(zeroM, i, info.id) + } + + parsed, err := ops.Parse(spec) + if err != nil { + // Letting ops.Parse's own text out would lose the file, the line, and the + // op index: the operator gets `unknown op "add_ownr"` with no idea which + // of 40 ops in which of 100 repos. + v.at(specOff, i, info.id, `op %q is not valid: %v`, spec, err) + return info + } + info.kind, info.parsed = parsed.Kind, true + + if zeroM != nil { + v.checkZeroMatchLegality(zeroM, parsed.Kind, zero, i, info.id) + } + + parsed.ID = info.id + if zeroOK { + parsed.OnZeroMatch = zero + } + p.Ops = append(p.Ops, parsed) + + if note != "" { + if p.Notes == nil { + p.Notes = make(map[string]string) + } + // Notes are keyed by the same label the renderer computes, so a note + // never goes missing just because nobody named its op. + p.Notes[OpLabel(info.id, i)] = note + } + return info +} + +// zeroMatch validates the R-21 enum. It reports whether the value is usable, so +// a rejected value is never written onto the op — an op that carried "SKIP" +// through to the planner would run the default under a spelling that says +// otherwise. +func (v *validator) zeroMatch(m *member, i int, id string) (string, bool) { + val := m.val + switch { + case val.kind != kString: + v.at(val.off, i, id, `field "on_zero_match" must be a string, got %s; legal values are %s`, val.describe(), list(zeroMatchValues)) + case val.str == "": + // An ABSENT on_zero_match means require; a PRESENT and empty one is an + // error. A generator that emitted "" where it meant to emit a decision + // produced a file that reads, to a reviewer, as though a choice was made + // — accepting it applies the default across the fleet under a spelling + // that says otherwise. + v.at(val.off, i, id, `field "on_zero_match" is present but empty; omitting the field means %q, while "" states no decision at all — legal values are %s`, + ops.ZeroMatchRequire, list(zeroMatchValues)) + case !contains(zeroMatchValues, val.str): + v.at(val.off, i, id, `field "on_zero_match" has unknown value %q%s; legal values are %s`, + val.str, zeroMatchHint(val.str), list(zeroMatchValues)) + default: + return val.str, true + } + return "", false +} + +// checkZeroMatchLegality enforces the legality table: whether an op may carry +// on_zero_match at all depends only on the op KIND, which is a repo-independent +// fact and therefore a policy error — caught on repo 0 rather than repo 47. +// Accepting the field and ignoring it is the same class of failure as the typo. +func (v *validator) checkZeroMatchLegality(m *member, kind ops.Kind, zero string, i int, id string) { + switch kind { + case ops.RenameOwner: + // rename_owner's scope comes from current ownership rather than a + // pattern, so plan.go exempts it from R-5 entirely and zero-match can + // never fire on it. + v.at(m.val.off, i, id, `field "on_zero_match" is not meaningful on rename_owner: its scope is derived from current ownership rather than from a pattern, so it can never match zero files — remove the field`) + case ops.RemoveOwner: + if zero == ops.ZeroMatchDeclare { + v.at(m.val.off, i, id, `"declare" is not meaningful on remove_owner: declare exists to write a rule for files that do not exist yet, and there is no rule that declares the absence of an owner — use %q or %q`, + ops.ZeroMatchRequire, ops.ZeroMatchSkip) + } + } +} + +// checkDuplicateIDs rejects two ops sharing an id. Per-op results are keyed by +// id — the summary a reviewer reads, and whatever a fleet script pipes into jq — +// so a repeated id silently overwrites one op's outcome with another's, and the +// reviewer sees a policy that ran N ops report N-1 results. +func (v *validator) checkDuplicateIDs(infos []opInfo) { + firstAt := make(map[string]int, len(infos)) + for _, info := range infos { + if info.id == "" { + // The empty id is the ABSENCE of a name, not a name two ops share; + // unnamed ops are referred to by position and never collide. + continue + } + if prev, dup := firstAt[info.id]; dup { + v.at(info.idOff, info.index, info.id, `duplicate op id %q, already used by ops[%d]; per-op results are keyed by id, so one op's outcome would silently overwrite the other's`, info.id, prev) + continue + } + firstAt[info.id] = info.index + } +} + +// checkOnEmptyRequired makes R-6's question a load-time one. Without it, "what +// happens when a removal empties an owner set?" is answered lazily on whichever +// repo first hits it — and a policy with no removals must not be forced to +// answer a question it never asks. +func (v *validator) checkOnEmptyRequired(present bool, infos []opInfo) { + if present { + return + } + for _, info := range infos { + if info.parsed && info.kind == ops.RemoveOwner { + v.at(info.off, info.index, info.id, `this op is a remove_owner, so the policy must set a top-level "on_empty" (%s); leaving it unset settles R-6 lazily, on whichever repo first has a removal empty an owner set`, list(onEmptyValues)) + return + } + } +} + +func (v *validator) unknownTopField(m *member) { + if contains(opFields, m.key) { + v.at(m.keyOff, -1, "", `unknown field %q at the top level: %q belongs to an individual op, not to the policy; the top level accepts %s`, + m.key, m.key, list(topFields)) + return + } + v.at(m.keyOff, -1, "", `unknown field %q%s; the top level of a policy accepts %s`, m.key, hint(m.key, topFields), list(topFields)) +} + +func (v *validator) unknownOpField(m *member, i int, id string) { + if to, renamed := renamedOpFields[m.key]; renamed { + v.at(m.keyOff, i, id, `unknown field %q; it was renamed to %q — an op accepts %s`, m.key, to, list(opFields)) + return + } + if contains(topFields, m.key) { + v.at(m.keyOff, i, id, `unknown field %q on an op: %q is a top-level policy field, and the field sets are per level rather than one merged bag; an op accepts %s`, + m.key, m.key, list(opFields)) + return + } + v.at(m.keyOff, i, id, `unknown field %q%s; an op accepts %s`, m.key, hint(m.key, opFields), list(opFields)) +} + +// zeroMatchHint prefers the revision-1 rename over a spelling guess: `write` is +// two edits from nothing in the current set, but it is exactly what the old +// design called `declare`, and a policy written against those docs is a likelier +// explanation than a typo. +func zeroMatchHint(bad string) string { + if to, renamed := renamedZeroMatch[bad]; renamed { + return fmt.Sprintf(" (revision 1 spelled this %q; it is now %q)", bad, to) + } + return hint(bad, zeroMatchValues) +} + +// OpLabel is the name one op is filed and displayed under: its policy id, or +// its POSITION when it has none (D2). `ops[N]` is a computed label and never a +// value stored in Op.ID — storing it would make an unnamed op indistinguishable +// from one deliberately named "ops[0]", keyed by a name that shifts the moment +// somebody inserts an op above it. +// +// Both sides of the note lookup go through here. Notes are recorded under this +// label at parse time and read back under it when the PR body is rendered, so a +// second spelling of "ops[N]" anywhere would make notes silently stop appearing +// while every test on either side still passed. +func OpLabel(id string, index int) string { + if id != "" { + return id + } + return fmt.Sprintf("ops[%d]", index) +} + +func contains(set []string, s string) bool { + for _, x := range set { + if x == s { + return true + } + } + return false +} diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..6bd9eac --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,1078 @@ +// Package policy_test encodes the acceptance tests for the policy file. +// +// The policy file is the unit of review for a fleet rollout (R-20): one +// artifact in git that states what ran across N repositories. That makes it +// the one place where a silent default is unaffordable — a typo'd +// `on_zero_mtach` that fell back to the default would apply the WRONG policy +// to every repo at once, and nothing downstream would notice. +// +// So the contract is: +// +// Unknown fields, bad enum values, and duplicate JSON keys are HARD errors. +// Syntax errors fail fast; semantic errors accumulate into *MultiError. +// Every error carries file:line:col and names the op by index and id. +// Go's own decoder messages never reach the operator. +// +// Per-op zero-match (R-21) is validated here too, because whether an op may +// carry `on_zero_match` at all depends only on the op kind — a repo-independent +// fact, and therefore a policy error, caught on repo 0 rather than repo 47. +package policy_test + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/jordonpeterson/codeowners-tool/internal/ops" + "github.com/jordonpeterson/codeowners-tool/internal/policy" +) + +const testFile = "policy.json" + +func mustParse(t *testing.T, src string) *policy.Policy { + t.Helper() + p, err := policy.Parse([]byte(src), testFile) + if err != nil { + t.Fatalf("policy must parse, got error:\n%v\nsource:\n%s", err, src) + } + if p == nil { + t.Fatal("Parse returned (nil, nil)") + } + return p +} + +func mustReject(t *testing.T, src string) error { + t.Helper() + p, err := policy.Parse([]byte(src), testFile) + if err == nil { + t.Fatalf("policy must be rejected, got %+v for source:\n%s", p, src) + } + return err +} + +// flatten expands a *MultiError so a test can count and inspect the individual +// problems rather than string-matching one blob. +func flatten(err error) []error { + var multi *policy.MultiError + if errors.As(err, &multi) { + var out []error + for _, e := range multi.Errs { + out = append(out, flatten(e)...) + } + return out + } + return []error{err} +} + +// located returns every problem that carries position information. +func located(err error) []*policy.Error { + var out []*policy.Error + for _, e := range flatten(err) { + var pe *policy.Error + if errors.As(e, &pe) { + out = append(out, pe) + } + } + return out +} + +// assertMentions fails unless every wanted fragment appears in the error text. +func assertMentions(t *testing.T, err error, want ...string) { + t.Helper() + got := err.Error() + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("error must mention %q; got:\n%s", w, got) + } + } +} + +// assertMentionsAny fails unless at least one wanted fragment appears. Used +// where the message must SHOW the operator what it found but the natural +// rendering differs by JSON type — a literal for scalars, a type name for a +// composite. Pinning one of those spellings would be pinning wording. +func assertMentionsAny(t *testing.T, err error, want ...string) { + t.Helper() + got := err.Error() + for _, w := range want { + if strings.Contains(got, w) { + return + } + } + t.Errorf("error must show the offending value, as one of %q; got:\n%s", want, got) +} + +// findLocated returns the first problem satisfying match. Tests search the +// error set instead of indexing it: an accumulating *MultiError makes no +// promise about WHICH problem prints first, and a test that indexes locs[0] +// turns any future reordering of the validation passes into a false failure. +func findLocated(err error, match func(*policy.Error) bool) (*policy.Error, bool) { + for _, e := range located(err) { + if match(e) { + return e, true + } + } + return nil, false +} + +// readsAsFutureVersion reports whether an error tells the operator that their +// BINARY is behind the file, rather than that the file is wrong. The vocabulary +// is a set rather than one word so that a rewritten message keeps passing; it +// is checked in the negative for malformed versions, where a broad set makes +// the guard stricter, and in the positive alongside the version numbers +// themselves, which carry the actual meaning. +func readsAsFutureVersion(err error) bool { + got := strings.ToLower(err.Error()) + for _, phrase := range []string{"newer", "too new", "future", "upgrade"} { + if strings.Contains(got, phrase) { + return true + } + } + return false +} + +// assertNoGoInternals enforces UX rule 5: Go's default decoder message names Go +// types and points at the wrong concept, so it must never reach the operator. +func assertNoGoInternals(t *testing.T, err error) { + t.Helper() + got := err.Error() + for _, bad := range []string{"cannot unmarshal", "Go value of type", "encoding/json"} { + if strings.Contains(got, bad) { + t.Errorf("Go's own decoder message escaped (contains %q):\n%s", bad, got) + } + } +} + +// SPEC R-20: the smallest policy anyone would write by hand — a version and a +// list of op strings — is accepted exactly as written, and every field the +// author did not mention stays at its default rather than acquiring one. +// +// This is step two of the escalation the README promises: run one operation +// with --op, decide you want to keep it, paste it into a file. If the minimal +// file needs a name, a description, or a per-op object before the tool will +// take it, that promise breaks at the moment an operator first tries to save +// their work — and a policy file nobody can write by hand is a policy file +// nobody reviews. +func TestR20_MinimalPolicyParses(t *testing.T) { + p := mustParse(t, `{ + "version": 1, + "ops": [ + "add_owner(/services/api/, @org/api-team)", + "add_owner(/.github/workflows/, @org/ci)" + ] +}`) + if p.Version != policy.Version { + t.Errorf("Version = %d, want %d", p.Version, policy.Version) + } + if len(p.Ops) != 2 { + t.Fatalf("Ops = %+v, want 2 ops", p.Ops) + } + if p.Ops[0].Kind != ops.AddOwner || p.Ops[0].Scope != "/services/api/" || p.Ops[0].Owner != "@org/api-team" { + t.Errorf("Ops[0] = %+v, want the parsed add_owner", p.Ops[0]) + } + if p.OnEmpty != "" || p.Name != "" || p.Description != "" { + t.Errorf("optional fields must stay at their zero value, got %+v", p) + } +} + +// SPEC R-20: a bare string is SHORTHAND for {"op": ""} with every other +// field at its default — not a second form with its own code path. If the two +// ever diverge, a reviewer reading a mixed ops array cannot tell what runs. +// +// "Every other field at its default" includes the id, which stays EMPTY. The +// `ops[0]` form that appears in error messages and results is a display label +// the renderer computes from the position; storing it in the field instead +// would make an unnamed op indistinguishable from one a policy author +// deliberately named "ops[0]", and would key that op's fleet-wide results by a +// name that shifts the moment somebody inserts an op above it. +func TestR20_BareStringIsExactlyObjectShorthand(t *testing.T) { + const spec = "add_owner(/x/, @a)" + bare := mustParse(t, `{"version":1,"ops":["`+spec+`"]}`) + object := mustParse(t, `{"version":1,"ops":[{"op":"`+spec+`"}]}`) + if !reflect.DeepEqual(bare.Ops, object.Ops) { + t.Errorf("shorthand and object form must be identical:\n bare = %+v\n obj = %+v", bare.Ops, object.Ops) + } + // ...and both must equal what --op would have produced, plus defaults. + direct, err := ops.Parse(spec) + if err != nil { + t.Fatal(err) + } + got := bare.Ops[0] + if got.Kind != direct.Kind || got.Scope != direct.Scope || got.Owner != direct.Owner || got.Raw != direct.Raw { + t.Errorf("policy op = %+v, want the same parse as --op: %+v", got, direct) + } + if got.OnZeroMatch != "" { + t.Errorf("OnZeroMatch = %q, want \"\" — the zero value must mean require, so shorthand preserves R-5 exactly", got.OnZeroMatch) + } + if got.ID != "" { + t.Errorf("ID = %q, want \"\" — `ops[0]` is a display label, never a stored value", got.ID) + } + if object.Ops[0].ID != "" { + t.Errorf("object-form ID = %q, want \"\" — omitting `id` must not synthesize one either", object.Ops[0].ID) + } +} + +// SPEC R-20: both forms are legal in the same ops array, in any order, and +// order is preserved (R-8 conflict detection downstream depends on it). +func TestR20_ShorthandAndObjectFormsMix(t *testing.T) { + p := mustParse(t, `{ + "version": 1, + "ops": [ + "add_owner(/services/api/, @org/api-team)", + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip" }, + "add_owner(/docs/, @org/docs)" + ] +}`) + if len(p.Ops) != 3 { + t.Fatalf("Ops = %+v, want 3", p.Ops) + } + wantScopes := []string{"/services/api/", "**/*.tf", "/docs/"} + for i, want := range wantScopes { + if p.Ops[i].Scope != want { + t.Errorf("Ops[%d].Scope = %q, want %q — order must be preserved", i, p.Ops[i].Scope, want) + } + } + if p.Ops[1].ID != "tf" || p.Ops[1].OnZeroMatch != ops.ZeroMatchSkip { + t.Errorf("Ops[1] = %+v, want id=tf on_zero_match=skip", p.Ops[1]) + } + if p.Ops[0].OnZeroMatch != "" || p.Ops[2].OnZeroMatch != "" { + t.Error("an object-form neighbour must not leak its settings onto the shorthand ops") + } +} + +// SPEC R-20: every optional field round-trips. name/description/note exist so +// the PR reviewer on repo 63 learns WHY the change is in front of them; if they +// are silently dropped at parse, --summary-out has nothing to render. +func TestR20_AllOptionalFieldsArePopulated(t *testing.T) { + p := mustParse(t, `{ + "version": 1, + "name": "org baseline ownership", + "description": "CI owns workflows everywhere; infra owns Terraform where it exists.", + "on_empty": "error", + "ops": [ + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip", + "note": "Opportunistic — only repos that actually have Terraform." }, + { "id": "ci", "op": "add_owner(/.github/workflows/, @org/ci)", "on_zero_match": "declare", + "note": "Baseline; also covers workflows added later." } + ] +}`) + if p.Name != "org baseline ownership" { + t.Errorf("Name = %q", p.Name) + } + if !strings.HasPrefix(p.Description, "CI owns workflows") { + t.Errorf("Description = %q", p.Description) + } + if p.OnEmpty != "error" { + t.Errorf("OnEmpty = %q, want %q", p.OnEmpty, "error") + } + wantNotes := map[string]string{ + "tf": "Opportunistic — only repos that actually have Terraform.", + "ci": "Baseline; also covers workflows added later.", + } + if !reflect.DeepEqual(p.Notes, wantNotes) { + t.Errorf("Notes = %#v, want %#v — keyed by op id", p.Notes, wantNotes) + } +} + +// SPEC R-21: on_zero_match and id ride on ops.Op, because plan.Build's signature +// does not change — the planner learns per-op zero-match behavior only from the +// op it is handed. A policy that parsed but left these zero would silently run +// the default everywhere. +func TestR21_OnZeroMatchAndIDComeFromTheFile(t *testing.T) { + p := mustParse(t, `{ + "version": 1, + "ops": [ + { "id": "req", "op": "add_owner(/a/, @a)", "on_zero_match": "require" }, + { "id": "skp", "op": "add_owner(/b/, @b)", "on_zero_match": "skip" }, + { "id": "dcl", "op": "add_owner(/c/, @c)", "on_zero_match": "declare" } + ] +}`) + want := []struct{ id, ozm string }{ + {"req", ops.ZeroMatchRequire}, + {"skp", ops.ZeroMatchSkip}, + {"dcl", ops.ZeroMatchDeclare}, + } + for i, w := range want { + if p.Ops[i].ID != w.id { + t.Errorf("Ops[%d].ID = %q, want %q", i, p.Ops[i].ID, w.id) + } + if p.Ops[i].OnZeroMatch != w.ozm { + t.Errorf("Ops[%d].OnZeroMatch = %q, want %q", i, p.Ops[i].OnZeroMatch, w.ozm) + } + } +} + +// SPEC R-20: an unknown top-level field is a hard error. Silently ignoring +// "opps" means the policy that ran is not the policy that was reviewed. +func TestR20_UnknownTopLevelFieldRejected(t *testing.T) { + for _, field := range []string{"opps", "onEmpty", "owners", "repos", "except_repos"} { + t.Run(field, func(t *testing.T) { + err := mustReject(t, `{"version":1,"`+field+`":"x","ops":["add_owner(/x/, @a)"]}`) + assertMentions(t, err, field) + assertNoGoInternals(t, err) + }) + } +} + +// SPEC R-20: unknown fields inside an OP object are the dangerous case — the +// natural string-or-object implementation loses DisallowUnknownFields the +// moment a custom unmarshaler takes over, so this is the test that catches the +// regression the UX doc calls out by name. +func TestR20_UnknownFieldInsideOpRejected(t *testing.T) { + // "description" and "version" are legal at TOP level; inside an op they are + // not. A per-level field set, not one merged bag. + for _, field := range []string{"notes", "descriptoin", "description", "version", "ops", "when_absent"} { + t.Run(field, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[{"op":"add_owner(/x/, @a)","`+field+`":"x"}]}`) + assertMentions(t, err, field) + assertNoGoInternals(t, err) + }) + } +} + +// SPEC R-20: the motivating case. `on_zero_mtach` differs from the real field by +// two transposed letters; under a permissive decoder it applies the DEFAULT +// zero-match policy to 100 repos while the file in git says "skip". The error +// must quote the offending key and point at the one that was meant. +func TestR20_NearMissTypoNamesTheOffendingField(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[{"id":"tf","op":"add_owner(**/*.tf, @org/infra)","on_zero_mtach":"skip"}]}`) + assertMentions(t, err, "on_zero_mtach", "on_zero_match") + assertNoGoInternals(t, err) +} + +// SPEC R-20: version is required, not optional-defaulting-to-1. A strict format +// read by pinned binaries across a fleet, with no version marker, is a corner +// with no way out. +func TestR20_MissingVersionRejected(t *testing.T) { + err := mustReject(t, `{"ops":["add_owner(/x/, @a)"]}`) + assertMentions(t, err, "version") + assertNoGoInternals(t, err) +} + +// SPEC R-20: version 0 is what an absent field decodes to. It must be rejected +// for the same reason absence is — otherwise "version": 0 and "no version at +// all" become indistinguishable and the marker stops carrying information. +func TestR20_VersionZeroRejected(t *testing.T) { + err := mustReject(t, `{"version":0,"ops":["add_owner(/x/, @a)"]}`) + assertMentions(t, err, "version") + assertNoGoInternals(t, err) +} + +// SPEC R-20: "your binary is too old" and "this file is nonsense" are two +// different jobs for the operator — upgrade the tool, or go fix whatever +// generated the file. A pinned fleet binary that meets a version it does not +// implement has to say which one it is, or the operator picks wrong and +// spends the outage on the other. +// +// The message carries that verdict in the two NUMBERS it names: the version +// the file asks for and the version this binary implements. A malformed +// version has no such pair — there is nothing to compare — so it names the +// field and shows what it actually found, and must never send anyone off to +// upgrade over a stray quote mark. +func TestR20_NewerVersionDistinguishedFromGarbage(t *testing.T) { + newer := mustReject(t, `{"version":2,"ops":["add_owner(/x/, @a)"]}`) + assertNoGoInternals(t, newer) + assertMentions(t, newer, "version", "2", strconv.Itoa(policy.Version)) + if !readsAsFutureVersion(newer) { + t.Errorf("an unsupported FUTURE version must tell the operator the file came from a later release than this binary; got:\n%s", newer.Error()) + } + + // Each malformed version must name the field and show the value that was + // found. `shown` lists the renderings that count: a scalar can be echoed + // literally, a composite is more honestly named by its JSON type. + garbage := []struct { + json string + shown []string + }{ + {`"1"`, []string{`"1"`, "string"}}, + {`-1`, []string{"-1"}}, + {`1.5`, []string{"1.5"}}, + {`true`, []string{"true", "bool"}}, + {`null`, []string{"null"}}, + {`[1]`, []string{"[1]", "array", "list"}}, + } + for _, g := range garbage { + t.Run("garbage_"+g.json, func(t *testing.T) { + err := mustReject(t, `{"version":`+g.json+`,"ops":["add_owner(/x/, @a)"]}`) + assertNoGoInternals(t, err) + assertMentions(t, err, "version") + assertMentionsAny(t, err, g.shown...) + if readsAsFutureVersion(err) { + t.Errorf("version %s is malformed, not from the future; the message must not send the operator to upgrade:\n%s", g.json, err.Error()) + } + }) + } +} + +// SPEC R-20: a policy with no ops does nothing on 100 repos and exits 0 on all +// of them — the silent-success failure this design exists to make impossible. +func TestR20_MissingOpsRejected(t *testing.T) { + err := mustReject(t, `{"version":1}`) + assertMentions(t, err, "ops") + assertNoGoInternals(t, err) +} + +// SPEC R-20: an EMPTY ops array is the same silent success, and is what a +// generator emits when its input query returned nothing. +func TestR20_EmptyOpsArrayRejected(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[]}`) + assertMentions(t, err, "ops") + assertNoGoInternals(t, err) +} + +// SPEC R-20: dispatch is on the first non-space byte — `"` is the string form, +// `{` the object form, and ANYTHING ELSE is a typed error. A number or null +// slipping through as a zero-value op would run an empty op against the fleet. +func TestR20_OpsEntryOfWrongJSONTypeRejected(t *testing.T) { + for name, entry := range map[string]string{ + "number": `7`, + "float": `1.5`, + "array": `["add_owner(/x/, @a)"]`, + "null": `null`, + "bool": `true`, + "nested": `[{"op":"add_owner(/x/, @a)"}]`, + "emptyob": `{}`, + } { + t.Run(name, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[`+entry+`]}`) + assertNoGoInternals(t, err) + if len(located(err)) == 0 { + t.Errorf("a wrong-typed ops entry must still report a position; got %T: %v", err, err) + } + }) + } +} + +// SPEC R-20: "ops" itself must be an array. A single string is the plausible +// generator mistake, and reading it as one op would be a helpful guess — the +// exact behavior a strict format forbids. +func TestR20_OpsItselfMustBeAnArray(t *testing.T) { + for name, val := range map[string]string{ + "string": `"add_owner(/x/, @a)"`, + "object": `{"op":"add_owner(/x/, @a)"}`, + "null": `null`, + "number": `3`, + } { + t.Run(name, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":`+val+`}`) + assertMentions(t, err, "ops") + assertNoGoInternals(t, err) + }) + } +} + +// SPEC R-20: `op` is the one required field of the object form. An object +// carrying only id and note describes nothing; accepting it would put a +// phantom entry in the per-op results of every repo. +func TestR20_OpObjectWithoutOpKeyRejected(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[{"id":"tf","note":"terraform","on_zero_match":"skip"}]}`) + assertMentions(t, err, "op") + assertNoGoInternals(t, err) +} + +// SPEC R-21: the enum is exactly require|skip|declare, case-sensitively. "write" +// is the OLD name from revision 1 — accepting it would silently give a fleet +// the default behavior under a spelling whose author meant something else, and +// the rename would never actually have happened. +func TestR21_BadOnZeroMatchValueRejected(t *testing.T) { + for _, bad := range []string{"SKIP", "Skip", "write", "wrote", "error", "declair", "true", ""} { + t.Run("value_"+bad, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[{"id":"tf","op":"add_owner(/x/, @a)","on_zero_match":"`+bad+`"}]}`) + assertNoGoInternals(t, err) + assertMentions(t, err, "on_zero_match") + // The legal set must be enumerated — "invalid value" alone sends the + // operator to the source. + for _, legal := range []string{"require", "skip", "declare"} { + if !strings.Contains(err.Error(), legal) { + t.Errorf("error must enumerate the legal set (missing %q):\n%s", legal, err.Error()) + } + } + }) + } +} + +// SPEC R-20: on_empty is validated lazily today — plan.go only reaches "unknown +// --on-empty policy" when a removal actually empties an owner set. A policy +// saying "inhrit" would pass, work on 46 repos, and blow up on repo 47. +// Validate the enum at load; that is precisely what `check` exists to prevent. +func TestR20_BadOnEmptyValueRejected(t *testing.T) { + for _, bad := range []string{"inhrit", "ERROR", "unowend", "keep", "true", ""} { + t.Run("value_"+bad, func(t *testing.T) { + err := mustReject(t, `{"version":1,"on_empty":"`+bad+`","ops":["remove_owner(/x/, @a)"]}`) + assertMentions(t, err, "on_empty") + assertNoGoInternals(t, err) + for _, legal := range []string{"error", "inherit", "unowned"} { + if !strings.Contains(err.Error(), legal) { + t.Errorf("error must enumerate the legal set (missing %q):\n%s", legal, err.Error()) + } + } + }) + } + // The three legal values must survive. + for _, good := range []string{"error", "inherit", "unowned"} { + t.Run("legal_"+good, func(t *testing.T) { + p := mustParse(t, `{"version":1,"on_empty":"`+good+`","ops":["remove_owner(/x/, @a)"]}`) + if p.OnEmpty != good { + t.Errorf("OnEmpty = %q, want %q", p.OnEmpty, good) + } + }) + } +} + +// SPEC R-20: encoding/json silently takes the LAST duplicate key. A generator +// concatenating fragments produces on_empty twice and `inherit` wins without a +// word — the same failure class as the typo, and invisible in review because +// both lines are individually correct. +// The message must show BOTH values, because the whole failure is that one of +// them vanished without trace. Naming the key alone leaves the reviewer to +// work out which of their two lines the tool would have obeyed. +func TestR20_DuplicateTopLevelKeyRejected(t *testing.T) { + cases := []struct { + key string + src string + // The two values in conflict, which the message must both show. Empty + // for a key whose values are whole arrays: quoting those back is not a + // readable message, so `ops` is held only to naming the key. + conflicting []string + }{ + {"on_empty", `{"version":1,"on_empty":"error","ops":["remove_owner(/x/, @a)"],"on_empty":"inherit"}`, + []string{"error", "inherit"}}, + {"ops", `{"version":1,"ops":["add_owner(/x/, @a)"],"ops":["add_owner(/y/, @b)"]}`, nil}, + {"version", `{"version":1,"ops":["add_owner(/x/, @a)"],"version":2}`, + []string{"1", "2"}}, + {"name", `{"version":1,"name":"baseline-fragment-one","name":"baseline-fragment-two","ops":["add_owner(/x/, @a)"]}`, + []string{"baseline-fragment-one", "baseline-fragment-two"}}, + } + for _, c := range cases { + t.Run(c.key, func(t *testing.T) { + err := mustReject(t, c.src) + assertMentions(t, err, c.key) + assertMentions(t, err, c.conflicting...) + assertNoGoInternals(t, err) + }) + } +} + +// SPEC R-20: the same, one level down. Per-element decoders are built fresh for +// each op, so it is easy to implement duplicate detection at the top level and +// forget it inside the ops array — where the values that steer behavior live. +// Every value here is a scalar, so the message has no excuse: it must name the +// key and show both of the values that were in conflict. +func TestR20_DuplicateKeyInsideOpRejected(t *testing.T) { + cases := []struct { + key string + src string + conflicting []string + }{ + {"on_zero_match", `{"version":1,"ops":[{"id":"tf","op":"add_owner(/x/, @a)","on_zero_match":"skip","on_zero_match":"declare"}]}`, + []string{"skip", "declare"}}, + {"op", `{"version":1,"ops":[{"op":"add_owner(/x/, @a)","op":"add_owner(/y/, @b)"}]}`, + []string{"add_owner(/x/, @a)", "add_owner(/y/, @b)"}}, + {"id", `{"version":1,"ops":[{"id":"terraform-first","id":"terraform-second","op":"add_owner(/x/, @a)"}]}`, + []string{"terraform-first", "terraform-second"}}, + } + for _, c := range cases { + t.Run(c.key, func(t *testing.T) { + err := mustReject(t, c.src) + assertMentions(t, err, c.key) + assertMentions(t, err, c.conflicting...) + assertNoGoInternals(t, err) + }) + } +} + +// SPEC R-20: two ops in one policy may not share an id. Results are keyed by +// id — the summary a reviewer reads, and whatever a fleet script pipes into +// jq — so a repeated id silently overwrites one op's outcome with another's. +// The reviewer then sees a policy that ran N ops reporting N-1 results, with +// no indication which repo the missing one applied to. +// +// An op with NO id is not a duplicate of another op with no id: the empty id +// is the absence of a name, and unnamed ops are referred to by position. +func TestR20_DuplicateOpIDRejected(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[ + {"id":"tf","op":"add_owner(/a/, @a)"}, + {"id":"ci","op":"add_owner(/b/, @b)"}, + {"id":"tf","op":"add_owner(/c/, @c)"} +]}`) + assertMentions(t, err, "tf") + assertNoGoInternals(t, err) + e, ok := findLocated(err, func(e *policy.Error) bool { return e.OpIndex == 0 || e.OpIndex == 2 }) + if !ok { + t.Fatalf("the repeated id must be attributed to one of the ops that carries it (ops[0] or ops[2]); got %T:\n%v", err, err) + } + if e.OpID != "tf" { + t.Errorf("OpID = %q, want %q — the error names the id that collided", e.OpID, "tf") + } + + // Distinct ids are fine, and so is a policy where no op is named at all. + mustParse(t, `{"version":1,"ops":[{"id":"a","op":"add_owner(/a/, @a)"},{"id":"b","op":"add_owner(/b/, @b)"}]}`) + mustParse(t, `{"version":1,"ops":[{"op":"add_owner(/a/, @a)"},{"op":"add_owner(/b/, @b)"},"add_owner(/c/, @c)"]}`) +} + +// SPEC R-21: an ABSENT on_zero_match means require. An on_zero_match that is +// PRESENT and empty is an error. These are two different states of the file and +// the parser must be able to tell them apart. +// +// The distinction is easy to lose: a plain Go struct field decodes both to the +// empty string, so an implementation that reads the field and checks its value +// cannot see the difference and will accept `"on_zero_match": ""` as a default. +// Detecting which keys the file actually contained is therefore part of the +// contract, not an implementation preference. +// +// What it buys: a generator that emits an empty string where it meant to emit +// a decision has produced a file that reads, to a human reviewer, as if a +// choice was made. Accepting it applies the default across the fleet under a +// spelling that says otherwise — the same silent-default failure as the typo, +// arriving through the correctly-spelled field. +func TestR21_ExplicitEmptyOnZeroMatchIsNotTheSameAsAbsent(t *testing.T) { + // Absent: legal, and means require. + absent := mustParse(t, `{"version":1,"ops":[{"id":"a","op":"add_owner(/x/, @a)"}]}`) + if absent.Ops[0].OnZeroMatch != "" { + t.Errorf("OnZeroMatch = %q, want \"\" — an absent key leaves the zero value, which means require", absent.Ops[0].OnZeroMatch) + } + // The bare-string shorthand has no key either, and lands in the same state. + shorthand := mustParse(t, `{"version":1,"ops":["add_owner(/x/, @a)"]}`) + if shorthand.Ops[0].OnZeroMatch != "" { + t.Errorf("OnZeroMatch = %q, want \"\"", shorthand.Ops[0].OnZeroMatch) + } + + // Present and empty: rejected, and the message names the field so the + // operator can find the line their generator wrote. + err := mustReject(t, `{"version":1,"ops":[{"id":"a","op":"add_owner(/x/, @a)","on_zero_match":""}]}`) + assertMentions(t, err, "on_zero_match") + assertNoGoInternals(t, err) + for _, legal := range []string{"require", "skip", "declare"} { + if !strings.Contains(err.Error(), legal) { + t.Errorf("error must enumerate the legal set (missing %q):\n%s", legal, err.Error()) + } + } + if _, ok := findLocated(err, func(e *policy.Error) bool { return e.OpIndex == 0 }); !ok { + t.Errorf("the empty on_zero_match must be attributed to ops[0]; got %T:\n%v", err, err) + } + + // The same distinction one level up: on_empty is optional when no op + // removes an owner, but writing it as an empty string is not "unset". + mustParse(t, `{"version":1,"ops":["add_owner(/x/, @a)"]}`) + err = mustReject(t, `{"version":1,"on_empty":"","ops":["add_owner(/x/, @a)"]}`) + assertMentions(t, err, "on_empty") + assertNoGoInternals(t, err) +} + +// SPEC R-20: JSON has no comments and unknown fields are fatal, so without an +// escape hatch the universal "_comment" convention would be ILLEGAL. Keys +// starting with _ and the key // are ignored at EVERY level. This does not +// weaken typo detection — on_zero_mtach does not start with an underscore. +func TestR20_UnderscoreAndSlashKeysAreIgnored(t *testing.T) { + p := mustParse(t, `{ + "version": 1, + "_note": "keys starting with _ are ignored — JSON has no comments", + "//": "so is this one", + "_": "and a bare underscore", + "on_empty": "error", + "ops": [ + "add_owner(/x/, @a)", + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip", + "_why": "generated from the terraform inventory", + "//": "do not hand-edit" } + ] +}`) + if len(p.Ops) != 2 { + t.Fatalf("Ops = %+v, want 2", p.Ops) + } + if p.Ops[1].ID != "tf" || p.Ops[1].OnZeroMatch != ops.ZeroMatchSkip { + t.Errorf("Ops[1] = %+v — ignored keys must not disturb the fields around them", p.Ops[1]) + } + if _, ok := p.Notes["tf"]; ok { + t.Errorf("Notes = %#v — `_why` is ignored, not a note", p.Notes) + } +} + +// SPEC R-20: an op string that ops.Parse rejects is a POLICY error. Letting the +// raw ops.Parse text out loses the file, line, and op index — the operator gets +// `unknown op "add_ownr"` with no idea which of 40 ops in which of 100 repos. +func TestR20_MalformedOpStringIsAPolicyError(t *testing.T) { + cases := map[string]string{ + "unknown kind": "add_ownr(/x/, @a)", + "no parens": "add_owner /x/ @a", + "missing arg": "add_owner(/x/)", + "bad owner": "add_owner(/x/, notanowner!)", + "unescaped ws": "add_owner(a b, @x)", + "empty": "", + "set without []": "set_owners(/x/, @a)", + } + for name, spec := range cases { + t.Run(name, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":["`+spec+`"]}`) + assertNoGoInternals(t, err) + locs := located(err) + if len(locs) == 0 { + t.Fatalf("a bad op string must surface as *policy.Error, got %T: %v", err, err) + } + e := locs[0] + if e.File != testFile { + t.Errorf("File = %q, want %q", e.File, testFile) + } + if e.OpIndex != 0 { + t.Errorf("OpIndex = %d, want 0 — the operator must be told WHICH op", e.OpIndex) + } + if e.Line <= 0 { + t.Errorf("Line = %d, want a real line number", e.Line) + } + }) + } +} + +// SPEC R-21: rename_owner's scope is derived from current ownership, not a +// pattern — plan.go exempts it from R-5 entirely, so on_zero_match can never +// fire on it. Accepting the field and ignoring it is the same class of failure +// as the typo the strictness rule exists to catch. +func TestR21_OnZeroMatchRejectedOnRenameOwner(t *testing.T) { + for _, val := range []string{"require", "skip", "declare"} { + t.Run(val, func(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[{"id":"ren","op":"rename_owner(@old, @new)","on_zero_match":"`+val+`"}]}`) + assertMentions(t, err, "on_zero_match", "rename_owner") + assertNoGoInternals(t, err) + }) + } + // Without the field it is a perfectly good op. + p := mustParse(t, `{"version":1,"ops":["rename_owner(@old, @new)"]}`) + if p.Ops[0].Kind != ops.RenameOwner { + t.Errorf("Ops[0] = %+v, want a rename_owner", p.Ops[0]) + } +} + +// SPEC R-21: `declare` means "write the rule anyway, for files that do not +// exist yet". A remove_owner has nothing to write — there is no rule to declare +// the absence of an owner on paths that are not there. Reject at parse. +func TestR21_DeclareRejectedOnRemoveOwner(t *testing.T) { + err := mustReject(t, `{"version":1,"on_empty":"error","ops":[{"id":"rm","op":"remove_owner(/x/, @a)","on_zero_match":"declare"}]}`) + assertMentions(t, err, "declare", "remove_owner") + assertNoGoInternals(t, err) +} + +// SPEC R-21: require and skip ARE meaningful on remove_owner — "this repo must +// have had @a here" versus "clean it up where it exists". Rejecting them would +// make the only fleet-safe removal impossible. +func TestR21_RequireAndSkipAreLegalOnRemoveOwner(t *testing.T) { + for _, val := range []string{ops.ZeroMatchRequire, ops.ZeroMatchSkip} { + t.Run(val, func(t *testing.T) { + p := mustParse(t, `{"version":1,"on_empty":"error","ops":[{"id":"rm","op":"remove_owner(/x/, @a)","on_zero_match":"`+val+`"}]}`) + if p.Ops[0].OnZeroMatch != val { + t.Errorf("OnZeroMatch = %q, want %q", p.Ops[0].OnZeroMatch, val) + } + }) + } +} + +// SPEC R-21: all three values are legal on add_owner and set_owners, including +// set_owners with an empty list (the zero-owner rule, S-9) under declare. +// The legality table is the contract; a value legal in the docs and rejected by +// the parser halts a fleet on repo 0 for no reason. +func TestR21_AllZeroMatchValuesLegalOnAddOwnerAndSetOwners(t *testing.T) { + specs := []string{ + "add_owner(/x/, @a)", + "set_owners(/x/, [@a, @b])", + "set_owners(/x/, [])", + } + for _, spec := range specs { + for _, val := range []string{ops.ZeroMatchRequire, ops.ZeroMatchSkip, ops.ZeroMatchDeclare} { + t.Run(spec+"/"+val, func(t *testing.T) { + p := mustParse(t, `{"version":1,"ops":[{"id":"o","op":"`+spec+`","on_zero_match":"`+val+`"}]}`) + if p.Ops[0].OnZeroMatch != val { + t.Errorf("OnZeroMatch = %q, want %q", p.Ops[0].OnZeroMatch, val) + } + }) + } + } +} + +// SPEC R-20: on_empty is REQUIRED when any op is a remove_owner, validated +// statically. Otherwise the R-6 question ("what happens when a removal empties +// an owner set?") is answered lazily on whichever repo first hits it — a repo-47 +// surprise, turned here into a repo-0 one. +func TestR20_RemoveOwnerRequiresTopLevelOnEmpty(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":["add_owner(/x/, @a)","remove_owner(/y/, @b)"]}`) + assertMentions(t, err, "on_empty", "remove_owner") + assertNoGoInternals(t, err) + + // With on_empty present the same policy is fine... + mustParse(t, `{"version":1,"on_empty":"inherit","ops":["add_owner(/x/, @a)","remove_owner(/y/, @b)"]}`) + // ...and a policy with no removals must NOT be forced to declare one. + if p := mustParse(t, `{"version":1,"ops":["add_owner(/x/, @a)"]}`); p.OnEmpty != "" { + t.Errorf("OnEmpty = %q, want \"\" — a policy without removals must not need the field", p.OnEmpty) + } +} + +// SPEC R-20 (error quality): the format is file:line:col, then the op by index +// and id, then the message. A byte offset is what encoding/json gives and what +// nobody can use; this string is what a person greps out of a 100-repo log at +// 2am. +func TestR20_ErrorFormatsFileLineColAndOp(t *testing.T) { + e := &policy.Error{File: "policy.json", Line: 14, Col: 22, OpIndex: 2, OpID: "tf", Msg: "unknown field \"on_zero_mtach\""} + if got, want := e.Error(), `policy.json:14:22: ops[2] (id "tf"): unknown field "on_zero_mtach"`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + // Not op-scoped: OpIndex -1 drops the op clause entirely. + e = &policy.Error{File: "policy.json", Line: 2, Col: 3, OpIndex: -1, Msg: "missing required field \"version\""} + if got, want := e.Error(), `policy.json:2:3: missing required field "version"`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + // An op with no id still names its index. + e = &policy.Error{File: "policy.json", Line: 5, Col: 7, OpIndex: 0, Msg: "boom"} + if got, want := e.Error(), `policy.json:5:7: ops[0]: boom`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + // A MultiError prints every problem, one per line. + m := &policy.MultiError{Errs: []error{ + &policy.Error{File: "policy.json", Line: 1, OpIndex: -1, Msg: "a"}, + &policy.Error{File: "policy.json", Line: 2, OpIndex: -1, Msg: "b"}, + }} + if got, want := m.Error(), "policy.json:1: a\npolicy.json:2: b"; got != want { + t.Errorf("MultiError.Error() = %q, want %q", got, want) + } +} + +// SPEC R-20 (error quality): a real multi-line policy must report the line the +// mistake is actually on. Line 0 — the value you get by not converting the byte +// offset — is the whole feature failing quietly. +func TestR20_ErrorCarriesAPlausibleLineNumber(t *testing.T) { + src := `{ + "version": 1, + "on_empty": "error", + "ops": [ + "add_owner(/services/api/, @org/api-team)", + { "id": "tf", + "op": "add_owner(**/*.tf, @org/infra)", + "on_zero_mtach": "skip" } + ] +}` + err := mustReject(t, src) + e, ok := findLocated(err, func(e *policy.Error) bool { return e.Line == 8 }) + if !ok { + t.Fatalf("no problem was reported on line 8, the on_zero_mtach line; a byte offset that was never converted reports 0. Got %T:\n%v", err, err) + } + if e.Col <= 0 { + t.Errorf("Col = %d, want a 1-based column", e.Col) + } + if e.File != testFile { + t.Errorf("File = %q, want %q — Parse's filename argument exists for exactly this", e.File, testFile) + } + if !strings.Contains(err.Error(), testFile+":8:") { + t.Errorf("rendered error = %q, want it to locate the problem at %q", err.Error(), testFile+":8:") + } +} + +// SPEC R-20 (error quality): the op is identified by index AND id. The index +// alone makes a reviewer count array elements; the id alone is optional and may +// be absent. Both, always. +// +// When the op has no id the index still stands alone, and no id is invented to +// fill the gap: `ops[1]` is how the renderer refers to an unnamed op, and an +// error claiming an op is named "ops[1]" sends a reviewer searching the policy +// file for a string that is not in it. +func TestR20_ErrorsIdentifyTheOpByIndexAndID(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[ + "add_owner(/a/, @a)", + "add_owner(/b/, @b)", + {"id":"tf","op":"add_owner(**/*.tf, @org/infra)","on_zero_match":"nope"} +]}`) + e, ok := findLocated(err, func(e *policy.Error) bool { return e.OpIndex == 2 }) + if !ok { + t.Fatalf("no problem was attributed to ops[2], the only op with a mistake in it; got %T:\n%v", err, err) + } + if e.OpID != "tf" { + t.Errorf("OpID = %q, want %q", e.OpID, "tf") + } + assertMentions(t, err, "ops[2]", `id "tf"`) + + // An op with no id still reports its index, and reports no phantom id. + err = mustReject(t, `{"version":1,"ops":["add_owner(/a/, @a)",{"op":"add_owner(/b/, @b)","on_zero_match":"nope"}]}`) + e, ok = findLocated(err, func(e *policy.Error) bool { return e.OpIndex == 1 }) + if !ok { + t.Fatalf("no problem was attributed to ops[1], the only op with a mistake in it; got %T:\n%v", err, err) + } + if e.OpID != "" { + t.Errorf("OpID = %q, want \"\" — the op has no id, and `ops[1]` is the display label, never a stored value", e.OpID) + } + assertMentions(t, err, "ops[1]") +} + +// SPEC R-20: SEMANTIC errors accumulate. Fixing a generated 40-op policy one +// error per run is miserable and is how an operator gives up and stops running +// `check` at all. +func TestR20_SemanticErrorsAccumulate(t *testing.T) { + err := mustReject(t, `{"version":1,"ops":[ + {"id":"a","op":"add_owner(/x/, @a)","on_zero_match":"SKIP"}, + {"id":"b","op":"add_owner(/y/, @b)","on_zero_match":"wrote"}, + {"id":"c","op":"add_owner(/z/, @c)","on_zero_match":"write"} +]}`) + var multi *policy.MultiError + if !errors.As(err, &multi) { + t.Fatalf("three independent semantic errors must arrive as one *MultiError, got %T: %v", err, err) + } + if len(multi.Errs) != 3 { + t.Errorf("MultiError carries %d errors, want 3:\n%s", len(multi.Errs), err.Error()) + } + assertMentions(t, err, `id "a"`, `id "b"`, `id "c"`, "SKIP", "wrote", "write") + assertNoGoInternals(t, err) + + // Errors of different kinds accumulate together, not just repeats of one. + err = mustReject(t, `{"version":1,"ops":[ + {"id":"a","op":"add_owner(/x/, @a)","on_zero_match":"SKIP"}, + {"id":"b","op":"rename_owner(@o, @n)","on_zero_match":"skip"}, + {"id":"c","op":"remove_owner(/z/, @c)","on_zero_match":"declare"}, + {"id":"d","op":"add_ownr(/w/, @d)"} +]}`) + if !errors.As(err, &multi) || len(multi.Errs) < 4 { + t.Errorf("want at least 4 accumulated problems (bad enum, rename legality, declare-on-remove, bad op string), got %T with %d:\n%v", + err, len(flatten(err)), err) + } +} + +// SPEC R-20: SYNTAX errors fail fast. Once the token stream is broken every +// subsequent "error" is invented, and a wall of phantom problems is worse than +// the one real one. +func TestR20_SyntaxErrorsFailFast(t *testing.T) { + cases := map[string]string{ + "truncated": `{"version":1,"ops":[`, + "trailing comma": `{"version":1,"ops":["add_owner(/x/, @a)",]}`, + "unquoted key": `{version:1,"ops":["add_owner(/x/, @a)"]}`, + "not an object": `["add_owner(/x/, @a)"]`, + "empty input": ``, + "garbage": `not json at all`, + } + for name, src := range cases { + t.Run(name, func(t *testing.T) { + err := mustReject(t, src) + assertNoGoInternals(t, err) + if n := len(flatten(err)); n != 1 { + t.Errorf("syntax errors must fail fast with exactly 1 problem, got %d:\n%s", n, err.Error()) + } + if len(located(err)) != 1 { + t.Fatalf("a syntax error is still a located *policy.Error, got %T: %v", err, err) + } + if !strings.HasPrefix(err.Error(), testFile+":") { + t.Errorf("a syntax error must still name the file it came from; got:\n%s", err.Error()) + } + }) + } +} + +// SPEC R-20 (error quality, rule 5): `json: cannot unmarshal object into Go +// value of type string` names Go types and points at the wrong concept. It must +// never reach an operator, for ANY malformed input. +func TestR20_GoUnmarshalMessagesNeverEscape(t *testing.T) { + cases := map[string]string{ + "op is a number": `{"version":1,"ops":[{"op":5}]}`, + "op is an object": `{"version":1,"ops":[{"op":{"kind":"add_owner"}}]}`, + "id is a number": `{"version":1,"ops":[{"id":7,"op":"add_owner(/x/, @a)"}]}`, + "on_zero_match is a number": `{"version":1,"ops":[{"op":"add_owner(/x/, @a)","on_zero_match":3}]}`, + "note is an array": `{"version":1,"ops":[{"op":"add_owner(/x/, @a)","note":["a"]}]}`, + "name is an object": `{"version":1,"name":{},"ops":["add_owner(/x/, @a)"]}`, + "on_empty is a bool": `{"version":1,"on_empty":true,"ops":["add_owner(/x/, @a)"]}`, + "version is a string": `{"version":"1","ops":["add_owner(/x/, @a)"]}`, + "ops is an object": `{"version":1,"ops":{"0":"add_owner(/x/, @a)"}}`, + "entry is a number": `{"version":1,"ops":[3]}`, + } + for name, src := range cases { + t.Run(name, func(t *testing.T) { + err := mustReject(t, src) + assertNoGoInternals(t, err) + if !strings.HasPrefix(err.Error(), testFile+":") { + t.Errorf("every policy error must lead with the file it came from; got:\n%s", err.Error()) + } + }) + } +} + +// SPEC R-20: Load is Parse over a file on disk — same policy, same result. The +// filename in the errors is the path the operator passed, so they can open it. +func TestR20_LoadReadsAPolicyFromDisk(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + src := `{ + "version": 1, + "name": "org baseline ownership", + "on_empty": "error", + "ops": [ + "add_owner(/services/api/, @org/api-team)", + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_match": "skip", "note": "opportunistic" } + ] +}` + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := policy.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + parsed, err := policy.Parse([]byte(src), path) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !reflect.DeepEqual(loaded, parsed) { + t.Errorf("Load = %+v, want the same policy as Parse = %+v", loaded, parsed) + } + if loaded.Ops[1].ID != "tf" || loaded.Notes["tf"] != "opportunistic" { + t.Errorf("loaded policy lost per-op detail: %+v / %#v", loaded.Ops, loaded.Notes) + } +} + +// SPEC R-20: a missing policy file is a policy error, not a crash and not a +// silent empty policy — the fleet script's first line is `check --policy`, and +// a typo'd path must halt there rather than run zero ops against 100 repos. +func TestR20_LoadOfMissingPathFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "nope.json") + p, err := policy.Load(path) + if err == nil { + t.Fatalf("Load of a nonexistent path must fail, got %+v", p) + } + if p != nil { + t.Errorf("Load must return a nil policy on error, got %+v", p) + } + assertMentions(t, err, "nope.json") + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("the OS error must stay wrapped so callers can errors.Is it; got: %v", err) + } +} + +// SPEC R-20: the policy is the unit of review, and reviewing it touches NO +// repository. Every error above is reachable with nothing on disk but the file +// itself — no git, no tree, no CODEOWNERS — which is what lets `check` run once +// before the loop instead of failing on repo 100. +func TestR20_MalformedPolicyIsCaughtWithoutARepo(t *testing.T) { + dir := t.TempDir() // not a git repo; no CODEOWNERS anywhere + path := filepath.Join(dir, "policy.json") + src := `{ + "version": 1, + "ops": [ + { "id": "tf", "op": "add_owner(**/*.tf, @org/infra)", "on_zero_mtach": "skip" } + ] +}` + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + err := func() error { + _, err := policy.Load(path) + return err + }() + if err == nil { + t.Fatal("the typo must be caught from the file alone") + } + assertMentions(t, err, "on_zero_mtach", path) + assertNoGoInternals(t, err) + + // And a good policy validates just as well with no repo in sight. + good := filepath.Join(dir, "good.json") + if err := os.WriteFile(good, []byte(`{"version":1,"ops":["add_owner(/ghost/does/not/exist/, @a)"]}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := policy.Load(good); err != nil { + t.Errorf("a scope no repo contains is a per-repo fact (exit 2), not a policy error: %v", err) + } +} diff --git a/internal/policy/suggest.go b/internal/policy/suggest.go new file mode 100644 index 0000000..91a0ef0 --- /dev/null +++ b/internal/policy/suggest.go @@ -0,0 +1,82 @@ +package policy + +import "strings" + +// Did-you-mean over the known field and enum sets. +// +// The motivating error is `on_zero_mtach` — two transposed letters from the real +// field, and under a permissive decoder it applies the DEFAULT zero-match policy +// to 100 repos while the file in git says "skip". Naming the offending key is +// half the fix; pointing at the one that was meant is the other half, and it is +// twenty lines and zero dependencies. + +// suggest returns the closest known name to bad, or "" when nothing is close +// enough to be worth guessing. A wrong guess is worse than none: it sends the +// operator to edit a field they never wrote. +func suggest(bad string, known []string) string { + // Case is checked first and exactly. `SKIP` is not a near miss of `skip`, it + // is the same word in the wrong case, and saying "did you mean skip?" reads + // as nonsense unless the case difference is what we actually spotted. + for _, k := range known { + if k != bad && strings.EqualFold(k, bad) { + return k + } + } + best, bestDist := "", 0 + for _, k := range known { + d := levenshtein(bad, k) + if d == 0 { + continue + } + // Scale the tolerance with the candidate's length — one edit in `ops` is + // a different kind of evidence than two edits in `on_zero_match` — with a + // floor of 2 so short names still get their obvious typo caught. + limit := max((len(k)+2)/3, 2) + if d <= limit && (best == "" || d < bestDist) { + best, bestDist = k, d + } + } + return best +} + +// levenshtein is the standard two-row edit distance. +func levenshtein(a, b string) int { + ar, br := []rune(a), []rune(b) + prev := make([]int, len(br)+1) + cur := make([]int, len(br)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(ar); i++ { + cur[0] = i + for j := 1; j <= len(br); j++ { + cost := 1 + if ar[i-1] == br[j-1] { + cost = 0 + } + cur[j] = min(cur[j-1]+1, min(prev[j]+1, prev[j-1]+cost)) + } + prev, cur = cur, prev + } + return prev[len(br)] +} + +// hint renders a did-you-mean clause, or "" when there is nothing to suggest. +func hint(bad string, known []string) string { + if s := suggest(bad, known); s != "" { + return " (did you mean " + quote(s) + "?)" + } + return "" +} + +func quote(s string) string { return `"` + s + `"` } + +// list renders a legal set the way the message should read it back: quoted, in +// the order the docs use, never alphabetized into a different meaning. +func list(items []string) string { + parts := make([]string, len(items)) + for i, it := range items { + parts[i] = quote(it) + } + return strings.Join(parts, ", ") +} diff --git a/tools/gendocs/main.go b/tools/gendocs/main.go index fab0cc2..f62adb7 100644 --- a/tools/gendocs/main.go +++ b/tools/gendocs/main.go @@ -5,6 +5,11 @@ // implementation honors it. This tool extracts package and test doc comments // with go/ast so the documentation cannot drift from what is actually // verified — regenerate with `make docs`. +// +// Determinism is a hard requirement, not a nicety: `make docs` is checked in +// CI with `git diff --exit-code`, so any map-iteration order leaking into the +// output would produce spurious failures and destroy trust in the check. +// Every collection this tool walks is therefore sorted before use. package main import ( @@ -23,9 +28,16 @@ type testDoc struct { Doc string } +// fileDoc is the file-level prose of a single _test.go file: its package doc +// comment plus every free-floating comment group in it, in source order. +type fileDoc struct { + Name string // base filename, e.g. "fleet_idempotence_test.go" + Prose []string +} + type pkgDoc struct { Dir string - Doc string + Files []fileDoc Tests []testDoc } @@ -50,12 +62,18 @@ func main() { sb.WriteString("Every statement below is enforced by a named test. Spec references\n") sb.WriteString("(S-* semantics, R-* rules, INV-* invariants, T-* acceptance tests, A-* audit\n") sb.WriteString("checks) map to the specification this tool was built against.\n\n") + sb.WriteString("Each package section opens with the file-level prose of its test files —\n") + sb.WriteString("package doc comments and free-floating commentary — quoted per file in\n") + sb.WriteString("filename order, then one entry per test in spec-tag order.\n\n") total := 0 for _, p := range pkgs { sb.WriteString("## " + p.Dir + "\n\n") - if p.Doc != "" { - sb.WriteString(quote(p.Doc) + "\n") + for _, f := range p.Files { + sb.WriteString("**`" + f.Name + "`**\n\n") + for _, prose := range f.Prose { + sb.WriteString(quote(prose) + "\n") + } } for _, t := range p.Tests { total++ @@ -85,32 +103,61 @@ func scan(dir string) *pkgDoc { if err != nil { return nil } + + // A directory can hold two Go packages (e.g. `plan` and `plan_test`), and + // both parser.ParseDir's result and each package's Files field are maps. + // Flatten to a single filename-keyed map and walk it in sorted filename + // order, so neither the prose we emit nor the tie-breaking below depends + // on Go's randomized map iteration. + files := map[string]*ast.File{} + for _, pkg := range pkgsMap { + for path, f := range pkg.Files { + files[path] = f + } + } + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + out := &pkgDoc{Dir: filepath.ToSlash(dir)} var names []string byName := map[string]testDoc{} - for _, pkg := range pkgsMap { - for _, f := range pkg.Files { - if f.Doc != nil && out.Doc == "" { - out.Doc = strings.TrimSpace(f.Doc.Text()) + for _, path := range paths { + f := files[path] + + // RULE: no file-level prose is dropped. Every package doc comment in + // the directory is emitted, attributed to its file, in filename order + // — rather than picking one file's comment and discarding the rest, + // which both lost documentation and made the choice order-dependent. + // Free-floating comments (attached to no declaration, and outside + // every declaration's body) are emitted the same way: much of the + // project's best prose is a file preamble separated from the package + // clause by a blank line, which go/ast does NOT record as a package + // doc, so requiring attachment would silently drop it. + if prose := fileProse(f); len(prose) > 0 { + out.Files = append(out.Files, fileDoc{Name: filepath.Base(path), Prose: prose}) + } + + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || !strings.HasPrefix(fn.Name.Name, "Test") { + continue } - for _, decl := range f.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || !strings.HasPrefix(fn.Name.Name, "Test") { - continue - } - d := "" - if fn.Doc != nil { - d = strings.TrimSpace(fn.Doc.Text()) - } - byName[fn.Name.Name] = testDoc{Name: fn.Name.Name, Doc: d} - names = append(names, fn.Name.Name) + d := "" + if fn.Doc != nil { + d = strings.TrimSpace(fn.Doc.Text()) } + byName[fn.Name.Name] = testDoc{Name: fn.Name.Name, Doc: d} + names = append(names, fn.Name.Name) } } if len(names) == 0 { return nil } - // Keep declaration order within a file set: sort by spec-tag-aware name. + // Order tests by spec tag. `names` is already in (filename, declaration) + // order, so the stable sort's tie-break is deterministic too. sort.SliceStable(names, func(i, j int) bool { return specKey(names[i]) < specKey(names[j]) }) seen := map[string]bool{} for _, n := range names { @@ -122,6 +169,67 @@ func scan(dir string) *pkgDoc { return out } +// fileProse returns the file's package doc comment followed by every +// free-floating comment group, in source order. A comment group is +// free-floating when it documents no declaration: it is not the doc comment of +// a top-level declaration and does not sit inside one. Comments inside a +// function body, and doc comments on tests, types and vars, are excluded — +// tests are rendered from their own doc comments below. +func fileProse(f *ast.File) []string { + attached := map[*ast.CommentGroup]bool{} + for _, decl := range f.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + attached[d.Doc] = true + case *ast.GenDecl: + attached[d.Doc] = true + } + } + var prose []string + for _, cg := range f.Comments { + if cg != f.Doc && attached[cg] { + continue + } + if cg != f.Doc && withinDecl(f, cg) { + continue + } + if s := cleanProse(cg.Text()); s != "" { + prose = append(prose, s) + } + } + return prose +} + +func withinDecl(f *ast.File, cg *ast.CommentGroup) bool { + for _, decl := range f.Decls { + if cg.Pos() >= decl.Pos() && cg.End() <= decl.End() { + return true + } + } + return false +} + +// cleanProse strips the ASCII rule lines ("// ------...") the test files use to +// bracket section headers. They carry no information, and in a Markdown +// blockquote a trailing run of dashes turns the line above it into a heading. +func cleanProse(s string) string { + var keep []string + for _, line := range strings.Split(s, "\n") { + if isRule(strings.TrimSpace(line)) { + continue + } + keep = append(keep, line) + } + return strings.TrimSpace(strings.Join(keep, "\n")) +} + +func isRule(s string) bool { + if len(s) < 3 { + return false + } + return strings.Trim(s, "-=*_") == "" +} + // specKey orders tests by their spec tag (T-1 before T-10, S before R…). func specKey(name string) string { n := strings.TrimPrefix(name, "Test") @@ -145,6 +253,10 @@ func specKey(name string) string { func quote(s string) string { var out strings.Builder for _, line := range strings.Split(s, "\n") { + if line == "" { + out.WriteString(">\n") + continue + } out.WriteString("> " + line + "\n") } return out.String()