mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
refactor(p1): one source-facts module owns function ranges, callers, declaration tables and language aliases
src/openultrasast/source.py replaces the copies in semantic/functions.py, plane/tasks/repo_facts.py, learn/features.py, learn/excerpt.py and plane/tasks/verify.py. Two engines share one sentinel (<module>), one DECLARATION/KEYWORDS/RESERVED set and one alias map: the parser engine (ranges, enclosing, bounds, call_sites, callers_of, facts_for) for the scorer, the overlay and the hunter's annotations; the line-pattern engine (declared_functions, enclosing_at, function_span, function_bounds, the brace matcher) that labels, features, roles, excerpts and plane facts were built on. learn no longer imports plane.tasks. Frozen records that store the old sentinel are mapped on read (module_function). Gate: labels rebuilt before and after are identical, 7,921 rows, after sentinel normalisation (benchmarks/measurements/2026-10-13-source-module- label-diff/record.json). 490 targeted tests pass; 3,312 collect. Net: 657 lines deleted, 212 added. benchmarks/unseen keeps <global> for its own frozen manifests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f63703a604
commit
062a7b780e
+2
-2
@@ -12,14 +12,14 @@
|
||||
"degradations": [],
|
||||
"findings": [
|
||||
{
|
||||
"site": "app/index.php:3:<global>",
|
||||
"site": "app/index.php:3:<module>",
|
||||
"family": "injection",
|
||||
"rung": "model_entailed",
|
||||
"witness": "$_GET[\"id\"] -> mysqli_query($conn, \"SELECT * FROM users WHERE id = \" . $id) in app/index.php (line 3, 4 steps)",
|
||||
"reached_from": 1
|
||||
},
|
||||
{
|
||||
"site": "app/index.php:5:<global>",
|
||||
"site": "app/index.php:5:<module>",
|
||||
"family": "injection",
|
||||
"rung": "model_entailed",
|
||||
"witness": "$_GET[\"host\"] -> system(\"ping \" . $_GET[\"host\"]) in app/index.php (line 5, 2 steps)",
|
||||
|
||||
@@ -86,9 +86,10 @@ def test_read_file_truncates_and_rejects_symlink_escape(tmp_path: Path) -> None:
|
||||
|
||||
def test_find_refs_searches_source_text(tmp_path: Path) -> None:
|
||||
root = _repo_with_split_sink(tmp_path)
|
||||
refs = find_refs(root, "query")
|
||||
assert [ref["line"] for ref in refs] == [2, 3]
|
||||
assert all(ref["path"] == "app.py" and ref["name"] == "query" and ref["source"] == "text" for ref in refs)
|
||||
refs = find_refs(root, "execute")
|
||||
assert [ref["line"] for ref in refs] == [3]
|
||||
assert all(ref["path"] == "app.py" and ref["name"] == "execute" and ref["source"] == "text" for ref in refs)
|
||||
assert find_refs(root, "query") == []
|
||||
assert find_refs(root, "search") == []
|
||||
assert find_refs(root, "") == []
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""freeze.py resolves an advisory's file-only site to the function the fix changes (population v3, decision 2).
|
||||
|
||||
The resolver reads PHP text only; it must not be fooled by braces in strings, comments or inline HTML, and a line
|
||||
outside every function is `<global>`."""
|
||||
outside every function is `<module>`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,12 +48,12 @@ def test_a_line_resolves_to_its_innermost_named_function_or_global() -> None:
|
||||
spans = freeze.php_functions(PHP)
|
||||
assert freeze.enclosing(spans, 9) == "open" # inside the closure: the named function owns it
|
||||
assert freeze.enclosing(spans, 17) == "close"
|
||||
assert freeze.enclosing(spans, 21) == "<global>"
|
||||
assert freeze.enclosing(spans, 1) == "<global>"
|
||||
assert freeze.enclosing(spans, 21) == "<module>"
|
||||
assert freeze.enclosing(spans, 1) == "<module>"
|
||||
|
||||
|
||||
def test_an_insertion_after_a_closing_brace_is_outside_the_function() -> None:
|
||||
spans = freeze.php_functions(PHP)
|
||||
assert freeze.enclosing(spans, 10) == "open" # a changed line on the closing brace
|
||||
assert freeze.enclosing(spans, 10, inserted=True) == "<global>" # new code that follows it
|
||||
assert freeze.enclosing(spans, 10, inserted=True) == "<module>" # new code that follows it
|
||||
assert freeze.enclosing(spans, 8, inserted=True) == "open"
|
||||
|
||||
+21
-12
@@ -512,12 +512,17 @@ def test_known_limit_pairs_emit_no_improve_signals(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_function_at_skips_anonymous_and_module_ranges() -> None:
|
||||
from openultrasast.semantic.overlay import function_at
|
||||
from openultrasast.source import FunctionRange
|
||||
from openultrasast.source import enclosing as function_at
|
||||
|
||||
ranges = (("<module>", 1, 40), ("handler", 3, 20), ("<anon>", 5, 12))
|
||||
ranges = (
|
||||
FunctionRange("<module>", 1, 40, "module"),
|
||||
FunctionRange("handler", 3, 20, "function"),
|
||||
FunctionRange("<anon>", 5, 12, "anonymous"),
|
||||
)
|
||||
assert function_at(ranges, 7) == "handler" # innermost *named* function, never "<anon>"
|
||||
assert function_at(ranges, 25) is None
|
||||
assert function_at((("<anon>", 1, 9),), 4) is None
|
||||
assert function_at(ranges, 25) == "<module>"
|
||||
assert function_at((FunctionRange("<anon>", 1, 9, "anonymous"),), 4) == "<module>"
|
||||
|
||||
|
||||
@pytest.mark.semantic
|
||||
@@ -738,7 +743,8 @@ def test_unresolvable_function_label_is_a_visible_loss_not_a_silent_miss(tmp_pat
|
||||
|
||||
def test_named_function_ranges_use_declarator_names() -> None:
|
||||
from openultrasast.semantic.extra import has_semantic_extra
|
||||
from openultrasast.semantic.functions import declarator_name, named_function_ranges
|
||||
from openultrasast.source import declarator_name
|
||||
from openultrasast.source import ranges as named_function_ranges
|
||||
|
||||
assert declarator_name("const listProcessesOnPort = module.exports.listProcessesOnPort = async port => {") == "listProcessesOnPort"
|
||||
assert declarator_name(" exec('df -k ' + path, function(err, stdout) {") is None
|
||||
@@ -765,8 +771,8 @@ def test_named_function_ranges_use_declarator_names() -> None:
|
||||
)
|
||||
ranges = named_function_ranges("f.js", src, "javascript")
|
||||
assert ranges is not None
|
||||
assert {name for name, _, _ in ranges} >= {"a", "b", "c", "d", "e"}
|
||||
assert "<anon>" not in {name for name, _, _ in ranges}
|
||||
assert {name for name, _, _, _ in ranges} >= {"a", "b", "c", "d", "e"}
|
||||
assert "<anon>" not in {name for name, _, _, _ in ranges}
|
||||
|
||||
|
||||
# --- review round 4 remediation (RED first) -----------------------------------
|
||||
@@ -775,17 +781,18 @@ def test_named_function_ranges_use_declarator_names() -> None:
|
||||
@pytest.mark.semantic
|
||||
def test_c_function_with_return_type_on_previous_line_keeps_its_parser_name() -> None:
|
||||
from openultrasast.semantic.extra import has_semantic_extra
|
||||
from openultrasast.semantic.functions import function_at, named_function_ranges
|
||||
from openultrasast.source import enclosing as function_at
|
||||
from openultrasast.source import ranges as named_function_ranges
|
||||
|
||||
if not has_semantic_extra():
|
||||
pytest.skip("tree-sitter extra absent")
|
||||
ranges = named_function_ranges("a.c", "static int\nfoo(int x)\n{\n return x;\n}\n", "c")
|
||||
assert ranges is not None and any(name == "foo" and start <= 4 <= end for name, start, end in ranges)
|
||||
assert ranges is not None and any(name == "foo" and start <= 4 <= end for name, start, end, _ in ranges)
|
||||
assert function_at(ranges, 4) == "foo"
|
||||
|
||||
|
||||
def test_declarator_name_knows_perl_subs_and_never_names_calls() -> None:
|
||||
from openultrasast.semantic.functions import declarator_name
|
||||
from openultrasast.source import declarator_name
|
||||
|
||||
assert declarator_name("sub link_hash_cert {") == "link_hash_cert"
|
||||
assert declarator_name(" return foo(bar)") is None
|
||||
@@ -798,7 +805,7 @@ def test_declarator_name_knows_perl_subs_and_never_names_calls() -> None:
|
||||
|
||||
|
||||
def test_declarator_name_strips_cpp_qualification_to_match_unqualified_labels() -> None:
|
||||
from openultrasast.semantic.functions import declarator_name
|
||||
from openultrasast.source import declarator_name
|
||||
|
||||
assert declarator_name("bool FileReaderLoader::ArrayBufferResult(int a) {") == "ArrayBufferResult"
|
||||
assert declarator_name("void ns::Klass::run(const std::string& s) const {") == "run"
|
||||
@@ -808,7 +815,9 @@ def test_declarator_name_strips_cpp_qualification_to_match_unqualified_labels()
|
||||
@pytest.mark.semantic
|
||||
def test_cpp_qualified_method_range_is_found_by_its_unqualified_label() -> None:
|
||||
from openultrasast.semantic.extra import has_semantic_extra
|
||||
from openultrasast.semantic.functions import function_at, named_function_ranges, spans_named
|
||||
from openultrasast.source import enclosing as function_at
|
||||
from openultrasast.source import ranges as named_function_ranges
|
||||
from openultrasast.source import spans_named
|
||||
|
||||
if not has_semantic_extra():
|
||||
pytest.skip("tree-sitter extra absent")
|
||||
|
||||
@@ -98,13 +98,13 @@ def _produce(world: dict[str, Any], docker: FakeDocker, tmp_path: Path) -> list[
|
||||
|
||||
|
||||
def test_recorded_engine_findings_become_alert_rows() -> None:
|
||||
assert parse_site("app/index.php:3:<global>") == ("app/index.php", 3, "<global>")
|
||||
assert parse_site("app/index.php:3:<module>") == ("app/index.php", 3, "<module>")
|
||||
assert parse_site("lib/A.php:76:Foo::bar") == ("lib/A.php", 76, "Foo::bar")
|
||||
record = read_record(FIXTURE / "probe.json", LOG, 0)
|
||||
assert (record["files"], record["bytes"], record["questions"], record["completed"]) == (1, 145, 1, 1)
|
||||
rows = engine_rows(record, FIXTURE, "vulnerable", "a" * 40, {"app/index.php": [[3, 3]]})
|
||||
assert rows == [
|
||||
{"rule_id": "engine:injection", "rule_status": "enabled", "path": "app/index.php", "line": line, "function": "<global>",
|
||||
{"rule_id": "engine:injection", "rule_status": "enabled", "path": "app/index.php", "line": line, "function": "<module>",
|
||||
"pin_role": "vulnerable", "pin": "a" * 40, "in_fix_range": line == 3, "source": "engine"}
|
||||
for line in (3, 5)
|
||||
] # fmt: skip
|
||||
|
||||
@@ -45,7 +45,7 @@ def _units() -> list[Unit]:
|
||||
return [excerpt, roles_only, oversized, repo]
|
||||
|
||||
|
||||
GLOBAL_ = "<global>"
|
||||
GLOBAL_ = "<module>"
|
||||
|
||||
|
||||
def _render(tmp_path: Path, ceiling: float = 10.0) -> tuple[dict[str, str], Path]:
|
||||
|
||||
@@ -20,7 +20,7 @@ from openultrasast.plane.budget import prices_from
|
||||
from openultrasast.plane.generate import TEMPLATES, fix_ranges, increment, read_cases, render, repin_templates
|
||||
from openultrasast.plane.manifests import load_manifests
|
||||
from openultrasast.plane.memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SPLIT_ANNOTATION, parse_memory_key
|
||||
from openultrasast.plane.tasks.repo_facts import _digest
|
||||
from openultrasast.source import _digest
|
||||
|
||||
PLANE = Path("plane")
|
||||
RUN = PLANE / "runs" / "validation-46.yaml"
|
||||
|
||||
@@ -34,7 +34,7 @@ from openultrasast.plane.memory import (
|
||||
seed,
|
||||
where_sql,
|
||||
)
|
||||
from openultrasast.plane.tasks.repo_facts import _digest
|
||||
from openultrasast.source import _digest
|
||||
|
||||
PIN = "0123456789abcdef0123456789abcdef01234567"
|
||||
OTHER_PIN = "fedcba9876543210fedcba9876543210fedcba98"
|
||||
|
||||
@@ -93,7 +93,7 @@ def test_cross_file_callers_have_line_and_enclosing_function(tree: Path, tmp_pat
|
||||
]
|
||||
assert facts["callers"]["app/util.py::render"] == [
|
||||
{"path": "app/handlers.py", "line": 6, "enclosing": "handler"},
|
||||
{"path": "app/handlers.py", "line": 11, "enclosing": "<global>"},
|
||||
{"path": "app/handlers.py", "line": 11, "enclosing": "<module>"},
|
||||
]
|
||||
assert facts["counts"] == {"files": 3, "functions": 7, "candidates": 2, "callers": 4}
|
||||
summary = json.loads((out / "summary.json").read_text())
|
||||
@@ -271,4 +271,4 @@ def test_enclosing_function_in_python_respects_indentation() -> None:
|
||||
assert repo_facts.enclosing(lines, 1, "python") == "a"
|
||||
assert repo_facts.enclosing(lines, 5, "python") == "inner"
|
||||
assert repo_facts.enclosing(lines, 6, "python") == "b"
|
||||
assert repo_facts.enclosing(lines, 8, "python") == "<global>"
|
||||
assert repo_facts.enclosing(lines, 8, "python") == "<module>"
|
||||
|
||||
@@ -270,10 +270,10 @@ def test_agree_reports_cost_with_the_recorded_triage_and_needs_fix_ranges_to_ass
|
||||
|
||||
|
||||
def test_matches_declared_is_the_protocol_v2_rule() -> None:
|
||||
case = {"family": "injection", "sites": ["a.py::run", "b.py::<global>"]}
|
||||
case = {"family": "injection", "sites": ["a.py::run", "b.py::<module>"]}
|
||||
ranges = {"a.py": [[50, 52]], "c.py": [[5, 5]]}
|
||||
hit = lambda site, family="injection": agree.matches_declared({"site": site, "family": family}, case, ranges) # noqa: E731
|
||||
assert hit("a.py:0:run") and hit("b.py:0:<global>") # a declared path::function, whether or not the fix touched it
|
||||
assert hit("a.py:0:run") and hit("b.py:0:<module>") # a declared path::function, whether or not the fix touched it
|
||||
assert not hit("a.py:0:run", "ssrf") # another family never matches
|
||||
assert hit("a.py:62:helper") and not hit("a.py:63:helper") and hit("a.py:40:x") and not hit("a.py:39:x") # 10-line window
|
||||
assert hit("c.py:900:run") and not hit("d.py:1:run") # a declared function name in a changed file only
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Repository facts use the same names, ranges and calls in every consumer."""
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast import source
|
||||
from openultrasast.semantic import cst
|
||||
from openultrasast.semantic.extra import grammar_for
|
||||
|
||||
SAMPLES = [
|
||||
("python", "app.py", "def run(x):\n return x\n", (1, 2)),
|
||||
("javascript", "app.js", "function run(x) {\n return x;\n}\n", (1, 3)),
|
||||
("java", "App.java", "class App {\n public int run(int x) {\n return x;\n }\n}\n", (2, 4)),
|
||||
("go", "app.go", "package app\nfunc (r *Repo) run(x int) int {\n return x\n}\n", (2, 4)),
|
||||
("ruby", "app.rb", "class App\n def run(x)\n x\n end\nend\n", (2, 4)),
|
||||
("php", "app.php", "<?php\nfunction run($x) {\n return $x;\n}\n", (2, 4)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("language", "path", "text", "expected"), SAMPLES)
|
||||
@pytest.mark.parametrize("with_grammar", [False, True])
|
||||
def test_ranges_with_and_without_grammars(monkeypatch, language, path, text, expected, with_grammar):
|
||||
if not with_grammar:
|
||||
monkeypatch.setenv("OPENULTRASAST_TREE_SITTER_PROBE", "off")
|
||||
else:
|
||||
monkeypatch.delenv("OPENULTRASAST_TREE_SITTER_PROBE", raising=False)
|
||||
items = source.ranges(path, text, language)
|
||||
assert source.bounds(items, "run") == expected
|
||||
assert source.enclosing(items, expected[0]) == "run"
|
||||
assert source.enclosing(items, expected[1]) == "run"
|
||||
if with_grammar and grammar_for(language) is not None:
|
||||
assert cst.parse_with_cst(path, text, language).engine == "tree-sitter"
|
||||
|
||||
|
||||
def test_nested_ranges_and_module_bounds():
|
||||
text = "def outer():\n def inner():\n pass\n inner()\nouter()\n"
|
||||
items = source.ranges("app.py", text, "python")
|
||||
assert source.enclosing(items, 3) == "inner"
|
||||
assert source.enclosing(items, 4) == "outer"
|
||||
assert source.enclosing(items, 5) == source.MODULE_FUNCTION
|
||||
assert source.enclosing(items, None) == source.MODULE_FUNCTION
|
||||
assert source.bounds(items, "outer") == (1, 4)
|
||||
assert source.bounds(items, source.MODULE_FUNCTION) == (1, 5)
|
||||
assert source.bounds(items, "missing") is None
|
||||
|
||||
|
||||
def test_fallback_does_not_include_siblings_or_strings(monkeypatch):
|
||||
monkeypatch.setenv("OPENULTRASAST_TREE_SITTER_PROBE", "off")
|
||||
items = source.ranges("app.js", 'function first() {\n const s = "}"; // }\n}\nfunction last() {}\nfirst();', "javascript")
|
||||
assert source.bounds(items, "first") == (1, 3)
|
||||
assert source.bounds(items, "last") == (4, 4)
|
||||
assert source.enclosing(items, 5) == source.MODULE_FUNCTION
|
||||
|
||||
|
||||
def test_calls_skip_declarations_comments_strings_and_duplicate_mentions():
|
||||
text = 'def run():\n target(1); target(2)\n# target(3)\ns = "target(4)"\nobj.target(5)\nrun()\n'
|
||||
assert source.call_sites(text, ["target", "run"]) == (
|
||||
source.CallSite(2, "target"),
|
||||
source.CallSite(5, "target"),
|
||||
source.CallSite(6, "run"),
|
||||
)
|
||||
assert source.call_sites(text, []) == ()
|
||||
assert source.call_sites("function run() { return run(); }", ["run"]) == (source.CallSite(1, "run"),)
|
||||
|
||||
|
||||
def test_callers_scan_only_supplied_files(tmp_path):
|
||||
(tmp_path / "app.py").write_text("def run():\n pass\nrun()\n")
|
||||
(tmp_path / "web.py").write_text("def handler():\n run()\n")
|
||||
assert source.callers_of(tmp_path, "run", files=["web.py", "app.py"]) == (("app.py", 3), ("web.py", 2))
|
||||
assert source.callers_of(tmp_path, "run", files=["web.py"]) == (("web.py", 2),)
|
||||
assert source.callers_of(tmp_path, "absent", files=["web.py"]) == ()
|
||||
with pytest.raises(OSError):
|
||||
source.callers_of(tmp_path, "run", files=["missing.py"])
|
||||
with pytest.raises(ValueError):
|
||||
source.callers_of(tmp_path, "run", files=["../outside.py"])
|
||||
|
||||
|
||||
def test_product_files_exclude_vendor_tests_and_private_paths(tmp_path):
|
||||
names = [
|
||||
"src/app.py",
|
||||
"vendor/a.py",
|
||||
"node_modules/a.js",
|
||||
"tests/a.py",
|
||||
"spec/a.rb",
|
||||
"src/a_test.go",
|
||||
"src/AppTest.java",
|
||||
"src/a.test.js",
|
||||
"dist/a.js",
|
||||
".hidden/a.py",
|
||||
".env",
|
||||
"src/a.min.js",
|
||||
]
|
||||
for name in names:
|
||||
path = tmp_path / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("run()\n")
|
||||
(tmp_path / "link.py").symlink_to(tmp_path / "src/app.py")
|
||||
assert source.product_files(tmp_path) == ["src/app.py"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("py", "python"),
|
||||
("python_web", "python"),
|
||||
("node", "javascript"),
|
||||
("js", "javascript"),
|
||||
("ts", "typescript"),
|
||||
("typescript", "typescript"),
|
||||
("c_cpp", "cpp"),
|
||||
("c++", "cpp"),
|
||||
("cpp", "cpp"),
|
||||
("Ruby", "ruby"),
|
||||
("unknown", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_canonical_language(name, expected):
|
||||
assert source.canonical_language(name) == expected
|
||||
|
||||
|
||||
def test_facts_reuse_supplied_entry_points_and_read_only_repository(tmp_path, monkeypatch):
|
||||
from openultrasast import mapping
|
||||
|
||||
(tmp_path / "app.py").write_text("def run(x):\n return x\n")
|
||||
(tmp_path / "web.py").write_text("def handler():\n run(1)\n")
|
||||
monkeypatch.setattr(mapping, "analyze_entry_points", lambda *_: pytest.fail("entry points were recomputed"))
|
||||
result = source.facts_for(tmp_path, "app.py", 2, entry_points=[])
|
||||
assert result == source.SourceFacts("python", "run", (1, 2), (("web.py", 2),), ())
|
||||
|
||||
|
||||
def test_parse_rejection_is_not_an_empty_result(monkeypatch):
|
||||
from openultrasast.semantic.ir import FileIR
|
||||
|
||||
monkeypatch.setattr(
|
||||
cst, "parse_with_cst", lambda path, text, language: FileIR(path, language, "tree-sitter", (), False, "parse_failed")
|
||||
)
|
||||
with pytest.raises(ValueError, match="source parse failed"):
|
||||
source.ranges("app.py", "def broken(", "python")
|
||||
Reference in New Issue
Block a user