GitNexus/gitnexus/test/integration/resolvers/python.test.ts
Gergő Magyar 95f87fc12a
perf(ingestion): Linux-kernel-scale analysis — worker-pool parse + finalize O(n²) + scope-resolution memory wall (#1983) (#2038)
* fix(ingestion): reduce parse-phase memory for huge repos (#1983)

Stop retaining full parse-cache chunks in RAM alongside the merged graph,
slim on-disk shards, defer worker ParsedFile emission for scope-resolver
languages, and add GITNEXUS_DEBUG_HEAP probes for OOM diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ingestion): address #2038 tri-review findings (parse-phase memory)

Resolves the confirmed review findings on PR #2038:

- P1: thread exportedTypeMap through the sequential parse path
  (processParsingSequential) so a no-worker run over a partially-warm
  cache no longer silently drops the sequential-miss files' exported
  types. Cache hits made exportedTypeMap.size > 0, suppressing the
  end-of-loop buildExportedTypeMapFromGraph rebuild, but the sequential
  path never populated the map. Regression test added (fails on the
  pre-fix tree, passes after) plus a fully-sequential differential oracle.
- P2: saveParseCache builds its on-disk index from hashes actually
  written/copied (writtenKeys), never a usedKeys hash whose shard write
  or copy was skipped — no more phantom index entries.
- P2: add a unit test asserting SCOPE_RESOLUTION_LANGUAGES stays in sync
  with SCOPE_RESOLVERS (asymmetric drift would lose a language's ParsedFile).
- Backfill cache coverage: loadParseCacheChunk missing/corrupt -> undefined,
  pruneCache onDiskKeys branch, slim preserves nodes, saveParseCache
  copy-evicted-shard round-trip.
- Cleanups: single-source heap-probe gating via isDebugHeapEnabled();
  hoist the per-chunk mkdir in persistParseCacheChunk behind a
  process-scoped Set; gate COBOL's unused worker-side ParsedFile
  extraction (graph nodes still come from cobolPhase) while keeping
  fileCount/progress unconditional.

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

* refactor(ingestion): remove dead worker-side ParsedFile extraction

After #2038 gated worker `ParsedFile` emission behind `!isScopeResolutionLanguage(language)`, and with all 16 SupportedLanguages registered in SCOPE_RESOLVERS, that gate was structurally always true — the worker already produced no ParsedFiles and scope-resolution re-extracts each file from source on the main thread (run.ts). Remove the now-dead machinery:

- Drop both worker `extractParsedFile` call-sites (tree-sitter processFileGroup + the standalone-provider branch) and the `result.parsedFiles.push`. The standalone branch keeps fileCount/onFileProcessed per file. `result.parsedFiles` stays declared but empty (field removal deferred).
- Remove the now-orphaned `scopeSourceKind` var + `ScopeCaptureSourceKind`/`extractParsedFile`/`isScopeResolutionLanguage` imports.
- Delete the consumerless `migrated-languages.ts` (isScopeResolutionLanguage + SCOPE_RESOLUTION_LANGUAGES) and its drift-guard test — parse-worker was their only importer. Also improves AGENTS.md "shared ingestion code must not name languages" compliance.

`extractParsedFile` and the scope-extractor-bridge stay (scope-resolution/run.ts + Vue resolver use them). Behavior-preserving: worker-sequential-parity passes before and after; tsc/eslint clean; no baseline/golden drift.

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

* refactor(ingestion): worker-pool-only parsing; remove sequential parser (#1983)

Completes the #1983 huge-repo parse-OOM effort by making the worker pool
GitNexus's sole parse path.

Parallel serialization (the perf core): workers serialize their ParsedFiles to
a disk store in parallel and stream them back to scope-resolution, so the main
thread no longer re-parses every file (the tree-sitter native-memory leak that
caused the OOM). Adds chunk merge-pipelining + work-proportional chunk sizing so
the pool stays saturated.

Remove the sequential parser: `--workers 0`, `GITNEXUS_WORKER_POOL_SIZE=0`, and
`skipWorkers` now hard-error (no silent degrade — #1741); the small-repo
threshold no longer selects an in-process path; pool creation stays lazy /
cache-miss-gated so warm all-hit runs never spawn workers.

Worker-path parity fixes — removing sequential surfaced two pre-existing gaps
that tiny-fixture tests had masked by running below the worker threshold, both
fixed by carrying per-file metadata as DATA across the worker boundary (never
re-parsing on the main thread, preserving the OOM fix):
  - C++: templateConstraints wired into worker node identity (SFINAE overload
    disambiguation) + ADL / inline-namespace capture side-channel serialized
    onto the ParsedFile.
  - Kotlin: companion-scope side-channel serialized the same way (companion /
    static dispatch).

Validation: tsc + build clean; full suite green (10,190 pass — the only
deterministic failures were the now-fixed C++/Kotlin worker-path gaps; the 2
remaining full-run failures are pre-existing load flakiness, green in
isolation); cpp-pipeline benchmark stays linear on a 1-worker pool.

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

* fix(ingestion): wire C static-linkage side-channel + ADL O(1) collect + tri-review cleanups (#1983)

Follow-up to the worker-pool-only refactor, from a tri-review of the parse path.

- C static-linkage side-channel (P1): cProvider had no collect/applyCaptureSideChannel,
  so on the now-sole worker path C `static` file-local marks were lost across the worker
  boundary -> false cross-file CALLS edges + over-broad #include wildcard visibility on
  every C analysis (the Linux kernel is C). Mirror the C++/Kotlin wiring: serialize
  `staticNames` per file onto ParsedFile.captureSideChannel and restore it on the main
  thread (no re-parse). + a worker-path regression test (the existing c-static-isolation
  fixture passed vacuously — its collision resolves via #include before the global
  free-call fallback ever consults static-linkage).

- captureSideChannel `kind` discriminant: add `kind:'cpp'`/`kind:'c'` tags + guards
  (Kotlin already had one) now that C/C++/Kotlin share the single generic field.

- Perf: collectCppAdlSideChannel scanned the whole argInfoBySite/noAdlSites maps per file
  (O(F^2) per sub-batch, ~100M parseSiteKey calls at kernel scale). Add per-filePath
  lockstep indexes -> O(1) collect; serialized snapshot byte-identical.

- Cleanups: inline the one-line processParsingWithWorkers wrapper into processParsing;
  drop the always-empty WorkerExtractedData.calls/assignments/constructorBindings fields;
  remove the voided astCache param from processParsing; refresh stale "sequential
  fallback" JSDoc.

Validation: tsc + build clean; cpp 297/297, c 8/8 (incl. the new worker-path
static-linkage guard), typescript + parsedfile-store green; cpp ADL benchmark stays linear.

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

* perf(scope-resolution): index C/C++ #include resolution in finalize (O(n²)→O(n))

Kernel-scale C/C++ analysis ground in finalizeScopeModel because three
per-#include operations each did a full O(F) scan with no index — the
finalize O(n²) that surfaced once the #1983 parse-phase OOM was fixed:

- expand{C,Cpp}WildcardNames: parsedFiles.find() per wildcard edge → O(R·F)
- resolveImportTarget: new Set(allFilePaths) rebuilt per #include
- resolveCImportTarget: suffix-match scanned all workspace paths

Each is replaced with a WeakMap-per-pass index keyed on the stable
parsedFiles/allFilePaths references that scope-resolution run.ts passes
once per pass:

- Map<ScopeId,ParsedFile> for wildcard expansion (c/static-linkage.ts +
  cpp/file-local-linkage.ts)
- memoized augmented header set (c/scope-resolver.ts + cpp/scope-resolver.ts)
- basename-bucketed suffix index in resolveCImportTarget (c/import-target.ts),
  shared by C and C++ since resolveCppImportTarget delegates to it

Collapses the C/C++ finalize from O(R·F) to O(R+F). Pure-perf, byte-identical
edge output: 962 targeted tests green (490 C + 472 C/C++ scope-resolution);
the basename index preserves the exact endsWith('/'+target) match and the
fewest-path-components-then-lexicographic tie-break.

The kernel's ~25-30k .h headers are classified C++, so both providers must
be fixed. Proven on the Linux kernel: the C finalize completed
(sr-post-finalize lang=c → sr-end lang=c), which the pre-fix run never
reached in 16+ min of grinding.

Build-independent follow-ups (separate from this finalize fix), documented
for later: emitFreeCallFallback same-name buckets (emit phase),
buildGraphNodeLookup + precount global setup, the ParsedFile store-load,
the dart/go/ruby expand-wildcards .find siblings, and the ~26GB
scope-resolution memory floor (full kernel completion needs >~40GB RAM).

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

* test(bench): regenerate C scope-capture baseline for the #1983 c-static-linkage-worker fixture

bench/scope-capture/measure.mjs fingerprints emitCScopeCaptures over the
lang-resolution/c-* fixture corpus. The #1983 PR added the
c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — the
worker-path static-linkage side-channel test) but did not regenerate the C
baseline, so `--check` has been red on this branch (main, lacking the
fixture, still matches 0de009b).

Pure fixture-corpus drift — no c/captures.ts or query change branch-vs-main,
existing fixtures' captures byte-identical (c-captures.test.ts 45/45),
scaling stays linear (~0.97). Regenerated: 0de009b -> 39f3a83. Bench now
PASS (14 languages). Unrelated to the finalize O(n²) fix.

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

* perf(scope-resolution): lower kernel-scale resident memory floor + setup cost

Reduce the scope-resolution resident-memory floor and setup throughput on
huge repos (Linux kernel), the wall that remains after #1983 (parse OOM) and
the finalize O(n^2) fix (b71c77b8). Five units; all preserve byte-identical
edge output (C fixture 177n/255e + c/cpp/cross-file/php/static-linkage suites
green, 619 tests).

U1 (src/cli/analyze.ts): RAM-aware auto heap-cap. Replace the hardcoded
16384MB cap with computeHeapCapMb = max(16384, floor(0.75*effectiveRAM)),
where effectiveRAM = min(os.totalmem(), process.constrainedMemory()) with the
unconstrained-sentinel guard. Add --max-semi-space-size=128 on the respawn.
A user-supplied NODE_OPTIONS heap still wins (no re-exec). Verified: 23973MB
on a 31964MB box, 16384 floor on small machines, cgroup-aware, sentinel safe.

U2 (src/storage/parsedfile-store.ts, .../pipeline/phase.ts): export forceGc()
and call it at the per-language eviction boundary, so a finished language's
ParsedFiles are reclaimed before the next language's store-load instead of
collected lazily under the next pass's allocation pressure (which at cap>=RAM
degrades into swap-thrash). Measured on a real drivers/net/ethernet run:
C 2113->894MB and C++ 1754->1057MB reclaimed at the boundary (no fragmentation
defeat). Answers the plan's Open Question 1.

U3 (src/storage/parsedfile-store.ts): intern def objects by nodeId in the load
reviver so a SymbolDefinition's three serialized copies (localDefs /
scope.ownedDefs / scope.bindings[].def) collapse to one shared object on load.
Per-shard def pool (a def's copies are shard-local). Measured ~42% off the
def-object retained heap (3->1; 1.8M->600k distinct objects on 600k defs).

U4 (.../passes/free-call-fallback.ts): memoize pickUniqueGlobalCallable's
post-filter candidate list per (name, callerFilePath), only when no per-caller
visibility filter applies (the list is then a pure function of name+file), so
repeated free calls of one name from a file reuse the same-name-bucket scan
instead of re-walking a potentially huge bucket per site. The cached array is
read-only-consumed by the .filter()-based arity/overload narrowers. Exported
pickUniqueGlobalCallable + buildGlobalCallableIndex and added an equivalence
test (memoized == un-memoized reference for every (name, file, arity),
including warm-cache repeats and cross-file file-local exclusion).

U5 (.../pipeline/phase.ts): replace the O(L*F) per-language precount + repeated
scannedFiles.filter() with a single O(F) partition-by-language pass; bracket
buildGraphNodeLookup with scope-setup-nodeLookup heap probes so the long setup
is no longer silent.

Plan: docs/plans/2026-06-06-001-perf-kernel-scope-resolution-memory-plan.md
(U6 out-of-core global index deferred). Note: the kernel's full C++ pass floor
(~20k headers + the 8.8GB graph) likely still exceeds 24GB by itself, which is
why U6 remains the only unit that clears the wall.

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

* fix(test): match OOM-guidance e2e assertions to the U1 reworded hint

The analyze-heap-oom-e2e real-child-OOM test still asserted the pre-U1
wording ('...out of memory.' + a hardcoded 24576 cap). U1 reworded the hint
to mention the auto heap-cap and use a <MB> placeholder, so the three
toContain substrings no longer matched (the assertion at line 62 failed on
all platforms). Update them to the current message. The unit twin
(analyze-heap-respawn) was already updated in 85bfc216; this integration
test was missed by the targeted local run.

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

* perf(lbug): U6a — deterministic id-sorted graph output behind GITNEXUS_SORT_GRAPH_OUTPUT

First increment of U6 (out-of-core scope-resolution). Adds an optional
deterministic ordering of node + relationship CSV rows by their unique graph
id, behind GITNEXUS_SORT_GRAPH_OUTPUT (default OFF = today's graph-insertion
order, byte-identical — the iterator is returned untouched). With the flag ON
the CSV becomes a pure function of the node/edge SET rather than of emit order.

This is the structural enabler for the windowed/out-of-core resolve (U6b-U6d):
csv-generator.ts:518 currently iterates graph.iterRelationships() in insertion
order with NO terminal sort, so any deviation from parsedFiles-order emit would
change bytes. With U6a on, a windowed emit need only reproduce the same edge
SET, not the global insertion order — removing the single largest byte-identical
hazard from every later windowing step.

Verified: default off keeps the existing csv-pipeline suite byte-identical; on,
node rows are id-sorted and output is independent of graph insertion order
(set-build) with the same node/edge set.

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

* perf(storage): U6d foundation — disk-backed scope store + lazy ScopeTree

Adds scope-index-store.ts: persistScopeShards (per-file scope shards via the
proven mapReplacer + def-interning reviver) + DiskBackedScopeTree, a lazy
ScopeTree that serves getScope from a bounded LRU of decoded shards plus a small
resident skeleton (scopeId -> {shard, childIds, parent}). Exports
makeInterningReviver from parsedfile-store for reuse.

This is the contained, highest-risk mechanism of U6d (out-of-core scope
resolution): the emit passes reach the heavy per-Scope binding payload
(~17-20GB on the kernel) ONLY through scopeTree.getScope (a point lookup) and
getChildren — they never read parsed.scopes directly — so moving that payload to
disk behind getScope is transparent. Every consumer reads a Scope BY VALUE, so a
value-faithful disk round-trip is byte-identical to resolution.

Proven in isolation: DiskBackedScopeTree is value-identical to buildScopeTree
for getScope/getChildren/getParent/getAncestors/has/size across multiple files
and after LRU eviction, and preserves the def-identity collapse (ownedDefs[i]
=== binding.def). Nothing wires it yet (the resolution-pipeline integration is
the next increment) — zero production impact; default off.

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

* perf(scope-resolution): U6d integration — seal scopeTree to disk before emit (GITNEXUS_DISK_SCOPE_INDEX)

Wires the U6d out-of-core scope index into the live pipeline behind
GITNEXUS_DISK_SCOPE_INDEX (default OFF = byte-identical). When on:

- finalize-orchestrator builds a TransitionalScopeTree (validated, fully
  resident) instead of buildScopeTree, so finalize/propagate/resolve are
  unchanged.
- After resolve, before emit, run.ts seals it: persists the scopes to a
  file-sharded scope-index-store, swaps the model's scopeTree to disk-backed
  serving from the inside (the frozen bundle can't be reassigned, but the
  wrapper nulls its own resident backing), and drops the heavy Scope.bindings
  payload from all THREE holders — the model's tree (seal), the caller's
  preExtractedParsedFiles, and run.ts's own parsedFiles (scope-stripped copies
  for emit). Emit reads scopes only via scopeTree.getScope (a point lookup,
  now disk-backed + LRU) — verified it never reads parsed.scopes.

Purpose: lower the per-language resident PEAK (kernel C pass ~20→~12 GB by
moving the ~8-9 GB scope payload to disk) so the analysis fits on smaller-RAM
machines. At >=24 GB the full kernel already fits with U1-U5 (U2's 8.7 GB
inter-language forceGc reclaim keeps each pass under cap) — empirically
confirmed — so this is the sub-24 GB lever, not needed at 24 GB.

Byte-identical evidence: DiskBackedScopeTree/TransitionalScopeTree return
value-identical scopes vs buildScopeTree (getScope/getChildren/getParent/
getAncestors, across files + after LRU eviction + post-seal); emit reads only
getScope + referenceSites; flag-off (394 tests) and flag-on-resident (91 tests)
resolver suites stay green; an end-to-end A/B on a 212-file C+cpp+rust subset
produced identical 17,444 nodes / 31,343 edges with the seal firing per language
(c: 410→141 MB reclaimed). Kernel-scale peak-drop measurement pending the
in-flight verdict run freeing memory.

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

* perf(scope-resolution): U6d — id-back workspaceIndex so the disk seal can reclaim scopes

The kernel run revealed the contained scopeTree seal didn't lower the heap:
WorkspaceResolutionIndex held Scope OBJECTS (classScopeByDefId / moduleScopeByFile),
built from every ParsedFile and live through emit, so the ~28k module + class
scopes stayed pinned past the seal (sr-seal-pre 17,583 -> sr-seal-post 17,771 MB,
no drop). It was the sole residual Scope-object holder (SemanticModel holds none).

Fix: classScopeByDefId / moduleScopeByFile become id-backed ScopeByKeyView
instances — a ReadonlyMap<K, Scope> facade over a K->ScopeId map + the scopeTree,
whose .get fetches via scopeTree.getScope(id). The index now pins only ids, so
once the tree seals to disk the scopes become collectible. Byte-identical: the
view returns the same Scope the resident tree holds (or a value-identical revived
one in disk mode), and iteration keeps the old insertion order. buildWorkspace
ResolutionIndex takes an optional scopeTree (live pipeline passes it); without it
(unit tests) the legacy direct Scope-object maps are returned unchanged.

Verified byte-identical: 733 tests across workspace-index / imported-return-types
/ c / cpp / cross-file / go / java. Kernel peak-drop re-measurement to follow.

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

* perf(scope-resolution): U6d — precompute exportedCallableByName (fix disk-getScope thrash)

The workspaceIndex id-backing freed the kernel scopes but exposed a throughput
collapse: findExportedDefByName's workspace fallback (walkers.ts:1019) scanned
EVERY module scope's bindings per unresolved free call, and under the U6d
disk-backed scopeTree each module-scope access faulted a shard in from disk —
lib ON went ~1min -> ~7.5min.

Fix: precompute the fallback result once into
WorkspaceResolutionIndex.exportedCallableByName (simpleName -> first module-local
callable def, first-file-wins — the exact semantics the scan returned), built
from the resident module-scope bindings at index-build time. findExportedDefByName
now does an O(1) lookup with zero disk reads.

Result: lib ON ~7.5min -> 21s (cache-warm), byte-identical 17,444/31,343; 758
tests green across workspace-index + c/cpp/cross-file/go/python.

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

* docs: rename cryptic U-unit codes to descriptive names in comments

The plan-unit shorthand (U3/U4/U6a/U6d/...) was meaningless in the code.
Renamed in comments + test descriptions (no behavior change, byte-identical):
  out-of-core scope index   (was U6)
  deterministic output      (was U6a)
  disk-backed scope seal    (was U6d)
  def-object interning      (was U3)
  free-call candidate cache (was U4)
Also renamed throughout the PR title/summary. Pushed commit messages keep
their original U-codes as historical record.

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

* fix(ingestion): durable ParsedFile shards for warm-cache coverage (#2038)

On a warm re-analyze where every chunk is a parse-cache HIT, no parse worker
runs, the run-scoped ParsedFile store is cleared at parse start, and the cached
ParseWorkerResult carries no ParsedFiles (the worker writes them to the store
and empties them from the message). Scope-resolution then found an empty store
and fell back to main-thread extractParsedFile — re-opening the #1983
tree-sitter native-leak OOM the disk store closes (abhigyanpatwari review on
parse-cache.ts).

Fix: workers ALSO write their ParsedFiles to a durable, content-addressed store
(parsedfile-cache/) keyed by chunk hash, mirroring the parse cache's lifecycle
(version-gated by PARSE_CACHE_VERSION, pruned in lockstep to the surviving
keys). On a warm hit the chunk's durable shards are byte-COPIED into the
run-scoped store (no re-parse, no re-serialize -> byte-identical), so
scope-resolution streams them exactly as on a cold run. A coherence gate
re-dispatches the worker whenever a cached chunk's durable shards are missing
(migration / pruned / version-stale) -- never the main-thread extract.

- worker-pool/parse-worker: thread chunkHash through dispatch->job->flush
  (incl. split/requeue) so the worker tags its durable shard by content
- parsedfile-store: durable persist / restore / index / prune API (sibling
  dir, never cleared per run); content-addressing makes stale reuse impossible
- parse-impl: load durable index, gate the cache hit on durable coverage,
  restore on hit, dispatch chunkHash on miss
- run-analyze: prune+save the durable store to the parse cache's surviving keys
- saveParseCache returns its written keys (the durable keepKeys)

Verified on linux/lib: warm preExtractedHits = full coverage (520/207/1, zero
main-thread re-parse), byte-identical cold==warm (17,456n/31,353e), warm 8.5x
faster. New two-run + mixed-mode + coherence-gate regression test.

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

* fix(ingestion): clear stale scope-index-store shards on each seal (#2038)

The disk-backed scope index writes sequential s<n>.json shards into a shared
<storagePath>/scope-index-store/ dir, with the index resetting per
persistScopeShards call. A seal that writes fewer shards than a previous one
(a later language with fewer files, or a re-run of a shrunken repo) left stale
tail shards on disk indefinitely -- never read by the disk-backed tree, but
multi-GB on kernel-scale repos.

Add clearScopeIndexStore() and clear at the start of persistScopeShards: the
previously sealed language has finished emit and been released before the next
seal runs, so its DiskBackedScopeTree never reads those shards again. Unit
tests: a stale prior-run shard is removed, a fewer-files re-seal leaves no tail
shards, and the helper is idempotent.

Addresses abhigyanpatwari review on run.ts (disk hygiene for the
GITNEXUS_DISK_SCOPE_INDEX path).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:46:34 +01:00

3022 lines
119 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* Python: relative imports + class inheritance + ambiguous module disambiguation
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
// ---------------------------------------------------------------------------
// 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();
});
});
// ---------------------------------------------------------------------------
// 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');
}
});
});