feat(extraction): same-file value-reference edges for impact analysis — 15 languages (#897)

Adds same-file value-reference edges (reader symbol → const/var it reads) so impact analysis catches a constant's same-file consumers, closing the 'change this table, break its readers' hole. 15 languages validated S/M/L on public OSS: TS/JS/tsx, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, Pascal/Delphi (+ Svelte/Vue/Astro inherited). Edges-only — node count identical on/off; default ON, CODEGRAPH_VALUE_REFS=0 opts out.
This commit is contained in:
Colby Mchenry
2026-06-16 12:16:00 -05:00
committed by GitHub
parent 2f6316500d
commit f34f606342
11 changed files with 2081 additions and 47 deletions
+7
View File
@@ -110,6 +110,13 @@ export const cExtractor: LanguageExtractor = {
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
// A `const`/`static const` file-scope declaration carries a `type_qualifier`
// child reading "const" — extract those as `constant`, plain globals as
// `variable`.
isConst: (node) =>
node.namedChildren.some(
(c: SyntaxNode) => c.type === 'type_qualifier' && c.text === 'const'
),
getReturnType: extractCppReturnType,
resolveTypeAliasKind: (node, _source) => {
// C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;`
+16
View File
@@ -121,6 +121,22 @@ export const csharpExtractor: LanguageExtractor = {
}
return false;
},
// `const` and `static readonly` fields are C# constants (`MaxItems`, lookup
// tables, shared config). Drives `constant` kind so value-reference edges
// target them; instance `readonly` / plain `static` fields stay `field`s.
isConst: (node) => {
let hasStatic = false;
let hasReadonly = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type !== 'modifier') continue;
const t = child.text;
if (t === 'const') return true;
if (t === 'static') hasStatic = true;
else if (t === 'readonly') hasReadonly = true;
}
return hasStatic && hasReadonly;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
+22
View File
@@ -133,6 +133,28 @@ export const dartExtractor: LanguageExtractor = {
callTypes: [], // Dart calls use identifier+selector, handled via extractBareCall
variableTypes: [],
extraClassNodeTypes: ['mixin_declaration', 'extension_declaration'],
// A Dart `static_final_declaration` is exactly a top-level or class-`static`
// `const`/`final` — the shared-constant idiom — so extract it as `constant`
// for value-reference edges. Instance fields, `var`, and typed declarations
// use `initialized_identifier`, and method-locals use
// `initialized_variable_definition`; neither is this node, so there are no
// instance/local leaks to guard. The name is the first `identifier`; its
// parent scope (`file:` top-level / `class:` static member) comes from the
// node stack, both of which the value-reference target gate accepts.
visitNode: (node, ctx) => {
if (node.type === 'static_final_declaration') {
const nameNode = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (nameNode) {
const valueNode = nameNode.nextNamedSibling;
const initValue = valueNode ? getNodeText(valueNode, ctx.source).slice(0, 100) : undefined;
ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined,
});
}
return true;
}
return false;
},
resolveBody: (node, bodyField) => {
// Dart: function_body is a next sibling of function_signature/method_signature
if (node.type === 'function_signature' || node.type === 'method_signature') {
+13
View File
@@ -86,6 +86,19 @@ export const javaExtractor: LanguageExtractor = {
}
return false;
},
// A `static final` field is a Java constant (`MAX_ITEMS`, lookup tables,
// shared config). Drives `constant` kind so value-reference edges target it;
// instance / `final`-only / `static`-only fields stay mutable `field`s.
isConst: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
const text = child.text;
return /\bstatic\b/.test(text) && /\bfinal\b/.test(text);
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const scopedId = node.namedChildren.find((c: SyntaxNode) => c.type === 'scoped_identifier');
+45
View File
@@ -85,6 +85,51 @@ export const kotlinExtractor: LanguageExtractor = {
nameField: 'simple_identifier',
bodyField: 'function_body',
visitNode: (node, ctx) => {
// Kotlin properties (`val` / `var` / `const val`). The name nests as
// property_declaration → variable_declaration → simple_identifier, which the
// generic variable/field path can't read — so nothing was extracted before.
// Kind by enclosing scope: a singleton `object` / `companion object` (and a
// top-level property) holds *shared* values — `val`→`constant`,
// `var`→`variable` (the Scala-object rule; a `const val` is a `val`). A
// `class`/`interface`/`enum` instance `val`/`var` is per-instance state →
// `field` (never a value-ref target, like a Java instance `final`). A
// property inside a function body / `init` block / lambda is a local and is
// skipped entirely.
if (node.type === 'property_declaration') {
const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration');
const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier');
if (!nameNode) return false; // destructuring `val (a,b)` etc. — leave to default
const name = getNodeText(nameNode, ctx.source);
if (!name) return false;
// Walk to the nearest enclosing definition: a function body / init / lambda
// means it's a local; `object`/`companion object` is a constant scope; a
// `class_declaration` (covers class/interface/enum) is an instance scope.
let scope: 'local' | 'const' | 'instance' = 'const';
for (let p = node.parent; p; p = p.parent) {
const pt = p.type;
if (
pt === 'function_body' || pt === 'function_declaration' ||
pt === 'lambda_literal' || pt === 'anonymous_initializer' ||
pt === 'control_structure_body' || pt === 'getter' || pt === 'setter'
) { scope = 'local'; break; }
if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; }
if (pt === 'class_declaration') { scope = 'instance'; break; }
}
if (scope === 'local') return true; // a local — don't extract
const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind');
const isVal = binding != null && getNodeText(binding, ctx.source) === 'val';
const kind = scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable';
const typeNode = node.childForFieldName('type');
const sig = typeNode
? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
: undefined;
ctx.createNode(kind, name, node, { signature: sig });
return true;
}
// Handle Kotlin `fun interface` declarations.
// Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+).
// It produces two different misparse patterns:
+22 -11
View File
@@ -136,18 +136,29 @@ export const scalaExtractor: LanguageExtractor = {
const name = getValVarName(node, ctx.source);
if (!name) return false;
const isInClass = ctx.nodeStack.length > 0 &&
(() => {
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
const parentNode = ctx.nodes.find((n) => n.id === parentId);
return parentNode != null && (
parentNode.kind === 'class' || parentNode.kind === 'trait' ||
parentNode.kind === 'interface' || parentNode.kind === 'struct' ||
parentNode.kind === 'enum' || parentNode.kind === 'module'
);
})();
// An `object` is a singleton: its `val`s are shared constants (the Scala
// idiom for `static final` — `object Config { val Timeout = 30 }`), so
// emit them as `constant`/`variable` like a top-level val, which lets
// value-reference edges target them. A `class`/`trait`/`enum`/`given` val
// is a per-instance immutable field. Both an `object` and a `class`
// extract as `class` kind, so the AST node type of the enclosing
// definition — not the parent node's kind — is what distinguishes them.
let enclosingDef: string | null = null;
for (let p = node.parent; p; p = p.parent) {
if (
p.type === 'class_definition' || p.type === 'trait_definition' ||
p.type === 'enum_definition' || p.type === 'given_definition' ||
p.type === 'object_definition'
) {
enclosingDef = p.type;
break;
}
}
const isInstanceField =
enclosingDef === 'class_definition' || enclosingDef === 'trait_definition' ||
enclosingDef === 'enum_definition' || enclosingDef === 'given_definition';
const kind = isInClass ? 'field' : (t === 'val_definition' ? 'constant' : 'variable');
const kind = isInstanceField ? 'field' : (t === 'val_definition' ? 'constant' : 'variable');
const typeNode = node.childForFieldName('type');
const sig = typeNode
? `${t === 'val_definition' ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
+324 -28
View File
@@ -151,6 +151,84 @@ function scalaBaseTypeName(node: SyntaxNode | null, source: string): string | nu
}
}
/**
* Resolve the declared identifier inside a C declarator. A `declaration`'s
* `declarator` field nests the name through `init_declarator` (with value),
* `pointer_declarator`/`array_declarator`/`parenthesized_declarator`
* wrappers (each via their own `declarator` field) down to an `identifier`.
* A `function_declarator` means the declaration is a function prototype (or a
* function-pointer var) — return null so it isn't extracted as a variable.
*/
function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | null {
let cur: SyntaxNode | null = node;
let guard = 0;
while (cur && guard++ < 12) {
switch (cur.type) {
case 'identifier':
return cur;
case 'function_declarator':
return null;
case 'init_declarator':
case 'pointer_declarator':
case 'array_declarator':
case 'parenthesized_declarator':
cur = getChildByField(cur, 'declarator');
break;
default:
return null;
}
}
return null;
}
/** First `simple_identifier` in `node`'s subtree (breadth-ish, first-found).
* Swift's property name nests as `property_declaration → <name> pattern →
* bound_identifier → simple_identifier`; this resolves it (and the bound name of
* a Kotlin/Swift property declarator for the shadow prune). For a tuple pattern
* (`let (a, b)`) it returns the first — acceptable, those are rare for consts. */
function firstSimpleIdentifier(node: SyntaxNode | null): SyntaxNode | null {
const stack: SyntaxNode[] = node ? [node] : [];
let guard = 0;
while (stack.length > 0 && guard++ < 40) {
const n = stack.shift()!;
if (n.type === 'simple_identifier') return n;
for (let i = 0; i < n.namedChildCount; i++) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return null;
}
/** Swift property facts: the bound name, whether it's a `let`, and whether it's
* a *computed* property (a getter block, no stored value — never a constant). */
function swiftPropertyInfo(
node: SyntaxNode,
source: string,
): { nameNode: SyntaxNode | null; isLet: boolean; isComputed: boolean } {
const pattern =
getChildByField(node, 'name') ??
node.namedChildren.find((c) => c.type === 'value_binding_pattern' || c.type === 'pattern') ??
null;
const binding = node.namedChildren.find((c) => c.type === 'value_binding_pattern');
const isLet = binding != null && getNodeText(binding, source).trimStart().startsWith('let');
const isComputed = node.namedChildren.some(
(c) => c.type === 'computed_property' || c.type === 'protocol_property_requirements',
);
return { nameNode: firstSimpleIdentifier(pattern), isLet, isComputed };
}
/** True when `node` is (transitively) inside a C function body — i.e. a local,
* not a file/namespace-scope declaration. Walks the parent chain to the root. */
function hasFunctionAncestor(node: SyntaxNode): boolean {
let p = node.parent;
while (p) {
if (p.type === 'function_definition') return true;
p = p.parent;
}
return false;
}
/**
* PHP type-position wrapper node kinds (a type-hint is `named_type`,
* `?Foo` is `optional_type`, `A|B` is `union_type`, `A&B` is
@@ -224,11 +302,12 @@ export class TreeSitterExtractor {
// Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
// Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
// value consumers ("change this constant/table, affect its readers").
private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx']);
private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
private static readonly MAX_VALUE_REF_NODES = 20_000;
private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
private fileScopeValues = new Map<string, string>();
private valueRefScopes: Array<{ id: string; node: SyntaxNode }> = [];
private fileScopeValueCounts = new Map<string, number>(); // file-scope nodes per name (conditional-def detection)
private valueRefScopes: Array<{ id: string; node: SyntaxNode; name: string }> = [];
private errors: ExtractionError[] = [];
private extractor: LanguageExtractor | null = null;
private nodeStack: string[] = []; // Stack of parent node IDs
@@ -531,12 +610,36 @@ export class TreeSitterExtractor {
* scopes whose bodies flushValueRefs scans.
*/
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
if ((kind === 'constant' || kind === 'variable') && name.length >= 3 && /[A-Z_]/.test(name)) {
// Pascal targets `constant` only: its extractor emits function PARAMETERS
// (`Dest: TBufferWriter`) and class fields (`declField`) as `variable` at the
// enclosing scope, which would otherwise become noisy targets (a param name
// shared across many procs collapses to one file-wide target). Genuine
// Pascal shared values are `const` (`constant`), so restrict to that. (Unit
// `var` globals are the rare cost; the parameter/field noise dominates.)
const targetKindOk =
this.language === 'pascal' ? kind === 'constant' : kind === 'constant' || kind === 'variable';
if (targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (parentId?.startsWith('file:')) this.fileScopeValues.set(name, id);
// file-scope OR class/module/struct/enum-scope constants are targets.
// Class/module scope matters for languages (Ruby) that keep nearly all
// constants inside a class or module; struct/enum scope matters for Swift,
// which namespaces shared constants in `struct`/`enum` (`enum Constants {
// static let X }`). Readers are same-file methods of that type.
if (
parentId &&
(parentId.startsWith('file:') || parentId.startsWith('class:') ||
parentId.startsWith('module:') || parentId.startsWith('struct:') ||
parentId.startsWith('enum:'))
) {
this.fileScopeValues.set(name, id);
// How many target nodes carry this name. A conditional def
// (`try: X = a; except: X = b`) makes >1 — distinct from a local shadow,
// which adds a binding the prune must catch (see flushValueRefs).
this.fileScopeValueCounts.set(name, (this.fileScopeValueCounts.get(name) ?? 0) + 1);
}
}
if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable') {
this.valueRefScopes.push({ id, node });
this.valueRefScopes.push({ id, node, name });
}
}
@@ -551,32 +654,95 @@ export class TreeSitterExtractor {
private flushValueRefs(): void {
const scopes = this.valueRefScopes;
const targets = this.fileScopeValues;
const fileScopeCounts = this.fileScopeValueCounts;
this.valueRefScopes = [];
this.fileScopeValues = new Map();
this.fileScopeValueCounts = new Map();
if (!this.valueRefsEnabled || !TreeSitterExtractor.VALUE_REF_LANGS.has(this.language)) return;
if (targets.size === 0 || scopes.length === 0 || isGeneratedFile(this.filePath)) return;
// Prune SHADOWED targets. A name bound more than once in the file (e.g. a
// bundled/Emscripten `const Module` re-declared as an inner `var Module` /
// function param) resolves to the INNER binding for nested readers, so a
// file-scope edge to it is a false positive. Those inner re-declarations
// aren't extracted as graph nodes, so detect them at the syntax level:
// count `variable_declarator` names across the tree and drop any target
// bound twice or more. Single-binding (unambiguous) names are kept. This
// complements the path-based isGeneratedFile() check for content-minified
// bundles it can't catch by suffix.
// Prune SHADOWED targets. A target re-bound in an INNER scope (a
// bundled/Emscripten `const Module` re-declared as a nested `var Module`; a
// Go package `const Timeout` shadowed by a local `Timeout := …`; a Python
// module `CONFIG` shadowed by a local `CONFIG = …`) resolves to the inner
// binding for nested readers, so a file-scope edge is a false positive.
// Inner re-bindings aren't graph nodes, so detect them at the syntax level:
// count every declarator of the name across the tree and compare against how
// many FILE-SCOPE nodes carry it. A real shadow makes (declarators >
// file-scope nodes) — the excess is the local binding. A conditional
// module-level def (`try: X = a; except: X = b`) makes them EQUAL (both
// declarators are file-scope nodes), so it's correctly kept. Complements the
// path-based isGeneratedFile() check, which can't catch content-minified
// bundles.
//
// Declarator node types are per-grammar; a file only contains its own
// language's nodes, so matching all of them in one switch is safe.
if (this.tree) {
const declCounts = new Map<string, number>();
const bump = (nameNode: SyntaxNode | null) => {
// `simple_identifier` is Kotlin's name node (a property declarator's name).
if (nameNode && (nameNode.type === 'identifier' || nameNode.type === 'simple_identifier')) {
const nm = getNodeText(nameNode, this.source);
if (targets.has(nm)) declCounts.set(nm, (declCounts.get(nm) ?? 0) + 1);
}
};
const dstack: SyntaxNode[] = [this.tree.rootNode];
let dvisited = 0;
while (dstack.length > 0 && dvisited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {
const n = dstack.pop()!;
dvisited++;
if (n.type === 'variable_declarator') {
const nameNode = n.namedChild(0);
if (nameNode && nameNode.type === 'identifier') {
const nm = getNodeText(nameNode, this.source);
if (targets.has(nm)) declCounts.set(nm, (declCounts.get(nm) ?? 0) + 1);
switch (n.type) {
case 'variable_declarator': // TS/JS/tsx
case 'const_spec': // Go `const X = …`
case 'var_spec': // Go `var X = …`
bump(n.namedChild(0));
break;
case 'const_item': // Rust `const X: T = …`
case 'static_item': // Rust `static X: T = …`
bump(getChildByField(n, 'name'));
break;
case 'let_declaration': // Rust `let x = …` (locals — the shadow source)
case 'short_var_declaration': // Go `x, Y := …`
case 'assignment': { // Python `X = …` / `X: T = …` / `A, B = …`
const left = getChildByField(n, 'left') ?? getChildByField(n, 'pattern') ?? n.namedChild(0);
if (left?.type === 'identifier') bump(left);
else if (left) for (const c of left.namedChildren) bump(c);
break;
}
case 'init_declarator': // C `T X = …` (file-scope const AND the local that shadows it)
bump(cDeclaratorIdentifier(n));
break;
case 'val_definition': // Scala `val X = …` (object/top-level const AND a method-local that shadows it)
case 'var_definition': { // Scala `var X = …`
const pat = getChildByField(n, 'pattern');
if (pat?.type === 'identifier') bump(pat);
break;
}
case 'static_final_declaration': // Dart top-level/`static` `const`/`final` (the target itself)
case 'initialized_identifier': // Dart instance field / `var`
case 'initialized_variable_definition': { // Dart a method-local `const`/`final`/`var` that shadows a const
const id = n.namedChildren.find((c) => c.type === 'identifier');
if (id) bump(id);
break;
}
case 'declConst': // Pascal unit/class `const` (the target itself) AND a function-local `const` that shadows it
case 'declVar': { // Pascal a function-local `var` that shadows a const
bump(getChildByField(n, 'name'));
break;
}
case 'property_declaration': { // Kotlin / Swift `val`/`let X = …` (object/static const AND a method-local that shadows it)
// Kotlin: variable_declaration → simple_identifier; Swift: a `pattern`
// (`<name>` field) → simple_identifier. Resolve either shape.
const vd = n.namedChildren.find((c) => c.type === 'variable_declaration');
const id = vd
? vd.namedChildren.find((c) => c.type === 'simple_identifier')
: firstSimpleIdentifier(
getChildByField(n, 'name') ??
n.namedChildren.find((c) => c.type === 'value_binding_pattern' || c.type === 'pattern') ??
null,
);
if (id) bump(id);
break;
}
}
for (let i = 0; i < n.namedChildCount; i++) {
@@ -584,20 +750,46 @@ export class TreeSitterExtractor {
if (c) dstack.push(c);
}
}
for (const [nm, c] of declCounts) if (c > 1) targets.delete(nm);
for (const [nm, c] of declCounts) if (c > (fileScopeCounts.get(nm) ?? 1)) targets.delete(nm);
if (targets.size === 0) return;
}
for (const scope of scopes) {
const seen = new Set<string>();
const stack: SyntaxNode[] = [scope.node];
// Dart and Pascal attach a function/method BODY as a *next sibling* of the
// signature node that is stored as the reader scope (Dart `method_signature`
// ← `function_body`; Pascal `declProc` ← `block`, both under a `defProc`),
// not as a child — so the scope subtree is just the signature and the reads
// live in the sibling. Pull it in. (A body as a next sibling of the scope
// node is unique to Dart/Pascal among the value-ref languages — every other
// grammar nests the body inside the function node — so this is inert
// elsewhere.)
const sib = scope.node.nextNamedSibling;
if (sib && (sib.type === 'function_body' || sib.type === 'block')) stack.push(sib);
let visited = 0;
while (stack.length > 0 && visited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {
const n = stack.pop()!;
visited++;
if (n.type === 'identifier') {
const targetId = targets.get(getNodeText(n, this.source));
if (targetId && targetId !== scope.id && !seen.has(targetId)) {
// `constant` covers Ruby, where both a constant's definition and its
// references are `constant`-typed nodes, not `identifier`. `name` covers
// PHP, where a constant reference — bare `MAX_ITEMS` or the const half of
// `self::MAX_ITEMS` / `Foo::MAX_ITEMS` — is a `name` node (a `$var` local
// is a `variable_name`, a different namespace, so it can never shadow a
// bare constant — no prune wiring needed). `simple_identifier` covers
// Kotlin, whose every name reference (a const read included) is that
// node type. Safe across languages: a file only holds its own grammar's
// nodes; `name` is PHP-only and `simple_identifier` is Kotlin-only here.
if (
n.type === 'identifier' || n.type === 'constant' ||
n.type === 'name' || n.type === 'simple_identifier'
) {
const refName = getNodeText(n, this.source);
const targetId = targets.get(refName);
// Skip self and same-name targets: a symbol referencing a file-scope
// sibling of its own name (the two halves of a conditional `try: X=…;
// except: X=…`) is never a meaningful value read.
if (targetId && targetId !== scope.id && refName !== scope.name && !seen.has(targetId)) {
seen.add(targetId);
this.edges.push({
source: scope.id,
@@ -750,8 +942,15 @@ export class TreeSitterExtractor {
skipChildren = true;
}
// Check for variable declarations (const, let, var, etc.)
// Only extract top-level variables (not inside functions/methods)
else if (this.extractor.variableTypes.includes(nodeType) && !this.isInsideClassLikeNode()) {
// Only extract top-level variables (not inside functions/methods) — plus
// class/module-scope CONSTANTS, which Ruby (and other const-in-class
// languages) keep almost exclusively inside a class/module. A Ruby `CONST =
// …` has a `constant`-typed LHS; other languages don't put one here, so this
// is effectively Ruby-only and doesn't disturb their class-internal locals.
else if (
this.extractor.variableTypes.includes(nodeType) &&
(!this.isInsideClassLikeNode() || this.isClassScopeConstantAssignment(node))
) {
this.extractVariable(node);
// extractVariable doesn't walk every initializer shape (object literals
// are deliberately skipped; Python/Ruby don't walk at all), so scan the
@@ -775,6 +974,21 @@ export class TreeSitterExtractor {
this.isInsideClassLikeNode()
) {
const ownerId = this.nodeStack[this.nodeStack.length - 1];
// A `static let`/`static var` member is a SHARED constant of the type
// (Swift's `static`-namespacing idiom, esp. in `enum`/`struct`) — extract
// it as `constant`/`variable` so value-reference edges can target it. An
// instance stored property stays a `field` (per-instance; Swift instance
// properties otherwise aren't own nodes — that's unchanged). A *computed*
// property (getter, no stored value) is never a constant — skip the node.
const { nameNode, isLet, isComputed } = swiftPropertyInfo(node, this.source);
if (nameNode && !isComputed) {
const isStatic = this.extractor.isStatic?.(node) ?? false;
this.createNode(isStatic ? (isLet ? 'constant' : 'variable') : 'field',
getNodeText(nameNode, this.source), node, {
visibility: this.extractor.getVisibility?.(node),
isStatic,
});
}
if (ownerId) {
this.extractDecoratorsFor(node, ownerId);
this.extractVariableTypeAnnotation(node, ownerId);
@@ -1060,6 +1274,18 @@ export class TreeSitterExtractor {
);
}
/**
* Ruby `CONST = …` assignment whose LHS is a `constant` node — a class/module
* (or top-level) constant worth extracting as a symbol even inside a class.
* Other languages don't give an assignment a `constant`-typed LHS, so this
* gate is effectively Ruby-only.
*/
private isClassScopeConstantAssignment(node: SyntaxNode): boolean {
if (node.type !== 'assignment') return false;
const left = getChildByField(node, 'left') ?? node.namedChild(0);
return left?.type === 'constant';
}
/**
* Extract a function
*/
@@ -1519,6 +1745,17 @@ export class TreeSitterExtractor {
const visibility = this.extractor.getVisibility?.(node);
const isStatic = this.extractor.isStatic?.(node) ?? false;
// A class field that is actually a CONSTANT (Java `static final`, C# `const`
// / `static readonly`) is extracted as `constant` kind, not `field`, so
// value-reference edges treat it as a target (the gate accepts
// constant/variable, not field). Scoped to languages whose `isConst`
// predicate is field-shaped — other languages' fields stay `field`.
const fieldKind: NodeKind =
(this.language === 'java' || this.language === 'csharp') &&
(this.extractor.isConst?.(node) ?? false)
? 'constant'
: 'field';
// Java field_declaration: "private final String name = value;" → variable_declarator(s) are direct children
// C# field_declaration: wraps in variable_declaration → variable_declarator(s)
let declarators = node.namedChildren.filter(
@@ -1579,7 +1816,7 @@ export class TreeSitterExtractor {
if (!nameNode) continue;
const name = getNodeText(nameNode, this.source);
const signature = typeText ? `${typeText} ${name}` : name;
const fieldNode = this.createNode('field', name, decl, {
const fieldNode = this.createNode(fieldKind, name, decl, {
docstring,
signature,
visibility,
@@ -1603,7 +1840,7 @@ export class TreeSitterExtractor {
|| node.namedChildren.find(c => c.type === 'identifier');
if (nameNode) {
const name = getNodeText(nameNode, this.source);
this.createNode('field', name, node, {
this.createNode(fieldKind, name, node, {
docstring,
visibility,
isStatic,
@@ -1813,7 +2050,9 @@ export class TreeSitterExtractor {
const left = getChildByField(node, 'left') || node.namedChild(0);
const right = getChildByField(node, 'right') || node.namedChild(1);
if (left && left.type === 'identifier') {
// Ruby constant assignments (`MAX = 3`) have a `constant`-typed LHS, not
// `identifier`; without this they were never extracted as symbols at all.
if (left && (left.type === 'identifier' || left.type === 'constant')) {
const name = getNodeText(left, this.source);
// Skip if name starts with lowercase and looks like a function call result
// Python constants are usually UPPER_CASE
@@ -1903,6 +2142,63 @@ export class TreeSitterExtractor {
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported });
});
} else if (this.language === 'c') {
// C: a `declaration` node's name nests inside the `declarator` field —
// `init_declarator` (with value) or bare/pointer/array declarators (no
// value); a `function_declarator` is a prototype, not a variable. The
// generic fallback below only finds a *direct* identifier child, which C
// never has, so file-scope consts/globals went unextracted entirely (and
// so had no impact-radius edges). Only file-scope declarations are tracked
// — locals inside a function body are skipped (a `static const` table read
// by same-file functions is the value the impact graph wants, not every
// block-local). C allows several declarators per declaration
// (`int a = 1, b = 2;`), so iterate them.
if (!hasFunctionAncestor(node)) {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
// Accept only `init_declarator` (has a value) and pointer/array
// declarators. A *bare* `identifier` declarator is deliberately
// skipped: an unknown leading macro (`CURL_EXTERN`, `XXH_PUBLIC_API`)
// makes tree-sitter-c misparse a prototype `MACRO RetType fn(args);`
// as a declaration whose "variable" is the bare return-type
// identifier, splitting `fn(args)` off as a bogus expression — minting
// a spurious type-named global for every macro-prefixed prototype in a
// header. Those misparses are always bare identifiers; real
// consts/tables always carry an initializer. The only legit loss is
// uninitialized scalar globals (`static int g;`).
if (
child.type !== 'init_declarator' &&
child.type !== 'pointer_declarator' &&
child.type !== 'array_declarator'
) {
continue;
}
const nameNode = cDeclaratorIdentifier(child);
if (!nameNode) continue;
const name = getNodeText(nameNode, this.source);
if (!name) continue;
const valueNode =
child.type === 'init_declarator' ? getChildByField(child, 'value') : null;
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue
? `= ${initValue}${initValue.length >= 100 ? '...' : ''}`
: undefined;
this.createNode(kind, name, child, { docstring, signature: initSignature, isExported });
}
}
} else if (this.language === 'swift') {
// Swift top-level property (`let X = …` / `var Y = …`). The name nests in
// a `pattern`, which the generic fallback can't read, so top-level Swift
// constants/globals went unextracted. A top-level `let`→`constant`,
// `var`→`variable`; a computed property (getter, no value) is skipped.
const { nameNode, isLet, isComputed } = swiftPropertyInfo(node, this.source);
if (nameNode && !isComputed) {
this.createNode(isLet ? 'constant' : 'variable', getNodeText(nameNode, this.source), node, {
docstring,
isExported,
});
}
} else {
// Generic fallback for other languages
// Try to find identifier children