Routes bare pytest through the project interpreter and requires a test file or node-id per pytest run

Two pytest guards for the Bash and PowerShell tools, written as function hooks. Each is off until its option is set, and every failure path (a missing file, a failed stat, no git, an unreadable state store) passes the call through unchanged.
/plugin install pytest-scope --marketplace t968rs/operator-straps
Set at install: /plugin opens a dialog for unset options, and claude plugin install takes --config KEY=VALUE. Change later with /plugin configure pytest-scope@operator-straps or /config.
| Option | Type | Default | Meaning |
|---|---|---|---|
python | file | unset | Absolute path of the interpreter bare pytest runs under |
require_test_file | boolean | false | Deny pytest calls that name no test file |
hooks/interpreter.ts)A bare pytest resolves to whatever Python is first on PATH, often one without the project's test dependencies. With python set, a bare pytest invocation that opens a simple Bash command runs under it:
| Written | Runs as |
|---|---|
pytest a.py -q, py.test a.py, pytest.exe a.py | "<python>" -m pytest a.py -q |
python / python3 / python.exe / py -m pytest a.py | "<python>" -m pytest a.py |
;, &&, ||, | and a newline. Words inside quotes, arguments (echo pytest) and qualified names (/usr/bin/python, python3.11) stay as written.$(...), backticks, subshells, an unclosed quote or heredoc) are never rewritten. Neither are python -c, scripts, other modules, or commands led by an assignment (PYTHONPATH=... pytest), a keyword or a redirection.python must be absolute and name an existing file; backslashes are written as forward slashes. A relative path, or one holding ", $ or a backtick, turns the rewrite off.[pytest-scope] bare pytest rewritten ... note beside the result.Permissions. tool.call runs before the permission rules, so the rules judge the rewritten text. With the harness plugin installed, its bash_allow_shapes hook approves "<python>" -m pytest ARGS when <python> is this option's value and the bare form (pytest ARGS, python -m pytest ARGS) matches an existing Bash(...) allow rule, so the rewrite never needs a wider rule than the bare call had. Without the harness, allow the rewritten form in the project's settings, for example Bash("C:/path/to/env/python.exe" -m pytest *), or every rewritten call prompts.
hooks/scope.ts)With require_test_file on, a pytest call on Bash or PowerShell with no test path, or with a directory as a test path, is denied: a directory path is never the unit; a file or a node-id is. The message lists up to 15 test files (test_*.py, *_test.py) git knows under that directory, tracked or untracked.
pytest, py.test, python -m pytest, a quoted interpreter path -m pytest, rewritten commands, and wrappers such as env, timeout, nohup, conda run and uv run.--collect-only, --version, --help and runner scripts are never denied.cd) and against the root: the git top level of the cwd, else of CLAUDE_PROJECT_DIR, else the cwd itself./pytest-broad-ok arms a one-shot override for the next broad call. Meant for the developer.$.state['pytest-scope'] (contract: types/index.d.ts): rewrites (number) and broadOk (boolean).
bash -c "...", xargs or $(...) are neither rewritten nor judged.cd inside the same command (cd tests && pytest unit/) is not followed.pytest --cov tests/test_x.py names no test path (--cov consumes the next word, as pytest-cov's argparse option does), so it is denied as a whole-suite run. Write --cov=<src>.claude plugin validate --strict plugins/pytest-scope
claude plugin test plugins/pytest-scope
Source and import history: docs/PROVENANCE.md at the marketplace root.
hooks/register.ts 14 lines1import type { Register } from 'claude-code'
2
3import { registerInterpreter } from './interpreter'
4import { registerScope } from './scope'
5
6/**
7 * The interpreter rewrite registers first, so it runs outermost and the scope hook judges the rewritten
8 * command. Each feature registers nothing while its option is unset.
9 */
10export const register: Register = (on, options) => {
11 registerInterpreter(on, typeof options.python === 'string' ? options.python : undefined)
12 registerScope(on, options.require_test_file === true)
13}
14hooks/interpreter.ts 315 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { atom, update } from 'claude-code'
3
4/**
5 * Rewrites bare pytest invocations at Bash command positions to the interpreter named by the plugin's
6 * `python` option. A bare name resolves to whatever Python is first on PATH, often a base env without the
7 * project's test dependencies. Every failure path passes the command through.
8 *
9 * The rewrite runs before the permission rules and the PreToolUse hooks read the command, so it produces
10 * one form only: `"<python>" -m pytest ...` opening a simple command. The project allows that form in its
11 * own permission rules. `python -c`, scripts, other modules and assignment- or keyword-led calls keep
12 * their bare spelling and the rules written for it.
13 */
14
15type On = Parameters<Register>[0]
16type BashResult = ToolCallResult<'Bash'>
17
18/** Program words that are pytest itself: rewritten to `"<python>" -m pytest`. */
19const PYTEST_WORDS: ReadonlySet<string> = new Set(['pytest', 'py.test', 'pytest.exe'])
20/** Interpreter words rewritten only when followed by `-m pytest`. */
21const PYTHON_WORDS: ReadonlySet<string> = new Set(['python', 'python3', 'python.exe', 'python3.exe', 'py'])
22
23/** Cheap pre-filter: skip env/fs work for commands that cannot hold a candidate word. */
24const CANDIDATE = /(^|[^\w./\\-])(python3?(?:\.exe)?|pytest(?:\.exe)?|py\.test|py)(?![\w.\\/-])/i
25/** Characters ending an unquoted word. */
26const WORD_END = new Set([' ', '\t', '\r', '\n', ';', '&', '|', '<', '>', '(', ')'])
27/** Characters an interpreter path may not hold and still be double-quoted verbatim. */
28const UNQUOTABLE = /["$`\n\r]/
29const NOTE_LIMIT = 200
30
31const rewrites = atom({ plugin: 'pytest-scope', key: 'rewrites' } as const, 0)
32
33type Word = { kind: 'word'; start: number; end: number }
34type Token = Word | { kind: 'sep' } | { kind: 'redir' }
35type Heredoc = { delim: string; strip: boolean }
36type Cursor = { s: string; i: number; tokens: Token[]; pending: Heredoc[] }
37
38/** Raised when the scanner cannot be sure of the command's structure. */
39class Unsure extends Error {}
40
41const unsure = (): never => {
42 throw new Unsure()
43}
44
45/** Index just past the closing quote of a single-quoted span opened at `i`. */
46const skipSingle = (s: string, i: number): number => {
47 const close = s.indexOf("'", i + 1)
48 return close < 0 ? unsure() : close + 1
49}
50
51/** Index just past an ANSI-C `$'...'` span whose `$` sits at `i`. */
52const skipAnsi = (s: string, i: number): number => {
53 let j = i + 2
54 while (j < s.length && s[j] !== "'") j += s[j] === '\\' ? 2 : 1
55 return j < s.length ? j + 1 : unsure()
56}
57
58/** Index just past a double-quoted span opened at `i`; bails on command substitution. */
59const skipDouble = (s: string, i: number): number => {
60 let j = i + 1
61 while (j < s.length && s[j] !== '"') {
62 if (s[j] === '`' || (s[j] === '$' && s[j + 1] === '(')) unsure()
63 j += s[j] === '\\' ? 2 : 1
64 }
65 return j < s.length ? j + 1 : unsure()
66}
67
68/** Index just past a `${...}` expansion whose `$` sits at `i`. */
69const skipBrace = (s: string, i: number): number => {
70 const close = s.indexOf('}', i + 2)
71 return close < 0 ? unsure() : close + 1
72}
73
74/** Index just past the quoted or escaped unit at `i`, or `i + 1` for a plain character. */
75const skipUnit = (s: string, i: number): number => {
76 const ch = s[i]
77 const after = s[i + 1]
78 if (ch === "'") return skipSingle(s, i)
79 if (ch === '"') return skipDouble(s, i)
80 if (ch === '`') return unsure()
81 if (ch === '\\') return i + 1 < s.length ? i + 2 : unsure()
82 if (ch === '$' && after === '(') return unsure()
83 if (ch === '$' && after === "'") return skipAnsi(s, i)
84 if (ch === '$' && after === '{') return skipBrace(s, i)
85 return i + 1
86}
87
88/** Reads one word at the cursor and records it. */
89const readWord = (c: Cursor): Word => {
90 const start = c.i
91 while (c.i < c.s.length && !WORD_END.has(c.s[c.i] ?? '')) c.i = skipUnit(c.s, c.i)
92 const word: Word = { kind: 'word', start, end: c.i }
93 c.tokens.push(word)
94 return word
95}
96
97/** Reads a heredoc operator's delimiter and queues its body. */
98const readHeredoc = (c: Cursor): void => {
99 const strip = c.s[c.i] === '-'
100 if (strip) c.i += 1
101 while (c.s[c.i] === ' ' || c.s[c.i] === '\t') c.i += 1
102 const word = readWord(c)
103 const delim = c.s.slice(word.start, word.end).replace(/['"\\]/g, '')
104 if (delim === '') unsure()
105 c.pending.push({ delim, strip })
106}
107
108/** Reads a redirection operator (and a heredoc's delimiter) at the cursor. */
109const readRedirect = (c: Cursor): void => {
110 const { s } = c
111 c.tokens.push({ kind: 'redir' })
112 if (s.startsWith('<<<', c.i)) {
113 c.i += 3
114 return
115 }
116 if (s.startsWith('<<', c.i)) {
117 c.i += 2
118 readHeredoc(c)
119 return
120 }
121 c.i += 1
122 if ('>&|<'.includes(s[c.i] ?? ' ')) c.i += 1
123}
124
125/** Skips every queued heredoc body that starts at the cursor (just after a newline). */
126const readHeredocBodies = (c: Cursor): void => {
127 for (const doc of c.pending) {
128 let found = false
129 while (!found && c.i < c.s.length) {
130 const nl = c.s.indexOf('\n', c.i)
131 const end = nl < 0 ? c.s.length : nl
132 let line = c.s.slice(c.i, end).replace(/\r$/, '')
133 if (doc.strip) line = line.replace(/^\t+/, '')
134 found = line === doc.delim
135 c.i = end + 1
136 }
137 if (!found) unsure()
138 }
139 c.pending = []
140}
141
142/** Reads a control operator (`;`, `&`, `&&`, `|`, `||`, `|&`) or `&>` redirection. */
143const readControl = (c: Cursor): void => {
144 const ch = c.s[c.i]
145 const after = c.s[c.i + 1]
146 if (ch === '&' && after === '>') {
147 c.i += 1
148 readRedirect(c)
149 return
150 }
151 const isDouble = (ch === '&' && after === '&') || (ch === '|' && (after === '|' || after === '&'))
152 c.i += isDouble ? 2 : 1
153 c.tokens.push({ kind: 'sep' })
154}
155
156/** Advances past a `#` comment, leaving the newline in place. */
157const skipComment = (c: Cursor): void => {
158 const nl = c.s.indexOf('\n', c.i)
159 c.i = nl < 0 ? c.s.length : nl
160}
161
162/** Handles one step of the scan at the cursor. */
163const step = (c: Cursor): void => {
164 const ch = c.s[c.i] ?? ''
165 if (ch === '\n') {
166 c.i += 1
167 c.tokens.push({ kind: 'sep' })
168 readHeredocBodies(c)
169 } else if (ch === ' ' || ch === '\t' || ch === '\r') c.i += 1
170 else if (ch === '\\' && /^\\\r?\n/.test(c.s.slice(c.i, c.i + 3))) c.i += c.s[c.i + 1] === '\r' ? 3 : 2
171 else if (ch === '#') skipComment(c)
172 else if (ch === ';' || ch === '&' || ch === '|') readControl(c)
173 else if (ch === '<' || ch === '>') readRedirect(c)
174 else if (ch === '(' || ch === ')') unsure()
175 else readWord(c)
176}
177
178/** Tokenizes a Bash command; null when its structure is uncertain. */
179export const scan = (s: string): Token[] | null => {
180 const c: Cursor = { s, i: 0, tokens: [], pending: [] }
181 try {
182 while (c.i < s.length) step(c)
183 return c.pending.length > 0 ? null : c.tokens
184 } catch (err) {
185 if (err instanceof Unsure) return null
186 throw err
187 }
188}
189
190/** A program word that opens its simple command, with the index of its token. */
191type Head = { word: Word; index: number }
192
193/**
194 * The program word of each simple command that starts it outright: a command led by an assignment, a
195 * reserved word or a redirection yields none, since its permission rule names that prefix.
196 */
197export const commandHeads = (tokens: readonly Token[]): Head[] => {
198 const out: Head[] = []
199 let isAtStart = true
200 tokens.forEach((t, index) => {
201 if (t.kind === 'sep') isAtStart = true
202 else {
203 if (isAtStart && t.kind === 'word') out.push({ word: t, index })
204 isAtStart = false
205 }
206 })
207 return out
208}
209
210/** The raw text of token `i` when it is a word; undefined otherwise. */
211const wordText = (s: string, tokens: readonly Token[], i: number): string | undefined => {
212 const t = tokens[i]
213 return t?.kind === 'word' ? s.slice(t.start, t.end) : undefined
214}
215
216/** What replaces a head word, or undefined when the head is not a pytest invocation. */
217const replacementFor = (s: string, tokens: readonly Token[], head: Head, quoted: string): string | undefined => {
218 const raw = s.slice(head.word.start, head.word.end)
219 const name = raw.toLowerCase()
220 if (PYTEST_WORDS.has(name)) return `${quoted} -m pytest`
221 const isModule = wordText(s, tokens, head.index + 1) === '-m' && wordText(s, tokens, head.index + 2) === 'pytest'
222 return PYTHON_WORDS.has(name) && isModule ? quoted : undefined
223}
224
225/** The command with each bare pytest invocation routed through `python`; null when nothing changed. */
226export const rewriteCommand = (command: string, python: string): string | null => {
227 const tokens = scan(command)
228 if (tokens === null) return null
229 const edits = commandHeads(tokens).flatMap(head => {
230 const text = replacementFor(command, tokens, head, `"${python}"`)
231 return text === undefined ? [] : [{ ...head.word, text }]
232 })
233 if (edits.length === 0) return null
234 let out = command
235 for (const edit of [...edits].reverse()) out = `${out.slice(0, edit.start)}${edit.text}${out.slice(edit.end)}`
236 return out
237}
238
239/** Per-path existence results, kept for the module's lifetime. */
240const existence = new Map<string, boolean>()
241
242/** True when the path names a file; a missing path is cached, other failures are not. */
243const isFile = async ($: EngineInterface, path: string): Promise<boolean> => {
244 const known = existence.get(path)
245 if (known !== undefined) return known
246 try {
247 const stat = await $.fs.stat(path)
248 existence.set(path, stat.kind === 'file')
249 return stat.kind === 'file'
250 } catch (err) {
251 if (/ENOENT|ENOTDIR|not found|no such/i.test(String(err))) existence.set(path, false)
252 return false
253 }
254}
255
256/**
257 * The configured interpreter in the spelling the rewrite writes: trimmed, forward slashes. Undefined when
258 * unset, relative (the engine would resolve it against the plugin folder), or not quotable verbatim.
259 */
260export const interpreterPath = (raw: string | undefined): string | undefined => {
261 const path = (raw ?? '').trim().replace(/\\/g, '/')
262 if (path === '' || UNQUOTABLE.test(path)) return undefined
263 return /^(?:[a-zA-Z]:)?\//.test(path) ? path : undefined
264}
265
266/** The interpreter when the command may need it and the file exists; else undefined. */
267const usableInterpreter = async (
268 $: EngineInterface,
269 command: string,
270 python: string,
271): Promise<string | undefined> => {
272 if (!CANDIDATE.test(command)) return undefined
273 return (await isFile($, python)) ? python : undefined
274}
275
276/** The one-line note the model reads beside a rewritten call's result. */
277export const rewriteNote = (rewritten: string): string => {
278 const shown = rewritten.length > NOTE_LIMIT ? `${rewritten.slice(0, NOTE_LIMIT)}...` : rewritten
279 return `[pytest-scope] bare pytest rewritten to the configured interpreter: ${shown}`
280}
281
282/** Adds the rewrite note to an answered or errored result; a deny stays as it came. */
283const withNote = (ran: BashResult, rewritten: string): BashResult => {
284 if (ran.deny !== undefined) return ran
285 return { ...ran, context: [rewriteNote(rewritten), ...(ran.context ?? [])] }
286}
287
288/** Bumps the session's rewrite counter; a state failure never costs the call. */
289const countRewrite = async ($: EngineInterface): Promise<void> => {
290 try {
291 await update($, rewrites, n => n + 1)
292 } catch {
293 // the counter is advisory
294 }
295}
296
297/**
298 * Registers the Bash interpreter rewrite for the configured `python`; registers nothing when it is unset
299 * or unusable. The `.catch` handler never denies: `next` is replay-safe, so once called `next(e)` settles
300 * to that call's result without running the tool twice, and before it was called `next(e)` runs the
301 * original command.
302 */
303export const registerInterpreter = (on: On, configured: string | undefined): void => {
304 const python = interpreterPath(configured)
305 if (python === undefined) return
306 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
307 const usable = await usableInterpreter($, e.command, python)
308 const rewritten = usable === undefined ? null : rewriteCommand(e.command, usable)
309 if (rewritten === null) return next(e)
310 const ran = await next({ ...e, command: rewritten })
311 if (ran.deny === undefined) await countRewrite($)
312 return withNote(ran, rewritten)
313 }).catch(($, e, next) => next(e))
314}
315hooks/scope.ts 656 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4/**
5 * Denies pytest runs that name no test file: no path at all, or a directory path. A file or a node-id is
6 * the unit. Bash and PowerShell commands are both judged, after the interpreter rewrite. `--collect-only`
7 * and other information-only runs pass. `/pytest-broad-ok` lets the next broad run through once.
8 * Every failure path (no root, a failed stat, an unreadable state store) lets the call through.
9 */
10
11type On = Parameters<Register>[0]
12type Dollar = EngineInterface
13
14const broadOk = atom({ plugin: 'pytest-scope', key: 'broadOk' } as const, false)
15
16const RULE = 'A directory path is never the unit; a file or a node-id is'
17const OVERRIDE = 'pytest-broad-ok'
18const MAX_DENY_FILES = 15
19const GIT_TIMEOUT_MS = 5000
20/** Test module names pytest collects by default. */
21const TEST_FILE = /(?:^|\/)(?:test_[^/]*|[^/]*_test)\.py$/i
22
23// ---------------------------------------------------------------------------
24// Paths
25
26/** Forward slashes, no surrounding quotes, no `./` lead, no duplicate or trailing slash. */
27export const cleanPath = (raw: string): string => {
28 let p = raw.trim().replace(/^["']|["']$/g, '').replace(/\\/g, '/')
29 p = p.replace(/(?<!^)\/{2,}/g, '/')
30 while (p.startsWith('./')) p = p.slice(2)
31 return p.length > 1 ? p.replace(/\/+$/, '') : p
32}
33
34/** Git Bash `/c/x` spelled as `c:/x`, so it compares with a drive-letter root. */
35export const driveForm = (p: string): string => p.replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:')
36
37/** True for `/x`, `C:/x` and `//share` spellings. */
38export const isAbsolute = (p: string): boolean => /^(?:[a-zA-Z]:)?\//.test(p)
39
40/** Root-relative form of `raw` when it lies under `root`; otherwise the cleaned path. */
41export const toRepoRelative = (raw: string, root: string | undefined): string => {
42 const p = cleanPath(raw)
43 if (root === undefined || !isAbsolute(p)) return p
44 const r = cleanPath(root)
45 const [pl, rl] = [driveForm(p).toLowerCase(), driveForm(r).toLowerCase()]
46 if (pl === rl) return '.'
47 return pl.startsWith(`${rl}/`) ? driveForm(p).slice(rl.length + 1) : p
48}
49
50/** Where to ask git for the top level, in order: the session cwd, then CLAUDE_PROJECT_DIR (no repeats). */
51export const rootStarts = (rawEnv: string | undefined, rawCwd: string | undefined): string[] => {
52 const out: string[] = []
53 for (const raw of [rawCwd, rawEnv]) {
54 const c = raw === undefined || raw.trim() === '' ? undefined : cleanPath(raw)
55 if (c !== undefined && !out.some(o => o.toLowerCase() === c.toLowerCase())) out.push(c)
56 }
57 return out
58}
59
60// ---------------------------------------------------------------------------
61// Host access (kept in this file: the engine follows `$` past an import only into function declarations)
62
63/** `git rev-parse --show-toplevel` from `start`; undefined on any failure. */
64const toplevelOf = async ($: Dollar, start: string): Promise<string | undefined> => {
65 const argv = ['git', '-C', start, 'rev-parse', '--show-toplevel']
66 const ran = await $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
67 const top = ran === undefined || ran.exitCode !== 0 ? '' : ran.stdout.trim()
68 return top === '' ? undefined : cleanPath(top)
69}
70
71/**
72 * The root relative paths resolve against: the git top level of the session cwd, else of
73 * CLAUDE_PROJECT_DIR, else the first of those that is set. Undefined when neither is. Never throws.
74 */
75const resolveRoot = async ($: Dollar, cwd: string | undefined): Promise<string | undefined> => {
76 const env = await $.env.get('CLAUDE_PROJECT_DIR').catch(() => undefined)
77 const starts = rootStarts(env, cwd)
78 for (const start of starts) {
79 const top = await toplevelOf($, start)
80 if (top !== undefined) return top
81 }
82 return starts[0]
83}
84
85/** The session cwd (it follows a Bash `cd`); undefined when the host does not answer. */
86const sessionCwd = async ($: Dollar): Promise<string | undefined> => {
87 const cwd = await $.session.cwd().catch(() => undefined)
88 return cwd === undefined || cwd.trim() === '' ? undefined : cleanPath(cwd)
89}
90
91/** Test files git knows under `dir` (root-relative; '.' for all), tracked or untracked; [] on failure. */
92const testFilesUnder = async ($: Dollar, root: string, dir: string): Promise<string[]> => {
93 const argv = ['git', '-C', root, 'ls-files', '--cached', '--others', '--exclude-standard', '--', dir]
94 const ran = await $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
95 if (ran === undefined || ran.exitCode !== 0) return []
96 return ran.stdout.split(/\r?\n/).map(cleanPath).filter(f => f !== '' && TEST_FILE.test(f))
97}
98
99// ---------------------------------------------------------------------------
100// Shell tokenizing
101
102/** A shell word, or a control operator between commands. */
103export type Token = { kind: 'word'; text: string } | { kind: 'op'; text: string }
104
105const OPS = ['&&', '||', ';;', '|&', ';', '|', '&', '\n'] as const
106
107/** Reads a quoted run starting at `i` (on the quote); returns its text and the index after it. */
108const readQuoted = (s: string, i: number): [string, number] => {
109 const q = s[i]
110 let out = ''
111 let j = i + 1
112 while (j < s.length && s[j] !== q) {
113 if (q === '"' && s[j] === '\\' && /["\\$`]/.test(s[j + 1] ?? '')) j++
114 out += s[j]
115 j++
116 }
117 return [out, j + 1]
118}
119
120/** Reads a redirection operator at `i` (`>`, `>>`, `<`, `>&1`, `<&-`); returns it and the index after. */
121const readRedirect = (s: string, i: number): [string, number] => {
122 const m = /^(?:[<>]{1,2}|&>>?)(?:&[0-9-]*)?/.exec(s.slice(i))
123 const text = m?.[0] ?? s[i] ?? ''
124 return [text, i + text.length]
125}
126
127/** Index of the next newline at or after `i` (the end of a `#` comment), or the end of `s`. */
128const commentEnd = (s: string, i: number): number => {
129 const nl = s.indexOf('\n', i)
130 return nl < 0 ? s.length : nl
131}
132
133/**
134 * Splits a command line into words and operators, honouring quotes; backslashes stay literal.
135 * A `#` starting a word opens a comment to the end of the line. A `(` starting a word and a `)`
136 * that closes no `(` of its own word are operators (subshell / group), so `$(pwd)/x` stays one word.
137 */
138export const tokenize = (s: string): Token[] => {
139 const tokens: Token[] = []
140 let word: string | undefined
141 let depth = 0
142 const flush = (): void => {
143 if (word !== undefined) tokens.push({ kind: 'word', text: word })
144 word = undefined
145 depth = 0
146 }
147 const op = (text: string): void => {
148 flush()
149 tokens.push({ kind: 'op', text })
150 }
151 let i = 0
152 while (i < s.length) {
153 const c = s[i] ?? ''
154 if (c === '\\' && s[i + 1] === '\n') {
155 i += 2
156 continue
157 }
158 if (c === '#' && word === undefined) {
159 i = commentEnd(s, i)
160 continue
161 }
162 if (c === "'" || c === '"') {
163 const [text, next] = readQuoted(s, i)
164 word = (word ?? '') + text
165 i = next
166 continue
167 }
168 if (c === '<' || c === '>' || (c === '&' && s[i + 1] === '>')) {
169 const [text, next] = readRedirect(s, i)
170 word = (word ?? '') + text
171 i = next
172 continue
173 }
174 if (c === '(' && word === undefined) {
175 op('(')
176 i++
177 continue
178 }
179 if (c === '(' || (c === ')' && depth > 0)) {
180 depth += c === '(' ? 1 : -1
181 word = (word ?? '') + c
182 i++
183 continue
184 }
185 if (c === ')') {
186 op(')')
187 i++
188 continue
189 }
190 const found = OPS.find(o => s.startsWith(o, i))
191 if (found !== undefined) {
192 op(found)
193 i += found.length
194 continue
195 }
196 if (c === ' ' || c === '\t' || c === '\r') flush()
197 else word = (word ?? '') + c
198 i++
199 }
200 flush()
201 return tokens
202}
203
204/** A queued heredoc: its delimiter, and whether `<<-` strips leading tabs from body lines. */
205type Heredoc = { delim: string; strip: boolean }
206
207/** Characters that end an unquoted heredoc delimiter word. */
208const DELIM_END = /[\s;&|<>()]/
209
210/**
211 * Reads the delimiter word after a `<<` that ends just before `i`: quotes and backslashes removed
212 * (`'EOF'`, `"EOF"`, `\EOF` all give EOF). Returns the heredoc and the index after the word.
213 */
214const readDelimiter = (s: string, i: number): [Heredoc, number] => {
215 const strip = s[i] === '-'
216 let j = strip ? i + 1 : i
217 while (s[j] === ' ' || s[j] === '\t') j++
218 let delim = ''
219 while (j < s.length && !DELIM_END.test(s[j] ?? '')) {
220 const c = s[j] ?? ''
221 if (c === "'" || c === '"') {
222 const close = s.indexOf(c, j + 1)
223 const stop = close < 0 ? s.length : close
224 delim += s.slice(j + 1, stop)
225 j = stop + 1
226 } else if (c === '\\') {
227 delim += s[j + 1] ?? ''
228 j += 2
229 } else {
230 delim += c
231 j++
232 }
233 }
234 return [{ delim, strip }, j]
235}
236
237/** Index after the bodies of `docs`, which start at `i`; an unterminated body runs to the end. */
238const skipBodies = (s: string, i: number, docs: readonly Heredoc[]): number => {
239 let at = i
240 for (const doc of docs) {
241 let found = false
242 while (!found && at < s.length) {
243 const nl = s.indexOf('\n', at)
244 const stop = nl < 0 ? s.length : nl
245 let line = s.slice(at, stop).replace(/\r$/, '')
246 if (doc.strip) line = line.replace(/^\t+/, '')
247 found = line === doc.delim
248 at = stop + 1
249 }
250 }
251 return Math.min(at, s.length)
252}
253
254/** Whether `s[i]` starts a word (so a `#` there opens a comment). */
255const atWordStart = (s: string, i: number): boolean => i === 0 || /[\s;&|()<>]/.test(s[i - 1] ?? '')
256
257/** Scanner state for stripHeredocs. */
258type HeredocScan = { s: string; i: number; out: string; quote: string | undefined; pending: Heredoc[] }
259
260/** Copies `n` characters at the cursor to the output. */
261const copy = (c: HeredocScan, n: number): void => {
262 c.out += c.s.slice(c.i, c.i + n)
263 c.i += n
264}
265
266/** One step inside a quoted span: a backslash in double quotes escapes the next character. */
267const stepQuoted = (c: HeredocScan): void => {
268 const ch = c.s[c.i]
269 if (ch === '\\' && c.quote === '"') return copy(c, 2)
270 if (ch === c.quote) c.quote = undefined
271 copy(c, 1)
272}
273
274/** A `<<` operator at the cursor (not `<<<`): copies it and its delimiter, and queues the body. */
275const stepHeredoc = (c: HeredocScan): void => {
276 const [doc, next] = readDelimiter(c.s, c.i + 2)
277 copy(c, next - c.i)
278 if (/^[A-Za-z_]/.test(doc.delim)) c.pending.push(doc)
279}
280
281/** One step outside quotes. */
282const stepPlain = (c: HeredocScan): void => {
283 const { s, i } = c
284 const ch = s[i] ?? ''
285 if (ch === '\\') copy(c, 2)
286 else if (ch === "'" || ch === '"') {
287 c.quote = ch
288 copy(c, 1)
289 } else if (ch === '#' && atWordStart(s, i)) copy(c, commentEnd(s, i) - i)
290 else if (s.startsWith('<<<', i)) copy(c, 3)
291 else if (s.startsWith('<<', i)) stepHeredoc(c)
292 else if (ch === '\n' && c.pending.length > 0) {
293 copy(c, 1)
294 c.i = skipBodies(s, c.i, c.pending)
295 c.pending = []
296 } else copy(c, 1)
297}
298
299/**
300 * Drops heredoc bodies (their lines are data, not commands); an unterminated body runs to the end.
301 * A `<<` inside quotes or a comment, and a `<<<` here-string, open no heredoc.
302 */
303export const stripHeredocs = (s: string): string => {
304 const c: HeredocScan = { s, i: 0, out: '', quote: undefined, pending: [] }
305 while (c.i < s.length) {
306 if (c.quote === undefined) stepPlain(c)
307 else stepQuoted(c)
308 }
309 return c.out
310}
311
312/** One simple command of a line: its words, and the operator that follows it ('' at the end). */
313export type Segment = { words: string[]; after: string }
314
315/** Groups tokens into simple commands. */
316export const segments = (tokens: readonly Token[]): Segment[] => {
317 const out: Segment[] = []
318 let words: string[] = []
319 for (const t of tokens) {
320 if (t.kind === 'word') words.push(t.text)
321 else {
322 if (words.length > 0) out.push({ words, after: t.text })
323 words = []
324 }
325 }
326 if (words.length > 0) out.push({ words, after: '' })
327 const last = out[out.length - 1]
328 // a trailing `;` or newline ends the line: the last command's status is the line's
329 if (words.length === 0 && last !== undefined && (last.after === ';' || last.after === '\n')) last.after = ''
330 return out
331}
332
333// ---------------------------------------------------------------------------
334// pytest argument parsing
335
336/** A parsed pytest invocation. */
337export type PytestRun = {
338 positionals: string[]
339 /** `--collect-only`, `--version`, `-h` and kin: nothing runs. */
340 isInfoOnly: boolean
341 /** `-k`, `-m`, `--deselect`, `--lf` or `--sw` given: a positional file may not run whole. */
342 isNarrowed: boolean
343}
344
345/**
346 * Options that take the next word. `--cov` takes an optional value through argparse `nargs='?'`, which
347 * consumes the next word whenever it does not start with `-`: `pytest --cov tests/test_x.py` names no
348 * test path and runs the whole suite, so the word is consumed here too.
349 */
350const SHORT_WITH_VALUE = new Set(['-k', '-m', '-p', '-c', '-o', '-n', '-W', '-r'])
351const LONG_WITH_VALUE = new Set([
352 '--rootdir', '--deselect', '--ignore', '--ignore-glob', '--confcutdir', '--basetemp', '--tb',
353 '--maxfail', '--durations', '--junitxml', '--junit-xml', '--log-level', '--log-file',
354 '--log-cli-level', '--log-file-level', '--override-ini', '--import-mode', '--capture',
355 '--color', '--timeout', '--durations-min', '--cov', '--cov-report', '--dist',
356])
357const INFO_ONLY = new Set(['--collect-only', '--co', '--version', '-V', '-h', '--help', '--fixtures', '--markers'])
358/** Selections that may leave part of a named file unrun. */
359const NARROWING = new Set([
360 '-k', '-m', '--deselect', '--lf', '--last-failed', '--sw', '--stepwise', '--sw-skip', '--stepwise-skip',
361])
362
363/** The option name of `tok`: `--x=v` -> `--x`, `-kexpr` -> `-k`, else `tok`. */
364const optionName = (tok: string): string => {
365 if (tok.startsWith('--')) return tok.split('=')[0] ?? tok
366 const short = tok.slice(0, 2)
367 return SHORT_WITH_VALUE.has(short) ? short : tok
368}
369
370/** Whether `tok` is an option that takes the next word as its value. */
371const takesNext = (tok: string): boolean =>
372 tok.startsWith('--') ? !tok.includes('=') && LONG_WITH_VALUE.has(tok) : SHORT_WITH_VALUE.has(tok)
373
374/** Parses pytest's own arguments (everything after `pytest`). */
375export const parsePytestArgs = (args: readonly string[]): PytestRun => {
376 const run: PytestRun = { positionals: [], isInfoOnly: false, isNarrowed: false }
377 let onlyPositionals = false
378 for (let i = 0; i < args.length; i++) {
379 const tok = args[i] ?? ''
380 if (onlyPositionals || !tok.startsWith('-') || tok === '-') {
381 if (!/^\d*[<>&]/.test(tok)) run.positionals.push(tok)
382 else if (/^\d*[<>]{1,2}$|^&>>?$/.test(tok)) i++
383 continue
384 }
385 if (tok === '--') {
386 onlyPositionals = true
387 continue
388 }
389 const name = optionName(tok)
390 if (INFO_ONLY.has(name)) run.isInfoOnly = true
391 if (NARROWING.has(name)) run.isNarrowed = true
392 if (takesNext(tok)) i++
393 }
394 return run
395}
396
397const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
398const PYTEST_NAMES = new Set(['pytest', 'py.test'])
399/** Interpreter options that take the next word (`python -X utf8 -m pytest`). */
400const PY_OPTS_WITH_VALUE = new Set(['-X', '-W'])
401
402/** The final path component, case-folded, without `.exe`. */
403const baseName = (word: string): string =>
404 (word.replace(/\\/g, '/').split('/').pop() ?? '').toLowerCase().replace(/\.exe$/, '')
405
406/** Shell keywords and `!` that may precede a command word. */
407const KEYWORDS = new Set(['!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', '{', 'exec', 'nohup'])
408
409/** Wrapper programs that run the rest of the line, with their options that take a value. */
410const WRAPPERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
411 ['time', new Set<string>()],
412 ['env', new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string'])],
413 ['nice', new Set(['-n', '--adjustment'])],
414 ['timeout', new Set(['-s', '--signal', '-k', '--kill-after'])],
415])
416
417/** `<x> run` runners, with their `run` options that take a value. */
418const RUNNERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
419 ['conda', new Set(['-n', '--name', '-p', '--prefix', '--cwd'])],
420 ['mamba', new Set(['-n', '--name', '-p', '--prefix', '--cwd'])],
421 ['micromamba', new Set(['-n', '--name', '-p', '--prefix', '--cwd'])],
422 ['uv', new Set(['--with', '--python', '-p', '--project', '--directory', '--env-file', '--extra', '--group',
423 '--package', '--with-requirements', '--index'])],
424 ['poetry', new Set<string>()],
425 ['pipenv', new Set<string>()],
426])
427
428/** Index after the options at `i` (each in `withValue` also takes the next word). */
429const skipOptions = (words: readonly string[], i: number, withValue: ReadonlySet<string>): number => {
430 let j = i
431 while (j < words.length && (words[j] ?? '').startsWith('-')) {
432 const w = words[j] ?? ''
433 j += withValue.has(w) ? 2 : 1
434 if (w === '--') break
435 }
436 return j
437}
438
439/** Index of the word after a wrapper or runner prefix at `i`; `i` when none starts there. */
440const skipWrapper = (words: readonly string[], i: number): number => {
441 const name = baseName(words[i] ?? '').replace(/\.(?:bat|cmd)$/, '')
442 const wrapper = WRAPPERS.get(name)
443 if (wrapper !== undefined) {
444 const j = skipOptions(words, i + 1, wrapper)
445 return name === 'timeout' ? j + 1 : j
446 }
447 const runner = RUNNERS.get(name)
448 if (runner !== undefined && words[i + 1] === 'run') return skipOptions(words, i + 2, runner)
449 return i
450}
451
452/** Index of the command word: past assignments, keywords, wrappers and runners. */
453export const commandStart = (words: readonly string[]): number => {
454 let i = 0
455 while (i < words.length) {
456 const w = words[i] ?? ''
457 if (ASSIGNMENT.test(w) || KEYWORDS.has(w)) {
458 i++
459 continue
460 }
461 const next = skipWrapper(words, i)
462 if (next === i) return i
463 i = next
464 }
465 return i
466}
467
468/** pytest's argument words when `words` invokes pytest; undefined otherwise. */
469export const pytestArgsOf = (words: readonly string[]): string[] | undefined => {
470 const i = commandStart(words)
471 const head = words[i]
472 if (head === undefined) return undefined
473 if (PYTEST_NAMES.has(baseName(head))) return words.slice(i + 1)
474 for (let j = i + 1; j < words.length; j++) {
475 const w = words[j] ?? ''
476 if (w === '-m' && PYTEST_NAMES.has(words[j + 1] ?? '')) return words.slice(j + 2)
477 if (w.startsWith('-m') && PYTEST_NAMES.has(w.slice(2))) return words.slice(j + 1)
478 if (!w.startsWith('-')) return undefined
479 if (PY_OPTS_WITH_VALUE.has(w)) j++
480 }
481 return undefined
482}
483
484/** A pytest call found in a command line, and whether its exit status decides the line's. */
485export type PytestCall = PytestRun & { decidesExit: boolean }
486
487/** Every pytest call in a Bash command line (raw or interpreter-rewritten). */
488export const findPytestCalls = (command: string): PytestCall[] => {
489 const segs = segments(tokenize(stripHeredocs(command)))
490 return segs.flatMap((seg, idx) => {
491 const args = pytestArgsOf(seg.words)
492 if (args === undefined) return []
493 const decidesExit = segs.slice(idx).every(s => s.after === '&&' || s.after === '')
494 return [{ ...parsePytestArgs(args), decidesExit }]
495 })
496}
497
498// ---------------------------------------------------------------------------
499// Broad pytest deny
500
501/** Strips a `::node` suffix (a drive letter's colon is kept). */
502export const stripNode = (p: string): string => {
503 const at = p.indexOf('::')
504 return at < 0 ? p : p.slice(0, at)
505}
506
507/** Joins a relative path onto `base`, folding `.` and `..` segments. */
508export const joinPath = (base: string, rel: string): string => {
509 const parts = cleanPath(base).split('/')
510 for (const seg of cleanPath(rel).split('/')) {
511 if (seg === '..' && parts.length > 1) parts.pop()
512 else if (seg !== '' && seg !== '.' && seg !== '..') parts.push(seg)
513 }
514 return parts.join('/') || '/'
515}
516
517/**
518 * Absolute spellings of a positional: as given when absolute, else under the session cwd (the Bash
519 * tool keeps its cwd between calls) and under the root.
520 */
521const absoluteSpellings = (p: string, root: string, cwd: string | undefined): string[] => {
522 if (isAbsolute(p)) return [p]
523 const bases = cwd === undefined ? [root] : [cwd, root]
524 return [...new Set(bases.map(b => joinPath(b, p)))]
525}
526
527/** Spellings of an absolute path to stat: under the root's own spelling, else as given and as `c:/x`. */
528const statCandidates = (p: string, root: string): string[] => {
529 const rel = toRepoRelative(p, root)
530 if (!isAbsolute(rel)) return [rel === '.' ? root : `${root}/${rel}`]
531 return [...new Set([p, driveForm(p)])]
532}
533
534/** Whether an absolute path is an existing directory; a failed stat counts as no. */
535const isDirectory = async ($: Dollar, p: string, root: string): Promise<boolean> => {
536 for (const candidate of statCandidates(p, root)) {
537 const stat = await $.fs.stat(candidate).catch(() => undefined)
538 if (stat !== undefined) return stat.kind === 'dir'
539 }
540 return false
541}
542
543/** The root-relative spelling of the first of `spellings` that is a directory, if any. */
544const firstDirectory = async (
545 $: Dollar,
546 spellings: readonly string[],
547 root: string,
548): Promise<string | undefined> => {
549 for (const abs of spellings) {
550 if (await isDirectory($, abs, root)) return toRepoRelative(abs, root)
551 }
552 return undefined
553}
554
555/** The broad targets of a run: '.' when nothing is named, else each positional that is a directory. */
556const broadTargets = async (
557 $: Dollar,
558 root: string,
559 cwd: string | undefined,
560 run: PytestRun,
561): Promise<string[]> => {
562 if (run.isInfoOnly) return []
563 if (run.positionals.length === 0) return ['.']
564 const found: string[] = []
565 for (const raw of run.positionals) {
566 const dir = await firstDirectory($, absoluteSpellings(cleanPath(stripNode(raw)), root, cwd), root)
567 if (dir !== undefined) found.push(dir)
568 }
569 return found
570}
571
572/** The listing lines for up to MAX_DENY_FILES test files, naming how many were left out. */
573export const fileListing = (files: readonly string[]): string[] => {
574 if (files.length === 0) return []
575 const shown = files.slice(0, MAX_DENY_FILES).map(f => ` ${f}`)
576 const more = files.length > MAX_DENY_FILES ? [` ... ${files.length - MAX_DENY_FILES} more`] : []
577 return [`Test files under it (${files.length}):`, ...shown, ...more]
578}
579
580/** The deny text naming the broad targets and up to MAX_DENY_FILES test files under them. */
581const denyMessage = async ($: Dollar, root: string, targets: string[]): Promise<string> => {
582 const what = targets.map(t => (t === '.' ? 'no test path (the whole suite)' : `directory ${t}`))
583 const found = await Promise.all(targets.map(t => testFilesUnder($, root, t)))
584 return [
585 `pytest-scope: broad pytest run blocked (${what.join(', ')}).`,
586 `${RULE}: name the test files that prove the change, or node-ids (file.py::test_name).`,
587 ...fileListing([...new Set(found.flat())]),
588 `The developer can arm a one-shot override with /${OVERRIDE}.`,
589 ].join('\n')
590}
591
592/** The deny text when any call is broad and no override is armed; consumes an armed override. */
593const judgeBroad = async ($: Dollar, calls: readonly PytestCall[]): Promise<string | undefined> => {
594 if (calls.length === 0) return undefined
595 const cwd = await sessionCwd($)
596 const root = await resolveRoot($, cwd)
597 if (root === undefined) return undefined
598 const targets = (await Promise.all(calls.map(c => broadTargets($, root, cwd, c)))).flat()
599 if (targets.length === 0) return undefined
600 if (await read($, broadOk)) {
601 await update($, broadOk, () => false)
602 return undefined
603 }
604 return denyMessage($, root, [...new Set(targets)])
605}
606
607// ---------------------------------------------------------------------------
608// Hooks
609
610type ShellResult = ToolCallResult<'Bash'> | ToolCallResult<'PowerShell'>
611
612/** Denies a broad pytest run, else runs the command. */
613const guardShell = async <R extends ShellResult>(
614 $: Dollar,
615 command: string,
616 run: () => Promise<R>,
617): Promise<R | { deny: string }> => {
618 const deny = await judgeBroad($, findPytestCalls(command))
619 return deny === undefined ? run() : { deny }
620}
621
622/** Bash and PowerShell: deny broad pytest runs. */
623const onShell = (on: On): void => {
624 on('tool.call', { tool: 'Bash' }, ($, e, next) => guardShell($, e.command, () => next(e)))
625 .catch(($, e, next) => next(e))
626 on('tool.call', { tool: 'PowerShell' }, ($, e, next) => guardShell($, e.command, () => next(e)))
627 .catch(($, e, next) => next(e))
628}
629
630/** session.start: register the one-shot override command. */
631const onSessionStart = (on: On): void => {
632 on('session.start', async ($, e, next) => {
633 await $.command.register({
634 name: OVERRIDE,
635 description: 'Allow the next broad (directory / no-path) pytest call once',
636 }).catch(() => undefined)
637 return next(e)
638 }).catch(($, e, next) => next(e))
639}
640
641/** The override command's answer. */
642const onOverride = (on: On): void => {
643 on('command.run', { command: OVERRIDE }, async $ => {
644 await update($, broadOk, () => true)
645 return { text: 'Next broad pytest call is allowed once.' }
646 }).catch(() => ({ text: 'pytest-scope: could not arm the override.' }))
647}
648
649/** Registers the broad-run deny and its override when `enabled`; registers nothing otherwise. */
650export const registerScope = (on: On, enabled: boolean): void => {
651 if (!enabled) return
652 onShell(on)
653 onSessionStart(on)
654 onOverride(on)
655}
656types/index.d.ts 14 lines1/** A `/pytest-broad-ok` override: armed until the next broad pytest call consumes it. */
2export type BroadOverride = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 'pytest-scope': {
7 /** Interpreter rewrites of calls that were not denied, this session. */
8 rewrites: number
9 /** One-shot override armed by /pytest-broad-ok. */
10 broadOk: BroadOverride
11 }
12 }
13}
14