Coverage for tests / test_circular_deps / test_typescript.py: 100%
257 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
1from __future__ import annotations
3import os
4import shutil
5import sys
6import tempfile
7import json
8from pathlib import Path
10sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12from circular_deps import find_cycles
13from circular_deps.parsers.typescript import TypeScriptImportParser
16def test_typescript_parser_extract_import():
17 parser = TypeScriptImportParser()
18 source = """
19import { foo } from './b';
20import bar from './c';
21export * from './d';
22"""
23 imports = parser.extract_imports(source, "test.ts")
24 assert len(imports) == 3
25 assert imports[0].raw_module == "./b"
26 assert imports[1].raw_module == "./c"
27 assert imports[2].raw_module == "./d"
28 assert all(imp.line > 0 for imp in imports)
31def test_typescript_cycle_detection_simple():
32 tmpdir = Path(tempfile.mkdtemp())
33 try:
34 a_ts = tmpdir / "a.ts"
35 b_ts = tmpdir / "b.ts"
37 a_ts.write_text(
38 "import { foo } from './b.ts';\nexport const bar = () => 'bar';\n"
39 )
40 b_ts.write_text(
41 "import { bar } from './a.ts';\nexport const foo = () => 'foo';\n"
42 )
44 files = [a_ts, b_ts]
45 cycles = find_cycles(files)
47 assert len(cycles) == 1
48 assert cycles[0].depth == 2
49 assert cycles[0].severity == "warning"
50 finally:
51 shutil.rmtree(tmpdir)
54def test_typescript_resolver_relative_imports():
55 from circular_deps.resolvers.typescript import TypeScriptPathResolver
57 tmpdir = Path(tempfile.mkdtemp())
58 try:
59 a_ts = tmpdir / "a.ts"
60 b_dir = tmpdir / "b"
61 b_sub_dir = b_dir / "c"
62 b_ts = b_dir / "b.ts"
63 sub_ts = b_sub_dir / "sub.ts"
65 a_ts.write_text("")
66 b_dir.mkdir(parents=True, exist_ok=True)
67 b_sub_dir.mkdir(parents=True, exist_ok=True)
68 b_ts.write_text("")
69 sub_ts.write_text("")
71 resolver = TypeScriptPathResolver(module_resolution="node")
73 path = resolver.resolve("./b", str(a_ts), str(tmpdir))
74 assert path is not None
75 assert path == str(b_ts)
77 path = resolver.resolve("./c/sub.ts", str(b_ts), str(tmpdir))
78 assert path is not None
79 assert path == str(sub_ts)
80 finally:
81 shutil.rmtree(tmpdir)
84def test_typescript_resolver_index_precedence():
85 from circular_deps.resolvers.typescript import TypeScriptPathResolver
87 tmpdir = Path(tempfile.mkdtemp())
88 try:
89 dir_path = tmpdir / "pkg"
90 index_ts = dir_path / "index.ts"
91 index_tsx = dir_path / "index.tsx"
92 a_ts = tmpdir / "a.ts"
94 dir_path.mkdir(parents=True, exist_ok=True)
95 a_ts.write_text("")
97 index_ts.write_text("")
98 resolver = TypeScriptPathResolver()
99 path = resolver.resolve("./pkg/index.ts", str(a_ts), str(tmpdir))
100 assert path == str(index_ts)
102 index_tsx.write_text("")
103 path = resolver.resolve("./pkg/index.ts", str(a_ts), str(tmpdir))
104 assert path == str(index_ts)
105 finally:
106 shutil.rmtree(tmpdir)
109def test_typescript_no_external_imports():
110 from circular_deps.resolvers.typescript import TypeScriptPathResolver
112 resolver = TypeScriptPathResolver()
113 path = resolver.resolve("lodash", "test.ts", "/project")
114 path2 = resolver.resolve("react", "test.ts", "/project")
115 assert path is None
116 assert path2 is None
119def test_typescript_cycle_detection_in_src_subdirectory():
120 """Test circular dependency detection with files in ./src subdirectory."""
121 import subprocess
123 tmpdir = Path(tempfile.mkdtemp())
124 try:
125 src_dir = tmpdir / "src"
126 src_dir.mkdir(parents=True, exist_ok=True)
128 afet_dir = src_dir / "afet"
129 bfet_dir = src_dir / "bfet"
130 afet_dir.mkdir(parents=True, exist_ok=True)
131 bfet_dir.mkdir(parents=True, exist_ok=True)
133 a_ts = afet_dir / "a.ts"
134 b_ts = bfet_dir / "b.ts"
136 a_ts.write_text(
137 "import { foo } from '../bfet/b.js';\nexport const bar = () => 'bar';\n"
138 )
139 b_ts.write_text(
140 "import { bar } from '../afet/a.js';\nexport const foo = () => 'foo';\n"
141 )
142 files = [a_ts, b_ts]
143 cycles = find_cycles(files)
145 assert len(cycles) == 1
146 finally:
147 shutil.rmtree(tmpdir)
150def test_typescript_resolver_auto_discovery_from_tsconfig():
151 """Auto-discover module resolution from tsconfig.json."""
152 from circular_deps.resolvers.typescript import TypeScriptPathResolver
154 tmpdir = Path(tempfile.mkdtemp())
155 try:
156 tsconfig = tmpdir / "tsconfig.json"
157 a_ts = tmpdir / "a.ts"
159 tsconfig.write_text(
160 json.dumps({"compilerOptions": {"moduleResolution": "bundler"}})
161 )
163 a_ts.write_text("")
164 resolver = TypeScriptPathResolver(filepath=str(a_ts))
166 assert resolver.module_resolution == "bundler"
167 finally:
168 shutil.rmtree(tmpdir)
171def test_typescript_resolver_auto_discovery_no_tsconfig():
172 """Falls back to node16 when no tsconfig.json found."""
173 from circular_deps.resolvers.typescript import TypeScriptPathResolver
175 tmpdir = Path(tempfile.mkdtemp())
176 try:
177 a_ts = tmpdir / "a.ts"
179 a_ts.write_text("")
180 resolver = TypeScriptPathResolver(filepath=str(a_ts))
182 assert resolver.module_resolution == "node16"
183 finally:
184 shutil.rmtree(tmpdir)
187def test_typescript_resolver_cli_overrides_tsconfig():
188 """CLI argument overrides tsconfig.json."""
189 from circular_deps.resolvers.typescript import TypeScriptPathResolver
191 tmpdir = Path(tempfile.mkdtemp())
192 try:
193 tsconfig = tmpdir / "tsconfig.json"
194 a_ts = tmpdir / "a.ts"
196 tsconfig.write_text(
197 json.dumps({"compilerOptions": {"moduleResolution": "node"}})
198 )
200 a_ts.write_text("")
201 resolver = TypeScriptPathResolver(
202 module_resolution="bundler", filepath=str(a_ts)
203 )
205 assert resolver.module_resolution == "bundler"
206 finally:
207 shutil.rmtree(tmpdir)
210def test_typescript_resolver_invalid_tsconfig():
211 """Silent fallback for invalid tsconfig.json."""
212 from circular_deps.resolvers.typescript import TypeScriptPathResolver
214 tmpdir = Path(tempfile.mkdtemp())
215 try:
216 tsconfig = tmpdir / "tsconfig.json"
217 a_ts = tmpdir / "a.ts"
219 tsconfig.write_text("{ invalid json")
221 a_ts.write_text("")
222 resolver = TypeScriptPathResolver(filepath=str(a_ts))
224 assert resolver.module_resolution == "node16"
225 finally:
226 shutil.rmtree(tmpdir)
229def test_typescript_resolver_node_mode_with_extension():
230 """node mode: literal match only for extensions."""
231 from circular_deps.resolvers.typescript import TypeScriptPathResolver
233 tmpdir = Path(tempfile.mkdtemp())
234 try:
235 moa_ts = tmpdir / "moa.ts"
236 moa_js = tmpdir / "moa.js"
237 a_ts = tmpdir / "a.ts"
239 moa_ts.write_text("")
240 moa_js.write_text("")
241 a_ts.write_text("")
243 resolver = TypeScriptPathResolver(module_resolution="node")
245 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir))
246 assert path == str(moa_js)
248 path = resolver.resolve("./moa.ts", str(a_ts), str(tmpdir))
249 assert path == str(moa_ts)
250 finally:
251 shutil.rmtree(tmpdir)
254def test_typescript_resolver_node_mode_without_extension():
255 """node mode: .ts -> .tsx -> .d.ts -> .js -> index.*"""
256 from circular_deps.resolvers.typescript import TypeScriptPathResolver
258 tmpdir = Path(tempfile.mkdtemp())
259 try:
260 moa_ts = tmpdir / "moa.ts"
261 dir_path = tmpdir / "pkg"
262 index_ts = dir_path / "index.ts"
263 a_ts = tmpdir / "a.ts"
265 moa_ts.write_text("")
266 a_ts.write_text("")
267 dir_path.mkdir(parents=True, exist_ok=True)
269 resolver = TypeScriptPathResolver(module_resolution="node")
271 path = resolver.resolve("./moa", str(a_ts), str(tmpdir))
272 assert path == str(moa_ts)
274 index_ts.write_text("")
275 path = resolver.resolve("./pkg", str(a_ts), str(tmpdir))
276 assert path == str(index_ts)
277 finally:
278 shutil.rmtree(tmpdir)
281def test_typescript_resolver_node16_mode_with_extension():
282 """node16 mode: .ext -> .ts -> .tsx"""
283 from circular_deps.resolvers.typescript import TypeScriptPathResolver
285 tmpdir = Path(tempfile.mkdtemp())
286 try:
287 moa_ts = tmpdir / "moa.ts"
288 moa_jss = tmpdir / "moa.js"
289 a_ts = tmpdir / "a.ts"
291 moa_jss.write_text("")
292 a_ts.write_text("")
294 resolver = TypeScriptPathResolver(module_resolution="node16")
296 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir))
297 assert path == str(moa_jss)
299 moa_ts.write_text("")
300 path = resolver.resolve("./moa.ts", str(a_ts), str(tmpdir))
301 assert path == str(moa_ts)
302 finally:
303 shutil.rmtree(tmpdir)
306def test_typescript_resolver_node16_mode_without_extension():
307 """node16 mode: return None (ESM requires extension)"""
308 from circular_deps.resolvers.typescript import TypeScriptPathResolver
310 tmpdir = Path(tempfile.mkdtemp())
311 try:
312 moa_ts = tmpdir / "moa.ts"
313 a_ts = tmpdir / "a.ts"
315 moa_ts.write_text("")
316 a_ts.write_text("")
318 resolver = TypeScriptPathResolver(module_resolution="node16")
320 path = resolver.resolve("./moa", str(a_ts), str(tmpdir))
321 assert path is None
322 finally:
323 shutil.rmtree(tmpdir)
326def test_typescript_resolver_bundler_mode():
327 """bundler mode: lenient extension resolution"""
328 from circular_deps.resolvers.typescript import TypeScriptPathResolver
330 tmpdir = Path(tempfile.mkdtemp())
331 try:
332 moa_ts = tmpdir / "moa.ts"
333 moa_js = tmpdir / "moa.js"
334 dir_path = tmpdir / "pkg"
335 index_ts = dir_path / "index.ts"
336 a_ts = tmpdir / "a.ts"
338 moa_ts.write_text("")
339 moa_js.write_text("")
340 a_ts.write_text("")
341 dir_path.mkdir(parents=True, exist_ok=True)
343 resolver = TypeScriptPathResolver(module_resolution="bundler")
345 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir))
346 assert path == str(moa_js), f"Expected {moa_js}, got {path}"
348 path = resolver.resolve("./moa.ts", str(a_ts), str(tmpdir))
349 assert path == str(moa_ts), f"Expected {moa_ts}, got {path}"
351 index_ts.write_text("")
352 path = resolver.resolve("./pkg", str(a_ts), str(tmpdir))
353 assert path is not None
354 finally:
355 shutil.rmtree(tmpdir)
358def test_typescript_resolver_closest_tsconfig():
359 """Use tsconfig closest to file being analyzed."""
360 from circular_deps.resolvers.typescript import TypeScriptPathResolver
362 tmpdir = Path(tempfile.mkdtemp())
363 try:
364 tsconfig_root = tmpdir / "tsconfig.json"
365 tsconfig_inner = tmpdir / "inner" / "tsconfig.json"
366 outer_ts = tmpdir / "outer.ts"
367 inner_ts = tmpdir / "inner" / "inner.ts"
369 tsconfig_root.write_text(
370 json.dumps({"compilerOptions": {"moduleResolution": "node16"}})
371 )
373 (tmpdir / "inner").mkdir(parents=True, exist_ok=True)
375 tsconfig_inner.write_text(
376 json.dumps({"compilerOptions": {"moduleResolution": "bundler"}})
377 )
379 outer_ts.write_text("")
380 inner_ts.write_text("")
382 resolver = TypeScriptPathResolver(filepath=str(outer_ts))
383 assert resolver.module_resolution == "node16"
385 resolver = TypeScriptPathResolver(filepath=str(inner_ts))
386 assert resolver.module_resolution == "bundler"
387 finally:
388 shutil.rmtree(tmpdir)
391def test_typescript_cycle_nested_to_root():
392 """Test circular dependency with src/analysis/verticality.ts → src/index.ts → src/analysis/verticality.ts"""
393 tmpdir = Path(tempfile.mkdtemp())
394 try:
395 src_dir = tmpdir / "src"
396 src_dir.mkdir(parents=True, exist_ok=True)
398 analysis_dir = src_dir / "analysis"
399 analysis_dir.mkdir(parents=True, exist_ok=True)
401 verticality_ts = analysis_dir / "verticality.js"
402 index_ts = src_dir / "index.js"
404 verticality_ts.write_text(
405 "import { foo } from '../index.js';\nexport const bar = () => 'bar';\n"
406 )
407 index_ts.write_text(
408 "import { bar } from './analysis/verticality.js';\nexport const foo = () => 'foo';\n"
409 )
411 import subprocess
413 cmd = ["uv", "run", "vibelexity", "check-cycles", str(tmpdir)]
414 output = subprocess.run(cmd, text=True, capture_output=True)
415 print(output)
416 # files = [verticality_ts, index_ts]
417 # cycles = find_cycles(files)
419 # assert len(cycles) == 1
420 # assert cycles[0].depth == 2
421 finally:
422 shutil.rmtree(tmpdir)