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

1from __future__ import annotations 

2 

3import os 

4import shutil 

5import sys 

6import tempfile 

7import json 

8from pathlib import Path 

9 

10sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 

11 

12from circular_deps import find_cycles 

13from circular_deps.parsers.typescript import TypeScriptImportParser 

14 

15 

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) 

29 

30 

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" 

36 

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 ) 

43 

44 files = [a_ts, b_ts] 

45 cycles = find_cycles(files) 

46 

47 assert len(cycles) == 1 

48 assert cycles[0].depth == 2 

49 assert cycles[0].severity == "warning" 

50 finally: 

51 shutil.rmtree(tmpdir) 

52 

53 

54def test_typescript_resolver_relative_imports(): 

55 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

56 

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" 

64 

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("") 

70 

71 resolver = TypeScriptPathResolver(module_resolution="node") 

72 

73 path = resolver.resolve("./b", str(a_ts), str(tmpdir)) 

74 assert path is not None 

75 assert path == str(b_ts) 

76 

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) 

82 

83 

84def test_typescript_resolver_index_precedence(): 

85 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

86 

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" 

93 

94 dir_path.mkdir(parents=True, exist_ok=True) 

95 a_ts.write_text("") 

96 

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) 

101 

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) 

107 

108 

109def test_typescript_no_external_imports(): 

110 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

111 

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 

117 

118 

119def test_typescript_cycle_detection_in_src_subdirectory(): 

120 """Test circular dependency detection with files in ./src subdirectory.""" 

121 import subprocess 

122 

123 tmpdir = Path(tempfile.mkdtemp()) 

124 try: 

125 src_dir = tmpdir / "src" 

126 src_dir.mkdir(parents=True, exist_ok=True) 

127 

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) 

132 

133 a_ts = afet_dir / "a.ts" 

134 b_ts = bfet_dir / "b.ts" 

135 

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) 

144 

145 assert len(cycles) == 1 

146 finally: 

147 shutil.rmtree(tmpdir) 

148 

149 

150def test_typescript_resolver_auto_discovery_from_tsconfig(): 

151 """Auto-discover module resolution from tsconfig.json.""" 

152 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

153 

154 tmpdir = Path(tempfile.mkdtemp()) 

155 try: 

156 tsconfig = tmpdir / "tsconfig.json" 

157 a_ts = tmpdir / "a.ts" 

158 

159 tsconfig.write_text( 

160 json.dumps({"compilerOptions": {"moduleResolution": "bundler"}}) 

161 ) 

162 

163 a_ts.write_text("") 

164 resolver = TypeScriptPathResolver(filepath=str(a_ts)) 

165 

166 assert resolver.module_resolution == "bundler" 

167 finally: 

168 shutil.rmtree(tmpdir) 

169 

170 

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 

174 

175 tmpdir = Path(tempfile.mkdtemp()) 

176 try: 

177 a_ts = tmpdir / "a.ts" 

178 

179 a_ts.write_text("") 

180 resolver = TypeScriptPathResolver(filepath=str(a_ts)) 

181 

182 assert resolver.module_resolution == "node16" 

183 finally: 

184 shutil.rmtree(tmpdir) 

185 

186 

187def test_typescript_resolver_cli_overrides_tsconfig(): 

188 """CLI argument overrides tsconfig.json.""" 

189 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

190 

191 tmpdir = Path(tempfile.mkdtemp()) 

192 try: 

193 tsconfig = tmpdir / "tsconfig.json" 

194 a_ts = tmpdir / "a.ts" 

195 

196 tsconfig.write_text( 

197 json.dumps({"compilerOptions": {"moduleResolution": "node"}}) 

198 ) 

199 

200 a_ts.write_text("") 

201 resolver = TypeScriptPathResolver( 

202 module_resolution="bundler", filepath=str(a_ts) 

203 ) 

204 

205 assert resolver.module_resolution == "bundler" 

206 finally: 

207 shutil.rmtree(tmpdir) 

208 

209 

210def test_typescript_resolver_invalid_tsconfig(): 

211 """Silent fallback for invalid tsconfig.json.""" 

212 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

213 

214 tmpdir = Path(tempfile.mkdtemp()) 

215 try: 

216 tsconfig = tmpdir / "tsconfig.json" 

217 a_ts = tmpdir / "a.ts" 

218 

219 tsconfig.write_text("{ invalid json") 

220 

221 a_ts.write_text("") 

222 resolver = TypeScriptPathResolver(filepath=str(a_ts)) 

223 

224 assert resolver.module_resolution == "node16" 

225 finally: 

