SLOPSHOPPER

pytest-scope

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

newguardcommandprocess
A shopper browsing a rack in a slop shop
README

pytest-scope

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.

Install

/plugin install pytest-scope --marketplace t968rs/operator-straps

Options

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.

OptionTypeDefaultMeaning
pythonfileunsetAbsolute path of the interpreter bare pytest runs under
require_test_filebooleanfalseDeny pytest calls that name no test file

1. Interpreter rewrite (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:

WrittenRuns 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
  • The rewrite applies after ;, &&, ||, | and a newline. Words inside quotes, arguments (echo pytest) and qualified names (/usr/bin/python, python3.11) stay as written.
  • Commands whose structure is uncertain ($(...), 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.
  • The model sees a one-line [pytest-scope] bare pytest rewritten ... note beside the result.
  • Only the Bash tool is rewritten.

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.

2. Test-file requirement (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.

  • Recognised: 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.
  • Files and node-ids always run. --collect-only, --version, --help and runner scripts are never denied.
  • Heredoc bodies and comments are data, not commands.
  • A relative path resolves against the session cwd (which follows an earlier 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

$.state['pytest-scope'] (contract: types/index.d.ts): rewrites (number) and broadOk (boolean).

Known limitations

  • Commands nested inside bash -c "...", xargs or $(...) are neither rewritten nor judged.
  • A 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>.
  • PowerShell commands are parsed with the Bash tokenizer; PowerShell backtick escapes are not understood.

Checks

claude plugin validate --strict plugins/pytest-scope
claude plugin test plugins/pytest-scope

Source and import history: docs/PROVENANCE.md at the marketplace root.

Source 4 files
hooks/register.ts 14 lines
1import 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}
14
hooks/interpreter.ts 315 lines
1import 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}
315
hooks/scope.ts 656 lines
1import 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}
656
types/index.d.ts 14 lines
1/** 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