Skip to content

Commit 62821c1

Browse files
IronAdamantclaude
andcommitted
Add dependency extractors for 8 new languages + test_project.py
Dependency extractors (test_mapper.py): - C#: using statements (using System; using MyApp.Models;) - Java/Kotlin: import statements (import com.myapp.Calculator;) - C/C++: #include directives (#include <gtest/gtest.h>) - Swift: import statements (import XCTest) - PHP: use statements + require/include (use App\Models\User;) - Ruby: require/require_relative (require 'rspec') - Dart: import statements (import 'package:myapp/utils.dart';) Previously these 8 languages had test file discovery and parsing but zero dependency extraction — meaning impact, suggest_tests, and diff_impact were blind to their test coverage. Now test-to-code edges are built for all 12 supported languages. Tests: - test_project.py: 14 tests covering detect_project_root (git repo, subdir, non-git), normalize_path (absolute, relative, dot-slash, parent refs), resolve_storage_dir (priority chain: explicit > env > project-local > home), ProcessLock (exclusive, shared, nested shared, directory creation, release on exception) - test_test_mapper.py: 11 new tests for all 8 language extractors including edge cases (Java wildcard imports skipped) 450 tests pass, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9982e6c commit 62821c1

3 files changed

Lines changed: 396 additions & 0 deletions

File tree

chisel/test_mapper.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,20 @@ def extract_test_dependencies(self, file_path, content):
151151
return _extract_go_deps(content)
152152
if lang == "rust":
153153
return _extract_rust_deps(content)
154+
if lang == "csharp":
155+
return _extract_csharp_deps(content)
156+
if lang == "java" or lang == "kotlin":
157+
return _extract_java_deps(content)
158+
if lang in ("c", "cpp"):
159+
return _extract_cpp_deps(content)
160+
if lang == "swift":
161+
return _extract_swift_deps(content)
162+
if lang == "php":
163+
return _extract_php_deps(content)
164+
if lang == "ruby":
165+
return _extract_ruby_deps(content)
166+
if lang == "dart":
167+
return _extract_dart_deps(content)
154168
return []
155169

156170
# ------------------------------------------------------------------ #
@@ -423,6 +437,135 @@ def _extract_rust_deps(content):
423437
return _dedupe_deps(deps)
424438

425439

