From 65a3a74b5ea0789db62c409d8fed9b3f0ba0c949 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:04:21 +0200 Subject: [PATCH 1/2] test: define import and symbol analysis behavior Acceptance tests for the rest of Phase 2: module names in the source layer, per-scope import bindings with relative imports and recorded wildcards, and a SymbolAnalysis resolving names through scopes, imports and builtins. Co-Authored-By: Claude Fable 5.1 --- tests/test_emit_ir.py | 2 +- tests/test_hir_builder.py | 5 ++ tests/test_imports.py | 120 +++++++++++++++++++++++++----- tests/test_source_manager.py | 34 +++++++++ tests/test_symbol_resolution.py | 74 ++++++++++++++++--- tests/test_symbols.py | 126 ++++++++++++++++++++++++++++++++ 6 files changed, 331 insertions(+), 30 deletions(-) diff --git a/tests/test_emit_ir.py b/tests/test_emit_ir.py index 3fb9776..b3af862 100644 --- a/tests/test_emit_ir.py +++ b/tests/test_emit_ir.py @@ -41,7 +41,7 @@ def test_emit_ir_for_call(tmp_path, capsys) -> None: assert main(["--emit-ir", str(source)]) == 0 output = capsys.readouterr().out - assert "%1 = global 'print'" in output + assert "%1 = symbol @python.builtins.print" in output assert "%2 = call %1(%0)" in output diff --git a/tests/test_hir_builder.py b/tests/test_hir_builder.py index e9fb106..fbf3481 100644 --- a/tests/test_hir_builder.py +++ b/tests/test_hir_builder.py @@ -139,3 +139,8 @@ def test_represents_list_comprehensions() -> None: assert isinstance(generator.iterable, nodes.Name) assert len(generator.conditions) == 1 assert isinstance(generator.conditions[0], nodes.Compare) + + +def test_module_carries_its_dotted_name() -> None: + source = SourceManager().add_source("db.py", "value = 1\n", module_name="app.services.db") + assert build_hir(source).name == "app.services.db" diff --git a/tests/test_imports.py b/tests/test_imports.py index ab84d67..734bfc3 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,41 +1,123 @@ +"""Acceptance tests for import analysis (``docs/architecture.md`` §4.2). + +Every import binding maps a local name to a canonical, alias-independent identity. +Imports are collected per scope, relative imports resolve against the module's dotted +name, and wildcard imports are recorded instead of aborting the analysis. + +Expected to remain red until ``analyze_imports`` replaces ``collect_imports``. +""" + from __future__ import annotations import pytest from coretrace_python.frontend import build_hir -from coretrace_python.semantic.imports import ( - ImportBindings, - ImportResolutionError, - collect_imports, -) -from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.semantic.scopes import Scope, ScopeAnalysis, analyze_scopes from coretrace_python.source import SourceManager +try: + from coretrace_python.semantic.imports import ( + ImportAnalysis, + ImportResolutionError, + analyze_imports, + ) + from coretrace_python.semantic.symbols import SymbolId +except ImportError as error: # pragma: no cover - red until import analysis lands + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_import_analysis() -> None: + if MISSING is not None: + pytest.fail(f"import analysis is not implemented yet: {MISSING}") -def bindings_for(source: str) -> ImportBindings: - source_file = SourceManager().add_source("imports.py", source) - return collect_imports(build_hir(source_file)) + +def analyze(source_text: str, module_name: str = "imports") -> tuple[ScopeAnalysis, ImportAnalysis]: + source = SourceManager().add_source("imports.py", source_text, module_name=module_name) + module = build_hir(source) + scopes = analyze_scopes(module) + return scopes, analyze_imports(module, scopes) + + +def function_scope(scopes: ScopeAnalysis, name: str) -> Scope: + return next(s for s in scopes.children(scopes.module_scope.id) if s.name == name) def test_collects_plain_and_aliased_imports() -> None: - bindings = bindings_for("import os\nimport subprocess as sp\n") + scopes, imports = analyze("import os\nimport subprocess as sp\n") + bindings = imports.bindings(scopes.module_scope.id) + assert bindings["os"] == SymbolId("python.os") assert bindings["sp"] == SymbolId("python.subprocess") def test_plain_dotted_import_binds_its_top_level_package() -> None: - bindings = bindings_for("import xml.etree.ElementTree\n") - assert bindings["xml"] == SymbolId("python.xml") + scopes, imports = analyze("import xml.etree.ElementTree\n") + assert imports.bindings(scopes.module_scope.id)["xml"] == SymbolId("python.xml") + + +def test_aliased_dotted_import_binds_the_full_path() -> None: + scopes, imports = analyze("import xml.etree.ElementTree as ET\n") + bindings = imports.bindings(scopes.module_scope.id) + assert bindings["ET"] == SymbolId("python.xml.etree.ElementTree") def test_collects_from_imports() -> None: - bindings = bindings_for("from os import system as run\n") + scopes, imports = analyze("from os import system as run, path\n") + bindings = imports.bindings(scopes.module_scope.id) + assert bindings["run"] == SymbolId("python.os.system") + assert bindings["path"] == SymbolId("python.os.path") -def test_wildcard_import_is_rejected_with_location() -> None: - with pytest.raises( - ImportResolutionError, - match=r"imports.py:1:1: wildcard imports are not supported", - ): - bindings_for("from os import *\n") +def test_function_level_imports_bind_in_the_function_scope() -> None: + scopes, imports = analyze("def execute(command):\n import os\n os.system(command)\n") + execute = function_scope(scopes, "execute") + + assert imports.bindings(execute.id)["os"] == SymbolId("python.os") + assert "os" not in imports.bindings(scopes.module_scope.id) + + +@pytest.mark.parametrize( + "statement, name, expected", + [ + ("from . import utils", "utils", "python.app.services.utils"), + ("from .db import query as q", "q", "python.app.services.db.query"), + ("from ..models import User", "User", "python.app.models.User"), + ("from .. import config", "config", "python.app.config"), + ], + ids=["sibling-module", "sibling-attribute", "parent-module", "parent-package"], +) +def test_relative_imports_resolve_against_the_module_name( + statement: str, name: str, expected: str +) -> None: + scopes, imports = analyze(f"{statement}\n", module_name="app.services.db") + assert imports.bindings(scopes.module_scope.id)[name] == SymbolId(expected) + + +@pytest.mark.parametrize( + "source_text, module_name", + [("from ... import x\n", "app.services.db"), ("from . import x\n", "script")], + ids=["too-many-levels", "top-level-script"], +) +def test_relative_import_beyond_the_top_level_package_is_an_error( + source_text: str, module_name: str +) -> None: + with pytest.raises(ImportResolutionError, match=r"imports.py:1:1: .*beyond top-level package"): + analyze(source_text, module_name=module_name) + + +def test_wildcard_imports_are_recorded_without_bindings() -> None: + scopes, imports = analyze("from os import *\n") + module = scopes.module_scope.id + + assert imports.bindings(module) == {} + assert imports.wildcards(module) == (SymbolId("python.os"),) + + +def test_bindings_are_immutable() -> None: + scopes, imports = analyze("import os\n") + with pytest.raises(TypeError): + imports.bindings(scopes.module_scope.id)["sys"] = SymbolId("python.sys") # type: ignore[index] diff --git a/tests/test_source_manager.py b/tests/test_source_manager.py index 5d5f874..3f90786 100644 --- a/tests/test_source_manager.py +++ b/tests/test_source_manager.py @@ -52,3 +52,37 @@ def test_source_span_formats_its_start_location() -> None: span = SourceSpan(SourceId("module.py"), start_line=3, start_column=7) assert span.display() == "module.py:3:7" + + +# --------------------------------------------------------------------------- next milestone +# Relative imports resolve against the importing module's dotted name, so the source +# layer must know it (docs/architecture.md §3.1 source discovery, §4.2 ImportAnalysis). +# Expected to remain red until ``SourceFile.module_name`` exists. + + +def test_add_source_defaults_the_module_name_to_the_file_stem() -> None: + source = SourceManager().add_source("scopes.py", "value = 1\n") + assert source.module_name == "scopes" + + +def test_add_source_accepts_an_explicit_module_name() -> None: + source = SourceManager().add_source("db.py", "value = 1\n", module_name="app.services.db") + assert source.module_name == "app.services.db" + + +def test_load_file_derives_the_module_name_from_enclosing_packages(tmp_path: Path) -> None: + (tmp_path / "app" / "services").mkdir(parents=True) + (tmp_path / "app" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "app" / "services" / "__init__.py").write_text("", encoding="utf-8") + module = tmp_path / "app" / "services" / "db.py" + module.write_text("value = 1\n", encoding="utf-8") + + assert SourceManager().load_file(module).module_name == "app.services.db" + + +def test_load_file_stops_at_the_first_directory_without_init(tmp_path: Path) -> None: + (tmp_path / "scripts").mkdir() + module = tmp_path / "scripts" / "tool.py" + module.write_text("value = 1\n", encoding="utf-8") + + assert SourceManager().load_file(module).module_name == "tool" diff --git a/tests/test_symbol_resolution.py b/tests/test_symbol_resolution.py index e8e7983..1776a6d 100644 --- a/tests/test_symbol_resolution.py +++ b/tests/test_symbol_resolution.py @@ -5,6 +5,7 @@ import pytest from coretrace_python.cli import main +from coretrace_python.cli import main as main_cli def emit_ir(source_text: str, tmp_path, capsys) -> tuple[int, str, str]: @@ -79,17 +80,17 @@ def test_import_bindings_are_available_to_every_function(tmp_path, capsys) -> No assert "func @second(%0)" in output -def test_non_imported_name_remains_a_global(tmp_path, capsys) -> None: +def test_unknown_name_remains_a_global(tmp_path, capsys) -> None: exit_code, output, error = emit_ir( "def greet(name):\n" - " print(name)\n", + " undefined_helper(name)\n", tmp_path, capsys, ) assert exit_code == 0, error - assert "%1 = global 'print'" in output - assert "symbol @python.print" not in output + assert "%1 = global 'undefined_helper'" in output + assert "symbol @" not in output def test_local_assignment_shadows_an_import_binding(tmp_path, capsys) -> None: @@ -108,17 +109,18 @@ def test_local_assignment_shadows_an_import_binding(tmp_path, capsys) -> None: assert "symbol @python.os.system" not in output -def test_wildcard_import_fails_with_a_source_location(tmp_path, capsys) -> None: +def test_wildcard_import_leaves_names_unresolved(tmp_path, capsys) -> None: exit_code, output, error = emit_ir( - "from os import *\n", + "from os import *\n\n" + "def execute(command):\n" + " system(command)\n", tmp_path, capsys, ) - assert exit_code == 1 - assert output == "" - assert "symbols.py:1:1" in error - assert "wildcard imports are not supported" in error + assert exit_code == 0, error + assert "%1 = global 'system'" in output + assert "symbol @" not in output # --------------------------------------------------------------------------- next milestone @@ -156,3 +158,55 @@ def test_global_declaration_is_not_a_local(tmp_path, capsys) -> None: assert exit_code == 0, error assert "symbol @python.os.system" in output assert 'load_local "os"' not in output + + +# --------------------------------------------------------------------------- symbol analysis +# Lowering resolves names through the Phase 2 SymbolAnalysis (docs/architecture.md §4.2, +# §4.3): builtins, function-level and relative imports become canonical symbols. +# Expected to remain red until symbol analysis lands. + + +def test_builtins_are_canonical_symbols(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "def greet(name):\n" + " print(name)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert "%1 = symbol @python.builtins.print" in output + assert "global 'print'" not in output + + +def test_function_level_import_resolves_to_a_symbol(tmp_path, capsys) -> None: + exit_code, output, error = emit_ir( + "def execute(command):\n" + " import os\n" + " os.system(command)\n", + tmp_path, + capsys, + ) + + assert exit_code == 0, error + assert "symbol @python.os.system" in output + + +def test_relative_import_resolves_inside_a_package(tmp_path, capsys) -> None: + package = tmp_path / "pkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "helpers.py").write_text("def run(command):\n return command\n", encoding="utf-8") + main = package / "main.py" + main.write_text( + "from .helpers import run\n\n" + "def execute(command):\n" + " run(command)\n", + encoding="utf-8", + ) + + exit_code = main_cli(["--emit-ir", str(main)]) + captured = capsys.readouterr() + + assert exit_code == 0, captured.err + assert "symbol @python.pkg.helpers.run" in captured.out diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 9bff137..b3db50e 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1,6 +1,31 @@ +"""Acceptance tests for canonical symbols (``docs/architecture.md`` §4.3). + +``SymbolId`` is a namespace-qualified dotted identity. ``SymbolAnalysis`` resolves any +name in any scope to such an identity through scope rules, imports and builtins, so +security rules never depend on the textual name visible in the file. + +The ``analyze_symbols`` tests are expected to remain red until symbol analysis lands. +""" + +from __future__ import annotations + import pytest +from coretrace_python.frontend import build_hir +from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId, analyze_scopes from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceManager + +try: + from coretrace_python.semantic.imports import analyze_imports + from coretrace_python.semantic.symbols import SymbolAnalysis, analyze_symbols +except ImportError as error: # pragma: no cover - red until symbol analysis lands + MISSING = error +else: + MISSING = None + + +# --------------------------------------------------------------------------- SymbolId def test_symbol_id_adds_the_python_namespace() -> None: @@ -22,3 +47,104 @@ def test_symbol_attribute_creates_a_child_identity() -> None: def test_invalid_symbol_paths_are_rejected(path: str) -> None: with pytest.raises(ValueError): SymbolId.from_python_path(path) + + +@pytest.mark.parametrize( + "name", ["flask.request.args", "django.http.HttpRequest.GET", "python.builtins.eval"] +) +def test_symbol_id_accepts_any_namespace(name: str) -> None: + assert SymbolId(name).canonical_name == name + + +@pytest.mark.parametrize("name", ["", "os", ".os", "os.", "a..b", "python."]) +def test_symbol_id_requires_a_namespace_and_non_empty_components(name: str) -> None: + with pytest.raises(ValueError): + SymbolId(name) + + +# --------------------------------------------------------------------------- SymbolAnalysis + + +@pytest.fixture +def symbols() -> None: + if MISSING is not None: + pytest.fail(f"symbol analysis is not implemented yet: {MISSING}") + + +def analyze(source_text: str) -> tuple[ScopeAnalysis, SymbolAnalysis]: + source = SourceManager().add_source("symbols.py", source_text) + module = build_hir(source) + scopes = analyze_scopes(module) + return scopes, analyze_symbols(scopes, analyze_imports(module, scopes)) + + +def scope_named(scopes: ScopeAnalysis, name: str) -> ScopeId: + return next(s for s in scopes.children(scopes.module_scope.id) if s.name == name).id + + +@pytest.mark.usefixtures("symbols") +def test_module_import_resolves_from_a_function() -> None: + scopes, symbols = analyze("import os\n\ndef execute(command):\n os.system(command)\n") + assert symbols.resolve(scope_named(scopes, "execute"), "os") == SymbolId("python.os") + assert symbols.resolve(scopes.module_scope.id, "os") == SymbolId("python.os") + + +@pytest.mark.usefixtures("symbols") +def test_function_level_import_resolves_only_inside_the_function() -> None: + scopes, symbols = analyze("def execute(command):\n import os\n os.system(command)\n") + assert symbols.resolve(scope_named(scopes, "execute"), "os") == SymbolId("python.os") + assert symbols.resolve(scopes.module_scope.id, "os") is None + + +@pytest.mark.usefixtures("symbols") +def test_import_captured_by_a_nested_function_resolves() -> None: + scopes, symbols = analyze( + "def outer():\n" + " from os import system as run\n" + " def inner(command):\n" + " return run(command)\n" + " return inner\n" + ) + outer = scope_named(scopes, "outer") + inner = next(s for s in scopes.children(outer) if s.name == "inner").id + assert symbols.resolve(inner, "run") == SymbolId("python.os.system") + + +@pytest.mark.usefixtures("symbols") +def test_local_assignment_shadows_an_import() -> None: + scopes, symbols = analyze( + "from os import system as run\n\n" + "def execute(callback, command):\n" + " run(command)\n" + " run = callback\n" + ) + assert symbols.resolve(scope_named(scopes, "execute"), "run") is None + + +@pytest.mark.usefixtures("symbols") +def test_global_declaration_reaches_the_module_import() -> None: + scopes, symbols = analyze("import os\n\ndef execute(command):\n global os\n os.system(command)\n") + assert symbols.resolve(scope_named(scopes, "execute"), "os") == SymbolId("python.os") + + +@pytest.mark.usefixtures("symbols") +def test_builtins_resolve_to_the_builtins_namespace() -> None: + scopes, symbols = analyze("def greet(name):\n print(name)\n eval(name)\n") + greet = scope_named(scopes, "greet") + assert symbols.resolve(greet, "print") == SymbolId("python.builtins.print") + assert symbols.resolve(greet, "eval") == SymbolId("python.builtins.eval") + assert symbols.resolve(scopes.module_scope.id, "open") == SymbolId("python.builtins.open") + + +@pytest.mark.usefixtures("symbols") +def test_rebound_builtin_is_not_a_builtin() -> None: + scopes, symbols = analyze("print = 1\n\ndef greet(name):\n print(name)\n") + assert symbols.resolve(scope_named(scopes, "greet"), "print") is None + + +@pytest.mark.usefixtures("symbols") +def test_unknown_and_wildcard_provided_names_are_unresolved() -> None: + scopes, symbols = analyze("from os import *\n\ndef execute(command):\n system(command)\n helper(command)\n") + execute = scope_named(scopes, "execute") + assert symbols.resolve(execute, "system") is None + assert symbols.resolve(execute, "helper") is None From c97b8e7a2f6430bbc8f969d14a3db06a9ce0bd06 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:06:36 +0200 Subject: [PATCH 2/2] feat: add import and symbol analysis Source files carry their dotted module name so relative imports resolve statically. ImportAnalysis collects bindings per scope and records wildcard imports instead of aborting. SymbolAnalysis resolves any name through scopes, imports and builtins, and lowering emits canonical symbols from it. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +- src/coretrace_python/frontend/ast_adapter.py | 2 +- src/coretrace_python/hir/nodes.py | 1 + src/coretrace_python/ir/lowering.py | 18 +-- src/coretrace_python/semantic/imports.py | 146 ++++++++++++------- src/coretrace_python/semantic/symbols.py | 43 +++++- src/coretrace_python/source/manager.py | 26 +++- src/coretrace_python/source/model.py | 3 +- 8 files changed, 170 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 9baed88..4d14ff7 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ Currently supported inside functions: arguments, assignments, names, constants, operations, unary operations, comparisons, calls, attribute access, indexing, and returns. Unsupported syntax produces a source-located diagnostic and a non-zero exit status. -Module-level imports are resolved to canonical symbols. Import aliases do not change an -API's identity, so all of the following resolve to `python.os.system`: +Names are resolved to canonical symbols through lexical scopes, imports and builtins. +Import aliases do not change an API's identity, so all of the following resolve to +`python.os.system`: ```python import os @@ -45,4 +46,6 @@ from os import system from os import system as run ``` -Relative and wildcard imports are not supported yet and produce source-located diagnostics. +Relative imports resolve against the module's dotted name, derived from the enclosing +`__init__.py` packages. Builtins resolve to `python.builtins.`. Names introduced by +wildcard imports stay unresolved and are emitted as `global`. diff --git a/src/coretrace_python/frontend/ast_adapter.py b/src/coretrace_python/frontend/ast_adapter.py index fddcbd1..350155f 100644 --- a/src/coretrace_python/frontend/ast_adapter.py +++ b/src/coretrace_python/frontend/ast_adapter.py @@ -208,7 +208,7 @@ def module(self, tree: ast.Module) -> nodes.Module: ) else: span = SourceSpan(self._source.source_id, 1, 1, 1, 1) - return nodes.Module(body, span) + return nodes.Module(self._source.module_name, body, span) def build_module(source: SourceFile, tree: ast.Module) -> nodes.Module: diff --git a/src/coretrace_python/hir/nodes.py b/src/coretrace_python/hir/nodes.py index 064a723..949f0cd 100644 --- a/src/coretrace_python/hir/nodes.py +++ b/src/coretrace_python/hir/nodes.py @@ -201,6 +201,7 @@ class Class: @dataclass(frozen=True, slots=True) class Module: + name: str body: tuple[Statement, ...] span: SourceSpan diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index a292e6a..77ed3a0 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -25,7 +25,7 @@ Value, ValueInstruction, ) -from coretrace_python.semantic.imports import ImportBindings, collect_imports +from coretrace_python.semantic.imports import analyze_imports from coretrace_python.semantic.scopes import ( Resolution, ResolutionKind, @@ -33,7 +33,7 @@ ScopeAnalysis, analyze_scopes, ) -from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, analyze_symbols class LoweringError(Exception): @@ -42,7 +42,7 @@ class LoweringError(Exception): @dataclass class _FunctionLowerer: - imports: ImportBindings + symbols: SymbolAnalysis scopes: ScopeAnalysis scope: Scope next_value_id: int = 0 @@ -75,9 +75,7 @@ def resolve(self, name: str) -> Resolution: def imported_symbol(self, node: nodes.Expression) -> SymbolId | None: if isinstance(node, nodes.Name): - if self.resolve(node.identifier).kind is not ResolutionKind.GLOBAL: - return None - return self.imports.resolve(node.identifier) + return self.symbols.resolve(self.scope.id, node.identifier) if isinstance(node, nodes.Attribute): parent = self.imported_symbol(node.value) return parent.attribute(node.name) if parent is not None else None @@ -139,8 +137,8 @@ def statement(self, node: nodes.Statement) -> None: if isinstance(node, nodes.ExpressionStatement): self.expression(node.expression) return - if isinstance(node, (nodes.Pass, nodes.Global)): - # ``global`` is a declaration already applied by scope analysis. + if isinstance(node, (nodes.Pass, nodes.Global, nodes.Import, nodes.ImportFrom)): + # Declarations and imports are already applied by the semantic analyses. return self.fail(node) @@ -154,12 +152,12 @@ def function(self, node: nodes.Function) -> FunctionIR: def lower_module(module: nodes.Module) -> ModuleIR: - imports = collect_imports(module) scopes = analyze_scopes(module) + symbols = analyze_symbols(scopes, analyze_imports(module, scopes)) functions: list[FunctionIR] = [] for statement in module.body: if isinstance(statement, nodes.Function): - lowerer = _FunctionLowerer(imports, scopes, scopes.scope_for(statement)) + lowerer = _FunctionLowerer(symbols, scopes, scopes.scope_for(statement)) functions.append(lowerer.function(statement)) elif isinstance(statement, (nodes.Import, nodes.ImportFrom)): continue diff --git a/src/coretrace_python/semantic/imports.py b/src/coretrace_python/semantic/imports.py index 8b2944b..fa8849f 100644 --- a/src/coretrace_python/semantic/imports.py +++ b/src/coretrace_python/semantic/imports.py @@ -1,65 +1,99 @@ -"""Collection of module-level import bindings.""" +"""Import analysis: per-scope bindings from import statements (architecture §4.2). + +No module is ever imported. Each binding maps the local name an import introduces to +the canonical path of the object it refers to, resolved statically from the statement +and, for relative imports, from the importing module's dotted name. +""" from __future__ import annotations -from collections.abc import Iterator, Mapping -from dataclasses import dataclass +from collections.abc import Mapping +from types import MappingProxyType from coretrace_python.hir import nodes +from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId from coretrace_python.semantic.symbols import SymbolId +_NO_BINDINGS: Mapping[str, SymbolId] = MappingProxyType({}) -class ImportResolutionError(Exception): - """A source-located import that cannot be represented safely.""" - - -@dataclass(frozen=True) -class ImportBindings(Mapping[str, SymbolId]): - """Map names visible in a module to their canonical identities.""" - - _bindings: dict[str, SymbolId] - - def __getitem__(self, name: str) -> SymbolId: - return self._bindings[name] - - def __iter__(self) -> Iterator[str]: - return iter(self._bindings) - - def __len__(self) -> int: - return len(self._bindings) - - def resolve(self, name: str) -> SymbolId | None: - return self._bindings.get(name) - - -def _error(node: nodes.ImportFrom, message: str) -> ImportResolutionError: - return ImportResolutionError(f"{node.span.display()}: {message}") - - -def collect_imports(module: nodes.Module) -> ImportBindings: - """Collect supported top-level imports without importing any modules.""" - - bindings: dict[str, SymbolId] = {} - for statement in module.body: - if isinstance(statement, nodes.Import): - for alias in statement.names: - if alias.as_name is None: - local_name = alias.name.partition(".")[0] - canonical_path = local_name - else: - local_name = alias.as_name - canonical_path = alias.name - bindings[local_name] = SymbolId.from_python_path(canonical_path) - elif isinstance(statement, nodes.ImportFrom): - if statement.level: - raise _error(statement, "relative imports are not supported yet") - if statement.module is None: - raise _error(statement, "import module is missing") - for alias in statement.names: - if alias.name == "*": - raise _error(statement, "wildcard imports are not supported") - local_name = alias.as_name or alias.name - canonical_path = f"{statement.module}.{alias.name}" - bindings[local_name] = SymbolId.from_python_path(canonical_path) - return ImportBindings(bindings) +class ImportResolutionError(Exception): + """A source-located import that cannot be resolved statically.""" + + +class ImportAnalysis: + """Immutable import bindings and wildcard imports, keyed by scope.""" + + def __init__( + self, + bindings: Mapping[ScopeId, Mapping[str, SymbolId]], + wildcards: Mapping[ScopeId, tuple[SymbolId, ...]], + ) -> None: + self._bindings = MappingProxyType( + {scope_id: MappingProxyType(dict(found)) for scope_id, found in bindings.items()} + ) + self._wildcards = MappingProxyType(dict(wildcards)) + + def bindings(self, scope_id: ScopeId) -> Mapping[str, SymbolId]: + return self._bindings.get(scope_id, _NO_BINDINGS) + + def wildcards(self, scope_id: ScopeId) -> tuple[SymbolId, ...]: + return self._wildcards.get(scope_id, ()) + + +class _Collector: + def __init__(self, module: nodes.Module, scopes: ScopeAnalysis) -> None: + self.package = module.name.rpartition(".")[0] + self.scopes = scopes + self.bindings: dict[ScopeId, dict[str, SymbolId]] = {} + self.wildcards: dict[ScopeId, list[SymbolId]] = {} + self.body(module.body, scopes.module_scope.id) + + def body(self, statements: tuple[nodes.Statement, ...], scope_id: ScopeId) -> None: + for statement in statements: + if isinstance(statement, nodes.Import): + for alias in statement.names: + # ``import a.b.c`` binds ``a``; ``import a.b.c as d`` binds the full path. + top_level = alias.name.partition(".")[0] + if alias.as_name is None: + self.bind(scope_id, top_level, top_level) + else: + self.bind(scope_id, alias.as_name, alias.name) + elif isinstance(statement, nodes.ImportFrom): + base = self.base_module(statement) + for alias in statement.names: + if alias.name == "*": + self.wildcards.setdefault(scope_id, []).append( + SymbolId.from_python_path(base) + ) + else: + self.bind(scope_id, alias.as_name or alias.name, f"{base}.{alias.name}") + elif isinstance(statement, (nodes.Function, nodes.Class)): + self.body(statement.body, self.scopes.scope_for(statement).id) + + def base_module(self, statement: nodes.ImportFrom) -> str: + if not statement.level: + assert statement.module is not None, "absolute import without a module" + return statement.module + parts = self.package.split(".") if self.package else [] + kept = len(parts) - (statement.level - 1) + if kept < 1: + raise ImportResolutionError( + f"{statement.span.display()}: relative import beyond top-level package" + ) + base = ".".join(parts[:kept]) + return f"{base}.{statement.module}" if statement.module else base + + def bind(self, scope_id: ScopeId, local_name: str, canonical_path: str) -> None: + symbol = SymbolId.from_python_path(canonical_path) + self.bindings.setdefault(scope_id, {})[local_name] = symbol + + +def analyze_imports(module: nodes.Module, scopes: ScopeAnalysis) -> ImportAnalysis: + """Collect every import binding of ``module`` without importing anything.""" + + collector = _Collector(module, scopes) + return ImportAnalysis( + collector.bindings, + {scope_id: tuple(found) for scope_id, found in collector.wildcards.items()}, + ) diff --git a/src/coretrace_python/semantic/symbols.py b/src/coretrace_python/semantic/symbols.py index 7ad8cca..44cc852 100644 --- a/src/coretrace_python/semantic/symbols.py +++ b/src/coretrace_python/semantic/symbols.py @@ -1,19 +1,36 @@ -"""Canonical symbol identities stored in PyIR.""" +"""Canonical symbol identities and their resolution (architecture §4.3). + +A ``SymbolId`` names a Python object independently of how a file spells it: every way +of importing ``os.system`` yields ``python.os.system``. Framework models may live in +their own namespaces, such as ``flask.request.args``. +""" from __future__ import annotations +import builtins from dataclasses import dataclass +from typing import TYPE_CHECKING + +from coretrace_python.semantic.scopes import ResolutionKind, ScopeAnalysis, ScopeId + +if TYPE_CHECKING: + from coretrace_python.semantic.imports import ImportAnalysis + +BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith("_")) @dataclass(frozen=True, order=True) class SymbolId: - """A stable, import-style-independent name for a Python object.""" + """A stable, import-style-independent, namespace-qualified dotted name.""" canonical_name: str def __post_init__(self) -> None: - if not self.canonical_name.startswith("python."): - raise ValueError("canonical Python symbols must start with 'python.'") + components = self.canonical_name.split(".") + if len(components) < 2: + raise ValueError("a canonical symbol needs a namespace and a path") + if not all(component.isidentifier() for component in components): + raise ValueError(f"invalid canonical symbol: {self.canonical_name!r}") @classmethod def from_python_path(cls, path: str) -> SymbolId: @@ -30,3 +47,21 @@ def attribute(self, name: str) -> SymbolId: def __str__(self) -> str: return self.canonical_name + +class SymbolAnalysis: + """Resolve names to canonical symbols through scopes, imports and builtins.""" + + def __init__(self, scopes: ScopeAnalysis, imports: ImportAnalysis) -> None: + self._scopes = scopes + self._imports = imports + + def resolve(self, scope_id: ScopeId, name: str) -> SymbolId | None: + resolution = self._scopes.resolve(scope_id, name) + if resolution.kind is ResolutionKind.UNBOUND: + return SymbolId(f"python.builtins.{name}") if name in BUILTIN_NAMES else None + assert resolution.scope is not None + return self._imports.bindings(resolution.scope).get(name) + + +def analyze_symbols(scopes: ScopeAnalysis, imports: ImportAnalysis) -> SymbolAnalysis: + return SymbolAnalysis(scopes, imports) diff --git a/src/coretrace_python/source/manager.py b/src/coretrace_python/source/manager.py index cfdf59f..0da4fb1 100644 --- a/src/coretrace_python/source/manager.py +++ b/src/coretrace_python/source/manager.py @@ -7,13 +7,24 @@ from coretrace_python.source.model import SourceFile, SourceId +def module_name_for(path: Path) -> str: + """Dotted module name of ``path``, walking up through ``__init__.py`` packages.""" + + parts = [path.stem] + directory = path.parent + while (directory / "__init__.py").is_file(): + parts.append(directory.name) + directory = directory.parent + return ".".join(reversed(parts)) + + class SourceManager: """Own source text and return one canonical object for each source ID.""" def __init__(self) -> None: self._sources: dict[SourceId, SourceFile] = {} - def add_source(self, name: str, text: str) -> SourceFile: + def add_source(self, name: str, text: str, module_name: str | None = None) -> SourceFile: source_id = SourceId(name) existing = self._sources.get(source_id) if existing is not None: @@ -21,7 +32,11 @@ def add_source(self, name: str, text: str) -> SourceFile: raise ValueError(f"source {name!r} already exists with different text") return existing - source = SourceFile(source_id=source_id, text=text) + source = SourceFile( + source_id=source_id, + text=text, + module_name=module_name if module_name is not None else Path(name).stem, + ) self._sources[source_id] = source return source @@ -34,7 +49,12 @@ def load_file(self, path: str | Path) -> SourceFile: # utf-8-sig accepts ordinary UTF-8 and strips a leading UTF-8 BOM. text = resolved_path.read_text(encoding="utf-8-sig") - source = SourceFile(source_id=source_id, text=text, path=resolved_path) + source = SourceFile( + source_id=source_id, + text=text, + module_name=module_name_for(resolved_path), + path=resolved_path, + ) self._sources[source_id] = source return source diff --git a/src/coretrace_python/source/model.py b/src/coretrace_python/source/model.py index e809141..2b0dc72 100644 --- a/src/coretrace_python/source/model.py +++ b/src/coretrace_python/source/model.py @@ -47,9 +47,10 @@ def display(self) -> str: @dataclass(frozen=True) class SourceFile: - """Decoded source text and its identity.""" + """Decoded source text, its identity and its dotted module name.""" source_id: SourceId text: str + module_name: str path: Path | None = None