# v0.12.3 Audit Fixes Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix all P1 (14), P2 (23), and P3 (25) findings from the v0.12.3 audit — 62 findings total (~76 unique raw, deduplicated into these tasks).

**Architecture:** Group fixes by file ownership to enable parallel dispatch. Each task owns a set of files exclusively — no two tasks touch the same file. Tasks within a phase can run in parallel; phases are sequential. TDD where practical — write tests first for correctness bugs, add tests after for mechanical fixes.

**Tech Stack:** Rust, SQLite (via sqlx), clap, tracing, walkdir, dunce, regex (LazyLock), tokenizers (encode_batch)

**Source of truth:** `docs/audit-triage.md` — mark Status column as fixes land.

---

## Phase 1: Critical Fixes (4 parallel tasks)

### Task 1: Convert Module Security + Error Handling

Fixes: SEC-9, SEC-10, SEC-11, PB-11, EH-19, EH-20, EH-21/RB-13, EXT-19, PB-12, PB-13, RM-13, CQ-6

**Files:**
- Modify: `src/convert/chm.rs`
- Modify: `src/convert/webhelp.rs`
- Modify: `src/convert/mod.rs`
- Modify: `src/convert/cleaning.rs`
- Modify: `src/convert/pdf.rs`

**Step 1: CHM symlink + zip-slip protection (SEC-9, SEC-10)**

In `src/convert/chm.rs`, after 7z extraction, add symlink filter and path containment check to the walkdir chain (~line 43):

```rust
let temp_canonical = dunce::canonicalize(temp_dir.path())
    .unwrap_or_else(|_| temp_dir.path().to_path_buf());

// Add to walkdir filter chain:
.filter_entry(|e| !e.path_is_symlink())
.filter_map(|e| e.ok())
.filter(|e| {
    // SEC-10: zip-slip containment
    dunce::canonicalize(e.path())
        .map(|p| p.starts_with(&temp_canonical))
        .unwrap_or(false)
})
```

**Step 2: Webhelp symlink protection (SEC-11)**

In `src/convert/webhelp.rs` (~line 51), add symlink filter to walkdir:

```rust
.filter_entry(|e| !e.path_is_symlink())
```

**Step 3: Convert overwrite guard — dunce (PB-11)**

In `src/convert/mod.rs:158`, change:
```rust
// Before:
opts.output_dir.canonicalize().map(|d| d.join(&filename))
// After:
dunce::canonicalize(&opts.output_dir).map(|d| d.join(&filename))
```

**Step 4: Error context in convert (EH-19, EH-20)**

In `src/convert/chm.rs:30`, add `.context()` on 7z spawn:
```rust
.output()
.context(format!("Failed to run '{}'. Install: apt install p7zip-full / brew install p7zip", sevenzip))?;
```
This also fixes PB-13 (platform-aware error message).

In `src/convert/mod.rs` (~lines 151, 177, 280, 289), add `.with_context()` on all `fs::create_dir_all` and `fs::write` calls with path info.

**Step 5: LazyLock for cleaning regexes (EH-21/RB-13)**

In `src/convert/cleaning.rs`, move all 6 `Regex::new().unwrap()` calls (~lines 130, 131, 160, 161, 296, 319) to module-level `LazyLock<Regex>` statics:

```rust
use std::sync::LazyLock;
use regex::Regex;

static RE_COPYRIGHT: LazyLock<Regex> = LazyLock::new(||
    Regex::new(r"(?i)\u{a9}\s*\d{4}[-\u{2013}]\d{4}.*AVEVA Group Limited")
        .expect("hardcoded regex"));
// ... etc for all 6
```
This also fixes EXT-19 (broadened year pattern).

**Step 6: python3 fallback (PB-12)**

In `src/convert/pdf.rs:19`, try `python3` first, fall back to `python`:
```rust
let output = Command::new("python3")
    .args([script_path.to_str().unwrap(), path.to_str().unwrap()])
    .output();
let output = match output {
    Ok(o) => o,
    Err(_) => Command::new("python")
        .args([script_path.to_str().unwrap(), path.to_str().unwrap()])
        .output()
        .context("Failed to run python3 or python. Is Python 3 installed?")?
};
```

**Step 7: Memory cap for CHM/WebHelp (RM-13)**

