Skip to content

JS/TS: a class defined inside a function body is never extracted (declaration or expression), so mixin factories function f(Base) { return class X extends Base {…} } lose their methods and the inheritance chain dead-ends at the applied const #3349

Description

@meridun

Summary

Two related extraction gaps, both JS and TS (tested on 0.9.54):

A. Classes nested inside a function body get no node at all. This applies to a named class declaration (function outer() { class Inner {…} }), a named class expression in a return, and an anonymous return class extends Base {…}. Their methods therefore have no method edges and no call edges in either direction.

B. A module-level class expression bound to a const gets a bare variable node. export const Foo = class Foo extends Root { m() {} } produces a Foo node but no method edge for m() and no inherits Foo -> Root, even though Root is in-corpus and imported.

The case this hits hardest is the mixin-factory idiom, where A and B combine: a factory returns a class extending its parameter, callers apply it to an environment-specific base, and subclass the result. On a codebase built that way, class X extends Y sites vs. inherits edges after 0.9.54 (#1790 covers the module-scope declarations):

count
class X extends Y sites (shared/client/server dirs) 673
with an inherits edge 383
missing, return class X extends <param> inside a factory 188
missing, inline class X extends createSharedFoo(Base) 15
missing, extends Map etc. (correctly not fabricated) 3

The ~200 unresolved sites are the base-class links of every entity hierarchy, so graphify path between a concrete class and its base has to route through imports_from.

Not asking for a calls edge from the top-level const Y = f(Z) to f here; that's #1972 and the proposal below doesn't depend on it, because the const binding already has a node.

Minimal repro (5 files, copy-paste)

shared/Widget.js

export function createSharedWidget(Base) {
    return class SharedWidget extends Base {
        describe() { return `widget ${this.id}`; }
    };
}

clientjs/ClientEntity.js

export class ClientEntity {
    constructor(id) { this.id = id; }
}

clientjs/Widget.js

import { createSharedWidget } from '../shared/Widget.js';
import { ClientEntity } from './ClientEntity.js';

const SharedWidget = createSharedWidget(ClientEntity);

export default class Widget extends SharedWidget {
    render() { return this.describe(); }
}

serverjs/ServerEntity.js

export class ServerEntity {
    constructor(id) { this.id = id; }
}

serverjs/Widget.js

import { createSharedWidget } from '../shared/Widget.js';
import { ServerEntity } from './ServerEntity.js';

const SharedWidget = createSharedWidget(ServerEntity);

export default class Widget extends SharedWidget {
    persist() { return this.describe(); }
}
graphify update .

Actual (0.9.54, contains/imports_from omitted)

Nodes: createSharedWidget(), Widget ×2, SharedWidget ×2 (the const bindings in clientjs/serverjs), ClientEntity, ServerEntity, .render(), .persist(), .constructor() ×2.

No node for the SharedWidget class inside createSharedWidget, and none for .describe().

imports  : clientjs/Widget.js -> createSharedWidget()
imports  : clientjs/Widget.js -> ClientEntity
inherits : Widget -> SharedWidget          (dead end)
method   : Widget -> .render()
method   : ClientEntity -> .constructor()
(serverjs mirror)

Missing: any inherits reaching ClientEntity / ServerEntity; method for .describe(); consequently calls .render() -> .describe().

Variants confirming the scope (each run separately, same result shape)

// A: nested declaration — InnerClass gets no node, only outer() and inner()
export function outer() {
    function inner() { return 5; }
    class InnerClass { innerMethod() { return inner(); } }
    return new InnerClass();
}

// A: anonymous return — only mixAnon() exists
export function mixAnon(Base) { return class extends Base { anonMethod() { return 1; } }; }

// B: module-level class expression — ConstExpr node exists, no method/inherits edges
import { Root } from './root.js';
export const ConstExpr = class ConstExpr extends Root { constMethod() { return 2; } };

TypeScript, same outcome (Applied and UsesTs exist, SharedTs/tsMethod don't, no inherits to Root):

import { Root } from './root.js';
type Ctor<T = {}> = new (...args: any[]) => T;
export function mixTs<TBase extends Ctor>(Base: TBase) {
    return class SharedTs extends Base { tsMethod() { return 4; } };
}
const Applied = mixTs(Root);
export class UsesTs extends Applied { go() { return this.tsMethod(); } }

Expected for the repro

One node for the factory's class body (methods live there), and each application site keeps its own const node with its own inherits to the base it was applied with; the two applications have different superclasses:

method   : SharedWidget(shared/Widget.js)   -> .describe()
inherits : Widget(clientjs)                 -> SharedWidget(clientjs const)
inherits : SharedWidget(clientjs const)     -> SharedWidget(shared/Widget.js)
inherits : SharedWidget(clientjs const)     -> ClientEntity
(serverjs mirror, binding to ServerEntity)

graphify path "Widget" "ClientEntity" then walks pure inherits, and .render() -> .describe() resolves through the chain.

Suggested resolution, three independent steps, all static

  1. Extract class declarations and class expressions wherever they occur, not only at module scope: nested in a function body, as a return value, or bound by const. Parent them to the enclosing function/file the way nested functions already are (inner() in the variant above does get a node). This alone fixes A and B and recovers the mixin methods; it can land independently of 2 and 3.
  2. Tag mixin factories: a function whose body returns a class whose extends target is one of the function's own parameters. Record the parameter index. JS analogue of the Ruby Class.new(Super) handling already in the engine.
  3. Bind at the application site. For const Y = f(Z) (or the inline class X extends f(Z)) where f resolves to exactly one in-corpus mixin factory and Z to exactly one class, emit inherits Y -> <factory's class> and inherits Y -> Z. Ambiguous resolution emits nothing, same single-match rule as C# dispatches_to (0.9.49) and the cross-repo type link (0.9.54). No caller node is needed, so this is independent of Calls and constant reads in top-level / script context never get a caller node, so they emit no edges (uniform across languages) #1972.

Environment

graphifyy 0.9.54, Python 3.12, Windows 11. Real-world corpus: 1,581 files / 14.5k nodes; pattern is export function createShared<Name>(Base) { return class Shared<Name> extends Base {…} } applied once each in clientjs/ and serverjs/.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions