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 0–4% 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);
}