440+
# ------------------------------------------------------------------ #
441+
# C# dependency extraction
442+
# ------------------------------------------------------------------ #
443+
444+
_CS_USING_RE = re.compile(r"^\s*using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;", re.MULTILINE)
445+
446+
447+
def _extract_csharp_deps(content):
448+
"""Extract using statements from C# content."""
449+
deps = []
450+
for m in _CS_USING_RE.finditer(content):
451+
name = m.group(1).rsplit(".", 1)[-1]
452+
deps.append({"name": name, "dep_type": "import"})
453+
return _dedupe_deps(deps)
454+
455+
456+
# ------------------------------------------------------------------ #
457+
# Java / Kotlin dependency extraction
458+
# ------------------------------------------------------------------ #
459+
460+
_JAVA_IMPORT_RE = re.compile(r"^\s*import\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;?", re.MULTILINE)
461+
462+
463+
def _extract_java_deps(content):
464+
"""Extract import statements from Java/Kotlin content."""
465+
deps = []
466+
for m in _JAVA_IMPORT_RE.finditer(content):
467+
name = m.group(1).rsplit(".", 1)[-1]
468+
if name != "*":
469+
deps.append({"name": name, "dep_type": "import"})
470+
return _dedupe_deps(deps)
471+
472+
473+
# ------------------------------------------------------------------ #
474+
# C / C++ dependency extraction
475+
# ------------------------------------------------------------------ #
476+
477+
_CPP_INCLUDE_RE = re.compile(r'^\s*#\s*include\s*[<"]([^>"]+)[>"]', re.MULTILINE)
478+
479+
480+
def _extract_cpp_deps(content):
481+
"""Extract #include directives from C/C++ content."""
482+
deps = []
483+
for m in _CPP_INCLUDE_RE.finditer(content):
484+
header = m.group(1)
485+
# Extract base name: "mylib/utils.h" -> "utils"
486+
name = header.rsplit("/", 1)[-1].split(".")[0]
487+
deps.append({"name": name, "dep_type": "import"})
488+
return _dedupe_deps(deps)
489+
490+
491+
# ------------------------------------------------------------------ #
492+
# Swift dependency extraction
493+
# ------------------------------------------------------------------ #
494+
495+
_SWIFT_IMPORT_RE = re.compile(r"^\s*import\s+(\w+)", re.MULTILINE)
496+
497+
498+
def _extract_swift_deps(content):
499+
"""Extract import statements from Swift content."""
500+
deps = []
501+
for m in _SWIFT_IMPORT_RE.finditer(content):
502+
deps.append({"name": m.group(1), "dep_type": "import"})
503+
return _dedupe_deps(deps)
504+
505+
506+
# ------------------------------------------------------------------ #
507+
# PHP dependency extraction
508+
# ------------------------------------------------------------------ #
509+
510+
_PHP_USE_RE = re.compile(r"^\s*use\s+([A-Za-z_][\w\\]*)\s*;", re.MULTILINE)
511+
_PHP_REQUIRE_RE = re.compile(
512+
r"(?:require|require_once|include|include_once)\s*\(?\s*['\"]([^'\"]+)['\"]\s*\)?",
513+
re.MULTILINE,
514+
)
515+
516+
517+
def _extract_php_deps(content):
518+
"""Extract use statements and require/include from PHP content."""
519+
deps = []
520+
for m in _PHP_USE_RE.finditer(content):
521+
name = m.group(1).rsplit("\\", 1)[-1]
522+
deps.append({"name": name, "dep_type": "import"})
523+
for m in _PHP_REQUIRE_RE.finditer(content):
524+
path = m.group(1)
525+
name = path.rsplit("/", 1)[-1].split(".")[0]
526+
deps.append({"name": name, "dep_type": "import"})
527+
return _dedupe_deps(deps)
528+
529+
530+
# ------------------------------------------------------------------ #
531+
# Ruby dependency extraction
532+
# ------------------------------------------------------------------ #
533+
534+
_RB_REQUIRE_RE = re.compile(r"^\s*require(?:_relative)?\s+['\"]([^'\"]+)['\"]", re.MULTILINE)
535+
536+
537+
def _extract_ruby_deps(content):
538+
"""Extract require/require_relative from Ruby content."""
539+
deps = []
540+
for m in _RB_REQUIRE_RE.finditer(content):
541+
path = m.group(1)
542+
name = path.rsplit("/", 1)[-1]
543+
deps.append({"name": name, "dep_type": "import"})
544+
return _dedupe_deps(deps)
545+
546+
547+
# ------------------------------------------------------------------ #
548+
# Dart dependency extraction
549+
# ------------------------------------------------------------------ #
550+
551+
_DART_IMPORT_RE = re.compile(r"^\s*import\s+['\"]([^'\"]+)['\"]", re.MULTILINE)
552+
553+
554+
def _extract_dart_deps(content):
555+
"""Extract import statements from Dart content."""
556+
deps = []
557+
for m in _DART_IMPORT_RE.finditer(content):
558+
path = m.group(1)
559+
# "package:myapp/utils.dart" -> "utils"
560+
name = path.rsplit("/", 1)[-1].split(".")[0]
561+
deps.append({"name": name, "dep_type": "import"})
562+
return _dedupe_deps(deps)
563+
564+
565+
# ------------------------------------------------------------------ #
566+
# Shared utility
567+
# ------------------------------------------------------------------ #
568+
426569
def _dedupe_deps(deps):
427570
"""Remove duplicate dependencies, keeping first occurrence."""
428571
seen = set()