226 shutil.rmtree(tmpdir) 

227 

228 

229def test_typescript_resolver_node_mode_with_extension(): 

230 """node mode: literal match only for extensions.""" 

231 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

232 

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" 

238 

239 moa_ts.write_text("") 

240 moa_js.write_text("") 

241 a_ts.write_text("") 

242 

243 resolver = TypeScriptPathResolver(module_resolution="node") 

244 

245 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir)) 

246 assert path == str(moa_js) 

247 

248 path = resolver.resolve("./moa.ts", str(a_ts), str(tmpdir)) 

249 assert path == str(moa_ts) 

250 finally: 

251 shutil.rmtree(tmpdir) 

252 

253 

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 

257 

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" 

264 

265 moa_ts.write_text("") 

266 a_ts.write_text("") 

267 dir_path.mkdir(parents=True, exist_ok=True) 

268 

269 resolver = TypeScriptPathResolver(module_resolution="node") 

270 

271 path = resolver.resolve("./moa", str(a_ts), str(tmpdir)) 

272 assert path == str(moa_ts) 

273 

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) 

279 

280 

281def test_typescript_resolver_node16_mode_with_extension(): 

282 """node16 mode: .ext -> .ts -> .tsx""" 

283 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

284 

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" 

290 

291 moa_jss.write_text("") 

292 a_ts.write_text("") 

293 

294 resolver = TypeScriptPathResolver(module_resolution="node16") 

295 

296 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir)) 

297 assert path == str(moa_jss) 

298 

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) 

304 

305 

306def test_typescript_resolver_node16_mode_without_extension(): 

307 """node16 mode: return None (ESM requires extension)""" 

308 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

309 

310 tmpdir = Path(tempfile.mkdtemp()) 

311 try: 

312 moa_ts = tmpdir / "moa.ts" 

313 a_ts = tmpdir / "a.ts" 

314 

315 moa_ts.write_text("") 

316 a_ts.write_text("") 

317 

318 resolver = TypeScriptPathResolver(module_resolution="node16") 

319 

320 path = resolver.resolve("./moa", str(a_ts), str(tmpdir)) 

321 assert path is None 

322 finally: 

323 shutil.rmtree(tmpdir) 

324 

325 

326def test_typescript_resolver_bundler_mode(): 

327 """bundler mode: lenient extension resolution""" 

328 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

329 

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" 

337 

338 moa_ts.write_text("") 

339 moa_js.write_text("") 

340 a_ts.write_text("") 

341 dir_path.mkdir(parents=True, exist_ok=True) 

342 

343 resolver = TypeScriptPathResolver(module_resolution="bundler") 

344 

345 path = resolver.resolve("./moa.js", str(a_ts), str(tmpdir)) 

346 assert path == str(moa_js), f"Expected {moa_js}, got {path}" 

347 

348 path = resolver.resolve("./moa.ts", str(a_ts), str(tmpdir)) 

349 assert path == str(moa_ts), f"Expected {moa_ts}, got {path}" 

350 

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) 

356 

357 

358def test_typescript_resolver_closest_tsconfig(): 

359 """Use tsconfig closest to file being analyzed.""" 

360 from circular_deps.resolvers.typescript import TypeScriptPathResolver 

361 

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" 

368 

369 tsconfig_root.write_text( 

370 json.dumps({"compilerOptions": {"moduleResolution": "node16"}}) 

371 ) 

372 

373 (tmpdir / "inner").mkdir(parents=True, exist_ok=True) 

374 

375 tsconfig_inner.write_text( 

376 json.dumps({"compilerOptions": {"moduleResolution": "bundler"}}) 

377 ) 

378 

379 outer_ts.write_text("") 

380 inner_ts.write_text("") 

381 

382 resolver = TypeScriptPathResolver(filepath=str(outer_ts)) 

383 assert resolver.module_resolution == "node16" 

384 

385 resolver = TypeScriptPathResolver(filepath=str(inner_ts)) 

386 assert resolver.module_resolution == "bundler" 

387 finally: 

388 shutil.rmtree(tmpdir) 

389 

390 

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) 

397 

398 analysis_dir = src_dir / "analysis" 

399 analysis_dir.mkdir(parents=True, exist_ok=True) 

400 

401 verticality_ts = analysis_dir / "verticality.js" 

402 index_ts = src_dir / "index.js" 

403 

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 ) 

410 

411 import subprocess 

412 

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) 

418 

419 # assert len(cycles) == 1 

420 # assert cycles[0].depth == 2 

421 finally: 

422 shutil.rmtree(tmpdir)