diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index 57ba5f1..a29ea01 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -129,7 +129,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with - _Requirements: 3.3, 3.4, 11.1, 11.6_ - _Boundary: Call Resolution_ - _Depends: 8.2_ -- [ ] 8.4 Resolve inheritance and base-declaration attachment +- [x] 8.4 Resolve inheritance and base-declaration attachment - Attach base operations and entries only through repository-defined descendant relations. - Preserve ambiguous inheritance as incomplete rather than projecting to unrelated routes. - Done when subclass and unrelated-class fixtures produce distinct attachments and judgments. diff --git a/src/openultrasast/semantic/cst.py b/src/openultrasast/semantic/cst.py index 4059fe2..0f845bc 100644 --- a/src/openultrasast/semantic/cst.py +++ b/src/openultrasast/semantic/cst.py @@ -154,6 +154,36 @@ def declaration_scopes(path: str, text: str, language: str) -> tuple[Declaration return tuple(scopes) +def class_bases(path: str, text: str, language: str) -> tuple[tuple[str, tuple[str, ...]], ...]: + """Each class in the file with its declared base names; method-less classes included (task 8.4).""" + from .extra import grammar_for + + kinds = _KIND.get(language) + grammar = grammar_for(_grammar_language(language, path)) + if grammar is None or kinds is None: + return () + try: + from tree_sitter import Parser + + tree = Parser(grammar).parse(text.encode()) + except Exception: + return () + if bool(getattr(tree.root_node, "has_error", False)): + return () + declarations: list[tuple[str, tuple[str, ...]]] = [] + _collect_class_bases(tree.root_node, declarations) + return tuple(declarations) + + +def _collect_class_bases(node: object, declarations: list[tuple[str, tuple[str, ...]]]) -> None: + if node.type in _CLASS_KINDS: # type: ignore[attr-defined] + name_node = node.child_by_field_name("name") # type: ignore[attr-defined] + if name_node is not None: + declarations.append((_text(name_node), _class_bases(node))) # type: ignore[arg-type] + for child in getattr(node, "named_children", ()): # type: ignore[attr-defined] + _collect_class_bases(child, declarations) + + def _collect_scopes( node: Any, kinds: dict[str, frozenset[str]], diff --git a/src/openultrasast/semantic/obligations/resolution.py b/src/openultrasast/semantic/obligations/resolution.py index 1a01247..e6d29d0 100644 --- a/src/openultrasast/semantic/obligations/resolution.py +++ b/src/openultrasast/semantic/obligations/resolution.py @@ -11,7 +11,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from ..cst import DeclarationScope, declaration_scopes +from ..cst import DeclarationScope, class_bases, declaration_scopes from ..ir import CallSite from .abstract_domain import Completeness @@ -53,6 +53,8 @@ class SymbolIndex: scopes = declaration_scopes(path, text, language) declarations.update(_symbol_id(path, scope) for scope in scopes) base_types.update(_inheritance_edges(path, scopes)) + for type_name, bases in class_bases(path, text, language): + base_types[(path, type_name)] = bases by_name: dict[str, list[SymbolId]] = {} for declaration in sorted(declarations): by_name.setdefault(declaration.name, []).append(declaration) @@ -63,6 +65,53 @@ class SymbolIndex: ) +def ancestors_of(index: SymbolIndex, module: str, type_name: str) -> tuple[str, ...]: + """Repository-defined base types of one type, transitive, nearest first, cycle-safe.""" + bases: list[str] = [] + queue: list[str] = [type_name] + while queue: + current = queue.pop(0) + for base in index.base_types.get((module, current), ()): + if base in bases or base == type_name: + continue + bases.append(base) + queue.append(base) + return tuple(bases) + + +def descendants_of(index: SymbolIndex, base_name: str) -> tuple[tuple[str, str], ...]: + """(module, type) pairs whose ancestry includes base_name; unrelated types never appear.""" + return tuple( + sorted((module, type_name) for module, type_name in index.base_types if base_name in ancestors_of(index, module, type_name)) + ) + + +def inherited_method( + index: SymbolIndex, + base_type: str, + method_name: str, + *, + arity: int | None, +) -> CallTargetSet: + """Resolve a method as declared on base_type; attachment flows only along descendant edges. + + A unique declaration resolves complete. Declarations of the same base name in + several modules stay a candidate set with an ambiguity reason (Req 3.5). No + same-name method on an unrelated class ever enters the target set. + """ + same_name = index.by_name.get(method_name, ()) + declared = [symbol for symbol in same_name if symbol.enclosing_type == base_type] + narrowed = _arity_narrow(tuple(declared), arity) if declared else () + if not narrowed: + return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:no_candidate",))) + if len(narrowed) == 1: + return CallTargetSet(targets=(narrowed[0],), completeness=Completeness(is_complete=True)) + return CallTargetSet( + targets=tuple(narrowed), + completeness=Completeness(is_complete=False, reasons=("resolution:inheritance_ambiguous_across_modules",)), + ) + + def _symbol_id(module: str, scope: DeclarationScope) -> SymbolId: """Parameter names are the signature when present; a declaration without them has none.""" signature = tuple(scope.params) if scope.params else None diff --git a/tests/test_inheritance_attachment.py b/tests/test_inheritance_attachment.py new file mode 100644 index 0000000..0b5ad64 --- /dev/null +++ b/tests/test_inheritance_attachment.py @@ -0,0 +1,127 @@ +"""Task 8.4 -- inheritance and base-declaration attachment (Req 3.5). + +Base-type operations and entries attach only through repository-defined +descendant relations; ambiguous inheritance stays incomplete; unrelated +same-name classes never receive attachments (the mage-ai shape). +""" + +from __future__ import annotations + +from openultrasast.semantic.obligations.abstract_domain import Completeness +from openultrasast.semantic.obligations.resolution import ( + SymbolIndex, + ancestors_of, + descendants_of, + inherited_method, +) + +JAVA_BASE = ( + "src/Base.java", + "public class Base { public void run(String command) { } }", + "java", +) +JAVA_SUB = ( + "src/Sub.java", + "public class Sub extends Base { public void handle() { } }", + "java", +) +JAVA_UNRELATED = ( + "src/Other.java", + "public class Unrelated { public void run(String command) { } }", + "java", +) +JAVA_CHAIN = ( + "src/Chain.java", + "public class A extends B { } class B extends C { } class C { public void audit(String request) { } }", + "java", +) +JAVA_CYCLE = ( + "src/Cycle.java", + "public class P extends Q { } class Q extends P { }", + "java", +) +PHP_BASE_A = ( + "a/Base.php", + " SymbolIndex: + return SymbolIndex.build(files) + + +def test_ancestors_walk_the_transitive_chain_nearest_first() -> None: + index = _index(JAVA_CHAIN) + + assert ancestors_of(index, "src/Chain.java", "A") == ("B", "C") + + +def test_ancestors_survive_inheritance_cycles_without_hanging() -> None: + index = _index(JAVA_CYCLE) + + assert ancestors_of(index, "src/Cycle.java", "P") == ("Q",) + + +def test_descendants_cover_only_repo_defined_descendants() -> None: + index = _index(JAVA_BASE, JAVA_SUB, JAVA_UNRELATED) + + assert descendants_of(index, "Base") == (("src/Sub.java", "Sub"),) + + +def test_inherited_method_resolves_complete_on_a_unique_base() -> None: + index = _index(JAVA_BASE, JAVA_SUB, JAVA_UNRELATED) + + target_set = inherited_method(index, "Base", "run", arity=1) + + assert [symbol.enclosing_type for symbol in target_set.targets] == ["Base"] + assert target_set.completeness == Completeness(is_complete=True) + + +def test_inherited_method_is_absent_when_no_base_declares_it() -> None: + index = _index(JAVA_BASE, JAVA_SUB, JAVA_UNRELATED) + + target_set = inherited_method(index, "Base", "handle", arity=None) + + assert target_set.targets == () + assert target_set.completeness == Completeness(is_complete=False, reasons=("resolution:no_candidate",)) + + +def test_inherited_method_keeps_ambiguous_bases_as_incomplete_candidates() -> None: + index = _index(PHP_BASE_A, PHP_BASE_B) + + target_set = inherited_method(index, "Base", "run", arity=None) + + assert len(target_set.targets) == 2 + assert {symbol.module for symbol in target_set.targets} == {"a/Base.php", "b/Base.php"} + assert target_set.completeness == Completeness(is_complete=False, reasons=("resolution:inheritance_ambiguous_across_modules",)) + + +def test_inherited_method_narrows_overloads_by_arity() -> None: + overloaded_base = ( + "src/Ops.java", + "public class Ops { public void go() { } public void go(String payload) { } }", + "java", + ) + index = _index(overloaded_base) + + target_set = inherited_method(index, "Ops", "go", arity=1) + + assert [symbol.signature for symbol in target_set.targets] == [("payload",)] + assert target_set.completeness == Completeness(is_complete=True) + + +def test_unrelated_same_name_classes_never_receive_base_attachments() -> None: + index = _index(JAVA_BASE, JAVA_SUB, JAVA_UNRELATED) + + target_set = inherited_method(index, "Base", "run", arity=1) + unrelated = index.by_name["run"] + + assert all(symbol.enclosing_type == "Base" for symbol in target_set.targets) + assert ("src/Other.java", "Unrelated") not in descendants_of(index, "Base") + assert any(symbol.enclosing_type == "Unrelated" and symbol.module == "src/Other.java" for symbol in unrelated)