In `src/convert/chm.rs` and `src/convert/webhelp.rs`, add page count limit:
```rust
const MAX_PAGES: usize = 1000;
// In the page collection loop:
if pages.len() >= MAX_PAGES {
    tracing::warn!(max = MAX_PAGES, "Page limit reached, truncating");
    break;
}
```

**Step 8: Deduplicate convert pipeline (CQ-6)**

Extract shared post-processing from `convert_file()` and `convert_webhelp()` in `src/convert/mod.rs` into:
```rust
fn finalize_output(raw_markdown: &str, source: &Path, format: DocFormat,
                   opts: &ConvertOptions, check_source_overwrite: bool) -> Result<ConvertResult>
```

**Step 9: Run tests**

```bash
cargo test --features gpu-search convert 2>&1
cargo clippy --features gpu-search -- -D warnings 2>&1
```

**Step 10: Commit**

```
fix: harden convert module — symlink/zip-slip protection, error context, LazyLock regexes
```

---

### Task 2: Correctness Bugs (store + reference)

Fixes: AC-13, DS-8/RM-11, RB-15/16/DS-7, CQ-7, CQ-5, DS-10, AD-19

**Files:**
- Modify: `src/store/helpers.rs`
- Modify: `src/store/calls.rs`
- Modify: `src/store/chunks.rs` (if upsert_chunks_and_calls has same pattern)
- Modify: `src/reference.rs`
- Modify: `src/gather.rs`

**Step 1: Write test for score_name_match (AC-13)**

In `src/store/helpers.rs` tests, add:
```rust
#[test]
fn test_score_name_match_no_match_returns_zero() {
    assert_eq!(score_name_match("parse_diff", "reverse_bfs"), 0.0);
    assert_eq!(score_name_match("foo", "bar"), 0.0);
}
```

**Step 2: Fix score_name_match**

In `src/store/helpers.rs:596`, change the else branch from `0.5` to `0.0`:
```rust
} else {
    0.0  // was 0.5 — 0.5 caused search_by_names_batch to misassign results
}
```

**Step 3: Run test to verify**

```bash
cargo test --features gpu-search score_name_match 2>&1
```

**Step 4: Fix reference stores — open_readonly (DS-8/RM-11)**

In `src/reference.rs:56`, change:
```rust
// Before:
let store = match Store::open(&db_path) {
// After:
let store = match Store::open_readonly(&db_path) {
```

**Step 5: Fix SQLite batch inserts (RB-15/16/DS-7)**

In `src/store/calls.rs`, add batching to `upsert_calls_batch` (~line 236) and `upsert_function_calls` (~line 369):

```rust
const INSERT_BATCH: usize = 300; // 300 * 3 binds = 900 < 999

for batch in calls.chunks(INSERT_BATCH) {
    let mut qb = sqlx::QueryBuilder::new(/* same INSERT prefix */);
    qb.push_values(batch.iter(), |mut b, (chunk_id, call)| { /* same binds */ });
    qb.build().execute(&mut *tx).await?;
}
```

Same for `upsert_function_calls` with `INSERT_BATCH = 190` (190 * 5 = 950).

Check `src/store/chunks.rs` for `upsert_chunks_and_calls` — apply same pattern if unbounded.

**Step 6: Deduplicate caller/callee counts (CQ-7)**

In `src/store/calls.rs`, extract shared batching logic from `get_caller_counts_batch` and `get_callee_counts_batch` into:
```rust
fn batch_count_query(&self, column: &str, group_column: &str, names: &[&str]) -> Result<HashMap<String, u64>>
```

**Step 7: Consolidate reference search functions (CQ-5)**

In `src/reference.rs`, collapse 4 functions into 2 with `apply_weight: bool` parameter:
```rust
pub fn search_reference(ref_idx: &ReferenceIndex, embedding: &[f32],
    filter: &SearchFilter, limit: usize, threshold: f32, apply_weight: bool) -> Vec<SearchResult>
pub fn search_reference_by_name(ref_idx: &ReferenceIndex, name: &str,
    limit: usize, threshold: f32, apply_weight: bool) -> Vec<SearchResult>
```
Update all callers.

**Step 8: Add model compatibility check (DS-10)**

In `src/gather.rs`, at the top of `gather_cross_index` (~line 300), before bridge search:
```rust
if let (Ok(proj_model), Ok(ref_model)) = (
    store.get_metadata("model_name"),
    ref_store.get_metadata("model_name"),
) {
    if proj_model != ref_model {
        tracing::warn!(project = %proj_model, reference = %ref_model,
            "Model mismatch between project and reference — results may be inaccurate");
    }
}
```

