From 809565dea512ab830e09c747f2284ab508d1a810 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:16:37 +0200 Subject: [PATCH 1/4] refactor: name semantic results as tables ScopeTable, ImportTable and SymbolTable are the results; the ScopeAnalysis, ImportAnalysis and SymbolAnalysis names are reserved for the analyses the manager will expose, as in the architecture document. Co-Authored-By: Claude Fable 5.1 --- src/coretrace_python/ir/lowering.py | 8 +- src/coretrace_python/semantic/imports.py | 10 +- src/coretrace_python/semantic/scopes.py | 6 +- src/coretrace_python/semantic/symbols.py | 12 +- tests/test_analysis_manager.py | 339 +++++++++++++++++++++++ tests/test_architecture.py | 11 +- tests/test_imports.py | 8 +- tests/test_scopes.py | 12 +- tests/test_source_manager.py | 2 +- tests/test_symbol_resolution.py | 4 +- tests/test_symbols.py | 12 +- 11 files changed, 382 insertions(+), 42 deletions(-) create mode 100644 tests/test_analysis_manager.py diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index 77ed3a0..983efb7 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -30,10 +30,10 @@ Resolution, ResolutionKind, Scope, - ScopeAnalysis, + ScopeTable, analyze_scopes, ) -from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, analyze_symbols +from coretrace_python.semantic.symbols import SymbolId, SymbolTable, analyze_symbols class LoweringError(Exception): @@ -42,8 +42,8 @@ class LoweringError(Exception): @dataclass class _FunctionLowerer: - symbols: SymbolAnalysis - scopes: ScopeAnalysis + symbols: SymbolTable + scopes: ScopeTable scope: Scope next_value_id: int = 0 assigned: set[str] = field(default_factory=set) diff --git a/src/coretrace_python/semantic/imports.py b/src/coretrace_python/semantic/imports.py index fa8849f..a9594b1 100644 --- a/src/coretrace_python/semantic/imports.py +++ b/src/coretrace_python/semantic/imports.py @@ -11,7 +11,7 @@ from types import MappingProxyType from coretrace_python.hir import nodes -from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId +from coretrace_python.semantic.scopes import ScopeId, ScopeTable from coretrace_python.semantic.symbols import SymbolId _NO_BINDINGS: Mapping[str, SymbolId] = MappingProxyType({}) @@ -21,7 +21,7 @@ class ImportResolutionError(Exception): """A source-located import that cannot be resolved statically.""" -class ImportAnalysis: +class ImportTable: """Immutable import bindings and wildcard imports, keyed by scope.""" def __init__( @@ -42,7 +42,7 @@ def wildcards(self, scope_id: ScopeId) -> tuple[SymbolId, ...]: class _Collector: - def __init__(self, module: nodes.Module, scopes: ScopeAnalysis) -> None: + def __init__(self, module: nodes.Module, scopes: ScopeTable) -> None: self.package = module.name.rpartition(".")[0] self.scopes = scopes self.bindings: dict[ScopeId, dict[str, SymbolId]] = {} @@ -89,11 +89,11 @@ def bind(self, scope_id: ScopeId, local_name: str, canonical_path: str) -> None: self.bindings.setdefault(scope_id, {})[local_name] = symbol -def analyze_imports(module: nodes.Module, scopes: ScopeAnalysis) -> ImportAnalysis: +def analyze_imports(module: nodes.Module, scopes: ScopeTable) -> ImportTable: """Collect every import binding of ``module`` without importing anything.""" collector = _Collector(module, scopes) - return ImportAnalysis( + return ImportTable( collector.bindings, {scope_id: tuple(found) for scope_id, found in collector.wildcards.items()}, ) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 509e231..495381a 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -86,7 +86,7 @@ class Resolution: scope: ScopeId | None -class ScopeAnalysis: +class ScopeTable: """Immutable scope tree with Python name resolution.""" def __init__(self, scopes: tuple[Scope, ...], spans: Mapping[SourceSpan, ScopeId]) -> None: @@ -311,11 +311,11 @@ def open( return builder -def analyze_scopes(module: nodes.Module) -> ScopeAnalysis: +def analyze_scopes(module: nodes.Module) -> ScopeTable: """Build the scope tree of ``module`` and validate its declarations.""" collector = _Collector(module) - analysis = ScopeAnalysis( + analysis = ScopeTable( tuple(builder.freeze() for builder in collector.builders), collector.spans ) for builder in collector.builders: diff --git a/src/coretrace_python/semantic/symbols.py b/src/coretrace_python/semantic/symbols.py index 44cc852..2460d13 100644 --- a/src/coretrace_python/semantic/symbols.py +++ b/src/coretrace_python/semantic/symbols.py @@ -11,10 +11,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from coretrace_python.semantic.scopes import ResolutionKind, ScopeAnalysis, ScopeId +from coretrace_python.semantic.scopes import ResolutionKind, ScopeId, ScopeTable if TYPE_CHECKING: - from coretrace_python.semantic.imports import ImportAnalysis + from coretrace_python.semantic.imports import ImportTable BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith("_")) @@ -48,10 +48,10 @@ def __str__(self) -> str: return self.canonical_name -class SymbolAnalysis: +class SymbolTable: """Resolve names to canonical symbols through scopes, imports and builtins.""" - def __init__(self, scopes: ScopeAnalysis, imports: ImportAnalysis) -> None: + def __init__(self, scopes: ScopeTable, imports: ImportTable) -> None: self._scopes = scopes self._imports = imports @@ -63,5 +63,5 @@ def resolve(self, scope_id: ScopeId, name: str) -> SymbolId | None: return self._imports.bindings(resolution.scope).get(name) -def analyze_symbols(scopes: ScopeAnalysis, imports: ImportAnalysis) -> SymbolAnalysis: - return SymbolAnalysis(scopes, imports) +def analyze_symbols(scopes: ScopeTable, imports: ImportTable) -> SymbolTable: + return SymbolTable(scopes, imports) diff --git a/tests/test_analysis_manager.py b/tests/test_analysis_manager.py new file mode 100644 index 0000000..26966d3 --- /dev/null +++ b/tests/test_analysis_manager.py @@ -0,0 +1,339 @@ +"""Acceptance tests for the Analysis Manager (``docs/architecture.md`` §8–§12). + +The manager is the core of the engine: analyses are registered with declarative +dependencies, computed lazily at module or function granularity, cached, shared, and +invalidated only by transformations that say what they preserve. + +Contract under test: + +- ``Analysis[R]`` and ``FunctionAnalysis[R]`` are subclassed with ``name``, ``version``, + ``requires`` and a ``compute`` classmethod receiving an ``AnalysisContext``. +- ``AnalysisManager(module)``: ``register``, ``get``, ``is_cached``, ``dependencies``, + ``run(transformation)``. +- The Phase 2 semantic results and per-function PyIR are the first registered analyses. + +Expected to remain red until ``coretrace_python.analysis`` exists. +""" + +from __future__ import annotations + +from typing import ClassVar + +import pytest + +from coretrace_python.frontend import build_hir +from coretrace_python.hir import nodes +from coretrace_python.ir.lowering import lower_module +from coretrace_python.ir.model import FunctionIR, ModuleIR +from coretrace_python.semantic.scopes import ScopeTable +from coretrace_python.semantic.symbols import SymbolId, SymbolTable +from coretrace_python.source import SourceManager + +try: + from coretrace_python.analysis import ( + Analysis, + AnalysisContext, + AnalysisManager, + CyclicDependencyError, + FunctionAnalysis, + TransformationPass, + UndeclaredDependencyError, + UnregisteredAnalysisError, + ) + + from coretrace_python.ir.lowering import ModuleIRAnalysis, PyIRAnalysis + from coretrace_python.semantic import SEMANTIC_ANALYSES + from coretrace_python.semantic.imports import ImportAnalysis + from coretrace_python.semantic.scopes import ScopeAnalysis + from coretrace_python.semantic.symbols import SymbolAnalysis +except ImportError as error: # pragma: no cover - red until the analysis manager lands + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_analysis_manager() -> None: + if MISSING is not None: + pytest.fail(f"analysis manager is not implemented yet: {MISSING}") + + +SOURCE = ( + "import os\n\n" + "def first(command):\n" + " os.system(command)\n\n" + "def second(command):\n" + " print(command)\n" +) + + +def hir(source_text: str = SOURCE) -> nodes.Module: + return build_hir(SourceManager().add_source("managed.py", source_text)) + + +def functions(module: nodes.Module) -> dict[str, nodes.Function]: + return {s.name: s for s in module.body if isinstance(s, nodes.Function)} + + +# --------------------------------------------------------------------------- stub analyses + + +class Counting(Analysis[int]): + """Module-level analysis that counts how often it is computed.""" + + name: ClassVar[str] = "test.counting" + computed: ClassVar[int] = 0 + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + cls.computed += 1 + return len(ctx.module.body) + + +class Doubled(Analysis[int]): + name: ClassVar[str] = "test.doubled" + requires: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) + computed: ClassVar[int] = 0 + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + cls.computed += 1 + return 2 * ctx.get(Counting) + + +class Undeclared(Analysis[int]): + """Requests an analysis it did not declare.""" + + name: ClassVar[str] = "test.undeclared" + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + return ctx.get(Counting) + + +class ParameterCount(FunctionAnalysis[int]): + name: ClassVar[str] = "test.parameter-count" + computed: ClassVar[list[str]] = [] + + @classmethod + def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> int: + cls.computed.append(function.name) + return len(function.parameters) + + +class Rewrite(TransformationPass): + name: ClassVar[str] = "test.rewrite" + preserves: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) + + @classmethod + def run(cls, ctx: AnalysisContext) -> None: + pass + + +@pytest.fixture(autouse=True) +def reset_counters() -> None: + Counting.computed = 0 + Doubled.computed = 0 + ParameterCount.computed = [] + + +def manager(*analyses: type[object], source_text: str = SOURCE) -> AnalysisManager: + result = AnalysisManager(hir(source_text)) + result.register(*analyses) + return result + + +# --------------------------------------------------------------------------- laziness and cache + + +def test_context_exposes_the_module() -> None: + engine = manager(Counting) + assert engine.get(Counting) == 3 + + +def test_results_are_computed_once_and_shared() -> None: + engine = manager(Counting) + + assert engine.is_cached(Counting) is False + first = engine.get(Counting) + second = engine.get(Counting) + + assert first == second + assert Counting.computed == 1 + assert engine.is_cached(Counting) is True + + +def test_nothing_is_computed_before_it_is_requested() -> None: + engine = manager(Counting, Doubled) + + assert Counting.computed == 0 + assert engine.get(Doubled) == 6 + assert Counting.computed == 1 + assert Doubled.computed == 1 + + +def test_requesting_a_dependency_does_not_compute_its_dependants() -> None: + engine = manager(Counting, Doubled) + + engine.get(Counting) + + assert Doubled.computed == 0 + assert engine.is_cached(Doubled) is False + + +# --------------------------------------------------------------------------- registry and DAG + + +def test_unregistered_analysis_is_rejected() -> None: + engine = manager(Counting) + + with pytest.raises(UnregisteredAnalysisError, match="test.doubled"): + engine.get(Doubled) + + +def test_dependencies_must_be_declared() -> None: + engine = manager(Counting, Undeclared) + + with pytest.raises(UndeclaredDependencyError, match="test.undeclared.*test.counting"): + engine.get(Undeclared) + + +def test_dependency_cycles_are_rejected() -> None: + class Left(Analysis[int]): + name: ClassVar[str] = "test.left" + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + return ctx.get(Right) + + class Right(Analysis[int]): + name: ClassVar[str] = "test.right" + requires: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Left}) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + return ctx.get(Left) + + Left.requires = frozenset({Right}) + + with pytest.raises(CyclicDependencyError, match="test.left.*test.right"): + manager(Left, Right) + + +def test_transitive_dependencies_are_exposed() -> None: + engine = manager(*SEMANTIC_ANALYSES) + + assert engine.dependencies(SymbolAnalysis) == frozenset({ScopeAnalysis, ImportAnalysis}) + assert engine.dependencies(ScopeAnalysis) == frozenset() + + +def test_registering_the_same_analysis_twice_is_harmless() -> None: + engine = manager(Counting, Counting) + assert engine.get(Counting) == 3 + + +# --------------------------------------------------------------------------- function granularity + + +def test_function_analyses_are_computed_per_function() -> None: + engine = manager(ParameterCount) + module_functions = functions(engine.module) + + assert engine.get(ParameterCount, module_functions["first"]) == 1 + assert ParameterCount.computed == ["first"] + assert engine.is_cached(ParameterCount, module_functions["first"]) is True + assert engine.is_cached(ParameterCount, module_functions["second"]) is False + + engine.get(ParameterCount, module_functions["first"]) + assert ParameterCount.computed == ["first"] + + +def test_function_analysis_requires_a_function_target() -> None: + engine = manager(ParameterCount, Counting) + with pytest.raises(TypeError): + engine.get(ParameterCount) # type: ignore[call-overload] + with pytest.raises(TypeError): + engine.get(Counting, functions(engine.module)["first"]) # type: ignore[call-overload] + + +# --------------------------------------------------------------------------- invalidation + + +def test_transformations_invalidate_what_they_do_not_preserve() -> None: + engine = manager(Counting, Doubled) + engine.get(Doubled) + + engine.run(Rewrite) + + assert engine.is_cached(Counting) is True + assert engine.is_cached(Doubled) is False + assert engine.get(Doubled) == 6 + assert Counting.computed == 1 + assert Doubled.computed == 2 + + +def test_transformations_invalidate_function_results_too() -> None: + engine = manager(ParameterCount) + first = functions(engine.module)["first"] + engine.get(ParameterCount, first) + + engine.run(Rewrite) + + assert engine.is_cached(ParameterCount, first) is False + + +# --------------------------------------------------------------------------- semantic analyses + + +def test_semantic_results_are_available_through_the_manager() -> None: + engine = manager(*SEMANTIC_ANALYSES) + first = functions(engine.module)["first"] + + scopes = engine.get(ScopeAnalysis) + symbols = engine.get(SymbolAnalysis) + + assert isinstance(scopes, ScopeTable) + assert isinstance(symbols, SymbolTable) + assert symbols.resolve(scopes.scope_for(first).id, "os") == SymbolId("python.os") + + +def test_semantic_results_are_shared_between_dependants() -> None: + engine = manager(*SEMANTIC_ANALYSES) + + scopes = engine.get(ScopeAnalysis) + engine.get(ImportAnalysis) + engine.get(SymbolAnalysis) + + assert engine.get(ScopeAnalysis) is scopes + + +def test_analyses_declare_names_and_versions() -> None: + for analysis in (*SEMANTIC_ANALYSES, PyIRAnalysis, ModuleIRAnalysis): + assert analysis.name.startswith(("semantic.", "ir.")) + assert analysis.version >= 1 + + +# --------------------------------------------------------------------------- PyIR + + +def test_pyir_is_computed_per_function_on_demand() -> None: + engine = manager(*SEMANTIC_ANALYSES, PyIRAnalysis) + module_functions = functions(engine.module) + + first = engine.get(PyIRAnalysis, module_functions["first"]) + + assert isinstance(first, FunctionIR) + assert first.name == "first" + assert engine.is_cached(PyIRAnalysis, module_functions["second"]) is False + + +def test_module_ir_matches_lower_module() -> None: + module = hir() + engine = AnalysisManager(module) + engine.register(*SEMANTIC_ANALYSES, PyIRAnalysis, ModuleIRAnalysis) + + module_ir = engine.get(ModuleIRAnalysis) + + assert isinstance(module_ir, ModuleIR) + assert module_ir == lower_module(module) + assert engine.is_cached(PyIRAnalysis, functions(module)["second"]) is True diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 8000d1d..6ada799 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -21,15 +21,16 @@ PARSER_MODULES = {"ast", "tree_sitter", "tree_sitter_python"} # Dependency direction of the pipeline (§2, §37). A package may import only packages -# with a strictly lower layer number, or itself. +# with a strictly lower layer number, or itself. ``analysis`` is infrastructure every +# provider subclasses, so it sits below the analyses it manages (§8). LAYERS = { "source": 0, "hir": 1, + "analysis": 2, "frontend": 2, - "semantic": 2, - "cfg": 3, - "ir": 4, - "analysis": 5, + "semantic": 3, + "cfg": 4, + "ir": 5, "dataflow": 6, "abstract": 6, "interprocedural": 6, diff --git a/tests/test_imports.py b/tests/test_imports.py index 734bfc3..17d7ec1 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -12,13 +12,13 @@ import pytest from coretrace_python.frontend import build_hir -from coretrace_python.semantic.scopes import Scope, ScopeAnalysis, analyze_scopes +from coretrace_python.semantic.scopes import Scope, ScopeTable, analyze_scopes from coretrace_python.source import SourceManager try: from coretrace_python.semantic.imports import ( - ImportAnalysis, ImportResolutionError, + ImportTable, analyze_imports, ) from coretrace_python.semantic.symbols import SymbolId @@ -34,14 +34,14 @@ def require_import_analysis() -> None: pytest.fail(f"import analysis is not implemented yet: {MISSING}") -def analyze(source_text: str, module_name: str = "imports") -> tuple[ScopeAnalysis, ImportAnalysis]: +def analyze(source_text: str, module_name: str = "imports") -> tuple[ScopeTable, ImportTable]: 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: +def function_scope(scopes: ScopeTable, name: str) -> Scope: return next(s for s in scopes.children(scopes.module_scope.id) if s.name == name) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 2d15873..0029ffb 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -1,12 +1,12 @@ """Acceptance tests for lexical scope analysis (``docs/architecture.md`` §4.1). -This module describes the public behavior of the Phase 2 ``ScopeAnalysis``. It is +This module describes the public behavior of the Phase 2 ``ScopeTable``. It is expected to remain red until ``coretrace_python.semantic.scopes`` exists. Contract under test: -- ``analyze_scopes(module: hir.Module) -> ScopeAnalysis`` is a pure function over PyHIR. -- ``ScopeAnalysis`` exposes ``module_scope``, ``scope(id)``, ``children(id)`` and +- ``analyze_scopes(module: hir.Module) -> ScopeTable`` is a pure function over PyHIR. +- ``ScopeTable`` exposes ``module_scope``, ``scope(id)``, ``children(id)`` and ``resolve(id, name)``. - ``Scope`` and ``Binding`` are immutable and keyed by a stable ``ScopeId``. - Resolution follows Python rules: an assignment anywhere in a function makes the name @@ -28,9 +28,9 @@ BindingKind, ResolutionKind, Scope, - ScopeAnalysis, ScopeError, ScopeKind, + ScopeTable, analyze_scopes, ) except ImportError as error: # pragma: no cover - red until the semantic layer lands @@ -45,12 +45,12 @@ def require_semantic_layer() -> None: pytest.fail(f"semantic scope analysis is not implemented yet: {MISSING}") -def analyze(source_text: str) -> ScopeAnalysis: +def analyze(source_text: str) -> ScopeTable: source = SourceManager().add_source("scopes.py", source_text) return analyze_scopes(build_hir(source)) -def child(analysis: ScopeAnalysis, parent: Scope, name: str) -> Scope: +def child(analysis: ScopeTable, parent: Scope, name: str) -> Scope: matches = [scope for scope in analysis.children(parent.id) if scope.name == name] assert len(matches) == 1, f"expected one child scope named {name!r}, found {matches}" return matches[0] diff --git a/tests/test_source_manager.py b/tests/test_source_manager.py index 3f90786..a85d3fc 100644 --- a/tests/test_source_manager.py +++ b/tests/test_source_manager.py @@ -56,7 +56,7 @@ def test_source_span_formats_its_start_location() -> None: # --------------------------------------------------------------------------- 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). +# layer must know it (docs/architecture.md §3.1 source discovery, §4.2 ImportTable). # Expected to remain red until ``SourceFile.module_name`` exists. diff --git a/tests/test_symbol_resolution.py b/tests/test_symbol_resolution.py index 1776a6d..f38bbea 100644 --- a/tests/test_symbol_resolution.py +++ b/tests/test_symbol_resolution.py @@ -124,7 +124,7 @@ def test_wildcard_import_leaves_names_unresolved(tmp_path, capsys) -> None: # --------------------------------------------------------------------------- next milestone -# Lowering must consume the Phase 2 ScopeAnalysis instead of tracking locals itself +# Lowering must consume the Phase 2 ScopeTable instead of tracking locals itself # (docs/architecture.md §4.1 and boundary "PyIR lowering consumes semantic results"). # Expected to remain red until lowering is routed through scope analysis. @@ -161,7 +161,7 @@ def test_global_declaration_is_not_a_local(tmp_path, capsys) -> None: # --------------------------------------------------------------------------- symbol analysis -# Lowering resolves names through the Phase 2 SymbolAnalysis (docs/architecture.md §4.2, +# Lowering resolves names through the Phase 2 SymbolTable (docs/architecture.md §4.2, # §4.3): builtins, function-level and relative imports become canonical symbols. # Expected to remain red until symbol analysis lands. diff --git a/tests/test_symbols.py b/tests/test_symbols.py index b3db50e..9f2fe2e 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1,6 +1,6 @@ """Acceptance tests for canonical symbols (``docs/architecture.md`` §4.3). -``SymbolId`` is a namespace-qualified dotted identity. ``SymbolAnalysis`` resolves any +``SymbolId`` is a namespace-qualified dotted identity. ``SymbolTable`` 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. @@ -12,13 +12,13 @@ import pytest from coretrace_python.frontend import build_hir -from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId, analyze_scopes +from coretrace_python.semantic.scopes import ScopeId, ScopeTable, 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 + from coretrace_python.semantic.symbols import SymbolTable, analyze_symbols except ImportError as error: # pragma: no cover - red until symbol analysis lands MISSING = error else: @@ -62,7 +62,7 @@ def test_symbol_id_requires_a_namespace_and_non_empty_components(name: str) -> N SymbolId(name) -# --------------------------------------------------------------------------- SymbolAnalysis +# --------------------------------------------------------------------------- SymbolTable @pytest.fixture @@ -71,14 +71,14 @@ def symbols() -> None: pytest.fail(f"symbol analysis is not implemented yet: {MISSING}") -def analyze(source_text: str) -> tuple[ScopeAnalysis, SymbolAnalysis]: +def analyze(source_text: str) -> tuple[ScopeTable, SymbolTable]: 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: +def scope_named(scopes: ScopeTable, name: str) -> ScopeId: return next(s for s in scopes.children(scopes.module_scope.id) if s.name == name).id From 0eeb446fb2a9c209b88258ac2a27504911a795f5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:17:20 +0200 Subject: [PATCH 2/4] test: define analysis manager behavior Acceptance tests for the Analysis Manager: registry with declarative dependencies, lazy module and function granularity, shared cached results, transformation invalidation, and the semantic tables and PyIR as first analyses. Co-Authored-By: Claude Fable 5.1 --- tests/test_analysis_manager.py | 72 +++++++++++++++++----------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/tests/test_analysis_manager.py b/tests/test_analysis_manager.py index 26966d3..f591653 100644 --- a/tests/test_analysis_manager.py +++ b/tests/test_analysis_manager.py @@ -78,56 +78,58 @@ def functions(module: nodes.Module) -> dict[str, nodes.Function]: # --------------------------------------------------------------------------- stub analyses -class Counting(Analysis[int]): - """Module-level analysis that counts how often it is computed.""" +if MISSING is None: # the stubs subclass the base classes under test - name: ClassVar[str] = "test.counting" - computed: ClassVar[int] = 0 + class Counting(Analysis[int]): + """Module-level analysis that counts how often it is computed.""" - @classmethod - def compute(cls, ctx: AnalysisContext) -> int: - cls.computed += 1 - return len(ctx.module.body) + name: ClassVar[str] = "test.counting" + computed: ClassVar[int] = 0 + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + cls.computed += 1 + return len(ctx.module.body) -class Doubled(Analysis[int]): - name: ClassVar[str] = "test.doubled" - requires: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) - computed: ClassVar[int] = 0 - @classmethod - def compute(cls, ctx: AnalysisContext) -> int: - cls.computed += 1 - return 2 * ctx.get(Counting) + class Doubled(Analysis[int]): + name: ClassVar[str] = "test.doubled" + requires: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) + computed: ClassVar[int] = 0 + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + cls.computed += 1 + return 2 * ctx.get(Counting) -class Undeclared(Analysis[int]): - """Requests an analysis it did not declare.""" - name: ClassVar[str] = "test.undeclared" + class Undeclared(Analysis[int]): + """Requests an analysis it did not declare.""" - @classmethod - def compute(cls, ctx: AnalysisContext) -> int: - return ctx.get(Counting) + name: ClassVar[str] = "test.undeclared" + + @classmethod + def compute(cls, ctx: AnalysisContext) -> int: + return ctx.get(Counting) -class ParameterCount(FunctionAnalysis[int]): - name: ClassVar[str] = "test.parameter-count" - computed: ClassVar[list[str]] = [] + class ParameterCount(FunctionAnalysis[int]): + name: ClassVar[str] = "test.parameter-count" + computed: ClassVar[list[str]] = [] - @classmethod - def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> int: - cls.computed.append(function.name) - return len(function.parameters) + @classmethod + def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> int: + cls.computed.append(function.name) + return len(function.parameters) -class Rewrite(TransformationPass): - name: ClassVar[str] = "test.rewrite" - preserves: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) + class Rewrite(TransformationPass): + name: ClassVar[str] = "test.rewrite" + preserves: ClassVar[frozenset[type[Analysis[object]]]] = frozenset({Counting}) - @classmethod - def run(cls, ctx: AnalysisContext) -> None: - pass + @classmethod + def run(cls, ctx: AnalysisContext) -> None: + pass @pytest.fixture(autouse=True) From f92a31fa4b69ad6df21c7a925cdcf791565fb0bd Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:20:31 +0200 Subject: [PATCH 3/4] feat: add analysis manager Analyses are classes with a name, a version and declarative requires, computed lazily at module or function granularity by an AnalysisManager that caches and shares results, rejects undeclared dependencies and cycles, and invalidates what a TransformationPass does not preserve. The semantic tables and PyIR are the first registered analyses; lower_module runs through the manager. Co-Authored-By: Claude Fable 5.1 --- src/coretrace_python/analysis/__init__.py | 29 +++++ src/coretrace_python/analysis/manager.py | 145 ++++++++++++++++++++++ src/coretrace_python/analysis/provider.py | 73 +++++++++++ src/coretrace_python/ir/lowering.py | 86 +++++++++---- src/coretrace_python/semantic/__init__.py | 10 +- src/coretrace_python/semantic/identity.py | 39 ++++++ src/coretrace_python/semantic/imports.py | 15 ++- src/coretrace_python/semantic/scopes.py | 10 ++ src/coretrace_python/semantic/symbols.py | 59 +++------ 9 files changed, 395 insertions(+), 71 deletions(-) create mode 100644 src/coretrace_python/analysis/__init__.py create mode 100644 src/coretrace_python/analysis/manager.py create mode 100644 src/coretrace_python/analysis/provider.py create mode 100644 src/coretrace_python/semantic/identity.py diff --git a/src/coretrace_python/analysis/__init__.py b/src/coretrace_python/analysis/__init__.py new file mode 100644 index 0000000..0edba6b --- /dev/null +++ b/src/coretrace_python/analysis/__init__.py @@ -0,0 +1,29 @@ +"""Analysis infrastructure: typed providers managed as a lazy, cached dependency DAG.""" + +from coretrace_python.analysis.manager import ( + AnalysisError, + AnalysisManager, + CyclicDependencyError, + UndeclaredDependencyError, + UnregisteredAnalysisError, +) +from coretrace_python.analysis.provider import ( + Analysis, + AnalysisContext, + AnyAnalysis, + FunctionAnalysis, + TransformationPass, +) + +__all__ = [ + "Analysis", + "AnalysisContext", + "AnalysisError", + "AnalysisManager", + "AnyAnalysis", + "CyclicDependencyError", + "FunctionAnalysis", + "TransformationPass", + "UndeclaredDependencyError", + "UnregisteredAnalysisError", +] diff --git a/src/coretrace_python/analysis/manager.py b/src/coretrace_python/analysis/manager.py new file mode 100644 index 0000000..b6bb30c --- /dev/null +++ b/src/coretrace_python/analysis/manager.py @@ -0,0 +1,145 @@ +"""The Analysis Manager: registry, dependency DAG, lazy evaluation, cache, invalidation.""" + +from __future__ import annotations + +from typing import Any, overload + +from coretrace_python.analysis.provider import ( + Analysis, + AnyAnalysis, + FunctionAnalysis, + R, + TransformationPass, +) +from coretrace_python.hir import nodes +from coretrace_python.source import SourceSpan + + +class AnalysisError(Exception): + """Misuse of the analysis registry or dependency declarations.""" + + +class UnregisteredAnalysisError(AnalysisError): + pass + + +class UndeclaredDependencyError(AnalysisError): + pass + + +class CyclicDependencyError(AnalysisError): + pass + + +_CacheKey = tuple[AnyAnalysis, SourceSpan | None] + + +class AnalysisManager: + """Compute registered analyses lazily, once, and share their results.""" + + def __init__(self, module: nodes.Module) -> None: + self._module = module + self._registry: set[AnyAnalysis] = set() + self._cache: dict[_CacheKey, Any] = {} + self._computing: list[AnyAnalysis] = [] + + @property + def module(self) -> nodes.Module: + return self._module + + # ------------------------------------------------------------------ registry + + def register(self, *analyses: AnyAnalysis) -> None: + for analysis in analyses: + self._registry.add(analysis) + self._check_acyclic(analysis) + + def dependencies(self, analysis: AnyAnalysis) -> frozenset[AnyAnalysis]: + """Transitive closure of ``analysis.requires``.""" + + found: set[AnyAnalysis] = set() + pending = list(analysis.requires) + while pending: + dependency = pending.pop() + if dependency not in found: + found.add(dependency) + pending.extend(dependency.requires) + return frozenset(found) + + def _check_acyclic(self, root: AnyAnalysis) -> None: + path: list[AnyAnalysis] = [] + + def visit(analysis: AnyAnalysis) -> None: + if analysis in path: + cycle = [*path[path.index(analysis) :], analysis] + raise CyclicDependencyError( + "dependency cycle: " + " -> ".join(a.name for a in cycle) + ) + path.append(analysis) + for dependency in analysis.requires: + visit(dependency) + path.pop() + + visit(root) + + # ------------------------------------------------------------------ evaluation + + @overload + def get(self, analysis: type[Analysis[R]], function: None = None) -> R: ... + + @overload + def get(self, analysis: type[FunctionAnalysis[R]], function: nodes.Function) -> R: ... + + def get(self, analysis: AnyAnalysis, function: nodes.Function | None = None) -> Any: + self._check_target(analysis, function) + if analysis not in self._registry: + raise UnregisteredAnalysisError(f"analysis {analysis.name!r} is not registered") + if self._computing and analysis not in self._computing[-1].requires: + current = self._computing[-1] + raise UndeclaredDependencyError( + f"{current.name} requests {analysis.name} without declaring it in requires" + ) + + key = self._key(analysis, function) + if key in self._cache: + return self._cache[key] + + self._computing.append(analysis) + try: + if function is None: + assert issubclass(analysis, Analysis) + result = analysis.compute(self) + else: + assert issubclass(analysis, FunctionAnalysis) + result = analysis.compute(self, function) + finally: + self._computing.pop() + self._cache[key] = result + return result + + def is_cached(self, analysis: AnyAnalysis, function: nodes.Function | None = None) -> bool: + self._check_target(analysis, function) + return self._key(analysis, function) in self._cache + + # ------------------------------------------------------------------ invalidation + + def run(self, transformation: type[TransformationPass]) -> None: + """Run a transformation, then drop every cached result it does not preserve.""" + + transformation.run(self) + self._cache = { + key: result for key, result in self._cache.items() if key[0] in transformation.preserves + } + + # ------------------------------------------------------------------ helpers + + @staticmethod + def _check_target(analysis: AnyAnalysis, function: nodes.Function | None) -> None: + if issubclass(analysis, FunctionAnalysis) and function is None: + raise TypeError(f"{analysis.name} is a function analysis and needs a function") + if issubclass(analysis, Analysis) and function is not None: + raise TypeError(f"{analysis.name} is a module analysis and takes no function") + + @staticmethod + def _key(analysis: AnyAnalysis, function: nodes.Function | None) -> _CacheKey: + return (analysis, None if function is None else function.span) diff --git a/src/coretrace_python/analysis/provider.py b/src/coretrace_python/analysis/provider.py new file mode 100644 index 0000000..72cf226 --- /dev/null +++ b/src/coretrace_python/analysis/provider.py @@ -0,0 +1,73 @@ +"""Typed analysis providers and the context they compute in (architecture §8, §12). + +An analysis is a class, not an instance: its ``name`` and ``version`` identify cached +results, its ``requires`` declares the dependency DAG, and ``compute`` builds the +result from an ``AnalysisContext``. Read-only analyses never modify shared IR; a +``TransformationPass`` may, and must say what it preserves. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Generic, Protocol, TypeVar, overload + +from coretrace_python.hir import nodes + +R = TypeVar("R") + + +class Analysis(ABC, Generic[R]): + """A module-level analysis producing one immutable result per module.""" + + name: ClassVar[str] + version: ClassVar[int] = 1 + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset() + + @classmethod + @abstractmethod + def compute(cls, ctx: AnalysisContext) -> R: + raise NotImplementedError + + +class FunctionAnalysis(ABC, Generic[R]): + """A function-level analysis producing one result per function, on demand.""" + + name: ClassVar[str] + version: ClassVar[int] = 1 + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset() + + @classmethod + @abstractmethod + def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> R: + raise NotImplementedError + + +AnyAnalysis = type[Analysis[Any]] | type[FunctionAnalysis[Any]] + + +class TransformationPass(ABC): + """A pass that may change shared state and therefore invalidates cached results. + + Every cached analysis not listed in ``preserves`` is dropped after the pass runs. + """ + + name: ClassVar[str] + preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset() + + @classmethod + @abstractmethod + def run(cls, ctx: AnalysisContext) -> None: + raise NotImplementedError + + +class AnalysisContext(Protocol): + """What an analysis sees while computing: the module and its declared dependencies.""" + + @property + def module(self) -> nodes.Module: ... + + @overload + def get(self, analysis: type[Analysis[R]], function: None = None) -> R: ... + + @overload + def get(self, analysis: type[FunctionAnalysis[R]], function: nodes.Function) -> R: ... diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index 983efb7..0476e06 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -3,8 +3,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import NoReturn - +from typing import ClassVar, NoReturn + +from coretrace_python.analysis import ( + Analysis, + AnalysisContext, + AnalysisManager, + AnyAnalysis, + FunctionAnalysis, +) from coretrace_python.hir import nodes from coretrace_python.ir.model import ( BasicBlock, @@ -25,15 +32,15 @@ Value, ValueInstruction, ) -from coretrace_python.semantic.imports import analyze_imports +from coretrace_python.semantic import SEMANTIC_ANALYSES from coretrace_python.semantic.scopes import ( Resolution, ResolutionKind, Scope, + ScopeAnalysis, ScopeTable, - analyze_scopes, ) -from coretrace_python.semantic.symbols import SymbolId, SymbolTable, analyze_symbols +from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, SymbolTable class LoweringError(Exception): @@ -151,26 +158,51 @@ def function(self, node: nodes.Function) -> FunctionIR: return FunctionIR(node.name, parameters, (self.block,), node.span) +class PyIRAnalysis(FunctionAnalysis[FunctionIR]): + """Lower one function to PyIR on demand.""" + + name: ClassVar[str] = "ir.pyir" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({ScopeAnalysis, SymbolAnalysis}) + + @classmethod + def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> FunctionIR: + scopes = ctx.get(ScopeAnalysis) + lowerer = _FunctionLowerer(ctx.get(SymbolAnalysis), scopes, scopes.scope_for(function)) + return lowerer.function(function) + + +class ModuleIRAnalysis(Analysis[ModuleIR]): + """Assemble the PyIR of every top-level function of the module.""" + + name: ClassVar[str] = "ir.module" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({PyIRAnalysis}) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> ModuleIR: + functions: list[FunctionIR] = [] + for statement in ctx.module.body: + if isinstance(statement, nodes.Function): + functions.append(ctx.get(PyIRAnalysis, statement)) + elif isinstance(statement, (nodes.Import, nodes.ImportFrom)): + continue + elif ( + isinstance(statement, nodes.ExpressionStatement) + and isinstance(statement.expression, nodes.Constant) + and isinstance(statement.expression.value, str) + ): + # Permit module docstrings. + continue + else: + raise LoweringError( + f"{statement.span.display()}: " + f"unsupported module syntax: {type(statement).__name__}" + ) + return ModuleIR(tuple(functions)) + + def lower_module(module: nodes.Module) -> ModuleIR: - 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(symbols, scopes, scopes.scope_for(statement)) - functions.append(lowerer.function(statement)) - elif isinstance(statement, (nodes.Import, nodes.ImportFrom)): - continue - elif ( - isinstance(statement, nodes.ExpressionStatement) - and isinstance(statement.expression, nodes.Constant) - and isinstance(statement.expression.value, str) - ): - # Permit module docstrings. - continue - else: - raise LoweringError( - f"{statement.span.display()}: " - f"unsupported module syntax: {type(statement).__name__}" - ) - return ModuleIR(tuple(functions)) + """Lower a whole module through a fresh analysis manager.""" + + manager = AnalysisManager(module) + manager.register(*SEMANTIC_ANALYSES, PyIRAnalysis, ModuleIRAnalysis) + return manager.get(ModuleIRAnalysis) diff --git a/src/coretrace_python/semantic/__init__.py b/src/coretrace_python/semantic/__init__.py index 356673c..3806215 100644 --- a/src/coretrace_python/semantic/__init__.py +++ b/src/coretrace_python/semantic/__init__.py @@ -1 +1,9 @@ -"""Semantic analyses computed from PyHIR: imports, symbols and lexical scopes.""" +"""Semantic analyses computed from PyHIR: scopes, imports and symbols.""" + +from coretrace_python.semantic.imports import ImportAnalysis +from coretrace_python.semantic.scopes import ScopeAnalysis +from coretrace_python.semantic.symbols import SymbolAnalysis + +SEMANTIC_ANALYSES = (ScopeAnalysis, ImportAnalysis, SymbolAnalysis) + +__all__ = ["SEMANTIC_ANALYSES", "ImportAnalysis", "ScopeAnalysis", "SymbolAnalysis"] diff --git a/src/coretrace_python/semantic/identity.py b/src/coretrace_python/semantic/identity.py new file mode 100644 index 0000000..74828fa --- /dev/null +++ b/src/coretrace_python/semantic/identity.py @@ -0,0 +1,39 @@ +"""Canonical symbol identities (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 + +from dataclasses import dataclass + + +@dataclass(frozen=True, order=True) +class SymbolId: + """A stable, import-style-independent, namespace-qualified dotted name.""" + + canonical_name: str + + def __post_init__(self) -> None: + 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: + normalized_path = path.removeprefix("python.") + if not normalized_path or normalized_path.startswith("."): + raise ValueError("a Python symbol path cannot be empty") + return cls(f"python.{normalized_path}") + + def attribute(self, name: str) -> SymbolId: + if not name or "." in name: + raise ValueError("an attribute name must be one non-empty component") + return SymbolId(f"{self.canonical_name}.{name}") + + def __str__(self) -> str: + return self.canonical_name diff --git a/src/coretrace_python/semantic/imports.py b/src/coretrace_python/semantic/imports.py index a9594b1..ee4a5b2 100644 --- a/src/coretrace_python/semantic/imports.py +++ b/src/coretrace_python/semantic/imports.py @@ -9,10 +9,12 @@ from collections.abc import Mapping from types import MappingProxyType +from typing import ClassVar +from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis from coretrace_python.hir import nodes -from coretrace_python.semantic.scopes import ScopeId, ScopeTable -from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.semantic.identity import SymbolId +from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId, ScopeTable _NO_BINDINGS: Mapping[str, SymbolId] = MappingProxyType({}) @@ -97,3 +99,12 @@ def analyze_imports(module: nodes.Module, scopes: ScopeTable) -> ImportTable: collector.bindings, {scope_id: tuple(found) for scope_id, found in collector.wildcards.items()}, ) + + +class ImportAnalysis(Analysis[ImportTable]): + name: ClassVar[str] = "semantic.imports" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({ScopeAnalysis}) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> ImportTable: + return analyze_imports(ctx.module, ctx.get(ScopeAnalysis)) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 495381a..83bbc26 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -14,7 +14,9 @@ from dataclasses import dataclass from enum import Enum from types import MappingProxyType +from typing import ClassVar +from coretrace_python.analysis import Analysis, AnalysisContext from coretrace_python.hir import nodes from coretrace_python.source import SourceSpan @@ -326,3 +328,11 @@ def analyze_scopes(module: nodes.Module) -> ScopeTable: f"{declaration.span.display()}: no binding for nonlocal {name!r}" ) return analysis + + +class ScopeAnalysis(Analysis[ScopeTable]): + name: ClassVar[str] = "semantic.scopes" + + @classmethod + def compute(cls, ctx: AnalysisContext) -> ScopeTable: + return analyze_scopes(ctx.module) diff --git a/src/coretrace_python/semantic/symbols.py b/src/coretrace_python/semantic/symbols.py index 2460d13..47dadc1 100644 --- a/src/coretrace_python/semantic/symbols.py +++ b/src/coretrace_python/semantic/symbols.py @@ -1,53 +1,18 @@ -"""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``. -""" +"""Symbol analysis: resolve names to canonical identities (architecture §4.3).""" from __future__ import annotations import builtins -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from coretrace_python.semantic.scopes import ResolutionKind, ScopeId, ScopeTable +from typing import ClassVar -if TYPE_CHECKING: - from coretrace_python.semantic.imports import ImportTable +from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis +from coretrace_python.semantic.identity import SymbolId +from coretrace_python.semantic.imports import ImportAnalysis, ImportTable +from coretrace_python.semantic.scopes import ResolutionKind, ScopeAnalysis, ScopeId, ScopeTable 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, namespace-qualified dotted name.""" - - canonical_name: str - - def __post_init__(self) -> None: - 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: - normalized_path = path.removeprefix("python.") - if not normalized_path or normalized_path.startswith("."): - raise ValueError("a Python symbol path cannot be empty") - return cls(f"python.{normalized_path}") - - def attribute(self, name: str) -> SymbolId: - if not name or "." in name: - raise ValueError("an attribute name must be one non-empty component") - return SymbolId(f"{self.canonical_name}.{name}") - - def __str__(self) -> str: - return self.canonical_name - - class SymbolTable: """Resolve names to canonical symbols through scopes, imports and builtins.""" @@ -65,3 +30,15 @@ def resolve(self, scope_id: ScopeId, name: str) -> SymbolId | None: def analyze_symbols(scopes: ScopeTable, imports: ImportTable) -> SymbolTable: return SymbolTable(scopes, imports) + + +class SymbolAnalysis(Analysis[SymbolTable]): + name: ClassVar[str] = "semantic.symbols" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({ScopeAnalysis, ImportAnalysis}) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> SymbolTable: + return analyze_symbols(ctx.get(ScopeAnalysis), ctx.get(ImportAnalysis)) + + +__all__ = ["BUILTIN_NAMES", "SymbolAnalysis", "SymbolId", "SymbolTable", "analyze_symbols"] From b05d800205eed82403cc3491b180495bdfee4582 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 3 Sep 2026 06:21:50 +0200 Subject: [PATCH 4/4] style: pin first-party import sorting Declare coretrace_python as first-party for ruff's isort so import order no longer depends on whether a package already exists on disk when tests are written before their implementation. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 3 +++ tests/test_analysis_manager.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fb27a90..bd57050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ testpaths = ["tests"] line-length = 100 target-version = "py311" +[tool.ruff.lint.isort] +known-first-party = ["coretrace_python"] + [tool.mypy] python_version = "3.11" strict = true diff --git a/tests/test_analysis_manager.py b/tests/test_analysis_manager.py index f591653..64d989a 100644 --- a/tests/test_analysis_manager.py +++ b/tests/test_analysis_manager.py @@ -40,7 +40,6 @@ UndeclaredDependencyError, UnregisteredAnalysisError, ) - from coretrace_python.ir.lowering import ModuleIRAnalysis, PyIRAnalysis from coretrace_python.semantic import SEMANTIC_ANALYSES from coretrace_python.semantic.imports import ImportAnalysis