diff --git a/.gitignore b/.gitignore index 71c7af4..9b4e81f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ .worktrees/ homepage/public/demo/ .private/ +__pycache__/ +*.pyc diff --git a/understand-anything-plugin/skills/understand/merge-batch-graphs.py b/understand-anything-plugin/skills/understand/merge-batch-graphs.py index 54a1b5c..c0154de 100644 --- a/understand-anything-plugin/skills/understand/merge-batch-graphs.py +++ b/understand-anything-plugin/skills/understand/merge-batch-graphs.py @@ -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. or *.spec. + 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/). + 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) diff --git a/understand-anything-plugin/skills/understand/test_merge_batch_graphs.py b/understand-anything-plugin/skills/understand/test_merge_batch_graphs.py new file mode 100644 index 0000000..d56875b --- /dev/null +++ b/understand-anything-plugin/skills/understand/test_merge_batch_graphs.py @@ -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()