**Step 9: Add Debug to GatherOptions (AD-19)**

In `src/gather.rs:22`, add `#[derive(Debug)]` to `GatherOptions`.

**Step 10: Run tests**

```bash
cargo test --features gpu-search store 2>&1
cargo test --features gpu-search reference 2>&1
cargo test --features gpu-search gather 2>&1
```

**Step 11: Commit**

```
fix: correctness bugs — score_name_match floor, ref stores readonly, batch inserts, ref consolidation
```

---

### Task 3: Impact Module Fixes (types, spans, API)

Fixes: AD-12, AD-13, AD-18, EH-17/OB-12, OB-13, EXT-13, AC-16, EH-22

**Files:**
- Modify: `src/impact/types.rs`
- Modify: `src/impact/mod.rs`
- Modify: `src/impact/format.rs`
- Modify: `src/impact/hints.rs`
- Modify: `src/impact/analysis.rs`
- Modify: `src/impact/diff.rs`
- Modify: `src/lib.rs`

**Step 1: Add derives to impact types (AD-12)**

In `src/impact/types.rs`, add `#[derive(Debug, Clone, serde::Serialize)]` to all 10 structs that lack it: CallerDetail, TestInfo, TransitiveCaller, ImpactResult, FunctionHints, ChangedFunction, DiffTestInfo, DiffImpactSummary, DiffImpactResult, TestSuggestion.

For `PathBuf` fields that need relativization, add `#[serde(serialize_with = "serialize_path")]` or handle in the serialization layer.

**Step 2: Re-export all public field types (AD-13)**

In `src/impact/mod.rs`, add to the `pub use types::` block:
```rust
pub use types::{CallerDetail, TestInfo, TransitiveCaller, DiffTestInfo, DiffImpactSummary, TestSuggestion, FunctionHints, ChangedFunction};
```
In `src/lib.rs`, add to the `pub use impact::` block.

**Step 3: Remove redundant name from RiskScore (AD-18)**

In `src/impact/types.rs`, remove `name: String` from `RiskScore`. Update `compute_risk_batch` in `hints.rs` to not set the name. Update callers in `review.rs` and `cli/commands/impact_diff.rs` that read `risk.name`.

**Step 4: Extract risk threshold constants (EXT-13)**

In `src/impact/mod.rs` (or `hints.rs`), add:
```rust
pub const RISK_THRESHOLD_HIGH: f32 = 5.0;
pub const RISK_THRESHOLD_MEDIUM: f32 = 2.0;
```
Use in `compute_risk_batch` (~hints.rs:122-127).

**Step 5: Add tracing spans (EH-17/OB-12, OB-13)**

In `src/impact/analysis.rs:188` (`suggest_tests`):
```rust
let _span = tracing::info_span!("suggest_tests", function = %impact.function_name).entered();
```

In `src/impact/hints.rs:69` (`compute_hints`):
```rust
let _span = tracing::info_span!("compute_hints", function = function_name).entered();
```

**Step 6: Fix DiffTestInfo.via attribution (AC-16)**

In `src/impact/diff.rs:144-148`, replace the `.find()` with per-source attribution. Since `reverse_bfs_multi` seeds all changed functions at depth 0, we need to track provenance. The simplest fix: for each test, find which changed function has the shortest individual path:

```rust
let via = changed.iter()
    .filter_map(|f| {
        let single = reverse_bfs(&graph_reverse, &f.name, max_depth);
        single.get(&test_name).map(|&d| (f.name.clone(), d))
    })
    .min_by_key(|(_, d)| *d)
    .map(|(name, _)| name)
    .unwrap_or_else(|| {
        tracing::debug!(test = %test_name, "No changed function reaches test");
        String::from("(unknown)")
    });
```
This also fixes EH-22 (diagnostic on fallback).

Note: This adds per-source BFS calls — may need to build graph_reverse once outside the loop. Check if `reverse_bfs` already takes a graph or builds its own.

**Step 7: Simplify format.rs with serde (depends on Step 1)**

After adding `Serialize` derives, simplify `impact_to_json` and `diff_impact_to_json` in `format.rs` to use `serde_json::to_value(&result)` instead of hand-built JSON where possible. Keep path relativization as a post-processing step or use `#[serde(serialize_with)]`.

