chore: import upstream snapshot with attribution
Deploy site to GitHub Pages / build (push) Failing after 1s
Deploy site to GitHub Pages / deploy (push) Has been skipped

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:56 +08:00
commit 5f98960d22
495 changed files with 149129 additions and 0 deletions
+558
View File
@@ -0,0 +1,558 @@
# CodeGraph Language Verification Guide
You are verifying that CodeGraph fully supports a specific programming language. The user will give you a path to a real-world, popular open-source codebase cloned locally. Your job is to run a battery of realistic prompts against it using CodeGraph's API and verify the results are good enough to say that language is **covered and supported**.
A language is NOT verified until an LLM can reliably use CodeGraph's MCP tools to navigate that codebase — finding the right symbols, understanding call chains, exploring subsystems, and getting useful context for real tasks.
## Setup
### 1. Build and index
```bash
npm run build
rm -rf <codebase_path>/.codegraph
node dist/bin/codegraph.js init -iv <codebase_path>
```
The `-iv` flag gives verbose output showing extraction progress, node/edge counts, and timing.
### 2. Quick sanity check
```bash
# Verify nodes were extracted with proper qualified names
sqlite3 <codebase_path>/.codegraph/codegraph.db \
"SELECT name, kind, qualified_name FROM nodes WHERE kind = 'method' LIMIT 10;"
# GOOD: file.go::StructName::method_name (owner type present)
# BAD: file.go::file.go::method_name (owner type missing — needs getReceiverType)
# Check edge counts
sqlite3 <codebase_path>/.codegraph/codegraph.db \
"SELECT kind, COUNT(*) FROM edges GROUP BY kind ORDER BY COUNT(*) DESC;"
# Check node kind distribution
sqlite3 <codebase_path>/.codegraph/codegraph.db \
"SELECT kind, COUNT(*) FROM nodes GROUP BY kind ORDER BY COUNT(*) DESC;"
```
If methods are missing their owner type in `qualified_name`, fix that first (see [Adding getReceiverType](#adding-getreceivertype)) before proceeding with the full test battery.
## The Test Battery
Run **all** of the following test categories against the codebase. Use the Node.js API directly — the test scripts below are templates. Adapt the queries to match real types, methods, and subsystems in the codebase you're testing.
**Pass criteria for each test:** Does the result give an LLM enough correct information to answer the question or complete the task? Would you trust these results if you were the LLM?
---
### Test 1: `codegraph_explore` — Deep Exploration (MOST IMPORTANT)
This is the primary tool LLMs use. It must return relevant source code grouped by file, with correct relationships, for a natural language query. Test it with **at least 5 different query types**:
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
const queries = [
// A. Subsystem exploration — broad topic, should find the right files and key classes
'How does the caching system work?',
// B. Specific class/type deep dive — should return that class, its methods, and related types
'CacheBuilder configuration and build process',
// C. Cross-cutting concern — should find implementations across multiple files
'How are errors handled and propagated?',
// D. Data flow question — should trace through multiple layers
'How does data flow from input to storage?',
// E. Implementation detail — specific method behavior
'How does eviction decide which entries to remove?',
];
for (const query of queries) {
console.log(\`\n========================================\`);
console.log(\`QUERY: \${query}\`);
console.log(\`========================================\`);
const subgraph = await cg.findRelevantContext(query, {
searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2,
});
// Show entry points — these are what the LLM sees first
console.log(\`\nEntry points (\${subgraph.roots.length}):\`);
for (const rootId of subgraph.roots.slice(0, 8)) {
const node = subgraph.nodes.get(rootId);
if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`);
}
// Show file distribution — are the right files surfacing?
const fileGroups = new Map();
for (const node of subgraph.nodes.values()) {
if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []);
fileGroups.get(node.filePath).push(node.name);
}
console.log(\`\nFiles (\${fileGroups.size}):\`);
for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 8)) {
console.log(\` \${file} (\${nodes.length} symbols): \${nodes.slice(0, 6).join(', ')}\`);
}
// Show edge distribution — are relationships being captured?
const edgeKinds = new Map();
for (const edge of subgraph.edges) {
edgeKinds.set(edge.kind, (edgeKinds.get(edge.kind) || 0) + 1);
}
console.log(\`\nEdges (\${subgraph.edges.length}):\`);
for (const [kind, count] of [...edgeKinds.entries()].sort((a,b) => b - a)) {
console.log(\` \${kind}: \${count}\`);
}
console.log(\`\nTotal: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${fileGroups.size} files\`);
}
await cg.close();
}
test().catch(console.error);
"
```
**What to check for each query:**
- Do the entry points make sense for the question?
- Are the right files surfacing (not just test files or unrelated code)?
- Is there a mix of edge types (calls, contains, extends, implements) — not just `contains`?
- Does the node count feel right? Too few (<5) means search failed. Too many irrelevant ones means noise.
---
### Test 2: `codegraph_search` — Symbol Lookup
Test that searching for specific symbols returns the right results ranked correctly.
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
const searches = [
// A. Class by name
{ query: 'CacheBuilder', kinds: ['class'], desc: 'Find a specific class' },
// B. Method on a specific type (the classic disambiguation test)
{ query: 'CacheBuilder build', kinds: ['method'], desc: 'Method on specific class' },
// C. Common method name — should still find relevant ones
{ query: 'get', kinds: ['method'], desc: 'Common method name' },
// D. Interface/trait
{ query: 'Cache', kinds: ['interface'], desc: 'Find an interface' },
// E. Enum
{ query: 'Strength', kinds: ['enum'], desc: 'Find an enum' },
];
for (const s of searches) {
console.log(\`\n--- \${s.desc}: \"\${s.query}\" (kinds: \${s.kinds}) ---\`);
const results = cg.searchNodes(s.query, { limit: 10, kinds: s.kinds });
for (const r of results) {
console.log(\` \${r.score.toFixed(1)} | \${r.node.name} (\${r.node.kind}) | \${r.node.qualifiedName}\`);
}
if (results.length === 0) console.log(' *** NO RESULTS ***');
}
await cg.close();
}
test().catch(console.error);
"
```
**What to check:**
- Does the target symbol rank in the top 3?
- For common names like `get`, do the results include qualified names that help disambiguate?
- Are there zero-result queries? That's a bug.
---
### Test 3: `codegraph_callers` / `codegraph_callees` — Call Chain Tracing
Test that call relationships were extracted correctly.
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
// Pick 3-4 important methods and check their call graphs
const symbols = ['build', 'get', 'put', 'invalidate'];
for (const sym of symbols) {
// Find the symbol
const results = cg.searchNodes(sym, { limit: 5, kinds: ['method'] });
if (results.length === 0) { console.log(\`\${sym}: not found\`); continue; }
const node = results[0].node;
console.log(\`\n--- \${node.name} (\${node.qualifiedName}) ---\`);
// Check callees (what does it call?)
const callees = cg.getCallees(node.id);
console.log(\` Callees (\${callees.length}): \${callees.slice(0, 10).map(c => c.node.name).join(', ')}\`);
// Check callers (what calls it?)
const callers = cg.getCallers(node.id);
console.log(\` Callers (\${callers.length}): \${callers.slice(0, 10).map(c => c.node.name).join(', ')}\`);
}
await cg.close();
}
test().catch(console.error);
"
```
**What to check:**
- Do methods have callers AND callees? If a method has 0 of both, edge extraction may be broken.
- Do the callers/callees make sense? A `build()` method should call constructor-like things, and be called by setup/initialization code.
- Are the counts reasonable? A core method in a popular codebase should have multiple callers.
---
### Test 4: `codegraph_impact` — Change Impact Analysis
Test that the impact radius correctly identifies affected code.
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
// Pick a core class or interface that many things depend on
const results = cg.searchNodes('<CoreClass>', { limit: 1, kinds: ['class', 'interface'] });
if (results.length === 0) { console.log('Not found'); return; }
const node = results[0].node;
console.log(\`Impact analysis for: \${node.name} (\${node.kind}) — \${node.filePath}\`);
const impact = cg.getImpactRadius(node.id, 2);
console.log(\`\nAffected nodes: \${impact.nodes.size}\`);
console.log(\`Affected edges: \${impact.edges.length}\`);
// Group by file
const files = new Map();
for (const n of impact.nodes.values()) {
if (!files.has(n.filePath)) files.set(n.filePath, []);
files.get(n.filePath).push(n.name);
}
console.log(\`Affected files: \${files.size}\`);
for (const [file, nodes] of [...files.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 10)) {
console.log(\` \${file}: \${nodes.slice(0, 5).join(', ')}\`);
}
await cg.close();
}
test().catch(console.error);
"
```
**What to check:**
- Does changing a core interface/class show a wide impact radius?
- Are the affected files reasonable (things that import/extend/use it)?
- Is the impact radius non-empty? Zero impact on a core type means edges are missing.
---
### Test 5: Edge Extraction Quality
Directly verify that the major edge types are being extracted for this language.
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
// Check overall edge distribution
console.log('=== Edge distribution ===');
// (Use sqlite3 query from sanity check above)
// Find a class that extends another
const classes = cg.searchNodes('', { limit: 100, kinds: ['class'] });
let foundExtends = false, foundImplements = false;
for (const r of classes) {
const callees = cg.getCallees(r.node.id);
// getCallees returns all outgoing edges, check for extends/implements
// Better: use graph traversal
}
// Verify specific relationship types exist
const checks = [
{ desc: 'contains edges (class → method)', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"contains\"' },
{ desc: 'calls edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"calls\"' },
{ desc: 'imports edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"imports\"' },
{ desc: 'extends edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"extends\"' },
{ desc: 'implements edges', query: 'SELECT COUNT(*) FROM edges WHERE kind = \"implements\"' },
];
// Run these via sqlite3 (shown in sanity check section)
await cg.close();
}
test().catch(console.error);
"
```
```bash
sqlite3 <codebase_path>/.codegraph/codegraph.db "
SELECT kind, COUNT(*) as cnt FROM edges GROUP BY kind ORDER BY cnt DESC;
"
```
**What to check:**
- `contains` should be the most common (structural hierarchy).
- `calls` should be plentiful — if near zero, call extraction is broken for this language.
- `imports` should exist — if zero, import parsing is broken.
- `extends` and `implements` should exist if the language has inheritance — if zero, `extractInheritance()` may not handle this language's AST.
---
### Test 6: Node Extraction Completeness
Verify all expected node kinds are being extracted.
```bash
sqlite3 <codebase_path>/.codegraph/codegraph.db "
SELECT kind, COUNT(*) as cnt FROM nodes GROUP BY kind ORDER BY cnt DESC;
"
```
**What to check for each language:**
| Node Kind | Expected? | Notes |
|-----------|-----------|-------|
| `file` | Always | One per source file |
| `class` | If language has classes | |
| `method` | If language has methods | Should include owner type in `qualified_name` |
| `function` | If language has top-level functions | |
| `interface` | If language has interfaces/protocols | |
| `enum` | If language has enums | |
| `enum_member` | If language has enums | Values inside enums |
| `import` | Always | One per import statement |
| `variable` / `field` | Usually | Fields, constants, top-level vars |
| `struct` | If language has structs | Go, Rust, C, Swift |
| `trait` | If language has traits | Rust |
If an expected node kind has 0 count, the language extractor is missing that AST type.
---
### Test 7: Real-World LLM Prompts
This is the final and most important test. Simulate the kinds of questions a developer would actually ask an LLM that's using CodeGraph. For each prompt, run `findRelevantContext` (which powers `codegraph_explore`) and evaluate whether the returned context would let an LLM give a correct, complete answer.
**Run at least 5 of these prompt styles, adapted to the actual codebase:**
```bash
node -e "
const { CodeGraph } = require('./dist/index.js');
async function test() {
const cg = await CodeGraph.open('<codebase_path>');
const prompts = [
// 1. \"How does X work?\" — subsystem understanding
'How does the cache eviction policy work?',
// 2. \"Where is X implemented?\" — symbol location
'Where is the LRU eviction logic implemented?',
// 3. \"What calls X?\" — usage discovery
'What code triggers cache invalidation?',
// 4. \"I want to change X, what breaks?\" — impact assessment
'If I change the Cache interface, what else is affected?',
// 5. \"How do X and Y interact?\" — cross-component relationships
'How does CacheBuilder connect to LocalCache?',
// 6. \"Show me the flow from A to B\" — data/control flow
'What happens when a cache entry expires?',
// 7. \"What are all the implementations of X?\" — polymorphism
'What classes implement the Cache interface?',
// 8. Bug investigation prompt
'Cache entries are not being evicted when they should be — where should I look?',
];
for (const prompt of prompts) {
console.log(\`\n========================================\`);
console.log(\`PROMPT: \${prompt}\`);
console.log(\`========================================\`);
const subgraph = await cg.findRelevantContext(prompt, {
searchLimit: 8, traversalDepth: 3, maxNodes: 80, minScore: 0.2,
});
console.log(\`Result: \${subgraph.nodes.size} nodes, \${subgraph.edges.length} edges, \${subgraph.roots.length} entry points\`);
console.log('Entry points:');
for (const rootId of subgraph.roots.slice(0, 5)) {
const node = subgraph.nodes.get(rootId);
if (node) console.log(\` \${node.name} (\${node.kind}) — \${node.filePath}:\${node.startLine}\`);
}
const fileGroups = new Map();
for (const node of subgraph.nodes.values()) {
if (!fileGroups.has(node.filePath)) fileGroups.set(node.filePath, []);
fileGroups.get(node.filePath).push(node.name);
}
console.log('Top files:');
for (const [file, nodes] of [...fileGroups.entries()].sort((a,b) => b[1].length - a[1].length).slice(0, 5)) {
console.log(\` \${file} (\${nodes.length}): \${nodes.slice(0, 5).join(', ')}\`);
}
// PASS/FAIL judgment
const hasEntryPoints = subgraph.roots.length > 0;
const hasEdges = subgraph.edges.length > 0;
const hasMultipleFiles = fileGroups.size > 1;
console.log(\`\\nVERDICT: \${hasEntryPoints && hasEdges && hasMultipleFiles ? 'PASS' : 'FAIL — needs investigation'}\`);
}
await cg.close();
}
test().catch(console.error);
"
```
**What to check for each prompt:**
- Does it return entry points? Zero entry points = total failure.
- Are the entry points **relevant** to the question? (Not just random symbols that happen to share a word.)
- Does it span multiple files? Most real questions involve cross-file understanding.
- Are relationships present? An LLM needs to understand how symbols connect, not just a list of names.
- Would **you** be able to answer the question from this context?
---
## Diagnosing Failures
| Symptom | Likely Cause | Where to Fix |
|---------|-------------|--------------|
| Method missing owner type in `qualified_name` | Language needs `getReceiverType` | `src/extraction/languages/<lang>.ts` |
| `codegraph_explore` returns irrelevant files | Common names flooding FTS; co-location boost not helping | `src/db/queries.ts: findNodesByExactName`, `src/context/index.ts` |
| Zero `calls` edges | `callTypes` missing or wrong AST node type | `src/extraction/languages/<lang>.ts: callTypes` |
| Zero `extends`/`implements` edges | `extractInheritance()` doesn't handle this language's AST | `src/extraction/tree-sitter.ts: extractInheritance()` |
| Missing node kinds (no enums, no interfaces) | AST type not listed in extractor | `src/extraction/languages/<lang>.ts: enumTypes`, `interfaceTypes`, etc. |
| Search term dropped from query | Term is in the stop words list | `src/search/query-utils.ts: STOP_WORDS` |
| `qualified_name` missing class for nested methods | Extraction not walking parent stack correctly | `src/extraction/tree-sitter.ts: visitNode()` |
| Import edges missing | `extractImport` returns null for this syntax | `src/extraction/languages/<lang>.ts: extractImport` |
| C++ classes/structs/enums missing from macro namespaces | Macros like `NLOHMANN_JSON_NAMESPACE_BEGIN` cause tree-sitter to misparse namespace blocks as `function_definition` | `src/extraction/languages/c-cpp.ts: isMisparsedFunction` filters bad names; `src/extraction/tree-sitter.ts: visitFunctionBody` extracts structural nodes |
| C++ classes missing from `.h` headers | `.h` files default to `c` language which has `classTypes: []` | `src/extraction/grammars.ts: looksLikeCpp()` — content-based heuristic promotes `.h` files to `cpp` when C++ patterns detected |
| Ruby methods inside modules missing owner in `qualified_name` | Ruby `module` AST nodes not being extracted | `src/extraction/languages/ruby.ts: visitNode` hook extracts modules; `src/extraction/tree-sitter.ts: isInsideClassLikeNode` includes `module` kind |
| TypeScript abstract classes missing | `abstract_class_declaration` not in `classTypes` | `src/extraction/languages/typescript.ts: classTypes` — add `abstract_class_declaration` |
| Single-expression arrow functions silently dropped | `extractName` finds identifier in expression body instead of returning `<anonymous>` | `src/extraction/tree-sitter.ts: extractName` — skip identifier search for `arrow_function`/`function_expression` nodes |
| Kotlin interfaces/enums extracted as classes | `class_declaration` matches `classTypes` first; `interfaceTypes`/`enumTypes` never fire | `src/extraction/languages/kotlin.ts: classifyClassNode` detects `interface`/`enum` keywords in AST children |
| Kotlin functions have zero calls extracted | Tree-sitter grammar doesn't use field names, so `getChildByField(node, 'function_body')` returns null | `src/extraction/languages/kotlin.ts: resolveBody` finds body by type (`function_body`, `class_body`, `enum_class_body`) |
| Kotlin `navigation_expression` calls not resolved cleanly | `navigation_expression` fell through to `getNodeText` producing messy names with parentheses | `src/extraction/tree-sitter.ts: extractCall` — handle `navigation_expression` by extracting method name from `navigation_suffix > simple_identifier` |
| Kotlin `fun interface` declarations invisible | Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+), producing ERROR or misparse as `function_declaration` | `src/extraction/languages/kotlin.ts: visitNode` detects three misparse patterns: (1) ERROR node + lambda body, (2) function_declaration with `user_type("interface")` direct child + name in ERROR child, (3) function_declaration with ERROR child containing `user_type("interface")` + name. `isFunInterfaceNode` checks both direct and ERROR-nested `user_type` children |
| Kotlin class/interface methods missing when nested `fun interface` present | Tree-sitter misparsed parent body as ERROR (starting with `{`) + class_body (nested interface body); `resolveBody` found wrong body | `src/extraction/languages/kotlin.ts: resolveBody` prefers ERROR bodies starting with `{`; `visitNode` excludes body-like ERROR from `fun interface` detection |
| Svelte `$props()` destructuring produces ugly variable names | `let { x, y } = $props()` has `object_pattern` as variable name node; `getNodeText` returns full pattern | `src/extraction/tree-sitter.ts: extractVariable` skips `object_pattern`/`array_pattern` named declarators |
| Svelte template function calls invisible (e.g. `class={cn(...)}`) | SvelteExtractor only parsed `<script>` blocks, missing calls in template markup | `src/extraction/svelte-extractor.ts: extractTemplateCalls` scans `{expression}` blocks in template for call patterns |
| Svelte `$state`/`$derived` rune calls creating noise | Runes are compiler builtins, not real function calls | `src/extraction/svelte-extractor.ts` filters `SVELTE_RUNES` set from unresolved references |
| Object literal getters/setters extracted as standalone functions | `method_definition` inside `object` literals treated same as class methods | `src/extraction/tree-sitter.ts: extractMethod` skips `method_definition` nodes whose parent is `object`/`object_expression` |
| JavaScript `class extends` produces zero inheritance edges | JS tree-sitter uses `class_heritage → identifier` (bare), not `class_heritage → extends_clause → identifier` like TypeScript | `src/extraction/tree-sitter.ts: extractInheritance` — handle bare `identifier`/`type_identifier` children when parent is `class_heritage` |
| PHP traits extracted as classes | `trait_declaration` in `classTypes` but `extractClass` hardcodes `class` kind | `src/extraction/languages/php.ts: classifyClassNode` returns `'trait'` for `trait_declaration`; `src/extraction/tree-sitter-types.ts` adds `'trait'` to return type |
| PHP class properties missing (0 field nodes) | `extractField` looks for `variable_declarator` children; PHP uses `property_element > variable_name > name` | `src/extraction/tree-sitter.ts: extractField` — handle `property_element` children with `variable_name > name` path |
| PHP class constants skipped inside classes | `variableTypes` check has `!isInsideClassLikeNode()` guard, so `const_declaration` inside classes falls through | `src/extraction/languages/php.ts: visitNode` hook catches `const_declaration`, extracts `const_element > name` as `constant` kind |
| PHP `use TraitName` inside classes invisible | `use_declaration` nodes in class body not processed for edges | `src/extraction/languages/php.ts: visitNode` hook extracts trait names from `use_declaration` and creates `implements` unresolved references |
## After Fixing Issues
```bash
npm run build
rm -rf <codebase_path>/.codegraph
node dist/bin/codegraph.js init -iv <codebase_path>
# Re-run the failing tests from above
```
Always run the full test suite before marking a language as verified:
```bash
npm test
```
## Adding `getReceiverType`
**Only needed for languages where methods are top-level or outside their owner type in the AST.** If the language nests methods inside class/struct bodies (Python, Java, TypeScript, C#), the qualified name already includes the parent — verify with the sanity check before adding anything.
### 1. Add the hook to the language extractor
In `src/extraction/languages/<lang>.ts`, add `getReceiverType` to the extractor object:
```typescript
getReceiverType: (node, source) => {
// Extract the owner type name from the method's AST node.
// Return the type name string, or undefined if not applicable.
//
// The core extractMethod() in tree-sitter.ts will use this to set:
// qualifiedName = `${filePath}::${receiverType}::${methodName}`
},
```
### 2. Reference: Go implementation
```typescript
// src/extraction/languages/go.ts
getReceiverType: (node, source) => {
const receiver = getChildByField(node, 'receiver');
if (!receiver) return undefined;
const text = getNodeText(receiver, source);
const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
return match?.[1];
},
```
### 3. Where it's consumed
`src/extraction/tree-sitter.ts` in `extractMethod()`:
```typescript
const receiverType = this.extractor.getReceiverType?.(node, this.source);
if (receiverType) {
extraProps.qualifiedName = `${this.filePath}::${receiverType}::${name}`;
}
```
## Key Files
| File | Role |
|------|------|
| `src/extraction/languages/<lang>.ts` | Language extractor — node types, call types, `getReceiverType` |
| `src/extraction/tree-sitter.ts` | Core extraction — `extractMethod()`, `extractCall()`, `extractInheritance()` |
| `src/extraction/tree-sitter-types.ts` | `LanguageExtractor` interface definition |
| `src/search/query-utils.ts` | `STOP_WORDS`, `extractSearchTerms`, `scorePathRelevance` |
| `src/db/queries.ts` | `searchNodesFTS` (BM25), `findNodesByExactName` (co-location boost) |
| `src/context/index.ts` | `findRelevantContext` — hybrid search + graph traversal |
| `src/mcp/tools.ts` | MCP tool handlers — `codegraph_explore` implementation |
## Language Status
### Verified
- [x] **Go**`getReceiverType` extracts receiver from `func (sl *Type) method()`
- [x] **Swift** — NOT needed. Tree-sitter nests methods inside class/extension bodies
- [x] **Java** — NOT needed. Methods nested in class body. Verified against Guava
- [x] **Python** — NOT needed. Methods nested in class body. Verified against Flask
- [x] **Rust**`getReceiverType` walks up to parent `impl_item` to extract type name. Also adds `contains` edges from struct to impl methods. Verified against Deno
- [x] **C** — NOT needed. No methods in C. Strong function/struct/enum extraction with excellent call edge density. Verified against Redis
- [x] **C++** — NOT needed for header-only libs. `isMisparsedFunction` hook filters macro-caused misparse artifacts (e.g. `NLOHMANN_JSON_NAMESPACE_BEGIN`). `visitFunctionBody` now extracts structural nodes (classes/structs/enums) inside macro-confused "function" bodies. Content-based `.h` detection (`looksLikeCpp` in `grammars.ts`) promotes C++ headers to `cpp` language so classes in `.h` files are extracted. Verified against nlohmann/json and gRPC. Note: out-of-class `Type::method()` definitions would need `getReceiverType` but are uncommon in header-only codebases.
- [x] **C#** — NOT needed. Methods nested in class body. Added `base_list` handling in `extractInheritance` for C#'s `: Parent, IInterface` syntax. Added `propertyTypes` support for C# `property_declaration` nodes. Fixed `extractField` to handle C#'s nested `variable_declaration > variable_declarator` structure. Verified against Jellyfin
- [x] **Ruby** — NOT needed for `getReceiverType`. Methods nested in class body. Added `visitNode` hook to extract Ruby `module` nodes (concerns, namespaces) with proper containment and qualified names. Methods inside modules get `Module::method` qualified names. Also wired up the `ExtractorContext` with `pushScope`/`popScope` for language hooks. Verified against Discourse
- [x] **TypeScript** — NOT needed for `getReceiverType`. Methods nested in class body. Added `abstract_class_declaration` to `classTypes` so abstract classes are properly extracted. Fixed single-expression arrow function extraction (`const fn = () => expr` was silently dropped because `extractName` picked up the body identifier instead of returning `<anonymous>` for parent name resolution). Verified against Grafana
- [x] **Dart** — NOT needed for `getReceiverType`. Methods nested in class body. Added bare call extraction for selector-based method calls (e.g. `object.method()`). Verified against Flutter
- [x] **Kotlin**`getReceiverType` extracts receiver from extension functions `fun Type.method()`. Added `classifyClassNode` to distinguish interfaces/enums from classes (all use `class_declaration` AST node). Added `resolveBody` hook since Kotlin's tree-sitter grammar doesn't use field names. Added `navigation_expression` handling for method call extraction. Added `object_declaration` via `extraClassNodeTypes`. Added `delegation_specifier` handling in `extractInheritance` for Kotlin's `: Parent, Interface` syntax. Also fixed `extractInterface` to visit body children (interface methods were not being extracted). Added `visitNode` hook to handle `fun interface` (SAM) declarations — tree-sitter-kotlin doesn't support this Kotlin 1.4+ syntax, producing ERROR or function_declaration misparse; the hook detects both patterns and extracts the interface. Verified against Koin, LeakCanary
- [x] **Svelte** — Custom `SvelteExtractor` delegates `<script>` blocks to TS/JS parser; creates `component` nodes for each `.svelte` file. Added template expression call extraction: scans `{expression}` blocks in markup for function calls (e.g. `class={cn(...)}`), creating call edges from component to callees — increased Svelte call edges from 29 to 387. Filtered Svelte 5 rune calls (`$state`, `$props`, `$derived`, `$effect`, `$bindable`). Also fixed: destructured `$props()` patterns (e.g. `let { x, y } = $props()`) no longer extracted as ugly multi-line variable names (skip `object_pattern`/`array_pattern` in `extractVariable`). Object literal getter/setter methods no longer extracted as standalone functions. Verified against shadcn-svelte
- [x] **PHP** — NOT needed for `getReceiverType`. Methods nested in class body. Added `classifyClassNode` to distinguish traits from classes (`trait_declaration``trait` kind). Added `'trait'` to `classifyClassNode` return type in `tree-sitter-types.ts` and handling in visitor. Fixed PHP property extraction: `extractField` now handles `property_element > variable_name > name` AST structure (added 4,366 field nodes). Added `visitNode` hook for class constants (`const_declaration` inside classes was skipped by `variableTypes` guard) and trait `use` declarations (`use HasFactory, SoftDeletes;` creates `implements` edges — increased from 636 to 1,514). Verified against Laravel
### Needs Verification
(none currently)
@@ -0,0 +1,88 @@
# Answer directly vs. delegate to an Explore agent (interactive A/B)
**Question:** Does answering a "how does X work?" question *directly* with CodeGraph in the
main session bloat main-session context — and would Claude Code be better off delegating that
exploration to a disposable **Explore agent** (which keeps main context lean by absorbing the
file reads in a sub-transcript)? And critically: **does the answer change at scale**, on a
codebase far larger than Excalidraw?
**Short answer:** No. With CodeGraph, main-session context is roughly **scale-invariant (~50k)**
because the retrieval is targeted and the `explore` payload is budget-capped — it does not
balloon on a 16× larger repo. Answering directly wins at **every** scale: same-or-leaner main
context than the delegation path, **zero file reads**, and ~28% fewer tokens. The
delegation-for-hygiene advantage stays marginal even on a large codebase.
## Methodology
- **Harness:** interactive Claude Code TUI driven via `scripts/agent-eval/itrun.sh` (tmux),
**not** headless `claude -p`. This matters: headless spawns **0** Explore agents, so it cannot
measure delegation behavior at all; only the interactive TUI does.
- **Arms:** `WITH` = CodeGraph in the MCP config; `WITHOUT` = empty MCP config (`--strict-mcp-config`).
- **Model:** `opus`. **n = 3 runs per arm.** Main **and** sub-agent transcripts parsed
(`scripts/agent-eval/parse-session.mjs`); reads/bash are summed across main + sub-agents.
- **Repos:** Excalidraw (643 files, medium) and VS Code (~10.7k files, large — ~16× Excalidraw).
- **Build:** 0.9.4. **Date:** 2026-05-24.
- "main-session context" is the TUI's reported `Context X/Y` for the *main* thread (sub-agent
context does not count against it). "billable tokens" = summed per-turn assistant usage
(input + output + cache read + cache creation).
## Excalidraw (643 files, medium)
Question: *"How does Excalidraw render and update canvas elements?"*
| metric | WITH codegraph | WITHOUT |
|---|---|---|
| Explore agents spawned | 0 / 0 / 0 | 0 / 1 / 1 (delegated 2 of 3) |
| main-session context | 51k / 49k / 50k (~50k) | 48k / 34k / 26k (~36k) |
| total tool calls | 4 / 4 / 4 | 16 / 55 / 37 |
| Reads (main+sub) | 0 / 0 / 0 | 6 / 25 / 16 |
| billable tokens | ~127k | ~175k |
## VS Code (~10.7k files, large — ~16× Excalidraw)
Question: *"How does the extension host communicate with the main process?"*
| metric | WITH codegraph | WITHOUT |
|---|---|---|
| main-session context | 47k / 43k / 50k (~47k) | 54k / 29k / 31k (~38k) |
| Explore agents | 0 / 0 / 0 | 0 / 1 / 1 (delegated 2/3) |
| codegraph calls | ~8 (search + explore×23 + context) | 0 |
| Reads (main+sub) | 0 / 1 / 0 | 6 / 26 / 19 |
| billable tokens | ~126k | ~176k |
## Findings
**Main-session context is scale-invariant with CodeGraph.** With codegraph, main-session
context was **~47k on VS Code — essentially identical to Excalidraw's ~50k**, despite a 16×
bigger repo. It didn't balloon. Reason: codegraph's `explore` payload is **budget-capped** and
retrieval is **targeted** — answering one question pulls in the relevant *flow/area*, not more
just because the repo is huge. So codegraph makes main-session context roughly scale-invariant
(~50k). The delegation-for-hygiene advantage stays marginal even on a large codebase — exactly
the opposite of "it gets significant at scale."
The thing that *would* balloon at scale is reading many big files directly into main — and
Claude Code avoids that **without** codegraph by delegating to an Explore agent (2931k main),
but at the cost of **1726 reads** and ~28% more tokens. CodeGraph keeps main lean a *better*
way: a capped, targeted payload — no delegation, **0 reads**.
**On "the Explore agents use codegraph."** I couldn't reproduce it: across **6/6**
with-codegraph runs (both repos), Claude Code **never delegated** — it answered directly every
time. The Explore-agent path only appeared in the `without` arm (using grep/read, since codegraph
wasn't in that config). So with the current instructions + codegraph present, Claude Code stays
in the main session — the lean-main-via-Explore-agent best case simply isn't what happens;
lean-main-via-capped-codegraph is, and it's cheaper.
## Verdict
**"Answer directly with codegraph" wins for Claude Code too — at every scale.** No per-agent
split is needed; the unified "answer directly" instruction is right for Claude Code *and* for
Codex / Cursor / opencode (which have no Explore-agent mechanism and would otherwise read files
directly). This conclusion drove updating the README's `## CodeGraph` example block, which
previously told agents to "NEVER call `codegraph_explore` directly / ALWAYS spawn an Explore
agent" — i.e., it steered Claude Code toward the *worse* (1726 read, ~28%-more-token) path.
**Caveat / future work (not a blocker):** an Explore agent that *itself uses codegraph* could in
principle get lean-main *and* low-work. But the "answer directly" instruction prevents delegation
in practice (0 delegations observed across 6 runs), the main-context gain would be marginal
(~50k → ~30k, both a few percent of a 1M window), and it adds a sub-agent round-trip. Worth a
future experiment, not a default.
+426
View File
@@ -0,0 +1,426 @@
# Call-sequence analysis — why read savings don't convert to wall-clock
**Date:** 2026-05-23 · **Branch:** `architectural-improvements` · **Source data:** the surviving
stream-json logs from the A/B matrix (`/tmp/ab-matrix/<Cell>/run-headless-{with,without}.jsonl`,
37 cells × 2 arms). Re-mined — **no re-runs** — with `scripts/agent-eval/seq-matrix.mjs`.
## Why this exists
The [A/B matrix](codegraph-ab-matrix.md) showed codegraph cuts **reads 75%** but **wall-clock only
~16%**, and 63% of the wall-clock win comes from just 3 large-repo cells. Reads are at the floor
(~0), so the remaining wall-clock is **round-trips + the synthesis turn** — neither of which read
count can explain. The matrix records tool *counts*, not the call **sequence** or per-call
**payload size**. This analysis recovers both, to find where the wall-clock actually goes.
## TL;DR — the bottleneck is trace ADOPTION, not trace completeness
1. **Trace is called in 3 of 37 cells** — even though every question is a canonical flow question
("trace the controller → service → repository", "how does X reach Y"). The agent overwhelmingly
reaches for **`context → search → search → explore`** instead — the exact path-reconstruction
anti-pattern the instructions tell it to avoid.
2. **`explore` averages 17.9K chars/call; `trace` averages 0.8K** — a **22× payload difference**.
The path-scoped tool that solves the small-repo-bloat problem exists and is tiny. It's just not
being invoked.
3. **Small repos still get bloated payloads** because of the explore-default: a **6-file** repo
(`flutter_module_books`) pulls **17.4K**; a 10-file repo pulls 18.0K. This is precisely the
"too much context on small codebases" failure mode — happening right now, via explore.
4. **Round-trips are 25% fewer with codegraph (283 vs 375 turns)** but wall-clock is only 16%
faster — because the with-arm's turns each carry a ~18K explore payload, inflating TTFT and
eroding the turn savings.
5. **Root cause:** `src/mcp/server-instructions.ts` leads with *"answer directly … `codegraph_context`
first, then ONE `codegraph_explore`"* as the headline pattern. The trace-first guidance is buried
in a table + a chain list below it. Agents anchor on the prominent headline → context→explore.
**Decision:** the next experiment is **trace-first steering / adoption**, not enriching trace. We
can't evaluate trace's completeness when it's used 3/37 times. Get adoption up first, then measure
whether the residual `node`/`explore` follow-ups need a richer trace.
## Finding 1 — trace adoption: 3/37
| metric | value |
|---|---|
| flow-question cells | 37 (all of them) |
| cells that called `codegraph_trace` | **3** (`cpp-leveldb`, `excalidraw`, `c-redis`) |
| dominant pattern instead | `context``search`×N → `explore` |
The 3 trace cells, and what followed the trace call:
| repo | files | cg sequence | turns (with/without) |
|---|--:|---|---|
| cpp-leveldb | 134 | `trace, node, node` | 5 / 8 |
| excalidraw | 643 | `context, trace, trace, explore` | 6 / **19** |
| c-redis | 884 | `context, trace, explore, node` | 10 / 15 |
Even when trace *is* used, the agent follows it with `node`/`explore` to fetch bodies — so a
secondary lever (after adoption) is making one trace call self-sufficient enough to kill those
follow-ups. But that's step 2.
## Finding 2 — payload size: path-scoped trace (0.8K) vs breadth-scoped explore (17.9K)
Across all cells, per codegraph tool — call count and **average payload per call**:
| tool | calls | avg/call | total |
|---|--:|--:|--:|
| `explore` | 32 | **17.9K** | 573K |
| `context` | 36 | 4.3K | 156K |
| `search` | 39 | 1.3K | 50K |
| `files` | 5 | 3.4K | 17K |
| `node` | 19 | 2.0K | 38K |
| `trace` | 4 | **0.8K** | 3.4K |
`context` (used in 36/37 cells) is the default opener; `explore` is the default closer. Together
they are the ~22K breadth dump. `trace` — the tool that would replace that with the actual path —
is 22× smaller and barely used. This is the user's premise confirmed in numbers: explore is
breadth-scoped (returns the neighborhood), trace is path-scoped (returns the line).
## Finding 3 — payload grows with repo size, and over-returns on small repos
With-arm **total** codegraph payload by repo-size tier:
| tier | cells | avg total payload | range |
|---|--:|--:|--:|
| S (<200 files) | 19 | 12.7K | 3.031.2K |
| M (<2000) | 9 | 32.4K | 5.458.2K |
| L (≥2000) | 9 | 34.0K | 20.243.1K |
The small-repo waste is concrete — these all have a 23 file flow but pull a full neighborhood:
| repo | files | with-arm payload | sequence |
|---|--:|--:|---|
| flutter_module_books | 6 | 17.4K | `context, explore` |
| computer-database | 10 | 18.0K | `context, search, status, explore` |
| aspnet-realworld | 78 | 22.2K | `context, explore` |
| django-realworld | 44 | 14.8K | `context, explore` |
`explore`'s per-call budget is already adaptive (#185), but it doesn't help here because the agent
isn't choosing the path-scoped tool — it's choosing breadth.
## Finding 4 — round-trips, and the ToolSearch tax
| metric | with | without |
|---|--:|--:|
| total turns (37 cells) | 283 | 375 |
| avg turns / cell | 7.6 | 10.1 |
25% fewer turns, but only ~16% faster wall-clock — the gap is the per-turn cost of the big explore
payloads. Also: **every with-arm run opens with a `ToolSearch` round-trip** (MCP tools are deferred
in this harness), a fixed 1-turn tax before any codegraph call. Worth confirming whether the
production install defers codegraph tools the same way.
## Conclusion → the experiment to run next
Measure-first changed the plan. The hypothesis was "enrich trace so one call is self-sufficient."
The data says trace is **used 3/37 times**, so completeness is moot until adoption is fixed.
**Experiment: trace-first steering A/B.**
- **Change:** rewrite the `server-instructions.ts` headline so a *flow* question (how does X reach Y
/ trace / from→to) routes to `codegraph_trace` **first**, demoting the context→explore pattern to
non-flow/onboarding questions. Mirror into `instructions-template.ts` + `.cursor/rules/codegraph.mdc`.
- **Metric:** trace-adoption rate (target ≫ 3/37), with-arm total payload (expect ↓ sharply,
especially small repos), turns (expect ↓), wall-clock (expect the 16% gap to widen toward the
25% turn gap as 18K explore payloads are replaced by <1K traces).
- **Control:** a non-flow "what's the deal with module X" question must still go context→explore —
don't over-steer everything to trace.
- **Then, step 2:** with adoption up, measure the `node`/`explore` follow-ups after trace
(cpp-leveldb/excalidraw/c-redis all had them). If they're frequent, enrich trace (per-hop body
snippet, capped per hop) so one trace call ends the flow investigation.
## Reproduce
```bash
node scripts/agent-eval/seq-matrix.mjs # regenerates every table above from /tmp/ab-matrix
```
---
# Ablation experiment — do `context`, `explore`, and `trace` compete? Is `trace` enough?
**Date:** 2026-05-23 · 52 runs, ~$20. Tool surface trimmed **server-side** via the new
`CODEGRAPH_MCP_TOOLS` allowlist (so an ablated tool is genuinely absent from ListTools, not
denied-on-call); trace-first steering injected with `--append-system-prompt`. 6 repos (2 S / 2 M /
2 L) × 2 runs; arm E is a **non-flow** survey question on 2 repos. Driver `arms-matrix.sh`,
analysis `parse-arms.mjs`.
| arm | tools | steering | adoption | reads | cgOut | turns | dur |
|---|---|---|--:|--:|--:|--:|--:|
| **A** control | all | none | 2/12 | 1.25 | 28.8K | 7.6 | 38s |
| **B** steer | all | trace-first | **8/12** | 1.00 | **32.0K** | 7.9 | 43s |
| **C** no-explore | hide explore | trace-first | 8/12 | **2.08** | **9.2K** | 9.0 | 44s |
| **D** trace-centric | hide explore+context | trace-first | 8/12 | 2.00 | 6.6K | 10.5 | 46s |
| **E** control-probe | hide explore+context | trace-first | 0/4 | 2.50 | 27.8K | **20.0** | **72s** |
## What it says
1. **Steering works for adoption, not for payload.** B lifted trace use **2/12 → 8/12** (and 4/4 on
the genuinely path-shaped questions — the 2 non-adopters, flutter "what widgets" and vapor "name
the route", aren't from→to questions). But B's payload (32.0K) is *bigger* than control (28.8K)
and it's slightly slower — because the agent calls trace **and still calls explore**. Steering
adds a trace hop without displacing the explore dump.
2. **`explore` is the payload, and it's load-bearing — but 35× too heavy.** Removing it (C) cuts
payload **71%** (32K→9.2K) — confirming it's the bloat. But reads **double** (1.0→2.1) and turns
rise: the agent Reads files to recover the bodies explore had inlined. So explore isn't
redundant; it's the only one-call body-supplier, just delivered with a 32K sledgehammer.
3. **`context` is the most redundant of the three — as a body-supplier.** Removing it on top of
explore (D vs C) left reads flat (2.08→2.00) but raised turns (9.0→10.5). It supplies no unique
bodies; it earns its keep only as a round-trip-saver (the composed orient call).
4. **Removing tools makes flow questions SLOWER, not faster.** Turns climb monotonically
A→D (7.6→10.5) and duration with them — the Read + trace-follow-up round-trips cost more
wall-clock than the saved payload. Leaner payload ≠ faster.
5. **`trace` is definitively NOT sufficient.** The non-flow probe (E) thrashed without the survey
tools — **20 turns, 72s** reconstructing an overview from search/node/files. Survey questions
need a survey tool; trace can't substitute.
## Verdict on the three design questions
- **Do we need all three?** Yes — but for different reasons. trace = flow tool (real, under-adopted).
explore = the one-call body-supplier (load-bearing, over-heavy). context = round-trip-saving
opener (redundant for bodies, useful for orientation).
- **Are they competing?** Yes: explore competes with trace and *wins by default* — even when steered,
the agent traces **and** explores, so the payload win never lands until explore is displaced.
- **Could trace be all we need?** No. E rules it out for non-flow questions; C/D rule it out even
for flow (reads double without explore's bodies).
**Three cheap fixes are now ruled out by data:** "trace is all we need" (false), "just steer to
trace" (B: slower + bigger than control), and "remove explore" (C/D: more reads/turns, slower).
## The fix the data points to → next experiment
The only path that wins: **make `trace` self-sufficient by inlining per-hop bodies** (capped per
hop → still path-scoped) so one trace call supplies what explore does *and* what the Read fallback
recovers — displacing both for flow questions. Keep **one** survey tool (context; demote explore to
deep-survey, not the flow default) for the non-flow class E proved is load-bearing.
- **Experiment:** enriched body-inlining `trace` + steering vs control.
- **Target:** C/D's lean payload (~79K, not 32K) **without** C/D's extra reads/turns, and **beat A
on wall-clock** (the bar B/C/D all failed).
- **Metric:** payload, reads (must stay ≈ A's ~1.0, not rise to 2.0), turns, duration.
## Reproduce (ablation)
```bash
bash scripts/agent-eval/arms-matrix.sh # 52 runs into /tmp/arms (RUNS=2 default)
node scripts/agent-eval/parse-arms.mjs # the arm-comparison tables above
```
---
# Validation — body-inlining trace (arm F)
The ablation pointed to one fix: make `trace` self-sufficient by inlining per-hop **bodies**
(capped per hop → still path-scoped) so one trace call displaces both the explore dump and the
Read fallback. Implemented in `handleTrace` (`sourceRangeAt`, 28 lines / 1200 chars per hop, with a
`… (+N more lines)` marker). Arm **F** = arm B's surface (all tools + trace-first steering) run on
the body-inlining build, so **F vs B isolates the enrichment**.
| arm | adoption | reads | cgOut | turns | dur | cost |
|---|--:|--:|--:|--:|--:|--:|
| A all/none | 2/12 | 1.25 | 28.8K | 7.6 | 38s | $0.390 |
| B all/steer (thin trace) | 8/12 | 1.00 | 32.0K | 7.9 | 43s | $0.411 |
| **F all/steer (body trace)** | 5/12 | **1.17** | **25.1K** | **6.8** | **37s** | **$0.348** |
| C no-explore | 8/12 | 2.08 | 9.2K | 9.0 | 44s | $0.356 |
| D trace-centric | 8/12 | 2.00 | 6.6K | 10.5 | 46s | $0.368 |
**F is the best-balanced arm:** lowest turns (6.8), fastest (37s), cheapest, payload leaner than
A/B — and it hits the target the ablation set: **C/D-class efficiency without C/D's Read penalty**
(F reads 1.17 vs C/D's ~2.0). It gets there not by *removing* a tool but by giving the agent a
complete trace so it *stops early*.
**The win is clearest where trace connects** — excalidraw (the validated 6-hop path):
| arm | sequence | turns | reads | dur |
|---|---|--:|--:|--:|
| B (thin) | `trace → context → explore → Grep → Read` | 7 | 1 | 47s |
| **F (body) r1** | `trace → context` | **4** | **0** | **31s** |
| F (body) r2 | `trace → trace → explore` | 5 | 0 | 42s |
The body-trace ended the investigation in `trace → context` (run 1) — 0 reads, 0 grep, 0 explore.
**Connectivity is the cap.** On flows that break at *unbridged* dynamic dispatch — aspnet-realworld
(MediatR `_mediator.Send → Handle`), vapor-spi (closure routing) — trace returns "no path" and the
agent falls back to explore, so F ≈ B (no regression, no gain). F's aggregate lift is therefore
**gated by dynamic-dispatch coverage**: the more flows the graph connects end-to-end, the more often
the self-sufficient trace fires. (n=2/arm — adoption and per-repo numbers are noisy; excalidraw and
spring-halo, the connecting repos, are 2/2 trace in both B and F.)
## Verdict & ship list
1. **Ship the body-inlining trace** — strict improvement (best-balanced arm; clean 0-read/4-turn win
on connecting traces; no regression on non-connecting ones).
2. **Strengthen the steering.** Arm A (shipped server-instructions, which *already* say "trace first
for flow") adopted trace only 2/12 — the guidance is too buried. The explicit
`--append-system-prompt` used in BF lifted it. Port that into `server-instructions.ts` +
`instructions-template.ts` + `.cursor/rules/codegraph.mdc` (house rule: all three together),
flow-gated so non-flow survey questions still go context/explore (arm E proved they must).
3. **Next frontier to widen F's reach:** bridge more dynamic dispatch (MediatR/.NET, Vapor routing) —
every newly-connected flow converts an F≈B repo into an F-win repo.
## Reproduce (arm F)
```bash
bash scripts/agent-eval/arms-F.sh # 12 runs (RUNS=2); needs the body-inlining build
node scripts/agent-eval/parse-arms.mjs # F appears alongside A/B/C/D/E
```
---
# Steering port — the negative result (arm G)
F's win used `--append-system-prompt`, which real users don't get. Arm **G** = arm A's invocation
(NO append-prompt) on a build where the steering was ported into the production channels
(`server-instructions.ts` + the `context`/`trace` tool descriptions + `instructions-template.ts` +
`.cursor/rules`). Three wording iterations, 12 runs each:
| arm | adoption | reads | payload | turns | dur |
|---|--:|--:|--:|--:|--:|
| A (shipped instructions) | 2/12 | 1.25 | 28.8K | 7.6 | **38s** |
| F (body-trace + append-prompt) | 5/12 | **1.17** | 25.1K | 6.8 | **37s** |
| G v1 — anti-explore wording | 6/12 | 2.08 | 13.8K | 8.8 | 46s |
| G v2 — restore explore as fallback | 6/12 | 1.67 | 22.0K | 7.8 | 46s |
| G v3 — restore context as opener | 6/12 | 2.08 | 11.7K | 8.9 | 46s |
**Production-instruction steering does not reproduce F, and regresses the A baseline.** All three G
variants pin at **~46s** (slower than A's 38s and F's 37s) with reads at 1.72.1 (vs A 1.25, F 1.17).
Wording only shuffled the slack between Read and explore — v1 suppressed explore → Read; v2/v3
restored explore → over-investigation — never landing F's lean `trace → context`.
**Two root causes:**
1. **Salience.** The same trace-first wording works as a top-of-prompt `--append-system-prompt` (F)
but not as an MCP `initialize` instruction / tool description (G). An MCP server has no
higher-salience channel — this is an architectural limit, not a wording bug.
2. **Forcing trace-first backfires where trace doesn't connect.** Steering pushed trace onto
MediatR (`_mediator.Send`) and Spring interface-DI (`@Autowired` iface → impl) flows, where trace
returns no-path; the forced trace is then a wasted round-trip *before* the fallback → slower.
The **unsteered** agent (A) is better-calibrated: it traces only when trace will obviously
connect (2/12) and explores otherwise.
## Arm H — body-trace alone (the ship candidate) regresses
The clean ship test: body-inlining trace + ORIGINAL instructions + no steering (= A's invocation,
only the trace *tool* changed). H vs A isolates the body-trace feature with nothing else moving.
| arm | adoption | reads | payload | turns | dur |
|---|--:|--:|--:|--:|--:|
| A (no body-trace) | 2/12 | 1.25 | 28.8K | 7.6 | **38s** |
| H (body-trace, no steering) | 3/12 | 1.50 | 29.7K | 8.0 | **45s** |
| F (body-trace + append-prompt) | 5/12 | 1.17 | 25.1K | 6.8 | 37s |
**Body-trace alone does NOT beat A — it mildly regresses** (45s vs 38s). The sequences show why:
unsteered, the agent treats trace as just one more call in its usual loop — excalidraw H was
`context → trace → explore → node×3 → Grep → Read` (77s) — so the bigger body-trace payload is pure
added cost, not offset by fewer follow-ups. The body-trace only pays off when the agent **leads with
trace and stops after it**, which only the append-prompt (F) achieved.
## Final verdict
The body-inlining trace is a real win (F) but its value is **entirely contingent on
lead-with-and-stop-after-trace steering we cannot deliver through any production MCP channel**
(append-prompt salience ≫ server-instructions / tool-descriptions; G failed three times). On its own
(H) it regresses. So:
- **SHIP: the `CODEGRAPH_MCP_TOOLS` allowlist** — independent, clean, validated.
- **DON'T ship the body-inlining trace or the steering as-is** — measured neutral-to-negative
without a steering channel we don't have.
- **The real lever is connectivity, not steering** — trace earns its keep only when flows connect
end-to-end; dynamic-dispatch synthesizers (MediatR/.NET, Spring interface-DI, Vapor closures) help
the *unsteered* agent, which already traces when trace will connect.
- **One untested lever** to rescue the body-trace: steer via the trace tool's OWN OUTPUT (the
highest-salience channel — the agent reads it fresh, right at the decision point) with a strong
leading "complete flow — answer from this, don't explore" banner. Instructions/descriptions are
too far from the action; the tool result is not. Unproven; the only remaining shot at making the
body-trace pay off in production.
measure-first paid off three times: it killed three cheap fixes in the ablation, stopped a steering
change that would have shipped an ~8s/query regression (G), and stopped shipping the body-trace
itself on a confounded assumption (H showed it needs steering we can't deliver).
## Reproduce (arm G)
```bash
ARM=G bash scripts/agent-eval/arms-F.sh # production-instruction steering, no append-prompt
node scripts/agent-eval/parse-arms.mjs
```
---
# Arm I — sufficiency, not steering (the shippable win)
An LLM stops investigating when its context is *sufficient*, not when it's told to stop. So arm I
makes the trace OUTPUT complete instead of steering — same invocation as H (original instructions,
**no steering**), only the trace tool changed:
1. **Hop bodies no longer clipped** at 28 lines (that clip is why H re-fetched `mutateElement`).
2. **The destination's own callees are inlined** — the "last mile" the agent otherwise explores/Reads
for (excalidraw: `renderStaticScene → _renderStaticScene / renderStaticSceneThrottled`).
| arm | adoption | reads | greps | payload | turns | dur | cost |
|---|--:|--:|--:|--:|--:|--:|--:|
| A baseline | 2/12 | 1.25 | 1.17 | 28.8K | 7.6 | 38s | $0.390 |
| H body-trace alone | 3/12 | 1.50 | 0.42 | 29.7K | 8.0 | 45s | $0.398 |
| **I body-trace + dest callees** | 2/12 | **1.17** | **0.25** | 27.2K | **7.0** | 39s | **$0.359** |
| F body-trace + append-steer | 5/12 | 1.17 | 0.17 | 25.1K | 6.8 | 37s | $0.348 |
**I ≥ A on every axis** (reads, greps, turns, cost down; wall-clock flat) and **≈ F on outcomes with
zero steering** — despite *lower* trace adoption (2/12 vs F's 5/12). The destination-callees fix
turned the body-trace from a net-negative (H, 45s) into a net-positive (I, 39s): one richer trace
call now displaces the explore+node+Read follow-ups it used to trigger. excalidraw I-r2 was
`context → trace → explore`**0 reads, 5 turns**, stopped because the data was present. The residual
reads (I-r1) are the `canvasNonce` data-flow — the def-use frontier the graph deliberately omits.
This confirms the thesis: **completeness stops the agent; steering doesn't.** Every steering arm
(B/F append-prompt, G instructions) was either unshippable or a regression; the sufficiency arm (I)
ships and needs no steering.
## Revised final verdict (supersedes the arm-G/H verdict above)
- **SHIP: body-inlining trace + destination callees** (arm I) — ≥ A on all axes, no steering, no
regression; makes the self-sufficient-trace property real (one trace call answers the flow).
- **SHIP: the `CODEGRAPH_MCP_TOOLS` allowlist** — independent, validated.
- **DON'T ship steering** (instructions or tool descriptions) — three variants regressed; MCP can't
deliver append-prompt salience, and forcing trace where it doesn't connect backfires.
- **Connectivity is the multiplier** — arm I helps most where the trace connects; MediatR/.NET,
Spring interface-DI, and Vapor closures are the next synthesizers, and they help the *unsteered*
agent (which already traces when trace will connect).
## Reproduce (arm I)
```bash
ARM=I bash scripts/agent-eval/arms-F.sh # body-trace + destination callees, no steering
node scripts/agent-eval/parse-arms.mjs
```
---
# Current-build with/without A/B — the 7 README repos (2026-05-24)
Re-ran the published README benchmark on the **current build** (all 7 repos freshly reindexed),
same queries, **median of 4 runs/arm** (headless: codegraph-only MCP vs empty MCP):
| repo | time with→without | tools w→wo | tokens w→wo (saved) | cost w→wo (saved) |
|---|---|--:|--:|--:|
| vscode | 1m10s→2m26s | 8→55 | 601k→2.8M (78%) | $0.60→$0.80 (26%) |
| excalidraw | 48s→2m58s | 3→79 | 344k→3.5M (90%) | $0.43→$0.90 (52%) |
| django | 1m19s→1m38s | 9→19 | 739k→1.2M (36%) | $0.59→$0.67 (12%) |
| tokio | 53s→3m2s | 4→53 | 379k→2.6M (86%) | $0.42→$2.41 (82%) |
| okhttp | 42s→1m1s | 6→11 | 636k→730k (13%) | $0.47→$0.47 (2%) |
| gin | 44s→1m0s | 6→10 | 444k→675k (34%) | $0.37→$0.47 (21%) |
| alamofire | 1m17s→2m27s | 12→69 | 1.0M→2.8M (64%) | $0.61→$1.14 (47%) |
**Average saved: 35% cost · 57% tokens · 46% time · 71% tool calls** — reproduces the published
README headline (35% / 59% / 49% / 70%); the current build holds the benchmark with no regression.
**Cost is lower, not "flat"** (corrects the earlier note). But the **mechanism is volume, not
cache-ability**: codegraph answers in far fewer turns over a much smaller accumulated context, while
the without-arm fans out across many more turns (5579 tool calls on the big repos), each
re-processing a large, growing context. The without-arm's token volume is *mostly* cheap cache-reads,
which is why **token-count savings (57%) look bigger than cost savings (35%)**. Per-repo margin tracks
how hard the without-arm thrashes that run (tokio blew up to $2.41/3m; django thrashed less).
**Measurement gotcha:** `result.usage` in this Claude Code version is the **last turn only**, not
cumulative — using it under-counts tokens badly (an earlier excalidraw cut reported "34% tokens"
off this bug; the real figure is ~90%). Sum **per-turn assistant `usage`** for the true total.
`total_cost_usd` and `duration_ms` are already cumulative/correct.
Reproduce:
```bash
bash scripts/agent-eval/bench-readme.sh # 7 repos × with/without × 4 runs (RUNS=4) → /tmp/ab-readme
node scripts/agent-eval/parse-bench-readme.mjs # medians + % saved (summed per-turn tokens)
```
+125
View File
@@ -0,0 +1,125 @@
# CodeGraph A/B benchmark — with vs without, every language × S/M/L
**Date:** 2026-05-24 · **Branch:** `main` · **codegraph 0.9.4**
A headless agent (Claude Opus, `--permission-mode bypassPermissions`) answers one
**canonical flow question** per repo — twice: **with** the codegraph MCP server, and
**without** any MCP (built-in Read/Grep/Glob/Bash only). Same model, same prompt; codegraph
is the only variable. Each cell was **re-indexed fresh** first (against a `dist/` build of the
current `main` HEAD), so the "with" arm reflects the shipped 0.9.4 resolvers.
## Headline
**Across 37 cells, codegraph cut total file reads from 159 → 38 — 76% fewer.** It never
*increased* reads in any cell (0 regressions). The mechanism: a few sub-millisecond codegraph
calls replace a read-and-grep exploration.
**Cost stays roughly flat — marginally higher on the with-arm here** (summed across the 37
cells: with `$15.4` vs without `$13.8`). On these short single-flow questions the without-arm
resolves in <10 calls and never balloons, so it doesn't reach the regime where codegraph's cost
savings compound, while the with-arm pays fixed MCP overhead (tool definitions in context +
tool-loading) that short tasks don't amortize. The win is **fewer tool calls (189 vs 321, 41%)
+ lower wall-clock** (mean **38s vs 48s**), which is the design target. On harder multi-turn
investigations cost flips to a net saving as the without-arm's accumulated context balloons —
see `docs/benchmarks/call-sequence-analysis.md`.
The gap widens with repo size and flow complexity: on medium/large repos the without-codegraph
arm often **thrashes** — many greps/globs, shell `find`/`grep` (Bash), and occasionally spawning
a **sub-agent** — while the with-codegraph arm answers in 28 calls. On tiny repos (a handful of
files) the two arms tie or codegraph is marginally slower (MCP/index overhead doesn't pay off
when the whole flow fits in one or two files) — but reads still drop.
## How to read the table
- **R / G / Gl / B / Ag** = Read / Grep / Glob / Bash / sub-agent (Task) tool calls.
- **cg-calls** = codegraph MCP calls in the "with" arm (the trade for reads/greps).
- **dur** = wall-clock seconds. **files** = indexed file count (the size proxy).
- **reads saved** = without-reads with-reads.
- One run per arm (a **snapshot** — run-to-run variance is real; treat ±12 reads and ±10s as
noise, look at the pattern across cells). 2-runs/arm headline numbers for several of these flows
live in `docs/design/dynamic-dispatch-coverage-playbook.md` §7.
## Results
| Language | Size | Repo | files | **with** R/G | cg-calls | dur | **without** R/G | dur | reads saved |
|---|---|---|--:|---|--:|--:|---|--:|--:|
| C | L | `c-redis` | 884 | 0R / 2G | 4 | 42s | 5R / 6G | 51s | 5 |
| C# | S | `aspnet-realworld` | 78 | 0R / 0G | 2 | 27s | 5R / 3G / 2Gl | 54s | 5 |
| C# | M | `aspnet-eshop` | 262 | 0R / 1G | 5 | 39s | 9R / 2G / 5Gl | 58s | 9 |
| C# | L | `aspnet-jellyfin` | 2081 | 3R / 0G | 4 | 51s | 17R / 1G / 2Gl / 17B / 1Ag | 212s | 14 |
| C++ | M | `cpp-leveldb` | 134 | 0R / 0G | 3 | 26s | 4R / 2G | 37s | 4 |
| Dart | S | `flutter_module_books` | 6 | 1R / 0G | 2 | 24s | 2R / 0G / 1Gl | 29s | 1 |
| Dart | M | `compass_app` | 212 | 2R / 0G / 1Gl | 2 | 42s | 3R / 0G / 2Gl | 30s | 1 |
| Go | S | `gin-realworld` | 21 | 0R / 0G | 5 | 35s | 4R / 3G / 1Gl | 57s | 4 |
| Go | M | `gin-vueadmin` | 625 | 1R / 1G | 4 | 47s | 3R / 3G / 1Gl | 44s | 2 |
| Go | L | `gin-gitness` | 4438 | 4R / 3G | 4 | 64s | 8R / 7G / 2Gl | 57s | 4 |
| Java | S | `spring-realworld` | 117 | 2R / 0G | 3 | 35s | 8R / 1G / 5B | 57s | 6 |
| Java | M | `spring-mall` | 536 | 1R / 0G | 5 | 39s | 2R / 4G / 2Gl | 49s | 1 |
| Java | L | `spring-halo` | 2444 | 1R / 2G | 8 | 60s | 4R / 1G / 6B | 52s | 3 |
| Kotlin | S | `kotlin-petclinic` | 43 | 0R / 0G | 2 | 37s | 3R / 0G / 1Gl | 23s | 3 |
| Kotlin | M | `Jetcaster` | 166 | 1R / 0G | 3 | 36s | 1R / 0G / 2Gl | 46s | 0 |
| Lua | S | `lualine.nvim` | 123 | 1R / 1G | 4 | 48s | 4R / 0G / 2Gl | 49s | 3 |
| Lua | M | `telescope.nvim` | 84 | 0R / 0G | 1 | 15s | 1R / 0G / 1Gl | 20s | 1 |
| Luau | S | `Knit` | 11 | 0R / 0G | 2 | 30s | 5R / 0G / 2Gl | 37s | 5 |
| PHP | S | `laravel-realworld` | 114 | 1R / 0G | 6 | 40s | 5R / 1G / 3Gl | 39s | 4 |
| PHP | M | `laravel-firefly` | 2047 | 2R / 1G | 4 | 47s | 4R / 5G / 3Gl | 75s | 2 |
| PHP | L | `laravel-bookstack` | 2160 | 1R / 2G | 2 | 41s | 2R / 4G / 1Gl | 50s | 1 |
| Python | S | `django-realworld` | 44 | 2R / 1G | 2 | 47s | 9R / 0G / 1B | 38s | 7 |
| Python | M | `django-wagtail` | 1672 | 2R / 0G | 4 | 45s | 8R / 3G / 3Gl / 1B | 66s | 6 |
| Python | L | `django-saleor` | 4429 | 2R / 2G | 4 | 52s | 4R / 6G / 1Gl | 64s | 2 |
| Ruby | S | `rails-realworld` | 59 | 0R / 0G | 2 | 30s | 3R / 0G / 2B | 33s | 3 |
| Ruby | M | `rails-spree` | 2905 | 2R / 3G / 1Gl | 5 | 43s | 3R / 3G / 2Gl / 1B | 55s | 1 |
| Ruby | L | `rails-forem` | 4658 | 3R / 1G | 3 | 43s | 4R / 2G / 3Gl | 48s | 1 |
| Rust | S | `rust-axum-realworld` | 13 | 0R / 0G | 2 | 21s | 3R / 0G / 1Gl | 38s | 3 |
| Rust | M | `rust-actix-examples` | 176 | 0R / 1G | 3 | 42s | 3R / 0G / 3B | 36s | 3 |
| Rust | L | `rust-cratesio` | 1053 | 1R / 0G | 3 | 22s | 1R / 2G | 18s | 0 |
| Scala | S | `computer-database` | 10 | 1R / 0G | 2 | 27s | 3R / 0G / 1Gl | 25s | 2 |
| Swift | S | `vapor-template` | 14 | 0R / 0G | 2 | 21s | 2R / 0G / 2Gl | 22s | 2 |
| Swift | M | `vapor-steampress` | 100 | 0R / 0G | 5 | 49s | 3R / 1G / 2Gl | 39s | 3 |
| Swift | L | `vapor-spi` | 542 | 1R / 1G | 4 | 27s | 2R / 5G | 34s | 1 |
| TypeScript/JS | S | `express-realworld` | 39 | 1R / 0G | 1 | 25s | 2R / 2G | 19s | 1 |
| TypeScript/JS | M | `excalidraw` | 643 | 1R / 0G | 3 | 55s | 7R / 5G / 3Gl / 1B | 87s | 6 |
| TypeScript/JS | L | `nest-immich` | 2759 | 1R / 0G | 7 | 50s | 3R / 0G / 1Gl | 44s | 2 |
**Totals (37 cells):** with codegraph **38 reads / 22 greps**, without **159 reads / 72 greps**
**76% fewer reads, ~69% fewer greps.** Codegraph never increased reads in any cell, and the
without-arm additionally ran **52 globs + 37 shell `find`/`grep` (Bash) + 1 sub-agent** that the
with-arm (**0 Bash, 0 sub-agents**) never needed. (74 agent runs, $29.18 total.)
## Observations
- **Biggest wins are medium/large backends with a real route→handler→service flow:** aspnet-jellyfin
(3R / 51s vs **17R + 17 Bash + a spawned sub-agent / 212s** — the single most dramatic cell),
aspnet-eshop (0R vs 9R), django-realworld (2R vs 9R), spring-realworld (2R vs 8R + 5 Bash),
django-wagtail (2R vs 8R), excalidraw (1R / 55s vs 7R / 87s), Luau Knit (0R vs 5R), aspnet-realworld
(0R vs 5R), c-redis (0R vs 5R).
- **Without codegraph, large repos make the agent thrash:** it falls back to shell `find`/`grep`
(37 Bash calls across the matrix) and on jellyfin even spawned a sub-agent — exactly the behavior
codegraph is meant to prevent. The with-arm answers those in 28 codegraph calls and used **0 Bash
and 0 sub-agents** anywhere.
- **Tie zone = tiny repos** (Kotlin Jetcaster 1R/1R, Rust cratesio 1R/1R, express 1R/2R, Swift template
0R/2R): the whole flow fits in 12 files, so reading is already cheap; codegraph ties on reads and is
sometimes a few seconds slower (MCP + index overhead — Kotlin petclinic 37s vs 23s, cratesio 22s vs
18s). This matches the design note that codegraph's value scales with repo size.
- **Duration tracks reads on the big repos** (jellyfin 51s vs 212s, excalidraw 55s vs 87s, aspnet-eshop
39s vs 58s, django-wagtail 45s vs 66s) and is noise on small ones; mean wall-clock is 38s with vs 48s
without.
- Some "with" cells still read 24 files (jellyfin, gitness, forem, saleor, django) — the residual is
the documented frontier (anonymous handlers, deep service chains, dynamic finders); codegraph gets the
agent to the right file, then it reads one to confirm a detail.
## Coverage note
All 14 README frameworks and every flow-relevant language are validated (see the playbook). The
sizes here are by indexed file count; a few languages lack a clean third size in the corpus
(Dart/Kotlin = S/M, Scala/Luau = S only, C = L only, C++ = M only) — those cells are omitted rather
than faked.
## Reproduce
Canonical harness: `scripts/agent-eval/run-all.sh <repo> "<question>" headless` (with = codegraph-only
MCP, without = empty MCP), parsed from the stream-json logs. The throwaway matrix driver + parser used
for this table live in `/tmp/ab-matrix/`: `run.sh` (the `lang|size|repo|question` matrix — each cell does
`rm -rf .codegraph && codegraph init -i` then both arms), `parse-matrix.mjs` (cells → this table), and
`compare.mjs` (old-vs-new diff + aggregates). Build `dist/` from the target commit first so the MCP
server loads the code under test (`codegraph` on PATH is `npm link`ed to the dev `dist/`).
+285
View File
@@ -0,0 +1,285 @@
# Design + status: adaptive `codegraph_explore` sizing (sibling skeletonization)
**Status:** Implemented & validated, **default-on**, on branch
`feat/adaptive-explore-sizing` (initial commit `d6d059f`; **refined 2026-05-29**
after a real-agent A/B exposed a read-back regression — see
"Refinement" below). Escape hatch: `CODEGRAPH_ADAPTIVE_EXPLORE=0`.
**Motivation:** make `codegraph_explore` size its output to the *answer* rather
than always filling the budget cap — so a "sibling-heavy" flow (many
interchangeable implementations of one interface) stops costing *more* than
plain grep/read, without starving "diffuse" flows that genuinely need broad
source.
> **Refinement (2026-05-29) — the read-back regression.** The first cut gated
> only on *off-spine + polymorphic-sibling*. A real-agent A/B (not the
> deterministic probe) showed that this skeletonized two files the agent then
> **Read back**, defeating the point: OkHttp's `RealCall` (it implements the
> 9-impl `Lockable` *mixin*, so it tripped the sibling signal even though it's
> the orchestrator) and Django's `compiler.py` (it *defines* `SQLCompiler` and
> co-locates its subclasses). Two conditions fixed it — a file skeletonizes only
> if it is **not spared**, where **spared = the agent NAMED a callable in it**
> (`getResponseWithInterceptorChain`, `SQLCompiler.execute_sql` → keep it full)
> **UNLESS the file DEFINES a ≥3-impl supertype** (a base+subclasses "family"
> file is huge and Read-anyway, so skeletonizing it *frees explore budget* for
> the sibling files the agent would otherwise Read). Result: OkHttp **3%
> costlier → ~10% cheaper** (RealCall full, 0 read-backs); Django **10% costlier
> → ~10% cheaper** (compiler.py skeleton frees ~6.5 KB of the 28 KB budget; half
> the runs answer with 0 reads). The supertype signal was initially used as a
> *spare* — that was backwards and regressed Django to 9% costlier by starving
> its budget; it is now an *override* of the named-callable spare. The
> single-condition history below is kept for context.
> **Further refinement (2026-05-29) — per-symbol focused view + named-cluster
> survival.** Whole-file skeleton/spare was still too coarse on a real Django
> A/B: the agent Read back `compiler.py` (collapsed → its `execute_sql`/`as_sql`
> bodies elided) and `query.py` (a non-sibling god-file whose `_fetch_all` cluster
> got trimmed). Four changes took both repos from ~910% to **~1417% cheaper**
> with **median 0 reads**:
> 1. **Uniqueness-aware spare** — only a (near-)UNIQUE named callable spares a
> file. `as_sql` has **110 defs** across every Compiler/Expression subclass;
> naming it must not keep every backend variant full (it was flooding Django's
> budget). `getResponseWithInterceptorChain` (1 def) still spares RealCall.
> 2. **Per-symbol focused view** — a collapsed family file shows the **full body**
> of on-spine / unique-named / canonical-base-supertype methods and only
> **signatures** for the rest. So `SQLCompiler.execute_sql`/`as_sql` survive
> while the 80 other symbols + redundant subclasses collapse → no Read-back.
> 3. **Test-file exclusion on all tiers** — a test file (`custom_lookups/tests.py`)
> was eating 2.3 KB of Django's 28 KB budget; tests rarely answer an
> architecture question. (Previously only the <500-file tiers excluded them.)
> 4. **Named-cluster survival in non-sibling files** — inject agent-named method
> defs into a file's clusters even when the gather missed them, rank them at
> importance 9, and cap cluster selection at `min(per-file, remaining-total)`
> so high-importance named clusters survive instead of being source-order
> trimmed (Django's `_fetch_all`, L2237, the last of four big files emitted).
> Controls held: OkHttp 14% cheaper / 0 RealCall read-backs; Excalidraw 31%
> cheaper / 0 reads (god-file clustering unaffected — its big file is emitted
> first, so the budget cap never binds it). OkHttp's interceptors stay a pure
> signature skeleton (no named callable in them, don't define a supertype).
---
## TL;DR
`codegraph_explore` returned full source for **every** relevant file up to its
char budget. On a question whose answer spans many *same-shaped* classes — e.g.
"how does OkHttp process a request through its interceptor chain?", which touches
~14 `class … : Interceptor` implementations — that meant ~28 KB of mostly
**redundant full bodies**. Because those bodies ride in the context window for
the rest of the session, the WITH-CodeGraph arm cost *more* than the WITHOUT arm
(which answers the well-named interceptor question in ~10 cheap greps). OkHttp
was the benchmark's cost outlier (3% — i.e. *costlier* than native search).
Fix: when a file is **both (a) off the synthesized flow spine and (b) a
polymorphic sibling**, render it as a **skeleton** (class + member *signatures*,
bodies elided) instead of full source — keeping the on-spine exemplar and the
mechanism in full.
- **OkHttp:** the interceptor-chain flow skeletonizes the 5 redundant
`: Interceptor` impls while keeping `RealInterceptorChain` (the dispatch
mechanism) and `RealCall` (the orchestrator the agent named) full → **~10%
cheaper than native, 0 RealCall read-backs** (see Refinement for the corrected
numbers; the original `28.5k → 16.6k` / "reads 1 vs 3" figures came from a
deterministic probe query, not the agent's real query).
- **Django:** the QuerySet→SQL flow skeletonizes `compiler.py` (a
base+subclasses family file), freeing budget → **~10% cheaper**. (The earlier
claim that Django was "byte-identical / 0 skeletons" was an artifact of the
*probe* query; the agent's real query DOES surface the SQLCompiler family.)
- **Excalidraw / Tokio / VS Code / Gin:** explore output is **byte-identical**
with the flag on/off (0 skeletons) — their flows have no off-spine
≥3-implementer sibling group. The corrected gate only *adds* a spare
condition, so it skeletonizes a **strict subset** of the original gate → these
repos provably stay at 0 skeletons (verified by probe).
---
## The problem in one picture
`handleExplore` gathers relevant files, sorts by relevance, and fills up to
`maxOutputChars` (the "whole-small-file rule" dumps any relevant file ≤220 lines
in full). The budget is a **target**, not a ceiling:
```
OkHttp explore (shipped): RealCall (full) + RealInterceptorChain (full)
+ CallServerInterceptor (full, 8.7k)
+ Bridge/Connect/Cache/… (full, ~4-5k each) ← all ~same shape
= ~28k, most of it redundant interceptor bodies
```
The agent only needs the **mechanism** (`RealInterceptorChain.proceed` iterating
the chain) + the **contract** every interceptor implements + maybe one concrete
example. The other five full bodies are padding — but only *because they're
interchangeable*. On a diffuse question (Excalidraw's render pipeline:
`mutateElement → … → renderStaticScene`), the off-spine files are **distinct
steps**, and their bodies do real work — eliding them just makes the agent
reconstruct them from signatures (more reasoning, net costlier; see "Dead ends").
So the whole game is: **tell "interchangeable sibling" apart from "distinct
step," cheaply.**
## The gate (refined)
A file is skeletonized iff **all** hold (and `CODEGRAPH_ADAPTIVE_EXPLORE != 0`):
1. **A spine exists.** `buildFlowFromNamedSymbols` returns its path node set
(`pathNodeIds`) and the full set of agent-named callables (`namedNodeIds`). If
no spine forms, nothing skeletonizes.
2. **Off the flow spine.** No symbol in the file is on the traced chain — that
chain is the mechanism the agent is walking, always kept full.
3. **A polymorphic sibling.** The file's class `implements`/`extends` a supertype
with **≥ 3 implementers** (`MIN_SIBLINGS`) — the signal that it's one of many
*interchangeable* impls. From real `implements`/`extends` edges, cached.
4. **Not spared.** A file is **spared** (kept full) iff the agent **named a
callable in it** — a named method/function is something the agent asked to
*see* (`getResponseWithInterceptorChain`, `SQLCompiler.execute_sql`), not an
interchangeable leaf — **UNLESS the file itself DEFINES a ≥3-impl supertype**.
That last clause is the override: a base+subclasses "family" file (Django's
`compiler.py`) is huge and Read-anyway, so a full copy just eats explore
budget; skeletonizing it *frees* that budget for the sibling files the agent
would otherwise Read. So: *named ⇒ spare, unless it's a family file ⇒
skeletonize anyway.*
Worked through the two repos:
- **`RealInterceptorChain`** — `proceed` is on the spine → kept full (cond. 2).
- **`RealCall`** — off-spine, and it trips the sibling signal via the **9-impl
`Lockable` mixin** (not because it's an interchangeable interceptor). But the
agent named `getResponseWithInterceptorChain`/`execute`/`enqueue` in it, and it
defines no ≥3-impl supertype → **spared, kept full** (cond. 4). This is the fix
for the read-back: before cond. 4 it skeletonized and the agent Read it back.
- **`BridgeInterceptor` & the other 4** — off-spine, ≥3-impl siblings, named only
by *type*, define no supertype → **skeletonized**. The win.
- **Django `compiler.py`** — off-spine, a sibling (its subclasses extend
`SQLCompiler`), the agent named `execute_sql` in it — *but it defines the
`SQLCompiler` supertype*, so the override fires → **skeletonized** (frees
budget). Sparing it instead (the wrong first attempt) cost MORE and Read MORE.
## Why "shared supertype with ≥3 implementers" is the signal
The thing that makes OkHttp's interceptors interchangeable is precisely that
they're **N implementations of one interface**, invoked polymorphically. That is
a *structural* property the graph records as `implements`/`extends` edges:
```
14 classes ──implements──▶ Interceptor (BridgeInterceptor, CacheInterceptor,
CallServerInterceptor, … )
```
Excalidraw's `renderStaticScene`, `Scene`, `Collab` share **no** common
supertype — the ≥3-implementer query returns nothing for them. So the signal
cleanly separates the two repos, and (validated below) leaves every non-sibling
flow untouched.
The `≥ 3` threshold matters: 1:1 "service interface → single impl" pairs (the
common Spring/Java shape) are **not** siblings and stay full. Only genuine
many-impl families (interceptor chains, strategy/visitor families, codec
registries) trip the gate.
## Skeleton rendering
For a skeletonized file we emit the class + member **signature lines** (not
bodies). Because a symbol node's `startLine` can point at a decorator/annotation
(`@Throws`, `@Override`, `@objc`), we scan forward up to 4 lines for the line
that actually *names* the symbol, so the skeleton shows the real signature:
```
#### …/CallServerInterceptor.kt — CallServerInterceptor, intercept, … · skeleton (signatures only; Read for a full body)
```kotlin
30 object CallServerInterceptor : Interceptor {
32 override fun intercept(chain: Interceptor.Chain): Response {
194 private fun shouldIgnoreAndWaitForRealResponse(code: Int): Boolean =
```
```
The header still lists the file's symbols and says `Read for a full body`, so the
agent can pull one specific implementation if it truly needs it.
## Validation (refined gate)
Headless `claude -p`, Opus 4.8, **WITH vs WITHOUT** CodeGraph (the real benchmark
arm, not the on/off probe the first cut used). Cost = median `total_cost_usd`.
| Repo | WITH→WITHOUT cost | WITH reads | WITHOUT reads | RealCall/compiler read-back |
|---|---|---|---|---|
| **OkHttp** (n=4) | **$0.45 → $0.50** (~10% cheaper) | 2 | 3.5 | **0 / —** (RealCall full) |
| **Django** (n=6) | **$0.56 → $0.63** (~10% cheaper) | 2 | 8.5 | half the runs read 0 |
Both were the README's **cost outliers** (OkHttp 3% costlier, Django 10%
costlier) and both flipped to clear wins. OkHttp WITH was cheaper in all 4 runs;
Django in 5 of 6 (n=6 to see through its high variance). WITHOUT baselines match
the README ($0.50/$0.63 vs $0.57/$0.64), so the gain is the WITH-arm improving.
The **decisive check now passes for the right reason**: with the named-callable
spare, OkHttp's `RealCall` stays full and is **never** Read back (it was Read
back in 3/4 runs before the fix). The inert repos (Excalidraw / Tokio / VS Code /
Gin) stay at **0 skeletons** — verified by probe — because the refined gate
skeletonizes a strict subset of the original. (The first cut's "on vs off, reads
flat 1 vs 3" claim came from a deterministic probe query and did **not** hold for
the agent's real query — that mismatch is what this refinement corrects.)
## Dead ends (don't re-attempt these)
1. **Demote/rank low-value files** (e.g. broaden `isLowValuePath` to drop
`*-testing-support/` fixtures). Improves *content quality* but **not size** —
explore refills the freed budget with other full bodies (28,478 → 28,424).
Ranking ≠ shrinking; you must *skeletonize* to shrink.
2. **Gate on entry-node membership.** A precise symbol-bag explore query *names*
every chain participant, so they're all "entry nodes" — no separation, nothing
skeletonizes.
3. **Rely on interface-impl synthesizer edges** (`synthesizedBy:'interface-impl'`)
for the sibling signal. They were **not** created for OkHttp's `Interceptor`
(a Kotlin `fun interface`), so the signal must come from the real
`implements`/`extends` edges, not synth edges.
4. **A plain "core-floor" gate** (keep first N full, skeletonize the rest) —
skeletonized Excalidraw's *distinct* steps → **+17% cost regression**. The
sibling condition is what makes it safe.
5. **Sparing a file because it DEFINES the supertype** (the first refinement
attempt). Backwards: a base+subclasses *family* file (Django's `compiler.py`,
2,266 lines) is huge and Read-anyway, so keeping it full just **eats the 28 KB
explore budget and starves the sibling files** the agent then Reads — it
regressed Django to **9% costlier** ($0.71). Defining a supertype is instead
an **override** that lets a named family file skeletonize anyway.
6. **Validating skeletonization with the deterministic probe query only.** The
probe (`probe-explore.mjs "<symbol bag>"`) and the *agent's* real explore
query name symbols differently, so they form different spines and skeletonize
different files. The probe said "Django: 0 skeletons / reads flat"; the real
agent query skeletonized `compiler.py` and Read it back. **Always confirm with
a real-agent A/B (`run-all.sh`), not just the probe.**
## Code
- `src/mcp/tools.ts`
- `adaptiveExploreEnabled()` — the flag (default on).
- `buildFlowFromNamedSymbols()` — returns `{ text, pathNodeIds, namedNodeIds }`.
`namedNodeIds` is every callable the agent named (a superset of the spine) —
the named-callable spare reads it.
- `handleExplore()` — two cached helpers: `isPolymorphicSibling()` (a node has
an outgoing `implements`/`extends` to a ≥3-impl supertype) and
`definesPolymorphicSupertype()` (a node HAS ≥3 incoming `implements`/`extends`
— i.e. the file is the family base). The skeleton branch:
`off-spine && isPolymorphicSibling && !(namedInFile && !definesSupertype)`.
- `__tests__/adaptive-explore-sizing.test.ts` — 7 cases incl. the named-callable
spare (RealCall) and the supertype-family override (compiler.py).
## Frontier / future work
- **Per-symbol skeletonization within a family file.** `compiler.py` is
skeletonized whole, so `SQLCompiler.execute_sql` (the base mechanism) becomes a
signature too and *is* Read back in ~half the Django runs. The ideal is to keep
the base class's methods full and elide only the redundant subclass bodies —
shrinking the payload without eliding the answer. Whole-file skeletonization
can't express that yet.
- **Big non-sibling files dominate Django's residual reads.** `query.py` (3,040
lines) and `sql/query.py` are not polymorphic families, so skeletonization
can't touch them; the agent Reads them when the 28 KB clustered view is
insufficient. That's the explore-budget / big-file-clustering frontier, not
skeletonization.
- **Non-interface sibling families** (Go `HandlerFunc` slices, function-pointer
registries) aren't caught — they have no `implements`/`extends` edge. Gin's
middleware chain, for instance, doesn't trip the gate (its handlers are funcs,
not interface impls).
- **Exemplar selection** when *no* interceptor is on the spine: today all siblings
skeletonize and the agent leans on the interface contract; showing one as a
forced exemplar might read slightly better (untested).
+136
View File
@@ -0,0 +1,136 @@
# Getting agents to actually use codegraph (not Read) — design notes & handoff
> Working doc for a fresh session. Two problems to crack:
> **(P1)** agents still reach for `Read`/`grep` during implementation instead of codegraph;
> **(P2)** on startup the codegraph MCP server can be `pending` when the agent's first turn fires, so the agent runs with *no* codegraph at all.
>
> Read `codegraph/CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" first — it's the doctrine these ideas must respect.
---
## Context — what already shipped (so you don't repeat it)
- **#733 (`7175dc4`)** — reframed the agent-facing steering (`src/mcp/server-instructions.ts` + the `codegraph_node`/`codegraph_explore` descriptions in `src/mcp/tools.ts`) to cover *implementation*, not just Q&A; and added **file-view mode**: `codegraph_node` now accepts a bare `file` (no `symbol`) → returns that file's symbol map + its dependents (blast radius) + verbatim bodies (`includeCode`). `handleFileView` in `src/mcp/tools.ts`.
- **Clean A/B result** (new build vs baseline build, both codegraph-connected, same fully-implemented task — `kindExclude` added to `codegraph_search`):
- **baseline:** 0 codegraph calls, 8 Reads (agent *ignored* available codegraph).
- **new:** 2 `codegraph_explore` calls, 5 Reads.
- So the reframe *did* move tool-choice — but the agent used `codegraph_explore`, **never the file-view**, and still Read 5×. n=1/arm.
- **Eval harness fix** (`#735`): nested attach is a *startup-latency* problem, not a hard block. `scripts/agent-eval/ab-new-vs-baseline.sh` now pre-warms a daemon + skips the re-exec; use it (run non-nested for cleanest results).
**Doctrine constraints (from CLAUDE.md — do not relitigate):**
- *Adapt the tool to the agent.* Changing tool descriptions / `server-instructions.ts` is **low-salience** and has *regressed* wall-clock before. Wording alone won't reliably move tool-choice.
- *New tools fare worse than extending an existing one* (the agent under-picks even `trace`; `codegraph_context` was removed).
- The real levers that landed historically: **coverage** (more flows connect statically → `explore` surfaces them) and **sufficiency** (output complete enough that the agent *stops* reading).
- The optimization target is **wall-clock + tool-call count + Read=0**, not token cost (cost is lower as a side effect).
---
## P1 — Agents under-use codegraph during implementation
### STATUS — 2026-06-08 (RESOLVED via Read-parity, not a hook)
**The fix: make `codegraph_node` read a file *exactly like the Read tool*, only
faster — so the agent reaches for it naturally. No forcing.** The owner's steer
settled the direction: *"codegraph should be able to Read just like the Read
tool… make it as good as Read. Read is slow and old; querying the index is fast.
You keep diverging away from using codegraph rather than pursuing the fix."*
**DONE — `handleFileView` (`src/mcp/tools.ts`) is now full Read parity:**
- A `file` with no `symbol` returns the file's current source numbered
**byte-for-byte the way Read does — `<n>\t<line>`, no padding, trailing empty
line kept** (verified by reading the same file with both and diffing). The only
addition is a **one-line blast-radius header** (`used by N files: …`).
- **`offset` / `limit` mean exactly what they do on Read** (1-based start; max
lines; default whole file capped at 2000 lines like Read). Large files paginate
honestly (`(lines XY of N — pass offset/limit…)`), never the 15k `truncateOutput` chop.
- Content is the **default** (no `includeCode` needed); `symbolsOnly: true` returns
the cheap structural map instead. Security preserved: `yaml`/`properties`
summarized by key, never dumped (#383); reads via `validatePathWithinRoot` (#527).
- Tests: `__tests__/node-file-view.test.ts` (9, incl. strict format parity
`^1000\t const v998 = 998;` and unpadded `^1\timport …`). Full suite green
(1270). Descriptions / `server-instructions.ts` / CHANGELOG reframed: "read a
source file with codegraph_node instead of Read — same bytes, faster."
**The hook (idea 1) — A/B'd and REJECTED. Do not ship.** Kept only as an eval
artifact (`scripts/agent-eval/redirect-read-hook.sh` + `ab-hook.sh`).
- Clean A/B (2 runs/arm, devpit "add `dp ping`, build it"; both arms codegraph-attached):
- **nohook:** 0 codegraph calls, 1 Read, **57 tool calls, 68 turns, 5577s.** (Reproduces P1: agent ignores codegraph — but read-once-and-edit is *efficient* here.)
- **hook (deny-redirect):** 0 *successful* Reads + 1 file-view call (parity worked, edit compiled), but **89 tool calls, 910 turns, 200239s**, and the agent **fought the deny**`ToolSearch` to find the tool, reflexive re-Read (denied), then **`Bash python3` to read the file around the block.**
- Verdict: a blanket Read-deny **regresses the target metrics (~2× tool calls, more turns) on a simple edit** and the agent routes around it. Forcing is the wrong lever; making the tool genuinely better than Read is the right one.
- If routing is ever revisited: not a blanket hook. Either a narrow trigger (large
files only / after-N-reads) **with a clean A/B on a Read-heavy multi-file task**
(the hook's best case, untested), or just keep widening coverage + sufficiency.
---
**Symptom:** even with codegraph attached + the new steering, the agent reflexively `Read`s/`grep`s mid-implementation, and never reaches for the file-view. Descriptions can't fix this (low-salience wall).
### Ideas, ranked by expected leverage
1. **PreToolUse(Read/Grep) hook that redirects to codegraph***highest leverage; the only channel that actually changes behavior.*
- Claude Code **hooks** can intercept a tool call and inject context or block it — unlike descriptions, this is *not* low-salience. We already have `scripts/agent-eval/block-read-hook.sh` + `hook-settings.json` (used to force Read=0 in evals).
- Ship a **recommended (opt-in) hook**: on `Read` (or `Grep`) of a path that's *indexed*, inject "this file is indexed — `codegraph_node {file}` returns it + its blast radius for fewer tokens; treat its output as already-Read." Soft nudge (don't hard-block, or it'll frustrate users on configs/docs codegraph doesn't index).
- The installer (`src/installer/targets/claude.ts`) could offer to add this hook (opt-in, like the auto-allow permissions).
- **Validate** with `ab-new-vs-baseline.sh` (Read count, with vs without the hook). This is the experiment most likely to move the needle.
- Open Qs: how to know a path is indexed from inside a hook (query `codegraph files`/`status`, or a fast local check against `.codegraph`); avoiding noise on non-indexed files; per-language false positives.
2. **Sufficiency: make the file-view the obvious Read replacement so the agent *wants* it.**
- The A/B showed the agent never passed a `file` to `codegraph_node`. Why? It doesn't think "Read this file" → "codegraph_node file=X". Investigate: is the file-view's value (symbols + dependents + bodies) actually *better than Read* for the agent's next step (an `Edit`)? It returns bodies — but does it return enough surrounding context to `Edit` confidently? If not, the agent Reads anyway.
- Consider: when the agent *does* Read an indexed file, is there a way to make codegraph's prior `explore`/`node` output have *already* given it what it needed? (i.e. fix the upstream sufficiency, not the Read itself.)
3. **Coverage — the durable lever.** Every flow that connects statically is one the agent doesn't Read to reconstruct. Keep closing dynamic-dispatch gaps (`src/resolution/`). Less about "stop Reading," more about "never need to."
4. **Naming / affordance experiments (low confidence, cheap).** The file-view is buried inside `codegraph_node`. A dedicated, obviously-named affordance might get picked more — *but* "new tools fare worse," so this likely loses. If tried, A/B it; don't assume.
**Recommendation:** prototype **idea 1 (the Read-redirect hook)** and A/B it. It's the one lever with a real chance of moving behavior. Everything else is incremental.
---
## P2 — Agent runs without codegraph because the server is `pending` at startup
**Symptom:** `serve --mcp` isn't ready when the agent's first turn fires (the host marks the MCP server `status:"pending"` / 0 tools), so the agent starts Read/grep and never uses codegraph. We saw this hard in nested evals (~2-3s startup vs the agent's turn-1); **real users hit a milder version** — the first query of a session may not have codegraph.
### Root cause
`serve --mcp` does a `--liftoff-only` **re-exec** (for a node memory flag) **and** spawns/binds a detached **daemon** before tools are usable. Under load that exceeds the host's MCP-startup window. (`CODEGRAPH_WASM_RELAUNCHED=1` skips the re-exec; pre-warming a daemon removes the bind latency — both proven in `ab-new-vs-baseline.sh`. But a real user can't pre-warm.)
### Ideas, ranked
1. **CODEGRAPH-SIDE — expose the static tool list INSTANTLY, decoupled from the daemon. *Biggest shippable win; helps every user.***
- Hypothesis: the host marks codegraph `pending` because `tools/list` (tool exposure) waits on the daemon connect. The local handshake already answers `initialize` fast (~107ms; `runLocalHandshakeProxy` in `src/mcp/proxy.ts`, `getStaticTools` is imported there). **Investigate: does `serve --mcp` answer `tools/list` *locally and instantly* from `getStaticTools`, or does it forward it to the still-connecting daemon?** If the latter, decouple it: advertise the static tools the moment the client asks, mark connected, and resolve the daemon in the background for actual tool *calls*.
- Verify with: `printf '<initialize>\n<initialized>\n<tools/list>\n' | node dist/bin/codegraph.js serve --mcp --path <repo>` and time the `tools/list` response, daemon-mode vs in-process. In-process answered in ~165ms; daemon-mode is the suspect.
- If this lands, `pending`-at-startup largely disappears without any host change.
2. **CODEGRAPH-SIDE — speed/skip the re-exec on the MCP serve path.** The re-exec exists for a V8 memory flag (`src/extraction/wasm-runtime-flags.ts`, `RELAUNCH_GUARD_ENV = CODEGRAPH_WASM_RELAUNCHED`). For MCP serving on a normal repo the flag may be unnecessary, or settable without a full process re-exec. Removing one process spawn from the cold path shaves the startup window.
3. **CODEGRAPH-SIDE — a SessionStart hook that pre-warms the daemon.** Ship an opt-in Claude Code `SessionStart` hook (installer-added) that spawns/warms the daemon for the project at session start, so it's bound before the first query. Mitigation if (1) is hard.
4. **HOST-SIDE — "wait/retry on pending" — this is what you asked about, but it's a Claude Code (MCP client) behavior, not codegraph's to fix.** codegraph can't make the agent retry. Options: (a) raise it with Anthropic as an MCP-client improvement (don't let the agent's first turn proceed until configured MCP servers finish connecting, or retry `pending` servers); (b) note `MCP_TIMEOUT` exists but did **not** help here, because the problem is *tool exposure timing*, not a connection timeout. Frame this as a request, and lean on (1)(3) for what we control.
**Recommendation:** chase **idea 1** (decouple `tools/list` from the daemon). It's the fix that makes codegraph "connected" instantly for everyone. Ship **idea 3** (pre-warm SessionStart hook) as a cheap mitigation in parallel. File the host-side request (4) but don't depend on it.
---
## Key files / pointers
- **Steering / tools:** `src/mcp/server-instructions.ts` (the `initialize` instructions — single source of truth), `src/mcp/tools.ts` (tool descriptions + handlers; `handleNode`/`handleFileView`/`handleSearch`, `getStaticTools`).
- **Startup / daemon / proxy:** `src/mcp/proxy.ts` (`runProxy`, `connectWithHello`, `runLocalHandshakeProxy`, PPID watchdog), `src/mcp/index.ts` (`runProxyWithLocalHandshake`, `spawnDetachedDaemon`), `src/mcp/daemon.ts`.
- **Runtime flags:** `src/extraction/wasm-runtime-flags.ts` (`RELAUNCH_GUARD_ENV=CODEGRAPH_WASM_RELAUNCHED`, `HOST_PPID_ENV=CODEGRAPH_HOST_PPID`).
- **Hooks (existing):** `scripts/agent-eval/block-read-hook.sh`, `scripts/agent-eval/hook-settings.json` (the eval's force-Read-0 hook — basis for the P1 redirect hook).
- **Installer (where to add a recommended hook):** `src/installer/targets/claude.ts`.
- **Eval harness:** `scripts/agent-eval/ab-new-vs-baseline.sh` (new-vs-baseline, pre-warm baked in), `run-all.sh` (with-vs-without), `parse-run.mjs` (tool-by-type counts; `codegraph tools exposed: 0` + 0 codegraph calls = ran without).
- **Doctrine:** `CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" + the agent-eval note under "Validation methodology".
## How to validate anything here
- **P1 (Read displacement):** `bash scripts/agent-eval/ab-new-vs-baseline.sh <indexed-repo> "<implementation task>" [baseline-ref]` — compare `Read` vs `mcp__codegraph__*` counts. ≥2 runs/arm (n=1 is noisy). Run non-nested for cleanest results. Use a *genuinely new* feature task (verify it doesn't already exist — the first A/B attempt wasted a run on an already-implemented `--quiet`).
- **P2 (startup):** time `tools/list` from `serve --mcp` (above); and count cold-start runs where `init` shows `connected` + tools > 0. Don't trust a single `pending` init snapshot — confirm by whether the agent actually called codegraph.
## Constraints / gotchas to remember
- Descriptions/instructions are low-salience — **A/B every behavioral claim**, don't ship wording on faith.
- New tools < extending existing ones.
- The host's `init` snapshot can say `pending` even when the server then connects — judge by actual usage.
- Don't run evals nested for "clean" numbers unless pre-warmed; even then, a real terminal is better.
## Suggested start order for the fresh session
1. **P2 idea 1** — verify whether `serve --mcp` answers `tools/list` locally/instantly; if not, decouple it from the daemon. (Highest-value, shippable, helps all users, no behavioral guesswork.)
2. **P1 idea 1** — prototype the PreToolUse(Read) redirect hook; A/B it. (Highest-value behavioral lever.)
3. Ship the P2 SessionStart pre-warm hook as a mitigation; file the host-side wait/retry request.
+187
View File
@@ -0,0 +1,187 @@
# Design + status: general callback / observer edge synthesis
**Status:** SHIPPED (the synthesizer in `callback-synthesizer.ts` is merged and on
`main`). This doc records the original design.
**Motivation:** close the dynamic-dispatch hole that static extraction leaves for
observer / event-emitter / signal patterns, where a *dispatcher* invokes callbacks
registered elsewhere through a shared store — so flows like "how does an update
reach the screen" actually exist in the graph.
> **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools
> were since **removed** — `codegraph_explore` is the single surfacing tool now. Its
> "Flow" section (`buildFlowFromNamedSymbols`) and the `codegraph_node` trail surface
> these synthesized edges; the `trace(a, b)` notation below means "the a→b flow,"
> which you now verify with `codegraph_explore` / `probe-explore.mjs` (the
> `probe-trace.mjs` / `probe-context.mjs` dev probes went away with the tools).
---
## TL;DR for a new session
We synthesize `dispatcher → callback` edges that static parsing misses. It works:
- **Field observer** (excalidraw `Scene.onUpdate`/`triggerUpdate`): synthesizes
`triggerUpdate → triggerRender`. `trace(mutateElement, triggerRender)` now = 3 hops.
- **EventEmitter** (express `on('mount', …)`/`emit('mount')`): synthesizes `use → onmount`.
- Precision is high: excalidraw got **1** synthesized edge out of 27k (the correct one);
node count moved +3 after Phase 3 (no explosion).
**Files touched (all uncommitted on `main`):**
- `src/resolution/callback-synthesizer.ts` — the whole-graph synthesis pass (Phase 1 + 2).
- `src/resolution/index.ts` — calls `synthesizeCallbackEdges()` at the end of
`resolveAndPersistBatched()` (after base edges are persisted) + the import.
- `src/extraction/tree-sitter.ts``visitFunctionBody` now extracts **named** nested
functions (Phase 3), so inline named handlers become linkable nodes.
**How to reproduce / test:**
```bash
npm run build
rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter}):
sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
"select s.name||' → '||t.name||' '||coalesce(e.metadata,'') from edges e \
join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
# end-to-end flow (the synthesized edge shows up in explore's Flow section + node trail):
node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/excalidraw "triggerUpdate triggerRender"
```
Probe scripts (dev-only, in `scripts/agent-eval/`): `probe-node.mjs` (symbol + trail),
`probe-explore.mjs` (relevant source + the flow among named symbols). EventEmitter
fixture lives at `/tmp/cb-fixture/bus.js` (ephemeral — recreate or move into `__tests__/`).
---
## The hole
```ts
class Scene {
private callbacks = new Set<Callback>();
onUpdate(cb: Callback) { this.callbacks.add(cb); } // REGISTRAR
triggerUpdate() { for (const cb of this.callbacks) cb(); } // DISPATCHER
}
this.scene.onUpdate(this.triggerRender); // REGISTRATION SITE
```
The runtime edge `triggerUpdate → triggerRender` does not exist statically:
`triggerUpdate`'s only literal call is `cb()` (anonymous). Measured: `triggerUpdate`'s
only callee was `randomInteger`; `trace(triggerUpdate, triggerRender)` returned no path.
## Why it's a whole-graph pass, not a `FrameworkResolver.resolve()`
`resolve(ref)` answers "what does this **named** ref point to," one ref at a time. The
callback edge has **no ref to resolve** (`cb()` is anonymous) and needs **cross-file,
multi-site correlation** (registrar, registration, dispatcher). So it's a whole-graph
pass after base resolution, language-level (any OO observer), living in
`src/resolution/callback-synthesizer.ts`**not** under `frameworks/`.
> Sibling mechanism for the *other* dynamic-dispatch class — **named** attribute/
> descriptor dispatch (e.g. django `self._iterable_class(...)`) — is the
> `claimsReference` hook (`resolution/types.ts` + `resolution/index.ts` pre-filter)
> + a `FrameworkResolver.resolve()` (django ORM resolver in `frameworks/python.ts`).
> That one *does* fit `resolve()` because the ref is named. Both are part of the same
> coverage effort; see the "Related work" section.
---
## As-built algorithm (and where it diverged from the original design)
### Field-observer channels (`fieldChannelEdges`, Phase 1)
1. **Candidates** by method/function **name** — registrar `^(on[A-Z]\w*|subscribe|
addListener|addEventListener|register|watch|listen|addCallback)$`; dispatcher
contains `(emit|trigger|notify|dispatch|fire|publish|flush)`.
2. **Confirm by body** (read via `ctx.readFile` + slice node lines): registrar has
`this.<F>.add|push|set(`; dispatcher has `for (… of [Array.from(]this.<F>)` + a call,
or `this.<F>.forEach(`.
3. **Pairing — DIVERGENCE:** the design said pair by *class*; the build pairs by
**same file + same field `F`** (file as a class proxy — getting the containing class
reliably was harder). Works for the common 1-class-per-file case; revisit for
multi-class files.
4. **Registrations:** `queries.getIncomingEdges(registrar.id, ['calls'])` → for each,
read the caller's source at the edge line and **regex-recover the arg**
(`<registrarName>\s*\(\s*(?:this\.)?(\w+)`). DIVERGENCE: design preferred tree-sitter
re-parse; build uses regex (named refs only — arrows/inline args are missed here).
5. **Synthesize** `dispatcher → fn` (`getNodesByName(arg)` → method|function). Capped at
`MAX_CALLBACKS_PER_CHANNEL = 40`.
### EventEmitter channels (`eventEmitterEdges`, Phase 2)
- **File-oriented scan** (`ctx.getAllFiles()` + `readFile`, substring pre-filter on
`.emit(`/`.on(`/etc). `ON_RE` = `\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*
(?:function\s+(\w+)|(?:this\.)?(\w+))`; `EMIT_RE` = `\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]`.
- Dispatcher = **enclosing function** of the `emit('e')` call (`enclosingFn` finds the
tightest function/method/component node containing the line). Handler = `getNodesByName`
of the on-handler name.
- Correlate by **event-name literal**; synthesize dispatcher → handler.
- **Precision — DIVERGENCE:** design proposed receiver-type matching; build uses an
**event fan-out cap** (`EVENT_FANOUT_CAP = 6`) — skip events with >6 handlers or
dispatchers (generic names like `error`/`change` would over-link without type info).
### Provenance — DIVERGENCE
`Edge.provenance` is a fixed enum (`'tree-sitter'|'scip'|'heuristic'`), so synthesized
edges use **`provenance: 'heuristic'`** + `metadata: { synthesizedBy: 'callback'|
'event-emitter', via/event/field }`. The design's `'callback-synthesis'` provenance and
high/medium/low **confidence tiers were NOT implemented** — the fan-out cap +
registrar-name uniqueness + named-only handlers are the precision guards instead.
### Phase 3 — inline callback extraction (`tree-sitter.ts`)
The real blocker for EventEmitter on real repos: inline handlers
(`on('mount', function onmount(){})`) weren't **nodes**, so nothing could link to them.
Root cause: `visitFunctionBody` walked *through* nested functions without extracting them.
Fix: in `visitForCallsAndStructure`, when a body node is a `functionType` and
`extractName` returns a real name, call `extractFunction` (which extracts it and walks
its own body) and return. **Named only** — anonymous arrows fall through to the existing
recursion (so their inner calls stay attributed to the enclosing fn). This bounded it:
excalidraw +3 nodes, no explosion, no regression.
---
## Validation results (actual)
| Repo | Result |
|---|---|
| excalidraw | 1 synthesized edge `triggerUpdate → triggerRender` (of 27,214); `trace(mutateElement, triggerRender)` = 3 hops; nodes 9,286 → 9,289 |
| express | after Phase 3: `use → onmount` `{event-emitter, event:"mount"}` (`onmount` now extracted at `application.js:109`) |
| `/tmp/cb-fixture/bus.js` | `tick → handleRefresh`, `persist → handleSave` (named-method EventEmitter handlers) |
| excalidraw / express | no Phase-1 regression; node counts stable |
---
## Remaining work (prioritized for the next session)
1. **Anonymous-arrow handlers** — `on('e', () => foo())` still produce no edge (no node,
intentionally not extracted in Phase 3). The fix is **synthesizer link-through-body**:
parse the arrow's body and link `dispatcher → (calls inside the arrow)`. Highest
remaining recall win; handles the most common modern callback shape.
2. **Wire into `resolveAndPersist`** (incremental sync) — synthesis currently runs only
in `resolveAndPersistBatched` (full index). Incremental re-index won't refresh
synthesized edges.
3. **Receiver-type matching** for EventEmitter precision (replace/augment the fan-out
cap) — use `type_of` edges so `x.emit('change')` only links to `y.on('change', fn)`
when `x`,`y` are the same type. Lets the fan-out cap relax.
4. **Tree-sitter arg recovery** (replace the regex in field-channel Stage 4) — robust for
arrows, multi-arg, line-wrapped calls.
5. **Single-callback fields** (`this.onChange = cb; … this.onChange()`) — scalar-store
variant of the field observer; not built.
6. **Broad precision/recall audit** — run across the full corpus; tally synthesized edges
per repo, spot-check, confirm no explosion on EventEmitter-heavy repos.
7. **Tests + CHANGELOG** — the fixture is a ready vitest case for the synthesizer; add
extractor tests for Phase 3 (named-nested-fn extraction; confirm other languages
unaffected — the change is in the shared walker), resolver tests for the django side.
## Edge cases / model
- **Over-approximation across instances** is accepted (reachability, not instance
precision). `unregister`/`off` ignored.
- Synthesized edges are **additive** — never replace static edges; tooling can filter on
`provenance='heuristic'` + `metadata.synthesizedBy`.
## Related work (same coverage effort)
This is one half of closing dynamic-dispatch coverage. The other artifacts on `main`:
- **Named attribute/descriptor resolver**: `claimsReference` (`resolution/types.ts`,
pre-filter in `resolution/index.ts`) + django ORM resolver (`frameworks/python.ts`,
`_iterable_class` → `ModelIterable.__iter__`).
- **Retrieval/UX changes** (separate from coverage): `explore` whole-small-file + glue
fixes, the `explore` Flow section (`buildFlowFromNamedSymbols`), and `node`-with-trail
— all in `src/mcp/tools.ts`. (`codegraph_trace` / `codegraph_context` were later
removed; explore is the one surfacing tool.)
- **Full investigation context + findings:** auto-memory
`project_codegraph_read_displacement` (why coverage — not prompting/hooks/new-tools —
is the lever for getting agents to use codegraph over Read).
+146
View File
@@ -0,0 +1,146 @@
# Design + status: chained static-factory / fluent call resolution
**Status:** SHIPPED for **13 languages** (C++, C, PHP, Java, Kotlin, C#, Swift, Rust,
Go, Scala, Dart, Objective-C, Pascal/Delphi) + a conformance pass. **TypeScript and Luau
were evaluated and intentionally skipped** (both gradually typed → the mechanism is +0 /
regresses on real code). See "Full README classification" below. Tracking issue:
**#750** (which began as "the statically-typed README languages" but that enumeration was
incomplete — it missed ObjC / Pascal / Luau).
**Motivation:** a call whose **receiver is itself a call** — a factory / singleton /
builder that returns an object — should produce a `calls` edge to the chained method:
```java
Foo.getInstance().bar(); // bar() should resolve to Foo::bar, never a same-named decoy
```
Before this work, every statically-typed language **dropped the receiver** and
name-matched the bare method (`bar`), so in 7 of 9 languages it silently attached to a
**same-named method on an unrelated type** — a correctness bug, not just missing coverage.
---
## The 3-part mechanism (per language)
1. **Capture the factory's declared return type** — a per-language `getReturnType`
hook writes `nodes.return_type` (schema v5). `*Foo``Foo`, `List<Bar>``List`,
`pkg.Foo``Foo`, `-> Self` / `: self` / `this.type` → the declaring type.
2. **Preserve the chained receiver at extraction**`tree-sitter.ts` (or a bespoke
extractor) encodes `Foo.getInstance().bar()` as the marker string
`Foo.getInstance().bar` (the `().` marker never appears in an ordinary ref). A
per-language gate keeps **instance** chains (`list.map().filter()`) bare so their
existing resolution is untouched — only capitalized-receiver / factory chains re-encode.
3. **Resolve AND VALIDATE** — at resolution the receiver's type is inferred from what
the inner call returns, then the outer method is resolved **on that type** and
validated: the method must exist on the type (or a supertype it conforms to), so a
wrong inference yields **no edge**, never a wrong one.
Three shared resolvers in `src/resolution/name-matcher.ts`, all calling
`resolveMethodOnType` (which has the conformance supertype-walk):
| Resolver | Receiver style | Languages |
|---|---|---|
| `matchCppCallChain` | `field_expression` (`Foo::instance().bar`) | C++, C |
| `matchScopedCallChain` | `::` (`Cls::for($x)->m`, `Foo::new().bar`) | PHP, Rust |
| `matchDottedCallChain` | `.` (`Foo.create().bar`) | Java, Kotlin, C#, Swift, Go, Scala, Dart |
**Conformance pass (#754).** When the chained method lives on a **supertype** the
return type conforms to (an inherited / default-interface / trait / mixin / embedded
method), the first pass can't see it — `implements`/`extends` edges aren't built yet.
So failed chain refs are deferred (`CHAIN_LANGUAGES` in `resolution/index.ts`) and
re-resolved in a second pass `resolveChainedCallsViaConformance()` after edges exist,
walking `context.getSupertypes(...)`.
**Adding a language:** `getReturnType` in `languages/*.ts`; encode the chained receiver
+ a node-type gate; add the language to the right `matchReference` gate (and
`CONSTRUCTS_VIA_BARE_CALL` if a bare capitalized call constructs the class); add to
`CHAIN_LANGUAGES`; synthetic tests + a real-repo A/B; bump `EXTRACTION_VERSION`.
---
## Coverage (validated — each via synthetic decoy/absent-method tests + a real-repo A/B)
| Language | PR | Receiver | Real-repo A/B (unique `calls` edges) | Notes |
|---|---|---|---|---|
| **C++ / C** | #645 (#742) | `field_expression` | — | The original: singletons / factories / chained getters. |
| **PHP** | #608 (#749) | `::``->` | — | `Cls::for($x)->method()` — the Laravel per-tenant client idiom. `: self`/`: static`. |
| **Java** | #751 | `.` | Guava **+1,507 / 0** | Missing-edge → purely additive. |
| **Kotlin** | #752 | `.` | arrow **+49 / 438** | Wrong-edge → precision win (438 removed = test/doc noise + wrong). Needed the capitalized-receiver gate + constructor-receiver handling. |
| **C#** | #753 | `.` | Newtonsoft +3 / NodaTime **+73 / 0** | Additive. Return type is the `returns` field; extension-method chains correctly don't resolve. |
| **conformance** | #754 | (resolver upgrade) | arrow **+22 / 0** | Supertype walk — enables Swift protocol-ext, Rust trait, Go embedded, Dart mixin, Java/Kotlin/C# inherited chains. |
| **Swift** | #755 | `.` | Alamofire / Kingfisher **0 / 0** | Neutral-safe (unique fluent names already bare-resolved). Needed a nested-extension naming fix (`KF.Builder``KF::Builder`). |
| **Rust** | #757 | `::` | clap **+937 / 775** | Precision win (622 wrong→right retargets, +162 net). `-> Self`; trait-default methods via conformance. Single-hop. |
| **Go** | #760 | `.` | gin **net-zero** | `New().Method()`; embedded structs via conformance. Variable-inner fallback. **Found + fixed a batched-resolver runaway** (a mutated `original.referenceName` looped the offset-0 batch → 5M edges / 1.4 GB; fixed by tying the fallback to the original ref + a non-progress guard). |
| **Scala** | #761 | `.` | gatling **+14 / 59** | Precision win (59 = stdlib `Option`/`Iterator` `.map`/`.flatMap` the baseline mis-tied to gatling's `Validation::*`). Companion factories + case-class `apply`. |
| **Dart** | #762 | `.` | localsend hand-written **+17 / 10** | Precision win **+ constructors made first-class** (factory/named ctors `Foo.create()`/`Foo._()` are now indexed; unnamed `Foo()` stays `instantiates`). `dartCtorInfo` validates a ctor against the enclosing class name — handles a tree-sitter misparse where `@override (A,B) m()` makes `m()` look like a ctor. |
| **Objective-C** | #786 | message send | SDWebImage **+35 / 75** | Precision win. Chained message send `[[Foo create] doIt]` over `message_expression`. getReturnType skips nullability qualifiers (`nonnull instancetype`). A class-message factory returns the receiver class by convention, so `[[X alloc] init]` / singleton chains resolve on `X` (validated). The 75 are wrong `init` mis-matches retargeted to the right class. |
| **Pascal/Delphi** | #791 | `.` (`exprDot`) | PascalCoin **+19 / 18** | Precision win. `TFoo.GetInstance().DoIt()` over Pascal's `exprCall`/`exprDot`. getReturnType from the `typeref` (incl. interface returns `IFoo`). Re-encoding gated on the Delphi `TFoo`/`IFoo` type convention so capitalized *variable* chains stay bare. A constructor (no `: TBar`) or typecast `TFoo(x)` resolves on the class. 15 of the 18 are correct class→interface retargets (`GetInstance(): IAsn1OctetString`). |
| **TypeScript** | — | `.` | typeorm +0/6 · nest **+0/164** | **Evaluated, NOT shipped** — gradual typing; see below. |
| **Luau** | — | `:` / `.` | Fusion +0/0 · matter +0/0 | **Evaluated, NOT shipped** — gradually typed; additive-safe (missing-edge gap, no regression) but real Luau rarely annotates factory returns, so +0 on both benchmarks. Works for `Foo.create(): Bar` then `:doIt()` (synthetic). |
`EXTRACTION_VERSION` is now **18** (C++→…→Pascal chains→paren-less calls→free-routine attribution). Re-index with `codegraph index -f`
to pick up the newer extractor on an existing graph.
## Why TypeScript was skipped
The mechanism resolves a chain from the factory's **declared** return type. TypeScript
leans on **type inference** — e.g. NestJS's `Test.createTestingModule(m) { return new
TestingModuleBuilder(...) }` has no `: TestingModuleBuilder` annotation — so the
factory's type can't be recovered, the re-encoded chain can't resolve, and it **drops
the bare-name edge** the existing resolver found. Real-repo A/B was **+0 added on both
typeorm and nest** with a net recall regression (164 on nest, mostly the ubiquitous
`Test.createTestingModule({…}).compile()` pattern). The removed edges were mostly
*wrong* (baseline mis-resolved `.compile()` to `ModuleCompiler::compile`), so it's
precision-positive but recall-negative — against the recall-first invariant, and adding
nothing where it doesn't hurt (TS method names are unique enough that bare-name already
lands them). It was fully implemented (5 synthetic tests passed, runaway-safe bare-name
fallback) and consciously not shipped. The only path to a TS win would be reading
**inferred** return types (resolving `return new X()` in the factory body) — a much
larger change. Full write-up on issue #750.
---
## Full README classification (all 21 languages)
The mechanism's real requirement is a **declared return type** to recover the receiver's
type — not "statically typed" (PHP qualifies via its `: self` / `: Type` return
declarations). Against the README's full supported-language list:
| Bucket | Languages |
|---|---|
| **Covered** (13) | C++, C, PHP, Java, Kotlin, C#, Swift, Rust, Go, Scala, Dart, Objective-C, Pascal/Delphi |
| **Evaluated, skipped** (2) | **TypeScript** — gradual typing → inference-typed factories can't be recovered; net recall regression. **Luau** — gradually typed; additive-safe but +0 on Fusion AND matter (real Luau rarely annotates factory returns). Both: the mechanism needs reliably-declared return types, which gradually-typed code too often omits. |
| **Pascal call-coverage follow-ups** | Two gaps from the chained-call work, both resolved. **Paren-less calls (#793):** Pascal lets a no-arg method drop its parens (`Obj.Free;`, `TFoo.GetInstance.DoIt;`), which parse as a bare `exprDot` and weren't extracted as calls at all. Now extracted, scoped to STATEMENT position (a bare dot in assignment/condition position is left alone — ambiguous with a field/property access). PascalCoin A/B **+1131 / 1**, all new edges resolve to methods. **Free-routine attribution (#795):** a procedure/function defined only in the `implementation` section (no interface decl, not a method) had no node, so its body's calls were lumped under the file; now it gets a function node and its calls attribute to it. PascalCoin A/B **+511 / 145** (file-level aggregates → per-routine edges). |
| **Out of scope — no declared return types** (6) | JavaScript, Ruby, Lua, Svelte, Vue, Liquid (Liquid has no methods/chains at all) |
| **Partial / separate** (1) | Python — only optional `-> T` hints; tracked as #578, not part of this mechanism |
So #750's original framing ("the 9 statically-typed README languages") was incomplete —
it missed three more typed languages, all now resolved: **Objective-C** shipped (#786,
same wrong-edge gap, mechanism ports directly); **Pascal/Delphi** shipped (#791, a clean
port for the paren'd chain — an initial "blocked" read was wrong, caused by probing only
the paren-less form); **Luau** evaluated and skipped (gradual typing → +0 on real repos,
additive-safe).
The through-line: this mechanism fits languages with **reliably-declared return types**
(the 13 shipped). Gradually-typed languages (TypeScript, Luau) omit them too often for
it to pay off, and dynamically-typed languages have none.
---
## Edge cases / model
- **Single-hop**: a chain re-encodes one hop; deeper hops (`a.b().c().d()`) keep the
bare name (the inner `()` defeats the `Class::method` split). Re-measure on deep
fluent-builder repos.
- **Validation, not guessing**: every resolver ends in `resolveMethodOnType`, so an
unknown / wrong inferred type produces **no edge** — the decoy / absent-method
guarantee that makes this safe to ship.
- **Per-language receiver gate** keeps instance chains bare so existing resolution is
never regressed; the A/B "removed" counts are wrong-edge corrections, not losses.
## Related work
- **Dynamic-dispatch / callback synthesis** (a *different* mechanism): observer /
EventEmitter / React-render / JSX-child / django-ORM edge synthesis lives in
`callback-edge-synthesis.md` + `dynamic-dispatch-coverage-playbook.md`.
- The verbose session working-notes for #750 are in
`.claude/handoffs/chained-call-multilang-probe.md` (scratch; this doc is the
permanent record).
File diff suppressed because one or more lines are too long
@@ -0,0 +1,659 @@
# Dynamic-Dispatch Coverage Playbook
**Audience:** a Claude agent continuing this work.
**Mission:** systematically close static-extraction coverage holes for **dynamic
dispatch** across **every language and framework codegraph supports**, and validate
each one the same way, so cross-symbol *flows* exist in the graph everywhere.
> This is the top-level playbook. The deep design for one mechanism (the callback
> synthesizer) is in [`callback-edge-synthesis.md`](./callback-edge-synthesis.md).
> The cross-cutting **dispatch-shape** queue (Redux/RTK Query/NgRx/MediatR/registries —
> organized by indirection shape, not language×framework) is in
> [`dispatch-synthesizer-backlog.md`](./dispatch-synthesizer-backlog.md).
> Full investigation context + findings: auto-memory `project_codegraph_read_displacement`.
> **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools were
> **removed** — `codegraph_explore` is the single surfacing tool now. Its "Flow" section
> (`buildFlowFromNamedSymbols`) surfaces the synthesized edges this playbook is about, and
> you validate coverage with `codegraph_explore` / `scripts/agent-eval/probe-explore.mjs`.
> Where the text below writes `trace(a, b)` or lists `trace`/`context` among the tools,
> read it as "the a→b flow, now surfaced and verified via explore." The synthesizers and
> the coverage matrix are unchanged.
---
## 1. The goal (why this matters)
codegraph's value is being **the map** — answering structural/flow questions
(`trace`, `impact`, callers, "how does X reach Y") that grep/Read cannot. Agents
will use codegraph instead of Read **only when it is sufficient**. We proved
empirically (see memory) that the lever for sufficiency is **coverage**, not
prompting/hooks/new-tools: when a flow is missing from the graph, the agent reads
the files to reconstruct it; when the flow *is* in the graph, the agent can answer
completely without reading.
**Validated end-to-end on excalidraw:** after closing the update-flow hole, 2/3
headless agent runs answered the "how does an update reach the screen" question with
**Read 0 and a complete answer** — impossible before, because the key edge wasn't in
the graph. (Caveat: coverage *enables* the no-read path; agent confirm-by-reading
variance means it doesn't *force* it. Completeness improves unconditionally.)
The mission is to make that true for **all** languages/frameworks.
---
## 2. The problem class: dynamic dispatch
Static tree-sitter extraction captures explicit calls (`foo()`, `this.bar()`). It
**misses** any call whose target is computed/indirect. Four recurring shapes, with a
**difficulty gradient** (do the cheap ones first):
| # | Shape | Example | Fix mechanism | Cost |
|---|---|---|---|---|
| 1 | **Named attribute / descriptor** | django `self._iterable_class(self)` | framework resolver (`claimsReference` + `resolve()`) | **cheap** |
| 2 | **Field-backed observer** | `onUpdate(cb)` + `for(cb of cbs)cb()` | callback synthesizer (whole-graph pass) | medium |
| 3 | **String-keyed EventEmitter** | `on('e',fn)` / `emit('e')` | callback synthesizer (event-keyed) | medium |
| 4 | **Inline callback handler** | `on('e', function h(){})` / `() => {}` | extraction (named) + synthesizer link-through-body (anon) | named: cheap · anon: hard |
| 5 | **Closure-collection dispatch** | Swift `validators.write{$0.append(v)}``validators.forEach{$0()}` | callback synthesizer (`closureCollectionEdges`, element-invoke gated) | medium |
Key distinction driving the mechanism choice:
- **A named ref exists** to resolve (`_iterable_class` is an attribute name) → **resolver**.
- **No ref exists** (`cb()` is anonymous; needs registrar↔dispatcher correlation) → **synthesizer**.
---
## 3. Worked examples (the two mechanisms, end to end)
### 3a. Django ORM descriptor — the **resolver** pattern (Python)
- **Hole:** `QuerySet._fetch_all` calls `self._iterable_class(self)` (a runtime-chosen
iterable, default `ModelIterable`), whose `__iter__` runs the SQL compiler. Static
parsing can't resolve the attribute-as-callable → `_fetch_all`'s only callee was
`_prefetch_related_objects`; `trace(_fetch_all, execute_sql)` returned no path.
- **Fix:** `djangoResolver` claims the unresolved `_iterable_class` ref through the
name-exists pre-filter, then resolves it to `ModelIterable.__iter__`.
- **Files:** `src/resolution/types.ts` (`claimsReference?` on `FrameworkResolver`),
`src/resolution/index.ts` (pre-filter in `resolveOne` consults `claimsReference`),
`src/resolution/frameworks/python.ts` (`djangoResolver.resolve` + `claimsReference` +
`resolveModelIterableIter`).
- **Result:** `trace(_fetch_all, execute_sql)``_fetch_all → __iter__ → execute_sql` (3 hops).
### 3b. Excalidraw observer + EventEmitter — the **synthesizer** (TS)
- **Hole:** `Scene.triggerUpdate` does `for (cb of this.callbacks) cb()`; `triggerRender`
is registered via `scene.onUpdate(this.triggerRender)`. The `triggerUpdate →
triggerRender` edge is dynamic → `trace` returned no path; the whole update flow broke.
- **Fix:** a whole-graph pass that detects registrar/dispatcher channels, correlates
registration sites, and synthesizes `dispatcher → callback` edges. Plus extraction of
**named** inline callbacks so handlers like express's `function onmount(){}` are nodes.
- **Files:** `src/resolution/callback-synthesizer.ts` (the pass — field observers +
EventEmitter), `src/resolution/index.ts` (calls `synthesizeCallbackEdges()` at the end
of `resolveAndPersistBatched`), `src/extraction/tree-sitter.ts` (`visitFunctionBody`
extracts named nested functions).
- **Result:** `trace(mutateElement, triggerRender)` → 3 hops; express `use → onmount`.
### 3c. Alamofire deferred validation — closure-collection dispatch (Swift)
- **Hole:** `DataRequest.validate(_:)` builds a closure and `validators.write { $0.append(validator) }`;
the base `Request.didCompleteTask` runs them via `validators.forEach { $0() }`. Append and
dispatch live in *different files and classes* (a subclass appends, the base iterates) and the
field is a Swift `Protected<[@Sendable () -> Void]>` — so neither same-file pairing nor the
name-based registrar match (`onX`/`subscribe`/…) reaches it. `trace(didCompleteTask, validate)`
returned no path; the agent grepped `validators` and read three files to reconstruct it.
- **Fix:** `closureCollectionEdges` (callback-synthesizer.ts). A **dispatcher** iterates a collection
*invoking each element* (`coll.forEach { $0() }` / `{ it() }`); a **registrar** appends a closure to
the same-named field (`.append`/`.add`/`.push`/`.insert`, incl. Swift `.write { $0.append }`). The
element-invoke (`$0(` / `it(`) is the precision **gate** — it proves the collection holds closures —
so a repo with no closure-collection dispatch yields **0 edges** regardless of how many `.append`
sites it has. Pairs dispatcher → registrar globally by field name (cross-file/class required),
fan-out-capped. Surfaced two ways: inline in `trace`, and as a "Dynamic-dispatch links among your
symbols" section in `codegraph_explore` (`buildFlowFromNamedSymbols`) so the relationship shows even
when the agent named only `validate`, not the `didCompleteTask` that drains the list.
- **Files:** `src/resolution/callback-synthesizer.ts` (`closureCollectionEdges`),
`src/mcp/tools.ts` (`synthEdgeNote` closure-collection case + the explore synth-links section).
- **Result:** `trace(didCompleteTask, validate)` connects with the closure-collection hop + the
`validators.write { $0.append }` wiring site inlined. 9 precise edges on Alamofire
(`validators`/`streams`/`finishHandlers`/`requestsToRetry`), **0 on every non-Swift control**.
Forced codegraph-only (Read+Grep+Bash blocked): 3/3 runs answer build/send/validate correctly.
### 3d. Insight — an "adoption floor" can hide a trace-endpoint bug (Alamofire)
Alamofire (110 files) was the README's weakest repo and was written off as the "small-repo floor"
(native grep is cheap, so the agent reads anyway). It wasn't. Reading the **transcripts** — every
`Read`'s `file_path`+offset and the assistant text right before it — surfaced the agent's own words:
*"the trace collided with same-named symbols (44 `request`s, 8 `task`s), let me read by line."*
`codegraph_trace`'s endpoint disambiguation (`scorePair`, shared-dir-prefix only) was resolving an
overloaded name to an **empty delegate/protocol stub**`request``EventMonitor.request(){}`
(a 1-line no-op) over the real `Session.request`, because two unrelated `Source/Features/` stubs
shared a deeper dir prefix than the correct `Source/Core/` pair. Garbage trace → manual reading,
sometimes a spiral (12 reads / 11 greps in one run). **Fix:** a `nodeRelevance` term in `handleTrace`
pair scoring that penalizes empty stubs (≤1 body line) and test-file symbols; among real methods it's
flat, so path-proximity (cosmos `EndBlocker`) is unaffected. Result (n=8): WITH-arm tool calls
12 → 8 median, and the read **variance collapsed** (012 → 14 — the meltdowns *were* the
trace-collision flounder). General bug: protocol/delegate-stub flooding hits Swift/Java/C#/Go.
**Methodology lesson:** when the agent reads on a small repo, don't conclude "adoption floor" — diff
*what it read* against what the tool returned *immediately before*. A read of content the tool already
gave = adoption; a read after the tool returned the **wrong thing** (stub endpoints, collided names) =
a fixable bug. The transcript reasoning, not the median, tells you which. The forced codegraph-only
hook (block Read+Grep+Glob+Bash-search) is the variance-free way to confirm sufficiency separately
from adoption.
---
## 4. The repeatable methodology (run this per language/framework)
### Step 1 — Pick the framework's canonical *flow* question
Every framework has a signature data/control flow. Pick the "how does X reach/become Y"
question and a real repo (add to `.claude/skills/agent-eval/corpus.json`). Examples:
- React state→DOM, Vue reactive→render, Svelte store→update
- Rails request→controller→view, Spring request→`@Controller`→service
- Express/Koa request→middleware→handler, FastAPI request→route→dependency
- Redux action→reducer→store, RxJS subscribe→operator→observer
- Any ORM: query builder → SQL execution (django pattern)
### Step 2 — Measure the hole (deterministic, no agent)
```bash
rm -rf <repo>/.codegraph && ( cd <repo> && codegraph init -i )
node scripts/agent-eval/probe-trace.mjs <repo> <from-symbol> <to-symbol> # does the flow break? where?
node scripts/agent-eval/probe-node.mjs <repo> <break-symbol> # trail: is the next hop missing?
```
A "No direct call path … breaks at dynamic dispatch" + a sparse trail at the break
point **locates the hole** (this is exactly how `_iterable_class` and `triggerUpdate`
were found). Confirm it's dynamic by reading the break symbol's body.
### Step 3 — Classify → choose the mechanism (use the §2 table)
- `self.<attr>(...)` / descriptor / metaclass → **resolver** (§3a).
- `for(cb of store)cb()` / `store.forEach(cb=>cb())`**field-observer synthesizer** (§3b).
- `on('e',fn)` + `emit('e')`**EventEmitter synthesizer** (§3b).
- Inline handler not a node → **named:** extraction (already done generically in
`tree-sitter.ts`); **anonymous:** synthesizer link-through-body (not yet built).
- Dispatch that CAN'T be precision-gated as a class (runtime-keyed `table[key](...)`,
`getattr(self, expr)`, reflection, typed mediator buses, `new Proxy`) → **boundary
surfacing** (`src/mcp/dynamic-boundaries.ts`, #687): explore ANNOUNCES the dispatch
site where the static path ends — file:line, form, and candidate targets when the
key is statically visible — instead of synthesizing an edge. Query-time only, zero
graph mutation, fires only when the asked-about flow fails to connect. This is the
deliberate floor for the frontier: a wrong edge poisons the map (silent beats
wrong), but an honest "the flow continues at THIS site, likely into THESE
candidates" still saves the read-reconstruction spiral. When a boundary form later
proves precision-gateable on real repos (e.g. a same-repo literal-key command bus),
promote it to a synthesizer channel and the boundary note disappears on its own —
the flow then connects.
### Step 4 — Implement
- **Resolver:** add to `src/resolution/frameworks/<lang>.ts` — a `resolve()` branch +
`claimsReference(name)` if the ref name isn't a declared symbol. Copy `djangoResolver`.
- **Synthesizer channel:** extend `src/resolution/callback-synthesizer.ts` — add the
framework's registrar/dispatcher **name patterns** and **body patterns** (e.g. signals
use `.connect()`/`.emit()`; Rx uses `.subscribe()`/`.next()`).
- Reindex (Step 2 command) and re-run `probe-trace` — the flow should now connect.
### Step 5 — Validate (the same way every time)
1. **Deterministic:** `probe-trace(from,to)` finds the path; `probe-node` shows the
bridged hop. The previously-broken hop is closed.
2. **Precision:** count + spot-check synthesized/resolved edges — no explosion, correct targets:
```bash
sqlite3 <repo>/.codegraph/codegraph.db \
"select s.name||' → '||t.name||' '||coalesce(e.metadata,'') from edges e \
join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
```
(Resolver edges aren't `heuristic`; verify via the trace + callees instead.)
3. **Regression:** node count stable (`select count(*) from nodes;` before/after — a big
jump means an extraction change over-fired); existing traces on a control repo intact.
4. **End-to-end agent eval:** run the flow question with codegraph and measure
**reads / answer-completeness / cost** vs a pre-fix baseline:
```bash
# headless (exact cost + clean tool sequence)
bash scripts/agent-eval/run-agent.sh <repo> with "<flow question>"
# or the full A/B + interactive Explore-subagent path:
scripts/agent-eval/audit.sh local <name> <url> "<flow question>" all
```
Then parse: `Read` count, codegraph-tool count, cost, and whether the answer now
contains the glue symbols (the ones that previously required a read).
### Success criteria (per language/framework)
- `trace` finds the canonical flow end-to-end (no dynamic-dispatch break).
- Agent can answer the flow question with **Read 0** (achievable in ≥ some runs) and the
glue symbols appear in the answer.
- **No node explosion** and no regression on a control repo.
- Synthesized edges are precise on a spot-check (no generic-name over-linking).
---
## 5. Validation toolkit (reference)
| Tool | Purpose |
|---|---|
| `scripts/agent-eval/probe-trace.mjs <repo> <from> <to>` | call-path between two symbols (the hole detector) |
| `scripts/agent-eval/probe-node.mjs <repo> <sym> [code]` | symbol + trail (callers/callees); `code` adds the body |
| `scripts/agent-eval/probe-context.mjs <repo> "<task>"` | context output incl. call-paths |
| `scripts/agent-eval/probe-explore.mjs <repo> "<query>"` | explore output |
| `scripts/agent-eval/{audit,run-agent,itrun}.sh` | agent A/B (headless + interactive); also the `/agent-eval` skill |
| `sqlite3 <repo>/.codegraph/codegraph.db` | direct edge/node inspection (provenance, metadata, counts) |
Probe scripts use the built `dist/` — run `npm run build` first. Reindex after any
extraction or resolution change (`rm -rf <repo>/.codegraph && codegraph init -i`) — the
synthesizer/resolvers run at index time. Test fixtures: keep a tiny per-pattern fixture
(see `/tmp/cb-fixture/bus.js`; **move into `__tests__/`** when shipping).
---
## 6. Coverage matrix (fill in as you go)
Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
`Mechanism`: R = resolver, S = synthesizer channel, X = extraction.
| Language | Framework(s) | Canonical flow to test | Mechanism | Status |
|---|---|---|---|---|
| TypeScript/JS | React / observer / EventEmitter / React Router | state→render; dispatch→callback; route→component | S + X | ✅ rendering+dispatch (excalidraw); **React Router JSX routing** `<Route path component={C}/>` (v5) + `element={<C/>}` (v6) → component (react-realworld **0→10, 10/10**). + **object data-router** `createBrowserRouter([{path, element/Component}])` (literal form); Next.js config/`nextjs-pages` false-positives FIXED. 🔬 lazy data-router (`path: paths.x.path, lazy: () => import()` — variable paths + lazy modules) |
| TypeScript/JS | Vue / Nuxt | template events (@click→handler); component composition; reactive→render | S + X | ✅ events + composition (vitepress S / vben M / element-plus L); 🔬 reactive→render (vue-core Proxy runtime — frontier, deferred) |
| TypeScript/JS | Svelte / SvelteKit | template calls/composition; SvelteKit action→api; store→DOM | X | ✅ already strong (realworld S / skeleton M / shadcn L): template `{fn()}` calls, `<Pascal/>` composition, `import * as api` namespace, `load`→api all work out of the box. + exported-const object-of-functions extraction (SvelteKit `actions`). 🔬 `$lib`-namespace-from-action + store/reactive frontier |
| TypeScript/JS | Express / Koa | request → route → handler → service | R + X | ✅ named handlers + middleware + controller/service (resolver) + **inline arrow handlers → service body calls** (realworld S 19 / parse M / ghost L 65 edges). 🔬 custom routers (payload had 0 routes — not `app.get`-style) |
| TypeScript/JS | NestJS | request → @Controller → DI service → repo | R | ✅ already well-covered (realworld S / immich M-L / amplication L): @decorator routes (HTTP/GraphQL/microservice/WS) via resolver + DI `this.svc.method()` controller→service resolves correctly at scale (name + co-location). No dynamic-dispatch hole. 🔬 committed `dist/` build output gets indexed (realworld) — general build-dir-ignore follow-up |
| TypeScript/JS | RxJS / signals | subscribe → operator → observer | S | ⬜ |
| Python | Django ORM | QuerySet → SQL compiler | R | ✅ |
| Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (`path`/`url`/`as_view`) + **DRF `router.register`→ViewSet** (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (`post_save`→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers |
| Python | Flask / FastAPI | request → route → handler → dependency | R + X | ✅ **Flask: handler resolved across intervening decorators (`@login_required`) + stacked `@x.route` lines** (microblog S 6→27, redash L decorator routes 6/6); **FastAPI: empty-path router-root routes `@router.get("")` incl. multi-line** (realworld S 12→20 / Netflix dispatch L **290/290 100%**) + **bare-name builtin guard** — a handler named after a Python builtin method (`index`/`get`/`update`/`count`…) was filtered as a builtin and lost its route→handler edge. + **Flask-RESTful `add_resource(Resource,'/x')` → Resource class** (redash 6→**77**) + **tuple `methods=('GET',)`** (was mislabeled GET) + **broadened detection** (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→**19**). 🔬 FastAPI `Depends()` dependency edges (light validation) |
| Go | Gin / chi / gorilla/mux / net-http | request → route → handler → service; middleware chain (`Use`→`Next`) | S + X | ✅ **routes on ANY group var** (`v1.GET`, `PublicGroup.GET`) not just `r/router` (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. **gorilla/mux confirmed covered** by the any-receiver `HandleFunc`/`Handle` handling (subrouter-var `s.HandleFunc(...)` + namespaced handlers; `.Methods()` chain ignored). + **gin middleware-chain synthesizer** (`ginMiddlewareChainEdges`): gin runs its entire chain through one dynamic line — `(*Context).Next` does `c.handlers[c.index](c)`, a slice-index dispatch tree-sitter can't resolve, so `callees(Next)` dead-ended at the `len()` helper (`safeInt8`) and the agent rabbit-holed re-querying it. Find the dispatcher (a Go method invoking a `handlers` slice by index) and link it → every HandlerFunc registered via `.Use`/`.GET`/…/`.Handle`; gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (closures skipped), capped. gin L: `callees(Next)` now surfaces `Logger`/`Recovery`/`ErrorLogger`+handlers (node count stable 2,544; 5 precise edges with `registeredAt` wiring sites). **Agent A/B (headless median-of-4, Opus 4.8): gin flipped from codegraph 58% cost / 129% time (the rabbit-hole, incl. a stray `Workflow` mis-fire on 2/4 WITH runs) → +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash, no Workflow, no duplicate calls).** 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
| Go | GoFrame (standard router) | request-type `g.Meta` route → controller method (reflective `group.Bind`) | R (extract) + S | ✅ **GoFrame `g.Meta` route coverage** (#747) — extractor (`frameworks/goframe.ts`, detect `gogf/gf` in go.mod) turns each `` g.Meta `path:.. method:..` `` request-type tag into a `route` node (requires `path:`, so a response `mime:`-only `g.Meta` is skipped), encoding the **package-qualified** request type in qualifiedName. `goframeRouteEdges` synthesizer joins route → the controller method whose **signature** takes that request type — NOT by name (`DeptSearchReq` is served by `List`, `GetDictReq` by `GetDictData`) — keyed `pkg.Type` to separate the dozens of identical bare names a big app defines one-per-module (`cash.ListReq` vs `order.ListReq`), with an **addon-root tiebreak** so a cloned demo addon (`addons/hgexample/`) binds within itself and never cross-links to core. Validated: gf-demo-user S **7/7**, gfast M **65/68** (3 genuinely handler-less DbInit), hotgo L (697 files) **242/247 (98%), 100% precision** (0 non-controller handlers, 0 core/addon cross-binding); node count stable on re-index; surfaces in the handler's caller trail (`POST /dept/add` → `Add` via `goframe-route`). **Agent A/B (gfast M, sonnet/high, 2 runs/arm): WITH = 1 `codegraph_explore` / 0 Read / 0 Grep / ~20s / correct; WITHOUT = 7.5 Read avg + grep-hunting for the non-existent literal `/dept/add` string + re-reading sys_dept.go up to 12× / ~42s — reads eliminated, 83% tool calls, 2.1× faster, cost a wash; both arms reach the same correct call path.** 🔬 group prefix from reflective `Bind` not prepended (route shows the `g.Meta` path, not `/system/dept/list`); the 4×-cloned `index` sub-packages inside one addon are left unlinked (needs import-path resolution, not just package name) |
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ **Axum chained methods + namespaced handlers** — `.route("/x", get(h1).post(h2))` emitted only the first method+handler, and `get(mod::handler)` captured the module not the fn (realworld-axum S **12→19, 19/19**); balanced-paren scan + per-method nodes + last-`::`-segment handler. **Rocket attribute macros 550/556 (99%)** (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa `routes!` macro = frontier). Cargo-workspace module resolution (prior work). **actix builder API** `web::resource("/x").route(web::get().to(h))` / `.to(h)` / App `.route("/x", web::get().to(h))` (actix-examples **51→128 routes, 35→112 resolved**) — was the dominant actix style and fully missed (the handler is in `.to(h)`, not `get(h)`). 🔬 actix `web::scope("/api")` prefix (not prepended to nested resource paths) + anonymous `.to` closure handlers |
| Java | Spring | request → @RestController → @Autowired service → repo | R + X | ✅ **bare `@GetMapping`/`@PostMapping` + class `@RequestMapping` prefix join → route→method** (realworld S / mall M / halo L) — was missing all path-less method mappings; DI controller→service resolves (name + dir) + **interface→impl dispatch synthesizer** (`interfaceOverrideEdges`: a class's `implements`/`extends` → link each interface/base method → its same-name override; JVM-gated, capped, **overload-aware**; mall **310** / halo **734** synth edges, node count unchanged) so trace follows controller→service-**interface**→**impl** instead of dead-ending at the abstract method — `trace("PmsProductController.getList","PmsProductServiceImpl.list")` connects in **3 hops** (probe-validated). + **field-injected concrete-bean trace** (#389): `this.<field>.method()` strips the `this.` receiver at extraction, and the resolver looks up the receiver name in the enclosing class's field declarations to get the declared type, then resolves the method on it — closes the controller→bean hop when the field-name doesn't capitalize to the type (`@Resource(name="userBO") UserBO userbo` → `userbo.toLogin2()` reaches `UserBO.toLogin2`). + **`@Value("${k}")` / `@ConfigurationProperties(prefix="X")` → application.{yml,yaml,properties}** binding with Spring's relaxed binding (kebab↔camel↔snake), incl. `${k:default}`. mall-tiny S: 11/11 `@Value` resolved. ⚠️ **agent A/B null** (n=2: the agent went context→explore→Read and never invoked `trace`, so the synth edges weren't exercised — adoption-gated, the recurring wall; see `docs/benchmarks/call-sequence-analysis.md`). The fix is correct + improves trace/callees/impact/context connectivity regardless; agent-visible read reduction needs trace adoption. 🔬 Spring Data JPA derived queries (`findByEmail`) — metaprogramming frontier; `@PropertySource` external files; Spring Cloud Config; mapper-class simple-name collisions across packages (dropped to avoid mis-resolution) |
| Java | MyBatis (XML mappers) | DAO interface method → `<select\|insert\|update\|delete id="X">` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing `<mapper namespace="...">`; emits one method-shaped node per statement qualified `<namespace>::<id>` + `<sql id="X">` fragments + `<include refid>` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `<ClassName>::<methodName>` and joins to XML qualified names by suffix-match — ambiguous simple-name collisions dropped (precision over recall). mall-tiny S **6/6 custom-SQL mapper methods bridge** to their XML statements; full enterprise chain `trace(controller.action → mapper.method-xml)` connects across controller / service-iface / impl / mapper / XML. 🔬 cross-mapper `<include>` via unqualified refid; MyBatis Plus dynamic methods (`BaseMapper<T>` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) |
| Kotlin | Spring Boot / Jetpack Compose | request → @RestController → service; @Composable → child | R + X | ✅ **Spring Boot Kotlin** — the Spring resolver was `['java']`-only with a Java-syntax method regex (`public X name()`); extended to `.kt` + Kotlin `fun name(` handler matching (petclinic-kotlin **0→18, 18/18**; class-prefix joins; DI controller→repo resolves — `showOwner ← GET /owners/{ownerId}` → `OwnerRepository.findById`). **Compose composition already static** (@Composable→child are plain function calls — Jetcaster `PodcastInformation→HtmlTextContainer`). Java Spring unchanged (realworld 19/19). 🔬 Ktor `routing { get("/x"){…} }` lambda handlers (anonymous) + Compose recomposition (implicit `mutableStateOf`, no setState gate) + coroutines/Flow |
| Swift | Vapor | request → route → controller | R + X | ✅ **was 0 routes on every real app** — the extractor required an `app/router/routes` receiver + a `"path"` literal, but real Vapor routes on grouped builders (`let todos = routes.grouped("todos"); todos.get(use: index)`) with NO path arg. Rewrote: any receiver, optional/non-string path segments, `.grouped`/`.group{}` prefix tracking, `use:` discriminator. vapor-template S **0→3 (3/3**, nested `/todos/:todoID`), SteamPress M **0→27 (27/27)**, SwiftPackageIndex-Server L **0→14 (14/14** handler resolution). 🔬 typed-route enums (SPI `SiteURL.x.pathComponents` — path label only, handler still resolves) + closure handlers `app.get("x"){ }` (anonymous) |
| Swift | Alamofire / closure-collection | request → build → send → **validate** (deferred closures) | S | ✅ **closure-collection dispatch synthesizer** (`closureCollectionEdges`): the Swift deferred-handler pattern `DataRequest.validate` `validators.write{$0.append(v)}` … base `Request.didCompleteTask` `validators.forEach{$0()}` (append + dispatch in different files/classes, field is `Protected<[() -> Void]>`). The element-invoke `$0(`/`it(` is the precision gate → **9 edges on Alamofire** (validators/streams/finishHandlers/requestsToRetry), **0 on every non-closure-collection control**. Surfaced inline in `trace` + as an explore "Dynamic-dispatch links" section (so it shows when the agent named only `validate`, not the `didCompleteTask` that drains the list). Forced codegraph-only: **3/3** build/send/validate correct. + **trace endpoint relevance** (`nodeRelevance`): overloaded `request`/`task` (44/8 defs, mostly empty `EventMonitor` delegate stubs) now resolve to the real `Session.request`, not a 1-line no-op — **WITH-arm tool calls 12→8 median, read variance 012→14** (the meltdowns were all the trace-collision flounder); control-safe (excalidraw/okhttp/gin traces intact, gin A/B 0 reads). + **god-file multi-phase rendering** (`handleExplore`): a flow whose necessary code spans a god-file (Session.swift build chain ~11K) PLUS other files (validate logic) used to truncate at the fixed `maxOutputChars` and drop whichever phase came last. Six coordinated layers make it render all phases: (1) on-spine god-files render spine-full + off-path methods as signatures (true-spine), (2) every NAMED token's substantive def is seeded into the subgraph (FTS buried `validate` under the build terms → Validation.swift was never gathered), (3) a file that DEFINES a named symbol outranks one that merely references the flow (Validation=50 > incidental Combine=23), (4) the 90%-budget early-break and (5) the total cap both exempt necessary (named/spine) files — incidental files stay capped, (6) the final ceiling is 1.5× so it doesn't slice the necessary content the loop assembled. Alamofire now renders build+validators-exec+validate in ONE explore (~16K); A/B reads med 2→**0.5**, tools 8→**5.5**; excalidraw control held at 0 reads (no bloat). Sequential-flow spine is irreducible (no redundant siblings to collapse) — the fix is to render it, not cap it. |
| C# | ASP.NET Core | request → [Http*] action → DI service → EF | X | ✅ **feature-folder detection** (realworld 0→19 — was undetected) + **bare `[HttpGet]` + class `[Route]` prefix** (eShopOnWeb 9→33 / jellyfin L) — co-located so no claimsReference needed. 🔬 EF Core LINQ/DbSet (metaprogramming frontier) |
| Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ **RESTful `resources`/`resource` routing → controller#action** (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise `controller#action` too. 🔬 ActiveRecord dynamic finders (`Article.find_by_slug`) — metaprogramming frontier |
| PHP | Laravel | request → route → controller → Eloquent | R | ✅ **precise `Route::get([Ctrl::class,'m'])` / `'Ctrl@m'` → Ctrl@method** (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every `index`→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) |
| PHP | Drupal | request → *.routing.yml → _controller/_form | R | ✅ **`claimsReference` for FQCN handlers** (`\Drupal\…\Class::method` passed the pre-filter only because the `::method` name was known; bare `_form` FQCNs `\…\FormClass` and single-colon `Class:method` controller-services were dropped before resolve()) + **single-colon controller match** + **detect via composer `type:drupal-*` / `name:drupal/*` + `*.info.yml` fallback** (a contrib module with empty `require` was undetected → 0 routes). admin_toolbar S **0→14 (14/14)** / webform M 208 (**144**) / core L 836 (536→**731, 87%**). Remainder is the **entity-annotation handler frontier** (`_entity_form: type.op` resolves via the entity's PHP `#[ContentEntityType]` handlers, not a direct class). 🔬 **OOP `#[Hook]` attributes** — Drupal 11 moved ~all procedural hooks to attribute methods (core: 418 `#[Hook]` files vs 3 procedural), so the resolver's docblock/`module_hook` detection is obsolete for modern core (0 hook edges) |
| C/C++ | C++ vtables / inheritance | virtual call → override; general direct dispatch | S + X | ✅ **general dispatch strong** (redis C **29k** cross-file calls / leveldb C++ **1.4k**) + **C++ inheritance extraction fix** (`base_class_clause` was unhandled, so C++ extends edges were missing — leveldb **219→298**) + **cpp-override synthesizer** (base virtual method → subclass override, gated to C++, capped — leveldb 12 precise: `Iterator::Next→MergingIterator`). 🔬 C callback structs (`s->fn()` → 422-way fan-out, too noisy to synthesize) + C++ pure-virtual base methods (`virtual void f()=0;` declarations aren't extracted as nodes, so those overrides can't bridge) |
| Dart | Flutter | setState → build; build → child widgets | S + X | ✅ **setState→build synthesizer** (Dart analog of react-render: a State method whose body calls `setState(` → `build`) gated to `.dart` + **foundational Dart method-range fix** — Dart models a method body as a *sibling* of the signature, so method nodes were signature-only (`end==start`); now `endLine` spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter `initState→build`, books `build→BookDetail/BookForm`; widget composition already static (compass_app `build→ErrorIndicator/HomeButton`). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + `Navigator.push(MaterialPageRoute(builder:))` nav routes |
| Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer |
| Erlang | OTP behaviours | request → behaviour dispatch (`Var:callback(...)` folds) → implementer callback | S | ✅ **behaviour-callback dispatch synthesizer** (`erlangBehaviourDispatchEdges`) — a behaviour declares `-callback fn/N`, implementers declare `-behaviour(B)`, and the framework dispatches through a VARIABLE module (`Handler:init`, `Middleware:execute` folds), a hop extraction deliberately leaves silent. Bridge: each `Var:fn(args)` site → every implementer of the ONE in-repo behaviour declaring (fn, site-arity) that defines+exports fn; a name+arity collision across behaviours bails (cowboy's `init/2` is declared by FIVE handler-flavored behaviours → correctly silent), and above the fan-out cap (24) the site is skipped entirely (ejabberd's `gen_mod`, ~230 mod_* implementers, stays a visibly dynamic boundary rather than 24 arbitrary edges). Behaviour discovery scans `-callback` decls in every module (not just `implements` targets) so implementer-less behaviours still gate ambiguity. Validated: cowboy S — 38 edges, all real contracts (middleware chain `cowboy_stream_h::execute → cowboy_router/cowboy_handler::execute`, stream-handler `init/data/early_error` folds → all 5 core + 2 test handlers, sub-protocol `upgrade`, `websocket_init`); ejabberd M — 598 edges (listener/auth/pubsub/MIX backends, max per-site fan-out 9); emqx L — 843 edges (gateway codec/channel families, max fan-out 20); **precision spot-check 36/36** (every sampled target declares the via-behaviour + exports the callback); node counts unchanged; erl-sample 0-control clean (dispatch with no valid implementer → no edge); index cost +~1.4s on emqx's 2,273 files. The cowboy request flow now connects END-TO-END in one explore: `cowboy_stream:init → [erlang behaviour] cowboy_stream_h:init → request_process → execute → [erlang behaviour] cowboy_handler:execute`. 🔬 gen_server registered-name cross-module targets (atom == module-name convention); the terminal `Handler:init` hop where multiple sub-protocol behaviours share the contract (genuinely ambiguous — the dispatch site's body is the answer) |
| Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler |
| Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith<First>:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) |
| JS × native | React Native legacy bridge | JS `NativeModules.X.fn(...)` → ObjC `RCT_EXPORT_METHOD` / Java/Kotlin `@ReactMethod` | R | ✅ **RN legacy bridge** — `frameworks/react-native.ts` parses `RCT_EXPORT_MODULE` (default-name from `RCT`-prefix-stripped class name) + `RCT_EXPORT_METHOD(selector:(...))` + `RCT_REMAP_METHOD(jsName, selector)` on the ObjC side and `@ReactMethod` + `getName()` literal on Java/Kotlin. AsyncStorage S **8/8 precise** (`setItem`→`legacy_multiSet`, etc.), react-native-firebase L **18 precise after `RCTEventEmitter` built-in blocklist** (initial 78 included 60 `addListener:`/`remove:` false positives — every emitter subclass declares those via `RCT_EXPORT_METHOD`, JS callers route through the `NativeEventEmitter` abstraction not the native method directly). 🔬 dynamic bridge keys (`NativeModules[someVar]`) — literal-key only |
| JS × native | React Native TurboModules | JS spec interface ↔ native impl | R (spec as ground truth) | ✅ partial — parses `TurboModuleRegistry.get*<Spec>('Name')` + the `Spec` interface methods. Each spec method matches to a native impl by selector first-keyword (ObjC) / identifier (JVM). react-native-svg S **9 precise** (`getTotalLength`, `getPointAtLength`, `getCTM`, `isPointInFill`, …) bridging to Java impls (the iOS side is Codegen-auto-generated without `RCT_EXPORT_METHOD` declarations). 🔬 TurboModule native impl classes that don't use legacy macros (RNSvg iOS — would need inheritance-aware bridging via the Codegen-generated `NativeFooSpec` superclass) |
| ObjC/Java/Kotlin → JS | React Native event emitters | native `sendEventWithName:`/`emit(...)` → JS `addListener('e', handler)` | S (cross-lang channel) | ✅ **rn-event-channel synthesizer** — matches ObjC `sendEventWithName:@"X"`, Swift `sendEvent(withName: "X", ...)`, and JVM `.emit("X", ...)` to JS `addListener('X', handler)` keyed by literal event name. Same fan-out cap (`EVENT_FANOUT_CAP=6`) as in-language channel. **Subscribe-wrapper fallback** for RN-library APIs (`const Foo = { watchX(listener) { addListener('e', listener) } }`) — when the handler arg is a parameter, falls back to the enclosing function and then the enclosing `constant`/`variable` (reachability-correct attribution to the JS API surface). RNFirebase L **3 push-notification flow edges** (UIApplicationDelegate → JS `onMessage`/`onNotificationOpenedApp`), RNGeolocation S **2 location-event edges** (Swift `onLocationChange`/`onLocationError` → JS `Geolocation`). 🔬 inline arrow handlers `addListener('e', d => …)` (anonymous frontier) |
| JS × Swift/Kotlin | Expo Modules | JS `requireNativeModule('X').fn(...)` → Swift/Kotlin `Function("fn") { ... }` | R (extract → synthetic method nodes) | ✅ **expo-modules framework extractor** — parses Swift/Kotlin `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } }` literals and synthesizes `method` nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate `resolve()` needed). expo-haptics S **6 method nodes** (`notificationAsync`, `impactAsync`, `selectionAsync` × Swift + Kotlin), expo-camera M **41** (full SDK surface incl. `takePictureAsync`, `record`, `scanFromURLAsync`, view props `width`/`height`), expo SDK sweep L **134** (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (`CameraView.tsx`'s `pausePreview` wraps native `pausePreview`); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) |
| JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX `<MyView prop={v}/>` → Codegen spec → native class (or Paper `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp`) | R (extract) + S (native-impl) + JSX | ✅ **fabric-view extractor + fabric-native-impl synthesizer** — extractor parses **both** modern Codegen TS specs (`codegenNativeComponent<NativeProps>('Name', ...)`) **and** legacy Paper view-manager macros (`RCT_EXPORT_VIEW_PROPERTY` on ObjC, `@ReactProp` on Java/Kotlin). Emits a `component` node per declaration + a `property` node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (`exact`/`View`/`ComponentView`/`Manager`/`ViewManager`). Combined with `reactJsxChildEdges`, full consumer flow: JSX `<MyView/>` → fabric `component` → native class. Validated on RNSegmentedControl S **(legacy Paper) 1 component + 11 props + 4 bridges**, RNScreens M **(pure Codegen) 27 components + 272 props + 68 bridges** (was 0 before Phase 6), RNSkia L **(hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC**. **Monorepo detect** added: probes `packages/<sub>/package.json` etc. via `listDirectories` when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (`onTap={cb}`) — JSX attribute extraction needed |
(Verify the exact supported set against `src/extraction/languages/` and
`src/resolution/frameworks/` before starting — this table is a starting point.)
---
## 7. Known limits & gotchas (from the excalidraw/django work)
- **Coverage enables, doesn't force, the no-read path.** Agents still read to *confirm
source* sometimes; cost stays ~flat (codegraph calls trade for reads). The reliable
win is **completeness** + making Read-0 *possible*. Don't expect a guaranteed cost drop.
- **Vue (validated 2026-05-23, vitepress S / vben M / element-plus L).** SFC `<template>`
is unparsed by the extractor, so template usage needs synthesis (`vueTemplateEdges`):
`@click="fn"` → handler, kebab `<el-button>` → `ElButton`. PascalCase `<Child/>` is
already covered by the JSX channel (the SFC component node spans the template). Result:
agent reads drop in every size (vben login 13 vs 411), **strongest where handlers are
local functions** (vben `handleLogin`/`handleSubmit`).
**Composable-destructure handlers RESOLVED:** `@click="closeSidebar"` where
`const { close: closeSidebar } = useSidebarControl()` now follows alias → composable →
the returned `close` fn (when it's defined in the composable's file). vitepress sidebar
flow dropped **6 → 0 reads** (best case). Precise-only — no fallback to the composable
itself (the static `useX()` call edge already covers that), so it adds nothing where the
returned fn can't be located (e.g. re-exported / external composable). Remaining limits:
**prefix-convention kebab** — element-plus `el-button` → `button.vue` (component named
`button`, not `ElButton`), so kebab stays unresolved there; and **reactive→render**
(vue-core Proxy runtime) — the deep framework-internal frontier, deferred.
- **Svelte / SvelteKit (validated 2026-05-23, realworld S / skeleton M / shadcn L) — already well-covered.**
Unlike Vue, the `.svelte` extractor already parses the template: `extractTemplateCalls` (`{fn()}`),
`extractTemplateComponents` (`<Pascal/>` composition — skeleton 956 / shadcn 1610 reference edges),
plus `import * as api` namespace + `load`→api resolution all work. Agent A/B (realworld login): with
codegraph **1 read** vs without **4** — codegraph already wins out of the box. The one extraction gap
was **object-of-functions** (`export const actions = { default: async () => {} }`; the walker
deliberately skips object-literal functions to avoid inline-object noise). Fixed for EXPORTED consts
(general — Redux/Express handler maps too); `extractFunction` `nameOverride` keeps inline-object arrows
skipped. **Residual:** a `$lib`-alias namespace call (`api.post`) from an extracted action node doesn't
resolve even though the same alias resolves for `load` — a deeper resolver interaction, deferred
(local/relative calls from actions connect). **Lesson: measure before assuming a hole** — modern Svelte
barely uses `on:click={fn}` (form actions / callback props instead), so the assumed event-handler hole
wasn't the real one; Svelte needed far less than Vue.
- **Express / Koa (validated 2026-05-23, realworld S / parse M / ghost L) — high-value inline-handler fix.**
The resolver already handled named handlers, middleware, and `XController.method`/`XService.method`.
The real hole was **inline arrow route handlers** (`router.post('/x', async (req,res) => {...})` — the
dominant modern pattern): the handler regex `[^)]+` broke on the arrow's `)`, so the route connected to
NOTHING and the anonymous handler's body (the request→service flow) was lost. The entire inline-handler
API was unreachable (realworld `POST /users/login` → 0 edges). Fixed (`frameworks/express.ts`): span the
call with a string-aware balanced scan; for inline arrows, extract the body's calls (RESERVED-filtered to
drop res/req/builtins) and attribute them to the route node → realworld **19** / ghost **65** precise
route→service edges (POST /users/login→login, POST /articles→createArticle, …), no node explosion,
framework-scoped (zero blast radius off Express). **Deterministic win is clear; the agent A/B is muddied
by repo characteristics** — realworld (39 files) is below the size where codegraph beats reading, and
Ghost's layered custom-API architecture makes both arms thrash. Residual: **custom routers** — payload's
6.4k-file codebase had 0 routes (its router abstraction isn't `app.get`-style, so undetected). Lesson
inverse of Svelte: Express's dominant pattern WAS the uncovered one, so it needed real work like Vue.
- **NestJS (validated 2026-05-23, realworld S / immich M-L / amplication L) — already well-covered.** The
`nestjs` resolver handles @decorator routes (HTTP/GraphQL/microservice/WS). DI controller→service
(`this.svc.method()`) resolves correctly **even at scale** — every immich controller→service edge hit the
right same-module service (`addUsersToAlbum→addUsers`, `getMyApiKey→getMine`, `copyAsset→copy`) via
name + co-location, no type_of edge needed. Agent A/B (immich album flow): codegraph **eliminated Grep
(0 vs 3)** tracing route→controller→service. No dynamic-dispatch hole. One GENERAL hygiene gap surfaced
(not NestJS-specific): the realworld example **commits its `dist/`** build output, which codegraph indexes
(246 dup nodes) because the file walk only respects `.gitignore` with no default build-dir ignore. Real
apps (immich/amplication) gitignore `dist/` (0 dup nodes), so it's narrow — a default ignore for
`dist/build/out/.next/coverage` is a clean follow-up, deferred (core-indexer change, the user's call).
- **Rails (validated 2026-05-23, realworld S / spree M / forem L) — high-value RESTful-routing fix.** The
`rails` resolver only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps (the dominant
pattern) had ZERO route nodes (realworld + spree). Fixed (`frameworks/ruby.ts`): expand `resources :x` /
`resource :x` into their RESTful actions (only/except filters + pluralization for the singular `resource`),
reference a precise `controller#action`, and resolve that to the action method in `<ctrl>_controller.rb`
(explicit routes fixed too — they referenced a bare ambiguous `action`). realworld **0→16**, forem
**0→635** precise route→action edges. Agent A/B (forem comment-creation, large): codegraph **14 reads /
0 grep / 4753s** vs without **45 reads / 23 grep / 6685s** — fewer reads, no grep, faster. **The
`claimsReference` pre-filter was the gotcha:** `articles#index` names no declared symbol, so `resolveOne`
dropped it before `resolve()` ran — needed the same claim hook as the django ORM work. Residuals: **Rails
Engine routing** (spree still 0 — it mounts an engine, not `config/routes.rb` resources); ActiveRecord
dynamic finders (`Article.find_by_slug` — metaprogramming frontier).
- **Spring/MyBatis enterprise flow (validated 2026-05-26, mall-tiny S — closes #389).** Three holes that left
the canonical enterprise-Java chain (`HTTP route → Controller → BO/Service → ServiceImpl → DAO/Mapper →
MyBatis XML SQL`) broken at multiple hops on real Spring projects.
1. **Field-injected concrete-bean trace.** Java's `this.userbo.toLogin2()` parsed as `method_invocation(
object=field_access(this, userbo))`. The extractor surfaced `this.userbo.toLogin2` verbatim and the
name-matcher's single-dot regex couldn't unwrap it; even if it had, `userbo` doesn't capitalize cleanly
to `UserBO` (the JVM naming heuristic in `matchMethodCall.Strategy2`) so the receiver-typed lookup also
missed. Fix is in the language layer, not Spring-specific: (a) extractor unwraps `field_access(this, X)`
to use `X` as the receiver (`src/extraction/tree-sitter.ts`); (b) `matchMethodCall` learns to look up
the receiver name as a field declaration in the enclosing class and use the field's `signature`-stored
declared type (`inferJavaFieldReceiverType` in `src/resolution/name-matcher.ts`). Repro confirmed on the
issue's exact example: `UserAction.toLogin2 → UserBO.toLogin2` edge appeared (was 0 outgoing edges).
2. **MyBatis XML mapper indexing + Java↔XML bridge.** `*.xml` is now a language (`xml`), with a custom
extractor (`src/extraction/mybatis-extractor.ts`) that emits one method-shaped node per `<select|insert|
update|delete|sql id="X">` qualified as `<namespace>::<id>`, plus `<include refid="X"/>` → `<sql>`
fragment refs. Non-mapper XML (pom, log4j, web.xml) emits only a file node — no symbol noise. A new
synthesizer (`mybatisJavaXmlEdges` in `callback-synthesizer.ts`) indexes Java methods by
`<ClassName>::<methodName>` and joins them to the XML qualified names by suffix-match. Ambiguous
simple-name collisions are dropped (precision over recall). mall-tiny: 6/6 custom-SQL mapper methods
bridge to their `<select>` statements; full chain `trace(UmsRoleController.listResource → UmsResource
Mapper::getResourceListByRoleId(xml))` connects in 4 hops across controller/service/impl/mapper/XML.
3. **Spring config-key linkage.** `application.{yml,yaml,properties}` + profile variants
(`application-dev.yml`, `bootstrap.yml`, etc.) parse on the framework path. Leaf YAML keys + every
`.properties` line become `constant` nodes qualified by their dotted path. `@Value("${k}")` /
`@Value("${k:default}")` and `@ConfigurationProperties(prefix="X")` emit binding nodes that resolve to
the matching key (or, for prefix, the closest key under it). **Relaxed binding** (kebab `cache-list`
↔ camel `cacheList` ↔ snake `cache_list` ↔ `CACHE_LIST`) handled via canonical-form match. mall-tiny:
11/11 `@Value` annotations resolved (incl. `secure.ignored` `@ConfigurationProperties` prefix).
Coverage frontier: cross-module XML statement references (`<include refid="other.X">` to a fragment in
another mapper file — works when the include uses the dotted namespace form); `@PropertySource` external
property files; Spring Cloud Config (remote properties); ambiguous mapper-name collisions across packages
(Java mapper `com.a.X` and `com.b.X` both with `selectOne` — currently dropped to avoid mis-resolving).
- **Spring (validated 2026-05-23, realworld S / mall M / halo L) — bare-mapping + class-prefix routing fix.**
The resolver required a string path in the mapping regex, so BARE method mappings (`@PostMapping` with the
path on the class `@RequestMapping`) — the dominant multi-method-controller pattern — were missed (halo
had 28 routes for 2444 files; realworld's 2-action favorite controller linked only one). Fix
(`frameworks/java.ts`): treat class `@RequestMapping` as a PREFIX (joined, not a bogus route); match
verb-specific mappings BARE-or-with-path; also handle method-level `@RequestMapping(method=...)` (older
style). realworld 13→19, mall →246 precise route→method (class prefix joined); DI controller→service
resolves (`article→findBySlug`). Agent A/B (mall cart flow): with codegraph 0 reads/0 grep vs without 2/2.
**A first cut regressed mall 292→1** by dropping `@RequestMapping`-on-method — *caught by the cross-repo
route-count check*; the playbook's regression guard earns its keep. Residuals: halo's custom patterns
(9/29 resolve); Spring Data JPA derived queries (metaprogramming frontier).
- **Django / DRF (validated 2026-05-23, realworld S / wagtail M / saleor L) — mostly covered + a DRF-router
fix.** The ORM (`_iterable_class`→ModelIterable, the original investigation) and URL routing
(`path`/`url`/`as_view`→view) were already done. The one hole: **DRF `router.register(r'articles',
ArticleViewSet)`** (the core CRUD endpoints) wasn't extracted — only `path()`/`url()` were. Fix
(`frameworks/python.ts`): match `router.register` (the STRING first arg separates it from
`admin.register(Model, Admin)`, whose first arg is a model class) → route→ViewSet class. Narrow in this
corpus (realworld has 1 router; wagtail uses `path()`, saleor is GraphQL) but real for DRF-router APIs.
Agent A/B (wagtail Page flow, medium): codegraph **47 reads / 14 grep / 5881s** vs without **79 reads
/ 6 grep / 8286s** — fewer reads, fewer greps, faster. No regression (wagtail/saleor route counts
unchanged — purely additive). Residuals: signals (`post_save`→receiver), DRF viewset CRUD actions
(inherited from the base class, not in the user's ViewSet), saleor's GraphQL resolvers.
- **Laravel (validated 2026-05-23, realworld S / firefly M / bookstack L) — route precision fix.** The
resolver discarded the controller from the handler: `Route::get([UserController::class,'index'])` /
`'UserController@index'` emitted a BARE `index` ref, which name-matching mis-resolved to the WRONG
controller (every `index`/`show` → whichever it found first; realworld GET user → ArticleController.index,
should be UserController). Fix (`frameworks/laravel.ts`): emit precise `Controller@method` (array + string
syntax, namespace-stripped) + `claimsReference` it past the pre-filter → existing Pattern-4
`resolveControllerMethod`. realworld all routes correct; bookstack 267/332 precise (GET pages →
PageApiController.list). Agent A/B (bookstack page-view, large): codegraph **23 reads / 12 grep /
5160s** vs without **46 / 35 / 6074s**. No node explosion. Residuals: firefly resolves only 3/568
(its fluent `->uses()` / `['uses'=>...]` handler format isn't parsed); Eloquent dynamic finders
(metaprogramming frontier).
- **Gin / chi (validated 2026-05-23, realworld S / gin-vue-admin M / gitness L) — group-var routing fix.**
The route regex matched only `(router|r|mux|app|e).METHOD(...)`, but real apps route on GROUP vars
(`v1.GET`, `PublicGroup.GET`, `userRouter.POST`), so group-routed apps connected almost nothing
(gin-vue-admin: **4 routes for 625 files**). Fix (`frameworks/go.ts`): broaden the receiver to ANY
identifier — the verb + string-path + handler-arg gates keep it route-specific (`http.Get(url)` has no
handler arg → excluded). gin-vue-admin **4→259** routes (257 resolve precisely: `POST createInfo →
CreateInfo`); realworld stable (no regression); no garbage. **Agent A/B (create-user flow): codegraph
0 reads / 0 grep / 2630s vs without 3 / 3 / 5253s — cleanest backend win yet (0/0, 2× faster).**
Residuals: inline `func(c *gin.Context){}` handlers (anonymous, body lost — like Express before its fix);
gitness's chi custom handlers (26/321).
- **ASP.NET Core (validated 2026-05-23, realworld S / eShopOnWeb M / jellyfin L) — detection + bare-attribute
fix.** Two holes: (1) `detect()` only fired on a `/Controllers/` dir or root `Program.cs`/`.csproj` (which
often isn't in the indexed source set), so feature-folder apps (realworld: `Features/*/FooController.cs`,
subdir `Program.cs`) were NEVER detected → 0 routes despite a full controller set. Broaden: scan
Controller/Program/Startup `.cs` for ASP.NET signatures. (2) the attribute regex required a string path →
bare `[HttpGet]` (route on the class `[Route("[controller]")]`) missed (eShopOnWeb was 24 bare / 2
string). Match bare-or-path + join the class `[Route]` prefix (like Spring). **No `claimsReference`
needed** — ASP.NET attribute routes are co-located IN the controller with the action, so the bare method
ref resolves same-file (unlike Rails/Laravel, whose routes live in a separate file). realworld 0→19,
eShopOnWeb 9→33, jellyfin 362→399, all precise (`GET /articles → Get`, class prefix joined), no explosion.
Agent A/B (eShop catalog listing): codegraph **12 reads / 0 grep / 6375s** vs without **67 / 16 /
7779s**. Residual: EF Core LINQ/DbSet (metaprogramming frontier).
- **Flask / FastAPI (validated 2026-05-23, fastapi-realworld S / flask-microblog S / Netflix dispatch L /
redash L) — decorator-extraction + builtin-name fixes.** Routes were extracted but the request→route→handler
flow broke at two regex assumptions and one resolver filter. (1) **Flask required `def` immediately after
`@x.route(...)`**, so any intervening decorator (`@login_required`, `@cache.cached`) or **stacked `@x.route`
lines** (one view bound to several URLs) dropped the route — microblog extracted **6 of 27** real routes.
Switched Flask to FastAPI's `findHandler` scan (match the decorator, then find the next `def`), skipping
intervening decorators: **6→27**, all resolved. (2) **FastAPI's path regex `[^'"]+` rejected the empty path**
`@router.get("")` (router/prefix-root routes, frequently multi-line) → realworld lost 8 endpoints (list/create
article, comments, login/register). `[^'"]+`→`[^'"]*` + empty-path name guard: realworld **12→20**, Netflix
dispatch **290/290 (100%)**. (3) **Bare-name builtin guard** (`src/resolution/index.ts`): a handler named
after a Python builtin *method* (`index`, `get`, `update`, `count`…) was filtered by `isBuiltInOrExternal`
and lost its route→handler edge — microblog's `index` view (its `/` + `/index` stacked routes) resolved to
nothing. The dotted-method branch already had a `knownNames` guard; mirrored it onto the bare branch (a name
a declared symbol owns is not a builtin call). +2 legit edges on realworld, **0 change on the django control**
(302/373 identical — precision held). Flows trace end-to-end (`login → get_user_by_email` 2 hops;
`create_user → from_dict`). Agent A/B (realworld login-auth flow, n=2/arm): codegraph **01 read / 0 grep /
34 codegraph / 3039s** (context→[search]→trace→node) vs without **3 read / 2 grep / 3336s** — eliminates
grep, cuts reads to 01 (small repo, so wall-clock ties; the tool-count drop is the win). Residuals: **Flask-RESTful** class-based
`api.add_resource(Resource,'/x')` (redash's actual API shape — a separate class-method-as-verb mechanism, NOT
the README's documented decorator/blueprint Flask) and a pre-existing **JS file-route false-positive** in
redash's React frontend (32 bogus `.js` "routes" from a JS resolver — unrelated to Python). **Lesson: the
builtin-name filter is a silent precision tax across Python** — any view/function named `get`/`index`/`update`
loses edges; the fix is general (helps Django/DRF handlers too), not Flask-specific.
- **Drupal (validated 2026-05-23, admin_toolbar S / webform M / drupal-core L) — pre-filter + detection fixes.**
The `*.routing.yml` extractor and the `_controller`/`_form` resolver already existed but two gaps kept most
routes unlinked. (1) **The `claimsReference` pre-filter gotcha (again):** Drupal handler refs are FQCNs
(`\Drupal\…\Class::method`), bare form classes (`\…\SettingsForm`), or single-colon controller-services
(`\…\Controller:method`). Only the `::method` shape survived `resolveOne`'s pre-filter (its `member` is a
known method name); the bare-FQCN forms and single-colon controllers named no declared symbol and were
dropped before `resolve()` ran. Added `claimsReference` (FQCN / `Class:method` / `hook_*`) + a single-colon
branch in the controller regex → core **536→731 of 836 routes (87%)**; all three previously-broken shapes now
resolve (`/admin/content/comment`→CommentAdminOverview form, `/big_pipe/no-js`→setNoJsCookie controller).
(2) **Detection missed standalone contrib modules:** `detect()` only checked composer `require` for a
`drupal/*` dep, but a contrib module often has an EMPTY `require` and is identified only by
`"name":"drupal/<m>"` + `"type":"drupal-module"` (admin_toolbar → 0 routes). Broadened to composer name/type
+ a `*.info.yml` fallback → admin_toolbar **0→14 (14/14)**. Canonical flow traverses (`getAnnouncements` ←
`/admin/announcements_feed`); node count unchanged (resolution-only). Agent A/B (dblog route→controller,
n=2/arm): codegraph **0 read / 1 grep / 2022s** vs without **1 read / 2 grep + glob / 2832s** — fewer
tools and faster on the ~10k-file core. **Residuals (frontier):**
entity-annotation handlers (`_entity_form: comment.default` → handler classes declared in the entity's
`#[ContentEntityType]` annotation, not a direct ref — ~78 of core's ~105 remaining unresolved) and **OOP
`#[Hook]` attributes** — Drupal 11 converted nearly all procedural hooks to `#[Hook('event')]` methods (core:
418 attribute files vs 3 procedural `*.module` hooks), so the resolver's procedural-hook detection (docblock
`@Implements` / `module_hook` naming) finds essentially nothing in modern core (0 hook edges). Both are real
follow-ups, not regressions.
- **Rust / Axum + Rocket + actix (validated 2026-05-23, realworld-axum S / actix-examples + Rocket M / crates.io L) — Axum chained-method + namespaced-handler fix.**
The attribute-macro path (`#[get("/x")] fn h`, actix/Rocket) and single Axum `.route("/x", get(h))` already
worked, but the Axum extractor used a flat regex that captured only the FIRST `method(handler)` of a route
and only a bare `\w+` handler. Two dominant Axum idioms broke it: (1) **method chains**
`.route("/user", get(get_current_user).put(update_user))` — the `.put` arm produced NO route node, so half
the API was missing (realworld-axum had only the GET of each chain); (2) **namespaced handlers**
`get(listing::feed_articles)` — `\w+` captured `listing` (the module), so the route resolved to nothing.
Rewrote with a balanced-paren scan of each `.route(...)` call, a per-method node, and last-`::`-segment
handler names → realworld-axum **12→19 routes, 19/19 resolved** (every chained PUT/DELETE/POST now present;
`feed_articles` resolves). **Rocket needed nothing** (550/556, 99% — attribute macros). crates.io confirms
namespaced axum handlers resolve (router.rs 6/6) but defines most of its API via the `utoipa_axum` `routes!`
macro (frontier) and has a SvelteKit frontend (42 of its 50 "routes" are `+page.svelte`, correctly
attributed to SvelteKit). Agent A/B (update-user flow,
n=2/arm): codegraph **02 read / 0 grep / 3240s** vs without **3 read / 01 grep + glob / 3341s** — modest
(realworld-axum is in the small-repo tie zone) but consistent, with one fully-clean 0-read/0-grep run. Node
count stable; the Axum fix is Axum-scoped (the attribute/actix/Rocket path is untouched).
- **Actix runtime routing (validated 2026-05-23, actix-examples) — the builder API was the dominant style and fully missed.**
Actix's attribute macros (`#[get("/x")] fn h`) were covered, but real actix apps route via the builder API:
`web::resource("/path").route(web::get().to(handler))`, `web::resource("/").to(handler)` (all methods), and
App-level `.route("/path", web::get().to(handler))`. The handler lives in `.to(handler)`, not `get(handler)`,
so the Axum `.route` scan extracted nothing for them — actix-examples had **80 `web::resource` calls** all
unlinked. Added an actix block: scan each `web::resource("/path")` (bounding its method chain at the next
resource to avoid bleed) for `web::METHOD().to(h)` pairs, fall back to a direct `.to(h)` (method `ANY`), plus
the App-level `.route("/x", web::METHOD().to(h))` form. actix-examples **51→128 routes, 35→112 resolved
(87.5%)** (`GET /user/{name}`→with_param, `POST /user`→add_user). No regression on Axum (realworld-axum still
19/19) — the actix patterns (`web::resource`/`web::method().to()`) don't appear in Axum code. **Residuals
(frontier):** `web::scope("/api")` prefixes aren't prepended to nested resource paths, and anonymous `.to(|req|
…)` closure handlers have no named target (the ~16 still-unresolved).
- **Swift / Vapor (validated 2026-05-23, vapor-template S / SteamPress M / SwiftPackageIndex-Server L) — the resolver was effectively dead on real apps.**
The Vapor extractor only matched `(app|router|routes).METHOD("path", use: handler)`, but modern Vapor routes
on a grouped builder inside `RouteCollection.boot(routes:)`: `let todos = routes.grouped("todos");
todos.get(use: index)` — any var receiver, NO path arg (the path is the group prefix). Every real app tested
extracted **0 routes** (template, penny-bot, Feather, SteamPress, SPI). Rewrote the extractor: (1) any
receiver `\w+` (not just app/router/routes); (2) optional path segments that may be non-string
(`User.parameter`, `:id`, a path constant) — the `use:` keyword is the discriminator separating a route from
`Environment.get("X")` / `req.parameters.get("X")`; (3) a group-prefix map from `let X = Y.grouped("a")` and
`Y.group("a") { X in }` so a route on a grouped/nested var gets the full path (`todo.delete(use: delete)` →
`DELETE /todos/:todoID`). Result: vapor-template **0→3 (3/3**, nested path exact), SteamPress **0→27
(27/27**, incl. `BlogPost.parameter` routes), SPI **0→14 (14/14** handler resolution). Canonical flow
traverses (`createPostHandler` ← `GET /createPost`, → `createPostView`). **Residuals (frontier):**
typed-route enums (SPI registers via `app.get(SiteURL.x.pathComponents, use:)` — handler resolves but the
path label is `/`, no string literal) and closure handlers (`app.get("hello") { req in }` — anonymous, no
named target). penny-bot (Discord bot) and Feather (custom module router) have no standard Vapor routing at
all — the Vapor ecosystem's routing styles vary widely. Agent A/B (create-post flow, n=2/arm): codegraph
**0 read / 0 grep / 4 codegraph / 2630s** (both runs fully clean) vs without **14 read / 02 grep +
glob/bash, one run spawned a sub-agent / 3448s**. Node count stable; fix is Vapor-scoped (SwiftUI/UIKit
untouched).
- **React Router routing (validated 2026-05-23, react-realworld S) — the routing half of the React row.**
React rendering (state→render, jsx-child) was already covered; route→component was NOT — `react.ts` extracted
components/hooks and Next.js file routes but returned `references: []`, so `<Route>` declarations produced
nothing. Added `<Route>` JSX extraction: scan a window after each `<Route\b` (so the nested `>` in
`element={<Comp/>}` doesn't truncate it), pull `path="…"` + `component={C}` (v5) or `element={<C/>}` (v6) in
any attribute order, emit a route node + component reference (resolves via the existing PascalCase
`resolveComponent`). react-realworld **0→10, 10/10** (`/login`→Login, `/editor/:slug`→Editor,
`/@:username`→Profile); `<Routes>` container excluded via the `\b` boundary. No regression on excalidraw
(9,290 nodes, 46 react-render synth edges intact, 0 false routes). 🔬 the object **data-router** API
`createBrowserRouter([{ path, element }])` (modern v6, used by bulletproof-react) is object-based not JSX — a
separate frontier; plus a pre-existing Next.js false-positive (`*.config.mjs` in a `pages/` app dir treated
as a route).
- **Dart / Flutter (validated 2026-05-23, flutter/samples: counter S / books S / compass_app M) — synthesizer + a foundational extractor fix.**
Flutter's reactive hop is `setState(() {…})` re-running `build(context)` — framework-internal, no static edge,
so "tap → handler → setState → rebuilt UI" dead-ends at setState (the Dart analog of React's setState→render).
Added a `flutter-build` synthesizer channel (Phase 4b): for each Dart class with a `build` method, link every
sibling method whose body calls `setState(` → `build` (gated to `.dart`). **But it was blocked by a
foundational gap:** Dart models a method body as a *sibling* of the `method_signature` node, so every Dart
method node had `endLine == startLine` (signature only) — `sliceLines(start,end)` saw only `void f() {`, never
the body. Fixed in the shared `createNode`: when a function/method's resolved body sits beyond the node,
extend `endLine` to it (guarded — child-body grammars are a no-op; controls excalidraw 9,290 / django 302
unchanged). This fix is foundational, not Flutter-specific — every Dart callee/context/body scan was
previously truncated. Result: counter `initState→build`, books `initState→build` + `build→BookDetail/BookForm`.
**Widget composition needs no synthesis** — unlike JSX, Dart widgets are explicit constructor calls
(`BookDetail(...)`), already static (compass_app `build→ErrorIndicator/HomeButton/_Card`). **Residuals
(frontier):** MVVM state management (compass_app uses Command/ChangeNotifier + ListenableBuilder, 0 setState —
a different dispatch shape) and `Navigator.push(MaterialPageRoute(builder: (_) => DetailPage()))` navigation
(route-as-widget, uncovered).
- **Kotlin / Spring Boot + Jetpack Compose (validated 2026-05-23, spring-petclinic-kotlin S / compose-samples) — extend Spring to Kotlin; Compose is free.**
Kotlin had ZERO framework coverage — no resolver listed `kotlin`, and the Spring resolver was `languages:
['java']` with a `.java`-only extract gate and a Java-syntax handler regex (`public X name()`). So Spring Boot
Kotlin apps (identical `@GetMapping`/`@RestController` annotations, `.kt` files) extracted 0 routes. Extended
the Spring resolver: `['java','kotlin']`, accept `.kt`, and add a Kotlin `fun name(` alternative to the
handler-method regex (Kotlin has no access modifier and the return type follows the name). petclinic-kotlin
**0→18, 18/18**; class `@RequestMapping` prefixes join, stacked annotations (`@ResponseBody`) are skipped, DI
controller→repo resolves (`showOwner ← GET /owners/{ownerId}` → `OwnerRepository.findById` /
`VisitRepository.findByPetId`). Java Spring unchanged (realworld 19/19 — the Kotlin `fun` and Java `public X`
alternatives are disjoint per language). **Jetpack Compose composition needs no work** — `@Composable`
functions calling child `@Composable`s are plain Kotlin function calls, already static (Jetcaster
`PodcastInformation→HtmlTextContainer`, `FollowedPodcastCarouselItem→PodcastImage`), like Dart widget
constructors. Agent A/B (view-owner flow, n=2/arm): codegraph **01 read / 0 grep / 1 codegraph / 1118s** (a
single `context` call answers it) vs without **2 read / 01 grep + glob / 2028s**. **Residuals (frontier):**
Ktor `routing { get("/x") { … } }` inline-lambda handlers (anonymous,
no named target), Compose recomposition (implicit — reading `mutableStateOf` triggers recompose, no
`setState`-style gate to anchor a synthesizer), and coroutines/Flow dispatch.
- **Lua / Luau (validated 2026-05-23, telescope.nvim / lualine.nvim / Knit — measure-first, already covered).**
The matrix guessed "event/callback dispatch (synthesizer)", but measurement says otherwise: real Neovim
plugins are MODULE-dispatch-heavy (`local m = require('telescope.actions'); m.fn()`), and codegraph's general
`require`-import + cross-file name resolution already handles it — telescope.nvim has **220 resolved imports
and 335 cross-file `module.fn` call edges**, and a flow traces end-to-end (`map_entries ← init.lua →
get_current_picker` in actions/state.lua). The Luau extractor already handles Roblox instance-path requires
(`require(game:GetService("ReplicatedStorage").Packages.Knit)`). **The assumed hole isn't real** — like
Svelte/NestJS. The genuine frontier is event-callback registration (`vim.keymap.set(mode, lhs, fn)`, autocmd
`{callback=fn}`, Roblox `signal:Connect(fn)`), but it's predominantly INLINE anonymous closures (corpus: ~12
inline `:Connect(function…)` vs ~2 named), and telescope's keymaps are inline functions or vim-command
STRINGS, not named refs. A named-only callback synthesizer would cover a tiny fraction, so per "measure before
building / partial coverage is worse than none", none was built — no code change; recorded as validated.
Agent A/B (actions.utils map flow, n=2/arm): codegraph **0 read / 0 grep / 1824s** vs without **1 read
(+glob) / 2425s** — small flow so modest, but the 0-read confirms the module dispatch is navigable.
- **Scala / Play (validated 2026-05-23, play-samples: computer-database / starter / rest-api) — Play conf/routes → controller.**
Scala's general dispatch (controller→DAO) already resolves, but Play declares routes in an EXTENSIONLESS
`conf/routes` file (`GET /computers controllers.Application.list(p: Int ?= 0)`) the file walk never indexed
(`isSourceFile` requires an extension). Added a narrow opt-in (`isPlayRoutesFile`: `conf/routes` / `*.routes`)
routed through the no-grammar (yaml-style) path, plus a Play resolver that parses each
`METHOD /path Controller.action(args)` line (dropping package prefix + args) and resolves `Controller.action`
to the action method in that controller class. computer-database **0→8 routes, 7/8** (the 1 unresolved is
`controllers.Assets.versioned` — Play's framework Assets controller, external), starter 0→4 (3/4). The flow
connects request→route→controller→DAO. A/B (list-computers, n=2/arm): codegraph **0 read / 0 grep / 3
codegraph / 1722s** vs without **23 read / 12 grep + glob / 1617s**. **No-regression:** the file-walk
change only ADDS Play routes files (narrow match) — excalidraw 9,290 and the full suite (800) unchanged.
**Residuals (frontier):** Play SIRD programmatic routers (`-> /v1 v1.PostRouter` include + `case GET(p"/x")`
in a Router class — rest-api-example) and Akka actor message→handler (`receive { case Msg => … }` /
`Behaviors.receiveMessage` — untyped, a synthesizer shape).
- **C / C++ (validated 2026-05-23, redis C / leveldb C++) — general dispatch works; a C++ inheritance fix + override bridge.**
Measure-first: C/C++ DIRECT dispatch is excellent out of the box (redis **29,464 cross-file call edges**,
leveldb **1,462**) — the bulk of the value. The dynamic-dispatch frontier is two shapes: (1) C callback
structs (`struct {.proc=fn}` + `cmd->proc()`) — but in redis the `proc` field fans out to **422** command
functions, far too noisy to synthesize precisely, so deliberately skipped (per "partial coverage worse than
none"). (2) C++ vtables (`iter->Next()` → the subclass override). The override link was blocked upstream:
`extractInheritance` handled `base_clause` (PHP) but not C++'s `base_class_clause`, so C++ `extends` edges
were missing/partial (leveldb 219→**298** after the fix). Added a `cpp-override` synthesizer channel (the C++
analog of react-render): for each `extends` edge, link each base method → the subclass method of the same
name, so trace/callees from the interface method reach the implementation. leveldb **12 precise edges**
(`Iterator::Next/Seek/Prev → MergingIterator`), 0 on C (redis) and TS (excalidraw — gated to C++); the C++
override integration test passes. **Residual (frontier):** pure-virtual base methods (`virtual void Next() =
0;`) are declarations the extractor doesn't emit as nodes, so overrides of a purely-abstract interface can't
be bridged (only bases with a real method node — an inline default or non-pure virtual); plus the C
callback-struct fan-out. Relied on deterministic validation (no A/B): the cross-file-call counts + precise
override spot-check are conclusive.
- **Frontier pass (2026-05-23) — tractable partials closed, noise/hard ones deliberately left.** After the main
sweep, swept the documented frontiers and triaged by precision/value. **DONE:** React Router object
data-router (literal `createBrowserRouter([{path, element}])`); Next.js route false-positives (config files +
`nextjs-pages/` substring → require a real page ext + path-segment match; bulletproof 4→0); Flask-RESTful
`add_resource`→Resource class (redash 6→**77**); Flask tuple `methods=(…)`; Flask detection broadened to
subdir/app-factory entrypoints (flask-realworld 0→**19**); gorilla/mux confirmed already covered (any-receiver
HandleFunc) + a test. **LEFT (with rationale, not punts):** C callback-struct dispatch (`cmd->proc()` →
422-way field fan-out = noise); metaprogramming finders (ActiveRecord/Eloquent/Spring-Data-JPA/EF — dynamic
naming, no static target); reactive runtimes (Vue Proxy / Compose recomposition — deep internals, no
setState-style gate); Akka actor message dispatch (untyped); pure anonymous inline closures (the def-use
frontier — no named target); React lazy data-router (variable paths + lazy imports); C++ pure-virtual base
methods (extracting bodyless decls risks duplicate decl/def nodes for modest gain). Forcing these would add
noise, violating "partial coverage worse than none."
- **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous
callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard
remaining gap** (no identity → need synthesizer link-through-body, not yet built).
- **Extraction changes are high blast radius.** The Phase-3 named-inline-callback
extraction is in the *shared* `tree-sitter.ts` walker — re-check **node counts across
several languages** after any extraction change (it held at +3 on excalidraw because
anonymous arrows are skipped).
- **Synthesizer precision guards:** registrar-name uniqueness, named-only handlers, and
an event **fan-out cap** (skip generic events like `error`/`change`). Receiver-type
matching (via `type_of` edges) is the planned precision upgrade — deferred.
- **As-built shortcuts** (callback synthesizer): pairs registrar/dispatcher by *file*+field
(class proxy), regex arg-recovery (named refs only), `provenance:'heuristic'` +
`metadata.synthesizedBy` (the enum has no `'callback-synthesis'`). See the design doc.
- **Synthesizer runs only in `resolveAndPersistBatched`** (full index) — wire into
`resolveAndPersist` for incremental sync before shipping.
- **Symbol ambiguity in `trace`:** common names (`render`, `execute_sql`) match many
nodes; trace picks among them and may start from the wrong one. Trace from the specific
method, not a class name.
---
## 8. Definition of done (the whole mission)
For each language × framework: the canonical flow `trace`s end-to-end, an agent can
answer the flow question with Read 0 in at least some runs with the glue present, no node
explosion, no regression — recorded in the matrix (§6) with the validating repo + numbers.
Then ship-prep: tests per mechanism, CHANGELOG, wire incremental, commit.
+226
View File
@@ -0,0 +1,226 @@
# Function-as-value capture (#756) — registration-linking for callbacks
**Problem.** A function used as a *value* — passed as an argument, assigned to a
function pointer or field, placed in a struct initializer or handler table —
produced **no edge** in any of the 19 tree-sitter languages (probed 2026-06-11;
0/19). `callers(my_recv_cb)` on a C callback showed nothing but direct calls, so
every registered callback looked dead, and the registration sites — the agent's
actual next question ("where is this wired up?") — were invisible.
**Non-goal, deliberate.** Resolving the *dispatch* (`o->cb(x)` → the concrete
registered function) needs data-flow through struct fields; even an LSP needs
fallbacks there (see the #756 thread). Partial coverage is worse than none and
a wrong edge is worse than silence — dispatch resolution stays uncovered. What
ships is the *registration* side, which is deterministic: the function's name
is literally in the source at the registration site.
## Mechanism
```
capture (tree-sitter.ts walkers, table-driven per language: src/extraction/function-ref.ts)
→ gate (flushFnRefCandidates: same-file fn/method name imported binding names;
C-family file-scope initializers skip the gate — see below)
→ unresolved ref, referenceKind 'function_ref' (internal-only kind)
→ resolution (resolveOne branch: resolveViaImport first, then matchFunctionRef —
exact name, function/method kinds only, same-family, same-file first,
cross-file only when UNIQUE, never fuzzy)
→ edge kind 'references', metadata { fnRef: true, resolvedBy, confidence }
```
`getCallers`/`getCallees`/`getImpactRadius` already traverse `references`, so
registration sites surface with no graph-layer changes. The MCP callers/callees
lists label them "via callback registration".
Capture fires from three walkers (a node is only ever visited by one):
`visitNode` (file/class scope), `visitForCallsAndStructure` (function bodies),
`visitPascalBlock` (Pascal bodies). Subtrees the walkers consume without
descending (top-level variable initializers, class field/property initializers,
custom `visitNode` hooks like Scala's val/var handler) get a candidates-only
`scanFnRefSubtree` that halts at nested function boundaries.
## Per-language value positions (probe-verified)
| Language | arg | assign RHS | keyed init | list/table | wrapper forms |
|---|---|---|---|---|---|
| C / ObjC | `argument_list` | `assignment_expression.right` | `initializer_pair.value` | `initializer_list`, `init_declarator.value` | `&fn` (`pointer_expression`), `@selector(...)` (ObjC) |
| C++ | **`&` forms only** in args/rhs/varinit | (same — explicit `&` only) | bare ids at FILE scope only | bare ids at FILE scope only | `&fn`, `&Cls::method` (resolved scoped to the class) |
| TS / JS (tsx/jsx) | `arguments` | `assignment_expression.right` | `pair.value` | `array`, `variable_declarator.value` | `this.method` (`member_expression`, class-scoped — see rule 3) |
| Python | `argument_list`, `keyword_argument.value` | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) |
| Go | `argument_list` | `assignment_statement` / `short_var_declaration` (`expression_list`) | `keyed_element` | `literal_value`, `var_spec.value` | — |
| Rust | `arguments` | `assignment_expression.right` | `field_initializer.value` | `array_expression`, `static_item` / `let_declaration.value` | — |
| Java | `argument_list` | `assignment_expression.right` | — | `variable_declarator.value` | `method_reference` (`Cls::m`, `this::m`) — the only form |
| Kotlin | `value_arguments` | `assignment` (last child) | — | — | `callable_reference` (`::f`), `navigation_expression` `this::m` |
| C# | `argument_list` (`argument`) | `assignment_expression.right` (incl. `+=`) | — | `initializer_expression`, `variable_declarator` | `this.M` (`member_access_expression`; vendored grammar keeps `this` anonymous — handled) |
| Ruby | `argument_list` | — | `pair.value` | — | only `method(:sym)` / `&method(:sym)` — bare ids are calls/locals in Ruby |
| Swift | `value_arguments` (`value_argument.value`) | `assignment.result` | (labeled ctor args = args) | `array_literal`, `property_declaration.value` | `#selector(...)` |
| Scala | `arguments` | `assignment_expression.right` | — | `val_definition.value` (via hook scan) | eta `fn _` (`postfix_expression`) |
| Dart | `arguments` (`argument`) | `assignment_expression.right` | `pair.value` | `list_literal`, `static_final_declaration` | — |
| Lua / Luau | `arguments` | `assignment_statement` (`expression_list.value`) | `field.value` (keyed + positional) | (same) | — |
| Pascal | `exprArgs` (via `visitPascalBlock`) | `assignment.rhs` (`OnFire := Handler`) | — | — | `@Handler` (`exprUnary.operand`) |
| PHP | string callables ONLY as args of known core HOFs (`usort`, `array_map`, `call_user_func*`… — `PHP_CALLABLE_HOFS`), ungated + unique-or-drop (PHP globals aren't imported) | — | — | — | `[$this, 'm']` → class-scoped `this.m`; `[Foo::class, 'm']` → qualified; `'Cls::m'` → qualified; first-class callable `fn(...)` already extracts as `calls` |
| Ruby hooks | `(skip_)?(before\|after\|around)_*` + `validate`/`set_callback`/`helper_method`/`rescue_from(with:)` symbols → class-scoped `this.<sym>` (rides the supertype pass: `before_action :authenticate` → ApplicationController). `validates` (plural) excluded — its symbols are ATTRIBUTES | — | — | — | symbols under any other call yield nothing |
## Precision rules (each one bought by a real-repo false positive)
1. **The gate** (extraction-time): a candidate survives only if its name matches
a same-file function/method or an **imported binding** (`referenceKind ===
'imports'` only — scraping type-annotation `references` names let locals that
shared a type-member's name through; excalidraw).
2. **C-family ungated file scope**: C has no symbol imports and registers
callbacks cross-file at repo scale (redis `server.c`'s command table names
handlers from `t_*.c`). File-scope initializer positions (`value`/`list`
modes) skip the gate — safe because a C file-scope initializer is a
**constant-expression context**: a bare identifier there can only be a
function address (enum/macro names get dropped by the kind filter). Local
initializers and assignments stay gated: `prev = next`, `*str = field`,
`arena_ind_prev = arena_ind` (redis/jemalloc) each matched a unique
same-named function somewhere and produced wrong edges when `rhs`/`varinit`
were ungated.
3. **TS/JS/Python: bare ids resolve to `function` kind only.** A bare
identifier can never be a method value in these languages (methods need a
receiver — `this.m` / `self.m`), so allowing method targets soaked up
locals passed as arguments (`new Set(selectedPointsIndices)`;
docopt.py's `name`/`match` params — excalidraw/fmt A/B findings).
TS/JS `this.X` values are captured as `this.`-PREFIXED candidates and
resolved CLASS-SCOPED (`resolveThisMemberFnRef` in
`src/resolution/index.ts`): the target must be a function/method whose
qualified name shares the from-symbol's class prefix, same file, no
fallback of any kind — `addEventListener(…, this.onResize)` hits the
enclosing class's method; `this.fonts` (a property, post-#808 field
classification) and inherited/unknown members yield no edge. Python's
`self.m` form keeps method targets through its own capture shape.
C#/Swift/Dart/Java/Kotlin keep method targets (method groups,
implicit-self, method references are real method values).
4. **C++ is `&`-explicit** (`addressOfOnly`): bare identifiers qualify only in
FILE-scope initializer tables; everywhere else (args, assignments, local
braced-init lists `{begin, size}`) only `&fn` / `&Cls::method` count.
C++ codebases are dense with generic free-function names (`begin`, `end`,
`out`, `size`, `data`) colliding with locals, and OUT-OF-LINE member
definitions extract as *function*-kind nodes, defeating the kind filter —
bare-id matching on fmt was mostly wrong edges (72 generic-name + 105
member/macro mismatches → after the rule: 22 edges, ~20 genuine gtest
member-pointer wirings). `&x` vs `*x` share C's `pointer_expression`; only
the `&` operator qualifies. `&Cls::method` resolves SCOPED to that class.
5. **Swift overload-family refusal**: several same-named METHODS in one file
(`Session.request(...)` × N) + a bare identifier = almost always a
same-named parameter, not a method value (Alamofire) — refuse rather than
guess. A unique method (SwiftUI `action: handleTap`) still resolves.
6. **Param-forward skips**: `this.status = status` / `o->cb = cb` (assignment
whose member name equals the RHS identifier) and Swift/Kotlin labeled args
`value: value` — a forwarded local/parameter whose function value is
unknowable; a same-named function elsewhere would be the WRONG target.
7. **Destructuring skip**: `const { center } = ellipse` extracts data, never a
function alias.
8. **Generated/minified files** (`*.min.js` and the codegen patterns in
`generated-detection.ts`) produce no fn-ref candidates — minified
single-letter symbols resolve everywhere (Alamofire's vendored jquery).
9. **Resolution**: function/method kinds only, same language family, never the
ref's own node (no self-loops), same-file match first, cross-file only when
the name is UNIQUE — ambiguity yields **no edge**. No fuzzy fallback,
ever (`matchReference` short-circuits `function_ref` refs to
`matchFunctionRef`).
10. **Runaway invariant** (#760): `matchFunctionRef` always returns
`original: ref` — the stored row — so `deleteSpecificResolvedReferences`
drains the batch.
## Validation (2026-06-11, EXTRACTION_VERSION 19)
Stash-free A/B (baseline = worktree at `main`), fresh shallow clones, public
OSS only. Per repo: node count must be identical, `calls` edges identical,
`references` strictly additive, precision spot-checked by reading the source
line of sampled `fnRef` edges.
Final build, all 17 repos (nodes identical and calls edges untouched on every
row; `unresolved_refs` fully drained — no batched-resolver runaway):
| Lang | Repo | Nodes (base=fix) | calls Δ | refs gained | Notes |
|---|---|---|---|---|---|
| C | redis | 18931 | 0/0 | **+1918** | 30/30 sample genuine — ops tables, qsort comparators, module registration, lua lib tables |
| TS/React | excalidraw | 10299 | 0/0 | **+121** | 18/20 — residual = param shadowing an imported function (file-level dep real) |
| Go | gin | 2599 | 0/0 | +14 | |
| Rust | bytes | 947 | 0/0 | +76 | `map(fn)`, struct init |
| Java | okhttp | 16008 | 0/0 | +2 | method-ref forms only, by design |
| Kotlin | okio | 7801 | 0/0 | +1 | `::fn` forms only, by design |
| Swift | alamofire | 3477 | 0/0 | +116 | adversarial case (params mirror API names); overload-family + label==name rules applied |
| Python | flask | 2705 | 0/0 | +111 | 8/8 sample genuine — incl. `ensure_sync(self.dispatch_request)` |
| Ruby | sinatra | 1751 | 0/0 | +8 | `method(:sym)` only |
| C# | newtonsoft | 20208 | 0/0 | +38 | method groups, `+=` |
| Scala | scopt | 694 | 0/0 | +10 | eta-expansion |
| Dart | provider | 1154 | 0/0 | +73 | implicit-this getter reads — true same-class dependencies |
| Lua | busted | 1257 | 0/0 | +14 | |
| Luau | fusion | 2126 | 0/0 | +18 | `:Connect(fn)` |
| ObjC | afnetworking | 1487 | 0/0 | +52 | `@selector`, target-action |
| Pascal | pascalcoin | 48788 | 0/0 | +577 | `OnClick :=` event wiring + paren-less-call refs (see limits) |
| C++ | fmt | 7345 | 0/0 | +22 | ~20/22 genuine gtest member-pointer plumbing after addressOfOnly |
Index cost on redis: +6% time, +5% db size.
## Known limits (documented, deliberate)
- **Dispatch resolution** (`o->cb(x)` → implementations): uncovered, see above.
- **C cross-file in gated positions**: an extern callback registered via
*assignment* in a different file than its definition only resolves when the
name is repo-unique (initializer tables don't have this limit — they're
ungated at file scope).
- **C++ bare-name registration** (`register_handler(my_cb)` without `&`):
dropped by `addressOfOnly` — the generic-name collision rate made bare ids
net-negative on real C++ (fmt). `&my_cb` / file-scope tables cover the
idioms; C files keep bare args.
- **Local/param shadowing an imported or same-file function**
(`mutateElement(newElement, …)` where the file also imports `newElement`;
JS plugins' `indexOf(val)` with a same-file `val()` helper): irreducible
without local-scope tracking — the data-flow frontier deliberately left
uncovered. ~1-2 per 20 sampled edges on callback-heavy repos; the file-level
dependency is real in every observed case.
- **Swift same-class param collisions** (`eventMonitor?.request(self,
didFailTask: task…)` where the enclosing type ALSO has a `task` method):
enclosing-type scoping (implicit self — methods match only the from-symbol's
own type, top-level bare ids never match methods) eliminated the CROSS-class
collision class on Alamofire (44 wrong edges), but a parameter named after
a method of the SAME type is statically indistinguishable from an
implicit-self method value. Residual, documented.
- **Pascal paren-less calls** (`Result := DoInitialize`): captured as
references (Pascal can't distinguish a procedure VALUE from a paren-less
CALL without types). The dependency direction is correct and these calls
were previously invisible entirely (#791) — strictly more truth, imperfect
label.
- **Java/Kotlin method refs through a VARIABLE** (`subscriber::onNext`,
`m::run0`): receiver type unknown statically — deliberately no edge (the
obj.method class). RxJava's baseline bare capture was resolving these to
same-named same-file methods (a test method "registering" an anonymous
class's `onNext`); the qualified rework drops them. `Type::method` resolves
cross-file (scope gated on same-file types imported names, incl. the last
segment of dotted JVM imports); `this::m` / `super::m` ride the
class-scoped + supertype path.
- **Qualified `Type::member` candidates skip the name gate** (like `this.X`):
Java/Kotlin same-package references and Kotlin companions need NO import,
so the gate could never see their scope — and the explicit-ref syntax is
self-selecting while resolution stays scope-suffix-anchored +
unique-or-drop (a `Decoy::handle` can't match a `KtHandlers::handle` ref).
This is also what resolves companion-member refs: companions extract
TRANSPARENTLY (`KtHandlers::handle`, method of the class) in real
multi-line code. (A single-line `class X { companion object { … } }` is an
upstream tree-sitter-kotlin misparse — ERROR node — and only ever appeared
in our own probe fixture; don't chase it.)
- **Swift cross-file bare references**: Swift sees module-wide symbols without
imports, so cross-file bare callbacks only resolve when repo-unique
(functions; methods are enclosing-type-only). Cross-TYPE `#selector`
targets (rare — target-action is normally self) are scoped away too.
- **`obj.method` member values** where `obj` isn't `this`/`self`: deferred —
the receiver's type is statically unknowable without local data-flow.
- **PHP strings outside known-HOF positions** (a bare `'handler'` to an
arbitrary function; framework registries like WordPress `add_action`):
deliberately uncaptured — a string is only trustworthy as a callable in a
known callable position. Framework registries belong in a `frameworks/`
resolver if ever added. **Ruby symbols outside the hook DSLs** likewise.
- **The supertype pass is NODE-anchored** (file-anchored class node →
implements/extends edge targets → `contains`-anchored member lookup): a
name-keyed `getSupertypes('Engine')` unioned every rails `Engine`'s parents
and produced a cross-class wrong edge; the node walk eliminated it
(rails +440 → +385, all sampled edges genuine).
- **`this.X` inherited members resolve through the supertype pass**
(`resolveDeferredThisMemberRefs`, depth-capped BFS over implements/extends,
runs after edges persist — same lifecycle as the #750 conformance pass).
Reading a getter into a local (`const s = this.snapshot`) still produces a
references edge to the getter — a true dependency with an imperfect
"registration" flavor.
+188
View File
@@ -0,0 +1,188 @@
# Main-thread stall budget — extraction & resolution follow-up
**Status: IMPLEMENTED** (same branch as the #1212 tail fix — attribution runs
promoted "suspects" to proven culprits fast enough to justify shipping
together). What landed, per suspect:
- **Post-index maintenance — the proven killer, not on the original suspect
list.** The first full kernel `init` on the FIXED tail completed every
synthesis pass (cFnPtr alone ran 433s at default heap, yielding throughout)
and was then SIGKILLed by the default-window watchdog at
`db.runMaintenance()`: `PRAGMA optimize` + `wal_checkpoint(PASSIVE)` over a
4.2GB DB with a 593MB WAL is minutes of synchronous IO on 2 cores.
`runMaintenance` now runs on a worker thread with its own connection
(checkpointing from a second connection is standard; `PRAGMA optimize`
persists stats in sqlite_stat tables), with a bounded in-line fallback that
skips the checkpoint (close() checkpoints after the CLI disarms the
watchdog).
- **Per-file store commits:** `storeExtractionResult` chunks its node/edge/ref
inserts (2,000 rows) with time-budgeted yields between; the ordered-commit
pump serializes async stores on a promise chain (preserving the #1015
file-order determinism invariant) and its backpressure now also waits on the
commit chain so the parse buffer stays bounded.
- **Resolver warm-up:** `warmCachesYielding` streams the DISTINCT name set
with yields (the sync `warmCaches` stays for non-async callers). The 28.2s
`sync` stall dropped to ≤4s total across the whole sync.
- **Resolution batch-tail:** edge inserts and keyed deletes run in 1,000-row
sub-transactions with yields between (crash semantics unchanged — the batch
was already several transactions, and #1187's sweep re-resolves leftovers).
- **Scan:** attributed (phase timings now in the code, `[phase-timing]` on
`CODEGRAPH_SYNTH_TIMINGS`) — it is the synchronous git enumeration
(`getGitVisibleFiles`/`collectGitFiles`), NOT a hash loop. See "Accepted
residuals" below for why it was left synchronous.
**Verification:** full-graph parity (every node id + edge, sorted dump diff)
byte-identical on fresh redis and vim indexes, baseline vs fixed; full test
suite green; kernel `sync` worst stall 28.2s → ~4s; ES synthesis tail worst
stall ≤2.7s.
**Acceptance gate PASSED:** fresh full kernel `init` (70,129 indexed files,
2,048,673 nodes / 6,402,391 edges) completed in **27m 8s** on the 2-core/6GB
container at Node's default heap with the default 60s watchdog — `EXIT 0`,
identical node/edge counts to the pre-fix partial runs, maintenance 48.8s
off-thread with the WAL fully checkpointed (0 bytes). v1.3.0 could not finish
this repo at all (OOM at default heap; watchdog kill at the maintenance step
even with the tail fixed). Post-run, the one genuine synchronous span the run
exposed — the merged synthesized-edge insert (~275k rows, 20.2s in one
transaction) — was chunked (2k rows + yield) like the rest; redis parity
re-verified byte-identical after.
## Accepted residuals (measured, documented, deliberately not fixed)
- **Git enumeration (scan): 2.210.5s** single sync span on ~95k-file repos.
Fixing it means async-ifying `collectGitFiles`' recursive gitlink/submodule
logic (#1038/#1065) or forking sync/async variants — high regression risk
for a CPU-bound span ~6× under the watchdog window even on a 2-core
container (its cost does not get the Windows/Defender per-file-IO
multiplier; it scales with CPU only).
- **End-of-sync aggregates: ~2.7s** (count recompute / vocab backfill on a
4.2GB DB).
- **Warm-up first chunk: ~2.6s** — the DISTINCT name scan's initial sort
chunk before the first cursor row arrives; the rest of the scan yields.
- **Worker-contention timer lag on tiny containers** — with 2 cpuset cores,
the off-thread checkpoint (and the parse pool early in the run) can delay
main-loop timers 1520s even though the main thread executes nothing. The
stall monitor and the watchdog heartbeat both measure timer latency, so on
a ~1-core box a long checkpoint could still starve heartbeats; if that ever
reproduces, the mitigations are a niced worker or heartbeat-side allowance,
not more yields.
If any of these ever shows up in a real watchdog kill, the async-refactor
shape for the scan is: thread a `MaybeYield` through `collectGitFiles`'
per-line loop and make `getGitVisibleFiles` async, keeping `scanDirectory`
(sync) on the walk fallback only.
---
*Original plan below, kept for the record.*
## Context
The #850 liveness watchdog SIGKILLs the indexer when its event loop stalls past
the window (default 60s). #1091#1122/#1137#1212 each moved the fix deeper:
per-batch yields, per-ref yields, then (with #1212) yields + streamed queries +
language gates across the entire dynamic-edge synthesis tail, which eliminated
the 1457s single-pass stalls and the two whole-graph OOMs.
While validating #1212 with an event-loop stall monitor over *full* `init` runs
(Linux kernel, 70k indexed files / 2.05M nodes, 2-core 6GB container; and
llvm-project, 180k tracked files, macOS), the phases **before** the synthesis
tail showed recurring single stalls that nothing currently yields through:
| Run | Phase | Observed single stalls |
|---|---|---|
| kernel (2 cores) | initial scan (t+14s, t+22s) | 5.1s, 10.5s |
| kernel (2 cores) | extraction (t+9801080s) | 3.03.3s |
| kernel (2 cores) | extraction→resolution boundary (t+1354s) | 8.5s |
| llvm (mac, fast) | extraction / early resolution (t+10001320s) | 514s, recurring |
| kernel (2 cores) | `codegraph sync` on the same DB (110 files) | **28.2s** (single stall) |
None of these approaches 60s on the tested hardware, and none are regressions —
they pre-date #1212. But the #1212 pattern (Windows NTFS + Defender, small VMs)
multiplies per-file and per-transaction costs several-fold, and 14s × a few-fold
is a watchdog kill. These are the spans that will produce the *fourth* iteration
of this bug class if left unmeasured.
## Suspects (with code locations)
1. **Per-file store commits on the main thread**
`ExtractionOrchestrator.storeExtractionResult` (`src/extraction/index.ts:2065`)
runs one synchronous transaction per file (`insertNodes` + `insertEdges` +
unresolved-ref batch + FTS triggers). A giant generated file (llvm has
many multi-MB generated `.inc`/`.cpp`) inserts tens of thousands of nodes in
one unyielding span. The parse pool (#1015) moved *parsing* off-thread; the
*commit* is still a single main-thread block per file.
2. **Resolver cache warm-up**`warmCaches` (`src/resolution/index.ts:319`)
calls `getAllNodeNames()` (`src/db/queries.ts:1879`, `SELECT DISTINCT name`
over the whole node table) plus `getAllFilePaths()` synchronously. On the
kernel's 2M-row table the DISTINCT alone is seconds; it is the prime suspect
for the 8.5s boundary stall and the 28.2s `sync` stall (sync also enters
resolution via the orphan sweep, #1191).
3. **Resolution batch-tail DB ops** — between the per-ref yields,
`resolveAndPersistBatched` (`src/resolution/index.ts`) runs per-5000-ref
synchronous spans: `insertEdges(batch)`,
`deleteSpecificResolvedReferences` × 2 (a 5000-statement transaction), and
`getUnresolvedReferencesCount()`. On a multi-GB DB each is a solid block.
4. **Initial scan** (kernel t+14/22s) — file enumeration + content hashing
before extraction starts. Unattributed; measure before assuming.
## Diagnosis plan (before any fix)
Extend the env-gated timing that located #1212 (`CODEGRAPH_SYNTH_TIMINGS`) to
the suspects — or add a sibling `CODEGRAPH_PHASE_TIMINGS` — so each suspect
logs spans >250ms with a label:
- wrap `storeExtractionResult` (log file path + node count when slow — this
also identifies the offending generated files),
- wrap `warmCaches` (split `getAllNodeNames` vs `getAllFilePaths`),
- wrap the three batch-tail ops in `resolveAndPersistBatched`,
- wrap the scan phase.
Re-run the stall monitor + timings on the two existing indexes (assets below).
Attribution first: the fix for each suspect is different, and #1180 showed the
first guess is often wrong.
## Fix sketches (per suspect, once confirmed)
1. **Chunked per-file commits:** split a file's node/edge/ref inserts into
bounded sub-transactions (e.g. 25k rows) with `maybeYield()` between chunks.
**Invariant to preserve:** files must still commit in scan order, whole-file
at a time from the resolver's perspective (#1015 — resolution disambiguates
same-named candidates by insertion order; chunking *within* one file keeps
the order stable). The existing index-completeness marker (`index_state`)
already covers a mid-file kill.
2. **Yielding warm-up:** stream `SELECT DISTINCT name` with a cursor
(`stmt.iterate()`), building the Set with a periodic `maybeYield()` — an
async `warmCachesYielding()` used from the async entry points
(`resolveAndPersistBatched`, the sync path), leaving the sync `warmCaches()`
for callers that can't await. Memory is unchanged (the Set already exists).
3. **Chunked batch-tail ops:** split the keyed-delete transaction and the edge
insert into sub-transactions with yields between, same pattern as (1).
`getUnresolvedReferencesCount` is an indexed aggregate; leave it unless
timing says otherwise.
4. **Scan:** measure first; likely chunk the hash loop with yields.
## Acceptance criteria
- Instrumented full `init` on the kernel index (2-core/6GB container) and
llvm-project shows **no single event-loop stall > ~2s** in any phase.
- `codegraph sync` on the kernel DB shows the same bound (kills the 28.2s span).
- Graph parity: byte-identical node/edge sets on a re-index of at least
elasticsearch + redis (the #1212 parity harness in the session scratchpad
automates the synthesized-edge half; extraction parity = compare
`getNodeAndEdgeCount` + a sorted node-id dump).
- No end-to-end throughput regression beyond noise (< ~5%) on the same runs —
chunked transactions can slow bulk inserts; measure, don't assume.
## Repro assets (from the #1212 investigation, 2026-07-08)
- Docker container `cg1212` (2 cores / 6GB, node:22-bookworm) with the Linux
kernel cloned at `/work/linux` and its 4.2GB index.
- llvm-project (180,074 files) + elasticsearch (45k) + redis + vim clones with
indexes in the session scratchpad.
- `stall-monitor.cjs` (preload; logs event-loop gaps >1s with timestamps),
`synth-only.mjs` / `synth-watchdog.mjs` (drive resolution+synthesis directly
against an existing index — ~2 min iteration instead of a 40-min re-index),
`parity.mjs` (synthesized-edge set differ).
- The #1091 methodology note applies: a real CLI run at a lowered
`CODEGRAPH_WATCHDOG_TIMEOUT_MS` is the authoritative kill/no-kill test.
@@ -0,0 +1,555 @@
# Mixed iOS + React Native Bridging — Coverage Design
**Audience:** a Claude agent (or human) continuing this work after #165 landed
pure-Objective-C support.
**Mission:** make codegraph's `trace` / `callers` / `callees` / `impact` /
flow-context calls connect end-to-end across **cross-language runtime
dispatch boundaries** that today silently break flows: **Swift ↔ Objective-C**
in mixed iOS codebases, and **JavaScript ↔ native** in React Native / Expo
apps.
> This doc is the **plan**, not the implementation. No code lands on this
> branch — only the design, the validation corpus, and the success bar.
> Coding starts on a follow-up branch per phase.
This work is the next item on the
[dynamic-dispatch coverage playbook](./dynamic-dispatch-coverage-playbook.md) §6
matrix: row "Swift × Objective-C bridging" and a new "React Native bridge"
row. Both are **resolver** patterns (named refs exist on both sides — the
bridging rule is deterministic) — not synthesizer patterns. See §3a of the
playbook for the reference Django ORM resolver.
---
## 1. Why this matters (the gap today)
After #165, codegraph indexes Swift, Objective-C, and JavaScript/TypeScript
each correctly **in isolation**. But the value is in cross-language flows —
exactly where iOS apps and React Native apps live:
- **Mixed iOS app:** `MyViewController.swift` calls `imageDownloader.download(url:completion:)`,
which is `-[ImageDownloader downloadURL:completion:]` in `ImageDownloader.m`.
Today: a `trace("MyViewController.viewDidLoad", "downloadURL:completion:")`
call returns no path. The Swift callsite parses as a `call_expression` whose
selector goes nowhere; the ObjC method exists as a node with no incoming
edge. The agent reads both files to reconstruct the bridge.
- **React Native app:** `useEffect(() => NativeModules.Geolocation.getCurrentPosition(cb))`
in `App.js` reaches `RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb)`
in `RNCGeolocation.m`. Today: the JS callsite has no outgoing edge to
the ObjC implementation; the ObjC handler has no incoming edge from JS.
`impact(getCurrentPosition)` (ObjC side) shows no JS callers.
- **Expo module:** `await ExpoCamera.takePictureAsync(options)` (JS) reaches
`AsyncFunction("takePictureAsync") { ... }` in `ExpoCamera.swift` (Expo
Modules API). Same break.
In every case **a name exists on both sides** that an agent or a name-matcher
can correlate — Swift's auto-bridged ObjC selector, `RCT_EXPORT_METHOD`'s
literal first argument, an Expo `Function("name")` literal. The fix is a
**resolver** that knows the bridging rules per channel and emits
`references` edges with `provenance:'heuristic'` and `metadata.synthesizedBy:'<channel>'`.
The playbook's load-bearing warning applies here harder than usual:
> **Partial coverage is WORSE than none.** Bridging one boundary but not the
> next reveals a hop the agent then drills + reads to finish. Always close
> the flow end-to-end and re-measure — never ship a half-bridged flow.
For mixed iOS, this means **both directions** (Swift→ObjC and ObjC→Swift) and
**all bridged kinds** (methods, properties, init/initializers, protocols)
must close before measuring. For React Native, JS→native AND
native→JS (`RCTEventEmitter`, `sendEvent`) must both close, AND on **both
the legacy bridge and TurboModules**, or apps that mix them will half-bridge.
---
## 2. The bridging mechanisms to model
Each row is a separate **dispatch channel** in the playbook's vocabulary —
each gets its own resolver (or synthesizer if no static ref exists), its own
validation, its own row in the §6 matrix.
| # | Direction | Channel | Mapping rule | Where it lives | Difficulty |
|---|---|---|---|---|---|
| 1 | Swift → ObjC | direct call, ObjC class imported via `-Bridging-Header.h` | Swift call `obj.x(y:z:)` ↔ ObjC selector `-x:z:` (literal mapping, see §3a) | resolver in `frameworks/swift-objc.ts` | medium |
| 2 | ObjC → Swift | `@objc` exposure | Swift `@objc func foo(bar:)` ↔ ObjC `-fooWithBar:` (auto-name); `@objc(custom:)` overrides | resolver in `frameworks/swift-objc.ts` | medium |
| 3 | Swift ↔ ObjC | property/getter/setter bridging | Swift `var name: String` ↔ ObjC `-name` / `-setName:` | resolver in `frameworks/swift-objc.ts` | low |
| 4 | Swift ↔ ObjC | initializer bridging | Swift `init(name:age:)` ↔ ObjC `-initWithName:age:` | resolver in `frameworks/swift-objc.ts` | low |
| 5 | Swift ↔ ObjC | protocol bridging (`@objc protocol`) | conformance edges across language | resolver in `frameworks/swift-objc.ts` | medium |
| 6 | JS → ObjC (RN legacy bridge) | `NativeModules.<Mod>.<fn>``RCT_EXPORT_METHOD(<fn>:...)` or `RCT_REMAP_METHOD(<jsName>, <selector>:...)` | name match keyed by `RCT_EXPORT_MODULE()` literal on the ObjC side | resolver in `frameworks/react-native.ts` | medium |
| 7 | JS → Java/Kotlin (RN legacy bridge, Android) | `NativeModules.<Mod>.<fn>``@ReactMethod` annotated method on a `ReactContextBaseJavaModule` subclass with `getName()` returning `<Mod>` | resolver — same shape as #6, JVM side | medium |
| 8 | JS ↔ native (RN TurboModules / Codegen) | `TurboModuleRegistry.get('Mod')` ↔ generated spec interface (`NativeMod` TS type) ↔ ObjC++/Kotlin impl matching the spec | resolver that reads the spec file as ground truth | hard |
| 9 | Native → JS (events) | ObjC `[self sendEventWithName:@"x" body:b]` (extending `RCTEventEmitter`) ↔ JS `new NativeEventEmitter(NativeModules.Mod).addListener('x', cb)` | EventEmitter-style synthesizer (matches existing `callback-synthesizer.ts` for in-language EventEmitter) | medium |
| 10 | JS → native (Expo modules) | JS `ExpoX.fn(args)` ↔ Swift `Function("fn") { ... }` or `AsyncFunction("fn") { ... }` inside a `Module` subclass with `Name("ExpoX")` | resolver in `frameworks/expo-modules.ts` | medium |
| 11 | JS → native (Fabric view components) | JS `<MyView prop={v}/>` ↔ ObjC/Swift `RCT_EXPORT_VIEW_PROPERTY(prop, ...)` or Codegen view spec | resolver + JSX hop (compose with existing JSX synthesizer) | hard (defer) |
The **Difficulty** column drives phasing — see §6.
### 2a. Why these are resolvers, not synthesizers
In every row, **the bridging rule is deterministic from a name**:
- Swift's `@objc` exposure is a documented automatic mapping; `@objc(custom:)`
is an explicit override; both are statically extractable.
- `RCT_EXPORT_METHOD` takes a literal selector; `RCT_EXPORT_MODULE()` takes
an optional literal module name (default: class name minus `RCT` prefix);
`NativeModules.Mod.fn` is a literal-property access on a known global.
- Expo Modules `Function("name") { ... }` and `Module { Name("ExpoX"); ... }`
are literal strings inside `Module` definitions.
- TurboModules spec interfaces are literal `Native<Name>` exports with
`TurboModuleRegistry.get<...>('<Name>')`.
So the work is: **extract the bridging-side names → make the resolver match
them**. Same shape as `djangoResolver` resolving `_iterable_class` to
`ModelIterable` — no whole-graph correlation pass needed.
The one exception is **#9 native→JS events**, where the registration sites
look very much like the in-language EventEmitter pattern the existing
callback synthesizer already handles. Extending that synthesizer with a
cross-language channel is the natural fit.
---
## 3. Concrete bridging rules (the reference table)
### 3a. Swift → ObjC selector mapping (auto)
Swift uses standard rules to derive an ObjC selector from a Swift method:
| Swift declaration | ObjC selector |
|---|---|
| `func greet()` | `greet` |
| `func say(_ msg: String)` | `say:` |
| `func set(name: String)` | `setWithName:` |
| `func setName(_ name: String)` | `setName:` |
| `func move(to point: CGPoint)` | `moveTo:` |
| `func move(from a: CGPoint, to b: CGPoint)` | `moveFrom:to:` |
| `init(name: String)` | `initWithName:` |
| `init(name: String, age: Int)` | `initWithName:age:` |
| `var name: String` (getter) | `name` |
| `var name: String` (setter) | `setName:` |
| `@objc(customSel:) func f(...)` | `customSel:` (explicit override) |
The full rule set is at
[Apple — Importing Swift into Objective-C](https://developer.apple.com/documentation/swift/importing-swift-into-objective-c)
— specifically the "method name translation" and "initializer name translation"
sections. The resolver implements this mapping in **one direction at extract
time** (Swift declarations produce the bridged ObjC name, attached as an
alias on the Swift method node), so name resolution on the ObjC side finds
the Swift method through normal name-matching.
### 3b. React Native legacy bridge — name resolution
```objc
// Native side (ObjC)
@implementation RCTGeolocation
RCT_EXPORT_MODULE(); // module name: "Geolocation" (RCT prefix stripped)
RCT_EXPORT_METHOD(getCurrentPosition:(RCTResponseSenderBlock)cb) { ... }
@end
```
```js
// JS side
import { NativeModules } from 'react-native';
NativeModules.Geolocation.getCurrentPosition(cb); // resolves to the ObjC method above
```
Rule:
1. On the native side, extract a synthetic `module` node per class containing
`RCT_EXPORT_MODULE()`. Name = explicit string argument if present, else
class name with `RCT` prefix stripped.
2. Each `RCT_EXPORT_METHOD(<sel>)` and `RCT_REMAP_METHOD(<jsName>, <sel>)`
becomes a method node attached to that module node, with the JS-visible
name (`<sel>`'s first keyword for `RCT_EXPORT_METHOD`, or `<jsName>` for
`RCT_REMAP_METHOD`).
3. On the JS side, the resolver matches the literal property chain
`NativeModules.<Mod>.<fn>` against `(module, jsName)` pairs from the
native side.
4. Resolver emits `references` (`provenance:'heuristic'`, `synthesizedBy:'rn-bridge'`)
from the JS callsite to the native method.
### 3c. React Native TurboModule — name resolution
```ts
// Spec (TS) — codegen ground truth
export interface Spec extends TurboModule {
getCurrentPosition(cb: (loc: Location) => void): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('Geolocation');
```
```objc
// ObjC++ impl
@implementation RCTGeolocation
- (void)getCurrentPosition:(RCTResponseSenderBlock)cb { ... }
@end
```
```js
import Geolocation from './NativeGeolocation';
Geolocation.getCurrentPosition(cb); // resolves to the ObjC method via the spec
```
Rule:
1. The spec file is the source of truth: parse `TurboModuleRegistry.get*<Spec>('<Name>')`
to find the module name, then read the `Spec` interface methods.
2. Match each spec method to the native impl's same-named method (by selector
first-keyword, in the class identified by name convention or by reading
any `JSI_EXPORT_MODULE` macro if present).
3. JS imports of the spec file get name resolution through the spec.
4. Emits the same `references` edges as #3b, with `synthesizedBy:'rn-turbomodule'`.
### 3d. Expo Modules — name resolution
```swift
// Native (Swift, expo-modules-core API)
public class ExpoCameraModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoCamera")
AsyncFunction("takePictureAsync") { (options: CameraOptions) in /* ... */ }
View(ExpoCameraView.self) {
Prop("type") { (view: ExpoCameraView, type: String) in /* ... */ }
}
}
}
```
```js
import { requireNativeModule } from 'expo-modules-core';
const ExpoCamera = requireNativeModule('ExpoCamera');
await ExpoCamera.takePictureAsync({ quality: 1 });
```
Rule:
1. On the native side: a class extending `Module` whose `definition()` (or
`init { /* DSL */ }` for newer API) contains a `Name("X")` call defines
the module. Each `Function("y")` / `AsyncFunction("y")` literal defines a
method. The trailing closure is the implementation body — extract as a
method node named `y`, attached to module `X`.
2. On the JS side: `requireNativeModule('X')` produces a binding; resolve
property accesses on it to the named methods.
3. `Prop("name")` for view modules behaves like RN's `RCT_EXPORT_VIEW_PROPERTY`
defer with the rest of the view-component frontier.
---
## 4. What edges need to exist
For each channel, the closed flow is:
- **JS callsite → bridged-method-node** (`references`, heuristic, `synthesizedBy:'<channel>'`)
- **Bridged-method-node → native-impl-method** (already extracted; for #6/#7
the bridged-method IS the native impl; for #10 the closure body IS the
impl)
- **Native-impl-method → its own callees** (already extracted in-language)
For Swift↔ObjC specifically, the cleanest model is **alias-name on the
declaration node**: extend Swift method extraction to compute the ObjC
auto-bridged name and store it as an alternate name the resolver
considers. No new edges between Swift and ObjC method nodes are needed
— normal name resolution suffices because both sides agree on the bridged
selector after extraction.
The MCP read tools surface heuristic edges inline already
(see `metadata.synthesizedBy` plumbing from #312/#403); these new edges
ride that path with no additional plumbing.
---
## 5. Validation corpus (the small/medium/large bar)
Following CLAUDE.md's validation methodology — **≥3 flow prompts each on
small / medium / large repos, with deterministic probes + agent A/B,
≥2 runs/arm**. Picks below are candidates to commit to in the
implementation branch; the implementation PR confirms the choices after
verifying each repo still builds an index cleanly.
### 5a. Mixed iOS (Swift+ObjC) — pick 3
| Tier | Repo | Why | Canonical flow |
|---|---|---|---|
| **Small** | [Charts](https://github.com/danielgindi/Charts) (~150 files Swift+ObjC) | Swift-first lib with ObjC compatibility layer; well-known | "How does setting `data` on a `ChartView` reach the renderer?" |
| **Small (alt)** | [Lottie-ios](https://github.com/airbnb/lottie-ios) (~300 files, was mixed; current may be pure-Swift — verify) | Animation engine, well-known mix | "How does `AnimationView.play()` reach the layer compositor?" |
| **Medium** | [Realm-Cocoa](https://github.com/realm/realm-swift) (~500 files) | Heavy Swift-on-top-of-ObjC: Swift API wraps an ObjC core that wraps C++ Realm Core | "How does `Realm.write { realm.add(obj) }` reach the ObjC persistence layer?" |
| **Large** | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) (~2500 Swift+ObjC files) | Real app, deeply mixed, active development | "How does tapping a search result reach the article-fetch network call?" |
| **Large (alt)** | [WordPress-iOS](https://github.com/wordpress-mobile/WordPress-iOS) | Heavier ObjC legacy + Swift additions | "How does a new-post draft save reach Core Data persistence?" |
Bar per repo:
1. Pure-language probes still pass (Swift-in-Swift trace; ObjC-in-ObjC trace) — no regression vs #165's pure-ObjC baseline.
2. **Cross-language probe passes:** the canonical flow above traces end-to-end with `trace`, no break at the language boundary.
3. **Agent A/B (with vs without codegraph, ≥2 runs/arm):** Read = 0 within the explore-call budget; faster than without-codegraph; no regression on a pure-Swift or pure-ObjC control repo (e.g. Texture).
4. **No node-count explosion** vs pre-bridging baseline (`select count(*) from nodes` before/after).
### 5b. React Native — pick 3
| Tier | Repo | Why | Canonical flow |
|---|---|---|---|
| **Small** | [react-native-svg](https://github.com/software-mansion/react-native-svg) (~100 files JS+ObjC+Java) | Small, well-scoped native module set | "How does setting `<Path d=.../>` reach the iOS Core Graphics call?" |
| **Medium** | [react-native-screens](https://github.com/software-mansion/react-native-screens) (~300 files, JS+native) | Real navigation primitives, both legacy bridge and Fabric | "How does navigating to a new screen reach UINavigationController?" |
| **Medium (alt)** | [react-native-firebase](https://github.com/invertase/react-native-firebase) (~1000 files across packages) | Many native modules, both platforms — stresses module discovery | "How does `firestore().collection('x').get()` reach the iOS Firebase SDK call?" |
| **Large** | [facebook/react-native](https://github.com/facebook/react-native) RNTester subset (~3000 files) | The framework itself + sample app; canonical bridge usage | "How does pressing a button in RNTester's GeolocationExample reach the iOS Core Location call?" |
Bar per repo:
1. Pure-JS probes unchanged (`useState` → re-render flow still resolves — existing react synthesizer not regressed).
2. **JS → ObjC bridge probe passes** for ≥1 known RCT_EXPORT_METHOD on each repo.
3. **JS → TurboModule probe passes** on a repo that uses TurboModules (react-native main has both; pick one of each).
4. **Native → JS event probe passes** for ≥1 emitter (NativeEventEmitter pattern).
5. **Agent A/B** as above. Critical: a question that *crosses the bridge* (e.g. "how does pressing Button X reach the network call") must drop Read to 0 in ≥1 run with codegraph.
6. **No regression** on a pure-JS control repo (existing react-realworld / excalidraw measurements unchanged).
### 5c. Expo — pick 2 (smaller scope, narrower API surface)
| Tier | Repo | Why |
|---|---|---|
| **Small/Medium** | [expo/expo](https://github.com/expo/expo) — one SDK module like `expo-camera` or `expo-location` | The cleanest Expo Modules API examples; live |
| **Large** | full `expo/expo` monorepo (all SDK modules + the JS API) | Stress-test module-name resolution across many packages |
Canonical flow: "How does `await Camera.takePictureAsync()` (JS) reach the
native camera API call (Swift `AVCaptureSession` or Kotlin
`CameraDevice`)?"
---
## 6. Phasing — what comes first
Per the playbook's difficulty gradient and the half-bridge rule, the order
is fixed by what closes a flow end-to-end on the **smallest repo first**.
### Phase 1 — Swift ↔ ObjC bridging (rows 15 above)
Smallest scope, deterministic name mapping, no JS involved. Validate on the
Charts/Realm/Wikipedia corpus before moving on. **Don't proceed to Phase 2
until Phase 1 passes the §5a bar on all three repos.**
### Phase 2 — React Native legacy bridge (rows 67, ObjC + Java/Kotlin)
Both iOS and Android sides must close in the same PR — half-bridging one
platform reveals the half-coverage hop on the other and the agent reads.
Validate on the §5b corpus.
### Phase 3 — Native → JS events (row 9)
Extends the existing callback synthesizer with a cross-language channel.
Validate on the same §5b corpus (most RN libs use at least one event emitter).
### Phase 4 — Expo Modules (row 10)
Layered on Phase 1's Swift extraction. Smaller corpus (§5c).
### Phase 5 — RN TurboModules / Codegen (row 8)
Requires reading the spec file as cross-language ground truth. Validate on
the §5b corpus's TurboModule users (react-native main, post-0.73 libs).
### Phase 6 — Fabric view components (row 11)
Deferred — composes with the existing JSX synthesizer and the view side of
TurboModules. Address when ≥1 of the §5b corpus repos has its bridge
otherwise closed but a Fabric flow still breaks.
---
## 7. Anti-goals (what we will not try to do)
- **Android Kotlin/Java extraction quality** — out of scope. We use what
Kotlin/Java extractors already produce. If they miss a `@ReactMethod`
annotation's literal name we may add a tiny extractor refinement, but we
do not redesign JVM extraction.
- **Dynamic / computed bridge keys** — `NativeModules[someVar]`,
`requireNativeModule(name)` where `name` is a parameter, etc. We only
resolve literal-key access (matches the
[agent-eval Lua frontier](./dynamic-dispatch-coverage-playbook.md) — anonymous-only patterns deferred).
- **Bridging-header file content parsing** — we *do* index `.h` files
(already does via #165's content sniff) but we do **not** parse the
bridging header's `#import` list as a special "what's visible to Swift"
manifest. Treat it as a normal ObjC header.
- **Runtime dispatch on `performSelector:`** — out of scope; matches the
same "named-only" anti-goal.
- **JSI (raw, non-TurboModule)** — out of scope. Apps using bare JSI
call into native through a custom `Host*` interface that has no documented
declarative spec. Wait for those apps to migrate to TurboModules.
- **Swift-only generics over ObjC protocols / Swift extensions on ObjC
classes** — extension methods are still callable in ObjC if `@objc`, so
they go through the same Phase 1 path. Generics are not — we silently
miss them. Acceptable; matches Java/Kotlin generics frontier.
---
## 8. Coverage-matrix entries — measured
| Language | Framework | Canonical flow | Mechanism | Status |
|---|---|---|---|---|
| Swift × Objective-C | bridging | Swift call → ObjC selector; ObjC call → @objc Swift method | R | ✅ Phase 1 (§8a) |
| JavaScript × Objective-C/Java/Kotlin | React Native legacy bridge | `NativeModules.<M>.<f>``RCT_EXPORT_METHOD` / `@ReactMethod` | R | ✅ Phase 2 (§8b) |
| JavaScript × native | React Native TurboModules | spec interface ↔ impl | R (spec as ground truth) | ✅ partial — name-match path lands (§8b) |
| Objective-C/Java/Kotlin → JavaScript | React Native event emitters | `[self sendEventWithName:]``addListener` | S (cross-lang channel) | ✅ Phase 3 (§8e) |
| JavaScript × Swift/Kotlin | Expo Modules | `requireNativeModule('X').fn(...)``Function("fn") { }` | R (extract synthesizes method nodes) | ✅ Phase 4 (§8f) |
| JavaScript × native | React Native Fabric views | `<MyView p=v/>` → Codegen spec component + NativeProps | R (extract) + S (native-impl) + JSX | ✅ Phase 6 (§8g) |
### 8a. Phase 1 measurements — Swift ↔ ObjC
| Repo | Source files | Bridge edges (framework-resolved) | Sample edges |
|---|---|---|---|
| **Charts** (small) | 269 (205 Swift + 59 ObjC/.h) | 28 objc→swift, 1 swift→objc | `handleOption:forChartView:``animate` · `setupPieChartView:``setExtraOffsets` · `setDataCount:range:``setColor` |
| **realm-swift** (medium) | 369 (151 Swift + 218 ObjC family) | 36 objc→swift, 1185 swift→objc | `valueForUndefinedKey:``get` · `setValue:forUndefinedKey:``set` · `promote:on:``initialize` |
| **wikipedia-ios** (large) | 1734 (1234 Swift + 500 ObjC/.h) | 52 objc→swift, 983 swift→objc | real-iOS-app bridging across many feature modules |
All three: in-language baselines unchanged, no node-count explosion,
`trace` connects canonical flows across the boundary (verified on
Charts: `trace(handleOption:forChartView:, animate)` surfaces the
bridge edge directly).
### 8b. Phase 2 + 5 (partial) measurements — React Native bridge
| Repo | Source files | Bridge edges (framework-resolved) | Notes |
|---|---|---|---|
| **react-native-svg** (small/medium) | ~700 (93 .mm + 115 .java + 6 .kt + 49 js + 92 ts + 154 tsx) | 9 tsx→java via TurboModule spec | RNSvg's iOS uses TurboModule auto-gen (no `RCT_EXPORT_METHOD`); resolutions land on Java. All 9 precise: `isPointInStroke`, `isPointInFill`, `getTotalLength`, `getPointAtLength`, `getCTM`, `getScreenCTM`, `getBBox`, `toDataURL`. |
| **AsyncStorage** (small, pure legacy bridge) | ~60 (28 kt + 2 mm + 16 ts + 14 tsx + …) | **8/8 precise** | The canonical legacy bridge test — Kotlin `@ReactMethod` + ObjC `RCT_EXPORT_METHOD`. JS `setItem` → Kotlin `legacy_multiSet`; `getItem``legacy_multiGet`; `clear``legacy_clear`; etc. |
| **react-native-firebase** (large) | ~1100 (111 .java + 63 .m + 13 .mm + 239 js + 427 ts + 9 tsx) | 18 after RCTEventEmitter blocklist (was 78 before) | Initial 78 included 60 false positives targeting `addListener:` / `remove:` (every RCTEventEmitter declares them; every JS call to `.addListener(...)` resolved into noise). Blocklist cut to 18, all precise: `httpsCallable:region:emulatorHost:...`, `signInWithProvider`, `configureProvider`, `removeFunctionsStreaming:`. |
| **react-native-screens** (medium) | 1211 | 0 — empty TurboModule spec, no `RCT_EXPORT_METHOD`, all Fabric/Codegen view-side | RNScreens lives entirely in Phase 6 (Fabric, deferred). The bridge declining to over-match here is the right behavior. |
### 8c. Architectural fix discovered during validation
The resolver's `initialize()` runs at CodeGraph construction — before any
files are indexed — so framework resolvers whose `detect()` consults
the indexed file list (UIKit / SwiftUI scanning for imports,
`swift-objc-bridge` looking for both Swift and ObjC files,
`react-native-bridge` looking for RN markers) all returned false on that
initial pass and silently dropped themselves. This affected every
framework resolver in the codebase that read `context.getAllFiles()` /
`context.readFile()` rather than scanning the filesystem directly — a
pre-existing latent bug, not bridge-specific. Fixed: `indexAll()` now
calls `resolver.initialize()` after extraction completes, so detect()
runs against the populated index.
### 8d. Bridge-precision blocklists (lessons learned)
| Bridge | Blocked names | Reason |
|---|---|---|
| swift-objc | `init`, `description`, `hash`, `isEqual`, `copy`, `count`, `value`, `data`, `string`, `object`, `add`, `remove`, `update`, `load`, `save`, `reload`, `cancel`, `start`, `stop`, `pause`, `resume`, `close`, `open`, `show`, `hide`, `dealloc`, `release`, `retain`, `autorelease`, … | Every NSObject subclass implements these; bridging them to arbitrary project-local ObjC methods produces noise. Regular name-matcher handles them on its own. |
| react-native | `addListener`, `removeListeners`, `remove`, `invalidate`, `startObserving`, `stopObserving` | Every `RCTEventEmitter` subclass declares these via `RCT_EXPORT_METHOD`. JS callers of `.addListener(...)` / `.remove(...)` go through `NativeEventEmitter` (JS abstraction), not the native bridge directly. |
### 8e. Phase 3 measurements — RN native → JS event channel
Synthesizer pattern; extends `src/resolution/callback-synthesizer.ts` with a
cross-language event channel keyed by literal event name. Validates on
**RNFirebase** (large):
| Synthesized event channel | Edges | Sample |
|---|---|---|
| `messaging_message_received` | 2 | `application:didReceiveRemoteNotification:fetchCompletionHandler:` → TS `onMessage` (and the `UNUserNotificationCenter` willPresent variant → same `onMessage`) |
| `messaging_notification_opened` | 1 | `userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:` → TS `onNotificationOpenedApp` |
Each edge is `provenance:'heuristic'`,
`metadata.synthesizedBy:'rn-event-channel'`. Same `EVENT_FANOUT_CAP = 6`
as the in-language channel — generic event names with too many handlers
or dispatchers skip rather than over-link.
The synthesizer also handles the **subscribe-wrapper pattern** common in
RN libraries (`messaging().onMessage(listener)` where `listener` is a
parameter that flows up to user code): when the JS handler arg isn't a
named symbol, it attributes the listener to the ENCLOSING JS function
(reachability-correct, attributes to the abstraction layer).
### 8f. Phase 4 measurements — Expo Modules
Framework `extract()` parses Swift / Kotlin source for literal
`Function("X") { … }` / `AsyncFunction("X") { … }` / `Property("X") { … }`
/ `Constants` declarations inside `class X: Module` (or `: Module()` in
Kotlin) and emits a `method` node named `X` per literal. The standard
name-matcher resolves JS callsites like `Foo.takePictureAsync(...)` to
these synthetic nodes via the existing `obj.method` → method-name path.
Validated on real Expo SDK packages:
| Package | Files indexed | Expo method nodes extracted | Cross-language edges |
|---|---|---|---|
| **expo-haptics** | 14 | 6 (3 Swift + 3 Kotlin: `notificationAsync`, `impactAsync`, `selectionAsync` / `performHapticsAsync`) | Module nodes registered; consumer-app callers resolve via name-match |
| **expo-camera** | 72 | 41 (Swift + Kotlin; covers `takePictureAsync`, `record`, `resumePreview`, `getAvailableLenses`, `scanFromURLAsync`, `requestCameraPermissionsAsync`, view-side `width` / `height` properties, …) | 9 swift→expo, 7 kotlin→expo internal edges. JS-side callsites in the package shadow the native names with TS wrappers (`pausePreview()` defined on `CameraView.tsx`); name-match correctly prefers the local TS method. An external consumer app of `Camera.takePictureAsync()` resolves through to the native method directly. |
Five tests cover the extractor + an end-to-end fixture:
`JS callsite of literal AsyncFunction("uniqueExpoHapticCall") resolves
to the native impl node` — confirms the resolver-free bridge path
works when names aren't shadowed.
### 8g. Phase 6 measurements — Fabric / Codegen view components
Two-part design:
1. **Framework extractor** (`src/resolution/frameworks/fabric.ts`) — parses
TS / TSX spec files for `codegenNativeComponent<Props>('Name', ...)`
declarations. Emits:
- One `component` node per declaration (named after the JS-visible
component name; matches the JSX synthesizer's name+kind filter).
- One `property` node per declared field of the `NativeProps`
interface — surfacing JSX-callable props like `onTap`,
`nativeContainerBackgroundColor` as discoverable graph nodes.
2. **Synthesizer** (`fabricNativeImplEdges` in `callback-synthesizer.ts`) —
walks every `fabric-component:*` node and looks for a native class
matching its name with one of RN's convention suffixes (empty / `View`
/ `ViewManager` / `ComponentView` / `Manager`). Emits a `calls` edge
with `metadata.synthesizedBy:'fabric-native-impl'` from the component
to each match. The convention is precise enough that there's no name
collision in well-formed RN libraries.
Combined with the existing `reactJsxChildEdges` JSX synthesizer, this
closes the full JSX → native flow: consumer-app JSX `<MyView prop=v/>`
→ Fabric `component` node `MyView` → native class `MyViewView`
(or `MyViewManager` / `MyViewComponentView` / …).
Re-validated on **react-native-screens** (the corpus repo that was
entirely Fabric and showed 0 bridges in Phase 2):
| Metric | Count |
|---|---|
| `codegenNativeComponent` spec declarations | 54 |
| Fabric component nodes extracted | 27 (one per non-web spec; the `*.web.ts` variants are filtered out by spec validity) |
| Fabric prop nodes extracted | 272 (the full NativeProps interface surface across all components) |
| `fabric-native-impl` bridge edges | 68 |
Sample bridge edges:
| JS component | Native class | Suffix |
|---|---|---|
| `RNSFullWindowOverlay` | `RNSFullWindowOverlay` (ObjC) | (exact) |
| `RNSFullWindowOverlay` | `RNSFullWindowOverlayManager` (ObjC) | `Manager` |
| `RNSModalScreen` | `RNSModalScreenManager` (ObjC) | `Manager` |
| `RNSScreenContainer` | `RNSScreenContainerView` (ObjC) | `View` |
Four tests cover the extractor + a full end-to-end fixture
(`App (TSX) → MyView (fabric-component) → MyViewView (ObjC class)`)
that asserts the JSX→component edge AND the
component→native-class edge both exist after indexing.
---
## 9. Open questions to settle in Phase 1
These are not blocking the start of Phase 1 — they're the first things to
decide *while* writing the Swift↔ObjC resolver:
1. **Alias on declaration vs new bridge edge?** Storing the auto-bridged
ObjC selector as an alternate name on the Swift method node is cheaper
and aligns with how name resolution already works. The alternative
(synthesize a cross-language `references` edge between matching nodes)
is more explicit in `trace` output but adds N edges per `@objc` symbol.
**Default: alias.** Verify the alias surfaces in `callers`/`callees`/`trace`
results.
2. **How does `trace` display a cross-language hop?** The MCP `trace` tool
inlines each hop's body. A Swift → ObjC hop should make this obvious in
the rendered output ("Swift `func foo(bar:)` → bridged to ObjC selector
`-fooWithBar:` → ObjC `-[ImageDownloader fooWithBar:]`"). Will likely
need a small renderer tweak in `trace.ts` to label the bridge.
3. **Where do the resolver bridging rules live?** Suggest a
`src/resolution/frameworks/swift-objc.ts` for the auto-name mapping (a
pure function) imported by both the Swift extractor (to compute the
alias at extract time) and tests. Keeps the mapping in one place.
4. **What about `@objcMembers`?** Class-level export — applies to all members
unless `@nonobjc`. Handle by checking the class's modifiers in the Swift
extractor and defaulting each member's `@objc`-ness from that.
---
## 10. Done-bar (so we know when to stop)
Phase 1 (Swift↔ObjC) is done when:
- All three §5a corpora pass: pure-language probes unchanged; cross-language
canonical flow probe finds the path end-to-end; agent A/B shows Read = 0
in ≥1 run with codegraph, faster than without.
- Coverage matrix row in §6 of the playbook is filled in with numbers.
- A CHANGELOG `[Unreleased]` entry exists, written user-side.
Each subsequent Phase has the same shape — its own §5 corpus, its own
matrix row, its own CHANGELOG entry — and **doesn't ship until the
previous one passes**. Half-bridges are not optional to avoid here; they
actively make codegraph worse on these codebases than not having any
bridging at all.
+208
View File
@@ -0,0 +1,208 @@
# Anonymous usage telemetry
Status: implemented — ingest Worker (`telemetry-worker/`), client (`src/telemetry/`),
`codegraph telemetry` CLI, MCP + installer wiring, `TELEMETRY.md`. Pending: Worker deploy
+ DNS, release.
Scope: public `codegraph` engine (CLI + MCP server + installer)
CodeGraph is a local-first tool whose whole pitch is "your code never leaves your machine."
Telemetry has to be designed so that sentence stays true and provable: a short, auditable list
of anonymous counters, documented field-by-field, easy to turn off, and impossible to grow
quietly. This doc is the contract; `TELEMETRY.md` (repo root, user-facing) restates it and the
implementation must never collect anything not listed there.
## Goals
Answer, in aggregate and anonymously:
- How many machines actively use codegraph (daily/weekly), and how does that change?
- Which agents drive usage (Claude Code, Cursor, Codex, opencode, …) — via MCP `clientInfo`.
- Which install targets people pick, local vs global, fresh vs upgrade.
- Which MCP tools and CLI commands get used, how often, and how often they error.
- Which languages people index (prioritize extractor/framework work by real usage).
- Version adoption speed, OS/arch/Node mix. (The SQLite backend is always the built-in `node:sqlite` now — there is no native-vs-wasm split left to measure.)
## Non-goals / never collected
- **No source code, ever.** No file paths, file names, repo names, symbol names, query
strings, search terms, or anything derived from the contents of an indexed project.
- No IP addresses (stripped at the edge; storage disabled at the backend too).
- No hardware fingerprinting — the machine ID is a random UUID, not derived from anything.
- No per-keystroke / per-call event stream — usage is aggregated locally into daily rollups
before anything is sent.
- No telemetry from the `codegraph-pro` fork (see "codegraph-pro rule" below).
## Principles
1. **The schema is the allowlist.** Client sends only the events below; the ingest Worker
validates against the same allowlist and drops anything else. Adding a field = PR that
edits this doc + `TELEMETRY.md` + the Worker allowlist together.
2. **Telemetry may never cost the user anything**: zero added latency on the MCP tool-call
hot path (the repo's core invariant), zero new npm dependencies (global `fetch`, Node ≥18),
zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise.
Every failure mode is silence.
3. **Off is off.** When disabled, no process opens a socket to the telemetry endpoint — not
even an "opted out" ping.
4. **First-party endpoint.** Clients only ever talk to `telemetry.getcodegraph.com`. The URL
baked into a published npm version POSTs there forever, so the domain must be ours; the
backend behind it can change without a client release.
## Events
Common envelope on every batch (computed once per process):
| field | example | notes |
|---|---|---|
| `machine_id` | `b3a8…` (UUIDv4) | random, minted at first run, stored in global config |
| `codegraph_version` | `0.9.12` | from package.json |
| `os` / `arch` | `darwin` / `arm64` | `process.platform` / `process.arch` |
| `node_major` | `22` | major only |
| `ci` | `false` | `CI` env var present |
| `schema_version` | `1` | bump when the schema changes |
Event types:
- **`install`** — one per installer run. Props: `targets` (e.g. `["claude","cursor"]`),
`scope` (`local`/`global`), `kind` (`fresh`/`upgrade`/`reinstall`).
- **`index`** — one per full index (`init`/`index`, not per `sync`). Props: `languages`
(names only, e.g. `["typescript","go"]`), `file_count_bucket` (`<100`, `100-1k`, `1k-10k`,
`10k+`), `duration_bucket` (`<10s`, `10-60s`, `1-5m`, `5m+`).
- **`usage_rollup`** — the workhorse. One event per `(day, kind, name)` per machine,
aggregated locally. Props: `kind` (`mcp_tool`/`cli_command`), `name`
(e.g. `codegraph_explore`, `affected`), `count`, `error_count`, and for MCP:
`client_name`/`client_version` from the `initialize` handshake (`src/mcp/session.ts`
`case 'initialize'` — plumbing to add; currently unread).
The prompt hook additionally rolls up its gate DECISION as `cli_command`
counters named `prompt-hook-gate-<outcome>`, outcome ∈ `high-keyword` /
`high-token` / `medium-segment` / `nudge-projects` / `noop-shape` /
`noop-no-index` / `noop-unverified` / `noop-explore-keyword` /
`noop-explore-token` / `noop-vocab-empty` — decision names only, never
prompt content. This is the gate's measured recall/precision funnel: a
rising `noop-*` share against the `high`/`medium` tiers is the signal that
the gate (keyword table or segment matching) is missing real questions.
A `high-*` outcome means context was actually injected — a gate decision
whose `codegraph_explore` errored or returned nothing records
`noop-explore-<trigger>` instead (#1143), and a MEDIUM-eligible prompt
hitting a not-yet-backfilled segment vocabulary records `noop-vocab-empty`
rather than polluting `noop-unverified` (#1142).
- **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`.
Volume math: rollups mean monthly events ≈ active machines × active days × distinct
tools used (single digits) — the PostHog free tier (1M events/mo) covers tens of
thousands of MAU. There is no per-call event by design.
Events are sent as PostHog **anonymous events** (`$process_person_profile: false`):
cheaper, no person profiles, unique-machine counts still work on `distinct_id` =
`machine_id`. Revisit only if retention tooling demands profiles.
## Consent & controls
Resolution order (first match wins):
1. `DO_NOT_TRACK=1` (community standard — always honored) → off
2. `CODEGRAPH_TELEMETRY=0|1` → forced off/on for that process
3. Global config `~/.codegraph/telemetry.json` → stored user choice
4. Default: **on**, gated by the first-run notice below
Surfaces:
- **Installer (interactive):** a visible clack toggle in the existing prompt flow —
"Share anonymous usage data? (no code, paths, or names — see TELEMETRY.md)" — default
yes. Choice persisted with `consent_source: "installer"`. Re-runs/upgrades respect the
stored choice and don't re-ask.
- **Headless paths** (`npx codegraph init`, MCP server — no TTY, never prompt): right
before the **first actual send** (recording only buffers locally and stays silent — so
the installer's explicit toggle always precedes any notice), print one line to
**stderr** and record `first_run_notice_shown`:
`codegraph collects anonymous usage stats (no code or paths) — "codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: TELEMETRY.md`
- **CLI:** `codegraph telemetry status|on|off` (status prints the machine ID, current
state, and what decided it). Deleting `~/.codegraph/telemetry.json` resets everything,
including the machine ID.
`~/.codegraph/telemetry.json`:
```json
{
"enabled": true,
"machine_id": "uuid-v4",
"consent_source": "installer | default-notice | cli",
"first_run_notice_shown": true,
"updated_at": "2026-06-12T00:00:00Z"
}
```
(`~/.codegraph/` is new — today nothing global exists. Coexists by filename if a user ever
indexes `$HOME` itself, since per-project data lives in `<project>/.codegraph/` with fixed
other filenames.)
## Client architecture
New module `src/telemetry/` (single small module, no deps):
- **Counters in memory** — recording a tool call/CLI command is an in-memory increment.
Nothing on the hot path touches disk or network. MCP tool handlers call
`telemetry.count('mcp_tool', name, ok)` and move on.
- **Buffer** — counters persist (debounced, async) to `~/.codegraph/telemetry-queue.jsonl`.
Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.
- **Flush** — many CLI actions end via `process.exit()`, where `beforeExit` never fires
and async sends die, so the design is: a tiny **synchronous append** on `process.on('exit')`
persists in-memory deltas (survives `process.exit`), and actual network sends happen
opportunistically — at the start of long-running commands (`init`/`index`/`sync`/
`uninit`/`upgrade`), on an unref'd interval in the long-lived MCP server/daemon, and
awaited-with-cap at the end of `install`/`init`/`index`/`uninit` where a second is
invisible. Sends POST completed-day rollups + lifecycle events to
`https://telemetry.getcodegraph.com/v1/events` with `AbortSignal.timeout(1500)`,
fire-and-forget: any response (or none) is final — no retry, no error surfaced. The
queue is claimed by atomic rename so concurrent processes can't double-send (a crashed
sender's claim merges back after an hour). `CODEGRAPH_TELEMETRY_DEBUG=1` echoes
payloads to stderr for development.
- **Offline / air-gapped:** flush fails silently, buffer stays within cap, steady state is
a bounded file and zero noise.
## Ingest endpoint (Cloudflare Worker)
`telemetry.getcodegraph.com` → small Worker living at `telemetry-worker/` in this repo —
public on purpose, so anyone can audit exactly what the endpoint stores. It ships nowhere
with the npm package (excluded by the `files` allowlist):
- `POST /v1/events`: validate against the event/property allowlist (drop unknown events,
strip unknown props), enforce sane sizes, **never forward or log the client IP**
(drop `CF-Connecting-IP`), light per-`machine_id` rate limit so abuse can't burn the
ingest cap, forward to `https://us.i.posthog.com/batch/` with the project key from a
Worker secret. Responds `204` on accept (including events dropped by the allowlist)
and honest `4xx` for malformed/oversized/rate-limited requests — the client treats
every response as final and never retries.
- Backend today: PostHog Cloud US, free plan, "discard client IP" enabled, GeoIP disabled,
autocapture/replay/heatmaps/web-vitals all off. The Worker is the seam: swapping the
backend later is a Worker change, not a client release.
## codegraph-pro rule (do not lose this in upstream merges)
The private `codegraph-pro` fork ships inside customer containers whose guarantee is
"nothing leaves the box" — including telemetry. In the fork, telemetry must be **default-off
and not enableable by the installer** (compile-time constant or stripped module), and the
container sets `CODEGRAPH_TELEMETRY=0` as belt-and-braces. This rule lives in the fork's
CLAUDE.md and must survive every upstream merge.
## Rollout
1. This doc + repo-root `TELEMETRY.md` (user-facing field-by-field list) + README section.
2. Worker + DNS live first (so the first shipping client never 404s), PostHog dashboards:
weekly active machines, installs by target, usage by tool × client, version adoption,
languages indexed.
3. Client module + config + `codegraph telemetry` subcommand + MCP `clientInfo` plumbing.
4. Installer toggle + first-run notice. CHANGELOG entry under `[Unreleased]` announcing
telemetry, the default, and every off-switch. Release.
Tests (no DB mocking, per repo convention; fetch mocked at `globalThis.fetch`):
consent precedence (env > config > default), off ⇒ zero fetch calls, rollup aggregation
across days, buffer cap + corrupt-buffer recovery, no-stdout invariant under MCP transport,
flush abort honors timeout, installer toggle persists + re-run doesn't re-ask
(`__tests__/installer-targets.test.ts` per house rules).
## Open questions
- Exact installer copy / notice wording — maintainer call before release.
- `uninstall` event: keep or drop? (Honest churn signal vs. "pinging on the way out" optics.)
- CI events are kept (tagged `ci: true`) because engine-in-CI is a real usage mode — revisit
if it ever dominates volume.
+155
View File
@@ -0,0 +1,155 @@
# Scope: Template-markup parser (Razor / Blazor / Thymeleaf)
Status: **P1+P2+@code IMPLEMENTED** (commits 59b8de2 directives/tags, 90c5f39 @code
delegation) on `feat/cross-language-impact-coverage`. Razor/Blazor markup is parsed
(`src/extraction/razor-extractor.ts`). Remaining: `@using` namespace disambiguation
for DTO-vs-entity name collisions (the residual ASP.NET gap), and Thymeleaf/Django
(P4, deferred — weak code links). Authored 2026-06-04.
## Problem
The impact graph is built from code the engine parses. **Template markup is not
parsed**, so any code-behind, component, view-model, or DTO that is referenced
*only* from markup looks like it has no in-repo dependent. On convention-heavy
frameworks this is the dominant residual gap after framework-entry exclusions:
| Framework | App | FAIR coverage (entries excluded) | Residual cause |
|---|---|---|---|
| ASP.NET | eShopOnWeb | **77.2%** (115/149) | Razor `.cshtml` + Blazor `.razor` reference `.cs` we don't parse |
| Spring | petclinic | 65.2% | mostly Spring Data proxies + JPA, **not** templates (Thymeleaf links are weak) |
| Django | django-realworld | 74.1% | signals / DRF / string-config, **not** templates |
**This feature is primarily an ASP.NET (Razor + Blazor) win.** Thymeleaf and Django
templates link to code only weakly (template→template fragments + fuzzy
model-attribute strings), and those frameworks' real gaps are elsewhere — so they
are explicitly lower priority here.
### Quantified target (eShopOnWeb, the 34 residual zeros after entry-exclusion)
- **~20 markup-coverable** by this feature:
- 5 MVC `ViewModels/*` ← Razor `@model X`
- 7 `BlazorShared/Models/*` (DTOs) ← Blazor `@bind` / component params
- 6 `BlazorAdmin/*` C# components ← Blazor `<Component/>` tags
- 1 `BasketComponent` ViewComponent ← `<vc:basket>` / `Component.InvokeAsync`
- 1 Razor page helper
- **~13 NOT covered** (separate frontier — reflection/proxy + value-reads): AutoMapper
`MappingProfile`, Swagger `CustomSchemaFilters`/`ImageValidators`, `ExceptionMiddleware`,
health checks, `Constants` (static-member reads), `Buyer` entity.
**Honest ceiling: ASP.NET ~77% → ~90%**, not 95%. The last ~10% is reflection/proxy
(AutoMapper, Swagger, DI/middleware registration) + C# static-const reads — a
*separate* feature (reflection modeling + extending the static-member pass to C#).
## Reference patterns to extract (prioritized)
| Pri | Format | Markup construct | Edge to emit | Resolves to |
|---|---|---|---|---|
| P1 | Razor `.cshtml`/`.razor` | `@model Foo` / `@inherits X<Foo>` | `references` | the model/VM class `Foo` |
| P1 | Razor/Blazor | `@inject IBar bar` | `references` | the service type `IBar` |
| P2 | Blazor `.razor` | `<MyComponent .../>` (PascalCase element) | `references` | component class (`.razor` or `.cs : ComponentBase`) |
| P2 | Blazor `.razor` | `@typeof(MainLayout)`, `@inherits LayoutBase` | `references` | the type |
| P3 | Razor `.cshtml` | `<partial name="_X"/>`, `<vc:basket>`, `Component.InvokeAsync("X")` | `references` | the partial view / `XViewComponent` |
| P3 | Razor `.cshtml` | `asp-page="./Register"`, `asp-controller`/`asp-action` | `references` | the page / controller action |
| P4 (defer) | Thymeleaf `.html` | `th:replace="~{frag :: x}"` | `references` | template fragment (template→template only) |
| P4 (defer) | Django `.html` | `{% extends %}` / `{% include %}` / `{% url 'n' %}` | `references` | template / named route |
`asp-for="Prop"`, `th:field="*{prop}"` (property-string bindings) are the data-flow
frontier — **out of scope** (would need model-type inference; low value, high noise).
## Architecture — follow the existing standalone-extractor pattern
The engine already has non-tree-sitter extractors (`svelte-extractor.ts`,
`vue-extractor.ts`, `liquid-extractor.ts`): a class taking `(filePath, source)`,
returning `{ nodes, references }`, wired in two places. Mirror exactly:
1. **`src/extraction/grammars.ts`** — map extensions to a synthetic language:
`.cshtml`/`.razor``'razor'`, (later) `.html` under `templates/``'thymeleaf'`.
(Django `.html` is ambiguous with plain HTML — gate on a `templates/` path or a
`{% %}`/`{{ }}` content sniff, like the framework resolvers do.)
2. **`src/extraction/tree-sitter.ts`** — dispatch by extension to a new
`RazorExtractor` (and `ThymeleafExtractor`), exactly as `SvelteExtractor` is
dispatched (~line 4025).
3. **`src/extraction/razor-extractor.ts`** (new) — regex/line scan (markup is
highly stylized; no grammar needed, same as Liquid/Svelte template scanning):
- Emit ONE `component` node for the file (so `.razor` components are linkable as
`<X/>` targets and the file is a graph citizen).
- Emit `references` per the P1P3 patterns above, `fromNodeId` = the file/component
node, `referenceKind: 'references'`, `language: 'razor'`.
- **Code-behind link:** a `Foo.razor` + `Foo.razor.cs` (partial class) — emit a
`references` (or rely on same-basename) so the markup's refs also credit the
code-behind. (eShop's Blazor components are plain `.cs : ComponentBase`, named
`<ToastComponent/>` → resolves by class name; the `.razor.cs` partial case is
the other shape.)
**Resolution: no new resolver needed.** The emitted refs are ordinary `references`
to a class/component by name; the existing name-matcher resolves them (`@model
RegisterModel` → class `RegisterModel`; `<ToastComponent/>` → class `ToastComponent`).
Apply the **same cross-family language gate** already in place — a `razor` ref must
resolve to a `csharp` symbol, so add `razor` to the `web`/dotnet family or treat
`razor``csharp` as same-family (otherwise the gate from commit 082353e drops it).
**This is the one resolver-side change** and must be done or every edge is gated away.
## Node/edge shape & invariants
- +1 `component` node per template file (real new symbol — like `.svelte`/`.vue`).
Node count grows by the template-file count only; **no per-tag node explosion**
(component tags become `references` edges, not nodes).
- All edges are `references` (counted by impact / `affected` / `getFileDependents`,
not by `callers`/`callees` — matches how `route`/`component` edges already behave).
- Idempotent re-index; node count stable across re-runs.
## Phasing
- **P1 (highest value/effort ratio):** Razor `@model` + `@inject` for `.cshtml` AND
`.razor`. Covers the 5 ViewModels + injected services. + the resolver family-gate fix.
- **P2:** Blazor `<PascalComponent/>` tags + `@typeof`/`@inherits` + code-behind link.
Covers the 6 Blazor `.cs` components + the 7 DTOs (via component params/`@bind`).
- **P3:** Razor `<partial>` / `<vc:>` / `Component.InvokeAsync` / `asp-page`.
- **P4 (defer / probably skip):** Thymeleaf + Django templates — weak code links,
low coverage payoff; revisit only if a Thymeleaf/Django app is a priority.
## Edge cases & risks
- **PascalCase tag vs HTML element:** only `[A-Z]`-initial tags are Blazor components
(HTML is lowercase) — safe discriminator. Skip known framework components
(`<Router>`, `<Found>`, `<LayoutView>`, `<RouteView>`, `<CascadingValue>`) via a
builtin set, or just let them fail to resolve (no false edge — they're not in-repo).
- **`_Imports.razor` `@using`:** namespace imports, not code refs — ignore (or emit
`imports` to the namespace, low value).
- **Generic components `<Grid TItem="CatalogItem">`:** capture the type-arg as a
`references` to `CatalogItem` (bonus DTO coverage).
- **Name collisions:** component/model names are usually unique; rely on the
name-matcher's existing proximity scoring. Same-named class in another language is
blocked by the family gate.
- **Razor `@{ ... }` C# blocks:** contain real C# (calls, `new`) — P-future; regex
scanning the C# inside markup is noisy. Defer (the directives above are the wins).
- **`.razor` is NOT `.cs`:** must add to `grammars.ts` + the indexer's include globs
(verify `.razor`/`.cshtml` aren't in a default-exclude).
## Validation (per the engine's methodology)
1. Build `RazorExtractor`; unit tests in `__tests__/extraction.test.ts` (a `.cshtml`
with `@model X` covers `X`; a `.razor` with `<ToastComponent/>` covers it; an HTML
`<div>` does NOT create an edge).
2. Re-measure eShopOnWeb FAIR coverage before/after (`/tmp/faircov.cjs`): target
77% → ~90%; **node count stable** (only +template-file component nodes); residual
zeros are the reflection/value-read set only.
3. No regression on a non-.NET control (gin/requests) and on the Razor-free C#
repos (cs-mediatr/cs-polly unchanged).
4. Record in this doc + the coverage handoff.
## Effort
- P1: ~0.5 day (extractor skeleton + `@model`/`@inject` scan + family-gate fix + tests).
- P2: ~1 day (Blazor tags + code-behind + generic type-args).
- P3: ~0.5 day. P4 (Thymeleaf/Django): ~12 days, low ROI — defer.
- **Total for the ASP.NET win (P1+P2+P3): ~2 days → ASP.NET ~90%.**
## Non-goals (and what's still needed for 95% on convention apps)
This feature does NOT close: reflection/proxy registration (Spring Data repository
proxies, AutoMapper profiles, Swagger filters, DI container / middleware), property-
string data bindings (`asp-for`/`th:field`), or C# static-const value reads
(`Constants.X`). Convention apps reaching literal 95% additionally need a **reflection/
DI-registration modeling** pass and **extending the static-member pass to C#/TS**
tracked separately. Markup parsing is the single biggest, most self-contained step.
@@ -0,0 +1,544 @@
# Playbook: extend value-reference edges to a new language
**Purpose.** This is the operational runbook for adding + validating value-reference-edge
coverage for one more language. Point a fresh session at this file and say **"Start on
language X"** — it has everything: how the feature works, where the code is, the exact
validation recipe (with scripts), the per-language checklist, and the traps already hit.
Design rationale + the validation matrix already done live in the companion doc:
[`value-reference-edges.md`](./value-reference-edges.md). This file is the *how-to*.
---
## 0. "Start on language X" — do this in order
1. Read §1 (how it works) and §2 (current state) so you know the mechanism and what's done.
2. Do the **per-language wiring check** (§5 step AC) — this is where languages differ and
where most of the real work/decisions are. Do NOT skip: a wrong declarator node type or a
class-scope-vs-file-scope mismatch makes the feature silently emit nothing (or wrong edges).
3. Run the **validation sweep** (§4) on small/medium/large **public OSS** repos for that
language. Hunt FPs. **Fix FP clusters; record singletons.** (See §3 for what a real FP
looks like vs an acceptable one.)
4. Add a **row to the matrix** in `value-reference-edges.md` and a **test case** in
`__tests__/value-reference-edges.test.ts`.
5. Commit on a branch, open a PR. (§6 has the git workflow + how the prior PRs were done.)
Scope rule (hard): **never eval on the maintainer's own repos** — clone a real public OSS
repo for the language. (Memory: `agent-eval-targets-public-oss-only`.)
---
## 1. How value-reference edges work
**What:** a `references` edge with `metadata: { valueRef: true }` from a *reader symbol* to
the **file-scope `const`/`var` it reads**, same-file only. It exists so impact analysis
catches "change this constant / config object / lookup table → affect its readers" — a class
of change calls/imports/inheritance edges never captured (a const's consumers used to look
like "nothing depends on this").
**Where it flows:** straight into `getImpactRadius``codegraph impact` and the impact trail
in `codegraph_explore` / `codegraph_node`. No agent-behaviour change required. **The win is
impact-radius correctness** (a const 90 symbols read going from "1 affected" to "90"), *not*
agent read-reduction (see §4.3).
**Code — all in `src/extraction/tree-sitter.ts`:**
| Symbol | Role |
|---|---|
| `VALUE_REF_LANGS` (static Set) | languages the feature runs for. Currently `typescript`, `javascript`, `tsx`, `go`, `python`, `rust`, `ruby`, `c`, `java`, `csharp`, `php`, `scala`, `kotlin`, `swift`, `dart`, `pascal`. **Add the new language here.** |
| `valueRefsEnabled` | `process.env.CODEGRAPH_VALUE_REFS !== '0'` — default ON, env opts out. |
| `MAX_VALUE_REF_NODES` (20_000) | per-scope traversal cap (and the shadow-scan cap). |
| `captureValueRefScope(kind, name, id, node)` | called from `createNode` on every node. Records **targets** (file-scope `const`/`var`) and **reader scopes** (`function`/`method`/`const`/`var`). |
| `flushValueRefs()` | called once at end of `extract()`. Prunes shadowed targets, then for each reader scope walks its subtree for identifiers matching a target name and emits the edges. |
**The two gates inside `captureValueRefScope`** (what you may need to adjust per language):
- **Target gate:** `kind ∈ {constant, variable}` **and** `name.length >= 3` **and**
`/[A-Z_]/.test(name)` (distinctive name — dodges single-letter / all-lowercase shadowing)
**and** the node's parent id starts with `file:`, `class:`, or `module:` (file/class/module scope).
- **Reader gate:** `kind ∈ {function, method, constant, variable}`.
**The emit loop in `flushValueRefs`:** same-file only (targets + scopes are per-file, reset
each flush); deduped per `(reader, target)`; skips `isGeneratedFile(path)`; **prunes shadowed
targets** (see §3).
---
## 2. Current state (what's shipped + validated)
- **Default ON** for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# (`CODEGRAPH_VALUE_REFS=0` disables). Shipped in **PR #895**
(flip-on + the shadow prune); Go added in a later PR (the shadow-prune declarator switch +
`VALUE_REF_LANGS`); C added later still (extractor change to emit the nodes + the bare-identifier
misparse guard); Java + C# after that (field→constant kind switch for the const subset).
- **Validated S/M/L** in **TS, JS, tsx, Go, Python, Rust, Ruby, C, Java, and C#** — see the matrix in the
design doc. All clean: node count identical on/off, precision guards held, impact win
reproduced. Go required extending the shadow prune (per-grammar declarators) — the worked
example of "step B is load-bearing." **C required the Ruby treatment** (the extractor didn't emit
C file-scope const/var nodes at all) **plus** a C-specific FP guard (a macro-prefixed-prototype
misparse mints a bare-identifier "variable" named after the return type — skip bare-`identifier`
declarators). It was the worked example of "the §2b coverage table's *easy-path* guess can be
wrong — always do §5 step C (confirm the nodes exist) before trusting it."
- **Java + C# were the cleanest class-scope ("Ruby treatment") languages.** The constants already
extract — but as `field` kind, which the gate rejects. The whole change was emitting the const
*subset* as `constant`: an `isConst` predicate on each extractor (Java `static final`; C# `const`
/ `static readonly`) + a kind switch in `extractField`. **No new shadow-prune wiring** (method
locals are `variable_declarator`, already in the switch) and **no FP guards** (UPPER_SNAKE /
PascalCase fit the distinctive-name gate). Instance `final`/`readonly` fields correctly stay
`field`. Validated S/M/L: gson/commons-lang/guava, automapper/newtonsoft/efcore — 0 leaks, node
parity, big impact wins (`INDEX_NOT_FOUND` 4→165, `_resourceManager` 22→1664).
- **PHP was the cleanest of all — one reader-scan line.** Constants already extract as `constant`
(top-level + class), so the only change was teaching the reader-scan that a PHP constant
*reference* is a `name` node (bare `X`, or the const half of `self::X` / `Foo::X`). **No extractor
change, no prune wiring** (a `$var` local can't shadow a bare constant — different namespace).
Validated S/M/L (guzzle/monolog/laravel), all clean, 0 class/const collisions. The honest caveat:
**lower yield** — PHP reads constants cross-file far more than same-file (laravel 2,956 files → 86
edges), and value-refs is same-file only; still correct, just a smaller contribution.
- **Scala — an `object` is the constant scope.** Scala has no `static`; a singleton `object`'s `val`s
are the shared-constant idiom (`object Config { val Timeout = 30 }`). Top-level `val` already
extracted as `constant`, but object/class vals both came out as `field`. The fix: in the Scala
`val_definition` handler, walk to the enclosing definition — `object_definition` (or top-level) →
`constant`/`variable`; `class`/`trait`/`enum``field` (per-instance, like Java instance `final`).
Added `val_definition`/`var_definition` to the shadow prune (method-local `val` shadows). Reader-scan
needed nothing (refs are `identifier`). Minor known limitation: Scala uses `val`/`def`
interchangeably for members, so a camelCase val can share a name with a method — same-file name
matching can't tell them apart (bounded, like Ruby's sibling-class; sweep showed flagged collisions
were mostly real object vals read by siblings). Validated S/M/L (upickle/cats/pekko).
- **C++ was attempted and reverted — DON'T retry without solving parse fidelity first.** tree-sitter-cpp
mis-parses real template/macro-heavy C++ (and `.h` files route to the C grammar): class members and
parameters leak to file scope as bogus constants/variables. Two guards (skip `ERROR`-ancestor and
`compound_statement`-ancestor declarations) removed ~83% of gross leaks, but the residual pervades
even well-structured library source (template-class member leaks, amalgamated mega-headers,
`.h`-as-C++). It did not reach the precision bar of the other languages. See the C++ section below.
- **Kotlin = C + Scala + PHP techniques combined (and clean).** Nothing extracted before (property name
nests `property_declaration → variable_declaration → simple_identifier` — the C problem). Fix:
handle `property_declaration` in the Kotlin `visitNode` hook — pull the nested name, walk to the
enclosing definition for the kind (`object`/`companion object`/top-level → `constant`/`variable`;
`class``field` — the Scala rule; skip locals under a `function_body`/`init`/lambda), add
`simple_identifier` to the reader-scan (the PHP-`name` move), and `property_declaration` to the
shadow prune. Clean parse fidelity (the one `fun interface` misparse is already handled), so no
C++-style tail. One of the cleanest yields — companion-object bit-masks/state consts are a heavy
same-file-read idiom. Validated S/M/L (okio/coroutines/ktor); only the bounded val/def-or-class and
sibling-companion name overlaps remain (shared with Scala/Ruby).
- **Swift reused Kotlin + two Swift-specific touches.** Top-level `let` + `static let` in a type are
the shared constants (`enum`/`struct` namespace them); instance `let` stays `field`. Nested name
(`property_declaration → <name> pattern → simple_identifier`); reader-scan already covered
(`simple_identifier`, from Kotlin). Two new things: **(1) the target gate was widened to `struct:`/
`enum:` parents** — Swift namespaces constants there (`enum Constants { static let X }`), and every
other language's targets are `file:`/`class:`/`module:`; **(2) computed properties are skipped** (a
`var x:Int{ … }` getter has no stored value — detect the `computed_property` child). Node creation
slots into the *existing* Swift `property_declaration` handler (property-wrapper/type deps), leaving
that untouched. Clean parse, no tail. Validated S/M/L (Alamofire/swift-argument-parser/swift-nio).
- **Dart — clean grammar separation, but a sibling-body reader-scan fix.** Dart's grammar already
splits the cases: **`static_final_declaration`** is *exactly* a top-level/`static` `const`/`final`
(the shared-constant idiom), while instance fields/`var` use `initialized_identifier` and locals use
`initialized_variable_definition` — so extracting `static_final_declaration``constant` (in a
`visitNode` hook) has **no instance/local leaks to guard**. Reader-scan free (Dart refs are
`identifier`). The catch was the **reader-scan**: Dart attaches a method/function `body` as a *next
sibling* of the signature node (the stored scope), not a child, so the scan saw only the signature
and **found nothing** until it was taught to pull in a `function_body` next-sibling (Dart-only among
the value-ref set). Shadow prune needed `static_final_declaration` + `initialized_identifier` +
`initialized_variable_definition` (a local `const X` shadowing a file `const X`). Validated S/M/L
(http/flame/flutter-packages). **Caveat:** generated Dart files inflate the sibling-class ambiguity
(a JNIGEN `_bindings.dart` with hundreds of `static final _class` collapses to the file-wide target).
The common codegen suffixes (`.g.dart`/`.freezed.dart`/`.pb.dart`) are already filtered by
`isGeneratedFile`; header-only-marked generators (JNIGEN) are not, so real source is clean but
generated FFI/JNI bindings are noisy.
- **Pascal — the genuine easy path + the Dart sibling-body fix again.** Unit/class `const` *already*
extracted as `constant` (`variableTypes: ['declConst', …]`), so it was add-to-`VALUE_REF_LANGS` +
the shadow prune (`declConst`/`declVar`; a local `const X` shadows a unit `const X`). The catch was
the *same* reader-scan bug as Dart: Pascal's proc body is a **`block` sibling** of the `declProc`
header (the reader scope), both under a `defProc` — so the same sibling-pull fix was extended to
`block`. Reader-scan node type already covered (refs are `identifier`). **Low yield** — Pascal reads
constants cross-unit more than same-file (horse: 4 edges). **Caveat:** Pascal is case-insensitive,
but the reader-scan matches exact text, so a differently-cased reference is missed (no FP, just a
miss); not worth normalizing.
- **Tests:** `__tests__/value-reference-edges.test.ts` — same-file readers edged; surfaced in
impact radius; shadowed const NOT edged (verified to fail without the guard); JSX-only read
edged (tsx); `CODEGRAPH_VALUE_REFS=0` emits nothing.
- **Memory:** `value-reference-edges-default-on` (the A/B finding + shadow guard rationale).
---
## 2b. Coverage vs the README (languages + frameworks)
Tracked against the README's **Supported Languages** table (24 rows) and **Framework-aware
Routes** list. Value-refs is **language-level**, so frameworks are *not* a separate axis (see
the bottom of this section).
**✅ Done — validated S/M/L (15 + 3 inherited):**
| Language | How |
|---|---|
| TypeScript, JavaScript, tsx | file-scope `const`/`var`; the original languages |
| Python | module-level `NAME =` |
| Go | package `const`/`var` |
| Rust | module + impl `const`/`static` |
| Ruby | class/module `CONST` (the class-scope extension) |
| C | file-scope `static const` scalars + pointer/array lookup tables + mutable globals. **Needed an extractor change** (nodes weren't emitted) + a bare-identifier misparse guard — NOT the easy path the table below first guessed |
| Java | class `static final` fields. Nodes existed as `field` kind; emitted the const subset as `constant` (`isConst` + `extractField` kind switch). No new prune wiring, no FP guards |
| C# | class `const` / `static readonly`. Identical to Java — same `field``constant` change |
| PHP | top-level `const` + class `const` (both already `constant` kind). **Only** change was the reader-scan: a PHP const *reference* is a `name` node. No extractor change, no prune wiring (a `$var` local can't shadow a bare constant). Lower yield — PHP reads consts cross-file more than same-file |
| Scala | top-level `val` (already `constant`) + **`object` val** (the singleton-constant idiom; re-kinded from `field` by walking to the enclosing `object_definition`). `class`/`trait`/`enum` vals stay `field`. `val_definition`/`var_definition` added to the shadow prune. Minor val/def name-collision limit |
| Kotlin | top-level / `object` / `companion object` `val` (re-kinded from nothing — properties weren't extracted at all). Handled in `visitNode`: nested name (`variable_declaration → simple_identifier`, the C move) + scope-walk for kind (Scala move) + `simple_identifier` in the reader-scan (PHP move) + prune. `class` instance vals stay `field`. Clean — one of the best yields (companion bit-masks) |
| Swift | top-level `let` + `static let` in `struct`/`enum`/`class`. Reused Kotlin (nested name + `simple_identifier` reader-scan). Two Swift touches: **gate widened to `struct:`/`enum:` parents** (Swift namespaces consts there), and **computed properties skipped**. `class`/instance stored props stay `field`. Slots into the existing Swift property-wrapper handler |
| Dart | top-level `const`/`final` + class `static const`/`static final` — all the **`static_final_declaration`** node, cleanly separated by the grammar from instance/`var`/local (so no leak guard). `visitNode``constant`. Needed a reader-scan fix: Dart's method **body is a next sibling** of the signature, so the scan pulls in a `function_body` sibling. Generated-FFI noise (JNIGEN `_bindings.dart`) is the one caveat |
| Pascal / Delphi | unit/class `const` (already extracted as `constant`). Add-to-`VALUE_REF_LANGS` + shadow prune (`declConst`/`declVar`) + the **same Dart sibling-body fix** (Pascal's proc body is a `block` sibling of the `declProc` header). Low yield (cross-unit reads); case-insensitive (exact-text scan misses re-cased refs) |
| **Svelte, Vue, Astro** | **inherited for free** — their extractors re-parse the `<script>`/frontmatter block as `typescript`/`javascript`, which are in `VALUE_REF_LANGS` (verified: a `.svelte` `const` edges its readers). No separate work; no separate matrix row needed. |
**🔜 Remaining — likely the easy path** (constants are file/module-scope, or top-level; do §5: add
to `VALUE_REF_LANGS`, verify the declarator node type + extractor kind, sweep). Classify each
*before* building — several are mixed file+class scope. **Caveat learned from C:** "easy path" here
means *scope* fits — it does NOT promise the extractor already emits the const nodes. C was in this
column but emitted *no* file-scope const/var nodes (its name nests in an `init_declarator` the
generic fallback can't read), so it needed the Ruby-style extractor change after all. **Always run
§5 step C (confirm `select kind,name from nodes …` actually shows the consts) before trusting this
column.**
| Language | Constant forms | Note |
|---|---|---|
| Lua / Luau | file/chunk `local X =` + globals; no `const` keyword | distinctive-name gate (needs `[A-Z_]`) catches fewer — Lua casing varies |
| R | file-scope `X <- …` / `X = …` | |
**🧱 Remaining — needs the Ruby treatment** (constants live almost entirely **inside a
class/type**; the class-scope *gate* exists now, but first confirm the extractor emits them as
`constant`/`variable` nodes — Ruby's weren't extracted at all, and class fields often come out as
`field`/`property` kind, which the gate rejects). **Java + C# (done) were this case**: their
constants extracted as `field` kind, and the fix was emitting the const subset (`static final` /
`const` / `static readonly`) as `constant` — the template for the rest of this bucket:
| Language | Constant forms |
|---|---|
| Objective-C | `static const` / `extern const` / `#define` (file-ish; macros unparsed; already "partial support") |
**⛔ Attempted & reverted — C++.** file-scope + class `static const`/`constexpr` (mixed). Machinery
built and correct on clean C++, but **tree-sitter-cpp parse fidelity is the blocker**: template/
macro-heavy real C++ leaks class members + parameters to file scope as bogus constants/variables, and
`.h` files route to the C grammar (mangling C++ classes). Two guards (skip `ERROR`-ancestor and
`compound_statement`-ancestor declarations) cut ~83% of gross leaks but the residual pervades even
well-structured library source. **Did not meet the precision bar; reverted.** Don't retry as a
"value-refs" task — it needs prior work on C++ parse handling (template-class member scoping,
`.h`-as-C++ detection, amalgamated-header exclusion).
**🚫 N/A:** Liquid (template language — no value constants to track).
**Frameworks — not a value-refs axis.** The README's framework list (Django, Flask, Express,
NestJS, Rails, Spring, Gin, Laravel, …) is a *separate* feature: **route-node extraction**.
Value-refs is framework-agnostic — it covers constants in any framework's code through the
underlying language support, with **nothing to do per framework**. The validation sweeps already
ran on framework repos (Rails → Ruby, Django → Python, gin → Go, express/eslint/webpack → JS,
jekyll/sinatra → Ruby), so framework code is exercised; there's no separate framework matrix.
---
## 3. Precision guards + what counts as a false positive
Guards run in `flushValueRefs`, in order:
1. **`isGeneratedFile(path)`** (`src/extraction/generated-detection.ts`) — skips
*suffix-recognised* generated files (`.pb.ts`, `.min.js`, …). **Path-only** — cannot catch
content-minified bundles.
2. **Shadow prune** — drop a target when its **declarator count exceeds its file-scope node
count** (so it's also bound in an inner/local scope). Rationale: a bundled/Emscripten `const
Module` re-declared as an inner `var Module`, a Go package const shadowed by a local `:=`, or
a Python module const shadowed by a local `=` resolves to the *inner* binding for nested
readers, so a file-scope edge is wrong. Inner re-bindings aren't graph nodes, so declarators
are counted at the **syntax-tree** level. *This is the per-language-sensitive guard:* the
declarator node types differ per grammar (§5 step B), and comparing against file-scope node
count (not a flat `>1`) is what keeps **conditional module defs** (`try: X=…; except: X=…`).
3. **Distinctive-name + same-file** (the target gate).
**What a real FP looks like** (fix it): a reader edged to a file-scope const it does **not**
actually read — almost always **intra-file shadowing** (the name is re-bound in an inner
scope) concentrated in **bundled/minified/generated** files. On excalidraw this was 23 edges
in one Emscripten blob.
**What is NOT an FP** (leave it):
- **CommonJS `var x = require('…')` bindings** (JS) — correct same-file reads; changing the
binding *does* affect its readers; dedups against `calls` edges in impact. Not noise.
- **Module-level mutable `var` state** read by many same-file functions — the intended case.
- A higher edge share in a language (JS ~45% vs TS ~0.71.6%) is fine if precision holds.
**Known limitations (intentional, documented):** parameter-only shadowing is *not* guarded
(the prune counts declarators, not params — guarding it would over-prune legit consts whose
name coincides with a param); same-file only (no cross-file consumers); reactive/computed
reads with no static identifier aren't covered.
---
## 4. Validation recipe
### 4.1 Deterministic probe (the core — finds FPs)
Index the same repo twice (on vs `CODEGRAPH_VALUE_REFS=0`); node count **must be identical**
(edges-only feature). Build first: `npm run build`. Save this as `probe.sh`:
```bash
#!/usr/bin/env bash
set -uo pipefail
SRC="$1"; NAME="$2"; WORK="${WORK:-/tmp/cg-vr}"
CG="$(pwd)/dist/bin/codegraph.js"
export CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1 CODEGRAPH_NO_DAEMON=1
ON="$WORK/$NAME-on"; OFF="$WORK/$NAME-off"
rm -rf "$ON" "$OFF"; mkdir -p "$WORK"
rsync -a --exclude='.git' "$SRC/" "$ON/"; rsync -a --exclude='.git' "$SRC/" "$OFF/"
node "$CG" init "$ON" 2>&1 | grep -E "nodes,|Indexed"
CODEGRAPH_VALUE_REFS=0 node "$CG" init "$OFF" 2>&1 | grep -E "nodes,|Indexed"
OND="$ON/.codegraph/codegraph.db"; OFD="$OFF/.codegraph/codegraph.db"
echo "nodes on/off: $(sqlite3 "$OND" 'select count(*) from nodes') / $(sqlite3 "$OFD" 'select count(*) from nodes') (MUST MATCH)"
# PRECISE filter — do NOT use LIKE '%valueRef%' (it matches filenames like
# textModelValueReference.ts; see §7). Always: kind='references' AND the exact key.
F="kind='references' and metadata like '%\"valueRef\":true%'"
echo "value-ref edges: $(sqlite3 "$OND" "select count(*) from edges where $F")"
echo "=== top targets by same-file reader count ==="
sqlite3 -column "$OND" "select t.name, count(*) r, replace(t.file_path,'$ON/','') f from edges e join nodes t on e.target=t.id where e.$F group by e.target order by r desc limit 15;"
```
Run: `WORK=/tmp/cg-vr bash probe.sh /path/to/cloned-repo reponame`.
### 4.2 FP hunts (run against the ON db `$OND`, with `F` from above)
```bash
# (a) bundled/minified files among targets — the #1 FP source (the woff2 case):
sqlite3 "$OND" "select distinct t.file_path from edges e join nodes t on e.target=t.id where e.$F;" \
| while read -r f; do [ -f "$f" ] || continue; \
m=$(awk '{if(length>x)x=length}END{print x+0}' "$f"); [ "$m" -gt 300 ] && echo "MINIFIED? $m $f"; done
# (b) guard invariant — no surviving target re-declared in its file (adjust regex per language):
sqlite3 "$OND" "select distinct t.name, t.file_path from edges e join nodes t on e.target=t.id where e.$F limit 80;" \
| while IFS='|' read -r n f; do [ -f "$f" ] || continue; \
c=$(grep -cE "(const|let|var)[[:space:]]+$n\b" "$f"); [ "${c:-0}" -gt 1 ] && echo "LEAK $n x$c $f"; done
# (c) precision sample — eyeball reader->target pairs across the tree:
sqlite3 -column "$OND" "select s.name,'->',t.name from edges e join nodes s on e.source=s.id join nodes t on e.target=t.id where e.$F order by e.id desc limit 12;"
```
For each FP suspect, open the file and confirm whether the reader truly reads that file-scope
target. Cluster of FPs in one file → fix (extend a guard). One-off → record it, don't chase.
### 4.3 Impact-API delta (the headline) + agent A/B
Headline metric — value-refs turns a blind impact into a real one:
```bash
for s in SOME_CONST ANOTHER_CONST; do
printf "%-20s ON %s OFF %s\n" "$s" \
"$(node dist/bin/codegraph.js impact "$s" --path "$ON" 2>/dev/null | grep -oE '— [0-9]+ affected' | head -1)" \
"$(node dist/bin/codegraph.js impact "$s" --path "$OFF" 2>/dev/null | grep -oE '— [0-9]+ affected' | head -1)"
done
```
Pick targets from the probe's "top targets" list. Expect ON ≫ OFF (e.g. 1 → 90).
**Agent A/B** (optional per language — the finding below is size/language-independent, so the
deterministic probe + impact delta usually suffice). If you run it: two **fresh on/off
indexes**, pre-warm a `--no-watch` daemon per index, `claude -p` with **`--model sonnet
--effort high`**, ≥2 runs/arm. The pattern in `scripts/agent-eval/ab-new-vs-baseline.sh` is
the template **but it switches builds + re-indexes (no flag), which wipes a flag-specific
index — don't use it as-is for a flag A/B.** (Memories: `agent-eval-nested-attach`,
`agent-eval-targets-public-oss-only`.)
**The established A/B finding (don't re-derive):** across 12 runs on excalidraw both arms did
0 Read / 0 Grep — the agent answers impact questions in one call and reaches for
`codegraph_search`/`callers`, *not* `impact`/`explore`, so it often doesn't query the
value-ref edges at all. ON was never worse than OFF. **So: value-refs does NOT reduce agent
reads — the win is blast-radius correctness** (impact API / CodeGraph Pro's verdict engine).
---
## 5. Per-language checklist (the actual work)
### A. Where do "constants worth tracking" live? (decide FIRST)
The target gate now accepts **`file:`, `class:`, and `module:`** parents. Before anything:
- If the language puts shareable constants at **file/module scope** (TS/JS, Python module
consts, Go package vars, Rust module/impl `const`/`static`) → fits as-is; proceed.
- If constants live **inside a class/module** (Ruby — done) → the `class:`/`module:` gate now
covers them, BUT two things may need fixing first: (1) the extractor must actually *extract*
the class-internal constant as a node (the dispatch at the `variableTypes` branch skips
class-internal assignments — Ruby needed an exception for `constant`-LHS assignments); (2) the
reader-scan must match however the grammar represents a constant *reference* (Ruby uses
`constant` nodes, not `identifier`). See the Ruby block in the design doc.
- **Class-scope precision** uses a **file-wide** target map (one target per name per file), NOT
strict same-class matching — because lexical-scope languages (Ruby) let a nested class read an
enclosing class's constant, and strict matching would drop those valid reads. The only real FP
is the same constant name in *sibling* classes in one file (~1.7% of Ruby targets on rails);
valid code rarely hits it (a bare sibling-class constant is a NameError in Ruby).
- **Java/C#/Kotlin/Swift class-scope constants are DONE.** The gate now accepts `file:`/`class:`/
`module:`/**`struct:`/`enum:`** parents — the `struct:`/`enum:` widening was added for Swift, which
namespaces shared constants in `enum`/`struct` (`enum Constants { static let X }`). **Lesson for the
next class-scope language:** check the *parent kind* of a sample const (`select … substr(id…)`) — if
it's `struct:`/`enum:`/`interface:` and the gate doesn't list it, widen the gate (one line) or the
feature silently emits nothing despite the nodes existing.
- **Confirm the reader-scan matches the language's constant *reference* node type (the PHP lesson).**
The reader-scan in `flushValueRefs` matches `identifier` / `constant` / `name`. If the new language
represents a constant *read* as some other node type, the scan finds nothing and **no edges form**
even with targets correctly registered. PHP refs a const as a **`name`** node (bare `X`, and the
const half of `self::X` / `Foo::X`), which the scan missed until `name` was added. Dump a sample's
reader body (`scripts/agent-eval` or a quick `getParser` walk) and check the node type of a
constant reference *before* sweeping — a zero-edge sweep usually means this, not a target-gate bug.
### B. Confirm the declarator node type (for the shadow prune)
The shadow prune (in `flushValueRefs`) counts declarator names via a `switch (n.type)` over
declarator node types — a file only has its own grammar's nodes, so it's safe to list all
languages' types in one switch. **Add the new grammar's declarator types there**, with the
right way to pull the bound name(s). **Verify against the actual grammar** (don't trust this
table — confirm by parsing a sample). **This step is load-bearing:** if you skip it, the prune
silently does nothing for the new language and intra-file shadowing produces false positives
(this is exactly what happened on the first Go pass — see §5-Go below).
| Language | declarator node(s) | name extraction | status |
|---|---|---|---|
| TS/JS/tsx | `variable_declarator` | `namedChild(0)` | done |
| Go | `const_spec`, `var_spec`, `short_var_declaration` | spec → `namedChild(0)`; short-var → identifiers in the `left` field | **done** |
| Python | `assignment` | `left` field: identifier, or iterate a `pattern_list`/`tuple_pattern` | **done** |
| Rust | `const_item`, `static_item`, `let_declaration` | const/static → `name` field; let → `pattern` field | **done** |
| Ruby | `assignment` (LHS is a `constant` node) | already in the switch; Ruby can't local-shadow a constant, so the prune is effectively a no-op for it | **done** (class-scope) |
| Ruby | `assignment` with constant LHS (`CONST`) | LHS | to verify |
| C | `init_declarator` in a file-scope `declaration` | `cDeclaratorIdentifier` walks the `declarator` chain (init → pointer/array → identifier) | **done** |
| C++ | **attempted & reverted** — parse fidelity (see the C++ note in §2b) | — | reverted |
| Java | `variable_declarator` (field AND method-local) | `namedChild(0)` = name identifier — **already the TS/JS case**, no new wiring | **done** |
| C# | `variable_declarator` (field AND method-local) | same as Java — already in the switch | **done** |
| PHP | **none** | a `$var` local (`variable_name`) is a different namespace from a bare constant — a local can never shadow a constant, so the prune is a no-op and needs no PHP declarator | **done** (n/a) |
| Scala | `val_definition`, `var_definition` | `pattern` field (identifier) — catches an object/top-level val shadowed by a method-local `val` | **done** |
| Kotlin | `property_declaration` | `variable_declaration → simple_identifier` (and `bump` accepts `simple_identifier`) — catches an object/companion const shadowed by a method-local `val` | **done** |
| Swift | `property_declaration` | `<name> pattern → simple_identifier` (`firstSimpleIdentifier`) — the prune case resolves both Kotlin and Swift shapes; catches a static const shadowed by a method-local `let` | **done** |
| Dart | `static_final_declaration` (target) + `initialized_identifier` (field/`var`) + `initialized_variable_definition` (local) | each has a direct `identifier` child — catches a top-level/static const shadowed by a method-local `const` | **done** |
| Pascal | `declConst` (unit/class const = the target) + `declVar` (a local `var`) | `<name>` field — catches a unit `const X` shadowed by a function-local `const X` | **done** |
**The prune rule is `declarators > file-scope-node-count`, NOT `> 1`.** A name can be bound
twice *at file scope* legitimately — a **conditional module def** (`try: X = a; except: X = b`,
or `if cond: X = a else: X = b`). Those make N file-scope nodes AND N declarators, so they're
kept; a real local shadow makes declarators exceed file-scope nodes. Python forced this
refinement (try/except const defs are everywhere); it's strictly more correct for all
languages. `fileScopeValueCounts` (incremented in `captureValueRefScope`) tracks the file-scope
node count per name. Also: same-name value-ref edges are suppressed (`refName !== scope.name`),
since the two halves of a conditional def would otherwise cross-reference.
**Go was the worked example of "step B matters":** the first pass added `go` to
`VALUE_REF_LANGS` only, and a synthetic probe immediately showed a false positive —
`func withShadow() { TimeoutSeconds := 5; return TimeoutSeconds }` got edged to the package
`const TimeoutSeconds`, because the prune scanned `variable_declarator` (which Go doesn't
have). Fix: add Go's `const_spec`/`var_spec`/`short_var_declaration` to the switch. Note the
**precision-first tradeoff** this inherits from TS/JS — a shadowed target is dropped for the
*whole file*, so a legit reader elsewhere in that file loses its edge too. On the Go sweep
(gin/hugo/prometheus) this over-pruning was negligible (guard invariant clean, no LEAKs), so
it wasn't worth per-reader analysis — but re-check it per language.
### C. Confirm what kind the extractor assigns
`captureValueRefScope` keys off `kind ∈ {constant, variable}` for targets. Index a sample file
and check `select kind,name from nodes where file_path like '%sample%'` — confirm module-level
constants come out as `constant`/`variable` (not `field`, `property`, `import`, etc.). If they
come out as something else, adjust the target gate.
### D. Wire + sweep
1. Add the language string to `VALUE_REF_LANGS`.
2. `npm run build`.
3. Run §4.1 probe on **small / medium / large** public OSS repos (≥3 sizes). Prefer repos
with real config/constant/lookup-table modules (where the feature shines).
4. Run §4.2 FP hunts on each. Fix FP clusters (extend a guard); record singletons.
5. Run §4.3 impact delta on a few targets.
6. Add a **matrix row** to `value-reference-edges.md` (per language) and a **test** to
`__tests__/value-reference-edges.test.ts` (positive read + a shadow/negative case).
7. `npx vitest run __tests__/value-reference-edges.test.ts` and the full suite.
**Pass bar:** node count identical on/off at every size; precision samples clean (FP clusters
fixed); impact delta shows the blind→real radius win; full test suite green.
---
## 6. Git / PR workflow (how the prior ones were done)
- Branch off `main` (e.g. `feat/value-refs-<lang>`). This validation work has lived on
`feat/value-refs-validation`; a new language can extend it or take its own branch.
- A pure-validation change is **docs (+ a test)**; a precision fix is a focused **code** PR
(like #895). Keep code fixes separate from the doc/matrix update when practical.
- Commit-message trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- PR body trailer: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Merge is the **maintainer's call** — don't self-merge unless told. Branch protection needs
`gh pr merge --squash --admin` when authorised (memory: `gh-merge-needs-admin`).
- CHANGELOG: user-facing entries under `## [Unreleased]`; don't pre-create a version block.
---
## 7. Traps already hit (save yourself the time)
- **Probe false-match:** `metadata LIKE '%valueRef%'` matches *filenames* in other edges'
metadata (e.g. an `interface-impl` `calls` edge whose `registeredAt` is
`…/textModelValueReference.ts`). **Always** filter `kind='references' AND metadata LIKE
'%"valueRef":true%'`. This created a phantom "method target" FP on vscode that was pure
query noise.
- **`searchNodes` returns `SearchResult[]`** (`.node` wraps the `Node`) — in tests use
`.map(r => r.node)`. `getImpactRadius().nodes` is a **`Map`** — iterate `.values()`.
- **`CodeGraph.initSync(dir, opts)` ignores `opts`** — it takes only the path; the default
config indexes `.ts`/`.tsx`/`.js`. Don't rely on a passed `include`.
- **Node count must be identical on/off.** If it isn't, value-refs is (wrongly) creating nodes
— investigate before anything else.
- **Big repos:** indexing vscode (11.5k files) took ~2m and a ~1GB DB per arm; clean up
`/tmp` after (each on/off pair is hundreds of MB to >2GB).
- **require-bindings (CommonJS) are not FPs** — see §3. Don't "fix" them.
- **Don't over-engineer a guard for a gap that doesn't manifest** (e.g. param-only shadow):
evidence-driven only. The maintainer steered toward minimal, surgical fixes.
- **C macro-prefixed-prototype misparse (the C FP cluster):** an unknown leading macro
(`CURL_EXTERN`, `XXH_PUBLIC_API`) makes tree-sitter-c misparse a prototype `MACRO RetType
fn(args);` as a *declaration* whose declared "variable" is the bare return-type identifier
(`XXH_errorcode`), splitting `fn(args)` into a bogus expression. It mints one spurious type-named
global per prototype — then edged by every function of that type (redis `XXH_errorcode` 1→18).
These misparses *always* produce a **bare `identifier`** declarator (checked across
pointer/array/sized-return variants); real consts/tables always have an `init_declarator` and real
pointer/array globals their own declarator. Fix = **skip bare-`identifier` declarators** in the C
branch. The "extra" file-scope variable nodes also drop node-count vs an early pass — both arms
match, but don't be surprised the post-fix count is *lower*.
- **"Easy path" ≠ "nodes already exist."** The §2b table classifies by *scope*; it does not promise
the language's consts are extracted. C sat in the easy column yet emitted zero file-scope const
nodes. Run §5 step C (`select kind,name from nodes where file_path like '%sample%'`) on a sample
*first* — if the consts aren't there, you're doing the Ruby treatment, not the easy path.
- **Class consts may extract as `field` kind, not `constant` (Java/C#).** Step C must check the
*kind*, not just that a node exists: Java `static final` and C# `const`/`static readonly` came out
as `field`, which the value-ref target gate (`constant`/`variable` only) silently rejects — so the
feature emitted nothing despite the nodes being present. Fix = an `isConst` predicate on the
extractor (gated on the const modifiers) + a kind switch in `extractField` (scoped per-language so
other languages' fields stay `field`). Don't widen the *gate* to accept `field` — that would pull
in every mutable instance field as a target. And only the const *subset* converts: a Java instance
`final` or C# instance `readonly` is per-object state, must stay `field`.
- **A zero-edge sweep with correctly-registered targets = the reader-scan node type (the PHP trap).**
Targets can register perfectly (right kind, right scope) and *still* produce zero edges if the
reader-scan doesn't recognise how the language writes a constant *read*. PHP refs a const as a
**`name`** node, not `identifier`/`constant`, so the scan saw nothing until `name` was added to the
match. Before assuming a target-gate bug on a sparse/empty sweep, dump a reader body and check the
node type of a known constant reference. (Adding a ref node type to the scan is safe across
languages — `flushValueRefs` only runs for the value-ref set, and a file holds only its own
grammar's nodes; `name` is PHP-only among the current set.)
- **Same-file-only means cross-file-heavy languages yield less — that's correct, not a miss.** PHP
reads constants across files far more than within one (`Logger::DEBUG` everywhere), so laravel
(2,956 files) gave only 86 edges vs Ruby rails's 2,255. Don't chase it: cross-file value consumers
are out of scope for *every* language (would need import/scope resolution). Report the lower yield
honestly in the matrix rather than treating it as a bug to fix.
- **Some extractors emit parameters/fields as `variable` at the wrong scope — restrict to `constant`
(the Pascal trap).** Pascal's extractor emits function `const`/`var` parameters and class fields as
`variable` parented to the enclosing unit/class, so they pass the target gate and collapse to noisy
file-wide targets (`Dest`, `aItem` read "everywhere"). The genuine shared values were all `constant`
(`declConst`), so the fix is a one-line per-language restriction in `captureValueRefScope`: Pascal
targets `constant` only. Before trusting a new language's `variable` targets, sample them — if they're
parameters or instance fields rather than module/global state, restrict to `constant`. (A residual
tail can still leak: tree-sitter-pascal context-dependently misparses a `const` param in a complex
Delphi signature as a `declConst` — a small parse-fidelity FP, accepted as a documented caveat.)
- **A zero-edge sweep with targets present can be the READER side, not just the reader-scan node type
(the Dart trap).** Targets extracted fine, reader scopes registered, reader-scan node type correct —
and still zero edges, because Dart attaches a method **body as a next *sibling*** of the signature
node (which is what gets stored as the reader scope), so the scan walked only the signature subtree.
If a language's function/method body isn't a descendant of the node you register as the reader scope,
the scan won't see the reads — pull in the sibling/linked body. Check this when edges are zero but
both the targets and the reader nodes look right.
---
## 8. Reference
- Code: `src/extraction/tree-sitter.ts` (`VALUE_REF_LANGS`, `captureValueRefScope`,
`flushValueRefs`), `src/extraction/generated-detection.ts` (`isGeneratedFile`).
- Design + matrix: `docs/design/value-reference-edges.md`.
- Tests: `__tests__/value-reference-edges.test.ts`.
- PRs: **#895** (default-on + shadow prune), **#897** (TS/JS/tsx validation).
- Memories: `value-reference-edges-default-on`, `agent-eval-targets-public-oss-only`,
`agent-eval-nested-attach`, `gh-merge-needs-admin`, `impact-coverage-findings`.
+469
View File
@@ -0,0 +1,469 @@
# Design + status: same-file value-reference edges
**Status:** SHIPPED (default-on for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift + Dart + Pascal; `CODEGRAPH_VALUE_REFS=0` disables). The
emitter lives in `TreeSitterExtractor.flushValueRefs` (`src/extraction/tree-sitter.ts`).
**Motivation:** close the impact-analysis hole for *value consumers*. Static
extraction edges calls, imports, and inheritance, but never edges a constant to the
symbols that read it — so changing a config object / lookup table / shared constant
looked like "nothing depends on this." This is the "change this table, break its
readers" class of change (the ReScript-PR false positive that motivated the work).
---
## TL;DR for a new session
We emit a `references` edge (`metadata: { valueRef: true }`) from a reader symbol to
the **file/package-scope `const`/`var` it reads**, same-file only, for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift + Dart + Pascal. Those edges
flow straight into `getImpactRadius` / `codegraph impact` and the impact trail in
`codegraph_explore` / `codegraph_node` — no agent-behaviour change required.
The win is **impact-radius correctness**, not agent read-reduction (see "Agent A/B").
## Edge semantics
- **Target:** a file-scope `const`/`var` whose name is "distinctive" (≥3 chars and
contains an uppercase letter or `_`) — dodges the local-shadowing precision trap
that single-letter / all-lowercase names invite.
- **Reader (source):** any `function` / `method` / `const` / `var` symbol whose body
references the target name.
- **Same-file only** — resolution is unambiguous without import/scope analysis.
- **Deduped** per `(reader, target)`. **Additive** — adds edges, never nodes.
## Precision guards (in emission order)
1. **`isGeneratedFile(path)`** — skip suffix-recognised generated files (`.pb.ts`,
`.min.js`, …). Path-only; it cannot catch content-minified bundles.
2. **Shadow prune** — drop a target when its **declarator count exceeds its file-scope node
count**, i.e. it's also bound in an *inner* (local) scope. A bundled/Emscripten `const
Module` re-declared as an inner `var Module`, a Go package const shadowed by a local `:=`,
or a Python module const shadowed by a local `=` all resolve to the inner binding for nested
readers — a file-scope edge would be a false positive. Inner re-bindings aren't graph nodes,
so declarators are counted at the syntax level (per-grammar node types: `variable_declarator`
for TS/JS, `const_spec`/`var_spec`/`short_var_declaration` for Go, `assignment` for Python,
`const_item`/`static_item`/`let_declaration` for Rust).
Comparing against file-scope node count (not a flat ">1") keeps **conditional module defs**
(`try: X=…; except: X=…`), which legitimately bind a name twice at file scope. This catches
the content-minified bundles guard #1 misses.
3. **Distinctive-name + same-file** as above.
## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin / Swift / Dart / Pascal
Method per repo: index the same tree twice (value-refs on vs `CODEGRAPH_VALUE_REFS=0`),
diff node/edge counts, spot-check precision, and measure `codegraph impact` on a few
file-scope consts. Node count must be **identical** on/off (edges-only feature).
**TypeScript**
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| sindresorhus/ky | small | 54 | 562 (stable) | +29 (0.8%) | all sampled TP | — |
| excalidraw/excalidraw | medium | 645 | 10,301 (stable) | +717 (1.6%) | TP after shadow prune (#895 removed 23 woff2-bundle FPs) | `tablerIconProps` 1→**170** |
| microsoft/vscode | large | 11,548 | 333,999 (stable) | +10,605 (0.69%) | all sampled TP; no param-shadow / bundle FPs in top 200 | `LayoutStateKeys` 1→**85**, `CORE_WEIGHT` 1→52 |
**JavaScript** (same extractor; CommonJS, `var`, IIFE/UMD)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| expressjs/express | small | 147 | 1,082 (stable) | +27 (0.75%) | all sampled TP | — |
| eslint/eslint | medium | 1,420 | 7,167 (stable) | +1,192 (4.2%) | all sampled TP; guard holds; no minified-file FPs | `internalSlotsMap` 1→**32**, `INDEX_MAP` 1→27 |
| webpack/webpack | large | 9,371 | 28,922 (stable) | +3,521 (4.8%) | all sampled TP; guard holds; no minified-file FPs | `LogType` 1→**89**, `LOG_SYMBOL` 1→90, `UsageState` 2→52 |
**Go** (package-level `const`/`var`; required extending the shadow prune — see below)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| gin-gonic/gin | small | 110 | 2,599 (stable) | +166 (1.9%) | all sampled TP; guard holds | `abortIndex` 1→**24**, `jsonContentType` 1→8 |
| gohugoio/hugo | medium | 952 | 19,160 (stable) | +1,616 (2.5%) | all sampled TP; guard holds | `filepathSeparator` 2→**26** |
| prometheus/prometheus | large | 1,329 | 23,322 (stable) | +3,466 (3.3%) | all sampled TP; guard holds | `rdsLabelInstance` 1→**82**, `ec2Label` 1→24 |
| kubernetes/kubernetes | very large | 19,160 | 251,086 (stable) | +20,574 (1.9%) | all sampled TP; guard holds on 250 targets | `KubeletSubsystem` 3→**138**, `LEVEL_0` 1→102 |
**Python** (module-level `NAME = …`; required extending the prune *and* refining its rule — see below)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| psf/requests | small | 49 | 1,299 (stable) | +85 (2.9%) | all sampled TP; guard holds | `ITER_CHUNK_SIZE` 1→4, `DEFAULT_POOLBLOCK` 1→4 |
| sqlalchemy/sqlalchemy | medium | 679 | 59,963 (stable) | +1,929 (0.8%) | all sampled TP; guard holds | `COMPARE_FAILED` 1→**26**, `DB_LINK_PLACEHOLDER` 1→19 |
| django/django | large | 3,005 | 61,748 (stable) | +1,328 (0.7%) | all sampled TP; guard holds | `_trans` 1→**138**, `SEARCH_VAR` 4→8 |
**Rust** (module-level `const`/`static`; declarators added, no rule change needed)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| BurntSushi/ripgrep | small | 107 | 3,731 (stable) | +144 (0.9%) | all sampled TP; guard holds | `SHERLOCK` 7→**113** |
| tokio-rs/tokio | medium | 795 | 13,281 (stable) | +476 (1.1%) | all sampled TP; `#[cfg]`-conditional consts kept | `PERMIT_SHIFT` 1→**97**, `LOCAL_QUEUE_CAPACITY` 2→46 |
| rust-lang/rust-analyzer | large | 1,530 | 38,780 (stable) | +475 (0.25%) | all sampled TP; 0 real shadow leaks | `INLINE_CAP` 2→**183**, `SPAN_PARTS_BIT` 2→18 |
**Ruby** (`CONST = …`, almost always **inside a class/module** — needed the class-scope extension)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| sinatra/sinatra | small | 96 | 1,800 (stable) | +73 (2.1%) | ~100% TP (flags are valid nested reads) | `HEADER_PARAM` 1→**5** |
| jekyll/jekyll | medium | 218 | 1,906 (stable) | +100 (2.4%) | ~100% TP | `DEFAULT_PRIORITY` 1→3, `LOG_LEVELS` 4→5 |
| rails/rails | large | 1,452 | 61,911 (stable) | +2,255 (1.2%) | ~98% TP (same-file ambiguity 21/1208 targets) | `Post` (Struct const) 75 readers |
**C** (file-scope `static const` scalars + pointer/array lookup tables + mutable globals; required
extracting the nodes first — see below)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| redis/hiredis | small | 52 | 1,161 (stable) | +29 (2.5%) | all sampled TP; guard holds | `hiredisAllocFns` 1→**71** |
| curl/curl | large | 994 | 16,124 (stable) | +597 (3.7%) | all sampled TP; guard holds; no minified FPs | `Curl_ssl` 3→**57** |
| redis/redis | medium | 782 | 19,446 (stable) | +1,634 (8.4%) | all sampled TP after the macro-misparse fix; guard holds | `asmManager` 2→**97**, `keyMetaClass` 1→36, `XXH3_kSecret` 1→27, `helpEntries` 1→13 |
**Java** (class-scope `static final` constants; required emitting them as `constant` kind — see below)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| google/gson | small | 262 | 8,563 (stable) | +387 | all sampled TP; guard holds | `PEEKED_NONE` 1→**31** |
| apache/commons-lang | medium | 623 | 19,976 (stable) | +2,087 | all sampled TP; guard holds; no minified FPs | `INDEX_NOT_FOUND` 4→**165**, `EMPTY` 5→161 |
| google/guava | large | 3,227 | 130,945 (stable) | +6,354 | all sampled TP; guard holds; no minified FPs | `APPLICATION_TYPE` 2→**126**, `ABSENT` 4→66 |
**C#** (class-scope `const` / `static readonly`; same `field``constant` change as Java)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| AutoMapper/AutoMapper | small | 511 | 19,254 (stable) | +133 | all sampled TP; guard holds | `ContextParameter` 1→**17**, `InstanceFlags` 1→14 |
| JamesNK/Newtonsoft.Json | medium | 945 | 20,208 (stable) | +344 | all sampled TP; guard holds | `DefaultFlags` 1→**37**, `JsonNamespaceUri` 1→15 |
| dotnet/efcore | large | 5,731 | 140,847 (stable) | +3,720 | all sampled TP; guard holds; no minified FPs | `_resourceManager` 22→**1664**, `Prefix` 40→237, `Guid77` 2→191 |
**PHP** (top-level `const` + class `const`, both already `constant`; needed only a reader-scan tweak — see below)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| guzzle/guzzle | small | 81 | 1,655 (stable) | +5 (sparse — see note) | all sampled TP; no collisions | `CONNECTION_ERRORS` 1→3 |
| Seldaek/monolog | medium | 217 | 3,047 (stable) | +79 | all sampled TP; no class/const collisions | `DEFAULT_JSON_FLAGS` 1→**18**, `RFC_5424_LEVELS` 1→17 |
| laravel/framework | large | 2,956 | 57,519 (stable) | +86 | all sampled TP; no minified/collision FPs | `INVISIBLE_CHARACTERS` 1→**93**, `SESSION_ID_LENGTH` 1→9 |
**Scala** (top-level `val` + `object` val — re-kinded from `field`; `class` instance vals stay `field`)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| com-lihaoyi/upickle | small | 145 | 3,052 (stable) | +82 | all sampled TP; no class/method collisions | `IntegralPattern` 1→**9** |
| typelevel/cats | medium | 835 | 15,774 (stable) | +89 | sampled TP; flagged val/def name-collisions were real object vals read by siblings | `maxArity` 3→**17**, `fusionMaxStackDepth` 1→13, `minIntValue` 1→7 |
| apache/pekko | large | 2,720 | 135,041 (stable) | +8,453 (2,065 Scala) | Scala object vals clean; the bulk are valid Java `PARSER`/`DEFAULT_INSTANCE` from generated protobuf `.java` | `ErrorLevel` 5→**33**, `WarningLevel` 5→29 |
**Kotlin** (top-level / `object` / `companion object` `val``constant`; `class` instance vals stay `field`)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| square/okio | small | 307 | 8,540 (stable) | +157 | all sampled TP; 0 collisions | `STATE_IN_QUEUE` 1→**32**, `HMAC_KEY` 1→9 |
| Kotlin/kotlinx.coroutines | medium | 1,039 | 17,058 (stable) | +210 | all sampled TP; 1 cross-file collision | `BLOCKING_SHIFT` 1→**24**, `TERMINATED` 2→22 (companion bit-masks) |
| ktorio/ktor | large | 2,302 | 43,272 (stable) | +849 | object/companion consts (HTTP header names); flagged collisions are real consts; `TYPE` is a sibling-companion ambiguity | `TYPE` 8→**109**, `FailedPath` 1→22 |
**Swift** (top-level `let` + `static let` in `struct`/`enum`/`class``constant`; instance `let` stays `field`; computed properties skipped)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| Alamofire/Alamofire | small | 98 | 4,192 (stable) | +108 | all sampled TP; 0 collisions; computed properties skipped | `defaultRetryLimit` 1→3, `defaultWait` 1→4 |
| apple/swift-argument-parser | medium | 165 | 4,435 (stable) | +36 | all sampled TP; 1 sibling-type collision (`usageString`) | `usageString` 8→**18**, `labelColumnWidth` 1→2 |
| apple/swift-nio | large | 554 | 20,136 (stable) | +589 | all sampled TP; 0 collisions; `eventLoop` (static let) verified TP | `CONNECT_DELAYER` 1→**15**, `SINGLE_IPv4_RESULT` 1→12 |
**Dart** (top-level `const`/`final` + class `static const`/`static final` = the `static_final_declaration` node → `constant`)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| dart-lang/http | small | 324 | 4,860 (stable) | +668 | real source TP; numbers skewed by a JNIGEN `_bindings.dart` (sibling-class collapse) | `Finishing` 1→**10**, `CONNECTION_PREFACE` 5→7 |
| flame-engine/flame | medium | 1,655 | 19,608 (stable) | +465 | all sampled TP; bounded const-vs-getter collisions | `cardWidth` 4→**15**, `tileSize` 3→12 |
| flutter/packages | large | 3,452 | 116,075 (stable) | +10,015 | real Flutter consts; some `.gen.dart` (pigeon) generated noise | `iconFont` 1→**1790**, `_channel` 6→72, `kMaxId` 1→23 |
**Pascal / Delphi** (unit/class `const``constant`; **`constant`-only** targets — the extractor emits params/fields as `variable`)
| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
|---|---|---|---|---|---|---|
| HashLoad/horse | small | 74 | 2,464 (stable) | +4 (sparse — cross-unit reads) | all sampled TP | `LOG_NFACILITIES` (Syslog const) |
| synopse/mORMot2 | medium | 539 | 66,760 (stable) | +2,240 | precision sample 100% TP (font/crypto/DB consts); a few `const`-param misparse FPs in complex Delphi sigs | `LIB_CRYPTO` 1→**358**, `DEFAULT_ECCROUNDS` 1→31 |
| castle-engine | large | 2,430 | 93,692 (stable) | +6,983 | top targets all real FFI binding consts; 0 collisions | `LazGio2_library` 2→**1880**, `LIB_CAIRO` 1→223 |
Across S/M/L in all fifteen languages: node count never moved, the precision guards held, and
the `impact` OFF column is the bug — a const that 80140 symbols read reports "1 affected"
without value-refs.
**Go required a code change** (unlike JS/tsx, which the existing guards covered unchanged).
Go puts its constants at package = file scope (good — the target gate fits), but its
declarators are `const_spec`/`var_spec`/`short_var_declaration`, not `variable_declarator`, so
the shadow prune was a no-op for Go and a package `const Timeout` shadowed by a local
`Timeout := …` produced a false positive. Extending the prune's declarator switch to Go's node
types fixed it (one synthetic repro, then clean across gin/hugo/prometheus). This is the
template for the next language: **the shadow prune is per-grammar and must be wired per
language** (see the playbook).
**Python forced a refinement of the prune *rule* — a general improvement.** Python's
declarator is `assignment` (added to the switch). But Python also **conditionally defines
module constants** (`try: HAS_SSL = True; except: HAS_SSL = False`) — a very common idiom that
binds the name twice *at module scope*. The old "bound more than once → drop" rule over-pruned
these (dropping a real const and its readers). The fix distinguishes a conditional module def
from a real shadow by comparing declarator count against the number of **file-scope nodes** the
name has: a conditional def makes them equal (both bindings are file-scope), a local shadow
makes declarators exceed file-scope nodes (the excess is the local). This is strictly more
correct for *all* languages. (It also made the two halves of a conditional def cross-reference
via their own names, so same-name value-ref edges are now suppressed.)
**Rust needed only declarators — the rule was already right.** Rust's are `const_item` /
`static_item` (module consts) and `let_declaration` (the local that shadows). Adding them to
the switch fixed the expected shadow FP (a `const TIMEOUT` shadowed by a local `let TIMEOUT`).
Rust also has the conditional-def pattern — `#[cfg(unix)] const SEP = …; #[cfg(windows)] const
SEP = …` — and the Python-era file-scope-count rule already keeps those correctly (validated on
tokio's `io/interest.rs` cfg-gated flags). One nice property fell out: consts written inside a
config macro (`cfg_aio! { … }`) live in an unparsed token tree, so the prune's syntax walk
doesn't even see them.
**Ruby is the class-scope case — and required three changes.** Ruby keeps almost all constants
*inside* a class/module (jekyll's `lib/`: 0 top-level vs 58 class-internal), so the original
file-scope-only target gate covered ~nothing. Three Ruby-specific fixes: (1) the extractor now
creates nodes for constant assignments (`CONST = …` has a `constant`-typed LHS, not
`identifier`, so they were never extracted at all) — including class-internal ones; (2) the
value-ref target gate accepts `class:`/`module:` parents, not just `file:`; (3) the reader-scan
matches `constant` nodes, since in Ruby both a constant's definition and its references are
`constant`-typed. **Effectively Ruby-only:** Rust impl consts are parented to `file:` already
(so the gate change doesn't touch them — ripgrep stayed at 144 edges), and TS/Python class
members aren't `constant`/`variable` kind.
The interesting precision question — *which* class does a class-scope target belong to — turns
out to favor a **file-wide** target map (a name maps to one target per file), because Ruby's
constant lookup is **lexical + ancestor**: a method in a nested class legitimately reads an
enclosing class's constant (verified on jekyll's `ERBRenderer→ThemeBuilder::SCAFFOLD_DIRECTORIES`
and sinatra's `AcceptEntry→Request::HEADER_PARAM`). Strict same-class matching would wrongly drop
those. The only real false positive is the same constant name defined in *sibling* (un-nested)
classes in one file — 21 of 1,208 targets (1.7%) on rails, and most of those resolve fine too;
referencing a sibling class's bare constant is a NameError in real Ruby, so valid code rarely
hits it. Net precision ~98100%.
**C was NOT the "easy path" the language tracker first assumed — it needed the extractor to emit
the nodes first.** C keeps shareable values at file scope (`static const` scalars, and very
commonly pointer/array **lookup tables** + mutable global state), which fits the file-scope target
gate. But unlike Go/Rust (whose const nodes already existed), C's file-scope `const`/`var` were
**never extracted as nodes at all**: a C `declaration` nests its name inside an `init_declarator`
(through `pointer_declarator`/`array_declarator`), and the generic variable-extraction fallback
only finds a *direct* `identifier` child — so it produced nothing. Three changes (the same shape as
Ruby's): (1) a C branch in `extractVariable` that resolves the name through the declarator chain and
emits file-scope declarations as `constant`/`variable` (skipping function-body locals via an
ancestor check, and `function_declarator` prototypes); (2) an `isConst` on the C extractor (a
`const` `type_qualifier``constant` kind); (3) the shadow prune's declarator switch extended with
`init_declarator`. Scoped to **C only** — C++ stays on the generic fallback (its class-scope members
are the harder bucket).
The one false-positive cluster the sweep surfaced was a **macro-prefixed-prototype misparse**, and
the fix is the load-bearing C detail: an unknown leading macro (`CURL_EXTERN`, `XXH_PUBLIC_API`)
makes tree-sitter-c misparse a prototype `MACRO RetType fn(args);` as a declaration whose declared
"variable" is the **bare return-type identifier** (`XXH_errorcode`/`CURLcode`), splitting `fn(args)`
off as a bogus expression — minting one spurious type-named global per prototype, then edged by
every function returning that type (redis's `XXH_errorcode` 1→18 before the fix). These misparses
*always* yield a **bare `identifier`** declarator (verified across pointer/array/sized return
variants); real consts/tables always carry an initializer (`init_declarator`) and real
pointer/array globals carry their own declarator. So the C branch **skips bare-`identifier`
declarators entirely** — killing the whole FP class at the cost of only uninitialized scalar globals
(`static int g;`), which are rare and low-value. After the fix: every sampled edge on
hiredis/redis/curl was a true positive, the guard-invariant leak check found 0 shadows across all
three, and `impact` deltas confirm the blind→real radius (`asmManager` 2→97, `Curl_ssl` 3→57,
`hiredisAllocFns` 1→71).
**Java + C# were the cleanest class-scope languages — one kind switch, no new guards.** Both keep
constants *inside a class* (Java `static final` fields; C# `const` / `static readonly`), so unlike
C the nodes already existed — but as **`field`** kind, which the value-ref gate (`constant`/
`variable` only) rejects. The whole change was emitting the constant *subset* as `constant`: an
`isConst` predicate on each extractor (Java = a `static final` field; C# = a `const`, or a `static
readonly`) plus a kind switch in `extractField`. Everything else was already in place — the
class-scope target gate (from Ruby), the `identifier` reader-scan, and crucially the shadow prune:
a method-local that shadows a class const is a `variable_declarator` in both grammars, *already* in
the prune switch, so a class const shadowed by a local is dropped with no new wiring (validated by
the Java/C# shadow tests). Instance fields stay `field` — a Java instance `final` or a C# instance
`readonly` is per-object state, not a shared constant, so it's never a target. The distinctive-name
gate fits both conventions cleanly (Java `UPPER_SNAKE`, C# `PascalCase`), so no FP class emerged:
across S/M/L (gson/commons-lang/guava, automapper/newtonsoft/efcore) every sampled edge was a true
positive, 0 shadow leaks, no minified-file FPs, node count identical on/off. The `impact` wins are
the headline — Java's canonical `public static final` constants (`INDEX_NOT_FOUND` 4→165, `EMPTY`
5→161) and C#'s `const`/`static readonly` (`Prefix` 40→237, a generated `_resourceManager` 22→1664)
all went from a blind "1 affected" to their real radius. The known sibling-class limitation (the
same const name in two classes in one file resolves to the file-wide target) is shared with Ruby and
stayed negligible.
**PHP was a near-pure "easy path" — one reader-scan line, no extractor change, no prune wiring.**
PHP already extracts both top-level `const X = …` and class `const X = …` as `constant` kind (a
dedicated `const_declaration` handler), inside the right scope (`file:` / `class:`, both gated). The
*only* change was the reader-scan: PHP represents a constant *reference* — bare `X`, or the const
half of `self::X` / `Foo::X` / `static::X` — as a **`name`** node, which the scan (matching
`identifier` / `constant`) missed, so it found nothing until `name` was added. That's safe across
languages: `flushValueRefs` only runs for the value-ref set, and `name` is PHP-only among them. **No
shadow prune was needed at all** — a PHP local is a `$var` (`variable_name`), a different namespace
from a bare constant, so a local can *never* shadow a constant; there is nothing to prune (the
cleanest case yet). Precision was excellent: UPPER_SNAKE constants fit the distinctive-name gate, and
a dedicated check for a target whose name collides with a same-file *class* (PHP's one realistic FP —
`name` nodes also name classes in `new Foo()` / `Foo::`) found **zero** collisions across
guzzle/monolog/laravel; every sampled edge was a true positive, node count identical on/off.
**The honest caveat: PHP is lower-yield than the class-scope languages, by design.** PHP idiom reads
constants *across* files far more than within one (a `Logger::DEBUG` or a config constant consumed
everywhere), and value-refs is **same-file only** — so laravel (2,956 files) produced only 86 edges
vs. Ruby rails's 2,255 (1,452 files). This is not a miss: the cross-file reads are out of scope for
*every* language (resolution would need import/scope analysis), and PHP simply leans on them more.
The same-file reads it *does* capture are clean and the transitive impact wins are real
(`INVISIBLE_CHARACTERS` 1→93 from 3 direct readers). Net: correct and additive, just a smaller
absolute contribution than Java/C#/Go.
**Scala — the `object` is the constant scope.** Scala has no `static`; the idiom for a shared
constant is a `val` inside a singleton `object` (`object Config { val Timeout = 30 }`). A top-level
`val` already extracted as `constant`, but `object` and `class` vals both came out as `field` (the
gate rejects `field`). The fix is a kind refinement in the Scala `val_definition` handler: walk to
the enclosing definition and treat an `object_definition` (or top level) val as `constant`/`variable`
— while a `class`/`trait`/`enum` val stays `field`, because it is per-instance immutable state, the
exact analogue of the Java instance `final` we also keep as `field`. (`object` and `class` both
extract as `class` *kind*, so the distinction is the enclosing AST node type, not the node kind.)
The shadow prune gained `val_definition`/`var_definition` (a method-local `val` can shadow an object
val); the reader-scan needed nothing, since a Scala val reference is a plain `identifier`. Method-local
vals are not extracted at all, so they're not a target source. The one **known limitation** is
Scala's interchangeable `val`/`def` for members: a camelCase val can share a name with a method in the
same file, and same-file name matching can't distinguish them — but it's bounded (like Ruby's
sibling-class case), and on the sweep every flagged val/def collision turned out to be a real `object`
val read by sibling vals (cats' typeclass instances: `val flatMap = monad`, read by
`invariantSemigroupal`). Validated S/M/L (upickle/cats/pekko): node count identical on/off, top
targets genuine object vals (`maxArity` `val = 22`, `DigitTens` lookup table), impact wins real
(`maxArity` 3→17). The distinctive-name gate fits Scala's camelCase/PascalCase constants (`maxArity`,
`IntegralPattern`) via their internal uppercase letter.
**Kotlin combined three already-built techniques.** Kotlin has no `static`: shared constants live at
top level, in an `object` (singleton), or in a class's `companion object` — all `val`/`const val`. A
class instance `val` is per-object state. Nothing extracted before because a Kotlin property name
nests (`property_declaration → variable_declaration → simple_identifier`) and the generic path reads
only a direct child — the **C** problem. The fix handles `property_declaration` in the Kotlin
`visitNode` hook (where the existing one already manages `fun interface` misparses): pull the nested
name, then walk to the enclosing definition to set the kind — `object_declaration`/`companion_object`
(or top level) → `constant`/`variable` (the **Scala** object-vs-class rule), `class_declaration`
`field`, and a property under a `function_body`/`init`/lambda is a local and skipped. The reader-scan
gained `simple_identifier` (Kotlin's reference node — the **PHP `name`** move; `simple_identifier` is
Kotlin-only among the value-ref set), and the shadow prune gained `property_declaration` (a method-local
`val` can shadow an object const). Kotlin's parse fidelity is clean (its one known misparse,
`fun interface`, is already handled), so unlike C++ no precision tail emerged. It validated as one of
the *cleanest* languages: companion-object bit-masks and state constants are a heavy, same-file-read
idiom (coroutines' `BLOCKING_SHIFT` 1→24, `TERMINATED` 2→22 in the scheduler; okio's `STATE_IN_QUEUE`
1→32; ktor's content-type `TYPE` 8→109). okio had 0 collisions, coroutines 1 (cross-file). The same
val/def-or-class name-overlap limitation as Scala applies (ktor's HTTP DSL names a header const and a
class the same), plus the sibling-companion case (several `companion object { const val TYPE }` in one
file collapse to the file-wide target, like Ruby's sibling-class) — both bounded, and every flagged
collision investigated was a real object/companion const.
**Swift reused the Kotlin techniques and added two Swift-specific touches.** Swift has no `static`
keyword for globals; its shared-constant idiom is a top-level `let` or a `static let` inside a type —
and Swift idiomatically *namespaces* constants in `enum`/`struct` (`enum Constants { static let X }`).
A property name nests (`property_declaration → <name> pattern → simple_identifier`), the C-style
problem; the reader-scan already matched `simple_identifier` (added for Kotlin — Swift shares it). The
kind rule: top-level `let` and `static let` (in any type) → `constant` (`var``variable`); an
*instance* `let`/`var` stays `field` (Swift instance stored properties otherwise aren't own nodes —
unchanged). The two Swift-specific touches: (1) **the value-ref target gate was widened to `struct:`/
`enum:` parents**, because Swift namespaces constants in those (every other language's targets sit at
`file:`/`class:`/`module:`); without it, the heavily-used `enum`/`struct` static consts would all be
missed. (2) **Computed properties are skipped** — a `var x: Int { … }` has a getter block, no stored
value, and isn't a constant; the extractor detects the `computed_property` child and emits no node
(verified: no computed-property leaks across the sweep). The node creation slots into the *existing*
Swift `property_declaration` handler (which already extracts property-wrapper / type-annotation
dependencies like `@Published`/`@State`), so that behavior is untouched. Validated S/M/L
(Alamofire/swift-argument-parser/swift-nio): node count identical on/off, genuine static-let
constants (`defaultRetryLimit`, swift-nio's `CONNECT_DELAYER`/`SINGLE_IPv4_RESULT` test constants, a
shared `static let eventLoop` read by 37 methods), computed properties skipped, 01 collisions per
repo (the same sibling-type name-overlap bound as Kotlin/Ruby).
**Dart — the grammar did the scope separation; the catch was a sibling body.** Dart's tree-sitter
grammar is unusually helpful here: a **`static_final_declaration`** node is *exactly* a top-level or
class-`static` `const`/`final` — the shared-constant idiom — while instance fields and `var` use
`initialized_identifier` and method-locals use `initialized_variable_definition`. So a single
`visitNode` rule (`static_final_declaration``constant`, named by its `identifier` child) captures
all and only the constants, with **no instance/local leaks to guard** and no scope-walk needed (the
node stack gives `file:` for top-level, `class:` for a static member). The reader-scan was already
covered (Dart references are plain `identifier`). The non-obvious bug: **Dart attaches a method/function
`body` as a next *sibling* of the signature node** — and the signature is what gets stored as the
reader scope — so the scan walked only the signature and produced *zero* edges until it was taught to
also pull in a `function_body` next-sibling (Dart is the only value-ref language that structures bodies
this way, so the check is inert elsewhere). The shadow prune counts all three Dart declarator nodes so
a method-local `const X` correctly drops a file-scope `const X`. Validated S/M/L (http /
flame-engine/flame / flutter/packages): node count identical on/off, genuine static consts on real
source (flame's `cardWidth` 4→15, `tileSize` 3→12; HTTP/2's `Finishing` 1→10), the same bounded
const-vs-getter name overlap as Kotlin/Scala. **The one caveat is generated code:** the common Dart
codegen suffixes (`.g.dart` / `.freezed.dart` / `.pb.dart`) are already skipped by `isGeneratedFile`,
but a header-only-marked generator (a JNIGEN `_bindings.dart` with hundreds of `static final _class`)
isn't suffix-detected, so it collapses to the file-wide target and dominates a small repo's numbers
(http) — real source stays clean.
**Pascal / Delphi — the easy path plus the Dart sibling-body fix and a `constant`-only restriction.**
Pascal keeps shared constants in a `const` section at unit (file) or class scope, and those *already*
extracted as `constant` (`variableTypes: ['declConst', …]`), so wiring was add-to-`VALUE_REF_LANGS` +
the shadow prune (`declConst`/`declVar` — a function-local `const X` shadows a unit `const X`). It hit
the **same reader-scan bug as Dart**: Pascal attaches a proc body (`block`) as a *next sibling* of the
`declProc` header (the reader scope), both under a `defProc`, so the same sibling-pull fix was extended
to `block`. The Pascal-specific wrinkle is precision: the Pascal extractor emits function **parameters**
(`const ATarget: TControl`, `var Dest: …`) and class **fields** as `variable` at the enclosing scope,
which collapse to noisy file-wide targets — so **Pascal value-ref targets are restricted to
`constant`** (genuine shared values are `const`; the cost is the rare unit-level `var` global). That
cleaned the bulk (`var`-param/field FPs gone). A residual minority remains — tree-sitter-pascal
*context-dependently* misparses a `const` parameter in a complex multi-line Delphi method signature as
a `declConst` (the `ATarget` case; not reproducible in isolation), a parse-fidelity tail like C++ but
far smaller. After the fix: a random precision sample on mORMot was 100% TP (font/crypto/DB constants
referencing each other), castle's top targets are all real FFI binding consts with 0 collisions, and
the headline is FFI library-name constants — `LazGio2_library = 'libgio-2.0…'` read by **1880**
`external` declarations (2→1880), mORMot's `LIB_CRYPTO` 1→358. **Caveats:** low same-file density on
app code (cross-unit reads; horse gave 4 edges), the `const`-only restriction, the rare const-param
misparse, and Pascal's case-insensitivity (the exact-text reader-scan misses a differently-cased
reference — a miss, never an FP).
**C++ was attempted and reverted** — the machinery (file/namespace-scope + class `field_declaration`
extraction) is correct on clean C++, but tree-sitter-cpp's parse fidelity on real template/macro-heavy
code (and the `.h`→C-grammar routing) leaks class members and parameters to file scope as bogus
constants. Two guards (skip declarations under an `ERROR` or `compound_statement` ancestor) removed
~83% of the gross leaks, but the residual pervaded even well-structured library source
(template-class member leaks, amalgamated mega-headers, `.h`-as-C++). It did not reach the precision
bar the other languages hold, so it was reverted. Reviving C++ needs prior work on C++ parse handling
(template-class member scoping, `.h`-as-C++ detection, amalgamated-header exclusion), not a value-refs
wiring pass. See the playbook's §2b C++ note.
**`tsx` is covered by the TS rows** — excalidraw is a React/.tsx codebase, so the headline
`tablerIconProps` (1→170) and most of its targets live in `.tsx` files. The one
tsx-specific path — a const read *only* inside JSX (`<Foo x={CONST}/>`) — relies on the
reader-scan descending into the JSX subtree; it's locked by a unit test
(`value-reference-edges.test.ts`), so no separate tsx repo sweep is needed.
**Svelte / Vue / Astro are covered for free** — their extractors re-parse the `<script>` /
frontmatter block as `typescript` / `javascript`, which are in `VALUE_REF_LANGS`, so a `const`
in a `.svelte`/`.vue`/`.astro` script edges its readers without any extra work (verified on a
synthetic `.svelte`). No separate matrix row. See the playbook's coverage tracker (§2b) for the
full status against the README's language list.
**JavaScript note — CommonJS `require` bindings are targets, and that's correct.** JS edge
growth (~45%) runs higher than TS (~0.71.6%) because `var x = require('…')` bindings and
module-level `var` state pass the distinctive-name gate and are read by same-file functions.
These are *not* noise: changing such a binding (swap the dependency, reassign the state)
genuinely affects its readers, so it's a legitimate impact target. Where it overlaps an
existing `calls` edge, `getImpactRadius` dedups by node — no double-counting. (TS `import`s
dodge this entirely: they're `import`-kind nodes, not `const`/`var`, so never targets.)
## Agent A/B — what it does and doesn't buy (excalidraw, sonnet/high, 12 runs)
- **Impact API (the win):** `impact` ON vs OFF — `tablerIconProps` 1→170,
`COLOR_PALETTE` 15→26, `CaptureUpdateAction` 61→86. This is what `codegraph impact`
and CodeGraph Pro's verdict engine consume via `getImpactRadius`.
- **Agent read-displacement: none — and that's expected.** On an indexed repo the agent
answers impact questions in one codegraph call (0 Read / 0 Grep in *both* arms), and it
reaches for `codegraph_search` / `callers`, **not** `impact`/`explore`, so it often
doesn't query the value-ref edges at all. ON was never worse than OFF. **Do not claim
value-refs reduces agent reads** — the win is blast-radius correctness, not fewer turns.
(This is the "adapt the tool to the agent" wall: edges only help if the agent calls the
edge-traversing tool.)
## Known limitations (intentional)
- **Parameter-only shadowing** is not guarded. The shadow prune counts
`variable_declarator`s, so a file-scope const shadowed *only* by a function parameter of
the same name would slip through. Not observed in S/M/L TS validation, and guarding it
would over-prune legitimate consts whose name coincides with a parameter elsewhere in
the file — so it's left unguarded until a real repo surfaces it.
- **Same-file only.** Cross-file value consumers (a const imported and read elsewhere) are
not edged; that needs import/scope resolution and is out of scope.
- **Reactive/computed reads** (a value read only through a framework getter) have no static
identifier to match and aren't covered.
## Extending to another language
The step-by-step runbook — wiring checklist, validation scripts, FP hunts, per-language
declarator types, and traps — is in
[`value-reference-edges-playbook.md`](./value-reference-edges-playbook.md). Point a fresh
session at it and say "Start on language X." In short: decide whether the language's
constants are file/module-scope (fits) or class-scope (bigger change); confirm the declarator
node type for the shadow prune; sweep small/medium/large public OSS repos; fix FP clusters;
add a matrix row here + a test.
+128
View File
@@ -0,0 +1,128 @@
# tree-sitter-cobol.wasm — provenance & rebuild
`src/extraction/wasm/tree-sitter-cobol.wasm` is built from
[yutaro-sakamoto/tree-sitter-cobol](https://github.com/yutaro-sakamoto/tree-sitter-cobol)
at commit `e99dbdc3d800d5fa2796476efd60af91f6b43d93` with the patch in
`tree-sitter-cobol.patch` applied (grammar.js + src/scanner.c; everything else
is regenerated by `tree-sitter generate`).
## What the patch adds
The upstream grammar (COBOL85, derived from opensource-cobol, NIST-tested)
parses batch COBOL well but chokes on the constructs that dominate real
mainframe and GnuCOBOL code:
1. **`EXEC ... END-EXEC` blocks** (CICS / SQL / DLI). Upstream has no EXEC
support at all — the tokens get absorbed into the preceding statement until
one breaks the parse, cascading hundreds of lines into one ERROR. The patch
adds an `EXEC_BLOCK` external-scanner token that consumes the whole block,
surfaced as a single `exec_statement` node (valid as a procedure statement
and as a data-division entry, for `EXEC SQL INCLUDE`/`DECLARE`).
2. **`NOT=`** without a space (IBM COBOL accepts it).
3. **`FD <name>.` followed by `COPY`** for the record layout — upstream
required a literal record description after every FD (also fixed for
LINKAGE SECTION).
4. **`COPY ... REPLACING ==pseudo-text== BY ==pseudo-text==`** with multiple
replacement pairs. (Upstream's `replacing_clause` never consumed the
REPLACING keyword and allowed only one WORD/string pair.)
5. **Single-quote string continuation lines** (hyphen in the indicator
column). Upstream's scanner handled continuation only for double-quoted
strings; the patch generalizes the quote character and handles doubled-
quote escapes (`'DON''T'`).
6. **`FUNCTION <intrinsic>(refmod)`** — upstream's generic FUNCTION fallback
took arguments but not a reference-modification suffix
(`FUNCTION CURRENT-DATE(1:4)`; the dedicated intrinsic tokens like
`CURRENT-DATE-FUNC` only match opensource-cobol's *preprocessed* names).
7. **Standalone copybook entry point** — a `copybook_fragment` start
alternative so `.cpy` files (data records or procedure paragraphs, no
IDENTIFICATION DIVISION) parse without a wrapper. A file is either
programs or one fragment, keeping program suffixes unambiguous.
8. **Wide mode for free-format source** — the extractor converts free-format
COBOL to fixed by left-padding 7 columns and plants a `CGWIDE` sentinel in
the first line's sequence area; the scanner then relaxes the column-72
right margin (free-format lines routinely exceed it). One byte of scanner
state, carried through serialize/deserialize. Fixed-format files are
untouched.
9. **COBOL-2002 / GnuCOBOL surface**: `BINARY-LONG-LONG`, `FLOAT-LONG`,
`FLOAT-SHORT` usages; `PROGRAM-ID. X IS RECURSIVE.`; relational `WHEN`
objects (`WHEN > 0`); abbreviated combined relations
(`WHEN X < 0 OR > Y`); bitwise operators (`B-AND`, `B-OR`, `B-XOR`,
`B-NOT`, `B-SHIFT-L/-LC`, `B-SHIFT-R/-RC`); the `FREE` statement;
`VALUE <constant-name>`; `PIC X(CONSTANT-NAME)`; `>>` compiler
directives as comments; `ENTRY 'literal' USING ...` (IMS batch
alternate entry points); empty `DATE-COMPILED.` / `DATE-WRITTEN.` /
`SECURITY.` headers.
10. **`CALL ... GIVING`** — upstream *intended* to support it but a misnested
`field()` call swallowed the GIVING alternative entirely.
## Measured parse health (at vendoring time)
| Corpus | Clean parses |
|---|---|
| AWS CardDemo, all programs incl. DB2/IMS/MQ variants (44 `.cbl`) | 43/44 — upstream: 9/31 on the base set alone. The one residual (a period-less `EXEC SQL INCLUDE` between paragraphs) is repaired by the extractor's preParse, which terminates the single-line form with a period. |
| AWS CardDemo copybooks (29 `.cpy`) | 28/29 — upstream: 0/29 (the failure is a `COPY REPLACING` template containing `(TESTVAR1)` placeholders, not valid COBOL pre-substitution) |
| NIST COBOL85 suite (382 programs) | 373/382 — unchanged from upstream |
| CobolCraft (17 free-format GnuCOBOL programs, via wide mode) | 17/17 — upstream: unparseable (free format) |
| Upstream corpus tests | 1 pre-existing failure (`comment`), no new failures |
## Rebuild
```bash
git clone https://github.com/yutaro-sakamoto/tree-sitter-cobol
cd tree-sitter-cobol
git checkout e99dbdc3d800d5fa2796476efd60af91f6b43d93
git apply path/to/tree-sitter-cobol.patch
# tree-sitter 0.24.x needs a tree-sitter.json; grammar name must stay COBOL
# (the C symbols are tree_sitter_COBOL*):
cat > tree-sitter.json <<'JSON'
{
"grammars": [
{ "name": "COBOL", "camelcase": "COBOL", "scope": "source.cobol",
"path": ".", "file-types": ["cbl", "cob", "cpy"] }
],
"metadata": { "version": "0.1.1", "license": "MIT",
"description": "COBOL grammar for tree-sitter",
"links": { "repository": "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } }
}
JSON
npm install tree-sitter-cli@0.24.5
npx tree-sitter generate
npx tree-sitter build --wasm -o tree-sitter-cobol.wasm # needs emscripten or Docker
```
The patches are written to be upstreamable — each is independent and comes
with the failing construct documented above.
## Upstreaming
Sent as [yutaro-sakamoto/tree-sitter-cobol#41](https://github.com/yutaro-sakamoto/tree-sitter-cobol/pull/41)
(branch `real-world-cobol-sources` on the colbymchenry fork). If upstream
merges it, the vendored wasm can track upstream releases instead of this
patch. Until then, `git apply tree-sitter-cobol.patch` on upstream commit
`e99dbdc3` reproduces the fork exactly. The PR body as sent:
> **Parse real-world CICS/DB2 and GnuCOBOL sources**
>
> This adds the constructs that block the grammar on production COBOL, found
> while integrating it into a code-indexing tool. Measured on public corpora:
> AWS CardDemo goes from 9/31 to 43/44 clean parses, CobolCraft (free-format
> GnuCOBOL) from 0 to 17/17, NIST COBOL85 unchanged at 373/382, and the
> existing corpus tests keep their single pre-existing failure (`comment`).
>
> - `EXEC ... END-EXEC` blocks as an external-scanner token (`exec_statement`)
> - Fixed-format single-quote string continuation + doubled-quote escapes
> - `COPY ... REPLACING ==pseudo-text==` with multiple pairs
> - `FD`/`LINKAGE SECTION` record descriptions supplied via `COPY`
> - `NOT=`, `CALL ... GIVING` (a misnested `field()` dropped it), `FREE`,
> `ENTRY`, `PROGRAM-ID ... IS RECURSIVE`, empty `DATE-COMPILED.` headers
> - `FUNCTION <name>(refmod)`, `VALUE <constant>`, `PIC X(CONSTANT)`
> - Relational and abbreviated-combined `WHEN` objects, bitwise `B-*` ops,
> `>>` directives-as-comments, COBOL-2002 usages (`BINARY-LONG-LONG`, ...)
> - A `copybook_fragment` entry point so standalone `.cpy` files parse
> - An opt-in wide mode (sentinel in the first line's sequence area) that a
> free-format preprocessor can use to relax the column-72 margin
>
> Happy to split any of these out or adjust naming/style. Each item is
> independent; `tree-sitter test` passes minus the one pre-existing failure.
+513
View File
@@ -0,0 +1,513 @@
diff --git a/grammar.js b/grammar.js
index 6d48b47..e700a18 100644
--- a/grammar.js
+++ b/grammar.js
@@ -19,6 +19,7 @@ module.exports = grammar({
$._LINE_COMMENT,
$.comment_entry,
$._multiline_string,
+ $._exec_block,
],
extras: $ => [
@@ -29,20 +30,30 @@ module.exports = grammar({
$._LINE_COMMENT_ALIAS,
$.copy_statement,
$.comment,
+ $.directive,
],
rules: {
- start: $ => repeat(
- choice(
- $.program_definition,
- //optional($.function_definition) //todo
- )
+ start: $ => optional(choice(
+ repeat1($.program_definition),
+ $.copybook_fragment,
+ //optional($.function_definition) //todo
+ )),
+
+ // A standalone copybook (.cpy): data descriptions or procedure code
+ // without a program header.
+ copybook_fragment: $ => choice(
+ $.record_description_list,
+ $._procedure_division_contenet
),
_LINE_COMMENT_ALIAS: $ => alias($._LINE_COMMENT, $.comment),
comment: $ => /\*>[^\n]*/,
+ // GnuCOBOL / COBOL-2002 compiler directives: >>SOURCE, >>IF, >>DEFINE, ...
+ directive: $ => />>[^\n]*/,
+
program_definition: $ => prec.right(seq(
$.identification_division,
optional($.environment_division),
@@ -60,7 +71,8 @@ module.exports = grammar({
optional(choice(
$.as_literal,
$.is_initial,
- $.is_common)),
+ $.is_common,
+ $.is_recursive)),
'.')),
repeat(choice(
$.author_section,
@@ -90,6 +102,11 @@ module.exports = grammar({
$._COMMON
),
+ is_recursive: $ => seq(
+ optional($._IS),
+ $._RECURSIVE
+ ),
+
author_section: $ => seq(
$._AUTHOR, '.',
field('comment', repeat1($.comment_entry)),
@@ -102,17 +119,17 @@ module.exports = grammar({
date_written_section: $ => seq(
$._DATE_WRITTEN, '.',
- field('comment', repeat1($.comment_entry)),
+ field('comment', repeat($.comment_entry)),
),
date_compiled_section: $ => seq(
$._DATE_COMPILED, '.',
- field('comment', repeat1($.comment_entry)),
+ field('comment', repeat($.comment_entry)),
),
security_section: $ => seq(
$._SECURITY, '.',
- field('comment', repeat1($.comment_entry)),
+ field('comment', repeat($.comment_entry)),
),
function_definition: $ => seq(
@@ -734,7 +751,7 @@ module.exports = grammar({
file_description: $ => seq(
$.file_type,
$.file_description_entry,
- $.record_description_list
+ optional($.record_description_list)
),
file_type: $ => choice(
@@ -870,12 +887,12 @@ module.exports = grammar({
),
record_description_list: $ => seq(
- repeat1(seq($.data_description, repeat1('.')))
+ repeat1(choice(seq($.data_description, repeat1('.')), prec(1, seq($.exec_statement, optional('.')))))
),
working_storage_section: $ => seq(
$._WORKING_STORAGE, $._SECTION, '.',
- repeat(seq($.data_description, repeat1('.')))
+ repeat(choice(seq($.data_description, repeat1('.')), seq($.exec_statement, optional('.'))))
),
data_description: $ => choice(
@@ -1070,7 +1087,7 @@ module.exports = grammar({
$.picture_edit,
),
- picture_x: $ => /([xX](\([0-9]+\))?)+/,
+ picture_x: $ => /([xX](\(([0-9]+|[a-zA-Z][a-zA-Z0-9-]*)\))?)+/,
picture_n: $ => /([nN](\([0-9]+\))?)+/,
@@ -1119,6 +1136,11 @@ module.exports = grammar({
seq($.BINARY_SHORT, $.SIGNED),
seq($.BINARY_SHORT, $.UNSIGNED),
$.BINARY_SHORT,
+ seq($.BINARY_LONG_LONG, $.SIGNED),
+ seq($.BINARY_LONG_LONG, $.UNSIGNED),
+ $.BINARY_LONG_LONG,
+ $.FLOAT_LONG,
+ $.FLOAT_SHORT,
seq($.BINARY_LONG, $.SIGNED),
seq($.BINARY_LONG, $.UNSIGNED),
$.BINARY_LONG,
@@ -1197,7 +1219,7 @@ module.exports = grammar({
)),
value_item: $ => seq(
- $._literal,
+ choice($._literal, $.qualified_word),
optional(seq(
$.THRU,
$._literal
@@ -1227,7 +1249,7 @@ module.exports = grammar({
linkage_section: $ => seq(
$._LINKAGE, $._SECTION, '.',
- $.record_description_list
+ optional($.record_description_list)
),
report_section: $ => /report_section/,
@@ -1357,6 +1379,19 @@ module.exports = grammar({
'.'
),
+ exec_statement: $ => $._exec_block,
+
+ free_statement: $ => prec.right(seq(
+ $._FREE,
+ repeat1($._target_x)
+ )),
+
+ entry_statement: $ => prec.right(seq(
+ $._ENTRY,
+ field('name', $.string),
+ optional(seq($._USING, repeat1($._call_param)))
+ )),
+
end_program: $ => prec(1, seq(
$._END_PROGRAM,
$.program_name,
@@ -1364,6 +1399,9 @@ module.exports = grammar({
)),
_statement: $ => choice(
+ $.exec_statement,
+ $.entry_statement,
+ $.free_statement,
$.accept_statement,
$.add_statement,
$.allocate_statement,
@@ -1465,12 +1503,19 @@ module.exports = grammar({
),
replacing_clause: $ => seq(
+ $._REPLACING,
+ repeat1($.replacing_pair)
+ ),
+
+ replacing_pair: $ => seq(
field('leading_or_trailing', optional(choice($.LEADING, $.TRAILING))),
- field('x', choice($.WORD, $.string)),
- optional($._BY),
- field('by', choice($.WORD, $.string)),
+ field('x', choice($.pseudo_text, $.WORD, $.string)),
+ $._BY,
+ field('by', choice($.pseudo_text, $.WORD, $.string)),
),
+ pseudo_text: $ => /==([^=\n]|=[^=\n])*==/,
+
start_statement: $ => seq(
$._START,
field('file_name', $.WORD),
@@ -1670,9 +1715,9 @@ module.exports = grammar({
repeat1($._call_param)
))),
optional(choice(
- field('returning', seq($._RETURNING, $._identifier),
- field('giving', seq($._GIVING, $._identifier)),
- )))
+ field('returning', seq($._RETURNING, $._identifier)),
+ field('giving', seq($._GIVING, $._identifier))
+ ))
),
_call_param: $ => choice(
@@ -1902,6 +1947,7 @@ module.exports = grammar({
_evaluate_object: $ => choice(
$.expr,
+ prec.right(seq(choice('=', '>', '<', '>=', '<=', $._NOT_EQUAL), $.expr)),
$.ANY,
$.TRUE,
$.FALSE
@@ -1951,6 +1997,10 @@ module.exports = grammar({
expr: $ => prec.left(choice(
seq($.NOT, $.expr),
seq($.expr, choice($.AND, $.OR), $.expr),
+ // Abbreviated combined relation: `X < 0 OR > Y` — the subject of the
+ // second comparison is implied. (The AND_LT/OR_GT combined tokens the
+ // grammar carries for this never win against keyword lexing.)
+ seq($.expr, choice($.AND, $.OR), $._comparator, $._expr_calc),
$._expr_bool,
seq("(", $.expr, ")")
)),
@@ -1965,6 +2015,11 @@ module.exports = grammar({
)),
_expr_calc_binary: $ => choice(
+ prec.left(1, seq($._expr_calc, $.B_AND, $._expr_calc)),
+ prec.left(1, seq($._expr_calc, $.B_OR, $._expr_calc)),
+ prec.left(1, seq($._expr_calc, $.B_XOR, $._expr_calc)),
+ prec.left(1, seq($._expr_calc, $.B_SHIFT_L, $._expr_calc)),
+ prec.left(1, seq($._expr_calc, $.B_SHIFT_R, $._expr_calc)),
prec.left(1, seq($._expr_calc, '+', $._expr_calc)),
prec.left(1, seq($._expr_calc, '-', $._expr_calc)),
prec.left(2, seq($._expr_calc, '**', $._expr_calc)),
@@ -1974,6 +2029,7 @@ module.exports = grammar({
),
_expr_calc_unary: $ => prec(4, choice(
+ seq($.B_NOT, $._expr_calc),
seq('+', $._expr_calc),
seq('-', $._expr_calc),
seq('^', $._expr_calc),
@@ -2753,7 +2809,7 @@ module.exports = grammar({
seq($.TRIM_FUNCTION, '(', $._trim_args, ')', optional($.func_refmod)),
seq($.NUMVALC_FUNC, '(', $._numvalc_args, ')'),
seq($.LOCALE_DT_FUNC, '(', $._locale_dt_args, ')', optional($.func_refmod)),
- seq($.WORD, optional($.func_args)),
+ seq($.WORD, optional($.func_args), optional($.func_refmod)),
))),
func_refmod: $ => choice(
@@ -2868,6 +2924,16 @@ module.exports = grammar({
_BINARY_CHAR: $ => /[bB][iI][nN][aA][rR][yY]-[cC][hH][aA][rR]/,
_BINARY_DOUBLE: $ => /[bB][iI][nN][aA][rR][yY]-[dD][oO][uU][bB][lL][eE]/,
_BINARY_LONG: $ => /[bB][iI][nN][aA][rR][yY]-[lL][oO][nN][gG]/,
+ _BINARY_LONG_LONG: $ => /[bB][iI][nN][aA][rR][yY]-[lL][oO][nN][gG]-[lL][oO][nN][gG]/,
+ _FREE: $ => /[fF][rR][eE][eE]/,
+ B_AND: $ => /[bB]-[aA][nN][dD]/,
+ B_OR: $ => /[bB]-[oO][rR]/,
+ B_XOR: $ => /[bB]-[xX][oO][rR]/,
+ B_NOT: $ => /[bB]-[nN][oO][tT]/,
+ B_SHIFT_L: $ => /[bB]-[sS][hH][iI][fF][tT]-[lL][cC]?/,
+ B_SHIFT_R: $ => /[bB]-[sS][hH][iI][fF][tT]-[rR][cC]?/,
+ _FLOAT_LONG: $ => /[fF][lL][oO][aA][tT]-[lL][oO][nN][gG]/,
+ _FLOAT_SHORT: $ => /[fF][lL][oO][aA][tT]-[sS][hH][oO][rR][tT]/,
_BINARY_SHORT: $ => /[bB][iI][nN][aA][rR][yY]-[sS][hH][oO][rR][tT]/,
_BLANK: $ => /[bB][lL][aA][nN][kK]/,
_BLANK_LINE: $ => /[bB][lL][aA][nN][kK]-[lL][iI][nN][eE]/,
@@ -3335,6 +3401,9 @@ module.exports = grammar({
BINARY_CHAR: $ => $._BINARY_CHAR,
BINARY_DOUBLE: $ => $._BINARY_DOUBLE,
BINARY_LONG: $ => $._BINARY_LONG,
+ BINARY_LONG_LONG: $ => $._BINARY_LONG_LONG,
+ FLOAT_LONG: $ => $._FLOAT_LONG,
+ FLOAT_SHORT: $ => $._FLOAT_SHORT,
BINARY_SHORT: $ => $._BINARY_SHORT,
//BLANK: $ => $._BLANK,
BLANK_LINE: $ => $._BLANK_LINE,
@@ -3754,7 +3823,7 @@ module.exports = grammar({
COMPUTATIONAL: $ => $._COMPUTATIONAL,
_COMPUTATIONAL: $ => /[cC][oO][mM][pP][uU][tT][aA][tT][iI][oO][nN][aA][lL]/,
- _NOT_EQUAL: $ => /(!=)|([nN][oO][tT][ \t]+(([eE][qQ][uU][aA][lL])|=))/,
+ _NOT_EQUAL: $ => /(!=)|([nN][oO][tT]([ \t]+[eE][qQ][uU][aA][lL]|[ \t]*=))/,
_NOT_LESS: $ => /([nN][oO][tT][ \t]+(<|[lL][eE][sS][sS]))/,
_NOT_GREATER: $ => /([nN][oO][tT][ \t]+(>|[gG][rR][eE][aA][tT][eE][rR]))/,
diff --git a/src/scanner.c b/src/scanner.c
index 6c1ae90..b69cf53 100644
--- a/src/scanner.c
+++ b/src/scanner.c
@@ -1,4 +1,5 @@
#include <tree_sitter/parser.h>
+#include <stdlib.h>
#include <wctype.h>
enum TokenType {
@@ -8,10 +9,21 @@ enum TokenType {
LINE_COMMENT,
COMMENT_ENTRY,
multiline_string,
+ EXEC_BLOCK,
};
+// Wide mode: a preprocessor that converts free-format COBOL to fixed format
+// by left-padding every line 7 columns writes the sentinel "CGWIDE" into the
+// sequence area (columns 1-6) of the FIRST line. Free-format lines routinely
+// run past column 72, so when the sentinel is seen the fixed-format right
+// margin (column 73+ = ignored identification area) is pushed out of reach.
+// The one-byte flag is scanner state, carried through serialize/deserialize.
+#define CG_FIXED_WIDTH 72
+#define CG_WIDE_WIDTH 4096
+static const char CG_WIDE_SENTINEL[6] = {'C', 'G', 'W', 'I', 'D', 'E'};
+
void *tree_sitter_COBOL_external_scanner_create() {
- return NULL;
+ return calloc(1, 1);
}
static bool is_white_space(int c) {
@@ -31,7 +43,7 @@ char* any_content_keyword[] = {
"procedure division",
};
-static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words) {
+static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words, int width) {
while(lexer->lookahead == ' ' || lexer->lookahead == '\t') {
lexer->advance(lexer, true);
}
@@ -45,7 +57,7 @@ static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words)
while(true) {
// At the end of the line
- if(lexer->get_column(lexer) > 71 || lexer->lookahead == '\n' || lexer->lookahead == 0) {
+ if(lexer->get_column(lexer) > width - 1 || lexer->lookahead == '\n' || lexer->lookahead == 0) {
return false;
}
@@ -58,7 +70,7 @@ static bool start_with_word( TSLexer *lexer, char *words[], int number_of_words)
}
if(all_match_failed) {
- for(; lexer->get_column(lexer) < 71 && lexer->lookahead != '\n' && lexer->lookahead != 0;
+ for(; lexer->get_column(lexer) < width - 1 && lexer->lookahead != '\n' && lexer->lookahead != 0;
lexer->advance(lexer, true)) {
}
return false;
@@ -94,6 +106,9 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
return false;
}
+ char *wide = (char *)payload;
+ const int width = (wide && *wide) ? CG_WIDE_WIDTH : CG_FIXED_WIDTH;
+
if(valid_symbols[WHITE_SPACES]) {
if(is_white_space(lexer->lookahead)) {
while(is_white_space(lexer->lookahead)) {
@@ -106,9 +121,20 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
if(valid_symbols[LINE_PREFIX_COMMENT] && lexer->get_column(lexer) <= 5) {
+ // The sequence area is ignored content — but the free-format
+ // preprocessor plants the CGWIDE sentinel here on the first line.
+ int match = 0;
while(lexer->get_column(lexer) <= 5) {
+ if(match >= 0 && match < 6 && lexer->lookahead == CG_WIDE_SENTINEL[match]) {
+ match++;
+ } else {
+ match = -1;
+ }
lexer->advance(lexer, true);
}
+ if(match == 6 && wide) {
+ *wide = 1;
+ }
lexer->result_symbol = LINE_PREFIX_COMMENT;
lexer->mark_end(lexer);
return true;
@@ -132,7 +158,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
if(valid_symbols[LINE_SUFFIX_COMMENT]) {
- if(lexer->get_column(lexer) >= 72) {
+ if(lexer->get_column(lexer) >= width) {
while(lexer->lookahead != '\n' && lexer->lookahead != 0) {
lexer->advance(lexer, true);
}
@@ -143,7 +169,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
if(valid_symbols[COMMENT_ENTRY]) {
- if(!start_with_word(lexer, any_content_keyword, number_of_comment_entry_keywords)) {
+ if(!start_with_word(lexer, any_content_keyword, number_of_comment_entry_keywords, width)) {
lexer->mark_end(lexer);
lexer->result_symbol = COMMENT_ENTRY;
return true;
@@ -152,18 +178,66 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
}
+ if(valid_symbols[EXEC_BLOCK]) {
+ // EXEC (CICS|SQL|DLI|...) ... END-EXEC embedded block. Match the word
+ // EXEC (case-insensitive) followed by whitespace, then consume through
+ // the next END-EXEC. On any mismatch return false so the internal
+ // lexer re-reads the same characters as an ordinary WORD.
+ if(lexer->lookahead == 'e' || lexer->lookahead == 'E') {
+ const char *kw = "exec";
+ int ki = 0;
+ while(ki < 4 && (lexer->lookahead == towupper(kw[ki]) || lexer->lookahead == towlower(kw[ki]))) {
+ lexer->advance(lexer, false);
+ ki++;
+ }
+ if(ki == 4 && (lexer->lookahead == ' ' || lexer->lookahead == '\t' ||
+ lexer->lookahead == '\n' || lexer->lookahead == '\r')) {
+ char ring[8] = {0,0,0,0,0,0,0,0};
+ while(lexer->lookahead != 0) {
+ for(int i = 0; i < 7; ++i) ring[i] = ring[i+1];
+ ring[7] = (char)towlower(lexer->lookahead);
+ lexer->advance(lexer, false);
+ if(ring[0]=='e' && ring[1]=='n' && ring[2]=='d' && ring[3]=='-' &&
+ ring[4]=='e' && ring[5]=='x' && ring[6]=='e' && ring[7]=='c') {
+ lexer->result_symbol = EXEC_BLOCK;
+ lexer->mark_end(lexer);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ }
+
if(valid_symbols[multiline_string]) {
+ int quote = lexer->lookahead;
+ if(quote != '"' && quote != '\'') {
+ return false;
+ }
while(true) {
- if(lexer->lookahead != '"') {
+ if(lexer->lookahead != quote) {
return false;
}
lexer->advance(lexer, false);
- while(lexer->lookahead != '"' && lexer->lookahead != 0 && lexer->get_column(lexer) < 72) {
+ bool closed = false;
+ while(true) {
+ while(lexer->lookahead != quote && lexer->lookahead != 0 && lexer->get_column(lexer) < width) {
+ lexer->advance(lexer, false);
+ }
+ if(lexer->lookahead != quote) {
+ break;
+ }
lexer->advance(lexer, false);
+ if(lexer->lookahead == quote) {
+ // doubled quote = escaped quote inside the literal
+ lexer->advance(lexer, false);
+ continue;
+ }
+ closed = true;
+ break;
}
- if(lexer->lookahead == '"') {
+ if(closed) {
lexer->result_symbol = multiline_string;
- lexer->advance(lexer, false);
lexer->mark_end(lexer);
return true;
}
@@ -187,7 +261,7 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
lexer->advance(lexer, true);
- while(lexer->lookahead == ' ' && lexer->get_column(lexer) < 72) {
+ while(lexer->lookahead == ' ' && lexer->get_column(lexer) < width) {
lexer->advance(lexer, true);
}
}
@@ -197,11 +271,19 @@ bool tree_sitter_COBOL_external_scanner_scan(void *payload, TSLexer *lexer,
}
unsigned tree_sitter_COBOL_external_scanner_serialize(void *payload, char *buffer) {
+ if(payload && buffer) {
+ buffer[0] = *(char *)payload;
+ return 1;
+ }
return 0;
}
void tree_sitter_COBOL_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
+ if(payload) {
+ *(char *)payload = (buffer && length >= 1) ? buffer[0] : 0;
+ }
}
void tree_sitter_COBOL_external_scanner_destroy(void *payload) {
+ free(payload);
}
+171
View File
@@ -0,0 +1,171 @@
# tree-sitter-vbnet.wasm — provenance & rebuild
`src/extraction/wasm/tree-sitter-vbnet.wasm` is built from
[govindbanura/tree-sitter-vbnet](https://github.com/govindbanura/tree-sitter-vbnet)
(MIT) at commit `538b7087bf80e86004531b392fe1186379c0a2b5` with the patch in
`tree-sitter-vbnet.patch` applied. The patch carries two files: `grammar.js`
(edits) and `src/scanner.c` (a new external scanner; upstream has none). The
upstream repo checks in no generated `src/`, so everything else is produced by
`tree-sitter generate`.
Alternatives considered: `CodeAnt-AI/tree-sitter-vb-dotnet` (22★) has **no
license file** and its git history stopped in July 2025 — unusable for
vendoring; `gabriel-gubert/tree-sitter-vbnet` is a 470-line VBScript-flavored
toy. The Roslyn-based approach (PR #627) was withdrawn by its author in favor
of tree-sitter — a Roslyn sidecar would add a .NET runtime dependency to a
local-first npm tool.
## What the patch adds
Upstream parses textbook VB.NET but fails on the constructs that dominate real
codebases (measured: 318% of files parsed clean across PolicyPlus, CompactGUI,
and staxrip before patching). Each item below was found by parse-error census
on those repos plus SCrawler and PCL:
1. **Generic type arguments in dotted names** — `System.Collections.Generic.
Dictionary(Of K, V)`, `Implements IRepository(Of Invoice)`, and
method-level `Implements I(Of T).Member` (generic segments were only
accepted unqualified). Open generic types (`GetType(LoaderTask(Of ,))`)
parse too.
2. **Interpolated strings** `$"… {expr[,align][:fmt]} …"` with `""`/`{{`/`}}`
escapes — including multi-line bodies and content pieces that begin with an
apostrophe: the pieces carry lexical precedence 101 (above `comment`'s 100)
because the comment **extra** otherwise fires *inside* the string rule and
eats the rest of the line, closing quote included.
3. **Date/time literals** `#1/15/2020#` — previously lexed as a preprocessor
directive that swallowed to end-of-line. Directives are now constrained to
`#` + letter (`#If`, `#Region`, …), which real directives always satisfy.
4. **VB 14 multi-line string literals** (a `"…"` literal may span lines since
VS 2015) and single-token `string_literal`/`character_literal` (`"["c`) —
the old multi-token form let extras interleave mid-string.
5. **Numeric literal forms** — hex/octal/binary (`&HFF`, `&O777`, `&B1010`),
digit separators (`1_000`), type characters (`6.0!`, `50.0#`, `1.5@`,
`123&`, `7%`), and lowercase `f/r/d` suffixes. WinForms `.Designer.vb`
files are full of `6.0!`.
6. **Identifier type characters and Unicode identifiers** — `Dim i% = 0`,
`Dim r$ = …` (classic VB style, pervasive in SCrawler) and full Unicode
identifiers (`CrashReason.Java虚拟机参数有误` — PCL is written in Chinese).
The identifier token is now `[\p{L}\p{Nl}_][\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Pc}]*
[%$&!#@]?` with the `u` regex flag. **The `u` flag requires
tree-sitter-cli ≥ 0.25** — 0.24.x silently drops the `\p{…}` classes.
7. **`As New T(args)` initializer clauses** — `as_clause` embeds a full
`object_creation_expression` for the `As New` form, so `Dim x As New
StringBuilder` / `Property P As New List(Of String)` produce instantiation
nodes. `Dim x? = expr` nullable declarators parse as well.
8. **Statement separators and single-line forms** — `:` as a statement
terminator and block opener (`Class X : Inherits Y`, `Case 1 : Return "X"`),
single-line `If … Then stmt Else stmt` (via terminator-less inline statement
variants, aliased to the normal statement node names), inline `RaiseEvent`,
and optional `Then` on block `If` and `ElseIf` (legal VB, used in staxrip).
9. **Multi-line lambdas** — `Sub(…) … End Sub` / `Function(…) … End Function`
bodies (upstream had a statement-block body with no `End` closer, so every
block lambda broke its surrounding argument list), `Async`/`Iterator`
lambda modifiers, `ByVal`/`ByRef` lambda parameters, and single-line
`Sub() If cond Then …` statement bodies.
10. **Member declarations** — `Declare [Auto|Ansi|Unicode] Sub/Function … Lib
"dll" [Alias "…"]` P/Invoke declarations, `Custom Event … AddHandler/
RemoveHandler/RaiseEvent … End Event`, stacked attribute lines above one
member, property `= initializer` before `Implements`, type-less
auto-properties, and **`MustOverride` body-less methods and properties**:
`MustOverride` lexes as a dedicated token (removed from the
`member_modifier` alternation) that only `abstract_method_declaration` /
`abstract_property_declaration` accept, making the body-less parse
deterministic. (A GLR body-less alternative on `method_declaration` was
tried first and measurably poisoned error recovery — 100%→60% clean on
PolicyPlus — before being replaced with the token split.)
11. **Expressions** — VB 15 tuple literals `(a, b)`, array literals
`{1, 2, 3}` (plus nested `{{k, v}, …}` dictionary groups, replacing the
ambiguous upstream `dictionary_initializer`), omitted argument slots
(`f(a,, b)` — Optional parameters passed positionally), `TypeOf x IsNot T`,
generic method calls without parens (`items.OfType(Of Panel)`),
null-conditional indexing `x?(0)`, and `Global.`-qualified type names.
12. **LINQ queries** — query expressions no longer require a trailing
`Select`/`Group` clause, `Aggregate`-led queries, and
`Distinct`/`Skip`/`Take` clauses.
### External scanner (`src/scanner.c`, new)
Two constructs are not LR(1)-parseable with tree-sitter's newline-as-extra
treatment; both get external tokens:
- **`QUERY_CLAUSE_CONTINUATION`** — multi-line LINQ (`From x In xs` ↵
`Where …`). At a clause boundary the newline alone cannot distinguish
"query continues on the next line" from "statement ends here". The scanner
looks past the newline run at the next word and emits the continuation
token only when it is a query-clause keyword (with a `Select Case`
guard), so the decision is made by the lexer instead of the LR table.
- **`XML_LITERAL`** — whole VB XML literals (`<Tags><Tag/></Tags>`) consumed
as one opaque token: element nesting, attributes, comments, CDATA,
processing instructions, and **nested** `<%= … %>` embedded expressions
(the staxrip `WriteTagfile` shape). Valid only where a literal can begin an
expression, so a relational `<` (which always *follows* an expression)
never collides. The scanner never skips a leading newline (it must remain
available as a statement terminator).
The scanner is stateless (serialize/deserialize are no-ops).
The `_eof` hack upstream (a literal-`$` token) cannot match a real
end-of-file, so files whose last line has no trailing newline would end with a
MISSING-newline error; the extractor's `preParse` appends a trailing newline
instead of patching that in the grammar.
## Measured parse health (at vendoring time)
| Corpus | Clean parses |
|---|---|
| Fleex255/PolicyPlus (94 `.vb`) | 94/94 (100%) — upstream: 3/94 |
| IridiumIO/CompactGUI (66) | 66/66 (100%) — upstream: 12/66 |
| staxrip/staxrip (145) | 138/145 (95.2%) — upstream: 22/145 |
| AAndyProgram/SCrawler (320) | 279/320 (87.2%) |
| Meloong-Git/PCL (112, Chinese identifiers) | 98/112 (87.5%) |
Known remaining gap (localized ERROR regions, deliberately unpatched):
- **Column-0 GoTo labels** (`Recheck:` at the start of a line inside indented
code — the classic VB label style, used heavily in PCL). The `word:`
keyword-extraction token interacts badly with a newline immediately followed
by a word at column 0, consuming the newline and dropping the previous
statement's terminator. Removing `word:` fixes labels but reintroduces
keyword-prefix identifier bugs corpus-wide (measured: staxrip 95%→28%), so
`word:` stays and column-0 labels keep a localized error; indented labels
parse fine. Worth an upstream tree-sitter investigation eventually.
## Rebuild
```bash
git clone https://github.com/govindbanura/tree-sitter-vbnet
cd tree-sitter-vbnet
git checkout 538b7087bf80e86004531b392fe1186379c0a2b5
git apply path/to/tree-sitter-vbnet.patch # patches grammar.js, adds src/scanner.c
# tree-sitter needs a tree-sitter.json (upstream ships none); grammar name is
# `vbnet` (C symbols tree_sitter_vbnet*):
cat > tree-sitter.json <<'JSON'
{
"grammars": [
{ "name": "vbnet", "camelcase": "Vbnet", "scope": "source.vbnet",
"path": ".", "file-types": ["vb"] }
],
"metadata": { "version": "0.1.0", "license": "MIT",
"description": "VB.NET grammar for tree-sitter",
"links": { "repository": "https://github.com/govindbanura/tree-sitter-vbnet" } }
}
JSON
npm install tree-sitter-cli@0.25.10 # ≥0.25 REQUIRED: the /u regex flag (Unicode
# identifiers) is dropped silently by 0.24.x
npx tree-sitter generate # src/scanner.c from the patch is picked up
npx tree-sitter build --wasm -o tree-sitter-vbnet.wasm # needs emscripten or Docker
```
Upstream's checked-in `test/corpus` expectations predate its own grammar.js
(every corpus test fails at the pinned commit, before any patching), so the
five-repo parse-health sweep above — plus 16 construct repros and the
`__tests__/extraction.test.ts` VB.NET block — is the regression baseline.
## Upstreaming
Not yet sent. The patch is one large, coherent "parse real-world VB.NET"
change; if upstream shows signs of life it can be offered as a PR the same way
the COBOL patch was ([tree-sitter-cobol#41](https://github.com/yutaro-sakamoto/tree-sitter-cobol/pull/41)),
with the corpus numbers above as the motivation. Until then,
`git apply tree-sitter-vbnet.patch` on upstream commit `538b708` reproduces
the vendored grammar exactly.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff