Skip to content

fix(repo): Bash reads #374 left: substitutions, activate, case words, time, shell functions - #397

Open
antonwolfy wants to merge 16 commits into
intel:mainfrom
antonwolfy:fix/bash-reads-374
Open

antonwolfy wants to merge 16 commits into
intel:mainfrom
antonwolfy:fix/bash-reads-374

Conversation

@antonwolfy

@antonwolfy antonwolfy commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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) in dokimi/repos/cli_traces.py gives (reads, shell_moved, unknown):

script main here bash
cd $(printf sub; true) | cat; cat AGENTS.md (and &, and the backtick forms) ([], True, 1) (['AGENTS.md'], False, 0) reads AGENTS.md; the cd ran in a subshell
cd $(printf sub; true); cat AGENTS.md ([], True, 1) ([], True, 1) the cd may have moved the shell
source .venv/bin/activate; cat AGENTS.md ([], True, 1) (['AGENTS.md'], False, 0) an activate script runs no cd
source env.sh; cat AGENTS.md ([], True, 1) ([], True, 1) may run a cd
f() { cd sub; }; f; cat AGENTS.md (['AGENTS.md'], False, 0) ([], True, 1) reads sub/AGENTS.md: main kept a read at the wrong directory
g() { echo hi; }; g; cat AGENTS.md (['AGENTS.md'], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md
f()⏎{⏎ cd sub⏎}⏎cat AGENTS.md (and function f⏎{, a comment line before {) (['sub/AGENTS.md'], True, 0) (['AGENTS.md'], False, 0) only defines f: reads AGENTS.md
the same, then f⏎cat AGENTS.md (['sub/AGENTS.md'], True, 0) ([], True, 1) reads sub/AGENTS.md; main got there by running the body at the definition
cat() { echo hi; }; cat AGENTS.md (and \cat, "cat", X=1 cat) (['AGENTS.md'], False, 0) ([], False, 0) prints hi: the function runs, not cat
cat() { echo hi; }; command cat AGENTS.md ([], False, 0) (['AGENTS.md'], False, 0) command runs the program, not the function
cd() { echo hi; }; cd sub; cat AGENTS.md (['sub/AGENTS.md'], True, 0) ([], True, 1) reads AGENTS.md; the function shadows cd
case $x in a) echo case;; esac; cat AGENTS.md ([], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md
case $x in a) (case $y in b) echo;; esac); cd sub;; esac; cat AGENTS.md (['sub/AGENTS.md'], True, 0) ([], True, 1) reads sub/AGENTS.md only when $x is a
time cat AGENTS.md, time -p cat AGENTS.md, time X=1 cat AGENTS.md ([], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md; the report goes to stderr
X=1 time cat AGENTS.md, \time cat AGENTS.md ([], False, 0) (['AGENTS.md'], False, 0) /usr/bin/time passes the child's stdout through
time cd sub; cat AGENTS.md (['sub/AGENTS.md'], True, 0) (['sub/AGENTS.md'], True, 0) unchanged
source .venv/bin/activate >/dev/null; cat AGENTS.md ([], True, 1) (['AGENTS.md'], False, 0) an activate script runs no cd, redirected too
v=$(case $x in (a) echo;; esac); cat AGENTS.md (['AGENTS.md'], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md; the case is inside the substitution
v=$(case x in x) echo hi; cat INNER.md;; esac); cat AGENTS.md (['INNER.md', 'AGENTS.md'], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md; INNER.md is printed into v
v=$(case x in x) cd sub;; esac); cat AGENTS.md ([], True, 1) (['AGENTS.md'], False, 0) reads AGENTS.md; the cd ran in the substitution's subshell
f() { git add .; }; f; cat AGENTS.md (and find . -name x, source .venv/bin/activate as the body) (['AGENTS.md'], False, 0) (['AGENTS.md'], False, 0) reads AGENTS.md: . as a path runs nothing

(⏎ is a newline.) The backgrounded { case x in x) cd sub;; esac; } & cat AGENTS.md from the issue's first
comment already reads AGENTS.md on main (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 later
line, 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 case inside $( ) no longer hides the rest of the script, . and source in a function body count only as a command, the activate rule holds with a redirection, and define runs in linear time. The next one answers the review at 5a6b63e (review): a case pattern'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 in shell_functions.py and shell_split.py; it changes no code.

Changes

  • Substitutions (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 it
    is seen and the part is in a subshell, as before. A plain ( ... ) list still splits inside: its parts
    record no reads either way (( cd sub; cat a ); cat AGENTS.md still reads only AGENTS.md).
    Inside a $( ... ), a case that is a command word (first in a list, or after a keyword) stays on that
    stack until its esac, so a pattern's ) with no ( (x)) ends the pattern, not the substitution.
  • Activate scripts (shell_words._activates, _loses). source or . whose only operand ends in
    bin/activate (relative or absolute, quoted or not, also after builtin or with a redirection such as
    > /dev/null) is assumed to move no shell:
    the scripts venv, virtualenv and uv write set variables and PATH and run no cd. source env.sh,
    source x/bin/activate extra, source $V/bin/activate, source bin/activate.fish and eval lose the
    directory as before. A failed source .venv/bin/activate && cat AGENTS.md is no read, as after a cd
    (_is_activate in _failed_read): the read this PR now keeps cannot survive a failure. A part with a
    redirection is decided by _runs_dir, which applies the same rule to an operand with no expansion, so
    source $V/bin/activate > /dev/null still loses the directory.
  • Shell functions that move the shell (new shell_functions.py, _Walk.command, events._walk_bash).
    define finds the functions a script defines (f() { ...; }, function f { ...; }), with the words of
    each 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, source or . (these two
    only as a command's word and not of an activate script: f() { git add .; } names none), or calls
    such a function (g() { f; }, defined before or after), loses the directory and moves the shell, as an
    eval does. That holds also after X=1, time or a redirection (f > log), and in a case arm. Piped
    or backgrounded, the call moves nothing, and so does env f or command f. The claude-stream-json
    converter 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.
  • A definition's { on a later line (_Walk.in_function, shell_functions.define). A part that is
    only a function's name (f(), function f, function f()) is skipped, and so is the body that opens in
    the next part (the splitter drops blank lines and comments, so f(), a comment line, { works too).
    define blanks comments before it looks for the {, so a comment holding a quote does not hide the
    definition, and a later f call still loses the directory.
  • A function shadowing a command (_Walk.command, shell_words._runs_program). bash looks a command
    word 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 cat or { cat AGENTS.md; }. If the function also names a directory
    builtin, or has the name of one (cd() { echo hi; }; cd sub), the call loses the directory. Definitions
    are found in the whole script, so cd may not have been the function yet. command cat AGENTS.md now
    reads the file: command before a name runs the builtin or program and never the function. That does not
    apply before an option (command -v, command -p), NAME=value, a redirection or another prefix
    (command builtin cd sub still loses the directory). env cat and /bin/cat (a name with a slash is
    never looked up as a function) read the file as before.
  • case words (shell_words._case_opens, _case_closes, _Walk.in_case). A case opens a level
    only as a command word: first in the part (past {, time, keywords), or after (, | or a pattern's
    ), and not followed by ) or |. So echo case, a|case), "case" and \case open none. A quoted
    "esac" closes none. Nested cases still nest. A miscount stays on the drop side: a case after any |
    counts. A $( ... ) is left out up to the ) that closes it as the splitter reads it
    (shell_split.substitution_end runs the splitter): nested parentheses counted, a pattern's ) not, so a
    case with an (a) or an a) pattern inside one opens nothing outside it.
  • time (shell_words._command_words, _past_time). The keyword, with -p, assignments and
    redirections, is skipped before any command, not only before a directory builtin. time -p cd sub is
    still 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, as env does: X=1 time cat AGENTS.md reads
    it, \time -o log cat AGENTS.md and \time X=1 cat AGENTS.md read nothing, and X=1 time cd sub
    still moves nothing.
  • Guard tests (tests/test_repo_events_bash.py). A } that closes no brace group (echo }, before or after a
    definition) closes nothing, so f() { cd sub; }'s body still names its cd and a call of f loses the directory
    (shell_functions._bodies). An esac with no case innermost (a (esac) pattern, or an esac with no case
    open, which bash refuses) closes no case and no substitution: the ) still ends the $( )
    (shell_split._Splitter._case_word). Both already held; no code changes.
  • Contract §4.1 (guidance_read, unknown_reads), §7 and its limits list (the activate-script
    assumption 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_walk docstrings, and
    architecture.md for the new module.
  • Changelog fragment (Fixed).

Risk

guidance_read, unknown_reads and the fetched class of reach can change for traces with these forms.
More reads are kept for the timed, activated, substitution-piped, case-word and command cat forms above,
and after a cd in an arm of a case inside $( ).
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 case inside $( ) whose pattern has no
(, where main kept 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 that
does run cd would leave the later relative reads at the wrong directory. Every other new imprecision
drops 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, and
cd() { echo hi; } makes any cd sub in the script lose the directory (command cd sub and builtin cd sub
run 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's cat AGENTS.md is a read, as Claude Code keeps no shell function
between calls. shell_walk's signature and result shape are unchanged.

Testing

On a Linux review host, at 1e6e226 (on main b087c40):

  • 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.py collects 421 cases. 214 of them, in 51 tests, are new in this PR (the forms
    in the table and their negative cases, each asserting the full shell_walk result or the converter's
    events), and 114 of those fail on main's code. The rest are guards that hold on both: negative function,
    source and command -v cases, \time -o, env cat, /bin/cat, and forms main already got right.
    Four existing parameters changed in the first commit.
    source .venv/bin/activate, used as an example of a move, became source env.sh (twice in
    tests/test_repo_events_bash.py, once in tests/test_repo_run.py). time cat AGENTS.md . became
    time wc -l AGENTS.md ., since a timed cat is now a read.
  • The review at 86684a9 adds or changes 35 cases in 11 tests, and 21 of them fail at 86684a9: the
    cross-call ones, the $( ) case forms, the body . and activate forms, the redirected activate forms, and
    both 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 source as a command, source $V/bin/activate > /dev/null, nested definitions). It removes 13
    cases in 8 tests that pinned the carry of functions across calls, refused or not.
  • The review at 5a6b63e adds 11 cases in 2 tests, and 9 of them fail at 5a6b63e and on main: the reviewer's
    form, a cd in the arm, a pipe, an if, a nested case, an a|case) pattern, a second arm, a multi-line
    case, and a cd in an outer arm after a $( ) holding a case. The 2 that pass are guards: v=$(echo case x) and a case
    after a | in an arm.
  • Codecov's report at c1f6ba2 adds 4 cases in 2 tests. They pass at c1f6ba2. Both changed modules now have no
    partial branch, under --cov-branch as CI runs it. The 2 echo } cases fail on main, which follows no function
    call ((['AGENTS.md'], False, 0)). The 2 esac cases pass there.
  • Mutants: 29 breaks of the first commit's code (the substitution check, $( detection, the subshell
    flag, each condition of _activates, the activate case of _failed_read, the function-call check,
    env f, the closure over functions calling functions, _runs_dir and in_case without the functions,
    the converter's carry, unquoting body words, the function f form, a body that never closes, each
    condition of _case_opens, quote masking, _case_closes, the keyword skip, the /usr/bin/time skip and
    its stop, _past_time for time -p cd). There are 13 more for the second: the later-line body opening, the
    name-alone skip, comment blanking in define, the function-call check, its command/env bypass,
    looking up /bin/cat as cat, the directory-builtin loss of a shadowing function, _runs_program, and
    each 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, _closing
    without counting (, _runs_dir without the activate rule, with expanded operands allowed, _activates
    without skipping redirections, body words with . and source as any word, with none, segments starting
    at 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: the
    splitter 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 the case, case taken as a command word anywhere, without the
    keyword rule, case) taken as a case, esac not closing it, and the previous _closing for the case
    count. Each makes its test fail. For codecov's report at c1f6ba2, 2 more: a } popping with no brace open
    (_bodies raises IndexError on both cases), and an esac popping whatever is innermost (the stray esac closes
    the $( ), and the cat inside it becomes a read). Each makes its test fail. The (esac) pattern case passes
    under the second: popping the pattern's ( there leaves the same stack once the pattern's ) is read, so it is a
    guard for the valid form rather than for the check.
  • The bash lookup rules were checked with GNU bash 5: with cat() { echo FN; } defined, \cat f and
    "cat" f print FN, command cat f and /bin/cat f print the file. A definition with a comment line
    and its { on later lines defines the function, and command X=1 cat runs X=1 as 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

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md
  • Self-reviewed per "Before opening a PR" in AGENTS.md
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml)

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>
@antonwolfy
antonwolfy requested a review from a team as a code owner November 4, 2026 19:22
Copilot AI balanced review requested due to automatic review settings November 4, 2026 19:22
@antonwolfy antonwolfy self-assigned this Nov 4, 2026
@exai-bot

