chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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×2–3 + 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 (29–31k main),
|
||||
but at the cost of **17–26 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* (17–26 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.
|
||||
@@ -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.0–31.2K |
|
||||
| M (<2000) | 9 | 32.4K | 5.4–58.2K |
|
||||
| L (≥2000) | 9 | 34.0K | 20.2–43.1K |
|
||||
|
||||
The small-repo waste is concrete — these all have a 2–3 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 3–5× 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 (~7–9K, 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 B–F 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.7–2.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 (55–79 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)
|
||||
```
|
||||
@@ -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 2–8 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 ±1–2 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 2–8 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 1–2 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 2–4 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/`).
|
||||
@@ -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 ~9–10% to **~14–17% 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).
|
||||
@@ -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 X–Y 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, **5–7 tool calls, 6–8 turns, 55–77s.** (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 **8–9 tool calls, 9–10 turns, 200–239s**, 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.
|
||||
@@ -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).
|
||||
@@ -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** (0–12 → 1–4 — 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 0–12→1–4** (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 1–3 vs 4–11), **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 **1–4 reads /
|
||||
0 grep / 47–53s** vs without **4–5 reads / 2–3 grep / 66–85s** — 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 **4–7 reads / 1–4 grep / 58–81s** vs without **7–9 reads
|
||||
/ 6 grep / 82–86s** — 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 **2–3 reads / 1–2 grep /
|
||||
51–60s** vs without **4–6 / 3–5 / 60–74s**. 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 / 26–30s vs without 3 / 3 / 52–53s — 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 **1–2 reads / 0 grep / 63–75s** vs without **6–7 / 1–6 /
|
||||
77–79s**. 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 **0–1 read / 0 grep /
|
||||
3–4 codegraph / 30–39s** (context→[search]→trace→node) vs without **3 read / 2 grep / 33–36s** — eliminates
|
||||
grep, cuts reads to 0–1 (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 / 20–22s** vs without **1 read / 2 grep + glob / 28–32s** — 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 **0–2 read / 0 grep / 32–40s** vs without **3 read / 0–1 grep + glob / 33–41s** — 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 / 26–30s** (both runs fully clean) vs without **1–4 read / 0–2 grep +
|
||||
glob/bash, one run spawned a sub-agent / 34–48s**. 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 **0–1 read / 0 grep / 1 codegraph / 11–18s** (a
|
||||
single `context` call answers it) vs without **2 read / 0–1 grep + glob / 20–28s**. **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 / 18–24s** vs without **1 read
|
||||
(+glob) / 24–25s** — 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 / 17–22s** vs without **2–3 read / 1–2 grep + glob / 16–17s**. **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.
|
||||
@@ -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.
|
||||
@@ -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.2–10.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 15–20s 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 14–57s 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+980–1080s) | 3.0–3.3s |
|
||||
| kernel (2 cores) | extraction→resolution boundary (t+1354s) | 8.5s |
|
||||
| llvm (mac, fast) | extraction / early resolution (t+1000–1320s) | 5–14s, 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. 2–5k 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 1–5 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 6–7, 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.
|
||||
@@ -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.
|
||||
@@ -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 P1–P3 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): ~1–2 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 A–C) — 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 ~4–5% vs TS ~0.7–1.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`.
|
||||
@@ -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 80–140 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 ~98–100%.
|
||||
|
||||
**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, 0–1 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 (~4–5%) runs higher than TS (~0.7–1.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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: 3–18% 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
Reference in New Issue
Block a user