GitNexus/gitnexus/test/unit/scope-resolution/python/python-fixtures.test.ts
Gergő Magyar 98ee665889
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
fix(ingestion): two-channel binding lifecycle (closes #1066) + scope-resolution I8 hardening (#1082)
* fix(csharp): adaptive tree-sitter buffer + frozen-bucket clone for cross-namespace siblings (#1066)

Two coupled regressions surfaced when analyzing real-world C# repos with
large source files (issue #1066):

1. Tree-sitter `parser.parse()` is hard-coded to a 32 KB buffer by
   default. Any file exceeding that threshold throws `Invalid argument`
   on the worker re-parse path of `populateCsharpNamespaceSiblings`
   (and the analogous Python / TypeScript captures fallbacks).
2. After the buffer fix unblocks the AST walk, the hook tries to
   `push()` onto the inner `BindingRef[]` array fetched from
   `indexes.bindings` — but `materializeBindings` froze that array via
   `Object.freeze(refs.slice())`. Result: `Cannot add property N,
   object is not extensible`.

Fixes:

- `csharp/captures.ts`, `python/captures.ts`, `typescript/captures.ts`:
  pass `bufferSize: getTreeSitterBufferSize(sourceText.length)` to
  `parser.parse()` on the cache-miss path so multi-MB files parse.
- `csharp/namespace-siblings.ts`: introduce `cloneBindingBucket` to
  copy the frozen array before mutating, then `set()` the new array
  back. This is a working but architecturally compromised workaround
  (#1050 follow-up will replace it with an explicit augmentation
  channel — see docs/plans/2026-04-26-001 plan).

Tests:

- New `csharp-large-cache-miss-resolution` fixture (Models/Services/
  Other layout, ~77 KB padded UserService.cs) drives the buffer-size
  failure end-to-end through worker mode.
- `csharp.test.ts`: 4 new regression assertions covering both the
  parse-time buffer-size failure and the freeze workaround.
- Per-language captures unit tests gain "large cache-miss file uses
  adaptive buffer" coverage (TS, Python, C#).
- `csharp-hooks.test.ts`: in-memory freeze regression test that
  reproduces the `Cannot add property` crash without invoking the C#
  parser at all.

Made-with: Cursor

* refactor(scope-resolution): add bindingAugmentations channel to indexes

Step 1 of the binding-augmentation-channel refactor (issue #1066
follow-up). Pure shape change — no consumers yet.

Adds a new `readonly bindingAugmentations` field to
`ScopeResolutionIndexes` initialized as an empty `Map` by
`finalizeScopeModel`. The new channel is the dedicated post-finalize
write target for hooks like `populateCsharpNamespaceSiblings`, so
`indexes.bindings` can stay frozen and finalize-owned.

Behavior unchanged: nothing reads or writes the new field yet. tsc and
the full unit suite remain green.

Plan: docs/plans/2026-04-26-001-binding-augmentation-channel.md (local
only — `docs/plans/` is gitignored).

Made-with: Cursor

* feat(scope-resolution): add lookupBindingsAt dual-source helper

Step 2 of the binding-augmentation-channel refactor. Introduces a
single primitive every walker uses to read both the finalize-owned
`indexes.bindings` channel and the post-finalize
`indexes.bindingAugmentations` channel.

Contract:
- Finalized refs come first (preserves existing precedence).
- Augmented refs append, deduped by `def.nodeId`.
- Empty input on both channels returns a shared frozen empty array.
- Single-channel hits return the bucket by reference (no allocation).

No consumers are wired yet — Step 3 routes the existing walker
primitives through this helper. Augmentations remain empty for every
language; behavior of the full suite is unchanged.

8 unit tests pin precedence, dedup, identity for single-channel hits,
and the shared-empty-frozen-array sentinel.

Made-with: Cursor

* refactor(scope-resolution): route binding lookups through lookupBindingsAt

Step 3 of the binding-augmentation-channel refactor. Every direct
`indexes.bindings.get(...)` consumer in the post-finalize phase is
now routed through `lookupBindingsAt` (per-name) or `namesAtScope`
+ `lookupBindingsAt` (bulk iteration).

Routed sites:
- `findClassBindingInScope` (walkers.ts) — class-receiver lookups.
- `findCallableBindingInScope` (walkers.ts) — free-call lookups.
- `findExportedDefByName` (walkers.ts) — module-scope-fallback
  callable lookups.
- `propagateImportedReturnTypes` (passes/imported-return-types.ts)
  — bulk iteration over an importer's binding entries; switched to
  `namesAtScope` + per-name `lookupBindingsAt` so post-finalize
  augmentations are visible to import-derived typeBinding mirrors.

Behavior unchanged: augmentations are empty across the suite (Step 4
populates them for C# `populateNamespaceSiblings`). 587
scope-resolution unit tests + 50 integration resolver suites green
(4 pre-existing Swift method-implements failures unrelated to this
work).

Adds `namesAtScope` companion helper for the bulk-iteration callers.

Made-with: Cursor

* refactor(csharp): write namespace siblings to bindingAugmentations channel

Step 4 of the binding-augmentation-channel refactor. The C#
`populateNamespaceSiblings` hook is the only consumer that needed
to inject cross-file bindings post-finalize, and prior to this
change it cloned the (frozen) finalized `BindingRef[]` arrays
through a `cloneBindingBucket` helper, then `set()`-back the new
array — a workaround for the `Object.freeze` applied by
`finalize-algorithm.ts` (issue #1066 root cause).

Architecturally that violated `ScopeResolver` Invariant I8 (which
permits post-finalize modifications but not in-place mutation of
finalized buckets). It also forced read-side consumers to be aware
of the workaround.

This change:
* Switches the three C# write sites to append into
  `indexes.bindingAugmentations` via `getAugmentationBucket`. The
  augmentation channel was added in Step 1 and is mutable by
  contract: inner `BindingRef[]` arrays here are NEVER frozen.
* Deletes `cloneBindingBucket` and `getMutableScopeBindings`
  (workaround helpers no longer needed).
* `lookupBindingsAt` (Step 2) merges the two channels transparently
  for every walker (Step 3), so behavior is unchanged for callers.
* Updates the unit test to assert against both channels: finalized
  bucket stays frozen and untouched, cross-file siblings show up in
  augmentations only. Renamed the test accordingly.

Validation:
* `npx tsc --noEmit` clean.
* csharp hooks unit + walkers-augmentations unit + csharp integration
  resolver suite all green (236/236).
* Wider `test/unit/scope-resolution test/integration/resolvers`
  suite: 2507 pass, only 4 pre-existing Swift METHOD_IMPLEMENTS
  failures remain (unrelated to this work, present on baseline).

Refs: issue #1066, ADR-pending binding-augmentation-channel.
Made-with: Cursor

* feat(scope-resolution): tighten I8 + add validateBindingsImmutability dev guard

Step 5 of the binding-augmentation-channel refactor. Captures the
new two-channel binding lifecycle in the contract docs and adds a
dev-mode runtime validator so a future hook cannot silently drift
back into mutating `indexes.bindings`.

Contract changes:
* `contract/scope-resolver.ts` — rewrote Invariant I8 to describe
  the two channels (`indexes.bindings` is finalize-output and
  immutable post-finalize; `indexes.bindingAugmentations` is the
  append-only post-finalize channel populated by hooks like
  `populateNamespaceSiblings`). Documented `lookupBindingsAt` as
  the read-side merger and pointed at the new validator as the
  enforcement mechanism.
* `gitnexus-shared/src/scope-resolution/types.ts` — extended the
  module-header lifecycle contract to call out
  `bindingAugmentations` alongside `ReferenceIndex` as the two
  structures populated after the freeze.

Validator:
* New `pipeline/validate-bindings-immutability.ts` mirrors the
  shape of `validateOwnershipParity` (#909): runs only when
  `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`,
  emits via `onWarn`, never throws. Asserts (a) every inner
  `BindingRef[]` in `indexes.bindings` is `Object.isFrozen`, and
  (b) every inner array in `indexes.bindingAugmentations` is NOT
  frozen.
* Wired into `pipeline/run.ts` after both
  `populateNamespaceSiblings` and `propagateImportedReturnTypes`,
  before `resolveReferenceSites`. One sweep covers the full
  post-finalize surface.

Tests:
* `validate-bindings-immutability.test.ts` — 6 cases pinning happy
  path, both drift directions, multi-violation accumulation, and
  both production no-op gates.

All scope-resolution + csharp resolver tests green (242/242 in the
focused run; matches the wider Step 4 baseline).

Made-with: Cursor

* fix(ingestion): size tree-sitter buffers from UTF-8 bytes

Tree-sitter buffer sizing is byte-based, so computing adaptive buffers from JavaScript string length under-sized UTF-8-heavy files. Make getTreeSitterBufferSize accept source text directly and compute Buffer.byteLength internally, then update all parse call sites and max-buffer skip checks to use byte length.

Add multibyte cache-miss and cap regressions for C#, Python, TypeScript, and the C# namespace-sibling fallback parse path.

Made-with: Cursor

* test(scope-resolution): pin augmentation read paths

Add focused unit coverage for augmented-only binding reads across the routed walker helpers and imported-return-type propagation path. Clarify I8 wording around lexical Scope.bindings versus post-finalize index channels, and document the intentional local-only behavior of findExportedDef.

Also switch the immutability validator tests to Vitest env stubs, document one intentional validator blind spot, and split C# namespace-sibling tests so UTF-8 parsing and augmentation-channel behavior are asserted independently.

Made-with: Cursor

* test(scope-resolution): avoid slow parser stress fixtures

Replace high-cardinality large-file capture fixtures with large padding plus a trailing declaration. This still proves adaptive tree-sitter buffers parse beyond large ASCII and UTF-8-heavy input, without making query matching process thousands of declarations and risking timeouts.

Made-with: Cursor

* test(scope-resolution): add python and typescript cache-miss resolver regressions

Add worker-mode resolver integration coverage mirroring the C# #1066 scenario for Python and TypeScript. Each test builds a temp fixture with large ASCII and UTF-8-heavy source padding, then asserts trailing declarations and call edges still resolve after scope-resolution cache-miss reparsing.

Made-with: Cursor

* refactor(scope-resolution): gate I8 validator and fast-path namesAtScope

Addresses SPARC reviewer feedback on the binding-augmentation channel:

- Validator gate is now opt-in outside development. Extract
  isSemanticModelValidatorEnabled() in utils/env.ts as the single
  predicate; both validateBindingsImmutability and phase.ts's warn
  handler share it. Default CLI runs no longer pay the O(binding-buckets)
  scan, and explicit VALIDATE_SEMANTIC_MODEL=1 now emits warnings even
  when NODE_ENV is unset.
- namesAtScope returns Iterable<string> and zero-allocates when at most
  one channel is populated (returns Map.keys() directly), only
  materializing a Set when both channels carry names. The caller-side
  branching and EMPTY_NAMES escape hatch in propagateImportedReturnTypes
  are gone -- both helpers handle the empty-augmentation case internally.
- C# namespace-siblings header/JSDoc, model JSDoc, I8 contract prose, and
  the #1066 integration-test header rewritten to say post-finalize fanout
  appends only to bindingAugmentations; finalized refs come first and win
  duplicate def.nodeId metadata; local lexical Scope.bindings remains the
  first-tier shadowing channel.

Validator unit-test setup deduplicated via beforeEach and extended with
default-CLI no-op + explicit-opt-in cases.

Made-with: Cursor
2026-04-26 12:16:09 +01:00

381 lines
15 KiB
TypeScript

/**
* End-to-end fixture tests for the Python scope-resolution migration
* (RFC #909 Ring 3, RFC §5.1 — first-rollout language).
*
* Each fixture:
* 1. Drives `extractPythonScopeCaptures` on a real Python source string.
* 2. Threads the captures through the central `ScopeExtractor` (via
* `extractParsedFile`) — exactly the path `parse-worker.ts`
* executes at ingest time.
* 3. Asserts on the resulting `ParsedFile` (scopes / declarations /
* imports / type bindings / reference sites).
*
* Coverage matrix (≥30 cases, per Ring 3 deliverables):
*
* * Module / function / class scope construction
* * No-block-scope semantics (if / for / while / with / try)
* * Class- and function-local declarations + variables
* * Imports: plain, aliased, multi-target, from, from-as, multi-from,
* wildcard, dotted-relative
* * Function-local imports
* * Receiver type binding: `self` for instance methods, `cls` for
* classmethods; no binding for `@staticmethod`; no binding for free
* functions
* * Parameter type annotations (typed_parameter / typed_default_parameter
* / forward-ref strings)
* * Call references: free vs member, with explicit-receiver capture
* * `global` / `nonlocal` no-op behaviour (documented gap)
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
// ─── Test helper ───────────────────────────────────────────────────────────
function parse(src: string, filePath = 'test.py'): ParsedFile {
const result = extractParsedFile(pythonProvider, src, filePath);
if (result === undefined) {
throw new Error(
`extractParsedFile returned undefined for:\n${src}\n— check warnings or capture shape`,
);
}
return result;
}
function scopesByKind(file: ParsedFile, kind: string) {
return file.scopes.filter((s) => s.kind === kind);
}
function findDef(file: ParsedFile, name: string) {
return file.localDefs.find((d) => d.qualifiedName === name);
}
// ─── Pass 1: scope tree ────────────────────────────────────────────────────
describe('Python scopes — module / class / function', () => {
it('case 01: minimal module produces a single Module scope', () => {
// Empty source produces a zero-range module node; the central
// extractor treats zero-range scopes as malformed (and rightly so —
// they collide with sibling-overlap detection on subsequent reparses).
// Real Python files always have at least a newline.
const f = parse('pass\n');
expect(f.scopes).toHaveLength(1);
expect(f.scopes[0]!.kind).toBe('Module');
});
it('case 01b: large cache-miss files use the adaptive tree-sitter buffer', () => {
const padding = 'x'.repeat(600 * 1024);
const f = parse(`# ${padding}\ndef after_padding():\n return 1\n`);
expect(scopesByKind(f, 'Module')).toHaveLength(1);
expect(findDef(f, 'after_padding')?.type).toBe('Function');
});
it('case 01c: UTF-8-heavy cache-miss files use byte-sized parser buffers', () => {
const padding = '漢'.repeat(190_000);
const f = parse(`# ${padding}\ndef after_padding():\n return 1\n`);
expect(scopesByKind(f, 'Module')).toHaveLength(1);
expect(findDef(f, 'after_padding')?.type).toBe('Function');
});
it('case 02: module-level assignment produces a Variable declaration in Module scope', () => {
const f = parse('x = 1\n');
expect(scopesByKind(f, 'Module')).toHaveLength(1);
expect(findDef(f, 'x')?.type).toBe('Variable');
});
it('case 03: top-level def produces a Function scope under Module', () => {
const f = parse('def foo():\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(fn.parent).toBe(mod.id);
expect(findDef(f, 'foo')?.type).toBe('Function');
});
it('case 04: top-level class produces a Class scope under Module', () => {
const f = parse('class A:\n pass\n');
const cls = scopesByKind(f, 'Class')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(cls.parent).toBe(mod.id);
expect(findDef(f, 'A')?.type).toBe('Class');
});
it('case 05: method nests Function under Class under Module', () => {
const f = parse('class A:\n def m(self):\n pass\n');
const mod = scopesByKind(f, 'Module')[0]!;
const cls = scopesByKind(f, 'Class')[0]!;
const fn = scopesByKind(f, 'Function')[0]!;
expect(cls.parent).toBe(mod.id);
expect(fn.parent).toBe(cls.id);
});
it('case 06: nested function nests Function under Function', () => {
const f = parse('def outer():\n def inner():\n pass\n');
const fns = scopesByKind(f, 'Function');
expect(fns).toHaveLength(2);
const outer = fns.find((s) => s.range.startLine === 1)!;
const inner = fns.find((s) => s.range.startLine === 2)!;
expect(inner.parent).toBe(outer.id);
});
});
// ─── Pass 1: no block scope ────────────────────────────────────────────────
describe('Python scopes — no block scope (PEP language reference)', () => {
it('case 07: `if` body does NOT create a scope; declarations land in enclosing fn', () => {
const f = parse('def f():\n if True:\n x = 1\n');
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.bindings.has('x')).toBe(true);
});
it('case 08: `for` target binds in enclosing function scope, not in for body', () => {
const f = parse('def f():\n for i in range(10):\n pass\n');
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.bindings.has('i')).toBe(true);
});
it('case 09: `while`/`try`/`with` bodies do not produce Block scopes', () => {
const f = parse(
`def f():
while True:
a = 1
try:
b = 2
except Exception:
c = 3
with open('x') as fh:
d = 4
`,
);
expect(scopesByKind(f, 'Block')).toHaveLength(0);
const fn = scopesByKind(f, 'Function')[0]!;
for (const name of ['a', 'b', 'c', 'd']) expect(fn.bindings.has(name)).toBe(true);
});
});
// ─── Pass 3: imports ──────────────────────────────────────────────────────
describe('Python imports — interpretImport', () => {
it('case 10: `import numpy` → namespace import', () => {
const f = parse('import numpy\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' },
]);
});
it('case 11: `import numpy as np` → namespace import with rename', () => {
const f = parse('import numpy as np\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' },
]);
});
it('case 12: `import a.b.c` exposes the leading segment as the local name', () => {
const f = parse('import a.b.c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a.b.c', targetRaw: 'a.b.c' },
]);
});
it('case 13: `import a, b as c` decomposes into one ParsedImport per name', () => {
const f = parse('import a, b as c\n');
expect(f.parsedImports).toEqual([
{ kind: 'namespace', localName: 'a', importedName: 'a', targetRaw: 'a' },
{ kind: 'namespace', localName: 'c', importedName: 'b', targetRaw: 'b' },
]);
});
it('case 14: `from m import x` → named import', () => {
const f = parse('from m import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm' },
]);
});
it('case 15: `from m import x as y` → alias import', () => {
const f = parse('from m import x as y\n');
expect(f.parsedImports).toEqual([
{ kind: 'alias', localName: 'y', importedName: 'x', alias: 'y', targetRaw: 'm' },
]);
});
it('case 16: `from m import x, y, z` decomposes into three ParsedImports', () => {
const f = parse('from m import x, y, z\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm' },
{ kind: 'named', localName: 'y', importedName: 'y', targetRaw: 'm' },
{ kind: 'named', localName: 'z', importedName: 'z', targetRaw: 'm' },
]);
});
it('case 17: `from m import *` → wildcard', () => {
const f = parse('from m import *\n');
expect(f.parsedImports).toEqual([{ kind: 'wildcard', targetRaw: 'm' }]);
});
it('case 18: PEP-328 dotted relative import `from .pkg import x`', () => {
const f = parse('from .pkg import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '.pkg' },
]);
});
it('case 19: PEP-328 parent-relative import `from ..pkg.sub import x`', () => {
const f = parse('from ..pkg.sub import x\n');
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: '..pkg.sub' },
]);
});
});
// ─── Imports inside functions ─────────────────────────────────────────────
describe('Python imports — function-local', () => {
it('case 20: function-local `from x import Y` is captured (visible to importOwningScope)', () => {
const f = parse('def loader():\n from m import X\n');
// Decomposed at parse time; finalize will route via importOwningScope.
expect(f.parsedImports).toEqual([
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
]);
});
});
// ─── Pass 4: type bindings ────────────────────────────────────────────────
describe('Python type bindings — parameter annotations + self/cls', () => {
it('case 21: typed parameter `def f(x: User)` binds x → User on function scope', () => {
const f = parse('def f(x: User):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const tb = fn.typeBindings.get('x');
expect(tb).toBeDefined();
expect(tb!.rawName).toBe('User');
expect(tb!.source).toBe('parameter-annotation');
});
it('case 22: typed default parameter `def f(x: int = 0)` is captured', () => {
const f = parse('def f(x: int = 0):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('x')?.rawName).toBe('int');
});
it('case 23: forward-ref string `def f(x: "User")` is unquoted', () => {
const f = parse('def f(x: "User"):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('x')?.rawName).toBe('User');
});
it('case 24: instance method gets self → ClassName as `self` source', () => {
const f = parse('class A:\n def m(self):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
const self = fn.typeBindings.get('self');
expect(self).toBeDefined();
expect(self!.rawName).toBe('A');
expect(self!.source).toBe('self');
});
it('case 25: `@classmethod`-decorated method gets cls → ClassName', () => {
const f = parse(
`class A:
@classmethod
def make(cls):
pass
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.get('cls')?.rawName).toBe('A');
expect(fn.typeBindings.has('self')).toBe(false);
});
it('case 26: `@staticmethod`-decorated method gets NO implicit receiver', () => {
const f = parse(
`class A:
@staticmethod
def util(x):
pass
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.has('self')).toBe(false);
expect(fn.typeBindings.has('cls')).toBe(false);
});
it('case 27: free function gets NO `self`/`cls` binding', () => {
const f = parse('def free(x):\n pass\n');
const fn = scopesByKind(f, 'Function')[0]!;
expect(fn.typeBindings.has('self')).toBe(false);
expect(fn.typeBindings.has('cls')).toBe(false);
});
it('case 28: nested function inside method does NOT inherit `self`', () => {
const f = parse(
`class A:
def m(self):
def inner():
pass
`,
);
const inner = scopesByKind(f, 'Function').find((s) => s.range.startLine === 3)!;
expect(inner.typeBindings.has('self')).toBe(false);
});
});
// ─── Pass 5: reference sites ──────────────────────────────────────────────
describe('Python reference sites — calls', () => {
it('case 29: free call `print(x)` records a call reference', () => {
const f = parse('def f():\n print(1)\n');
const calls = f.referenceSites.filter((r) => r.kind === 'call');
expect(calls.some((c) => c.name === 'print' && c.callForm === 'free')).toBe(true);
});
it('case 30: member call `obj.save()` records explicit receiver `obj`', () => {
const f = parse('def f(obj):\n obj.save()\n');
const member = f.referenceSites.find((r) => r.kind === 'call' && r.name === 'save')!;
expect(member.callForm).toBe('member');
expect(member.explicitReceiver).toEqual({ name: 'obj' });
});
it('case 31: chained member call `a.b.c()` captures `c` with receiver `a.b`', () => {
const f = parse('def f(a):\n a.b.c()\n');
const member = f.referenceSites.find((r) => r.kind === 'call' && r.name === 'c')!;
expect(member.callForm).toBe('member');
expect(member.explicitReceiver?.name).toBe('a.b');
});
});
// ─── global / nonlocal — documented under-reporting ───────────────────────
describe('Python `global`/`nonlocal` — documented behavior', () => {
it('case 32: `global x` inside a function does NOT promote the binding to module scope', () => {
// Documented limitation: the assignment lexically lives in `f`, so
// we attach `x` to f's scope. A future Ring may re-bind via
// bindingScopeFor; for Ring 3 this is expected behavior.
const f = parse(
`x = 0
def f():
global x
x = 1
`,
);
const fn = scopesByKind(f, 'Function')[0]!;
const mod = scopesByKind(f, 'Module')[0]!;
expect(mod.bindings.has('x')).toBe(true); // module-level x = 0
expect(fn.bindings.has('x')).toBe(true); // local x = 1 — under-reported as fn-local
});
it('case 33: `nonlocal x` inside a closure does NOT lift binding to enclosing fn', () => {
const f = parse(
`def outer():
x = 0
def inner():
nonlocal x
x = 1
`,
);
const inner = scopesByKind(f, 'Function').find((s) => s.range.startLine === 3)!;
expect(inner.bindings.has('x')).toBe(true); // under-reported
});
});