mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
* feat(csharp-scope): unit 1 — scope query + captures orchestrator First slice of the C# scope-resolution migration (issue #934, RFC #909 Ring 3). Closes `Unit 1` of docs/plans/2026-04-21-004-feat-csharp-scope-resolution-plan.md. Adds: - src/core/ingestion/languages/csharp/query.ts — tree-sitter scope query covering compilation_unit, namespace (block + file-scoped), class-like (class/interface/struct/record/enum), method-like (method/constructor/destructor/local_function/operator), property and field declarations, using directives, type bindings (parameter annotations, local variable annotations, constructor inference, invocation alias), and references (free call, member call including null-conditional, constructor call, member write). - src/core/ingestion/languages/csharp/captures.ts — pass-through orchestrator mirroring python/captures.ts. Import decomposition (Unit 2), receiver-type-binding synthesis (Unit 3), and arity metadata synthesis (Unit 5) stub out for future units. - src/core/ingestion/languages/csharp/cache-stats.ts — PROF instrumentation mirror of python/cache-stats.ts. Design notes: - Return-type / field-type / property-type captures deferred. tree-sitter-c-sharp does not expose these under a clean named field that pattern-matches. When Unit 7 parity gate surfaces a gap, add positional patterns or a post-hoc extractor lookup. - object_creation_expression with qualified_name type — the qualified name itself is the reference text; captured as a whole via a dedicated tag so interpretation in later units can split namespace + name. - Null-conditional calls use positional descendant patterns because tree-sitter-c-sharp's member_binding_expression and conditional_access_expression don't expose named fields. Coverage: - 23/23 new unit tests in test/unit/scope-resolution/csharp/csharp-captures.test.ts cover every capture tag. Confirmed against tree-sitter-c-sharp via the probe-script loop during development; grammar drift would surface as a capture-shape assertion failure. - tsc --noEmit clean. No changes to shared infrastructure. Resolver wiring + registration land in Unit 6. * fix(csharp-scope): capture null-conditional receiver + operator decls Adversarial review surfaced two Unit 1 bugs that would silently corrupt the graph once C# is flipped on the scope-resolution path: - `obj?.Save()` only emitted @reference.name, so receiver-bound resolution downgraded to the free-call fallback and could mis-link to an imported `Save`. Capture the conditional_access_expression receiver under @reference.receiver. - `operator_declaration` had @scope.function but no @declaration.method owner, so calls inside operator bodies were attributed to the enclosing class and the operator itself disappeared from method lookup. Capture the operator token as @declaration.name (downstream csharpMethodConfig normalizes to op_Addition etc.). - `conversion_operator_declaration` was missing from both scope and declaration sets. Added with the target type as the name anchor. Arity metadata for overload resolution remains deferred to Unit 5 and gated behind Unit 7's parity flip, as documented in captures.ts. * chore(scope-resolution): drop unused python/scopes.scm sibling The file was documentation-only — the authoritative scope query is the embedded `PYTHON_SCOPE_QUERY` constant in `python/query.ts`. Nothing loaded the `.scm` at runtime, so it drifted from the code. Remove it and update the four doc comments that pointed at it: - language-provider.ts: "scopes.scm query" → "scope query (embedded in each language's query.ts)". - languages/python.ts: capture-vocabulary pointer → query.ts. - python/query.ts header: drop the "edit both together" note. - python/receiver-binding.ts: "keeps the .scm declarative" → "keeps the embedded scope query declarative". - scope/walkers.ts: "Python's scopes.scm" → "Python's scope query". Historical plan docs under docs/plans/ still reference scopes.scm but are frozen artifacts, not living documentation. C# never had a .scm sibling, so no action needed there. * feat(csharp-scope): Unit 2 — import interpret + target resolver Adds the three files Unit 2 of the C# scope-resolution plan calls for: - `import-decomposer.ts` — inspects each `using_directive` node and synthesizes `@import.kind/source/name/alias` markers. Kinds: `namespace` — `using X;` / `using X.Y.Z;` `alias` — `using Alias = X.Y.Z;` (generics stripped) `static` — `using static X.Y;` `global using` maps to namespace (plan's deferred decision); the `global::` qualifier is stripped before emitting. - `interpret.ts` — reads the markers and builds `ParsedImport`. Static using maps to `kind: 'wildcard'` since it brings members into unqualified scope; Unit 4's merge-bindings tiers wildcards lowest. Also provides `interpretCsharpTypeBinding` with nullable/single-arg generic/qualifier stripping so receiver-typed resolution sees the concrete class name. - `import-target.ts` — suffix-match adapter returning a single primary file. Cross-file partial-class aggregation runs later at graph-bridge time (Unit 6). The csproj-based `resolveCSharpImportInternal` stays on the legacy path until Unit 7's parity gate surfaces a gap. - `captures.ts` routes `@import.statement` matches through the decomposer so the interpreter sees the markers it needs. Tests cover every using flavor + resolution edge cases. 38/38 scope- resolution C# unit tests pass; tsc clean. * feat(csharp-scope): Unit 3 — simple hooks (binding/import/receiver) Adds simple-hooks.ts mirroring Python's pattern: - `csharpBindingScopeFor` — delegates to innermost (block scope is already captured by @scope.block in the query). - `csharpImportOwningScope` — binds `using` inside a namespace to that namespace's scope so imports don't leak into sibling namespaces. File-level using delegates to module. Function-body using (not legal C# but possible from malformed input) attaches to the function. - `csharpReceiverBinding` — looks up `this` / `base` in the function scope's type bindings; returns null for statics, free functions, and non-Function scopes. `this` / `base` synthesis itself is deferred to a follow-up (matches Python's receiver-binding.ts pattern). 9 new tests pin delegation semantics. 47/47 C# scope-resolution unit tests pass; tsc clean. * feat(csharp-scope): Unit 4 — mergeBindings (using precedence) Three-tier shadowing, same shape as Python's LEGB merge: 0: local — class members, locals, parameters 1: using — namespace / named / reexport (equal tier; compiler requires explicit qualifier if two using collide) 2: wildcard — `using static X.Y;` static-member imports Within the surviving tier, de-dup by DefId (last-write-wins) so a re-declared `using` cleanly replaces its earlier binding. Explicit interface implementations bind under their qualified name in the extractor layer, so they don't collide with plain simple names here. 7 new tests pin precedence + dedup semantics. 54/54 C# scope-resolution unit tests pass. * feat(csharp-scope): Unit 5 — arity metadata synthesis + compatibility Adversarial review flagged overload narrowing as a blocker for the Unit 7 flip. This lands the declaration-side metadata; callsite-side arity synthesis is a separate gap we'll address if the parity gate surfaces overload misresolution. - `arity-metadata.ts` — reads `csharpMethodConfig.extractParameters` and produces `{ parameterCount, requiredParameterCount, parameterTypes }`. `params` variadic collapses parameterCount to undefined (matches Python's `*args` treatment) and appends a literal `'params'` marker to parameterTypes so the compatibility hook can detect it without re-reading the AST. Default-valued parameters contribute to optionalCount → requiredParameterCount = total − optional. - `arity.ts` — `csharpArityCompatibility(def, callsite)` returns compatible / incompatible / unknown. Mirrors Python's three-verdict shape so the central registry's arity filter works without adapter logic per-verdict. - `captures.ts` — on every @declaration.method / @declaration.constructor / @declaration.function match, synthesize @declaration.parameter-count, @declaration.required-parameter-count, and @declaration.parameter-types captures. Covers method_declaration, constructor_declaration, destructor_declaration, operator_declaration, conversion_operator_declaration, and local_function_statement. 12 new tests: 5 on captures-side synthesis (method + params + types + variadic + constructor + local function), 7 on the compatibility hook. 66/66 C# scope-resolution unit tests pass; tsc clean. * feat(csharp-scope): Unit 6 — wire csharpScopeResolver + register Creates the public barrel (index.ts) and ScopeResolver (scope-resolver.ts) and plumbs them into the provider + registry: - `languages/csharp/index.ts` — re-exports the hook entry points and documents the 8 known limitations of the registry-primary path (csproj-driven namespace resolution, multi-file namespace expansion, type-based overload resolution, nested generics, dynamic, preprocessor branches, cross-file global using, expression-bodied members). - `languages/csharp/scope-resolver.ts` — ScopeResolver shape mirroring Python's. `isSuperReceiver` matches the literal `base` keyword. `fieldFallbackOnMethodLookup: false` since C# is statically typed — the type-binding layer already produces precise owner types; `propagatesReturnTypesAcrossImports: true` since signatures are authoritative. - `languages/csharp.ts` — adds the 9 hook entry points to the provider (emitScopeCaptures, interpretImport, interpretTypeBinding, four simple hooks, mergeBindings, arityCompatibility, resolveImportTarget). - `scope-resolution/pipeline/registry.ts` — registers csharpScopeResolver alongside the Python entry. MIGRATED_LANGUAGES stays at {Python} — the resolver sits idle until Unit 7's parity gate confirms ≥99% fixture parity. 368/368 scope-resolution unit tests pass; tsc clean. * feat(csharp-scope): parity Unit 1 — this/base receiver-binding synthesis Closes 3 parity failures (51 → 48). Target bucket: Category C from the parity plan. Changes: - `languages/csharp/receiver-binding.ts` (new): walks up from a function node to the enclosing class/struct/record/interface, synthesizes `@type-binding.self` captures with boundName `'this'` (and `'base'` when the enclosing type is a class/record with an explicit base_list entry). Skips static methods and interface / struct `base` cases. Anchors to the method's `body` block so the scope-extractor's positionIndex places the binding inside the function scope (not the enclosing class scope). - `languages/csharp/captures.ts`: route `@scope.function` matches through the synth, emitting the receiver captures as separate matches. - `languages/csharp/interpret.ts`: map `@type-binding.self` to `source: 'self'` (parity with Python). - `languages/csharp/query.ts`: explicit patterns for `this.X()`, `base.X()`, and `this.X = ...` / `base.X = ...` assignment writes. `this` and `base` are anonymous tokens in tree-sitter-c-sharp so the existing `expression: (_)` pattern (named-only) didn't match. Tests: - 8 new unit tests for receiver-binding synthesis edge cases (class/struct/record/interface, static, nested, constructor, local function inside method). - Parity: 48 failed | 127 passed (175) under REGISTRY_PRIMARY_CSHARP=1; legacy path 175/175 green. * feat(csharp-scope): parity Unit 2a — foreach + pattern + field captures Closes 11 parity failures (48 → 37). Partial Unit 2 progress. Adds type-binding captures for every shape the parity suite exercises whose resolution path is in-file: - Typed foreach `foreach (User u in xs)` — @type-binding.annotation with bindingName `u` and type `User`. - Var foreach `foreach (var u in xs)` — @type-binding.alias so the generic-stripper unwraps `List<User>` / `Dictionary<K,V>.Values` to the element type at chain-follow time. Matches Python's for-loop alias pattern. - `is` pattern `if (obj is User u)` — @type-binding.annotation with scope narrowing simplified to function scope (matches Python's match-case treatment since we don't emit @scope.block). - `switch_section > declaration_pattern` (`case User u:`) — no case_pattern_switch_label wrapper in tree-sitter-c-sharp. - `recursive_pattern` (`is User { Age: 1 } u` / `case User { ... } u:`) — named binding via type+name fields on the pattern node. - Field declaration `private City _city;` — @type-binding.annotation attached to the class scope for `this._city.X` resolution. - Property declaration `public User Owner { get; set; }` — same. - Assignment rebind `alias = Factory()` / `alias = new User()` — @type-binding.alias / @type-binding.constructor so reassignment propagates type info to later receiver-typed resolution. Closed tests: foreach (3), var foreach Tier 1c (2), is-pattern (1), switch pattern (2), recursive_pattern (3). Remaining 37 include tests that need cross-file same-namespace visibility (field chains, assignment chain, cross-file return-type propagation) — deferred to Unit 5 where the IMPORTS/cross-file work lives. 74/74 scope-resolution unit tests pass; legacy path 175/175 green. * feat(csharp-scope): parity Unit 2b — same-namespace cross-file visibility Closes 3 parity failures (37 → 34). Adds the C#-specific implicit import that has no syntactic counterpart: every type declared in `namespace X` is visible to every other file also declaring `namespace X`, without any `using` directive. Changes: - `scope-resolution/contract/scope-resolver.ts` — new optional hook `populateNamespaceSiblings(parsedFiles, indexes, { fileContents })`. Most languages leave it undefined; Python / TypeScript / Java need explicit imports so there's no analogous pass. - `scope-resolution/pipeline/run.ts` — invoke the hook after `buildWorkspaceResolutionIndex` and before `propagateImportedReturnTypes` so the return-type pass sees cross-file sibling class bindings. - `languages/csharp/namespace-siblings.ts` (new) — groups top-level class-like defs by namespace name (extracted from source via regex since `file_scoped_namespace_declaration` scope range covers only the declaration line, not the rest of the file). Injects sibling classes into each file's Module AND Namespace scope bindings with origin='namespace'. Local declarations shadow cross-file siblings via mergeBindings tier precedence. - `languages/csharp/scope-resolver.ts` — wire the hook. 74/74 scope-resolution unit tests pass; legacy path 175/175 green; 34 parity failures remain (was 37) under REGISTRY_PRIMARY_CSHARP=1. * feat(csharp-scope): parity Unit 2c — alias/await/return-type captures Closes 7 parity failures (34 → 27). Adds the remaining type-binding shapes the parity suite exercises: - `var alias = u;` / `alias = u;` — identifier-to-identifier alias. The resolver's chain-follow walks alias → u → u's declared type. - `var u = svc.GetUser();` — chained method call alias. Anchors on the method_access_expression's `name` field; chain-follow picks up GetUser's return type. - `var u = await Factory();` / `await svc.Get();` — await propagation. Strips the `await_expression` wrapper; interpret layer's `stripGeneric` handles `Task<T>` / `ValueTask<T>` unwrapping. - `public User GetUser() { ... }` — method return-type annotation via `@type-binding.return`. Required for `propagateImportedReturnTypes` to see the return type in later cross-file passes. Covers identifier, generic_name, qualified_name, and nullable_type return shapes. 74/74 scope-resolution unit tests pass; legacy path 175/175 green; 27 parity failures remain under REGISTRY_PRIMARY_CSHARP=1. * feat(csharp-scope): parity Unit 3a — cross-namespace `using` binding Closes 2 parity failures (27 → 25). Extends the namespace-siblings pass to resolve `using X;` directives against known namespace buckets: for each `using` that targets a namespace declared somewhere in the workspace, inject that namespace's classes into the importer's module scope with origin='namespace'. This is the scope-resolution analog of legacy's csproj-driven directory↔namespace mapping. Without it, `new User()` in `Services/UserService.cs` (namespace MyApp.Services) can't see the User class in `Models/User.cs` (namespace MyApp.Models) even with `using MyApp.Models;` — the scope-resolver layer doesn't have csproj metadata to translate the dotted namespace path into a directory lookup. Legacy 175/175 green; 25 parity failures remain. * feat(csharp-scope): parity Unit 3b — constructor CALLS emission Closes 3 parity failures (25 → 22). Adds constructor-form CALLS edge emission + C# 12 primary constructor synthesis. Changes: - `scope-resolution/passes/free-call-fallback.ts`: when a site's callForm === 'constructor', look up the class def (not a callable) and pick its explicit Constructor def via workspaceIndex's memberByOwner — or fall back to the Class def itself for implicit constructors. Matches legacy behavior (targetLabel === 'Constructor' when explicit, 'Class' when implicit). - `scope-resolution/pipeline/run.ts`: pass workspaceIndex to the free-call fallback. - `languages/csharp/captures.ts`: synthesize @declaration.constructor for C# 12 primary constructors — `class User(string name, int age)` / `record Person(string First, string Last)`. The parameter_list is a named child of the class_declaration / record_declaration (not a separate constructor_declaration node). Skip the synthesis when the type already has an explicit constructor to avoid duplicates. Emits @declaration.parameter-count + required-parameter-count alongside. Legacy 175/175 green; 376/376 scope-resolution unit tests pass; 22 parity failures remain. * feat(csharp-scope): parity Unit 3c — static call + default-namespace Closes 2 parity failures (22 → 21). - `receiver-bound-calls.ts`: add Case 5 for class-as-receiver. When `Animal.Classify()` has an identifier receiver that resolves to a Class binding (rather than a variable with a typeBinding), look up the member on the class's MRO chain. Covers C#-style static calls and any type-qualified member access. Python doesn't hit this because `ClassName.method()` is syntactically identical to a free call there. - `namespace-siblings.ts`: treat files with no `namespace X;` declaration as living in the default (empty-name) bucket, so types declared in no-namespace files share cross-file visibility. Required for fixtures without explicit namespaces (e.g. the method-enrichment fixture's Animal/App/Dog classes). Legacy 175/175 green; 21 parity failures remain. * feat(csharp-scope): parity Unit 4 — callsite arity synthesis (infra) Synthesize @reference.arity on every invocation_expression and object_creation_expression by counting `argument` named children of the backing `argument_list`. Wires the capture-to-Callsite pipeline shared extractor already consumes (`scope-extractor.ts:878`). No parity-count movement: the remaining arity-adjacent failures (overload disambiguation, optional-parameter dedup, variadic resolution) need type-based argument inference or member-call dedup, both explicitly deferred in the plan's Known Limitations section. This commit is infrastructure — future work lands on top of it. Legacy 175/175 green; 21 parity failures remain. * feat(csharp-scope): parity Unit 5a — IMPORTS edge + static-using mapping Closes 1 parity failure (21 → 20). Fixes cross-file IMPORTS edge emission for C#: - `languages/csharp/interpret.ts`: map `using static X.Y;` to `kind: 'namespace'` rather than `'wildcard'`. The File→File IMPORTS edge needs a non-wildcard kind to survive finalize's Phase 4 (wildcard-expanded edges drop to empty when the provider doesn't implement `expandsWildcardTo`). Unqualified static-member access is a deferred limitation — covered by the namespace-siblings cross-namespace pass for type lookups, and documented under the module's Known Limitations. - `languages/csharp/import-target.ts`: progressive prefix stripping. `using CrossFile.Models;` in a repo laid out `Models/User.cs` (no `CrossFile/` directory) works because the legacy resolver consults csproj; the scope-resolver tries each suffix of the dotted path against `.cs` files. Also handles `using static NS.Type;` by stripping leading segments until a direct match lands. - `test/unit/scope-resolution/csharp/csharp-imports.test.ts`: update the `using static` test to the new namespace-kind shape. 376/376 scope-resolution unit tests pass; legacy 175/175 green; 20 parity failures remain. * feat(csharp-scope): parity Unit 5b — return-type module hoist + chain fallback Closes 1 parity failure (20 → 19) and lays groundwork for Unit 6. Based on investigation-agent findings, addresses cluster of 7 cross-file + chain tests whose return-type bindings were stuck at Class scope and invisible to the chain-follow and propagation passes. Changes: - `languages/csharp/simple-hooks.ts::csharpBindingScopeFor`: when the declaration is a `@type-binding.return`, hoist the binding all the way to the Module scope. The central extractor's auto-hoist only promotes one level (Function → Class); for C# methods the parent is always a Class, so without this override the return binding never reaches Module where chain-follow and cross-file `propagateImportedReturnTypes` read from. - `scope-resolution/passes/compound-receiver.ts`: when the class-scope typeBindings lookup at `objClass.typeBindings.get( methodName)` misses, walk up from the class scope through the parent chain (→ Module) for a return-type binding. Preserves the existing class-scope fast-path while restoring owner-chain lookup for languages that hoist to Module. Python parity suite stays 204/204 green on both flag paths; legacy C# 175/175 green; 19 C# parity failures remain. * feat(csharp-scope): parity Unit 5c — switch-expr + reasons + ACCESSES 1.0 Closes 4 parity failures (19 → 15). - `languages/csharp/query.ts`: add captures for `switch_expression_arm` with `declaration_pattern` and `recursive_pattern`. C# expression- switch (`obj switch { User u => ..., Repo { Name: "x" } r => ... }`) uses a different AST node from classic `switch_statement`'s `switch_section` — needed separate query patterns. - `scope-resolution/passes/receiver-bound-calls.ts`: replace the self-describing `'scope-resolution: *-receiver'` reason strings (which fail legacy-parity consumer filters) with the legacy convention: `'import-resolved'` when the resolved member lives in a different file, `'global'` otherwise. Mirrors `free-call-fallback.ts`'s existing reason logic. - `scope-resolution/passes/receiver-bound-calls.ts`: pass `confidence: 1.0` to `tryEmitEdge` for write/read ACCESSES edges, matching legacy DAG behavior (default 0.85 was legacy-CALLS). Python parity 204/204 on both flag paths; legacy C# 175/175; 15 C# parity failures remain. * feat(csharp-scope): parity Unit 5d — cross-file typeBinding mirror Closes 3 parity failures (15 → 12). `languages/csharp/namespace-siblings.ts`: extend the pass to mirror method return-type bindings from accessible sibling files' Module scopes into the importer's Module scope. "Accessible" = same-namespace siblings + `using namespace X;` targets. Without this mirror, `var u = svc.GetUser()` in App.cs couldn't chain-follow to User even after Unit 5b's module-scope hoist: `GetUser → User` lived on User.cs's Module scope, which isn't on the ancestor chain of App.cs's function scope, and `propagateImportedReturnTypes` only mirrors across explicit ImportEdge targets (not same-namespace implicit visibility). Closes: var-invocation return type, async/await u.Save (ambient namespace), cross-file return-type propagation (via u.Save / u.GetName in Program.cs). Python parity 204/204 on both flag paths; legacy C# 175/175; 12 C# parity failures remain. * feat(csharp-scope): parity Unit 5e — namespace-prefix bucket matching Closes 2 parity failures (12 → 10). `languages/csharp/namespace-siblings.ts`: when matching accessible namespaces against class buckets, also probe every dotted prefix. `using static CrossFile.Models.UserFactory;` parses into the importer's accessible-namespace set as the full type path, but the matching bucket is keyed on the containing namespace (`CrossFile.Models`). Walking back through the dotted segments ensures the static-using importer sees the containing namespace's sibling files' return-type bindings. Legacy 175/175 green; 10 C# parity failures remain. * feat(csharp-scope): parity Unit 6a — class-like owner extension Closes 1 parity failure (10 → 9). Extends `populateClassOwnedMembers` to recognize Interface / Struct / Record / Enum / Trait as class-like owners, not just Class. The C# scope query collapses interface_declaration / struct_declaration / record_declaration / enum_declaration to @scope.class (they share body-scope semantics), but the declaration-side tags produce defs of type Interface / Struct / Record / Enum. `populateClassOwnedMembers` previously only looked for Class-typed defs in class scopes, so interface members (including C# 8+ default methods) never got ownerIds — making them invisible to `findOwnedMember` via `memberByOwner`. With this fix, `user.Validate()` on a variable typed as `IValidator` resolves correctly: receiver-bound-calls Case 4 finds IValidator via findClassBindingInScope (which already accepted Interface), walks the chain, and findOwnedMember locates Validate now that the interface default has a proper ownerId. Legacy C# 175/175 green; Python parity 204/204 on both flag paths; 9 C# parity failures remain. * feat(csharp-scope): parity Unit 6b — member-call dedup + handled-site fix Closes 1 parity failure (9 → 8). Adds the missing legacy-parity behavior: collapse multiple member-call sites from the same caller to the same target into one CALLS edge. Changes: - `scope-resolution/contract/scope-resolver.ts`: new optional `collapseMemberCallsByCallerTarget` flag. Default false (preserves the per-site invariant); C# sets it true. - `scope-resolution/graph-bridge/edges.ts`: dedup key drops `line:col` when `collapseByCallerTarget` is on AND edgeType is `CALLS` (ACCESSES writes keep per-site granularity). - `scope-resolution/passes/receiver-bound-calls.ts`: plumbs `collapse` through every `tryEmitEdge` call, and crucially marks `handledSites.add(siteKey)` whenever a resolved def was found — not only when the edge was freshly emitted. Otherwise the site leaked through to `emitReferencesViaLookup` which re-emitted a per-site edge, defeating the collapse. - `languages/csharp/scope-resolver.ts`: opt in to the collapse. Python parity 204/204 on both flag paths; legacy C# 175/175 green; 8 C# parity failures remain. * feat(csharp-scope): parity Unit 6c — Dictionary.Values / .Keys unwrap Closes 2 parity failures (8 → 6). Dictionary<K,V>.Values in a foreach binds the element to V; .Keys binds to K. Without this, `foreach (var user in data.Values)` where `data: Dictionary<string, User>` couldn't propagate user's type to User, and `user.Save()` stayed unresolved. Changes: - `languages/csharp/interpret.ts`: don't strip the qualifier when the final dotted segment is a known collection accessor (`Values` / `Keys`). Preserves the dotted form so downstream resolvers can unwrap the receiver's generic type based on the suffix. - `scope-resolution/passes/compound-receiver.ts`: new `extractDictionaryArgs` helper splits `Dictionary<K, V>` at the top-level comma. In the dotted-access walk, detect trailing `.Values` / `.Keys` and return V/K via findClassBindingInScope instead of the normal class-walk (Dictionary itself isn't a local class def). - Handles nested cases: `this.data.Values` walks `this.data` recursively (resolving `data` as a field on `this`'s class) before applying the unwrap. - `scope-resolution/passes/receiver-bound-calls.ts` Case 3b: when the typeRef's trailing segment is an accessor, pass the raw dotted path to `resolveCompoundReceiverClass` without appending `()` — the extra parens would misroute to the call-expression branch. Python parity 204/204 on both flag paths; legacy C# 175/175 green; 6 C# parity failures remain. * feat(csharp-scope): parity Unit 6d — using-static member injection Closes 2 parity failures (6 → 4). `using static X.Y.Z;` now injects every public static method of class Z into the importer's module scope, so `Record("hi")` (without `Logger.` qualifier) resolves to `Logger.Record` as a free call. `languages/csharp/namespace-siblings.ts`: regex-scan each file's source for `using static X.Y.Z;` directives. For each, look up the class Z in the `X.Y` namespace bucket, walk its owning file's localDefs for method/function members with `ownerId === Z.nodeId`, and inject them as `origin: 'import'` bindings in the importer's module-scope finalized bindings map. `findCallableBindingInScope` then picks them up via its imported-bindings check. Closes: variadic `Record(params string[])` + heritage arity narrowing `WriteAudit`. Python parity 204/204 on both flag paths; legacy C# 175/175 green; 4 C# parity failures remain (interface-dispatch pass + type-based overload disambiguation). * feat(csharp-scope): parity Unit 6e — overload disambig + interface dispatch + FLAG FLIP Closes the final 4 parity failures (4 → 0). C# now runs the registry-primary scope-resolution path by default — added to MIGRATED_LANGUAGES. Changes: - `scope-resolution/scope/walkers.ts`: was already extended in Unit 6a to recognize Interface/Struct/Record/Enum as class-like owners (interface default methods get ownerIds). - `scope-resolution/passes/receiver-bound-calls.ts`: build IMPLEMENTS edge index → emit secondary `interface-dispatch` CALLS edges to every implementor's same-named member when the primary receiver-typed edge targets an Interface method (closes heritage CreateUser CALLS-count test). - `scope-resolution/passes/receiver-bound-calls.ts`: new `pickOverload` helper narrows multi-valued `membersByOwner.get(owner).get(name)` candidates by arity then argument types. Replaces the first-seen `findOwnedMember` lookup in Case 4 so receiver-typed overloaded calls pick the right def. - `scope-resolution/passes/free-call-fallback.ts`: new `pickImplicitThisOverload` walks up to the enclosing class scope and applies the same arity + argument-type narrowing for free calls inside a class body (`Lookup("alice")` → `Lookup(string)`). - `scope-resolution/workspace-index.ts`: new `membersByOwner` multi-valued index (`Map<owner, Map<name, Def[]>>`) preserves every overload alongside the existing first-seen `memberByOwner`. - `scope-resolution/graph-bridge/node-lookup.ts` + `scope-resolution/graph-bridge/ids.ts`: include parameter-types suffix in the qualified lookup key for Method nodes. Legacy parse-phase encodes the type tag into the node id (`Method:f.cs: UserService.Lookup#1~int`); without this two same-arity overloads collapsed to one lookup entry and routed to the wrong graph node. - `scope-resolution/contract/scope-resolver.ts`: new `collapseMemberCallsByCallerTarget` opt-in flag (was added in Unit 6b for member-call dedup; documented here). - `gitnexus-shared/src/scope-resolution/reference-site.ts`: new `argumentTypes` field carrying inferred per-arg types. - `scope-extractor.ts`: read @reference.parameter-types capture into `site.argumentTypes` and add it + the declaration-arity tags to KNOWN_SUB_TAGS so the anchor-detection picks the right anchor. - `languages/csharp/captures.ts`: synthesize @reference.parameter-types by inferring arg types from literal AST nodes (integer_literal → 'int', string_literal → 'string', constructor_expression → type-name, etc). - `languages/csharp/scope-resolver.ts`: opt in to `collapseMemberCallsByCallerTarget`. - `registry-primary-flag.ts`: **add CSharp to MIGRATED_LANGUAGES**. Final state: - C# parity: 175/175 green on flag-on AND flag-off. - Python parity: 204/204 green on both flag paths (no regression). - TypeScript clean. 51 → 0 failures across 18 commits on `feat/csharp-scope-resolution`. * refactor(scope-resolution): extract language-specific accessor unwrap to provider hook Optimizer pass: move C# Dictionary-family `.Values`/`.Keys` handling out of the shared `compound-receiver.ts` (where it had hardcoded regex + accessor names) into a provider-level `unwrapCollectionAccessor` hook. The shared pass now takes an arbitrary language-specific unwrap function; C# supplies its Dictionary implementation in `languages/csharp/accessor-unwrap.ts`. Related cleanup in `receiver-bound-calls.ts` Case 3b: replace the hardcoded `tail === 'Values' || tail === 'Keys'` accessor check with a try-dotted-walk-first / fall-back-to-call-form strategy. This removes the last C#-specific branch in the shared pass and makes the logic generalize cleanly to other languages that use property-style accessors for collection views (Kotlin `.size`, future languages). Changes: - `scope-resolution/contract/scope-resolver.ts`: new optional `unwrapCollectionAccessor(receiverType, accessor) => string | undefined` hook. Documented as language-specific with examples. - `scope-resolution/passes/compound-receiver.ts`: delete `extractDictionaryArgs`, accept `unwrapCollectionAccessor` via options, call it for trailing accessor segments. - `scope-resolution/passes/receiver-bound-calls.ts`: plumb the hook through to `resolveCompoundReceiverClass`, remove the C#-hardcoded Case 3b accessor check. - `languages/csharp/accessor-unwrap.ts` (new): C# Dictionary-family regex + element-type extraction. - `languages/csharp/scope-resolver.ts`: opt in. Audit outcome: everything else added across the 19 C# migration commits is either correctly scoped to `languages/csharp/` (query, captures, namespace-siblings, receiver-binding, interpret, imports) or correctly generic in shared paths (argumentTypes field, collapseMemberCallsByCallerTarget flag, overload narrowing via parameterTypes, interface-dispatch via IMPLEMENTS edges, class-like owner extension for Interface/Struct/Record/Enum, type-tagged node IDs, module-scope return-type lookup fallback). 175/175 C# green on both flag paths; 204/204 Python green on both flag paths; TypeScript clean. * refactor(scope-resolution): gate module-scope typeBinding walk-up on hook Add optional `hoistTypeBindingsToModule` to the ScopeResolver contract and gate the Module-scope walk-up in `resolveCompoundReceiverClass` on it. Only providers that hoist method return-type bindings to Module scope (C#) opt in; Python and other providers no longer traverse that fallback path. Closes the architectural leak flagged in the production-readiness review: the walk-up was unconditional and therefore widened Python's code path despite existing only for C#. No behavior change for C# (hook=true restores the prior lookup). No behavior change for Python (hook undefined = walk-up skipped, matching pre-PR behavior). Verified: - npx tsc --noEmit clean - C# unit suite 74/74 passing - C# + Python integration 388/388 passing * refactor(csharp-scope): remove as-unknown-as double casts in scope-resolver Tighten three type boundaries that were previously papered over with `as unknown as` casts: * `CsharpResolveContext.allFilePaths`: `Set<string>` → `ReadonlySet<string>`. The orchestrator only hands out a read-only view; drop the widening cast at the resolver-adapter site. * `resolveCsharpImportTarget`: call passes the narrow context directly. `WorkspaceIndex` is `unknown` in the shared contract, so the `as unknown as WorkspaceIndex` cast was gratuitous — structural assignability covers it. * `csharpMergeBindings`: drop unused `_scope: Scope` parameter. The implementation never read it; the cast chain in `scope-resolver.ts` existed only to satisfy an unused slot. LanguageProvider.mergeBindings now wraps with a tiny arrow adapter; ScopeResolver.mergeBindings passes through directly. No runtime behavior change. `grep 'as unknown as' csharp/scope-resolver.ts` returns zero matches. Verified: - npx tsc --noEmit clean - C# unit + integration 462/462 passing (incl. Python integration) * test(csharp-scope): integration fixtures for Units 6c/6d/6e runtime behavior Close the integration-coverage gap flagged in the production-readiness review. Units 6c (collection-accessor unwrap), 6d (using-static member injection), and 6e (overload disambig + interface dispatch) previously had only hook-level unit tests; the end-to-end wiring was exercised only by the parity harness. Three minimal fixtures + four new it() blocks: * csharp-collection-accessor — RenderAll iterates Dictionary<string, Widget>.Values and calls .Render(); asserts the CALLS edge lands on Widget.Render. * csharp-using-static — `using static Helpers.MathUtils;` makes Square(int) a free-callable in the consumer; asserts the CALLS edge lands on MathUtils.Square. * csharp-overload-interface — three assertions: 1. Run → Log binds to the 2-arg overload only (arity narrowing); verified via target Method node's parameterTypes.length === 2. 2. Run → Greet emits one primary edge to IGreeter.Greet plus two reason='interface-dispatch' siblings to En/FrGreeter.Greet. 3. Interface-dispatch fan-out excludes the primary target. Verified: - csharp integration 189/189 passing * docs(scope-resolution): de-c#-ify optional-hook doc-comments on contract Rewrite the doc-comments on four optional hooks so they describe the behavior and when a provider would enable it, rather than naming C# as the sole consumer. Hook names were already generic — only the comments had baked in one-language framing, which risked discouraging future reuse. Affected hooks: * unwrapCollectionAccessor * collapseMemberCallsByCallerTarget * populateNamespaceSiblings * hoistTypeBindingsToModule Language-specific rationale stays where it belongs — next to the hook assignment in `languages/csharp/scope-resolver.ts`. Zero-match grep for `C#|csharp|CSharp` in the contract file confirms the separation. No code change. * docs(csharp-scope): justify regex-based namespace-sibling detection Record why `namespace-siblings.ts` uses regex over AST walks and enumerate the known misses so the next reader has ground to stand on: * `global using static X.Y;` — no plain `using static` token. * Aliased `using static X = Y.Z;` — `=` breaks the pattern. * Attributed namespace declarations between `]` and `{`. * Multi-namespace files — first-wins attribution. * Preprocessor-gated namespace declarations — textual branch only. Rationale: the pass is file-path-driven and the tree-sitter tree isn't available at its call site (the orchestrator feeds raw fileContents); re-parsing to count namespaces would cost more than the regex walk. Refactor to AST-driven detection is deferred to a separate PR. Mirrored the known-miss list into `csharp/index.ts`'s limitations ledger so the operator-visible surface and the in-code justification stay in sync. No code change. * refactor(csharp-scope): AST-driven namespace detection with treeCache reuse Replace regex-over-source-content with tree-sitter AST walks in namespace-siblings.ts; thread the orchestrator's treeCache through the populateNamespaceSiblings hook so the pass reuses the same parse trees `extractParsedFile` already consumed (single-source-of-truth for the AST — no double-parse). Behavior gains (no longer "known misses"): * `global using static X.Y;` is now detected. * Aliased `using static X = Y.Z;` is now detected. * Attributed namespace declarations (`[attr] namespace X`) parse correctly because tree-sitter sees them as one node. * Preprocessor-gated namespace declarations parse via the grammar. Contract change (additive, optional): * `populateNamespaceSiblings` ctx now carries an optional `treeCache?: { get(filePath): unknown }`. Existing providers that don't set it on `RunScopeResolutionInput` see undefined, and the hook falls back to a fresh parse (current behavior preserved on cache miss). Limitation ledger updated in csharp/index.ts: the AST-based detection removes 4 of the 5 prior known misses; only "first-wins multi-namespace file attribution" remains. Verified: - npx tsc --noEmit clean - C# + Python integration 393/393 passing * refactor(python-scope): remove as-unknown-as casts in scope-resolver (mirrors Unit 2) Replay the C# scope-resolver cleanup on the Python side so both providers share a single clean pattern: * Drop `ws as unknown as WorkspaceIndex` — `WorkspaceIndex` is `unknown` in the shared contract, so the narrow context assigns structurally without a cast. * Drop `{ id: scopeId } as unknown as Scope` — `pythonMergeBindings` never read the scope (the parameter was `_scope`), so the stub was a type-only ghost. Signature is now `(bindings)` and the LanguageProvider slot wraps with an arrow adapter. * Drop `allFilePaths as Set<string>` — the orchestrator hands a `ReadonlySet<string>`; we copy it into a `Set` at the resolver adapter so the legacy downstream `resolvePythonImportInternal` chain (typed for mutable `Set<string>`) keeps working. The copy is O(N) once per import, trivial cost. Left intact on purpose: the `(callsite, def) → (def, callsite)` arrow wrapper on `arityCompatibility`. That's a documented shape difference between `LanguageProvider.arityCompatibility(def, callsite)` and `ScopeResolver.arityCompatibility(callsite, def)`; both providers (Python + C#) carry the same wrapper. Reconciling is a separate refactor across both contracts. No runtime behavior change. Verified: - npx tsc --noEmit clean - Python + C# unit + integration suites 529/529 passing * docs(scope-resolution): document I1-I8 invariants, source-of-truth, and same-graph guarantee Promote contract knowledge that was implicit in code into the canonical docs so future migrations and the next reviewer don't have to reverse-engineer it. contract/scope-resolver.ts: * Migration cookbook lists every optional hook (was: only the two booleans), with one-line guidance per hook including when to enable `hoistTypeBindingsToModule`. * Contract Invariants I1-I7 are now spelled out in full (was: only I1/I3/I5 summarized with a pointer to a plan file). Added new I8 "post-finalize hooks may mutate Scope.typeBindings and indexes.bindings; consumers must not freeze or snapshot before all post-finalize hooks have run". * New "Semantic-model source of truth" section: ParsedFile is the single semantic model; passes that need AST-level facts must reuse the orchestrator's treeCache rather than re-parse. * New "Same-graph guarantee" section: legacy DAG and scope-resolution emit indistinguishable edges (node identity, edge vocabulary, confidence). CI parity workflow enforces this. gitnexus-shared/src/scope-resolution/parsed-file.ts: * Added "Source-of-truth invariant" pointer paragraph. ARCHITECTURE.md (Coexistence section): * Updated migrated-language list (Python + C#). * Added "Same-graph guarantee" subsection. * Added "Semantic-model source of truth" subsection. * Filled in the ScopeResolver hook table with the five optional hooks that landed in this branch (unwrapCollectionAccessor, collapseMemberCallsByCallerTarget, populateNamespaceSiblings, hoistTypeBindingsToModule, fieldFallbackOnMethodLookup). * Added C# rows to the code-references table. Verified: - npx tsc --noEmit clean - C# + Python integration 393/393 passing * refactor(scope-resolution): consume SemanticModel as single authoritative store Unify scope-resolution and legacy parse into one symbol index per the industry pattern (Roslyn / tsc / rust-analyzer). Scope-resolution passes now consume `SemanticModel.methods` / `SemanticModel.fields` / `SemanticModel.symbols` for all symbol-keyed lookups. The legacy DAG already read from these; the drift — two parallel owner-keyed indexes populated by two writers with divergent ownerId semantics — is closed. Changes: * `MethodRegistry.lookupAllByOwner(owner, name)`: new API returning every overload without arity narrowing. Powers `findOwnedMember` / `pickOverload`. * `pipeline/run.ts` reconciliation pass: after `provider.populateOwners(parsed)`, iterate `parsed.localDefs[i]` and register methods/fields into the SemanticModel under the corrected ownerId. Idempotent — skips defs already present under `(ownerId, simple)` by nodeId, so unmigrated languages whose legacy extractor already set ownerId (C#) don't double-register. Closes the Python gap where class-body methods were invisible to `MethodRegistry` because the legacy Python method extractor couldn't resolve `enclosingClassId` at parse time. * `WorkspaceResolutionIndex` slimmed to Scope-valued maps only (`classScopeByDefId`, `moduleScopeByFile`). Dropped `memberByOwner`, `membersByOwner`, `defsByFileAndName`, `callablesBySimpleName` — all symbol-keyed duplicates of SemanticModel indexes. * Walker helpers now consume SemanticModel: - `findOwnedMember(owner, name, model)` → methods then fields fallback (ACCESSES writes target Property/Variable defs too). - `findExportedDefByName` fallback walks every Module scope's `origin === 'local'` bindings via `index.moduleScopeByFile` (preserves the module-export-visibility filter that SymbolTable.fileIndex can't cheaply encode). - `findExportedDef` reads `moduleScope.bindings` directly. * `pickOverload` in receiver-bound-calls.ts falls back to `model.fields.lookupFieldByOwner` when method lookup returns empty, fixing ACCESSES write edges that receive a Property target. * `phase.ts` threads `resolutionContext.model` into `RunScopeResolutionInput`. Boundary rule, enforced by file placement: - symbol-indexed lookups (key = nodeId / name / filePath) → `SemanticModel` - Scope-valued lookups (value = `Scope`) → `WorkspaceResolutionIndex` Research synthesized from web-researcher + Explore + best-practices + system-architect agents; canonical references: Roslyn Overview, rust-analyzer architecture, stack-graphs paper. Verified: - npx tsc --noEmit clean - C# + Python integration 393/393 passing * docs(scope-resolution): refresh comments after dropping duplicated indexes Replace references to the now-deleted `memberByOwner` / `callablesBySimpleName` index fields with comments that describe the actual lookup path (`SemanticModel` registries + scope-tied module bindings). Pure doc cleanup; no behavior change. * feat(scope-resolution): extract reconciliation pass + add parity validator Extract the SemanticModel reconciliation pass (previously inline in `pipeline/run.ts`) into a dedicated module with: * `reconcileOwnership(parsedFiles, model)` — pure function returning stats (methodsRegistered / fieldsRegistered / skippedAlreadyPresent). Idempotent; safe to re-run. * `validateOwnershipParity(parsedFiles, model, onWarn)` — dev-mode runtime validator for Contract Invariant I9. Walks every def with an `ownerId` and asserts it is reachable via `model.methods.lookupAllByOwner` or `model.fields.lookupFieldByOwner`. Soft-fails via `onWarn`; never throws. Validator is gated on both `NODE_ENV !== 'production'` and `VALIDATE_SEMANTIC_MODEL !== '0'` so production incurs zero cost but development surfaces any drift between `parsed.localDefs` ownership and the registries. 12 new unit tests cover: * happy path: method, property, Variable registration * edge case: defs without ownerId are skipped * idempotency: second call is a no-op * coexistence: defs the legacy extractor already registered (via `model.symbols.add`) are skipped on reconcile * overloads: multiple methods under the same (owner, name) * validator: no warnings after reconciliation * validator: warns on drift * validator: no-op under NODE_ENV=production * validator: no-op when VALIDATE_SEMANTIC_MODEL=0 * validator: warns on missing Property same as missing Method Verified: - npx tsc --noEmit clean - reconcile-ownership unit tests 12/12 passing - C# + Python integration 393/393 passing * refactor(scope-resolution): narrow handles + tighten required params Two small hygiene fixes that fell out of the unified-model work: * Introduce `readonlyModel: SemanticModel` in `runScopeResolution` immediately after reconciliation so the write/read phase boundary is explicit at the code level. Downstream passes (receiver-bound, free-call) receive the narrowed `SemanticModel` rather than the `MutableSemanticModel` that only the reconciliation pass needs. The type system now rejects accidental writes in the read phase. * Make `emitFreeCallFallback`'s `workspaceIndex` parameter required. It's now always passed (every caller threads it through), and the `workspaceIndex?` guard was dead code. Also drops the `| undefined` branch from `pickConstructorOrClass` which no caller can hit. No behavior change. * docs(semantic-model): document unified single-source-of-truth invariant (I9) Add Contract Invariant I9 to the ScopeResolver contract and write the single-source-of-truth + write/read phase contract into both the SemanticModel file-head and ARCHITECTURE.md. Three landing points so the rule is reachable from every entry: * contract/scope-resolver.ts — new I9 entry in the Contract Invariants list: scope-resolution passes consult SemanticModel exclusively for symbol-keyed lookups; WorkspaceResolutionIndex is reserved for Scope-valued maps. Documents the two-phase write (legacy parse + reconcileOwnership) and the narrowed-handle read posture. Calls out the reconciliation shim as transitional. * model/semantic-model.ts — new "Single-source-of-truth invariant" and "Write / read phase contract" sections in the file-head. Three ordered write phases (parse → reconcile → attachScopeIndexes), then frozen for readers. * ARCHITECTURE.md § "Semantic-model source of truth" — expanded subsection covering both invariants (ParsedFile = AST truth, SemanticModel = symbol truth), the write/read phase diagram, and the reconciliation-shim rationale. No code change. * test(scope-resolution): rewrite workspace-index test for slimmed index The test file previously asserted on \`defsByFileAndName\`, \`callablesBySimpleName\`, and \`memberByOwner\` — fields removed when symbol-keyed lookups moved to \`SemanticModel\`. Rewrite so the same invariants are asserted via the authoritative consumers: * New WorkspaceResolutionIndex shape test (scope-only maps). * \`findExportedDef\` module-export visibility tests: - keeps top-level class and function defs. - excludes class-body Variable defs (MAX_USERS = 100). - excludes class methods from module-export lookup. * \`findExportedDefByName\` fallback excludes class methods when a same-named module function exists. * \`findOwnedMember\` via the reconciled SemanticModel finds Python class methods after populateOwners + reconcileOwnership. Total assertions preserved: every invariant from the old test file is still pinned; the assertion surface shifted from the index shape to the walker helpers. Verified: - workspace-index.test.ts 8/8 passing * fix(tests): update registry-primary-flag test for C# migration The "returns exactly the flipped languages" case expected `enabled.size === 1` after toggling Python off and Go on. After the C# migration lands C# in MIGRATED_LANGUAGES, C# is default-on too — so the size is now 2 (Go + C#) unless C# is also opted out. Turn off C# alongside Python in the test setup. Added a comment noting that future migrations must add their REGISTRY_PRIMARY_<LANG>='false' line here. * refactor(scope-resolution): address PR #1019 review findings Resolves all 5 findings from the automated review on feat/csharp-scope-resolution. Shared ingestion code stays language-agnostic; C# (and every class-like language) benefits. F1 [high] Broaden class-like predicate Hoist `isClassLike` in `scope/walkers.ts` to an exported top-level helper covering Class | Interface | Struct | Record | Enum | Trait. Use it in `findClassBindingInScope`, `findEnclosingClassDef`, and `buildWorkspaceResolutionIndex` so C# records, structs, interfaces, and enums participate in scope chains and receiver binding the same way Python classes do. F2 [medium] Remove stale comment in csharp simple-hooks `csharpReceiverBinding`'s doc claimed this/base synthesis was "planned for a follow-up"; synthesis has been implemented in receiver-binding.ts since the migration landed. Rewrite the doc to describe the actual behavior (non-null TypeRef on instance-method bodies, null on static/free functions). F3 [medium] O(1) reverse lookup for classScopeId -> classDefId Add `classScopeIdToDefId: ReadonlyMap<ScopeId, string>` to `WorkspaceResolutionIndex`, populated as the inverse of `classScopeByDefId`. Replace the O(C) linear scan in `pickImplicitThisOverload` (free-call-fallback.ts) with an O(1) `Map.get` — turns per-site reverse resolution from linear in class count to constant time for every free call. F4 [low] Extract narrowOverloadCandidates shared utility New `passes/overload-narrowing.ts` centralizes the arity + argument- type narrowing previously duplicated across `pickOverload` (receiver-bound-calls.ts) and `pickImplicitThisOverload` (free-call-fallback.ts). Both callsites now share identical narrowing semantics; variadic `params T` handling is preserved. Return type is `readonly SymbolDefinition[]` with no defensive spreads (allocations saved on the hot path). F5 [low] Merge unreachable Case 5 into Case 2 `Case 5` in `receiver-bound-calls.ts` was dead code — `Case 2` pre-empted it for every static/class-name receiver. Delete Case 5 and lift its kind-aware read/write ACCESSES reason/confidence logic into Case 2 so static-style member access (e.g. `Interface.Member`, `TypeName.StaticMember`) gets the correct edge metadata. Tests - New unit tests for `narrowOverloadCandidates` covering empty input, arity filtering, variadic params, type narrowing, and fallback semantics. - New unit tests for `classScopeIdToDefId` verifying inverse invariant and empty index behavior. - New C# integration fixtures and tests: * csharp-record-base — record inheritance + `base.Save()` * csharp-struct-overloads — struct with implicit-this overload narrowing (pinned exact edge count under registry-primary) * csharp-interface-receiver-static — interface-qualified static- style call exercises the merged Case 2. - Full runs green: * scope-resolution unit: 406/406 * csharp integration (registry-primary): 197/197 * csharp integration (legacy DAG): 197/197 * python integration (regression guard): 204/204 Chore - Add `.context/` to root `.gitignore` to prevent agent scratch files from being committed. Made-with: Cursor * test(csharp-scope-resolution): address adversarial review follow-ups on PR #1019 Applies the three actionable follow-ups from the post-commit adversarial review of5a1bce7fagainst DoD.md. No runtime code changes. - [medium] Strengthen bounds-only assertion in the struct-overloads suite: `methods.length` is now pinned to `toBe(2)` and the arity list to `toEqual([1, 2])`. Fixture `csharp-struct-overloads/src/Calc.cs` declares exactly two `Add` methods, so a regression that adds, drops, or merges an overload will now fail the test instead of silently passing a `>= 2` gate. - [low] Pin the merged Case 2 kind-aware branch (receiver-bound-calls.ts lines 257-289) with a dedicated fixture and three new assertions: `csharp-class-static-field-access/src/Counters.cs` exercises `ClassName.Field = value` where the receiver resolves via `findClassBindingInScope` (no typeBinding on `Counters`). The test verifies (a) two distinct ACCESSES writes are emitted from a single method (per-site dedup from graph-bridge/edges.ts:80-87), (b) `reason === 'write'`, (c) `confidence === 1.0`, and (d) no spurious CALLS edges are produced for the same sites. This is the semantic upgrade lifted from the deleted Case 5; without a pinning test a future revert of the kind-aware branch would silently drop back to `import-resolved`/`global` at 0.85 for the same sites. Read-side coverage is intentionally not asserted because the C# tree-sitter query currently emits only `write.member` captures (languages/csharp/query.ts:485-501) — a read counterpart would have no reference site today and would give a false sense of coverage. - [info] Left the `?? overloads[0]` fallback in place at receiver-bound-calls.ts:450 unchanged. With the package's current tsconfig (strict: false, no noUncheckedIndexedAccess) both the defensive fallback and a `candidates[0]!` assertion type-check identically, so the finding has no production-readiness impact. Keeping the fallback minimizes churn. Validation (local, Windows PowerShell): - `npx prettier --check test/integration/resolvers/csharp.test.ts` -> clean - `npx tsc --noEmit` -> 0 errors - `REGISTRY_PRIMARY_CSHARP=1 npx vitest run test/integration/resolvers/csharp.test.ts` -> 200/200 - `REGISTRY_PRIMARY_CSHARP=0 npx vitest run test/integration/resolvers/csharp.test.ts` -> 200/200 - `npx vitest run test/integration/resolvers/python.test.ts` -> 204/204 - `npm test` (full gitnexus suite) -> 6967 passed, 6 pre-existing failures (4x Swift overload/dedup, 1x Swift method-extraction unit, 1x Swift type-env unit, 1x LadybugDB lockfile on Windows). All six reproduce on5a1bce7fwith these follow-up changes stashed, confirming they are environment/baseline failures unrelated to this work. Swift is not in MIGRATED_LANGUAGES so the merged Case 2 path cannot affect it. Refs: PR #1019 Made-with: Cursor * refactor(scope-resolution): address full-PR review findings on PR #1019 Resolves the two remaining findings from the code-review-swarm full-PR sweep (verdict: production-ready with minor follow-ups). [low] Complete the csharp/index.ts module-layout JSDoc. `languages/csharp/index.ts` is the discovery surface for the C# scope- resolution module decomposition (per AGENTS.md). The "Module layout" list silently omitted three load-bearing modules — `accessor-unwrap.ts` (`.Values`/`.Keys` receiver-type unwrap), `namespace-siblings.ts` (AST-driven cross-file implicit-namespace visibility), and `receiver-binding.ts` (`this`/`base` type-binding synthesis). Extended the JSDoc list so the "single-concern" decomposition story is honest and the next contributor can locate the right file without grep. No behavior change. [info] Replace the non-standard `'scope-resolution: super-receiver'` edge reason with the canonical `'global'` tier. `passes/receiver-bound-calls.ts` emitted a non-canonical reason string for the super/base branch, which falls outside the vocabulary declared in ARCHITECTURE.md § Scope-Resolution Pipeline (`'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`). Super/base calls resolve through the MRO chain rather than through import directives, so the correct canonical tier is `'global'` (same classification the legacy DAG's `toResolveResult` applies to non-same-file, non-import-scoped resolutions). Locked the contract with `rel.reason === 'global'` assertions on the existing `csharp-super-resolution` and `csharp-generic-parent- resolution` suites, both of which go through the super-branch MRO path. The `csharp-record-base` suite intentionally does not pin a reason (records don't currently emit EXTENDS edges, so the MRO lookup misses and the edge is produced by the reference-index fallback instead of the super-branch). A code comment flags the pre-existing Python-legacy asymmetry (Python legacy tier classifier marks `super()` as `'import-resolved'` because the ancestor arrives via an `import` statement); closing that gap requires realigning the legacy tier classifier and is tracked separately. Validation: - `npx tsc --noEmit` passes. - `npx prettier --check` clean on all four touched files. - `test/integration/resolvers/csharp.test.ts` — 200/200 under both `REGISTRY_PRIMARY_CSHARP=0` (legacy DAG) and `REGISTRY_PRIMARY_CSHARP=1` (registry-primary), preserving same-graph parity on the super branch. - `test/integration/resolvers/python.test.ts` — 204/204 under both `REGISTRY_PRIMARY_PYTHON=0` and `=1`. - `test/unit/scope-resolution/` — 406/406 passing. Unstaged: `gitnexus/package-lock.json` (drift from `npm install` run to resolve the pre-existing missing `jsonc-parser` dependency — not part of this change). Made-with: Cursor * test(ci): raise integration-test timeouts so slow Windows runners stop flaking The `windows-latest` CI runner for this branch was consistently failing two integration suites in ways that had nothing to do with the PR's scope-resolution changes: * `cli-e2e.test.ts` — `analyze command runs pipeline on mini-repo` hit the default 30 s vitest test timeout, which raced the test's own 30 s subprocess timeout and prevented the existing `if (result.status === null) return;` slow-CI tolerance from ever firing. That single timeout then cascaded into the downstream `cypher`/`query`/`impact` tests (which exited non-zero because the mini-repo was never indexed) and the `EPIPE handling` test. * `skills-e2e.test.ts` — `beforeAll` hooks run a full `runSkillsCli(tmpDir)` subprocess that analyzes a fixture repo and generates skills. 50 s was enough on Linux/macOS but not on slow Windows CPUs, producing "Hook timed out in 50000ms" errors and cascading test failures across every language describe block. Fix: * Bump the `analyze` test's vitest test-level timeout to 60 s so it exceeds the 30 s subprocess timeout and the slow-CI tolerance can actually activate. * Bump all 12 `runSkillsCli`-driven `beforeAll` hooks from 50 s to 120 s. No production-code behavior changes. No change to what the tests assert — only the per-test/hook wall-clock budget. Made-with: Cursor
2487 lines
99 KiB
TypeScript
2487 lines
99 KiB
TypeScript
/**
|
||
* Python: relative imports + class inheritance + ambiguous module disambiguation
|
||
*/
|
||
import { describe, it, expect, beforeAll } from 'vitest';
|
||
import path from 'path';
|
||
import {
|
||
FIXTURES,
|
||
CROSS_FILE_FIXTURES,
|
||
getRelationships,
|
||
getNodesByLabel,
|
||
getNodesByLabelFull,
|
||
edgeSet,
|
||
runPipelineFromRepo,
|
||
type PipelineResult,
|
||
} from './helpers.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Heritage: relative imports + class inheritance
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('Python relative import & heritage resolution', () => {
|
||
let result: PipelineResult;
|
||
|
||
beforeAll(async () => {
|
||
result = await runPipelineFromRepo(path.join(FIXTURES, 'python-pkg'), () => {});
|
||
}, 60000);
|
||
|
||
it('detects exactly 3 classes and 5 functions', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']);
|
||
expect(getNodesByLabel(result, 'Function')).toEqual([
|
||
'authenticate',
|
||
'get_name',
|
||
'process_model',
|
||
'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');
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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('detects User class and save function (Python methods are Function nodes)', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||
// Python tree-sitter captures all function_definitions as Function, including methods
|
||
expect(getNodesByLabel(result, 'Function')).toContain('save');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 functions', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||
// Python tree-sitter captures all function_definitions as Function
|
||
const saveFns = getNodesByLabel(result, 'Function').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');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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, 'Function').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, 'Function')).toContain('__init__');
|
||
expect(getNodesByLabel(result, 'Function')).toContain('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 function', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
|
||
const saveFns = getNodesByLabel(result, 'Function').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, 'Function')).toContain('save');
|
||
expect(getNodesByLabel(result, 'Function')).toContain('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, 'Function').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 functions', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||
const saveFns = getNodesByLabel(result, 'Function').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, 'Function').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, 'Function').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 function', () => {
|
||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
||
const saveFns = getNodesByLabel(result, 'Function').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, 'Function').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');
|
||
// Python tree-sitter captures all function_definitions as Function, including methods
|
||
expect(getNodesByLabel(result, 'Function')).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, 'Function');
|
||
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, 'Function')).toContain('save');
|
||
expect(getNodesByLabel(result, 'Function')).toContain('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 resolveCallTarget 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 Function nodes: save, verify, login', () => {
|
||
const fns = getNodesByLabel(result, 'Function');
|
||
expect(fns.length).toBe(3);
|
||
expect(fns).toContain('save');
|
||
expect(fns).toContain('verify');
|
||
expect(fns).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('detects all functions including methods', () => {
|
||
const fns = getNodesByLabel(result, 'Function');
|
||
expect(fns).toContain('format');
|
||
expect(fns).toContain('format_with_prefix');
|
||
expect(fns).toContain('format_text');
|
||
expect(fns).toContain('format_text_with_width');
|
||
expect(fns).toContain('run');
|
||
});
|
||
|
||
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: lookupMethodByOwnerWithMRO — 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');
|
||
});
|
||
});
|