**Step 8: Run tests**

```bash
cargo test --features gpu-search impact 2>&1
cargo test --features gpu-search -- test_diff_impact 2>&1
```

**Step 9: Commit**

```
fix: impact types — derives, re-exports, spans, via attribution, risk constants
```

---

### Task 4: CLI Fixes

Fixes: OB-14, EH-16, AD-17, RB-18, SEC-8

**Files:**
- Modify: `src/cli/commands/query.rs`
- Modify: `src/cli/mod.rs`
- Modify: `src/cli/commands/review.rs` (only for SEC-8 log)
- Modify: `src/convert/pdf.rs` (SEC-8 warning — or combine with Task 1)

**Step 1: Add tracing span to cmd_query_name_only (OB-14)**

In `src/cli/commands/query.rs:364`:
```rust
let _span = tracing::info_span!("cmd_query_name_only", query).entered();
```

**Step 2: Add tracing on resolve_parent_context failure (EH-16)**

In `src/cli/commands/query.rs:600`, change `if let Ok(content)` to explicit match with warning:
```rust
match std::fs::read_to_string(&abs_path) {
    Ok(content) => { /* existing logic */ }
    Err(e) => {
        tracing::warn!(path = %abs_path.display(), error = %e,
            "Failed to read source for parent context");
    }
}
```

**Step 3: Convert CLI args to value_enum (AD-17)**

In `src/cli/mod.rs`, create enums and use `#[derive(clap::ValueEnum)]`:

```rust
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum OutputFormat { Text, Json, Mermaid }

#[derive(Clone, Debug, clap::ValueEnum)]
pub enum DeadConfidence { Low, Medium, High }
```

Change `--format` fields from `String` to `OutputFormat`, `--min-confidence` to `DeadConfidence`, `--direction` to `GatherDirection` (already exists, just add `ValueEnum`).

Update all match statements that parse these strings.

**Step 4: Validate --tokens > 0 (RB-18)**

In `src/cli/mod.rs`, add validation to the tokens field:
```rust
#[arg(long, value_parser = clap::value_parser!(usize).range(1..))]
pub tokens: Option<usize>,
```
Or validate at the command level: `if tokens == Some(0) { bail!("--tokens must be > 0") }`.

**Step 5: SEC-8 — Log warning when CQS_PDF_SCRIPT is active**

In `src/convert/pdf.rs`, when `CQS_PDF_SCRIPT` env var is read:
```rust
if let Ok(custom) = std::env::var("CQS_PDF_SCRIPT") {
    tracing::warn!(script = %custom, "Using custom PDF script from CQS_PDF_SCRIPT");
    // ... existing logic
}
```

**Step 6: Run tests**

```bash
cargo test --features gpu-search cli 2>&1
cargo test --features gpu-search -- test_cli 2>&1
```

**Step 7: Commit**

```
fix: CLI — value_enum types, tracing spans, --tokens validation, PDF script warning
```

---

## Phase 2: Performance + Docs (4 parallel tasks, after Phase 1)

### Task 5: review_diff Fixes

Depends on: Task 3 (impact types must be done first — review.rs uses impact types)

Fixes: CQ-1/RM-10, DS-12, CQ-4/AD-14

**Files:**
- Modify: `src/review.rs`
- Modify: `src/impact/diff.rs` (add `_with_graph` variant)

**Step 1: Add analyze_diff_impact_with_graph variant (CQ-1)**

In `src/impact/diff.rs`, add:
```rust
pub fn analyze_diff_impact_with_graph(
    store: &Store, changed: &[ChangedFunction],
    graph: &CallGraph, test_chunks: &[ChunkSummary],
) -> Result<DiffImpactResult>
```
This variant skips the internal `get_call_graph()` and `find_test_chunks()` calls, using the provided data.

**Step 2: Fix review_diff to load once (CQ-1/RM-10)**

In `src/review.rs`, load graph and test_chunks once, pass to both:
```rust
let graph = store.get_call_graph()?;
let test_chunks = store.find_test_chunks()?;
let diff_impact = analyze_diff_impact_with_graph(store, &changed, &graph, &test_chunks)?;
let risks = compute_risk_batch(store, &fn_names, &graph, &test_chunks)?;
```

