mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(merge): deterministic tested_by linker (#113)
The file-analyzer LLM only sees the production↔test relationship when
analyzing a test file (production files don't import their tests), so
its emitted direction was unreliable across batches and recall was
massively undercounted (~7% on a real Nuxt 4 + Directus repo).
Move tested_by production entirely into the merge step. The linker:
- Strips every tested_by edge from batch input (LLM direction unreliable).
- Indexes file:* nodes and classifies each path as test or production.
- For each test, walks ordered candidate production paths (sibling
de-infix, __tests__/ walk-out, mirrored tests/→{src,app,lib,<root>}
tree, Maven/Gradle src/test/...→src/main/...).
- Emits canonical production → test edges and tags production nodes
"tested".
Supported conventions: JS/TS family (.test/.spec), Go (_test.go),
Python (test_*.py, *_test.py), Java (*Test/*Tests/*IT.java), Kotlin
(*Test/*Tests.kt), C# (*Test/*Tests.cs), C/C++ (test_*, *_test).
Stdlib only, type-hinted in existing style. Hooked into
merge_and_normalize between node dedup (Step 5) and edge dedup
(Step 6). Reports drops under "Fixed" and additions under a new
"Tested-by linker" section.
Tests cover path classification, candidate generation, full link_tests
behaviour (forward direction, idempotence, LLM-edge stripping,
test-to-test rejection), and the merge integration. 31 cases, stdlib
unittest, runnable with `python -m unittest test_merge_batch_graphs.py`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,3 +11,5 @@ coverage/
|
||||
.worktrees/
|
||||
homepage/public/demo/
|
||||
.private/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -69,6 +69,24 @@ COMPLEXITY_MAP: dict[str, str] = {
|
||||
VALID_COMPLEXITY = {"simple", "moderate", "complex"}
|
||||
|
||||
|
||||
# ── tested_by linker configuration ────────────────────────────────────────
|
||||
|
||||
# JS/TS family: a `.test.ts` file may be testing a `.ts`, `.tsx`, `.js`, etc.
|
||||
# We try each candidate extension in priority order.
|
||||
_JS_TS_EXTS: tuple[str, ...] = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue")
|
||||
_JS_TS_TEST_EXTS: frozenset[str] = frozenset(_JS_TS_EXTS)
|
||||
|
||||
# Test directory names — if any path segment matches one of these, the file
|
||||
# is *located in* a test area. By itself this is not enough to classify the
|
||||
# file as a test (helpers/fixtures live there too); we still require a test
|
||||
# extension on the basename.
|
||||
_TEST_DIR_SEGMENTS: frozenset[str] = frozenset({"__tests__", "tests", "test", "spec"})
|
||||
|
||||
# Mirrored production roots — when a test sits under `tests/`, it might be
|
||||
# mirroring `src/`, `app/`, `lib/`, or the project root.
|
||||
_MIRROR_PRODUCTION_ROOTS: tuple[str, ...] = ("src", "app", "lib", "")
|
||||
|
||||
|
||||
def _num(v: Any) -> float:
|
||||
"""Coerce a value to float for safe comparison (handles string weights)."""
|
||||
try:
|
||||
@@ -190,6 +208,314 @@ def normalize_complexity(value: Any) -> tuple[str, str]:
|
||||
return "moderate", "unknown"
|
||||
|
||||
|
||||
# ── Deterministic tested_by linker ────────────────────────────────────────
|
||||
#
|
||||
# `tested_by` edges are produced here, not by the LLM. The LLM sees the
|
||||
# relationship only when analyzing a *test* file (production files don't
|
||||
# import their tests), so its emitted direction is unreliable across
|
||||
# batches. We strip every LLM-emitted `tested_by` edge and produce canonical
|
||||
# `production → test` edges from path conventions instead.
|
||||
|
||||
def _path_segments(path: str) -> list[str]:
|
||||
"""Split a relative POSIX-style path into segments (ignoring empties)."""
|
||||
return [seg for seg in path.split("/") if seg]
|
||||
|
||||
|
||||
def _basename(path: str) -> str:
|
||||
return path.rsplit("/", 1)[-1] if "/" in path else path
|
||||
|
||||
|
||||
def _splitext(name: str) -> tuple[str, str]:
|
||||
"""Return (stem, ext) for a basename. Single-extension only."""
|
||||
if "." not in name:
|
||||
return name, ""
|
||||
stem, _, ext = name.rpartition(".")
|
||||
return stem, "." + ext
|
||||
|
||||
|
||||
def is_test_path(path: str) -> bool:
|
||||
"""Return True if `path` looks like a test file by basename convention.
|
||||
|
||||
Files inside `tests/`, `__tests__/`, `test/`, or `spec/` directories that
|
||||
do NOT carry a recognized test extension are treated as helpers/fixtures
|
||||
and classified as non-test (so `__tests__/helpers.ts` is not a test).
|
||||
"""
|
||||
name = _basename(path)
|
||||
stem, ext = _splitext(name)
|
||||
|
||||
# JS/TS family: *.test.<ext> or *.spec.<ext>
|
||||
if ext in _JS_TS_TEST_EXTS:
|
||||
# stem may itself end with .test or .spec
|
||||
for infix in (".test", ".spec"):
|
||||
if stem.endswith(infix):
|
||||
return True
|
||||
|
||||
# Go: *_test.go
|
||||
if ext == ".go" and stem.endswith("_test"):
|
||||
return True
|
||||
|
||||
# Python: test_*.py or *_test.py
|
||||
if ext == ".py" and (stem.startswith("test_") or stem.endswith("_test")):
|
||||
return True
|
||||
|
||||
# Java: *Test.java, *Tests.java, *IT.java
|
||||
if ext == ".java" and (
|
||||
stem.endswith("Test") or stem.endswith("Tests") or stem.endswith("IT")
|
||||
):
|
||||
return True
|
||||
|
||||
# Kotlin: *Test.kt, *Tests.kt
|
||||
if ext == ".kt" and (stem.endswith("Test") or stem.endswith("Tests")):
|
||||
return True
|
||||
|
||||
# C#: *Test.cs, *Tests.cs
|
||||
if ext == ".cs" and (stem.endswith("Test") or stem.endswith("Tests")):
|
||||
return True
|
||||
|
||||
# C/C++: *_test.{c,cpp,cc} or test_*.{c,cpp,cc}
|
||||
if ext in {".c", ".cpp", ".cc"} and (
|
||||
stem.startswith("test_") or stem.endswith("_test")
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _strip_test_infix(stem: str) -> str | None:
|
||||
"""For a JS/TS-family stem like `foo.test` or `foo.spec`, strip the
|
||||
trailing `.test` / `.spec`. Returns None if no infix is present."""
|
||||
for infix in (".test", ".spec"):
|
||||
if stem.endswith(infix):
|
||||
return stem[: -len(infix)]
|
||||
return None
|
||||
|
||||
|
||||
def _js_ts_sibling_candidates(dir_path: str, base_stem: str) -> list[str]:
|
||||
"""Build sibling candidates for a JS/TS family base stem.
|
||||
|
||||
`dir_path` is the parent dir (no trailing slash, may be empty).
|
||||
`base_stem` is the stem with the test infix already stripped.
|
||||
"""
|
||||
prefix = f"{dir_path}/" if dir_path else ""
|
||||
return [f"{prefix}{base_stem}{e}" for e in _JS_TS_EXTS]
|
||||
|
||||
|
||||
def production_candidates(test_path: str) -> list[str]:
|
||||
"""For a test file path, return ordered candidate production paths.
|
||||
|
||||
The returned list is in priority order (sibling first, then `__tests__`
|
||||
walk-out, then mirrored-tree variants). Duplicates are removed while
|
||||
preserving order. Caller should pick the first candidate that resolves
|
||||
to a known production node.
|
||||
"""
|
||||
name = _basename(test_path)
|
||||
stem, ext = _splitext(name)
|
||||
segs = _path_segments(test_path)
|
||||
dir_segs = segs[:-1]
|
||||
dir_path = "/".join(dir_segs)
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
def add(path: str) -> None:
|
||||
if path and path not in candidates:
|
||||
candidates.append(path)
|
||||
|
||||
# ── JS/TS family ──────────────────────────────────────────────────
|
||||
if ext in _JS_TS_TEST_EXTS:
|
||||
base_stem = _strip_test_infix(stem)
|
||||
if base_stem is not None:
|
||||
# 1. Sibling de-infix: prefer the same extension as the test, then
|
||||
# the rest of the family.
|
||||
sibling_dir = dir_path
|
||||
same_ext = f"{(sibling_dir + '/') if sibling_dir else ''}{base_stem}{ext}"
|
||||
add(same_ext)
|
||||
for c in _js_ts_sibling_candidates(sibling_dir, base_stem):
|
||||
add(c)
|
||||
|
||||
# 2. Walk out of __tests__/ — drop the trailing __tests__ segment.
|
||||
if dir_segs and dir_segs[-1] == "__tests__":
|
||||
parent_dir = "/".join(dir_segs[:-1])
|
||||
add(f"{(parent_dir + '/') if parent_dir else ''}{base_stem}{ext}")
|
||||
for c in _js_ts_sibling_candidates(parent_dir, base_stem):
|
||||
add(c)
|
||||
|
||||
# 3. Mirrored tree: tests/foo/X.test.ts → src/foo/X.ts (and
|
||||
# variants for app/lib/<root>).
|
||||
if dir_segs and dir_segs[0] in ("tests", "test", "__tests__"):
|
||||
tail = dir_segs[1:]
|
||||
tail_path = "/".join(tail)
|
||||
for root in _MIRROR_PRODUCTION_ROOTS:
|
||||
parts = [p for p in (root, tail_path) if p]
|
||||
new_dir = "/".join(parts)
|
||||
add(f"{(new_dir + '/') if new_dir else ''}{base_stem}{ext}")
|
||||
for c in _js_ts_sibling_candidates(new_dir, base_stem):
|
||||
add(c)
|
||||
|
||||
# ── Go ────────────────────────────────────────────────────────────
|
||||
elif ext == ".go" and stem.endswith("_test"):
|
||||
base_stem = stem[: -len("_test")]
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}.go")
|
||||
|
||||
# ── Python ────────────────────────────────────────────────────────
|
||||
elif ext == ".py" and (stem.startswith("test_") or stem.endswith("_test")):
|
||||
if stem.startswith("test_"):
|
||||
base_stem = stem[len("test_"):]
|
||||
else:
|
||||
base_stem = stem[: -len("_test")]
|
||||
|
||||
# Sibling
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}.py")
|
||||
|
||||
# Mirrored: tests/foo/test_bar.py → src/foo/bar.py (and variants)
|
||||
if dir_segs and dir_segs[0] in ("tests", "test"):
|
||||
tail = dir_segs[1:]
|
||||
tail_path = "/".join(tail)
|
||||
for root in _MIRROR_PRODUCTION_ROOTS:
|
||||
parts = [p for p in (root, tail_path) if p]
|
||||
new_dir = "/".join(parts)
|
||||
add(f"{(new_dir + '/') if new_dir else ''}{base_stem}.py")
|
||||
|
||||
# ── Java ──────────────────────────────────────────────────────────
|
||||
elif ext == ".java":
|
||||
for suffix in ("Tests", "Test", "IT"):
|
||||
if stem.endswith(suffix):
|
||||
base_stem = stem[: -len(suffix)]
|
||||
# Maven/Gradle layout: swap src/test/java/... → src/main/java/...
|
||||
if (
|
||||
len(dir_segs) >= 3
|
||||
and dir_segs[0] == "src"
|
||||
and dir_segs[1] == "test"
|
||||
and dir_segs[2] == "java"
|
||||
):
|
||||
new_segs = ["src", "main", "java"] + list(dir_segs[3:])
|
||||
new_dir = "/".join(new_segs)
|
||||
add(f"{new_dir}/{base_stem}.java")
|
||||
# Sibling fallback
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}.java")
|
||||
break
|
||||
|
||||
# ── Kotlin ────────────────────────────────────────────────────────
|
||||
elif ext == ".kt":
|
||||
for suffix in ("Tests", "Test"):
|
||||
if stem.endswith(suffix):
|
||||
base_stem = stem[: -len(suffix)]
|
||||
if (
|
||||
len(dir_segs) >= 3
|
||||
and dir_segs[0] == "src"
|
||||
and dir_segs[1] == "test"
|
||||
and dir_segs[2] == "kotlin"
|
||||
):
|
||||
new_segs = ["src", "main", "kotlin"] + list(dir_segs[3:])
|
||||
new_dir = "/".join(new_segs)
|
||||
add(f"{new_dir}/{base_stem}.kt")
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}.kt")
|
||||
break
|
||||
|
||||
# ── C# ────────────────────────────────────────────────────────────
|
||||
elif ext == ".cs":
|
||||
for suffix in ("Tests", "Test"):
|
||||
if stem.endswith(suffix):
|
||||
base_stem = stem[: -len(suffix)]
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}.cs")
|
||||
break
|
||||
|
||||
# ── C/C++ ─────────────────────────────────────────────────────────
|
||||
elif ext in {".c", ".cpp", ".cc"}:
|
||||
if stem.startswith("test_"):
|
||||
base_stem = stem[len("test_"):]
|
||||
elif stem.endswith("_test"):
|
||||
base_stem = stem[: -len("_test")]
|
||||
else:
|
||||
base_stem = None
|
||||
if base_stem is not None:
|
||||
add(f"{(dir_path + '/') if dir_path else ''}{base_stem}{ext}")
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _file_node_path(node: dict[str, Any]) -> str | None:
|
||||
"""Return the relative project path for a `file:`-prefixed node, else None."""
|
||||
nid = node.get("id", "")
|
||||
if not isinstance(nid, str) or not nid.startswith("file:"):
|
||||
return None
|
||||
fp = node.get("filePath")
|
||||
if isinstance(fp, str) and fp:
|
||||
return fp
|
||||
return nid[len("file:"):]
|
||||
|
||||
|
||||
def link_tests(
|
||||
nodes_by_id: dict[str, dict[str, Any]],
|
||||
edges: list[dict[str, Any]],
|
||||
) -> tuple[int, int, int]:
|
||||
"""Strip LLM-emitted `tested_by` edges, then link production files to
|
||||
their tests deterministically.
|
||||
|
||||
Mutates `nodes_by_id` (adds "tested" tag) and `edges` (in-place strip
|
||||
+ append).
|
||||
|
||||
Returns (added, dropped, tagged):
|
||||
added: number of deterministic tested_by edges appended
|
||||
dropped: number of pre-existing tested_by edges removed
|
||||
tagged: number of production nodes newly tagged "tested"
|
||||
"""
|
||||
# 1. Strip every existing tested_by edge — the LLM's direction is
|
||||
# unreliable, so we discard and replace.
|
||||
dropped = 0
|
||||
write_idx = 0
|
||||
for edge in edges:
|
||||
if edge.get("type") == "tested_by":
|
||||
dropped += 1
|
||||
continue
|
||||
edges[write_idx] = edge
|
||||
write_idx += 1
|
||||
del edges[write_idx:]
|
||||
|
||||
# 2. Index file nodes by relative path; classify each as test or production.
|
||||
file_paths_to_nodes: dict[str, dict[str, Any]] = {}
|
||||
test_nodes: list[tuple[str, dict[str, Any]]] = []
|
||||
for node in nodes_by_id.values():
|
||||
path = _file_node_path(node)
|
||||
if path is None:
|
||||
continue
|
||||
file_paths_to_nodes[path] = node
|
||||
if is_test_path(path):
|
||||
test_nodes.append((path, node))
|
||||
|
||||
# 3. For each test, walk its candidate production paths and take the
|
||||
# first one that exists AND is itself classified as production.
|
||||
added = 0
|
||||
tagged = 0
|
||||
for test_path, test_node in test_nodes:
|
||||
for cand_path in production_candidates(test_path):
|
||||
prod_node = file_paths_to_nodes.get(cand_path)
|
||||
if prod_node is None:
|
||||
continue
|
||||
if is_test_path(cand_path):
|
||||
# Don't link a test to another test even if naming aligns.
|
||||
continue
|
||||
edges.append({
|
||||
"source": prod_node["id"],
|
||||
"target": test_node["id"],
|
||||
"type": "tested_by",
|
||||
"direction": "forward",
|
||||
"weight": 0.5,
|
||||
"description": "Linked by path convention",
|
||||
})
|
||||
added += 1
|
||||
tags = prod_node.setdefault("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
# Defensive: replace malformed tags with a fresh list.
|
||||
tags = []
|
||||
prod_node["tags"] = tags
|
||||
if "tested" not in tags:
|
||||
tags.append("tested")
|
||||
tagged += 1
|
||||
break # one production match per test file
|
||||
|
||||
return added, dropped, tagged
|
||||
|
||||
|
||||
# ── Main merge + normalize ────────────────────────────────────────────────
|
||||
|
||||
def merge_and_normalize(batches: list[dict[str, Any]]) -> tuple[dict[str, Any], list[str]]:
|
||||
@@ -273,6 +599,15 @@ def merge_and_normalize(batches: list[dict[str, Any]]) -> tuple[dict[str, Any],
|
||||
duplicate_count += 1
|
||||
nodes_by_id[nid] = node
|
||||
|
||||
# ── Step 5b: Deterministic tested_by linker ──────────────────────
|
||||
# Strip every LLM-emitted tested_by edge (direction unreliable across
|
||||
# batches) and replace with canonical `production → test` edges derived
|
||||
# from path conventions. Production files that gain a paired test get
|
||||
# the "tested" tag.
|
||||
tested_by_added, tested_by_dropped, tested_by_tagged = link_tests(
|
||||
nodes_by_id, all_edges
|
||||
)
|
||||
|
||||
# ── Step 6: Deduplicate edges, drop dangling ─────────────────────
|
||||
node_ids = set(nodes_by_id.keys())
|
||||
edges_by_key: dict[tuple[str, str, str], dict] = {}
|
||||
@@ -311,12 +646,22 @@ def merge_and_normalize(batches: list[dict[str, Any]]) -> tuple[dict[str, Any],
|
||||
fixed_lines.append(f" {edges_rewritten:>4} × edge references rewritten after ID normalization")
|
||||
if duplicate_count:
|
||||
fixed_lines.append(f" {duplicate_count:>4} × duplicate node IDs removed (kept last)")
|
||||
if tested_by_dropped:
|
||||
fixed_lines.append(f" {tested_by_dropped:>4} × LLM-emitted tested_by edges dropped (direction unreliable)")
|
||||
|
||||
if fixed_lines:
|
||||
report.append("")
|
||||
report.append(f"Fixed ({sum(id_fix_patterns.values()) + sum(complexity_fix_patterns.values()) + edges_rewritten + duplicate_count} corrections):")
|
||||
report.append(f"Fixed ({sum(id_fix_patterns.values()) + sum(complexity_fix_patterns.values()) + edges_rewritten + duplicate_count + tested_by_dropped} corrections):")
|
||||
report.extend(fixed_lines)
|
||||
|
||||
# Tested-by linker section — separate from Fixed since these are net-new
|
||||
# additions, not corrections.
|
||||
if tested_by_added or tested_by_tagged:
|
||||
report.append("")
|
||||
report.append("Tested-by linker:")
|
||||
report.append(f" {tested_by_added:>4} × tested_by edges produced (production → test)")
|
||||
report.append(f" {tested_by_tagged:>4} × production nodes tagged \"tested\"")
|
||||
|
||||
# Could not fix section — unknown patterns (grouped) + individual details
|
||||
unfixable_total = (
|
||||
len(unfixable)
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_merge_batch_graphs.py — Tests for the deterministic tested_by linker.
|
||||
|
||||
Run from this directory:
|
||||
python -m unittest test_merge_batch_graphs.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Module loader ─────────────────────────────────────────────────────────
|
||||
# `merge-batch-graphs.py` has a hyphen in its name, so we cannot `import` it
|
||||
# directly. Load it via importlib so we can call its module-level helpers.
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_MODULE_PATH = _HERE / "merge-batch-graphs.py"
|
||||
|
||||
|
||||
def _load_module() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("merge_batch_graphs", _MODULE_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load module from {_MODULE_PATH}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["merge_batch_graphs"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
mbg = _load_module()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _file_node(path: str, **extra: Any) -> dict[str, Any]:
|
||||
"""Build a minimal file node with the given relative path."""
|
||||
node: dict[str, Any] = {
|
||||
"id": f"file:{path}",
|
||||
"type": "file",
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"filePath": path,
|
||||
"summary": "",
|
||||
"tags": [],
|
||||
"complexity": "simple",
|
||||
}
|
||||
node.update(extra)
|
||||
return node
|
||||
|
||||
|
||||
# ── is_test_path ──────────────────────────────────────────────────────────
|
||||
|
||||
class IsTestPathTests(unittest.TestCase):
|
||||
"""Path classification: production vs. test."""
|
||||
|
||||
def test_js_ts_sibling_test_extensions(self) -> None:
|
||||
for path in [
|
||||
"src/foo.test.ts",
|
||||
"src/foo.test.tsx",
|
||||
"src/foo.test.js",
|
||||
"src/foo.test.jsx",
|
||||
"src/foo.test.mjs",
|
||||
"src/foo.test.cjs",
|
||||
"src/Component.test.vue",
|
||||
"src/foo.spec.ts",
|
||||
"src/foo.spec.tsx",
|
||||
"src/foo.spec.js",
|
||||
"src/Component.spec.vue",
|
||||
]:
|
||||
with self.subTest(path=path):
|
||||
self.assertTrue(mbg.is_test_path(path), f"{path} should be a test")
|
||||
|
||||
def test_underscore_test_dir_with_test_extension(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("src/__tests__/foo.test.js"))
|
||||
self.assertTrue(mbg.is_test_path("src/__tests__/foo.test.ts"))
|
||||
|
||||
def test_tests_directory_with_test_extension(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("tests/foo/X.test.ts"))
|
||||
self.assertTrue(mbg.is_test_path("test/foo/X.test.ts"))
|
||||
self.assertTrue(mbg.is_test_path("spec/foo/X.spec.ts"))
|
||||
|
||||
def test_go_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("internal/bar_test.go"))
|
||||
self.assertTrue(mbg.is_test_path("bar_test.go"))
|
||||
|
||||
def test_python_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("tests/test_bar.py"))
|
||||
self.assertTrue(mbg.is_test_path("bar_test.py"))
|
||||
self.assertTrue(mbg.is_test_path("test_bar.py"))
|
||||
|
||||
def test_java_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("src/test/java/com/foo/BarTest.java"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/java/com/foo/BarTests.java"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/java/com/foo/BarIT.java"))
|
||||
|
||||
def test_kotlin_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("src/test/kotlin/com/foo/BarTest.kt"))
|
||||
self.assertTrue(mbg.is_test_path("src/test/kotlin/com/foo/BarTests.kt"))
|
||||
|
||||
def test_csharp_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("Foo.Tests/BarTests.cs"))
|
||||
self.assertTrue(mbg.is_test_path("Foo.Tests/BarTest.cs"))
|
||||
|
||||
def test_c_cpp_test_files(self) -> None:
|
||||
self.assertTrue(mbg.is_test_path("test/bar_test.c"))
|
||||
self.assertTrue(mbg.is_test_path("test/test_bar.c"))
|
||||
self.assertTrue(mbg.is_test_path("test/bar_test.cpp"))
|
||||
self.assertTrue(mbg.is_test_path("test/bar_test.cc"))
|
||||
self.assertTrue(mbg.is_test_path("test/test_bar.cpp"))
|
||||
|
||||
def test_production_files_rejected(self) -> None:
|
||||
for path in [
|
||||
"src/foo.ts",
|
||||
"src/foo.tsx",
|
||||
"internal/bar.go",
|
||||
"src/index.tsx",
|
||||
"README.md",
|
||||
"docs/guide.md",
|
||||
"main.py",
|
||||
"src/foo/bar.js",
|
||||
"Foo.cs",
|
||||
"Bar.kt",
|
||||
"Bar.java",
|
||||
]:
|
||||
with self.subTest(path=path):
|
||||
self.assertFalse(mbg.is_test_path(path), f"{path} should be production")
|
||||
|
||||
def test_helper_in_tests_dir_without_test_extension_is_not_test(self) -> None:
|
||||
# Files that live inside a __tests__ directory but don't carry a test
|
||||
# extension are treated as helpers, not tests. We only count code files
|
||||
# whose basename matches a test pattern. Assets/non-code files in
|
||||
# tests/ are not flagged.
|
||||
self.assertFalse(mbg.is_test_path("src/__tests__/helpers.ts"))
|
||||
self.assertFalse(mbg.is_test_path("tests/fixtures/data.json"))
|
||||
|
||||
|
||||
# ── production_candidates ─────────────────────────────────────────────────
|
||||
|
||||
class ProductionCandidatesTests(unittest.TestCase):
|
||||
"""For each test path, what production paths should we try?"""
|
||||
|
||||
def test_js_ts_sibling(self) -> None:
|
||||
cands = mbg.production_candidates("src/foo/X.test.ts")
|
||||
# Sibling de-infix should be in the candidate list, with .ts as the
|
||||
# most natural target. Several extensions are tried because a .test.ts
|
||||
# file might test a .tsx file.
|
||||
self.assertIn("src/foo/X.ts", cands)
|
||||
self.assertIn("src/foo/X.tsx", cands)
|
||||
|
||||
def test_js_ts_spec_sibling(self) -> None:
|
||||
cands = mbg.production_candidates("src/foo/X.spec.tsx")
|
||||
self.assertIn("src/foo/X.tsx", cands)
|
||||
self.assertIn("src/foo/X.ts", cands)
|
||||
|
||||
def test_underscore_tests_dir(self) -> None:
|
||||
cands = mbg.production_candidates("src/foo/__tests__/X.test.ts")
|
||||
# Walking out of __tests__/ should produce src/foo/X.ts
|
||||
self.assertIn("src/foo/X.ts", cands)
|
||||
|
||||
def test_mirrored_tests_tree(self) -> None:
|
||||
cands = mbg.production_candidates("tests/foo/X.test.ts")
|
||||
# Should try src/foo/X.ts, app/foo/X.ts, lib/foo/X.ts, foo/X.ts
|
||||
self.assertIn("src/foo/X.ts", cands)
|
||||
self.assertIn("foo/X.ts", cands)
|
||||
|
||||
def test_go_sibling(self) -> None:
|
||||
cands = mbg.production_candidates("internal/bar_test.go")
|
||||
self.assertIn("internal/bar.go", cands)
|
||||
|
||||
def test_python_test_prefix(self) -> None:
|
||||
cands = mbg.production_candidates("tests/test_bar.py")
|
||||
self.assertIn("tests/bar.py", cands)
|
||||
# Also try mirrored layout
|
||||
self.assertIn("bar.py", cands)
|
||||
self.assertIn("src/bar.py", cands)
|
||||
|
||||
def test_python_test_suffix(self) -> None:
|
||||
cands = mbg.production_candidates("foo/bar_test.py")
|
||||
self.assertIn("foo/bar.py", cands)
|
||||
|
||||
def test_java_maven_layout(self) -> None:
|
||||
cands = mbg.production_candidates("src/test/java/com/foo/BarTest.java")
|
||||
self.assertIn("src/main/java/com/foo/Bar.java", cands)
|
||||
|
||||
def test_java_tests_suffix(self) -> None:
|
||||
cands = mbg.production_candidates("src/test/java/com/foo/BarTests.java")
|
||||
self.assertIn("src/main/java/com/foo/Bar.java", cands)
|
||||
|
||||
def test_java_it_suffix(self) -> None:
|
||||
cands = mbg.production_candidates("src/test/java/com/foo/BarIT.java")
|
||||
self.assertIn("src/main/java/com/foo/Bar.java", cands)
|
||||
|
||||
def test_kotlin_maven_layout(self) -> None:
|
||||
cands = mbg.production_candidates("src/test/kotlin/com/foo/BarTest.kt")
|
||||
self.assertIn("src/main/kotlin/com/foo/Bar.kt", cands)
|
||||
|
||||
|
||||
# ── link_tests (end-to-end) ───────────────────────────────────────────────
|
||||
|
||||
class LinkTestsTests(unittest.TestCase):
|
||||
"""End-to-end behaviour of the linker against a node/edge set."""
|
||||
|
||||
def test_basic_pairing_emits_forward_edge(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts"),
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(dropped, 0)
|
||||
self.assertEqual(tagged, 1)
|
||||
self.assertEqual(len(edges), 1)
|
||||
edge = edges[0]
|
||||
self.assertEqual(edge["source"], "file:src/foo.ts")
|
||||
self.assertEqual(edge["target"], "file:src/foo.test.ts")
|
||||
self.assertEqual(edge["type"], "tested_by")
|
||||
self.assertEqual(edge["direction"], "forward")
|
||||
self.assertEqual(edge["weight"], 0.5)
|
||||
self.assertIn("tested", nodes_by_id["file:src/foo.ts"]["tags"])
|
||||
# Test node is not tagged with "tested"
|
||||
self.assertNotIn("tested", nodes_by_id["file:src/foo.test.ts"]["tags"])
|
||||
|
||||
def test_no_production_counterpart_no_edge(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 0)
|
||||
self.assertEqual(tagged, 0)
|
||||
self.assertEqual(len(edges), 0)
|
||||
|
||||
def test_strips_existing_llm_tested_by_edges(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts"),
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
# Existing inverted LLM edge: test → production (wrong direction)
|
||||
edges: list[dict[str, Any]] = [
|
||||
{
|
||||
"source": "file:src/foo.test.ts",
|
||||
"target": "file:src/foo.ts",
|
||||
"type": "tested_by",
|
||||
"direction": "forward",
|
||||
"weight": 0.5,
|
||||
"description": "from LLM",
|
||||
},
|
||||
# Unrelated edge — should survive untouched
|
||||
{
|
||||
"source": "file:src/foo.ts",
|
||||
"target": "file:src/foo.test.ts",
|
||||
"type": "imports",
|
||||
"direction": "forward",
|
||||
"weight": 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(dropped, 1)
|
||||
self.assertEqual(tagged, 1)
|
||||
|
||||
tested_by_edges = [e for e in edges if e["type"] == "tested_by"]
|
||||
self.assertEqual(len(tested_by_edges), 1)
|
||||
self.assertEqual(tested_by_edges[0]["source"], "file:src/foo.ts")
|
||||
self.assertEqual(tested_by_edges[0]["target"], "file:src/foo.test.ts")
|
||||
|
||||
# Imports edge survives
|
||||
import_edges = [e for e in edges if e["type"] == "imports"]
|
||||
self.assertEqual(len(import_edges), 1)
|
||||
|
||||
def test_direction_always_forward_production_to_test(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts"),
|
||||
"file:src/__tests__/foo.test.ts": _file_node("src/__tests__/foo.test.ts"),
|
||||
"file:internal/bar.go": _file_node("internal/bar.go"),
|
||||
"file:internal/bar_test.go": _file_node("internal/bar_test.go"),
|
||||
"file:src/main/java/com/foo/Bar.java": _file_node("src/main/java/com/foo/Bar.java"),
|
||||
"file:src/test/java/com/foo/BarTest.java": _file_node("src/test/java/com/foo/BarTest.java"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 3)
|
||||
for edge in edges:
|
||||
self.assertEqual(edge["type"], "tested_by")
|
||||
self.assertEqual(edge["direction"], "forward")
|
||||
# Target must be the test file (basename gives it away)
|
||||
self.assertTrue(
|
||||
mbg.is_test_path(edge["target"][len("file:"):]),
|
||||
f"target {edge['target']} should classify as test",
|
||||
)
|
||||
self.assertFalse(
|
||||
mbg.is_test_path(edge["source"][len("file:"):]),
|
||||
f"source {edge['source']} should classify as production",
|
||||
)
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts"),
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
mbg.link_tests(nodes_by_id, edges)
|
||||
# Second invocation must not duplicate edges or tags.
|
||||
added2, dropped2, tagged2 = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
# On the second pass: existing deterministic edge gets stripped (it is
|
||||
# a tested_by edge) and re-added; tag is already there. So edge count
|
||||
# stays at 1 and tags has exactly one "tested".
|
||||
tested_by_edges = [e for e in edges if e["type"] == "tested_by"]
|
||||
self.assertEqual(len(tested_by_edges), 1)
|
||||
tags = nodes_by_id["file:src/foo.ts"]["tags"]
|
||||
self.assertEqual(tags.count("tested"), 1)
|
||||
|
||||
def test_first_matching_candidate_wins(self) -> None:
|
||||
# If both src/foo.ts and src/foo.tsx exist, the linker should match
|
||||
# exactly one of them (the first candidate). Sibling de-infix yields
|
||||
# .ts before .tsx (since the test is named foo.test.ts).
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts"),
|
||||
"file:src/foo.tsx": _file_node("src/foo.tsx"),
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(tagged, 1)
|
||||
# Only one of them gets tagged.
|
||||
ts_tagged = "tested" in nodes_by_id["file:src/foo.ts"]["tags"]
|
||||
tsx_tagged = "tested" in nodes_by_id["file:src/foo.tsx"]["tags"]
|
||||
self.assertTrue(ts_tagged != tsx_tagged, "exactly one should be tagged")
|
||||
# The .ts file should win (it matches the test-file extension).
|
||||
self.assertTrue(ts_tagged)
|
||||
|
||||
def test_does_not_match_test_to_test(self) -> None:
|
||||
# If only test files exist, no edges are produced — we never link a
|
||||
# test to another test.
|
||||
nodes_by_id = {
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
"file:src/foo.spec.ts": _file_node("src/foo.spec.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
added, dropped, tagged = mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
self.assertEqual(added, 0)
|
||||
self.assertEqual(tagged, 0)
|
||||
|
||||
def test_does_not_duplicate_existing_tag(self) -> None:
|
||||
# Production node already carries the "tested" tag — linker should
|
||||
# not duplicate it.
|
||||
nodes_by_id = {
|
||||
"file:src/foo.ts": _file_node("src/foo.ts", tags=["tested", "core"]),
|
||||
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
|
||||
}
|
||||
edges: list[dict[str, Any]] = []
|
||||
|
||||
mbg.link_tests(nodes_by_id, edges)
|
||||
|
||||
tags = nodes_by_id["file:src/foo.ts"]["tags"]
|
||||
self.assertEqual(tags.count("tested"), 1)
|
||||
self.assertIn("core", tags)
|
||||
|
||||
|
||||
# ── merge_and_normalize integration ───────────────────────────────────────
|
||||
|
||||
class MergeIntegrationTests(unittest.TestCase):
|
||||
"""Verify the linker is wired into merge_and_normalize correctly."""
|
||||
|
||||
def test_linker_runs_during_merge(self) -> None:
|
||||
batch = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "file:src/foo.ts",
|
||||
"type": "file",
|
||||
"name": "foo.ts",
|
||||
"filePath": "src/foo.ts",
|
||||
"summary": "",
|
||||
"tags": [],
|
||||
"complexity": "simple",
|
||||
},
|
||||
{
|
||||
"id": "file:src/foo.test.ts",
|
||||
"type": "file",
|
||||
"name": "foo.test.ts",
|
||||
"filePath": "src/foo.test.ts",
|
||||
"summary": "",
|
||||
"tags": [],
|
||||
"complexity": "simple",
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
# An LLM-emitted (inverted) tested_by edge — should be dropped
|
||||
{
|
||||
"source": "file:src/foo.test.ts",
|
||||
"target": "file:src/foo.ts",
|
||||
"type": "tested_by",
|
||||
"direction": "forward",
|
||||
"weight": 0.5,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
assembled, report = mbg.merge_and_normalize([batch])
|
||||
|
||||
# Output should have exactly one tested_by edge with canonical direction
|
||||
tested_by_edges = [e for e in assembled["edges"] if e["type"] == "tested_by"]
|
||||
self.assertEqual(len(tested_by_edges), 1)
|
||||
self.assertEqual(tested_by_edges[0]["source"], "file:src/foo.ts")
|
||||
self.assertEqual(tested_by_edges[0]["target"], "file:src/foo.test.ts")
|
||||
|
||||
# Production node tagged
|
||||
prod_node = next(n for n in assembled["nodes"] if n["id"] == "file:src/foo.ts")
|
||||
self.assertIn("tested", prod_node["tags"])
|
||||
|
||||
# Report mentions the linker
|
||||
report_text = "\n".join(report)
|
||||
self.assertIn("tested_by", report_text.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user