41 KiB
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. This file is the how-to.
0. "Start on language X" — do this in order
- Read §1 (how it works) and §2 (current state) so you know the mechanism and what's done.
- 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).
- 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.)
- Add a row to the matrix in
value-reference-edges.mdand a test case in__tests__/value-reference-edges.test.ts. - 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}andname.length >= 3and/[A-Z_]/.test(name)(distinctive name — dodges single-letter / all-lowercase shadowing) and the node's parent id starts withfile:,class:, ormodule:(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=0disables). 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-
identifierdeclarators). 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
fieldkind, which the gate rejects. The whole change was emitting the const subset asconstant: anisConstpredicate on each extractor (Javastatic final; C#const/static readonly) + a kind switch inextractField. No new shadow-prune wiring (method locals arevariable_declarator, already in the switch) and no FP guards (UPPER_SNAKE / PascalCase fit the distinctive-name gate). Instancefinal/readonlyfields correctly stayfield. Validated S/M/L: gson/commons-lang/guava, automapper/newtonsoft/efcore — 0 leaks, node parity, big impact wins (INDEX_NOT_FOUND4→165,_resourceManager22→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 anamenode (bareX, or the const half ofself::X/Foo::X). No extractor change, no prune wiring (a$varlocal 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
objectis the constant scope. Scala has nostatic; a singletonobject'svals are the shared-constant idiom (object Config { val Timeout = 30 }). Top-levelvalalready extracted asconstant, but object/class vals both came out asfield. The fix: in the Scalaval_definitionhandler, walk to the enclosing definition —object_definition(or top-level) →constant/variable;class/trait/enum→field(per-instance, like Java instancefinal). Addedval_definition/var_definitionto the shadow prune (method-localvalshadows). Reader-scan needed nothing (refs areidentifier). Minor known limitation: Scala usesval/definterchangeably 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
.hfiles route to the C grammar): class members and parameters leak to file scope as bogus constants/variables. Two guards (skipERROR-ancestor andcompound_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: handleproperty_declarationin the KotlinvisitNodehook — 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 afunction_body/init/lambda), addsimple_identifierto the reader-scan (the PHP-namemove), andproperty_declarationto the shadow prune. Clean parse fidelity (the onefun interfacemisparse 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 letin a type are the shared constants (enum/structnamespace them); instanceletstaysfield. 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 tostruct:/enum:parents — Swift namespaces constants there (enum Constants { static let X }), and every other language's targets arefile:/class:/module:; (2) computed properties are skipped (avar x:Int{ … }getter has no stored value — detect thecomputed_propertychild). Node creation slots into the existing Swiftproperty_declarationhandler (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_declarationis exactly a top-level/staticconst/final(the shared-constant idiom), while instance fields/varuseinitialized_identifierand locals useinitialized_variable_definition— so extractingstatic_final_declaration→constant(in avisitNodehook) has no instance/local leaks to guard. Reader-scan free (Dart refs areidentifier). The catch was the reader-scan: Dart attaches a method/functionbodyas 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 afunction_bodynext-sibling (Dart-only among the value-ref set). Shadow prune neededstatic_final_declaration+initialized_identifier+initialized_variable_definition(a localconst Xshadowing a fileconst X). Validated S/M/L (http/flame/flutter-packages). Caveat: generated Dart files inflate the sibling-class ambiguity (a JNIGEN_bindings.dartwith hundreds ofstatic final _classcollapses to the file-wide target). The common codegen suffixes (.g.dart/.freezed.dart/.pb.dart) are already filtered byisGeneratedFile; 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
constalready extracted asconstant(variableTypes: ['declConst', …]), so it was add-to-VALUE_REF_LANGS+ the shadow prune (declConst/declVar; a localconst Xshadows a unitconst X). The catch was the same reader-scan bug as Dart: Pascal's proc body is ablocksibling of thedeclProcheader (the reader scope), both under adefProc— so the same sibling-pull fix was extended toblock. Reader-scan node type already covered (refs areidentifier). 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=0emits 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:
isGeneratedFile(path)(src/extraction/generated-detection.ts) — skips suffix-recognised generated files (.pb.ts,.min.js, …). Path-only — cannot catch content-minified bundles.- 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 Modulere-declared as an innervar 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=…). - 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 againstcallsedges in impact. Not noise. - Module-level mutable
varstate 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:
#!/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)
# (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:
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 thevariableTypesbranch skips class-internal assignments — Ruby needed an exception forconstant-LHS assignments); (2) the reader-scan must match however the grammar represents a constant reference (Ruby usesconstantnodes, notidentifier). 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 — thestruct:/enum:widening was added for Swift, which namespaces shared constants inenum/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'sstruct:/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
flushValueRefsmatchesidentifier/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 anamenode (bareX, and the const half ofself::X/Foo::X), which the scan missed untilnamewas added. Dump a sample's reader body (scripts/agent-evalor a quickgetParserwalk) 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
- Add the language string to
VALUE_REF_LANGS. npm run build.- 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).
- Run §4.2 FP hunts on each. Fix FP clusters (extend a guard); record singletons.
- Run §4.3 impact delta on a few targets.
- 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). npx vitest run __tests__/value-reference-edges.test.tsand 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 onfeat/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 --adminwhen 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. aninterface-implcallsedge whoseregisteredAtis…/textModelValueReference.ts). Always filterkind='references' AND metadata LIKE '%"valueRef":true%'. This created a phantom "method target" FP on vscode that was pure query noise. searchNodesreturnsSearchResult[](.nodewraps theNode) — in tests use.map(r => r.node).getImpactRadius().nodesis aMap— iterate.values().CodeGraph.initSync(dir, opts)ignoresopts— it takes only the path; the default config indexes.ts/.tsx/.js. Don't rely on a passedinclude.- 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
/tmpafter (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 prototypeMACRO RetType fn(args);as a declaration whose declared "variable" is the bare return-type identifier (XXH_errorcode), splittingfn(args)into a bogus expression. It mints one spurious type-named global per prototype — then edged by every function of that type (redisXXH_errorcode1→18). These misparses always produce a bareidentifierdeclarator (checked across pointer/array/sized-return variants); real consts/tables always have aninit_declaratorand real pointer/array globals their own declarator. Fix = skip bare-identifierdeclarators 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
fieldkind, notconstant(Java/C#). Step C must check the kind, not just that a node exists: Javastatic finaland C#const/static readonlycame out asfield, which the value-ref target gate (constant/variableonly) silently rejects — so the feature emitted nothing despite the nodes being present. Fix = anisConstpredicate on the extractor (gated on the const modifiers) + a kind switch inextractField(scoped per-language so other languages' fields stayfield). Don't widen the gate to acceptfield— that would pull in every mutable instance field as a target. And only the const subset converts: a Java instancefinalor C# instancereadonlyis per-object state, must stayfield. - 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
namenode, notidentifier/constant, so the scan saw nothing untilnamewas 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 —flushValueRefsonly runs for the value-ref set, and a file holds only its own grammar's nodes;nameis 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::DEBUGeverywhere), 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
variableat the wrong scope — restrict toconstant(the Pascal trap). Pascal's extractor emits functionconst/varparameters and class fields asvariableparented to the enclosing unit/class, so they pass the target gate and collapse to noisy file-wide targets (Dest,aItemread "everywhere"). The genuine shared values were allconstant(declConst), so the fix is a one-line per-language restriction incaptureValueRefScope: Pascal targetsconstantonly. Before trusting a new language'svariabletargets, sample them — if they're parameters or instance fields rather than module/global state, restrict toconstant. (A residual tail can still leak: tree-sitter-pascal context-dependently misparses aconstparam in a complex Delphi signature as adeclConst— 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.