diff --git a/.agents/skills/land-pr b/.agents/skills/land-pr new file mode 120000 index 0000000..1718979 --- /dev/null +++ b/.agents/skills/land-pr @@ -0,0 +1 @@ +../../.claude/skills/land-pr \ No newline at end of file diff --git a/.agents/skills/open-pr b/.agents/skills/open-pr new file mode 120000 index 0000000..ec53132 --- /dev/null +++ b/.agents/skills/open-pr @@ -0,0 +1 @@ +../../.claude/skills/open-pr \ No newline at end of file diff --git a/.claude/skills/land-pr/.pr-kit b/.claude/skills/land-pr/.pr-kit new file mode 100644 index 0000000..c2f0c29 --- /dev/null +++ b/.claude/skills/land-pr/.pr-kit @@ -0,0 +1,2 @@ +Installed by the PR kit (open-pr/kit.ts). Edit the kit at its source, not here. +version 24 b18d559e4c1c3dece07b9486b5418a2cff7cad63e65a328bf4c11db8be7f1c44 diff --git a/.claude/skills/land-pr/SKILL.md b/.claude/skills/land-pr/SKILL.md new file mode 100644 index 0000000..c886433 --- /dev/null +++ b/.claude/skills/land-pr/SKILL.md @@ -0,0 +1,62 @@ +--- +name: land-pr +description: Drive an open pull request to merge-ready (reviewed, CI green, comments resolved, spec fidelity checked, review bot passed on the head), then merge only on Ossie's word. Owns the review-bot pass signal, the convergence rule for fix rounds, spec fidelity, and merge proof. Use after open-pr, for "babysit", "get it green", "address the review comments", or any `gh pr merge`. +metadata: + category: git/pr + lanes: [claude, codex, pi] + machines: [universal] + requires: [bun, git, gh] +--- + +# land-pr + +The PR gate refuses a merge unless the PR body passes the contract and the review bot passed the exact head. This skill gets the PR to merge-ready, the point where Ossie can say "merge". Merging is a separate step that takes his word. + +## Merge policy + +| Condition | Rule | +|---|---| +| Pass signal | The `pullfrog-approval` check-run from the Pullfrog app concludes `success` on the exact head SHA. Missing, running, failed, or stale verdicts do not pass, and neither does a second run of that name or one from another app. The kit's source repo is Ossie's curated settings repo and is never gated by a review bot. | +| Source repo merges | Name the repo in the merge command: `-R /` or the PR URL. The gate grants the exemption only then. | +| Conflict fix | When the bot approved the previous head and the only new commit merges the base to resolve conflicts, prove the resolution and merge on that approval. The gate allows this case. | +| CI | Required checks pass on the same head. | +| Threads | Fix actionable findings. Resolve bot threads with evidence. Resolve or disposition every other thread. | +| Authorization | Merge only on Ossie's word: an explicit "merge", or a request whose wording implies it ("ship it", "get it merged", "merge when green"). Without it, stop at merge-ready and report the head SHA, CI, bot verdict, threads, and spec fidelity. Never merge when the repo's AGENTS.md forbids agent merges. | +| Other bots | Open Code Review, CodeRabbit, Greptile, and Codex are advisory unless branch rules require them. | + +## Convergence rule + +A stale PR costs more than one more round. Keep fixing while each round makes progress. + +- Fix only confirmed, in-scope blockers. Verify each bot finding against the source first. Dismiss a false positive with a written reason. +- Count the reviews each open finding has been through. A push or a restart does not reset the count. +- The loop spins when one finding is still open at its fourth review, after three fixes. +- The first spin means the approach is wrong, not the patch. Throw out the fix, redesign from the original problem, and push the new approach as the next round. Do not patch the patch. +- If the redesign spins too, stop. Open an issue that names the root cause, link it in the PR, and report. The cause is usually a miss in the local review, or a disagreement between the local review and Pullfrog. A spinning loop is a bug to fix, not a reason to leave the PR. +- When a verdict never starts (no `pullfrog-approval` check-run on the head after 60 minutes), stop and report. That is a runner or app fault, not a review round. + +## Steps + +1. **Read the state.** `gh api repos/{owner}/{repo}/pulls/` for the head SHA and draft flag, then `gh api repos/{owner}/{repo}/commits//check-runs` for CI and `pullfrog-approval`. In a cloud session use REST only, because the proxy blocks GraphQL. +2. **Fix CI.** Read the failing log (`gh run view --log-failed`). Tell a real break from a known infra flake. Fix real breaks in a new commit. +3. **Handle review comments.** Fetch them with `gh api repos/{owner}/{repo}/pulls//comments` and `.../reviews`. Fix, decline with a reason, or defer to an issue. Add each to the PR body's Review table. + - Mark each Pullfrog finding that the local review missed with `missed locally` in its Reason cell, so the local review instructions can learn from it. + - List unresolved threads with variables. Set `owner`, `repo`, and `pr` from the PR, then copy the returned thread ID into `thread_id`. Pagination includes threads after the first 50. + + ```bash + gh api graphql --paginate -f query='query($owner:String!,$repo:String!,$n:Int!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:50,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved comments(first:1){nodes{databaseId}}}}}}}' -f owner="$owner" -f repo="$repo" -F n="$pr" --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)' + gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id="$thread_id" + ``` + +4. **Wait on the real state change.** Watch the check-run, not a timer. Count the round and apply the convergence rule. + - When a T3 watch is the wake source and Bash supports `run_in_background`, also arm one bounded `until` loop on the head's `pullfrog-approval` check-run as a backstop. +5. **Check spec fidelity before merge.** Diff the result against the issue, the plan, or the mock. Post every deviation, or "no deviations", as a PR comment. +6. **Merge, on Ossie's word.** Without it, stop here and report merge-ready. With it, first check allowed methods with `gh api repos/{owner}/{repo} --jq '{allow_squash_merge,allow_merge_commit,allow_rebase_merge}'`. Follow the repo's required method if allowed. Otherwise fetch main and run `git log origin/main --first-parent -10 --format='%h %p %s'`. Two parents show a merge commit. Squash and rebase history can look the same. Use an allowed method supported by policy or history, and prefer squash when neither names one. Stop if the evidence conflicts with the settings. Run `gh pr merge ` alone in its call, with `-R /` when the current directory is not that repo's checkout. The gate checks a merge only when nothing runs before it but a literal `cd` or a `set` of `-e`, `-u`, `-x`, or `-o pipefail`, and nothing runs beside or after it, not even a pipe into a filter, since any other command can move the head. The merge is the only command in its call, so `git pull` or `git checkout main` runs in a later call. The gate checks the policy again. +7. **Prove the merge.** Only `gh api repos/{owner}/{repo}/pulls/ --jq .merged` returning `true` proves it. A tool's success message does not. + +## Never + +- Never pass `--no-verify`, skip a hook, or bypass the gate. Fix the cause or report the blocker. +- Never force-push, amend, or rebase a branch others fetched without Ossie's yes for that push. +- Never write `CHANGELOG.md` on a PR branch. The release on main writes it. +- Never open or leave a PR as a draft. Drafts get no review-bot coverage. diff --git a/.claude/skills/land-pr/merge.ts b/.claude/skills/land-pr/merge.ts new file mode 100644 index 0000000..591c33e --- /dev/null +++ b/.claude/skills/land-pr/merge.ts @@ -0,0 +1,89 @@ +/** + * The merge decision: may this PR head merge, judged from the review bot's check-runs. + * Pure, so the gate and tests share it. The gate fetches the state over the REST API. + * + * Pullfrog is the active review bot. Its `pullfrog-approval` check-run concluding + * `success` on the exact head is the pass signal. The gate reads the latest run per check + * suite, and Pullfrog has one suite per commit, so its reruns collapse to one run. A second + * run of that name, or a run from another app, means another producer used the name, and + * no verdict on that commit is trusted. One exception: when the bot approved + * the previous head and the head is a merge commit on top of it (a conflict fix), the + * approval carries forward, provided the merged-in parent is on the base branch and no + * finished verdict on the head itself says otherwise. + * + * The kit's source repo is Ossie's curated settings repo, and no review bot gates it. The + * list lives here in the kit, never in a file the target repo could edit. It holds SHA-256 + * digests of lowercase `owner/name`, so the kit can ship to public repos without naming a + * private one. The exemption holds only when the merge command names the repo itself, + * because a repo guessed from the cwd or the environment can differ from the one gh merges. + */ + +export const PASS_SIGNAL = "pullfrog-approval"; +/** Slug of the GitHub App that publishes PASS_SIGNAL. */ +export const PASS_APP = "pullfrog"; + +const BOT_EXEMPT = new Set([ + "6159f38c55dea314c96c15a2b5ddf2edd32a5b51019c9d0b4521e2ff077c0815", + "8425c2e91b73ef6b8e23370c43b9c5d4bb3db376684cbadcc131c269dff47bbe", +]); + +/** True for a repo that no review bot gates. `repo` is `owner/name`, any case. */ +export const botExempt = (repo: string): boolean => + BOT_EXEMPT.has(new Bun.CryptoHasher("sha256").update(repo.toLowerCase()).digest("hex")); + +export interface CheckRun { + name: string; + status: string; + conclusion: string | null; + /** Slug of the GitHub App that created the run. */ + app: string; +} + +export interface HeadState { + /** The PR's base repo as GitHub reports it, `owner/name`. */ + repo: string; + /** The merge command named the repo with `-R`, a PR URL, or a literal REST path. */ + pinned: boolean; + sha: string; + parents: string[]; + /** Latest check-runs named PASS_SIGNAL on the head. */ + runs: CheckRun[]; + /** Latest check-runs named PASS_SIGNAL on parents[0]. Fetched only for a merge commit. */ + parentRuns?: CheckRun[]; + /** For a merge commit: parents[1] is the base branch head or one of its ancestors. */ + mergesBase?: boolean; +} + +export type MergeVerdict = + | { ok: true; via: "approval" | "conflict-fix" | "exempt" } + | { ok: false; reason: string }; + +/** + * The bot's current verdict on one commit. `open` (missing or running) lets a conflict fix + * inherit its parent's approval. `refused` (finished non-success, or untrusted) never does. + */ +export type Verdict = { state: "pass" } | { state: "open" | "refused"; why: string }; + +export function verdictOf(runs: CheckRun[]): Verdict { + const named = runs.filter((r) => r.name === PASS_SIGNAL); + if (named.length === 0) return { state: "open", why: `no ${PASS_SIGNAL} check-run exists` }; + if (named.length > 1) { + return { state: "refused", why: `${named.length} latest ${PASS_SIGNAL} check-runs exist (apps: ${named.map((r) => r.app).join(", ")}), so the verdict is ambiguous` }; + } + const [run] = named; + if (run.app !== PASS_APP) return { state: "refused", why: `${PASS_SIGNAL} comes from the ${run.app} app, not ${PASS_APP}` }; + if (run.status !== "completed") return { state: "open", why: `${PASS_SIGNAL} is ${run.status}` }; + return run.conclusion === "success" ? { state: "pass" } : { state: "refused", why: `${PASS_SIGNAL} concluded ${run.conclusion}` }; +} + +export function decideMerge(head: HeadState): MergeVerdict { + if (botExempt(head.repo)) { + if (head.pinned) return { ok: true, via: "exempt" }; + return { ok: false, reason: `${head.repo} skips the review bot only when the merge names it. Pass -R ${head.repo} or the PR URL.` }; + } + const verdict = verdictOf(head.runs); + if (verdict.state === "pass") return { ok: true, via: "approval" }; + const conflictFix = verdict.state === "open" && head.parents.length === 2 && head.mergesBase === true; + if (conflictFix && head.parentRuns && verdictOf(head.parentRuns).state === "pass") return { ok: true, via: "conflict-fix" }; + return { ok: false, reason: `${verdict.why} on head ${head.sha.slice(0, 7)}. A missing, running, failed, or untrusted verdict does not pass.` }; +} diff --git a/.claude/skills/open-pr/.pr-kit b/.claude/skills/open-pr/.pr-kit new file mode 100644 index 0000000..c2f0c29 --- /dev/null +++ b/.claude/skills/open-pr/.pr-kit @@ -0,0 +1,2 @@ +Installed by the PR kit (open-pr/kit.ts). Edit the kit at its source, not here. +version 24 b18d559e4c1c3dece07b9486b5418a2cff7cad63e65a328bf4c11db8be7f1c44 diff --git a/.claude/skills/open-pr/SKILL.md b/.claude/skills/open-pr/SKILL.md new file mode 100644 index 0000000..034cf15 --- /dev/null +++ b/.claude/skills/open-pr/SKILL.md @@ -0,0 +1,118 @@ +--- +name: open-pr +description: Open every pull request through this skill. It self-reviews the diff, proves the change, measures the merge danger, and writes the fixed PR body (Problem, Change, Proof, Review, Merge danger) that the PR gate and the pr-kit CI check require. Use before any `gh pr create`, `gh api .../pulls`, or PR body edit. +metadata: + category: git/pr + lanes: [claude, codex, pi] + machines: [universal] + requires: [bun, git, gh] +--- + +# open-pr + +The PR body is a briefing for a reviewer who has one minute. A gate checks the body on create, edit, and merge, and the `pr-kit` CI check checks it again. A body that breaks the contract does not open. + +## Steps + +1. **Rebase.** Fetch the base and rebase onto it. Stop on a conflict you cannot prove correct. +2. **Self-review with the review-gate skill.** Its pre-push receipt already needs a second-model review, so that review is the only one. Do not run `/code-review` as well. + - Run the checks: `bun ~/.agents/skills/review-gate/scripts/review-receipt.ts run`. + - Review the diff against the merge base with the reviewer that review-gate step 3 names. Give the reviewer what Pullfrog reads in this repo: the repo's AGENTS.md (and CLAUDE.md when AGENTS.md is absent), plus any file under `.github/pullfrog/`. A local pass then predicts a Pullfrog pass. + - Give every finding one disposition: **Fix** (fix it now, then review again), **Decline** (write why), or **Defer** (open an issue and link it). + - A Decline that states a fact about the code must cite the `path:line` or command that proved it in its Reason cell. + - Record it: `bun ~/.agents/skills/review-gate/scripts/review-receipt.ts review --skill --act-on N --closed N --notes ""`. + - The receipt stores only the counts. Fill the Review table from that same review, one row per finding, so the rows match the receipt. + - Where the review-gate skill is absent, as in a cloud session, give one fresh-context reviewer the same inputs and skip the receipt commands. +3. **Prove it.** Show evidence that could fail, best first. Evidence that passes while the real goal fails is a Goodhart test, so prove the behavior a user sees, not a mock. + - For a bug fix, reproduce the reported behavior on the base first and keep it as the before. Check the report against what really happens, since it may not be exact. + - For a UI or API change, walk the real user path on the base and on the branch, and show the before and after: a screenshot pair or a 15 to 45 second walkthrough for UI, the request and response for an API. Use the repo's recorder script when it has one. + - For a performance change, a chart or table of the before and after numbers. + - The test that fails on the base and passes on the branch. Quote the failure. + - Command output in a code block. +4. **Measure the merge danger.** + - **Door.** Two-way when a revert undoes it. One-way when it deletes something you didn't make (dead entries that point at nothing stay two-way), deletes data or work that cannot be put back, migrates a schema, publishes, sends, deploys to users, spends money, changes prod user data, or changes a contract that others consume. The door rates the change, not the merge. Merge always waits for the owner's word (see `land-pr`). + - **Blast radius.** Find what the change can break outside the diff: callers, consumers, config, jobs, other repos. Prove the one fact that makes it safe by running code, not by reading it. Name the radius in one word: `none`, `low`, `medium`, `high`. +5. **Write the body** to a file with the template below. Write in ASD-STE100: + - Start with the problem. No preamble, no "This PR". + - One topic per sentence, at most 25 words. Active voice, present tense. + - Periods and commas. No em dashes. + - Problem uses at most four sentences. +6. **Check the body.** Run `bun /check.ts body.md --title ""`. Fix every violation it names. +7. **Open the PR ready for review.** Never a draft. + - `gh pr create --title "<title>" --body-file body.md --base <base>` + - Run the push in its own call before the create, since the alone rule refuses `git push && gh pr create ...`. + - To write the body in the same call, put the heredoc first and join it with `&&`: `cat > body.md <<'EOF' && gh pr create --title "<title>" --body-file body.md --base <base>`, with the body lines and `EOF` after it. Under noclobber `cat` fails on an existing file, and only `&&` stops gh from then reading the old file. Use `cat >| body.md` when the file already exists. + - Run it as the only command in its call. The gate checks a PR write only when it runs alone: before it, at most a literal `cd`, a `set` of `-e`, `-u`, `-x`, or `-o pipefail` (other options can change what `cd` or a redirect does), and one `cat > body.md <<'EOF'` joined by `&&` that writes the body file it reads. After it, at most a pipe into a read-only filter such as `tail` or `jq`, with no option or redirect that writes a file. The pipe must follow `gh` itself, not a `bash -c` or `ssh` that runs it, and `tee` is not a filter. With `--body-file`, any other command in the call, such as `git push && gh pr create ...`, a background `&`, a subshell, or an output redirect to a file, makes the gate refuse it. A body passed inline may follow other commands, but not one joined by `||`. + - In a cloud session, `gh pr` can fail with a 403 because the proxy blocks GraphQL. Use REST: `gh api repos/{owner}/{repo}/pulls -f title="<title>" -F body=@body.md -f head=<branch> -f base=<base>`. + - Attach screenshots and videos with `gh pr create|edit --attach <file>`. It needs gh 2.99 or newer. Run `gh --version` first. + - For a remote checkout, prefer an agent session on that machine, in that checkout, so the gate and `gh` share one filesystem. + - When `gh` runs through `ssh`, put the remote command in single quotes and pass the body inline with a quoted heredoc. Inside single quotes, quote the delimiter with double quotes: `<<"EOF"`. + - The gate refuses text a shell changes after the gate reads it. That is an unquoted heredoc with `$` or backquotes, and any `$` or backquote in a double-quoted ssh command. The local shell expands the second kind before ssh runs, even under a quoted heredoc. The gate also refuses remote `--body-file` paths, even when the file exists there. It refuses a `gh pr` or `gh api` PR write whose method, a flag, or a field name comes from the shell, such as `-X "$M"` or `"$DRAFT_FLAG"`, since any request could hide in it. Write those as text. It also refuses an unquoted `$...` anywhere in that command, even after `--jq`, because the shell can split it into more flags. Double-quote it: `--jq "$Q"`. + - After `--attach`, move any appended image links into Proof and keep `Changes made by ...` last. Check the body again before submitting the edit. +8. **Read it back.** `gh api repos/{owner}/{repo}/pulls/<n>` and confirm base, body, and draft state. Then hand over to the `land-pr` skill. + +## Install the kit + +A repo without the kit gets it in its own PR, opened with this skill. Use the first source you have: + +- The fleet store: `bun ~/.agents/skills/open-pr/kit.ts install <repo>` +- A repo that already has the kit: `bun .claude/skills/open-pr/kit.ts install <repo>` +- Neither: download the two skill folders with `gh` from a repo that carries the kit in `.claude/skills/`, then run `kit.ts` from them. Set `src` to that repo. + + ```bash + repo=/path/to/repo + (set -e; kit=$(mktemp -d); trap 'rm -rf "$kit"' EXIT + src=repos/OWNER/REPO/contents/.claude/skills + for s in open-pr land-pr; do + mkdir "$kit/$s" + gh api "$src/$s?ref=main" --jq '.[] | select(.type == "file") | .name' >"$kit/$s.files" + while read -r f; do gh api -H "Accept: application/vnd.github.raw" "$src/$s/$f?ref=main" >"$kit/$s/$f"; done <"$kit/$s.files" + done + bun "$kit/open-pr/kit.ts" install "$repo") + ``` + +The runner label comes from `--runs-on <label>`, else the label in the installed `pr-kit.yml`, else the one self-hosted label the repo's other workflows use most. The kit refuses before writing anything when it finds none, a tie, or only a hosted label. Pass `--runs-on` then. A hosted label such as `ubuntu-24.04` is your call to make, never the kit's. A second run keeps the label and prints "PR kit already current". + +Every copy of the kit carries a `VERSION` file, and each installed skill's `.pr-kit` marker records the version it installed. An install refuses before writing anything when it runs from a copy behind `kits/pr` in the kit's source repo: the target repo already has a newer version or the same version with other files, the source repo's main has a different `VERSION`, newer, older, or the same one with other files, or the copy's files do not match its own `VERSION`. There is no override. Update the copy with `sk push` or a pull of the kit's source repo, then rerun. After a kit change, run `bun kit.ts stamp` in `kits/pr/open-pr` and commit `VERSION` with the change. `bun test` fails until you do. `stamp` refuses in any other copy, so an installed or synced copy is updated, never restamped. + +The gate hook uses `onFailure: "block"`, so a gate that times out or cannot start refuses the command instead of letting it run. + +## Template + +The title is a conventional commit: `fix(web): new threads no longer spike CPU`. An emoji prefix is allowed when the repo uses one. + +```markdown +## Problem +One to four sentences. What is wrong, for whom, and where. + +## Change +The smallest visual of the change: a shape-matched diff, a call tree, a file tree, or pseudocode. One sentence above it when the visual needs a caption. + +## Proof +| Check | Before | After | +|---|---|---| +| test_name | fails: <quoted error> | passes | + +Screenshot pair or walkthrough link when the change is visible. + +## Review +| Finding | Disposition | Reason | +|---|---|---| +| The finding in one line | Fix / Decline / Defer #N | Why, with path:line or a proof command for a Decline about code | + +## Merge danger +Door: two-way. Revert this PR to undo it. +Blast radius: low. What outside the diff could break, and the fact that proves it safe. + +Closes #N +Changes made by <model> via <harness> +``` + +Rules the check enforces: + +- `## Problem` is the first line. The five sections appear in this order, and none is empty. Extra sections such as `## Deviations` may sit between them. +- Proof holds a table, a code block, or a link. +- Code blocks use backtick or tilde fences. A heading inside one is not a section. +- Review holds a table with a `Disposition` column, and each row starts its disposition with `Fix`, `Decline`, or `Defer #<issue>`. Write `No findings.` only when it is the whole section, after a real review. +- Merge danger holds a `Door:` line and a `Blast radius:` line. +- The last line is exactly `Changes made by <model> via <harness>`, for example `Changes made by Claude Opus 5.5 via Claude Code`. diff --git a/.claude/skills/open-pr/VERSION b/.claude/skills/open-pr/VERSION new file mode 100644 index 0000000..e3a392f --- /dev/null +++ b/.claude/skills/open-pr/VERSION @@ -0,0 +1 @@ +24 b18d559e4c1c3dece07b9486b5418a2cff7cad63e65a328bf4c11db8be7f1c44 diff --git a/.claude/skills/open-pr/check.ts b/.claude/skills/open-pr/check.ts new file mode 100644 index 0000000..60bb2a3 --- /dev/null +++ b/.claude/skills/open-pr/check.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun +/** + * Checks a PR body against the contract. The same rules as the gate and CI. + * + * bun check.ts body.md [--title "fix(x): y"] [--draft] # before opening a PR + * bun check.ts --event "$GITHUB_EVENT_PATH" # in the pr-kit workflow + * + * Exit 0 passes, exit 1 lists every violation with its fix. + */ + +import { readFileSync } from "node:fs"; +import { parseArgs } from "node:util"; +import { checkPullRequest, formatViolations, type PullRequest } from "./contract"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { title: { type: "string" }, draft: { type: "boolean" }, event: { type: "string" } }, +}); + +function fromEvent(path: string): PullRequest { + const event: unknown = JSON.parse(readFileSync(path, "utf8")); + const pr = typeof event === "object" && event !== null && "pull_request" in event ? event.pull_request : undefined; + if (typeof pr !== "object" || pr === null) throw new Error(`${path} holds no pull_request payload`); + const fields: Record<string, unknown> = Object.fromEntries(Object.entries(pr)); + const { title, body, draft } = fields; + return { title: typeof title === "string" ? title : "", body: typeof body === "string" ? body : "", draft: draft === true }; +} + +let pr: PullRequest; +if (values.event) { + pr = fromEvent(values.event); +} else if (positionals[0]) { + pr = { title: values.title, body: readFileSync(positionals[0], "utf8"), draft: values.draft }; +} else { + console.error('usage: bun check.ts <body.md> [--title "..."] [--draft] | --event <path>'); + process.exit(1); +} + +const verdict = checkPullRequest(pr); +if (verdict.ok) { + console.log(`PR body contract: pass. Door ${verdict.door}, blast radius ${verdict.blastRadius}.`); +} else { + console.error(`PR body contract: ${verdict.violations.length} violation(s).\n${formatViolations(verdict.violations)}`); + process.exit(1); +} diff --git a/.claude/skills/open-pr/command.ts b/.claude/skills/open-pr/command.ts new file mode 100644 index 0000000..dbcc30f --- /dev/null +++ b/.claude/skills/open-pr/command.ts @@ -0,0 +1,1545 @@ +/** + * Finds pull-request writes inside a Bash command, so the gate can check them. + * Pure: it never reads files or calls gh. The gate resolves file bodies and merge state. + * + * Covered: `gh pr create|edit|merge`, `gh api` POST/PATCH on `repos/O/R/pulls[/N]` and PUT on + * `repos/O/R/pulls/N/merge`, and every `gh api graphql` call whose query is not a literal that provably + * neither merges nor writes a PR. The same holds inside `ssh host '...'`, `bash -c`, and a program a shell + * reads on stdin: its last heredoc or here-string, a pipe from cat or from echo or printf of literal words, + * also into a group or compound opened right after `|`, a heredoc on a closing `)`, `}`, `done`, `fi`, or + * `esac`, or the stdin of the ssh or bash -c it runs in. A descriptor copy onto that stdin, such as `<&3` or + * `< /dev/fd/3`, comes back `opaque`, and `<&0` or `< /dev/stdin` changes nothing. A REST write whose PR number is not literal comes back `unresolved`, + * for the gate to refuse. + * + * The alone rule: a PR write or merge is checked only when it runs alone in its call, and is otherwise + * `crowded`. The gh command sits at the top of its shell, outside any group or compound body, in the + * foreground, first in any pipeline, with no output redirect to a file and no substitution but a cat + * read, or `gh auth token` in an assignment before it, which names no context variable. After it come + * only filters that cannot write a file, in gh's own shell, and none after a merge. Before a write that + * reads a file, or a merge, run only a literal cd, a set of -e, -u, -x, or -o pipefail, and, for a write, + * one `cat > f <<'EOF'` whose file is the only file the body, title, and payload reads open, by that same name, + * joined to gh by `&&`. A write whose text is all inline may follow other commands, but not one joined by `||`. + * A handed-over shell must be alone too, inside and out. + * + * The command is read the way bash reads it: quotes, escapes, comments, redirections as tokens, and + * heredocs as the shell expands them. Text the shell computes comes back marked `expands`, and a PR + * write whose method, flag, field name, or gh name the shell computes, or that holds an unquoted `$...`, + * comes back `opaque`. `"$(cat f)"`, `"$(< f)"`, and `"$(cat <<'EOF' ...)"` are read as text, unless the + * cat copies or closes a descriptor. `~` or `$HOME` at the start of a path is the home directory, unless the + * command can set HOME. A merge carries its GitHub hosts and the head it requires, and drifts when a word + * before gh or a cd the gate cannot follow can change the repo gh picks, or the command can change gh's + * host or config. + * + * Limit: this is a static read of the command text. `eval "gh ..."`, `gh api "$P"`, aliases, shell + * functions, globs, and profile settings are not followed. A script a shell runs as an operand + * (`bash script.sh`), a program a shell reads through a pipe from any other command (`curl ... | bash`), or + * one the outer shell splices an expansion into as an argument, is not read, and the gate allows it, as it + * allows `eval`. A program a shell reads from a `<` or `<>` file is opaque, since a path can reach a heredoc, + * a pipe, or a descriptor, so `bash < script.sh` is refused even when the script holds no PR command. fish, csh, + * tcsh, yash, posh, and Korn shells other than plain ksh (mksh, ksh93, pdksh) take options the gate does not model, + * so one given any option word or any stdin is opaque. + * A word the shell computes where any shell still reads options, as in `bash "$opts" ...`, is opaque, since it can be `-c`. + * So is a command name the shell computes, which can be a shell, when a later word or its stdin holds a PR command. + * A `-c` script or ssh command the outer shell computes is opaque when a command name in it is computed, since that can be gh. + * Output the outer shell computes, piped into a shell, is opaque too, whatever command or group prints it. + */ + +import { ASSIGN, atSource, type AtFiles, type BodySource, CONTEXT, drift, fileKey, type Head, head, KEYWORDS, move, readFile, RUNS_CODE, SETTERS, type Where, type Written } from "./context"; + +export { atSource, fileKey } from "./context"; +export type { AtFiles, BodySource, Where } from "./context"; + +export type PrField = "title" | "draft" | "body"; + +/** A REST field: the text gh sends, and `typed` when `-F` turns true, false, null, or an integer in that text into JSON. */ +export interface FieldValue { + source: BodySource; + typed: boolean; + /** Set when a typed value is a file's text, known only once the gate reads it. gh reads `@path` text through `atSource`. */ + at?: AtFiles; +} + + +interface Base { + /** The command segment, for messages. */ + via: string; + /** Directory the segment runs in: the last top-level `cd` before it, relative to the hook cwd. Exact only when not `crowded`. */ + dir?: string; + /** Set when the segment runs on another machine over ssh. */ + host?: string; + /** `owner/name` when the command names it. */ + repo?: string; + /** The command changed directory in a way the parser cannot follow, so `dir` is unknown. */ + lost?: boolean; + /** The PR command does not run on its own in its call, so another command can change what gh reads or merges. */ + crowded?: true; +} + +export type PrCommand = + | (Base & { kind: "create"; title?: BodySource; draft: boolean; body: BodySource }) + | (Base & { kind: "edit"; title?: BodySource; body?: BodySource }) + /** A REST create or edit whose JSON `request` sets the PR's title, body, and draft. */ + | (Base & { kind: "input"; write: "create" | "edit"; request: BodySource }) + /** A REST create or edit whose `-f`/`-F` flags set the PR's title, body, and draft. */ + | (Base & { kind: "fields"; write: "create" | "edit"; fields: Partial<Record<PrField, FieldValue>> }) + /** + * A PR write the shell computes part of: the method or a word gh could read as flags (`control`), a field name (`field`), or the + * stdin program it runs in (`program`), or a shell whose options the gate does not read (`shell`). Any write could hide in it. + */ + | (Base & { kind: "opaque"; part: "control" | "field" | "program" | "shell" }) + /** + * `pinned`: the command itself names the repo with `-R`, a PR URL, or a literal REST path. `hosts`: every GitHub host it names. + * `match`: the head commit the merge requires, which gh or GitHub checks when it merges. + * `drift`: the gate cannot know the `repo` or PR gh picks from the current directory, or the `host` and config gh uses. + */ + | (Base & { kind: "merge"; target?: string; pinned: boolean; drift?: "repo" | "host"; hosts: string[]; match?: HeadMatch }) + /** `query` is the GraphQL text, or with `json` the `--input` request whose `query` field holds it. */ + | (Base & { kind: "graphql"; query: FieldValue; json: boolean; operationName: boolean }) + /** A REST PR write whose PR number the command builds at run time, so no check can name the PR. */ + | (Base & { kind: "unresolved"; target: string }); + +/** The head a merge names: `--match-head-commit`, a REST `sha` field, or with `json` the `--input` request whose `sha` field holds it. */ +export interface HeadMatch { + value: FieldValue; + json: boolean; +} + +/** + * What a GraphQL document can do to a PR: merge it, write it (create or update), or + * `opaque` when shell expansion in the text could turn it into either. Null means neither. + */ +export function graphqlRisk(text: string, operationName = false): "merge" | "write" | "opaque" | null { + if (/\b(mergePullRequest|enablePullRequestAutoMerge)\b/.test(text)) return "merge"; + if (/\b(createPullRequest|updatePullRequest)\b/.test(text)) return "write"; + if (/[$`]/.test(topLevel(text).replace(/\$\w+\s*:/g, ""))) return "opaque"; + // A query cannot write. With operationName set, an expansion could add a mutation as a second operation. + if (!operationName && !/\bmutation\b/.test(text)) return null; + const declared = new Set([...text.matchAll(/\$(\w+)\s*:/g)].map((m) => m[1])); + const undeclared = [...text.matchAll(/\$(\w+)/g)].some((m) => !declared.has(m[1])); + return /\$\(|`|\$\{|\w\$/.test(text) || undeclared ? "opaque" : null; +} + +/** + * The text outside every `{...}` block, where operation types live. Braces in strings and + * `#` comments do not count. Comments always count as top-level text, since an expansion + * in one can end the comment. + */ +function topLevel(text: string): string { + let out = ""; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const end = text.startsWith('"""', i) ? text.indexOf('"""', i + 3) : -1; + const str = text.startsWith('"""', i) ? (end < 0 ? text.length : end + 3) : text[i] === '"' ? closeQuote(text, i) : -1; + if (text[i] === "#") { + const eol = text.indexOf("\n", i); + out += text.slice(i, eol < 0 ? text.length : eol); + i = eol < 0 ? text.length : eol; + } else if (str >= 0) { + if (depth === 0) out += text.slice(i, str); + i = str - 1; + } else if (text[i] === "{") depth++; + else if (text[i] === "}") depth = Math.max(0, depth - 1); + else if (depth === 0) out += text[i]; + } + return out; +} + +/** The index just past the `"` string that opens at `start`. */ +function closeQuote(text: string, start: number): number { + for (let i = start + 1; i < text.length; i++) { + if (text[i] === "\\") i++; + else if (text[i] === '"' || text[i] === "\n") return i + 1; + } + return text.length; +} + +/** + * Markers for text the shell computes when it runs. A NUL never occurs in a bash command, + * so a word holding one is never plain text. `\0n\0` is substitution n, `HOME` is `"$HOME"` or `~`. + * `UNKNOWN` stays in its word, and `LOOSE` is unquoted, so the shell can split it into more words. + * `LOOSE_HOME` is an unquoted `$HOME`: the home directory, which the shell can also split. + */ +const HOME = "\0~\0"; +const UNKNOWN = "\0?\0"; +const LOOSE = "\0*\0"; +const LOOSE_HOME = "\0~*\0"; +/** A path that starts at the home directory. */ +const AT_HOME = /^\0~\*?\0(?=\/|$)/; +const MARKER = /\0[^\0]*\0/g; +const SUB = /\0(\d+)\0/g; + +interface Heredoc { + text: string; + /** The delimiter was quoted (`<<'EOF'`), so the shell keeps the text as written. */ + quoted: boolean; + /** An unquoted heredoc's text with each expansion as its marker, so the substitutions it runs are scanned. */ + marked?: string; +} + +/** + * One stdin redirection. bash applies them left to right, so gh reads the last one. `word` keeps a target whose substitutions run first. + * A `copy` is `<&N`, `0>&N`, a `descriptorPath`, or a close or move with `-`: stdin becomes descriptor `fd`, or closes. + */ +type Stdin = { kind: "doc"; doc: number } | { kind: "file"; path: string } | { kind: "string"; word: string } | { kind: "copy"; fd: string; word: string } | { kind: "other"; word: string }; + +/** An output redirection to a file: `fd` is the descriptor, or `&` for stdout and stderr together. A copy such as `2>&1` is not one. */ +interface Output { + fd: string; + append: boolean; + target: string; +} + +/** + * One simple command: its words after quote removal, and its redirections in order. `always`: the shell runs it whenever + * it reaches its list, since it starts the list and sits in no compound body. + */ +interface Segment { + words: string[]; + stdin: Stdin[]; + /** Input redirections on other file descriptors. The command does not read them as stdin, but they still expand. */ + other: Stdin[]; + out: Output[]; + /** Output descriptor copies and closes, such as `2>&1` or `>&-`. */ + copies: number; + /** It runs in a `( )` subshell. */ + subshell: boolean; + /** In or opening a compound or `{ }` body. */ + nested: boolean; + /** The operator that ends it, such as `;`, `&`, or `|`. */ + after?: string; + always: boolean; + /** The pipeline part whose output this command reads on stdin. */ + pipe?: Segment; + /** The commands of the `{ }`, `( )`, or compound command this segment closes. They print to its pipe. */ + group?: Segment[]; +} + +/** + * A `$(...)`, backquote, or `<(...)` command, and what the shell puts in its place: inside double + * quotes its output as one `word`, unquoted its output split into `words`, and for `<(...)` a `path`. + */ +interface Sub { + segments: Segment[]; + output: "word" | "words" | "path"; +} + +/** What a command can change in the environment gh and cd read, from its words after quote removal. */ +interface Env { + /** The context variables a setting word names, or `*` when a setter's name is computed or the command runs other shell code. */ + names: Set<string>; +} + +/** One shell's parse: its commands, and the heredocs and substitutions their markers point at. */ +interface Scope { + segments: Segment[]; + docs: Heredoc[]; + subs: Sub[]; + /** No setting word names HOME, so `~` and `$HOME` are the gate's home directory. */ + home: boolean; + env: Env; + /** A single `&` runs a command in the background somewhere in this text. */ + background?: true; +} + + +/** What the commands of `scope`, its substitutions included, can change, added to what an outer shell changed. */ +function envOf(scope: Scope, outer: Env): Env { + const env: Env = { names: new Set(outer.names) }; + for (const seg of [scope.segments, ...scope.subs.map((s) => s.segments)].flat()) { + const found = head(seg.words, (w) => computed(w, scope)); + const cmd = found.at < 0 ? "" : seg.words[found.at]; + if (!found.sure || RUNS_CODE.includes(cmd)) env.names.add("*"); + // Only words that set variables name one: the assignments and wrapper options before the command, and a setter's + // operands. A literal argument, such as a merge subject that mentions HOME, sets nothing. + const setting = found.at < 0 || SETTERS.includes(cmd) ? seg.words : seg.words.slice(0, found.at); + for (const word of setting) { + for (const name of word.replace(MARKER, " ").match(/[A-Za-z_]\w*/g) ?? []) if (CONTEXT.includes(name)) env.names.add(name); + if (SETTERS.includes(cmd) && word.split("=")[0].includes("\0")) env.names.add("*"); + } + } + return env; +} + +/** Reads `src`. When its words can set HOME, it is read again with `~` and `$HOME` as computed text. */ +function lex(src: string, outer: Env = { names: new Set() }): Scope { + const parse = (home: boolean): Scope => { + const scope: Scope = { segments: [], docs: [], subs: [], home, env: outer }; + scope.segments = new Lexer(src, scope).commands(false); + return scope; + }; + const first = parse(true); + const env = envOf(first, outer); + const scope = env.names.has("HOME") || env.names.has("*") ? parse(false) : first; + scope.env = env; + return scope; +} + +const REDIRECT = /(\d*)(<<<|<<-|<<|<&|<>|<)/y; + +/** + * The descriptor a literal redirection path opens a copy of: /dev/stdin, /dev/stdout, /dev/stderr, or /dev/fd/N, which + * bash handles itself, or /proc/self/fd/N. `?` for any other path under /dev/fd/ or /proc/, which the gate cannot follow. + * Any other path can still reach a descriptor, through `..`, a link, or text the shell computes, so `stdinText` decides. + */ +function descriptorPath(word: string): string | undefined { + const path = localPath(word); + if (path === undefined) return undefined; + const std = ["/dev/stdin", "/dev/stdout", "/dev/stderr"].indexOf(path); + if (std >= 0) return String(std); + const fd = /^\/(?:dev|proc\/self)\/fd\/(\d+)$/.exec(path)?.[1]; + if (fd !== undefined) return String(Number(fd)); + return /^\/(?:dev\/fd|proc)\//.test(path) ? "?" : undefined; +} + +const OUTPUT = /(\d*)(>>|>\||>&|>)|&>>?/y; +/** The characters that end a word. `commands` consumes each one, so every word read advances. */ +const BREAK = /[ \t\n;&|()<>]/; + +const ANSI_C: Record<string, string> = { a: "\x07", b: "\b", e: "\x1b", E: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "\\": "\\", "'": "'", '"': '"', "?": "?" }; + +/** + * The text of a `$'...'` body as bash decodes it. Undefined when the command text does not fix it: + * a `\c` control escape, a NUL, which ends the string in bash, or an escape past ASCII, which bash encodes by locale. + */ +function ansiC(body: string): string | undefined { + if (body.includes("\\c")) return undefined; + let fixed = true; + const text = body.replace(/\\(?:([0-7]{1,3})|x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{1,4})|U([0-9a-fA-F]{1,8})|([\s\S]))/g, (all, oct, hex, u, big, ch) => { + const code = oct ? parseInt(oct, 8) & 0xff : hex || u || big ? parseInt(hex || u || big, 16) : undefined; + if (code === undefined) return ANSI_C[ch] ?? all; + if (code === 0 || code > 0x7f) fixed = false; + return String.fromCharCode(code & 0x7f); + }); + return fixed ? text : undefined; +} + +/** Reads bash syntax far enough to find words, stdin redirections, heredocs, and substitutions. It never runs anything. */ +class Lexer { + private pos = 0; + private pending: { delim: string; strip: boolean; doc: number }[] = []; + /** The `( ... )` subshells open at `pos`. */ + private parens = 0; + /** If, while, until, for, case, and select bodies open at `pos`. */ + private compound = 0; + private groups = 0; + /** The paren depth of each open case, so its pattern `)` closes no subshell. -1 marks a `$(` boundary. */ + private cases: number[] = []; + /** Groups opened right after `|`: the pipeline part they read, and the depths they close at. Every command inside reads it. */ + private feeds: { pipe: Segment; parens: number; compound: number; groups: number }[] = []; + + constructor( + private readonly src: string, + private readonly scope: Scope, + ) {} + + /** Commands until the end, or with `close` until the `)` that ends a `$(`. */ + commands(close: boolean): Segment[] { + const out: Segment[] = [this.segment(true)]; + const opens: number[] = []; + let depth = 0; + this.cases.push(-1); + while (this.pos < this.src.length) { + const c = this.src[this.pos]; + const next = this.src[this.pos + 1]; + REDIRECT.lastIndex = this.pos; + OUTPUT.lastIndex = this.pos; + if (c === "\\" && next === "\n") this.pos += 2; + else if (c === " " || c === "\t") this.pos++; + else if (c === "#") { + // A `#` that starts a word is a comment to the end of the line. The newline stays, so heredoc bodies still follow it. + const eol = this.src.indexOf("\n", this.pos); + this.pos = eol < 0 ? this.src.length : eol; + } else if ((c === "<" || c === ">") && next === "(") { + this.pos += 2; + out.at(-1)!.words.push(this.sub(this.commands(true), "path")); + } else if (OUTPUT.test(this.src)) this.output(out.at(-1)!); + else if (c === "\n" || c === ";" || c === "&" || c === "|") { + const pair = next === c && c !== "\n"; + const op = /^(?:;;&|;;|;&|\|&)/.test(this.src.slice(this.pos, this.pos + 3)) ? (this.src.startsWith(";;&", this.pos) ? ";;&" : this.src.slice(this.pos, this.pos + 2)) : pair ? c + c : c; + this.pos += op.length; + const left = out.at(-1)!; + left.after = op === "|&" ? "|" : op; + if (op === "&") this.scope.background = true; + if (c === "\n") this.bodies(); + out.push(this.segment(!pair || c === ";")); + if (left.after === "|") out.at(-1)!.pipe = left; + if (["|", "&&", "||"].includes(left.after)) this.continuation(); + } else if (c === "(") { + this.feed(out.at(-1)!); + depth++; + this.parens++; + opens.push(out.length); + out.push(this.segment(true)); + this.pos++; + } else if (c === ")") { + this.pos++; + if (close && depth === 0) break; + if (this.cases.at(-1) === depth) { + out.push(this.segment(true)); + continue; + } + const start = depth > 0 ? opens.pop() : undefined; + if (depth > 0) this.parens--; + depth = Math.max(0, depth - 1); + out.push(this.segment(true)); + if (start !== undefined) out.at(-1)!.group = out.slice(start, -1); + } else if (REDIRECT.test(this.src)) this.redirect(out.at(-1)!); + else { + const seg = out.at(-1)!; + const word = this.word(); + if (seg.words.every((w) => KEYWORDS.includes(w))) { + this.keyword(word, seg, depth); + if (["{", "if", "while", "until", "for", "case", "select"].includes(word)) opens.push(out.length - 1); + else if (["}", "fi", "done", "esac"].includes(word)) seg.group = out.slice(opens.pop() ?? -1, -1); + } + seg.words.push(word); + } + } + this.parens -= depth; + while (this.cases.length > 0 && this.cases.pop() !== -1); + return out.filter((s) => s.words.length > 0 || s.stdin.length > 0 || s.other.length > 0 || s.out.length > 0 || s.copies > 0); + } + + /** After `|`, `&&`, or `||` a command must follow, so bash reads the newlines and comments before it as space. */ + private continuation(): void { + for (;;) { + const c = this.src[this.pos]; + if (c === " " || c === "\t") this.pos++; + else if (c === "\\" && this.src[this.pos + 1] === "\n") this.pos += 2; + else if (c === "\n") { + this.pos++; + this.bodies(); + } else if (c === "#") { + const eol = this.src.indexOf("\n", this.pos); + this.pos = eol < 0 ? this.src.length : eol; + } else return; + } + } + + /** Tracks compound bodies from a word where a command name goes. A command that opens or sits in one may be skipped. */ + private keyword(word: string, seg: Segment, depth: number): void { + if (["{", "if", "while", "until", "for", "case", "select"].includes(word)) this.feed(seg); + if (word === "{") { + this.groups++; + seg.nested = true; + } else if (word === "}") this.groups = Math.max(0, this.groups - 1); + else if (["if", "while", "until", "for", "case", "select"].includes(word)) { + this.compound++; + seg.always = false; + seg.nested = true; + if (word === "case") this.cases.push(depth); + } else if (["fi", "done", "esac"].includes(word)) { + this.compound = Math.max(0, this.compound - 1); + if (word === "esac" && this.cases.at(-1) !== -1) this.cases.pop(); + } + } + + /** + * One word after quote removal. Text the shell computes becomes a marker, including a `~` right after the + * first `=`, as in `--flag=~/x`. An unquoted brace list such as `{a,b}` or `{1..3}` gets `LOOSE`, since bash + * expands it into more words. + */ + private word(): string { + let out = ""; + let eq = -1; + const brace = { depth: 0, open: 0, list: false, done: false }; + const start = this.pos; + while (this.pos < this.src.length) { + const c = this.src[this.pos]; + if (BREAK.test(c)) break; + else if (c === "'") { + const end = this.src.indexOf("'", this.pos + 1); + out += this.src.slice(this.pos + 1, end < 0 ? undefined : end); + this.pos = end < 0 ? this.src.length : end + 1; + } else if (c === '"') { + this.pos++; + out += this.double(); + } else if (c === "\\") { + if (this.src[this.pos + 1] !== "\n") out += this.src[this.pos + 1] ?? ""; + this.pos += 2; + } else if (c === "$" || c === "`") out += this.dollar(false); + else if (c === "~" && (this.pos === start || out.length === eq)) { + const after = this.src[this.pos + 1] ?? ""; + out += this.scope.home && this.pos === start && (after === "" || after === "/" || BREAK.test(after)) ? HOME : UNKNOWN; + this.pos++; + } else { + if (c === "{" && brace.depth++ === 0) brace.open = out.length; + else if (brace.depth > 0 && (c === "," || (c === "." && this.src[this.pos + 1] === "."))) brace.list = true; + else if (c === "}" && brace.depth > 0 && --brace.depth === 0) { + if (brace.list && !brace.done) { + out = out.slice(0, brace.open) + LOOSE + out.slice(brace.open); + brace.done = true; + } + brace.list = false; + } + out += c; + this.pos++; + if (c === "=" && eq < 0) eq = out.length; + } + } + return out; + } + + /** The text of a `"..."` string, from just past its opening quote. */ + private double(): string { + let out = ""; + while (this.pos < this.src.length && this.src[this.pos] !== '"') { + const c = this.src[this.pos]; + const next = this.src[this.pos + 1]; + if (c === "\\" && next !== undefined && '$`"\\\n'.includes(next)) { + if (next !== "\n") out += next; + this.pos += 2; + } else if (c === "$" || c === "`") out += this.dollar(true); + else { + out += c; + this.pos++; + } + } + this.pos++; + return out; + } + + /** + * A `$` or backquote expansion: its marker, or a plain `$` the shell keeps. ANSI-C `$'...'` is its decoded + * text, or computed when `ansiC` cannot fix it. Locale `$"..."` counts as computed, since bash can translate + * it. An unquoted expansion, and `"$@"`, can become several words. + */ + private dollar(quoted: boolean): string { + const next = this.src[this.pos + 1] ?? ""; + const output = quoted ? "word" : "words"; + const param = (name: string): string => + name === "HOME" && this.scope.home ? (quoted ? HOME : LOOSE_HOME) : quoted && !name.includes("@") ? UNKNOWN : LOOSE; + if (this.src[this.pos] === "`") { + let inner = ""; + let i = this.pos + 1; + for (; i < this.src.length && this.src[i] !== "`"; i++) { + if (this.src[i] === "\\" && "$`\\".includes(this.src[i + 1] ?? "")) i++; + inner += this.src[i]; + } + this.pos = i + 1; + return this.sub(new Lexer(inner, this.scope).commands(false), output); + } + if (next === "(") { + this.pos += 2; + return this.sub(this.commands(true), output); + } + if (next === "{") { + let i = this.pos + 1; + for (let depth = 0; i < this.src.length; i++) { + if (this.src[i] === "{") depth++; + else if (this.src[i] === "}" && --depth === 0) break; + } + const name = this.src.slice(this.pos + 2, i); + this.pos = i + 1; + // A default or alternate word runs its substitutions, so they are scanned like any other. + const inner = new Lexer(name, this.scope).expansions().match(SUB) ?? []; + return param(name) + inner.join(""); + } + const name = /^[A-Za-z_]\w*/.exec(this.src.slice(this.pos + 1, this.pos + 256))?.[0]; + if (name) { + this.pos += 1 + name.length; + return param(name); + } + if (next !== "" && "0123456789@*#?$!-".includes(next)) { + this.pos += 2; + return param(next); + } + if (!quoted && next === "'") { + let i = this.pos + 2; + while (i < this.src.length && this.src[i] !== "'") i += this.src[i] === "\\" ? 2 : 1; + const text = i < this.src.length ? ansiC(this.src.slice(this.pos + 2, i)) : undefined; + this.pos = i + 1; + return text ?? UNKNOWN; + } + this.pos++; + return !quoted && next === '"' ? UNKNOWN : "$"; + } + + /** Starts a feed when `seg` opens a group as the first thing after `|`. */ + private feed(seg: Segment): void { + if (seg.pipe && seg.words.length === 0) this.feeds.push({ pipe: seg.pipe, parens: this.parens, compound: this.compound, groups: this.groups }); + } + + private segment(always: boolean): Segment { + const nested = this.compound + this.groups > 0; + const open = (f: Lexer["feeds"][number]): boolean => this.parens > f.parens || this.compound > f.compound || this.groups > f.groups; + while (this.feeds.length > 0 && !open(this.feeds.at(-1)!)) this.feeds.pop(); + return { words: [], stdin: [], other: [], out: [], copies: 0, subshell: this.parens > 0, nested, always: always && !nested, pipe: this.feeds.at(-1)?.pipe }; + } + + private sub(segments: Segment[], output: Sub["output"]): string { + return `\0${this.scope.subs.push({ segments, output }) - 1}\0`; + } + + /** A `<` redirection. Only fd 0 is stdin, and the others are kept apart. A heredoc body starts at the next newline. */ + private redirect(seg: Segment): void { + REDIRECT.lastIndex = this.pos; + const [, fd, op] = REDIRECT.exec(this.src)!; + this.pos = REDIRECT.lastIndex; + while (this.src[this.pos] === " " || this.src[this.pos] === "\t") this.pos++; + const start = this.pos; + const sub = /^[<>]\(/.test(this.src.slice(this.pos, this.pos + 2)); + if (sub) this.pos += 2; + const target = sub ? this.sub(this.commands(true), "path") : this.word(); + let stdin: Stdin = { kind: "other", word: target }; + if (op === "<<" || op === "<<-") { + const doc = this.scope.docs.push({ text: "", quoted: /['"\\]/.test(this.src.slice(start, this.pos)) }) - 1; + this.pending.push({ delim: target, strip: op === "<<-", doc }); + stdin = { kind: "doc", doc }; + } else if (op === "<<<") stdin = { kind: "string", word: target }; + else if (op === "<&") stdin = { kind: "copy", fd: target, word: target }; + else if (descriptorPath(target) !== undefined) stdin = { kind: "copy", fd: descriptorPath(target)!, word: target }; + else if (op === "<") stdin = { kind: "file", path: target }; + (Number(fd || "0") === 0 ? seg.stdin : seg.other).push(stdin); + } + + /** A `>` redirection. `>&` to a descriptor number, a number and `-`, or `-` copies, moves, or closes one, and to any other word writes that file, as `&>` does. */ + private output(seg: Segment): void { + OUTPUT.lastIndex = this.pos; + const [op, fd, kind] = OUTPUT.exec(this.src)!; + this.pos = OUTPUT.lastIndex; + while (this.src[this.pos] === " " || this.src[this.pos] === "\t") this.pos++; + const target = this.word(); + // A target the shell computes can expand to a descriptor number, so it may copy one as well as write a file. + const computed = kind === ">&" && target.includes("\0"); + if (computed || (kind === ">&" && /^(?:\d+-?|-)$/.test(target))) { + seg.copies++; + if (fd !== "" && Number(fd) === 0) seg.stdin.push({ kind: "copy", fd: target, word: target }); + if (!computed) return; + } + seg.out.push({ fd: kind === ">&" || kind === undefined ? "&" : fd || "1", append: op.endsWith(">>"), target }); + } + + /** Reads the bodies of the heredocs opened on the line that just ended. */ + private bodies(): void { + for (const { delim, strip, doc } of this.pending.splice(0)) { + const lines: string[] = []; + while (this.pos < this.src.length) { + const eol = this.src.indexOf("\n", this.pos); + const end = eol < 0 ? this.src.length : eol; + const raw = this.src.slice(this.pos, end); + const line = strip ? raw.replace(/^\t+/, "") : raw; + this.pos = end + 1; + if (line === delim) break; + lines.push(line); + } + const heredoc = this.scope.docs[doc]; + heredoc.text = lines.join("\n"); + if (!heredoc.quoted) heredoc.marked = new Lexer(heredoc.text, this.scope).expansions(); + } + } + + /** The text of an unquoted heredoc as the shell expands it: each `$` or backquote expansion is its marker, and `\` drops before `$`, a backquote, `\`, or a newline. */ + private expansions(): string { + let out = ""; + while (this.pos < this.src.length) { + const c = this.src[this.pos]; + const next = this.src[this.pos + 1] ?? ""; + if (c === "\\" && next !== "" && "$`\\\n".includes(next)) { + if (next !== "\n") out += next; + this.pos += 2; + } else if (c === "$" || c === "`") out += this.dollar(true); + else { + out += c; + this.pos++; + } + } + return out; + } +} + +/** A word for messages, with each computed part shown as `$...`. */ +function display(word: string): string { + return word.replace(MARKER, "$..."); +} + +/** Whether the shell can split `word` into several words: it holds an unquoted `$...`, `$(...)`, or backquote. */ +function splits(word: string, scope: Scope): boolean { + return word.includes(LOOSE) || word.includes(LOOSE_HOME) || [...word.matchAll(SUB)].some(([, n]) => scope.subs[Number(n)].output === "words"); +} + +/** One gh flag: its long name, and whether it takes a value. */ +interface Flag { + name: string; + valued: boolean; +} + +/** One gh command's flags, by long name and by short letter. */ +interface FlagTable { + long: Map<string, Flag>; + short: Map<string, Flag>; +} + +/** A table from gh's `--help` list, comma-separated: `"X method"` has a short letter, `"input"` has none. */ +function flagTable(valued: string, boolean: string): FlagTable { + const table: FlagTable = { long: new Map(), short: new Map() }; + for (const [list, takesValue] of [[valued, true], [boolean, false]] as const) { + for (const entry of list.split(", ")) { + const [letter, name] = entry.split(" "); + const flag = { name: name ?? letter, valued: takesValue }; + table.long.set(flag.name, flag); + if (name !== undefined) table.short.set(letter, flag); + } + } + return table; +} + +/** A gh command's words as gh's flag parser reads them. */ +interface GhArgs { + /** Each flag in order, by long name. `value` is the value a flag takes, or a boolean's `=` text. */ + flags: { name: string; value?: string }[]; + positionals: string[]; + /** The shell computes a word gh could read as a flag, or can split a word into more, so gh's reading is unknown. */ + unknown: boolean; +} + +/** + * Reads a gh command's words once, as gh's flag parser does. In a short group such as `-iX POST`, the + * first letter that takes a value takes the rest of the group or the next word, even one that looks + * like a flag. A flag missing from the table is unknown, since gh could have it. + */ +function readArgs(words: string[], table: FlagTable, scope: Scope): GhArgs { + const args: GhArgs = { flags: [], positionals: [], unknown: false }; + const set = (flag: Flag, value: string | undefined): void => { + if (value !== undefined && (splits(value, scope) || (!flag.valued && value.includes("\0")))) args.unknown = true; + args.flags.push({ name: flag.name, value }); + }; + let flagsEnd = false; + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (splits(word, scope)) args.unknown = true; + else if (flagsEnd || word === "-" || !/^[-\0]/.test(word) || word.startsWith(HOME)) args.positionals.push(word); + else if (word === "--") flagsEnd = true; + else if (word.startsWith("\0")) args.unknown = true; + else if (word.startsWith("--")) { + const eq = word.indexOf("="); + const flag = table.long.get(word.slice(2, eq < 0 ? undefined : eq)); + if (flag === undefined) args.unknown = true; + else set(flag, eq >= 0 ? word.slice(eq + 1) : flag.valued ? words[++i] : undefined); + } else { + for (let j = 1; j < word.length; j++) { + const flag = table.short.get(word[j]); + const rest = word.slice(j + 1); + if (flag === undefined) args.unknown = true; + else if (rest.length > 1 && rest[0] === "=") set(flag, rest.slice(1)); + else if (flag.valued) set(flag, rest || words[++i]); + else { + set(flag, undefined); + continue; + } + break; + } + } + } + return args; +} + +/** One level of gh's command tree: the flags its command finder reads with no value, and the subcommands the gate reads. */ +interface Level { + bools: string[]; + names: string[]; +} + +const GH_ROOT: Level = { bools: ["--help", "--version"], names: ["pr", "api"] }; +const GH_PR: Level = { bools: ["--help"], names: ["create", "new", "edit", "merge"] }; + +/** The subcommand gh's command finder picks, and the words left for that subcommand's flag parser. */ +interface Found { + name?: string; + words: string[]; + /** A word the shell computes came first, so the finder could pick a later name. */ + unknown: boolean; +} + +/** Whether the shell computes part of `word`, other than a fixed home directory at its start. */ +function computed(word: string, scope: Scope): boolean { + return word.includes("\0") && (splits(word, scope) || !word.startsWith(HOME)); +} + +/** + * Picks a subcommand the way gh's command finder (cobra) does: the first word that is neither a + * flag nor a flag's value, removed so the flags around it go to the subcommand. The finder takes + * any flag missing from `bools` to own the next word, even one it does not know. After a word the + * shell computes, which could be any flag, the first later name from `names` is picked. + */ +function subcommand(words: string[], level: Level, scope: Scope, unknown = false): Found { + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (computed(word, scope)) unknown = true; + else if (unknown ? level.names.includes(word) : word !== "" && !word.startsWith("-")) { + return { name: word, words: [...words.slice(0, i), ...words.slice(i + 1)], unknown }; + } else if (unknown) continue; + else if (word === "--") break; + else if (!word.includes("=") && (word.startsWith("--") || word.length === 2) && !level.bools.includes(word)) i++; + } + return { words, unknown }; +} + +/** The value gh keeps for a flag that takes one: the last. */ +function last(args: GhArgs, name: string): string | undefined { + return args.flags.findLast((f) => f.name === name)?.value; +} + +/** Whether a boolean flag ends up on: its last spelling is bare, or not text gh reads as false. */ +function isOn(args: GhArgs, name: string): boolean { + const flag = args.flags.findLast((f) => f.name === name); + return flag !== undefined && !["0", "f", "F", "false", "FALSE", "False"].includes(flag.value ?? "true"); +} + +/** One shell's view while scanning. */ +interface Ctx extends Where { + scope: Scope; + /** The file the one heredoc before the PR command writes. */ + written?: Written; + host?: string; + /** A word came before the shell that runs this text, such as an assignment or env, so it can change the repo gh picks. */ + wrapped?: boolean; + /** Set for one gh call with no word before it or its shell, so the current directory names its repo and branch. */ + known?: boolean; + /** The program text this shell was given on stdin, which a command here reads when it has no redirection or pipe of its own. */ + stdin?: Input; + /** Collects the files the command being read makes gh read. */ + reads?: Read[]; +} + +/** A file a PR command reads, by its path and absolute name, or `unknown` when gh takes the path from text the shell gives it. */ +type Read = { path: string; key?: string } | "unknown"; + + +/** The fields every PR command carries about where it runs. */ +function at(seg: Segment, ctx: Ctx): Base { + return { via: display(seg.words.join(" ")).slice(0, 120), dir: ctx.dir, host: ctx.host, lost: ctx.lost }; +} + +const expanded = (word: string): BodySource => ({ kind: "inline", text: display(word), expands: true }); + +/** A path the gate can resolve: `~` at the start is the home directory, and a literal leading `~` is a relative name. Undefined when the shell computes any other part. */ +function localPath(word: string): string | undefined { + const home = AT_HOME.exec(word)?.[0]; + const path = home ? "~" + word.slice(home.length) : word.startsWith("~") ? "./" + word : word; + return path.includes("\0") ? undefined : path; +} + +function fileSource(word: string, ctx: Ctx): BodySource { + const path = localPath(word); + if (path === undefined) return expanded(word); + ctx.reads?.push({ path, key: fileKey(path, ctx) }); + return readFile(path, ctx.written, ctx); +} + +/** The text a heredoc gives, as the shell expands it. Text with an expansion in it is not what the command reads. */ +function docSource(doc: Heredoc): BodySource { + const text = doc.marked ?? doc.text; + return text.includes("\0") ? { kind: "inline", text: display(text), expands: true } : { kind: "inline", text }; +} + +/** A command's stdin redirections, without `<&0` or `< /dev/stdin`, which copy stdin onto itself and change nothing. */ +const given = (seg: Segment): Stdin[] => seg.stdin.filter((s) => !(s.kind === "copy" && s.fd === "0")); + +/** What gh reads on stdin: the last stdin redirection, since bash applies them in order. */ +function stdin(seg: Segment, ctx: Ctx): BodySource { + const last = given(seg).at(-1); + if (last?.kind === "doc") return docSource(ctx.scope.docs[last.doc]); + if (last?.kind === "file") return fileSource(last.path, ctx); + if (last?.kind === "string") return wordSource(last.word, ctx); + return { kind: "missing" }; +} + +/** A `--body-file`, `-F key=@path`, or `--input` value: a file, or stdin for `-`. */ +function pathSource(value: string, seg: Segment, ctx: Ctx): BodySource { + return value === "-" ? stdin(seg, ctx) : fileSource(value, ctx); +} + +/** What a word gives gh: its text, or the text `"$(cat f)"` prints. Any other computed part expands. */ +function wordSource(word: string, ctx: Ctx): BodySource { + const sub = /^\0(\d+)\0$/.exec(word); + const printed = sub ? catSource(ctx.scope.subs[Number(sub[1])], ctx) : undefined; + if (printed) return printed; + return word.includes("\0") ? expanded(word) : { kind: "inline", text: word }; +} + +/** + * The one command of `"$(cat f)"`, `"$(< f)"`, or `"$(cat <<'EOF' ...)"`, which only print a file or heredoc, and the file it + * names, or `stdin`. Undefined for any other substitution, or one that writes a file, copies or closes a descriptor, or runs + * another substitution. + */ +function catShape(sub: Sub, scope: Scope): { seg: Segment; file: string | "stdin" } | undefined { + const [seg, ...more] = sub.segments; + if (sub.output !== "word" || seg === undefined || more.length > 0 || seg.out.length > 0 || seg.copies > 0 || seg.other.length > 0) return undefined; + if (seg.stdin.some((s) => s.kind === "copy")) return undefined; + if (targets(seg, scope).some((t) => SUB.test(t))) return undefined; + const [cmd, ...args] = seg.words; + if (cmd === undefined && seg.stdin.length === 1 && seg.stdin[0].kind === "file") return { seg, file: seg.stdin[0].path }; + if (cmd === "cat" && args.length === 0 && seg.stdin.length > 0) return { seg, file: "stdin" }; + if (cmd === "cat" && args.length === 1 && seg.stdin.length === 0 && !args[0].startsWith("-") && !splits(args[0], scope)) return { seg, file: args[0] }; + return undefined; +} + +/** + * What a cat substitution prints: the file or heredoc, minus its trailing newlines. Other substitutions are unknown, and so + * is one whose cat copies or closes a descriptor, as `1>&2` or `>&-` does, since gh may then get something other than the + * file, or nothing. + */ +function catSource(sub: Sub, ctx: Ctx): BodySource | undefined { + const shape = catShape(sub, ctx.scope); + if (shape === undefined) return undefined; + const source = shape.file === "stdin" ? stdin(shape.seg, ctx) : fileSource(shape.file, ctx); + if (source?.kind === "file") return { ...source, chomp: true }; + return source?.kind === "inline" ? { ...source, text: source.text.replace(/\n+$/, "") } : source; +} + +/** The flags of `gh pr create`, `edit`, and `merge` in gh 2.101, as `--help` lists them. */ +const PR_FLAGS = { + create: flagTable( + "a assignee, attach, B base, b body, F body-file, H head, l label, m milestone, p project, recover, r reviewer, T template, t title, R repo", + "d draft, dry-run, e editor, f fill, fill-first, fill-verbose, no-maintainer-edit, w web, help", + ), + edit: flagTable( + "add-assignee, add-label, add-project, add-reviewer, attach, B base, b body, F body-file, m milestone, remove-assignee, remove-label, remove-project, remove-reviewer, t title, R repo", + "remove-milestone, help", + ), + merge: flagTable("A author-email, b body, F body-file, match-head-commit, t subject, R repo", "admin, auto, d delete-branch, disable-auto, m merge, r rebase, s squash, help"), +}; + +function ghPr(found: Found, seg: Segment, ctx: Ctx): PrCommand | null { + const verb = found.name === "new" ? "create" : found.name; + if (verb !== "create" && verb !== "edit" && verb !== "merge") return found.unknown ? { ...at(seg, ctx), kind: "opaque", part: "control" } : null; + const args = readArgs(found.words, PR_FLAGS[verb], ctx.scope); + const base = { ...at(seg, ctx), repo: last(args, "repo") }; + if (args.unknown || found.unknown) return { ...base, kind: "opaque", part: "control" }; + if (verb === "merge") { + const target = args.positionals[0]; + const url = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(target ?? ""); + const pinned = base.repo !== undefined || url; + // -R takes [HOST/]OWNER/REPO. + const repoHost = base.repo?.split("/").length === 3 ? display(base.repo.split("/")[0]) : undefined; + const hosts = [urlHost(target), repoHost].filter((h) => h !== undefined); + const head = last(args, "match-head-commit"); + const match = head === undefined ? undefined : { value: { source: wordSource(head, ctx), typed: false }, json: false }; + const repo = repoHost === undefined ? base.repo : base.repo?.split("/").slice(1).join("/"); + return { ...base, repo, kind: "merge", target, pinned, hosts, match, drift: drift(ctx.scope.env.names, !url && (base.repo === undefined || target === undefined), url, ctx) }; + } + // gh reads --body-file over --body. + const file = last(args, "body-file"); + const text = last(args, "body"); + const body = file !== undefined ? pathSource(file, seg, ctx) : text !== undefined ? wordSource(text, ctx) : undefined; + const titleWord = last(args, "title"); + const title = titleWord === undefined ? undefined : wordSource(titleWord, ctx); + if (verb === "create") return { ...base, kind: "create", title, draft: isOn(args, "draft"), body: body ?? { kind: "missing" } }; + return body === undefined && title === undefined ? null : { ...base, kind: "edit", title, body }; +} + +/** + * The query of a `gh api graphql` call. As on REST, an `--input` file is the payload, wherever it + * comes, and gh moves the field flags to the URL. Without it, the `query` field's value is read as + * any REST field's. A field whose key is not literal, or a word gh could read as more flags, makes + * the query missing, because the parser cannot read it. + */ +function graphqlQuery(args: GhArgs, seg: Segment, ctx: Ctx): { query: FieldValue; json: boolean } { + const missing = { query: { source: { kind: "missing" } as const, typed: false }, json: false }; + if (args.unknown || args.flags.some((f) => isField(f) && !/^\w+(\[\w*\])*=/.test(f.value ?? ""))) return missing; + const input = last(args, "input"); + if (input !== undefined) return { query: { source: pathSource(input, seg, ctx), typed: false }, json: true }; + const query = apiFields(args).fields.get("query"); + return query ? { query: fieldValue(query, seg, ctx), json: false } : missing; +} + +/** The flags of `gh api` in gh 2.101, as `--help` lists them. */ +const API_FLAGS = flagTable( + "X method, f raw-field, F field, H header, input, q jq, t template, hostname, cache, p preview", + "i include, paginate, silent, slurp, verbose, allow-escape-sequences, help", +); + +const isField = (flag: { name: string }): boolean => flag.name === "raw-field" || flag.name === "field"; + +/** The host a URL names. */ +function urlHost(word: string | undefined): string | undefined { + const host = word?.match(/^https?:\/\/([^/]+)/i)?.[1]; + return host === undefined ? undefined : display(host); +} + +/** The endpoint gh resolves: no scheme, host, Enterprise `api/v3` prefix, query string, or edge slashes. */ +function apiPath(raw: string): string { + // A computed part's marker can hold `?`, which is not the literal query string, so it becomes an empty marker first. + return raw.replace(MARKER, "\0\0").replace(/^https?:\/\/[^/]+/i, "").replace(/\?.*$/, "").replace(/^\/+|\/+$/g, "").replace(/^api\/(?:v3\/)?/i, ""); +} + +function ghApi(found: Found, seg: Segment, ctx: Ctx): PrCommand | null { + const args = readArgs(found.words, API_FLAGS, ctx.scope); + args.unknown ||= found.unknown; + const raw = args.positionals[0]; + const path = raw === undefined ? undefined : apiPath(raw); + const where = at(seg, ctx); + if (path?.toLowerCase() === "graphql") { + const { query, json } = graphqlQuery(args, seg, ctx); + const operationName = args.flags.some((f) => isField(f) && /^operationName[=[]/.test(f.value ?? "")); + const { source } = query; + if (source.kind === "inline" && !source.expands && !json && graphqlRisk(source.text, operationName) === null) return null; + return { ...where, kind: "graphql", query, json, operationName }; + } + const m = path?.match(PULLS); + const explicit = last(args, "method"); + const control = args.unknown || (explicit?.includes("\0") ?? false); + // A computed word can be a flag that takes the real endpoint as its value, or the family name, which makes a later pulls or GraphQL path the endpoint. + const endpoint = (w: string): boolean => PULLS.test(apiPath(w)) || apiPath(w).toLowerCase() === "graphql"; + if (!m) return control && seg.words.some(endpoint) ? { ...where, kind: "opaque", part: "control" } : null; + const [, repo, number, merge] = m; + const base = { ...where, repo }; + if (control) return { ...base, kind: "opaque", part: "control" }; + // `pulls/$PR` builds the PR number at run time, so a write there cannot be pinned to a PR. + const unresolved = number !== undefined && !/^\d+$/.test(number) ? ({ ...base, kind: "unresolved", target: display(number) } as const) : undefined; + const { fields, opaque } = apiFields(args); + const input = last(args, "input"); + const method = (explicit ?? (args.flags.some(isField) || input !== undefined ? "POST" : "GET")).toUpperCase(); + const pinned = !repo.includes("{") && !args.flags.some((f) => f.name === "hostname"); + const hostname = last(args, "hostname"); + // gh sends a full URL to the host it names, and --hostname elsewhere, so the gate checks both. + const hosts = [urlHost(raw), hostname === undefined ? undefined : display(hostname)].filter((h) => h !== undefined); + if (merge) { + if (method !== "PUT") return null; + if (unresolved) return unresolved; + const sha = fields.get("sha"); + const match = input !== undefined ? { value: { source: pathSource(input, seg, ctx), typed: false }, json: true } : sha && { value: fieldValue(sha, seg, ctx), json: false }; + return { ...base, kind: "merge", target: number, pinned, hosts, match, drift: drift(ctx.scope.env.names, !pinned, false, ctx) }; + } + const write = !number && method === "POST" ? "create" : number && method === "PATCH" ? "edit" : null; + if (write === null) return null; + // With --input, gh sends the JSON as the payload and moves every field flag to the query string. + if (input !== undefined) return unresolved ?? { ...base, kind: "input", write, request: pathSource(input, seg, ctx) }; + if (opaque) return { ...base, kind: "opaque", part: "field" }; + if (write === "edit" && !fields.has("title") && !fields.has("body")) return null; + if (unresolved) return unresolved; + const set: Partial<Record<PrField, FieldValue>> = {}; + for (const key of ["title", "draft", "body"] as const) { + const field = fields.get(key); + if (field) set[key] = fieldValue(field, seg, ctx); + } + return { ...base, kind: "fields", write, fields: set }; +} + +const PULLS = /^repos\/([^/]+\/[^/]+)\/pulls(?:\/([^/]+)(\/merge)?)?$/i; + +interface ApiField { + value: string; + typed: boolean; +} + +/** The REST payload fields by key. gh refuses a repeated key. `typed` marks `-F`. `opaque`: some key is computed by the shell. */ +function apiFields(args: GhArgs): { fields: Map<string, ApiField>; opaque?: true } { + const fields = new Map<string, ApiField>(); + let opaque: true | undefined; + for (const flag of args.flags) { + if (!isField(flag) || flag.value === undefined) continue; + const eq = flag.value.indexOf("="); + if ((eq < 0 ? flag.value : flag.value.slice(0, eq)).includes("\0")) opaque = true; + else if (eq > 0) fields.set(flag.value.slice(0, eq), { value: flag.value.slice(eq + 1), typed: flag.name === "field" }); + } + return { fields, opaque }; +} + +/** + * What gh sends for one field. gh converts a `-F` value after the shell gives it: `@path` sends that + * file's text as a string, and other text is typed. Text a substitution gives is converted here when + * the parser knows it, and otherwise by the gate through `at` once it reads the file. + */ +function fieldValue(field: ApiField, seg: Segment, ctx: Ctx): FieldValue { + if (field.typed && field.value.startsWith("@")) return { source: pathSource(field.value.slice(1), seg, ctx), typed: false }; + const source = wordSource(field.value, ctx); + if (!field.typed) return { source, typed: false }; + const at: AtFiles = { stdin: stdin(seg, ctx), written: ctx.written, where: { base: ctx.base, dir: ctx.dir, lost: ctx.lost } }; + if (source.kind === "file") { + ctx.reads?.push("unknown"); + return { source, typed: true, at }; + } + if (source.kind === "inline" && !source.expands && source.text.startsWith("@")) { + ctx.reads?.push("unknown"); + return { source: atSource(source.text.slice(1), at), typed: false }; + } + return { source, typed: true }; +} + +const SSH_VALUED = ["-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w"]; + +/** Every pull-request write in `command`, in order. Empty when the command touches no PR. `base` is where the hook runs it. */ +export function findPrCommands(command: string, base: Where["base"] = { cwd: ".", home: "~", cdpath: false }): PrCommand[] { + const scope = lex(command); + return scan(scope.segments, { scope, base }); +} + +/** The command an outer shell hands to `ssh` or `bash -c`. What the outer shell computed is unknown inside, and can end a quote or split a word there. */ +function handOver(words: string[], outer: Scope): Scope { + return lex(words.join(" ").replace(MARKER, LOOSE), outer.env); +} + +/** + * The PR command a gh call makes. A word the shell computes where gh reads the command name could + * be `pr` or `api`, and one where gh reads the pr subcommand could be any write, so both are opaque. + */ +function ghCommand(seg: Segment, ctx: Ctx, unknown: boolean): PrCommand | null { + const top = subcommand(seg.words.slice(1), GH_ROOT, ctx.scope, unknown); + if (top.name === "pr" || (top.name === undefined && top.unknown)) { + const pr = subcommand(top.words, GH_PR, ctx.scope, top.unknown); + if (pr.name !== undefined || (top.name === "pr" && pr.unknown)) return ghPr(pr, seg, ctx); + } + return top.name === "api" || (top.name === undefined && top.unknown) ? ghApi(top, seg, ctx) : null; +} + +/** The text a segment's substitutions sit in: its words, its redirect targets, and its unquoted heredocs, on any descriptor, since all of them expand before the command runs. */ +function targets(seg: Segment, scope: Scope): string[] { + return [ + ...seg.words, + ...[...seg.stdin, ...seg.other].map((s) => (s.kind === "file" ? s.path : s.kind === "doc" ? (scope.docs[s.doc].marked ?? "") : s.word)), + ...seg.out.map((o) => o.target), + ]; +} + +/** The files a command's output redirects write: any target outside /dev/, where /dev/stderr and /dev/null are no file. */ +const writes = (seg: Segment): string[] => seg.out.map((o) => o.target).filter((t) => !/^\/dev\//.test(t) || t.includes("\0") || t.split("/").includes("..")); + +/** + * The file `cat > f <<'EOF'` writes, the one write a PR command may follow: a quoted heredoc to a literal name, and nothing + * else. A descriptor copy such as `1>&2` can send the text somewhere other than the file. + */ +function heredocTarget(seg: Segment): string | undefined { + const [doc] = seg.stdin; + const [to] = seg.out; + if (seg.words.join(" ") !== "cat" || seg.stdin.length !== 1 || seg.out.length !== 1 || seg.other.length > 0 || seg.copies > 0) return undefined; + return doc.kind === "doc" && to.fd === "1" && !to.append ? localPath(to.target) : undefined; +} + +/** Marks PR commands that do not run alone in their call. */ +function crowd(cmds: PrCommand[]): PrCommand[] { + for (const cmd of cmds) cmd.crowded = true; + return cmds; +} + +/** + * Commands a PR command may pipe its output into, by the arguments that keep them from writing a file. GNU sort takes any + * unique prefix of a long option and runs a --compress-program, and uniq writes its second operand. + */ +const FILTERS: Record<string, (args: string[]) => boolean> = { + cat: (args) => args.every((a) => a === "-"), + head: () => true, + tail: () => true, + grep: () => true, + wc: () => true, + cut: () => true, + tr: () => true, + jq: () => true, + pbcopy: () => true, + sort: (args) => !args.some((a) => /^-[^-]*o|^--[oc]/.test(a)), + uniq: (args) => args.filter((a) => !/^-./.test(a)).every((a) => a === "-") && args.filter((a) => a === "-").length <= 1, + sed: (args) => args.every((a) => /^-[nEr]+$/.test(a) || SED.test(a)), + awk: awkPrints, +}; + +const top = (seg: Segment): boolean => !seg.subshell && !seg.nested; +const bare = (seg: Segment): boolean => seg.stdin.length === 0 && seg.other.length === 0 && seg.out.length === 0; +const cd = (seg: Segment): boolean => bare(seg) && seg.words.length === 2 && seg.words[0] === "cd" && !seg.words[1].startsWith("-") && localPath(seg.words[1]) !== undefined; + +/** + * `set` with only the error and tracing options, where each `o` in a cluster takes one option name. Any other option can + * change what cd or a redirect does, as -P and noclobber do. + */ +function setsOptions(seg: Segment): boolean { + let names = 0; + for (const word of seg.words.slice(1)) { + if (names > 0 && ["pipefail", "errexit", "nounset", "xtrace"].includes(word)) names--; + else if (names === 0 && /^-[euxo]+$/.test(word)) names = word.match(/o/g)?.length ?? 0; + else return false; + } + return bare(seg) && seg.words[0] === "set" && seg.words.length > 1 && names === 0; +} + +/** A pipeline part after a PR command that only reads its output: a FILTERS command with literal words and no redirect to a file. */ +function filter(part: Segment, scope: Scope): boolean { + const [cmd, ...args] = part.words; + const readOnly = cmd !== undefined && Object.hasOwn(FILTERS, cmd) && FILTERS[cmd](args); + return readOnly && top(part) && writes(part).length === 0 && targets(part, scope).every((t) => !t.includes("\0")); +} + +/** A sed script that only prints, deletes, quits, or substitutes: no `w`, `e`, or `r` command and no `-i`. */ +const SED = /^(?:(?:\d+|\$)(?:,(?:\d+|\$))?|\/[^/]*\/)?[pdq]$|^s(.)(?:(?!\1).)*\1(?:(?!\1).)*\1[gpI\d]*$/; + +/** awk with only -F and -v options and a program that cannot write a file, run a command, or load more code. */ +function awkPrints(args: string[]): boolean { + let i = 0; + while (/^-[Fv]/.test(args[i] ?? "")) i += args[i].length === 2 ? 2 : 1; + const program = args[i]; + return program !== undefined && !program.startsWith("-") && !/system|getline|close|fflush|[|>@]/.test(program); +} + +/** `gh auth token` with literal words: the one substitution an assignment before gh may run, since it reads a token and writes nothing. */ +function tokenRead(sub: Sub): boolean { + const [seg, ...more] = sub.segments; + return more.length === 0 && seg !== undefined && bare(seg) && seg.words.slice(0, 3).join(" ") === "gh auth token" && seg.words.every((w) => !w.includes("\0")); +} + +/** Whether the command before `at` in its and-or list is joined by `||`, so the PR command runs only when another fails. */ +function orBefore(segments: Segment[], at: number): boolean { + for (let i = at - 1; i >= 0 && (segments[i].after === "&&" || segments[i].after === "||"); i--) { + if (segments[i].after === "||") return true; + } + return false; +} + +/** + * Whether the PR commands `made` by `segments[at]` run alone in their call, so no other command can change the bytes gh + * reads or the head it merges. The command sits at the top of its shell, outside any pipeline but as its first part, in the + * foreground, with no output redirect to a file and no substitution but a cat read, or `gh auth token` in an assignment + * before it, which names no CONTEXT variable. After it come only FILTERS, and none after a merge or a shell it hands over + * to. Before a write that reads a file, or a merge, run only a literal cd, a `setsOptions` set, and, for a write, one + * `cat > f <<'EOF'` that `&&` joins to it, when f is the one file the body, title, and payload reads open. Before a write + * whose text is all inline, any command may run, since none can change that text, but not one joined by `||` to it. + */ +function alone(segments: Segment[], at: number, made: PrCommand[], reads: Read[], scope: Scope, heredocs: Map<number, string | undefined>): boolean { + const seg = segments[at]; + const merge = made.some((c) => c.kind === "merge"); + const lead = seg.words.findIndex((w) => !ASSIGN.test(w)); + const assigns = seg.words.slice(0, lead < 0 ? seg.words.length : lead); + // targets() lists the words first, so the first assigns.length texts are the assignments. + const subsOk = targets(seg, scope).every((t, j) => + [...t.matchAll(SUB)].every(([, n]) => catShape(scope.subs[Number(n)], scope) !== undefined || (j < assigns.length && tokenRead(scope.subs[Number(n)]))), + ); + if (scope.background || !top(seg) || seg.pipe || writes(seg).length > 0 || !subsOk || assigns.some((w) => CONTEXT.includes(w.split("=")[0]))) return false; + const found = head(seg.words, (w) => computed(w, scope)); + const gh = found.sure && found.at >= 0 && isGh(seg.words[found.at]); + let end = at; + for (; segments[end].after === "|"; end++) { + const part = segments[end + 1]; + if (merge || !gh || part === undefined || !filter(part, scope)) return false; + } + if (end !== segments.length - 1 || orBefore(segments, at)) return false; + if (reads.length === 0 && !merge) return true; + // A second read can open the heredoc's file by another name, a link, and get the bytes on disk before cat runs. + const only = reads.length === 1 && reads[0] !== "unknown" ? reads[0].key : undefined; + let docs = 0; + return segments.slice(0, at).every((s, i) => { + // A command after && is skipped when one before it fails, and the PR command still runs unless && joins them all. + const runs = s.always || segments.slice(i, at).every((t) => t.after === "&&"); + if (!top(s) || s.pipe || !runs || !["\n", ";", "&&"].includes(s.after ?? "")) return false; + if (cd(s) || setsOptions(s)) return true; + const key = heredocs.get(i); + // When cat fails, as under noclobber, only && keeps gh from reading the old file. + const joined = segments.slice(i, at).every((t) => t.after === "&&"); + return !merge && joined && key !== undefined && key === only && ++docs === 1; + }); +} + +/** + * Every PR command in `segments`, run in order. Substitutions run first, and every PR command in one is crowded. Then + * the command's PR commands are read, checked against the alone rule, and its top-level cd is followed, one after && only + * to the end of an unbroken && chain from the start of its list. A cd in a subshell stays there, and any later PR write that reads a file, and any later merge, is crowded. + */ +function scan(segments: Segment[], ctx: Ctx): PrCommand[] { + const found: PrCommand[] = []; + /** By segment index: the absolute name a `cat > f <<'EOF'` before a PR command writes. */ + const heredocs = new Map<number, string | undefined>(); + /** The directory before a cd after && was followed, since that cd holds only until its chain ends. */ + let chained: { dir?: string } | undefined; + for (const [i, seg] of segments.entries()) { + for (const word of targets(seg, ctx.scope)) { + for (const [, n] of word.matchAll(SUB)) found.push(...crowd(scan(ctx.scope.subs[Number(n)].segments, { ...ctx, reads: undefined }))); + } + const cmd = head(seg.words, (w) => computed(w, ctx.scope)); + const doc = heredocTarget(seg); + const key = doc === undefined ? undefined : fileKey(doc, ctx); + if (doc !== undefined) heredocs.set(i, key); + if (key !== undefined) { + const text = stdin(seg, ctx); + ctx.written = { key, source: text.kind === "inline" ? { ...text, written: key } : text }; + } + const reads: Read[] = []; + const made = command(seg, cmd, { ...ctx, reads }); + if (made.length > 0 && !alone(segments, i, made, reads, ctx.scope, heredocs)) crowd(made); + found.push(...made); + ctx.reads?.push(...reads); + // `exec` with no command keeps its redirections, so every later command in this shell reads that stdin. + if (cmd.at < 0 && given(seg).length > 0 && seg.words.some((w) => !ASSIGN.test(w) && w.replace(/^.*\//, "") === "exec")) ctx.stdin = stdinText(seg, ctx) ?? "unseen"; + if (!seg.subshell) { + const names = ctx.scope.env.names; + const step = move(seg.words, cmd, runs(segments, i) && seg.stdin.length === 0 && seg.other.length === 0, localPath, ctx.base.cdpath || names.has("CDPATH") || names.has("*")); + if (step.kind === "follow") { + if (!seg.always) chained ??= { dir: ctx.dir }; + const absolute = /^[/~]/.test(step.to); + if (absolute) ctx.lost = undefined; + ctx.dir = ctx.dir && !absolute ? `${ctx.dir}/${step.to}` : step.to; + } else if (step.kind === "lose") ctx.lost = true; + } + // Past its && chain, a cd the chain ran may have been skipped. + if (chained && seg.after !== "&&") { + ctx.dir = chained.dir; + ctx.lost = true; + chained = undefined; + } + } + return found; +} + +/** Whether `segments[i]` runs whenever its list runs: it starts the list, or an unbroken && chain from the start reaches it. */ +function runs(segments: Segment[], i: number): boolean { + let start = i; + while (start > 0 && segments[start - 1].after === "&&") start--; + return segments[start].always; +} + +/** + * The PR commands an `ssh` word between `from` and `to` runs on its host: its remote command, read after the remote + * shell joins and reads the words again, or with none, the program on its stdin. Undefined with no ssh and host there. + */ +function remote(seg: Segment, ctx: Ctx, from: number, to: number): PrCommand[] | undefined { + const { words } = seg; + const ssh = words.findIndex((w, i) => i >= from && i < to && /^(?:.*\/)?ssh$/.test(w)); + if (ssh < 0) return undefined; + let i = ssh + 1; + while (i < words.length && words[i].startsWith("-")) i += SSH_VALUED.includes(words[i]) ? 2 : 1; + if (words[i] === undefined) return undefined; + const argv = words.slice(i + 1); + const into = { base: ctx.base, host: words[i], stdin: stdinOf(seg, ctx) }; + // `ssh host` with no remote command runs the remote shell on its stdin. + if (argv.length === 0) return into.stdin === undefined ? [] : program(into.stdin, seg, ctx, into); + const scope = handOver(argv, ctx.scope); + // The outer shell computes part of this command, so a command name in it can be gh. + if (argv.some((w) => w.includes("\0")) && hidden(scope)) return [{ ...at(seg, ctx), kind: "opaque", part: "program" }]; + return scan(scope.segments, { ...into, scope }); +} + +const isGh = (t: string): boolean => t === "gh" || t.endsWith("/gh"); + +/** A script operand that names the shell's own stdin, so stdin is the program. */ +const STDIN = /^\/dev\/(?:stdin|fd\/0)$|^\/proc\/self\/fd\/0$/; + +/** Commands that read their arguments as text and never run one, so a shell name among them is data. */ +const DATA = ["grep", "egrep", "fgrep", "echo", "printf", "which", "type", "ps", "pgrep"]; + +/** A shell command word, by name or path. */ +const SHELL = /^(?:.*\/)?(?:bash|sh|zsh|dash|ash|rbash|[a-z]*ksh\d*|yash|posh|fish|csh|tcsh)$/; + +/** The shells in SHELL whose options and programs the gate does not read as bash's. */ +const FOREIGN = /^(?:.*\/)?(?:[a-z]+ksh\d*|ksh\d+|yash|posh|fish|csh|tcsh)$/; + +/** + * A shell's own options after the shell word at `at`: where its operands start, and whether it runs a `-c` script + * or reads its program on stdin with `-s`. As in bash, each `o` or `O` in a cluster takes the next word, and `--` or a lone `-` ends the options. + */ +function shellOptions(words: string[], at: number): { operand: number; script: boolean; stdin: boolean } { + const found = { operand: at + 1, script: false, stdin: false }; + for (; found.operand < words.length && /^(?:-|[-+].+)$/.test(words[found.operand]); found.operand++) { + const word = words[found.operand]; + if (word === "--" || word === "-") return { ...found, operand: found.operand + 1 }; + if (word === "--rcfile" || word === "--init-file") found.operand++; + else if (/^[-+][A-Za-z]+$/.test(word)) { + found.operand += word.match(/[oO]/g)?.length ?? 0; + found.script ||= word[0] === "-" && word.includes("c"); + found.stdin ||= word[0] === "-" && word.includes("s"); + } + } + return found; +} + +/** The `-c` script of a shell between `from` and `to`, after the shell's own options, and the shell word's index. */ +function shellScript(words: string[], from: number, to: number): { at: number; script: string } | undefined { + for (let i = from; i < to; i++) { + if (!SHELL.test(words[i])) continue; + const { operand, script } = shellOptions(words, i); + if (script && words[operand] !== undefined) return { at: i, script: words[operand] }; + } + return undefined; +} + +/** A shell program a command reads on stdin. `expands`: the outer shell computes part of these bytes before the program runs. */ +interface StdinProgram { + text: string; + expands: boolean; +} + +/** + * What a command reads on stdin: a program the gate can read, `unseen` text such as another command's output, or + * `opaque` text: a descriptor copy, which can hold a heredoc or here-string opened anywhere in the command, a file, or + * output the outer shell computes. + */ +type Input = StdinProgram | "unseen" | "opaque"; + +/** + * The program text the last stdin redirection gives: a here-string, or a heredoc as the outer shell expands it. A file + * other than /dev/null, or a descriptor copy, is opaque, since a path or descriptor can reach a heredoc, a pipe, or a + * descriptor the gate cannot follow. + */ +function stdinText(seg: Segment, ctx: Ctx): StdinProgram | "opaque" | undefined { + const { scope } = ctx; + const last = given(seg).at(-1); + if (last?.kind === "file" && last.path === "/dev/null") return undefined; + if (last?.kind === "copy" || last?.kind === "file" || last?.kind === "other") return "opaque"; + if (last?.kind === "string") return { text: last.word, expands: last.word.includes("\0") }; + if (last?.kind !== "doc") return undefined; + const text = scope.docs[last.doc].marked ?? scope.docs[last.doc].text; + return { text, expands: text.includes("\0") }; +} + +/** Whether the command at `found` is a data command such as grep, which takes any later shell name as text. */ +const dataCommand = (words: string[], found: Head): boolean => found.at >= 0 && DATA.includes(words[found.at].replace(/^.*\//, "")); + +/** Whether a word of `seg` that `name` matches can run: any one, except an argument of a data command such as grep. */ +function runsWord(seg: Segment, scope: Scope, name: (word: string) => boolean): boolean { + const found = head(seg.words, (w) => computed(w, scope)); + return seg.words.some((w, i) => name(w) && (i === found.at || !dataCommand(seg.words, found))); +} + +/** Whether any command in the scope can be a shell that reads a program on stdin: a shell or ssh word, or a command name the shell computes. */ +const runsShell = (scope: Scope): boolean => + [scope.segments, ...scope.subs.map((s) => s.segments)] + .flat() + .some((s) => runsWord(s, scope, (w) => SHELL.test(w) || /^(?:.*\/)?ssh$/.test(w)) || !head(s.words, (w) => computed(w, scope)).sure); + +/** What a command reads on stdin as program text: its last redirection, else the pipeline part before it, else the stdin its shell was given. */ +function stdinOf(seg: Segment, ctx: Ctx): Input | undefined { + if (given(seg).length > 0) return stdinText(seg, ctx); + return seg.pipe ? piped(seg.pipe, ctx) : ctx.stdin; +} + +/** Whether the shell computes part of `word`: any expansion or substitution but a leading literal `~`. */ +const varies = (word: string): boolean => word.replace(MARKER, (t) => (t === HOME ? "" : t)).includes("\0"); + +/** The printf escapes the gate decodes. Any other `\` or `%` makes the output unseen. */ +const PRINTF: Record<string, string> = { "%%": "%", "\\\\": "\\", "\\n": "\n", "\\t": "\t" }; + +/** + * What a pipeline part prints, when the gate can read it: cat of its own stdin, or echo or printf of literal words. + * echo decodes backslash escapes in some shells, zsh and dash among them, so echo text with one is unseen. + */ +function piped(left: Segment, ctx: Ctx): Input { + const [cmd, ...rest] = left.words; + const args = cmd === "printf" && rest[0] === "--" ? rest.slice(1) : rest; + // Output the outer shell computes can be any program, whatever command or group prints it. + if ([left, ...(left.group ?? [])].some((s) => s.words.some(varies))) return "opaque"; + if (cmd === "cat" && args.length === 0) return stdinOf(left, ctx) ?? "unseen"; + if (cmd === "echo") { + const start = args.findIndex((w) => !/^-[neE]+$/.test(w)); + const text = start < 0 ? "" : args.slice(start).join(" "); + return text.includes("\\") ? "unseen" : { text, expands: false }; + } + if (cmd === "printf" && /^%s(?:\\n)?$/.test(args[0] ?? "") && args.length > 1) { + return { text: args.slice(1).map((a) => (args[0] === "%s" ? a : `${a}\n`)).join(""), expands: false }; + } + if (cmd === "printf" && args.length === 1 && !args[0].startsWith("-")) { + let known = true; + const text = args[0].replace(/%%|\\[\\nt]|[%\\]/g, (t) => { + known &&= t in PRINTF; + return PRINTF[t] ?? t; + }); + return known ? { text, expands: false } : "unseen"; + } + return "unseen"; +} + +/** Whether a command name in `scope` is one the shell computes, which can be gh when the outer shell computed the text. */ +const hidden = (scope: Scope): boolean => scope.segments.some((s) => !head(s.words, (w) => computed(w, scope)).sure); + +/** + * The PR commands a program read on stdin runs, scanned in `into`. The program is that stdin, so its own commands get none. + * A program the gate cannot read, such as an installer piped from curl, runs unread, like `eval`. + */ +function program(input: Input, seg: Segment, ctx: Ctx, into: Omit<Ctx, "scope">): PrCommand[] { + if (input === "unseen") return []; + const opaque: PrCommand[] = [{ ...at(seg, ctx), kind: "opaque", part: "program" }]; + if (input === "opaque") return opaque; + const scope = handOver([input.text], ctx.scope); + const inner = scan(scope.segments, { ...into, scope, stdin: undefined }); + // The outer shell expands this text before the program runs, so a write it shows is not the one gh receives, and a command name it computes can be gh. + return input.expands && (inner.length > 0 || hidden(scope)) ? opaque : inner; +} + +/** + * The index of a shell between `from` and `to` that reads its program on stdin: one with no `-c` script. Without `-s`, + * an operand, even one after `--`, names a script file, and stdin is that script's data. Shell words an earlier + * command takes as arguments can be a stdin shell too, the way a `bash -c` among them is still read. + */ +function stdinShell(words: string[], from: number, to: number): number | undefined { + for (let i = from; i < to; i++) { + if (!SHELL.test(words[i])) continue; + const { operand, script, stdin } = shellOptions(words, i); + return script || (!stdin && words[operand] !== undefined && !STDIN.test(words[operand])) ? undefined : i; + } + return undefined; +} + +/** + * Whether a command whose name the shell computes, at `at`, can be a shell that runs a PR command: one in a later word it + * could run as a `-c` script, after any `--name=` prefix, or in the program on its stdin. A file on its stdin is opaque, as it is to bash. + * A script word the outer shell computes can name gh once an option, written or inside a name that splits, makes it a script. + */ +function hidesShell(seg: Segment, at: number, ctx: Ctx): boolean { + const { words } = seg; + const input = stdinOf(seg, ctx); + if (input === "opaque" || (input !== undefined && program(input, seg, ctx, { ...ctx }).length > 0)) return true; + return words.slice(at + 1).some((word, i) => { + const text = word.replace(/^-[^=\0]*=/, ""); + const scope = handOver([text], ctx.scope); + if (scan(scope.segments, { ...ctx, scope, reads: undefined, stdin: undefined }).length > 0) return true; + const optioned = splits(words[at], ctx.scope) || words.slice(at + 1, at + 2 + i).some((w) => w.startsWith("-")); + return text.includes("\0") && optioned && hidden(scope); + }); +} + +/** + * The PR commands one simple command runs: the ones inside a `bash -c` script or `ssh`, a shell program the + * command reads on stdin, or a gh call. gh at the command head is read as gh reads it. gh anywhere else, or a + * command name the shell computes, could be a write a wrapper hides, so it is read as opaque. + */ +function command(seg: Segment, found: Head, ctx: Ctx): PrCommand[] { + const { words } = seg; + // A redirection after `)`, `}`, `done`, `fi`, or `esac` is the stdin of every command inside, so a shell there can read it. + if (seg.stdin.length > 0 && (words.length === 0 || ["}", "done", "fi", "esac"].includes(words[0]))) { + const last = given(seg).at(-1); + // A file is a program only to a shell that reads it, so a loop over a file's lines with no shell in the command is data. + if ((last?.kind === "file" || last?.kind === "other") && !runsShell(ctx.scope)) return []; + const input = stdinText(seg, ctx); + if (input && ctx.scope.segments.some((s) => runsWord(s, ctx.scope, (w) => FOREIGN.test(w)))) return crowd([{ ...at(seg, ctx), kind: "opaque", part: "shell" }]); + return input && program(input, seg, ctx, { ...ctx }).length > 0 ? crowd([{ ...at(seg, ctx), kind: "opaque", part: "program" }]) : []; + } + if (found.split) return [{ ...at(seg, ctx), kind: "opaque", part: "control" }]; + const lost = ctx.lost || found.chdir || undefined; + const from = Math.max(found.at, 0); + // A shell or ssh word after gh is a value gh reads, so only the words before the first gh can run one. + const gh = words.findIndex((t, i) => i >= from && isGh(t)); + const to = gh < 0 ? words.length : gh; + // A shell word a data command takes as an argument, as in `grep fish`, runs nothing. + const first = words.findIndex((w, i) => i >= from && i < to && SHELL.test(w)); + const listed = first !== found.at && dataCommand(words, found); + if (first >= 0 && !listed) { + const foreign = FOREIGN.test(words[first]) && (/^[-+]/.test(words[first + 1] ?? "") || stdinOf(seg, ctx) !== undefined); + const { operand, script } = shellOptions(words, first); + // A word the shell computes where the shell still reads options can be one, such as -c, unless it starts with literal text. + const option = words[operand] ?? ""; + const cluster = words.slice(first + 1, operand).some((w) => computed(w, ctx.scope)); + if (foreign || cluster || (!script && computed(option, ctx.scope) && (option.startsWith("\0") || splits(option, ctx.scope)))) { + return [{ ...at(seg, ctx), kind: "opaque", part: "shell" }]; + } + } + const shell = shellScript(words, from, to); + // ssh hands its words to the remote shell, which joins them and reads them again, so a shell among them is read there. + const ssh = remote(seg, ctx, from, shell?.at ?? to); + if (ssh) return ssh; + if (shell) { + const scope = handOver([shell.script], ctx.scope); + if (shell.script.includes("\0") && hidden(scope)) return [{ ...at(seg, ctx), kind: "opaque", part: "program" }]; + return scan(scope.segments, { ...ctx, scope, lost, wrapped: ctx.wrapped || shell.at !== 0, stdin: stdinOf(seg, ctx) }); + } + const name = found.at < 0 ? undefined : words[found.at]; + const reader = stdinShell(words, from, to); + const input = reader === undefined ? undefined : stdinOf(seg, ctx); + // A shell name a data command takes as an argument, as in `echo x | grep bash`, reads no program. + const data = reader !== found.at && dataCommand(words, found); + if (input !== undefined && !data && (input !== "unseen" || reader === found.at)) { + return program(input, seg, ctx, { ...ctx, lost, wrapped: ctx.wrapped || found.at !== 0 }); + } + if (name !== undefined && found.sure && isGh(name)) { + const cmd = ghCommand({ ...seg, words: words.slice(found.at) }, { ...ctx, lost, known: !ctx.wrapped && found.at === 0 }, false); + return cmd ? [cmd] : []; + } + if (!found.sure && found.at >= 0 && hidesShell(seg, found.at, ctx)) return [{ ...at(seg, ctx), kind: "opaque", part: "shell" }]; + const start = gh >= 0 ? gh : found.sure ? -1 : found.at; + if (start < 0) return []; + const cmd = ghCommand({ ...seg, words: words.slice(start) }, { ...ctx, lost, known: false }, true); + return cmd ? [cmd] : []; +} diff --git a/.claude/skills/open-pr/context.ts b/.claude/skills/open-pr/context.ts new file mode 100644 index 0000000..e2a38b8 --- /dev/null +++ b/.claude/skills/open-pr/context.ts @@ -0,0 +1,173 @@ +/** + * Where a command runs and which file bytes gh reads, as far as a static read of the command can prove it. + * command.ts reads the words. This module finds a simple command's head after prefixes, decides what a cd + * does to the directory and what a merge leaves to the environment, and names the file each read opens. + * Pure: it never reads files. The gate reads the files this module names. + */ + +import { posix } from "node:path"; + +export type BodySource = + /** + * `expands`: text the shell changes before gh reads it, so `text` is not what gh sends. `written`: the absolute name of + * the file a `cat > f <<'EOF'` puts this text in, when gh reads it back from there. + */ + | { kind: "inline"; text: string; expands?: true; written?: string } + /** `chomp`: read through `$(cat f)`, which drops the file's trailing newlines. */ + | { kind: "file"; path: string; chomp?: true } + | { kind: "missing" }; + +/** Where a command runs, as far as the parser can follow it. */ +export interface Where { + /** The hook's directory and home, so file names resolve to absolute ones. `cdpath`: CDPATH is set, so a bare relative cd can go elsewhere. */ + base: { cwd: string; home: string; cdpath: boolean }; + /** The directory literal cds led to, relative to the hook's. */ + dir?: string; + /** A cd, pushd, popd, source, or eval changed the directory in a way the parser does not follow. */ + lost?: boolean; +} + +/** The file the one `cat > f <<'EOF'` before a PR command writes: its absolute name and the heredoc's text. */ +export interface Written { + key: string; + source: BodySource; +} + +/** What gh reads for `-F key=@path` once the shell gives the path: stdin for `-`, and the file this command writes before gh runs. */ +export interface AtFiles { + stdin: BodySource; + written?: Written; + where: Where; +} + +/** + * The absolute name of `path` where a command runs. Undefined for a relative path in a directory the parser + * lost, and for a path with `..`, which the OS resolves through symlinks, not by its spelling. + */ +export function fileKey(path: string, where: Where): string | undefined { + const { cwd, home } = where.base; + const abs = (p: string, from: string): string => (p.startsWith("/") ? p : p === "~" || p.startsWith("~/") ? home + p.slice(1) : posix.join(from, p)); + if ((where.lost && !/^[/~]/.test(path)) || path.split("/").includes("..")) return undefined; + return posix.normalize(abs(path, abs(where.dir ?? ".", cwd))); +} + +/** The file gh reads: the heredoc's text when this command writes it by the same name first, else the file on disk. */ +export function readFile(path: string, written: Written | undefined, where: Where): BodySource { + const key = fileKey(path, where); + return written !== undefined && key === written.key ? written.source : { kind: "file", path }; +} + +/** The text gh sends for an `@path` value. The shell does not touch text a substitution gives, so a leading `~` is a relative name. */ +export function atSource(path: string, at: AtFiles): BodySource { + if (path === "-") return at.stdin; + return readFile(path.startsWith("~") ? "./" + path : path, at.written, at.where); +} + +/** Variables that change where gh reads files, which repo it picks, or which host and config it uses. */ +export const CONTEXT = ["HOME", "GH_REPO", "GH_HOST", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "CDPATH"]; +const HOST_CONTEXT = ["*", "HOME", "GH_HOST", "GH_CONFIG_DIR", "XDG_CONFIG_HOME"]; +/** Builtins and commands that set variables, and ones that run shell code the parser does not read. */ +export const SETTERS = ["export", "declare", "typeset", "local", "readonly", "read", "unset", "env", "getopts", "mapfile", "readarray"]; +export const RUNS_CODE = ["eval", "source", "."]; +/** A shell variable assignment word, `NAME=value`. */ +export const ASSIGN = /^[A-Za-z_]\w*=/; + +/** What a merge leaves to the environment: the host and config, or the repo or PR when it takes them from a directory the gate cannot know. */ +export function drift(names: Set<string>, needsCwd: boolean, url: boolean, at: { known?: boolean; lost?: boolean }): "repo" | "host" | undefined { + if (!url && HOST_CONTEXT.some((n) => names.has(n))) return "host"; + return needsCwd && (!at.known || at.lost) ? "repo" : undefined; +} + +/** Words after which bash still reads a command name in this shell: reserved words, `builtin`, `command`, and `time`. */ +export const KEYWORDS = ["!", "{", "if", "then", "else", "elif", "do", "while", "until", "time", "builtin", "command"]; +const KEYWORD_FLAGS: Record<string, string[]> = { time: ["-p"], command: ["-p", "-v", "-V"] }; + +/** Wrappers that run the command as their own child: options that take a value, ones that do not, and words before the command. */ +const WRAPPERS: Record<string, { valued: string[]; flags: string[]; positional: number; split?: true }> = { + env: { valued: ["-u", "--unset", "-C", "--chdir"], flags: ["-i", "-0", "-v", "--ignore-environment", "--null", "--debug"], positional: 0, split: true }, + nohup: { valued: [], flags: [], positional: 0 }, + timeout: { valued: ["-k", "--kill-after", "-s", "--signal"], flags: ["--preserve-status", "--foreground", "-v", "--verbose"], positional: 1 }, + nice: { valued: ["-n", "--adjustment"], flags: [], positional: 0 }, + exec: { valued: ["-a"], flags: ["-c", "-l"], positional: 0 }, +}; + +/** The command a simple command runs, after its assignments, keywords, and wrappers. */ +export interface Head { + /** The command word's index, or -1 for a command of assignments only. */ + at: number; + /** Words came before it. */ + prefixed: boolean; + /** A wrapper changes the directory it runs in (`env -C`). */ + chdir: boolean; + /** Every prefix and option was read, and the command word is fixed text. */ + sure: boolean; + /** A wrapper splits text into the command it runs by rules the gate does not read, as `env -S` does. */ + split?: true; +} + +/** env's long options as GNU getopt reads them, by any unique prefix, and whether each takes the next word as its value. */ +const ENV_LONG: Record<string, boolean> = { "--unset": true, "--chdir": true, "--ignore-environment": false, "--null": false, "--debug": false }; + +/** Whether env's options up to its first operand can turn on -S, or hold one the gate does not know, read as getopt reads them. */ +function envSplits(words: string[], from: number): boolean { + for (let i = from; i < words.length && words[i].startsWith("-") && words[i] !== "--"; i++) { + const word = words[i]; + if (word.startsWith("--")) { + const [name, value] = [word.split("=")[0], word.includes("=")]; + const known = Object.keys(ENV_LONG).filter((long) => long.startsWith(name)); + if (known.length !== 1) return true; + if (ENV_LONG[known[0]] && !value) i++; + continue; + } + const cluster = /^-[iv0]*(?:([uC])(.*))?$/.exec(word); + if (!cluster) return true; + if (cluster[1] && cluster[2] === "") i++; + } + return false; +} + +/** Finds the command head. A prefix option the table does not know, or a command word the shell computes, leaves it unsure. */ +export function head(words: string[], computed: (word: string) => boolean): Head { + const found: Head = { at: -1, prefixed: false, chdir: false, sure: true }; + let i = 0; + for (;;) { + while (i < words.length && ASSIGN.test(words[i])) i++; + const word = words[i]; + if (word === undefined) return { ...found, prefixed: found.prefixed || i > 0 }; + found.prefixed ||= i > 0; + if (computed(word)) return { ...found, at: i, sure: false }; + const wrapper = WRAPPERS[word.replace(/^.*\//, "")]; + if (wrapper?.split && envSplits(words, i + 1)) return { ...found, at: i, sure: false, split: true }; + if (!wrapper && !KEYWORDS.includes(word)) return { ...found, at: i }; + found.prefixed = true; + const valued = wrapper?.valued ?? []; + const flags = wrapper?.flags ?? KEYWORD_FLAGS[word] ?? []; + for (i++; i < words.length && /^-./.test(words[i]); i++) { + const name = words[i].split("=")[0]; + if (words[i] === "--") { + i++; + break; + } + if (valued.includes(name)) { + found.chdir ||= name === "-C" || name === "--chdir"; + if (!words[i].includes("=")) i++; + } else if (!flags.includes(words[i])) return { ...found, at: i, sure: false }; + } + i += wrapper?.positional ?? 0; + } +} + +/** What a command does to the directory of the shell it runs in. */ +export type Move = { kind: "follow"; to: string } | { kind: "keep" } | { kind: "lose" }; + +/** + * What a cd does to the directory. Only `cd <dir>` with nothing around it, which the shell surely runs, is followed. + * A relative name that does not start with `.` or `..` goes through CDPATH when it is set, so it is lost then. Any + * other cd loses the directory. Any other command keeps it, since one that can change it crowds any later PR write that + * reads a file, and any later merge. + */ +export function move(words: string[], found: Head, always: boolean, place: (word: string) => string | undefined, cdpath: boolean): Move { + if (found.at < 0 || !found.sure || words[found.at] !== "cd") return { kind: "keep" }; + const to = always && !found.prefixed && words.length === 2 ? place(words[1]) : undefined; + return to !== undefined && !to.startsWith("-") && (/^(?:[/~]|\.\.?(?:\/|$))/.test(to) || !cdpath) ? { kind: "follow", to } : { kind: "lose" }; +} diff --git a/.claude/skills/open-pr/contract.ts b/.claude/skills/open-pr/contract.ts new file mode 100644 index 0000000..6f3d0d9 --- /dev/null +++ b/.claude/skills/open-pr/contract.ts @@ -0,0 +1,226 @@ +/** + * The PR body contract: the shape every pull request must have before it opens or merges. + * Pure functions only, so the hook, the CLI, and CI all run the same rules. + * + * The body is five fixed sections in order, with nothing above the first one: + * Problem, Change, Proof, Review, Merge danger + * and its last line names the model and harness: `Changes made by <model> via <harness>`. + */ + +export const SECTIONS = ["Problem", "Change", "Proof", "Review", "Merge danger"] as const; +export type Section = (typeof SECTIONS)[number]; + +export type Door = "one-way" | "two-way"; + +export interface PullRequest { + title?: string; + body: string; + draft?: boolean; +} + +export type Rule = + | "draft" + | "title" + | "preamble" + | "section" + | "proof" + | "review" + | "door" + | "blast-radius" + | "style" + | "blurb"; + +export interface Violation { + rule: Rule; + message: string; +} + +export type Verdict = + | { ok: true; door: Door; blastRadius: string } + | { ok: false; violations: Violation[] }; + +const TITLE = /^(?:\p{Extended_Pictographic}️?\s+)?(feat|fix|docs|test|chore|refactor|perf|ci|build|style|revert)(\([A-Za-z0-9][A-Za-z0-9/._-]*\))?!?: \S.*$/u; +const BLURB = /^Changes made by [^<\s].* via [^<\s].*$/; +const DOOR = /^Door:\s*(one-way|two-way)\b/i; +const BLAST = /^Blast radius:\s*([^\s.,]+)/i; +const DISPOSITION = /^(?:Fix|Decline)\b|^Defer #\d+\b/; +const MAX_WORDS = 25; +const MAX_PROBLEM_SENTENCES = 4; + +const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/; + +/** + * Marks each line that belongs to a fenced code block, fence lines included. + * CommonMark rules: backticks or tildes, three or more, up to three spaces of indent. + * Only the same character, at least as long as the opener, closes the block. + * An unclosed block runs to the end of the text. + */ +function fenced(lines: string[]): boolean[] { + let open: string | null = null; + return lines.map((line) => { + const m = line.match(FENCE); + if (open === null) { + if (!m || (m[1][0] === "`" && m[2].includes("`"))) return false; + open = m[1]; + return true; + } + if (m && m[1][0] === open[0] && m[1].length >= open.length && !m[2].trim()) open = null; + return true; + }); +} + +/** Removes fenced code blocks, so prose rules never fire on code. */ +function stripFences(text: string): string { + const lines = text.split(/\r?\n/); + const mask = fenced(lines); + return lines.filter((_, i) => !mask[i]).join("\n"); +} + +/** Bold and italic markers would hide `Door:` from the line rules. */ +function plain(line: string): string { + return line.replace(/[*_`]/g, "").trim(); +} + +interface Parsed { + preamble: string; + sections: Array<{ heading: string; text: string }>; +} + +function parse(body: string): Parsed { + const out: Parsed = { preamble: "", sections: [] }; + const lines = body.split(/\r?\n/); + const mask = fenced(lines); + for (const [i, line] of lines.entries()) { + const heading = !mask[i] && line.match(/^##\s+(.+?)\s*#*\s*$/); + if (heading) { + out.sections.push({ heading: heading[1], text: "" }); + continue; + } + const last = out.sections.at(-1); + if (last) last.text += line + "\n"; + else out.preamble += line + "\n"; + } + return out; +} + +function sentences(text: string): string[] { + return stripFences(text) + .split("\n") + .filter((l) => l.trim() && !/^\s*([|>#-]|\d+\.|!\[|<)/.test(l)) + .join(" ") + .replace(/\b(e\.g|i\.e|etc|vs)\./gi, "$1") + .split(/(?<=[.!?])\s+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function words(sentence: string): number { + return sentence.replace(/`[^`]*`/g, "x").split(/\s+/).filter(Boolean).length; +} + +/** "No findings." passes only as the whole section. Anything else needs the table. */ +function checkReview(text: string): Violation | null { + if (text.trim() === "No findings.") return null; + const rows = text.split("\n").filter((l) => l.trim().startsWith("|")); + const header = rows[0]?.split("|").map((c) => c.trim().toLowerCase()); + const col = header?.indexOf("disposition") ?? -1; + if (col < 0) { + return { + rule: "review", + message: 'Review needs a table with a "Disposition" column, or "No findings." alone as the whole section after a real self-review.', + }; + } + const data = rows.slice(1).filter((r) => !/^\|?[\s:|-]+\|?$/.test(r.trim())); + if (data.length === 0) return { rule: "review", message: "the Review table has no rows. Write \"No findings.\" when the review found nothing." }; + const bad = data.filter((r) => !DISPOSITION.test(r.split("|")[col]?.trim() ?? "")); + if (bad.length > 0) { + return { rule: "review", message: `each Review row starts its disposition with Fix, Decline, or Defer #<issue>. Bad row: ${bad[0].trim()}` }; + } + return null; +} + +/** The title rule alone, for an edit that changes only the title. */ +export function titleViolation(title: string): Violation | null { + if (TITLE.test(title.trim())) return null; + return { rule: "title", message: `title ${JSON.stringify(title)} is not conventional-commit shaped, for example "fix(web): threads no longer spike CPU".` }; +} + +/** Checks one pull request against the contract. Every violation carries the fix in its message. */ +// CodeRabbit appends its release notes inside these markers after the author's body. +const CODERABBIT_SUMMARY = + /\n*<!-- This is an auto-generated comment: release notes by coderabbit\.ai -->[\s\S]*?<!-- end of auto-generated comment: release notes by coderabbit\.ai -->\n*/g; + +export function checkPullRequest(input: PullRequest): Verdict { + const pr = { ...input, body: input.body.replace(CODERABBIT_SUMMARY, "\n") }; + const v: Violation[] = []; + if (pr.draft) v.push({ rule: "draft", message: "drafts get no review-bot coverage. Open the PR ready for review." }); + const title = pr.title === undefined ? null : titleViolation(pr.title); + if (title) v.push(title); + + const { preamble, sections } = parse(pr.body); + if (preamble.trim() || (sections[0] && sections[0].heading.toLowerCase() !== "problem")) { + v.push({ rule: "preamble", message: 'the body starts with "## Problem". Delete the text and headings above it.' }); + } + + const found = new Map<Section, string>(); + let cursor = -1; + for (const name of SECTIONS) { + const at = sections.findIndex((s, i) => i > cursor && s.heading.toLowerCase() === name.toLowerCase()); + if (at < 0) { + const exists = sections.some((s) => s.heading.toLowerCase() === name.toLowerCase()); + v.push({ rule: "section", message: exists ? `"## ${name}" is out of order. The order is ${SECTIONS.join(", ")}.` : `"## ${name}" is missing.` }); + continue; + } + cursor = at; + const text = sections[at].text; + if (!text.trim()) v.push({ rule: "section", message: `"## ${name}" is empty.` }); + found.set(name, text); + } + + const proof = found.get("Proof"); + if (proof?.trim() && !/^\s*\|.*\|/m.test(proof) && !fenced(proof.split("\n")).includes(true) && !/https?:\/\//.test(proof) && !/!\[/.test(proof)) { + v.push({ rule: "proof", message: "Proof needs evidence that could fail: a before and after table, command output in a code block, or a screenshot or video link." }); + } + + const review = found.get("Review"); + if (review?.trim()) { + const r = checkReview(review); + if (r) v.push(r); + } + + let door: Door | undefined; + let blastRadius: string | undefined; + const danger = found.get("Merge danger"); + if (danger !== undefined) { + for (const line of danger.split("\n").map(plain)) { + const d = line.match(DOOR)?.[1]?.toLowerCase(); + if (d === "one-way" || d === "two-way") door ??= d; + blastRadius ??= line.match(BLAST)?.[1]; + } + if (!door) v.push({ rule: "door", message: 'Merge danger needs a line "Door: one-way" or "Door: two-way".' }); + if (!blastRadius) v.push({ rule: "blast-radius", message: 'Merge danger needs a line "Blast radius: <one word>." followed by what outside the diff could break.' }); + } + + if (stripFences(pr.body).includes("—")) v.push({ rule: "style", message: "the body has an em dash. Use a period or a comma." }); + for (const name of ["Problem", "Merge danger"] as const) { + const text = found.get(name); + if (!text) continue; + const s = sentences(text); + const long = s.find((x) => words(x) > MAX_WORDS); + if (long) v.push({ rule: "style", message: `a ${name} sentence has ${words(long)} words. The limit is ${MAX_WORDS}: "${long.slice(0, 80)}..."` }); + if (name === "Problem" && s.length > MAX_PROBLEM_SENTENCES) { + v.push({ rule: "style", message: `Problem has ${s.length} sentences. State it in at most ${MAX_PROBLEM_SENTENCES}.` }); + } + } + + const last = pr.body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).at(-1) ?? ""; + if (!BLURB.test(last)) v.push({ rule: "blurb", message: 'the last line is exactly "Changes made by <model> via <harness>", for example "Changes made by Claude Opus 5.5 via Claude Code".' }); + + if (v.length > 0 || !door || !blastRadius) return { ok: false, violations: v }; + return { ok: true, door, blastRadius }; +} + +/** One message block for a refused PR, used by the hook and the CLI alike. */ +export function formatViolations(violations: Violation[]): string { + return violations.map((x) => `- [${x.rule}] ${x.message}`).join("\n"); +} diff --git a/.claude/skills/open-pr/gate.ts b/.claude/skills/open-pr/gate.ts new file mode 100644 index 0000000..6ae2b8f --- /dev/null +++ b/.claude/skills/open-pr/gate.ts @@ -0,0 +1,380 @@ +#!/usr/bin/env bun +/** + * PreToolUse (Bash) hook: every PR create, edit, and merge goes through the PR kit. + * + * - create and edit: the body must pass the contract in contract.ts. + * - merge: the PR body must pass, and the review bot must have passed the exact head + * (land-pr/merge.ts). + * + * - graphql: a PR create, update, or merge through `gh api graphql` is refused, and so + * is any GraphQL call whose query this gate cannot read. + * + * Two installs run this same file. A repo carries it at .claude/skills/open-pr/gate.ts, + * wired in the repo's .claude/settings.json, so cloud sessions get it. The MacBook also + * wires the kit's source copy globally. The global copy never + * stands down: in a repo with its own copy both run, and both check the same contract, + * so a repo cannot swap in a weaker gate. + * + * Exit 0 allows, exit 2 denies with the reason on stderr. A PR write that cannot be + * checked is denied, never passed. Claude Code lets the tool call run when a hook times + * out, fails to start, or exits with another code, so kit.ts wires the hook with + * `onFailure: "block"`. PR_KIT_OFF=1 is Ossie's switch, never an agent's. + */ + +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { checkPullRequest, formatViolations, titleViolation, type PullRequest } from "./contract"; +import { atSource, findPrCommands, graphqlRisk, type BodySource, type FieldValue, type HeadMatch, type PrCommand } from "./command"; +import { botExempt, decideMerge, PASS_SIGNAL, verdictOf, type CheckRun } from "../land-pr/merge"; + +interface HookInput { + tool: string; + command: string; + cwd: string; +} + +function parseInput(raw: string): HookInput { + const x: unknown = raw.trim() ? JSON.parse(raw) : {}; + const obj = isObj(x) ? x : {}; + const toolInput = isObj(obj.tool_input) ? obj.tool_input : {}; + return { + tool: isStr(obj.tool_name) ? obj.tool_name : "", + command: isStr(toolInput.command) ? toolInput.command : "", + cwd: isStr(obj.cwd) ? obj.cwd : process.cwd(), + }; +} + +class Refusal extends Error {} + +function run(argv: string[], cwd: string): string { + const r = Bun.spawnSync(argv, { cwd, stdout: "pipe", stderr: "pipe", timeout: 8_000 }); + if (r.exitCode !== 0) throw new Refusal(`\`${argv.join(" ")}\` failed: ${(r.stderr.toString() || r.stdout.toString()).trim()}`); + return r.stdout.toString().trim(); +} + +const ghJson = (path: string, cwd: string): unknown => JSON.parse(run(["gh", "api", path], cwd)); + +type Json = Record<string, unknown>; +const isObj = (x: unknown): x is Json => typeof x === "object" && x !== null && !Array.isArray(x); + +function field<T>(obj: unknown, key: string, guard: (v: unknown) => v is T, what: string): T { + const v = isObj(obj) ? obj[key] : undefined; + if (!guard(v)) throw new Refusal(`GitHub returned ${what} without a valid "${key}".`); + return v; +} +const isStr = (v: unknown): v is string => typeof v === "string"; +const isNum = (v: unknown): v is number => typeof v === "number"; +const isBool = (v: unknown): v is boolean => typeof v === "boolean"; +const isArr = (v: unknown): v is unknown[] => Array.isArray(v); +const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === "string"; + +/** A directory as bash's cd enters it: `..` is taken by spelling, as cd does by default. */ +function expand(path: string, cwd: string): string { + return resolve(cwd, path.replace(/^~(?=\/|$)/, homedir())); +} + +/** A file as the OS opens it: joined, never collapsed, so `..` after a symlink leaves the symlink's target. */ +function open(path: string, cwd: string): string { + const full = path.replace(/^~(?=\/|$)/, homedir()); + return full.startsWith("/") ? full : `${cwd}/${full}`; +} + +const ALONE = + "the PR command does not run on its own. Run it as the only command in this call, after at most a literal cd, a set -euxo pipefail, and one cat > <file> <<'EOF' that writes its body file and that && joins to it, and gh reads no other file. Before gh, assign no HOME, GH_REPO, GH_HOST, GH_CONFIG_DIR, XDG_CONFIG_HOME, or CDPATH. You may pipe its output into a read-only filter such as tail or jq."; +const LOST = "the command changes directory in a way this gate cannot follow (pushd, popd, source, eval, a cd after a prefix word or inside braces, or a cd that CDPATH can redirect), so a relative path can name another file. Use an absolute path, or cd to a literal path that starts with / or ./."; +const DRIFT = { + repo: "gh takes this merge's repo or PR from the current directory, and a word before gh or a directory change this gate cannot follow can make that differ from what this gate checks. Name the PR with its number and -R owner/name, or its github.com URL.", + host: "the command can change the host or config gh uses (GH_HOST, GH_CONFIG_DIR, XDG_CONFIG_HOME, HOME, or a variable it computes), so gh can merge somewhere this gate does not check. Leave them unset in a merge command, or name the PR by its github.com URL.", +}; +const NO_BODY = "the command sets no body. Write the body with the open-pr skill and pass it with --body-file."; +const EXPANDED = "the command holds `$`, a backquote, `~`, or an escape that a shell expands after this gate reads it, so the gate cannot see what gh gets. Write the text as gh should get it: quote a heredoc delimiter (<<'EOF'), and put an ssh or bash -c command in single quotes."; +const OPAQUE: Record<"control" | "field" | "program" | "shell", string> = { + field: "a -f or -F field name comes from the shell, so this gate cannot tell which PR fields the request sets. Write each field name as text.", + control: "the shell computes the method or a flag of this PR write, or can split an unquoted `$...` into more flags, so this gate cannot tell whether it writes a PR or what it sends. Write the method and every flag as text, and double-quote each `$...`.", + program: "a shell reads its program from an unquoted heredoc, a here-string, a -c script, an ssh command, or echo or printf output that the outer shell expands first, a descriptor copy such as <&3, or a file through < or <>, which can be a path to a heredoc, a pipe, or a descriptor, so this gate cannot see the PR write it runs. Quote the heredoc delimiter (<<'EOF') and give it to the shell directly, or run the PR command on its own.", + shell: "this gate cannot read the options of the shell this command runs: fish, csh, tcsh, mksh, yash, or posh given an option or stdin, a shell given an option word the shell computes, such as \"$opts\", or a command name the shell computes, which can be a shell. So it cannot see the PR write that shell runs. Run the PR command in bash with every option written as text, or on its own.", +}; + +/** + * Whether cat writing `path` leaves gh a plain file: one already there, or a new name cat creates. Under /dev and /proc a + * name such as /dev/fd/3 can exist for the shell's process alone, so a missing one there is not a new file. + */ +function plainFile(path: string): boolean { + try { + const stat = lstatSync(path, { throwIfNoEntry: false }); + return stat === undefined ? !/^\/(?:dev|proc)(?:\/|$)/.test(realpathSync(dirname(path))) : stat.isFile(); + } catch { + return false; + } +} + +function readBody(source: BodySource, cmd: PrCommand, cwd: string): string { + if (source.kind === "inline" && source.expands) throw new Refusal(EXPANDED); + if (source.kind === "inline" && source.written !== undefined) { + if (cmd.host) throw new Refusal(`the body heredoc writes a file on ${cmd.host}, so this gate cannot tell whether it is a plain file. Pass the body inline with a heredoc.`); + if (!plainFile(source.written)) throw new Refusal(`${source.written} is not a plain file, so this gate cannot know what cat writes into it. Write the body to a new plain file.`); + } + if (source.kind === "inline") return source.text; + if (source.kind === "missing") throw new Refusal(NO_BODY); + if (cmd.host) throw new Refusal(`the body file ${source.path} is on ${cmd.host}, so this gate cannot read it. Pass the body inline with a heredoc.`); + if (cmd.lost && !/^[/~]/.test(source.path)) throw new Refusal(LOST); + const file = open(source.path, cwd); + if (!existsSync(file)) throw new Refusal(`the body file ${file} does not exist yet. Write it in an earlier command.`); + const text = readFileSync(file, "utf8"); + return source.chomp ? text.replace(/\n+$/, "") : text; +} + +/** The PR fields a create or edit sets. An absent field is undefined, and on an edit it keeps the PR's value. */ +interface PrFields { + title?: string; + draft?: boolean; + body?: string; +} + +/** The fields an `--input` JSON request sets. */ +function readRequest(cmd: Extract<PrCommand, { kind: "input" }>, cwd: string): PrFields { + if (cmd.request.kind === "missing") throw new Refusal("this gate cannot read the --input request. Pass it in a heredoc or a file."); + const text = readBody(cmd.request, cmd, cwd); + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new Refusal("the --input request is not valid JSON, so this gate cannot read the PR fields it sets."); + } + if (!isObj(json)) throw new Refusal("the --input request is not a JSON object, so this gate cannot read the PR fields it sets."); + const keys = topLevelKeys(text); + const twice = ["title", "draft", "body"].find((k) => keys.indexOf(k) !== keys.lastIndexOf(k)); + if (twice) throw new Refusal(`the --input request sets "${twice}" twice, so this gate and GitHub could each keep a different one.`); + return typedFields(json); +} + +/** The JSON gh sends for one `-f` or `-F` field. */ +function fieldJson({ source, typed, at }: FieldValue, cmd: PrCommand, cwd: string): unknown { + const text = readBody(source, cmd, cwd); + // gh reads `@path` in the text a file gave, and sends that file's text as a string. + if (at && text.startsWith("@")) return readBody(atSource(text.slice(1), at), cmd, cwd); + return typed ? ghTyped(text) : text; +} + +/** The JSON the `-f`/`-F` flags of a REST write send, read into the same fields as an `--input` request. */ +function readFields(cmd: Extract<PrCommand, { kind: "fields" }>, cwd: string): PrFields { + const json: Json = {}; + for (const [key, value] of Object.entries(cmd.fields)) json[key] = fieldJson(value, cmd, cwd); + return typedFields(json); +} + +/** The JSON gh 2.101 sends for a `-F` value: an integer, true, false, or null, and any other text as a string. */ +function ghTyped(text: string): unknown { + if (/^[+-]?\d+$/.test(text)) return Number(text); + if (text === "true" || text === "false") return text === "true"; + return text === "null" ? null : text; +} + +/** The PR fields a REST request sets. A null body is an empty one. Any other wrong type is refused. */ +function typedFields(json: Json): PrFields { + const body = optional(json, "body", isStrOrNull); + return { title: optional(json, "title", isStr), draft: optional(json, "draft", isBool), body: body === null ? "" : body }; +} + +/** The keys of a valid JSON object's outermost level, in order, repeats kept. JSON.parse keeps only the last. */ +function topLevelKeys(text: string): string[] { + const keys: string[] = []; + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '"') { + let end = i + 1; + while (text[end] !== '"') end += text[end] === "\\" ? 2 : 1; + const key: unknown = depth === 1 && /^\s*:/.test(text.slice(end + 1)) ? JSON.parse(text.slice(i, end + 1)) : null; + if (isStr(key)) keys.push(key); + i = end; + } else if (text[i] === "{" || text[i] === "[") depth++; + else if (text[i] === "}" || text[i] === "]") depth--; + } + return keys; +} + +function optional<T>(obj: Json, key: string, guard: (v: unknown) => v is T): T | undefined { + if (!Object.hasOwn(obj, key)) return undefined; + if (!guard(obj[key])) throw new Refusal(`the request sets "${key}" to ${JSON.stringify(obj[key])}, which GitHub does not take as a PR ${key}.`); + return obj[key]; +} + +function contract(pr: PullRequest): void { + const verdict = checkPullRequest(pr); + if (!verdict.ok) throw new Refusal(`the PR body breaks the contract:\n${formatViolations(verdict.violations)}`); +} + +function checkFields(write: "create" | "edit", fields: PrFields): void { + if (write === "create") { + if (fields.body === undefined) throw new Refusal(NO_BODY); + return contract({ title: fields.title, draft: fields.draft, body: fields.body }); + } + if (fields.body !== undefined) return contract({ title: fields.title, body: fields.body }); + const title = fields.title === undefined ? null : titleViolation(fields.title); + if (title) throw new Refusal(`the PR title breaks the contract:\n${formatViolations([title])}`); +} + +interface RestPull { + number: number; + title: string; + body: string | null; + draft: boolean; + headSha: string; + baseSha: string; + baseRepo: string; +} + +function parsePull(x: unknown): RestPull { + return { + number: field(x, "number", isNum, "a PR"), + title: field(x, "title", isStr, "a PR"), + body: field(x, "body", isStrOrNull, "a PR"), + draft: field(x, "draft", isBool, "a PR"), + headSha: field(field(x, "head", isObj, "a PR"), "sha", isStr, "a PR head"), + baseSha: field(field(x, "base", isObj, "a PR"), "sha", isStr, "a PR base"), + baseRepo: field(field(field(x, "base", isObj, "a PR"), "repo", isObj, "a PR base"), "full_name", isStr, "a PR base repo"), + }; +} + +function parseRun(x: unknown): CheckRun { + return { + name: field(x, "name", isStr, "a check-run"), + status: field(x, "status", isStr, "a check-run"), + conclusion: field(x, "conclusion", isStrOrNull, "a check-run"), + app: field(field(x, "app", isObj, "a check-run"), "slug", isStr, "a check-run app"), + }; +} + +function resolvePull(cmd: Extract<PrCommand, { kind: "merge" }>, cwd: string): { repo: string; pull: RestPull } { + const url = cmd.target?.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); + let repo = url?.[1] ?? cmd.repo; + if (!repo) { + if (cmd.host) throw new Refusal(`a merge over ssh on ${cmd.host} must name the repo with --repo owner/name, so the gate can check it.`); + repo = run(["gh", "api", "repos/{owner}/{repo}", "--jq", ".full_name"], cwd); + } + const number = url?.[2] ?? (cmd.target && /^#?\d+$/.test(cmd.target) ? cmd.target.replace("#", "") : undefined); + if (number) return { repo, pull: parsePull(ghJson(`repos/${repo}/pulls/${number}`, cwd)) }; + if (cmd.host && !cmd.target) throw new Refusal(`a merge over ssh on ${cmd.host} must name the PR number.`); + const branch = cmd.target ?? run(["git", "branch", "--show-current"], cwd); + const owner = repo.split("/")[0]; + const open = ghJson(`repos/${repo}/pulls?state=open&head=${owner}:${encodeURIComponent(branch)}`, cwd); + if (!isArr(open) || open.length === 0) throw new Refusal(`no open PR in ${repo} has head branch ${branch}.`); + return { repo, pull: parsePull(open[0]) }; +} + +function latestRuns(repo: string, sha: string, cwd: string): CheckRun[] { + const page = ghJson(`repos/${repo}/commits/${sha}/check-runs?check_name=${PASS_SIGNAL}&filter=latest`, cwd); + return field(page, "check_runs", isArr, "a check-run list").map(parseRun); +} + +const UNREADABLE = "this gate cannot read the GraphQL query, so it cannot prove the call neither writes nor merges a PR. Pass the query as a literal with values as GraphQL variables (`-F name=value`), or use `gh pr` or the REST pulls endpoint."; + +/** The GraphQL text of a `graphql` command, read as gh sends the query field or `--input` file, or null when this gate cannot read it. */ +function graphqlText(cmd: Extract<PrCommand, { kind: "graphql" }>, cwd: string): string | null { + let raw: unknown; + try { + raw = fieldJson(cmd.query, cmd, cwd); + } catch (err) { + if (err instanceof Refusal) return null; + throw err; + } + if (!isStr(raw)) return null; + if (!cmd.json) return raw; + try { + const json: unknown = JSON.parse(raw); + return isObj(json) && isStr(json.query) ? json.query : null; + } catch { + return null; + } +} + +function checkGraphql(cmd: Extract<PrCommand, { kind: "graphql" }>, cwd: string): void { + const text = graphqlText(cmd, cwd); + // A JSON request can carry its own operationName, and an unquoted heredoc expands `$`. + const risk = text === null ? "opaque" : graphqlRisk(text, cmd.operationName || cmd.json); + if (risk === "merge") throw new Refusal("a GraphQL merge skips the policy check. Merge with `gh pr merge <n>` or the REST merge endpoint."); + if (risk === "write") throw new Refusal("a GraphQL createPullRequest or updatePullRequest skips the body contract. Use `gh pr create` or `gh pr edit`, or the REST pulls endpoint (`gh api repos/{owner}/{repo}/pulls`)."); + if (risk === "opaque") throw new Refusal(UNREADABLE); +} + +/** The host the gate's own gh calls reach when they name none. */ +const DEFAULT_HOST = (process.env.GH_HOST || "github.com").toLowerCase(); + +/** The head commit a merge requires, as gh or GitHub reads it, or undefined when a REST request names none. */ +function namedHead({ value, json }: HeadMatch, cmd: PrCommand, cwd: string): string | undefined { + const raw = fieldJson(value, cmd, cwd); + if (!json) return String(raw); + let request: unknown; + try { + request = JSON.parse(String(raw)); + } catch { + throw new Refusal("the merge's --input request is not valid JSON, so this gate cannot read the head it names."); + } + return isObj(request) ? optional(request, "sha", isStr) : undefined; +} + +function checkMerge(cmd: Extract<PrCommand, { kind: "merge" }>, cwd: string): void { + const host = cmd.hosts.find((h) => h.toLowerCase().replace(/^api\.(?=github\.com$)/, "") !== DEFAULT_HOST); + if (host !== undefined) { + throw new Refusal(`the merge names the GitHub host ${host}, but this gate reads PRs and review verdicts on ${DEFAULT_HOST} only, so it cannot check this merge. Name no host, or merge on ${DEFAULT_HOST}.`); + } + const { repo, pull } = resolvePull(cmd, cwd); + contract({ title: pull.title, body: pull.body ?? "", draft: pull.draft }); + const sha = pull.headSha; + const named = cmd.match && namedHead(cmd.match, cmd, cwd); + if (named !== undefined && named.toLowerCase() !== sha.toLowerCase()) { + throw new Refusal(`the merge names head ${named}, but the gate checked ${sha}, so it cannot vouch for the head gh would merge. Name the head the gate checked, or drop the head check.`); + } + if (botExempt(pull.baseRepo) && cmd.pinned) return; + const commit = ghJson(`repos/${repo}/commits/${sha}`, cwd); + const parents = field(commit, "parents", isArr, "a commit").map((p) => field(p, "sha", isStr, "a commit parent")); + const runs = latestRuns(repo, sha, cwd); + // Only a merge commit whose own verdict is missing or running can inherit, so only it needs the parent's. + const mergeCommit = parents.length === 2 && verdictOf(runs).state === "open"; + const parentRuns = mergeCommit ? latestRuns(repo, parents[0], cwd) : undefined; + const compare = mergeCommit ? ghJson(`repos/${repo}/compare/${parents[1]}...${pull.baseSha}`, cwd) : undefined; + const mergesBase = compare === undefined ? undefined : ["identical", "ahead"].includes(field(compare, "status", isStr, "a compare")); + const verdict = decideMerge({ repo: pull.baseRepo, pinned: cmd.pinned, sha, parents, runs, parentRuns, mergesBase }); + if (!verdict.ok) throw new Refusal(`${repo}#${pull.number} cannot merge: ${verdict.reason} Follow the land-pr skill.`); +} + +function check(cmd: PrCommand, hookCwd: string): void { + if (cmd.kind === "merge" && cmd.drift) throw new Refusal(DRIFT[cmd.drift]); + if (cmd.crowded) throw new Refusal(ALONE); + const cwd = cmd.host || cmd.lost ? hookCwd : expand(cmd.dir ?? ".", hookCwd); + if (cmd.kind === "merge") return checkMerge(cmd, cwd); + if (cmd.kind === "graphql") return checkGraphql(cmd, cwd); + if (cmd.kind === "opaque") throw new Refusal(OPAQUE[cmd.part]); + if (cmd.kind === "unresolved") throw new Refusal(`the PR number \`${cmd.target}\` is not a literal number, so this gate cannot tell which PR this writes. Put the number in the path, for example \`repos/${cmd.repo}/pulls/38\`.`); + const title = cmd.kind === "create" || cmd.kind === "edit" ? cmd.title && readBody(cmd.title, cmd, cwd) : undefined; + if (cmd.kind === "create") return checkFields("create", { title, draft: cmd.draft, body: readBody(cmd.body, cmd, cwd) }); + if (cmd.kind === "input") return checkFields(cmd.write, readRequest(cmd, cwd)); + if (cmd.kind === "fields") return checkFields(cmd.write, readFields(cmd, cwd)); + return checkFields("edit", { title, body: cmd.body === undefined ? undefined : readBody(cmd.body, cmd, cwd) }); +} + +if (process.env.PR_KIT_OFF === "1") process.exit(0); +let input: HookInput; +let commands: PrCommand[]; +try { + input = parseInput(await new Response(Bun.stdin.stream()).text()); + commands = input.tool === "Bash" ? findPrCommands(input.command, { cwd: input.cwd, home: homedir(), cdpath: Boolean(process.env.CDPATH) }) : []; +} catch (err) { + // An exit other than 0 or 2 lets the tool call run, so a payload the gate cannot read is refused here. + process.stderr.write(`PR kit blocked this command: the gate could not read the hook input (${String(err)}).\n`); + process.exit(2); +} +if (commands.length === 0) process.exit(0); +const cwd = input.cwd; + +for (const cmd of commands) { + try { + check(cmd, cwd); + } catch (err) { + const reason = err instanceof Refusal ? err.message : `the gate could not check this command: ${String(err)}`; + process.stderr.write(`PR kit blocked \`${cmd.via}\`.\n${reason}\nThe open-pr and land-pr skills write and land a PR that passes.\n`); + process.exit(2); + } +} +process.exit(0); diff --git a/.claude/skills/open-pr/kit.ts b/.claude/skills/open-pr/kit.ts new file mode 100644 index 0000000..8cfc133 --- /dev/null +++ b/.claude/skills/open-pr/kit.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env bun +/** + * Installs the PR kit into a repo, so every agent that opens the repo carries it, + * cloud sessions included. Run it again to update; a second run changes nothing. + * + * bun kit.ts install <repo> [--runs-on <label>] + * bun kit.ts stamp rewrite VERSION after a kit change, bumping its version by one (source folder only) + * bun kit.ts check exit 1 when VERSION does not match the shipped files + * + * VERSION holds `<version> <digest>`: an integer that gives every copy of the kit one total + * order, and the sha256 of the shipped files it describes. It ships with the kit, and each + * installed skill's .pr-kit marker records it as a second line, `version <N> <digest>`. An + * install refuses, before any write, to run from a copy whose files do not match its VERSION, + * to replace a newer installed kit or the same version with other files, or to run from a copy + * that differs from the source repo's main when gh can read it. + * + * The sources sit next to this file: `open-pr` is its folder and `land-pr` is the sibling. + * That holds in the kit's source folder, the fleet store, and an installed repo's + * .claude/skills, so any copy can install the kit into another repo. + * + * Writes, inside <repo>: + * .claude/skills/open-pr, .claude/skills/land-pr the skills, gate, and checker + * .agents/skills/{open-pr,land-pr} symlinks for Codex and Cursor + * .claude/settings.json the PreToolUse gate hook + * .github/workflows/pr-kit.yml the PR body check in CI + * AGENTS.md (and CLAUDE.md when it does not load AGENTS.md) the rule block + */ + +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { basename, join, relative } from "node:path"; +import { parseArgs } from "node:util"; + +const SKILLS = ["open-pr", "land-pr"] as const; +const SOURCE = { "open-pr": import.meta.dir, "land-pr": join(import.meta.dir, "..", "land-pr") } satisfies Record<(typeof SKILLS)[number], string>; +const MARKER = ".pr-kit"; +const VERSION = "VERSION"; +/** + * The sha256 of the kit's source repo as lowercase `owner/name`. The kit ships to public repos + * and names no private one, so the remote check finds the source among the owner's repos by + * digest. The kit sits there under a folder named for the repo, at `<name>/kits/pr`. + */ +const SOURCE_OWNER = "AojdevStudio"; +const SOURCE_REPO = "6159f38c55dea314c96c15a2b5ddf2edd32a5b51019c9d0b4521e2ff077c0815"; +const STAMP_FIX = "VERSION does not match the kit's shipped files. Run bun kit.ts stamp after a kit change, and commit VERSION with it."; +const UPDATE_COPY = "Update the copy from the kit's source folder (kits/pr) with sk push or a pull. Only the source folder stamps."; +const HOOK = 'bun "$CLAUDE_PROJECT_DIR/.claude/skills/open-pr/gate.ts"'; +const BLOCK_START = "<!-- pr-kit:start -->"; +const BLOCK_END = "<!-- pr-kit:end -->"; +const BLOCK = `${BLOCK_START} +## Pull requests + +- Open every PR with the \`open-pr\` skill and land it with the \`land-pr\` skill. Both live in \`.claude/skills/\`. +- The PR body has five fixed sections: Problem, Change, Proof, Review, Merge danger. Merge danger names the door and the blast radius. +- The open-pr gate and the \`pr-kit\` CI check refuse a body that breaks the contract. Fix the body. Never route around either. +${BLOCK_END}`; + +const shippedFiles = (dir: string) => readdirSync(dir).filter((f) => !f.startsWith(".") && !f.endsWith(".test.ts") && statSync(join(dir, f)).isFile()); +const sha256 = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex"); + +/** The sha256 of every shipped file but VERSION, as sorted `<skill>/<file>\n<file sha256>\n` lines. */ +function kitDigest(): string { + const files = SKILLS.flatMap((name) => shippedFiles(SOURCE[name]).filter((f) => f !== VERSION).map((f) => `${name}/${f}`)).sort(); + return sha256(files.map((rel) => `${rel}\n${sha256(readFileSync(join(SOURCE["open-pr"], "..", rel)))}\n`).join("")); +} + +interface Stamp { + version: number; + digest: string; +} + +const STAMP_LINE = /^(\d+) ([0-9a-f]{64})$/; + +function parseStamp(text: string): Stamp | undefined { + const m = STAMP_LINE.exec(text.replace(/\n$/, "")); + const version = Number(m?.[1]); + return m && Number.isSafeInteger(version) ? { version, digest: m[2] } : undefined; +} + +const versionPath = join(SOURCE["open-pr"], VERSION); +const ownStamp = () => (existsSync(versionPath) ? parseStamp(readFileSync(versionPath, "utf8")) : undefined); + +/** + * Whether this copy is the kit's source folder, `<repo>/kits/pr/open-pr` in a git checkout. + * A stamp in any other copy would bless files that never came from the source, so an install + * from it could downgrade a repo. + */ +function inSourceFolder(): boolean { + const dir = SOURCE["open-pr"]; + if (basename(join(dir, "..")) !== "pr" || basename(join(dir, "..", "..")) !== "kits" || existsSync(join(dir, MARKER))) return false; + return Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { cwd: dir, stdout: "ignore", stderr: "ignore" }).exitCode === 0; +} + +const { values, positionals } = parseArgs({ allowPositionals: true, options: { "runs-on": { type: "string" } } }); +const [verb, target] = positionals; +if (verb === "stamp") { + if (!inSourceFolder()) throw new Error("stamp runs only in the kit's source folder (<repo>/kits/pr/open-pr). This copy is installed or synced; update it instead."); + const text = existsSync(versionPath) ? readFileSync(versionPath, "utf8") : undefined; + const own = text === undefined ? undefined : parseStamp(text); + if (text !== undefined && !own) { + const lines = text.replace(/\n$/, "").split("\n"); + const at = lines.findIndex((line, i) => i > 0 || !parseStamp(line)); + throw new Error(`${versionPath} line ${at + 1} does not read as "<version> <digest>": ${JSON.stringify(lines[at])}. Fix it by hand. stamp starts at 1 only when VERSION is missing.`); + } + const digest = kitDigest(); + if (own?.digest === digest) { + console.log(`VERSION is current at version ${own.version}.`); + } else { + const version = (own?.version ?? 0) + 1; + writeFileSync(versionPath, `${version} ${digest}\n`); + console.log(`Stamped version ${version}.`); + } + process.exit(0); +} +if (verb === "check") { + const own = ownStamp(); + if (own?.digest !== kitDigest()) { + console.error(STAMP_FIX); + process.exit(1); + } + console.log(`VERSION ${own.version} matches the shipped files.`); + process.exit(0); +} +if (verb !== "install" || !target) { + console.error("usage: bun kit.ts install <repo> [--runs-on <label>] | stamp | check"); + process.exit(1); +} +const repo = target.replace(/\/+$/, ""); +if (!existsSync(join(repo, ".git"))) throw new Error(`${repo} is not a git checkout`); + +const MARKER_HEADER = "Installed by the PR kit (open-pr/kit.ts). Edit the kit at its source, not here."; +const MARKER_LINE = /^version (\d+) ([0-9a-f]{64})\r?$/; + +/** + * Both skills' installed markers: the version and digest each records. A marker with only its + * header line predates the version line and counts as version 0. Any other line, or more than + * one version line, refuses, since reading it as 0 would let an older copy overwrite a newer install. + */ +function installedMarkers(): { marker: string; version: number; digest?: string }[] { + return SKILLS.flatMap((name) => { + const marker = join(repo, ".claude", "skills", name, MARKER); + if (!existsSync(marker)) return []; + const rest = readFileSync(marker, "utf8").replace(/\n$/, "").split("\n").filter((line) => line.replace(/\r$/, "") !== MARKER_HEADER); + if (rest.length === 0) return [{ marker, version: 0 }]; + const m = rest.length === 1 ? MARKER_LINE.exec(rest[0]) : null; + const version = Number(m?.[1]); + if (!m || !Number.isSafeInteger(version)) { + throw new Error(`${marker} does not read as "version <version> <digest>": ${JSON.stringify(rest.join("\n"))}. Delete the marker's damaged lines, or the whole marker if you cannot tell which install wrote it, then rerun.`); + } + return [{ marker, version, digest: m[2] }]; + }); +} + +const own = ownStamp(); +if (own?.digest !== kitDigest()) throw new Error(`this kit copy cannot install. Its files do not match its VERSION, so they changed after it was stamped. ${UPDATE_COPY}`); +const markerText = `${MARKER_HEADER}\nversion ${own.version} ${own.digest}\n`; + +/** Refuses when the repo's installed kit is newer than this copy, or the same version with other files. */ +const guardInstalled = () => { + for (const { marker, version, digest } of installedMarkers()) { + const where = `The installed version is recorded in ${marker}.`; + if (version > own.version) { + throw new Error(`this kit copy is version ${own.version} but ${repo} already has version ${version}. ${where} Install from the kit's source folder (kits/pr), or a copy at least that new.`); + } + if (version === own.version && digest !== undefined && digest !== own.digest) { + throw new Error(`${repo} already has version ${version} with other files; this copy is not that kit. ${where}`); + } + } +}; +guardInstalled(); + +const GH_TIMEOUT_MS = Number(process.env.PR_KIT_GH_TIMEOUT_MS) || 15_000; + +function gh(args: string[]): { out: string } | { why: string } { + const r = Bun.spawnSync(["gh", ...args], { stdout: "pipe", stderr: "pipe", timeout: GH_TIMEOUT_MS }); + if (r.exitCode === 0) return { out: r.stdout.toString() }; + if (r.exitedDueToTimeout) return { why: `gh did not answer within ${GH_TIMEOUT_MS} ms` }; + return { why: r.stderr.toString().trim().split("\n")[0] || `gh exited ${r.exitCode ?? r.signalCode}` }; +} + +/** The source repo's VERSION on main, or why gh could not read it. */ +function sourceStamp(): { repo: string; stamp: Stamp } | { why: string } { + if (!Bun.which("gh")) return { why: "gh is not on PATH" }; + const list = gh(["repo", "list", SOURCE_OWNER, "--limit", "1000", "--json", "nameWithOwner", "--jq", ".[].nameWithOwner"]); + if ("why" in list) return list; + const source = list.out.split("\n").find((r) => sha256(r.toLowerCase()) === SOURCE_REPO); + if (!source) return { why: "gh cannot see the kit's source repo" }; + const answer = gh(["api", "-H", "Accept: application/vnd.github.raw", `repos/${source}/contents/${source.split("/")[1]}/kits/pr/open-pr/${VERSION}?ref=main`]); + if ("why" in answer) return answer; + const stamp = parseStamp(answer.out); + return stamp ? { repo: source, stamp } : { why: "its answer is not a VERSION line" }; +} + +const changes: string[] = []; +const note = (what: string) => changes.push(what); + +function writeIfChanged(path: string, text: string): void { + if (existsSync(path) && readFileSync(path, "utf8") === text) return; + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, text); + note(`wrote ${relative(repo, path)}`); +} + +function copySkill(name: (typeof SKILLS)[number]): void { + const dest = join(repo, ".claude", "skills", name); + if (existsSync(dest) && !existsSync(join(dest, MARKER))) { + throw new Error(`${relative(repo, dest)} exists and is not the PR kit. Move it, then rerun.`); + } + const src = SOURCE[name]; + if (!existsSync(join(src, "SKILL.md"))) throw new Error(`${src} is not the ${name} skill. Run kit.ts from a full copy of the kit.`); + const shipped = shippedFiles(src); + for (const file of shipped) writeIfChanged(join(dest, file), readFileSync(join(src, file), "utf8")); + for (const file of existsSync(dest) ? readdirSync(dest) : []) { + if (file === MARKER || shipped.includes(file)) continue; + rmSync(join(dest, file), { recursive: true }); + note(`removed ${relative(repo, join(dest, file))}`); + } + writeIfChanged(join(dest, MARKER), markerText); +} + +function linkSkill(name: string): void { + const link = join(repo, ".agents", "skills", name); + const to = join("..", "..", ".claude", "skills", name); + if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) { + if (lstatSync(link).isSymbolicLink() && readlinkSync(link) === to) return; + console.warn(`skip ${relative(repo, link)}: it exists and is not the kit link`); + return; + } + mkdirSync(join(link, ".."), { recursive: true }); + symlinkSync(to, link); + note(`linked ${relative(repo, link)}`); +} + +const isObj = (x: unknown): x is Record<string, unknown> => typeof x === "object" && x !== null && !Array.isArray(x); + +/** + * The gate hook's settings. 120 seconds covers a merge check's chain of `gh` calls (8 seconds + * each). `onFailure: "block"` makes a gate that times out, fails to start, or crashes deny + * the call, since Claude Code otherwise lets it run. + */ +const HOOK_SETTINGS = { timeout: 120, onFailure: "block" } as const; + +/** Wires the gate hook, and brings an entry from an earlier install up to HOOK_SETTINGS. */ +function wireHook(): void { + const path = join(repo, ".claude", "settings.json"); + const settings: unknown = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {}; + if (!isObj(settings)) throw new Error(`${relative(repo, path)} is not a JSON object`); + const hooks = isObj(settings.hooks) ? settings.hooks : {}; + const pre: unknown[] = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []; + const ours = pre.flatMap((m) => (isObj(m) && Array.isArray(m.hooks) ? m.hooks.filter((h): h is Record<string, unknown> => isObj(h) && h.command === HOOK) : [])); + const stale = ours.filter((h) => Object.entries(HOOK_SETTINGS).some(([k, v]) => h[k] !== v)); + if (ours.length > 0 && stale.length === 0) return; + for (const h of stale) Object.assign(h, HOOK_SETTINGS); + if (ours.length === 0) pre.push({ matcher: "Bash", hooks: [{ type: "command", command: HOOK, ...HOOK_SETTINGS }] }); + hooks.PreToolUse = pre; + settings.hooks = hooks; + writeIfChanged(path, JSON.stringify(settings, null, 2) + "\n"); +} + +const RUNS_ON = /^\s*runs-on:\s*(\S.*?)\s*$/gm; + +/** + * The runner label: the flag, else the label the installed pr-kit.yml already uses, else the + * one label most of the repo's other workflows use. Inference never picks a paid hosted + * label, and a tie for most used is refused, so the result never depends on file order. + */ +function runnerLabel(): string { + if (values["runs-on"]) return values["runs-on"]; + const dir = join(repo, ".github", "workflows"); + const installed = join(dir, "pr-kit.yml"); + if (existsSync(installed)) { + const label = [...readFileSync(installed, "utf8").matchAll(RUNS_ON)][0]?.[1]; + if (!label) throw new Error(`${relative(repo, installed)} has no single-line runs-on label. Pass --runs-on <label>.`); + return label; + } + const counts = new Map<string, number>(); + for (const f of existsSync(dir) ? readdirSync(dir) : []) { + for (const m of readFileSync(join(dir, f), "utf8").matchAll(RUNS_ON)) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1); + } + const top = Math.max(0, ...counts.values()); + const best = [...counts].filter(([, n]) => n === top).map(([label]) => label).sort(); + if (best.length !== 1 || /^(ubuntu|macos|windows)-|\$\{\{/.test(best[0])) { + throw new Error(`no single self-hosted runner label found (most used: ${best.join(", ") || "none"}). Pass --runs-on <label>. Never default to paid hosted minutes.`); + } + return best[0]; +} + +function writeWorkflow(label: string): void { + const yml = readFileSync(join(SOURCE["open-pr"], "pr-kit.yml"), "utf8").replace("__RUNS_ON__", label); + writeIfChanged(join(repo, ".github", "workflows", "pr-kit.yml"), yml); +} + +function writeBlock(path: string): void { + const text = existsSync(path) ? readFileSync(path, "utf8") : ""; + const at = text.indexOf(BLOCK_START); + const end = text.indexOf(BLOCK_END); + const next = at >= 0 && end > at + ? text.slice(0, at) + BLOCK + text.slice(end + BLOCK_END.length) + : `${text.replace(/\s*$/, text ? "\n\n" : "")}${BLOCK}\n`; + writeIfChanged(path, next); +} + +// Resolve the runner before any write, so a missing label cannot leave a partial install, and +// before the remote check, so that failure costs no gh calls. +const label = runnerLabel(); +const remote = sourceStamp(); +if ("why" in remote) console.log(`Remote version check skipped (this install was not compared with kits/pr): ${remote.why}.`); +else if (remote.stamp.version > own.version) { + throw new Error(`this kit copy is version ${own.version} but ${remote.repo} kits/pr is at ${remote.stamp.version}. Update the copy first.`); +} else if (remote.stamp.version === own.version && remote.stamp.digest !== own.digest) { + throw new Error(`this copy claims version ${own.version} but its files differ from kits/pr at ${own.version} in ${remote.repo}. Update the copy first.`); +} else if (remote.stamp.version < own.version) { + // A stale branch of the source repo can stamp past main. Installing it would lock main out. + throw new Error(`this kit copy is version ${own.version} but ${remote.repo} kits/pr on main is at ${remote.stamp.version}. Install from kits/pr after the change merges to main.`); +} +// The remote lookup can take seconds, so read the markers again: another install may have landed. +guardInstalled(); +for (const name of SKILLS) copySkill(name); +for (const name of SKILLS) linkSkill(name); +wireHook(); +writeWorkflow(label); +writeBlock(join(repo, "AGENTS.md")); +const claude = join(repo, "CLAUDE.md"); +if (existsSync(claude) && !readFileSync(claude, "utf8").includes("AGENTS.md")) writeBlock(claude); + +console.log(changes.length ? changes.map((c) => `- ${c}`).join("\n") : "PR kit already current."); +console.log("Next: commit these files in their own PR, then make the pr-kit check required in the repo ruleset."); diff --git a/.claude/skills/open-pr/pr-kit.yml b/.claude/skills/open-pr/pr-kit.yml new file mode 100644 index 0000000..6d7bae2 --- /dev/null +++ b/.claude/skills/open-pr/pr-kit.yml @@ -0,0 +1,31 @@ +name: pr-kit + +# pull_request_target runs this workflow, and the checker it calls, from the base branch. +# Under pull_request a PR could edit check.ts or this file and pass its own check. +# So this job checks out the base SHA and reads the PR only through the event payload. +# Never check out or run the PR's code here. +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review] + +permissions: + contents: read + +jobs: + body: + name: PR body contract + # Fork and Dependabot PRs skip this check, so they never run on our runners. + if: >- + github.actor != 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: __RUNS_ON__ + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + env: + HOME: ${{ runner.temp }}/setup-bun + - run: bun .claude/skills/open-pr/check.ts --event "$GITHUB_EVENT_PATH"