SLOPSHOPPER

review-cycle

Automated multi-agent code review cycle. Spawns reviewers in parallel (plus Codex when available), applies fixes per CLAUDE.md policy, admits a commit only…

newguardprompttoolprocesstimer
v0.28.0MITupdated 2026-10-08oakoss/claude-plugins/plugins/review-cycle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-cycle
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by review-cycle: review-cycle: `rm` alongside a commit or push. Run the commit as its own command, ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

review-cycle

Automated multi-agent code review cycle for Claude Code, with a commit gate that admits a commit only when a reviewer saw exactly what it records, and a push or pull request only when you asked for one or set it to go that far.

What it does

After you implement changes, review-cycle fans out parallel reviewers, applies fixes per embedded policies, loops until a pass applies no fixes or reaches its round limit, and runs a final cleanup. Agents work as they like between commits; nothing prompts a review on every turn.

The gate asks one question of a commit and one of a push:

  • Did a reviewer see it? Every path a commit records must hold content some review-cycle reviewer saw — unchanged from when that reviewer was spawned until it reported. An edit after the last review, an inline fix included, is unreviewed until a reviewer sees it again. A reviewed commit needs nothing else: it stays on your machine, and undoing one is a git reset, so the agent commits reviewed work without asking you, unless your stop-before setting is commit.
  • Did you ask for the push? The latest message you typed has to ask for one ("push it", "ship it", "ok, we can push", or "delete the branch", since deleting a remote branch is a push) or answer yes to the agent's question about one. After the agent asks to delete a branch it names ("Delete fix/x from origin?"), "delete it" answers too. Any of these allows every push until your next message, not only the one you named. Opening a pull request with gh pr create works the same way: "open a PR", "create the pull request" and "ship it" ask for one, and asking for one also allows the push it needs, unless that push would change the default branch or push a tag. Your stop-before setting can let either run without asking. When neither allows it, the gate refuses the command and tells the agent to stop and ask you in its reply, naming what it would push and where ("Push fix/x to origin?"), with the command quoted and any shell aliases expanded. There is no dialog: you answer in your next message, so "yes" allows it, as does a short go-ahead ("ok, lets do that", "sounds good, go ahead"), and anything else, such as "not yet, rename the helper first", is simply your next request. If the command also commits, the refusal says to run the commit on its own. A force push has to be asked for by name: "force push it" (or "yes" to the agent's "Force-push fix/x with a lease?") allows --force-with-lease and --force-if-includes. A bare --force, -f, +refspec or --mirror overwrites whatever the remote holds, so the gate points the agent to --force-with-lease --force-if-includes instead, and allows a bare force only when the request itself names it ("force push it without a lease", "bare force push", "push it with --force"). A bare force mentioned elsewhere in the message, or in the agent's question, grants nothing. Per git's documentation, --force-with-lease alone checks against your remote-tracking ref, which a background fetch can move; --force-if-includes closes that gap. Question dialogs, the agent's own or another plugin's, never grant anything. Messages from other sessions, background-task notifications and subagents never count, and a -p run, with no one to answer, stays refused.

Both answers come from what the gate watched in this session: which reviewers the engine spawned, what the working tree held when each started and finished, and which prompts you typed. None of it is a file, so there is nothing to mark, accept, or forge.

Architecture

Implement changes (no gate while you work)
       ↓
/review-cycle:review (you ask, or the agent runs it before committing)
       ↓
  ┌────┴─────┐
  ↓          ↓
Codex      review-cycle reviewers
(if        (parallel subagents; the gate records
installed)  the tree each one saw; spec conformance
            joins the first round)
  └────┬─────┘
       ↓
Scope wrong for the spec? → stop and ask you
       ↓
Aggregate findings → apply fixes
       ↓
Loop until a pass applies no fixes
(at the round limit: hold the last findings, ask you)
       ↓
Post-loop pass, once: maintainability
(report-only) + cleanup
       ↓
Coverage check (confirmation pass if cleanup changed anything)
       ↓
git commit ── the gate checks: every path reviewed?

Skills

/review-cycle:init

