Skip to content

Commit aa543fd

Browse files
committed
test(schema): canonical-schema conformance gate (L1)
Adds assert_conformant() check verifying schema_version, relative symbol_table keys, module sources, callable span validity, and absent null values. Fixes core.py to emit relative paths for symbol_table keys per v2 schema spec.
1 parent a5fc904 commit aa543fd

4 files changed

Lines changed: 68 additions & 4 deletions

File tree

codeanalyzer/core.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,9 +564,9 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
564564
if self.file_name is not None:
565565
single_file = self.project_dir / self.file_name
566566
logger.info(f"Analyzing single file: {single_file}")
567-
567+
568568
# Check if file is in cache and unchanged
569-
file_key = str(single_file)
569+
file_key = str(single_file.relative_to(self.project_dir))
570570
if file_key in cached_symbol_table and not self.rebuild_analysis:
571571
# Compute file checksum to see if it changed
572572
if self._file_unchanged(single_file, cached_symbol_table[file_key]):
@@ -616,7 +616,7 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
616616
# Separate files into cached and new/changed
617617
files_to_process = []
618618
for py_file in py_files:
619-
file_key = str(py_file)
619+
file_key = str(py_file.relative_to(self.project_dir))
620620
if file_key in cached_symbol_table and not self.rebuild_analysis:
621621
if self._file_unchanged(py_file, cached_symbol_table[file_key]):
622622
# Use cached version
@@ -644,7 +644,7 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
644644

645645
with ProgressBar(len(py_files), "Building symbol table") as progress:
646646
for py_file in py_files:
647-
file_key = str(py_file)
647+
file_key = str(py_file.relative_to(self.project_dir))
648648

649649
# Check if file is cached and unchanged
650650
if file_key in cached_symbol_table and not self.rebuild_analysis:

test/__init__.py

Whitespace-only changes.

test/conftest_v2.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
def _assert_no_nulls(obj, path="$"):
2+
if obj is None:
3+
raise AssertionError(f"unexpected null at {path} (exclude_none must drop it)")
4+
if isinstance(obj, dict):
5+
for k, v in obj.items():
6+
_assert_no_nulls(v, f"{path}.{k}")
7+
elif isinstance(obj, list):
8+
for i, v in enumerate(obj):
9+
_assert_no_nulls(v, f"{path}[{i}]")
10+
11+
12+
def _iter_callables(app):
13+
def walk_callable(c):
14+
yield c
15+
for ic in (c.get("inner_callables") or {}).values():
16+
yield from walk_callable(ic)
17+
for cl in (c.get("inner_classes") or {}).values():
18+
yield from walk_class(cl)
19+
20+
def walk_class(cl):
21+
for m in (cl.get("methods") or {}).values():
22+
yield from walk_callable(m)
23+
for ic in (cl.get("inner_classes") or {}).values():
24+
yield from walk_class(ic)
25+
26+
for mod in app["symbol_table"].values():
27+
for fn in (mod.get("functions") or {}).values():
28+
yield mod, fn
29+
for cl in (mod.get("classes") or {}).values():
30+
for m in walk_class(cl):
31+
yield mod, m
32+
33+
34+
def assert_conformant(payload: dict, max_level: int) -> None:
35+
_assert_no_nulls(payload)
36+
assert payload["schema_version"] == "2.0.0"
37+
app = payload["application"]
38+
for key, mod in app["symbol_table"].items():
39+
assert not key.startswith("/") and ".." not in key, f"non-relative key {key}"
40+
assert isinstance(mod.get("source"), str) and mod["source"], f"module {key} missing source"
41+
for mod, c in _iter_callables(app):
42+
lo, hi = c["span"]["bytes"]
43+
text = mod["source"].encode("utf-8")[lo:hi].decode("utf-8")
44+
assert text.lstrip().startswith(("def ", "async def ", "@")), f"{c['id']} span mismatch"
45+
for node in c.get("body", {}).values():
46+
if node["kind"] == "call" and max_level >= 2:
47+
assert node.get("callee") is None or isinstance(node["callee"], str)
48+
for mod, c in _iter_callables(app):
49+
node_ids = set(c.get("body", {}).keys())
50+
for lst in ("cfg", "cdg", "ddg", "summary"):
51+
for e in c.get(lst, []):
52+
assert e["src"] in node_ids and e["dst"] in node_ids, f"dangling {lst} in {c['id']}"

test/test_v2_conformance.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,15 @@ def test_cli_emits_v2_envelope(tmp_path: Path):
3333
assert payload["max_level"] == 1
3434
assert payload["application"]["kind"] == "application"
3535
assert "program_graphs" not in payload # dissolved into the tree
36+
37+
38+
def test_l1_output_is_conformant(tmp_path: Path):
39+
from test.conftest_v2 import assert_conformant
40+
proj = tmp_path / "proj"; proj.mkdir()
41+
(proj / "pkg").mkdir()
42+
(proj / "pkg" / "m.py").write_text("def f(a):\n return a\n", encoding="utf-8")
43+
out = subprocess.run(
44+
[sys.executable, "-m", "codeanalyzer", "-i", str(proj), "-a", "1", "--no-venv"],
45+
capture_output=True, text=True, check=True,
46+
).stdout
47+
assert_conformant(json.loads(out), max_level=1)

0 commit comments

Comments
 (0)