Coverage for tests/test_circular_deps/test_typescript.py: 100%

1 from __future__ import annotations

2

3 import os

4 import shutil

5 import sys

6 import tempfile

7 import json

8 from pathlib import Path

9

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

11

12 from circular_deps import find_cycles

13 from circular_deps.parsers.typescript import TypeScriptImportParser

14

15

16 def test_typescript_parser_extract_import():

17 parser = TypeScriptImportParser()

18 source = """

19 import { foo } from './b';

20 import bar from './c';

21 export * 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

31 def 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

54 def 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

84 def 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

109 def 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

119 def 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

150 def 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

171 def 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

187 def 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

210 def 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

229 def 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

254 def 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

281 def 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

306 def 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

326 def 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

358 def 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

391 def 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)