/** * 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', ]); }); });