exai-bot commented Nov 4, 2026 •

Copy link
Copy Markdown

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
Check Status
CLI interface diff still running
CodeQL (actions) still running
CodeQL (python) still running
Package install smoke test (py3.11) still running
Tests (py3.11) still running
Tests (py3.14) still running
Windows smoke test (py3.13) still running
self / repo validate still running

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>

Comment thread dokimi/dokimi/repos/cli_traces.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, case tracking, 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

Comment thread dokimi/dokimi/repos/shell_functions.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
@codecov-commenter

codecov-commenter commented Nov 4, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.81%. Comparing base (b087c40) to head (3d55652).
⚠️ Report is 8 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

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     
Files with missing lines Coverage Δ
dokimi/dokimi/repos/cli_traces.py 97.32% <100.00%> (+0.25%) ⬆️
dokimi/dokimi/repos/events.py 96.98% <100.00%> (+0.10%) ⬆️
dokimi/dokimi/repos/shell_functions.py 100.00% <100.00%> (ø)
dokimi/dokimi/repos/shell_split.py 100.00% <100.00%> (ø)
dokimi/dokimi/repos/shell_words.py 97.29% <100.00%> (+2.56%) ⬆️

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
Comment thread dokimi/dokimi/repos/events.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
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>
Comment thread dokimi/dokimi/repos/shell_functions.py Resolved
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 vlad-perevezentsev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py on the merge-base: 94 of its tests fail there, so the new tests cover the new behaviour.
  • shell_walk on the PR head and on main for 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 cd past a function cd, wrapper-named functions, time -x / time --help, function NAME ( ... ), env command cat, and the quoted activate in _failed_read all give the results the replies state.
  • Substitution splitting: cd $(printf sub; true) | cat; cat AGENTS.md and its & and backtick forms now read AGENTS.md, and a cd inside a substitution still stays in its subshell.
  • Docs and changelog: the new module is in architecture.md. The fragment is under Fixed and says that guidance_read and unknown_reads can change. shell_functions.py has the SPDX header.

