diff --git a/loopx/semantics/python_production.py b/loopx/semantics/python_production.py index 8c100ecf21..7ddeb26b5d 100644 --- a/loopx/semantics/python_production.py +++ b/loopx/semantics/python_production.py @@ -51,11 +51,11 @@ def _import_module(path: str, node: ast.ImportFrom) -> str: return '.'.join(parts[:len(parts) - node.level + 1] + ([node.module] if node.module else [])) -_TREES: dict[tuple[str, int], ast.Module] = {} +_TREES: dict[SourceFile, ast.Module] = {} def _parsed(source: SourceFile) -> ast.Module: - key = (source.path, hash(source.text)) + key = source tree = _TREES.get(key) if tree is None: tree = _TREES[key] = ast.parse(source.text, filename=source.path) @@ -139,7 +139,18 @@ def enum_members(source: SourceFile, symbol: str, *, strict: bool = False) -> di def python_literal_uses(source: SourceFile, field: str) -> set[str]: """Literal writes and direct dispatch operands; not alias/data-flow proof.""" - tree = ast.parse(source.text, filename=source.path) + tree = _parsed(source) + nodes = list(ast.walk(tree)) + # Inspect parsed names/keys, including escaped strings and normalized + # identifiers. Parse errors must still fail even when the field is absent. + if not any( + isinstance(node, ast.Name) and node.id == field + or isinstance(node, ast.Attribute) and node.attr == field + or isinstance(node, ast.Constant) and node.value == field + or isinstance(node, ast.keyword) and node.arg == field + for node in nodes + ): + return set() def reads(node: ast.AST) -> bool: if isinstance(node, ast.Name): @@ -177,7 +188,7 @@ def literals(node: ast.AST) -> set[str]: # conditions and does not inspect strings containing sample source text. rows = scan_python_production(source, field=field, enums={}) result = set().union(*(row.values for row in rows)) - for node in ast.walk(tree): + for node in nodes: if isinstance(node, ast.Compare): for left, operator, right in zip( [node.left, *node.comparators[:-1]], node.ops, node.comparators, strict=True, @@ -251,13 +262,13 @@ def _is_generator(node: ast.FunctionDef) -> bool: return False -# Keyed the same way ``_TREES`` is, by path and text hash, because the table is +# Keyed the same way ``_TREES`` is, by the complete frozen source, because the table is # derived from the module body alone and is the same for every vocabulary # scanned over that file. Keying it on ``id(tree)`` made its correctness depend # on ``_TREES`` never evicting: give that cache a bound and a reused id would # hand back another file's functions, binding a call to the wrong callee with # no symptom. -_MODULE_FUNCTIONS: dict[tuple[str, int], dict[str, ast.FunctionDef]] = {} +_MODULE_FUNCTIONS: dict[SourceFile, dict[str, ast.FunctionDef]] = {} def _enclosing_scope(tree: ast.Module, target: ast.Global) -> ast.AST: @@ -287,7 +298,7 @@ def _module_functions(source: SourceFile, tree: ast.Module) -> dict[str, ast.Fun binding of the name -- a redefinition, class, import, assignment or ``del`` -- leaves the name unproven and the call keeps ``call_result``. """ - key = (source.path, hash(source.text)) + key = source known = _MODULE_FUNCTIONS.get(key) if known is not None: return known diff --git a/tests/architecture/test_semantic_production.py b/tests/architecture/test_semantic_production.py index 6b0718058a..4a40a2d99c 100644 --- a/tests/architecture/test_semantic_production.py +++ b/tests/architecture/test_semantic_production.py @@ -1,8 +1,10 @@ """Guard the producer/owner distinction using independent finite counterexamples.""" from __future__ import annotations +import ast import pytest +from loopx.semantics import python_production from loopx.semantics.production import collect_production, validate_production, quota_action_domain from loopx.semantics.python_production import Production from loopx.semantics.inventory import SourceFile @@ -12,6 +14,47 @@ SITE = 'loopx/control_plane/quota/probe.py::emit' +@pytest.mark.parametrize('text', [ + "action = 'run'", "packet.action = 'run'", "dict(action='run')", + r"packet['\x61ction'] = 'run'", "𝖆ction = 'run'", + "packet.get('action') == 'run'", +]) +def test_literal_scan_keeps_parsed_names_keys_and_keyword_arguments(text): + source = SourceFile('loopx/literal_probe.py', '.py', text) + assert python_production.python_literal_uses(source, 'action') == {'run'} + + +def test_literal_scan_rejects_invalid_syntax_without_a_field_mention(): + source = SourceFile('loopx/broken_probe.py', '.py', 'def broken(') + with pytest.raises(SyntaxError): + python_production.python_literal_uses(source, 'action') + + +@pytest.mark.parametrize('first_text,second_text', [ + ("action = 'run'", "action = 'wait'"), + ("def choose(): return 'run'\naction = choose()", + "def choose(): return 'wait'\naction = choose()"), +]) +def test_shared_parse_keeps_distinct_source_text_despite_hash_collisions(first_text, second_text): + class CollidingText(str): + def __hash__(self): + return 0 + + first = SourceFile('loopx/collision_probe.py', '.py', CollidingText(first_text)) + second = SourceFile(first.path, first.suffix, CollidingText(second_text)) + assert python_production.python_literal_uses(first, 'action') == {'run'} + assert python_production.python_literal_uses(second, 'action') == {'wait'} + + +def test_literal_queries_preserve_the_shared_parse_tree(): + source = SourceFile('loopx/reused_probe.py', '.py', "action = 'run'\nother = 'wait'") + tree = python_production._parsed(source) + before = ast.dump(tree, include_attributes=True) + assert python_production.python_literal_uses(source, 'action') == {'run'} + assert python_production.python_literal_uses(source, 'other') == {'wait'} + assert ast.dump(tree, include_attributes=True) == before + + def test_composed_field_does_not_widen_canonical_return_or_establish_owner_liveness(): v = vocabulary() field_domain = {'run', 'wait', 'frontier_wait'}