mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-19 00:03:33 +00:00
* fix(python): resolve calls through `__init__.py` re-exports
A call to a name imported from a package never resolved when the package's
`__init__.py` re-exported it rather than defining it:
pkg/impl.py def target_fn(x): ...
pkg/__init__.py from pkg.impl import target_fn
caller.py from pkg import target_fn
def calls_it(): return target_fn(21) # no CALLS edge
`caller.py` gets no CALLS edge. Both IMPORTS hops are recorded, and all four
functions are extracted as nodes — only the call binding is missing. Because
`__init__.py` re-exports are how Python packages declare a public surface, this
misses a large fraction of real call edges, and the failure is silent: the
defining file looks like dead code with zero callers.
The re-export closure that should carry this already exists and is fully general
(`buildReexportClosures` — SCC over the re-export subgraph, bounded fixpoint for
cycles, transitive `via` chains). Python just never fed it: the subgraph admits
only `kind: 'reexport'` and `kind: 'wildcard'`, and Python emits neither for
`from m import x`.
Python has no dedicated re-export form. A module-level `from pkg.impl import X`
binds X locally AND publishes it as `pkg.X`, so it is both a named import and a
re-export. Emitting `kind: 'reexport'` would be wrong — that form drops the local
binding, which Python's does create. Instead add an optional `reexportsName` flag
to the `named`/`alias` variants, alongside the existing provider-specific
`importedSymbolKind` / `targetIncludesImportedName` flags, and admit flagged
imports into the closure subgraph. Languages with an explicit form keep emitting
`kind: 'reexport'` and leave the flag unset, so nothing changes for them — a
negative-control test asserts a plain named import still does not resolve.
Verified on a fixture covering the three shapes (direct, top-level-via-re-export,
function-local-via-re-export): 1 of 3 CALLS edges resolved before, 3 of 3 after.
On a 12.4k-file Python/Go/TypeScript repository: edges 294,416 -> 301,443
(+7,027) and execution flows 300 -> 813. A previously "100% orphaned" module
(`shared/db/event_writer.py`) now correctly reports its caller.
5 new finalize tests (single hop, 3-hop chain, alias keying, cycle termination,
and the negative control) plus 6 updated Python fixture shapes.
`npx tsc --noEmit` clean in both packages; full unit suite shows no regression
against baseline (remaining failures are pre-existing load-sensitive flakes in
analyzer-identity / evidence-provenance-helper / skip-git-cli / hooks, each
verified passing in isolation).
* fix(python): set reexportsName only for module-level imports
`interpretPythonImport` flagged every `from m import x` as republishing the
name, but only a module-level statement does. A `from m import X` inside a
`def` or `class` body binds locally and puts nothing in the module namespace,
so flagging it fabricates a re-export of a name no importer can reach:
# pkg/__init__.py
def loader():
from pkg.impl import InternalHelper
# caller.py
from pkg import InternalHelper # CPython: ImportError
resolved to `def:pkg.impl.InternalHelper`. Worse, with declaration-order
first-wins in the closure, a scope-blind entry could claim a name ahead of the
real module-level import and give a WRONG def for legal, running code.
`interpretImport` receives a `CaptureMatch`, which is `{name, range, text}`
with no syntax node, so the scope is not recoverable there — and it is not
recoverable downstream either: `pass3CollectImports` applies no scope filter
and `ImportEdgeDraft.fromScope` is hardcoded to the module scope. The decision
therefore moves up to `import-decomposer.ts`, which still holds the live
`import_from_statement` node, and rides down as an `@import.publishes` marker.
Computed once per statement, not once per imported name, with the existing
`findAncestorBeforeBoundary` helper.
Only `function_definition` and `class_definition` suppress publication.
`if` / `try` / `for` / `with` do NOT — Python has no block scope — so the
predicate is an ancestor walk for those two node types and nothing else.
Verified against CPython 3.11 in both directions; both are now pinned by
tests, including the counterpart control that a branch-nested import still
republishes.
Also corrects the docblock in `scope-extractor.ts` that sent this change the
wrong way. It claims pass 3 attaches imports "not to any `Scope` — finalize
reconstructs the owning scope via `provider.importOwningScope` during Phase
2". Finalize does no such thing: `importOwningScope` is declared on
`LanguageProvider` and implemented by a dozen providers, and
`grep -rnE "\.importOwningScope\b" gitnexus/src/` returns exactly one hit —
that doc comment. Nothing invokes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz
* fix(shared): stop guessing ambiguous and namespace re-exports; bound the via chain
Four changes to the re-export closure, all reachable only now that Python
feeds it.
1. AMBIGUOUS NAMES ARE DROPPED, NOT GUESSED. `populateFileClosure` documented
"declaration order first-wins for duplicates of the same exported name",
which is sound only where a duplicate export is illegal — two
`export { X } from …` is a TypeScript compile error, so the rule never
fires. Python has no such guarantee:
from .v1 import Client # legacy, left behind
from .v2 import Client # the actual public Client
CPython binds v2 (verified on 3.11); first-wins attributed every
`from pkg import Client` in the repo to the DEAD implementation, and
`impact("Client")` pointed at the wrong file. Last-wins is not the fix
either: for the equally common `try:`/`except ImportError:` and
`if sys.version_info` pairs exactly one branch runs, and which one is not
decidable here. Both directions are wrong on real code, so the entry is
dropped — the importer stays unresolved, which is exactly the pre-#2864
answer, and the file-level IMPORTS edge is untouched.
`collectAmbiguousReexports` runs as a PRE-PASS over data phase 0 froze,
so the poisoned set is constant across the fixpoint. That matters: a set
that grew mid-fixpoint would need retraction to propagate to files that
already inherited the name, would make `myClosure.size > before` an
unsound progress signal, and would invalidate the `|SCC| + 1` cap. As a
pre-pass the closure map stays monotone and every existing termination
argument survives unchanged. Only two flagged drafts resolving to two
DIFFERENT in-workspace files count; duplicates of one target are
harmless, and unresolvable targets never entered the closure.
Checked in both loops. Named re-exports take precedence over wildcards,
so suppressing only the named loop would hand the name to a later
`import *` and reinstate an arbitrary winner through the back door.
2. NAMESPACE-RECLASSIFIED DRAFTS ARE EXCLUDED. The admission guards tested
`draft.source.kind` while `tryFinalize` tests the post-reclassification
`draft.base.kind`. Python's `from . import logger` is emitted as `named`,
reclassified to `namespace` by `isNamespaceImport`, and was still
admitted — republishing whatever def shared the module's simple name. For
a `logger.py` holding a module-level `logger = logging.getLogger(...)`,
importers of `from pkg import logger` bound to that Variable instead of
the module. Reproduced end to end. Both predicates now take the draft and
test `base.kind`; this is a no-op for TS/Rust, whose only
`isNamespaceImport` implementation is Python's.
3. `transitiveVia` IS CAPPED AT 32. Each hop copies the inherited path, so
an unbounded chain is Theta(depth^2) in time AND retained memory, and
Theta(|SCC|^2) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH =
100` covered this until fc919ad6 removed it — correct for the shallow
TypeScript barrels that were then the only input, and invisible until the
input class changed. Measured at depth 400: 67 ms / 145 MB uncapped vs
25 ms / 40 MB capped. 32 against a real-world worst case of ~6 for
`__init__.py` chains. Safe because `ImportEdge.transitiveVia` has no
production reader — it is diagnostic provenance, emitted and typed but
dropped by graph emission.
4. `localDefs` ARE INDEXED BY SIMPLE NAME. `findExportByName` linearly
scanned a target's defs on every call, and the phase-3 fixpoint rescans
the same target once per iteration. Memoized on the array identity, which
`FinalizeFile` documents as static input. Worth 12-14% where lookups
repeat and neutral elsewhere.
The 46-line algorithm docblock was also ORPHANED by the helpers inserted
between it and `buildReexportClosures` — AST-verified, that function had zero
jsdoc blocks, so the cross-reference elsewhere in the file landed on an
undocumented function. Helpers move below it (declarations hoist), and its
step 1, precedence and complexity sections are rewritten: they still claimed
regular imports do not contribute to the export surface, and justified the
via-copy cost by TypeScript barrels being shallow.
The `reexportsName` contract consolidates onto `ParsedImport`, where its
"`kind: 'reexport'` would drop the local binding" rationale is corrected —
`materializeBindings` creates a module-scope binding for every linked edge,
re-export included. The real reasons are that `origin` flips, changing
evidence weight and priority, and that it misreports Python's syntax.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz
* test(shared): add a re-export closure scaling guard to CI
No bench covered `buildReexportClosures` at all. Until #2864 its input was
TypeScript barrel files — a handful of shallow edges — and it admitted only
`reexport` and `wildcard` drafts. It now admits every module-level Python
`from m import x`, measured ~20x more edges on the CPython stdlib and cyclic
SCCs where there were none. The pass went from "rarely runs" to "runs over
the whole named import graph" with nothing watching it.
The regression this guards has already happened once: fc919ad6 removed
`MAX_REEXPORT_DEPTH`, which was correct for shallow barrels and stayed
invisible for as long as the input stayed shallow.
The depth arm is an EXACT structural assertion — build a chain far past the
cap, assert the longest emitted `transitiveVia` is exactly `MAX_VIA_LENGTH`.
It started as a `depth_ratio` timing arm and that was a bad gate: sampled
five times capped it scored 2.71-3.52 and three times uncapped 5.87-7.65, so
the ranges nearly touch and one uncapped run came in UNDER budget. A gate
that passes a third of the time on a broken build is worse than none, because
it gets read as evidence. The structural form fails 3/3 with 401 vs 32.
`width_ms` stays a timing arm with a deliberately loose budget, because a
structural check cannot see a constant factor: restoring a per-lookup linear
scan of `localDefs` leaves every array length untouched while making every
real analyze slower.
Both arms drive `finalize` through INDEXED hooks. Reusing the unit tests'
`defaultHooks` is the trap — its `resolveImportTarget` does `files.some(...)`
per import, which is O(imports x files) in the FIXTURE and swamps the pass so
completely that removing the cap measures as no change at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz
* fix(cache): bump SCHEMA_BUMP 53 -> 60 for ParsedImport.reexportsName
`reexportsName` is a new field on `ParsedImport`, and `parsedfile-store.ts`
serializes the whole `ParsedFile` generically — so it is part of the cached
shape even though it is not a capture, which is the easy-to-miss variant of
the rule `parse-cache.ts` states as a MUST. (The `@import.publishes` marker
added alongside it moves the capture output too, so this qualifies twice; the
python captures golden confirms the drift.)
Without the bump, a warm `parsedfile-cache` replays pre-fix `ParsedImport`s
carrying no flag, `isNamedReexport`'s strict `=== true` takes the old path,
and the entire fix is a SILENT NO-OP on incremental analyze while every
cold-run test passes. It lands hardest on `__init__.py` — the rarest-changing,
highest-cache-hit files in a Python repo, i.e. exactly the target. A published
npm release invalidates via `GITNEXUS_PKG_VERSION`; dev trees, main-HEAD
installs and CI with a restored cache dir do not.
60, not 54, because the value has to clear every in-flight claim rather than
just origin/main: main is at 53 while open PR #2899 claims 54 and #2891 claims
59. Five exact clashes are recorded in the ledger, and the pin test cannot
detect a tie — both sides assert the same number and both pass. RE-CHECK
against origin/main immediately before merging.
Also documents the divergence between `pythonFileExportsName` and the
re-export closure. That predicate answers "does this package expose X?" from
`localDefs` alone, so with `pkg/__init__.py: from .impl import log`,
`pkg/impl.py: def log` and a same-named `pkg/log.py`, `from pkg import log`
still targets the submodule and the closure is never consulted — for exactly
the case it was built for.
Deliberately NOT fixed by reusing the flag, which is the obvious three-line
change and is WRONG: `reexportsName` is also set for `from . import log`,
where CPython binds `pkg.log` to the MODULE, not a name (verified on 3.11
against the `from .impl import log` form, which binds the function). Returning
true there would kill the correct namespace edge. Separating the two needs the
re-export's own resolved target — i.e. re-entering `resolvePythonImportTarget`
from a different `fromFile` — and that classification is the subject of open
issue #2882, so it belongs with that fix. Not a regression: both halves behave
exactly as they did before #2864.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz
* test(python): re-baseline the scope-capture fingerprint for @import.publishes
CI's `bench/python-scope/measure.mjs --check` failed on capture fingerprint
drift. Intentional: the module-level marker added for `reexportsName` is a new
synthetic capture, and that guard hashes `tag|text|range` over every
`emitPythonScopeCaptures` output.
Attributed before re-baselining rather than after. Reverting ONLY the
`@import.publishes` emission — nothing else — restores the previous hash
a0da3e7c exactly, so the whole drift is that one marker. `capture_groups_fp`
is 3246 either way and `scaling_ratio` stays ~1.0, so no capture group
appeared or vanished and the pass is still linear.
The other nine bench guards were run rather than assumed: scope-capture,
callable-value-flow, finalize-reexport, cpp-qualified-ns,
kotlin-import-target, receiver-resolution, scope-emission, import-target and
cfg all pass. The benchmarks job runs under `-e`, so this failure masked
whatever followed it — worth checking the rest before pushing a one-line
baseline change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz
---------
Co-authored-by: Carter LaSalle <carterlasalle@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
446 lines
18 KiB
TypeScript
446 lines
18 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', () => {
|
|
const f = parse('pass\n');
|
|
expect(f.scopes).toHaveLength(1);
|
|
expect(f.scopes[0]!.kind).toBe('Module');
|
|
});
|
|
|
|
it('case 01a: empty and whitespace-only files are skipped without warnings', () => {
|
|
for (const src of ['', ' \n\t']) {
|
|
const warnings: string[] = [];
|
|
const parsed = extractParsedFile(pythonProvider, src, 'pkg/__init__.py', (msg) => {
|
|
warnings.push(msg);
|
|
});
|
|
|
|
expect(parsed).toBeUndefined();
|
|
expect(warnings).toEqual([]);
|
|
}
|
|
});
|
|
|
|
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 01d: scope query failures are skipped through the bridge with context', () => {
|
|
const warnings: string[] = [];
|
|
const parsed = extractParsedFile(
|
|
pythonProvider,
|
|
'def broken():\n return 1\n',
|
|
'broken.py',
|
|
(msg) => warnings.push(msg),
|
|
{ rootNode: undefined },
|
|
);
|
|
|
|
expect(parsed).toBeUndefined();
|
|
expect(warnings.join('\n')).toMatch(/tree-sitter scope query failed for broken\.py/);
|
|
});
|
|
|
|
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);
|
|
expect(findDef(f, 'm')?.type).toBe('Method');
|
|
});
|
|
|
|
it('case 06: nested function remains Function when declared inside a method body', () => {
|
|
const f = parse('class A:\n def m(self):\n def inner():\n pass\n');
|
|
const fns = scopesByKind(f, 'Function');
|
|
expect(fns).toHaveLength(2);
|
|
const outer = fns.find((s) => s.range.startLine === 2)!;
|
|
const inner = fns.find((s) => s.range.startLine === 3)!;
|
|
expect(inner.parent).toBe(outer.id);
|
|
expect(findDef(f, 'm')?.type).toBe('Method');
|
|
expect(findDef(f, 'inner')?.type).toBe('Function');
|
|
});
|
|
});
|
|
|
|
// ─── 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');
|
|
// `reexportsName`: Python republishes the name as `<module>.x`, so it must
|
|
// enter the re-export closure for `from <module> import x` elsewhere.
|
|
expect(f.parsedImports).toEqual([
|
|
{ kind: 'named', localName: 'x', importedName: 'x', targetRaw: 'm', reexportsName: true },
|
|
]);
|
|
});
|
|
|
|
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',
|
|
reexportsName: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
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', reexportsName: true },
|
|
{ kind: 'named', localName: 'y', importedName: 'y', targetRaw: 'm', reexportsName: true },
|
|
{ kind: 'named', localName: 'z', importedName: 'z', targetRaw: 'm', reexportsName: true },
|
|
]);
|
|
});
|
|
|
|
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', reexportsName: true },
|
|
]);
|
|
});
|
|
|
|
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',
|
|
reexportsName: true,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── Imports inside functions ─────────────────────────────────────────────
|
|
|
|
describe('Python imports — function-local', () => {
|
|
it('case 20: function-local `from x import Y` is captured but does NOT republish', () => {
|
|
const f = parse('def loader():\n from m import X\n');
|
|
// No `reexportsName`: a function-body import binds `X` locally and puts
|
|
// nothing in the module namespace, so `from <this module> import X`
|
|
// elsewhere is an ImportError. Verified against CPython 3.11.
|
|
expect(f.parsedImports).toEqual([
|
|
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
|
|
]);
|
|
});
|
|
|
|
it('case 21: class-body `from x import Y` does NOT republish either', () => {
|
|
const f = parse('class C:\n from m import X\n');
|
|
// `class C: from m import X` makes `X` a class attribute (`C.X`), not a
|
|
// module attribute — same suppression as a function body.
|
|
expect(f.parsedImports).toEqual([
|
|
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'm' },
|
|
]);
|
|
});
|
|
|
|
it('case 22: `if` / `try` / `for` bodies DO republish — Python has no block scope', () => {
|
|
// The counterpart negative control: these are still module-level bindings
|
|
// in CPython, so narrowing the flag to "top level" must not narrow it to
|
|
// "first indentation level". Verified against CPython 3.11.
|
|
const f = parse(
|
|
'if TYPE_CHECKING:\n from m import A\ntry:\n from m import B\nexcept ImportError:\n B = None\nfor _ in r:\n from m import C\n',
|
|
);
|
|
expect(f.parsedImports).toEqual([
|
|
{ kind: 'named', localName: 'A', importedName: 'A', targetRaw: 'm', reexportsName: true },
|
|
{ kind: 'named', localName: 'B', importedName: 'B', targetRaw: 'm', reexportsName: true },
|
|
{ kind: 'named', localName: 'C', importedName: 'C', targetRaw: 'm', reexportsName: true },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── 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
|
|
});
|
|
});
|