Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.9.6",
"version": "2.9.7",
"author": {
"name": "Egonex"
},
Expand Down
2 changes: 1 addition & 1 deletion .copilot-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.9.6",
"version": "2.9.7",
"author": {
"name": "Egonex"
},
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.9.6",
"version": "2.9.7",
"author": {
"name": "Egonex"
},
Expand Down
144 changes: 144 additions & 0 deletions tests/skill/understand/test_merge_batch_graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ def test_scala_test_files(self) -> None:
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarTest.scala"))
self.assertTrue(mbg.is_test_path("src/test/scala/com/foo/BarTests.scala"))

def test_swift_test_files(self) -> None:
for path in [
"Sources/App/AppTests.swift",
"Sources/App/AppTest.swift",
"Sources/App/AppSpec.swift",
"Tests/AppTests/AppTests.swift",
"Tests/AppTests/TestSupport.swift",
]:
with self.subTest(path=path):
self.assertTrue(mbg.is_test_path(path), f"{path} should be a test")

def test_rust_test_files(self) -> None:
for path in ["src/parser_test.rs", "tests/parser.rs"]:
with self.subTest(path=path):
self.assertTrue(mbg.is_test_path(path), f"{path} should be a test")

def test_ruby_test_files(self) -> None:
for path in [
"test/user_test.rb",
"spec/user_spec.rb",
"spec/spec_helper.rb",
]:
with self.subTest(path=path):
self.assertTrue(mbg.is_test_path(path), f"{path} should be a test")

def test_php_test_files(self) -> None:
for path in ["src/UserTest.php", "tests/Feature/User.php"]:
with self.subTest(path=path):
self.assertTrue(mbg.is_test_path(path), f"{path} should be a test")

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"))
Expand All @@ -171,6 +201,10 @@ def test_production_files_rejected(self) -> None:
"Foo.cs",
"Bar.kt",
"Bar.java",
"Sources/App/Contest.swift",
"src/contest.rs",
"lib/latest.rb",
"src/Contest.php",
]:
with self.subTest(path=path):
self.assertFalse(mbg.is_test_path(path), f"{path} should be production")
Expand Down Expand Up @@ -393,6 +427,88 @@ def test_scala_multimodule_sbt_pairing_emits_forward_edge(self) -> None:
"file:modules/core/src/test/scala/com/foo/BarSpec.scala",
)

def test_swift_canonical_llm_edge_is_preserved(self) -> None:
# Regression for #646: a production → test edge must not be treated
# as production → production just because the test file is Swift.
nodes_by_id = {
"file:Sources/App/App.swift": _file_node("Sources/App/App.swift"),
"file:Sources/App/AppTests.swift": _file_node(
"Sources/App/AppTests.swift",
),
}
edges: list[dict[str, Any]] = [
{
"source": "file:Sources/App/App.swift",
"target": "file:Sources/App/AppTests.swift",
"type": "tested_by",
"direction": "forward",
"weight": 0.5,
"description": "from LLM",
},
]

added, dropped, tagged, swapped = mbg.link_tests(nodes_by_id, edges)

self.assertEqual((added, dropped, tagged, swapped), (0, 0, 1, 0))
self.assertEqual(len(edges), 1)
self.assertEqual(edges[0]["source"], "file:Sources/App/App.swift")
self.assertEqual(edges[0]["target"], "file:Sources/App/AppTests.swift")

def test_other_missing_language_patterns_preserve_canonical_edges(self) -> None:
cases = [
("src/parser.rs", "tests/parser.rs"),
("lib/user.rb", "spec/user_spec.rb"),
("src/User.php", "tests/Feature/User.php"),
]
for production_path, test_path in cases:
with self.subTest(test_path=test_path):
production_id = f"file:{production_path}"
test_id = f"file:{test_path}"
nodes_by_id = {
production_id: _file_node(production_path),
test_id: _file_node(test_path),
}
edges: list[dict[str, Any]] = [
{
"source": production_id,
"target": test_id,
"type": "tested_by",
"direction": "forward",
"weight": 0.5,
},
]

result = mbg.link_tests(nodes_by_id, edges)

self.assertEqual(result, (0, 0, 1, 0))
self.assertEqual(len(edges), 1)
self.assertEqual(edges[0]["source"], production_id)
self.assertEqual(edges[0]["target"], test_id)

def test_swift_inverted_llm_edge_is_swapped_and_tagged(self) -> None:
nodes_by_id = {
"file:Sources/App/App.swift": _file_node("Sources/App/App.swift"),
"file:Sources/App/AppTests.swift": _file_node(
"Sources/App/AppTests.swift",
),
}
edges: list[dict[str, Any]] = [
{
"source": "file:Sources/App/AppTests.swift",
"target": "file:Sources/App/App.swift",
"type": "tested_by",
"direction": "forward",
"weight": 0.5,
},
]

