Przeglądaj źródła

fix(resolution): follow alias bindings to the function they name (#1482) (#1808)

Adapt upstream PR #1485 by @valkyriweb (cc791cfc51b100097571421b4ab7c53b96ceb202)
onto current main. Keep the upstream alias-binding module and six-test suite
verbatim, preserve target-kind gating and default-export bindings, and add
one credited Unreleased changelog entry.

Linux verification (x86_64, Node 22.19.0): TypeScript build and asset copy
pass; the fresh ./impl.js repro changes callers/impact of realImpl from
missing consumerFn to including it. All 6 upstream tests and 231 related
resolver regression tests pass.

Fixes #1482

The Forge PR will supersede upstream PR #1485.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Colby Mchenry 5 godzin temu
rodzic
commit
d3f9ef9bef

+ 1 - 0
CHANGELOG.md

@@ -234,6 +234,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 #### Symbols, tests and the viewer
 
 - Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
+- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
 - `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
 
 - Calls inside declaration initializers in Kotlin, Java, TypeScript, JavaScript, Scala, Rust and Python now appear under the declaration that owns them, making callers and impact results more accurate after re-indexing with `codegraph index -f` (thanks @danusha2345; #1510, #1511).

+ 107 - 0
__tests__/alias-binding-resolution.test.ts

@@ -0,0 +1,107 @@
+/**
+ * Calls through an alias binding.
+ *
+ * A name bound to nothing but another symbol — `export const alias = fn`,
+ * `export { fn as alias }`, `export const api = { run: fn }`, or a same-file
+ * `const local = fn` — used to resolve to the BINDING, one hop short of the
+ * function. The edge existed, so nothing looked broken, but `callers fn` omitted
+ * every caller that went through the alias and reported a confident zero while
+ * `callers alias` found them.
+ *
+ * Specifiers here are extensionless so these cases stand independently of
+ * `.js`-specifier resolution.
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+
+describe('calls through an alias binding reach the aliased symbol', () => {
+  let cg: CodeGraph;
+  let dir: string;
+
+  afterEach(() => {
+    if (cg) cg.destroy();
+    if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  const index = async (files: Record<string, string>): Promise<void> => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alias-'));
+    for (const [name, content] of Object.entries(files)) {
+      fs.writeFileSync(path.join(dir, name), content);
+    }
+    cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+  };
+
+  const callersOf = (name: string): string[] => {
+    const target = cg.getNodesByKind('function').find((n) => n.name === name);
+    expect(target, `fixture symbol ${name} was not indexed`).toBeDefined();
+    return cg.getCallers(target!.id).map((c) => c.node.name);
+  };
+
+  it('follows `export const alias = fn`', async () => {
+    await index({
+      'impl.ts': 'export function realImpl(): number { return 1; }\nexport const aliasName = realImpl;\n',
+      'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n",
+    });
+    expect(callersOf('realImpl')).toContain('consumerFn');
+  });
+
+  it('follows a local `export { fn as alias }` clause', async () => {
+    // The declaration carries no `export` keyword, so extraction does not flag
+    // it exported — the export index must still bind the renamed export to it.
+    await index({
+      'impl.ts': 'function realImpl(): number { return 1; }\nexport { realImpl as aliasName };\n',
+      'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n",
+    });
+    expect(callersOf('realImpl')).toContain('consumerFn');
+  });
+
+  it('follows a function reference held in an object-literal property', async () => {
+    await index({
+      'impl.ts': 'export function realImpl(): number { return 1; }\nexport const api = { run: realImpl };\n',
+      'consumer.ts': "import { api } from './impl';\nexport function consumerFn(): number { return api.run(); }\n",
+    });
+    expect(callersOf('realImpl')).toContain('consumerFn');
+  });
+
+  it('follows a same-file alias binding', async () => {
+    await index({
+      'impl.ts':
+        'function realImpl(): number { return 1; }\n' +
+        'const localAlias = realImpl;\n' +
+        'export function consumerFn(): number { return localAlias(); }\n',
+    });
+    expect(callersOf('realImpl')).toContain('consumerFn');
+  });
+
+  it('leaves a genuine wrapper pointing at the wrapper, not the wrapped function', async () => {
+    // `wrapper` is a real function, not an alias: the call site calls IT.
+    await index({
+      'impl.ts':
+        'export function realImpl(): number { return 1; }\n' +
+        'export const wrapper = (): number => realImpl();\n',
+      'consumer.ts': "import { wrapper } from './impl';\nexport function consumerFn(): number { return wrapper(); }\n",
+    });
+    expect(callersOf('realImpl')).not.toContain('consumerFn');
+  });
+
+  it('does not hop when the aliased name is ambiguous across files', async () => {
+    // Two same-named callables and no same-file declaration to prefer: a hop
+    // would have to guess, and a wrong edge is worse than a missing one.
+    await index({
+      'one.ts': 'export function shared(): number { return 1; }\n',
+      'two.ts': 'export function shared(): number { return 2; }\n',
+      'alias.ts': "import { shared } from './one';\nexport const aliasName = shared;\n",
+      'consumer.ts': "import { aliasName } from './alias';\nexport function consumerFn(): number { return aliasName(); }\n",
+    });
+
+    const sharedNodes = cg.getNodesByKind('function').filter((n) => n.name === 'shared');
+    expect(sharedNodes).toHaveLength(2);
+    for (const node of sharedNodes) {
+      expect(cg.getCallers(node.id).map((c) => c.node.name)).not.toContain('consumerFn');
+    }
+  });
+});

+ 125 - 0
src/resolution/alias-binding.ts

@@ -0,0 +1,125 @@
+/**
+ * Alias bindings: a name bound to nothing but another symbol.
+ *
+ *   export const alias = realImpl;      // p1 — cross-file through an import
+ *   const local = realImpl;             // p6 — same file
+ *   export const api = { run: impl };   // p5 — property holding a function ref
+ *   export { realImpl as alias };       // p2 — a local export clause
+ *
+ * Extraction records the binding itself as a `constant`/`variable` node and
+ * stores its initializer in `signature` (`"= realImpl"`), so a call through the
+ * alias resolves to the BINDING, not the function. `callers realImpl` then
+ * omits every caller that went through the alias and reports a confident zero,
+ * while `callers alias` finds them — the edge exists, it just terminates one
+ * hop short. A local `export { X as Y }` clause is worse: the exported name
+ * matches no declaration at all, so resolution fails outright.
+ *
+ * A call through an alias IS a call to the aliased function, so these hops are
+ * only ever applied to `calls` refs. A `references` edge to the binding is
+ * correct as-is — reading the alias as a value is a genuine use of the alias.
+ */
+
+import type { Node } from '../types';
+import type { ResolutionContext } from './types';
+
+/** Kinds that can be a pure alias for another symbol. */
+const ALIAS_BINDING_KINDS = new Set<string>(['constant', 'variable', 'property']);
+
+/** Kinds an alias may usefully forward a CALL to. */
+const CALLABLE_KINDS = new Set<string>(['function', 'method', 'class', 'component']);
+
+/** `= identifier`, optionally with a cast or trailing semicolon, and nothing else. */
+const BARE_ALIAS_RE = /^=\s*([A-Za-z_$][\w$]*)\s*(?:as\s+[\w.<>[\]]+\s*)?;?$/;
+
+function escapeRegExp(s: string): string {
+  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * The symbol name an alias binding forwards to, or null when the initializer is
+ * anything else (a call, a literal, an expression — all genuine definitions).
+ *
+ * `memberName` targets a property of an object-literal initializer
+ * (`= { run: impl }` for `api.run()`), including ES shorthand (`= { impl }`).
+ */
+export function aliasTargetName(
+  signature: string | undefined | null,
+  memberName: string | null
+): string | null {
+  if (!signature) return null;
+  const initializer = signature.trim();
+
+  if (memberName) {
+    const key = escapeRegExp(memberName);
+    const explicit = new RegExp(`[{,]\\s*${key}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`).exec(initializer);
+    if (explicit) return explicit[1]!;
+    // `{ impl }` — shorthand binds the property to the same-named symbol.
+    const shorthand = new RegExp(`[{,]\\s*(${key})\\s*[,}]`).exec(initializer);
+    if (shorthand) return shorthand[1]!;
+    return null;
+  }
+
+  const bare = BARE_ALIAS_RE.exec(initializer);
+  return bare ? bare[1]! : null;
+}
+
+/**
+ * The callable an alias binding forwards to.
+ *
+ * Prefers a declaration in the alias's own file: an alias almost always names a
+ * local symbol or one it imported, and a same-file hit needs no disambiguation.
+ * Cross-file is accepted only when the name is unique in the project, so an
+ * ambiguous name yields no hop rather than an invented edge — a wrong edge is
+ * worse than a missing one.
+ */
+export function resolveAliasBinding(
+  aliasNode: Node,
+  memberName: string | null,
+  context: ResolutionContext
+): Node | null {
+  if (!ALIAS_BINDING_KINDS.has(aliasNode.kind)) return null;
+
+  const targetName = aliasTargetName(aliasNode.signature, memberName);
+  if (!targetName || targetName === aliasNode.name) return null;
+
+  const candidates = context.getNodesByName(targetName).filter((n) => CALLABLE_KINDS.has(n.kind));
+  if (candidates.length === 0) return null;
+
+  const sameFile = candidates.filter((n) => n.filePath === aliasNode.filePath);
+  if (sameFile.length === 1) return sameFile[0]!;
+  if (sameFile.length > 1) return null;
+  return candidates.length === 1 ? candidates[0]! : null;
+}
+
+/**
+ * Local export clauses: `export { realImpl as alias }` / `export { realImpl }`
+ * with no `from` source.
+ *
+ * `extractReExports` only models the `export … from './other'` form, so a local
+ * clause leaves the exported name bound to nothing the export index knows —
+ * importing `alias` matches no declaration and resolution falls through to the
+ * name-matcher, which cannot cross the rename (a false 0 callers).
+ *
+ * Type-only specifiers are skipped: they carry no runtime call.
+ */
+export function extractLocalExportAliases(content: string): Array<{ exportedName: string; localName: string }> {
+  const out: Array<{ exportedName: string; localName: string }> = [];
+  // `export { … }` NOT followed by `from` — the `from` form is a re-export.
+  const clauseRe = /export\s*\{([^}]*)\}\s*(?!\s*from)[;\n]/g;
+  let clause: RegExpExecArray | null;
+  while ((clause = clauseRe.exec(content)) !== null) {
+    for (const raw of clause[1]!.split(',')) {
+      const specifier = raw.trim();
+      if (!specifier || /^type\s/.test(specifier)) continue;
+      const renamed = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(specifier);
+      if (renamed) {
+        out.push({ localName: renamed[1]!, exportedName: renamed[2]! });
+        continue;
+      }
+      if (/^[A-Za-z_$][\w$]*$/.test(specifier)) {
+        out.push({ localName: specifier, exportedName: specifier });
+      }
+    }
+  }
+  return out;
+}

+ 16 - 0
src/resolution/import-resolver.ts

@@ -9,6 +9,7 @@ import * as path from 'path';
 import { Language, Node } from '../types';
 import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
 import { applyAliases } from './path-aliases';
+import { extractLocalExportAliases } from './alias-binding';
 import { resolveWorkspaceImport } from './workspace-packages';
 import {
   resolveMethodOnType,
@@ -113,7 +114,11 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE
   if (!idx) {
     idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined, defaultBinding: undefined };
     const nodesInFile = context.getNodesInFile(filePath);
+    // Every declaration, exported or not: a local `export { impl as alias }`
+    // clause exports a declaration the extractor never flagged isExported.
+    const declared = new Map<string, Node>();
     for (const n of nodesInFile) {
+      if (!declared.has(n.name)) declared.set(n.name, n);
       if (!n.isExported) continue;
       if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
       if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
@@ -125,6 +130,17 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE
         .filter((n) => n.name === bound && DEFAULT_BINDING_KINDS.has(n.kind))
         .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
     }
+    // Bind names introduced by a local export clause to their declarations, so
+    // an importer asking for the renamed name gets the real symbol instead of
+    // falling through to the name-matcher (which cannot cross the rename).
+    const content = context.readFile(filePath);
+    if (content && content.includes('export')) {
+      for (const { exportedName, localName } of extractLocalExportAliases(content)) {
+        if (idx.byName.has(exportedName)) continue;
+        const decl = declared.get(localName);
+        if (decl) idx.byName.set(exportedName, decl);
+      }
+    }
     perFile.set(filePath, idx);
   }
   return idx;

+ 19 - 1
src/resolution/index.ts

@@ -22,6 +22,7 @@ import {
 import { isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
 import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver';
 import { ResolverPool, minRefsForPool } from './resolver-pool';
+import { resolveAliasBinding } from './alias-binding';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
@@ -871,9 +872,26 @@ export class ReferenceResolver {
    * import, name-match, chain, CFML component path — passes through the
    * inheritance target-kind gate at ONE seam. Filtering inside the
    * name-matcher would have covered `matchByExactName` only.
+   * Calls that land on an alias binding then forward once to the callable
+   * the alias names (see ./alias-binding), regardless of the strategy.
    */
   resolveOne(ref: UnresolvedRef): ResolvedRef | null {
-    return this.gateTargetKind(this.resolveOneInner(ref), ref);
+    const resolved = this.gateTargetKind(this.resolveOneInner(ref), ref);
+    if (!resolved || ref.referenceKind !== 'calls') return resolved;
+
+    const target = this.queries.getNodeById(resolved.targetNodeId);
+    if (!target) return resolved;
+
+    const dot = ref.referenceName.lastIndexOf('.');
+    const memberName = dot >= 0 ? ref.referenceName.slice(dot + 1) : null;
+    const forwarded = resolveAliasBinding(target, memberName, this.context);
+    if (!forwarded || forwarded.id === resolved.targetNodeId) return resolved;
+
+    return {
+      ...resolved,
+      targetNodeId: forwarded.id,
+      confidence: Math.min(resolved.confidence, 0.85),
+    };
   }
 
   private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null {