One-time setup helper. Run after installing the plugin to:

  • Check for the optional Codex CLI, verify multi_agent = true in ~/.codex/config.toml, and report stored-login state (advisory — auth doesn't gate the leg)
  • Check that git is present and that the commit gate loaded (see Requirements)
  • Optionally append the comment, fix-vs-defer, and evidence policies to your global or project CLAUDE.md

Idempotent — safe to run multiple times. Replaces the manual setup steps below.

/review-cycle:review

The one command for the whole cycle. Fans out reviewers, checks the change against its spec in the first round and stops for your decision when the scope is wrong, auto-applies the safe fixes, loops until a pass applies none, names any file the fixes touched in every round, surfaces structural suggestions for you to act on, runs a final de-slopify pass, and checks that every changed path is covered. When the result is clean, it commits it unless you held off; it pushes only when you asked or your stop-before setting allows it.

The apparatus scales to the diff: light diffs (docs-only, or ~25 changed lines or fewer of anything) get the code reviewer alone, plus spec conformance in the first round when a spec exists; everything else gets the full conditional fan-out. The loop ends on a pass that applies no fixes, since every fix is content no reviewer has seen; a ceiling (2 light, 3 full) stops a cycle that will not converge. The last round applies none of its fixes: it lists them and asks whether to apply them, with one more review of the fixes, or commit with them deferred, which the gate admits because the round that found them covered every path. The Codex leg joins either tier when it's available. Cleanup is a separate, size-only decision — inline under ~150 changed lines, the cleanup agent above that, whatever the tier. Iterations whose fixes were mechanical, or message-only fixes the agent reproduced and verified, get a narrow confirmation pass — the code reviewer alone, on the fixes' delta — instead of a full fan-out; claims local verification can't reach (another OS or shell, a remote service) add the Codex leg to that pass at reduced effort. A reviewer that stalls is nudged once, then dropped and named in the summary rather than holding the cycle hostage. One that keeps working past 30 minutes is capped: the gate keeps each reviewer's clock and asks the session to stop it, and the summary lists it apart from the stalled ones. The budget clears the slowest normal reviewers measured (a 16-minute median for a fan-out's slowest leg, 25 minutes for the slowest reviewer type) and cuts off the 60–70 minute runaways. A plugin reload cancels the clocks of reviewers already running. Every reviewer works in its own directory inside one scratch directory per cycle, which the cycle sweeps at the end: it ends any process still running there, including one a stalled or dropped reviewer left behind, and removes the directory. Every reviewer opens its report with a two-line receipt — the heaviest verification that succeeded, then every verification that did not succeed plus any project check it never attempted — and the summary grades each leg from both lines as executed, partial with the part it couldn't reach, static-analysis-only, or unknown when a line is missing. A leg that could not run the project's checks keeps the findings it saw by reading; claims about an external tool's behavior drawn only from a manifest or config become questions instead of fixes, wherever they come from — the label says whether the leg could have checked, not whether the rule applies. A leg that omitted the receipt is labelled but not demoted for that alone, unless something else in its report shows it could not run the checks — a formatting miss should not cost you a finding, but omitting one should not beat admitting it.

The gate remembers what each reviewer saw for the rest of the session, and the next cycle scopes itself to what changed since: a 20-line follow-up to a converged review gets a small review at the delta's tier, not a full re-run. A review from an earlier session does not count; uncommitted work carried across a restart is reviewed again.

Arguments are natural language — no flags:

  • bare /review-cycle:review — review the uncommitted working tree
  • against <ref> (e.g. against main) — scope to git diff <ref>..HEAD
  • max <n> — override the iteration ceiling
  • effort <level> — pin the Codex leg's reasoning effort (none, minimal, low, medium, high, xhigh, max); overrides both the tier cap and your config, raising included

/review-cycle:review-pr

Single-pass, report-only review of a GitHub pull request, run from your machine. Takes a PR number, URL, or branch (bare invocation reviews the current branch's PR). It fetches the PR head into a disposable detached worktree — your checkout, branch, and index are never touched. The fan-out matches the review cycle's, with the intent brief sourced from the PR's title, body, and commits; on the full tier the report-only pair joins the same pass, since a single pass has no fix loop to shield them from. Findings are reported in the conversation with per-reviewer coverage, so "no findings" is never mistaken for "nobody looked". The Codex leg joins when the CLI is installed, briefed and scoped with --base against the PR's base branch; effort <level> pins its reasoning effort the same way it does in /review-cycle:review.

Nothing is fixed and nothing is posted by default. Say and post (or ask after reading the report) to publish the findings as a single COMMENT review — never an approval — with fingerprint-marked comments, inline and body-level alike, that deduplicate across re-runs. Its reviewers never count toward your own changes: the gate sees the skill start, and the legs it spawns review the PR, not your working tree.

/review-cycle:misses

Lists the gate's refusals of a push, pull request, merge or release that your latest message, or the offer it replied to, named by its verb, and drafts each as a candidate row for the consent grammar's spec. A refusal you did not expect is how the grammar's misses are found; this keeps them as they happen, instead of from a pasted transcript. A refusal counts only when the step refused is the one named; an offer counts only when your message is a yes or a go-ahead and nothing more. The gate keeps up to 20 in memory until Claude Code restarts, served by the mcp__review-cycle__misses tool; nothing leaves your machine. You decide which rows were requests.

/review-cycle:de-slopify

Bundled de-slopify skill — methodology for removing AI writing artifacts from prose, maintained here as part of the plugin (originally imported from oakoss/agent-skills, which no longer carries the canonical copy). The cleanup subagent preloads this skill, so the cycle uses it automatically. Invokable directly for ad-hoc cleanup of prose outside the cycle. Aligned with the standalone prose plugin's rules, so cycle cleanup and the always-on style apply the same standard.

Subagents (bundled)

Derived from Anthropic's pr-review-toolkit and modified by Oak OSS (Apache 2.0; see LICENSE-pr-review-toolkit):

  • review-cycle:code-reviewer — general quality + CLAUDE.md compliance
  • review-cycle:silent-failure-hunter — error handling, swallowed errors
  • review-cycle:type-design-analyzer — type invariants, encapsulation
  • review-cycle:pr-test-analyzer — test coverage gaps

CHANGELOG.md records the changes. The toolkit's code-simplifier is left out because it competes with the cycle's own fix application, and cleanup replaces its comment-analyzer.

New (this plugin):

  • review-cycle:cleanup — comment policy + de-slopify in one pass, correcting prose whose claims a run contradicts
  • review-cycle:maintainability-auditor — ambitious structural lens (code-judo moves, file-size sprawl, spaghetti branches, weak seams). Runs in review on substantial-code diffs, report-only — its speculative restructurings are surfaced for you to action, never auto-applied.
  • review-cycle:spec-conformance-analyzer — spec axis: does the diff implement what the originating issue/task/PRD asked for? Runs in the first round of review when a spec source is discoverable, so scope creep against a current spec stops the cycle before any review fix, while a missing or contradicted spec line is fixed like any defect; reported separately from quality findings.

The commit gate

What it guards against

The gate is there for a well-meaning agent that commits before a review, or pushes before you asked. It checks the commands agents write, such as git commit, git add … && git commit, a push, or an alias for one, before they run, and refuses one that names a commit or push in a shape it cannot read rather than guess. A command that commits from inside something the gate cannot see into, such as npm version, make release or a project script, is not checked beforehand: a commit it makes without a review, or a push you did not ask for, is reported after the command runs, so the agent tells you, but it is not refused. If the gate cannot read your shell aliases, an aliased commit is not checked, and the first command the gate lets through unchecked carries a note saying so.

It is not a sandbox. An agent set on getting around it can: an obscure shell construct or an environment trick can run git where the gate does not look, and your own shell (!) is never stopped. For containment against an adversarial agent, use an OS-level sandbox; the gate's job is to make the careless path fail loudly, not to make evasion impossible. A gap an ordinary command falls into is a bug; one that needs a deliberately obscure command is out of scope.

How it works

The gate is a hooks module (hooks/register.ts), not a shell script: it keeps what it observes in memory for the session, where no tool the agent holds can reach. It watches:

  • Your prompts. Only prompts the engine stamps as yours — typed at the terminal, from the Remote Control bridge, or the SDK host's own turn — can grant a push or a pull request. A grant lasts until your next prompt.
  • Reviewer spawns and completions. A review-cycle:* reviewer spawned from the main session counts once it reports with its two-line receipt. The gate captures the working tree as the reviewer starts and again as it finishes; the review covers a path only if the path was part of the change it was shown and held the same content at both moments. A review that ran but could not be counted is named in the next refusal and in the status tool. review-cycle:cleanup edits code and never counts, and reviewers /review-cycle:review-pr spawns review a PR, not your tree.
  • What reviewers change. A reviewer works on a copy outside the repository, such as a mktemp -d directory under /tmp. While it runs, the gate refuses its Edit, Write and NotebookEdit calls on a path inside the repository. Its Bash commands are not refused, since the gate cannot tell where a command writes. Instead the gate compares four things before and after each one: HEAD's commit and branch, the staged entries, the working tree, and the local and worktree git config. The reads leave the index, refs and config alone; the working-tree capture adds unreferenced objects to .git/objects, which git gc removes. When something changed, the gate tells the reviewer it may have been another agent, asks it to put back a change it made and to report it either way, and lists the change in the status tool under reviewerChanges. A part the gate could not read is noted there too. Some things are not checked:
  • writes outside the repository;
  • anything under .git/ beyond HEAD and the config, such as branches, the stash or hooks;
  • what a background command changes after it returns;
  • ignored files, such as build output or node_modules, which never reach a commit;
  • commands a reviewer runs through Monitor;
  • the edits of a subagent a reviewer spawns.

Each comparison costs about two working-tree captures per reviewer command; one measured 48 ms on a 123-file repository.

  • Every Bash call. A command that commits or pushes has to take a shape the gate can check: an optional leading cd <dir>, any git add …, read-only git commands and steps that commit nothing (fetch, branch, tag, remote, a fast-forward with pull --ff-only when no git commit follows, a statement that only assigns variables with no substitution or redirect; checkout, switch, stash and worktree only when no git commit follows, since they can change what it records), then one git commit … (or one command that makes commits from history: merge, cherry-pick, revert, pull, rebase) and optionally one git push …. A git add is joined to what follows with &&, so a failed add stops the commit; other steps may also use ; or newlines. The whole command may end in a pipe into tail with at most one line or byte count (nonzero, or +N to start from line N), or wc with its counting flags, as in git push 2>&1 | tail -3: those read all of git's output and run nothing. The filter takes no file, redirect or substitution, and the pipeline is not backgrounded with &. Other readers such as head, grep or cat file may stop reading early, which can kill a pre-commit hook still printing, so they are refused. Anything else that commits or pushes — a pipeline, a subshell, a substitution, bash -c, eval, a wrapper like timeout or xargs, a git alias for commit (aliases of aliases included, and one defined in the same command), commit <paths>, an abbreviated long option (--ame), GIT_INDEX_FILE=, a GIT_EDITOR/GIT_PAGER that is more than a program name, and git's commit-writing plumbing (commit-tree, fast-import, send-pack, subtree push) — is refused with the shape to use instead. Your shell's aliases are expanded as bash expands them, so gcam "msg" is judged exactly like git commit -s -a -m "msg", and an alias of several commands is judged like the commands it stands for. An alias for git itself is expanded too: git='git --no-pager' is judged as git with that option, but a command whose expansion no longer runs git as the gate reads it (git='hub', or git wrapped in a runner the gate refuses) is refused, with \git … to run git as itself. A command name or git subcommand the shell would expand as a pattern is refused: *, ?, a closed […] or {…}, zsh's #, ^ and inner ~, and a ( inside a word (/usr/bin/g(i)t, @(git)). The gate reads your aliases when the session starts, the way Claude Code builds its shell snapshot: CLAUDE_CODE_SHELL, else $SHELL, whichever names zsh or bash, else zsh, run as a login shell that sources ~/.zshrc or ~/.bashrc with no input and with CLAUDECODE=1 set, then lists them with the same pipeline the snapshot uses. Claude Code writes its own snapshot only once the session's first command has run, so this read is what covers that first command. If the read fails, takes more than ten seconds, or the rc file never gets to the end of it, the gate falls back to the snapshot (under ~/.claude/shell-snapshots/); if neither can be read, the first command the gate lets through without a check carries a note saying so. Text naming a git commit or push is data only where nothing will run it: an argument to grep, printf or gh, a quoted heredoc into cat. Handed to anything that runs code — a shell, an interpreter like python3, find -exec, a shell reading a pipe or heredoc — it is refused. Commands that make commits from history need neither a review nor your go-ahead, and are not reported afterwards: what they record comes from existing commits, not from the working tree. Their --continue forms record a conflict resolution, which is new content, so the gate refuses them, as it does git am, which applies patches from outside the repository.

For an accepted commit, the gate replays the command's git adds against a scratch copy of the index, so it judges exactly the tree the commit would record, then compares each path with the trees reviewers saw. A refusal names every uncovered path as edited after the last review or never reviewed. The real index is never touched, and a refused git add … && git commit … runs neither half.

When a turn you started ends having changed the working tree, and some of what changed has not been seen by any reviewer, the gate sends the agent one prompt telling it to run /review-cycle:review and report back, so it does not stop to ask whether to. The gate sends at most one per message of yours. It sends none while a reviewer is still running, none after an interrupted turn, none once a review has been started, and none for unreviewed work left over from before your message. The agent skips the review when your message said not to review, or when it ended the turn on a question you need to answer first.

Scripts and shell functions are opaque: ./release.sh runs whatever it contains. So after every Bash call, the gate reads what the call added to HEAD's reflog. A commit that slipped past the check — a script that commits, or a pre-commit hook that rewrote a file after the check — is reported back to the agent, which is told to tell you. An entry counts as a commit unless git's own action says it only moved HEAD: a checkout, a reset, a fetch, a fast-forward, a rebase starting or finishing, gh pr merge pulling the merged branch. So a commit, amend, merge commit, cherry-pick, revert or rebase pick counts, and so does an entry git gives no known action, which errs toward a report. That includes a commit made on another branch before HEAD came back. Each unbroken run of new commits is judged from where HEAD stood before it, so a rebase counts its own changes and not the upstream it rebased onto, and a commit on a side branch is judged apart from one on this branch. Not seen: a commit built with plumbing and reached by reset, which logs only the reset; a commit made in another worktree, which has its own HEAD; in a repository that keeps no HEAD reflog (core.logAllRefUpdates off from the start), a commit after which HEAD returns to where it started; and a commit whose reflog entries the same command deleted. The gate says it could not check when HEAD moved and its reflog does not show how, the reflog was expired or rewritten, one command added more than 200 entries, or HEAD is unborn in a reftable repository. A push is seen by the remote-tracking ref it moves or creates, which git logs as update by push: one the gate did not check is reported when your latest message did not ask for a push. Not seen: a push to a URL or a remote with no tracking ref, one that deletes a remote branch, one undone before the command ends, one whose reflog git did not keep, and one buried under more than 50 later moves of the same ref in one command.

Subagents never commit or push in the project, and neither does anything run from another worktree of the same repository. Commits in other repositories (a scratch fixture, git -C /tmp/…) are not the gate's business. When the gate cannot finish judging a command that may commit or push — git failed, or a target directory does not resolve — it refuses rather than letting it through.

The mcp__review-cycle__status tool reports the gate's view: the wo

Source 21 files
hooks/register.ts 1850 lines
1import type {
2  Caught,
3  CatchHandler,
4  EngineInterface,
5  HookFor,
6  MatchedHook,
7  PromptOrigin,
8  Register,
9  Timer,
10  ToolCallResult,
11} from 'claude-code';
12
13import { added, madeBy, MARK, type Entry } from './attribution';
14import {
15  aliasCommits,
16  classify,
17  ghActions,
18  possibleAliases,
19  shownCommand,
20  type Classification,
21} from './command';
22import {
23  covers,
24  grantOf,
25  holdOf,
26  liftsHold,
27  NO_GRANT,
28  isReply,
29  stepsNamed,
30  type Grant,
31  type HoldStep,
32} from './consent';
33import { containmentReport, insideRepo, repoStateOf, UNREAD, type Capture } from './containment';
34import { editsSkipped, mayWrite, measureEdits } from './edits';
35import {
36  askThem,
37  judgeGh,
38  notAsked,
39  parsePullRequest,
40  permitted,
41  PR_FIELDS,
42  PR_JQ,
43  type GhLookups,
44  type Hold,
45  type InForce,
46  type Lookup,
47  type Unasked as GhUnasked,
48} from './gh-verdict';
49import {
50  coverageOf,
51  firstLine,
52  headOf,
53  parentTree,
54  prospectTree,
55  headLog,
56  headLogCount,
57  messageOf,
58  pushedRefs,
59  remoteRefs,
60  repoAt,
61  reviewablePaths,
62  snapshotOf,
63  treeOf,
64  worktreeTree,
65  type Coverage,
66  type Git,
67  type Refs,
68  type Repo,
69  type Run,
70} from './git';
71import type { PushSpec } from './git-args';
72import { MCP_GITHUB, mcpAction, shownCall } from './github';
73import {
74  configured,
75  STRICTEST,
76  effective,
77  stopBeforeOf,
78  where,
79  type Step,
80  type StopBefore,
81} from './ladder';
82import {
83  KINDS,
84  MAX_LEDGER_BYTES,
85  MAX_REPOS,
86  MAX_TEXT,
87  parseRecord,
88  type Recording,
89} from './ledger';
90import { blobsAt, readLedger, recordInto, type Store } from './ledger-store';
91import { asksUser, nudgeOf, nudgeOn } from './nudge';
92import {
93  askingReason,
94  parseDryRun,
95  pushOutcome,
96  unasked,
97  type DefaultBranch,
98  type Needed,
99} from './push-verdict';
100import { applyEdit, bashTouchesGate, isJsonPath, touchesGate } from './settings';
101import { aliasScript, aliasShell, parseShellAliases, readAliases } from './shell';
102import { MAX_BYTES, skipsPath, slopDirective, slopFindings, type Written } from './slop';
103import { sweep } from './sweep';
104import {
105  EMPTY_TREE,
106  describeUncovered,
107  hasReceipt,
108  isReviewerType,
109  uncoveredOf,
110  type Review,
111  type Row,
112} from './witness';
113
114// Every function that calls `$` lives in this file: `claude plugin validate`
115// follows `$` only into functions declared beside the hook, never across an
116// import. The other modules are pure.
117//
118// The gate watches Bash with a function hook, not the classic PreToolUse
119// bridge. While any `tool.call { tool: "Bash" }` hook is loaded, Bash inside an
120// `isolation: "worktree"` subagent is refused (anthropics/claude-code#92533);
121// the bridge avoids that at the cost of a stub script, should it matter.
122
123type $ = EngineInterface;
124type BashHook = MatchedHook<'tool.call', { tool: 'Bash' }>;
125type GithubHook = MatchedHook<'tool.call', { tool: RegExp }>;
126type SkillHook = MatchedHook<'tool.call', { tool: 'Skill' }>;
127type StatusHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__status' }>;
128type LedgerHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__ledger' }>;
129type RecordHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__ledger_record' }>;
130type ScratchHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__scratch' }>;
131type SweepHook = MatchedHook<'tool.call', { tool: 'mcp__review-cycle__sweep' }>;
132type ConfigHook = MatchedHook<
133  'config.set',
134  { key: 'review-cycle.enabled' | 'review-cycle.stopBefore' | 'review-cycle.nudge' }
135>;
136type EditHook = MatchedHook<'tool.call', { tool: 'Edit' }>;
137type WriteHook = MatchedHook<'tool.call', { tool: 'Write' }>;
138type NotebookHook = MatchedHook<'tool.call', { tool: 'NotebookEdit' }>;
139type MonitorHook = MatchedHook<'tool.call', { tool: 'Monitor' }>;
140type Input<H> = H extends ($: never, e: infer E, next: never) => unknown ? E : never;
141type NextOf<H> = H extends ($: never, e: never, next: infer N) => unknown ? N : never;
142type Output<H> = H extends (...args: never[]) => infer R ? Awaited<R> : never;
143
144const HUMAN = new Set<PromptOrigin['kind']>(['composer', 'bridge', 'sdk']);
145
146// `spawnTree` is null when the tree could not be read as the leg started; such
147// a leg reviews nothing, but it is still under way until `done`.
148type Leg = {
149  type: string;
150  counts: boolean;
151  spawnTree: string | null;
152  done: boolean;
153  // Fires once the leg outlives its budget; cancelled when it finishes.
154  timer: Timer | null;
155  capped: boolean;
156};
157
158// Measured 2026-09-19 over 138 legs: a fan-out's slowest leg had a median of
159// 16 minutes and the slowest normal leg type 25; runaways ran 60 to 70.
160const LEG_BUDGET_MS = 30 * 60_000;
161
162// What the user's latest message settled: a fresh message replaces it, a
163// prompt queued into the running turn updates it.
164type Message = {
165  grant: Grant;
166  // The working tree when it arrived; null when unreadable.
167  tree: string | null;
168  // Whether the agent was told to review since it arrived.
169  nudged: boolean;
170  // Legs spawned while review-pr runs review a PR worktree, not this tree.
171  prWindow: boolean;
172};
173
174type Miss = { message: string; offer: string; steps: HoldStep[]; refusal: string };
175
176type GateState = {
177  // undefined until resolved; null when the session is not in a repository.
178  root: Repo | null | undefined;
179  reviews: Review[];
180  legs: Map<string, Leg>;
181  message: Message;
182  // How many messages the user has typed, queued ones included.
183  messages: number;
184  lastAnswer: string;
185  // The user's latest message, and the answer it replied to.
186  said: { text: string; before: string };
187  // Refusals of a step the user's message or the offer it answered named:
188  // candidate consent-grammar misses, kept in this session only.
189  misses: Miss[];
190  // The user's shell aliases, which the Bash tool expands.
191  shellAliases: Map<string, string>;
192  aliasError: string | null;
193  // Whether the agent has been told the aliases could not be read.
194  aliasErrorShown: boolean;
195  aliasesLoaded: boolean;
196  // The session-start read of the user's aliases; its error, or null.
197  ownRead: Promise<string | null> | undefined;
198  // Reviews that ran but could not be recorded, and why.
199  dropped: string[];
200  // Legs that outlived their budget this cycle, which the session was asked to stop.
201  capped: string[];
202  // Scratch directories made for review cycles and not yet swept.
203  scratch: string[];
204  // Whether the sweep tool registered; scratch makes nothing without it.
205  sweepServed: boolean;
206  // How many of `dropped` predate the latest recorded review.
207  droppedSince: number;
208  // What reviewers' commands changed in the repository under review.
209  reviewerChanges: string[];
210  // False when the user switched the gate off; the ledger is still served.
211  gateOn: boolean;
212  // From user or managed settings, which alone reach the register options.
213  stopBefore: StopBefore | null;
214  // The user's /config value for the end-of-turn review nudge.
215  nudge: boolean;
216  // "don't push yet" makes every step ask until a message asks for one.
217  held: Hold;
218};
219
220const state: GateState = {
221  root: undefined,
222  reviews: [],
223  legs: new Map(),
224  // No message yet, so nothing to nudge about.
225  message: { grant: NO_GRANT, tree: null, nudged: true, prWindow: false },
226  messages: 0,
227  lastAnswer: '',
228  said: { text: '', before: '' },
229  misses: [],
230  shellAliases: new Map(),
231  aliasError: null,
232  aliasErrorShown: false,
233  aliasesLoaded: false,
234  ownRead: undefined,
235  dropped: [],
236  capped: [],
237  scratch: [],
238  sweepServed: false,
239  droppedSince: 0,
240  reviewerChanges: [],
241  gateOn: true,
242  stopBefore: null,
243  nudge: true,
244  held: null,
245};
246
247// Without `cwd`, $.process.run runs in the Bash tool's current directory,
248// which is what a command's own relative paths resolve against.
249async function run(
250  $: $,
251  argv: string[],
252  opts: { cwd?: string; env?: Record<string, string>; stdin?: string } = {},
253): Promise<Run> {
254  // git's messages are matched in English, so its locale is pinned.
255  const env = { LC_ALL: 'C', LANGUAGE: 'C', ...opts.env };
256  return $.process.run(argv, { timeoutMs: 60_000, ...opts, env });
257}
258
259// The runner git.ts's reads take, bound to this hook's `$`.
260function gitOf($: $): Git {
261  return (argv, opts) => run($, argv, opts);
262}
263
264// Why uncovered content is uncovered, including what the gate itself lost.
265function explain(c: Coverage): string {
266  const parts = [describeUncovered(uncoveredOf(c.rows))];
267  if (c.unread > 0) parts.push(`${c.unread} reviewed tree(s) could not be compared: git failed`);
268  const dropped = state.dropped.slice(state.droppedSince).slice(-3);
269  if (dropped.length > 0) parts.push(`reviews not counted: ${dropped.join('; ')}`);
270  return parts.join('; ');
271}
272
273// Claude Code writes its shell snapshot only once the session's first Bash
274// command runs, after this hook, so the gate reads the aliases itself, the
275// way the snapshot does, when the session starts. Once per module load: an rc
276// file that hangs costs the timeout once, then the snapshot is read instead.
277async function readOwnAliases($: $): Promise<string | null> {
278  try {
279    const tag = Math.random().toString(36).slice(2);
280    const home = await $.env.get('HOME');
281    if (!home) return 'HOME is not set';
282    const shell = aliasShell(await $.env.get('CLAUDE_CODE_SHELL'), await $.env.get('SHELL'));
283    const rc = `${home}/${shell.rc}`;
284    const script = aliasScript((await $.fs.exists(rc)) ? rc : null, tag);
285    // The environment and time limit Claude Code gives its own snapshot: an rc
286    // file can branch on them.
287    const env = { CLAUDECODE: '1', SHELL: shell.path, GIT_EDITOR: 'true' };
288    const argv = [shell.path, '-c', '-l', script];
289    const r = await $.process.run(argv, { stdin: '', timeoutMs: 10_000, env });
290    if (r.exitCode !== 0) {
291      return `the shell exited ${r.exitCode}: ${firstLine(r.stderr) || 'no output'}`;
292    }
293    const aliases = readAliases(r.stdout, tag);
294    if (aliases === null) return 'the shell stopped before printing its aliases';
295    state.shellAliases = aliases;
296    state.aliasesLoaded = true;
297    return null;
298  } catch (error) {
299    return error instanceof Error ? error.message : String(error);
300  }
301}
302
303// Failing both reads leaves the aliases unknown, not empty: the snapshot is
304// retried on the next Bash call, and the first unchecked command says so.
305async function loadShellAliases($: $): Promise<void> {
306  if (state.aliasesLoaded) return;
307  state.ownRead ??= readOwnAliases($);
308  const own = await state.ownRead;
309  if (state.aliasesLoaded) return;
310  try {
311    const home = await $.env.get('HOME');
312    const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : null);
313    if (!config) throw new Error('neither CLAUDE_CONFIG_DIR nor HOME is set');
314    const shell = basenameOf((await $.env.get('SHELL')) ?? '');
315    const dir = `${config}/shell-snapshots`;
316    const newest = newestSnapshot(await $.fs.list(dir), shell);
317    if (newest === null) throw new Error(`no shell snapshot in ${dir} yet`);
318    state.shellAliases = parseShellAliases(await $.fs.read(`${dir}/${newest}`), true);
319    state.aliasError = null;
320    state.aliasesLoaded = true;
321  } catch (error) {
322    // Another Bash call's read may have succeeded while this one waited.
323    if (state.aliasesLoaded) return;
324    const snapshot = error instanceof Error ? error.message : String(error);
325    state.aliasError = `reading them from the shell failed (${own}); ${snapshot}`;
326  }
327}
328
329function basenameOf(p: string): string {
330  return p.slice(p.lastIndexOf('/') + 1);
331}
332
333// Snapshot files are named snapshot-<shell>-<milliseconds>-<id>.sh.
334function newestSnapshot(
335  entries: readonly { name: string; kind: string }[],
336  shell: string,
337): string | null {
338  let best: { name: string; at: number; same: boolean } | null = null;
339  for (const e of entries) {
340    const m = /^snapshot-([\w-]+?)-(\d+)-[\w-]+\.sh$/.exec(e.name);
341    if (e.kind !== 'file' || !m) continue;
342    const candidate = { name: e.name, at: Number(m[2]), same: m[1] === shell };
343    if (
344      best === null ||
345      (candidate.same && !best.same) ||
346      (candidate.same === best.same && candidate.at > best.at)
347    ) {
348      best = candidate;
349    }
350  }
351  return best?.name ?? null;
352}
353
354async function ensureRoot($: $): Promise<Repo | null> {
355  if (state.root === undefined) {
356    const repo = await repoAt(gitOf($), null, await $.session.cwd());
357    state.root = repo === 'none' ? null : repo;
358  }
359  return state.root;
360}
361
362function deny(reason: string): { deny: string } {
363  if (reason.includes(ASKS_THEM)) noteMiss(reason);
364  return { deny: `review-cycle: ${reason}` };
365}
366
367// What every refusal asking the user to decide a step says.
368const ASKS_THEM = 'stop and ask them in your reply';
369const MAX_MISSES = 20;
370const MISS_TEXT = 300;
371const clip = (s: string) => s.slice(0, MISS_TEXT);
372
373// The question the answer ended on, which a reply like "yes" answers.
374// The answer's last three lines, which the grammar reads an offer from.
375function closingOf(answer: string): string {
376  return answer.trim().split('\n').filter(Boolean).slice(-3).join('\n');
377}
378
379// The question those lines end on. Sentences split at a mark before a space,
380// so a version in backticks ("Release `v0.25.0`?") stays whole.
381function questionOf(closing: string): string {
382  const sentences = closing.split(/(?<=[.!?])\s+/);
383  return sentences.findLast((s) => s.trim().endsWith('?'))?.trim() ?? '';
384}
385
386// The step a refusal is about, from how notAsked and the merge check word it:
387// "doesn't ask for a merge", "the merge may be a release". Commits and
388// approvals are not consent the grammar misses.
389function refusedStep(refusal: string): HoldStep | null {
390  if (/\bmay be a release\b/.test(refusal)) return 'release';
391  const named =
392    /\bask(?:ed)? for (a (?:force |bare )?push|a bare force|a pull request|a merge|a release)\b/.exec(
393      refusal,
394    )?.[1];
395  if (named === undefined) return null;
396  if (named.endsWith('push') || named === 'a bare force') return 'push';
397  return named === 'a pull request' ? 'pr' : named === 'a merge' ? 'merge' : 'release';
398}
399
400function grants(grant: Grant, step: HoldStep): boolean {
401  if (step === 'push') return covers(grant, 'push');
402  if (step === 'merge') return grant.merge || grant.autoMerge;
403  // A merge the user asked for covers the version pull request too.
404  if (step === 'release') return grant.release || grant.merge;
405  return grant.pr;
406}
407
408function noteMiss(refusal: string): void {
409  const step = refusedStep(refusal);
410  if (step === null) return;
411  const { text, before } = state.said;
412  // The whole closing is kept, so a spec row drafted from it reads as the gate did.
413  const offer = isReply(text) ? closingOf(before) : '';
414  const steps = [...new Set([...stepsNamed(text), ...stepsNamed(questionOf(offer))])];
415  // Merging the version pull request is a release, so naming the merge counts.
416  const named = steps.includes(step) || (step === 'release' && steps.includes('merge'));
417  // A message queued while the gate judged may grant what an older one did not.
418  if (!named || grants(grantOf(text, before), step)) return;
419  const miss = { message: clip(text), offer: clip(offer), steps, refusal: clip(refusal) };
420  const same = (m: Miss) => m.message === miss.message && m.refusal === miss.refusal;
421  state.misses = [...state.misses.filter((m) => !same(m)), miss].slice(-MAX_MISSES);
422}
423
424async function onSessionStart(
425  $: $,
426  e: Input<HookFor<'session.start'>>,
427  next: NextOf<HookFor<'session.start'>>,
428): Promise<Output<HookFor<'session.start'>>> {
429  await registerLedger($);
430  await registerScratch($);
431  if (!state.gateOn) return next(e);
432  try {
433    await ensureRoot($);
434  } catch {
435    // Resolved again on first use; a gated command refuses if it still fails.
436  }
437  // Not awaited: the first Bash call waits on it instead of the session start.
438  state.ownRead ??= readOwnAliases($);
439  try {
440    await $.tool.register({
441      name: 'status',
442      description:
443        "review-cycle's view of the working tree: which changed paths a reviewer has seen, which were edited after the last review or never reviewed, and whether a push or a pull request may run now: asked for in the user's latest message, or below their stop-before setting. Read-only.",
444      inputSchema: { type: 'object', properties: {} },
445    });
446  } catch {
447    // The gate works without its status tool; the skill notes when it is missing.
448  }
449  try {
450    await $.tool.register({
451      name: 'misses',
452      description:
453        "The gate's refusals of a push, pull request, merge or release that the user's latest message, or the offer it answered, named: candidate rows for the consent grammar's spec. Each has the message, the offer (the answer's last lines, when the message replied to it), the steps named and the refusal. Read-only; kept in memory until Claude Code restarts.",
454      inputSchema: { type: 'object', properties: {} },
455    });
456  } catch {
457    // The misses skill says when the tool is missing.
458  }
459  return next(e);
460}
461
462async function onPromptSubmit(
463  $: $,
464  e: Input<HookFor<'prompt.submit'>>,
465  next: NextOf<HookFor<'prompt.submit'>>,
466): Promise<Output<HookFor<'prompt.submit'>>> {
467  if (HUMAN.has(e.origin.kind)) {
468    state.messages++;
469    // What a held message asks for still runs: "push it; don't open a PR yet".
470    state.said = { text: e.text, before: state.lastAnswer };
471    const grant = grantOf(e.text, state.lastAnswer);
472    const hold = holdOf(e.text, state.lastAnswer);
473    if (hold !== null) state.held = hold;
474    else if (liftsHold(grant)) state.held = null;
475    // A prompt queued into a running turn neither ends a review-pr run nor
476    // replaces that turn's starting tree.
477    if (e.turnId !== undefined) {
478      // Updated in place: a pending nudge's rollback holds this record.
479      state.message.grant = grant;
480      state.message.nudged = false;
481    } else {
482      let tree: string | null = null;
483      try {
484        const root = await ensureRoot($);
485        if (root !== null) tree = await worktreeTree(gitOf($), root.top);
486      } catch {
487        // No starting tree means no nudge this message; the commit gate still holds.
488      }
489      state.message = { grant, tree, nudged: false, prWindow: false };
490    }
491  }
492  return next(e);
493}
494
495// The nudge as the settings set it. A file that cannot be read sets nothing,
496// so the others decide: the nudge only prompts a review.
497async function nudgeWanted($: $): Promise<boolean> {
498  const set: Partial<Record<'project' | 'local', boolean | null>> = {};
499  for (const source of ['project', 'local'] as const) {
500    let settings: { pluginConfigs?: unknown } | null;
501    try {
502      settings = await $.settings.read({ source });
503    } catch {
504      continue;
505    }
506    set[source] = nudgeOf(settings?.pluginConfigs);
507  }
508  return nudgeOn(state.nudge, set.project ?? null, set.local ?? null);
509}
510
511// At the end of a main-loop turn that changed the tree, content no reviewer
512// has seen gets one prompt telling the agent to review it, so the agent does
513// not stop to ask the user whether to. Once per user message, and not while a
514// review is under way.
515async function nudgeReview($: $, e: Input<HookFor<'turn.complete'>>): Promise<void> {
516  const message = state.message;
517  if (message.nudged || e.reason !== 'answer' || asksUser(e.answer)) return;
518  const root = state.root;
519  const since = message.tree;
520  if (!root || since === null) return;
521  const pending = [...state.legs].filter(([, leg]) => leg.counts && !leg.done);
522  if (pending.length > 0) {
523    // A leg that ended without a turn.complete would otherwise hold this off for good.
524    const agents = await $.agent.list();
525    const live = new Set(agents.filter((a) => a.status === 'running').map((a) => a.id));
526    for (const [id, leg] of pending) if (!live.has(id)) leg.done = true;
527    if (pending.some(([, leg]) => !leg.done)) return;
528  }
529  const tree = await worktreeTree(gitOf($), root.top);
530  if (tree === since) return;
531  const touched = new Set(await reviewablePaths(gitOf($), root.top, since, tree));
532  const c = await coverageOf(
533    gitOf($),
534    root.top,
535    await headOf(gitOf($), root.top),
536    tree,
537    state.reviews,
538  );
539  if (c === null) return;
540  const rows = c.rows.filter((row) => touched.has(row.path));
541  if (uncoveredOf(rows).length === 0) return;
542  if (!(await nudgeWanted($))) return;
543  message.nudged = true;
544  const text = `review-cycle: this turn left changes no reviewer has seen (${explain({ ...c, rows })}). Invoke /review-cycle:review via the Skill tool now, then report back. Skip it only if the user's latest message said not to review, or your last message asked them something they must answer first.`;
545  // Not awaited: the prompt enters once this turn has ended. A hook's refusal
546  // resolves with `drop` rather than rejecting.
547  Promise.resolve()
548    .then(() => $.prompt.submit({ text }))
549    .then(
550      (r) => {
551        if (r.drop !== undefined) message.nudged = false;
552      },
553      () => {
554        message.nudged = false;
555      },
556    );
557}
558
559function onSkill($: $, e: Input<SkillHook>, next: NextOf<SkillHook>): ReturnType<SkillHook> {
560  if (!e.agentId && e.skill === 'review-cycle:review-pr') state.message.prWindow = true;
561  if (!e.agentId && e.skill === 'review-cycle:review') {
562    state.message.prWindow = false;
563    // A review already under way needs no reminder to start one.
564    state.message.nudged = true;
565  }
566  return next(e);
567}
568
569async function onAgentSpawn(
570  $: $,
571  e: Input<HookFor<'agent.spawn'>>,
572  next: NextOf<HookFor<'agent.spawn'>>,
573): Promise<Output<HookFor<'agent.spawn'>>> {
574  let isLeg = false;
575  let spawnTree: string | null = null;
576  let why = 'the working tree could not be read when it started';
577  try {
578    const root = await ensureRoot($);
579    const inRoot =
580      !e.cwd || (root !== null && (e.cwd === root.top || e.cwd.startsWith(`${root.top}/`)));
581    isLeg = root !== null && !e.parentAgentId && isReviewerType(e.subagentType) && inRoot;
582    // Captured before the leg starts, so it is the tree the leg is given.
583    if (isLeg && root !== null) spawnTree = await worktreeTree(gitOf($), root.top);
584  } catch (error) {
585    isLeg = isReviewerType(e.subagentType) && !e.parentAgentId;
586    why = `${why} (${error instanceof Error ? error.message : String(error)})`;
587  }
588  const r = await next(e);
589  if (isLeg && 'agentId' in r && r.agentId) {
590    if (spawnTree === null) state.dropped.push(`${e.subagentType}: ${why}`);
591    const counts = !state.message.prWindow;
592    const leg: Leg = {
593      type: e.subagentType,
594      counts,
595      spawnTree,
596      done: false,
597      timer: null,
598      capped: false,
599    };
600    state.legs.set(r.agentId, leg);
601    const id = r.agentId;
602    leg.timer = $.clock.after(LEG_BUDGET_MS, () => overBudget($, id, leg));
603  }
604  return r;
605}
606
607// The orchestrator waits on notifications, and a leg that keeps working sends
608// none, so the gate keeps the clock and asks the session to stop the leg.
609function overBudget($: $, agentId: string, leg: Leg): void {
610  if (leg.done || leg.capped) return;
611  leg.capped = true;
612  const minutes = LEG_BUDGET_MS / 60_000;
613  state.capped.push(`${leg.type} (agent ${agentId}): still running after ${minutes} minutes`);
614  const text = `review-cycle: reviewer leg ${leg.type} (agent ${agentId}) has run past its ${minutes}-minute budget. Stop it with the TaskStop tool, continue the cycle without it, and list it under "Reviewers capped (over budget)" in the summary.`;
615  Promise.resolve()
616    .then(() => $.prompt.submit({ text }))
617    .then(
618      (r) => {
619        if (r.drop !== undefined)
620          state.capped.push(
621            `${leg.type} (agent ${agentId}): the request to stop it was refused (${r.drop})`,
622          );
623      },
624      (error: unknown) => {
625        state.capped.push(
626          `${leg.type} (agent ${agentId}): the request to stop it failed (${error instanceof Error ? error.message : String(error)})`,
627        );
628      },
629    );
630}
631
632// A leg's completion is the review event: the tree it saw is the working tree
633// now. Nothing edited after this moment is reviewed until a leg sees it.
634async function onTurnComplete(
635  $: $,
636  e: Input<HookFor<'turn.complete'>>,
637  next: NextOf<HookFor<'turn.complete'>>,
638): Promise<Output<HookFor<'turn.complete'>>> {
639  if (!e.agentId) {
640    state.lastAnswer = e.answer;
641    const r = await next(e);
642    try {
643      await nudgeReview($, e);
644    } catch {
645      // A missed nudge leaves the commit gate to refuse the unreviewed commit.
646    }
647    return r;
648  }
649  const leg = state.legs.get(e.agentId);
650  if (leg) {
651    leg.done = true;
652    leg.timer?.cancel();
653  }
654  const root = state.root;
655  if (!leg?.counts || leg.spawnTree === null || !root) return next(e);
656  // A capped leg is already reported as capped, not as a dropped review.
657  if (leg.capped && (e.isAborted || e.reason !== 'answer')) return next(e);
658  if (e.isAborted || e.reason !== 'answer') {
659    state.dropped.push(`${leg.type}: it did not finish (${e.isAborted ? 'aborted' : e.reason})`);
660    return next(e);
661  }
662  if (!hasReceipt(e.answer)) {
663    state.dropped.push(`${leg.type}: its report did not open with the execution receipt`);
664    return next(e);
665  }
666  try {
667    const tree = await worktreeTree(gitOf($), root.top);
668    const head = await headOf(gitOf($), root.top);
669    const reviewedPaths = await reviewablePaths(gitOf($), root.top, head, tree);
670    if (reviewedPaths === null) throw new Error('the paths it reviewed could not be listed');
671    state.reviews.push({ type: leg.type, trees: [leg.spawnTree, tree], reviewedPaths });
672    // Refusals explain the current state; older failures stay in the status tool.
673    state.droppedSince = state.dropped.length;
674  } catch (error) {
675    state.dropped.push(`${leg.type}: ${error instanceof Error ? error.message : String(error)}`);
676  }
677  return next(e);
678}
679
680const SWITCHED_BY_USER =
681  'review-cycle is switched on and off only by the user, in /config. Ask them; to read a settings file, use the Read tool.';
682
683async function currentText($: $, path: string): Promise<string | null> {
684  return (await $.fs.exists(path)) ? $.fs.read(path) : null;
685}
686
687function runningLeg(agentId: string | undefined): Leg | null {
688  const leg = agentId === undefined ? undefined : state.legs.get(agentId);
689  return leg && !leg.done ? leg : null;
690}
691
692// A reviewer changing the tree it reviews voids its own review and can land in
693// the user's commit; scratch work belongs outside the repository.
694async function containedEdit(
695  $: $,
696  agentId: string | undefined,
697  path: string,
698): Promise<{ deny: string } | null> {
699  if (runningLeg(agentId) === null) return null;
700  let root: Repo | null;
701  try {
702    root = await ensureRoot($);
703  } catch (error) {
704    const why = error instanceof Error ? error.message : String(error);
705    return deny(
706      `the gate could not find the repository under review (${why}), so it refuses a reviewer's edits. Report back instead.`,
707    );
708  }
709  if (root === null || !insideRepo(path, root.top)) return null;
710  return deny(
711    `reviewers do not edit the repository under review (${root.top}). Copy what you need into a private directory from mktemp -d and change the copy.`,
712  );
713}
714
715async function onEditContained(
716  $: $,
717  e: Input<EditHook>,
718  next: NextOf<EditHook>,
719): Promise<Output<EditHook>> {
720  return (await containedEdit($, e.agentId, e.file_path)) ?? next(e);
721}
722
723async function onWriteContained(
724  $: $,
725  e: Input<WriteHook>,
726  next: NextOf<WriteHook>,
727): Promise<Output<WriteHook>> {
728  return (await containedEdit($, e.agentId, e.file_path)) ?? next(e);
729}
730
731async function onNotebookEdit(
732  $: $,
733  e: Input<NotebookHook>,
734  next: NextOf<NotebookHook>,
735): Promise<Output<NotebookHook>> {
736  return (await containedEdit($, e.agentId, e.notebook_path)) ?? next(e);
737}
738
739async function onEdit($: $, e: Input<EditHook>, next: NextOf<EditHook>): Promise<Output<EditHook>> {
740  if (!isJsonPath(e.file_path)) return next(e);
741  const before = await currentText($, e.file_path);
742  // An edit to a missing file creates it with the new text.
743  if (before === null) return touchesGate(null, e.new_string) ? deny(SWITCHED_BY_USER) : next(e);
744  const after = applyEdit(before, e.old_string, e.new_string, e.replace_all === true);
745  // An edit the gate cannot replay passes only when neither the file nor the
746  // edit names a switch.
747  const touches =
748    after === null
749      ? touchesGate(before, '') || touchesGate(e.old_string, e.new_string)
750      : touchesGate(before, after);
751  return touches ? deny(SWITCHED_BY_USER) : next(e);
752}
753
754async function onWrite(
755  $: $,
756  e: Input<WriteHook>,
757  next: NextOf<WriteHook>,
758): Promise<Output<WriteHook>> {
759  if (!isJsonPath(e.file_path)) return next(e);
760  if (touchesGate(await currentText($, e.file_path), e.content)) return deny(SWITCHED_BY_USER);
761  return next(e);
762}
763
764// The comment-slop scan runs after the tool, beside the gate's own Edit and
765// Write hooks and also when the gate is switched off.
766async function onEditSlop(
767  $: $,
768  e: Input<EditHook>,
769  next: NextOf<EditHook>,
770): Promise<Output<EditHook>> {
771  const r = await next(e);
772  return withSlop($, r, { path: e.file_path, text: e.new_string, replaced: e.old_string });
773}
774
775async function onWriteSlop(
776  $: $,
777  e: Input<WriteHook>,
778  next: NextOf<WriteHook>,
779): Promise<Output<WriteHook>> {
780  const r = await next(e);
781  return withSlop($, r, { path: e.file_path, text: e.content, replaced: null });
782}
783
784// A refused or failed call wrote nothing, so it is not scanned.
785async function withSlop<N extends string>(
786  $: $,
787  r: ToolCallResult<N>,
788  w: Omit<Written, 'file'>,
789): Promise<ToolCallResult<N>> {
790  if (r.deny !== undefined || r.isError === true) return r;
791  const note = await slopNote($, w);
792  return note === null ? r : { ...r, context: [...(r.context ?? []), note] };
793}
794
795// Never refuses: the write has already happened. Files outside a git
796// repository are not scanned.
797async function slopNote($: $, w: Omit<Written, 'file'>): Promise<string | null> {
798  const { path } = w;
799  if (skipsPath(path)) return null;
800  try {
801    const slash = path.lastIndexOf('/');
802    const dir = slash === -1 ? '.' : path.slice(0, slash) || '/';
803    const repo = await run($, ['git', '-C', dir, 'rev-parse', '--show-toplevel']);
804    if (repo.exitCode !== 0) {
805      if (repo.stderr.includes('not a git repository')) return null;
806      const why = firstLine(repo.stderr) || `exit ${repo.exitCode}`;
807      return `review-cycle: git failed (${why}); comment-slop scan skipped.`;
808    }
809    if (!(await $.fs.exists(path))) return null;
810    const { kind, size } = await $.fs.stat(path);
811    if (kind !== 'file' || size > MAX_BYTES) return null;
812    const findings = slopFindings({ ...w, file: await $.fs.read(path) });
813    return findings.length > 0 ? slopDirective(path, findings) : null;
814  } catch (error) {
815    const why = error instanceof Error ? error.message : String(error);
816    return `review-cycle: comment-slop scan skipped (${why}).`;
817  }
818}
819
820// A settings file the gate could not read may be switching it off.
821function fileCheckFailed(path: string, next: Caught): { deny: string } | null {
822  if (next.called || !isJsonPath(path)) return null;
823  return deny(
824    `the gate could not check whether this changes its own switch (${next.error.message ?? next.error.kind}), so it is refused.`,
825  );
826}
827
828function onEditError(
829  $: $,
830  e: Input<EditHook>,
831  next: NextOf<EditHook> & Caught,
832): ReturnType<CatchHandler<EditHook>> {
833  return fileCheckFailed(e.file_path, next) ?? next(e);
834}
835
836function onWriteError(
837  $: $,
838  e: Input<WriteHook>,
839  next: NextOf<WriteHook> & Caught,
840): ReturnType<CatchHandler<WriteHook>> {
841  return fileCheckFailed(e.file_path, next) ?? next(e);
842}
843
844// Monitor runs a shell command the Bash hooks never see, so one that the gate
845// would judge, or that touches its switch, goes through Bash instead.
846async function onMonitor(
847  $: $,
848  e: Input<MonitorHook>,
849  next: NextOf<MonitorHook>,
850): Promise<Output<MonitorHook>> {
851  if (e.command === undefined) return next(e);
852  await loadShellAliases($);
853  const judged =
854    bashTouchesGate(e.command) ||
855    classify(e.command, state.shellAliases).kind !== 'none' ||
856    ghActions(e.command, state.shellAliases).length > 0 ||
857    possibleAliases(e.command, state.shellAliases).length > 0;
858  if (!judged) return next(e);
859  return deny(
860    'Monitor runs commands the gate does not check. Run a command that commits, pushes, opens, merges, approves or comments on a pull request, releases, calls a git alias or writes settings with the Bash tool.',
861  );
862}
863
864function onMonitorError(
865  $: $,
866  e: Input<MonitorHook>,
867  next: NextOf<MonitorHook> & Caught,
868): ReturnType<CatchHandler<MonitorHook>> {
869  if (next.called || e.command === undefined) return next(e);
870  return deny(
871    `the gate could not check this command (${next.error.message ?? next.error.kind}), so it is refused.`,
872  );
873}
874
875// A GitHub MCP tool is judged as the gh command it stands for would be.
876async function onGithubTool(
877  $: $,
878  e: Input<GithubHook>,
879  next: NextOf<GithubHook>,
880): Promise<Output<GithubHook>> {
881  const action = mcpAction(e.tool, e);
882  if (action === null) return next(e);
883  if (e.agentId) return deny(SUBAGENT);
884  const message = state.messages;
885  const shown = shownCall(e.tool, e);
886  const gh = await judgeGh(shown, [action], state.message.grant, state.held, ghLookups($));
887  if ('deny' in gh) return deny(gh.deny);
888  const stale = overtaken(message, next.signal, 'call');
889  if (stale !== null) return stale;
890  const r = await next(e);
891  if (r.deny !== undefined) return r;
892  const notes = unaskedNotes($, gh.ran);
893  return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] };
894}
895
896// Why a judged call does not run after all: a newer message than the one its
897// grant was read at (`message`), or an abort, past which nothing would report
898// what ran.
899function overtaken(message: number, signal: AbortSignal, what: string): { deny: string } | null {
900  if (state.messages !== message) {
901    return deny(
902      `the user sent a new message while the gate was checking this ${what}, so it did not run. Act on their message.`,
903    );
904  }
905  if (signal.aborted) {
906    return deny('the call was interrupted while the gate was checking it, so it did not run.');
907  }
908  return null;
909}
910
911// The notes for the steps a call ran unasked; a push a requested pull request
912// needs is not noted.
913function unaskedNotes($: $, ran: readonly Unasked[]): string[] {
914  return ran.filter((step) => !step.forPr).map((step) => ranUnasked($, step));
915}
916
917function onGithubToolError(
918  $: $,
919  e: Input<GithubHook>,
920  next: NextOf<GithubHook> & Caught,
921): ReturnType<CatchHandler<GithubHook>> {
922  if (next.called) return next(e);
923  const why =
924    next.error.message ??
925    (next.error.kind === 'timeout'
926      ? 'it ran out of time; calling it again may work'
927      : next.error.kind);
928  return deny(
929    `the gate could not check this call (${why}), so it is refused. Tell the user; they can make the change on GitHub themselves.`,
930  );
931}
932
933// A reviewer's Bash is not refused, since it cannot be read for where it
934// writes; the repository is compared around it instead, and what changed is
935// put to the reviewer and kept for the status tool.
936async function onBash($: $, e: Input<BashHook>, next: NextOf<BashHook>): Promise<Output<BashHook>> {
937  const leg = runningLeg(e.agentId);
938  if (!leg) return judgeBash($, e, next);
939  let root: Repo | null = null;
940  let lookup: string | null = null;
941  try {
942    root = await ensureRoot($);
943  } catch (error) {
944    lookup = error instanceof Error ? error.message : String(error);
945  }
946  if (root === null && lookup === null) return judgeBash($, e, next);
947  const capture = async (): Promise<Capture> => {
948    if (root === null) return { state: UNREAD, why: lookup };
949    try {
950      return await repoStateOf(gitOf($), root.top);
951    } catch (error) {
952      return { state: UNREAD, why: error instanceof Error ? error.message : String(error) };
953    }
954  };
955  const before = await capture();
956  const report = async (): Promise<string[]> => {
957    const { notes, records } = containmentReport({
958      type: leg.type,
959      command: e.command,
960      before,
961      after: await capture(),
962      background: e.run_in_background === true,
963    });
964    state.reviewerChanges.push(...records);
965    return notes;
966  };
967  let r: Output<BashHook>;
968  try {
969    r = await judgeBash($, e, next);
970  } catch (error) {
971    // The gate's failure handler answers a call that threw, so no note can
972    // ride along; the record still reaches the status tool. A separate hook
973    // would never see this case, which is why the comparison wraps the gate.
974    await report();
975    throw error;
976  }
977  if (r.deny !== undefined) return r;
978  const notes = await report();
979  return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] };
980}
981
982// A note, never a refusal: the command has already run. Reviewer legs are
983// compared by the gate already, and a background command is still running
984// when the call returns. Never throws, so the gate's own notes always arrive.
985async function onBashEdits(
986  $: $,
987  e: Input<BashHook>,
988  next: NextOf<BashHook>,
989): Promise<Output<BashHook>> {
990  const skip =
991    runningLeg(e.agentId) !== null ||
992    e.run_in_background === true ||
993    !mayWrite(e.command, state.shellAliases);
994  if (skip) return next(e);
995  let root: Repo | null;
996  // onBash, registered first, has already looked the root up; this guards a
997  // registration order where it has not.
998  try {
999    root = await ensureRoot($);
1000  } catch (error) {
1001    return withNote(next(e), editsSkipped(messageOf(error)));
1002  }
1003  if (root === null) return next(e);
1004  const { top } = root;
1005  const git = gitOf($);
1006  const { result, note } = await measureEdits(
1007    () => worktreeTree(git, top),
1008    (before, after) => reviewablePaths(git, top, before, after),
1009    () => next(e),
1010  );
1011  return note === null ? result : withNote(result, note);
1012}
1013
1014async function judgeBash(
1015  $: $,
1016  e: Input<BashHook>,
1017  next: NextOf<BashHook>,
1018): Promise<Output<BashHook>> {
1019  // A newer message overtakes anything decided from this one.
1020  const message = state.messages;
1021  const granted = state.message.grant;
1022  if (bashTouchesGate(e.command)) return deny(SWITCHED_BY_USER);
1023  await loadShellAliases($);
1024  const cls = classify(e.command, state.shellAliases);
1025  if (cls.kind === 'refuse') return deny(cls.reason);
1026  // A gh command beside a commit or push is refused by classify.
1027  if (cls.kind === 'none') {
1028    const actions = ghActions(e.command, state.shellAliases);
1029    if (actions.length > 0 && e.agentId) return deny(SUBAGENT);
1030    const gh = await judgeGh(quote(e.command), actions, granted, state.held, ghLookups($));
1031    if ('deny' in gh) return deny(gh.deny);
1032    // Only a GitHub step is stopped by a newer message; other commands run.
1033    const since = actions.length > 0 ? message : null;
1034    const candidates = possibleAliases(e.command, state.shellAliases);
1035    if (candidates.length > 0) {
1036      const configured = await run($, ['git', 'config', '--get-regexp', String.raw`^alias\.`]);
1037      if (configured.exitCode > 1)
1038        throw new Error(`git config failed: ${firstLine(configured.stderr)}`);
1039      const aliases = new Map<string, string>();
1040      for (const line of configured.stdout.split('\n')) {
1041        const m = /^alias\.(\S+)\s(.*)$/.exec(line);
1042        if (m?.[1] && m[2] !== undefined) aliases.set(m[1], m[2]);
1043      }
1044      for (const a of candidates) {
1045        if (a.inline !== null) aliases.set(a.sub, a.inline);
1046      }
1047      for (const a of candidates) {
1048        if (aliasCommits(a.sub, (name) => aliases.get(name) ?? null)) {
1049          return deny(
1050            `\`git ${a.sub}\` is an alias that commits or pushes. Run the git command directly so the gate can check it.`,
1051          );
1052        }
1053      }
1054    }
1055    const r = await watch($, await ensureRoot($), e, next, 'unchecked', granted, since, gh.ran);
1056    if (state.aliasError === null || state.aliasErrorShown || r.deny !== undefined) return r;
1057    state.aliasErrorShown = true;
1058    return {
1059      ...r,
1060      context: [
1061        ...(r.context ?? []),
1062        `review-cycle could not read the user's shell aliases (${state.aliasError}), so an aliased git commit or push is not checked. Tell the user.`,
1063      ],
1064    };
1065  }
1066
1067  const root = await ensureRoot($);
1068  if (root === null) return next(e);
1069  const target = await repoAt(gitOf($), cls.dir);
1070  if (target === 'none' || target.common !== root.common) return next(e);
1071  if (target.top !== root.top) {
1072    return deny(
1073      `this commits or pushes from another worktree of this repository (${target.top}). Do it from ${root.top}, where the gate can check it.`,
1074    );
1075  }
1076  if (e.agentId) return deny(SUBAGENT);
1077
1078  // A commit needs only a review: it stays local, and undoing one costs a reset.
1079  const unreviewed = await reviewed($, root.top, cls);
1080  if (unreviewed !== null) return deny(unreviewed);
1081
1082  if (cls.commit && !cls.commit.dryRun && !granted.commit) {
1083    const ladder = await ladderOf($);
1084    if (!permitted(granted, ladder, state.held !== null).commit) {
1085      return deny(commitRefusal(e.command, ladder));
1086    }
1087  }
1088  const ran: Unasked[] = [];
1089  if (cls.push) {
1090    const verdict = await judgePush($, root.top, e.command, cls.commit !== null, cls.push, granted);
1091    if ('deny' in verdict) return deny(verdict.deny);
1092    if (verdict.ran !== null) ran.push(verdict.ran);
1093  }
1094  const checked = cls.commit ? 'commit' : cls.history !== null ? 'history' : 'push';
1095  return watch($, root, e, next, checked, granted, message, ran);
1096}
1097
1098// The refusal when a reviewer has not seen all of what a real commit would
1099// record; null otherwise.
1100async function reviewed(
1101  $: $,
1102  top: string,
1103  cls: Extract<Classification, { kind: 'gated' }>,
1104): Promise<string | null> {
1105  if (!cls.commit || cls.commit.dryRun) return null;
1106  const head = await headOf(gitOf($), top);
1107  // An amend replaces HEAD, so what it records is judged against HEAD's parent.
1108  const base = cls.commit.amend && head !== EMPTY_TREE ? await parentTree(gitOf($), top) : head;
1109  if (base === null)
1110    throw new Error("could not read the tree of HEAD's parent, which an amend replaces");
1111  const prospect = await prospectTree(gitOf($), top, cls);
1112  const c = await coverageOf(gitOf($), top, base, prospect, state.reviews);
1113  if (c === null) {
1114    const against =
1115      base === EMPTY_TREE ? 'the empty tree' : cls.commit.amend ? "HEAD's parent" : 'HEAD';
1116    throw new Error(`could not compare the tree this commit would record with ${against}`);
1117  }
1118  if (uncoveredOf(c.rows).length > 0) {
1119    return `no reviewer has seen what this commit records (${explain(c)}). Invoke /review-cycle:review via the Skill tool so a reviewer sees the current tree, then commit.`;
1120  }
1121  return null;
1122}
1123
1124// Longer than this, the refusal quotes only the start of the command.
1125const MAX_SHOWN = 300;
1126
1127const LEASE = '`--force-with-lease --force-if-includes`';
1128
1129const SUBAGENT =
1130  'subagents do not commit, push, open, merge, approve or comment on pull requests, or release, in this repository. Report back to the main session instead.';
1131
1132// A push a requested pull request needs (`forPr`) runs unasked, not noted.
1133type Unasked = GhUnasked & { forPr?: true };
1134
1135// The rung in force: the local file's, else the user's, made earlier by the
1136// project file's. Settings that cannot be read stop before every step.
1137async function ladderOf($: $): Promise<InForce> {
1138  const rungs: Partial<Record<'project' | 'local', StopBefore | null>> = {};
1139  for (const source of ['project', 'local'] as const) {
1140    try {
1141      const settings = await $.settings.read({ source });
1142      rungs[source] = configured(settings.pluginConfigs);
1143    } catch (error) {
1144      const unreadable = `${where(source)}: ${messageOf(error)}`;
1145      return { stopBefore: STRICTEST, source: 'default', unreadable };
1146    }
1147  }
1148  return effective(state.stopBefore, rungs.project ?? null, rungs.local ?? null);
1149}
1150
1151// Shown to the user as a dim line, and to the agent, so the step is never
1152// silent. Worded as having run, not succeeded: the command may still fail.
1153const STEP_NAME: Record<Step, string> = {
1154  commit: 'a commit',
1155  push: 'a push',
1156  pr: 'a pull request',
1157  merge: 'a merge',
1158  approve: 'an approval',
1159  release: 'a release',
1160};
1161
1162function ranUnasked($: $, { step, ladder }: Unasked): string {
1163  const line = `review-cycle: ran ${STEP_NAME[step]} without asking, since the stop-before setting is ${ladder.stopBefore} (${where(ladder.source)}).`;
1164  $.ui.log(line);
1165  return line;
1166}
1167
1168// Git's own credential prompt fails instead of waiting, so the push asks.
1169const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0' };
1170// Also after the push's options, to beat a -q or --verify; the leading
1171// --dry-run is the one no option value can swallow. Measured on git 2.56.
1172// A submodule push runs that submodule's hooks, which --no-verify misses.
1173const DRY_RUN = [
1174  '--dry-run',
1175  '--porcelain',
1176  '--no-verify',
1177  '--no-quiet',
1178  '--recurse-submodules=no',
1179];
1180
1181const DELETES = 'it deletes a remote branch';
1182
1183// Why a push asks whatever the setting, or null. Off the ladder: a push that
1184// changes a remote's default branch, pushes a tag, deletes or force-updates a
1185// ref, and one the gate cannot see the targets of. Git itself names the refs
1186// a push updates, with its own push config applied, through a dry run.
1187async function alwaysAsks($: $, top: string, spec: PushSpec): Promise<string | null> {
1188  if (spec.deletes) return DELETES;
1189  if (spec.every) return 'it pushes every branch';
1190  if (spec.tags) return 'it pushes tags';
1191  if (spec.argv === null) return 'its remote or branch is built at run time';
1192  if (spec.after !== null) {
1193    return `\`git ${spec.after}\` runs before it in the same command, which can change where it goes (run that step on its own first, and the push is judged after it)`;
1194  }
1195  const { config, args, end } = spec.argv;
1196  const probe = ['--dry-run', ...args.slice(0, end), ...DRY_RUN, ...args.slice(end)];
1197  const dry = await run($, ['git', ...config, 'push', ...probe], { cwd: top, env: NO_PROMPT });
1198  if (dry.exitCode !== 0) {
1199    return `a dry run of it, which shows what it would push, failed (${firstLine(dry.stderr) || `exit ${dry.exitCode}`})`;
1200  }
hooks/attribution.ts 86 lines
1// Which commits a Bash command made, read from HEAD's reflog. Pure.
2//
3// Every command that moves HEAD logs why: `commit: …`, `checkout: moving …`,
4// `merge x: Fast-forward`. An entry counts as recording a commit unless git's
5// own action prefix says it only moved HEAD, so an unknown or empty message
6// counts too. A commit built with plumbing and then reached by `reset` logs
7// only the reset, so it is not seen.
8
9// One HEAD reflog entry: the commit HEAD moved to, and the line git printed,
10// which identifies the entry.
11export type Entry = { id: string; line: string; message: string };
12
13// id, date, identity and message, NUL-separated: an identity may hold a tab.
14export const REFLOG_FORMAT = '%H%x00%gd%x00%gn <%ge>%x00%gs';
15
16// How many of HEAD's newest entries mark where a command began. More than one,
17// since a repeated operation within one second logs an identical line.
18export const MARK = 3;
19
20export function parseReflog(stdout: string): Entry[] {
21  const entries: Entry[] = [];
22  for (const line of stdout.split('\n')) {
23    const fields = line.split('\0');
24    const id = fields[0] ?? '';
25    if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(id)) continue;
26    entries.push({ id, line, message: fields.slice(3).join('\0') });
27  }
28  return entries;
29}
30
31// The `count` newest entries of `after`, the ones the command added, provided
32// the entries below them are the ones recorded before it (`before`, newest
33// first). Null when they are not: the log was expired, rewritten, or read too
34// short.
35export function added(
36  after: readonly Entry[],
37  before: readonly Entry[],
38  count: number,
39): Entry[] | null {
40  if (count < 0 || count > after.length) return null;
41  if (!before.every((b, j) => after[count + j]?.line === b.line)) return null;
42  return after.slice(0, count);
43}
44
45// Whether an entry records a commit rather than only moving HEAD, judged by
46// the action git writes before `: `, never by the commit subject after it. A
47// cherry-pick whose subject is `fast-forward` reads as git's own fast-forward
48// message would, so a cherry-pick fast-forward counts as a commit.
49export function recordsCommit(message: string): boolean {
50  const colon = message.indexOf(': ');
51  const action = colon === -1 ? message : message.slice(0, colon);
52  const rest = colon === -1 ? '' : message.slice(colon + 2);
53  // A fetch never records a commit; with --update-head-ok it can move HEAD.
54  if (/^(checkout|reset|clone|branch|fetch)\b/i.test(action)) return false;
55  if (message === 'am --abort' || message === 'initial pull') return false;
56  if (/^(rebase|pull)\b.*\((start|finish|abort|reset)\)$/.test(action)) return false;
57  if (/^(merge|pull)\b/.test(action) && /^fast-forward\b/i.test(rest)) return false;
58  return !(action === 'rebase' && (rest === 'fast-forward' || rest.startsWith('checkout ')));
59}
60
61// A run of commits recorded one on top of another: `base` is HEAD before the
62// first, `tip` the last.
63export type Lineage = { base: string; tip: string };
64
65// The commits `entries` (newest first) recorded, grouped into lineages: a
66// move between two commits starts a new one, so a commit on another branch is
67// judged apart from one on this. `start` is HEAD before the command.
68export function madeBy(entries: readonly Entry[], start: string): Lineage[] {
69  const oldestFirst = entries.toReversed();
70  const lineages: Lineage[] = [];
71  let current: Lineage | null = null;
72  for (const [i, entry] of oldestFirst.entries()) {
73    if (!recordsCommit(entry.message)) {
74      current = null;
75      continue;
76    }
77    if (current === null) {
78      current = { base: oldestFirst[i - 1]?.id ?? start, tip: entry.id };
79      lineages.push(current);
80    } else {
81      current.tip = entry.id;
82    }
83  }
84  return lineages;
85}
86
hooks/command.ts 1254 lines
1// Classifies a Bash command for the commit gate. Pure: no `$`, no I/O.
2//
3// The accepted shape is deliberately narrow: an optional leading `cd <dir>`,
4// then any of `git add …`, read-only git commands and steps such as
5// `git fetch` that commit nothing, then at most one `git commit …` (or one
6// command that makes commits from history, such as `git merge`) and at most
7// one `git push …`, joined by `&&`, `;` or newlines.
8// Every other command that would commit or push is refused. Text naming git
9// is data only where nothing will run it: an argument to `grep` or `printf`,
10// a quoted heredoc into `cat`. Handed to anything that runs code — a shell,
11// an interpreter, `xargs`, `find -exec` — it is refused.
12
13import { addArgv, commitSpec, pushSpec, type CommitSpec, type PushSpec } from './git-args';
14import { apiActions, asking, type GhAction, type GhContext, type Unlookable } from './github';
15import { PUBLISH_TEXT, publishActionsOf } from './publish';
16import {
17  assignmentName,
18  parse,
19  QUALIFIER,
20  type ShellAliases,
21  type Statement,
22  type Word,
23} from './shell';
24
25// The mode must come before the name: `git config <name> <value> --get` still
26// writes (measured on git 2.56).
27function readsConfig(args: Word[]): boolean {
28  const named = args.findIndex((w) => !w.text.startsWith('-'));
29  const before = named === -1 ? args : args.slice(0, named);
30  return before.some((w) => /^(--get(-all|-regexp)?|--list|-l)$/.test(w.text));
31}
32
33export const BUILTIN_RUNNERS: ReadonlySet<string> = new Set(['.', 'eval', 'source']);
34// Commands that run their arguments, or their standard input, as code.
35export const INTERPRETERS: ReadonlySet<string> = new Set([
36  'bash',
37  'sh',
38  'zsh',
39  'dash',
40  'ksh',
41  'fish',
42  'eval',
43  'source',
44  '.',
45  'python',
46  'python3',
47  'node',
48  'deno',
49  'bun',
50  'tsx',
51  'ts-node',
52  'lua',
53  'perl',
54  'ruby',
55  'php',
56  'find',
57  'parallel',
58  'watch',
59  'script',
60  'expect',
61  'osascript',
62  'uv',
63  'mise',
64  'nix-shell',
65  'sudo',
66  'doas',
67]);
68export const KEYWORDS: ReadonlySet<string> = new Set([
69  '!',
70  '{',
71  '}',
72  'if',
73  'then',
74  'else',
75  'elif',
76  'fi',
77  'do',
78  'done',
79  'while',
80  'until',
81  'for',
82  'case',
83  'esac',
84  'select',
85  'function',
86  'coproc',
87  '[[',
88  ']]',
89  'repeat',
90  'always',
91  'nocorrect',
92  'noglob',
93]);
94// Commands that record existing commits. Their `--continue` forms record a
95// conflict resolution instead, which is new content, so those are refused.
96const HISTORY = new Set(['merge', 'cherry-pick', 'revert', 'pull', 'rebase']);
97// A mention of a git command that commits or pushes.
98// `git` and the subcommand as separate words, so `pre-commit`, `.git/hooks/`,
99// `fix/merge-conflicts` and helpers like `git merge-base` do not count.
100const MENTION =
101  /(^|[\s;&|('"`=/])git(\s+-\S+(\s+[^\s-]\S*)?)*\s+(commit|push|merge(?!-(base|tree|file)\b)|cherry-pick|revert|am|pull|rebase)\b|(^|[\s;&|('"`=/])git-(commit|push|merge(?!-(base|tree|file)\b)|cherry-pick|revert|am|pull|rebase)\b/;
102// sed's `e` command and s///e, and awk's system() and pipes, run shell code.
103const RUNS_SHELL: Record<string, RegExp> = {
104  sed: /\/[gipIwmM0-9]*e[gipIwmM0-9]*(['"\s;}]|$)|(^|[\s;'"{])e(\s|$)/,
105  awk: /system\s*\(|\|\s*getline|["']\s*\|\s*"|\|\s*"/,
106  gawk: /system\s*\(|\|\s*getline|["']\s*\|\s*"|\|\s*"/,
107};
108// Commands whose arguments are only ever data, never a command to run.
109const DATA = new Set([
110  'echo',
111  'printf',
112  'rg',
113  'grep',
114  'egrep',
115  'fgrep',
116  'ag',
117  'cat',
118  'head',
119  'tail',
120  'less',
121  'more',
122  'wc',
123  'sort',
124  'uniq',
125  'jq',
126  'tr',
127  'cut',
128  'diff',
129  'gh',
130  'bd',
131  'column',
132]);
133// A committing verb as a word of its own, as in `$GIT commit`, not `commit-gate`.
134const WORD_VERB = /(^|[\s'"`])(commit|push|merge|cherry-pick|revert|am|pull|rebase)([\s'"`]|$)/;
135// Shells that run the script on their standard input when given no script.
136const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
137// Git commands that write commits or push other than commit and push
138// themselves; none is needed for everyday work, so none is judged.
139const REFUSED = new Set([
140  'send-pack',
141  'http-push',
142  'fast-import',
143  'commit-tree',
144  'filter-branch',
145  'am',
146]);
147// Steps that commit nothing and leave the index alone, allowed before the one
148// command that commits or pushes.
149const NEUTRAL = new Set([
150  'fetch',
151  'checkout',
152  'switch',
153  'branch',
154  'tag',
155  'stash',
156  'remote',
157  'worktree',
158]);
159// Of those, the ones that can change HEAD or the index: fine before a push or
160// a history command, but not before a commit the gate judges from the index.
161const MOVES_INDEX = new Set(['checkout', 'switch', 'stash', 'worktree']);
162// Steps that change the index; run them on their own before the commit.
163const RESTAGE = new Set(['rm', 'mv', 'restore', 'reset', 'apply', 'update-index']);
164const READ_ONLY = new Set([
165  'grep',
166  'status',
167  'log',
168  'diff',
169  'show',
170  'rev-parse',
171  'describe',
172  'ls-files',
173  'shortlog',
174  'blame',
175  'whatchanged',
176  'cat-file',
177  'rev-list',
178  'name-rev',
179  'merge-base',
180]);
181// Author and committer identity, and GIT_TERMINAL_PROMPT; any other GIT_*
182// variable can point git at a different index, directory or object store than
183// the one checked.
184const ALLOWED_GIT_ENV = /^GIT_((AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|TERMINAL_PROMPT)$/;
185// These name a program git runs, so only a bare program name (`cat`, `true`)
186// is allowed: anything longer could run a commit of its own.
187const GIT_PROGRAM_ENV = /^GIT_(PAGER|EDITOR|SEQUENCE_EDITOR)$/;
188// Git's own options before the subcommand that take a separate value.
189const GIT_VALUE_OPTIONS = new Set(['--attr-source', '--list-cmds']);
190
191export type GitCall = {
192  // Options before the subcommand that `add` replays (`-c key=value` pairs).
193  config: string[];
194  // `-C` directories, in order, relative to the statement's directory.
195  dirs: string[];
196  sub: string;
197  args: Word[];
198};
199
200type Kind =
201  // `dir` is null when the directory is not a literal path.
202  | { kind: 'cd'; dir: string | null }
203  // `via` is a reserved word the command runs under, such as `if` or `!`.
204  | { kind: 'git'; git: GitCall; via: string | null }
205  // Literal variable assignments with no substitution or redirect: they run nothing.
206  | { kind: 'assign' }
207  // `words` are the command's words after assignments and reserved words.
208  | { kind: 'other'; head: string; words: Word[] }
209  // `always` refusals stand whatever the command's text mentions.
210  | { kind: 'refuse'; reason: string; always?: boolean };
211
212export function basename(p: string): string {
213  return p.slice(p.lastIndexOf('/') + 1);
214}
215
216// Git's global options before the subcommand. Only `-C` and `-c` are kept;
217// options that point git at a different repository are refused. A `-C` built
218// at run time is allowed only for a subcommand that neither commits nor could
219// be an alias, which git would look up in that directory.
220function gitCall(words: Word[], first: string | null): GitCall | { refuse: string } | null {
221  const config: string[] = [];
222  const dirs: string[] = [];
223  if (first !== null) return { config, dirs, sub: first, args: words.slice(1) };
224  let dynamicDir = false;
225  let i = 1;
226  for (let w = words[i]; w !== undefined; w = words[i]) {
227    if (w.dynamic)
228      return {
229        refuse: 'git run with an option or subcommand built from a variable or substitution',
230      };
231    const t = w.text;
232    if (t === '-C' || t === '-c') {
233      const v = words[i + 1];
234      if (!v || (v.dynamic && t === '-c'))
235        return { refuse: `git ${t} with a value built from a variable or substitution` };
236      dynamicDir ||= v.dynamic;
237      if (t === '-C') dirs.push(v.text);
238      else config.push('-c', v.text);
239      i += 2;
240    } else if (/^-C./.test(t)) {
241      dirs.push(t.slice(2));
242      i++;
243    } else if (
244      /^--(git-dir|work-tree|namespace|bare|config-env|exec-path|super-prefix)(=|$)/.test(t)
245    ) {
246      return { refuse: `git ${t.split('=')[0]}, which points git at another repository` };
247    } else if (GIT_VALUE_OPTIONS.has(t)) {
248      i += 2;
249    } else if (t.startsWith('-')) {
250      i++;
251    } else if (w.pattern) {
252      return { refuse: `git ${t}, a subcommand the shell would expand` };
253    } else {
254      const args = words.slice(i + 1);
255      if (dynamicDir && !reads(t, args) && !NEUTRAL.has(t))
256        return { refuse: 'git -C with a directory built from a variable or substitution' };
257      return { config, dirs, sub: t, args };
258    }
259  }
260  return null;
261}
262
263function reads(sub: string, args: Word[]): boolean {
264  return READ_ONLY.has(sub) || (sub === 'config' && readsConfig(args));
265}
266
267function kindOf(st: Statement): Kind {
268  let words = st.words;
269  let assigned = false;
270  let via: string | null = null;
271  for (let first = words[0]; first !== undefined; first = words[0]) {
272    const name = assignmentName(first);
273    if (name) {
274      const value = first.text.slice(name.length + 1);
275      const program = GIT_PROGRAM_ENV.test(name) && !first.dynamic && /^[\w.-]+$/.test(value);
276      if (name.startsWith('GIT_') && !ALLOWED_GIT_ENV.test(name) && !program) {
277        return { kind: 'refuse', reason: `${name}, which changes what git commits` };
278      }
279      assigned = true;
280    } else if (!first.dynamic && KEYWORDS.has(first.text)) {
281      via ??= first.text;
282    } else {
283      break;
284    }
285    words = words.slice(1);
286  }
287  const [head, arg] = words;
288  if (head === undefined) {
289    // A substitution or redirect runs between the gate's check and the commit.
290    const inert = st.inner.length === 0 && !st.redirected;
291    if (assigned && via === null && inert) return { kind: 'assign' };
292    return { kind: 'other', head: via ?? '', words };
293  }
294  if (head.dynamic) {
295    return { kind: 'refuse', reason: 'a command name built from a variable or substitution' };
296  }
297  if (head.pattern) return { kind: 'refuse', reason: 'a command name the shell would expand' };
298  if (head.text === 'cd' && !assigned && via === null) {
299    if (words.length !== 2 || !arg || arg.dynamic) return { kind: 'cd', dir: null };
300    return { kind: 'cd', dir: arg.text };
301  }
302  const name = basename(head.text.replace(/^=/, ''));
303  const plumbing = /^git-(.+)$/.exec(name)?.[1] ?? null;
304  if (name !== 'git' && plumbing === null) return { kind: 'other', head: name, words };
305  const g = gitCall(words, plumbing);
306  if (g === null) return { kind: 'other', head: name, words };
307  if ('refuse' in g) return { kind: 'refuse', reason: g.refuse, always: true };
308  return { kind: 'git', git: g, via };
309}
310
311function commits(sub: string): boolean {
312  return sub === 'commit' || sub === 'push' || HISTORY.has(sub);
313}
314
315// A fast-forward pull records no commit. The last of `--ff`, `--no-ff` and
316// `--ff-only` wins, and a run-time word could be any; `merge --ff-only -s ours`
317// still records a merge, so merge is not included.
318function fastForwardOnly(g: GitCall): boolean {
319  if (g.sub !== 'pull' || g.args.some((w) => w.dynamic)) return false;
320  return g.args.findLast((w) => /^--(no-)?ff(-only)?$/.test(w.text))?.text === '--ff-only';
321}
322
323function records(g: GitCall): boolean {
324  return commits(g.sub) && !fastForwardOnly(g);
325}
326
327function textOf(st: Statement): string {
328  return [...st.words.map((w) => w.text), ...st.heredocs.map((h) => h.body)].join(' ');
329}
330
331export function every(
332  list: Statement[],
333  visit: (st: Statement, nested: boolean) => string | null,
334  nested = false,
335): string | null {
336  for (const st of list) {
337    const found = visit(st, nested);
338    if (found) return found;
339    for (const inner of st.inner) {
340      const deeper = every(inner, visit, true);
341      if (deeper) return deeper;
342    }
343  }
344  return null;
345}
346
347// The git calls a command's words make after the first, as `arch -arm64 git
348// push` or `flock /tmp/l git commit` would run them. A data command's words
349// are never run.
350function gitCallsIn(words: Word[]): (GitCall | { refuse: string })[] {
351  if (DATA.has(basename(words[0]?.text ?? ''))) return [];
352  const calls: (GitCall | { refuse: string })[] = [];
353  for (const [i, w] of words.entries()) {
354    if (i === 0 || w.dynamic) continue;
355    const name = basename(w.text);
356    const plumbing = /^git-(.+)$/.exec(name)?.[1] ?? null;
357    if (name !== 'git' && plumbing === null) continue;
358    const g = gitCall(words.slice(i), plumbing);
359    if (g !== null) calls.push(g);
360  }
361  return calls;
362}
363
364// Whether any word after the first starts a git command that commits or
365// pushes, or a variable stands in for git before a committing subcommand.
366function runsGit(words: Word[]): string | null {
367  if (DATA.has(basename(words[0]?.text ?? ''))) return null;
368  for (const [i, w] of words.entries()) {
369    if (i === 0 || !w.dynamic) continue;
370    const next = words.slice(i + 1).find((x) => !x.text.startsWith('-'));
371    if (next && (commits(next.text) || REFUSED.has(next.text))) {
372      return `\`${w.text} ${next.text}\``;
373    }
374  }
375  for (const g of gitCallsIn(words)) {
376    if ('refuse' in g) return 'git with options the gate cannot read';
377    // Run through another program, a fast-forward's arguments go unread.
378    if (commits(g.sub) || REFUSED.has(g.sub)) return `git ${g.sub}`;
379  }
380  return null;
381}
382
383// A program among the words that runs code it is given: `bash -c`, or
384// `timeout 5 sh` reading a script from a pipe. Every word is checked, since
385// anything can run the command after its own options.
386function runsCode(
387  words: Word[],
388  own: string,
389  mentions: boolean,
390  aliases: ShellAliases,
391): string | null {
392  if (DATA.has(basename(words[0]?.text ?? ''))) return null;
393  for (const [i, w] of words.entries()) {
394    if (w.dynamic) continue;
395    const name = basename(w.text);
396    const runs = Object.hasOwn(RUNS_SHELL, name) ? RUNS_SHELL[name] : undefined;
397    if (runs && runs.test(own) && MENTION.test(own)) {
398      return `\`${name}\` running a git commit or push`;
399    }
400    // xargs builds its command from its input, which anything in the command
401    // may feed it; one running a data command runs nothing else.
402    if (name === 'xargs') {
403      const target = words.slice(i + 1).find((x) => !x.text.startsWith('-'));
404      if (mentions && !DATA.has(basename(target?.text ?? ''))) {
405        return '`xargs` given a git commit or push to run';
406      }
407      continue;
408    }
409    if (!INTERPRETERS.has(name)) continue;
410    // Builtins run code only as the command itself: `fd x .` searches `.`.
411    if (i > 0 && BUILTIN_RUNNERS.has(name)) continue;
412    // A shell with no script reads one from its standard input: a pipe or
413    // heredoc anywhere in the command can feed it.
414    const scriptless =
415      STDIN_SHELLS.has(name) &&
416      words.slice(i + 1).every((x) => x.text.startsWith('-') && x.text !== '-c');
417    // A shell's `-c` script is a command like any other; judge it as one.
418    const c = words.findIndex((x, j) => j > i && x.text === '-c');
419    const script = c === -1 ? undefined : words[c + 1];
420    if (
421      STDIN_SHELLS.has(name) &&
422      script &&
423      !script.dynamic &&
424      classify(script.text, aliases).kind !== 'none'
425    ) {
426      return `\`${name} -c\` running a git commit or push`;
427    }
428    if (MENTION.test(own) || (scriptless && mentions)) {
429      return `\`${name}\` given a git commit or push to run`;
430    }
431  }
432  return null;
433}
434
435// Whether an alias's value, with the aliases inside it expanded and the
436// words that follow it in `rest`, names a git command that commits or pushes:
437// `g push` with `g=git` does.
438function aliasMentionsGit(name: string, aliases: ShellAliases, rest = ''): boolean {
439  const value = aliases.get(name);
440  if (value === undefined) return false;
441  return MENTION.test(` ${parse(value, aliases).text} ${rest}`);
442}
443
444// A reason to refuse found anywhere in the command, or null; the shape of the
445// top-level commit or push is checked in classify(). `text` is the command
446// with its aliases expanded.
447function hidden(statements: Statement[], text: string, aliases: ShellAliases): string | null {
448  const mentions = MENTION.test(text);
449  return every(statements, (st, nested) => {
450    const k = kindOf(st);
451    // Where the reader simplifies the shell's grammar — a case arm, a
452    // function body, eval's argument — an alias it left unexpanded may run.
453    const structured =
454      st.group ||
455      st.words.some((w) => !w.dynamic && KEYWORDS.has(w.text)) ||
456      (k.kind === 'other' && BUILTIN_RUNNERS.has(k.head));
457    const words = st.words.map((w) => w.text);
458    const unread = !structured
459      ? undefined
460      : st.aliases.find((a) =>
461          aliasMentionsGit(a, aliases, words.slice(words.indexOf(a) + 1).join(' ')),
462        );
463    if (unread !== undefined) {
464      return `the shell alias \`${unread}\`, which runs a git commit or push where the gate cannot read it`;
465    }
466    const own = ` ${textOf(st)}`;
467    const splits = st.words.some((w) => /^(-S|--split-string)/.test(w.text));
468    if (splits && st.words.some((w) => basename(w.text) === 'env') && MENTION.test(own)) {
469      return 'env -S running a git commit or push';
470    }
471    if (k.kind === 'refuse') {
472      return k.always || mentions || WORD_VERB.test(text) ? k.reason : null;
473    }
474    if (k.kind === 'git') {
475      const sub = k.git.sub;
476      if (REFUSED.has(sub)) return `git ${sub}, which writes commits the gate cannot judge`;
477      if (
478        sub === 'config' &&
479        !readsConfig(k.git.args) &&
480        k.git.args.some((w) => /^alias\./i.test(w.text)) &&
481        statements.length > 1
482      ) {
483        return 'a git alias defined alongside other commands, which the gate reads only beforehand';
484      }
485      if (sub === 'subtree' && k.git.args.some((w) => /^(push|add|merge|pull)$/.test(w.text))) {
486        return 'git subtree, which commits or pushes where the gate cannot judge it';
487      }
488      if (records(k.git) || sub === 'add') {
489        if (nested) return `git ${sub} inside a substitution, group or heredoc`;
490        if (k.via) return `git ${sub} run through ${k.via}`;
491        return null;
492      }
493      // A fast-forward's own `git pull` is not a mention; its arguments may be.
494      const judged = fastForwardOnly(k.git)
495        ? ` ${[...k.git.args.map((w) => w.text), ...st.heredocs.map((h) => h.body)].join(' ')}`
496        : own;
497      if (!READ_ONLY.has(sub) && MENTION.test(judged)) {
498        return `git ${sub} running a git command that commits or pushes`;
499      }
500      return null;
501    }
502    if (k.kind === 'other') {
503      const found = runsGit(k.words);
504      if (found) return `${found} run through \`${k.head}\``;
505      // `echo push | xargs git`: git's subcommand arrives at run time.
506      const bare = k.words.some(
507        (w, i) =>
508          i > 0 &&
509          !w.dynamic &&
510          basename(w.text) === 'git' &&
511          gitCall(k.words.slice(i), null) === null,
512      );
513      if (bare && WORD_VERB.test(text)) {
514        return `git run through \`${k.head}\` with its subcommand supplied at run time`;
515      }
516      // eval reads its arguments as a command, aliases and all.
517      const run =
518        k.head === 'eval'
519          ? ` ${
520              parse(
521                k.words
522                  .slice(1)
523                  .map((w) => w.text)
524                  .join(' '),
525                aliases,
526              ).text
527            }`
528          : own;
529      const code = runsCode(k.words, run, mentions, aliases);
530      if (code) return code;
531    }
532    return null;
533  });
534}
535
536type Gated = {
537  kind: 'gated';
538  // Directory the git statements run in, relative to the shell's cwd.
539  dir: string;
540  // Each `git add` to replay: the words after `git`, config options first.
541  adds: string[][];
542} & (
543  | { commit: CommitSpec; history: null; push: PushSpec | null }
544  | { commit: null; history: string; push: PushSpec | null }
545  | { commit: null; history: null; push: PushSpec }
546);
547
548export type Classification = { kind: 'none' } | { kind: 'refuse'; reason: string } | Gated;
549
550const SHAPE =
551  'Run the commit as its own command, optionally after `git add …` and read-only git commands, joined with && (for example `git add -A && git commit -m …`).';
552
553function joinDir(base: string, dirs: string[]): string {
554  let d = base;
555  for (const x of dirs) d = x.startsWith('/') ? x : d === '.' ? x : `${d}/${x}`;
556  return d;
557}
558
559// Git calls anywhere in the command whose subcommand git does not ship under
560// that name, so it may be an alias. `inline` is a `-c alias.<sub>=…` value.
561export function possibleAliases(
562  command: string,
563  aliases: ShellAliases = new Map(),
564): { sub: string; inline: string | null }[] {
565  // Read twice: with git as itself, so `git ci` is looked up even under
566  // `git='hub'`, and through the `git` alias, which can add its own `-c alias.<sub>=…`.
567  const out = aliasCallsIn(command, withoutGit(aliases));
568  if (!aliases.has('git')) return out;
569  for (const a of aliasCallsIn(command, aliases)) {
570    if (!out.some((o) => o.sub === a.sub && o.inline === a.inline)) out.push(a);
571  }
572  return out;
573}
574
575function aliasCallsIn(
576  command: string,
577  aliases: ShellAliases,
578): { sub: string; inline: string | null }[] {
579  const parsed = parse(command, aliases);
580  if ('error' in parsed) return [];
581  const out: { sub: string; inline: string | null }[] = [];
582  every(parsed.statements, (st) => {
583    const k = kindOf(st);
584    const calls = k.kind === 'git' ? [k.git] : k.kind === 'other' ? gitCallsIn(k.words) : [];
585    for (const g of calls) {
586      if ('refuse' in g || reads(g.sub, g.args) || commits(g.sub) || g.sub === 'add') continue;
587      const prefix = `alias.${g.sub}=`;
588      const inline = g.config.find((c) => c.startsWith(prefix))?.slice(prefix.length) ?? null;
589      out.push({ sub: g.sub, inline });
590    }
591    return null;
592  });
593  return out;
594}
595
596// A `!` git alias runs its text in a shell, with the alias's arguments
597// appended: one that ends in a bare `git`, or passes them on, runs whatever
598// it is given.
599function shellAliasCommits(script: string): boolean {
600  if (classify(script).kind !== 'none' || /\$[@*1-9]/.test(script)) return true;
601  const parsed = parse(script);
602  if ('error' in parsed) return true;
603  // An inline `-c alias.x=…` defines a git alias the gate cannot look up.
604  if (possibleAliases(script).some((a) => a.inline !== null)) return true;
605  const words = parsed.statements.at(-1)?.words ?? [];
606  return words.some(
607    (w, i) => !w.dynamic && basename(w.text) === 'git' && gitCall(words.slice(i), null) === null,
608  );
609}
610
611// Whether alias `sub` ends up committing or pushing, following aliases of
612// aliases through `lookup` (the expansion of a name, or null for none). An
613// expansion may lead with git's own options (`-c x=y commit`). Too deep a
614// chain counts as committing: it cannot be followed to the end.
615export function aliasCommits(sub: string, lookup: (name: string) => string | null): boolean {
616  let name = sub;
617  for (let depth = 0; depth < 8; depth++) {
618    const expansion = lookup(name)?.trim();
619    if (!expansion) return false;
620    if (expansion.startsWith('!')) return shellAliasCommits(expansion.slice(1));
621    // Git splits an alias as a shell would, quotes included.
622    const parsed = parse(expansion);
623    if ('error' in parsed) return true;
624    const words = (parsed.statements[0]?.words ?? []).map((w) => w.text);
625    let i = 0;
626    while (i < words.length && (words[i] ?? '').startsWith('-')) {
627      i += words[i] === '-c' || words[i] === '-C' || GIT_VALUE_OPTIONS.has(words[i] ?? '') ? 2 : 1;
628    }
629    const next = words[i] ?? '';
630    if (commits(next) || REFUSED.has(next)) return true;
631    name = next;
632  }
633  return true;
634}
635
636function execs(w: Word): boolean {
637  return /^(--exec(=|$)|-[a-zA-Z]*x[a-zA-Z]*$)/.test(w.text);
638}
639
640function withoutGit(aliases: ShellAliases): ShellAliases {
641  if (!aliases.has('git')) return aliases;
642  const own = new Map(aliases);
643  own.delete('git');
644  return own;
645}
646
647// A shell alias for git itself can run anything in git's place, or more than
648// git. A command that reaches it is judged on its expansion, which is what
649// runs; one whose expansion does not read as git is refused.
650export function classify(command: string, aliases: ShellAliases = new Map()): Classification {
651  const git = aliases.get('git');
652  const own = withoutGit(aliases);
653  const plain = judge(command, own);
654  // Read both ways: `eval` reparses its text, so whether the alias is reached
655  // cannot be told from the outer command.
656  if (git === undefined || plain.kind === 'refuse') return plain;
657  const expanded = judge(command, aliases);
658  if (expanded.kind === 'gated' || (plain.kind === 'none' && expanded.kind === 'none')) {
659    return expanded;
660  }
661  return {
662    kind: 'refuse',
663    reason: `\`git\` is a shell alias here (for \`${git}\`), so git would not run as the gate reads it. Run \`\\git …\` so git runs as itself`,
664  };
665}
666
667// A filter that reads the pipe to its end and runs nothing: `tail` with a
668// count, or `wc` with its counting flags. Anything that may stop reading early
669// (`head`, `grep -q`, a file operand) can kill a hook still printing, so git
670// aborts while the pipeline reports success.
671// One count: from the start (`+N`), or a nonzero last-N. GNU `tail` exits at
672// once on a zero count and BSD `tail` on a second count, neither reading.
673const COUNT = String.raw`(\+\d{1,9}|0*[1-9]\d{0,8})`;
674const TAIL_ONE = new RegExp(`^(-0*[1-9]\\d{0,8}|-[nc]${COUNT}|--(lines|bytes)=${COUNT})$`);
675const TAIL_COUNT = new RegExp(`^${COUNT}$`);
676
677function readsToEnd(head: string, args: string[]): boolean {
678  if (head === 'wc') return args.every((a) => /^-[lcwm]+$/.test(a));
679  if (head !== 'tail' || args.length > 2) return false;
680  const [a, b] = args;
681  if (a === undefined) return true;
682  if (b === undefined) return TAIL_ONE.test(a);
683  return (a === '-n' || a === '-c') && TAIL_COUNT.test(b);
684}
685
686// `op` is the operator after a statement, so a filter fed by a pipe is one
687// whose predecessor ends in `|`; one ending in `&` would run the whole
688// pipeline in the background, past the gate's check after the command.
689function isOutputFilter({ st, k }: { st: Statement; k: Kind }, fed: Statement): boolean {
690  return (
691    fed.op === '|' &&
692    st.op !== '&' &&
693    k.kind === 'other' &&
694    !st.redirected &&
695    st.inner.length === 0 &&
696    readsToEnd(
697      k.head,
698      k.words.slice(1).map((w) => w.text),
699    )
700  );
701}
702
703function judge(command: string, aliases: ShellAliases): Classification {
704  const parsed = parse(command, aliases);
705  if ('error' in parsed) {
706    // The qualifier's code can spell a commit any way, so nothing around it is read.
707    if (parsed.error === QUALIFIER) {
708      return {
709        kind: 'refuse',
710        reason: `${QUALIFIER}, which the gate does not read. Run the command without it`,
711      };
712    }
713    // The shell joins a backslash-newline before it reads anything.
714    const flat = command.replaceAll('\\\n', '');
715    const tokens = flat.split(/[^\w.:@+-]+/);
716    const aliased = tokens.some((w, i) =>
717      aliasMentionsGit(w, aliases, tokens.slice(i + 1).join(' ')),
718    );
719    return MENTION.test(flat) || /\bgit\b/.test(flat) || /\bgit\b/.test(parsed.text) || aliased
720      ? { kind: 'refuse', reason: `the command could not be read (${parsed.error})` }
721      : { kind: 'none' };
722  }
723  const sts = parsed.statements;
724
725  const reason = hidden(sts, parsed.text, aliases);
726  if (reason) return { kind: 'refuse', reason: `${reason}. ${SHAPE}` };
727
728  const all = sts.map((st) => ({ st, k: kindOf(st) }));
729  const sensitive = all.some(({ k }) => k.kind === 'git' && records(k.git));
730  if (!sensitive) return { kind: 'none' };
731  // A pipe into a plain output filter at the very end reads what the commit or
732  // push printed and runs nothing, so it is set aside before judging.
733  let end = all.length;
734  while (end > 1 && isOutputFilter(all[end - 1]!, all[end - 2]!.st)) end--;
735  const pairs = all.slice(0, end);
736  if (end < all.length) {
737    const tail = pairs[end - 1]!;
738    pairs[end - 1] = { ...tail, st: { ...tail.st, op: '' } };
739  }
740
741  let base = '.';
742  let dir: string | null = null;
743  const adds: string[][] = [];
744  let commit: CommitSpec | null = null;
745  let history: string | null = null;
746  let push: PushSpec | null = null;
747  let movedIndex: string | null = null;
748  let retargeted: string | null = null;
749  for (const [i, { st, k }] of pairs.entries()) {
750    if (st.op !== '' && st.op !== '&&' && st.op !== ';' && st.op !== '\n') {
751      return {
752        kind: 'refuse',
753        reason: `a commit or push in a command joined with \`${st.op}\`. ${SHAPE}`,
754      };
755    }
756    if (st.group)
757      return { kind: 'refuse', reason: `a group or function alongside a commit or push. ${SHAPE}` };
758    if (k.kind === 'refuse') return { kind: 'refuse', reason: `${k.reason}. ${SHAPE}` };
759    if (k.kind === 'assign') continue;
760    if (k.kind === 'cd') {
761      if (i !== 0) return { kind: 'refuse', reason: `a cd after the first command. ${SHAPE}` };
762      if (k.dir === null) {
763        return {
764          kind: 'refuse',
765          reason: `a cd whose directory is not a literal path, before a commit or push. ${SHAPE}`,
766        };
767      }
768      base = k.dir;
769      continue;
770    }
771    if (k.kind === 'other')
772      return {
773        kind: 'refuse',
774        reason: `${k.head ? `\`${k.head}\`` : 'an assignment with a substitution or redirect'} alongside a commit or push. ${SHAPE}`,
775      };
776    const g = k.git;
777    const d = joinDir(base, g.dirs);
778    if (reads(g.sub, g.args)) continue;
779    if ((NEUTRAL.has(g.sub) || fastForwardOnly(g)) && !commit && history === null && !push) {
780      // A pull moves HEAD, and `--squash` stages what it brings in.
781      if (MOVES_INDEX.has(g.sub) || g.sub === 'pull') movedIndex = g.sub;
782      // Any of them can move HEAD, a branch, an upstream or a remote URL.
783      retargeted ??= g.sub;
784      continue;
785    }
786    if (RESTAGE.has(g.sub)) {
787      return {
788        kind: 'refuse',
789        reason: `git ${g.sub} alongside a commit. Run it as its own command first, then commit`,
790      };
791    }
792    if (dir !== null && d !== dir)
793      return { kind: 'refuse', reason: `git commands in different directories. ${SHAPE}` };
794    dir = d;
795    if (g.sub === 'add') {
796      if (commit || history || push)
797        return { kind: 'refuse', reason: `git add after the commit. ${SHAPE}` };
798      // With `;`, a failed add still lets the commit run, on an index the
799      // gate never judged.
800      if (st.op !== '&&') {
801        return {
802          kind: 'refuse',
803          reason: `git add joined with \`${st.op === '\n' ? 'a newline' : st.op}\`. ${SHAPE}`,
804        };
805      }
806      const argv = addArgv(g.config, g.args);
807      if ('refuse' in argv) return { kind: 'refuse', reason: `${argv.refuse}. ${SHAPE}` };
808      adds.push(argv);
809    } else if (g.sub === 'push') {
810      if (push) return { kind: 'refuse', reason: `more than one push. ${SHAPE}` };
811      const spec = pushSpec(g.args);
812      if ('refuse' in spec) return { kind: 'refuse', reason: spec.refuse };
813      push = {
814        ...spec,
815        argv: spec.argv === null ? null : { ...spec.argv, config: g.config },
816        // `git rebase <upstream> <branch>` checks the branch out first.
817        after: retargeted ?? history,
818      };
819    } else {
820      if (commit || history || push)
821        return {
822          kind: 'refuse',
823          reason: `more than one command that commits, or one after a push. ${SHAPE}`,
824        };
825      if (g.sub === 'commit') {
826        if (movedIndex !== null) {
827          return {
828            kind: 'refuse',
829            reason: `git ${movedIndex} before a commit can change what it records. Run it as its own command first, then commit`,
830          };
831        }
832        const spec = commitSpec(g.args);
833        if ('refuse' in spec) return { kind: 'refuse', reason: spec.refuse };
834        commit = { ...spec, config: g.config };
835      } else if (HISTORY.has(g.sub)) {
836        if (adds.length > 0)
837          return { kind: 'refuse', reason: `git add before git ${g.sub}. ${SHAPE}` };
838        if (g.sub === 'rebase' && g.args.some(execs)) {
839          return {
840            kind: 'refuse',
841            reason: `git ${g.sub} --exec, which runs commands the gate cannot see. The user can run it from their terminal`,
842          };
843        }
844        if (g.args.some((w) => w.text === '--continue')) {
845          return {
846            kind: 'refuse',
847            reason: `git ${g.sub} --continue records the conflict resolution, content no reviewer has seen. Review the resolution first; the user can finish it from their terminal`,
848          };
849        }
850        history = g.sub;
851      } else {
852        return { kind: 'refuse', reason: `git ${g.sub} alongside a commit or push. ${SHAPE}` };
853      }
854    }
855  }
856  const at = dir ?? base;
857  if (commit) return { kind: 'gated', dir: at, adds, commit, history: null, push };
858  if (history !== null) return { kind: 'gated', dir: at, adds, commit: null, history, push };
859  if (push) return { kind: 'gated', dir: at, adds, commit: null, history: null, push };
860  throw new Error('a command that commits was classified with nothing to gate');
861}
862
863function tame(text: string): string {
864  return text
865    .trim()
866    .replaceAll(/[\p{Zs}\t]*\r?\n\s*/gu, ' ⏎ ')
867    .replaceAll(/[\p{Zs}\t]+/gu, ' ')
868    .replaceAll(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '\u{FFFD}');
869}
870
871// gh's own commands (gh 2.102.0's `gh --help`). Any other word is a gh alias
872// or an extension, which the gate does not read.
873const GH_COMMANDS = new Set([
874  'agent-task',
875  'alias',
876  'api',
877  'attestation',
878  'auth',
879  'browse',
880  'cache',
881  'codespace',
882  'cs',
883  'completion',
884  'config',
885  'copilot',
886  'discussion',
887  'extension',
888  'ext',
889  'extensions',
890  'gist',
891  'gpg-key',
892  'help',
893  'issue',
894  'label',
895  'licenses',
896  'org',
897  'pr',
898  'preview',
899  'project',
900  'release',
901  'repo',
902  'ruleset',
903  'run',
904  'search',
905  'secret',
906  'skill',
907  'ssh-key',
908  'status',
909  'variable',
910  'version',
911  'workflow',
912]);
913// The verbs of the groups the ladder reads; any other word there is an alias
914// (`pr m`), so it asks.
915const GH_VERBS: Record<string, ReadonlySet<string>> = {
916  pr: new Set([
917    'create',
918    'new',
919    'list',
920    'ls',
921    'status',
922    'checkout',
923    'co',
924    'checks',
925    'close',
926    'comment',
927    'diff',
928    'edit',
929    'lock',
930    'merge',
931    'ready',
932    'reopen',
933    'revert',
934    'review',
935    'unlock',
936    'update-branch',
937    'view',
938  ]),
939  issue: new Set([
940    'create',
941    'new',
942    'list',
943    'ls',
944    'status',
945    'close',
946    'comment',
947    'delete',
948    'develop',
949    'edit',
950    'lock',
951    'pin',
952    'reopen',
953    'transfer',
954    'unlock',
955    'unpin',
956    'view',
957  ]),
958  release: new Set([
959    'create',
960    'new',
961    'list',
962    'ls',
963    'delete',
964    'delete-asset',
965    'download',
966    'edit',
967    'upload',
968    'verify',
969    'verify-asset',
970    'view',
971  ]),
972};
973
974// gh's options that take a value: `gh -R o/r pr create`, `gh pr -R o/r create`.
975const GH_VALUE = new Set(['-R', '--repo', '--hostname']);
976// `gh pr merge`'s options that take a value.
977const MERGE_VALUE = new Set([
978  '-b',
979  '--body',
980  '-F',
981  '--body-file',
982  '-t',
983  '--subject',
984  '--match-head-commit',
985  '-A',
986  '--author-email',
987]);
988const RELEASE_WRITES = new Set(['create', 'new', 'edit', 'delete', 'delete-asset', 'upload']);
989// Read as text when the command does not parse: opening a pull request, and
990// any other gh write, which then cannot be read further.
991const GH_PR = /\bgh\b[^|;&\n]*\bpr\s+(?:[^\s;&|]+\s+)*?(create|new|ready)\b/;
992const GH_WRITE =
993  /\bgh\b[^|;&\n]*\b((pr\s+(?:[^\s;&|]+\s+)*?(merge|review|comment)|issue\s+(?:[^\s;&|]+\s+)*?comment|release\s+(?:[^\s;&|]+\s+)*?(create|new|edit|delete|upload))\b|api\s(?:[^|;&\n]*\s)?(-X|--method|-[fF]|--field|--raw-field|--input))/;
994
995const isRepo = (t: string) => t === '-R' || t === '--repo';
996const namesRepo = (t: string) => isRepo(t) || t.startsWith('--repo=') || /^-R./.test(t);
997
998// The repository an option names: `-R o/r`, `-Ro/r`, `--repo o/r`, `--repo=o/r`.
999function repoAt(words: Word[], i: number): Word | null {
1000  const w = words[i];
1001  const t = w?.text ?? '';
1002  if (w && t.startsWith('--repo=')) return { ...w, text: t.slice('--repo='.length) };
1003  if (w && /^-R./.test(t)) return { ...w, text: t.slice(2) };
1004  return words[i + 1] ?? null;
1005}
1006
1007// Skips options from `i`, noting the repository one names.
1008function pastOptions(words: Word[], from: number, into: { repo: Word | null }): number {
1009  let i = from;
1010  while (words[i]?.text.startsWith('-')) {
1011    const t = words[i]?.text ?? '';
1012    if (namesRepo(t)) into.repo = repoAt(words, i);
1013    i += GH_VALUE.has(t) ? 2 : 1;
1014  }
1015  return i;
1016}
1017
1018// Go's strconv.ParseBool, which gh's flags use: `--approve=0` is unset.
1019const FALSE = new Set(['0', 'f', 'F', 'false', 'False', 'FALSE']);
1020
1021// Whether a gh boolean flag is set, as gh's flag parser reads it: `--name`,
1022// `--name=<true>`, or its letter in a short cluster before a letter that takes
1023// the rest as its value (`-ab LGTM` approves, `-a=false` does not).
1024function flagSet(words: Word[], long: string, short: string | null, valueLetters: string): boolean {
1025  return words.some(({ text: t }) => {
1026    if (t === `--${long}`) return true;
1027    if (t.startsWith(`--${long}=`)) return !FALSE.has(t.slice(long.length + 3));
1028    if (short === null || !/^-[^-]/.test(t)) return false;
1029    for (let i = 1; i < t.length; i++) {
1030      const letter = t.charAt(i);
1031      if (letter === short) return t[i + 1] === '=' ? !FALSE.has(t.slice(i + 2)) : true;
1032      if (valueLetters.includes(letter)) return false;
1033    }
1034    return false;
1035  });
1036}
1037
1038// Whether a word built at run time may be a flag or a selector rather than
1039// the value of an option that takes one (`-b "$BODY"`, `--subject="$S"`,
1040// `-b"$BODY"`). Unquoted, a value may split into words that are flags.
1041function builtArgs(rest: Word[], values: ReadonlySet<string>): boolean {
1042  return rest.some((w, i) => {
1043    if (!w.dynamic) return false;
1044    if (w.splits) return true;
1045    if (values.has(rest[i - 1]?.text ?? '')) return false;
1046    const option = /^(--[^=]+)=/.exec(w.text)?.[1] ?? /^(-[^-])./.exec(w.text)?.[1];
1047    return option === undefined || !values.has(option);
1048  });
1049}
1050
1051const REVIEW_VALUE = new Set(['-b', '--body', '-F', '--body-file', '-R', '--repo']);
1052
1053// What a `gh pr` command names for `gh pr view` to look up, or why it cannot be.
1054function lookupOf(
1055  rest: Word[],
1056  repo: Word | null,
1057  context: GhContext,
1058): readonly string[] | Unlookable {
1059  let selector: Word | null = null;
1060  let target = repo;
1061  for (let i = 0; i < rest.length; i++) {
1062    const t = rest[i]?.text ?? '';
1063    if (namesRepo(t)) {
1064      target = repoAt(rest, i);
1065      if (isRepo(t)) i++;
1066    } else if (MERGE_VALUE.has(t)) i++;
1067    else if (!t.startsWith('-') && selector === null) selector = rest[i] ?? null;
1068  }
1069  if (context === 'beside') return { cannot: 'beside' };
1070  if (context === 'elsewhere' || target?.dynamic) return { cannot: 'elsewhere' };
1071  return [...(selector ? [selector.text] : []), ...(target ? ['--repo', target.text] : [])];
1072}
1073
1074// Whether `--auto` is set as a flag, not taken as an option's value: in
1075// `--body --auto` it is the body, and gh merges at once.
1076function autoSet(rest: Word[]): boolean {
1077  const flags = rest.filter((_, i) => {
1078    const before = rest[i - 1]?.text ?? '';
1079    return !MERGE_VALUE.has(before) && before !== '-R' && before !== '--repo';
1080  });
1081  return flagSet(flags, 'auto', null, '');
1082}
1083
1084function mergeOf(words: Word[], from: number, repo: Word | null, context: GhContext): GhAction {
1085  const rest = words.slice(from);
1086  return {
1087    kind: 'merge',
1088    admin: flagSet(rest, 'admin', null, ''),
1089    auto: autoSet(rest),
1090    lookup: lookupOf(rest, repo, context),
1091  };
1092}
1093
1094// gh prints help and stops, unless an option takes the word as its value:
1095// `-b --help` merges. After any option it counts as a value, so `--admin
1096// --help` still asks, and `--help=false` or xargs input can turn it off.
1097function asksHelp(words: Word[], fed: boolean): boolean {
1098  if (fed) return false;
1099  const end = words.findIndex((w) => w.text === '--');
1100  const args = end === -1 ? words : words.slice(0, end);
1101  if (args.some((w) => w.text.startsWith('--help='))) return false;
1102  return args.some((w, i) => {
1103    if (w.text !== '-h' && w.text !== '--help') return false;
1104    const before = args[i - 1];
1105    return before === undefined || (!before.dynamic && !before.text.startsWith('-'));
1106  });
1107}
1108
1109// The gh command at `at`. An alias or extension is not read, so the agent
1110// writes gh's own words; `fed` is xargs or parallel supplying the rest.
1111function ghAt(
1112  words: Word[],
1113  at: number,
1114  context: GhContext,
1115  fed: boolean,
1116): GhAction | GhAction[] | null {
1117  const seen = { repo: null as Word | null };
1118  const sub = pastOptions(words, at + 1, seen);
1119  const group = words[sub]?.text;
1120  const fromInput = { kind: 'unread', why: 'its gh command comes from its input' } as const;
1121  if (group === undefined) return fed ? fromInput : null;
1122  if (words[sub]?.dynamic) return { kind: 'unread', why: 'its gh command is built at run time' };
1123  if (!GH_COMMANDS.has(group)) {
1124    return {
1125      kind: 'unread',
1126      why: `\`gh ${group}\` is a gh alias or extension, which the gate does not read`,
1127    };
1128  }
1129  if (asksHelp(words.slice(at + 1), fed)) return null;
1130  if (group === 'api') {
1131    // `gh --hostname h api …` sends it to another host.
1132    const host = words.slice(at + 1, sub).some((w) => /^--hostname(=|$)/.test(w.text));
1133    return apiActions(words.slice(sub + 1), host ? 'elsewhere' : context, fed);
1134  }
1135  const verbs = GH_VERBS[group];
1136  if (verbs === undefined) return null;
1137  const verbAt = pastOptions(words, sub + 1, seen);
1138  const verb = words[verbAt]?.text;
1139  if (verb === undefined) return fed ? fromInput : null;
1140  if (words[verbAt]?.dynamic) return { kind: 'unread', why: 'its gh command is built at run time' };
1141  if (!verbs.has(verb)) {
1142    return {
1143      kind: 'unread',
1144      why: `\`gh ${group} ${verb}\` is a gh alias, which the gate does not read`,
1145    };
1146  }
1147  const w = words;
1148  const rest = w.slice(verbAt + 1);
1149  // A flag built at run time or fed by xargs could approve, merge with
1150  // --admin or point --repo elsewhere, which the words do not show.
1151  const unreadArgs = {
1152    kind: 'unread',
1153    why: `the arguments of \`gh pr ${verb}\` are built at run time or come from its input`,
1154  } as const;
1155  if (group === 'pr') {
1156    if (verb === 'create' || verb === 'new' || verb === 'revert') return { kind: 'pr' };
1157    if (verb === 'merge') {
1158      if (fed || builtArgs(rest, new Set([...MERGE_VALUE, '-R', '--repo']))) return unreadArgs;
1159      return mergeOf(w, verbAt + 1, seen.repo, context);
1160    }
1161    if (verb === 'review') {
1162      if (fed || builtArgs(rest, REVIEW_VALUE)) return unreadArgs;
1163      return { kind: flagSet(rest, 'approve', 'a', 'bFR') ? 'approve' : 'comment' };
1164    }
1165    if (verb === 'comment') return { kind: 'comment' };
1166    // `--undo` turns it back into a draft, which asks nobody to review it.
1167    if (verb === 'ready') {
1168      // A value built at run time may be `false`, which marks it ready.
1169      const built = rest.some((w) => w.dynamic && w.text.startsWith('--undo='));
1170      return flagSet(rest, 'undo', null, '') && !built ? null : { kind: 'pr' };
1171    }
1172    // Merges or rebases the base into the pull request's own branch.
1173    if (verb === 'update-branch') {
1174      if (fed || builtArgs(rest, new Set(['-R', '--repo']))) return unreadArgs;
1175      if (flagSet(rest, 'rebase', null, '')) {
1176        return { kind: 'push', ref: asking("it rebases the pull request's branch", true) };
1177      }
1178      const lookup = lookupOf(rest, seen.repo, context);
1179      return 'cannot' in lookup
1180        ? { kind: 'push', ref: asking('the pull request it updates cannot be looked up', false) }
1181        : { kind: 'push', ref: { head: lookup } };
1182    }
1183  }
1184  if (group === 'issue' && verb === 'comment') return { kind: 'comment' };
1185  if (group === 'release' && RELEASE_WRITES.has(verb)) return { kind: 'release' };
1186  return null;
1187}
1188
1189// Commands that hand their input to the command they run.
1190const FROM_INPUT = new Set(['xargs', 'parallel']);
1191
1192// As for git, `gh` or a publisher at any word runs unless the first word is a data command:
1193// `timeout 60 gh pr create`, `sudo -u bot gh …`. Quoted text is not read, so
1194// `bash -c "gh pr create"` goes unseen: a well-meaning agent writes it plainly.
1195function ghActionsOf(st: Statement, alone: boolean): GhAction[] {
1196  const first = basename(st.words[0]?.text ?? '');
1197  // `gh` is a data command to the git check: what it is given never runs git.
1198  if (first !== 'gh' && DATA.has(first)) return [];
1199  const found: GhAction[] = [];
1200  const published = publishActionsOf(st.words);
hooks/consent.ts 1058 lines
1// Decides whether a human prompt asks for a push. Pure.
2//
3// A grant needs the verb in a request the grammar below recognises: only
4// request words before it ("ok, please commit", "can you push?", "I want you
5// to commit") and only an object, a destination or a courtesy after it
6// ("commit the changes", "push to main now"). Anything else grants nothing:
7// "the commit gate", "agents would commit", "I'll push later", "commit to this
8// approach", "push the button". Missing a request costs one question; reading
9// one that was not made costs a push nobody asked for.
10//
11// A bare affirmative ("yes", "go ahead") grants what the previous answer's
12// closing question offered to do.
13
14// How far the user's message lets a push go; each level covers those before
15// it.
16const PUSH_LEVELS = ['none', 'push', 'lease', 'bare'] as const;
17export type PushLevel = (typeof PUSH_LEVELS)[number];
18export const pushRank = (level: PushLevel): number => PUSH_LEVELS.indexOf(level);
19
20// What the user's message asked for. `push` is only what they asked to push:
21// the push a pull request needs is judged by the gate, which still asks
22// before a tag or default-branch push it would carry. `comment` is a reply on
23// a pull request, asked for by addressing its review. `autoMerge` is a merge
24// asked for once the pull request is ready ("merge it when CI passes"), which
25// only `gh pr merge --auto` leaves to GitHub. `mergeNamed` holds the pull
26// request numbers a merge request named ("merge 131"), empty for "merge it".
27export type Grant = Readonly<{
28  commit: boolean;
29  push: PushLevel;
30  pr: boolean;
31  merge: boolean;
32  mergeNamed: readonly string[];
33  autoMerge: boolean;
34  approve: boolean;
35  release: boolean;
36  comment: boolean;
37}>;
38
39export const NO_GRANT: Grant = Object.freeze({
40  commit: false,
41  push: 'none',
42  pr: false,
43  merge: false,
44  mergeNamed: [],
45  autoMerge: false,
46  approve: false,
47  release: false,
48  comment: false,
49});
50
51// Compared by rank, so a level added between two others keeps its meaning.
52export function covers(grant: Grant, level: PushLevel): boolean {
53  return pushRank(grant.push) >= pushRank(level);
54}
55
56type Verb = 'commit' | 'push' | 'force' | 'pr' | 'merge' | 'approve' | 'release' | 'comment';
57type MutableGrant = { -readonly [K in keyof Grant]: Grant[K] };
58
59const fresh = (): MutableGrant => ({ ...NO_GRANT });
60
61function raise(g: MutableGrant, level: PushLevel): void {
62  if (pushRank(level) > pushRank(g.push)) g.push = level;
63}
64
65// A verb family: the word a request uses and the one an offer uses ("push",
66// "pushing"), what it grants, and what may follow it.
67type Family = Readonly<
68  {
69    forms: readonly [request: string, offer: string];
70    grants: readonly Verb[];
71    // What the tail must name for the verb to ask.
72    object?: (tail: readonly string[]) => boolean;
73  } &
74    // A merge or approval names a pull request, and nothing else.
75    (
76      | { tail: 'pull-request'; numbered?: never; packaged?: never; from?: never }
77      // A publish names a version or a registry, never git work.
78      | { tail: 'publish'; numbered: true; packaged: true; from?: never }
79      | {
80          tail?: never;
81          // A number or "on": "Release 0.25.0?", "Reply to the review on #116?".
82          numbered?: true;
83          // A package or registry: "release the crate", "cut a release to npm".
84          packaged?: true;
85          // "from" names a pull request's head; after a push it names a source.
86          from?: true;
87        }
88    )
89>;
90
91// Replying names the review it answers, so "address the TODO comments" asks
92// for nothing on GitHub.
93const REVIEW = new Set(['review', 'reviews', 'reviewer', 'reviewers', 'feedback', 'pr']);
94const reply = (forms: Family['forms']): Family => ({
95  forms,
96  grants: ['comment'],
97  numbered: true,
98  object: (tail) => tail.some((x) => REVIEW.has(x)),
99});
100const releasing = (forms: Family['forms']): Family => ({
101  forms,
102  grants: ['release'],
103  numbered: true,
104  packaged: true,
105});
106
107const FAMILIES: readonly Family[] = [
108  { forms: ['commit', 'committing'], grants: ['commit'] },
109  { forms: ['push', 'pushing'], grants: ['push'] },
110  { forms: ['ship', 'shipping'], grants: ['commit', 'push', 'pr'] },
111  // Deleting a remote branch is a push: the branch is what goes, so "delete it
112  // from the branch" (a commit) and "delete the branch comments" ask for none.
113  {
114    forms: ['delete', 'deleting'],
115    grants: ['push'],
116    object: (tail) => {
117      const at = tail.lastIndexOf('branch');
118      if (at === -1 || tail.slice(0, at).some((x) => TOWARD.has(x))) return false;
119      const rest = tail.slice(at + 1).filter((x) => !COURTESY.has(x));
120      const [toward, ...where] = rest;
121      return toward === undefined || (TOWARD.has(toward) && where.every((x) => REMOTE.has(x)));
122    },
123    from: true,
124  },
125  // "force push" and "force-push", read as one word by `forcePhrase`.
126  { forms: ['forcepush', 'forcepushing'], grants: ['push', 'force'] },
127  // "open a PR", read as one word by `prPhrase`.
128  { forms: ['openpr', 'openingpr'], grants: ['pr'], from: true },
129  { forms: ['merge', 'merging'], grants: ['merge'], tail: 'pull-request' },
130  { forms: ['approve', 'approving'], grants: ['approve'], tail: 'pull-request' },
131  releasing(['release', 'releasing']),
132  // "cut a release", read as one word by `prPhrase`.
133  releasing(['cutrelease', 'cuttingrelease']),
134  {
135    forms: ['publish', 'publishing'],
136    grants: ['release'],
137    tail: 'publish',
138    numbered: true,
139    packaged: true,
140  },
141  // "mark it ready for review", read as one word by `prPhrase`.
142  { forms: ['markready', 'markingready'], grants: ['pr'] },
143  reply(['address', 'addressing']),
144  reply(['reply', 'replying']),
145  reply(['respond', 'responding']),
146];
147
148// Every verb form, request and offer, as a copy the table never reads.
149export const VERB_FORMS: readonly string[] = FAMILIES.flatMap((f) => f.forms);
150
151const REQUESTED: Readonly<Record<string, Family>> = Object.fromEntries(
152  FAMILIES.map((f) => [f.forms[0], f]),
153);
154const OFFERED: Readonly<Record<string, Family>> = Object.fromEntries(
155  FAMILIES.flatMap((f) => f.forms.map((form) => [form, f])),
156);
157
158// "open a PR", "create the pull request", "opening a new draft PR"; "cut a
159// release", "publish the new release"; "mark it ready for review", "ready for
160// review".
161function prPhrase(text: string): string {
162  return (
163    text
164      .replaceAll(
165        /\b(?:(mark)|(marking))\s+(?:(?:it|this|the|pr|pull[\s-]request|draft|#?\d+)\s+)*(?:as\s+)?ready(?:\s+for\s+review)?\b/gi,
166        (_m: string, verb?: string) => (verb ? 'markready' : 'markingready'),
167      )
168      .replaceAll(/\bready for review\b/gi, 'markready')
169      .replaceAll(
170        /\b(?:(open|create|make|raise|submit)|(opening|creating|making|raising|submitting))\s+(?:(?:a|an|the|new)\s+)*(?:draft\s+)?(?:pr|pull[\s-]request)\b/gi,
171        (_m: string, verb?: string) => (verb ? 'openpr' : 'openingpr'),
172      )
173      .replaceAll(
174        /\b(?:(cut|create|make|publish|do)|(cutting|creating|making|publishing|doing))\s+(?:(?:a|an|the|new)\s+)*release\b/gi,
175        (_m: string, verb?: string) => (verb ? 'cutrelease' : 'cuttingrelease'),
176      )
177      // Merging the version pull request is the release, but only where the noun
178      // ends the clause: "merge the release fixes" names other work.
179      .replaceAll(
180        /\b(?:(merge)|(merging))\s+(?:(?:the|this|that|our|new)\s+)*(?:release|version(?:[\s-]packages)?)(?:\s+(?:pr|pull[\s-]request))?(?:\s+#?\d+)?(?=\s*(?:$|[.,;:!?)](?!\d))|\s+(?:now|please|then|and|too|yet|first|next)\b)/gi,
181        (_m: string, verb?: string) => (verb ? 'cutrelease' : 'cuttingrelease'),
182      )
183  );
184}
185
186// A bare --force named apart from a lease ("`--force`", "bare force push",
187// "without a lease") becomes the word `nolease`, which counts only in the
188// tail of a push the grammar grants. Run before quotes are read, since
189// `--force` is usually backticked; "without" would make the clause conditional.
190function forcePhrase(text: string): string {
191  return text
192    .replaceAll(/`--force`|(?<![\w-])--force(?![\w-])/g, 'nolease')
193    .replaceAll(/\bbare force[\s-]?push(ing)?\b/gi, (_m: string, ing?: string) =>
194      ing ? 'forcepushing nolease' : 'forcepush nolease',
195    )
196    .replaceAll(/\s*[,-]?\s*\bwithout (?:a |the )?lease\b/gi, ' nolease')
197    .replaceAll(/\bforce[\s-]?push(ing)?\b/gi, (_m: string, ing?: string) =>
198      ing ? 'forcepushing' : 'forcepush',
199    );
200}
201
202// Words that may come before the verb in a request.
203const REQUEST_LEAD = new Set([
204  'ok',
205  'okay',
206  'alright',
207  'great',
208  'cool',
209  'nice',
210  'perfect',
211  'awesome',
212  'good',
213  'fine',
214  'thanks',
215  'yes',
216  'yeah',
217  'sure',
218  'lgtm',
219  'now',
220  'then',
221  'also',
222  'just',
223  'please',
224  'go',
225  'ahead',
226  'lets',
227  "let's",
228  'let',
229  'us',
230  'can',
231  'could',
232  'would',
233  'will',
234  'you',
235  'feel',
236  'free',
237  'to',
238  'i',
239  "i'd",
240  'want',
241  'need',
242  'like',
243  'we',
244]);
245
246// Words that may come before the verb in the agent's offer.
247const OFFER_LEAD = new Set([
248  'ok',
249  'okay',
250  'so',
251  'now',
252  'then',
253  'want',
254  'me',
255  'to',
256  'should',
257  'shall',
258  'can',
259  'may',
260  'i',
261  "i'll",
262  'we',
263  'do',
264  'you',
265  'would',
266  'like',
267  'go',
268  'ahead',
269  'with',
270  'the',
271  'ready',
272  'proceed',
273  'and',
274]);
275
276// Words that may follow the verb: its object, where it goes, and courtesies.
277const TAIL = new Set([
278  'it',
279  'this',
280  'that',
281  'them',
282  'these',
283  'those',
284  'everything',
285  'all',
286  'of',
287  'the',
288  'my',
289  'your',
290  'our',
291  'changes',
292  'change',
293  'code',
294  'commits',
295  'fix',
296  'fixes',
297  'work',
298  'files',
299  'file',
300  'edits',
301  'diff',
302  'stuff',
303  'branch',
304  'tag',
305  'tags',
306  'up',
307  'to',
308  'into',
309  'against',
310  'from',
311  'main',
312  'master',
313  'origin',
314  'remote',
315  'upstream',
316  'github',
317  'pr',
318  'now',
319  'please',
320  'thanks',
321  'too',
322  'again',
323  'as',
324  'well',
325  'right',
326  'away',
327  'me',
328  'with',
329  'a',
330  'message',
331  'msg',
332  'quoted',
333  'lease',
334  'nolease',
335  'version',
336  'review',
337  'reviews',
338  'reviewer',
339  'reviewers',
340  'feedback',
341  'comments',
342  'comment',
343]);
344
345// What a publish or release may also name: "publish the package to npm". Kept
346// out of TAIL, so "push to npm" asks for no git push.
347const PACKAGE = new Set(['package', 'packages', 'crate', 'crates', 'npm', 'registry']);
348const PUBLISH_TAIL = new Set([
349  'it',
350  'this',
351  'that',
352  'them',
353  'these',
354  'those',
355  'all',
356  'of',
357  'my',
358  'our',
359  'your',
360  'me',
361  'as',
362  'well',
363  'the',
364  'a',
365  'version',
366  // A backticked name: "Publish `review-cycle@0.25.0`?".
367  'quoted',
368  'to',
369  'now',
370  'please',
371  'thanks',
372  'too',
373  'again',
374  'right',
375  'away',
376]);
377
378// After these, only a destination: "push to main", not "commit to this approach".
379const TOWARD = new Set(['to', 'into', 'against', 'from']);
380const COURTESY = new Set(['now', 'please', 'thanks', 'too', 'again']);
381// Where a deleted branch goes from.
382const REMOTE = new Set(['origin', 'upstream', 'remote', 'github', 'the']);
383const DESTINATION = new Set([
384  'main',
385  'master',
386  'origin',
387  'remote',
388  'upstream',
389  'github',
390  'the',
391  'it',
392  'pr',
393  'branch',
394  'review',
395  'reviewer',
396  'reviewers',
397  'feedback',
398  'comments',
399]);
400
401// A merge or approval names a pull request: "merge it", "merge the PR",
402// "approve 116". A tail naming branches or a destination ("merge main into
403// it") asks for a local `git merge`, which the push checks judge.
404const PULL_REQUEST = new Set([
405  'it',
406  'this',
407  'that',
408  'the',
409  'pr',
410  'now',
411  'please',
412  'thanks',
413  'too',
414  'again',
415  'right',
416  'away',
417]);
418// A pull request first, then at most the base it goes into: "merge 116 into
419// main", but not "merge main into it" or "merge this into the PR", which
420// name branches to merge locally.
421const BASE = new Set(['main', 'master', 'base', 'default']);
422function namesPullRequest(tail: string[]): boolean {
423  const into = tail.findIndex((x) => TOWARD.has(x) || x === 'on');
424  const named = into === -1 ? tail : tail.slice(0, into);
425  if (!named.every((x) => PULL_REQUEST.has(x) || /^\d+$/.test(x))) return false;
426  if (into === -1) return true;
427  const where = tail.slice(into + 1);
428  const pr = named.some((x) => /^\d+$/.test(x) || x === 'pr' || x === 'it' || x === 'this');
429  return (
430    pr &&
431    /^(into|to|against)$/.test(tail[into] ?? '') &&
432    where.some((x) => BASE.has(x)) &&
433    where.every((x) => BASE.has(x) || x === 'the' || x === 'branch')
434  );
435}
436
437// Opening a part joined by "and" or "then", these carry over to the parts
438// after it: "I didn't ask you to review and commit", "they review then commit".
439// A SUBORDINATE word withholds its clause before any part is read.
440const MOOD =
441  /^(don'?t|not|never|no|didn'?t|won'?t|can'?t|cannot|shouldn'?t|wouldn'?t|doesn'?t|i'll|i'm|i've|we'll|we're|they|he|she|agents?|claude|who|which|might|should)$/;
442
443// Opening a part, these describe something rather than ask for it: "the flow
444// is review, then commit". Later parts and continuing clauses grant nothing.
445const DESCRIBES = new Set([
446  'the',
447  'a',
448  'an',
449  'this',
450  'that',
451  'these',
452  'those',
453  'our',
454  'my',
455  'their',
456  'its',
457  "it's",
458  'it',
459  'there',
460  'here',
461]);
462
463// Anywhere in a part, these make it a statement about how work goes ("usually
464// I review", "the flow is review"), so the parts after it are description too.
465const STATEMENT = new Set([
466  'is',
467  'are',
468  'was',
469  'were',
470  'i',
471  'we',
472  'they',
473  'he',
474  'she',
475  'agents',
476  'agent',
477  'claude',
478  'usually',
479  'normally',
480  'typically',
481  'always',
482  'often',
483  'sometimes',
484  'generally',
485]);
486
487// Anywhere in the clause, these make the verb conditional, not a request.
488const SUBORDINATE = new Set([
489  'whether',
490  'if',
491  'unless',
492  'when',
493  'once',
494  'until',
495  'before',
496  'after',
497  'without',
498]);
499
500// A clause that only agrees: "ok", "sounds good".
501const AGREE =
502  /^\s*(ok|okay|alright|yes|yeah|yep|sure|great|cool|perfect|lgtm|sounds good|looks good)\s*$/i;
503const AGREE_WORD = new Set([
504  'ok',
505  'okay',
506  'alright',
507  'yes',
508  'yeah',
509  'yep',
510  'sure',
511  'great',
512  'cool',
513  'perfect',
514  'lgtm',
515]);
516// A clause with no verb that holds off: "not yet, commit later".
517const HOLD = /^\s*(not yet|not now|hold off|hold on|wait|don'?t yet)\b/i;
518const RETRACT = /^\s*(no|nope|wait|never ?mind|scratch that|hold on|actually,? (no|don'?t))\b/i;
519// A clause opening with one of these makes the whole prompt conditional.
520const CONDITION = /^\s*(if|when|once|after|unless|until|as soon as)\b/i;
521// A sentence opening with one of these asks something rather than asking for it.
522const QUESTION = new Set([
523  'why',
524  'how',
525  'what',
526  'when',
527  'where',
528  'which',
529  'who',
530  'is',
531  'are',
532  'was',
533  'does',
534  'do',
535  'did',
536  'has',
537  'have',
538  'should',
539  'explain',
540  'whether',
541]);
542// Questions that hand the action back to the user, or offer to skip it.
543const HANDBACK = new Set(['yourself', "you'd", 'rather', 'skip', 'leave', 'instead', 'terminal']);
544
545// "yes", "Ok, lets do that", "great, go ahead": a yes, a go-ahead, or both,
546// and nothing more, since a longer reply may say something else. "great" or
547// "looks good" alone may praise the work, so it answers only with a go-ahead.
548const YES = String.raw`(?:yes|yep|yeah|yup|y|ok|okay|sure|sounds good|lgtm)`;
549const AGREEMENT = String.raw`(?:${YES}|alright|great|cool|perfect|looks good)`;
550const GO_AHEAD = String.raw`(?:(?:let['’]?s|let us)\s+(?:do (?:it|that|this)|go(?: ahead| for it)?)|do (?:it|that|this)|go ahead|go for it|please do)`;
551const AFFIRMATIVE = new RegExp(
552  String.raw`^(?:${YES}|(?:${AGREEMENT}[\s,.!]+)+${GO_AHEAD}|${GO_AHEAD})\b[\s.!,]*(please|thanks|thank you)?[\s.!]*$`,
553  'i',
554);
555
556// Quoted text is a commit message or a name, never part of the request, even
557// when it runs over several lines.
558function unquote(text: string): string {
559  return text
560    .replaceAll(/(^|[\s(:=])(["'`])[\s\S]*?\2(?=$|[\s.,;:!?)])/g, '$1 quoted ')
561    .replaceAll(/“[^”]*”|‘[^’]*’/g, ' quoted ');
562}
563
564function words(text: string): string[] {
565  return text
566    .toLowerCase()
567    .replaceAll(/[’‘]/g, "'")
568    .split(/[^a-z0-9'-]+/)
569    .filter(Boolean);
570}
571
572// "commit it", "push to main now": nothing but tail words, with "to" naming a
573// destination and a message only in "with a message".
574function isTail(tail: string[], family: Family): boolean {
575  if (family.tail === 'pull-request') return namesPullRequest(tail);
576  const publish = family.tail === 'publish';
577  for (const [i, word] of tail.entries()) {
578    // A publish goes to or on a registry: "publish to npm", not "publish to it".
579    const where = word === 'to' || word === 'on';
580    const after = tail.slice(i + 1).find((x) => !/^(the|a|my|our|your)$/.test(x)) ?? '';
581    if (publish && where && !PACKAGE.has(after)) return false;
582    if (family.numbered && (word === 'on' || /^\d+$/.test(word))) continue;
583    if (family.packaged && PACKAGE.has(word)) continue;
584    // "Publish branch" is a first push in editors, so a publish names no git work.
585    if (publish && !PUBLISH_TAIL.has(word)) return false;
586    if (!TAIL.has(word)) return false;
587    if (word === 'from' && !family.from) return false;
588    const next = tail[i + 1] ?? '';
589    if (TOWARD.has(word) && !DESTINATION.has(next) && !(family.packaged && PACKAGE.has(after))) {
590      return false;
591    }
592    if ((word === 'message' || word === 'msg') && !tail.slice(0, i).includes('with')) return false;
593  }
594  return true;
595}
596
597// "we can push" after an agreement ("ok, we can push") answers, rather than
598// describes ("CI is green, we can push").
599function isLead(lead: string[], allowed: Set<string>, agreed: boolean): boolean {
600  if (!lead.every((x) => allowed.has(x) || x === 'to')) return false;
601  // The user's own plan unless addressed to the agent: "I want you to push".
602  if (allowed === REQUEST_LEAD) {
603    if (lead.some((x) => x === 'i' || x === "i'd") && !lead.includes('you')) return false;
604    const we = lead.indexOf('we');
605    const agreeing =
606      (agreed || (we > 0 && lead.slice(0, we).every((x) => AGREE_WORD.has(x)))) &&
607      /^(can|could)$/.test(lead[we + 1] ?? '');
608    if (we !== -1 && !agreeing && !/^(can|could|let'?s?)$/.test(lead[we - 1] ?? '')) return false;
609  }
610  // "Should we push?" offers; "Do we push to main?" asks how the repo works.
611  if (allowed === OFFER_LEAD) {
612    const we = lead.indexOf('we');
613    if (we !== -1 && !/^(should|shall)$/.test(lead[we - 1] ?? '')) return false;
614  }
615  return true;
616}
617
618// A condition GitHub's auto-merge waits on itself: "when it's ready", "once
619// CI passes", "after the checks are green".
620const READY =
621  /^(?:(?:it|it's|its|this|that|(?:the )?pr(?: \d+)?|(?:the )?ci|everything|(?:(?:the|its|all) )?(?:checks|tests))(?: is| are)? )?(?:ready(?: to merge)?|green|passing|passes|pass|succeeds|succeed|goes green|go green|turns green|turn green)(?: please| thanks| now)?$/;
622
623const asSoonAs = (w: readonly string[]): number =>
624  w.findIndex((x, i) => x === 'as' && w[i + 1] === 'soon' && w[i + 2] === 'as');
625
626// "merge it when it's ready": one merge request, then a condition only
627// auto-merge waits on. Anything else conditional asks for nothing.
628function isReadyMerge(
629  w: string[],
630  verbs: Readonly<Record<string, Family>>,
631  lead: Set<string>,
632  agreed: boolean,
633): boolean {
634  const soon = asSoonAs(w);
635  const k = soon === -1 ? w.findIndex((x) => SUBORDINATE.has(x)) : soon;
636  const after = soon === -1 ? k + 1 : k + 3;
637  if (k === -1 || (soon === -1 && !/^(when|once|after|if)$/.test(w[k] ?? ''))) return false;
638  if (!READY.test(w.slice(after).join(' '))) return false;
639  // "go ahead and merge it": only request words before the last "and".
640  const joined = w.slice(0, k).findLastIndex((x) => x === 'and' || x === 'then');
641  const before = w.slice(0, joined + 1).filter((x) => x !== 'and' && x !== 'then');
642  const request = w.slice(joined + 1, k);
643  const at = request.findIndex((x) => Object.hasOwn(verbs, x));
644  const family = verbs[request[at] ?? ''];
645  return (
646    family?.forms[0] === 'merge' &&
647    isLead([...before, ...request.slice(0, at)], lead, agreed) &&
648    namesPullRequest(request.slice(at + 1)) &&
649    samePullRequest(w.slice(after).join(' '), request.join(' '))
650  );
651}
652
653// "once it's ready, merge it" read as "merge it once it's ready", the form
654// isReadyMerge knows; null unless a readiness condition leads into a merge.
655function leadingReady(sentence: string): string | null {
656  const lead =
657    /^(\s*(?:(?:ok|okay|yes|yeah|sure|alright|great|cool|perfect)[\s,]+)*)(when|once|after|if|as soon as)\s+(.*?)([.!?;]*)$/is.exec(
658      sentence,
659    );
660  if (lead === null) return null;
661  const [, agreed = '', when = '', rest = '', end = ''] = lead;
662  const tokens = rest.split(/\s+/);
663  // The longest condition: "it is ready to merge, merge it" waits on all of it.
664  for (let i = tokens.length; i >= 1; i--) {
665    const condition = tokens.slice(0, i).join(' ').replace(/,$/, '');
666    if (!READY.test(words(condition).join(' '))) continue;
667    const request = tokens
668      .slice(i)
669      .join(' ')
670      .replace(/^then\s+/i, '');
671    if (!MERGE_WORD.test(request) || !samePullRequest(condition, request)) return null;
672    return `${agreed}${request} ${when} ${condition}${end}`;
673  }
674  return null;
675}
676
677// "merge 134 once PR 133 is ready" waits on #133, which `--auto` on #134
678// never does, so a condition naming a pull request names the one merged.
679function samePullRequest(condition: string, request: string): boolean {
680  const waits = /\bpr\s+#?(\d+)\b/i.exec(condition)?.[1];
681  const merges = /#?\b(\d+)\b/.exec(request)?.[1];
682  return waits === undefined || merges === undefined || waits === merges;
683}
684
685// Only a merge once it is ready: anything else the sentence asks for may wait
686// on the same condition.
687function onlyAutoMerge(g: MutableGrant): boolean {
688  return g.autoMerge && g.push === 'none' && STEPS.every((s) => s === 'autoMerge' || !g[s]);
689}
690
691// What a clause leaves for the clauses after it in the sentence: a mood
692// withholds all of them, a description those that continue it with "and" or
693// "then". `ready` withholds the rest, which may wait on its condition too.
694type Carry = 'none' | 'mood' | 'described' | 'ready';
695
696// The verbs a clause asks for, reading each part joined by "and" or "then" as
697// its own request: "fix the parser and commit it".
698function grammarGrant(
699  clause: string,
700  verbs: Readonly<Record<string, Family>>,
701  lead: Set<string>,
702  into: MutableGrant,
703  agreed = false,
704  heard?: Set<Family>,
705): Carry {
706  const w = words(clause);
707  if (isReadyMerge(w, verbs, lead, agreed)) {
708    into.autoMerge = true;
709    return 'ready';
710  }
711  if (w.some((x) => SUBORDINATE.has(x)) || asSoonAs(w) !== -1) return 'mood';
712  const parts: string[][] = [[]];
713  for (const word of w) {
714    if (word === 'and' || word === 'then') parts.push([]);
715    else parts.at(-1)?.push(word);
716  }
717  let described = false;
718  let merging = false;
719  for (const part of parts) {
720    // "merge 131 and 130": a list of numbers after a merge names them too.
721    if (merging && part.every((x) => /^\d+$/.test(x) || x === 'pr')) {
722      into.mergeNamed = [...into.mergeNamed, ...part.filter((x) => /^\d+$/.test(x))];
723      continue;
724    }
725    merging = false;
726    const at = part.findIndex((x) => Object.hasOwn(verbs, x));
727    const family = verbs[part[at] ?? ''];
728    const tail = part.slice(at + 1);
729    const asks =
730      family !== undefined &&
731      !described &&
732      isLead(part.slice(0, at), lead, agreed) &&
733      isTail(tail, family) &&
734      (family.object?.(tail) ?? true);
735    if (asks) {
736      heard?.add(family);
737      const granted = family.grants;
738      if (granted.includes('commit')) into.commit = true;
739      if (granted.includes('push')) raise(into, 'push');
740      if (granted.includes('force')) raise(into, 'lease');
741      if (granted.includes('pr')) into.pr = true;
742      if (granted.includes('merge')) {
743        into.merge = true;
744        into.mergeNamed = [...into.mergeNamed, ...tail.filter((x) => /^\d+$/.test(x))];
745        merging = true;
746      }
747      if (granted.includes('approve')) into.approve = true;
748      if (granted.includes('release')) into.release = true;
749      if (granted.includes('comment')) into.comment = true;
750      // "push it with `--force`", "force push it without a lease"; not a
751      // `--force` given as the commit message.
752      const bare = tail.indexOf('nolease');
753      const message = tail.findIndex((x) => x === 'message' || x === 'msg');
754      if (bare !== -1 && (message === -1 || message > bare) && granted.includes('push')) {
755        raise(into, 'bare');
756      }
757    }
758    const opening = part.find((x) => !lead.has(x));
759    if (opening !== undefined && MOOD.test(opening)) return 'mood';
760    if (opening !== undefined && DESCRIBES.has(opening) && opening === part[0]) described = true;
761    // A part shaped as a request is not a description, even when its tail
762    // is not one the grammar reads: "I want you to commit separately and push".
763    const requestShaped = at !== -1 && isLead(part.slice(0, at), lead, agreed);
764    if (!requestShaped && part.some((x) => STATEMENT.has(x))) described = true;
765  }
766  return described ? 'described' : 'none';
767}
768
769function continues(clause: string): boolean {
770  return /^\s*(and|then)\b/i.test(clause);
771}
772
773// Sentences keep their closing mark, so a question can be told from a request.
774function sentences(text: string): string[] {
775  return text.match(/[^.!?;\n]+[.!?;]*/g) ?? [];
776}
777
778// Clauses split on commas and "but", so "don't push, but commit it" withholds
779// the push and grants the commit.
780// A clause that opens with "but" contrasts with what came before, so it keeps
781// its leading "but" for the caller to see and strip.
782function clauses(sentence: string): string[] {
783  return sentence.split(/,|\s+(?=but\s)/i);
784}
785
786const CONTRAST = /^\s*but\s+/i;
787
788function isQuestion(sentence: string): boolean {
789  const first = words(sentence)[0] ?? '';
790  if (QUESTION.has(first)) return true;
791  if (!sentence.trim().endsWith('?')) return false;
792  return !/^\s*(can|could|would|will) (you|we)\b|^\s*(please|mind)\b/i.test(sentence);
793}
794
795function settled(g: MutableGrant): Grant {
796  return Object.freeze({ ...g });
797}
798
799// Each step a grant can name besides the push level.
800const STEPS = ['commit', 'pr', 'merge', 'autoMerge', 'approve', 'release', 'comment'] as const;
801
802function merged(into: MutableGrant, from: MutableGrant): void {
803  for (const step of STEPS) into[step] ||= from[step];
804  into.mergeNamed = [...into.mergeNamed, ...from.mergeNamed];
805  raise(into, from.push);
806}
807
808// Any step beyond the push the grant names.
809function asksBeyondPush(g: Grant): boolean {
810  return g.pr || g.merge || g.autoMerge || g.approve || g.release || g.comment;
811}
812
813// The verbs the previous answer's closing questions offered to do.
814function asked(answer: string, heard?: Set<Family>): Grant {
815  const g = fresh();
816  // A semicolon before "then" or "and" joins clauses as a comma does: "Do we commit; then push?".
817  // Elsewhere it ends a sentence: "I'll leave the docs alone; should I push?".
818  // A name the offer gives, as in "Push fix/x to `origin`?", reads as "it": an
819  // object or destination a push may take; after "delete" it is the branch.
820  // Only here, in the agent's offer: a user's "push it to `later`" defers. A
821  // ref ends on a word character, so a sentence's closing period stays, and
822  // one joining hand-back words ("rather/prefer") stays words.
823  const named = forcePhrase(answer.trim().split('\n').filter(Boolean).slice(-3).join('\n'))
824    // A path is deleted too, so a backticked or slashed name is a branch only
825    // beside "branch" or a remote; a plain word is never read as one.
826    .replaceAll(
827      /\b(delete|deleting)\s+(?:the\s+)?(?:remote\s+)?(?:branch\s+(?:`[\w./-]+`|[\w.-]+\/[\w./-]*[\w-])|(?:`[\w./-]+`|[\w.-]+\/[\w./-]*[\w-])(?=\s+from\s+(?:`[\w-]+`|(?:origin|upstream|remote|github)\b)))/gi,
828      '$1 branch',
829    )
830    // A backticked remote a delete names: `fork` is one, `main` or a file is not.
831    .replaceAll(
832      /(\bdelet(?:e|ing)\b[^?.!\n]*?\bfrom\s+)`([\w./-]+)`/gi,
833      (_m: string, lead: string, name: string) =>
834        /^[\w-]+$/.test(name) && !/^(main|master)$/i.test(name)
835          ? `${lead}remote`
836          : `${lead}\`${name}\``,
837    )
838    // A backticked hand-back stays a word: "or would you `rather` do it?".
839    .replaceAll(/`([\w./-]+)`/g, (_m: string, name: string) =>
840      words(name).some((x) => HANDBACK.has(x)) ? name.replaceAll('/', ' ') : 'it',
841    );
842  const tail = prPhrase(unquote(named))
843    .replaceAll(/[\w.-]+\/[\w./-]*[\w-]/g, (m) =>
844      words(m).some((x) => HANDBACK.has(x)) ? m.replaceAll('/', ' ') : 'it',
845    )
846    .replaceAll(/;(?=\s*(and|then)\b)/gi, ',');
847  for (const q of sentences(tail)) {
848    if (!q.trim().endsWith('?') || words(q).some((x) => HANDBACK.has(x))) continue;
849    // "Do we commit, then push?" asks how work goes; its "then push" offers nothing.
850    let carry: Carry = 'none';
851    for (const part of clauses(q)) {
852      const c = part.replace(CONTRAST, '');
853      if (carry !== 'none' && continues(c)) continue;
854      const left = grammarGrant(c, OFFERED, OFFER_LEAD, g, false, heard);
855      // "Should I fix it, then push?" offers the push; only a commit or push clause carries.
856      if (left !== 'none' && words(c).some((x) => Object.hasOwn(OFFERED, x))) carry = left;
857    }
858  }
859  return settled(g);
860}
861
862const DELETE_IT = new RegExp(
863  String.raw`^(?:${AGREEMENT}[\s,.!]+)*(?:(?:let['’]?s|let us|please|go ahead and)\s+)?delete (?:it|that|them)(?:\s+from\s+\x60?[\w.-]+\x60?)?(?:\s+(?:now|please|too))?\b[\s.!,]*(?:please|thanks|thank you)?[\s.!]*$`,
864  'i',
865);
866
867function offersDelete(answer: string): boolean {
868  const heard = new Set<Family>();
869  asked(answer, heard);
870  return [...heard].some((f) => f.forms[0] === 'delete');
871}
872
873const PUSH_WORD =
874  /\b(push|pushes|pushing|pushed|ship|ships|shipping|shipped|forcepush|forcepushing)\b/i;
875const PR_WORD = /\b(openpr|openingpr|markready|markingready)\b/i;
876const MERGE_WORD = /\b(merge|merges|merging|merged)\b/i;
877const APPROVE_WORD = /\b(approve|approves|approving|approved)\b/i;
878const RELEASE_WORD =
879  /\b(release|releases|releasing|released|cutrelease|cuttingrelease|publish|publishes|publishing|published)\b/i;
880
881// A step mentioned without being asked for, or an offer of one held off ("not yet").
882export type HoldStep = 'push' | 'pr' | 'merge' | 'approve' | 'release' | 'comment';
883
884// Deleting a remote branch is a push.
885const STEP_WORDS: readonly (readonly [HoldStep, RegExp])[] = [
886  ['push', PUSH_WORD],
887  ['push', /\b(delete|deletes|deleting|deleted)\b/i],
888  ['pr', PR_WORD],
889  ['merge', MERGE_WORD],
890  ['approve', APPROVE_WORD],
891  ['release', RELEASE_WORD],
892];
893
894// A message that is a yes or a go-ahead and nothing more, as the grammar
895// reads one: it answers the offer before it.
896export function isReply(text: string): boolean {
897  return AFFIRMATIVE.test(text.trim().replaceAll(/\bno (problem|worries)\b/gi, 'ok'));
898}
899
900// The steps a message names by their verbs, asked for or not.
901export function stepsNamed(text: string): HoldStep[] {
902  const read = prPhrase(unquote(forcePhrase(text)));
903  return [...new Set(STEP_WORDS.filter(([, word]) => word.test(read)).map(([step]) => step))];
904}
905export type HoldReason = Readonly<{ step: HoldStep; how: 'mentioned' | 'declined' }>;
906
907// A step mentioned without being asked for holds every step but a commit, read
908// by mention rather than grammar since a hold only makes the agent ask.
909export function holdOf(prompt: string, previousAnswer = ''): HoldReason | null {
910  const grant = grantOf(prompt, previousAnswer);
911  const text = prPhrase(unquote(forcePhrase(prompt)));
912  const mentioned = (step: HoldStep) => ({ step, how: 'mentioned' }) as const;
913  if (PUSH_WORD.test(text) && !covers(grant, 'push')) return mentioned('push');
914  if (PR_WORD.test(text) && !grant.pr) return mentioned('pr');
915  if (MERGE_WORD.test(text) && !grant.merge && !grant.autoMerge) return mentioned('merge');
916  if (APPROVE_WORD.test(text) && !grant.approve) return mentioned('approve');
917  if (RELEASE_WORD.test(text) && !grant.release) return mentioned('release');
918  if (!HOLD.test(text) || covers(grant, 'push') || asksBeyondPush(grant)) return null;
919  // The furthest step offered: "merge #116 and delete the branch?" offers a
920  // merge, though deleting the branch is a push too.
921  const offered = asked(previousAnswer);
922  const step: HoldStep | null = offered.release
923    ? 'release'
924    : offered.merge || offered.autoMerge
925      ? 'merge'
926      : offered.approve
927        ? 'approve'
928        : offered.pr
929          ? 'pr'
930          : covers(offered, 'push')
931            ? 'push'
932            : offered.comment
933              ? 'comment'
934              : null;
935  return step === null ? null : { step, how: 'declined' };
936}
937
938export function holdsOf(prompt: string, previousAnswer = ''): boolean {
939  return holdOf(prompt, previousAnswer) !== null;
940}
941
942// Whether a message asks for a step on the way out: a push, a pull request, a
943// merge or a release. An approval or a reply asks for none of them.
944export function liftsHold(grant: Grant): boolean {
945  return covers(grant, 'push') || grant.pr || grant.merge || grant.autoMerge || grant.release;
946}
947
948export function grantOf(prompt: string, previousAnswer = ''): Grant {
949  // "No problem" and "no worries" agree; they retract nothing.
950  const text = prompt.trim().replaceAll(/\bno (problem|worries)\b/gi, 'ok');
951  if (AFFIRMATIVE.test(text)) {
952    const offer = asked(previousAnswer);
953    if (!offer.merge || offer.mergeNamed.length > 0) return offer;
954    return settled({ ...offer, mergeNamed: unnamedMerge('', previousAnswer) });
955  }
956  // "delete it", answering an offer to delete a branch, names that branch;
957  // only as the whole reply, since "delete it, meaning the TODO" names another.
958  const answering = DELETE_IT.test(text) && offersDelete(previousAnswer);
959  // Pasted shell sessions and quoted output are not requests.
960  const typed = (answering ? text.replace(/\b(delete\s+(?:it|that|them))\b/i, '$1 branch') : text)
961    .split('\n')
962    .filter((line) => !/^\s*([$>+#]|PS\s|\w+@[\w.-]+[:$])/.test(line))
963    // A list item or an emphasised label names a step; it does not ask for it.
964    .filter((line) => !/^\s*(\d+[.)]|[-*•])\s|^\s*[*_]+[^*_]+[*_]+\s*$/.test(line))
965    .join('\n');
966  let g = fresh();
967  // What the previous sentence left: "we commit; then push" describes across
968  // the semicolon as "we commit, then push" does across the comma.
969  let prev: Carry = 'none';
970  for (const raw of sentences(prPhrase(unquote(forcePhrase(typed))))) {
971    const flipped = leadingReady(raw);
972    const sentence = flipped ?? raw;
973    // "once it's ready, do not merge it" reads as a question once reordered.
974    if (flipped !== null && (RETRACT.test(sentence) || isQuestion(sentence))) return NO_GRANT;
975    // A retraction ("no wait", "never mind") cancels what came before it.
976    if (RETRACT.test(sentence)) {
977      g = fresh();
978      prev = 'none';
979      continue;
980    }
981    if (isQuestion(sentence)) {
982      prev = 'none';
983      continue;
984    }
985    if (prev !== 'none' && continues(sentence)) {
986      if (!sentence.trim().endsWith(';')) prev = 'none';
987      continue;
988    }
989    // One clause that withholds withholds its whole sentence: "push it, but
990    // not until CI passes" grants nothing.
991    const mine = fresh();
992    let carry: Carry = 'none';
993    let withheld = false;
994    let agreed = false;
995    for (const part of clauses(sentence)) {
996      const c = part.replace(CONTRAST, '');
997      if (CONDITION.test(c)) return NO_GRANT;
998      if (HOLD.test(c)) withheld = true;
999      if (withheld) break;
1000      if (carry === 'described' && continues(c)) continue;
1001      // After a ready-merge a clause can still withhold, but grants nothing.
1002      const into = carry === 'ready' ? fresh() : mine;
1003      const left = grammarGrant(c, REQUESTED, REQUEST_LEAD, into, agreed);
1004      agreed = AGREE.test(c);
1005      if (left === 'mood') withheld = true;
1006      else if (left !== 'none' && carry !== 'ready') carry = left;
1007    }
1008    // Any other condition leading a sentence withholds the whole prompt.
1009    if (flipped !== null && (withheld || !onlyAutoMerge(mine))) return NO_GRANT;
1010    if (!withheld) merged(g, mine);
1011    // Only a semicolon ties two sentences together, and only around the verbs:
1012    // "I fixed it. Then push it." asks.
1013    const tied =
1014      sentence.trim().endsWith(';') && words(sentence).some((x) => Object.hasOwn(REQUESTED, x));
1015    prev = tied ? (withheld ? 'mood' : carry) : 'none';
1016  }
1017  if (g.merge && g.mergeNamed.length === 0) g.mergeNamed = unnamedMerge(typed, previousAnswer);
1018  return settled(g);
1019}
1020
1021// Never a pull request number: a merge naming it covers no pull request by name.
1022export const NAMED_ELSEWHERE = '?';
1023
1024// The pull request numbers a text names: #131, PR 131, /pull/131.
1025function numbers(text: string): Set<string> {
1026  return new Set(
1027    [...text.matchAll(/(?:#|\b(?:pr|pull request)\s+#?|\/pull\/)(\d+)\b/gi)].map((m) => m[1] ?? ''),
1028  );
1029}
1030
1031// What "merge it" names: the pull requests the end of the previous answer
1032// names (#131, PR 131, /pull/131), none meaning any. Several, or a message
1033// naming numbers or the version PR itself ("merge it; do not merge 130"),
1034// name none by name, so the version PR is looked up and asks.
1035function unnamedMerge(typed: string, previousAnswer: string): readonly string[] {
1036  const text = unquote(typed);
1037  if (/#?\b\d+\b/.test(text) || /\b(version|release)\s+(pr|pull\s+request)\b/i.test(text)) {
1038    return [NAMED_ELSEWHERE];
1039  }
1040  const closing = previousAnswer.trim().split('\n').filter(Boolean).slice(-3).join('\n');
1041  // The question it answers decides first: "Release by merging #130?" after a
1042  // summary that names #134 too.
1043  const questions = sentences(closing)
1044    .filter((q) => q.trim().endsWith('?'))
1045    .join(' ');
1046  const asked = numbers(questions);
1047  // "#134 merged. Merge the version PR?" offers the version PR, not #134.
1048  if (
1049    asked.size === 0 &&
1050    /\b(version|release)\s+(pr|pull\s+request)\b|\bmerge\s+it\s+to\s+release\b/i.test(questions)
1051  ) {
1052    return [];
1053  }
1054  const offered = asked.size > 0 ? asked : numbers(closing);
1055  if (offered.size > 1) return [NAMED_ELSEWHERE];
1056  return [...offered];
1057}
1058
hooks/containment.ts 123 lines
1// What a running reviewer is to leave as it found it, and what the gate tells
2// it and the status tool when something moved. The reads take a `Git` the hook
3// supplies, as git.ts does; the rest is pure.
4
5import { firstLine, sha, worktreeTree, type Git } from './git';
6
7// HEAD's commit and branch, the staged entries, the working tree and the local
8// and worktree config. null marks a part git could not read.
9export type RepoState = {
10  head: string | null;
11  index: string | null;
12  work: string | null;
13  config: string | null;
14};
15
16export const UNREAD: RepoState = { head: null, index: null, work: null, config: null };
17
18// A state and, when a read threw, why.
19export type Capture = { state: RepoState; why: string | null };
20
21const PARTS: Record<keyof RepoState, string> = {
22  head: 'HEAD',
23  index: 'the staged content',
24  work: 'the working tree',
25  config: 'the local git config',
26};
27
28// Neither read takes the index lock that `write-tree` would. The listing is
29// hashed inside git, so a large index is not cut at the output limit.
30const INDEX_DIGEST =
31  'list=$(git ls-files -s) || exit 2; printf %s "$list" | git hash-object --stdin';
32
33// A working tree git could not build is unreadable alone, so the other parts
34// are still compared, and its reason is kept.
35export async function repoStateOf(git: Git, root: string): Promise<Capture> {
36  const opts = { cwd: root };
37  // rev-parse exits 1 on an unborn branch, symbolic-ref on a detached HEAD;
38  // both exiting 1 is a read that failed, since a killed child reads as 1.
39  const commit = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD'], opts);
40  const branch = await git(['git', 'symbolic-ref', '-q', 'HEAD'], opts);
41  const index = await git(['sh', '-c', INDEX_DIGEST], opts);
42  const config = await git(['git', 'config', '--list', '--show-scope', '-z'], opts);
43  const headRead =
44    (commit.exitCode === 0 && branch.exitCode <= 1) ||
45    (commit.exitCode === 1 && branch.exitCode === 0);
46  let why: string | null = null;
47  const work = await worktreeTree(git, root).catch((error: unknown) => {
48    why = error instanceof Error ? error.message : String(error);
49    return null;
50  });
51  const state: RepoState = {
52    head: headRead ? `${commit.stdout.trim()} ${branch.stdout.trim()}` : null,
53    index: index.exitCode === 0 ? sha(index.stdout) : null,
54    work,
55    config: config.exitCode === 0 ? ownConfig(config.stdout) : null,
56  };
57  return { state, why };
58}
59
60// `-z` prints scope, then `key\nvalue`, each ended by NUL, so a value that
61// spans lines stays whole.
62export function ownConfig(z: string): string {
63  const fields = z.split('\0');
64  const own: string[] = [];
65  for (let i = 0; i + 1 < fields.length; i += 2) {
66    if (fields[i] === 'local' || fields[i] === 'worktree')
67      own.push(`${fields[i]}\0${fields[i + 1]}`);
68  }
69  return own.join('\0');
70}
71
72export function repoChanges(
73  a: RepoState,
74  b: RepoState,
75): { changed: string[]; unreadable: string[] } {
76  const changed: string[] = [];
77  const unreadable: string[] = [];
78  for (const [k, name] of Object.entries(PARTS) as [keyof RepoState, string][]) {
79    if (a[k] === null || b[k] === null) unreadable.push(name);
80    else if (a[k] !== b[k]) changed.push(name);
81  }
82  return { changed, unreadable };
83}
84
85export function insideRepo(path: string, top: string): boolean {
86  return path === top || path.startsWith(`${top}/`);
87}
88
89// The notes for the reviewer and the records for the status tool.
90export function containmentReport(r: {
91  type: string;
92  command: string;
93  before: Capture;
94  after: Capture;
95  background: boolean;
96}): { notes: string[]; records: string[] } {
97  const shown = firstLine(r.command).slice(0, 80);
98  const { changed, unreadable } = repoChanges(r.before.state, r.after.state);
99  const notes: string[] = [];
100  const records: string[] = [];
101  if (changed.length > 0) {
102    const what = changed.join(', ');
103    records.push(`${r.type}: ${what} changed while \`${shown}\` ran`);
104    notes.push(
105      `review-cycle: ${what} of the repository under review changed while this command ran, by it or by another agent. If this command made the change, put it back; either way, say so in your report, and keep scratch work in a private directory from mktemp -d.`,
106    );
107  }
108  if (unreadable.length > 0) {
109    const whys = [...new Set([r.before.why, r.after.why])].filter((w) => w !== null);
110    const what = `${unreadable.join(', ')}${whys.length === 0 ? '' : ` (${whys.join('; ')})`}`;
111    records.push(`${r.type}: could not check ${what} while \`${shown}\` ran`);
112    notes.push(
113      `review-cycle could not check whether this command changed ${what} of the repository under review.`,
114    );
115  }
116  if (r.background) {
117    notes.push(
118      'review-cycle: this command runs in the background, so what it changes in the repository under review after it returns is not checked.',
119    );
120  }
121  return { notes, records };
122}
123
hooks/edits.ts 125 lines
1// Notes the files a Bash command changed, so its edits go through Edit and
2// Write, where the comment-slop and settings checks run. No `$`: the snapshot
3// and the comparison are passed in, as git.ts takes its runner.
4//
5// The working tree is compared around the command; `mayWrite` only picks the
6// commands worth measuring, so a false yes costs time, not accuracy.
7
8import { basename, BUILTIN_RUNNERS, every, INTERPRETERS, KEYWORDS } from './command';
9import { messageOf } from './git';
10import { assignmentName, parse, type ShellAliases, type Statement, type Word } from './shell';
11
12// Writers and runners the gate does not treat as running code.
13const WRITERS = new Set([
14  'tee',
15  'sed',
16  'gsed',
17  'awk',
18  'gawk',
19  'cp',
20  'mv',
21  'install',
22  'truncate',
23  'dd',
24  'rsync',
25  'sponge',
26  'patch',
27  'xargs',
28]);
29const PYTHON = /^python[0-9.]*$/;
30// Wrappers that run the command after them, besides the reserved words.
31const WRAPPERS = new Set(['time', 'env', 'exec', 'command', 'nohup', 'nice']);
32// The paths a note lists before it counts the rest.
33const SHOWN = 10;
34
35// A redirect whose target the shell computes may land anywhere.
36const writesFile = (w: Word): boolean => w.dynamic || !w.text.startsWith('/dev/');
37
38const nameOf = (w: Word): string => basename(w.text).replace(/\.exe$/i, '');
39
40// Runners whose names are also ordinary words and paths (`git add .`,
41// `rg expect`, `pnpm run watch`): they count only as the command itself,
42// which runsScript checks.
43const COMMAND_ONLY = new Set([
44  ...BUILTIN_RUNNERS,
45  'expect',
46  'watch',
47  'script',
48  'sudo',
49  'doas',
50  'uv',
51  'mise',
52  'nix-shell',
53]);
54
55function names(w: Word): boolean {
56  const name = nameOf(w);
57  if (COMMAND_ONLY.has(name)) return false;
58  return INTERPRETERS.has(name) || WRITERS.has(name) || PYTHON.test(name);
59}
60
61const beforeCommand = (w: Word): boolean =>
62  assignmentName(w) !== null ||
63  KEYWORDS.has(w.text) ||
64  WRAPPERS.has(w.text) ||
65  w.text.startsWith('-');
66
67// A script run by its path, or a command-only runner, behind any
68// assignments, flags, reserved words and wrappers.
69function runsScript(st: Statement): boolean {
70  const command = st.words.find((w) => !beforeCommand(w));
71  if (command === undefined) return false;
72  return command.text.includes('/') || COMMAND_ONLY.has(nameOf(command));
73}
74
75// Any word counts, not only the first, so a writer behind `if`, `time`,
76// `env`, `sudo`, `xargs` or a wrapper still counts. A command it cannot read
77// counts.
78export function mayWrite(command: string, aliases: ShellAliases = new Map()): boolean {
79  const parsed = parse(command, aliases);
80  if ('error' in parsed) return true;
81  if (parsed.bareWrites.some(writesFile)) return true;
82  const writes = (st: Statement): boolean =>
83    st.writes.some(writesFile) || st.words.some(names) || runsScript(st);
84  return every(parsed.statements, (st) => (writes(st) ? 'writes' : null)) !== null;
85}
86
87// The comparison sees every change made while the command ran, a parallel
88// Edit or the user's own save included, so the note cannot claim the command
89// made them.
90export function editNote(paths: readonly string[]): string {
91  const shown = paths.slice(0, SHOWN).join(', ');
92  const more = paths.length > SHOWN ? ` and ${paths.length - SHOWN} more` : '';
93  return `review-cycle: files changed while this command ran: ${shown}${more}. If the command made those edits, make file changes with Edit or Write instead: the comment-slop and settings checks run there, not on Bash.`;
94}
95
96export function editsSkipped(why: string): string {
97  return `review-cycle: could not check which files this command changed (${why}).`;
98}
99
100// Runs the command between two snapshots and names what changed. Never
101// rejects for a failed measurement: the command runs, and a failure becomes
102// the note. `diff` returns null when it cannot compare.
103export async function measureEdits<R>(
104  snapshot: () => Promise<string>,
105  diff: (before: string, after: string) => Promise<string[] | null>,
106  run: () => Promise<R>,
107): Promise<{ result: R; note: string | null }> {
108  let before: string;
109  try {
110    before = await snapshot();
111  } catch (error) {
112    return { result: await run(), note: editsSkipped(messageOf(error)) };
113  }
114  const result = await run();
115  try {
116    const after = await snapshot();
117    if (after === before) return { result, note: null };
118    const changed = await diff(before, after);
119    if (changed === null) return { result, note: editsSkipped('git could not compare the trees') };
120    return { result, note: changed.length === 0 ? null : editNote(changed) };
121  } catch (error) {
122    return { result, note: editsSkipped(messageOf(error)) };
123  }
124}
125
hooks/gh-verdict.ts 302 lines
1// Whether the GitHub writes in a call may run on the stop-before ladder, and
2// the refusals when they may not. Pure: what it needs from gh or the settings
3// comes through `GhLookups`, which register.ts builds.
4
5import { covers, type Grant, type HoldReason, type HoldStep } from './consent';
6import type { GhAction, PushRef } from './github';
7import { asks, type Ladder, type Step } from './ladder';
8import { askingReason, pushOutcome } from './push-verdict';
9
10// `unreadable` names the settings file that could not be read, and why.
11export type InForce = Ladder & { unreadable?: string };
12// A step that runs without the user asking for it directly, by the ladder.
13export type Unasked = { step: Step; ladder: Ladder };
14
15// What a gh read printed, or why the step asks instead.
16export type Lookup = { out: string; asks?: never } | { asks: string; out?: never };
17
18export type PullRequest = { cross: boolean; repo: string; branch: string };
19
20export const PR_FIELDS = 'url,isCrossRepository,headRefName';
21export const PR_JQ = String.raw`"\(.isCrossRepository) \(.url) \(.headRefName)"`;
22
23export function parsePullRequest(out: string): PullRequest | { asks: string } {
24  const read = /^(true|false) https:\/\/([^/\s]+\/[^/\s]+\/[^/\s]+)\/pull\/\d+ (\S+)$/.exec(out);
25  if (read === null) return { asks: `looking up the pull request it updates printed \`${out}\`` };
26  const [, cross = '', repo = '', branch = ''] = read;
27  // The repository from the pull request's URL, so an Enterprise host survives.
28  return { cross: cross === 'true', repo, branch };
29}
30
31export type GhLookups = {
32  ladder: () => Promise<InForce>;
33  // The head branch of the pull request `gh pr view <lookup>` names.
34  mergeHead: (lookup: readonly string[]) => Promise<Lookup>;
35  pullRequest: (head: readonly string[]) => Promise<PullRequest | { asks: string }>;
36  // The default branch of `repo` (HOST/OWNER/NAME or OWNER/NAME), or of the
37  // repository gh picks when it is null.
38  defaultBranch: (repo: string | null) => Promise<Lookup>;
39};
40
41// A read's text without surrounding space; one that printed nothing names
42// nothing, so the step asks.
43function read(r: Lookup, what: string): Lookup {
44  if (r.asks !== undefined) return r;
45  const out = r.out.trim();
46  return out === '' ? { asks: `looking up ${what} printed nothing` } : { out };
47}
48
49// What may run: what the user asked for, and what the ladder lets through.
50// A hold stops every step but a commit.
51type Allowed = Readonly<Record<Step, boolean>>;
52export function permitted(granted: Grant, ladder: Ladder, held: boolean): Allowed {
53  const free = (step: Step) => !asks(ladder, step);
54  return {
55    commit: granted.commit || free('commit'),
56    push: covers(granted, 'push') || (!held && free('push')),
57    pr: granted.pr || (!held && free('pr')),
58    merge: granted.merge || (!held && free('merge')),
59    approve: granted.approve || (!held && free('approve')),
60    release: granted.release || (!held && free('release')),
61  };
62}
63
64// The agent asks in its reply, naming the step and its target, and ends its turn.
65// `shown` is the call as the refusal quotes it.
66export function askThem(naming: string, example: string, shown: string): string {
67  return `stop and ask them in your reply, naming ${naming} with names in backticks (for example ${example}), and end your turn; their answer decides. The command: ${shown}`;
68}
69
70// Why the steps are held, or null when nothing holds them.
71export type Hold = HoldReason | null;
72
73const STEP_NAME: Record<HoldStep, string> = {
74  push: 'a push',
75  pr: 'a pull request',
76  merge: 'a merge',
77  approve: 'an approval',
78  release: 'a release',
79  comment: 'a reply on GitHub',
80};
81
82// What held the steps, so the agent can tell the user.
83function heldWhy(held: HoldReason): string {
84  const step = STEP_NAME[held.step];
85  return held.how === 'mentioned'
86    ? `their message mentioned ${step} without asking for one`
87    : `they put off ${step} the agent offered`;
88}
89
90// Why the user's latest message does not cover the step, the setting
91// included when its files could not be read.
92export function notAsked(what: string, ladder: InForce, holdable: boolean, held: Hold): string {
93  const why =
94    holdable && held !== null
95      ? `the user held off (${heldWhy(held)}) and hasn't asked for ${what} since`
96      : `the user's latest message doesn't ask for ${what}`;
97  return ladder.unreadable === undefined
98    ? why
99    : `could not read ${ladder.unreadable}, so the gate stops before every step, and ${why}`;
100}
101
102function prRefusal(shown: string, ladder: InForce, held: Hold): string {
103  return `${notAsked('a pull request', ladder, true, held)}, so nothing ran. To open one, ${askThem('the branch and the base it targets', '"Open a PR from `fix/x` into `main`?"', shown)}`;
104}
105
106// The branch oakum's version pull request comes from: merging it is the release.
107const VERSION_BRANCH = 'oakum/version-packages';
108
109const CANNOT: Record<'elsewhere' | 'unnamed', string> = {
110  elsewhere:
111    'something outside the words the gate looks up picks where it merges (a `--repo` built at run time, `GH_REPO=`, `GIT_DIR=`, `env -C`, `--hostname`, a URL on another host), so the gate cannot tell a release from a merge',
112  unnamed:
113    'it does not name the pull request by number and repository, so the gate cannot tell a release from a merge',
114};
115
116// The pull request number a merge names (`116`, or a URL ending in it), or
117// null when it names none.
118function pullNumberOf(lookup: readonly string[] | { cannot: string }): string | null {
119  if ('cannot' in lookup) return null;
120  const selector = lookup[0];
121  if (selector === undefined || selector.startsWith('-')) return null;
122  return /(\d+)\/?$/.exec(selector)?.[1] ?? null;
123}
124
125// Which step a merge is: a release when it merges the version pull request.
126// A lookup that fails says why, and the merge asks.
127async function mergeStep(
128  lookups: GhLookups,
129  lookup: readonly string[],
130): Promise<{ step: 'merge' | 'release' } | { asks: string }> {
131  const r = read(await lookups.mergeHead(lookup), 'the pull request it merges');
132  if (r.asks !== undefined) return { asks: r.asks };
133  return { step: r.out === VERSION_BRANCH ? 'release' : 'merge' };
134}
135
136// Why a push the ladder lets through asks anyway, or null: one to the
137// default branch, or one whose branch the gate cannot tell.
138async function pushAsks(lookups: GhLookups, ref: PushRef): Promise<string | null> {
139  if ('asks' in ref) return ref.asks;
140  let target: { branch: string; repo: string | null } = { branch: '', repo: null };
141  if ('head' in ref) {
142    const head = await lookups.pullRequest(ref.head);
143    if ('asks' in head) return head.asks;
144    if (head.cross) return "it updates a branch in the pull request's fork";
145    if (head.branch === '') return 'the pull request it updates names no branch';
146    target = head;
147  } else target = ref;
148  const r = read(await lookups.defaultBranch(target.repo), 'the default branch');
149  if (r.asks !== undefined) return r.asks;
150  const url = target.repo ?? '';
151  return askingReason(
152    [{ flag: ' ', to: `refs/heads/${target.branch}`, url }],
153    new Map([[url, { branch: r.out }]]),
154  );
155}
156
157// Each example is one the consent grammar grants on a yes (consent.spec.ts).
158const GH_ASK: Record<
159  'merge' | 'approve' | 'release' | 'comment' | 'push',
160  [string, string, string]
161> = {
162  merge: ['a merge', 'the pull request', '"Merge #116?"'],
163  approve: ['an approval', 'the pull request', '"Approve #116?"'],
164  release: ['a release', 'the version it releases', '"Release `v0.25.0`?"'],
165  comment: [
166    'a comment on GitHub',
167    'where it goes and what it says',
168    '"Reply to the review on #116?"',
169  ],
170  push: ['a push', 'what it pushes and where', '"Push `fix/x` to `origin`?"'],
171};
172
173function ghRefusal(
174  shown: string,
175  kind: keyof typeof GH_ASK,
176  ladder: InForce,
177  always: string | null,
178  held: Hold,
179): string {
180  const [what, naming, example] = GH_ASK[kind];
181  const asksAnyway = always === null ? '' : ` This asks whatever the setting: ${always}.`;
182  const unreviewed =
183    kind === 'push' ? ' It writes to GitHub directly, so no review covers it.' : '';
184  return `${notAsked(what, ladder, true, held)}, so nothing ran.${asksAnyway}${unreviewed} To go ahead, ${askThem(naming, example, shown)}`;
185}
186
187// What the GitHub writes in a call may run, or the refusal of the first one
188// that may not. A comment is off the ladder: it needs the user's request. A
189// requested pull request lets no push through GitHub's API, which no review
190// covers.
191export async function judgeGh(
192  shown: string,
193  actions: readonly GhAction[],
194  granted: Grant,
195  held: Hold,
196  lookups: GhLookups,
197): Promise<{ deny: string } | { ran: Unasked[] }> {
198  const ran: Unasked[] = [];
199  if (actions.length === 0) return { ran };
200  const ladder = await lookups.ladder();
201  const may = permitted(granted, ladder, held !== null);
202  const refuse = (kind: keyof typeof GH_ASK, always: string | null) => ({
203    deny: ghRefusal(shown, kind, ladder, always, held),
204  });
205  for (const action of actions) {
206    switch (action.kind) {
207      case 'pr': {
208        if (!may.pr) return { deny: prRefusal(shown, ladder, held) };
209        if (!granted.pr) ran.push({ step: 'pr', ladder });
210        break;
211      }
212      case 'unread': {
213        const remedy = action.remedy ?? 'Run the gh command itself, written out.';
214        return {
215          deny: `${action.why}, so the gate cannot tell which step it takes, and nothing ran. ${remedy} The command: ${shown}`,
216        };
217      }
218      case 'comment': {
219        if (!granted.comment) return refuse('comment', null);
220        break;
221      }
222      case 'approve': {
223        if (!may.approve) return refuse('approve', null);
224        if (!granted.approve) ran.push({ step: 'approve', ladder });
225        break;
226      }
227      case 'release': {
228        if (!may.release) return refuse('release', null);
229        if (!granted.release) ran.push({ step: 'release', ladder });
230        break;
231      }
232      case 'push': {
233        const { ref } = action;
234        const needed = 'asks' in ref && ref.force ? 'bare' : 'push';
235        const outcome = await pushOutcome(covers(granted, needed) ? null : needed, may.push, () =>
236          pushAsks(lookups, ref),
237        );
238        if (outcome.kind === 'unasked') ran.push({ step: 'push', ladder });
239        if (outcome.kind !== 'refused') break;
240        if (outcome.missing === 'bare') {
241          return {
242            deny: `a forced ref update overwrites whatever the branch holds, with no lease, and the user's latest message doesn't ask for a bare force, so nothing ran. If they want one, ${askThem('what it overwrites and where', '"Force-push `fix/x` to `origin` without a lease?"', shown)}`,
243          };
244        }
245        return refuse('push', outcome.always);
246      }
247      case 'merge': {
248        if (action.admin) {
249          return {
250            deny: `--admin merges past branch protection, which the gate never lets an agent do, so nothing ran. Give the user the command to run themselves, and say why it needs --admin. The command: ${shown}`,
251          };
252        }
253        // A merge asked for once the pull request is ready is what `--auto`
254        // does; a merge now goes against that request, whatever the setting.
255        if (granted.autoMerge && !granted.merge && !action.auto) {
256          return {
257            deny: `the user asked to merge only once the pull request is ready, which \`gh pr merge --auto\` leaves to GitHub, so nothing ran. Run \`gh pr merge <number> --auto\`, naming the pull request by number, or ${askThem('the pull request', '"Merge #116 now?"', shown)}`,
258          };
259        }
260        const asked = { ...granted, merge: granted.merge || (action.auto && granted.autoMerge) };
261        const mayHere = permitted(asked, ladder, held !== null);
262        // A merge the user asked for covers oakum's version pull request too:
263        // merging it is the only way to release, so "merge it" asks for that,
264        // unless the same message held off a release ("merge 131. don't release yet")
265        // or named other pull requests ("merge 131" never merges #130).
266        const named = granted.mergeNamed;
267        const target = pullNumberOf(action.lookup);
268        const covered = named.length === 0 || (target !== null && named.includes(target));
269        if (granted.merge && held?.step !== 'release' && covered) break;
270        // Allowed either way, a merge needs no lookup to tell which it is.
271        if (mayHere.merge && mayHere.release) {
272          if (!asked.merge && !asked.release) ran.push({ step: 'merge', ladder });
273          break;
274        }
275        const { lookup } = action;
276        let which: Awaited<ReturnType<typeof mergeStep>>;
277        if (!('cannot' in lookup)) which = await mergeStep(lookups, lookup);
278        else if (lookup.cannot === 'beside') {
279          return {
280            deny: `other steps in the same command can change which pull request the merge reaches, so the gate cannot tell a release from a merge, and nothing ran. Run the merge as its own command. The command: ${shown}`,
281          };
282        } else which = { asks: CANNOT[lookup.cannot] };
283        if ('asks' in which && mayHere.merge) {
284          return {
285            deny: `the merge may be a release, which the user has not allowed: ${which.asks}. Nothing ran. To merge it, ${askThem('the pull request', '"Merge and release #62?"', shown)}`,
286          };
287        }
288        if ('asks' in which) return refuse('merge', which.asks);
289        const step = which.step;
290        if (!mayHere[step]) return refuse(step, null);
291        if (!asked[step]) ran.push({ step, ladder });
292        break;
293      }
294      default: {
295        const unhandled: never = action;
296        throw new Error(`no judgment for the gh action ${JSON.stringify(unhandled)}`);
297      }
298    }
299  }
300  return { ran };
301}
302
hooks/git.ts 370 lines
1// Git reads the gate makes, through a `Git` the hook supplies: `claude plugin
2// validate` follows `$` only into functions declared in register.ts, so the
3// runner is passed in rather than `$`.
4
5import { parseReflog, REFLOG_FORMAT, type Entry } from './attribution';
6import type { Classification } from './command';
7import {
8  CONFIG_PATH,
9  EMPTY_TREE,
10  EXCLUDES,
11  coverage,
12  ignoresOf,
13  type Review,
14  type Row,
15} from './witness';
16
17export type Run = { exitCode: number; stdout: string; stderr: string };
18export type Git = (
19  argv: string[],
20  opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
21) => Promise<Run>;
22
23// A repository by its working tree and by the object store its worktrees share.
24export type Repo = { top: string; common: string };
25
26export function sha(s: string): string | null {
27  const t = s.trim();
28  return /^[a-f0-9]{40}$/.test(t) ? t : null;
29}
30
31export function firstLine(s: string): string {
32  return s.trim().split('\n')[0] ?? '';
33}
34
35export function messageOf(error: unknown): string {
36  return error instanceof Error ? error.message : String(error);
37}
38
39// The repository at `dir` (or the given cwd), 'none' when there is none, and
40// a throw when git could not say.
41export async function repoAt(git: Git, dir: string | null, cwd?: string): Promise<Repo | 'none'> {
42  const argv = ['git', ...(dir === null ? [] : ['-C', dir])];
43  const r = await git(
44    [...argv, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
45    cwd === undefined ? {} : { cwd },
46  );
47  if (r.exitCode === 0) {
48    const [top, common] = r.stdout.trim().split('\n');
49    if (top && common) return { top, common };
50  }
51  if (/not a git repository/i.test(r.stderr)) return 'none';
52  throw new Error(`git rev-parse failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
53}
54
55// HEAD's commit, or EMPTY_TREE on an unborn branch or when HEAD names a
56// missing object; a throw when git failed.
57export async function headOf(git: Git, root: string): Promise<string> {
58  const r = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD^{commit}'], { cwd: root });
59  if (r.exitCode === 0) {
60    const head = sha(r.stdout);
61    if (head) return head;
62  }
63  if (r.exitCode === 1 && r.stdout.trim() === '') return EMPTY_TREE;
64  throw new Error(`git rev-parse HEAD failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
65}
66
67export async function treeOf(git: Git, root: string, commit: string): Promise<string | null> {
68  if (commit === EMPTY_TREE) return EMPTY_TREE;
69  const r = await git(['git', 'rev-parse', `${commit}^{tree}`], { cwd: root });
70  return r.exitCode === 0 ? sha(r.stdout) : null;
71}
72
73// The config is read from the tree being judged, not the working tree: an
74// `ignore` entry counts only once it is part of what gets reviewed.
75async function pathspecs(git: Git, root: string, tree: string): Promise<string[]> {
76  const r = await git(['git', 'show', `${tree}:${CONFIG_PATH}`], { cwd: root });
77  return ['.', ...EXCLUDES, ...ignoresOf(r.exitCode === 0 ? r.stdout : null)];
78}
79
80function nulList(out: string): string[] {
81  return out.split('\0').filter(Boolean);
82}
83
84// Paths that differ between two trees and need review: the excludes and
85// `ignore` patterns applied, the config always included.
86export async function reviewablePaths(
87  git: Git,
88  root: string,
89  from: string,
90  to: string,
91): Promise<string[] | null> {
92  const base = ['git', 'diff-tree', '-r', '-z', '--no-renames', '--name-only', from, to, '--'];
93  const main = await git([...base, ...(await pathspecs(git, root, to))], { cwd: root });
94  const cfg = await git([...base, CONFIG_PATH], { cwd: root });
95  if (main.exitCode !== 0 || cfg.exitCode !== 0) return null;
96  return [...new Set([...nulList(main.stdout), ...nulList(cfg.stdout)])];
97}
98
99// Runs one step and names it in any failure: git's own first error line, or
100// the runner's reason when it could not run the step at all (a timeout).
101async function must(name: string, run: Promise<Run>): Promise<Run> {
102  let r: Run;
103  try {
104    r = await run;
105  } catch (error) {
106    throw new Error(`${name} failed: ${error instanceof Error ? error.message : String(error)}`, {
107      cause: error,
108    });
109  }
110  if (r.exitCode !== 0)
111    throw new Error(`${name} failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
112  return r;
113}
114
115// A tree built in a scratch copy of the index, so the real index is never
116// touched. `prepare` stages into it; its commands see GIT_INDEX_FILE.
117async function scratchTree(
118  git: Git,
119  root: string,
120  prepare: (env: Record<string, string>) => Promise<void>,
121): Promise<string> {
122  const mk = await must('mktemp', git(['mktemp'], { cwd: root }));
123  const scratch = mk.stdout.trim();
124  if (!scratch) throw new Error('mktemp printed no path');
125  const env = { GIT_INDEX_FILE: scratch };
126  try {
127    const idx = await must(
128      'git rev-parse --git-path index',
129      git(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'index'], { cwd: root }),
130    );
131    await must(
132      'copying the index',
133      git(
134        [
135          'sh',
136          '-c',
137          'if [ -f "$1" ]; then cp "$1" "$2"; else rm -f "$2"; fi',
138          'sh',
139          idx.stdout.trim(),
140          scratch,
141        ],
142        { cwd: root },
143      ),
144    );
145    await prepare(env);
146    const wt = await must('git write-tree', git(['git', 'write-tree'], { cwd: root, env }));
147    const tree = sha(wt.stdout);
148    if (tree === null) {
149      throw new Error(`git write-tree printed no tree id: ${firstLine(wt.stdout) || '(nothing)'}`);
150    }
151    return tree;
152  } finally {
153    try {
154      await git(['rm', '-f', scratch], { cwd: root });
155    } catch {
156      // A leftover temp file does not change the verdict.
157    }
158  }
159}
160
161export async function worktreeTree(git: Git, root: string): Promise<string> {
162  return scratchTree(git, root, async (env) => {
163    await must('git add -A', git(['git', 'add', '-A'], { cwd: root, env }));
164  });
165}
166
167// The tree the command's commit would record: the index after replaying its
168// `git add`s, plus `add -u` for `commit -a`.
169export async function prospectTree(
170  git: Git,
171  root: string,
172  cls: Extract<Classification, { kind: 'gated' }>,
173): Promise<string> {
174  return scratchTree(git, root, async (env) => {
175    for (const argv of cls.adds) {
176      await must('git add', git(['git', '-C', cls.dir, ...argv], { env }));
177    }
178    if (!cls.commit?.all) return;
179    await must('git add -u', git(['git', ...cls.commit.config, 'add', '-u'], { cwd: root, env }));
180  });
181}
182
183export type Coverage = { rows: Row[]; unread: number };
184
185// Coverage of the paths `to` changes against `from`. `unread` counts reviewed
186// trees git could not compare, which then cover nothing.
187export async function coverageOf(
188  git: Git,
189  root: string,
190  from: string,
191  to: string,
192  reviews: readonly Review[],
193): Promise<Coverage | null> {
194  const changed = await reviewablePaths(git, root, from, to);
195  if (changed === null) return null;
196  if (changed.length === 0) return { rows: [], unread: 0 };
197  let unread = 0;
198  const differing = new Map<string, Set<string>>();
199  for (const tree of new Set(reviews.flatMap((r) => r.trees))) {
200    // Changed paths are literal file names here, never pathspec magic.
201    const d = await git(
202      [
203        'git',
204        '--literal-pathspecs',
205        'diff-tree',
206        '-r',
207        '-z',
208        '--no-renames',
209        '--name-only',
210        tree,
211        to,
212        '--',
213        ...changed,
214      ],
215      { cwd: root },
216    );
217    if (d.exitCode === 0) differing.set(tree, new Set(nulList(d.stdout)));
218    else unread++;
219  }
220  return { rows: coverage(changed, reviews, differing), unread };
221}
222
223// HEAD's parent's tree; the caller has already resolved HEAD to a commit.
224export async function parentTree(git: Git, root: string): Promise<string | null> {
225  const p = await git(['git', 'rev-parse', '--verify', '-q', 'HEAD^'], { cwd: root });
226  return p.exitCode === 0 ? treeOf(git, root, p.stdout.trim()) : EMPTY_TREE;
227}
228
229export type Refs = Map<string, string>;
230
231// Remote-tracking refs by name. Symbolic refs are left out: their reflog does
232// not follow the ref they point at.
233export async function remoteRefs(git: Git, root: string): Promise<Refs> {
234  const r = await git(
235    ['git', 'for-each-ref', '--format=%(objectname)%09%(symref)%09%(refname)', 'refs/remotes'],
236    { cwd: root },
237  );
238  if (r.exitCode !== 0) {
239    throw new Error(`git for-each-ref failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
240  }
241  const refs: Refs = new Map();
242  for (const line of r.stdout.split('\n')) {
243    const [id, symref, name] = line.split('\t');
244    if (id && name && !symref) refs.set(name, id);
245  }
246  return refs;
247}
248
249// The reflog messages the command added to `ref`, newest first: the entries
250// after the one that set its starting value `was`. A fresh clone logs nothing
251// before a ref's first move, so a log that never reaches `was` is all new. A
252// ref the command created stops at `remote: renamed`: a renamed remote keeps
253// the old remote's entries below that line. At most 50 are read.
254async function reflogSince(
255  git: Git,
256  root: string,
257  ref: string,
258  was: string | undefined,
259): Promise<string[]> {
260  const r = await git(['git', 'log', '-g', '-n', '50', '--format=%H %gs', ref, '--'], {
261    cwd: root,
262  });
263  if (r.exitCode !== 0) {
264    throw new Error(`git log -g ${ref} failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
265  }
266  const messages: string[] = [];
267  for (const line of r.stdout.split('\n')) {
268    const space = line.indexOf(' ');
269    if (space === -1) break;
270    const message = line.slice(space + 1);
271    if (line.slice(0, space) === was) break;
272    if (was === undefined && message.startsWith('remote: renamed ')) break;
273    messages.push(message);
274  }
275  return messages;
276}
277
278// Remote-tracking refs a push moved, by name: git logs those moves as
279// `update by push`. A push that updates no remote-tracking ref, deletes one,
280// or leaves no reflog is not seen.
281export async function pushedRefs(
282  git: Git,
283  root: string,
284  before: Refs,
285  after: Refs,
286): Promise<string[]> {
287  const pushed: string[] = [];
288  for (const [ref, id] of after) {
289    if (before.get(ref) === id) continue;
290    const log = await reflogSince(git, root, ref, before.get(ref));
291    if (log.some((m) => m.startsWith('update by push'))) {
292      pushed.push(ref.slice('refs/remotes/'.length));
293    }
294  }
295  return pushed;
296}
297
298// HEAD's newest `n` reflog entries, newest first. git cannot read it while
299// HEAD is unborn, so callers skip it then.
300export async function headLog(git: Git, root: string, n: number): Promise<Entry[]> {
301  const r = await git(
302    ['git', 'log', '-g', '-n', String(n), '--date=raw', `--format=${REFLOG_FORMAT}`, 'HEAD', '--'],
303    { cwd: root },
304  );
305  if (r.exitCode !== 0) {
306    throw new Error(`git log -g HEAD failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
307  }
308  return parseReflog(r.stdout);
309}
310
311// How many entries HEAD's reflog holds. While HEAD is unborn git will not
312// read it, so the log file's entries are counted instead, skipping the ones
313// that record a deletion (a null new id), as `rev-list -g` does. A reftable
314// repository keeps no such file, so an unborn HEAD there cannot be counted.
315export async function headLogCount(git: Git, root: string, unborn: boolean): Promise<number> {
316  if (!unborn) {
317    const r = await git(['git', 'rev-list', '-g', '--count', 'HEAD'], { cwd: root });
318    const n = Number(r.stdout.trim());
319    if (r.exitCode !== 0 || !Number.isInteger(n)) {
320      throw new Error(`git rev-list -g failed: ${firstLine(r.stderr) || `exit ${r.exitCode}`}`);
321    }
322    return n;
323  }
324  const p = await git(['git', 'rev-parse', '--path-format=absolute', '--git-path', 'logs/HEAD'], {
325    cwd: root,
326  });
327  const path = p.stdout.trim();
328  if (p.exitCode !== 0 || !path) {
329    throw new Error(`git rev-parse failed: ${firstLine(p.stderr) || `exit ${p.exitCode}`}`);
330  }
331  const count = await git([
332    'sh',
333    '-c',
334    // awk counts, so a file it cannot read fails the call rather than counting 0.
335    'if [ -f "$1" ]; then awk \'$2 ~ /^[0-9a-f]+$/ && $2 !~ /^0+$/ { n++ } END { print n + 0 }\' "$1"; else echo none; fi',
336    'sh',
337    path,
338  ]);
339  const out = count.stdout.trim();
340  if (out === 'none') {
341    const exists = await git(['git', 'reflog', 'exists', 'HEAD'], { cwd: root });
342    if (exists.exitCode === 0) throw new Error("HEAD's reflog cannot be read while HEAD is unborn");
343    return 0;
344  }
345  const n = Number(out);
346  if (count.exitCode !== 0 || !Number.isInteger(n)) {
347    throw new Error(
348      `counting ${path} failed: ${firstLine(count.stderr) || `exit ${count.exitCode}`}`,
349    );
350  }
351  return n;
352}
353
354// A digest of HEAD and every reviewable change against it, blob ids included:
355// equal digests mean nothing a reviewer should see has moved.
356export async function snapshotOf(
357  git: Git,
358  root: string,
359  head: string,
360  tree: string,
361): Promise<string | null> {
362  const base = ['git', 'diff-tree', '-r', '--no-renames', '--raw', head, tree, '--'];
363  const main = await git([...base, ...(await pathspecs(git, root, tree))], { cwd: root });
364  const cfg = await git([...base, CONFIG_PATH], { cwd: root });
365  if (main.exitCode !== 0 || cfg.exitCode !== 0) return null;
366  const bytes = new TextEncoder().encode(`${head}\n${main.stdout}${cfg.stdout}`);
367  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
368  return [...digest].map((b) => b.toString(16).padStart(2, '0')).join('');
369}
370
hooks/git-args.ts 367 lines
1import type { Word } from './shell';
2
3export type Refusal = { refuse: string };
4
5export type CommitSpec = {
6  all: boolean;
7  amend: boolean;
8  dryRun: boolean;
9  // The commit's own `-c` options, which `-a` staging must see too.
10  config: string[];
11};
12
13export type PushSpec = {
14  // `lease` is --force-with-lease or --force-if-includes; `bare` is --force,
15  // -f, --mirror or a `+refspec`, which overwrite whatever the remote holds.
16  force: 'none' | 'lease' | 'bare';
17  // --tags, --follow-tags, a refs/tags/ refspec or `tag <name>`.
18  tags: boolean;
19  // --delete, -d, --prune, --mirror or a `:ref` refspec.
20  deletes: boolean;
21  // --all, --branches or --mirror.
22  every: boolean;
23  // `default` when none is given, so git's configured default applies.
24  remote: 'default' | 'dynamic' | { name: string };
25  // The refspecs after the remote; null when one is built at run time.
26  refspecs: string[] | null;
27  // The push as git is given it, `-c` options then the words after `push`,
28  // for the gate to repeat as a dry run; null when a word is built at run time.
29  // `end` is the index of the `--` that ends its options, else the length.
30  argv: { config: string[]; args: string[]; end: number } | null;
31  // An earlier step that can retarget the push, unseen by a dry run run first.
32  after: string | null;
33};
34
35// `value` takes the rest of the word or else the next word (`--x=v` or
36// `--x v` for a long one); `attached` takes only the rest of the word, so
37// `-uno` and `--signed=if-asked` but never the next word; a refusal stops
38// the read with its reason.
39type Kind = 'flag' | 'value' | 'attached' | Refusal;
40
41type Table<L extends string, S extends string> = {
42  sub: string;
43  long: Readonly<Record<L, Kind>>;
44  short: Readonly<Record<S, Kind>>;
45  // Whether a short letter the table does not name is refused or let pass.
46  otherShort: 'refuse' | 'accept';
47  // Refuse an option whose value would be the next word when there is none.
48  needsValue: boolean;
49};
50
51// Tables are object literals, so a name listed twice does not compile, and
52// the option names a callback compares against are their keys, so a
53// misspelled one does not compile either.
54const table = <L extends string, S extends string>(t: Table<L, S>): Table<L, S> => t;
55
56// A known option as read. `value` is the attached text, or the next word
57// for a `value` kind (null when the words ran out).
58type Option<L extends string, S extends string> =
59  | { long: true; name: L; value: Word | null }
60  | { long: false; name: S; value: Word | null };
61
62type Read = { positionals: Word[]; end: number };
63
64function kindOf<K extends string>(names: Readonly<Record<K, Kind>>, name: string): Kind | null {
65  return Object.hasOwn(names, name) ? names[name as K] : null;
66}
67
68// Git accepts any unambiguous prefix of a long option (`--ame` amends), so
69// only exact names are read and any other is refused.
70function readOptions<L extends string, S extends string>(
71  args: Word[],
72  options: Table<L, S>,
73  option: (o: Option<L, S>) => void,
74  positional: (w: Word, afterOptions: boolean) => Refusal | null = () => null,
75): Read | Refusal {
76  const positionals: Word[] = [];
77  const unknown = (name: string) => ({
78    refuse: `git ${options.sub} ${name}, an option the gate does not know. Spell it out in full`,
79  });
80  const valueless = (name: string) => ({
81    refuse: `git ${options.sub} ${name} without a value. Give it one, or leave it out`,
82  });
83  let end = args.length;
84  for (let i = 0; i < args.length; i++) {
85    const arg = args[i];
86    if (arg === undefined) break;
87    // An option is read by its name even when its value is built at run
88    // time: `--force-with-lease=main:$expect`.
89    const t = arg.text;
90    if (i > end || !t.startsWith('-') || t === '-') {
91      const refused = positional(arg, i > end);
92      if (refused) return refused;
93      positionals.push(arg);
94      continue;
95    }
96    if (t === '--') {
97      end = i;
98      continue;
99    }
100    if (t.startsWith('--')) {
101      const name = t.slice(2).split('=')[0] ?? '';
102      const kind = kindOf(options.long, name);
103      if (kind === null) return unknown(`--${name}`);
104      if (typeof kind === 'object') return kind;
105      const eq = t.indexOf('=');
106      let value: Word | null = eq === -1 ? null : { ...arg, text: t.slice(eq + 1) };
107      if (kind === 'value' && eq === -1) {
108        value = args[i + 1] ?? null;
109        if (++i >= args.length && options.needsValue) return valueless(`--${name}`);
110      }
111      option({ long: true, name: name as L, value });
112      continue;
113    }
114    for (let j = 1; j < t.length; j++) {
115      const letter = t.charAt(j);
116      const kind = kindOf(options.short, letter);
117      if (kind === null) {
118        if (options.otherShort === 'refuse') return unknown(`-${letter}`);
119        continue;
120      }
121      if (typeof kind === 'object') return kind;
122      const rest = j < t.length - 1 ? { ...arg, text: t.slice(j + 1) } : null;
123      let value: Word | null = kind === 'flag' ? null : rest;
124      if (kind === 'value' && rest === null) {
125        value = args[i + 1] ?? null;
126        if (++i >= args.length && options.needsValue) return valueless(`-${letter}`);
127      }
128      option({ long: false, name: letter as S, value });
129      if (kind !== 'flag') break;
130    }
131  }
132  return { positionals, end };
133}
134
135const PUSH = table({
136  sub: 'push',
137  long: {
138    repo: 'value',
139    'push-option': 'value',
140    'receive-pack': 'value',
141    exec: 'value',
142    signed: 'attached',
143    'recurse-submodules': 'attached',
144    force: 'flag',
145    'force-with-lease': 'flag',
146    'force-if-includes': 'flag',
147    tags: 'flag',
148    'follow-tags': 'flag',
149    delete: 'flag',
150    prune: 'flag',
151    all: 'flag',
152    branches: 'flag',
153    mirror: 'flag',
154    'set-upstream': 'flag',
155    verbose: 'flag',
156    quiet: 'flag',
157    progress: 'flag',
158    'no-progress': 'flag',
159    verify: 'flag',
160    'no-verify': 'flag',
161    'dry-run': 'flag',
162    porcelain: 'flag',
163    atomic: 'flag',
164    'no-atomic': 'flag',
165    // Accepted but not read: a tag flag anywhere counts as a tag push, which
166    // errs toward asking (git keeps --tags and --follow-tags apart).
167    'no-tags': 'flag',
168    'no-follow-tags': 'flag',
169    thin: 'flag',
170    'no-thin': 'flag',
171    ipv4: 'flag',
172    ipv6: 'flag',
173    'no-force-with-lease': 'flag',
174    'no-force-if-includes': 'flag',
175    'no-recurse-submodules': 'flag',
176    'no-signed': 'flag',
177  },
178  short: {
179    o: 'value',
180    f: 'flag',
181    d: 'flag',
182    u: 'flag',
183    v: 'flag',
184    q: 'flag',
185    n: 'flag',
186    '4': 'flag',
187    '6': 'flag',
188  },
189  otherShort: 'refuse',
190  needsValue: true,
191});
192
193// What a refspec says about the push, read from the text as written so
194// `"+$B"` still shows its `+`. `next` is the refspec after it, if any.
195function refspecOf(
196  r: string,
197  next: boolean,
198): Pick<PushSpec, 'deletes' | 'tags'> & { bare: boolean } {
199  return {
200    bare: r.startsWith('+'),
201    deletes: r.startsWith(':'),
202    // `git push origin tag v1` pushes refs/tags/v1.
203    tags: /(^|:)refs\/tags\//.test(r.replace(/^\+/, '')) || (r === 'tag' && next),
204  };
205}
206
207export function pushSpec(args: Word[]): PushSpec | Refusal {
208  const spec: PushSpec = {
209    force: 'none',
210    tags: false,
211    deletes: false,
212    every: false,
213    remote: 'default',
214    refspecs: [],
215    argv: null,
216    after: null,
217  };
218  const bare = () => (spec.force = 'bare');
219  const lease = () => (spec.force = spec.force === 'bare' ? 'bare' : 'lease');
220  let repo: Word | null = null;
221  const read = readOptions(args, PUSH, (o) => {
222    if (!o.long) {
223      if (o.name === 'f') bare();
224      else if (o.name === 'd') spec.deletes = true;
225    } else if (o.name === 'force') bare();
226    else if (o.name === 'force-with-lease' || o.name === 'force-if-includes') lease();
227    else if (o.name === 'tags' || o.name === 'follow-tags') spec.tags = true;
228    else if (o.name === 'delete' || o.name === 'prune') spec.deletes = true;
229    else if (o.name === 'all' || o.name === 'branches') spec.every = true;
230    else if (o.name === 'mirror') {
231      // git help push: refs are "force updated" and missing ones "removed".
232      bare();
233      spec.deletes = true;
234      spec.every = true;
235    } else if (o.name === 'repo') repo = o.value;
236  });
237  if ('refuse' in read) return read;
238  if (!args.some((w) => w.dynamic)) {
239    spec.argv = { config: [], args: args.map((w) => w.text), end: read.end };
240  }
241  // `--repo` stands in for the remote argument, which wins when both are given.
242  const [remote = repo ?? undefined, ...refspecs] = read.positionals;
243  if (remote !== undefined) spec.remote = remote.dynamic ? 'dynamic' : { name: remote.text };
244  spec.refspecs = refspecs.some((w) => w.dynamic) ? null : refspecs.map((w) => w.text);
245  for (const [k, { text }] of refspecs.entries()) {
246    const r = refspecOf(text, k < refspecs.length - 1);
247    if (r.bare) bare();
248    if (r.deletes) spec.deletes = true;
249    if (r.tags) spec.tags = true;
250  }
251  return spec;
252}
253
254const interactive = (flag: string): Refusal => ({
255  refuse: `git commit ${flag}, which stages interactively`,
256});
257const stageFirst = (flag: string): Refusal => ({
258  refuse: `git commit ${flag}. Stage with \`git add\`, then run \`git commit\` alone`,
259});
260
261const COMMIT = table({
262  sub: 'commit',
263  long: {
264    patch: interactive('--patch'),
265    interactive: interactive('--interactive'),
266    include: stageFirst('--include'),
267    only: stageFirst('--only'),
268    'pathspec-from-file': {
269      refuse: 'git commit --pathspec-from-file. Stage with `git add`, then commit',
270    },
271    message: 'value',
272    file: 'value',
273    'reuse-message': 'value',
274    'reedit-message': 'value',
275    template: 'value',
276    author: 'value',
277    date: 'value',
278    cleanup: 'value',
279    fixup: 'value',
280    squash: 'value',
281    trailer: 'value',
282    all: 'flag',
283    amend: 'flag',
284    'dry-run': 'flag',
285    'no-edit': 'flag',
286    edit: 'flag',
287    'no-verify': 'flag',
288    verify: 'flag',
289    signoff: 'flag',
290    'no-signoff': 'flag',
291    'no-gpg-sign': 'flag',
292    'allow-empty': 'flag',
293    'allow-empty-message': 'flag',
294    quiet: 'flag',
295    verbose: 'flag',
296    status: 'flag',
297    'no-status': 'flag',
298    'reset-author': 'flag',
299    short: 'flag',
300    branch: 'flag',
301    porcelain: 'flag',
302    long: 'flag',
303    null: 'flag',
304    'no-post-rewrite': 'flag',
305    'gpg-sign': 'flag',
306    'untracked-files': 'flag',
307  },
308  short: {
309    p: interactive('-p'),
310    i: stageFirst('-i'),
311    o: stageFirst('-o'),
312    m: 'value',
313    F: 'value',
314    C: 'value',
315    c: 'value',
316    t: 'value',
317    S: 'attached',
318    u: 'attached',
319    a: 'flag',
320  },
321  otherShort: 'accept',
322  needsValue: false,
323});
324
325const PATHSPECS: Refusal = {
326  refuse: 'git commit with pathspecs. Stage them with `git add`, then run `git commit` alone',
327};
328
329export function commitSpec(args: Word[]): CommitSpec | Refusal {
330  const spec: CommitSpec = { all: false, amend: false, dryRun: false, config: [] };
331  const read = readOptions(
332    args,
333    COMMIT,
334    (o) => {
335      if (o.long ? o.name === 'all' : o.name === 'a') spec.all = true;
336      else if (o.long && o.name === 'amend') spec.amend = true;
337      else if (o.long && o.name === 'dry-run') spec.dryRun = true;
338    },
339    (w, afterOptions) =>
340      w.dynamic && !afterOptions
341        ? {
342            refuse:
343              'git commit with a pathspec built from a variable or substitution. Stage with `git add`, then commit',
344          }
345        : PATHSPECS,
346  );
347  return 'refuse' in read ? read : spec;
348}
349
350export function addArgv(config: string[], args: Word[]): string[] | Refusal {
351  for (const w of args) {
352    if (w.dynamic)
353      return { refuse: 'git add with an argument built from a variable or substitution' };
354    if (w.text.includes('{') && w.pattern)
355      return { refuse: 'git add with a brace expansion; list the paths' };
356    if (
357      /^(-p|-i|-e|--patch|--interactive|--edit)$/.test(w.text) ||
358      /^-[a-zA-Z]*[pie][a-zA-Z]*$/.test(w.text)
359    ) {
360      return { refuse: `git add ${w.text}, which stages interactively` };
361    }
362    if (w.text.startsWith('--pathspec-from-file'))
363      return { refuse: 'git add --pathspec-from-file' };
364  }
365  return [...config, 'add', ...args.map((w) => w.text)];
366}
367
hooks/github.ts 539 lines
1// What a GitHub write is on the stop-before ladder, read from a `gh api` call
2// or a GitHub MCP tool call. Pure: no `$`, no I/O.
3
4import type { Word } from './shell';
5
6// Where a gh write runs: `elsewhere` when something outside its words picks
7// the repository, `beside` when another step in the command may.
8export type GhContext = 'fixed' | 'elsewhere' | 'beside';
9
10// `asks` says why the push asks whatever the setting; a `force` one needs a
11// request for a bare force, since the API has no lease.
12export type Asking = { asks: string; force: boolean };
13export const asking = (asks: string, force: boolean): Asking => ({ asks, force });
14
15// `head` is a pull request's own branch, looked up by `gh pr view <head>`.
16export type PushRef =
17  | { branch: string; repo: string | null; force?: never }
18  | { head: readonly string[]; force?: never }
19  | Asking;
20
21export type Unlookable = { cannot: Exclude<GhContext, 'fixed'> | 'unnamed' };
22
23export type GhAction =
24  | { kind: 'pr' }
25  // `auto` waits for what GitHub requires to merge: `gh pr merge --auto`.
26  | { kind: 'merge'; admin: boolean; auto: boolean; lookup: readonly string[] | Unlookable }
27  | { kind: 'approve' }
28  | { kind: 'comment' }
29  | { kind: 'release' }
30  | { kind: 'push'; ref: PushRef }
31  | { kind: 'unread'; why: string; remedy?: string };
32
33// `gh api`'s options that take a value (gh 2.102.0's `gh api --help`), and
34// the short ones among them, which take the rest of a cluster (`-XPOST`).
35const API_VALUE = new Set([
36  '-F',
37  '--field',
38  '-f',
39  '--raw-field',
40  '-H',
41  '--header',
42  '-q',
43  '--jq',
44  '-X',
45  '--method',
46  '-p',
47  '--preview',
48  '-t',
49  '--template',
50  '--cache',
51  '--hostname',
52  '--input',
53]);
54const API_SHORT_VALUE = 'FfHqXpt';
55const FIELD = new Set(['-F', '--field', '-f', '--raw-field']);
56
57// A field's value, or null when it is built at run time or read from a file
58// (`-F body=@notes.md`).
59type Fields = Map<string, string | null>;
60
61type ApiCall = {
62  // The method `-X` names, upper-cased; `?` when it is built at run time.
63  method: string | null;
64  endpoint: Word | null;
65  // A word built at run time that may be options (`"$ARGS"`, `$X`), not a
66  // value or an endpoint with a written-out start.
67  opaque: boolean;
68  fields: Fields;
69  // A field whose name is built at run time, so any field may hide in it.
70  unnamed: boolean;
71  input: boolean;
72  hostname: boolean;
73};
74
75// The option a word is and its value, when it takes one: `-X POST`, `-XPOST`,
76// `-X=POST`, `--method=POST`, `-iXPOST`.
77function optionAt(words: Word[], i: number): { name: string; value: Word | null; next: number } {
78  const w = words[i]!;
79  const t = w.text;
80  const long = /^(--[^=]+)=([\s\S]*)$/.exec(t);
81  if (long?.[1] !== undefined) {
82    return { name: long[1], value: { ...w, text: long[2] ?? '' }, next: i + 1 };
83  }
84  if (t.startsWith('--')) {
85    return API_VALUE.has(t)
86      ? { name: t, value: words[i + 1] ?? null, next: i + 2 }
87      : { name: t, value: null, next: i + 1 };
88  }
89  for (let at = 1; at < t.length; at++) {
90    const letter = t.charAt(at);
91    if (!API_SHORT_VALUE.includes(letter)) continue;
92    const rest = t.slice(at + 1).replace(/^=/, '');
93    return rest === ''
94      ? { name: `-${letter}`, value: words[i + 1] ?? null, next: i + 2 }
95      : { name: `-${letter}`, value: { ...w, text: rest }, next: i + 1 };
96  }
97  return { name: t, value: null, next: i + 1 };
98}
99
100// The words after `gh api`.
101function readApi(words: Word[]): ApiCall {
102  const call: ApiCall = {
103    method: null,
104    endpoint: null,
105    opaque: false,
106    fields: new Map(),
107    unnamed: false,
108    input: false,
109    hostname: false,
110  };
111  let i = 0;
112  while (i < words.length) {
113    const w = words[i]!;
114    // A written-out start (`repos/…/$PR`) makes the word the endpoint.
115    if (w.dynamic && /^[$`]/.test(w.text)) {
116      call.opaque = true;
117      i++;
118      continue;
119    }
120    if (!w.text.startsWith('-') || w.text === '-') {
121      call.endpoint ??= w;
122      i++;
123      continue;
124    }
125    const { name, value, next } = optionAt(words, i);
126    i = next;
127    if (name === '-X' || name === '--method') {
128      call.method = value === null || value.dynamic ? '?' : value.text.toUpperCase();
129    } else if (FIELD.has(name) && value !== null) {
130      const eq = value.text.indexOf('=');
131      const key = eq === -1 ? value.text : value.text.slice(0, eq);
132      if (/[$`]/.test(key)) call.unnamed = true;
133      const raw = eq === -1 ? '' : value.text.slice(eq + 1);
134      // A typed field reads a file (`@f`) or fills in `{branch}` and the like.
135      const typed = (name === '-F' || name === '--field') && /^@|\{\w+\}/.test(raw);
136      call.fields.set(key, value.dynamic || typed ? null : raw);
137    } else if (name === '--input') call.input = true;
138    else if (name === '--hostname') call.hostname = true;
139  }
140  return call;
141}
142
143// A branch as GitHub's ref APIs take it, or why the push it names asks: `null`
144// is a name read at run time, `undefined` none at all.
145function branchOf(name: string | null | undefined, missing: string): string | Asking {
146  if (name === null) {
147    return asking('the branch it names is built at run time or read from a file', false);
148  }
149  if (name?.startsWith('refs/tags/')) return asking('it pushes a tag', false);
150  if (name?.startsWith('refs/') && !name.startsWith('refs/heads/')) {
151    return asking(`it writes \`${name}\`, which is no branch`, false);
152  }
153  const branch = name?.replace(/^refs\/heads\//, '');
154  return branch === undefined || branch === '' ? asking(missing, false) : branch;
155}
156
157function pushRef(
158  name: string | null | undefined,
159  missing: string,
160  repo: string | null,
161  where: GhContext,
162): PushRef {
163  if (where === 'elsewhere') return asking('where it pushes is picked outside its words', false);
164  const branch = branchOf(name, missing);
165  if (typeof branch !== 'string') return branch;
166  if (where === 'beside' && repo === null) {
167    return asking('another step in the command can change which repository it reaches', false);
168  }
169  return { branch, repo };
170}
171
172// What a REST write to `repos/<owner>/<repo>/<path>` is on the ladder.
173function restAction(
174  method: string,
175  path: string,
176  repo: string | null,
177  call: ApiCall,
178  where: GhContext,
179): GhAction | null {
180  const field = (key: string) => (call.unnamed || call.input ? null : call.fields.get(key));
181  const unknown = (key: string) => call.input || call.unnamed || call.fields.get(key) === null;
182  const push = (name: string | null | undefined, missing: string): GhAction => ({
183    kind: 'push',
184    ref: pushRef(name, missing, repo, where),
185  });
186  if (path === 'pulls') return method === 'POST' ? { kind: 'pr' } : null;
187  if (path === 'releases' || path.startsWith('releases/')) return { kind: 'release' };
188  const pull = /^pulls\/([^/]+)\/(.+)$/.exec(path);
189  if (pull?.[2] === 'merge') {
190    const number = pull[1] ?? '';
191    const lookup: readonly string[] | Unlookable =
192      where !== 'fixed'
193        ? { cannot: where }
194        : /^\d+$/.test(number)
195          ? [number, ...(repo === null ? [] : ['--repo', repo])]
196          : { cannot: 'unnamed' };
197    return { kind: 'merge', admin: false, auto: false, lookup };
198  }
199  if (pull?.[2] === 'update-branch') {
200    const number = pull[1] ?? '';
201    const unlookable = where === 'elsewhere' || (where === 'beside' && repo === null);
202    if (unlookable || !/^\d+$/.test(number)) {
203      return {
204        kind: 'push',
205        ref: asking('the pull request it updates cannot be looked up', false),
206      };
207    }
208    return { kind: 'push', ref: { head: [number, ...(repo === null ? [] : ['--repo', repo])] } };
209  }
210  if (pull?.[2] === 'reviews' || pull?.[2]?.startsWith('reviews/')) {
211    if (unknown('event')) {
212      return {
213        kind: 'unread',
214        why: 'the review event of this `gh api` call is built at run time or read from a file',
215        remedy: 'Write the event out with `-f event=…`.',
216      };
217    }
218    return { kind: field('event')?.toUpperCase() === 'APPROVE' ? 'approve' : 'comment' };
219  }
220  if (/^(pulls|issues)\/[^/]+\/comments(\/|$)|^(pulls|issues)\/comments(\/|$)/.test(path)) {
221    return { kind: 'comment' };
222  }
223  if (path.startsWith('contents/')) {
224    return push(field('branch'), 'it names no branch, so it commits to the default branch');
225  }
226  if (path === 'merges') return push(field('base'), 'its base branch is not written out');
227  if (path === 'merge-upstream') {
228    return push(field('branch'), 'the branch it updates is not written out');
229  }
230  if (/^branches\/.+\/rename$/.test(path)) {
231    return { kind: 'push', ref: asking('it renames a branch', false) };
232  }
233  if (path === 'git/refs' && method === 'POST') {
234    const ref = field('ref');
235    const named = ref === null || ref?.startsWith('refs/') ? ref : undefined;
236    return push(named, 'the ref it creates is not written out');
237  }
238  const ref = /^git\/refs\/(heads|tags)\/(.+)$/.exec(path);
239  if (ref) {
240    const name = ref[2] ?? '';
241    if (ref[1] === 'tags') return { kind: 'push', ref: asking('it pushes a tag', false) };
242    if (method === 'DELETE') return { kind: 'push', ref: asking(`it deletes \`${name}\``, false) };
243    const force = field('force');
244    if (force === null || (force !== undefined && force !== 'false')) {
245      return { kind: 'push', ref: asking(`it force-updates \`${name}\``, true) };
246    }
247    return push(name, '');
248  }
249  return null;
250}
251
252// GraphQL mutations that are ladder steps (GitHub's schema, read 2026-10-03);
253// GitHub has none that writes a release. A review approves when its event
254// says APPROVE.
255const BY_ID = {
256  kind: 'merge',
257  admin: false,
258  auto: false,
259  lookup: { cannot: 'unnamed' },
260} as const satisfies GhAction;
261const MUTATIONS: Record<string, GhAction | 'review' | 'ref' | 'update'> = {
262  createPullRequest: { kind: 'pr' },
263  markPullRequestReadyForReview: { kind: 'pr' },
264  revertPullRequest: { kind: 'pr' },
265  mergePullRequest: BY_ID,
266  enablePullRequestAutoMerge: { ...BY_ID, auto: true },
267  enqueuePullRequest: BY_ID,
268  addPullRequestReview: 'review',
269  submitPullRequestReview: 'review',
270  updatePullRequestBranch: 'update',
271  ...Object.fromEntries(
272    [
273      'addComment',
274      'addPullRequestReviewComment',
275      'addPullRequestReviewThread',
276      'addPullRequestReviewThreadReply',
277      'updateIssueComment',
278      'updatePullRequestReview',
279      'updatePullRequestReviewComment',
280      'deleteIssueComment',
281      'deletePullRequestReview',
282      'deletePullRequestReviewComment',
283      'dismissPullRequestReview',
284      'resolveReviewThread',
285      'unresolveReviewThread',
286      'minimizeComment',
287      'unminimizeComment',
288    ].map((name) => [name, { kind: 'comment' } as const]),
289  ),
290  ...Object.fromEntries(
291    [
292      'createCommitOnBranch',
293      'createLinkedBranch',
294      'createRef',
295      'updateRef',
296      'updateRefs',
297      'deleteRef',
298      'mergeBranch',
299    ].map((name) => [name, 'ref' as const]),
300  ),
301};
302
303const unreadEvent: GhAction = {
304  kind: 'unread',
305  why: 'the review event of this GraphQL mutation is built at run time or read from a file',
306  remedy: 'Write the event out.',
307};
308
309const approves = (v: string | null | undefined) => v?.toUpperCase() === 'APPROVE';
310
311// A review approves only by its `event` argument, written in the query or
312// passed in a variable (`-f event=…`, `-f input[event]=…`).
313function reviewOf(query: string, call: ApiCall): GhAction {
314  // Every review in the query counts, so one that approves is never read as a
315  // comment. `(?<!\$)` skips a variable's definition (`$event: …Event`).
316  const args = [...query.matchAll(/(?<!\$)\bevent\s*:\s*(\$?\w+)/g)].map((m) => m[1] ?? '');
317  // A variable no field sets takes its default; a declared one with neither
318  // sends no event (a pending review), and an undeclared one is unread.
319  const values = args.map((arg) => {
320    if (!arg.startsWith('$')) return arg;
321    const name = arg.slice(1);
322    if (call.fields.has(name)) return call.fields.get(name);
323    const declared = new RegExp(String.raw`\$${name}\s*:\s*[^,)=$]*(?:=\s*(\w+))?`).exec(query);
324    return declared === null ? null : (declared[1] ?? '');
325  });
326  const keys = [...call.fields.keys()].filter((k) => /(^|\[)event\]?$/.test(k));
327  if (values.some((v) => approves(v)) || keys.some((k) => approves(call.fields.get(k)))) {
328    return { kind: 'approve' };
329  }
330  if (values.includes(null)) return unreadEvent;
331  // A review given a whole input object (`input: $input`) carries its event in
332  // that variable or its `[event]` key, either of which may be read at run time.
333  const objects = [...query.matchAll(/\binput\s*:\s*\$(\w+)/g)].map((m) => m[1]);
334  const hidden = [...call.fields].some(
335    ([k, v]) => v === null && objects.some((o) => k === o || k === `${o}[event]`),
336  );
337  return hidden ? unreadEvent : { kind: 'comment' };
338}
339
340// The query with its strings and comments blanked, so text inside them names
341// no operation or mutation.
342const bare = (query: string) =>
343  query.replaceAll(/"""(?:\\"""|[\s\S])*?"""|"(?:\\.|[^"\\])*"|#[^\n]*/g, ' ');
344
345function graphqlActions(call: ApiCall): GhAction[] {
346  const query = call.unnamed || call.input ? null : call.fields.get('query');
347  if (query === undefined) return [];
348  if (query === null) {
349    return [
350      {
351        kind: 'unread',
352        why: 'the GraphQL query of this `gh api` call is built at run time or read from a file',
353        remedy: 'Write the query out with `-f query=…`.',
354      },
355    ];
356  }
357  const ops = bare(query);
358  if (!/(^|[\s}])mutation\b/.test(ops)) return [];
359  const found: GhAction[] = [];
360  for (const [name, action] of Object.entries(MUTATIONS)) {
361    if (!new RegExp(String.raw`\b${name}\s*\(`).test(ops)) continue;
362    if (action === 'review') found.push(reviewOf(ops, call));
363    else if (action === 'ref') {
364      // `force: $f` reads the field that sets `$f`.
365      const forced = (value: string | undefined) => {
366        const name = /^\$(\w+)$/.exec(value ?? '')?.[1];
367        const set = name === undefined ? value : call.fields.get(name);
368        return set !== 'false';
369      };
370      const force =
371        [...ops.matchAll(/\bforce\s*:\s*(\$?\w+)/g)].some((m) => forced(m[1])) ||
372        [...call.fields].some(([k, v]) => /(^|\[)force\]?$/.test(k) && v !== 'false');
373      const asks = 'the gate does not read which branch a GraphQL mutation writes';
374      found.push({ kind: 'push', ref: asking(asks, force) });
375    } else if (action === 'update') {
376      // A rebase rewrites the branch, as `gh pr update-branch --rebase` does.
377      const method = (value: string | undefined) => {
378        const name = /^\$(\w+)$/.exec(value ?? '')?.[1];
379        return name === undefined ? value : call.fields.get(name);
380      };
381      const rebases =
382        [...ops.matchAll(/\bupdateMethod\s*:\s*(\$?\w+)/g)].some((m) => method(m[1]) !== 'MERGE') ||
383        [...call.fields].some(([k, v]) => /(^|\[)updateMethod\]?$/.test(k) && v !== 'MERGE');
384      const asks = 'it names the pull request whose branch it updates by id';
385      found.push({ kind: 'push', ref: asking(asks, rebases) });
386    } else found.push(action);
387  }
388  return found;
389}
390
391// What a `gh api` call writes on the ladder, from the words after `api`. A
392// read, or a write to an endpoint no ladder step covers, is nothing.
393export function apiActions(words: Word[], context: GhContext, fed: boolean): GhAction[] {
394  const call = readApi(words);
395  if (call.method === 'GET' || call.method === 'HEAD') return [];
396  if (call.opaque || call.method === '?') {
397    return [
398      {
399        kind: 'unread',
400        why: 'the arguments of `gh api` are built at run time',
401        remedy: 'Write its options out; a read can name `--method GET`.',
402      },
403    ];
404  }
405  if (call.endpoint === null) {
406    return fed ? [{ kind: 'unread', why: 'its `gh api` endpoint comes from its input' }] : [];
407  }
408  // As gh's api.go decides it: POST once fields or --input are given.
409  const written = call.fields.size > 0 || call.input || call.unnamed;
410  const method = call.method ?? (written ? 'POST' : 'GET');
411  if (method === 'GET' || method === 'HEAD') return [];
412  if (fed) return [{ kind: 'unread', why: 'the arguments of `gh api` come from its input' }];
413  if (call.endpoint.dynamic) {
414    return [
415      {
416        kind: 'unread',
417        why: 'the endpoint of this `gh api` write is built at run time',
418        remedy: 'Write the endpoint out.',
419      },
420    ];
421  }
422  // gh sends a full URL as written: another host is another repository.
423  // GitHub Enterprise serves REST under /api/v3/ and GraphQL at /api/graphql.
424  const url = /^https?:\/\/([^/]+)\/(?:api\/v3\/|api\/(?=graphql\b))?/i.exec(call.endpoint.text);
425  const host = url !== null && url[1]?.toLowerCase() !== 'api.github.com';
426  const endpoint = call.endpoint.text
427    .slice(url?.[0].length ?? 0)
428    .replace(/^\//, '')
429    .replace(/\?.*$/, '');
430  if (endpoint === 'graphql') return graphqlActions(call);
431  const m = /^repos\/([^/]+)\/([^/]+)\/(.+)$/.exec(endpoint);
432  if (!m) return [];
433  const [owner = '', name = '', path = ''] = m.slice(1);
434  const placeholder = owner === '{owner}' && name === '{repo}';
435  const elsewhere =
436    context === 'elsewhere' || call.hostname || host || (!placeholder && /[{}]/.test(owner + name));
437  const where = elsewhere ? 'elsewhere' : context;
438  const action = restAction(method, path, placeholder ? null : `${owner}/${name}`, call, where);
439  return action === null ? [] : [action];
440}
441
442// The GitHub MCP server's write tools that are ladder steps (its README,
443// read 2026-10-03), matched by name under any server name. It has no tool
444// that writes a release.
445const MCP_COMMENT = new Set([
446  'add_comment_to_pending_review',
447  'add_reply_to_pull_request_comment',
448  'add_issue_comment',
449  'update_issue_comment',
450]);
451const MCP_PUSH = new Set(['push_files', 'create_or_update_file', 'delete_file', 'create_branch']);
452const MCP_TOOLS = [
453  'create_pull_request',
454  'create_pull_request_with_copilot',
455  'merge_pull_request',
456  'pull_request_review_write',
457  'update_pull_request_branch',
458  'update_pull_request',
459  ...MCP_COMMENT,
460  ...MCP_PUSH,
461];
462
463// Matches the tool names mcpAction reads.
464export const MCP_GITHUB = new RegExp(String.raw`^mcp__.+__(${MCP_TOOLS.join('|')})$`);
465
466// The arguments a refusal quotes: those naming where the write goes.
467const SHOWN = new Set([
468  'owner',
469  'repo',
470  'pullNumber',
471  'issue_number',
472  'comment_id',
473  'commentId',
474  'branch',
475  'from_branch',
476  'base',
477  'head',
478  'path',
479  'method',
480  'event',
481  'merge_method',
482]);
483
484// The tool call as a refusal quotes it: its name and the arguments naming
485// where it writes, with characters that could hide or reorder text replaced.
486export function shownCall(tool: string, input: Record<string, unknown>): string {
487  const args = Object.entries(input).filter(
488    ([key, v]) => SHOWN.has(key) && ['string', 'number', 'boolean'].includes(typeof v),
489  );
490  const shown = `${tool} ${JSON.stringify(Object.fromEntries(args))}`;
491  return shown.replaceAll(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '\u{FFFD}');
492}
493
494const text = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : null);
495// An owner or repository name gh reads as one, never as an option.
496const named = (s: string | null): s is string => s !== null && /^\w[\w.-]*$/.test(s);
497
498// What a GitHub MCP tool call is on the ladder, or null for any other tool.
499export function mcpAction(tool: string, input: Record<string, unknown>): GhAction | null {
500  const name = MCP_TOOLS.find((t) => tool.endsWith(`__${t}`));
501  if (name === undefined) return null;
502  const owner = text(input.owner);
503  const repoName = text(input.repo);
504  const repo = named(owner) && named(repoName) ? `${owner}/${repoName}` : null;
505  if (name === 'create_pull_request' || name === 'create_pull_request_with_copilot') {
506    return { kind: 'pr' };
507  }
508  if (name === 'merge_pull_request') {
509    const number = text(input.pullNumber);
510    const lookup =
511      number !== null && /^\d+$/.test(number) && repo !== null
512        ? [number, '--repo', repo]
513        : { cannot: 'unnamed' as const };
514    return { kind: 'merge', admin: false, auto: false, lookup };
515  }
516  if (name === 'pull_request_review_write') {
517    return { kind: text(input.event)?.toUpperCase() === 'APPROVE' ? 'approve' : 'comment' };
518  }
519  if (name === 'update_pull_request_branch') {
520    const number = text(input.pullNumber);
521    if (number === null || !/^\d+$/.test(number) || repo === null) {
522      return {
523        kind: 'push',
524        ref: asking('the pull request it updates cannot be looked up', false),
525      };
526    }
527    return { kind: 'push', ref: { head: [number, '--repo', repo] } };
528  }
529  // Marking a draft ready for review is the pull request step; other edits are not.
530  if (name === 'update_pull_request') {
531    return input.draft === false || input.draft === 'false' ? { kind: 'pr' } : null;
532  }
533  if (MCP_COMMENT.has(name)) return { kind: 'comment' };
534  // gh's lookup of the default branch needs the repository spelled out.
535  if (repo === null) return { kind: 'push', ref: asking('it does not name its repository', false) };
536  const branch = branchOf(text(input.branch) ?? undefined, 'it names no branch');
537  return { kind: 'push', ref: typeof branch === 'string' ? { branch, repo } : branch };
538}
539
hooks/ladder.ts 87 lines
1// Where the agent stops to ask the user before an outward step. Pure.
2//
3// One setting, review-cycle's `stopBefore`: a step below the chosen rung runs
4// without asking, one at or above it needs the user's request. Claude Code
5// passes the register options only from user and managed settings, so the
6// gate reads the project's and the local file's pluginConfigs itself.
7
8export const RUNGS = ['commit', 'push', 'open PR', 'merge', 'release', 'never stop'] as const;
9export type StopBefore = (typeof RUNGS)[number];
10export const DEFAULT_STOP: StopBefore = 'push';
11// What a setting the gate cannot trust falls back to.
12export const STRICTEST: StopBefore = RUNGS[0];
13
14// The steps the gate judges, in rung order.
15export type Step = 'commit' | 'push' | 'pr' | 'merge' | 'approve' | 'release';
16const STEP_RUNG: Record<Step, StopBefore> = {
17  commit: 'commit',
18  push: 'push',
19  pr: 'open PR',
20  merge: 'merge',
21  // An approval can be all a merge waits for.
22  approve: 'merge',
23  release: 'release',
24};
25
26export type Source = 'default' | 'user' | 'project' | 'local';
27export type Ladder = Readonly<{ stopBefore: StopBefore; source: Source }>;
28
29const PLUGIN = /^review-cycle(@|$)/;
30
31const rank = (r: StopBefore): number => RUNGS.indexOf(r);
32
33// A value outside the options counts as unset, as Claude Code documents for
34// the options it passes.
35export function stopBeforeOf(value: unknown): StopBefore | null {
36  return RUNGS.find((r) => r === value) ?? null;
37}
38
39// The value a settings file's pluginConfigs gives review-cycle, under its bare
40// name or a marketplace-qualified one (`review-cycle@oakoss`). A value outside
41// the options stops before every step: a typo meant to stop earlier must not
42// leave a looser rung in force.
43export function configured(pluginConfigs: unknown): StopBefore | null {
44  if (pluginConfigs === null || typeof pluginConfigs !== 'object') return null;
45  let found: StopBefore | null = null;
46  for (const [name, entry] of Object.entries(pluginConfigs)) {
47    if (!PLUGIN.test(name) || entry === null || typeof entry !== 'object') continue;
48    const options: unknown = (entry as { options?: unknown }).options;
49    if (options === null || typeof options !== 'object' || !('stopBefore' in options)) continue;
50    const value = stopBeforeOf(options.stopBefore) ?? STRICTEST;
51    if (found === null || rank(value) < rank(found)) found = value;
52  }
53  return found;
54}
55
56// The local file is the user's own and gitignored, so it may set any rung. A
57// committed project file only stops earlier than the user's own value.
58export function effective(
59  user: StopBefore | null,
60  project: StopBefore | null,
61  local: StopBefore | null,
62): Ladder {
63  if (local !== null) return { stopBefore: local, source: 'local' };
64  // Claude Code fills in the option's default, so a user value equal to it
65  // cannot be told from no value at all.
66  const mine: Ladder =
67    user === null || user === DEFAULT_STOP
68      ? { stopBefore: DEFAULT_STOP, source: 'default' }
69      : { stopBefore: user, source: 'user' };
70  if (project !== null && rank(project) < rank(mine.stopBefore)) {
71    return { stopBefore: project, source: 'project' };
72  }
73  return mine;
74}
75
76// Whether the step needs the user's request under this ladder.
77export function asks(ladder: Ladder, step: Step): boolean {
78  return rank(STEP_RUNG[step]) >= rank(ladder.stopBefore);
79}
80
81export function where(source: Source): string {
82  if (source === 'default') return 'the default';
83  if (source === 'project') return '.claude/settings.json';
84  if (source === 'local') return '.claude/settings.local.json';
85  return '/config';
86}
87
hooks/ledger.ts 343 lines
1// The findings ledger: what earlier review cycles in a repository settled
2// without fixing, carried into the next cycle's briefs so reviewers do not
3// re-litigate it. Pure.
4//
5// A fixed finding is never an entry: the code changed, so raising it again
6// means it came back. An entry leaves the ledger when a cycle resolves it, when
7// its file is gone, or when newer entries push it past the cap. One settled
8// longer ago than EXPIRY_DAYS reads as stale, so a cycle judges it again.
9
10export const KINDS = ['deferred', 'rebutted', 'left-alone', 'question'] as const;
11export type Kind = (typeof KINDS)[number];
12
13export type Entry = {
14  id: string;
15  path: string;
16  line: number | null;
17  kind: Kind;
18  finding: string;
19  reason: string;
20  source: string;
21  // The path's blob in the working tree the latest cycle to carry it reviewed,
22  // so a reader can tell whether the file changed since.
23  blob: string;
24  // When a cycle last settled it; carrying it forward does not move this.
25  date: string;
26};
27
28export type NewEntry = Omit<Entry, 'id' | 'blob' | 'date'>;
29
30// One repository's ledger as read. `unreadable` counts what this version could
31// not write back unchanged, such as entries a newer version wrote; the next
32// record drops them and says how many.
33export type Stored = { entries: Entry[]; unreadable: number; updated: number };
34
35// The store holds MAX_REPOS ledgers in 4 MiB of JSON. A byte budget, not an
36// entry count: entries run near 470 bytes but can reach about 10 KB.
37export const MAX_LEDGER_BYTES = 384 * 1024;
38export const MAX_TEXT = 400;
39export const MAX_PATH = 1024;
40export const MAX_REPOS = 10;
41export const EXPIRY_DAYS = 90;
42export const PREFIX = 'ledger:';
43
44const ENTRY_KEYS = [
45  'id',
46  'path',
47  'line',
48  'kind',
49  'finding',
50  'reason',
51  'source',
52  'blob',
53  'date',
54] as const;
55const BLOB = /^([0-9a-f]{40}|[0-9a-f]{64})$/;
56const DATE = /^\d{4}-\d{2}-\d{2}$/;
57
58// The store refuses a key over 256 characters.
59export const MAX_KEY = 256;
60
61// FNV-1a from `basis`, as 8 hex digits.
62function fnv(text: string, basis: number): string {
63  let h = basis;
64  for (const ch of text) {
65    h ^= ch.codePointAt(0) ?? 0;
66    h = Math.imul(h, 0x01_00_01_93) >>> 0;
67  }
68  return h.toString(16).padStart(8, '0');
69}
70
71// A path too long for the store is keyed by its hash. `common` is absolute, so
72// a hashed key (`#`) never equals a path's own.
73export function keyOf(common: string): string {
74  const key = `${PREFIX}${common}`;
75  if (key.length <= MAX_KEY) return key;
76  return `${PREFIX}#${fnv(common, 0x81_1c_9d_c5)}${fnv(common, 0x05_0c_5d_1f)}`;
77}
78
79// The same finding at the same path gets the same id in every cycle.
80export function entryId(path: string, finding: string): string {
81  return fnv(`${path}\0${finding}`, 0x81_1c_9d_c5);
82}
83
84// Never cuts a surrogate pair in half.
85function clip(s: string): string {
86  const t = s.trim().replaceAll(/\s+/g, ' ');
87  if (t.length <= MAX_TEXT) return t;
88  const head = t.slice(0, MAX_TEXT - 1);
89  return `${/[\uD800-\uDBFF]$/.test(head) ? head.slice(0, -1) : head}…`;
90}
91
92function isKind(k: unknown): k is Kind {
93  return typeof k === 'string' && (KINDS as readonly string[]).includes(k);
94}
95
96function text(v: unknown): string | null {
97  return typeof v === 'string' && v.trim() !== '' ? v : null;
98}
99
100function normalPath(p: string): string {
101  return p.trim().replace(/^(\.\/)+/, '');
102}
103
104// A path as git's changed-path list spells it, or why it is not one: Phase 3
105// selects entries by that list, so any other spelling is never read back.
106function pathOf(v: unknown): string | { error: string } {
107  if (typeof v !== 'string') return { error: 'path is required' };
108  if (/\p{Cc}/u.test(v)) return { error: 'path holds a control character' };
109  const p = normalPath(v);
110  if (p === '') return { error: 'path is empty' };
111  if (p.length > MAX_PATH) return { error: `path is over ${MAX_PATH} characters` };
112  // Normalizing again must change nothing, or the stored path reads back as
113  // another one.
114  if (normalPath(p) !== p) return { error: `path starts or ends with whitespace: ${p}` };
115  const segments = p.split(/[/\\]/);
116  if (/^[A-Za-z]:/.test(p) || segments.some((s) => s === '' || s === '.' || s === '..')) {
117    return { error: `path must be repository-relative, as git spells it: ${p}` };
118  }
119  return p;
120}
121
122// One finding as the record tool takes it, or its first fault.
123function newEntryOf(raw: unknown): NewEntry | { error: string } {
124  const e = (raw ?? {}) as Record<string, unknown>;
125  if (e.kind === 'fixed') {
126    return {
127      error:
128        'a fixed finding is not recorded; pass the id of the entry it settled in `resolve` instead',
129    };
130  }
131  if (!isKind(e.kind)) return { error: `kind must be one of ${KINDS.join(', ')}` };
132  const path = pathOf(e.path);
133  if (typeof path !== 'string') return path;
134  const finding = text(e.finding);
135  const reason = text(e.reason);
136  const source = text(e.source);
137  if (!finding || !reason || !source) {
138    return { error: 'finding, reason and source are all required' };
139  }
140  const line = e.line ?? null;
141  if (line !== null && !(Number.isSafeInteger(line) && (line as number) > 0)) {
142    return { error: 'line must be a positive integer' };
143  }
144  return {
145    path,
146    line: line as number | null,
147    kind: e.kind,
148    finding: clip(finding),
149    reason: clip(reason),
150    source: clip(source),
151  };
152}
153
154// A stored entry, or null unless this version would write it back exactly as
155// stored: every key known, every value one the record tool produces.
156function storedEntryOf(raw: unknown): Entry | null {
157  if (typeof raw !== 'object' || raw === null) return null;
158  const r = raw as Record<string, unknown>;
159  if (Object.keys(r).some((k) => !(ENTRY_KEYS as readonly string[]).includes(k))) return null;
160  const n = newEntryOf(r);
161  if ('error' in n) return null;
162  const { blob, date } = r;
163  if (typeof blob !== 'string' || !BLOB.test(blob)) return null;
164  if (typeof date !== 'string' || !DATE.test(date)) return null;
165  const e: Entry = { id: entryId(n.path, n.finding), ...n, blob, date };
166  return ENTRY_KEYS.every((k) => e[k] === r[k]) ? e : null;
167}
168
169// The `updated` stamp of any stored value, ledger or not, so pruning orders a
170// ledger this version cannot read by when it was written.
171export function updatedOf(value: unknown): number {
172  const u = (value as { updated?: unknown } | null | undefined)?.updated;
173  return typeof u === 'number' ? u : 0;
174}
175
176// Whatever the store holds under one repository's key.
177export function storedOf(value?: unknown): Stored {
178  if (value === undefined) return { entries: [], unreadable: 0, updated: 0 };
179  const updated = updatedOf(value);
180  const v = (value ?? {}) as { entries?: unknown; updated?: unknown };
181  const known = new Set(['entries', 'updated']);
182  if (
183    typeof value !== 'object' ||
184    value === null ||
185    !Array.isArray(v.entries) ||
186    typeof v.updated !== 'number' ||
187    Object.keys(value).some((k) => !known.has(k))
188  ) {
189    // Every entry is lost with the ledger around it, so each one counts.
190    const lost = Array.isArray(v.entries) ? v.entries.length : 0;
191    return { entries: [], unreadable: Math.max(1, lost), updated };
192  }
193  const entries: Entry[] = [];
194  const ids = new Set<string>();
195  let unreadable = 0;
196  for (const raw of v.entries) {
197    const e = storedEntryOf(raw);
198    if (e && !ids.has(e.id)) {
199      ids.add(e.id);
200      entries.push(e);
201    } else {
202      unreadable++;
203    }
204  }
205  return { entries, unreadable, updated };
206}
207
208// `keep` carries entries a cycle settled again unchanged: each is stamped with
209// its file as that cycle reviewed it, and its date stays.
210export type Recording = { add: NewEntry[]; resolve: string[]; keep: string[] };
211
212function ids(v: unknown): v is string[] {
213  return Array.isArray(v) && v.every((r) => typeof r === 'string' && /^[0-9a-f]{8}$/.test(r));
214}
215
216// The record tool's input, whole or not at all: a batch half-written is
217// harder to read back than one refused with its first fault named.
218export function parseRecord(input: unknown): Recording | { error: string } {
219  const i = (input ?? {}) as { entries?: unknown; resolve?: unknown; keep?: unknown };
220  const rawEntries = i.entries ?? [];
221  const rawResolve = i.resolve ?? [];
222  const rawKeep = i.keep ?? [];
223  if (!Array.isArray(rawEntries)) return { error: '`entries` must be an array' };
224  if (!ids(rawResolve)) return { error: '`resolve` must be an array of entry ids' };
225  if (!ids(rawKeep)) return { error: '`keep` must be an array of entry ids' };
226  const add: NewEntry[] = [];
227  for (const [n, raw] of rawEntries.entries()) {
228    const e = newEntryOf(raw);
229    if ('error' in e) return { error: `entries[${n}]: ${e.error}` };
230    add.push(e);
231  }
232  if (add.length === 0 && rawResolve.length === 0 && rawKeep.length === 0) {
233    return { error: 'nothing to record: pass `entries`, `keep` or `resolve`' };
234  }
235  return { add, resolve: rawResolve, keep: rawKeep };
236}
237
238export type Merged = {
239  entries: Entry[];
240  added: number;
241  updated: number;
242  resolved: number;
243  kept: number;
244  // Kept entries whose file is gone from the working tree, and so dropped.
245  gone: number;
246  // Ids in `resolve` or `keep` that named no entry.
247  unknown: string[];
248  evicted: number;
249};
250
251// Resolves first, then keeps, then adds, so a batch that resolves an entry and
252// records the same finding again keeps the new one. A kept or re-recorded
253// entry moves to the newest end, so eviction takes what no cycle has touched
254// longest. `blobs` must hold every added path; a kept entry whose path it
255// lacks is gone.
256export function merge(
257  existing: Entry[],
258  rec: Recording,
259  blobs: ReadonlyMap<string, string>,
260  date: string,
261): Merged {
262  const byId = new Map(existing.map((e) => [e.id, e]));
263  const unknown: string[] = [];
264  let resolved = 0;
265  for (const id of rec.resolve) {
266    if (byId.delete(id)) resolved++;
267    else unknown.push(id);
268  }
269  let kept = 0;
270  let gone = 0;
271  for (const id of rec.keep) {
272    const e = byId.get(id);
273    if (!e) {
274      unknown.push(id);
275      continue;
276    }
277    byId.delete(id);
278    const blob = blobs.get(e.path);
279    if (blob === undefined) {
280      gone++;
281    } else {
282      byId.set(id, { ...e, blob });
283      kept++;
284    }
285  }
286  let added = 0;
287  let updated = 0;
288  for (const n of rec.add) {
289    const blob = blobs.get(n.path);
290    if (blob === undefined) throw new Error(`no blob for ${n.path}`);
291    const id = entryId(n.path, n.finding);
292    if (byId.delete(id)) updated++;
293    else added++;
294    byId.set(id, { id, ...n, blob, date });
295  }
296  const all = [...byId.values()];
297  const sizes = all.map((e) => utf8Bytes(JSON.stringify(e)) + 1);
298  let size = sizes.reduce((n, s) => n + s, 0);
299  let evicted = 0;
300  while (evicted < all.length && size > MAX_LEDGER_BYTES) size -= sizes[evicted++] ?? 0;
301  return { entries: all.slice(evicted), added, updated, resolved, kept, gone, unknown, evicted };
302}
303
304export function utf8Bytes(s: string): number {
305  let n = 0;
306  for (const ch of s) {
307    const c = ch.codePointAt(0) ?? 0;
308    n += c < 0x80 ? 1 : c < 0x8_00 ? 2 : c < 0x1_00_00 ? 3 : 4;
309  }
310  return n;
311}
312
313export function isStale(date: string, now: number): boolean {
314  const [y, m, d] = date.split('-').map(Number);
315  return now - Date.UTC(y ?? 0, (m ?? 1) - 1, d ?? 1) > EXPIRY_DAYS * 86_400_000;
316}
317
318export function select(entries: Entry[], paths?: readonly string[]): Entry[] {
319  if (paths === undefined) return entries;
320  const want = new Set(paths.map((p) => normalPath(p)));
321  return entries.filter((e) => want.has(e.path));
322}
323
324// `git ls-tree -r -z` output as path to blob id.
325export function blobsOf(lsTree: string): Map<string, string> {
326  const blobs = new Map<string, string>();
327  for (const rec of lsTree.split('\0')) {
328    const m = /^\d+ blob ([0-9a-f]+)\t(.+)$/s.exec(rec);
329    if (m?.[1] && m[2]) blobs.set(m[2], m[1]);
330  }
331  return blobs;
332}
333
334// The other repositories' ledgers to drop so that, with this one, the store
335// holds at most MAX_REPOS: the least recently updated go first.
336export function staleKeys(
337  ledgers: readonly { key: string; updated: number }[],
338  keep: string,
339): string[] {
340  const others = ledgers.filter((l) => l.key !== keep).toSorted((a, b) => a.updated - b.updated);
341  return others.slice(0, Math.max(0, others.length - (MAX_REPOS - 1))).map((l) => l.key);
342}
343