Repository navigation
fix(repo): Bash reads #374 left: substitutions, activate, case words, time, shell functions - #397
antonwolfy wants to merge 16 commits into
Conversation
Settles the conservative limits of the shell-read walker listed in intel#374, opened from the review of intel#369. Each fix keeps the issue's rule: when unsure, the read is dropped, never kept. - 374/1: inside a $( ... ) or backtick substitution, ;, &, &&, || and newlines no longer end the part, so the | or & after it is seen and `cd $(printf sub; true) | cat; cat AGENTS.md` reads AGENTS.md (and the & and backtick forms). A plain ( ... ) list still splits; the foreground `cd $(...); cat AGENTS.md` still loses the directory. - 374/2: `source` or `.` of a virtualenv's activate script alone (one operand ending in bin/activate) is assumed to move no shell; any other source, a computed one, activate.fish or extra arguments still loses the directory. A failed `source .venv/bin/activate && cat AGENTS.md` is no read, as after a cd. - 374/3: a new shell_functions module finds the functions a script defines; a call of one whose body names a directory builtin (or another such function) loses the directory and moves the shell, as an eval does, not when piped or backgrounded. The claude-stream-json converter carries the definitions to later Bash calls. - 374/4: a `case` opens only as a command word, so `echo case`, `a|case)` and `"case"` open none, and a quoted "esac" closes none; nested cases still nest. - 374/5: `time cat`, `time -p cat`, `time X=1 cat` read their file, and so does /usr/bin/time with no option (`X=1 time cat`, `\time cat`); `time cd` and `time -p cd` move as before. The contract (§4.1, §7 and its limits) and the docstrings say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Two forms in which a shell function kept a Bash read the agent did not see, the side intel#374 forbids: - A definition with its `{` on a later line (`f()`, a newline, `{ cd sub; }`, also `function f` and a comment line in between) was walked as if its body ran, so `sub/AGENTS.md` was kept where bash only defines f and reads `AGENTS.md`. The walker now skips the body that opens after a function's name alone, and the definition finder blanks comments, so a later call of f still loses the directory. - A call of a function named like a printer (`cat() { echo hi; }; cat AGENTS.md`) gave a read. bash looks functions up after quote removal, so `\cat` and `"cat"` run it too: a call whose command word names a function the script, or an earlier Claude Code Bash call, defined is no read. One named like a directory builtin (`cd() { echo; }; cd sub`) loses the directory, as definitions are found script-wide. `command cat AGENTS.md` now reads the file, as `command` runs the program and not the function; `env cat` and `/bin/cat` read it as before. The contract (§4.1, §7 and its limits), the docstrings and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
OpenCodeReview: Commit 3d55652 was not reviewed. This PR has reached its review limit (6 reviews). CI at `3d55652`: 8 still running, 7 passing, 2 skipped
To review this commit: re-request review from exai-bot in the Reviewers list. <details><summary>Last review (1e6e226): no findings</summary> Open threads from the bot: 0 </details> |
There was a problem hiding this comment.
🟡 Changes recommended
Shell parsing still records files as read when Bash would not read them due to mishandled function shadowing, unsupported timing options, and quoted activation paths.
4 open issues
What changed in this PR
4 new findings.
This PR follows up on #369 and #374 to improve Bash-derived read events used by repository evaluation metrics, preserving more valid reads while suppressing reads hidden by shell functions or uncertain directory changes.
Changes:
- Refines substitution splitting, activation handling,
casetracking, and timed-command recognition. - Tracks shell functions and carries their definitions across Claude Code Bash calls.
- Adds regression coverage and updates the evaluation contract, architecture documentation, and changelog.
| File | Description |
|---|---|
dokimi/tests/test_repo_run.py |
Updates the unknown-directory test to use a general sourced script. |
dokimi/tests/test_repo_events_bash.py |
Adds regression cases for Bash parsing and function tracking. |
dokimi/dokimi/repos/shell_words.py |
Refines command prefixes, activation detection, and case syntax. |
dokimi/dokimi/repos/shell_split.py |
Keeps substitution separators within their enclosing command. |
dokimi/dokimi/repos/shell_functions.py |
Discovers function definitions and potential directory changes. |
dokimi/dokimi/repos/events.py |
Preserves function definitions across Bash calls. |
dokimi/dokimi/repos/cli_traces.py |
Integrates function tracking and revised read detection. |
dokimi/docs/repo-evaluation-contract.md |
Documents read-counting behavior and parser limits. |
dokimi/docs/architecture.md |
Describes the new shell-function module. |
dokimi/changelog.d/20261104_120000_antonwolfy_bash-reads-374.md |
Summarizes the Bash-read fixes and metric effects. |
Review effort: Balanced
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #397 +/- ##
==========================================
+ Coverage 94.75% 94.81% +0.05%
==========================================
Files 352 355 +3
Lines 46507 46774 +267
Branches 7970 8012 +42
==========================================
+ Hits 44070 44351 +281
+ Misses 1544 1538 -6
+ Partials 893 885 -8
🚀 New features to boost your workflow:
|
Settles review comments on the intel#374 follow-up, each keeping a read only where bash surely printed it: - `command cd` / `builtin cd` past a function named `cd` run the builtin: `_command_words` returns a separate "function bypassed" flag (not `via_env`, which `_run_name` uses to reject builtins run as children), and the walker follows the cd instead of losing it. - A function named `command`, `env`, `builtin` or `time` (not the keyword at a command's start) runs in place of that wrapper: `_command_words` and `_runs_dir` look the word up as a function before unwrapping it. `command() { echo F; }; command cat AGENTS.md` is no read, and `command cd sub` after it no longer moves the walker into sub. Such a function whose body names a directory builtin loses the directory as before. - `name() ( ... )` definitions are recorded by `shell_functions.define` with no body words, so they shadow printers and never move the shell (their body runs in a subshell); `_Walk.in_function` skips their head, so `cd() ( ... )` no longer loses the directory. - bash's `time` keyword takes one `-p`, then one `--`: any other dash word is the command's name, so `time -x cat AGENTS.md` and `time --help cat AGENTS.md` read nothing. Where only the directory is at stake the old any-order option skipper stays, so `time -x cd sub` and `time > log -p cd sub` still lose the directory. - `_is_cd` / `_is_activate` split a part with shlex, as the walker does (falling back to blanks when shlex cannot): a failed `source 'missing env/bin/activate' && cat AGENTS.md` or `X="a b" cd sub && cat AGENTS.md` is no read. Contract §4.1 and §7, the limits list, docstrings and the changelog fragment say what is now followed. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles the coverage report on this PR: `shell_functions._body_end`'s
fallback for a body that never closes (its last line and the loop's
exit branch) had no test. A real definition reaches it: bash closes
`f() { cd sub; }>/dev/null` at `}>`, which the brace pattern does not
see, so the body runs to the end of the script and keeps its `cd`. The
test defines `f` so in one Claude Code Bash call and checks that a later
call's `f; cat AGENTS.md` keeps no relative read.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Two review points on intel#374's follow-up. A refused Bash call ran nothing, but its walk recorded its function definitions into the stream at once, before the hook's error result or the final record's permission_denials marked it refused. A refused `cat() { echo hi; }` then hid the next call's `cat AGENTS.md`, and a refused `f() { cd sub; }` made a later `f` lose the directory. Bash calls are now walked when the trace ends, in call order, each on a copy of the functions the calls before it defined; a call's definitions are kept for the later calls only when it was not denied. _settle_bash is unchanged and runs after that walk. `env` runs a program from PATH, never a shell builtin: `env command cat AGENTS.md` fails with "No such file or directory" and cat never runs. _command_words no longer strips `command` or a `builtin`/`command` before a directory builtin once it is past `env` (also /usr/bin/env), so `env command cat AGENTS.md` is no read; `env time cat AGENTS.md` still reads, as /usr/bin/time exists. The rule for a part the walker does not fully follow (_may_run_dir) no longer applies after a child either, so `env command -p cd sub` moves no shell. Contract (§4.1 guidance_read, §7, limits), docstrings and the changelog fragment updated. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
bash accepts the function keyword with a subshell body and no
parentheses after the name: `function cat ( echo FN ); time cat
AGENTS.md` prints only FN. The walker did not see a definition there,
so it kept `AGENTS.md` as read, and `function cd ( echo hi ); cd sub;
cat AGENTS.md` kept `sub/AGENTS.md`, a read bash never made.
shell_functions.define now records the name of `function NAME (`
(not `function NAME ()`, which the existing forms take) with no body
words: a call of it is no read and, its body running in a subshell,
moves nothing. Also across the Bash calls of one trace, through the
functions the converter carries.
In cli_traces, a definition whose body is a subshell (`f() (`, and now
`function f (`) has its own pattern, _SUBSHELL_DEF, and _Walk.in_function
skips its head without counting braces. Counting them, as for a brace
body, took a `{` word in the subshell (`f() ( echo { ); cd sub`) for a
group that never closed, so the `cd` after it was skipped as body and
the shell was left unmoved, where main follows it.
Settles review comment 4473537298 on the follow-up to intel#374.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
Review at 86684a9. Most of the #374 forms are now followed correctly. Two problems need a fix before merge. First, shell functions are carried across Claude Code Bash calls, but Claude Code starts each call in a fresh shell. Second, a case inside $( ) now hides the rest of the script.
What I ran, at 86684a9:
make ci-local --offline: 0 failed, 19 passed, 13 skipped (network and Docker jobs).- The PR's
tests/test_repo_events_bash.pyon the merge-base: 94 of its tests fail there, so the new tests cover the new behaviour. shell_walkon the PR head and onmainfor the forms in the line comments, and for the forms from the earlier bot threads.- The Bash tool text of Claude Code 2.1.303: "Shell state, such as env vars and functions, does not persist".
Checked, no issues
- Earlier threads:
command cd/builtin cdpast a functioncd, wrapper-named functions,time -x/time --help,function NAME ( ... ),env command cat, and the quoted activate in_failed_readall give the results the replies state. - Substitution splitting:
cd $(printf sub; true) | cat; cat AGENTS.mdand its&and backtick forms now readAGENTS.md, and acdinside a substitution still stays in its subshell. - Docs and changelog: the new module is in
architecture.md. The fragment is under Fixed and says thatguidance_readandunknown_readscan change.shell_functions.pyhas the SPDX header.
napetrov
left a comment
There was a problem hiding this comment.
COMMENT at 86684a9. One new nit inline (quadratic define, probed). Main open items are @vlad-perevezentsev's T11–T14, all still open on head; I re-found T12/T13/T14 independently, no duplicate threads.
Questions (PR body only):
- Risk paragraph says
command cd subloses the directory aftercd() { echo hi; }; head follows it tosub/AGENTS.md, as contract §7 says. Stale text? - Testing counts (23 tests / 101 cases; 63 fail on main) don't match: 174 new collected, 95 fail on base code.
Coverage: plain + edge passes, perf probe on both trees; full suite and ruff/mypy not run.
Review point 4481039161: Claude Code starts each Bash call in a new
shell that keeps only the working directory, so a function one call
defines does not exist in the next. The converter carried the
definitions from call to call, so `cat() { echo hi; }` then
`cat AGENTS.md` lost a real read, and `f() { cd sub; }` then `f` (not
found) dropped every later relative read.
_walk_bash now walks each call alone, and shell_walk no longer takes
the functions of an earlier script. The refused-call handling of
definitions goes with it, as no call's definitions reach another. The
contract, the event docstring and the changelog fragment no longer say
"or an earlier call"; the cross-call tests now check that a later call
reads the file and moves no shell.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review point 4481039171: split_script keeps a $( ) substitution in one part, counting its parentheses, but _case_tokens stripped only a $( ) with no parenthesis inside. So `v=$(case $x in (a) echo;; esac); cat AGENTS.md` opened a case whose esac was mid-part, and every later part of the script was hidden: no read, and none counted in unknown_reads. _case_tokens now leaves out each $( ) up to the ) that balances it, nested parentheses counted as the splitter counts them, and an unclosed $( to the end, where the splitter's part ends too. Tests cover a parenthesised pattern, a cd in such an arm, nesting, a backtick inside, and an unclosed $(. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review point 4481039186: `source .venv/bin/activate; cat AGENTS.md` read AGENTS.md, but `source .venv/bin/activate >/dev/null; cat AGENTS.md` still lost the directory. A redirect makes the part one the walker does not split itself, and _runs_dir, which decides it, never asked _activates. _activates now reads its operands past redirections (`> /dev/null`, `2>&1`, `< in`), and _runs_dir applies it to an operand with no expansion: a computed `source $V/bin/activate > /dev/null` or a glob still loses the directory, as the contract says. The same rule now lets a failed `source .venv/bin/activate >/dev/null && cat AGENTS.md` count as a failed read. The contract and the fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review point 4481039178: define took any `.` or `source` word of a
function's body as a directory builtin, so `f() { find . -name x; }`,
`f() { git add .; }` or `f() { echo source; }` made a call of f lose
the directory, and with it every later relative read. A body that only
ran `source .venv/bin/activate`, which the walker exempts at the top
level, did too.
_words now splits the body at its command separators (outside quotes)
and counts `source` or `.` only when _runs_dir finds it as a command's
word (past keywords, assignments, builtin, command and time), which
also applies the activate exemption with its redirections. The other
directory builtins still count as any word. The contract and the
fragment say so; tests cover both sides.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review point 4483156818: define scanned each body to its close, and to
the end of the script when it never closed, then found the movers with
a fixpoint that adds one name per pass over every function. 2000
unclosed `f{i}() { echo {i}` lines took 16 s per walk, a 1000-deep
`g{i}() { g{i+1}; }` chain ending in a cd 2.8 s.
_bodies now takes one pass over the braces and gives each function the
text of its own body only: a nested body is its own, and its head stays
in the outer one, which so names it as before. The movers are the
functions naming a directory builtin and, by a worklist over reverse
callers, those that call one, each taken once. The two scripts now
walk in 0.08 s and 0.02 s with the same results; a test holds both
under 2 s, and nested definitions and brace groups in a body are
pinned.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
@vlad-perevezentsev (#397 (review)): Both blocking points are fixed: functions no longer carry across Claude Code Bash calls (a44eba6, thread 4481039161), and a |
@napetrov (#397 (review)): Both points about the description were right, and it is updated; the code needed no change for them. The Risk paragraph now says |
Review point 4484211005: in `v=$(case x in x) echo hi; cat INNER.md;; esac); cat AGENTS.md` the splitter took the pattern's ) as the one that closes the $(, so the `;` after it split the substitution and `cat INNER.md` was walked in the caller's shell: a read of a file whose output only went into v. A `cd sub` in that arm lost the caller's directory. `_closing`, used when counting case words, stopped at the same ), so `case $x in a) v=$(case $y in b) echo;; esac); cd sub;; esac` let the outer arm's cd be followed. Inside a $( the splitter now keeps an open `case` on its stack of parentheses when the word is a command word (first in a list, or after a keyword), and a ) while it is innermost ends a pattern; `esac` closes it. `_closing` is replaced by `shell_split.substitution_end`, which runs the splitter itself, so both read a substitution the same way. Tests cover the trigger, a cd in the arm, a pipe, an if, a nested case, an `a|case)` pattern, a second arm, a case after a pipe in an arm, a multi-line case, `echo case` as an argument, and the outer-arm cd. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Codecov's patch report left one partial branch in each of shell_functions.py and shell_split.py: the guards that keep a "}" closing no brace group, and an "esac" with no case innermost, from popping what is open. Neither had a test. Add a test for each guard, through shell_walk: - "echo }" before or after a function definition closes nothing, so the body's cd is still found and the call loses the directory. - a "(esac)" case pattern inside a $( ), and an esac with no case open, close no case and no substitution: a cat inside the $( ) is no read, and the ")" still ends it, so the cat after it is read. No code changes: both inputs already gave these results. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
The } of `find -exec cmd {} +` or `xargs -I{}` ended the body at it, so a
cd after it was not among the body's words and a call of the function kept
the next relative read at the start directory.
Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
`v=$(echo then case x)` or `v=$(echo $(date) case x)` took the argument case as a command word, so the $( ) never closed and the cd after it was lost. A keyword counts only at a command position, a ) only as a pattern's. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|

Summary
Follow-up to the review of #369, settling #374 ("repo: Bash reads dropped where the shell could not have
moved"), whose rule is: "A fix for each is welcome, as long as it does not keep a read the agent may not
have seen" (review, issue).
On
main(b087c40),shell_walk(script)indokimi/repos/cli_traces.pygives(reads, shell_moved, unknown):maincd $(printf sub; true) | cat; cat AGENTS.md(and&, and the backtick forms)([], True, 1)(['AGENTS.md'], False, 0)AGENTS.md; the cd ran in a subshellcd $(printf sub; true); cat AGENTS.md([], True, 1)([], True, 1)source .venv/bin/activate; cat AGENTS.md([], True, 1)(['AGENTS.md'], False, 0)cdsource env.sh; cat AGENTS.md([], True, 1)([], True, 1)cdf() { cd sub; }; f; cat AGENTS.md(['AGENTS.md'], False, 0)([], True, 1)sub/AGENTS.md:mainkept a read at the wrong directoryg() { echo hi; }; g; cat AGENTS.md(['AGENTS.md'], False, 0)(['AGENTS.md'], False, 0)AGENTS.mdf()⏎{⏎cd sub⏎}⏎cat AGENTS.md(andfunction f⏎{, a comment line before{)(['sub/AGENTS.md'], True, 0)(['AGENTS.md'], False, 0)f: readsAGENTS.mdf⏎cat AGENTS.md(['sub/AGENTS.md'], True, 0)([], True, 1)sub/AGENTS.md;maingot there by running the body at the definitioncat() { echo hi; }; cat AGENTS.md(and\cat,"cat",X=1 cat)(['AGENTS.md'], False, 0)([], False, 0)hi: the function runs, notcatcat() { echo hi; }; command cat AGENTS.md([], False, 0)(['AGENTS.md'], False, 0)commandruns the program, not the functioncd() { echo hi; }; cd sub; cat AGENTS.md(['sub/AGENTS.md'], True, 0)([], True, 1)AGENTS.md; the function shadowscdcase $x in a) echo case;; esac; cat AGENTS.md([], False, 0)(['AGENTS.md'], False, 0)AGENTS.mdcase $x in a) (case $y in b) echo;; esac); cd sub;; esac; cat AGENTS.md(['sub/AGENTS.md'], True, 0)([], True, 1)sub/AGENTS.mdonly when$xisatime cat AGENTS.md,time -p cat AGENTS.md,time X=1 cat AGENTS.md([], False, 0)(['AGENTS.md'], False, 0)AGENTS.md; the report goes to stderrX=1 time cat AGENTS.md,\time cat AGENTS.md([], False, 0)(['AGENTS.md'], False, 0)time cd sub; cat AGENTS.md(['sub/AGENTS.md'], True, 0)(['sub/AGENTS.md'], True, 0)source .venv/bin/activate >/dev/null; cat AGENTS.md([], True, 1)(['AGENTS.md'], False, 0)cd, redirected toov=$(case $x in (a) echo;; esac); cat AGENTS.md(['AGENTS.md'], False, 0)(['AGENTS.md'], False, 0)AGENTS.md; thecaseis inside the substitutionv=$(case x in x) echo hi; cat INNER.md;; esac); cat AGENTS.md(['INNER.md', 'AGENTS.md'], False, 0)(['AGENTS.md'], False, 0)AGENTS.md;INNER.mdis printed intovv=$(case x in x) cd sub;; esac); cat AGENTS.md([], True, 1)(['AGENTS.md'], False, 0)AGENTS.md; thecdran in the substitution's subshellf() { git add .; }; f; cat AGENTS.md(andfind . -name x,source .venv/bin/activateas the body)(['AGENTS.md'], False, 0)(['AGENTS.md'], False, 0)AGENTS.md:.as a path runs nothing(⏎ is a newline.) The backgrounded
{ case x in x) cd sub;; esac; } & cat AGENTS.mdfrom the issue's firstcomment already reads
AGENTS.mdonmain(fixed in #369); this PR adds nothing for it.The first commit settles the forms #374 lists. The second fixes two function forms the
first one still got wrong, both keeping a read bash did not make: a definition with its
{on a laterline, and a function that shadows a printer. The next five answer the review at 86684a9 (review, review): a Claude Code Bash call no longer sees the functions an earlier call defined, a
caseinside$( )no longer hides the rest of the script,.andsourcein a function body count only as a command, the activate rule holds with a redirection, anddefineruns in linear time. The next one answers the review at 5a6b63e (review): acasepattern's)inside a$( )no longer ends the substitution. The last one adds tests for the two guards codecov's patch report at c1f6ba2 found partly covered, one branch each inshell_functions.pyandshell_split.py; it changes no code.Changes
shell_split.py). The splitter keeps a stack of open parentheses. Inside a$( ... )or a backtick substitution,
;,&,&&,||and newlines stay in the part, so the|or&after itis seen and the part is in a subshell, as before. A plain
( ... )list still splits inside: its partsrecord no reads either way (
( cd sub; cat a ); cat AGENTS.mdstill reads onlyAGENTS.md).Inside a
$( ... ), acasethat is a command word (first in a list, or after a keyword) stays on thatstack until its
esac, so a pattern's)with no((x)) ends the pattern, not the substitution.shell_words._activates,_loses).sourceor.whose only operand ends inbin/activate(relative or absolute, quoted or not, also afterbuiltinor with a redirection such as> /dev/null) is assumed to move no shell:the scripts venv, virtualenv and uv write set variables and
PATHand run nocd.source env.sh,source x/bin/activate extra,source $V/bin/activate,source bin/activate.fishandevallose thedirectory as before. A failed
source .venv/bin/activate && cat AGENTS.mdis no read, as after acd(
_is_activatein_failed_read): the read this PR now keeps cannot survive a failure. A part with aredirection is decided by
_runs_dir, which applies the same rule to an operand with no expansion, sosource $V/bin/activate > /dev/nullstill loses the directory.shell_functions.py,_Walk.command,events._walk_bash).definefinds the functions a script defines (f() { ...; },function f { ...; }), with the words ofeach body, in one pass over the braces (each body's own text once; the movers by a worklist over their
callers). A call of a function whose body names
cd,pushd,popd,eval,sourceor.(these twoonly as a command's word and not of an activate script:
f() { git add .; }names none), or callssuch a function (
g() { f; }, defined before or after), loses the directory and moves the shell, as anevaldoes. That holds also afterX=1,timeor a redirection (f > log), and in acasearm. Pipedor backgrounded, the call moves nothing, and so does
env forcommand f. The claude-stream-jsonconverter walks each Bash call alone: Claude Code starts each call in a new shell, which keeps the
directory but no function an earlier call defined. A
function whose body names no directory builtin moves nothing.
{on a later line (_Walk.in_function,shell_functions.define). A part that isonly a function's name (
f(),function f,function f()) is skipped, and so is the body that opens inthe next part (the splitter drops blank lines and comments, so
f(), a comment line,{works too).defineblanks comments before it looks for the{, so a comment holding a quote does not hide thedefinition, and a later
fcall still loses the directory._Walk.command,shell_words._runs_program). bash looks a commandword up as a function after quote removal, so a call whose command word names a function the script
defined is no read:
cat() { echo hi; }; cat AGENTS.md, also with\cat,"cat",X=1 cat,time cator{ cat AGENTS.md; }. If the function also names a directorybuiltin, or has the name of one (
cd() { echo hi; }; cd sub), the call loses the directory. Definitionsare found in the whole script, so
cdmay not have been the function yet.command cat AGENTS.mdnowreads the file:
commandbefore a name runs the builtin or program and never the function. That does notapply before an option (
command -v,command -p),NAME=value, a redirection or another prefix(
command builtin cd substill loses the directory).env catand/bin/cat(a name with a slash isnever looked up as a function) read the file as before.
casewords (shell_words._case_opens,_case_closes,_Walk.in_case). Acaseopens a levelonly as a command word: first in the part (past
{,time, keywords), or after(,|or a pattern's), and not followed by)or|. Soecho case,a|case),"case"and\caseopen none. A quoted"esac"closes none. Nested cases still nest. A miscount stays on the drop side: acaseafter any|counts. A
$( ... )is left out up to the)that closes it as the splitter reads it(
shell_split.substitution_endruns the splitter): nested parentheses counted, a pattern's)not, so acasewith an(a)or ana)pattern inside one opens nothing outside it.time(shell_words._command_words,_past_time). The keyword, with-p, assignments andredirections, is skipped before any command, not only before a directory builtin.
time -p cd subisstill not followed (it loses the directory, as before). /usr/bin/time (
X=1 time,\time,/usr/bin/time) runs the next word as a child's program, asenvdoes:X=1 time cat AGENTS.mdreadsit,
\time -o log cat AGENTS.mdand\time X=1 cat AGENTS.mdread nothing, andX=1 time cd substill moves nothing.
tests/test_repo_events_bash.py). A}that closes no brace group (echo }, before or after adefinition) closes nothing, so
f() { cd sub; }'s body still names itscdand a call offloses the directory(
shell_functions._bodies). Anesacwith nocaseinnermost (a(esac)pattern, or anesacwith nocaseopen, which bash refuses) closes no case and no substitution: the
)still ends the$( )(
shell_split._Splitter._case_word). Both already held; no code changes.guidance_read,unknown_reads), §7 and its limits list (the activate-scriptassumption is a new limit bullet; the function bullet says what is followed now; the repo: Bash reads dropped where the shell could not have moved (conservative limits of #369) #374 link is gone,
since no form it listed is left), the
shell_reads/shell_reads_moved/shell_walkdocstrings, andarchitecture.mdfor the new module.Risk
guidance_read,unknown_readsand thefetchedclass of reach can change for traces with these forms.More reads are kept for the timed, activated, substitution-piped,
case-word andcommand catforms above,and after a
cdin an arm of acaseinside$( ).Fewer are kept after a call of a function that runs a
cd, after a definition whose{is on a later line,for a call of a function named like a printer, and in an arm of a
caseinside$( )whose pattern has no(, wheremainkept a read at the wrong directory or of a file that was not printed. The activate rule is an assumption about those scripts: an activate script thatdoes run
cdwould leave the later relative reads at the wrong directory. Every other new imprecisiondrops a read. A function defined again keeps what each definition named. A definition in a subshell, one
that never ran, or one later in the script counts too:
cat AGENTS.md; cat() { echo hi; }is no read, andcd() { echo hi; }makes anycd subin the script lose the directory (command cd subandbuiltin cd subrun the builtin, and are followed). A function counts only in the Bash call that defines it: after a call that
defined
cat() { echo hi; }, a later call'scat AGENTS.mdis a read, as Claude Code keeps no shell functionbetween calls.
shell_walk's signature and result shape are unchanged.Testing
On a Linux review host, at
1e6e226(onmainb087c40):make ci-local: 30 passed, 0 failed, 7 skipped (external-catalog-smoke: needs Docker and network;test-latest, docs deploy and pip-audit report: job conditions false on a PR; cli-surface "Package the
result": an expression the local runner does not know; conda-forge-deps: no micromamba; semgrep secrets:
not installed, runs on the PR). 15922 tests passed, 10 skipped; changed-line coverage 100% (229 lines).
The matrix legs on Python 3.11, 3.12 and 3.14 were not run here.
tests/test_repo_events_bash.pycollects 421 cases. 214 of them, in 51 tests, are new in this PR (the formsin the table and their negative cases, each asserting the full
shell_walkresult or the converter'sevents), and 114 of those fail on
main's code. The rest are guards that hold on both: negative function,sourceandcommand -vcases,\time -o,env cat,/bin/cat, and formsmainalready got right.Four existing parameters changed in the first commit.
source .venv/bin/activate, used as an example of a move, becamesource env.sh(twice intests/test_repo_events_bash.py, once intests/test_repo_run.py).time cat AGENTS.md .becametime wc -l AGENTS.md ., since a timedcatis now a read.cross-call ones, the
$( )caseforms, the body.and activate forms, the redirected activate forms, andboth timing cases (16 s and 2.8 s there, 0.08 s and 0.02 s here, bound at 2 s). The 14 that pass are guards
(a body's
sourceas a command,source $V/bin/activate > /dev/null, nested definitions). It removes 13cases in 8 tests that pinned the carry of functions across calls, refused or not.
main: the reviewer'sform, a
cdin the arm, a pipe, anif, a nested case, ana|case)pattern, a second arm, a multi-linecase, and a
cdin an outer arm after a$( )holding acase. The 2 that pass are guards:v=$(echo case x)and acaseafter a
|in an arm.partial branch, under
--cov-branchas CI runs it. The 2echo }cases fail onmain, which follows no functioncall (
(['AGENTS.md'], False, 0)). The 2esaccases pass there.$(detection, the subshellflag, each condition of
_activates, the activate case of_failed_read, the function-call check,env f, the closure over functions calling functions,_runs_dirandin_casewithout the functions,the converter's carry, unquoting body words, the
function fform, a body that never closes, eachcondition of
_case_opens, quote masking,_case_closes, the keyword skip, the /usr/bin/time skip andits stop,
_past_timefortime -p cd). There are 13 more for the second: the later-line body opening, thename-alone skip, comment blanking in
define, the function-call check, itscommand/envbypass,looking up
/bin/catascat, the directory-builtin loss of a shadowing function,_runs_program, andeach of its exclusions (option, assignment, redirection, prefix), and the converter's carry. Each makes
its test fail. For the review at 86684a9, 12 more: carrying functions across calls again,
_closingwithout counting
(,_runs_dirwithout the activate rule, with expanded operands allowed,_activateswithout skipping redirections, body words with
.andsourceas any word, with none, segments startingat the separator, a brace group in a body not in its function, the worklist not following callers, a body
never closed dropped, and the previous quadratic
define(the timing cases fail at 43.6 s and 3.0 s).Each makes its test fail. A 13th, an unclosed
$(stripped to its$(only, changes no result: thesplitter keeps the rest of the script in that part, so nothing after it is walked either way. For the review
at 5a6b63e, 6 more: a pattern's
)popping thecase,casetaken as a command word anywhere, without thekeyword rule,
case)taken as acase,esacnot closing it, and the previous_closingfor thecasecount. Each makes its test fail. For codecov's report at c1f6ba2, 2 more: a
}popping with no brace open(
_bodiesraisesIndexErroron both cases), and anesacpopping whatever is innermost (the strayesacclosesthe
$( ), and thecatinside it becomes a read). Each makes its test fail. The(esac)pattern case passesunder the second: popping the pattern's
(there leaves the same stack once the pattern's)is read, so it is aguard for the valid form rather than for the check.
cat() { echo FN; }defined,\cat fand"cat" fprintFN,command cat fand/bin/cat fprint the file. A definition with a comment lineand its
{on later lines defines the function, andcommand X=1 catrunsX=1as the command name.Revert
A plain revert of the PR's commits, newest first. No stored record or schema changes; events written with this
converter keep the reads it derived.
Checklist
pytestpassesruff,mypy, and schema checks passAGENTS.md.github/.licenserc.yaml)