diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index 02d89e1..62d0a9a 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -115,7 +115,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with - Done when language fixtures retain receiver and arity evidence without regressing existing IR consumers. - _Requirements: 3.2, 3.3_ - _Boundary: Semantic IR_ -- [ ] 8.2 Build stable declaration identities and the per-change symbol index +- [x] 8.2 Build stable declaration identities and the per-change symbol index - Identify declarations by module, enclosing type, name and available signature. - Cache the symbol and inheritance indexes on the per-change context. - Done when duplicate type names, same-name methods and overloads receive stable distinct identities. diff --git a/src/openultrasast/hunt/producers.py b/src/openultrasast/hunt/producers.py index 653d985..2c721db 100644 --- a/src/openultrasast/hunt/producers.py +++ b/src/openultrasast/hunt/producers.py @@ -16,7 +16,7 @@ from ..model.taxonomy import DEFAULT_FAMILIES_PATH, load_families from ..preprocess import build_file_target, detect_language from ..semantic.facts import load_facts from ..semantic.ir import FileIR, parse_file -from ..semantic.obligations import check_obligations, load_obligation_facts +from ..semantic.obligations import check_obligations, load_obligation_facts, resolution from ..semantic.obligations.dominance import OrderDominance from ..semantic.obligations.operations import covers, find_operations, valid_witness from .labels import label_rows @@ -102,6 +102,20 @@ class Context: continue return mapping.analyze_entry_points(self.root, [build_file_target(self.root, self.root / path) for path in self.product_files]) + @cached_property + def symbol_index(self) -> resolution.SymbolIndex: + """Declarations and inheritance for the product tree, read once per change (Req 6.2).""" + files: list[tuple[str, str, str]] = [] + for path in self.product_files: + try: + text = self.read(path) + except _Absent: + # Same guard as entry_points: an empty product file is not this index's problem. + continue + language = source.canonical_language(detect_language(self.root / path)) + files.append((path, text, language)) + return resolution.SymbolIndex.build(files) + @cached_property def hunks(self) -> dict[str, list[list[int]]]: """Line ranges per file that the push changed or added, at head.""" diff --git a/src/openultrasast/semantic/cst.py b/src/openultrasast/semantic/cst.py index 4e9ad34..4059fe2 100644 --- a/src/openultrasast/semantic/cst.py +++ b/src/openultrasast/semantic/cst.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from .ir import Bind, CallSite, FileIR, FunctionIR @@ -111,6 +112,105 @@ def parse_function_nodes(path: str, text: str, language: str) -> list[Any]: return [node for node in _walk(tree.root_node) if node.type in kinds["function"]] +@dataclass(frozen=True) +class DeclarationScope: + """One function declaration with the enclosing type it is declared in and that type's base names.""" + + function_name: str + params: tuple[str, ...] + start_line: int + enclosing_type: str | None + enclosing_base_types: tuple[str, ...] + + +_CLASS_KINDS = frozenset({"class_declaration", "class_definition"}) +_BASE_NAME_KINDS = frozenset({"identifier", "type_identifier", "name", "property_identifier"}) +_BASE_CLAUSE_FIELDS = ("superclass", "interfaces", "superclasses", "base_clause", "class_heritage", "extends_clause") +_BASE_CLAUSE_KINDS = frozenset({"base_clause", "class_heritage", "extends_clause", "superclasses"}) + + +def declaration_scopes(path: str, text: str, language: str) -> tuple[DeclarationScope, ...]: + """Every function declaration in the file with its enclosing type chain. + + The symbol index (task 8.2) needs what the flat FileIR drops: which class a method is declared in and + which bases that class names. An unparsable file or a language without a grammar yields no scopes. + """ + 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 () + scopes: list[DeclarationScope] = [] + _collect_scopes(tree.root_node, kinds, None, (), scopes) + return tuple(scopes) + + +def _collect_scopes( + node: Any, + kinds: dict[str, frozenset[str]], + enclosing_type: str | None, + enclosing_base_types: tuple[str, ...], + scopes: list[DeclarationScope], +) -> None: + if node.type in _CLASS_KINDS: + name_node = node.child_by_field_name("name") + enclosing_type = _text(name_node) if name_node is not None else None + enclosing_base_types = _class_bases(node) + if node.type in kinds["function"]: + scopes.append( + DeclarationScope( + function_name=_declaration_name(node), + params=_params(node, kinds), + start_line=_line(node), + enclosing_type=enclosing_type, + enclosing_base_types=enclosing_base_types, + ) + ) + for child in getattr(node, "named_children", ()) or (): + _collect_scopes(child, kinds, enclosing_type, enclosing_base_types, scopes) + + +def _declaration_name(node: Any) -> str: + """The declared name for the symbol index. + + Same resolution as `_function_name`, plus the property_identifier kind a TypeScript + method_declaration names itself with; the flat FileIR keeps its own naming. + """ + for child in getattr(node, "named_children", ()) or (): + if child.type in {"identifier", "name", "property_identifier"}: + return _text(child) + if child.type == "function_declarator": + for grandchild in getattr(child, "named_children", ()) or (): + if grandchild.type == "identifier": + return _text(grandchild) + return "" + + +def _class_bases(node: Any) -> tuple[str, ...]: + """The base type names a class declaration names, in declaration order, deduplicated.""" + clauses: list[Any] = [] + for base_field in _BASE_CLAUSE_FIELDS: + clause = node.child_by_field_name(base_field) + if clause is not None: + clauses.append(clause) + clauses.extend(child for child in getattr(node, "named_children", ()) or () if child.type in _BASE_CLAUSE_KINDS) + names: list[str] = [] + for clause in clauses: + names.extend( + _text(child) for child in _walk(clause, skip=frozenset()) if child.type in _BASE_NAME_KINDS and not _text(child).startswith("$") + ) + return tuple(dict.fromkeys(names)) + + def _grammar_language(language: str, path: str) -> str: """TypeScript ships two grammars; a `.tsx` file needs the JSX-aware one. The FileIR keeps the language it was given.""" return "tsx" if language == "typescript" and path.endswith(".tsx") else language diff --git a/src/openultrasast/semantic/obligations/resolution.py b/src/openultrasast/semantic/obligations/resolution.py new file mode 100644 index 0000000..9eee343 --- /dev/null +++ b/src/openultrasast/semantic/obligations/resolution.py @@ -0,0 +1,76 @@ +"""Stable declaration identities for repository-defined symbols (task 8.2). + +The symbol index gives every declaration an identity keyed by module, enclosing type, name, and -- when +the declaration carries them -- parameter names, so same-name targets never merge (Req 3.2) and base-type +declarations attach only through explicit descendant edges (Req 3.5). Engine purity: no framework or +product names appear here; target vocabulary arrives as data through the acquisition pipeline. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field + +from ..cst import DeclarationScope, declaration_scopes + + +@dataclass(frozen=True) +class SymbolId: + """The stable identity of one declaration: module (repository path), enclosing type, name, signature.""" + + module: str + enclosing_type: str | None + name: str + signature: tuple[str, ...] | None + + @property + def sort_key(self) -> tuple[str, str, str, tuple[str, ...]]: + """A total-order key; a missing signature sorts before every present one.""" + return (self.module, self.enclosing_type or "", self.name, self.signature or ()) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, SymbolId): + return NotImplemented + return self.sort_key < other.sort_key + + +@dataclass(frozen=True) +class SymbolIndex: + """Repository-defined declarations and the inheritance edges between their types.""" + + declarations: tuple[SymbolId, ...] + base_types: Mapping[tuple[str, str], tuple[str, ...]] + by_name: Mapping[str, tuple[SymbolId, ...]] = field(default_factory=dict) + + @classmethod + def build(cls, files: Iterable[tuple[str, str, str]]) -> SymbolIndex: + """Build the index from (path, text, language) triples; deterministic and deduplicated.""" + declarations: set[SymbolId] = set() + base_types: dict[tuple[str, str], tuple[str, ...]] = {} + for path, text, language in files: + scopes = declaration_scopes(path, text, language) + declarations.update(_symbol_id(path, scope) for scope in scopes) + base_types.update(_inheritance_edges(path, scopes)) + by_name: dict[str, list[SymbolId]] = {} + for declaration in sorted(declarations): + by_name.setdefault(declaration.name, []).append(declaration) + return cls( + declarations=tuple(sorted(declarations)), + base_types=dict(sorted(base_types.items())), + by_name={name: tuple(symbols) for name, symbols in sorted(by_name.items())}, + ) + + +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 + return SymbolId(module=module, enclosing_type=scope.enclosing_type, name=scope.function_name, signature=signature) + + +def _inheritance_edges(module: str, scopes: Iterable[DeclarationScope]) -> dict[tuple[str, str], tuple[str, ...]]: + """(module, type) -> the base names that type declares, from any method declared inside it.""" + edges: dict[tuple[str, str], tuple[str, ...]] = {} + for scope in scopes: + if scope.enclosing_type is not None: + edges[(module, scope.enclosing_type)] = scope.enclosing_base_types + return edges diff --git a/tests/test_symbol_index.py b/tests/test_symbol_index.py new file mode 100644 index 0000000..917deda --- /dev/null +++ b/tests/test_symbol_index.py @@ -0,0 +1,70 @@ +"""Task 8.2: stable declaration identities and the per-change symbol index (Reqs 3.2, 3.5, 6.2).""" + +from openultrasast.semantic.cst import declaration_scopes +from openultrasast.semantic.obligations.resolution import SymbolId, SymbolIndex + +JAVA_A = "package pkg.a;\npublic class Handler {\n public void run() {}\n public void run(String command) {}\n}\n" +JAVA_B = "package pkg.b;\npublic class Handler {\n public void run() {}\n}\n" +PHP_EXTENDS = " None: + index = SymbolIndex.build([("pkg/a/Handler.java", JAVA_A, "java"), ("pkg/b/Handler.java", JAVA_B, "java")]) + handler_a = SymbolId("pkg/a/Handler.java", "Handler", "run", None) + handler_b = SymbolId("pkg/b/Handler.java", "Handler", "run", None) + assert handler_a in index.declarations + assert handler_b in index.declarations + assert handler_a != handler_b + + +def test_same_class_overloads_get_distinct_signatures() -> None: + index = SymbolIndex.build([("pkg/a/Handler.java", JAVA_A, "java")]) + no_argument = SymbolId("pkg/a/Handler.java", "Handler", "run", None) + with_command = SymbolId("pkg/a/Handler.java", "Handler", "run", ("command",)) + assert no_argument in index.declarations + assert with_command in index.declarations + assert no_argument != with_command + + +def test_php_extends_records_base_types() -> None: + index = SymbolIndex.build([("src/InvoiceController.php", PHP_EXTENDS, "php")]) + assert index.base_types[("src/InvoiceController.php", "InvoiceController")] == ("Controller",) + + +def test_typescript_method_carries_enclosing_type() -> None: + index = SymbolIndex.build([("src/Client.ts", TS_CLASS, "typescript")]) + assert SymbolId("src/Client.ts", "Client", "send", ("payload",)) in index.declarations + + +def test_python_class_method_and_module_function_stay_distinct() -> None: + index = SymbolIndex.build([("service.py", PYTHON_MIXED, "python")]) + method = SymbolId("service.py", "Service", "audit", ("self", "request")) + module_function = SymbolId("service.py", None, "audit", ("request",)) + assert method in index.declarations + assert module_function in index.declarations + assert method != module_function + + +def test_by_name_returns_every_same_name_symbol() -> None: + index = SymbolIndex.build([("pkg/a/Handler.java", JAVA_A, "java"), ("pkg/b/Handler.java", JAVA_B, "java")]) + assert len(index.by_name["run"]) == 3 + + +def test_build_is_deterministic_and_deduplicating() -> None: + files = [("pkg/a/Handler.java", JAVA_A, "java"), ("pkg/a/Handler.java", JAVA_A, "java")] + first = SymbolIndex.build(files) + second = SymbolIndex.build(files) + assert first == second + assert len(first.declarations) == len(set(first.declarations)) + assert first.declarations == tuple(sorted(first.declarations)) + + +def test_no_argument_method_has_signature_none() -> None: + index = SymbolIndex.build([("pkg/b/Handler.java", JAVA_B, "java")]) + assert index.by_name["run"] == (SymbolId("pkg/b/Handler.java", "Handler", "run", None),) + + +def test_unparsable_file_yields_no_scopes() -> None: + assert declaration_scopes("broken.php", "