added, dropped, tagged, swapped = mbg.link_tests(nodes_by_id, edges)

self.assertEqual((added, dropped, tagged, swapped), (0, 0, 1, 1))
self.assertEqual(edges[0]["source"], "file:Sources/App/App.swift")
self.assertEqual(edges[0]["target"], "file:Sources/App/AppTests.swift")
self.assertIn("tested", nodes_by_id["file:Sources/App/App.swift"]["tags"])

def test_no_production_counterpart_no_edge(self) -> None:
nodes_by_id = {
"file:src/foo.test.ts": _file_node("src/foo.test.ts"),
Expand Down Expand Up @@ -924,6 +1040,34 @@ def test_malformed_tags_is_replaced_not_crashed(self) -> None:
class MergeIntegrationTests(unittest.TestCase):
"""Verify the linker is wired into merge_and_normalize correctly."""

def test_swift_canonical_edge_survives_full_merge(self) -> None:
production_path = "Sources/App/App.swift"
test_path = "Tests/AppTests/AppTests.swift"
batch = {
"nodes": [_file_node(production_path), _file_node(test_path)],
"edges": [
{
"source": f"file:{production_path}",
"target": f"file:{test_path}",
"type": "tested_by",
"direction": "forward",
"weight": 0.5,
"description": "LLM-emitted Swift coverage edge",
},
],
}

assembled, _report = mbg.merge_and_normalize([batch])

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"], f"file:{production_path}")
self.assertEqual(tested_by_edges[0]["target"], f"file:{test_path}")
production_node = next(
node for node in assembled["nodes"] if node["id"] == f"file:{production_path}"
)
self.assertIn("tested", production_node["tags"])

def test_linker_runs_during_merge(self) -> None:
batch = {
"nodes": [
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.9.6",
"version": "2.9.7",
"author": {
"name": "Egonex"
},
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "2.9.6",
"version": "2.9.7",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion understand-anything-plugin/packages/viewer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "understand-anything-viewer",
"version": "2.9.6",
"version": "2.9.7",
"description": "Standalone read-only viewer for Understand-Anything knowledge graphs — no Claude Code or LLM required.",
"type": "module",
"license": "MIT",
Expand Down
31 changes: 27 additions & 4 deletions understand-anything-plugin/skills/understand/merge-batch-graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,24 @@ def resolve_ua_dir(root: Path) -> Path:
".kt": ((), ("Test", "Tests")),
".scala": ((), ("Spec", "Suite", "Test", "Tests")),
".cs": ((), ("Test", "Tests")),
".swift": ((), ("Tests", "Test", "Spec")),
".rs": (("test_",), ("_test",)),
".rb": (("test_",), ("_test", "_spec")),
".php": ((), ("Test",)),
".c": (("test_",), ("_test",)),
".cpp": (("test_",), ("_test",)),
".cc": (("test_",), ("_test",)),
}

# These language configs treat every source file below `tests/` as part of a
# test target, even when the basename itself has no test marker. JS/TS is
# intentionally absent: files such as `__tests__/helpers.ts` remain helpers.
_TEST_DIRECTORY_EXTENSIONS: frozenset[str] = frozenset({".swift", ".rs", ".php"})

_EXACT_TEST_STEMS: dict[str, frozenset[str]] = {
".rb": frozenset({"spec_helper"}),
}


# Mirrors packages/core/src/schema.ts so the dashboard validator has nothing
# left to auto-correct for the `direction` field on merged graphs.
Expand Down Expand Up @@ -327,19 +340,29 @@ def _basename(path: str) -> str:


def is_test_path(path: str) -> bool:
"""Return True if `path` looks like a test file by basename convention.
"""Return True if `path` looks like a test file by language 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).
Most languages use basename markers. Swift, Rust, and PHP additionally
make `tests/` a test-source root. JS/TS files still require `.test` or
`.spec`, so `__tests__/helpers.ts` remains a non-test helper.
"""
stem, ext = os.path.splitext(_basename(path))
ext = ext.lower()

# JS/TS family: the test marker is an infix on the stem (foo.test.ts has
# stem "foo.test", ext ".ts"), not a prefix/suffix on the stem itself.
if ext in _JS_TS_TEST_EXTS:
return stem.endswith(".test") or stem.endswith(".spec")

if ext in _TEST_DIRECTORY_EXTENSIONS and any(
segment.lower() == "tests" for segment in _path_segments(path)[:-1]
):
return True

exact_stems = _EXACT_TEST_STEMS.get(ext)
if exact_stems is not None and stem in exact_stems:
return True

patterns = _TEST_NAME_PATTERNS.get(ext)
if patterns is None:
return False
Expand Down