/** * Whether a rule's trigger fires on this event. * * invariant: pure, and it never reads a host payload. It takes the published event shape, so a rule written once * fires the same way on every provider ([/decisions/ad-004.md](/decisions/ad-004.md)). * * hazard: a shell trigger cannot be a substring test against the whole command. `x && gh pr create` is a pull * request being opened, and a heredoc body containing the words `gh pr create` is a document. `tokenizeShell` * separates both and is the only splitter in this repository — a second regex here would be the duplication that * makes one of them wrong later ([/decisions/ad-100.md](/decisions/ad-100.md)). */ import { tokenizeShell } from "../floor/floor.tokenize.ts"; import type { Rule, RuleTrigger } from "./rules.types.ts"; /** * What the harness reads to decide whether a trigger fires. A subset of the event, named so the vocabulary is * visible: adding a trigger that needs a new field has to widen this deliberately. */ export type TriggerContext = { event: string; toolName?: string; command?: string; /** * why optional, and why `null` differs from `undefined`: unset means the caller never resolved this * dimension (every existing caller, unaffected). `null` means it tried and found no confirmable local * repo, which fails an API-shaped match rather than skipping the check * ([/decisions/ad-130.md](/decisions/ad-130.md)). */ repoRemote?: { owner: string; repo: string } | null; /** * why optional, and read only by the MCP shape: a shell-triggered event never carries this, and the vast * majority of MCP events are not pr-open-shaped either — populating it for every event would be work spent * on a field almost nothing reads ([/decisions/ad-135.md](/decisions/ad-135.md)). */ toolInput?: Record; }; /** * why a set per trigger rather than one pattern the operator writes: `pr-open` has to mean the same thing in * every repository, or a rule copied between them silently stops firing. An operator who wants their own shape * writes `command()`. */ type ShellShape = { readonly prefix: readonly string[]; /** * why this exists only on `gh pr create`: a draft is not yet open for review, so a rule gating `pr-open` * has no work to demand proof of. It is also the only way a proof that itself depends on the pull request * existing — `gh pr view`, for one — can ever run: open the draft, produce the proof against it, then * `gh pr ready`, which keeps its own gate ([/decisions/ad-118.md](/decisions/ad-118.md)). */ readonly excludeIfAny?: readonly string[]; }; const SHELL_SHAPES: Record<"pr-open" | "commit" | "push" | "pr-merge", readonly ShellShape[]> = { "pr-open": [ { prefix: ["gh", "pr", "create"], excludeIfAny: ["--draft", "-d"] }, { prefix: ["gh", "pr", "ready"] }, ], commit: [{ prefix: ["git", "commit"] }], push: [{ prefix: ["git", "push"] }], "pr-merge": [{ prefix: ["gh", "pr", "merge"] }], }; /** * why colocated with `SHELL_SHAPES`, not left to `rules.decide.ts`: this is the human-readable half of the * `excludeIfAny` fact above it. Only `pr-open` carries one today — a future trigger gaining its own * `excludeIfAny` shape has this constant sitting right beside the reminder that its own message may need one * too ([/decisions/ad-138.md](/decisions/ad-138.md)). */ export const TRIGGER_ESCAPE_HATCH_HINT: Partial> = { "pr-open": "A draft PR (`gh pr create --draft` or `-d`) is not gated by any pr-open rule — open it as a draft, satisfy this rule's proof, then `gh pr ready`, which is itself gated the normal way.", }; /** * invariant: `tokenizeShell` already declines to emit segments from a heredoc body, so a body is never mistaken * for a command and a command after one is still seen. Measured both ways on * `cat < runbook.md\ngh pr create --fill\nEOF` and on the same with a real command after the terminator: * identical output. * * hazard: the first version of this called `splitHeredocs` first as well. It changed nothing — the mutation that * removed it survived, which is what exposed it as dead rather than as untested * ([/decisions/ad-100.md](/decisions/ad-100.md)). */ function subCommands(command: string): string[][] { return tokenizeShell(command) .map((segment) => segment.words.map((word) => word.text)) .filter((words) => words.length > 0); } /** * why a basename fallback: a token with no `/` of its own names an act, not a location — `build.sh` is the * same script whether it runs as `build.sh`, `./scripts/build.sh` or `/home/user/tools/scripts/build.sh`. * Confirmed live: a real `command(