Skip to content

test(daemon): kill 26 surviving mutants in the tool and repo-context guards - #82

Merged
acamarata merged 1 commit into
mainfrom
test/guard-helpers-coverage
Sep 16, 2026
Merged

acamarata merged 1 commit into
mainfrom
test/guard-helpers-coverage

Conversation

@acamarata

Copy link
Copy Markdown
Contributor

Covers the pure helpers in session/claude.rs and all of intelligence/repo_context.rs, from the gate's exact surviving-mutant lists at b697493.

file in scope here killed equivalent
session/claude.rsclassify_tool_risk + validate_tool_args 12 of its 27 12 0
intelligence/repo_context.rs 15 14 1

Every mutant was hand-applied and the suite confirmed to fail — 26 of 27 killed, with the single survivor matching the one predicted to be equivalent.

Isolating one link in a || chain

Both claude.rs helpers are long || chains. A ||&& mutation pairs the terms on either side of the operator, and that is only observable for an input tripping exactly one of the pair — so each test uses a tool name isolating a single link:

  • kill_process and terminal_session for the high-risk chain
  • apply_patch, replace_text, insert_line, append_to_file for the medium chain
  • run_shell and terminal for the is_shell_tool chain

One term genuinely cannot be isolated from its own side: any name containing overwrite also contains write, which appears earlier in the same chain. That mutation is killed from the patch side instead, which is the side that can be isolated.

The home-directory guard is pinned in both directions — a path outside $HOME must be blocked, a path inside it must not. That kills all three of its mutations, and two of them (&&||, and dropping the ! on starts_with) would lock an agent out of the user's own working tree rather than letting anything escape. A guard tested only on the blocking path would miss both.

build_modified_section had no coverage at all

Every one of its mutants survived, so it now has real git2 fixtures rather than a mock. A one-commit repo and a two-commit repo together separate parent_count() > 0 from < 0, == 0 and >= 0:

  • the initial commit has no parent and must diff against the empty tree — >= 0 and == 0 send it down the parent branch, where parent(0) fails and the section collapses to "";
  • the second commit must diff against its parent and list only the file that changed — < 0 and == 0 fall into the empty-tree branch, which reports every file in the tree as recently modified.

Dropping the ! on is_sensitive inverts the diff filter so the section lists only the secrets; that is pinned by committing a .env alongside a normal file.

Fixtures built to land on an exact value

Two comparisons differ from their mutants at exactly one input, so the fixtures target that input rather than a value near it:

  • total > MAX_ROOT_ENTRIES vs >= — a directory holding exactly MAX_ROOT_ENTRIES files, which must produce no ... 0 more files line.
  • out.len() > MAX_CHARS vs >= — a context of exactly MAX_CHARS, which must not be truncated. That fixture computes its filename lengths from the constants and asserts it hit the cap before asserting anything else, so a later change to the header or section layout fails the test loudly instead of quietly making it vacuous.

Equivalent mutant, documented rather than chased

Replacing the Ok(files) if !files.is_empty() guard with a constant true: when files is empty, files.join("\n") is the empty string, which is exactly what the fallback arm returns. No input can tell them apart. This is the one that survived the hand-application run.

Still open in claude.rs

The other 15 mutants are the async runner internals (run_turn, event_loop) and the pid != 0 guards in pause/resume/stop. Those need a live child process, and the pid guards carry a real hazard: mutating != 0 to == 0 makes the code call libc::kill(0, ...), which signals the entire process group. They need an isolated harness, not an in-process test, so they are deliberately left for separate work rather than half-covered here.

Inline mod tests blocks move to <module>/tests.rs with existing cases kept verbatim. No production code changes — the source-side diff is 4 inserted lines, all mod tests; declarations.

cargo fmt --check, cargo clippy --all-targets -- -D warnings and the full 833-test lib suite are clean.

…guards

Covers the pure helpers in session/claude.rs (12 of its 27 missed mutants)
and all 15 in intelligence/repo_context.rs, from the gate's exact lists at
b697493.

Both claude.rs helpers are long `||` chains, where a `||` -> `&&` mutation
pairs the terms on either side of the operator. Such a mutation is only
observable for an input that trips exactly one of that pair, so each test
uses a tool name isolating a single link: "kill_process" and
"terminal_session" for the high-risk chain, "apply_patch" / "replace_text"
/ "insert_line" / "append_to_file" for the medium one, "run_shell" and
"terminal" for the is_shell_tool chain.

The home-directory guard gets both directions: a path outside $HOME must
be blocked and a path inside it must not. That pins all three of its
mutations, two of which would lock an agent out of the user's own working
tree rather than letting anything escape.

build_modified_section had no coverage at all -- every one of its mutants
survived -- so it now has real git2 fixtures. A single-commit repo and a
two-commit repo together separate `parent_count() > 0` from `< 0`, `== 0`
and `>= 0`: the first commit must diff against the empty tree, the second
must diff against its parent and list only the file that changed.

Every mutant was hand-applied and the suite confirmed to fail: 26 of 27
killed.

The one that survives is equivalent and documented in place: replacing the
`Ok(files) if !files.is_empty()` guard with a constant `true`. When files
is empty, `files.join("\n")` is the empty string, which is exactly what the
fallback arm returns, so no input can tell them apart.

Two comparisons needed fixtures built to land on an exact value rather than
near it. `total > MAX_ROOT_ENTRIES` is separated from `>=` by a directory
holding exactly MAX_ROOT_ENTRIES files, which must produce no "... 0 more
files" line. `out.len() > MAX_CHARS` is separated from `>=` by a context of
exactly MAX_CHARS, which must not be truncated; that fixture computes its
filename lengths from the constants and asserts it hit the cap before
asserting anything else, so a later change to the header or section layout
fails the test rather than quietly making it vacuous.

Not covered here, and still open in claude.rs: the async runner internals
(run_turn, event_loop) and the `pid != 0` guards in pause/resume/stop.
Those need a live child process, and the pid guards carry a real hazard --
mutating `!= 0` to `== 0` makes the code call libc::kill(0, ...), which
signals the entire process group. They need an isolated harness, not an
in-process test.

Inline `mod tests` blocks move to `<module>/tests.rs` with existing cases
kept verbatim. No production code changes.
@acamarata
acamarata merged commit a082cf3 into main Sep 16, 2026
12 checks passed
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.

1 participant