**Step 3: Fix inconsistent error handling (DS-12)**

Make `match_notes` and staleness check consistent with graph loading — either all degrade gracefully or all propagate. Prefer graceful degradation with a `warnings: Vec<String>` field in `ReviewResult`.

**Step 4: Simplify review types (CQ-4/AD-14)**

After Task 3 adds `Serialize` to impact types, evaluate whether `CallerEntry` and `TestEntry` can be eliminated in favor of using impact types directly. If path relativization is still needed, handle with a `#[serde(serialize_with)]` attribute or a display wrapper.

**Step 5: Run tests**

```bash
cargo test --features gpu-search review 2>&1
cargo test --features gpu-search impact 2>&1
```

**Step 6: Commit**

```
fix: review_diff — single graph load, consistent error handling, type simplification
```

---

### Task 6: N+1 Performance Fixes

Depends on: Task 3 (impact/analysis.rs spans must be done first)

Fixes: CQ-3/RM-12, PERF-13, PERF-12, RM-14

**Files:**
- Modify: `src/impact/analysis.rs`
- Modify: `src/impact/diff.rs` (only if Task 5 hasn't already changed the callers path)
- Modify: `src/cli/commands/context.rs`
- Modify: `src/store/calls.rs` (add batch methods if needed)

**Step 1: Fix find_transitive_callers N+1 (CQ-3/RM-12)**

In `src/impact/analysis.rs:141-182`:
1. Use `reverse_bfs(graph, target_name, depth)` to collect all ancestor names
2. Call `store.search_by_names_batch(&names, 1)` once
3. Build `TransitiveCaller` from the batch results

**Step 2: Fix suggest_tests N+1 (RM-14)**

In `src/impact/analysis.rs:221`:
1. Collect unique file paths from untested callers
2. Call `store.get_chunks_by_origins_batch(&paths)` once
3. Index into results per caller

**Step 3: Add get_callers_with_context_batch (PERF-13)**

In `src/store/calls.rs`, add:
```rust
pub fn get_callers_with_context_batch(&self, callee_names: &[&str]) -> Result<HashMap<String, Vec<CallerWithContext>>>
```
Uses `WHERE callee_name IN (...)` in a single query.

Update `analyze_diff_impact` in `src/impact/diff.rs:96-111` to use batch version.

**Step 4: Add batch callers/callees for context command (PERF-12)**

In `src/store/calls.rs`, add batch versions of `get_callers_full` and `get_callees_full`:
```rust
pub fn get_callers_full_batch(&self, names: &[&str]) -> Result<HashMap<String, Vec<CallerInfo>>>
pub fn get_callees_full_batch(&self, names: &[&str]) -> Result<HashMap<String, Vec<String>>>
```

Update `src/cli/commands/context.rs:100-143` to collect chunk names, batch-fetch, then distribute.

**Step 5: Run tests**

```bash
cargo test --features gpu-search impact 2>&1
cargo test --features gpu-search store 2>&1
cargo test --features gpu-search -- test_context 2>&1
```

**Step 6: Commit**

```
perf: batch N+1 queries — transitive callers, suggest_tests, diff_impact, context
```

---

### Task 7: Documentation Sweep

Fixes: DOC-1 through DOC-9

**Files:**
- Modify: `README.md`
- Modify: `CONTRIBUTING.md`
- Modify: `CHANGELOG.md`
- Modify: `SECURITY.md`
- Modify: `ROADMAP.md`

**Step 1: README — add review, --tokens, --ref (DOC-1/2/3)**

Add `cqs review` section with usage examples. Add `--tokens N` to filters section. Add `--ref` to reference indexes section.

**Step 2: CONTRIBUTING — impact/ directory + review.rs (DOC-4/5/6)**

Update architecture overview: replace `impact.rs` with `impact/` directory listing (7 files). Add `review.rs` to commands list and library modules list.

**Step 3: CHANGELOG — comparison URLs (DOC-7)**

Update footer links for v0.12.2, v0.12.3, and [Unreleased].

**Step 4: SECURITY — convert subprocess surface (DOC-8)**

Add subprocess execution section: python3, 7z invocation, CQS_PDF_SCRIPT env var, output directory.

**Step 5: ROADMAP — move completed items (DOC-9)**

Move `cqs review` and "Change risk scoring" from "Next" to "Recently Completed".

**Step 6: Commit**

```
docs: update README, CONTRIBUTING, CHANGELOG, SECURITY, ROADMAP for v0.12.3
```

---

### Task 8: P3 Algorithm + Gather Fixes

Fixes: AC-14, AC-18, AC-19, AC-21

**Files:**
- Modify: `src/gather.rs` (decay + expansion cap)
- Modify: `src/impact/analysis.rs` (snippet bounds — coordinate with Task 6)
- Modify: `src/cli/commands/context.rs` (token packing order — coordinate with Task 6)

**Note:** This task touches `analysis.rs` and `context.rs` which are also in Task 6. Run this AFTER Task 6.

**Step 1: Fix gather BFS decay (AC-14)**

In `src/gather.rs:200-202` (and ~line 458-460), replace:
```rust
let decay = opts.decay_factor.powi((depth + 1) as i32);
let new_score = base_score * decay;
```
With:
```rust
let new_score = base_score * opts.decay_factor;
```

**Step 2: Fix gather expansion cap (AC-18)**

In `src/gather.rs:194-196` (and ~line 452-454), add inner-loop cap:
```rust
for neighbor in neighbors {
    if name_scores.len() >= opts.max_expanded_nodes {
        expansion_capped = true;
        break;
    }
    // ... existing entry logic
}
```

**Step 3: Fix extract_call_snippet bounds (AC-19)**

In `src/impact/analysis.rs:98-106`, add bounds check:
```rust
if caller.call_line < best.chunk.line_start || caller.call_line > best.chunk.line_end {
    return None;
}
```

**Step 4: Fix context token packing order (AC-21)**

In `src/cli/commands/context.rs:192-198`, when `--tokens` is specified, sort chunks by caller count descending before packing, then re-sort to file order for display.

**Step 5: Run tests**

```bash
cargo test --features gpu-search gather 2>&1
cargo test --features gpu-search impact 2>&1
cargo test --features gpu-search -- test_context 2>&1
```

**Step 6: Commit**

```
fix: algorithm — gather decay, expansion cap, snippet bounds, context packing order
```

---

## Phase 3: P3 Cleanup + Tests (3 parallel tasks, after Phase 2)

### Task 9: Convention + Extensibility Fixes

Fixes: EH-18/RB-12, RB-14, EXT-16, EXT-18, PERF-15

**Files:**
- Modify: `src/impact/analysis.rs` (unwrap, suggest_test_file)
- Modify: `src/convert/naming.rs` (strip_prefix)
- Modify: `src/convert/cleaning.rs` (strip_prefix)
- Modify: `src/cli/commands/review.rs` (--tokens)
- Modify: `src/cli/mod.rs` (--tokens for review)
- Modify: `src/embedder.rs` (batch tokenization)

**Step 1: Fix unwrap in Java test name (EH-18/RB-12)**

In `src/impact/analysis.rs:256`:
```rust
// Replace chars().next().unwrap().to_uppercase()
let first = &base_name[..1].to_uppercase();
let rest = &base_name[1..];
format!("test{first}{rest}")
```

**Step 2: Fix byte-index slicing (RB-14)**

In `src/convert/naming.rs:23,34` and `src/convert/cleaning.rs:117`:
```rust
// Replace: trimmed[2..].trim()
// With:
trimmed.strip_prefix("# ").unwrap_or(trimmed).trim()
```

**Step 3: Move test conventions to LanguageDef (EXT-16)**

In the language definition, add optional `test_file_pattern` and `test_name_pattern` fields. Update `suggest_test_file` and test name generation to query these instead of hardcoded matches.

**Step 4: Add --tokens to review command (EXT-18)**

Add `tokens: Option<usize>` to review CLI args in `src/cli/mod.rs`. Apply greedy packing in `src/cli/commands/review.rs` to callers and tests lists.

**Step 5: Add batch token counting (PERF-15)**

In `src/embedder.rs`, add:
```rust
pub fn token_counts_batch(&self, texts: &[&str]) -> Result<Vec<usize>> {
    let tokenizer = self.tokenizer()?;
    let encodings = tokenizer.encode_batch(texts.to_vec(), false)
        .map_err(|e| anyhow::anyhow!("Batch tokenization failed: {e}"))?;
    Ok(encodings.iter().map(|e| e.get_ids().len()).collect())
}
```

Update token packing loops to pre-compute counts in batch.

**Step 6: Run tests**

```bash
cargo test --features gpu-search 2>&1
```

**Step 7: Commit**

```
fix: conventions — unwrap, strip_prefix, test conventions, review --tokens, batch tokenization
```

---

### Task 10: Code Duplication Cleanup

Fixes: CQ-2, EXT-15

**Files:**
- Modify: `src/gather.rs` (BFS dedup)
- Modify: `src/cli/commands/mod.rs` (token_pack generic)
- Modify: `src/cli/commands/query.rs` (use generic)
- Modify: `src/cli/commands/gather.rs` (use generic)
- Modify: `src/cli/commands/scout.rs` (use generic)
- Modify: `src/cli/commands/explain.rs` (use generic)
- Modify: `src/cli/commands/context.rs` (use generic)

**Step 1: Extract gather BFS helper (CQ-2)**

In `src/gather.rs`, extract shared BFS + fetch + sort logic from `gather()` and `gather_cross_index()`:
```rust
fn expand_and_fetch(
    store: &Store, name_scores: &mut HashMap<String, f32>,
    graph: &CallGraph, opts: &GatherOptions, project_root: &Path,
) -> Result<(Vec<GatheredChunk>, bool, bool)>
```

**Step 2: Extract generic token packing (EXT-15)**

In `src/cli/commands/mod.rs`, add:
```rust
pub fn token_pack<T>(
    items: Vec<T>, budget: usize, embedder: &Embedder,
    text_fn: impl Fn(&T) -> &str, label_fn: impl Fn(&T) -> &str,
) -> (Vec<T>, Option<(usize, usize)>)
```

Update all 5+ commands to use the generic version.

**Step 3: Run tests**

```bash
cargo test --features gpu-search 2>&1
```

**Step 4: Commit**

```
refactor: extract shared gather BFS and token packing helpers
```

---

### Task 11: New Tests

Fixes: TC-1, TC-3, TC-5, TC-9

**Files:**
- Create: `tests/review_test.rs`
- Modify: `src/impact/bfs.rs` (add unit tests)
- Modify: `tests/impact_diff_test.rs` (extend assertions)
- Modify: `tests/cli_commands_test.rs` (token budgeting tests)

**Step 1: Write review_diff tests (TC-1)**

Create `tests/review_test.rs`:
- Test with synthetic diff + seeded store: verify changed_functions, affected_callers, risk_summary
- Test empty diff → empty result
- Test diff with no indexed functions → graceful degradation

**Step 2: Write reverse_bfs_multi tests (TC-3)**

In `src/impact/bfs.rs::tests`, add:
- Multi-source non-overlapping ancestors
- Multi-source shared ancestor (minimum depth wins)
- Multi-source with depth limit
- Empty targets list

**Step 3: Extend analyze_diff_impact test (TC-9)**

In `tests/impact_diff_test.rs::test_diff_impact_aggregation`:
- Assert `result.all_tests` contains expected tests
- Verify `via` field points to correct changed function

**Step 4: Write token budgeting tests (TC-5)**

In `tests/cli_commands_test.rs`, add:
- `test_query_tokens_limits_output`: run `cqs query --tokens 500 --json`, verify `token_count <= token_budget`
- `test_gather_tokens_limits_output`: same for gather

**Step 5: Run all tests**

```bash
cargo test --features gpu-search 2>&1
```

**Step 6: Commit**

```
test: add review_diff, reverse_bfs_multi, token budgeting, diff_impact test coverage
```

---

## Execution Summary

| Phase | Tasks | Parallel? | Estimated Scope |
|-------|-------|-----------|-----------------|
| 1 | Tasks 1-4 | Yes (4 parallel) | Security, bugs, spans, CLI |
| 2 | Tasks 5-8 | Partial (5 depends on 3, 6 depends on 3, 7+8 parallel) | Review, N+1, docs, algorithm |
| 3 | Tasks 9-11 | Yes (3 parallel, after Phase 2) | Convention, dedup, tests |

**Dependencies:**
- Task 5 (review) depends on Task 3 (impact types)
- Task 6 (N+1) depends on Task 3 (impact spans)
- Task 8 (algorithm) depends on Task 6 (shared files)
- Tasks 9-11 depend on earlier phases completing

**Total: 11 tasks, ~62 findings across P1-P3**

After all tasks: update `docs/audit-triage.md` status column, run full test suite, create PR.
