| name | scalpel-static-analysis |
| description | Use when working on scalpel-analyze crate, tree-sitter parsing, call graph construction, function extraction, import resolution, or debugging false positives in the genome |
Static Analysis Expert Knowledge
Core Principle
Tree-sitter sees syntax, not semantics. Every edge it misses is a potential false positive in the genome. Know what it can't see BEFORE you write code.
Violating this principle means: you will discover blind spots by trial-and-error instead of predicting them. Each wrong assumption costs a debugging cycle.
When to Use
- Modifying any file in
crates/scalpel-analyze/
- Adding or changing tree-sitter queries for any language
- Debugging "unreachable" functions that are actually used
- Adding support for a new programming language
- Modifying call site extraction or function detection logic
- Working on import resolution
Domain Model
Tree-sitter's position in the compilation pipeline:
Source → [tree-sitter: HERE] → AST
|
Sees: syntax structure, function boundaries, call sites, imports
Cannot see: types, macros, dispatch, generics, monomorphization
|
Below this line requires rust-analyzer (HIR), MIR analysis, or runtime tracing
↓
HIR → MIR → LLVM IR → Binary
Tree-sitter CAN: Extract function boundaries, call sites, imports, class hierarchies — structurally, across 10 languages, in <10s for 100K LOC.
Tree-sitter CANNOT: Resolve types, expand macros, resolve trait/interface dispatch, handle generics, do cross-module name resolution.
Blind Spots Table
| Language | Blind Spot | Impact | Mitigation |
|---|
| All | Macro arguments opaque | Calls inside macros invisible | Runtime tracing covers |
| Rust | #[derive] impls | Derived trait impls never in graph | Mark ConditionallyReachable |
| Rust | Fully-qualified paths (crate::mod::func) | Cross-crate calls may miss | 3-tier resolution |
| Rust | Trait object dispatch (dyn Trait) | Concrete impl unknown | Keep all impls |
| Rust | async fn state machines | Coverage regions fragmented | Use function-level, not region |
| JS/TS | Computed properties obj[key]() | Callee unresolvable | Skip, runtime handles |
| JS/TS | Anonymous arrows | Empty function name | Resolve by byte offset |
| JS/TS | Source maps needed | TS offsets != JS offsets | VLQ decode, chain maps |
| Python | __getattr__ / metaclasses | Dynamic dispatch invisible | Flag class MustKeep |
| Python | Decorator chains | Wrapper calls not in AST | See @decorator only |
| Python | C-extension builtins | Bypass sys.monitoring | Known gap, document |
| Go | Interface dispatch | Concrete type unknown | Keep all implementations |
| Go | init() functions | May run before coverage | Log warning if missed |
| Java/C# | Generic erasure | List<T>.add() → raw add | Over-approximate |
| Ruby | method_missing | Invisible dynamic dispatch | Flag class MustKeep |
When adding a new language: Add its blind spots to this table FIRST.
Decision Trees
Call Resolution (3-tier)
digraph call_resolution {
"Extract callee name from call site" [shape=box];
"Same-file lookup?" [shape=diamond];
"Tier 1: Direct match (high confidence)" [shape=box style=filled fillcolor=lightgreen];
"Caller imports this name?" [shape=diamond];
"Tier 2: Import-aware (high confidence)" [shape=box style=filled fillcolor=lightgreen];
"Exactly 1 node with this name?" [shape=diamond];
"Tier 3: Suffix match (medium confidence)" [shape=box style=filled fillcolor=lightyellow];
"UNRESOLVED — do NOT guess" [shape=box style=filled fillcolor=lightsalmon];
"Extract callee name from call site" -> "Same-file lookup?";
"Same-file lookup?" -> "Tier 1: Direct match (high confidence)" [label="found"];
"Same-file lookup?" -> "Caller imports this name?" [label="not found"];
"Caller imports this name?" -> "Tier 2: Import-aware (high confidence)" [label="yes"];
"Caller imports this name?" -> "Exactly 1 node with this name?" [label="no"];
"Exactly 1 node with this name?" -> "Tier 3: Suffix match (medium confidence)" [label="exactly 1"];
"Exactly 1 node with this name?" -> "UNRESOLVED — do NOT guess" [label="0 or 2+"];
}
0 matches = likely macro-generated or dynamic. 2+ matches = ambiguous. Wrong edge is worse than missing edge.
Node Type Decision
| AST Node | Action | Reason |
|---|
| Function/method definition | Add as node | Executable code |
| Struct/enum definition | DO NOT add | No type-reference edges exist → orphaned "unreachable" |
| Trait definition | Walk children for methods, don't add trait | Not executable itself |
| Impl block | Walk children for methods, don't add block | Not callable itself |
| Closure/lambda | Add as anonymous node with byte range | Executable, needs offset-based matching |
Confidence Assignment
Extracted + called at runtime → ConfirmedUsed
Extracted + statically reachable → StaticallyReachable
Inside macro/derive/dynamic target → ConditionallyReachable
Error handler / lifecycle hook → MustKeep
No evidence of use → Unreachable
The judgment call: When a function is inside macro_rules! or decorated with #[derive]:
- DO NOT mark Unreachable — tree-sitter can't see the generated call
- Mark ConditionallyReachable — runtime tracing resolves this
Known Traps
1. Type definitions as function nodes
struct/enum create orphaned "unreachable" nodes because no type-reference edges exist. In self-analysis this caused 193 false positives (19% → 3.4% after fix). Never add type definitions as Function nodes.
2. Empty callee name
find_node(&file, "") always returns None — no empty-name nodes exist. Gate on non-empty string before any lookup. This is a latent bug in import edge creation.
3. Iterator function pointers
.map(func_name) passes a function as argument. Tree-sitter sees the identifier but not as a call_expression. Currently a known miss — runtime tracing covers it.
4. Re-export chains
pub use module::func creates an alias. If the alias is reachable, the source function must stay. Resolution must follow the chain or pruner may remove the source.
5. Macro argument calls
format!("...", func()) — the func() call is invisible to tree-sitter. Discovered in self-analysis: format_bytes was a false positive for exactly this reason. Same-file Tier 1 resolution works, but the call site was never extracted.
Verification Protocol
After ANY change to scalpel-analyze:
cargo test -p scalpel-analyze
cargo test --all (cross-crate integration)
cargo clippy --all -- -D warnings
- Delete stale cache:
rm -f .scalpel/genome-export.json
- Re-run self-analysis:
cargo run -p scalpel-cli -- trace --lang rust -- cargo test --all
- Compare unreachable count against baseline (current baseline: 37 / 3.4%)
- Spot-check 3 "unreachable" items via grep — confirm truly dead
If unreachable count INCREASED: investigate each new item before committing.
Red Flags — STOP
If you catch yourself:
- Adding a new node type without considering what edges connect to it
- Resolving ambiguous calls by picking "most likely" candidate
- Saying "accuracy improved" without running the verification protocol
- Testing tree-sitter changes with synthetic snippets only (must test on real codebase)
- Assuming tree-sitter can see something without checking the blind spots table
- Modifying call extraction without re-running self-analysis
ALL of these mean: STOP. Check the blind spots table. Run verification.
Quick Reference
| Operation | Rule |
|---|
| New language support | Add blind spots to table FIRST |
| Ambiguous callee (2+ matches) | Don't guess. Mark unresolved. |
| Macro-annotated function | ConditionallyReachable, never Unreachable |
| Type definition (struct/enum) | Don't add as node |
| Error handler | MustKeep regardless of reachability |
| Cross-crate call | Follow 3-tier resolution. All fail → unresolved |
| New node type | Define what edges connect to it before adding |
| Accuracy claim | Run full verification protocol first |