mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
feat(access-control-prover): 8.2 stable declaration identities and per-change symbol index
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 "<anon>"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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 = "<?php\nfinal class InvoiceController extends Controller {\n public function preview(array $fields) {}\n}\n"
|
||||
TS_CLASS = "class Client {\n send(payload: string): void {}\n}\n"
|
||||
PYTHON_MIXED = "class Service:\n def audit(self, request):\n return request\n\n\ndef audit(request):\n return request\n"
|
||||
|
||||
|
||||
def test_duplicate_type_names_across_modules_get_distinct_identities() -> 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", "<?php class {{{", "php") == ()
|
||||
Reference in New Issue
Block a user