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 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