fix(extraction): index TypeScript interface members (#1638) (#1780)

Land upstream #1686 (maxmilian + bompus kernel/CG-28 follow-ups)
onto current main. tree-sitter-typescript interface members
(method_signature / property_signature) were never listed in the
TS extractor, so platform .d.ts APIs had no declaration nodes for
call edges. Mirrors on the Rust kernel path; keeps CG-28 damping
for pure-interface declaration files; filters damped files from
the explore RWR seed set.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 11:27:05 -05:00
committed by GitHub
co-authored by Colby McHenry
parent 8c047342cd
commit ee83636acb
10 changed files with 375 additions and 49 deletions
+55 -8
View File
@@ -55,6 +55,24 @@ function isLowValueFile(filePath: string, generated?: ReadonlySet<string>): bool
const SQLITE_PARAM_CHUNK_SIZE = 500;
/**
* A SQL predicate: is the node aliased `alias` a member an INTERFACE declares?
*
* `method_signature` / `property_signature` enter the graph as `method` /
* `property` nodes hung off their interface by a `contains` edge (#1638). They
* have no body and originate no behaviour, so for a structural judgement about
* a FILE they are the interface restated, not an extra thing the file declares.
* See {@link QueryBuilder.getAmbientDeclarationPathsAmong}, the one caller, for
* why treating them as opaque would break that rule in three places at once.
*
* Seeks `idx_edges_target_kind`, so it costs a key lookup per row rather than a
* join over the whole edge table.
*/
const IS_INTERFACE_MEMBER = (alias: string): string => `EXISTS (
SELECT 1 FROM edges ce JOIN nodes owner ON owner.id = ce.source
WHERE ce.target = ${alias}.id AND ce.kind = 'contains' AND owner.kind = 'interface'
)`;
/**
* How much of the exact-name bonus a `deprioritize`d path keeps (#982). Damped
* rather than zeroed: a query that genuinely targets that tree must still rank
@@ -2736,6 +2754,29 @@ export class QueryBuilder {
* restricted to the candidate list: the file that imports it is usually
* not itself a candidate.
*
* ### Interface MEMBERS are transparent to all four conditions
*
* A `method_signature` / `property_signature` inside an interface enters the
* graph as a `method` / `property` node (#1638). Read literally that would
* break every condition here at once: condition 2 sees non-type kinds and
* stops flagging, and — worse, because it is silent — condition 4 starts
* seeing inbound `calls` edges the moment a call site through the shim's API
* finally has a signature to land on. An ambient `.d.ts` would quietly lose
* its damping precisely BECAUSE the platform API it declares is widely used.
*
* So an interface-owned member is treated the way `parameter` already is: it
* neither qualifies, disqualifies, nor counts as inbound dependency. That is
* not a new judgement call, it is what keeps the rule measuring what it was
* measured on — before #1638 these nodes did not exist, so excluding them
* reproduces the 04% flag rate the thresholds above were tuned against. It
* is also the semantically right answer: a signature with no body is on the
* same side of the line as the interface that owns it, and a call edge
* landing on one is still not a file that can answer a flow question.
*
* The interface ITSELF is untouched: the `references` edges an importing
* module aims at `UploadStorage` still disqualify the file under (4), which
* is what keeps a depended-on `types.ts` out of the flag.
*
* Bounded-lookup like {@link getGeneratedPathsAmong}: callers hold a ranked
* candidate list, so this is a partial-index probe over a handful of paths.
*/
@@ -2751,14 +2792,15 @@ export class QueryBuilder {
// things the file declares, so they neither qualify nor disqualify.
const rows = this.db
.prepare(`
SELECT file_path,
SUM(CASE WHEN kind NOT IN ('file','import','export','parameter')
SELECT n.file_path AS file_path,
SUM(CASE WHEN n.kind NOT IN ('file','import','export','parameter')
AND NOT ${IS_INTERFACE_MEMBER('n')}
THEN 1 ELSE 0 END) AS declared,
SUM(CASE WHEN kind IN ('interface','type_alias','enum','enum_member','namespace')
SUM(CASE WHEN n.kind IN ('interface','type_alias','enum','enum_member','namespace')
THEN 1 ELSE 0 END) AS typeDeclared
FROM nodes
WHERE file_path IN (${placeholders})
GROUP BY file_path
FROM nodes n
WHERE n.file_path IN (${placeholders})
GROUP BY n.file_path
`)
.all(...chunk) as Array<{ file_path: string; declared: number; typeDeclared: number }>;
let candidates = rows
@@ -2775,17 +2817,22 @@ export class QueryBuilder {
);
candidates = candidates.filter((p) => !hit.has(p));
};
// (3) originates behaviour
// (3) originates behaviour — a signature has no body to originate from,
// so an edge attributed to one is not evidence about this file.
disqualify(`
SELECT DISTINCT n.file_path AS file_path
FROM edges e JOIN nodes n ON n.id = e.source
WHERE e.kind IN ('calls','instantiates') AND n.file_path IN ($IN$)
AND NOT ${IS_INTERFACE_MEMBER('n')}
`);
// (4) something outside the file depends on it
// (4) something outside the file depends on it — but a call that lands on
// an interface's own signature is a use of the API, not a dependency on
// this file's structure. The edges aimed at the interface still count.
disqualify(`
SELECT DISTINCT t.file_path AS file_path
FROM edges e JOIN nodes t ON t.id = e.target JOIN nodes s ON s.id = e.source
WHERE t.file_path IN ($IN$) AND s.file_path <> t.file_path
AND NOT ${IS_INTERFACE_MEMBER('t')}
`);
for (const path of candidates) found.add(path);
}
+8 -1
View File
@@ -41,8 +41,15 @@ export function classifyTsClassMember(node: SyntaxNode): 'method' | 'property' {
export const typescriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'],
classTypes: ['class_declaration', 'abstract_class_declaration'],
methodTypes: ['method_definition', 'public_field_definition'],
// `method_signature` is the interface/type-literal form of a method; without it
// an interface's members never enter the graph, so a `.d.ts` platform API has
// no declaration node for call sites to attach to (#1638). Java/C# don't need
// an equivalent — their grammars reuse `method_declaration`.
methodTypes: ['method_definition', 'public_field_definition', 'method_signature'],
classifyMethodNode: classifyTsClassMember,
// The interface counterpart of `public_field_definition`. It carries no value,
// so it is always a property and never needs classifyMethodNode.
propertyTypes: ['property_signature'],
interfaceTypes: ['interface_declaration'],
structTypes: [],
enumTypes: ['enum_declaration'],
+42 -19
View File
@@ -52,6 +52,20 @@ const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
* initialized with one of these is a component, not a constant (#841). */
const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
/**
* Method node types that spell a SIGNATURE a declaration with no body (#1638).
*
* They are a method of whatever type declares them and nothing on their own, so
* they must not take `extractMethod`'s "no class-like parent, so treat it as a
* free function" fallback. The other `methodTypes` can: a `method_definition`
* outside a class really is a function. This one appears outside a class only
* inside a type literal (`type Handle = { stop(): void }`), whose members
* `extractTypeAlias` already extracts and attaches to the alias (#359) take
* the fallback and the file gains a phantom top-level `function stop` beside
* the real `Handle::stop`.
*/
const SIGNATURE_METHOD_NODE_TYPES = new Set(['method_signature']);
/** Vue store collections whose object-literal members are the symbols an agent
* looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
* findable + readable (the foundation under any later dispatch-bridge synth). */
@@ -1073,8 +1087,13 @@ export class TreeSitterExtractor {
this.extractClass(node);
skipChildren = true;
}
// Check for method declarations (only if not already handled by functionTypes)
else if (this.extractor.methodTypes.includes(nodeType)) {
// Check for method declarations (only if not already handled by functionTypes).
// A bodiless SIGNATURE only counts as one where a type declares it — see
// SIGNATURE_METHOD_NODE_TYPES for what falling through would otherwise mint.
else if (
this.extractor.methodTypes.includes(nodeType)
&& (!SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
) {
// TS/JS class fields parse as a methodTypes node; only function-valued
// fields are methods — a plain field (`public fonts: Fonts;`) is a
// property (#808). C++ lists `field_declaration` so pure-virtual methods
@@ -1335,22 +1354,16 @@ export class TreeSitterExtractor {
else if (nodeType === 'impl_item') {
this.extractRustImplItem(node);
}
// TypeScript interface members: property_signature (`foo: T`, `foo?: T`)
// and method_signature (`foo(arg: A): R`) both carry type annotations the
// interface walker would otherwise drop. Extract them as `references`
// edges from the interface so resolvers can wire callers/impact for
// types that only appear in interface members.
else if (
(nodeType === 'property_signature' || nodeType === 'method_signature') &&
this.isInsideClassLikeNode() &&
this.TYPE_ANNOTATION_LANGUAGES.has(this.language)
) {
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (parentId) {
this.extractTypeAnnotations(node, parentId);
}
// don't skipChildren — nested signatures still need traversal
}
// NOTE: `property_signature` / `method_signature` used to be handled here,
// hanging their type annotations off the ENCLOSING INTERFACE — the only
// anchor available while the members themselves went unextracted. Since
// #1638 they are in the TS extractor's `methodTypes` / `propertyTypes`, so
// the branches above claim them first (under the same `isInsideClassLikeNode`
// guard this branch had, so nothing it used to reach is now missed) and this
// one was dead. The `references` edges survive — `extractMethod` and
// `extractProperty` each call `extractTypeAnnotations` — but now hang off
// the member, which is the more precise anchor: `Api::fetch → PageId` says
// which member wants the type, where `Api → PageId` only said the file did.
// Visit children (unless the extract method already visited them)
if (!skipChildren) {
@@ -2096,8 +2109,18 @@ export class TreeSitterExtractor {
// and the initializer VALUE, which the generic finder below would
// wrongly pick — so fields use the type field only (#808). Other
// languages (C# property_declaration) keep the generic scan.
//
// A `property_signature` (an interface member, #1638) carries a `type`
// field and no value, so it reads the type field too. It cannot take the
// generic scan: that scan's exclusion list covers `identifier` but not the
// `property_identifier` an interface member is named with, so it stops on
// the name and `interface Stats { counts: Record<string, number> }` yields
// `signature: "counts counts"` instead of the type. Named explicitly
// rather than folded into the field test so no other language's
// `property_declaration` moves off the generic scan.
const isTsJsField =
node.type === 'public_field_definition' || node.type === 'field_definition';
node.type === 'public_field_definition' || node.type === 'field_definition'
|| node.type === 'property_signature';
const typeNode = isTsJsField
? getChildByField(node, 'type')
: node.namedChildren.find(
+92 -4
View File
@@ -358,6 +358,14 @@ export const RELEVANCE_KIND_WEIGHT: Readonly<Record<string, number>> = {
};
const DEFAULT_RELEVANCE_KIND_WEIGHT = 0.5;
/**
* The "member of a type" tier of the table above, named so the one kind that
* cannot be read off `node.kind` can be placed on it: an interface's
* `method_signature` (#1638). Same value as `property`/`field`, deliberately
* it is the same tier, not a new one.
*/
const TYPE_MEMBER_RELEVANCE_WEIGHT = 0.5;
/**
* Kinds whose evidentiary value depends on whether anything USES them. An
* exported `const DEFAULTS` that half the codebase references is a real
@@ -3465,6 +3473,35 @@ export class ToolHandler {
// substantive definition (skip empty stubs + test files, same relevance the
// trace endpoint picker uses) and inject it as an entry, so every symbol the
// agent explicitly named is in the subgraph and its file is scored.
/**
* Is this a member an INTERFACE declares a signature with no body (#1638)?
*
* It arrives as an ordinary `method` node, so without asking, every ranking
* stage reads a `.d.ts` full of `method_signature`s as a file full of
* callables. Two stages below ask, for the same reason: a signature is the
* declaration of behaviour, never behaviour, and the rank a file earns must
* not grow just because its interfaces spell their members out.
*
* Cached; reached only for `method` nodes on paths that already probe the
* graph per node, so it adds a key lookup, not a pass.
*/
const interfaceMemberCache = new Map<string, boolean>();
const isInterfaceOwnedMethod = (node: Node): boolean => {
if (node.kind !== 'method') return false;
const cached = interfaceMemberCache.get(node.id);
if (cached !== undefined) return cached;
let owned = false;
try {
owned = cg.getIncomingEdges(node.id).some(
(e) => e.kind === 'contains' && cg.getNode(e.source)?.kind === 'interface',
);
} catch {
owned = false; // a probe failure must not manufacture a penalty
}
interfaceMemberCache.set(node.id, owned);
return owned;
};
const namedSeedIds = new Set<string>();
// The subset of named seeds that earns the named-FIRST sort tier. We still
// SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
@@ -3635,7 +3672,21 @@ export class ToolHandler {
// so a named symbol FTS already gathered never sorted to the top.)
namedSeedIds.add(n.id);
}
for (const n of tierPicks) tierSeedIds.add(n.id);
// An interface's `method_signature` seeds (so RWR and the flow ranking
// still see it, and a query that names it still reaches its file) but
// never earns the named-FIRST tier (#1638). That tier means "the agent
// asked for the symbol DEFINED here", and this seeding says as much —
// it resolves a token to its substantive definition and sorts bodies
// first. A declaration is the stub that sort demotes, not the answer.
// Without this the tier is reachable by prose: `body`, `stream` and
// `metadata` are member names in any platform `.d.ts`, and each one
// corroborates the next through `coNamedInFile`, so an ambient shim
// walks past the NL-stopword guard and lands above every implementation
// file — the exact inversion CG-28 exists to prevent, arriving on a key
// that sorts above the CG-28 penalty.
for (const n of tierPicks) {
if (!isInterfaceOwnedMethod(n)) tierSeedIds.add(n.id);
}
}
}
@@ -3686,9 +3737,21 @@ export class ToolHandler {
isolationCache.set(node.id, isolated);
return isolated;
};
/**
* A `method_signature` reaches here as a `method`, which the kind table
* rates 1.0: "a callable — the unit an architecture question is about". It
* is not that. It is the row below on the same scale, "a member of a type",
* and rating it as a callable is how a 28-interface `.d.ts` doubled its
* score the moment its members became indexable (#1638). Only `method`
* needs correcting; `property` already sits in the member tier whoever
* declares it.
*/
const relevanceWeight = (node: Node, probeIsolation: boolean): number => {
const weight = RELEVANCE_KIND_WEIGHT[node.kind] ?? DEFAULT_RELEVANCE_KIND_WEIGHT;
if (!probeIsolation || !WEAK_RELEVANCE_KINDS.has(node.kind)) return weight;
const signatureOnly = isInterfaceOwnedMethod(node);
const weight = signatureOnly
? TYPE_MEMBER_RELEVANCE_WEIGHT
: RELEVANCE_KIND_WEIGHT[node.kind] ?? DEFAULT_RELEVANCE_KIND_WEIGHT;
if (!probeIsolation || !(signatureOnly || WEAK_RELEVANCE_KINDS.has(node.kind))) return weight;
return isUsageIsolated(node) ? ISOLATED_WEAK_KIND_WEIGHT : weight;
};
@@ -3916,8 +3979,33 @@ export class ToolHandler {
// (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
// text match (LensSwitcher.swift, matched "switch" but calls nothing in the
// flow) gets only its restart probability → ~0, and is dropped by the gate.
//
// A file the ambient-declaration penalty has already damped is a candidate,
// but not a place a walk STARTS. The restart vector is uniform over seeds,
// so every seed divides the restart mass the implementation files compete
// for — and since #1638 a platform `.d.ts` contributes one seed per member,
// whose names (`body`, `stream`, `metadata`) are exactly what a prose flow
// query matches. That is what halves an implementation file's graph mass
// while the shim's holds steady: dilution of the restart vector, not
// connectivity. `contains` is not a RANK_EDGE, so these members carry almost
// no walk mass of their own; seeding is the whole of their effect on rank.
//
// `isDampedDeclaration` and not a bare ambient test: it already exempts a
// file whose declared type the query NAMED, so a query genuinely about the
// declared type keeps its seeds and the shim still ranks first. Damped files
// stay in the candidate set, stay reachable, and keep their `score`
// contribution — this changes only where the walk starts.
const rwrSeedIds = new Set<string>();
for (const id of entryNodeIds) {
const seed = subgraph.nodes.get(id);
if (seed && isDampedDeclaration(seed.filePath)) continue;
rwrSeedIds.add(id);
}
const nodeRwr = this.computeGraphRelevance(
[...subgraph.nodes.keys()], subgraph.edges, entryNodeIds,
// Fall back to the unfiltered seeds when EVERY seed is damped: the walk
// must not lose its restart vector and return all-uniform.
[...subgraph.nodes.keys()], subgraph.edges,
rwrSeedIds.size > 0 ? rwrSeedIds : entryNodeIds,
);
//
// Carries `rankPenalty` too, so generated/low-value files are demoted on the