fix(extraction): a function in an initializer is referenced once, by what the initializer declares (#2743)

PRs #693 and #1802 walk a declaration's initializer under the symbol it declares,
so `const routes = { home }` references `home` from `routes`. The
dispatcher's declaration-wide function-as-value scan then took the same
values again for the enclosing file (or class). Flush-time dedup keys on
the source, so both stayed: a second reference to the same function from
the same spot.

Each initializer walk now records what it walked, and the scan skips it.
PR #2738 added that mechanism for component wrappers, and C#, Scala and Dart
already had their own. The newly covered paths:
- TS/JS module-level constants and variables, owned object literals
  included (#2300), and TS/JS class properties;
- Python assignments, Go var/const specs, Rust const/static items;
- Java fields, and Kotlin properties (walkInitializer takes the property's
  id, so their walk is unchanged).
Mirrored in the kernel: tsjs, python, go, rustlang, java and kotlin.

References no initializer walk takes are unchanged: a module-level call
argument `register(fn)`, C-family tables, Rust's `static CB: fn() = handler`
(the declaration's own value, which the scan still reads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 11:41:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ae0fc631e0
commit 51be28d15b
17 changed files with 233 additions and 79 deletions
@@ -31,6 +31,7 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab
}
};
private final Runnable fieldMethodRef = TortureService::compute;
private static final List<Runnable> FIELD_TABLE = List.of(TortureService::compute);
/** Ctor javadoc. */
public TortureService(List<String> names) {
@@ -14,6 +14,7 @@ const arrow = (x) => legacyHelper(x, 1);
class Widget extends EventEmitter {
static registry = new Map();
static hooks = { tick: legacyHelper };
#privateField = 1;
label = 'w';
onTick = () => {
@@ -78,6 +79,7 @@ export default {
// leaked to the FILE node; a non-exported object literal was skipped outright.
const eagerConfig = loadConfig();
const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() };
const registry = { legacyHelper, run: arrow, nested: [legacyHelper] };
const lazyList = [() => persist(eagerConfig)];
// --- CommonJS export assignments (#1675) -----------------------------------
exports.getItems = async (req, res) => { res.json(await findItems()); };
@@ -52,6 +52,7 @@ val withGetter: Int
get() = 42
val initLambda: () -> Unit = { caller() }
val initSam = Runnable { caller() }
val handlerList = listOf(::caller)
val initObject = object : Runnable {
override fun run() {
caller()
@@ -60,6 +61,7 @@ val initObject = object : Runnable {
class WidgetK(val size: Int, private var name: String = defaultName()) {
val area: Int = size * size
val hooks = listOf(::caller)
var label: String? = null
val computed: Int
get() = size * 2
@@ -305,6 +305,8 @@ routes![top_level_h];
const INIT_CONST: usize = compute_len();
static INIT_LAZY: Lazy<Cfg> = Lazy::new(|| build_cfg());
static INIT_ALIAS: fn() = free_fn;
// A table the walk above takes for its symbol, and the scan no longer for the file.
static INIT_TABLE: &[fn()] = &[handler, handler2];
pub union Reg {
pub raw: u32,
@@ -23,6 +23,7 @@ var oldVar = 'x';
@scoped.Registry<Config>('name')
export abstract class BaseService extends EventTarget implements Disposable, Serializable {
static instances = 0;
static handlers = { refresh: helperFn, table: CONFIG_TABLE };
private readonly cache: Map<string, Config> = new Map();
public fonts: FontConfig;
count = 0;
@@ -189,6 +190,7 @@ export const actionsMap = {
update(input: UpdateInput) {
return persistExisting(input);
},
fallback: helperFn,
};
// Zustand-style store through middleware wrappers.
+70 -3
View File
@@ -779,12 +779,11 @@ describe('Function-as-value capture (#756)', () => {
// The DRF wiring: get_serializer_class → the imported serializer class,
// via `return` — the issue's headline gap. The module-level registry
// dict rides BOTH the assigned name (the initializer walk, #693) and the
// file node (the dispatcher's own scan, which runs either way).
// dict rides the assigned name (the initializer walk, #693), and not the
// file node as well: the dispatcher's scan skips what that walk took.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([
'SERIALIZER_REGISTRY',
'get_serializer_class',
'views.py',
]);
// Second branch return + a module-level call argument.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerBrief'))).toEqual([
@@ -1581,4 +1580,72 @@ func Unknown(obj interface{}) { Submit(obj.Fetch) }
cg.destroy();
}
});
// An initializer walked under the symbol it declares (#693) takes its
// function values for that symbol. The declaration-wide scan took each one
// again for the enclosing file or class: a second reference to the same
// function from the same spot (TS/JS constants and class fields, Python,
// Go, Rust, Java fields, Kotlin properties). Each function here is
// referenced only from an initializer, so a second source shows.
it('INITIALIZER WALKS: a function in an initializer is referenced once, by what it declares', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-init-'));
fs.writeFileSync(
path.join(tmpDir, 'app.ts'),
[
'export function tsHome() {}',
'export function tsAbout() {}',
'export function tsClick() {}',
'export function tsBoot() {}',
'function register(fn: () => void) {}',
'const routes = { tsHome, about: tsAbout, list: [tsHome] };',
'const api = { load() {}, tsAbout };',
'export class Widget {',
' static handlers = { click: tsClick };',
'}',
'register(tsBoot);',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'views.py'),
['def py_home():', ' pass', '', 'HANDLERS = {"home": py_home}', 'ROUTES = [py_home]'].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'handlers.go'),
['package main', '', 'func goHome() {}', '', 'var handlers = map[string]func(){"home": goHome}'].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'lib.rs'),
['fn rust_home() {}', 'static HANDLERS: &[fn()] = &[rust_home];'].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'Registry.java'),
[
'class Registry {',
' static void javaHome() {}',
' static final java.util.List<Runnable> TABLE = java.util.List.of(Registry::javaHome);',
'}',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, 'handlers.kt'),
['fun ktHome() {}', 'val ktHandlers = listOf(::ktHome)', 'class KtWidget {', ' val hooks = listOf(::ktHome)', '}'].join('\n')
);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsHome'))).toEqual(['routes']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsAbout'))).toEqual(['api', 'routes']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsClick'))).toEqual(['handlers']);
// A call argument at module scope is the file's, as before.
expect(sourceNames(cg, fnRefEdgesInto(cg, 'tsBoot'))).toEqual(['app.ts']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'py_home'))).toEqual(['HANDLERS', 'ROUTES']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'goHome'))).toEqual(['handlers']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'rust_home'))).toEqual(['HANDLERS']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'javaHome'))).toEqual(['TABLE']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'ktHome'))).toEqual(['hooks', 'ktHandlers']);
} finally {
cg.destroy();
}
});
});