# Audit Findings — v0.26.0

## Code Quality

#### `find_content_child` (injection.rs) and `find_child_by_kind` (html.rs) are identical functions in different modules
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:174, src/language/html.rs:127
- **Description:** Both functions do exactly the same thing — iterate direct children of a tree-sitter node and return the first matching a given kind string. Different names, both with `#[allow(clippy::manual_find)]`, but structurally identical 10-line bodies. Because `html.rs` is a language definition module rather than a parser module, it can't import from `injection.rs` directly with the current structure, but the duplication will drift.
- **Suggested fix:** Move to `parser/mod.rs` or `parser/types.rs` as `pub(crate) fn find_child_by_kind(node: Node, kind: &str) -> Option<Node>` and have `html.rs` import it via `use crate::parser::find_child_by_kind`.

#### `MAX_FILE_SIZE` and `MAX_CHUNK_BYTES` inline constants defined at multiple sites
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:144, src/parser/mod.rs:203, src/parser/calls.rs:210, src/parser/injection.rs:253
- **Description:** `MAX_FILE_SIZE` (50 MB) is a local `const` inside both `parse_file` (mod.rs:144) and `parse_file_relationships` (calls.rs:210). `MAX_CHUNK_BYTES` (100,000 bytes) is a local `const` inside `parse_file` (mod.rs:203) and `parse_injected_chunks` (injection.rs:253). Changing either limit requires finding and updating all sites manually with no compiler enforcement.
- **Suggested fix:** Hoist to module-level `pub(crate) const` at the top of `src/parser/mod.rs` and reference from all four sites.

#### Capture-name-to-`ChunkType` `match` arm duplicated between `calls.rs` and `injection.rs`, diverges from `chunk.rs`
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:314-329, src/parser/injection.rs:400-415, src/parser/chunk.rs:18-34
- **Description:** Three sites in the parser module define the mapping from tree-sitter capture name string to `ChunkType`. `chunk.rs` uses a `&[(&str, ChunkType)]` slice (the authoritative table). `calls.rs` and `injection.rs` each have a hand-written `match` arm. All three already diverge: `chunk.rs` includes `("section", ChunkType::Section)` but neither `calls.rs` nor `injection.rs` has `"section"` in their `matches!` filter or their `match` arm — meaning `@section` captures (used by LaTeX) are filtered out of call/type extraction, and the `_` fallback assigns `ChunkType::Function` instead of `ChunkType::Section`. LaTeX is not currently an injection target so there's no user-visible bug today, but the divergence will cause a latent bug when a future host language injects LaTeX (e.g., in a documentation system).
- **Suggested fix:** Extract `pub(crate) fn capture_name_to_chunk_type(name: &str) -> Option<ChunkType>` in `parser/types.rs` or `parser/mod.rs` using the same table as `chunk.rs`. Replace all three sites. Fixes the `"section"` gap automatically.

#### `parse_injected_chunks` and `parse_injected_relationships` duplicate identical tree-sitter parser setup boilerplate
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:206-230 (chunks), src/parser/injection.rs:305-330 (relationships)
- **Description:** Both methods execute identical 4-step setup: `tree_sitter::Parser::new()`, `.set_language(&grammar)` with the same error wrapping, `.set_included_ranges(&group.ranges)` with warn-and-empty return, `.parse(source, None)` with warn-and-empty return. The only difference is the `Ok(vec![])` vs `Ok((vec![], vec![]))` on failure. The warn messages diverge slightly ("for injection" vs "for injection relationships"), making them hard to search in logs. Additionally, since `parse_file` and `parse_file_relationships` are separate callers, the inner grammar is parsed **twice** for each injection group per file (once for chunks, once for call graph relationships).
- **Suggested fix:** Extract `fn build_injection_tree(inner_language: Language, source: &str, ranges: &[tree_sitter::Range]) -> Option<tree_sitter::Tree>` returning `None` on any failure. Both methods call it, handle `None` with their respective empty return. Longer term: consider a combined `parse_injected_all` returning `(Vec<Chunk>, Vec<FunctionCalls>, Vec<ChunkTypeRefs>)` to eliminate the double parse.

#### `walk_for_containers` duplicates the cursor-advance-or-walk-up idiom twice within itself
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:138-168
- **Description:** `walk_for_containers` contains two identical 8-line cursor-advancement loops: one at lines 138-148 (after processing a container, skip to next sibling without descending) and one at lines 160-168 (at a leaf, walk back up to find next sibling). Both do `loop { if !cursor.goto_parent() { return; } if cursor.goto_next_sibling() { break; } }`. The duplication makes the traversal logic harder to audit for correctness.
- **Suggested fix:** Extract an `#[inline]` `fn advance_to_next_sibling(cursor: &mut TreeCursor) -> bool` returning `false` if the tree is exhausted. Reduces each 8-line block to a 1-line call.

#### `InjectionGroup` grouping loop uses O(n) linear scan that will degrade with many injection rules
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:83-94
- **Description:** The loop collecting `InjectionGroup`s calls `groups.iter_mut().find(|g| g.language == language)` — O(existing_groups) per entry. Currently only HTML is a host language with 2 injection rules, so worst-case is 2 groups. But as Svelte, Vue, or Markdown code-fence injection is added (each potentially having many `<script>`-like blocks all targeting the same language), this becomes O(n × rules) where n is the number of injection entries found.
- **Suggested fix:** Replace the `Vec<InjectionGroup>` accumulator with a `HashMap<Language, InjectionGroup>` keyed by language, collect into `Vec` at the end. Or add a comment explaining the bound.

## API Design

#### `"section"` capture missing from chunk-node-finder in `calls.rs` and `injection.rs`
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:276-291, src/parser/injection.rs:363-378
- **Description:** Three places in the parser module contain the list of "is this capture a chunk node?" — `DEF_CAPTURES` in `mod.rs` (line 279), the `matches!` filter in `calls.rs` (line 276), and the matching `matches!` in `injection.rs` (line 363). `mod.rs` includes `"section"` (used by LaTeX via `@section` captures in `latex.rs`). Both `calls.rs` and `injection.rs` omit it. Consequence: LaTeX section chunks are silently skipped when building the call graph and when parsing injected relationships — `parse_file_relationships` finds no chunk for LaTeX `@section` captures and emits no `FunctionCalls` or `ChunkTypeRefs` for them.
- **Suggested fix:** Add `"section"` to both `matches!` patterns, and add `"section" => ChunkType::Section` to both `cap_name`→`ChunkType` match arms (currently the `_` arm maps it to `ChunkType::Function` which is wrong). Longer term: extract `DEF_CAPTURES` into a shared module-level constant and reference it from all three sites.

#### `parse_injected_chunks` and `parse_injected_relationships` duplicate tree-sitter parser setup
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:206-230 and 305-330
- **Description:** Both methods repeat identical boilerplate: `tree_sitter::Parser::new()`, `set_language(&grammar)` with the same error message, `set_included_ranges(&group.ranges)` with warn-and-return-empty on failure, `parser.parse(source, None)` with warn-and-return-empty on None. The only difference is the `Ok(vec![])` vs `Ok((vec![], vec![]))` return type on failure. This violates DRY and the warn messages diverge slightly ("for injection" vs "for injection relationships"), making it harder to grep logs consistently.
- **Suggested fix:** Extract `fn build_injection_tree(grammar: tree_sitter::Language, source: &str, group: &InjectionGroup) -> Option<tree_sitter::Tree>` that returns `None` on any failure (with logging). Both methods call it and return their respective empty values on `None`. The error-handling asymmetry disappears.