tests/test_project.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Tests for chisel.project — multi-agent safety utilities."""
2+
3+
import os
4+
import subprocess
5+
6+
import pytest
7+
8+
from chisel.project import (
9+
ProcessLock,
10+
detect_project_root,
11+
normalize_path,
12+
resolve_storage_dir,
13+
)
14+
15+
16+
# ------------------------------------------------------------------ #
17+
# detect_project_root
18+
# ------------------------------------------------------------------ #
19+
20+
class TestDetectProjectRoot:
21+
def test_finds_current_repo(self):
22+
"""Running inside the Chisel repo should find its root."""
23+
root = detect_project_root()
24+
assert os.path.isdir(root)
25+
assert os.path.isfile(os.path.join(root, "pyproject.toml"))
26+
27+
def test_finds_root_from_subdirectory(self, tmp_path):
28+
"""Should walk up from a subdir to find the git root."""
29+
repo = tmp_path / "repo"
30+
repo.mkdir()
31+
subprocess.run(["git", "init"], cwd=str(repo), capture_output=True)
32+
subdir = repo / "a" / "b" / "c"
33+
subdir.mkdir(parents=True)
34+
root = detect_project_root(str(subdir))
35+
assert os.path.samefile(root, str(repo))
36+
37+
def test_non_git_dir_returns_start(self, tmp_path):
38+
"""A dir with no .git should return itself."""
39+
plain = tmp_path / "plain"
40+
plain.mkdir()
41+
root = detect_project_root(str(plain))
42+
assert os.path.samefile(root, str(plain))
43+
44+
def test_defaults_to_cwd(self):
45+
"""With no args, should use cwd."""
46+
root = detect_project_root()
47+
assert os.path.isabs(root)
48+
49+
def test_returns_absolute_path(self, tmp_path):
50+
repo = tmp_path / "repo"
51+
repo.mkdir()
52+
subprocess.run(["git", "init"], cwd=str(repo), capture_output=True)
53+
root = detect_project_root(str(repo))
54+
assert os.path.isabs(root)
55+
56+
57+
# ------------------------------------------------------------------ #
58+
# normalize_path
59+
# ------------------------------------------------------------------ #
60+
61+
class TestNormalizePath:
62+
def test_absolute_path_becomes_relative(self):
63+
root = "/home/user/project"
64+
result = normalize_path("/home/user/project/src/main.py", root)
65+
assert result == "src/main.py"
66+
67+
def test_relative_path_unchanged(self):
68+
result = normalize_path("src/main.py", "/any/root")
69+
assert result == "src/main.py"
70+
71+
def test_dot_slash_stripped(self):
72+
result = normalize_path("./src/main.py", "/any/root")
73+
assert result == "src/main.py"
74+
75+
def test_parent_refs_normalized(self):
76+
result = normalize_path("src/../lib/utils.py", "/any/root")
77+
assert result == "lib/utils.py"
78+
79+
def test_already_clean_path_unchanged(self):
80+
result = normalize_path("chisel/engine.py", "/any/root")
81+
assert result == "chisel/engine.py"
82+
83+
def test_plain_filename(self):
84+
result = normalize_path("README.md", "/any/root")
85+
assert result == "README.md"
86+
87+
88+
# ------------------------------------------------------------------ #
89+
# resolve_storage_dir
90+
# ------------------------------------------------------------------ #
91+
92+
class TestResolveStorageDir:
93+
def test_explicit_dir_wins(self, tmp_path):
94+
explicit = str(tmp_path / "custom")
95+
result = resolve_storage_dir(project_dir="/any", explicit_dir=explicit)
96+
assert result == explicit
97+
98+
def test_env_var_second_priority(self, tmp_path, monkeypatch):
99+
env_dir = str(tmp_path / "env_storage")
100+
monkeypatch.setenv("CHISEL_STORAGE_DIR", env_dir)
101+
result = resolve_storage_dir(project_dir="/any")
102+
assert result == env_dir
103+
104+
def test_project_local_third_priority(self, tmp_path, monkeypatch):
105+
monkeypatch.delenv("CHISEL_STORAGE_DIR", raising=False)
106+
repo = tmp_path / "repo"
107+
repo.mkdir()
108+
subprocess.run(["git", "init"], cwd=str(repo), capture_output=True)
109+
result = resolve_storage_dir(project_dir=str(repo))
110+
assert result.endswith(".chisel")
111+
# Should be under the repo root
112+
assert str(repo) in result
113+
114+
def test_fallback_to_home(self, monkeypatch):
115+
monkeypatch.delenv("CHISEL_STORAGE_DIR", raising=False)
116+
result = resolve_storage_dir(project_dir=None)
117+
assert result == os.path.join(os.path.expanduser("~"), ".chisel")
118+
119+
def test_explicit_overrides_env(self, tmp_path, monkeypatch):
120+
monkeypatch.setenv("CHISEL_STORAGE_DIR", "/should/be/ignored")
121+
explicit = str(tmp_path / "winner")
122+
result = resolve_storage_dir(project_dir="/any", explicit_dir=explicit)
123+
assert result == explicit
124+
125+
126+
# ------------------------------------------------------------------ #
127+
# ProcessLock
128+
# ------------------------------------------------------------------ #
129+
130+
class TestProcessLock:
131+
def test_exclusive_lock_context(self, tmp_path):
132+
lock = ProcessLock(str(tmp_path))
133+
with lock.exclusive():
134+
# Lock file should exist during the context
135+
assert os.path.isfile(os.path.join(str(tmp_path), "chisel.lock"))
136+
137+
def test_shared_lock_context(self, tmp_path):
138+
lock = ProcessLock(str(tmp_path))
139+
with lock.shared():
140+
assert os.path.isfile(os.path.join(str(tmp_path), "chisel.lock"))
141+
142+
def test_multiple_shared_locks(self, tmp_path):
143+
"""Multiple shared locks should not block each other."""
144+
lock = ProcessLock(str(tmp_path))
145+
with lock.shared():
146+
with lock.shared():
147+
pass # Should not deadlock
148+
149+
def test_lock_creates_directory(self, tmp_path):
150+
lock_dir = str(tmp_path / "nested" / "lock" / "dir")
151+
lock = ProcessLock(lock_dir)
152+
assert os.path.isdir(lock_dir)
153+
with lock.exclusive():
154+
pass
155+
156+
def test_lock_releases_on_exception(self, tmp_path):
157+
lock = ProcessLock(str(tmp_path))
158+
with pytest.raises(ValueError):
159+
with lock.exclusive():
160+
raise ValueError("test")
161+
# Should be able to acquire again
162+
with lock.exclusive():
163+
pass

0 commit comments

Comments
 (0)