GitNexus/gitnexus/test/integration/resolvers/python.test.ts
Gergő Magyar 9372b17049
fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828)
* fix(python): resolve calls through an unaliased dotted namespace import (#2826)

`import pkg.db` followed by `pkg.db.session_scope()` emitted no CALLS edge,
while all three sibling spellings resolved. In a codebase whose style guide
mandates absolute imports this is close to the only cross-module call form
used, so `impact()` reported `impactedCount: 0, risk: LOW, epistemic: exact`
for functions with dozens of real callers — a dropped caller reading as a
verified all-clear.

The resolution path was never missing; one map was keyed on the wrong half of
the import. `interpretPythonImport`'s plain arm splits `import pkg.db` into
`localName: 'pkg'` (the name Python actually binds) and
`importedName: 'pkg.db'`, and finalize carries both onto the edge as
`localName` / `targetExportedName`. `collectNamespaceTargets` keyed only on
`localName`, but the receiver text captured at the call site is the whole
dotted path — Python's query binds the attribute's `object` field with a
wildcard, so `pkg.db.session_scope()` yields the receiver `pkg.db`. Case 0
declines it (a module is not a class) and falls through, Case 1 looks up
`pkg.db` and misses, and Case 1.5 needs `resolveQualifiedReceiverMember`,
which only the C++ provider implements. The site drops silently.

Key the map on the dotted import path as well — gated on a provider opt-in,
not on the edge shape. The shape alone cannot decide it: Swift's
`import Foo.Bar` produces the identical pair (`localName: 'Foo'`,
`targetExportedName: 'Foo.Bar'`), but there the FIRST segment is the resolved
target and `Foo.Bar` names a nested type. Minting a key for it would hand
`resolveConstructionExpressionClass` an authoritative namespace — that branch
deliberately does not fall through on a miss — and break `Foo.Bar(x)`
construction that resolves correctly today. Hence
`ScopeResolver.namespaceReceiverIncludesImportPath`, which only Python sets.

The root-segment check on the added key does real work: `import pkg.db as pdb`
binds only `pdb`, so writing `pkg.db.f()` there is a NameError, and its edge
(localName `pdb`, path `pkg.db`) is correctly rejected.

Two same-package imports stay separate — `import pkg.db` + `import pkg.cache`
key `pkg.db` and `pkg.cache` independently, so neither call can land in the
other's module; the shared `pkg` bucket keeps its existing ambiguity rather
than gaining any.

Tests: five integration rows (the issue's own repro, the three sibling
spellings as controls, non-crossing two-package imports, a three-segment
receiver, and dotted construction) plus a unit pin on the keying rule that
asserts a Swift-shaped edge mints nothing. All five integration rows fail on
the pre-fix tree; the controls pass on both, which is what makes them
controls. Resolver integration suite 3024 passed / 1 skipped / 0 failed;
scope-resolution unit suite 1446 passed.

This changes what the resolver produces, not how it is stored — no schema or
version constant applies, and an existing index needs a re-analyze to show
the new edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(resolution): shadow-test a dotted namespace key by its root segment (#2826)

`isNamespaceNameShadowed` walks the scope chain looking for a binding, type
binding, lexical name, or owned def named exactly `namespaceName`. Once a
namespace key can be a dotted import path, that string never matches anything:
`import pkg.db` binds `pkg`, so a local `pkg = Decoy()` shadows the import,
but the guard was asked about `pkg.db` and answered "not shadowed".

The consequence is not a missed edge but a wrong one. The caller treats a
verified namespace as authoritative and deliberately does not fall through to
the workspace-wide simple-name heuristics, so an unguarded shadowed receiver
resolves construction against the imported module instead of the local value.

Test the first dot-separated segment instead. Single-segment names are
unaffected — their root is themselves — so every pre-existing row keeps its
behaviour.

This ships with the key that first routes a dotted name into the guard rather
than after it: the previous commit is what makes the defect reachable.

The new pin fails on the pre-fix guard (verified by reverting the four
comparisons and re-running: 1 failed / 5 passed), so it discriminates rather
than merely passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(python): pin the callee name on the dotted-construction row (#2826)

The row asserted only that `builds` reached `pkg/db.py`. That module also
exports `session_scope`, so a regression that resolved the construction to the
wrong member of the right module would have kept the test green — it pinned
the file, not the answer.

Assert the exact edge set for the caller instead. Verified against the current
tree with a scratch probe: `builds -> Model@pkg/db.py` is the only edge the
file produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): include the #2826 engineering plan in the PR

`.gitignore` keeps `docs/*` local because planning output is normally
throwaway. Force-added here at the reviewer's request so the plan travels with
the work it drove: it records the evidence chain behind the fix, the two places
the plan turned out to be wrong, and the follow-ups deliberately left out of
scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(resolution): make the namespace shadow guard shared (#2826)

`isNamespaceNameShadowed` lived module-private in `compound-receiver.ts` with a
single caller. The namespace map it guards has three consumers, and the next
commit adds the guard to a second one, so it moves to `scope/walkers.ts`
alongside the other scope-chain primitives rather than being duplicated.

Behaviour is unchanged — this is a move plus documentation. Two notes were
added because both are easy to get wrong later:

- Fails closed on a missing scope or a parent cycle. For every caller,
  suppressing costs a missing edge while trusting a corrupt scope chain costs a
  wrong one, so the bias is deliberate.
- It reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`,
  which is the opposite of the fix #2745 applied to Rust's `headBoundLocally`.
  There the question was "is this name bound at all?", so missing finalize's
  import channels lost real bindings. Here the question is "does something
  LOCAL shadow the import?", and the import's own finalized binding is exactly
  what must not count — routing this through `lookupBindingsAt` would find every
  namespace import shadowing itself and suppress the lot. Verified against a
  target module carrying a self-named def, which still resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): close the three remaining namespace-receiver gaps (#2826)

Three defects the first fix left behind. All three were confirmed by probe
before being touched, and a fourth suspected gap was disproved the same way.

## 1. Case 1 resolved through an import a local had shadowed

`namespaceTargets` is collected per FILE, but Case 1 in `receiver-bound-calls`
consulted it with no lexical guard at all, so

    import pkg.db
    def f(pkg):            # parameter shadows the package
        return pkg.db.session_scope()

emitted an edge to pkg/db.py. That is a WRONG edge, and it predates the dotted
key: the single-segment spelling (`import single` + `def f(single)`) failed
identically. The compound-receiver construction path has applied this guard
since #2770; Case 1 simply never did. Now both use the shared guard.

## 2 + 3. The root key named the leaf module, not the package

These read as two gaps and are one. `import a.b.c` binds ONE name — `a` — but
makes three attribute paths callable, naming three different files:

    a      → a/__init__.py
    a.b    → a/b/__init__.py
    a.b.c  → a/b/c.py

The map keyed only `a`, pointed at the LEAF. So `a.helper()` resolved into
a/b/c.py whenever that module happened to export `helper` — silently preferring
a decoy over the real definition in the package — and `a.b.mid()` resolved to
nothing at all. One wrong edge and one missing edge from a single mis-keying.

Fixing it needs per-language knowledge the shared collector cannot have: which
prefixes are reachable, and which file each names. The `__init__.py` convention
is Python's alone, and the edge shape is ambiguous across languages — Swift's
`import Foo.Bar` produces an identical `localName`/`targetExportedName` pair
that means the opposite thing. So the previous commit's boolean opt-in is
replaced by `ScopeResolver.namespaceReceiverPaths`, which returns every
spelling with the file it names; absent or declining, the shared default
(bound name → own target) is unchanged for every other language.

Prefix files are proposed, not asserted — `moduleFileExists` drops any the
workspace never parsed, so a PEP-420 namespace package contributes no key
rather than one pointing at a missing file.

## Disproved: C# was not a fourth gap

The plan listed C# `using System.Collections.Generic` +
`System.Collections.Generic.List` as the same class of bug. It is not: a probe
shows `My.Deep.Space.Helpers.Work()` already resolves through the FQN namespace
bindings in `walkers.ts`. No change made, and the claim is withdrawn rather
than carried forward as a known gap.

## Testing

Integration: the shadow block asserts the exact surviving edge set (an
absence-only assertion would also pass if the guard over-suppressed and killed
the clean rows); the prefix block asserts all three spellings land on their own
file, with `helper` defined in BOTH package and leaf so a wrong edge is visible
rather than merely possible. Unit: 16 rows on the keying contract, including
that a Swift-shaped edge mints nothing and an alias import keys neither the
path nor the root.

Resolver integration 3024 passed / 1 skipped / 0 failed; scope-resolution unit
1452 passed; tsc clean in both packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): probe both path separators when resolving a prefix package (#2826)

Workspace file paths are not normalized to POSIX at ingestion — `import-target`
already re-normalizes at five other comparison points, and `moduleScopeByFile`
is keyed by the raw `ParsedFile.filePath`. The prefix probe built only the `/`
spelling, so on Windows it would compare `a/b/__init__.py` against an
`a\b\__init__.py` key, find nothing, and mint no prefix keys at all.

That fails quietly, which is the worst shape for it: `a.b.mid()` simply goes
back to unresolved on one platform, with no drop recorded and every test on
POSIX still green. Probe both spellings and key whichever the workspace
actually holds.

The new row is mutation-tested — reverting to the `/`-only probe turns it red
(1 failed / 10 passed), so it pins the behaviour rather than passing alongside
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): correct three defects a multi-lane review found in this PR (#2826)

All three were introduced by this PR's own earlier commits, and none was found
by re-reading the diff — each came from a lane attacking an angle the author
had not.

## 1. The shadow guard ran BEFORE the map lookup it gates

Case 1 evaluated `isNamespaceNameShadowed` unconditionally, then consulted
`namespaceTargets`. So every call/read/write site with an explicit receiver, in
every language, paid a scope-chain walk (a Set allocation, three Map lookups and
a linear `ownedDefs` scan per level) ahead of an O(1) hash miss that was going to
decline it anyway.

The proof it was an oversight rather than a decision sits in this same PR: the
sibling guard in `compound-receiver.ts` reads the map first and only guards on a
hit. Two call sites of one shared function, opposite order. Semantics are
identical either way — a miss yields `undefined` regardless — which is exactly
why it survived several readings.

## 2. Prefix packages were anchored on the import spelling, not the resolved leaf

`pythonNamespaceReceiverPaths` built `a/__init__.py` from the dotted path joined
at the workspace root, never consulting the file the import actually resolved
to. But `resolvePythonImportTarget` resolves off-root in two of its three tiers,
so `import utils.db` can land on `libs/common/utils/db.py`. That produced a
wrong edge where a same-named `utils/` package exists at the root, and produced
NOTHING in a `src/` layout — the prefix feature was inert for the most common
Python project shape, silently.

Now the prefix directories are derived by walking back from the resolved leaf,
which is exact for root, `src/` and off-root layouts alike. It also inherits the
leaf's own separator, which subsumes the previous dual-separator probe: that
probe was dead code anyway, because `filesystem-walker.ts` normalizes `\` to `/`
before a path ever becomes a `ParsedFile.filePath`. Its test row is removed
rather than left asserting an unreachable state.

## 3. Keying the root at `__init__.py` INSTEAD of the leaf lost re-exports

`findExportedDef` accepts only a binding whose `origin === 'local'`. The
canonical Python package re-exports from its submodules — `from .b.c import
helper` in `__init__.py` — which is an IMPORT binding, so it is rejected. Keying
the prefix solely at the package therefore turned `a.helper()` from a correct
edge into no edge at all for the most common package shape.

Every fixture in this PR defined its members locally in `__init__.py`, which is
precisely the one layout where that mistake is invisible.

The prefix now keys the package FIRST and the leaf behind it. A real definition
in `__init__.py` still wins over a same-named decoy deeper in the package, and a
name merely re-exported there still resolves through the leaf. Ordering is the
contract, so the unit rows assert the exact arrays rather than membership.

## Testing

New rows: off-root layout with a decoy `utils/` at the root, and a `src/` layout.
Both mutation-tested — reverting to the spelling-anchored build turns them red.
The re-export case was verified end-to-end with a scratch fixture whose
`__init__.py` only re-exports (`uses -> helper@a/b/c.py`).

Resolver integration 3131 passed / 1 skipped / 0 failed — unchanged from before
these fixes, so they regress nothing. Scope-resolution unit 1459 passed.
tsc clean in both packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(resolution): stop the namespace shadow guard AT the module scope (#2826)

CI caught a regression this PR introduced: `cjs-exports-assignment.test.ts`
lost both of its cross-file rows —

    cross-file require() member call resolves
      expected [] to deeply equal [ 'handle' ]
    an `exports` parameter does not hijack the module (UMD factory)
      expected [] to deeply equal [ 'publicApi' ]

— i.e. `const svc = require('./svc'); svc.handle()` stopped resolving in
JavaScript.

Cause: in CommonJS the namespace import IS a variable declaration. One
statement produces both the ImportEdge and a module-scope `const` binding, so
the guard, by inspecting the module scope, found the import's own name there and
read it as a shadow of itself — suppressing exactly the receivers it exists to
enable.

The guard's own contract sentence already said the right thing: "a declaration
BETWEEN the call site and its module scope". The module scope is the floor of
that walk, not a rung on it. It now returns at Module without inspecting it.

Nothing is lost on the suppression side: a genuine shadow is a parameter, a
local, or a nested declaration, and all of those live in scopes strictly inside
the module. The Python rows that pin suppression (`def f(pkg): pkg.db.f()` and
its single-segment `import single` twin) still pass, because a parameter is an
inner scope.

Worth recording for the next reader: two independent review lanes examined this
exact scenario and both REFUTED it, reasoning that `require()` yields an
ImportEdge in `scope.imports` rather than a local binding. That is true for
Python's `import x` and false for CommonJS, where one statement is both. My own
probe used a Python fixture and so could not surface it either. Agreement
between reviewers was not evidence; the test corpus was.

Verified: cjs-exports-assignment 36/36, the #2826 integration rows 7/7,
scope-resolution unit 126/126.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:35:27 +01:00

3373 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Python: relative imports + class inheritance + ambiguous module disambiguation
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Heritage: relative imports + class inheritance
// ---------------------------------------------------------------------------
describe('Python relative import & heritage resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-pkg'), () => {});
}, 60000);
it('classifies top-level functions separately from class methods', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']);
expect(getNodesByLabel(result, 'Function')).toEqual(['process_model']);
expect(getNodesByLabel(result, 'Method')).toEqual([
'authenticate',
'get_name',
'save',
'validate',
]);
});
it('emits exactly 1 EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('User');
expect(extends_[0].target).toBe('BaseModel');
});
it('resolves all 3 relative imports', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(3);
expect(edgeSet(imports)).toEqual([
'auth.py → user.py',
'helpers.py → base.py',
'user.py → base.py',
]);
});
it('emits exactly 3 CALLS edges', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(3);
expect(edgeSet(calls)).toEqual([
'authenticate → validate',
'process_model → save',
'process_model → validate',
]);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'METHOD_OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Qualified / generic bases (#1951). An earlier synth DROPPED these shapes —
// only bare `identifier` bases emitted, so production silently omitted their
// inheritance edges. service.py exercises the three now-handled shapes plus a
// bare control, each base defined in a sibling module:
// - Service: `base_mod.Model` (attribute base, trailing id -> Model)
// - Nested: `a.b.Base` (nested attribute base, recurse -> Base)
// - Gen: `Container[str]` (subscript base, value: field -> Container)
// - Plain: `Container` (bare control, byte-identical capture)
// Scope-resolution (the single path since #942) owns these edges; the synth's
// bare-name text is asserted to match the documented per-shape reduction.
// ---------------------------------------------------------------------------
describe('Python qualified-base heritage resolution (#1951)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-qualified-base'), () => {});
}, 60000);
it('emits EXTENDS edges for attribute / nested-attribute / subscript / bare bases', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toEqual([
'Gen → Container',
'Nested → Base',
'Plain → Container',
'Service → Model',
]);
});
it('emits no IMPLEMENTS edges (Python has no interfaces)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(0);
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.properties.name).toBe(edge.target);
}
});
});
// ---------------------------------------------------------------------------
// Ambiguous: Handler in two packages, relative import disambiguates
// ---------------------------------------------------------------------------
describe('Python ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-ambiguous'), () => {});
}, 60000);
it('detects 2 Handler classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes.filter((n) => n === 'Handler').length).toBe(2);
expect(classes).toContain('UserHandler');
});
it('resolves EXTENDS to models/handler.py (not other/handler.py)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('UserHandler');
expect(extends_[0].target).toBe('Handler');
expect(extends_[0].targetFilePath).toBe('models/handler.py');
});
it('import edge points to models/ not other/', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].targetFilePath).toBe('models/handler.py');
});
it('all heritage edges point to real graph nodes', () => {
for (const edge of getRelationships(result, 'EXTENDS')) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
}
});
});
describe('Python call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-calls'), () => {});
}, 60000);
it('resolves run → write_audit to one.py via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(1);
expect(calls[0].source).toBe('run');
expect(calls[0].target).toBe('write_audit');
expect(calls[0].targetFilePath).toBe('one.py');
expect(calls[0].rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Python member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-member-calls'), () => {});
}, 60000);
it('resolves process_user → save as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
expect(saveCall!.targetFilePath).toBe('user.py');
});
it('classifies regular and dunder class-body functions as Method nodes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toEqual(
expect.arrayContaining(['save', '__getitem__']),
);
expect(getNodesByLabel(result, 'Function')).not.toContain('save');
expect(getNodesByLabel(result, 'Function')).not.toContain('__getitem__');
});
});
// ---------------------------------------------------------------------------
// Receiver-constrained resolution: typed variables disambiguate same-named methods
// ---------------------------------------------------------------------------
describe('Python receiver-constrained resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-receiver-resolution'), () => {});
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
const userSave = saveCalls.find((c) => c.targetFilePath === 'user.py');
const repoSave = saveCalls.find((c) => c.targetFilePath === 'repo.py');
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
expect(userSave!.source).toBe('process_entities');
expect(repoSave!.source).toBe('process_entities');
});
});
// ---------------------------------------------------------------------------
// Named import disambiguation: two modules export same name, from-import resolves
// ---------------------------------------------------------------------------
describe('Python named import disambiguation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-named-imports'), () => {});
}, 60000);
it('resolves process_input → format_data to format_upper.py via from-import', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find((c) => c.target === 'format_data');
expect(formatCall).toBeDefined();
expect(formatCall!.source).toBe('process_input');
expect(formatCall!.targetFilePath).toBe('format_upper.py');
});
it('emits IMPORTS edge to format_upper.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const appImport = imports.find((e) => e.source === 'app.py');
expect(appImport).toBeDefined();
expect(appImport!.targetFilePath).toBe('format_upper.py');
});
});
// ---------------------------------------------------------------------------
// Variadic resolution: *args don't get filtered by arity
// ---------------------------------------------------------------------------
describe('Python variadic call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-variadic-resolution'), () => {});
}, 60000);
it('resolves process_input → log_entry to logger.py despite 3 args vs *args', () => {
const calls = getRelationships(result, 'CALLS');
const logCall = calls.find((c) => c.target === 'log_entry');
expect(logCall).toBeDefined();
expect(logCall!.source).toBe('process_input');
expect(logCall!.targetFilePath).toBe('logger.py');
});
});
// ---------------------------------------------------------------------------
// Alias import resolution: from x import User as U resolves U → User
// ---------------------------------------------------------------------------
describe('Python alias import resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-alias-imports'), () => {});
}, 60000);
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
});
it('resolves u.save() to models.py and r.persist() to models.py via alias', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
const persistCall = calls.find((c) => c.target === 'persist');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
expect(saveCall!.targetFilePath).toBe('models.py');
expect(persistCall).toBeDefined();
expect(persistCall!.source).toBe('main');
expect(persistCall!.targetFilePath).toBe('models.py');
});
it('emits exactly 1 IMPORTS edge: app.py → models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].sourceFilePath).toBe('app.py');
expect(imports[0].targetFilePath).toBe('models.py');
});
});
// ---------------------------------------------------------------------------
// Plain import alias: import models as m → m.User() resolves to models.py
// ---------------------------------------------------------------------------
describe('Python plain import alias resolution (import X as Y)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-plain-import-alias'), () => {});
}, 60000);
it('detects User classes in both models.py and auth.py', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('Repo');
});
it('emits IMPORTS edges: app.py → models.py and app.py → auth.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const importFiles = imports
.filter((i) => i.sourceFilePath === 'app.py')
.map((i) => i.targetFilePath)
.sort();
expect(importFiles).toEqual(['auth.py', 'models.py']);
});
it('resolves m.User() and u.save() to models.py via alias', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'main');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('models.py');
});
it('resolves m.Repo() and r.persist() to models.py via alias', () => {
const calls = getRelationships(result, 'CALLS');
const persistCall = calls.find((c) => c.target === 'persist' && c.source === 'main');
expect(persistCall).toBeDefined();
expect(persistCall!.targetFilePath).toBe('models.py');
});
it('resolves a.User() and v.login() to auth.py via alias (disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const loginCall = calls.find((c) => c.target === 'login' && c.source === 'main');
expect(loginCall).toBeDefined();
expect(loginCall!.targetFilePath).toBe('auth.py');
});
});
// ---------------------------------------------------------------------------
// Same-name collision: import X as alias; alias.func() where caller is also named func
// Issue #417 — module-alias disambiguation must override same-file tier
// ---------------------------------------------------------------------------
describe('Python same-name collision via module alias (Issue #417)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-same-name-collision'), () => {});
}, 60000);
it('resolves app_metrics.get_metrics() to metrics.py, not self (same-name collision)', () => {
const calls = getRelationships(result, 'CALLS');
const getMetricsCall = calls.find(
(c) => c.source === 'get_metrics' && c.target === 'get_metrics',
);
expect(getMetricsCall).toBeDefined();
// Must resolve to metrics.py, NOT router.py (self-call)
expect(getMetricsCall!.sourceFilePath).toBe('router.py');
expect(getMetricsCall!.targetFilePath).toBe('metrics.py');
});
it('emits IMPORTS edge: router.py → metrics.py (module alias registered)', () => {
const imports = getRelationships(result, 'IMPORTS');
const metricsImport = imports.find(
(i) => i.sourceFilePath === 'router.py' && i.targetFilePath === 'metrics.py',
);
expect(metricsImport).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Ancestor directory import: Python single-segment import resolved via ancestor walk
// Issue #417 — prevents cross-language misresolution when suffix matching picks .ts over .py
// ---------------------------------------------------------------------------
describe('Python ancestor directory import resolution (Issue #417)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-ancestor-import'), () => {});
}, 60000);
it('resolves from middleware import to backend/middleware.py, not frontend/middleware.ts', () => {
const imports = getRelationships(result, 'IMPORTS');
const middlewareImport = imports.find(
(i) =>
i.sourceFilePath === 'backend/services/auth.py' && i.targetFilePath.includes('middleware'),
);
expect(middlewareImport).toBeDefined();
expect(middlewareImport!.targetFilePath).toBe('backend/middleware.py');
});
it('resolves _canonical() call to middleware.py:get_remaining_slots via alias', () => {
const calls = getRelationships(result, 'CALLS');
const canonicalCall = calls.find(
(c) => c.source === 'get_remaining_slots' && c.sourceFilePath === 'backend/services/auth.py',
);
expect(canonicalCall).toBeDefined();
expect(canonicalCall!.target).toBe('get_remaining_slots');
expect(canonicalCall!.targetFilePath).toBe('backend/middleware.py');
});
it('resolves depth-2 ancestor import: a/b/c/deep.py → a/utils.py (not suffix match)', () => {
const imports = getRelationships(result, 'IMPORTS');
const utilsImport = imports.find(
(i) => i.sourceFilePath === 'a/b/c/deep.py' && i.targetFilePath.includes('utils'),
);
expect(utilsImport).toBeDefined();
expect(utilsImport!.targetFilePath).toBe('a/utils.py');
});
it('resolves format_currency() call across depth-2 ancestor import', () => {
const calls = getRelationships(result, 'CALLS');
const fmtCall = calls.find(
(c) => c.source === 'render_price' && c.target === 'format_currency',
);
expect(fmtCall).toBeDefined();
expect(fmtCall!.targetFilePath).toBe('a/utils.py');
});
});
// ---------------------------------------------------------------------------
// Multi-segment ancestor walk: `from services.sync import X` style imports
// from a sibling sub-package nested under a shared root directory.
//
// Before this fix, single-segment ancestor walks worked (`from middleware
// import X` from `backend/services/auth.py` → `backend/middleware.py`) but
// multi-segment dotted imports were only resolved against the workspace
// root. In a `backend/`-prefixed repo, `from services.sync import X` from
// `backend/routers/cron.py` would silently drop because `services/sync.py`
// does not exist at the workspace root — only `backend/services/sync.py`
// does. The fix mirrors the single-segment ancestor walk for multi-segment
// paths.
// ---------------------------------------------------------------------------
describe('Python multi-segment ancestor directory import resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-multi-segment-ancestor-import'),
() => {},
);
}, 60000);
it('resolves from services.sync import to backend/services/sync.py via ancestor walk', () => {
const imports = getRelationships(result, 'IMPORTS');
const syncImport = imports.find(
(i) =>
i.sourceFilePath === 'backend/routers/cron.py' &&
i.targetFilePath === 'backend/services/sync.py',
);
expect(syncImport).toBeDefined();
});
it('resolves from services.alerts import to backend/services/alerts.py via ancestor walk', () => {
const imports = getRelationships(result, 'IMPORTS');
const alertsImport = imports.find(
(i) =>
i.sourceFilePath === 'backend/routers/cron.py' &&
i.targetFilePath === 'backend/services/alerts.py',
);
expect(alertsImport).toBeDefined();
});
it('resolves from routers.alerts import to backend/routers/alerts.py (sibling sub-package)', () => {
const imports = getRelationships(result, 'IMPORTS');
const routerImport = imports.find(
(i) =>
i.sourceFilePath === 'backend/routers/cron.py' &&
i.targetFilePath === 'backend/routers/alerts.py',
);
expect(routerImport).toBeDefined();
});
it('emits CALLS edges for every multi-segment-imported callee', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'backend/routers/cron.py',
);
const startCronRunCalls = calls.filter((c) => c.target === '_start_cron_run');
expect(startCronRunCalls.length).toBe(3);
expect(startCronRunCalls.every((c) => c.targetFilePath === 'backend/services/sync.py')).toBe(
true,
);
const completeCronRunCalls = calls.filter((c) => c.target === '_complete_cron_run');
expect(completeCronRunCalls.length).toBe(1);
expect(completeCronRunCalls[0].targetFilePath).toBe('backend/services/sync.py');
const opsAlertCalls = calls.filter((c) => c.target === '_create_ops_alert');
expect(opsAlertCalls.length).toBe(2);
expect(opsAlertCalls.every((c) => c.targetFilePath === 'backend/services/alerts.py')).toBe(
true,
);
const sendDailyCalls = calls.filter((c) => c.target === 'send_daily_alerts');
expect(sendDailyCalls.length).toBe(2);
expect(sendDailyCalls.every((c) => c.targetFilePath === 'backend/routers/alerts.py')).toBe(
true,
);
});
it('preserves single-segment ancestor walk (regression check for from auth_utils import X)', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'backend/routers/cron.py',
);
const verifyCalls = calls.filter((c) => c.target === 'verify_cron_secret');
expect(verifyCalls.length).toBe(1);
expect(verifyCalls[0].targetFilePath).toBe('backend/auth_utils.py');
const orgCalls = calls.filter((c) => c.target === 'get_org_id_from_header');
expect(orgCalls.length).toBe(1);
expect(orgCalls[0].targetFilePath).toBe('backend/auth_utils.py');
});
});
// ---------------------------------------------------------------------------
// Negative case for `hasRepoCandidate` widening: a vendored copy of an
// external package (e.g. `vendor/django/urls.py`) must not cause an external
// import like `from django.urls import path` issued from an unrelated file
// (`app/main.py`) to be treated as a local candidate. The ancestor-bounded
// nested check rejects vendored matches that don't sit on the importer's
// own ancestor path.
// ---------------------------------------------------------------------------
describe('Python multi-segment widening: vendored external package false-positive guard', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-vendored-django-'));
writeFixtureRepo(repoDir, {
'app/main.py': `from django.urls import path
def boot():
path("/")
`,
'vendor/django/__init__.py': '',
'vendor/django/urls.py': `def path(p):
return p
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('does not resolve from django.urls to vendor/django/urls.py from an unrelated importer', () => {
const imports = getRelationships(result, 'IMPORTS');
const stray = imports.find(
(i) => i.sourceFilePath === 'app/main.py' && i.targetFilePath === 'vendor/django/urls.py',
);
expect(stray).toBeUndefined();
});
it('does not emit a CALLS edge from app/main.py:boot to vendor/django/urls.py:path', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'app/main.py',
);
const stray = calls.find(
(c) => c.target === 'path' && c.targetFilePath === 'vendor/django/urls.py',
);
expect(stray).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Workspace-root precedence: when both `services/sync.py` (root) and
// `backend/services/sync.py` (ancestor) exist, an importer at
// `backend/routers/cron.py` doing `from services.sync import X` resolves to
// the root file. Mirrors Python's `sys.path` semantics where the project
// root is searched before package-local namespaces.
// ---------------------------------------------------------------------------
describe('Python multi-segment resolution: workspace root wins over ancestor', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-root-precedence-'));
writeFixtureRepo(repoDir, {
'services/__init__.py': '',
'services/sync.py': `def root_marker():
return "root"
`,
'backend/__init__.py': '',
'backend/services/__init__.py': '',
'backend/services/sync.py': `def ancestor_marker():
return "ancestor"
`,
'backend/routers/__init__.py': '',
'backend/routers/cron.py': `from services.sync import root_marker
def handler():
return root_marker()
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('resolves the import edge to the root services/sync.py, not backend/services/sync.py', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(i) => i.sourceFilePath === 'backend/routers/cron.py',
);
const rootEdge = imports.find((i) => i.targetFilePath === 'services/sync.py');
expect(rootEdge).toBeDefined();
const ancestorEdge = imports.find((i) => i.targetFilePath === 'backend/services/sync.py');
expect(ancestorEdge).toBeUndefined();
});
it('binds the imported name to the root file, not the ancestor copy', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'backend/routers/cron.py' && c.target === 'root_marker',
);
expect(calls.length).toBe(1);
expect(calls[0].targetFilePath).toBe('services/sync.py');
});
});
// ---------------------------------------------------------------------------
// Suffix-fallback determinism: when both root + ancestor walk miss but the
// suffix scan finds multiple candidates in unrelated trees, the resolver
// must pick the same file regardless of file-set insertion order. The
// previous implementation returned the first match in `Set` iteration
// order, which depended on file ingestion order and produced flapping
// edges across runs in multi-directory collision repos.
//
// Tie-break order: fewest path segments, then lexicographic.
// ---------------------------------------------------------------------------
describe('Python multi-segment resolution: suffix fallback determinism', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-'));
writeFixtureRepo(repoDir, {
// Importer's package. The `app/services/marker.py` file makes the
// `services` segment gate-pass under the ancestor-bounded
// `hasRepoCandidate` check, but `app/services/sync.py` is
// intentionally absent so the ancestor walk misses and the suffix
// fallback fires.
'app/services/marker.py': `def _marker(): return True
`,
'app/main.py': `from services.sync import handler
def boot():
return handler()
`,
// Two suffix candidates outside the importer's ancestor tree.
// `lib/services/sync.py` has 3 path segments, the alternative has
// 4 — the deterministic pick is `lib/services/sync.py`.
'lib/services/sync.py': `def handler():
return "lib"
`,
'tooling/extras/services/sync.py': `def handler():
return "tooling"
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('picks the shortest-path candidate (lib/services/sync.py) and only that one', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(i) => i.sourceFilePath === 'app/main.py',
);
const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
expect(libEdge).toBeDefined();
const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
expect(toolingEdge).toBeUndefined();
});
it('binds the call to the deterministic pick, not the alternate copy', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
);
expect(calls.length).toBe(1);
expect(calls[0].targetFilePath).toBe('lib/services/sync.py');
});
});
// ---------------------------------------------------------------------------
// Lexicographic tiebreak: when two suffix candidates have the same
// directory depth, the lexicographically smaller path wins. Without this,
// equal-depth collisions would still depend on file-set insertion order.
// ---------------------------------------------------------------------------
describe('Python multi-segment resolution: suffix fallback lexicographic tiebreak', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-tiebreak-'));
writeFixtureRepo(repoDir, {
// Same gate-passing pattern as the determinism test — non-init
// marker file makes the `services` segment satisfy
// `hasRepoCandidate` for an importer at `app/main.py`.
'app/services/marker.py': `def _marker(): return True
`,
'app/main.py': `from services.sync import handler
def boot():
return handler()
`,
// Both candidates have depth 3, so directory-depth alone cannot
// disambiguate. Lexicographic order picks `alpha/...` over
// `omega/...` regardless of which file was ingested first.
'alpha/services/sync.py': `def handler():
return "alpha"
`,
'omega/services/sync.py': `def handler():
return "omega"
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('picks the lexicographically smaller path on equal-depth ties', () => {
const imports = getRelationships(result, 'IMPORTS').filter(
(i) => i.sourceFilePath === 'app/main.py',
);
const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
expect(alphaEdge).toBeDefined();
const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
expect(omegaEdge).toBeUndefined();
});
it('binds the call to alpha/services/sync.py, not omega', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
);
expect(calls.length).toBe(1);
expect(calls[0].targetFilePath).toBe('alpha/services/sync.py');
});
});
// ---------------------------------------------------------------------------
// Insertion-order independence: re-runs the depth and lexicographic
// scenarios with the candidate files written in reverse order. The
// deterministic sort in `resolveAbsoluteFromFiles` should pick the same
// winner regardless. If a future refactor accidentally drops the sort
// and falls back to `Set` insertion order, these tests pin the
// regression directly.
// ---------------------------------------------------------------------------
describe('Python multi-segment resolution: suffix fallback insertion-order independence', () => {
let depthRepoDir: string;
let lexRepoDir: string;
let depthResult: PipelineResult;
let lexResult: PipelineResult;
beforeAll(async () => {
// Depth scenario, files written in reverse order: tooling first, lib
// second. `writeFixtureRepo` iterates in object-property insertion
// order, and the pipeline scanner's directory traversal is also
// affected by mtime/inode order on most filesystems. The expected
// winner is still `lib/services/sync.py` (depth 3 < depth 4).
depthRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-rev-'));
writeFixtureRepo(depthRepoDir, {
'tooling/extras/services/sync.py': `def handler():
return "tooling"
`,
'lib/services/sync.py': `def handler():
return "lib"
`,
'app/services/marker.py': `def _marker(): return True
`,
'app/main.py': `from services.sync import handler
def boot():
return handler()
`,
});
depthResult = await runPipelineFromRepo(depthRepoDir, () => {});
// Lexicographic scenario, files written in reverse order: omega first.
// Expected winner is still `alpha/services/sync.py`.
lexRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-rev-'));
writeFixtureRepo(lexRepoDir, {
'omega/services/sync.py': `def handler():
return "omega"
`,
'alpha/services/sync.py': `def handler():
return "alpha"
`,
'app/services/marker.py': `def _marker(): return True
`,
'app/main.py': `from services.sync import handler
def boot():
return handler()
`,
});
lexResult = await runPipelineFromRepo(lexRepoDir, () => {});
}, 120000);
afterAll(() => {
if (depthRepoDir !== undefined) fs.rmSync(depthRepoDir, { recursive: true, force: true });
if (lexRepoDir !== undefined) fs.rmSync(lexRepoDir, { recursive: true, force: true });
});
it('depth tiebreak still picks lib/services/sync.py with reversed file-write order', () => {
const imports = getRelationships(depthResult, 'IMPORTS').filter(
(i) => i.sourceFilePath === 'app/main.py',
);
const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
expect(libEdge).toBeDefined();
const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
expect(toolingEdge).toBeUndefined();
});
it('lex tiebreak still picks alpha/services/sync.py with reversed file-write order', () => {
const imports = getRelationships(lexResult, 'IMPORTS').filter(
(i) => i.sourceFilePath === 'app/main.py',
);
const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
expect(alphaEdge).toBeDefined();
const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
expect(omegaEdge).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Re-export chain: from .base import X barrel pattern via __init__.py
// ---------------------------------------------------------------------------
describe('Python re-export chain resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-reexport-chain'), () => {});
}, 60000);
it('resolves user.save() through __init__.py barrel to models/base.py', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
expect(saveCall!.targetFilePath).toBe('models/base.py');
});
it('resolves repo.persist() through __init__.py barrel to models/base.py', () => {
const calls = getRelationships(result, 'CALLS');
const persistCall = calls.find((c) => c.target === 'persist');
expect(persistCall).toBeDefined();
expect(persistCall!.source).toBe('main');
expect(persistCall!.targetFilePath).toBe('models/base.py');
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
describe('Python local definition shadows import', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-local-shadow'), () => {});
}, 60000);
it('resolves save("test") to local save in app.py, not utils.py', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'main');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('app.py');
});
});
// ---------------------------------------------------------------------------
// Bare import: `import user` from services/auth.py resolves to services/user.py
// not models/user.py, even though models/ is indexed first (proximity wins)
// ---------------------------------------------------------------------------
describe('Python bare import resolution (proximity over index order)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-bare-import'), () => {});
}, 60000);
it('detects User in models/ and UserService in services/', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
});
it('resolves `import user` from services/auth.py to services/user.py, not models/user.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const imp = imports.find((e) => e.sourceFilePath === 'services/auth.py');
expect(imp).toBeDefined();
expect(imp!.targetFilePath).toBe('services/user.py');
expect(imp!.targetFilePath).not.toBe('models/user.py');
});
it('resolves svc.execute() CALLS edge to UserService#execute in services/user.py', () => {
// End-to-end: correct IMPORTS resolution must propagate through type inference
// so that user.UserService() binds svc → UserService, and svc.execute() resolves
const calls = getRelationships(result, 'CALLS');
const executeCall = calls.find(
(c) => c.target === 'execute' && c.targetFilePath === 'services/user.py',
);
expect(executeCall).toBeDefined();
expect(executeCall!.source).toBe('authenticate');
});
});
// ---------------------------------------------------------------------------
// Constructor-inferred type resolution: user = User(); user.save() → User.save
// Cross-file SymbolTable verification (no explicit type annotations)
// ---------------------------------------------------------------------------
describe('Python constructor-inferred type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-constructor-type-inference'),
() => {},
);
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to models/user.py via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'models/user.py',
);
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('process_entities');
});
it('resolves repo.save() to models/repo.py via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'models/repo.py',
);
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('process_entities');
});
it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Constructor-call resolution: User("alice") resolves to User class
// ---------------------------------------------------------------------------
describe('Python constructor-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-constructor-calls'), () => {});
}, 60000);
it('detects User class with __init__ and save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['__init__', 'save']));
expect(getNodesByLabel(result, 'Function')).toContain('process');
});
it('resolves import from app.py to models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const imp = imports.find((e) => e.source === 'app.py' && e.targetFilePath === 'models.py');
expect(imp).toBeDefined();
});
it('emits HAS_METHOD from User class to __init__ and save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const initEdge = hasMethod.find((e) => e.source === 'User' && e.target === '__init__');
const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
expect(initEdge).toBeDefined();
expect(saveEdge).toBeDefined();
});
it('resolves user.save() as a method call to models.py', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process');
expect(saveCall!.targetFilePath).toBe('models.py');
});
});
// ---------------------------------------------------------------------------
// self.save() resolves to enclosing class's own save method
// ---------------------------------------------------------------------------
describe('Python self resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-self-this-resolution'),
() => {},
);
}, 60000);
it('detects User and Repo classes, each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves self.save() inside User.process to User.save, not Repo.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('models/user.py');
});
});
// ---------------------------------------------------------------------------
// Parent class resolution: EXTENDS edge
// ---------------------------------------------------------------------------
describe('Python parent resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-parent-resolution'), () => {});
}, 60000);
it('detects BaseModel and User classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']);
});
it('emits EXTENDS edge: User → BaseModel', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(1);
expect(extends_[0].source).toBe('User');
expect(extends_[0].target).toBe('BaseModel');
});
it('EXTENDS edge points to real graph node in base.py', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const target = result.graph.getNode(extends_[0].rel.targetId);
expect(target).toBeDefined();
expect(target!.properties.filePath).toBe('models/base.py');
});
});
// ---------------------------------------------------------------------------
// super().save() resolves to parent class's save method
// ---------------------------------------------------------------------------
describe('Python super resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-super-resolution'), () => {});
}, 60000);
it('detects BaseModel, User, and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']);
});
it('resolves super().save() inside User to BaseModel.save, not Repo.save', () => {
const calls = getRelationships(result, 'CALLS');
const superSave = calls.find(
(c) => c.source === 'save' && c.target === 'save' && c.targetFilePath === 'models/base.py',
);
expect(superSave).toBeDefined();
// NOTE: no `rel.reason` assertion here. The legacy DAG classifies
// Python `super()` as `'import-resolved'` (the ancestor arrives via
// `from base import BaseModel`), while the scope-resolution super-
// branch emits the canonical `'global'` (super resolves via MRO,
// not through an import directive). That legacy-path asymmetry is
// pre-existing (the scope-resolution path previously emitted the
// non-standard `'scope-resolution: super-receiver'`) and closing it
// requires realigning the legacy tier classifier, which is out of
// scope here. The C# `csharp-super-resolution` + `csharp-generic-
// parent` suites pin `'global'` because C# legacy also emits
// `'global'` for `base` calls, giving us a same-graph guarantee
// on at least one migrated language.
const repoSave = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'models/repo.py',
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Python qualified constructor: user = models.User("alice"); user.save()
// ---------------------------------------------------------------------------
describe('Python qualified constructor inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-qualified-constructor'),
() => {},
);
}, 60000);
it('resolves user.save() via qualified constructor (models.User)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'models.py');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
});
it('resolves user.greet() via qualified constructor (models.User)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath === 'models.py');
expect(greetCall).toBeDefined();
expect(greetCall!.source).toBe('main');
});
});
// ---------------------------------------------------------------------------
// Walrus operator: if (user := User("alice")): user.save()
// ---------------------------------------------------------------------------
describe('Python walrus operator type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-walrus-operator'), () => {});
}, 60000);
it('detects User class with save and greet methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['save', 'greet']));
});
it('resolves user.save() via walrus operator constructor inference', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'models.py');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process');
});
});
// ---------------------------------------------------------------------------
// Class-level annotations: file-scope `user: User` disambiguates method calls
// ---------------------------------------------------------------------------
describe('Python class-level annotation resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-class-annotations'), () => {});
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves active_user.save() to User.save via file-level annotation', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'user.py');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('process');
});
it('resolves active_repo.save() to Repo.save via file-level annotation', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'repo.py');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('process');
});
it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Return type inference: user = get_user('alice'); user.save()
// Python's scanner captures ALL call assignments, enabling return type inference.
// ---------------------------------------------------------------------------
describe('Python return type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-return-type-inference'),
() => {},
);
}, 60000);
it('detects User class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('detects get_user and save symbols', () => {
// Python methods inside classes may be labeled Method or Function depending on nesting
const allSymbols = [
...getNodesByLabel(result, 'Function'),
...getNodesByLabel(result, 'Method'),
];
expect(allSymbols).toContain('get_user');
expect(allSymbols).toContain('save');
});
it('resolves user.save() to User#save via return type inference from get_user() -> User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process_user');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toContain('models.py');
});
});
// ---------------------------------------------------------------------------
// Issue #289: static/classmethod classes must have HAS_METHOD edges
// ---------------------------------------------------------------------------
describe('Python static/classmethod class resolution (issue #289)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-static-class-methods'),
() => {},
);
}, 60000);
it('detects UserService and AdminService classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
expect(getNodesByLabel(result, 'Class')).toContain('AdminService');
});
it('detects all static/class methods as symbols', () => {
const allSymbols = [
...getNodesByLabel(result, 'Function'),
...getNodesByLabel(result, 'Method'),
];
expect(allSymbols).toContain('find_user');
expect(allSymbols).toContain('create_user');
expect(allSymbols).toContain('from_config');
expect(allSymbols).toContain('delete_user');
});
it('emits HAS_METHOD edges linking static methods to their enclosing class', () => {
// This is the core of issue #289: without HAS_METHOD, context() and impact()
// return empty for classes whose methods are all @staticmethod/@classmethod
const hasMethod = getRelationships(result, 'HAS_METHOD');
const userServiceMethods = hasMethod.filter((e) => e.source === 'UserService');
expect(userServiceMethods.length).toBe(3); // find_user, create_user, from_config
const adminServiceMethods = hasMethod.filter((e) => e.source === 'AdminService');
expect(adminServiceMethods.length).toBe(2); // find_user, delete_user
});
it('resolves unique static method calls (create_user, delete_user, from_config)', () => {
const calls = getRelationships(result, 'CALLS');
// delete_user is unique to AdminService — should resolve
const deleteCall = calls.find(
(c) =>
c.target === 'delete_user' &&
c.source === 'process' &&
c.targetFilePath.includes('service.py'),
);
expect(deleteCall).toBeDefined();
// create_user is unique to UserService — should resolve
const createCall = calls.find(
(c) =>
c.target === 'create_user' &&
c.source === 'process' &&
c.targetFilePath.includes('service.py'),
);
expect(createCall).toBeDefined();
});
it('resolves find_user() via class-as-receiver for static method calls', () => {
// With qualified IDs, UserService.find_user and AdminService.find_user are distinct
// nodes — so both CALLS edges are correctly emitted (no ID collision).
const calls = getRelationships(result, 'CALLS');
const findCalls = calls.filter((c) => c.target === 'find_user' && c.source === 'process');
expect(findCalls.length).toBe(2);
expect(findCalls.every((c) => c.targetFilePath.includes('service.py'))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Nullable receiver: user: User | None = find_user(); user.save()
// Python 3.10+ union syntax — stripNullable unwraps `User | None` → `User`
// ---------------------------------------------------------------------------
describe('Python nullable receiver resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-nullable-receiver'), () => {});
}, 60000);
it('detects User and Repo classes, both with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to User.save via nullable receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'user.py');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('process_entities');
});
it('resolves repo.save() to Repo.save via nullable receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'repo.py');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('process_entities');
});
it('user.save() does NOT resolve to Repo.save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_entities');
// Each save() call should resolve to exactly one target file
const userSaveToRepo = saveCalls.filter((c) => c.targetFilePath === 'repo.py');
const repoSaveToUser = saveCalls.filter((c) => c.targetFilePath === 'user.py');
// Exactly 1 edge to each file (not 2 to either)
expect(userSaveToRepo.length).toBe(1);
expect(repoSaveToUser.length).toBe(1);
});
it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Assignment chain propagation (Phase 4.3)
// ---------------------------------------------------------------------------
describe('Python assignment chain propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-assignment-chain'), () => {});
}, 60000);
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves alias.save() to User#save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
// Positive: alias.save() must resolve to User#save
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('alias.save() does NOT resolve to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
// Negative: only one save call from process to User#save
const wrongCall = calls.filter(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
);
expect(wrongCall.length).toBe(1);
});
it('resolves r_alias.save() to Repo#save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
// Positive: r_alias.save() must resolve to Repo#save
const repoSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'),
);
expect(repoSave).toBeDefined();
});
it('each alias resolves to its own class, not the other', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
);
const repoSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'),
);
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
});
});
// ---------------------------------------------------------------------------
// Python nullable (User | None) + assignment chain combined.
// Python 3.10+ union syntax is parsed as binary_operator by tree-sitter,
// stored as raw text "User | None" in TypeEnv. stripNullable's
// NULLABLE_KEYWORDS.has() path must resolve it at lookup time.
// ---------------------------------------------------------------------------
describe('Python nullable (User | None) + assignment chain combined', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-nullable-chain'), () => {});
}, 60000);
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves alias.save() to User#save when source is User | None', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'nullable_chain_user' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('alias.save() from User | None does NOT resolve to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'nullable_chain_user' &&
c.targetFilePath?.includes('repo.py'),
);
expect(wrongCall).toBeUndefined();
});
it('resolves alias.save() to Repo#save when source is Repo | None', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'nullable_chain_repo' &&
c.targetFilePath?.includes('repo.py'),
);
expect(repoSave).toBeDefined();
});
it('alias.save() from Repo | None does NOT resolve to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'nullable_chain_repo' &&
c.targetFilePath?.includes('user.py'),
);
expect(wrongCall).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Python walrus operator (:=) assignment chain.
// Tests that extractPendingAssignment handles named_expression nodes
// in addition to regular assignment nodes.
// ---------------------------------------------------------------------------
describe('Python walrus operator (:=) assignment chain', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-walrus-chain'), () => {});
}, 60000);
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves alias.save() to User#save via regular + walrus chains', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'walrus_chain_user' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('save() in walrus_chain_user does NOT resolve to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'walrus_chain_user' &&
c.targetFilePath?.includes('repo.py'),
);
expect(wrongCall).toBeUndefined();
});
it('resolves alias.save() to Repo#save via regular + walrus chains', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'walrus_chain_repo' &&
c.targetFilePath?.includes('repo.py'),
);
expect(repoSave).toBeDefined();
});
it('save() in walrus_chain_repo does NOT resolve to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'walrus_chain_repo' &&
c.targetFilePath?.includes('user.py'),
);
expect(wrongCall).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Python match/case as-pattern binding: `case User() as u: u.save()`
// Tests Phase 6 extractPatternBinding for Python's match statement.
// ---------------------------------------------------------------------------
describe('Python match/case as-pattern type binding', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-match-case'), () => {});
}, 60000);
it('detects User and Repo classes each with a save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves u.save() to User#save via match/case as-pattern binding', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve u.save() to Repo#save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Chained method calls: svc.get_user().save()
// Tests that Python's scanner correctly handles method-call chains where
// the intermediate receiver type is inferred from the return type annotation.
// ---------------------------------------------------------------------------
describe('Python chained method call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-chain-call'), () => {});
}, 60000);
it('detects User, Repo, and UserService classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('Repo');
expect(classes).toContain('UserService');
});
it('detects get_user and save functions', () => {
const allSymbols = [
...getNodesByLabel(result, 'Function'),
...getNodesByLabel(result, 'Method'),
];
expect(allSymbols).toContain('get_user');
expect(allSymbols).toContain('save');
});
it('resolves svc.get_user().save() to User#save via chain resolution', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve svc.get_user().save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('repo.py'),
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for key, user in data.items() — dict.items() call iterable + tuple unpacking
// ---------------------------------------------------------------------------
describe('Python dict.items() for-loop resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-dict-items-loop'), () => {});
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('resolves user.save() via dict.items() loop to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// self.users member access iterable: for user in self.users
// ---------------------------------------------------------------------------
describe('Python member access iterable for-loop', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-member-access-for-loop'),
() => {},
);
}, 60000);
it('detects User and Repo classes with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
expect(getNodesByLabel(result, 'Method')).toContain('save');
});
it('resolves user.save() via self.users to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT cross-resolve user.save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const wrong = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('repo.py'),
);
expect(wrong).toBeUndefined();
});
it('resolves repo.save() via self.repos to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('repo.py'),
);
expect(repoSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Python for-loop with call_expression iterable: for user in get_users()
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
// ---------------------------------------------------------------------------
describe('Python for-loop call_expression iterable resolution (Phase 7.3)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-for-call-expr'), () => {});
}, 60000);
it('detects User and Repo classes with competing save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves user.save() in for-loop over get_users() to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('models.py'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() in for-loop over get_repos() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('models.py'),
);
expect(repoSave).toBeDefined();
});
it('process_users resolves exactly one save call (no cross-binding)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_users');
expect(saveCalls.length).toBe(1);
});
it('process_repos resolves exactly one save call (no cross-binding)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_repos');
expect(saveCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// enumerate() for-loop: for i, k, v in enumerate(d.items())
// ---------------------------------------------------------------------------
describe('Python enumerate() for-loop resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-enumerate-loop'), () => {});
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
});
it('resolves v.save() in enumerate(users.items()) loop to User#save', () => {
// for i, k, v in enumerate(users.items()): v.save()
// v must bind to User (value type of dict[str, User]).
// Without enumerate() support, v is unbound → resolver emits 0 CALLS.
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve v.save() to a non-User target', () => {
// i is the int index from enumerate — must not produce a spurious CALLS edge
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
!c.targetFilePath?.includes('user.py'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves nested tuple pattern: for i, (k, v) in enumerate(d.items())', () => {
// Nested tuple_pattern inside pattern_list — must descend to find v
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_nested_tuple' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
it('resolves parenthesized tuple: for (i, u) in enumerate(users)', () => {
// tuple_pattern as top-level left node (not pattern_list)
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_parenthesized_tuple' &&
c.targetFilePath?.includes('user.py'),
);
expect(userSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — annotated attribute capture
// ---------------------------------------------------------------------------
describe('Field type resolution (Python)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-field-types'), () => {});
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Python annotated attributes', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking attributes to classes', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBe(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('Address → city');
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((e) => e.target === 'save');
const addressSave = saveCalls.find(
(e) => e.source === 'process_user' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
it('populates field metadata (visibility, isStatic, isReadonly) on Property nodes', () => {
const properties = getNodesByLabelFull(result, 'Property');
const city = properties.find((p) => p.name === 'city');
expect(city).toBeDefined();
expect(city!.properties.visibility).toBe('public');
expect(city!.properties.isStatic).toBe(false);
expect(city!.properties.isReadonly).toBe(false);
expect(city!.properties.declaredType).toBe('str');
const addr = properties.find((p) => p.name === 'address');
expect(addr).toBeDefined();
expect(addr!.properties.visibility).toBe('public');
expect(addr!.properties.isStatic).toBe(false);
expect(addr!.properties.declaredType).toBe('Address');
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field type disambiguation — both User and Address have save()
// ---------------------------------------------------------------------------
describe('Field type disambiguation (Python)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-field-type-disambig'), () => {});
}, 60000);
it('detects both User#save and Address#save', () => {
const methods = getNodesByLabel(result, 'Method');
const saveMethods = methods.filter((m) => m === 'save');
expect(saveMethods.length).toBe(2);
});
it('resolves user.address.save() → Address#save (not User#save)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('address');
expect(saveCalls[0].targetFilePath).not.toContain('user');
});
});
// ---------------------------------------------------------------------------
// ACCESSES write edges from assignment expressions
// ---------------------------------------------------------------------------
describe('Write access tracking (Python)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-write-access'), () => {});
}, 60000);
it('emits ACCESSES write edges for attribute assignments', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter((e) => e.rel.reason === 'write');
expect(writes.length).toBe(2);
const nameWrite = writes.find((e) => e.target === 'name');
const addressWrite = writes.find((e) => e.target === 'address');
expect(nameWrite).toBeDefined();
expect(nameWrite!.source).toBe('update_user');
expect(addressWrite).toBeDefined();
expect(addressWrite!.source).toBe('update_user');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): user = get_user(); user.save()
// ---------------------------------------------------------------------------
describe('Python call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-call-result-binding'), () => {});
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .get_city() → .save()
// ---------------------------------------------------------------------------
describe('Python method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('Python grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.targetFilePath.includes('greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.py'));
expect(greetCall).toBeDefined();
});
});
// ── Phase P: Default Parameter Arity Resolution ──────────────────────────
describe('Python default parameter arity resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-default-params'), () => {});
}, 60000);
it('resolves greet("alice") with 1 arg to greet with 2 params (1 default)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet');
expect(greetCalls.length).toBe(1);
});
it('resolves search("test") with 1 arg to search with 2 params (1 default)', () => {
const calls = getRelationships(result, 'CALLS');
const searchCalls = calls.filter((c) => c.source === 'process' && c.target === 'search');
expect(searchCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models.py exports get_user() -> User
// app.py imports get_user, calls u = get_user(); u.save(); u.get_name()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Python cross-file binding propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'py-cross-file'), () => {});
}, 60000);
it('detects User class with save and get_name methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['save', 'get_name']));
});
it('detects get_user and run functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('run');
});
it('emits IMPORTS edge from app.py to models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(
(e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.get_name() in run() to User#get_name via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(
(c) => c.target === 'get_name' && c.source === 'run' && c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and get_name to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Module import: `import models; models.User()` should produce CALLS edges
// even when multiple imported modules export a class with the same name.
// Python's `import models` is a namespace import — moduleAliasMap maps the
// module alias to its source file, enabling scope-resolution to disambiguate
// `models.User()` from `auth.User()` when both modules export `User`.
// ---------------------------------------------------------------------------
describe('Python module import CALLS resolution (Issue #337)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-module-import'), () => {});
}, 60000);
// ── Node detection ──────────────────────────────────────────────────
it('detects exactly 3 Class nodes: User (×2) and Admin (×1)', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes.length).toBe(3);
expect(classes.filter((c) => c === 'User').length).toBe(2);
expect(classes.filter((c) => c === 'Admin').length).toBe(1);
});
it('detects exactly 3 Method nodes: save, verify, login', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods.length).toBe(3);
expect(methods).toContain('save');
expect(methods).toContain('verify');
expect(methods).toContain('login');
});
// ── IMPORTS edges ───────────────────────────────────────────────────
it('emits exactly 2 IMPORTS edges from app.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const appImports = imports.filter((e) => e.sourceFilePath === 'app.py');
expect(appImports.length).toBe(2);
});
it('resolves `import models` IMPORTS edge: app.py → models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const toModels = imports.find(
(e) => e.sourceFilePath === 'app.py' && e.targetFilePath === 'models.py',
);
expect(toModels).toBeDefined();
});
it('resolves `import auth` IMPORTS edge: app.py → auth.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const toAuth = imports.find(
(e) => e.sourceFilePath === 'app.py' && e.targetFilePath === 'auth.py',
);
expect(toAuth).toBeDefined();
});
it('no IMPORTS edge from models.py or auth.py (they import nothing)', () => {
const imports = getRelationships(result, 'IMPORTS');
const fromModels = imports.filter((e) => e.sourceFilePath === 'models.py');
const fromAuth = imports.filter((e) => e.sourceFilePath === 'auth.py');
expect(fromModels.length).toBe(0);
expect(fromAuth.length).toBe(0);
});
// ── CALLS edges: key regression test (Issue #337) ───────────────────
it('resolves models.User() CALLS edge from app.py to models.py:User', () => {
const calls = getRelationships(result, 'CALLS');
const userCall = calls.find(
(c) =>
c.target === 'User' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py',
);
expect(userCall).toBeDefined();
});
it('resolves auth.Admin() CALLS edge from app.py to auth.py:Admin', () => {
const calls = getRelationships(result, 'CALLS');
const adminCall = calls.find(
(c) =>
c.target === 'Admin' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
);
expect(adminCall).toBeDefined();
});
it('resolves u.save() method call from app.py to models.py:save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py',
);
expect(saveCall).toBeDefined();
});
it('resolves a.login() method call from app.py to auth.py:login', () => {
const calls = getRelationships(result, 'CALLS');
const loginCall = calls.find(
(c) =>
c.target === 'login' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
);
expect(loginCall).toBeDefined();
});
// ── Negative tests ──────────────────────────────────────────────────
it('no CALLS edges originate from models.py or auth.py (they have no callers)', () => {
const calls = getRelationships(result, 'CALLS');
const fromModels = calls.filter((c) => c.sourceFilePath === 'models.py');
const fromAuth = calls.filter((c) => c.sourceFilePath === 'auth.py');
expect(fromModels.length).toBe(0);
expect(fromAuth.length).toBe(0);
});
it('Admin() does NOT resolve to models.py (Admin only exists in auth.py)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongAdmin = calls.find((c) => c.target === 'Admin' && c.targetFilePath === 'models.py');
expect(wrongAdmin).toBeUndefined();
});
it('no EXTENDS edges (no inheritance in this fixture)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
// ── Same-name cross-module disambiguation ───────────────────────────
it('resolves auth.User() CALLS edge to auth.py:User (not models.py:User)', () => {
// Both models.py and auth.py export User. moduleAliasMap maps
// receiverName='auth' → auth.py for correct disambiguation.
const calls = getRelationships(result, 'CALLS');
const authUserCall = calls.find(
(c) => c.target === 'User' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
);
expect(authUserCall).toBeDefined();
});
it('models.User() and auth.User() resolve to DIFFERENT files', () => {
const calls = getRelationships(result, 'CALLS');
const userCalls = calls.filter((c) => c.target === 'User' && c.sourceFilePath === 'app.py');
expect(userCalls.length).toBe(2);
const targetFiles = new Set(userCalls.map((c) => c.targetFilePath));
expect(targetFiles.size).toBe(2);
expect(targetFiles).toContain('models.py');
expect(targetFiles).toContain('auth.py');
});
it('v.verify() resolves to auth.py:verify (via auth.User() constructor inference)', () => {
const calls = getRelationships(result, 'CALLS');
const verifyCall = calls.find(
(c) =>
c.target === 'verify' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
);
expect(verifyCall).toBeDefined();
});
// ── HAS_METHOD edges ────────────────────────────────────────────────
it('emits HAS_METHOD edges linking methods to their classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
// models.py: User → save
const modelsUserSave = hasMethod.find(
(e) => e.source === 'User' && e.target === 'save' && e.sourceFilePath === 'models.py',
);
expect(modelsUserSave).toBeDefined();
// auth.py: User → verify, Admin → login
const authUserVerify = hasMethod.find(
(e) => e.source === 'User' && e.target === 'verify' && e.sourceFilePath === 'auth.py',
);
const authAdminLogin = hasMethod.find(
(e) => e.source === 'Admin' && e.target === 'login' && e.sourceFilePath === 'auth.py',
);
expect(authUserVerify).toBeDefined();
expect(authAdminLogin).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Module reached through `from pkg import models` (#2746)
// ---------------------------------------------------------------------------
describe('Python from-import module alias resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-from-module-alias'), () => {});
}, 60000);
it('links the imported module rather than the package initializer', () => {
const imports = getRelationships(result, 'IMPORTS');
const appImports = imports.filter((edge) => edge.sourceFilePath === 'pkg/app.py');
expect(appImports.length).toBeGreaterThan(0);
expect(appImports.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true);
});
it('resolves inline and assigned calls through the module alias', () => {
const calls = getRelationships(result, 'CALLS').filter(
(edge) => edge.sourceFilePath === 'pkg/app.py',
);
expect(calls.filter((edge) => edge.target === 'User')).toHaveLength(2);
expect(calls.filter((edge) => edge.target === 'save')).toHaveLength(3);
expect(calls.every((edge) => edge.targetFilePath === 'pkg/models.py')).toBe(true);
});
it('does not bind the same-named class from an unrelated module', () => {
const wrongCalls = getRelationships(result, 'CALLS').filter(
(edge) => edge.sourceFilePath === 'pkg/app.py' && edge.targetFilePath === 'decoy/models.py',
);
expect(wrongCalls).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// External dotted imports: framework modules like django.apps must not resolve
// to unrelated local basename matches such as accounts/apps.py or config/urls.py.
// ---------------------------------------------------------------------------
describe('Python external dotted imports do not self-resolve to local files', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-django-app-imports'), () => {});
}, 60000);
it('keeps the real local cross-app import: billing/models.py -> accounts/models.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const localImport = imports.find(
(e) => e.sourceFilePath === 'billing/models.py' && e.targetFilePath === 'accounts/models.py',
);
expect(localImport).toBeDefined();
});
it('does not resolve django.apps in app configs to local apps.py files', () => {
const imports = getRelationships(result, 'IMPORTS');
const appConfigImports = imports.filter((e) => e.sourceFilePath.endsWith('/apps.py'));
expect(appConfigImports.length).toBe(0);
});
it('does not resolve django.urls in config/urls.py to config/urls.py', () => {
const imports = getRelationships(result, 'IMPORTS');
const urlsImport = imports.find(
(e) => e.sourceFilePath === 'config/urls.py' && e.targetFilePath === 'config/urls.py',
);
expect(urlsImport).toBeUndefined();
});
it('does not resolve django.core.asgi or django.core.wsgi to local config modules', () => {
const imports = getRelationships(result, 'IMPORTS');
const asgiImport = imports.find(
(e) => e.sourceFilePath === 'config/asgi.py' && e.targetFilePath === 'config/asgi.py',
);
const wsgiImport = imports.find(
(e) => e.sourceFilePath === 'config/wsgi.py' && e.targetFilePath === 'config/wsgi.py',
);
expect(asgiImport).toBeUndefined();
expect(wsgiImport).toBeUndefined();
});
it('does not resolve other django.* imports to local same-basename files', () => {
const imports = getRelationships(result, 'IMPORTS');
const wrongTargets = new Set(['config/asgi.py', 'config/wsgi.py', 'config/urls.py']);
const misresolvedFrameworkImports = imports.filter((e) => wrongTargets.has(e.targetFilePath));
expect(misresolvedFrameworkImports.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Phase 16: Method enrichment (isAbstract, parameterTypes, static methods)
// models.py: Animal(ABC) with @abstractmethod speak, @staticmethod classify, breathe
// Dog(Animal) overrides speak
// app.py: dog.speak(), Dog.classify("dog")
// ---------------------------------------------------------------------------
describe('Python method enrichment', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-method-enrichment'), () => {});
}, 60000);
it('detects Animal and Dog classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Animal');
expect(classes).toContain('Dog');
});
it('emits HAS_METHOD edges for Animal methods', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const animalMethods = hasMethod
.filter((e) => e.source === 'Animal')
.map((e) => e.target)
.sort();
expect(animalMethods).toContain('speak');
expect(animalMethods).toContain('classify');
expect(animalMethods).toContain('breathe');
});
it('emits HAS_METHOD edge for Dog.speak', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const dogSpeak = hasMethod.find((e) => e.source === 'Dog' && e.target === 'speak');
expect(dogSpeak).toBeDefined();
});
it('emits EXTENDS edge Dog -> Animal', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const dogExtends = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(dogExtends).toBeDefined();
});
it('marks @abstractmethod speak as isAbstract (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const speak = methods.find((n) => n.name === 'speak' && n.properties.filePath === 'models.py');
if (speak?.properties.isAbstract !== undefined) {
expect(speak.properties.isAbstract).toBe(true);
}
});
it('marks breathe as NOT isAbstract (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const breathe = methods.find((n) => n.name === 'breathe');
if (breathe?.properties.isAbstract !== undefined) {
expect(breathe.properties.isAbstract).toBe(false);
}
});
it('populates parameterTypes for classify (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const classify = methods.find((n) => n.name === 'classify');
if (classify?.properties.parameterTypes !== undefined) {
const params = classify.properties.parameterTypes;
expect(params).toContain('str');
}
});
it('resolves dog.speak() CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const speakCall = calls.find((c) => c.target === 'speak' && c.sourceFilePath === 'app.py');
expect(speakCall).toBeDefined();
});
it('resolves Dog.classify("dog") static CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const classifyCall = calls.find(
(c) => c.target === 'classify' && c.sourceFilePath === 'app.py',
);
expect(classifyCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase 17: Overload dispatch (similarly-named methods/functions)
// service.py: Formatter.format, Formatter.format_with_prefix,
// format_text, format_text_with_width
// app.py: calls all four
// ---------------------------------------------------------------------------
describe('Python overload dispatch', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-overload-dispatch'), () => {});
}, 60000);
it('detects Formatter class', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Formatter');
});
it('classifies overload methods separately from free functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('format_text');
expect(fns).toContain('format_text_with_width');
expect(fns).toContain('run');
expect(getNodesByLabel(result, 'Method')).toEqual(
expect.arrayContaining(['format', 'format_with_prefix']),
);
});
it('emits HAS_METHOD for Formatter.format and Formatter.format_with_prefix', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const fmtFormat = hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format');
const fmtPrefix = hasMethod.find(
(e) => e.source === 'Formatter' && e.target === 'format_with_prefix',
);
expect(fmtFormat).toBeDefined();
expect(fmtPrefix).toBeDefined();
});
it('resolves f.format("hello") to Formatter.format', () => {
const calls = getRelationships(result, 'CALLS');
const formatCall = calls.find((c) => c.target === 'format' && c.sourceFilePath === 'app.py');
expect(formatCall).toBeDefined();
});
it('resolves f.format_with_prefix("hello",">>") to Formatter.format_with_prefix', () => {
const calls = getRelationships(result, 'CALLS');
const prefixCall = calls.find(
(c) => c.target === 'format_with_prefix' && c.sourceFilePath === 'app.py',
);
expect(prefixCall).toBeDefined();
});
it('resolves format_text() top-level call', () => {
const calls = getRelationships(result, 'CALLS');
const textCall = calls.find((c) => c.target === 'format_text' && c.sourceFilePath === 'app.py');
expect(textCall).toBeDefined();
});
it('resolves format_text_with_width() top-level call', () => {
const calls = getRelationships(result, 'CALLS');
const widthCall = calls.find(
(c) => c.target === 'format_text_with_width' && c.sourceFilePath === 'app.py',
);
expect(widthCall).toBeDefined();
});
it('populates parameterTypes for format_with_prefix (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const fwp = methods.find((n) => n.name === 'format_with_prefix');
if (fwp?.properties.parameterTypes !== undefined) {
const params = fwp.properties.parameterTypes;
expect(params).toContain('str');
}
});
it('populates parameterTypes for format_text_with_width (conditional)', () => {
const fns = getNodesByLabelFull(result, 'Function');
const ftw = fns.find((n) => n.name === 'format_text_with_width');
if (ftw?.properties.parameterTypes !== undefined) {
const params = ftw.properties.parameterTypes;
expect(params).toContain('str');
expect(params).toContain('int');
}
});
});
// ---------------------------------------------------------------------------
// Phase 18: Abstract dispatch (ABC base + concrete impl + receiver resolution)
// base.py: Repository(ABC) with @abstractmethod find, save
// impl.py: SqlRepository(Repository) implements find, save
// app.py: repo = SqlRepository(); repo.find(42); repo.save(user)
// ---------------------------------------------------------------------------
describe('Python abstract dispatch', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-abstract-dispatch'), () => {});
}, 60000);
it('detects Repository and SqlRepository classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Repository');
expect(classes).toContain('SqlRepository');
});
it('emits EXTENDS edge SqlRepository -> Repository', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'SqlRepository' && e.target === 'Repository');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD edges for Repository.find and Repository.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const repoFind = hasMethod.find((e) => e.source === 'Repository' && e.target === 'find');
const repoSave = hasMethod.find((e) => e.source === 'Repository' && e.target === 'save');
expect(repoFind).toBeDefined();
expect(repoSave).toBeDefined();
});
it('emits HAS_METHOD edges for SqlRepository.find and SqlRepository.save', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const sqlFind = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'find');
const sqlSave = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'save');
expect(sqlFind).toBeDefined();
expect(sqlSave).toBeDefined();
});
it('marks base Repository.find as isAbstract (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const baseFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'base.py');
if (baseFind?.properties.isAbstract !== undefined) {
expect(baseFind.properties.isAbstract).toBe(true);
}
});
it('marks base Repository.save as isAbstract (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const baseSave = methods.find((n) => n.name === 'save' && n.properties.filePath === 'base.py');
if (baseSave?.properties.isAbstract !== undefined) {
expect(baseSave.properties.isAbstract).toBe(true);
}
});
it('marks concrete SqlRepository.find as NOT isAbstract (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const sqlFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'impl.py');
if (sqlFind?.properties.isAbstract !== undefined) {
expect(sqlFind.properties.isAbstract).toBe(false);
}
});
it('resolves repo.find(42) CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const findCall = calls.find((c) => c.target === 'find' && c.sourceFilePath === 'app.py');
expect(findCall).toBeDefined();
});
it('resolves repo.save(user) CALLS edge', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.sourceFilePath === 'app.py');
expect(saveCall).toBeDefined();
});
it('populates parameterTypes for Repository.find (conditional)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const baseFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'base.py');
if (baseFind?.properties.parameterTypes !== undefined) {
const params = baseFind.properties.parameterTypes;
expect(params).toContain('int');
}
});
it('does not emit METHOD_IMPLEMENTS for abstract-class inheritance (only interface/trait parents)', () => {
// Python ABC is modelled as a Class with EXTENDS (not Interface with IMPLEMENTS),
// so the MRO processor does not emit METHOD_IMPLEMENTS edges here.
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
const edges = mi.filter(
(e) => e.sourceFilePath.includes('impl.py') && e.targetFilePath.includes('base.py'),
);
expect(edges.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// SM-9: inherited method resolution — child.parent_method() via C3 parent walk
// ---------------------------------------------------------------------------
describe('Python Child extends Parent — inherited method resolution (SM-9)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-child-extends-parent'),
() => {},
);
}, 60000);
it('detects Parent and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS edge: Child → Parent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
});
it('resolves c.parent_method() to Parent.parent_method via C3 MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const parentMethodCall = calls.find(
(c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.py'),
);
expect(parentMethodCall).toBeDefined();
expect(parentMethodCall!.source).toBe('run');
});
});
describe('Python Grandchild→Child→Parent — 3-level C3 MRO walk (SM-11)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-multi-level-mro'), () => {});
}, 60000);
it('detects Grandparent, Parent, and Child classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Grandparent');
expect(classes).toContain('Parent');
expect(classes).toContain('Child');
});
it('emits EXTENDS chain: Child → Parent, Parent → Grandparent', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Child → Parent');
expect(edgeSet(extends_)).toContain('Parent → Grandparent');
});
it('resolves c.gp_method() to Grandparent.gp_method via 3-level C3 walk', () => {
const calls = getRelationships(result, 'CALLS');
const gpCall = calls.find(
(c) => c.target === 'gp_method' && c.targetFilePath.includes('grandparent.py'),
);
expect(gpCall).toBeDefined();
expect(gpCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// Same-file method-name collision across classes
// PR #980 review feedback — without a qualified-name key in the node lookup,
// User.save and Document.save share the bucket `models.py::save`, so every
// d.save() CALLS edge silently resolves to the first save() seen.
// ---------------------------------------------------------------------------
describe('Python same-file method-name collision across classes', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-same-file-method-collision'),
() => {},
);
}, 60000);
it('u.save() resolves to User.save, not Document.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
const fromUseUser = saveCalls.find((c) => c.source === 'use_user');
expect(fromUseUser).toBeDefined();
// targetId encodes qualifier: Method:models.py:User.save#0
expect(fromUseUser!.rel.targetId).toContain('User.save');
expect(fromUseUser!.rel.targetId).not.toContain('Document.save');
});
it('d.save() resolves to Document.save, not User.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
const fromUseDoc = saveCalls.find((c) => c.source === 'use_document');
expect(fromUseDoc).toBeDefined();
expect(fromUseDoc!.rel.targetId).toContain('Document.save');
expect(fromUseDoc!.rel.targetId).not.toContain('User.save');
});
it('exactly two CALLS edges to save() — one per class, no duplication to wrong target', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls).toHaveLength(2);
const targets = saveCalls.map((c) => c.rel.targetId).sort();
expect(targets[0]).toContain('Document.save');
expect(targets[1]).toContain('User.save');
});
});
// ---------------------------------------------------------------------------
// Module export vs class method collision within the same file
// Codex review on PR #980 flagged: buildWorkspaceResolutionIndex feeds
// defsByFileAndName and callablesBySimpleName from parsed.localDefs (every
// def in the file, flat). A class method declared before a top-level
// function with the same simple name wins the file-level export lookup,
// so `mod.save(x)` silently binds to `User.save`.
// ---------------------------------------------------------------------------
describe('Python module export vs method-name collision in same file', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-module-export-vs-method-collision'),
() => {},
);
}, 60000);
it('mod.save(x) resolves to the module-level Function, not User.save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
const fromModuleExport = saveCalls.find((c) => c.source === 'use_module_export');
expect(fromModuleExport).toBeDefined();
// Target must be the top-level Function save, not the User.save Method.
// Node id format: `Function:mod.py:save` vs `Method:mod.py:User.save#0`.
expect(fromModuleExport!.rel.targetId).toContain('Function:');
expect(fromModuleExport!.rel.targetId).toContain('mod.py:save');
expect(fromModuleExport!.rel.targetId).not.toContain('User.save');
});
it('u.save() resolves to User.save Method via typed receiver', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
const fromMethod = saveCalls.find((c) => c.source === 'use_method');
expect(fromMethod).toBeDefined();
expect(fromMethod!.rel.targetId).toContain('User.save');
});
it('exactly two CALLS edges to save — one to the free function, one to the method', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls).toHaveLength(2);
const targetIds = saveCalls.map((c) => c.rel.targetId).sort();
// One Function target, one Method target. Exact shape pins the fix.
const hasFunctionTarget = targetIds.some(
(id) => id.startsWith('Function:') && !id.includes('User.save'),
);
const hasMethodTarget = targetIds.some((id) => id.includes('User.save'));
expect(hasFunctionTarget).toBe(true);
expect(hasMethodTarget).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Class-body attribute leak into module export index
// Codex round-2 review on PR #980: defsByFileAndName indexes ALL defs
// owned by every child scope of the module, including class-body defs
// (e.g. `User.MAX_USERS`). `mod.MAX_USERS` / `from mod import MAX_USERS`
// can silently bind to a class attribute that's not a module export.
// ---------------------------------------------------------------------------
describe('Python class-body attribute does NOT leak into module export index', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-class-attr-export-leak'),
() => {},
);
}, 60000);
it('mod.MAX_USERS does not resolve to User.MAX_USERS as a module export', () => {
// Any edge sourced from `use_class_attr` must NOT target a node
// that represents `User.MAX_USERS`. Under the bug, CALLS/USES/
// ACCESSES could silently bind to the class attribute.
const edges = [
...getRelationships(result, 'CALLS'),
...getRelationships(result, 'USES'),
...getRelationships(result, 'ACCESSES'),
];
const fromConsumer = edges.filter((e) => e.source === 'use_class_attr');
for (const edge of fromConsumer) {
expect(edge.rel.targetId).not.toContain('User.MAX_USERS');
}
});
it('mod.helper() still resolves to the top-level Function (happy-path guard)', () => {
// Regression guard: the narrowing fix must not drop legitimate
// top-level function exports. Without this, the fix would over-
// narrow and break normal `mod.helper()` calls.
const calls = getRelationships(result, 'CALLS');
const helperCall = calls.find((c) => c.source === 'use_helper' && c.target === 'helper');
expect(helperCall).toBeDefined();
expect(helperCall!.rel.targetId).toContain('mod.py:helper');
});
});
// ---------------------------------------------------------------------------
// Function-local import + cross-file return-type propagation
// Codex round-2 flagged this as potentially broken, but empirically the
// finalize-algorithm hoists the `from svc import get_user` binding to
// the app.py module scope (observed via indexes.bindings dump), so
// `propagateImportedReturnTypes`'s module-scope pass already handles
// it. These assertions pin that working behavior as a regression
// guard against any future change to binding-scope routing.
// ---------------------------------------------------------------------------
describe('Python function-local import feeds chained receiver-bound call', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-function-local-import-chain'),
() => {},
);
}, 60000);
it('emits CALLS edge do_work -> get_user (free call, baseline sanity)', () => {
const calls = getRelationships(result, 'CALLS');
const getUserCall = calls.find((c) => c.source === 'do_work' && c.target === 'get_user');
expect(getUserCall).toBeDefined();
expect(getUserCall!.rel.targetId).toContain('svc.py:get_user');
});
it('emits CALLS edge do_work -> User.save via function-local-scoped import return-type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.source === 'do_work' && c.target === 'save');
expect(saveCall).toBeDefined();
// Target must be the User.save Method in svc.py.
expect(saveCall!.rel.targetId).toContain('User.save');
});
});
// ---------------------------------------------------------------------------
// Function-local namespace import: `def f(): import svc as s; s.call()`
// Codex round-3 flagged this pattern as potentially broken because
// collectNamespaceTargets reads only module-scope imports. Empirically
// the edge IS emitted (finalize hoists ImportEdges onto the module
// scope), so these assertions pin the working behavior. If finalize
// routing ever changes to match pythonImportOwningScope's per-scope
// contract, this block will flip red and signal the need to make
// collectNamespaceTargets scope-chain-aware.
// ---------------------------------------------------------------------------
describe('Python function-local namespace import feeds receiver-bound call', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-function-local-namespace-import'),
() => {},
);
}, 60000);
it('emits CALLS edge outer -> svc.call via function-local `import svc as s`', () => {
const calls = getRelationships(result, 'CALLS');
const callEdge = calls.find((c) => c.source === 'outer' && c.target === 'call');
expect(callEdge).toBeDefined();
expect(callEdge!.rel.targetId).toContain('svc.py:call');
});
it('sanity: unrelated function without local import is still parsed as a Function node', () => {
const fns = result.graph.nodes.filter(
(n) => n.label === 'Function' && n.properties.name === 'sanity',
);
expect(fns).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Class-body namespace import: `class A: import mod; def use(): mod.helper()`
// Same theoretical concern as the function-local case above, same
// empirical outcome — finalize hoists the ImportEdge to the module
// scope so the namespace-receiver path finds it from inside A.use.
// These assertions pin that working behavior.
// ---------------------------------------------------------------------------
describe('Python class-body namespace import feeds method receiver-bound call', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-class-body-namespace-import'),
() => {},
);
}, 60000);
it('emits CALLS edge A.use -> mod.helper via class-body `import mod`', () => {
const calls = getRelationships(result, 'CALLS');
const callEdge = calls.find((c) => c.source === 'use' && c.target === 'helper');
expect(callEdge).toBeDefined();
expect(callEdge!.rel.targetId).toContain('mod.py:helper');
});
});
// ---------------------------------------------------------------------------
// Issue #1066 sibling regression for Python: force worker-mode extraction so
// scope-resolution reparses on cache miss, then assert large ASCII and
// UTF-8-heavy source files still produce trailing call edges.
// ---------------------------------------------------------------------------
describe('Python large-file cache-miss parser buffer regression', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-large-cache-'));
writeFixtureRepo(repoDir, {
'models.py': `
class User:
def save(self):
return True
`,
'ascii_app.py': `from models import User
# ${'x'.repeat(120 * 1024)}
def create_ascii_user():
user = User()
user.save()
`,
'utf8_app.py': `from models import User
# ${'漢'.repeat(120_000)}
def create_utf8_user():
user = User()
user.save()
`,
});
result = await runPipelineFromRepo(repoDir, () => {}, {});
}, 120000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('extracts trailing functions after large ASCII and UTF-8 padding', () => {
expect(getNodesByLabel(result, 'Function')).toEqual(
expect.arrayContaining(['create_ascii_user', 'create_utf8_user']),
);
});
it('resolves calls from both padded files to User.save', () => {
const calls = getRelationships(result, 'CALLS');
for (const source of ['create_ascii_user', 'create_utf8_user']) {
const save = calls.find((c) => c.source === source && c.target === 'save');
expect(save).toBeDefined();
expect(save!.targetFilePath).toBe('models.py');
}
});
});
// ---------------------------------------------------------------------------
// Inline constructor receiver: User(db).save() (#2708)
// The receiver is a constructor expression rather than a binding, so the
// compound-receiver resolver has to recognise that a free call naming a class
// yields that class. Before #2708 the call was dropped entirely — the caller
// was missing from impact(direction: 'upstream') while the two-step spelling
// of the same call resolved.
// ---------------------------------------------------------------------------
describe('Python inline constructor receiver resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-inline-constructor-receiver'),
() => {},
);
}, 60000);
it('resolves User(db).save() to User.save', () => {
const calls = getRelationships(result, 'CALLS');
const inlineSave = calls.find((c) => c.source === 'process_inline' && c.target === 'save');
expect(inlineSave).toMatchObject({
source: 'process_inline',
target: 'save',
targetFilePath: 'models/user.py',
});
});
it('keeps the two-step spelling resolving to Repo.save', () => {
const calls = getRelationships(result, 'CALLS');
const twostepSave = calls.find((c) => c.source === 'process_twostep' && c.target === 'save');
expect(twostepSave).toMatchObject({
source: 'process_twostep',
target: 'save',
targetFilePath: 'models/repo.py',
});
});
it('binds each caller to exactly one save() — no cross-class fan-out', () => {
const saveCalls = getRelationships(result, 'CALLS')
.filter((c) => c.target === 'save')
.map((c) => `${c.source}->${c.targetFilePath}`)
.sort();
expect(saveCalls).toEqual([
'process_inline->models/user.py',
'process_twostep->models/repo.py',
]);
});
});
// ---------------------------------------------------------------------------
// #2826 — unaliased multi-segment namespace import.
//
// `import pkg.db` binds only `pkg`, but the receiver text at the call site is
// the whole dotted path `pkg.db`. Every sibling spelling binds a single-segment
// name and so already resolved; this one fell between the namespace-receiver
// case (keyed on the local binding) and the qualified-receiver hook (C++ only).
//
// The three sibling rows are controls, not decoration: a run where they also
// broke would say nothing about the row under test.
// ---------------------------------------------------------------------------
describe('Python unaliased multi-segment namespace import (#2826)', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-dotted-ns-'));
writeFixtureRepo(repoDir, {
'pkg/__init__.py': '',
'pkg/db.py': `class Model:
pass
def session_scope():
return "db"
`,
// Same member name in a sibling module: makes a cross-resolution visible
// instead of letting the right answer and a lucky answer look identical.
'pkg/cache.py': `def session_scope():
return "cache"
`,
'pkg/sub/__init__.py': '',
'pkg/sub/deep.py': `def deep_fn():
return "deep"
`,
'caller_dotted.py': `import pkg.db
def uses_dotted():
return pkg.db.session_scope()
`,
'caller_from.py': `from pkg.db import session_scope
def uses_from():
return session_scope()
`,
'caller_alias.py': `import pkg.db as pdb
def uses_alias():
return pdb.session_scope()
`,
'caller_frommod.py': `from pkg import db
def uses_from_module_attr():
return db.session_scope()
`,
'caller_two_pkgs.py': `import pkg.db
import pkg.cache
def uses_db():
return pkg.db.session_scope()
def uses_cache():
return pkg.cache.session_scope()
`,
'caller_deep.py': `import pkg.sub.deep
def uses_deep():
return pkg.sub.deep.deep_fn()
`,
'caller_construct.py': `import pkg.db
def builds():
return pkg.db.Model()
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
const sessionScopeCallers = (targetFile: string): string[] =>
getRelationships(result, 'CALLS')
.filter((c) => c.target === 'session_scope' && c.targetFilePath === targetFile)
.map((c) => c.source)
.sort();
it('resolves the unaliased dotted receiver to the imported module', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'uses_dotted' && c.target === 'session_scope',
);
expect(edge).toMatchObject({
source: 'uses_dotted',
target: 'session_scope',
targetFilePath: 'pkg/db.py',
sourceFilePath: 'caller_dotted.py',
});
});
it('keeps the three sibling spellings resolving (control)', () => {
expect(sessionScopeCallers('pkg/db.py')).toEqual(
expect.arrayContaining(['uses_alias', 'uses_from', 'uses_from_module_attr']),
);
});
it('does not cross-resolve two same-package imports in one file', () => {
// Both modules export `session_scope`, so a receiver-blind fallback would
// be invisible in a presence-only assertion. Pin the exact pairing.
const pairs = getRelationships(result, 'CALLS')
.filter((c) => c.sourceFilePath === 'caller_two_pkgs.py' && c.target === 'session_scope')
.map((c) => `${c.source}->${c.targetFilePath}`)
.sort();
expect(pairs).toEqual(['uses_cache->pkg/cache.py', 'uses_db->pkg/db.py']);
});
it('resolves a three-segment dotted receiver', () => {
const edge = getRelationships(result, 'CALLS').find(
(c) => c.source === 'uses_deep' && c.target === 'deep_fn',
);
expect(edge).toMatchObject({ target: 'deep_fn', targetFilePath: 'pkg/sub/deep.py' });
});
it('resolves construction through a dotted namespace receiver', () => {
// Pin the callee NAME, not just the file: pkg/db.py also exports
// `session_scope`, so a file-only assertion would stay green if the
// construction resolved to the wrong member of the right module.
const edges = getRelationships(result, 'CALLS')
.filter((c) => c.sourceFilePath === 'caller_construct.py')
.map((c) => `${c.source}->${c.target}@${c.targetFilePath}`)
.sort();
expect(edges).toEqual(['builds->Model@pkg/db.py']);
});
});
// ---------------------------------------------------------------------------
// #2826 follow-up — a namespace receiver shadowed by a local declaration.
//
// Case 1 (namespace receiver) in `receiver-bound-calls.ts` consulted the
// per-file namespace map with no lexical guard at all, so a parameter or local
// named like the imported package still resolved through the import. That is a
// WRONG edge, not a missing one, and it predates the dotted-path key — the
// single-segment rows below fail the same way without the guard.
// ---------------------------------------------------------------------------
describe('Python namespace receiver shadowed by a local binding (#2826)', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-ns-shadow-'));
writeFixtureRepo(repoDir, {
'pkg/__init__.py': '',
'pkg/db.py': `def session_scope():
return "db"
`,
'single.py': `def session_scope():
return "single"
`,
'caller_dotted.py': `import pkg.db
def clean_dotted():
return pkg.db.session_scope()
def param_shadow_dotted(pkg):
return pkg.db.session_scope()
def local_shadow_dotted():
pkg = object()
return pkg.db.session_scope()
`,
'caller_single.py': `import single
def clean_single():
return single.session_scope()
def param_shadow_single(single):
return single.session_scope()
def local_shadow_single():
single = object()
return single.session_scope()
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('emits an edge only from the unshadowed callers', () => {
// Exact edge set: an assertion on absence alone would also pass if the
// guard over-suppressed and killed the clean rows too.
const edges = getRelationships(result, 'CALLS')
.filter((c) => c.target === 'session_scope')
.map((c) => `${c.source}->${c.targetFilePath}`)
.sort();
expect(edges).toEqual(['clean_dotted->pkg/db.py', 'clean_single->single.py']);
});
});
// ---------------------------------------------------------------------------
// #2826 follow-up — `import a.b.c` binds THREE receiver spellings, not one.
//
// Python makes `a`, `a.b` and `a.b.c` all callable off a single import, and
// each names a DIFFERENT file. The namespace map originally keyed only the
// bound name `a`, pointed at the LEAF module — wrong in both directions:
// `a.helper()` resolved into the leaf whenever it happened to export `helper`
// (a wrong edge, preferring a decoy over the real definition), and `a.b.mid()`
// resolved to nothing.
// ---------------------------------------------------------------------------
describe('Python dotted import binds every package prefix (#2826)', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-prefix-'));
writeFixtureRepo(repoDir, {
// `helper` exists in BOTH the package and the leaf. Without the fix the
// root key points at the leaf and the decoy wins, so this pair is what
// makes the wrong edge visible rather than merely plausible.
'a/__init__.py': `def helper():
return "package"
`,
'a/b/__init__.py': `def mid_fn():
return "mid"
`,
'a/b/c.py': `def helper():
return "leaf-decoy"
def leaf_fn():
return "leaf"
`,
'caller.py': `import a.b.c
def uses_leaf():
return a.b.c.leaf_fn()
def uses_mid():
return a.b.mid_fn()
def uses_root():
return a.helper()
`,
});
result = await runPipelineFromRepo(repoDir, () => {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('resolves each prefix to its own module', () => {
const edges = getRelationships(result, 'CALLS')
.filter((c) => c.sourceFilePath === 'caller.py')
.map((c) => `${c.source}->${c.target}@${c.targetFilePath}`)
.sort();
expect(edges).toEqual([
'uses_leaf->leaf_fn@a/b/c.py',
'uses_mid->mid_fn@a/b/__init__.py',
'uses_root->helper@a/__init__.py',
]);
});
});