/** * Shared shell-command scanning for the bash guards. * * A guard that bans a command family (`git merge`, `git push`, …) must answer one question * precisely: *does this command actually invoke `git `?* A bare * `/\bgit\s+merge\b/.test(command)` gets that wrong in both directions: * * - False positive: `grep 'git merge main' notes.md` or `echo "git rebase main"` merely MENTION * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated * the merge/rebase ban. * - False positive: `\b` sits between `e` and `-`, so `/\bgit\s+merge\b/` matches the read-only * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command. * * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply * not the token `merge`). No lookahead regex needed. */ /** * One invoked segment of a command, plus whether a PIPE fed it. * * `pipedInto` is what separates `git log | grep foo` (grep consumes the pipe — reads no file) from * `grep foo src/` (grep reads the working tree). A guard that cares about which FILES a command * reads cannot tell those apart from the segment text alone, because splitting on `|` throws exactly * that fact away. Data-only, so a class (per CLAUDE.md). */ export declare class CommandSegment { text: string; pipedInto: boolean; constructor(text: string, pipedInto: boolean); } export declare class CommandScanner { /** * Split a raw command into individually-invoked segments. * * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment. * * Quoted spans are opaque: a separator inside quotes is literal text, so * `git commit -m "fix; ship it"` stays one segment. (Corollary: a `$(…)` nested inside double * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.) */ commandSegments(command: string): readonly string[]; /** * commandSegments, but each segment also carries whether the separator BEFORE it was a pipe. * Only a guard reasoning about which files a segment reads needs that; everything else uses * commandSegments, which is this method with the flag dropped. */ segmentsWithPipes(command: string): readonly CommandSegment[]; /** * One segment's shell words, with wrappers/env-assignments stripped, so `words('sudo cat a b')` * is `['cat', 'a', 'b']`. The public view of the same tokenizer gitSubcommand uses — a guard that * must inspect a NON-git command's arguments (which paths does this `grep` actually read?) needs * the tokens, and re-splitting on whitespace in the guard would get quoting wrong. */ words(segment: string): readonly string[]; /** * The git subcommand a segment invokes, or null when the segment does not invoke git at all * (a different program, a mere mention inside quotes, an empty segment). * * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`. */ gitSubcommand(segment: string): string | null; /** * gitSubcommand, for a caller that already holds the segment's effective words (ShellSegmentScan * strips leading shell keywords, so `do git status` must be resolved from ITS words, not from the * raw segment text where `do` is the command). */ gitSubcommandOf(words: readonly string[]): string | null; /** * The ARGUMENTS following `git ` in this segment, or null when the segment does not * invoke that subcommand. * * `gitSubcommand` answers *which* subcommand; a guard that judges the subcommand's own flags needs * the tokens after it, and slicing them in the guard would mean re-deriving where the subcommand * sits — i.e. re-deriving the `sudo` / `env VAR=x` / `-C ` skipping this class exists to own. * `git -C /x commit -m "msg"` yields `['-m', 'msg']`, never `['/x', 'commit', '-m', 'msg']`. * * The tokens are QUOTE-STRIPPED (see tokenize), so a quoted argument arrives as ONE token holding * its literal text — newlines, backticks and all. That is what makes an argument's CONTENT * inspectable at all. */ gitSubcommandArgs(segment: string, subcommand: string): readonly string[] | null; /** Index of the subcommand token in already-prefix-stripped words, or -1 when git is not invoked. */ private gitSubcommandIndex; /** * The segment's words with the package-manager WRAPPER stripped, so `pnpm exec vitest run` and * `vitest run` reduce to the same words. `pnpm --silent run build-all` → `['build-all']`. * * Lives HERE rather than in a guard because two guards now need it and they must agree: one blocks a * whole-repo build, the other blocks piping a build's output. If they disagreed about whether * `npx wp-build` is `wp-build`, one of them would have a spelling-shaped side door — which is exactly * the failure mode the runner stripping exists to close. */ runnerStrippedWords(segment: string): readonly string[]; /** `./node_modules/.bin/nx` and `/usr/local/bin/pnpm.cmd` are the programs `nx` and `pnpm`. */ programName(token: string): string; /** True when a package-manager runner precedes the program (`pnpm …`, `npx …`). */ viaRunner(segment: string): boolean; /** True when this segment actually invokes `git `. */ invokesGit(segment: string, subcommand: string): boolean; /** True when ANY segment of the command invokes one of `subcommands`. */ commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean; /** * Split one segment into shell words, dropping quote characters (so the ARGUMENT of * `echo "git merge main"` is the single word `git merge main`, never the word `git`). */ private tokenize; private stripPrefixes; }