#### `InjectionRule` lacks `Debug` derive — inconsistent with all other public types
- **Difficulty:** easy
- **Location:** src/language/mod.rs:164
- **Description:** `InjectionRule` is the only `pub` struct in `src/language/mod.rs` without `#[derive(Debug)]`. Every other public type in the file derives `Debug`: `ParseLanguageError` (line 418), `ParseChunkTypeError` (line 327), `SignatureStyle` (line 261), `LanguageRegistry` has no `Debug` either but it's a registry singleton. `LanguageDef` also lacks `Debug` — but that's harder (it contains raw function pointers which don't impl `Debug`). `InjectionRule` contains only `&'static str` and `Option<fn(...)>` — the function pointer field prevents auto-derive, but a manual `Debug` impl showing the `container_kind`, `content_kind`, and `target_language` fields (omitting the function pointer) would be consistent and useful for logging injection rule mismatches.
- **Suggested fix:** Add a manual `impl Debug for InjectionRule` that shows `container_kind`, `content_kind`, `target_language`, and `detect_language: detect_language.is_some()`. Or at minimum add `#[derive(Debug)]` once Rust stabilizes function pointer debug (it already works for `Option<fn(...)>` in recent versions — check if `Option<fn(tree_sitter::Node, &str) -> Option<&'static str>>` derives `Debug`).

#### `capture_name`→`ChunkType` mapping duplicated three times without shared abstraction
- **Difficulty:** medium
- **Location:** src/parser/calls.rs:314-329, src/parser/injection.rs:400-415, src/parser/chunk.rs (implicit via ChunkType)
- **Description:** The mapping from tree-sitter capture name string (`"function"`, `"struct"`, etc.) to `ChunkType` is written out as a `match` arm in both `calls.rs` and `injection.rs`. `mod.rs` has `DEF_CAPTURES` for filtering, and `language/mod.rs` has `define_chunk_types!` which already encodes the name→variant mapping for `Display`/`FromStr`. The capture name is the same as the `ChunkType` serialization name (lowercase). So `cap_name.parse::<ChunkType>()` would work — but neither site uses it, both use manual match arms instead. The manual arms diverge: `"const" => ChunkType::Constant` (correct) vs the `ChunkType::from_str("const")` path which expects `"constant"` (the display string). This mismatch means a future `FromStr`-based refactor must account for the discrepancy.
- **Suggested fix:** Either (a) add a separate `from_capture_name(s: &str) -> Option<ChunkType>` method that maps capture names (not display names) to variants, or (b) note the discrepancy in a comment at both match sites so a future refactor doesn't accidentally use `FromStr` which expects different strings. Low priority since the code is correct, but the duplication will cause drift.

## Documentation

#### CHANGELOG missing multi-grammar injection feature
- **Difficulty:** easy
- **Location:** CHANGELOG.md:8 (`[Unreleased]` section)
- **Description:** The multi-grammar injection feature (commit `afcbed8`, PR #540) added `src/parser/injection.rs`, `InjectionRule` on `LanguageDef`, two-phase `parse_file` and `parse_file_relationships`, and HTML→JS/CSS script/style extraction. The `[Unreleased]` section is empty. This is the most significant feature addition since v0.26.0 and is completely undocumented in the CHANGELOG.
- **Suggested fix:** Add to `[Unreleased]`: "Multi-grammar injection parsing — HTML `<script>` blocks now extract JS/TS function chunks; `<style>` blocks extract CSS chunks. Powered by tree-sitter's `set_included_ranges()`. `InjectionRule` on `LanguageDef` makes this extensible to other host languages."

#### `html.rs` module comment claims Svelte/Vue/Astro support that doesn't exist
- **Difficulty:** easy
- **Location:** src/language/html.rs:4
- **Description:** The module-level doc comment reads "the outer grammar for multi-grammar parsing (Svelte, Vue, Astro)". Svelte (`.svelte`), Vue (`.vue`), and Astro (`.astro`) file extensions are not registered anywhere in the codebase — no `Language` variant, no extensions in the registry, no injection rules. The comment implies these formats work, but they don't.
- **Suggested fix:** Remove "(Svelte, Vue, Astro)" or replace with "(e.g., HTML with embedded `<script>` and `<style>` blocks)".

#### `parser/mod.rs` module comment omits `injection` and `markdown` submodules
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:1-7
- **Description:** The module-level doc comment lists only three submodules: `types`, `chunk`, `calls`. The module has two additional submodules: `injection` (multi-grammar injection, added in the same PR) and `markdown` (custom heading-based parser). Both are `pub` or `pub(crate)` and important enough to document.
- **Suggested fix:** Add to the module doc comment: "- `injection` — multi-grammar injection (HTML→JS/CSS via `set_included_ranges()`)" and "- `markdown` — heading-based Markdown parser with cross-reference extraction".

#### README does not mention multi-grammar injection in HTML language description
- **Difficulty:** easy
- **Location:** README.md:427
- **Description:** The Supported Languages section describes HTML as "headings, semantic landmarks, script/style blocks, id'd elements". The script/style description implies only `Module`-type chunks, but the actual behavior is that inline `<script>` blocks extract JavaScript (or TypeScript) function/class chunks, and `<style>` blocks extract CSS rule chunks. The brief description undersells the feature and is technically inaccurate for inline scripts.
- **Suggested fix:** Change to: "HTML (headings, semantic landmarks, script/style blocks, id'd elements; inline `<script>` blocks extract JS/TS functions, `<style>` blocks extract CSS rules via multi-grammar injection)"

#### CHANGELOG v0.24.0 HTML entry omits multi-grammar injection design (minor)
- **Difficulty:** easy
- **Location:** CHANGELOG.md:40
- **Description:** v0.24.0 added HTML support and lists "script/style blocks (Module)" — which was the initial behavior before injection was added. This entry is not wrong (it was accurate at v0.24.0 time), but once the `[Unreleased]` entry is added, the v0.24.0 entry may confuse readers about when injection was introduced. Low severity since version history is linear.
- **Suggested fix:** No change needed if `[Unreleased]` is updated correctly; the history is accurate for that version.

## Error Handling

#### EH-1: `extract_calls` silently discards `set_language` and parse failures without logging
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:30-37
- **Description:** `extract_calls` returns an empty `Vec` on two failures with zero observability: (1) `parser.set_language(&grammar).is_err()` at line 30 — no `tracing::warn!`, the caller sees an empty result indistinguishable from "language has no calls"; (2) `parser.parse(source, None)` returning `None` at line 36 — also silent. In both cases a call graph edge is silently dropped. The sibling method `parse_injected_chunks` logs both of these failure modes (`injection.rs:213-229`) — `extract_calls` is inconsistent.
- **Suggested fix:** Add `tracing::warn!(language = %language, "set_language failed in extract_calls")` and `tracing::warn!(language = %language, "parse returned None in extract_calls")` to the respective arms.

#### EH-2: `get_query` / `get_call_query` / `get_type_query` use `{:?}` (Debug) instead of `{}` (Display) for tree-sitter query compilation errors
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:95, 113, 131
- **Description:** All three query-get functions use `format!("{:?}", e)` when building the `QueryCompileFailed` error message. `tree_sitter::QueryError` implements `Display` with a human-readable message including the erroneous pattern text and byte offset; the `Debug` impl wraps it in a struct prefix that clutters the output. When a query compilation fails (e.g., due to a new language's malformed query string), the error message surfaced to the user or logs will be harder to read than necessary.
- **Suggested fix:** Change `format!("{:?}", e)` to `format!("{e}")` at lines 95, 113, and 131 in `src/parser/mod.rs`.

#### EH-3: `parse_injected_relationships` `get_call_query` `Err(_)` arm silently discards real errors with a misleading comment
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:340-346
- **Description:** The `Err(_)` arm drops the error with the comment "No call query is not unusual (some languages don't have one)". However, since injection already guards that the inner language has a grammar (`lang.def().grammar.is_some()` at `injection.rs:64`), and all grammar-having languages are populated into `call_queries` (`mod.rs:71`), the only real `Err` case here is a query compilation failure — a genuine bug, not a legitimately absent query. The comment misidentifies the error class and there is no warning logged. Contrast with the `get_chunk_query` arm directly above (lines 334-337), which correctly logs a warning.
- **Suggested fix:** Change `Err(_) => { ... }` to `Err(e) => { tracing::warn!(error = %e, language = %inner_language, "Call query compilation failed for injection language"); return Ok((vec![], vec![])); }`.

#### EH-4: `parse_file_relationships` relies on undocumented invariant that empty tree-sitter query patterns compile successfully
- **Difficulty:** medium
- **Location:** src/parser/calls.rs:258
- **Description:** `parse_file_relationships` calls `self.get_call_query(language)?` for all grammar-having languages including those with `call_query: None` (e.g., HTML, `html.rs:225`). This works today because `call_query_pattern()` returns `""` for `None`, and empty tree-sitter patterns happen to compile without error. The invariant is nowhere documented. If a future tree-sitter version changes this behavior, or a new language has `grammar: Some(...)` with `call_query: None` where the empty pattern causes unexpected match behavior, this will silently break call graph extraction. The same implicit dependency exists in `parse_injected_relationships` (injection.rs:340). By contrast, `extract_calls` at line 24-26 explicitly short-circuits for grammar-less languages — the guard pattern was clearly intentional but was not extended to the newer paths.
- **Suggested fix:** After language detection at line 238, add an explicit guard: `if language.def().call_query.is_none() { /* no call extraction for this language */ }` before the call-matches loop. This makes the skip-on-empty-query intent explicit and eliminates the undocumented invariant.

## Observability

#### OB-1: `parse_injected_chunks` span missing file path — can't identify which file's injection failed in logs
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:199-204
- **Description:** The `parse_injected_chunks` span records `language` and `range_count` but not the file path. When a warning fires inside this function (e.g., "Failed to set included ranges for injection" at line 213, or "Failed to extract injected chunk" at line 272), the log entry has no file context. In a large index run, multiple HTML files may trigger injection parsing in parallel; without the path you can't correlate which file caused the failure. The parent `parse_file` span does record the path, but spans are only useful for correlation when a tracing subscriber that supports nested spans (e.g., `tracing-tree`) is active. Direct log events (via `tracing::warn!`) do not inherit parent span fields automatically.
- **Suggested fix:** Add `path = %path.display()` to the span at `injection.rs:199`. `parse_injected_chunks` already receives `path: &Path` as a parameter, so this is a one-line change.

#### OB-2: Silent injection replacement — no log when injection actually substitutes outer chunks
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:241-250
- **Description:** When injection parsing succeeds (`inner_chunks.is_empty()` is false), the code silently removes outer chunks and extends with inner chunks. There is no `tracing::debug!` or `tracing::info!` recording that the replacement happened, how many outer chunks were removed, or how many inner chunks were added. This makes it impossible to diagnose unexpected injection behaviour (e.g., outer HTML chunks disappearing) without adding a debugger or temporarily adding prints. By contrast, the zero-inner-chunks fallback path in `parse_injected_chunks` logs `"Injection produced no chunks, keeping outer"` (line 282-286) — the success path has worse observability than the fallback path.
- **Suggested fix:** Add inside the `Ok(inner_chunks) if !inner_chunks.is_empty()` arm: `tracing::debug!(language = %group.language, removed = before_count, added = inner_chunks.len(), "Injection replaced outer chunks");` where `before_count` is captured before `chunks.retain(...)`.

#### OB-3: `parse_injected_chunks` does not log chunk count on success
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:288
- **Description:** `parse_injected_chunks` logs only the empty case (`"Injection produced no chunks, keeping outer"`) but emits nothing when it successfully extracts chunks. The span exits silently with an `Ok(chunks)` where `chunks` may contain 0–N entries. The sibling `parse_injected_relationships` has the same gap (line 478). For debugging "why did my HTML file not produce JS function chunks?", you'd see zero log output even when the function ran successfully and returned chunks. The `gather_with_graph` function provides a useful counter-example with `tracing::info!(final_chunks = chunks.len(), "Gather complete")`.
- **Suggested fix:** Before `Ok(chunks)` at line 288, add: `tracing::debug!(language = %inner_language, chunk_count = chunks.len(), "Injection chunks extracted");`. Similarly before line 478 add: `tracing::debug!(language = %inner_language, call_count = call_results.len(), type_count = type_results.len(), "Injection relationships extracted");`.

#### OB-4: `find_injection_ranges` uses `debug_span` while its callers use `info_span` — inconsistent span level for a hot parse path
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:41
- **Description:** `find_injection_ranges` uses `tracing::debug_span!` while every other parse-path function that's called per-file uses `tracing::info_span!` (e.g., `parse_file` at `mod.rs:141`, `parse_injected_chunks` at `injection.rs:199`, `parse_injected_relationships` at `injection.rs:298`, `parse_file_relationships` at `calls.rs:206`). This inconsistency means that at `RUST_LOG=info`, the span for the tree-scan phase disappears from tracing output even though all surrounding spans are visible. Since `find_injection_ranges` is called once per file with injections (currently all HTML files), it's not a hot-loop function — the debug level provides no performance benefit.
- **Suggested fix:** Change `tracing::debug_span!("find_injection_ranges", ...)` to `tracing::info_span!(...)` at line 41 for consistency with adjacent parse-path spans.

#### OB-5: `scout_core` — the actual scout computation function — has no entry span
- **Difficulty:** easy
- **Location:** src/scout.rs:199
- **Description:** `scout_core` is the primary compute function for the `cqs scout` command: it does the search, caller count query, staleness check, and note retrieval. Its wrappers `scout_with_options` (line 135) and `scout_with_resources` (line 170) each have their own `info_span!`. But `scout_core` itself has no span, so when called from batch mode via `dispatch_scout` → `scout` → `scout_with_options` → `scout_core`, the per-result timing is invisible. At `RUST_LOG=cqs=debug`, you'd see the wrapper span but can't isolate which phase inside `scout_core` is slow (search vs. caller counts vs. staleness).
- **Suggested fix:** Add `let _span = tracing::info_span!("scout_core", task_len = task.len(), limit).entered();` at the start of `scout_core`. The wrapper spans will still appear in the hierarchy; this adds a nested span for the actual computation.

#### OB-6: `cmd_read_focused` has no entry span
- **Difficulty:** easy
- **Location:** src/cli/commands/read.rs:312
- **Description:** `cmd_read_focused` is the implementation of `cqs read --focus <fn>` — a non-trivial operation that opens the project store, parses notes, builds focused output including caller hints and type dependencies. `cmd_read` (line 267) has `let _span = tracing::info_span!("cmd_read", path).entered()`, but `cmd_read_focused` — called from `cmd_read` when `--focus` is given — has no span of its own. All warnings emitted inside `cmd_read_focused` (e.g., "Failed to compute hints" at line 139, "Failed to query type deps" at line 202) are therefore not associated with any named span in tracing output.
- **Suggested fix:** Add `let _span = tracing::info_span!("cmd_read_focused", focus).entered();` at the start of `cmd_read_focused` (line 312).

#### OB-7: Oversized injected chunks skipped silently — no log matching `parse_file`'s own oversized-chunk debug log
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:252-256
- **Description:** `parse_injected_chunks` silently skips oversized chunks (`> 100_000` bytes) with a bare `continue` at line 255, no log. The regular `parse_file` path logs this at debug level: `tracing::debug!("Skipping {} ({} bytes > {} max)", chunk.id, ...)` (mod.rs:205-212). Users debugging why an injected JS function is missing from their HTML index would see nothing in logs. The inconsistency is especially notable because both paths use the same `MAX_CHUNK_BYTES` constant.
- **Suggested fix:** Add `tracing::debug!(chunk_id = %chunk.id, size = chunk.content.len(), max = MAX_CHUNK_BYTES, "Skipping oversized injected chunk");` before `continue` at line 255.

## Robustness

#### RB-1: `parse_injected_relationships` early-returns on `get_call_query` failure, silently skipping type extraction for the same language
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:340-346
- **Description:** When `get_call_query(inner_language)` returns `Err`, the function immediately returns `Ok((vec![], vec![]))` — discarding not just call extraction but also type extraction. Call queries and type queries are independent operations (one uses `call_query_pattern()`, the other `type_query_pattern()`). If the inner language has `type_query: Some(...)` but `call_query: None`, the type extraction at injection.rs:459-475 is never reached. Today this doesn't cause a user-visible regression because CSS (the only injection target without a call_query) also has no type_query — so there is nothing to extract. But for any future injection language with types but no calls (e.g., a CSS preprocessor or DSL), this silently produces no `ChunkTypeRefs` even though the type query would succeed. The finding is distinct from EH-3 (which only requests a warning be added) — the structural bug is that the early return conflates two independent failure modes into one early exit.
- **Suggested fix:** Split the call query handling from the early return: if `get_call_query` fails, assign an empty call result and continue to type extraction. Structure as:
  ```rust
  let has_call_query = match self.get_call_query(inner_language) {
      Ok(q) => Some(q),
      Err(e) => { tracing::warn!(...); None }
  };
  // ... then proceed to type extraction regardless
  ```
  Type extraction (lines 459-475) should run whether or not a call query exists.

#### RB-2: `walk_for_containers` leaves cursor state undefined if two rules share the same `container_kind` — future injection rules would add duplicate ranges
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:49-53, 83-94
- **Description:** `find_injection_ranges` calls `walk_for_containers` once per rule in a loop, resetting the cursor to root before each call (line 51). For two rules with the same `container_kind` but different `content_kind` (e.g., two rules targeting `"script_element"` but looking for different content children), both walks would find the same container nodes. Each walk would push the container's content range to `entries`. The grouping pass (lines 60-96) then groups by resolved language — if both rules resolve to the same language, the same byte range is added twice to one `InjectionGroup.ranges`. Calling `set_included_ranges` with duplicate ranges is not explicitly documented as safe in tree-sitter, and may cause duplicate chunk extraction (same JS function appearing twice in the output). No current HTML injection rule has this property (script and style have different container_kinds), but the constraint is not enforced or documented. The issue is especially subtle because it would be a silent data quality regression, not a crash.
- **Suggested fix:** Add a deduplication step in the grouping pass: after `group.ranges.push(range)`, check `if group.ranges.iter().filter(|r| r.start_byte == range.start_byte).count() > 1 { tracing::warn!("duplicate injection range"); group.ranges.pop(); }`. Alternatively, document in `InjectionRule` that two rules must not produce overlapping byte ranges for the same language.

#### RB-3: `chunk_overlaps_container` does not handle the case where `chunk_start == 0` and `container_start == 0` — works, but relies on u32 arithmetic that wraps silently on empty files
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:486-494
- **Description:** The containment check `chunk_start >= start && chunk_end <= end` is arithmetically sound for all valid inputs (line numbers are 1-indexed u32, container_start >= 1). However, if tree-sitter ever returns a node with `start_position().row == 0` (which happens for the root node of an empty parse), and `node.end_position().row as u32 + 1` overflows on a file with exactly u32::MAX lines (2^32 - 1 ≈ 4 billion lines), the `+ 1` wraps to 0 in release mode. This would cause `chunk_end(0) <= end(some_valid_u32)` to be true, spuriously removing chunks. This is purely theoretical — no file has 4 billion lines, and the 50 MB file size limit prevents it. However, the code at injection.rs:130-131 computes both `node.start_position().row as u32 + 1` and `node.end_position().row as u32 + 1` without overflow protection, while `parse_file` has an explicit 50 MB guard. The overflow is suppressed by the file size limit, but this is an undocumented dependency between the two limits.
- **Suggested fix:** Informational — document with a comment at injection.rs:130 that the `+ 1` cannot overflow because `parse_file`'s 50 MB size limit prevents files with enough lines to reach u32::MAX. No code change required.

#### RB-4: `source[cap.node.byte_range()]` in `parse_injected_relationships` panics on invalid byte boundary — inconsistent with `html.rs` which uses `utf8_text` returning `Result`
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:391, 434
- **Description:** Two sites in `parse_injected_relationships` use direct string indexing `source[c.node.byte_range()]` which panics (with an index-out-of-range or invalid-UTF8-boundary message) if tree-sitter reports a byte range that falls on a non-char boundary. By contrast, `html.rs` in `find_attribute_value` and `extract_element_text` uses `node.utf8_text(source.as_bytes()).unwrap_or("")`, which returns a `Result` and handles the error gracefully. The inconsistency is an auditor-visible code smell: the same tree-sitter API is used two different ways in the same PR. In practice, tree-sitter guarantees byte positions are on valid UTF-8 boundaries for valid UTF-8 input (and the source has already been validated by `read_to_string`), so this is not a practical panic risk. But a tree-sitter bug or a non-UTF-8-aligned grammar would cause a panic rather than a graceful fallback. The parallel code in `calls.rs` (lines 56, 152, 304, 348) also uses direct indexing — the pattern is consistent in `calls.rs` but `html.rs` breaks the pattern.
- **Suggested fix:** Change the two injection.rs sites to use `utf8_text` with a fallback: `cap.node.utf8_text(source.as_bytes()).unwrap_or("<?>")`. Low priority since the panic path is unreachable with valid input, but it makes the injection code consistent with `html.rs` and eliminates a theoretical panic in the production path.

## Platform Behavior

#### PB-1: `parse_file` and `parse_file_relationships` don't lowercase file extension before language lookup — uppercase extensions (`.HTML`, `.RS`) silently fail
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:171-174, src/parser/calls.rs:236-238, src/cli/watch.rs:241-242, src/lib.rs:373-374
- **Description:** `parse_file` and `parse_file_relationships` extract the file extension via `path.extension().and_then(|e| e.to_str())` and pass it verbatim to `Language::from_extension()`. The registry `by_extension` map is keyed on lowercase `&'static str` keys (e.g., `"html"`, `"rs"`). On case-insensitive filesystems (macOS, Windows, Windows-mounted NTFS in WSL), a file named `index.HTML` or `main.RS` is readable but `Language::from_extension("HTML")` returns `None` and the file is treated as unsupported, producing an `UnsupportedFileType` error. The converter module at `src/convert/mod.rs:165` handles this correctly with `.to_ascii_lowercase()` — the parser paths don't. The same gap exists in the watch path (`watch.rs:241-242`) where `supported_ext.contains(ext)` fails for uppercase extensions, silently skipping the file. The file-walk at `src/lib.rs:373-374` also lacks lowercasing and skips uppercase-extension files during initial index. In practice on Linux (ext4/btrfs) this doesn't trigger since the OS filesystem is case-sensitive and file names are normally lowercase, but on WSL2 with NTFS-backed `/mnt/c/` this is a real user-visible failure for any file with uppercase extension.
- **Suggested fix:** Add `.map(|s| s.to_ascii_lowercase())` after `.and_then(|e| e.to_str())` in all three parser extraction points (`mod.rs:171`, `calls.rs:236`), adjust `watch.rs:241` similarly, and lowercase `ext` before `extensions.contains` in `lib.rs:373`. The `supported_ext` HashSet in `watch.rs` already contains lowercase strings, so the consumer side is correct — only the producer side needs fixing.

#### PB-2: `detect_script_language` returns lowercase `&'static str` but `InjectionRule::target_language` has no compile-time enforcement that it is lowercase — future mixed-case injection rules are silently accepted by `from_str` but produce inconsistent `Language::Display` output
- **Difficulty:** easy
- **Location:** src/language/mod.rs:172, src/parser/injection.rs:63-81
- **Description:** `find_injection_ranges` resolves the injection target language with `lang_name.parse::<Language>()` which calls `s.to_lowercase()` (language/mod.rs:103), making it case-insensitive. So `InjectionRule::target_language = "JavaScript"` would resolve to the same `Language::JavaScript` as `"javascript"`. No bug today since all rules use lowercase. The risk is that future injection rules may set `target_language: "TypeScript"` or `"JavaScript"` (as the display name would suggest), relying on the case-insensitive parse. The `detect_script_language` function returns `"typescript"` (lowercase) — if it returned `"TypeScript"`, the parse would still work. The inconsistency means there's no single source of truth for what string format `target_language` expects. If the field were ever compared by string equality rather than going through `Language::from_str`, the bug would be silent.
- **Suggested fix:** Add a `debug_assert!(rule.target_language == rule.target_language.to_ascii_lowercase(), ...)` in `find_injection_ranges` or in `LanguageDef`'s initialization. Or document in `InjectionRule`'s field doc that `target_language` must be the lowercase language name matching `Language::to_string()`.

#### PB-3: `walk_for_containers` and injection parsing create per-call `tree_sitter::Parser` allocations in the rayon parallel stage — on HTML-heavy projects, memory amplification scales with parallelism
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:206-210, 305-310, src/cli/pipeline.rs:263-304
- **Description:** `parse_injected_chunks` and `parse_injected_relationships` each call `tree_sitter::Parser::new()` to create a fresh parser for the inner language. This is correct for thread safety. However, in the rayon parallel map at `pipeline.rs:263`, every HTML file creates two additional `tree_sitter::Parser` instances (one per parse function). Under rayon's default parallelism (num_cpus threads), processing a batch of 5000 HTML files means dozens of live `tree_sitter::Parser` allocations simultaneously, each holding grammar-specific internal state. The outer `Parser` struct uses `OnceCell<tree_sitter::Query>` to amortize query compilation — injection bypasses this entirely and re-creates parsers on every file. On a project with thousands of HTML files (e.g., a documentation site or large web codebase), this is a meaningful memory regression compared to pre-injection behavior. This is particularly relevant on WSL where RAM is shared with Windows and memory pressure from rayon workers is more pronounced.
- **Suggested fix:** Informational for now; document the tradeoff in `parse_injected_chunks`. If memory pressure is observed, cache injection parsers (not just queries) in `Parser` with `HashMap<Language, Mutex<tree_sitter::Parser>>` or use a per-thread pool. This is a precondition to the combined `parse_injected_all` optimization noted in Code Quality findings.

#### PB-4: `extract_calls` does not normalize CRLF — if called with un-normalized source, byte offsets from call-site captures may be wrong on Windows
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:15-78
- **Description:** `parse_file` (mod.rs:169) and `parse_file_relationships` (calls.rs:234) both normalize CRLF → LF before parsing. `extract_calls` (calls.rs:15-78) does not normalize. All current callers pass already-normalized content: `extract_calls_from_chunk` passes `chunk.content` (normalized at parse time), and `parse_file_relationships` passes the already-normalized `source`. However, if a future caller passes raw file content (e.g., from `std::fs::read_to_string` on a Windows CRLF file without normalizing), tree-sitter will parse with CRLF and produce byte offsets that are correct for the CRLF string. But any downstream code that strips CRLF before comparing byte ranges would produce mismatches. The three other functions in the module guard against this by normalizing at the top; `extract_calls` is the only one that doesn't.
- **Suggested fix:** Add `let source = if source.contains('\r') { std::borrow::Cow::Owned(source.replace("\r\n", "\n")) } else { std::borrow::Cow::Borrowed(source) };` at the top of `extract_calls`, matching the pattern in `diff_parse.rs:37-39`. Low priority since no current caller is affected, but defensive against future misuse.

## Algorithm Correctness

#### AC-1: `chunk_overlaps_container` implements containment, not overlap — misnamed and semantically incomplete
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:486-494
- **Description:** The function is named "overlaps" but implements strict containment: `chunk_start >= start && chunk_end <= end`. A true overlap (partial intersection) would be `chunk_start <= end && chunk_end >= start`. For the current HTML use case this works because the outer Module chunk for a `<script_element>` always has `line_start == container_start` and `line_end == container_end` (the chunk and container are the same node). But if any future host language produces an outer chunk that starts before the container and ends inside it — e.g., a preamble chunk that spans the injection boundary — the containment check yields `false` and the outer chunk is NOT removed, leaving both the outer and inner chunks for the same code region. The mismatch between name and implementation also makes the logic hard to audit: callers expect "does this chunk intersect a container?" but get "is this chunk fully inside a container?".
- **Suggested fix:** Either (a) rename to `chunk_contained_by_container` and add a comment explaining why containment suffices for the current HTML case, or (b) change the predicate to a true overlap check: `chunk_start <= end && chunk_end >= start`. Option (b) is safer and handles future host languages without needing to reconsider this function.

#### AC-2: `walk_for_containers` visits the same node multiple times when two injection rules share a container ancestor
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:49-53, 100-170
- **Description:** `find_injection_ranges` iterates over all `rules` and calls `walk_for_containers` once per rule, resetting the cursor to root each time. For HTML's two rules (script and style), this means the entire tree is walked twice. Nodes that are ancestors of both script and style elements (e.g., `html`, `body`, `head`) are visited and checked against each rule on each pass. In the current implementation the cost is O(tree_nodes × rules), not O(tree_nodes). For HTML with 2 rules and a large file this is a 2× overhead. More importantly, the walk's correctness depends on `cursor.reset(root)` being called before each walk — this is done correctly at line 51. However, the reset shares the cursor object between calls, which means any call to `walk_for_containers` that returns via the inner `loop { if !cursor.goto_parent() { return; } ... }` at line 141 leaves the cursor in an intermediate position — the parent that failed `goto_next_sibling()`. When `reset(root)` is called next iteration, this is correctly overwritten. But if `walk_for_containers` is ever called without a preceding `reset`, the bug would silently skip nodes. The reset is in the calling loop (line 51), so the current code is correct; the fragility is the coupling between caller and callee.
- **Suggested fix:** Move `cursor.reset(root)` inside `walk_for_containers` (first line of the function), making the function self-contained and eliminating the caller's responsibility. This also makes the code correct if `walk_for_containers` is ever called from a different context.

#### AC-3: Injected call sites have absolute source line numbers but `FunctionCalls::line_start` is used as a relative anchor — callee line numbers may be wrong in store lookup
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:422-455
- **Description:** In `parse_injected_relationships`, `line_start` is computed as `node.start_position().row as u32 + 1` (line 422) — an absolute line number in the full HTML source. `CallSite::line_number` (line 435) is also `cap.node.start_position().row as u32 + 1` — also absolute. Both are consistent. However, the store matches `FunctionCalls` to existing chunks by name and `line_start`. When injected JS function chunks are indexed (from `parse_injected_chunks`), their `line_start` is also absolute (from the same inner tree). So the name+line_start key should match. The potential issue: if `parse_file` and `parse_file_relationships` are called separately (which they are — `parse_file` for indexing, `parse_file_relationships` for relationship extraction), the inner tree is parsed twice with `set_included_ranges`. If the two parses produce different `start_position().row` values for the same node (e.g., due to a different tree-sitter version or grammar update between runs), the `line_start` mismatch would silently orphan call graph edges. This is not a bug today, but it is a fragile assumption: the correctness of cross-phase name+line matching for injected functions depends on both parses producing identical row numbers. By contrast, non-injection languages share the same tree parse across both phases (`parse_file_relationships` uses one tree for everything). The injection path breaks this single-parse invariant.
- **Suggested fix:** Add a comment at line 422 noting that `line_start` must match the value produced by `parse_injected_chunks` for the same function, and that both depend on tree-sitter's `set_included_ranges` producing deterministic absolute row numbers. No code change needed today; the comment documents the invariant for future maintainers.

#### AC-4: `detect_script_language` returns `None` (falls through to JS) for `<script type="text/javascript">` and module scripts — fine — but also for `<script type="application/ld+json">` (JSON, not JS)
- **Difficulty:** easy
- **Location:** src/language/html.rs:195-218
- **Description:** `detect_script_language` only detects TypeScript variants (`lang="ts"`, `type` containing `"typescript"`). All other `<script>` elements fall through to `None`, which means they use the default `target_language = "javascript"`. This includes `<script type="application/ld+json">` (JSON-LD), `<script type="text/template">` (Mustache/Handlebars templates), and `<script type="x-shader/x-vertex">` (GLSL shaders). These are sent to the JavaScript parser, which will produce no function chunks (since JSON/template/GLSL top-level content isn't function declarations), so the practical effect is just a wasted parse — `inner_chunks` will be empty, and the fallback keeps the outer Module chunk. No incorrect chunks are indexed. The issue is: if cqs adds GLSL or JSON injection support later and adds those types to `target_language`, the `detect_script_language` function's ordering could return wrong results. This is a latent bug, not a current one.
- **Suggested fix:** Add an early return for known non-JS types before the TypeScript detection: if `type_val` contains `"json"`, `"template"`, `"x-shader"`, or similar, return the appropriate language or a sentinel that `find_injection_ranges` can use to skip the entry entirely. The cleanest fix is a return type of `Option<Option<&'static str>>` where `None` means "no injection" and `Some(None)` means "use default". But that requires a signature change to `InjectionRule::detect_language`. Simpler short term: add a comment listing known non-JS types that will be silently passed to the JS parser.

#### AC-5: `find_content_child` returns the FIRST matching child by kind — HTML grammar puts `raw_text` as the only content child, but if multiple children match, injection uses the wrong range
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:172-185
- **Description:** `find_content_child(node, rule.content_kind)` returns the first child of `node` with `child.kind() == content_kind`. For the HTML `script_element` → `raw_text` injection, tree-sitter's HTML grammar always produces exactly one `raw_text` child. But the function searches linearly and returns the first match, silently ignoring any subsequent matches. If a grammar produces multiple children with the same kind (which can happen in error-recovery scenarios — tree-sitter can produce duplicate-kind siblings when parsing malformed input), only the first range is used, and the remaining code regions are not injected. The more common and practical issue: for `<script>` elements with a missing closing `</script>`, tree-sitter may split the content into multiple `raw_text` nodes (one per "paragraph" of content), and only the first would be injected.
- **Suggested fix:** Change `find_content_child` to return all matching children (iterator or `Vec`), and accumulate all their byte ranges into the `InjectionGroup`. Each `tree_sitter::Range` already handles individual byte ranges independently. This makes injection robust to multi-node content, which also future-proofs for languages where injection content may span multiple sibling nodes.

## Extensibility

#### EX-1: `InjectionRule::target_language` strings are never validated — a typo silently fails at every injection parse, not at startup
- **Difficulty:** easy
- **Location:** src/language/html.rs:248, 254; src/parser/injection.rs:62-81
- **Description:** `InjectionRule::target_language` is a `&'static str` that is resolved to a `Language` variant at parse time via `lang_name.parse::<Language>()` in `find_injection_ranges`. If the string is invalid (e.g., `"javscript"` typo, or `"js"` instead of `"javascript"`), the error is silently logged as a `tracing::warn!` per injection entry found — meaning the warn fires for every `<script>` element in every HTML file indexed, but no injection happens and no JS chunks are extracted. There is no startup or test-time validation that injection rule target_language strings resolve to known, enabled languages. The existing registry test `test_registry_by_extension` (language/mod.rs:708) validates extensions, not injection targets. By contrast, the `Language::from_extension` path in `parse_file` returns a hard error (`UnsupportedFileType`) on unknown extensions — the injection path's failure is much softer.
- **Suggested fix:** Add a test in `src/language/mod.rs` (near `test_registry_by_extension`) that iterates `REGISTRY.all()`, iterates each language's `injections`, and asserts `rule.target_language.parse::<Language>().is_ok()` for each rule. This makes the typo a test failure rather than a runtime warning storm.

#### EX-2: Adding a new injection host language requires no code changes outside the language module — but CONTRIBUTING.md has no guidance on `InjectionRule` fields
- **Difficulty:** easy
- **Location:** CONTRIBUTING.md:107-122; src/language/mod.rs:160-173
- **Description:** The injection framework is genuinely data-driven: adding injections to a new language (e.g., Svelte `.svelte` → JS/TS/CSS) requires only populating the `injections: &[InjectionRule { ... }]` field in that language's `definition()`. No parser code changes needed. However, CONTRIBUTING.md's Architecture Overview section lists `injection.rs` with a one-line description ("Multi-grammar injection (HTML→JS/CSS via set_included_ranges)") but gives no guidance on how to add injection rules for a new host language. A contributor adding Svelte/Vue/PHP (which embeds JS/CSS/HTML) would have to discover the `InjectionRule` fields by reading the HTML implementation directly. The `container_kind`/`content_kind` values are tree-sitter grammar node names — contributor needs to know to consult the grammar's `node-types.json`, which is undocumented. The optional `detect_language` callback (for attribute-based language detection like `<script lang="ts">`) is particularly non-obvious.
- **Suggested fix:** Add a short "Adding injection support to a language" section to CONTRIBUTING.md after the Architecture Overview. Cover: (1) the four `InjectionRule` fields and where to find `container_kind`/`content_kind` values (grammar `node-types.json`), (2) `target_language` must be the lowercase `Language::to_string()` value, (3) `detect_language` callback for attribute-based detection, (4) point to `html.rs` as the canonical example. This is a docs-only change but directly enables the "extensible to other host languages" promise.

#### EX-3: Chained injection (inner language with its own `injections` field) is silently ignored — PHP's inline HTML with JS is unindexable by the current design
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:187-289 (`parse_injected_chunks`); src/language/mod.rs:257
- **Description:** The injection framework currently supports one level of nesting: host language → inner language. `parse_injected_chunks` parses the inner language's tree and extracts chunks, but never checks `inner_language.def().injections`. If an inner language itself has injection rules (e.g., PHP with inline `<script>` blocks — PHP → HTML → JS), those rules are silently ignored. Currently no language has both `grammar.is_some()` and a non-empty `injections` field simultaneously; only HTML uses injections and it is never an injection target. But if PHP support is added with HTML injection (common: PHP templates contain `echo "<script>..."` patterns parsed as raw HTML string content), or if Svelte is added as a host language with both JS and CSS injections, the second level would silently fail. The issue is also relevant for Markdown code fences: if Markdown ever gains a tree-sitter grammar and injection rules for fenced code blocks, the inner language would not be recursively injected. This is a design gap, not a latent bug in current code, but it constrains which future languages can be modeled as injection targets.
- **Suggested fix:** Document the single-level restriction in `InjectionRule`'s doc comment and in CONTRIBUTING.md: "Chained injection (inner language with its own injection rules) is not supported. If the inner language is also a host language, only its own top-level chunks are extracted." This prevents incorrect expectations. If chained injection is needed later, `parse_injected_chunks` would need to call `find_injection_ranges` on the inner parse tree and recursively call itself — a clear extension point, but currently unimplemented.

#### EX-4: Grammar-less languages with non-empty `injections` field would silently produce no injections — no guard or assertion
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:177-179, 235-264; src/parser/calls.rs:241-244, 388-407
- **Description:** In both `parse_file` and `parse_file_relationships`, grammar-less languages (currently only Markdown) trigger an early return before Phase 2 (the injection phase). This means if a future grammar-less language defines `injections: &[...]`, those rules are silently never applied — no warning, no assertion, no test failure. The early return at `mod.rs:177` routes to `parse_markdown_chunks`, which returns directly without reaching the injection loop at line 235. Markdown code-fence injection (parsing fenced blocks as their respective languages) is a plausible future feature, and the current architecture would silently block it while appearing to support it via `LanguageDef::injections`. The same applies to `calls.rs:241` routing to `parse_markdown_references` before line 388. The `injections: &[]` on `markdown.rs` is currently correct, but there is no compile-time or test-time enforcement that the injection field remains vacuous for grammar-less languages.
- **Suggested fix:** Add a `debug_assert!(language.def().grammar.is_some() || language.def().injections.is_empty(), "Grammar-less language {} has injection rules that will never fire", language)` before the early return in `parse_file` and `parse_file_relationships`. This turns a silent misconfiguration into an immediate assertion failure during development. Alternatively, add a registry validation test that iterates all languages and asserts grammar-less ones have empty injection lists.

## Test Coverage

#### TC-1: `chunk_overlaps_container` has zero unit tests — the sole filter that decides which outer chunks are removed on injection
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:486-494
- **Description:** `chunk_overlaps_container` is the predicate used in `parse_file` (mod.rs:243-248) to decide which outer HTML chunks to discard when injection succeeds. It implements strict containment (`chunk_start >= container_start && chunk_end <= container_end`), not overlap. No tests exercise the function directly. The boundary conditions — chunk that starts exactly at container start, chunk that ends exactly at container end, chunk that spans the container, chunk one line outside — are all untested. AC-1 (filed by Algorithm Correctness) notes the naming mismatch; the missing tests mean neither the current containment semantics nor the boundary behavior is verified.
- **Suggested fix:** Add unit tests in `src/parser/injection.rs` covering: (1) exact containment — `assert!(chunk_overlaps_container(10, 20, &[(10, 20)]))`, (2) chunk one line before start — `assert!(!chunk_overlaps_container(9, 20, &[(10, 20)]))`, (3) chunk one line past end — `assert!(!chunk_overlaps_container(10, 21, &[(10, 20)]))`, (4) multiple container ranges with one match, (5) empty container list returns false.

#### TC-2: CSS injection test (`parse_html_with_style_extracts_css_rules`) has a conditional assertion — passes vacuously when CSS produces no chunks
- **Difficulty:** easy
- **Location:** src/language/html.rs:459-499
- **Description:** The test for CSS injection from HTML `<style>` blocks guards all CSS-specific assertions with `if !css_chunks.is_empty() { ... }`. If CSS injection fails silently or the CSS parser produces no chunks, the test exits with no failures. The CSS query (`css.rs:14-25`) has `(rule_set (selectors) @name) @property` which should match `.container { display: flex; gap: 1rem; }` — chunks should be produced. The conditional guard hides a potential regression: a broken CSS injection path would leave this test green. The `parse_html_with_style_extracts_css_rules` test can pass even with completely broken CSS injection.
- **Suggested fix:** Remove the `if !css_chunks.is_empty()` guard and add an unconditional assertion: `assert!(!css_chunks.is_empty(), "Expected CSS chunks from inline <style>, got: {:?}", chunks...)`. Add a specific assertion for the `.container` selector chunk by name. This makes the test a real regression gate for CSS injection.

#### TC-3: Injected type references (`ChunkTypeRefs`) are never asserted in any test — `_types` is silently discarded
- **Difficulty:** easy
- **Location:** src/language/html.rs:650
- **Description:** The single test for HTML injection call graphs (`injection_call_graph`) calls `parser.parse_file_relationships(...)` and destructures as `(calls, _types)` — the type references return value is discarded. `parse_injected_relationships` extracts both `FunctionCalls` and `ChunkTypeRefs` (injection.rs:459-475). No test in the codebase asserts that injected functions with type usage produce `ChunkTypeRefs` entries. A regression in `extract_types` for injected code (e.g., the byte range scoping at injection.rs:459-465) would be completely invisible in the test suite.
- **Suggested fix:** Add a test with TypeScript content in a `<script lang="ts">` block that uses a type annotation (e.g., `function foo(x: MyType): void {}`), then assert that `parse_file_relationships` returns a non-empty `types` vec containing a `ChunkTypeRefs` entry for `foo` referencing `MyType`.

#### TC-4: No integration test for the parse_file → index → search roundtrip with HTML injection
- **Difficulty:** medium
- **Location:** tests/cli_test.rs
- **Description:** The existing CLI integration tests index Rust/Python fixtures and search for functions. There is no integration test that: (1) creates an HTML file with inline `<script>`, (2) indexes it, (3) searches for the injected JS function by name or content. The unit tests in `html.rs` verify `parse_file` output, but the full pipeline — `parse_file` → chunk embedding → store upsert → `search_filtered` → result — for injected chunks is untested. A regression in how injected chunk `language` or `line_start` fields are stored/retrieved would not be caught by the existing test suite.
- **Suggested fix:** Add an integration test in `tests/cli_test.rs` that creates a temp directory with an `index.html` containing `<script>function zq_unique_injection_fn() { return 42; }</script>`, runs `cqs index`, then runs `cqs "zq_unique_injection_fn" --json` and asserts the result contains `"zq_unique_injection_fn"` with language `"javascript"`.

#### TC-5: `detect_script_language` type-attribute branch and None fallback have no test coverage
- **Difficulty:** easy
- **Location:** src/language/html.rs:195-218
- **Description:** `detect_script_language` has three branches: (1) `lang` attribute is `"ts"` or `"typescript"` → `Some("typescript")`, (2) `type` attribute contains `"typescript"` → `Some("typescript")`, (3) neither → `None`. Only branch 1 is exercised by `parse_html_with_typescript_script` (via `lang="ts"`). Branch 2 (`type="text/typescript"`) and branch 3 (e.g., `<script type="application/ld+json">`) have no direct test. The `None` path for non-JS/TS types is particularly important: for JSON-LD scripts, the fallback to `"javascript"` means the JavaScript parser tries to parse JSON, produces no function chunks, and keeps the outer Module chunk — but none of this behavior is asserted. If the fallback behavior changes (e.g., a future `detect_script_language` returns `Some("json")` for JSON-LD), no test would catch the difference.
- **Suggested fix:** Add tests via `parse_file` for: (1) `<script type="text/typescript">` — assert TypeScript language; (2) `<script lang="typescript">` — assert TypeScript language; (3) `<script type="application/ld+json">` — assert outer Module chunk is kept (no JSON-language injection occurs since JSON injection is not registered for HTML). All three are easy to add alongside the existing `parse_html_with_typescript_script` test.

#### TC-6: No test for injection with malformed/unclosed `<script>` — error-recovery behavior unverified
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:99-185, src/language/html.rs
- **Description:** All injection tests use well-formed HTML. tree-sitter is an error-tolerant parser: for malformed input it produces a parse tree with error nodes, but the structure may differ from the well-formed case. Untested scenarios: (1) `<script>` with no closing `</script>` — tree-sitter may produce a truncated `raw_text` node or no content child; `find_content_child` returning `None` is the graceful path but is never exercised by a test; (2) empty `<style></style>` with no whitespace — the `byte_range.start < byte_range.end` guard at injection.rs:114 should skip it, but this is untested; (3) `<script>` body containing a JS syntax error (e.g., `function incomplete() {`) — JS parser's error recovery should still produce a function node for any well-formed function before the error. A `tree-sitter-html` grammar update could change error-recovery behavior, silently breaking injection for common malformed-HTML patterns.
- **Suggested fix:** Add tests for: (1) HTML with unclosed `<script>` — assert `parse_file` completes without panic; (2) `<style></style>` (zero-byte content) — assert outer Module chunk is kept; (3) `<script>` with a JS syntax error followed by a valid function — assert the valid function chunk is extracted.

## Data Safety

#### DS-NEW-1: `parse_file_relationships` does not remove outer relationship entries when injection succeeds — call graph and type edges include both outer and inner entries for the same container region
- **Difficulty:** medium
- **Location:** src/parser/calls.rs:388-407, src/parser/mod.rs:235-265
- **Description:** `parse_file` (mod.rs:242-249) removes outer chunks that overlap with injection containers when inner parsing succeeds: `chunks.retain(|c| !injection::chunk_overlaps_container(...))`. This is the correct behavior — the outer HTML Module chunk that wraps a `<script>` block is replaced by the injected JS functions. But `parse_file_relationships` (calls.rs:388-407) has no analogous removal step: it appends injected `FunctionCalls` and `ChunkTypeRefs` via `.extend(inner_calls)` and `.extend(inner_types)` **without** removing the outer `FunctionCalls`/`ChunkTypeRefs` entries that the outer HTML parser already produced for the same container region. In practice, the HTML parser's chunk query only captures Module-level nodes (whole `<script>` blocks), so the outer call extraction for that node would be empty. But if the outer grammar produces a named chunk that contains the injection region (e.g., the Module chunk for `<script>`), and the outer call query captures any call sites within it (e.g., HTML event attributes), those calls would appear in the call graph alongside the injected JS calls, potentially doubling some entries. More critically, `upsert_function_calls` (calls.rs:300-303) does `DELETE FROM function_calls WHERE file = ?1` and re-inserts everything, so both outer and inner call entries are stored. The outer Module-level chunk is not stored in the chunks table (it was replaced), but its callers/callees may still appear in `function_calls` with a `caller_name` that no longer has a corresponding chunk, creating dangling function_calls entries.
- **Suggested fix:** Mirror `parse_file`'s retain logic in `parse_file_relationships`: after computing `groups`, for each group that produces non-empty `(inner_calls, inner_types)`, remove from `call_results` and `type_results` any entry whose `name` and `line_start` correspond to an outer chunk that `chunk_overlaps_container` would remove (i.e., an outer chunk fully within the container line range). This requires computing `InjectionGroup.container_lines` in `parse_file_relationships`, which is already available since `find_injection_ranges` returns `InjectionGroup` with `container_lines` populated.

#### DS-NEW-2: `chunk_overlaps_container` uses strict containment (`>=`/`<=`) rather than overlap — outer chunks that straddle injection boundaries are silently kept alongside inner chunks
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:486-494, src/parser/mod.rs:243-249
- **Description:** `chunk_overlaps_container` returns true only when `chunk_start >= container_start && chunk_end <= container_end` — i.e., the outer chunk is fully enclosed by the container. If an outer parser chunk partially overlaps an injection container (e.g., a chunk whose start line is within the container but end line is outside it, or vice versa), it will NOT be removed by the `retain` filter. The result is both the partial outer chunk AND the injected inner chunks from that region are stored, creating duplicate/conflicting index entries for the same source lines. For HTML, the outer grammar captures Module chunks and inline event handlers, and it is plausible for tree-sitter's error recovery (on malformed HTML) to produce a chunk whose line range straddles a `<script>` boundary. The naming is also misleading: "overlaps" typically implies any intersection, but the implementation requires full containment. This was previously noted in the Algorithm Correctness category (naming mismatch), but the data safety consequence — duplicate chunks from the same line range — was not filed.
- **Suggested fix:** Either (a) rename to `chunk_contained_by_any_container` to document the strict containment semantics, and verify that strict containment is correct for all expected outer grammar chunk kinds; or (b) change to an overlap check (`chunk_start <= container_end && chunk_end >= container_start`) to remove any outer chunk that shares any lines with an injection container, accepting more aggressive removal. Document which semantics are intended and add tests (already filed as TC-1).

#### DS-NEW-3: `upsert_chunks_and_calls` (store_stage) and `upsert_function_calls` (extract_relationships) write to overlapping tables for the same file without coordination — partial failure leaves calls table inconsistent
- **Difficulty:** medium
- **Location:** src/cli/pipeline.rs:577-621, src/cli/commands/index.rs:183-234, src/store/chunks.rs:434-464, src/store/calls.rs:283-343
- **Description:** The index pipeline has two separate write passes: (1) `store_stage` calls `upsert_chunks_and_calls` which writes to both the `chunks` table and the `calls` table (chunk-level calls, extracted via `extract_calls_from_chunk`); (2) `extract_relationships` calls `upsert_function_calls` which writes to `function_calls` table and `upsert_type_edges_for_file` which writes to `type_edges`. These are three different tables updated in two separate transactions at different stages. If the process is interrupted between `store_stage` completing and `extract_relationships` completing, the `chunks` table is up to date but `function_calls` and `type_edges` are stale (still reflecting the previous version of the file). On the next `cqs index` run, files are only reindexed if their mtime changed. If the file was not modified, `needs_reindex` returns `None`, the file is skipped by `store_stage`, and `extract_relationships` is called for all files in `existing_files` regardless of mtime. So `function_calls` would be updated even without a mtime change — but `type_edges` uses the same unconditional loop. The risk is: a crash mid-run leaves `function_calls` stale for already-processed files, but a re-run with unchanged files fixes it. The more serious case is: `upsert_type_edges_for_file` queries `chunks WHERE origin = ?1` inside a transaction to resolve `(name, line_start)` → `chunk_id` (types.rs:129-146). If a chunk was replaced with different ID structure (different hash after a code change), the `name_to_id` lookup at types.rs:152-163 logs a `warn!` and silently skips, leaving no type edges for that chunk. There is no aggregate signal that edges were dropped.
- **Suggested fix:** Add a counter for skipped type edges in `upsert_type_edges_for_file` and log a `warn!` with the count at the end (similar to how DS-6 was fixed). The skipped-chunks warning is already there (types.rs:158-164) but per-chunk; a summary log at function exit would surface systematic mismatches. Longer term: consider a single-pass write that combines chunk upsert, calls, and type edge updates in one transaction per file.

#### DS-NEW-4: Injected chunk IDs are generated from the outer file path + inner `line_start` + content hash — a collision is possible if two injection groups produce a chunk at the same line with the same first 8 chars of BLAKE3 hash
- **Difficulty:** hard
- **Location:** src/parser/chunk.rs:101, src/parser/injection.rs:250
- **Description:** Chunk IDs are `format!("{}:{}:{}", path, line_start, hash_prefix)` where `hash_prefix` is the first 8 hex chars (4 bytes) of the BLAKE3 hash of the chunk's content (chunk.rs:100-101). Two chunks in the same file at the same starting line with a hash collision in the first 8 hex digits would get the same ID. For non-injection files, two chunks at the same `line_start` would have to overlap substantially, which tree-sitter prevents (it produces non-overlapping captures for disjoint nodes). But with injection, the outer parser and inner parser both capture nodes that may start at the same line (e.g., the `<script>` tag starts at line 5, and the first JS function inside it also starts at line 5 before the outer chunks for line 5 are removed by `chunk_overlaps_container`). The 8-hex-char prefix means a birthday-attack collision requires only ~65,536 chunks, but a natural collision in a realistic codebase is extremely unlikely. If a collision occurs, `INSERT OR REPLACE INTO chunks` (chunks.rs:371) would silently overwrite the first chunk with the second. The real risk is not a random collision but a deterministic collision: if the outer HTML module chunk for a `<script>` block AND the inner JS function both start at line 5, the outer chunk is supposed to be removed by the `retain` filter — but if injection produces zero inner chunks (fallback path), the outer chunk is kept with a valid ID. When injection later succeeds (after a code edit), the injected chunk may get the same line_start as the outer chunk had, and `INSERT OR REPLACE` would silently replace it. This is actually correct behavior (replacement is desired), but there is no test asserting that re-indexing a file where injection transitions from "no inner chunks" to "has inner chunks" correctly replaces the outer chunk entry.
- **Suggested fix:** Low priority for hash collision — 8 hex chars (32 bits) is sufficient for typical codebases. For the injection transition case: add a test that verifies re-indexing an HTML file after adding a JS function to an empty `<script>` block correctly replaces the outer Module chunk with the inner JS chunk (no duplicate entries in the chunks table).

## Performance

#### PF-NEW-1: `parse_file` + `parse_file_relationships` double-reads and double-parses every file during full index
- **Difficulty:** hard
- **Location:** src/cli/commands/index.rs:87,130-131; src/cli/watch.rs:395,512; src/cli/pipeline.rs:267
- **Description:** The full index pipeline calls `run_index_pipeline` (which calls `parse_file` on every file) and then calls `extract_relationships` (which calls `parse_file_relationships` on every file). Each call reads the file from disk and runs tree-sitter parse. For a project with 1,000 source files, every `cqs index` run reads every file twice and runs two complete tree-sitter parses per file. For HTML files (the only language with injection rules), the outer tree is also built twice, and each injection group triggers an additional inner re-parse in both `parse_file` and `parse_file_relationships`. The watch incremental path (`reindex_files` in watch.rs:395,512) has the same double-parse: `parse_file` then `parse_file_relationships`. Additionally, `reindex_files` calls `extract_calls_from_chunk` for each chunk to build the call graph, which creates **yet another** tree-sitter parser and re-parses the chunk's content — so for the watch path, each file is parsed at least 3 times (once for chunks, once for relationships, once per chunk for calls). `parse_file_relationships` already returns both call sites and type refs in a single pass — the watch path uses it for type edges only and discards the call sites it already computed.
- **Suggested fix:** Merge `parse_file` and `parse_file_relationships` into a combined `parse_file_all` that returns `(Vec<Chunk>, Vec<FunctionCalls>, Vec<ChunkTypeRefs>)` in a single read + single parse. The full-index pipeline already separates the two passes by design (pipeline stages), so the short-term fix for watch is to use the `function_calls` returned by `parse_file_relationships` instead of discarding them, eliminating `extract_calls_from_chunk` from the watch path.

#### PF-NEW-2: `capture_index_for_name("name")` called inside per-match hot loops — should be hoisted
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:301; src/parser/injection.rs:388; src/parser/chunk.rs:41,50
- **Description:** `tree_sitter::Query::capture_index_for_name` performs a string search over the query's capture name list. It is called once per query match inside tight loops, but the result is invariant for a given `Query` object — the capture index for `"name"` never changes between matches. In `calls.rs:301` and `injection.rs:388`, `chunk_query.capture_index_for_name("name")` is inside `while let Some(m) = matches.next()`. In `chunk.rs`, `extract_chunk` is called once per match from `parse_file`'s inner loop, and calls `capture_index_for_name` up to 16 times (once per entry in `capture_types` + once for `"name"`). For a file with 100 functions, this is up to 1,700 redundant string lookups per parse.
- **Suggested fix:** In `parse_file_relationships` (calls.rs) and `parse_injected_relationships` (injection.rs), hoist `let name_idx = chunk_query.capture_index_for_name("name")` outside the `while let Some(m)` loop. In `extract_chunk` (chunk.rs), pre-build a `HashMap<u32, ChunkType>` mapping capture index → `ChunkType` before the match loop and pass it in, or cache it in the `Parser` struct alongside the `OnceCell<Query>`.

#### PF-NEW-3: `find_injection_groups` uses linear scan to group by language — O(n) per entry with O(groups) comparisons
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:83-93
- **Description:** After collecting all `(lang_name, range, container_lines)` entries, grouping uses `groups.iter_mut().find(|g| g.language == language)`. For each entry, this linearly scans all existing groups. In practice, HTML has 2 injection rules (script → JavaScript/TypeScript, style → CSS), so at most 2-3 groups — cost is negligible now. But if injection rules expand (e.g., PHP, Vue, Svelte with multiple embedded languages), this becomes O(entries × groups). The entries Vec is also created unnecessarily: the grouping could be done inline during the tree walk without the intermediate allocation.
- **Suggested fix:** Use a `HashMap<Language, InjectionGroup>` indexed by language during collection, then convert to `Vec<InjectionGroup>` at the end. This also eliminates the intermediate `entries` Vec.

#### PF-NEW-4: `parse_injected_chunks` and `parse_injected_relationships` each create a fresh `tree_sitter::Parser` and re-parse the same byte ranges independently
- **Difficulty:** medium
- **Location:** src/parser/injection.rs:207-231, 306-329
- **Description:** For HTML files with both `<script>` and `<style>` blocks, `parse_file` calls `parse_injected_chunks` once per `InjectionGroup` (one for JS/TS, one for CSS) — each creates a new `tree_sitter::Parser`, sets `set_included_ranges`, and calls `parser.parse(source, None)`. Then `parse_file_relationships` calls `parse_injected_relationships` for the same groups, repeating the same parser creation and `source` re-parse. The parsed `tree_sitter::Tree` from `parse_injected_chunks` cannot currently be reused in `parse_injected_relationships` because they're called in separate functions without sharing state. A tree-sitter `Parser` object itself is reusable (supports `reset_language` / `set_language`) — creating a fresh one per injection group foregoes this optimization.
- **Suggested fix:** Share a single `tree_sitter::Parser` instance across injection group parses by passing it as `&mut tree_sitter::Parser` into `parse_injected_chunks` and `parse_injected_relationships`. If the combined `parse_file_all` from PF-NEW-1 is implemented, injection chunks and relationships can be extracted from a single shared tree per inner language.

#### PF-NEW-5: `extract_types` builds `HashSet<String>` of classified names by cloning all type name strings — avoidable with `&str`
- **Difficulty:** easy
- **Location:** src/parser/calls.rs:170-178
- **Description:** After collecting classified and catch-all type refs, the deduplication step at calls.rs:170 builds `classified_names: HashSet<String>` by cloning the `type_name` field from every classified entry. Since `classified` is already a `Vec<TypeRef>` alive for the remainder of the function, the `HashSet` can reference the existing strings as `&str` without allocating new `String` copies.
- **Suggested fix:** Change to `let classified_names: HashSet<&str> = classified.iter().map(|t| t.type_name.as_str()).collect();` and update the containment check to `classified_names.contains(t.type_name.as_str())`.

## Resource Management

#### RM-NEW-1: Outer tree-sitter `Parser` and `Tree` not dropped before injection inner-parse phase in `parse_file`
- **Difficulty:** easy
- **Location:** src/parser/mod.rs:182-266, src/parser/calls.rs:247-409
- **Description:** In `parse_file` (mod.rs), the outer `tree_sitter::Parser` is created at line 182 and last used at line 188 (the `.parse()` call). The outer `tree_sitter::Tree` is last used at line 238 (`find_injection_ranges(&tree, ...)`). Neither is explicitly dropped: both remain live until end of scope at line 266. During the injection inner-parse loop (lines 239–263), each call to `parse_injected_chunks` creates an additional inner `Parser` and `Tree` — so the outer parser's internal reusable buffers (lookahead pool, subtree pool, held since the HTML parse) are retained idle while inner allocations happen. The outer tree for a large HTML file can be several MB. In the rayon parallel parsing stage (`pipeline.rs:263`), `num_cpus` threads may each hold an outer HTML tree + inner JS/CSS tree simultaneously, approximately doubling peak tree memory for HTML-heavy projects compared to what is necessary. The same pattern exists in `parse_file_relationships` (calls.rs:247–409): outer `parser` last used at line 254, outer `tree` last used at line 391, both live until line 409.
- **Suggested fix:** In `parse_file` (mod.rs), add `drop(parser);` after line 189 and `drop(tree);` after line 238 — four lines total (two per function), each a one-liner. Rust's NLL extends borrows but does not insert early drops for owned values; explicit `drop()` is required to reclaim memory before end of scope. In `parse_file_relationships` (calls.rs), add `drop(parser);` after line 254 and `drop(tree);` after line 391. Note: in `parse_file_relationships`, `tree` is borrowed inside the while loop (passed to `extract_types` at line 357 and `call_cursor.matches(call_query, tree.root_node(), ...)` at line 354) so `drop(tree)` can only follow line 391, not be moved earlier.

#### RM-NEW-2: Call-deduplication `HashSet` in injection and relationship extractors clones every callee name string unnecessarily
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:447-448, src/parser/calls.rs:351-357
- **Description:** `parse_injected_relationships` (injection.rs:447) and `parse_file_relationships` (calls.rs) deduplicate call sites with `calls.retain(|c| seen.insert(c.callee_name.clone()))`. The `seen: HashSet<String>` takes ownership of a fresh clone of every `callee_name` string, even though the original strings live in the `calls` Vec for the rest of the iteration. Since `seen.clear()` is called per function (injection.rs:447) or a new `seen` is created per iteration (calls.rs:352), these clones are discarded almost immediately. For a function with N distinct call sites, this allocates N `String` heap objects that are freed within the same loop iteration. For JavaScript React components or large generated files with many calls per function (50–100+), this is 50–100 redundant allocations per function during relationship extraction. The allocation pattern is correct (deduplication works) but wasteful.
- **Suggested fix:** Replace `HashSet<String>` with `HashSet<&str>` referencing into the existing `calls` Vec: `calls.retain(|c| seen.insert(c.callee_name.as_str()));` where `seen: HashSet<&str>`. This avoids all clones for the deduplication check. Apply to both injection.rs:447 and calls.rs (the corresponding `retain` at line 352). The lifetime of `&str` references is valid as long as `calls` is in scope, which it is for both sites.

## Security

#### SEC-NEW-1: Unbounded injection range count in HTML parser enables DoS via crafted file
- **Difficulty:** easy
- **Location:** src/parser/injection.rs:83-94, src/parser/injection.rs:212
- **Description:** `find_injection_ranges` collects one `tree_sitter::Range` per `<script>` or `<style>` block into a single `InjectionGroup.ranges` Vec (groups are per-language, so all JavaScript blocks share one group). There is no cap on how many ranges a group may contain. A crafted 50 MB HTML file with ~2.8 million minimal `<script>x</script>` blocks (18 bytes each) would produce a Vec of ~2.8 M `tree_sitter::Range` structs (~90 MB of range data), which is then passed wholesale to `parser.set_included_ranges(&group.ranges)`. tree-sitter processes this range array before parsing, and the subsequent parse covers all 2.8 M script regions in one pass. On a machine with limited RAM this can cause OOM; at minimum it causes multi-second stalls during indexing. The file-size limit (50 MB, `MAX_FILE_SIZE`) is the only existing guard but does not bound range count because each range can be very small.
- **Suggested fix:** Add a `MAX_INJECTION_RANGES` constant (e.g. `10_000`) and truncate — with a `tracing::warn!` — if `group.ranges.len()` would exceed it before calling `set_included_ranges`. Since the ranges within a single language group are non-overlapping script blocks, truncation means the tail of the file is treated as plain HTML rather than injected JS/CSS, which is a graceful degradation.

#### SEC-NEW-2: Temp files written with umask-derived permissions before `chmod 0o600` is applied
- **Difficulty:** easy
- **Location:** src/audit.rs:119-135, src/config.rs:332-349, src/note.rs:250-273
- **Description:** All three atomic-write paths share the same pattern: `std::fs::write(&tmp_path, &content)` creates the temp file with the process umask (typically `0o644` = world-readable), then `std::fs::rename(&tmp_path, &final_path)` renames it. `audit.rs` and `config.rs` apply `set_permissions(0o600)` **after** the rename, leaving a window where the renamed file is world-readable. `note.rs` (`rewrite_notes_file`) never applies `chmod` at all. On a multi-user system or shared CI runner the content is transiently readable. `config.toml` is the most sensitive: it contains `[[reference]]` entries with `path` and `source` fields that disclose filesystem layout. `audit-mode.json` contains only timestamps (low sensitivity). `notes.toml` contains user-authored note text (medium sensitivity, no `chmod` ever applied).
- **Suggested fix:** Replace `std::fs::write` with an `OpenOptions` that sets `mode(0o600)` before writing (Unix only): `OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp_path)` followed by `file.write_all(...)`. This ensures the temp file is created private from the start. The post-rename `chmod` in `audit.rs`/`config.rs` then becomes defense-in-depth rather than the primary guard. For `note.rs`, add the same `chmod` after rename.

#### SEC-NEW-3: `run_git_log_line_range` does not validate colons in the file path — git `-L` spec misparse
- **Difficulty:** easy
- **Location:** src/cli/commands/blame.rs:79-93
- **Description:** `run_git_log_line_range` validates that `rel_file` does not start with `'-'` (prevents git option injection), but does not check for embedded colons. The `-L` spec format is `start,end:filepath`. If a file in the index has a relative path containing `:` (legal on Linux, e.g. `src/proto:generated/foo.rs`), `line_range = format!("{},{}", start, end, git_file)` produces `"1,5:src/proto:generated/foo.rs"`. Git parses the first `:` as the end of the line-range expression and attempts to use `src/proto` as the ref and `generated/foo.rs` as the path, silently producing empty output or a git error rather than blame for the intended file. The caller `build_blame_data` returns `Ok(BlameData { commits: [] })` (empty result from `parse_git_log_output("")`) rather than an error, so the user sees an empty blame result with no indication of failure.
- **Suggested fix:** Before building `line_range`, validate that `git_file` contains no `:`: `if git_file.contains(':') { anyhow::bail!("File path contains ':' which is incompatible with git -L spec: {}", git_file); }`. Colons in source file paths are extremely rare but legal; a clear error is better than silent empty results.
