Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions __tests__/explore-declaration-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,36 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});

/**
* Type-level for the purposes of this gate: a type declaration, or a member
* an interface declares.
*
* The second half is not a loosening. Since #1638 a `method_signature` /
* `property_signature` is indexed as a `method` / `property` node, so a file
* of nothing but interfaces no longer reads as nothing but `interface` kinds
* — but a bodiless signature is on the same side of the line as the interface
* that owns it, which is exactly how `getAmbientDeclarationPathsAmong` counts
* it. What this still catches, and is here to catch, is a `function` or a
* `class` creeping into the fixture: that would silently exempt the file and
* make every assertion below vacuous.
*/
const isTypeLevel = (n: { id: string; kind: string }, filePath: string): boolean => {
if (n.kind === 'interface' || n.kind === 'type_alias') return true;
if (n.kind !== 'method' && n.kind !== 'property') return false;
const interfaceIds = new Set(
cg.getNodesInFile(filePath).filter((x) => x.kind === 'interface').map((x) => x.id),
);
return cg.getIncomingEdges(n.id)
.some((e) => e.kind === 'contains' && interfaceIds.has(e.source));
};

describe('fixture shape — if this rots, the gate below means nothing', () => {
it('holds two declaration-only files that differ only in the banner', () => {
for (const p of [HANDWRITTEN_DECL, GENERATED_DECL]) {
const nodes = cg.getNodesInFile(p).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length, `${p} declares nothing`).toBeGreaterThan(10);
// Every symbol type-level, nothing with a body — the structural test the
// penalty keys on. A `function`/`class` creeping in would silently exempt
// the file and make every assertion below vacuous.
expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias'), `${p} has a non-type symbol`).toBe(true);
// Nothing with a body — the structural test the penalty keys on.
expect(nodes.every((n) => isTypeLevel(n, p)), `${p} has a non-type symbol`).toBe(true);
}
// Only one of them announces itself, so the CG-25 penalty is the ONLY
// difference between the two — that is what makes them comparable.
Expand All @@ -119,7 +140,7 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
// structure of any answer about that code.
const nodes = cg.getNodesInFile(SHARED_TYPES).filter((n) => n.kind !== 'file' && n.kind !== 'import');
expect(nodes.length).toBeGreaterThan(0);
expect(nodes.every((n) => n.kind === 'interface' || n.kind === 'type_alias')).toBe(true);
expect(nodes.every((n) => isTypeLevel(n, SHARED_TYPES))).toBe(true);
expect(cg.getFile(SHARED_TYPES)?.generated).toBeFalsy();
});

Expand Down Expand Up @@ -176,6 +197,23 @@ describe('CG-28 — a declaration-only file does not outrank implementation on a
expect(isAmbient(SHARED_TYPES)).toBe(false);
expect(isAmbient(HANDWRITTEN_DECL)).toBe(true);
});

it('still flags a shim whose interfaces now contribute method/property nodes', () => {
// The silent-failure guard for #1638. Interface members are indexed, so a
// pure-interface `.d.ts` no longer holds only `interface` kinds — and the
// ambient rule is spelled as "EVERY declared symbol is type-level". Read
// literally that stops flagging the moment the extractor improves, and
// nothing else fails: the file just quietly ranks undamped again.
//
// Pinned from both ends on purpose. The `toBeGreaterThan(0)` half is what
// keeps the other half honest — assert only the flag and this test would
// still pass on an index where the members were never extracted at all,
// which is precisely the state it exists to detect a regression FROM.
const members = cg.getNodesInFile(HANDWRITTEN_DECL)
.filter((n) => n.kind === 'method' || n.kind === 'property');
expect(members.length, 'interface members are not indexed — see #1638').toBeGreaterThan(0);
expect(cg.ambientDeclarationFilePredicate([HANDWRITTEN_DECL])(HANDWRITTEN_DECL)).toBe(true);
});
});

describe('the counter-case — a query that NAMES a declared type', () => {
Expand Down
61 changes: 60 additions & 1 deletion __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,55 @@ interface Hprops {
expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
});

it('indexes interface members, not just the interface itself', () => {
// tree-sitter-typescript spells interface members `method_signature` /
// `property_signature`, distinct from the class-member types the extractor
// listed, so they were never captured (#1638). Java/C# are unaffected —
// their grammars reuse `method_declaration`, already in their methodTypes.
// The cost lands on `.d.ts` platform APIs: with no declaration node, call
// sites through the interface have nothing to attach an edge to.
const code = `
export interface PlatformApi {
fetchPage(id: string): Promise<string>;
version: string;
}
`;
const result = extractFromSource('api.d.ts', code);

const iface = result.nodes.find((n) => n.kind === 'interface' && n.name === 'PlatformApi');
const method = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetchPage');
const prop = result.nodes.find((n) => n.kind === 'property' && n.name === 'version');
expect(iface).toBeDefined();
expect(method).toBeDefined();
expect(prop).toBeDefined();

// Attached to the interface, not merely present. A member the graph holds
// but hangs off the file is not a declaration a call edge can be resolved
// through, which is the whole point of extracting it.
const contained = result.edges
.filter((e) => e.kind === 'contains' && e.source === iface!.id)
.map((e) => e.target);
expect(contained).toContain(method!.id);
expect(contained).toContain(prop!.id);
});

it('does not mint a top-level function from a type literal method signature', () => {
// The failure mode the class-like guard on `method_signature` exists for
// (#1638). `extractMethod` treats a method node with no class-like parent
// as a free function — right for `method_definition`, wrong for a bodiless
// signature, whose only home outside an interface is a type literal. Those
// members are already extracted onto the alias (#359), so without the guard
// the file gains a phantom `function stop` beside the real `Handle::stop`.
const result = extractFromSource('t.ts', `
export type Handle = { stop(): void; label: string };
`);

const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'Handle');
expect(alias).toBeDefined();
expect(result.nodes.find((n) => n.kind === 'method' && n.name === 'stop')).toBeDefined();
expect(result.nodes.filter((n) => n.kind === 'function' && n.name === 'stop')).toEqual([]);
});

it('should extract type references from interface method signatures', () => {
const code = `
import type { IPage } from '../PromoterList';
Expand Down Expand Up @@ -842,10 +891,20 @@ export type Names = ['alpha', 'beta'];
`;
const result = extractFromSource('noise.ts', code);

// Since #1638 the fixture's own interfaces legitimately declare `id` / `name`
// (`User::id`, `User::name`, `Service::name`), so membership in the name list
// no longer implies a leak. What #634 guards is the *source*: a node minted
// from a string literal in `Pick<User, 'id'>` or a tuple has no declaring
// interface, so exclude anything a `contains` edge ties to one.
const ifaceIds = new Set(result.nodes.filter((n) => n.kind === 'interface').map((n) => n.id));
const declaredInInterface = new Set(
result.edges.filter((e) => e.kind === 'contains' && ifaceIds.has(e.source)).map((e) => e.target)
);
const leaked = result.nodes.filter(
(n) =>
(n.kind === 'method' || n.kind === 'property') &&
['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name)
['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name) &&
!declaredInInterface.has(n.id)
);
expect(leaked).toEqual([]);
});
Expand Down
6 changes: 5 additions & 1 deletion __tests__/object-literal-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ describe('object-literal method extraction', () => {

// Each action's body was walked: fetchUser references its sibling `reset`,
// so an in-store calls edge will resolve once the pipeline runs.
const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
// By KIND as well as name: the fixture's `Store` interface declares a
// `fetchUser` too, and since #1638 that signature is a node of its own —
// one that appears FIRST in the file, so a name-only lookup finds the
// declaration and reads its return type where the action's body was meant.
const fetchUser = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchUser')!;
const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset');

Expand Down
63 changes: 55 additions & 8 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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
Expand All @@ -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);
}
Expand Down
9 changes: 8 additions & 1 deletion src/extraction/languages/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,15 @@ export function classifyTsClassMember(node: SyntaxNode): 'method' | 'property' {
export const typescriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
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'],
Expand Down
49 changes: 31 additions & 18 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -1037,8 +1051,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). classifyMethodNode is absent for other languages.
Expand Down Expand Up @@ -1293,22 +1312,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) {
Expand Down
Loading