Comment thread dokimi/dokimi/repos/events.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved
Comment thread dokimi/dokimi/repos/shell_functions.py Resolved
Comment thread dokimi/dokimi/repos/shell_words.py Resolved

@napetrov napetrov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sub loses the directory after cd() { echo hi; }; head follows it to sub/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.

Comment thread dokimi/dokimi/repos/shell_functions.py Resolved
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>
@antonwolfy

Copy link
Copy Markdown
Contributor Author

(commented) Review at 86684a9. Most of the #374 forms are now followed correctly. Two problems need a fix b

@vlad-perevezentsev (#397 (review)): Both blocking points are fixed: functions no longer carry across Claude Code Bash calls (a44eba6, thread 4481039161), and a case inside $( ) no longer hides the rest of the script (130cad7, thread 4481039171). The other two threads are fixed in a7db58e and 7a5e6df. The PR description's table, Changes, Risk and Testing sections are updated to match.

@antonwolfy

Copy link
Copy Markdown
Contributor Author

(commented) 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 dup

@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 command cd sub and builtin cd sub run the builtin past a function cd and are followed, as test_command_or_builtin_runs_the_cd_builtin_past_a_function_named_cd pins ((['sub/AGENTS.md'], True, 0)). Testing has this head's counts: tests/test_repo_events_bash.py collects 406 cases, 199 of them in 48 tests new in this PR, and 103 of those fail on main's code. The quadratic define is fixed in 5a6b63e (thread 4483156818).

Comment thread dokimi/dokimi/repos/shell_split.py Resolved
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>
@antonwolfy
antonwolfy requested a review from exai-bot November 5, 2026 19:02
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>
@xaleryb

xaleryb commented Nov 6, 2026

Copy link
Copy Markdown
Contributor
  • Ran on Python 3.13: main 040f86d + this head + the two commits below. The full suite, ruff, mypy, bandit, check_growth, check_comment_blocks and the benchmark gate all pass. Every earlier thread's input was re-run at the head and gives the result its reply states.
  • f() { find . -exec true {} \; ; cd sub; }; f; cat AGENTS.md (also {} + and xargs -I{}) gave (['AGENTS.md'], False, 0). Expected ([], True, 1): the } of {} closed the body before the cd.
  • v=$(echo then case x); cd sub; cat AGENTS.md (also echo $(date) case x, grep -e then case x) gave ([], False, 0). Expected (['sub/AGENTS.md'], True, 0), as main gives: an argument case opened a case, and the $( ) never closed.
  • Pushed:
    • 09b3de2: {} closes no brace group.
    • 3d55652: a keyword is a command word only at a command position, and a ) closes only a case pattern.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants