fix(extraction): classify TS/JS class fields by value — properties, not methods (#808) (#809)

Every TS `public_field_definition` / JS `field_definition` extracted as a
method-kind node, so a plain field (`public fonts: Fonts;`) was reported
as callable: class shape was misrepresented, kind-based filtering was
defeated, and bare-name call resolution landed on data fields — typeorm's
boolean `ColumnMetadata::isArray` field was soaking up Array.isArray(...)
call edges (685 such wrong edges on typeorm alone).

Classification now follows the VALUE (classifyMethodNode hook, mirroring
resolveBody's callable detection): arrow-function / function-expression
fields and HOF-wrapped ones (`onScroll = throttle(() => {…})`) stay
methods with their bodies walked; everything else becomes a property that
keeps its type-annotation references edge, visibility, static-ness, and
decorators. Field initializers are now walked too (`history =
createHistory()` attributes the call to the property — previously
invisible), and JS class fields — whose name lives in the grammar's
`property` field, so they never extracted a symbol at all — now appear in
the graph (resolveName on the JS extractor).

With fields correctly kinded, `this.X` callback registration is re-enabled
for TS/JS (removed in #807 because field pseudo-methods made it mostly
wrong): `this.<member>` candidates resolve CLASS-SCOPED
(resolveThisMemberFnRef) — the target must be a function/method sharing
the from-symbol's qualified-name class prefix, same file, no fallback —
so `addEventListener("online", this.onOfflineStatusToggle)` and API-object
wiring (`{ mutateElement: this.mutateElement }`) produce registration
edges to the enclosing class's own method, while `this.fonts` (a
property) and inherited/unknown members yield no edge.

A/B (baseline = #807 main): excalidraw / typeorm / express — node counts
identical on all three; kinds shift method→property only (typeorm: exactly
7,406 swapped; excalidraw also corrects 5 anonymous-class mock fields that
were function-kind); every one of the 736 dropped call edges targeted a
node that is now a property (calls into data fields — verified 100%);
gains are retargets to real callables, initializer-call attributions, and
+74/+7 class-scoped this.X registration edges (sampled: addEventListener/
removeEventListener wiring, imperative-API method maps). Full suite green
(1386).

EXTRACTION_VERSION 19 → 20 (re-index to benefit).

Closes #808

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 14:48:11 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8a114ba53c
commit 38eb4e688c
12 changed files with 419 additions and 42 deletions
+53 -4
View File
@@ -144,10 +144,9 @@ describe('Function-as-value capture (#756)', () => {
'objRegistrar',
'timerRegistrar',
]);
// `this.handleClick` is deliberately NOT captured in TS/JS: class fields
// extract as method-kind nodes, so `this.X` value positions (mostly data
// reads in real code) produced wrong edges — see TS_JS_SPEC note.
expect(fnRefEdgesInto(cg, 'handleClick')).toHaveLength(0);
// `this.handleClick` resolves class-scoped (#808): the target must be a
// method of the ENCLOSING class, in the same file.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'handleClick'))).toEqual(['wire']);
} finally {
cg.destroy();
tmpDir = undefined;
@@ -408,6 +407,56 @@ describe('Function-as-value capture (#756)', () => {
}
});
it('THIS-MEMBER SCOPING: this.X resolves only to the enclosing class, never elsewhere', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-thisx-'));
fs.writeFileSync(
path.join(tmpDir, 'main.ts'),
[
'declare const bus: { on(ev: string, cb: () => void): void };',
// Decoy: a same-named method on an UNRELATED class.
'export class Decoy { refresh(): void {} }',
'export class Panel {',
' views: number[] = [];', // property (post-#808), shares no name
' refresh(): void {}',
' wire(): void {',
' bus.on("update", this.refresh);', // → Panel::refresh, not Decoy::refresh
' bus.on("data", this.views as never);', // property → NO edge
' bus.on("gone", this.missing as never);', // unknown member → NO edge
' }',
'}',
].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const refreshes = cg.getNodesByName('refresh');
const panelRefresh = refreshes.find((n) => n.qualifiedName.includes('Panel'))!;
const decoyRefresh = refreshes.find((n) => n.qualifiedName.includes('Decoy'))!;
const intoPanel = cg
.getIncomingEdges(panelRefresh.id)
.filter((e) => e.metadata?.fnRef === true);
expect(intoPanel).toHaveLength(1);
expect(cg.getNode(intoPanel[0]!.source)?.name).toBe('wire');
expect(
cg.getIncomingEdges(decoyRefresh.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
// The property and the unknown member produce nothing.
const views = cg.getNodesByName('views').find((n) => n.kind === 'property');
if (views) {
expect(
cg.getIncomingEdges(views.id).filter((e) => e.metadata?.fnRef === true)
).toHaveLength(0);
}
} finally {
cg.destroy();
tmpDir = undefined;
}
});
it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-'));
// Handler defined in its own file…