graphitect 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,3584 @@
1
+ """resolution — moved verbatim from graphify/extract.py."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable
5
+ from pathlib import Path
6
+ from graphify.extractors.models import LanguageConfig, _JS_CACHE_BYPASS_SUFFIXES, _NamespaceExportFact, _StarExportFact, _SymbolAliasFact, _SymbolDeclarationFact, _SymbolExportFact, _SymbolImportFact, _SymbolResolutionFacts, _SymbolUseFact, _WORKSPACE_PACKAGE_CACHE # noqa: E402,F401
7
+ from graphify.extractors.base import ( # noqa: F401
8
+ _LANGUAGE_BUILTIN_GLOBALS,
9
+ _file_stem,
10
+ _make_id,
11
+ _read_text,
12
+ )
13
+ import functools
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+
21
+ _TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
22
+
23
+ # compilerOptions.baseUrl per config path, as an absolute dir (#2153).
24
+ _TSCONFIG_BASEURL_CACHE: "dict[str, Path | None]" = {}
25
+
26
+ _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json")
27
+
28
+ # Nearest package.json `imports` map (Node subpath imports), keyed by the
29
+ # resolved start directory. Same lifetime rule as _TSCONFIG_ALIAS_CACHE: no
30
+ # mtime component, so extract() clears it per run.
31
+ _PACKAGE_IMPORTS_CACHE: "dict[str, tuple[Path, dict] | None]" = {}
32
+
33
+ _JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs")
34
+
35
+ _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs")
36
+
37
+ def _resolve_js_import_path(candidate: Path) -> Path:
38
+ """Resolve a JS/TS/Svelte import target to a local file when it exists."""
39
+ candidate = Path(os.path.normpath(candidate))
40
+ if candidate.is_file():
41
+ return candidate
42
+
43
+ # TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx.
44
+ if candidate.suffix == ".js":
45
+ ts_candidate = candidate.with_suffix(".ts")
46
+ if ts_candidate.is_file():
47
+ return ts_candidate
48
+ elif candidate.suffix == ".jsx":
49
+ tsx_candidate = candidate.with_suffix(".tsx")
50
+ if tsx_candidate.is_file():
51
+ return tsx_candidate
52
+
53
+ # Append extensions to the full filename, which covers extensionless imports,
54
+ # multi-dot helpers, and Svelte 5 rune files like Foo.svelte.ts.
55
+ for ext in _JS_RESOLVE_EXTS:
56
+ with_ext = candidate.parent / f"{candidate.name}{ext}"
57
+ if with_ext.is_file():
58
+ return with_ext
59
+
60
+ # Only fall back to directory indexes after file candidates lose.
61
+ if candidate.is_dir():
62
+ for index_name in _JS_INDEX_FILES:
63
+ index_candidate = candidate / index_name
64
+ if index_candidate.is_file():
65
+ return index_candidate
66
+
67
+ return candidate
68
+
69
+ def _strip_jsonc(text: str) -> str:
70
+ """Strip // line comments, /* */ block comments, and trailing commas from JSONC.
71
+
72
+ Preserves string contents (including // and /* inside strings) by skipping over
73
+ quoted spans first. Required for tsconfig.json files generated by SvelteKit,
74
+ NestJS, Vite, T3, Astro, etc., which use JSONC by default (#700).
75
+ """
76
+ # Remove block and line comments while leaving string literals untouched.
77
+ pattern = re.compile(
78
+ r'"(?:\\.|[^"\\])*"' # double-quoted string (with escapes)
79
+ r"|/\*.*?\*/" # /* block comment */
80
+ r"|//[^\n]*", # // line comment
81
+ re.DOTALL,
82
+ )
83
+
84
+ def _replace(match: re.Match) -> str:
85
+ token = match.group(0)
86
+ if token.startswith('"'):
87
+ return token
88
+ return ""
89
+
90
+ stripped = pattern.sub(_replace, text)
91
+ # Remove trailing commas before } or ] (allowing whitespace between).
92
+ stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
93
+ return stripped
94
+
95
+ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, list[str]]:
96
+ """Recursively read path aliases from a tsconfig, following extends chains.
97
+
98
+ Child config paths override parent. Circular extends are detected via seen set.
99
+ npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk.
100
+ Handles JSONC (comments + trailing commas) which is the default tsconfig format
101
+ for SvelteKit, NestJS, Vite, T3, Astro, etc. (#700).
102
+ """
103
+ if str(tsconfig) in seen:
104
+ return {}
105
+ seen.add(str(tsconfig))
106
+ try:
107
+ raw = tsconfig.read_text(encoding="utf-8")
108
+ except Exception as e:
109
+ print(f" warning: could not read {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
110
+ return {}
111
+ try:
112
+ data = json.loads(raw)
113
+ except json.JSONDecodeError:
114
+ try:
115
+ data = json.loads(_strip_jsonc(raw))
116
+ except json.JSONDecodeError as e:
117
+ print(f" warning: failed to parse {tsconfig} as JSON/JSONC ({e.msg} at line {e.lineno} col {e.colno})", file=sys.stderr, flush=True)
118
+ return {}
119
+ except Exception as e:
120
+ print(f" warning: failed to parse {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
121
+ return {}
122
+
123
+ aliases: dict[str, list[str]] = {}
124
+ # `extends` may be a string or, since TypeScript 5.0, an array of paths.
125
+ # For an array, parents are processed in order with later entries
126
+ # overriding earlier ones; the extending config (paths below) overrides
127
+ # all parents. Without the list branch, an array `extends` raised
128
+ # `AttributeError: 'list' object has no attribute 'startswith'`, which
129
+ # _safe_extract turned into a skip of the whole file.
130
+ extends = data.get("extends")
131
+ if isinstance(extends, str):
132
+ extends_list = [extends]
133
+ elif isinstance(extends, list):
134
+ extends_list = [e for e in extends if isinstance(e, str)]
135
+ else:
136
+ extends_list = []
137
+ for ext in extends_list:
138
+ # Skip scoped npm package configs (e.g. @tsconfig/svelte) — not on disk.
139
+ if not ext or ext.startswith("@"):
140
+ continue
141
+ extended_path = _resolve_cached((base_dir / ext))
142
+ if not extended_path.suffix:
143
+ extended_path = extended_path.with_suffix(".json")
144
+ if extended_path.exists():
145
+ aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen))
146
+
147
+ # tsconfig `paths` are resolved relative to `baseUrl` (itself relative to
148
+ # the tsconfig's directory), not the tsconfig directory directly. Honoring
149
+ # baseUrl is required for the common monorepo / NestJS layout where
150
+ # baseUrl points at a subdirectory, e.g. baseUrl "./src" with
151
+ # "@services/*": ["services/*"] must resolve to <dir>/src/services rather
152
+ # than <dir>/services. Defaults to "." so configs without baseUrl (paths
153
+ # relative to the tsconfig dir, the TS 4.1+ behavior) keep working.
154
+ compiler_options = data.get("compilerOptions", {})
155
+ base_url = compiler_options.get("baseUrl") or "."
156
+ paths_base = base_dir / base_url
157
+ paths = compiler_options.get("paths", {})
158
+ for alias, targets in paths.items():
159
+ if not targets:
160
+ continue
161
+ # Keep ALL targets in declared order — tsc tries each until one resolves
162
+ # on disk. Discarding the fallbacks (#1531) misresolved/dropped imports
163
+ # whose file lived at a non-first target. Preserve wildcard tokens in
164
+ # both sides until the resolver substitutes the captured segment, then
165
+ # normalizes the concrete path (#927). Empty/non-string entries are skipped.
166
+ target_patterns = [
167
+ str(paths_base / t)
168
+ for t in targets
169
+ if isinstance(t, str) and t
170
+ ]
171
+ if target_patterns:
172
+ aliases[alias] = target_patterns
173
+
174
+ return aliases
175
+
176
+ def _read_json_config(path: Path) -> "dict | None":
177
+ """Parse a tsconfig/jsconfig as JSON, falling back to JSONC (#2153).
178
+
179
+ Mirrors the read/parse handling in `_read_tsconfig_aliases`; returns None on
180
+ any unreadable or unparseable file so a malformed config degrades to "no
181
+ baseUrl" instead of raising.
182
+ """
183
+ try:
184
+ raw = path.read_text(encoding="utf-8", errors="replace")
185
+ except OSError:
186
+ return None
187
+ for candidate in (raw, _strip_jsonc(raw)):
188
+ try:
189
+ data = json.loads(candidate)
190
+ except Exception:
191
+ continue
192
+ return data if isinstance(data, dict) else None
193
+ return None
194
+
195
+ def _find_js_config(start_dir: Path) -> "tuple[Path, Path] | None":
196
+ """Nearest tsconfig.json/jsconfig.json walking up from start_dir.
197
+
198
+ `jsconfig.json` is the plain-JS spelling of the same file (already indexed by
199
+ json_config.py) and was never probed here, so a Rails/webpacker project that
200
+ configures resolution in jsconfig.json got no aliases at all (#2153).
201
+ tsconfig.json wins when both sit in one directory, matching tsc and editors,
202
+ which consult jsconfig.json only when there is no tsconfig.json.
203
+ """
204
+ current = _resolve_cached(start_dir)
205
+ for candidate in [current, *current.parents]:
206
+ for name in ("tsconfig.json", "jsconfig.json"):
207
+ config = candidate / name
208
+ if config.exists():
209
+ return config, candidate
210
+ return None
211
+
212
+ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]:
213
+ """Walk up from start_dir to find tsconfig/jsconfig.json and return compilerOptions.paths aliases.
214
+
215
+ Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included.
216
+ Returns a dict mapping alias patterns to ordered resolved target patterns;
217
+ wildcard tokens remain intact for substitution during resolution (#927).
218
+ Result is cached by config path string. The cache has no mtime/content
219
+ component, so extract() clears it per run (#2917); do not assume the entry
220
+ survives a config edit within a long-lived process.
221
+ """
222
+ found = _find_js_config(start_dir)
223
+ if found is None:
224
+ return {}
225
+ config, candidate = found
226
+ key = str(config)
227
+ if key not in _TSCONFIG_ALIAS_CACHE:
228
+ _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(config, candidate, seen=set())
229
+ return _TSCONFIG_ALIAS_CACHE[key]
230
+
231
+ def _load_tsconfig_base_url(start_dir: Path) -> "Path | None":
232
+ """`compilerOptions.baseUrl` of the nearest config, as an absolute directory.
233
+
234
+ baseUrl was only ever used as the base that `paths` targets resolve against,
235
+ so a config declaring baseUrl and NO paths yielded an empty alias map and
236
+ every non-relative import went unresolved (#2153). Exposed separately so it
237
+ can act as a resolution root of last resort, after all declared aliases miss.
238
+ Returns None when no config declares baseUrl. Cached by config path with no
239
+ mtime component, so extract() clears it per run (#2917).
240
+ """
241
+ found = _find_js_config(start_dir)
242
+ if found is None:
243
+ return None
244
+ config, candidate = found
245
+ key = str(config)
246
+ if key not in _TSCONFIG_BASEURL_CACHE:
247
+ base_url = None
248
+ data = _read_json_config(config)
249
+ if data is not None:
250
+ raw_base = data.get("compilerOptions", {}).get("baseUrl")
251
+ if isinstance(raw_base, str) and raw_base:
252
+ base_url = Path(os.path.normpath(candidate / raw_base))
253
+ _TSCONFIG_BASEURL_CACHE[key] = base_url
254
+ return _TSCONFIG_BASEURL_CACHE[key]
255
+
256
+ def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None":
257
+ """Return (specificity, captured text, is_wildcard) when pattern matches raw.
258
+
259
+ Exact aliases win first. Wildcard aliases follow TypeScript's longest-prefix
260
+ rule. The final branch preserves Graphify's existing support for treating a
261
+ non-wildcard alias as a directory prefix, but only after real wildcard matches.
262
+ """
263
+ if "*" in pattern:
264
+ if pattern.count("*") != 1:
265
+ return None
266
+ prefix, suffix = pattern.split("*", 1)
267
+ if not raw.startswith(prefix) or not raw.endswith(suffix):
268
+ return None
269
+ end = len(raw) - len(suffix) if suffix else len(raw)
270
+ if end < len(prefix):
271
+ return None
272
+ return (1, -len(prefix)), raw[len(prefix):end], True
273
+
274
+ if raw == pattern:
275
+ return (0, -len(pattern)), "", False
276
+
277
+ prefix = pattern.rstrip("/")
278
+ if prefix and raw.startswith(prefix + "/"):
279
+ return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False
280
+ return None
281
+
282
+ def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]],
283
+ base_url: "Path | None" = None) -> "Path | None":
284
+ """Resolve `raw` against the most specific matching tsconfig alias pattern.
285
+
286
+ Within that pattern, try targets in declared order and return the first whose
287
+ candidate resolves to a real file. If none exist, return the first candidate
288
+ so existing phantom/external-edge behavior stays unchanged.
289
+
290
+ `base_url` is a resolution root of last resort, tried only when NO declared
291
+ alias matches (#2153). It must not participate in the specificity contest:
292
+ a bare `*` alias would score (1, 0) and so beat a declared non-wildcard
293
+ directory-prefix alias at (2, -len), silently shadowing it and regressing
294
+ #1269. Unlike the alias path this returns a candidate only when it is a real
295
+ file on disk, so a genuine external package (`import React from 'react'`)
296
+ still resolves to nothing instead of a fabricated <baseUrl>/react edge.
297
+ """
298
+ best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None
299
+ for pattern, targets in aliases.items():
300
+ match = _match_tsconfig_alias(raw, pattern)
301
+ if match is None:
302
+ continue
303
+ specificity, captured, is_wildcard = match
304
+ if best is None or specificity < best[0]:
305
+ best = specificity, captured, is_wildcard, targets
306
+
307
+ if best is None:
308
+ if base_url is not None:
309
+ candidate = Path(os.path.normpath(base_url / raw))
310
+ resolved = _resolve_js_import_path(candidate)
311
+ if resolved.is_file():
312
+ return resolved
313
+ return None
314
+
315
+ _, captured, is_wildcard, targets = best
316
+ first = None
317
+ for target in targets:
318
+ if is_wildcard:
319
+ # TypeScript substitutes only when the matched star is non-empty.
320
+ substituted = target.replace("*", captured, 1) if captured else target
321
+ cand = Path(os.path.normpath(substituted))
322
+ else:
323
+ cand = Path(target)
324
+ if captured:
325
+ cand = Path(os.path.normpath(cand / captured))
326
+ resolved = _resolve_js_import_path(cand)
327
+ if resolved.is_file():
328
+ return resolved
329
+ if first is None:
330
+ first = cand
331
+ return first
332
+
333
+ def _find_workspace_root(start_dir: Path) -> Path | None:
334
+ current = _resolve_cached(start_dir)
335
+ for candidate in [current, *current.parents]:
336
+ if (candidate / "pnpm-workspace.yaml").exists():
337
+ return candidate
338
+ package_json = candidate / "package.json"
339
+ if package_json.is_file():
340
+ try:
341
+ data = json.loads(package_json.read_text(encoding="utf-8"))
342
+ except Exception:
343
+ continue
344
+ if "workspaces" in data:
345
+ return candidate
346
+ return None
347
+
348
+ def _pnpm_workspace_globs(workspace_file: Path) -> list[str]:
349
+ globs: list[str] = []
350
+ in_packages = False
351
+ for raw_line in workspace_file.read_text(encoding="utf-8", errors="replace").splitlines():
352
+ line = raw_line.strip()
353
+ if not line or line.startswith("#"):
354
+ continue
355
+ if line.startswith("packages:"):
356
+ in_packages = True
357
+ continue
358
+ if in_packages and line.startswith("-"):
359
+ value = line[1:].strip().strip("'\"")
360
+ if value and not value.startswith("!"):
361
+ globs.append(value)
362
+ continue
363
+ if in_packages and not raw_line.startswith((" ", "\t")):
364
+ break
365
+ return globs
366
+
367
+ def _workspace_globs(root: Path) -> list[str]:
368
+ pnpm_workspace = root / "pnpm-workspace.yaml"
369
+ if pnpm_workspace.exists():
370
+ return _pnpm_workspace_globs(pnpm_workspace)
371
+
372
+ package_json = root / "package.json"
373
+ try:
374
+ data = json.loads(package_json.read_text(encoding="utf-8"))
375
+ except Exception:
376
+ return []
377
+
378
+ workspaces = data.get("workspaces")
379
+ if isinstance(workspaces, list):
380
+ return [item for item in workspaces if isinstance(item, str) and not item.startswith("!")]
381
+ if isinstance(workspaces, dict):
382
+ packages = workspaces.get("packages")
383
+ if isinstance(packages, list):
384
+ return [item for item in packages if isinstance(item, str) and not item.startswith("!")]
385
+ return []
386
+
387
+ def _load_workspace_packages(start_dir: Path) -> dict[str, Path]:
388
+ root = _find_workspace_root(start_dir)
389
+ if root is None:
390
+ return {}
391
+ manifest_mtimes = tuple(
392
+ (name, (root / name).stat().st_mtime_ns)
393
+ for name in _WORKSPACE_MANIFEST_NAMES
394
+ if (root / name).is_file()
395
+ )
396
+ key = str((root, manifest_mtimes))
397
+ if key in _WORKSPACE_PACKAGE_CACHE:
398
+ return _WORKSPACE_PACKAGE_CACHE[key]
399
+
400
+ packages: dict[str, Path] = {}
401
+ for pattern in _workspace_globs(root):
402
+ package_dirs: list[Path] = [root] if pattern in (".", "./") else list(root.glob(pattern))
403
+ for package_dir in package_dirs:
404
+ manifest = package_dir / "package.json"
405
+ if not manifest.is_file():
406
+ continue
407
+ try:
408
+ data = json.loads(manifest.read_text(encoding="utf-8"))
409
+ except Exception:
410
+ continue
411
+ name = data.get("name")
412
+ if isinstance(name, str) and name:
413
+ packages[name] = package_dir
414
+ _WORKSPACE_PACKAGE_CACHE[key] = packages
415
+ return packages
416
+
417
+ _EXPORT_CONDITION_PRIORITY = (
418
+ "source", "import", "module", "svelte", "types", "require", "default",
419
+ )
420
+
421
+ def _resolve_export_target(value: Any) -> str | None:
422
+ """Resolve an `exports` map value (string or condition object) to a
423
+ relative target string, honouring _EXPORT_CONDITION_PRIORITY for objects
424
+ and recursing into nested condition objects."""
425
+ if isinstance(value, str):
426
+ return value
427
+ if isinstance(value, dict):
428
+ for cond in _EXPORT_CONDITION_PRIORITY:
429
+ v = value.get(cond)
430
+ if isinstance(v, str):
431
+ return v
432
+ if isinstance(v, dict):
433
+ nested = _resolve_export_target(v)
434
+ if nested:
435
+ return nested
436
+ return None
437
+
438
+ def _contained_in_package(resolved: Path, package_dir: Path) -> bool:
439
+ """Guard against `exports` targets that escape the package directory
440
+ (e.g. "./evil": "../../../etc/passwd"). Only accept paths that stay
441
+ within package_dir after resolution."""
442
+ try:
443
+ return _resolve_cached(resolved).is_relative_to(_resolve_cached(package_dir))
444
+ except ValueError:
445
+ return False
446
+
447
+ def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]:
448
+ manifest = package_dir / "package.json"
449
+ manifest_data: dict[str, Any] = {}
450
+ try:
451
+ manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
452
+ except Exception:
453
+ pass
454
+
455
+ if subpath:
456
+ # Consult the package's `exports` subpath map before the bare-path
457
+ # fallback (#1308): "./browser" -> conditions -> file, plus single
458
+ # wildcard "./*" patterns. Targets that escape the package dir are
459
+ # rejected; resolution then falls through to the bare path.
460
+ exports = manifest_data.get("exports")
461
+ if isinstance(exports, dict):
462
+ subpath_key = "./" + subpath
463
+ target = _resolve_export_target(exports.get(subpath_key))
464
+ if target:
465
+ candidate = package_dir / target
466
+ if _contained_in_package(candidate, package_dir):
467
+ return [candidate]
468
+ else:
469
+ for pattern, pattern_value in exports.items():
470
+ if "*" in pattern and pattern.count("*") == 1:
471
+ prefix, suffix = pattern.split("*", 1)
472
+ if (subpath_key.startswith(prefix)
473
+ and (not suffix or subpath_key.endswith(suffix))):
474
+ matched = subpath_key[len(prefix):len(subpath_key) - len(suffix) if suffix else None]
475
+ resolved = _resolve_export_target(pattern_value)
476
+ if resolved and "*" in resolved:
477
+ candidate = package_dir / resolved.replace("*", matched)
478
+ if _contained_in_package(candidate, package_dir):
479
+ return [candidate]
480
+ return [package_dir / subpath]
481
+
482
+ exports = manifest_data.get("exports")
483
+ if isinstance(exports, str):
484
+ return [package_dir / exports]
485
+ if isinstance(exports, dict):
486
+ dot_target = _resolve_export_target(exports.get("."))
487
+ if dot_target:
488
+ return [package_dir / dot_target]
489
+
490
+ candidates: list[Path] = []
491
+ for key in ("svelte", "module", "main", "types"):
492
+ value = manifest_data.get(key)
493
+ if isinstance(value, str):
494
+ candidates.append(package_dir / value)
495
+ candidates.append(package_dir / "src/index")
496
+ candidates.append(package_dir / "index")
497
+ return candidates
498
+
499
+ def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None:
500
+ packages = _load_workspace_packages(start_dir)
501
+ for package_name, package_dir in packages.items():
502
+ if raw == package_name:
503
+ subpath = ""
504
+ elif raw.startswith(package_name + "/"):
505
+ subpath = raw[len(package_name) + 1:]
506
+ else:
507
+ continue
508
+ for candidate in _package_entry_candidates(package_dir, subpath):
509
+ resolved = _resolve_js_import_path(candidate)
510
+ if resolved.is_file():
511
+ return resolved
512
+ return None
513
+
514
+ def _find_js_project_anchor(start_dir: Path) -> Path:
515
+ """Discover the project root anchor for unmapped JS/TS convention aliases (#3357).
516
+
517
+ Walks upward from start_dir looking for:
518
+ 1. Nearest directory containing package.json or pnpm-workspace.yaml
519
+ 2. Nearest directory containing a VCS marker (_find_vcs_root)
520
+ 3. Fallback: start_dir itself
521
+ """
522
+ from graphify.detect import _find_vcs_root
523
+
524
+ current = _resolve_cached(start_dir)
525
+ home = Path.home()
526
+
527
+ for candidate in [current, *current.parents]:
528
+ if candidate == home:
529
+ break
530
+ if (candidate / "package.json").is_file() or (candidate / "pnpm-workspace.yaml").is_file():
531
+ return candidate
532
+
533
+ vcs_root = _find_vcs_root(start_dir)
534
+ if vcs_root is not None:
535
+ return vcs_root
536
+
537
+ return current
538
+
539
+
540
+ def _load_package_imports(start_dir: Path) -> "tuple[Path, dict] | None":
541
+ """Nearest package.json `imports` map walking up from start_dir.
542
+
543
+ Node subpath imports (https://nodejs.org/api/packages.html#subpath-imports):
544
+ keys start with `#`, values are relative targets or condition objects, and
545
+ the map applies to every file inside that package. AdonisJS 6 scaffolds
546
+ `"#services/*": "./app/services/*.js"` and imports through it everywhere;
547
+ without this map ~90% of an Adonis app's imports resolved to nothing and
548
+ `affected` saw only the handful of files using relative paths. Unlike
549
+ tsconfig `paths`, a nested tsconfig (e.g. `inertia/tsconfig.json`) cannot
550
+ shadow it, which matches how Node and tsc actually resolve these.
551
+
552
+ Returns (package_dir, imports) or None. The nearest package.json wins even
553
+ when it has no `imports` (Node never walks past the enclosing package).
554
+ """
555
+ current = _resolve_cached(start_dir)
556
+ key = str(current)
557
+ if key in _PACKAGE_IMPORTS_CACHE:
558
+ return _PACKAGE_IMPORTS_CACHE[key]
559
+ result: "tuple[Path, dict] | None" = None
560
+ for candidate in [current, *current.parents]:
561
+ manifest = candidate / "package.json"
562
+ if manifest.is_file():
563
+ data = _read_json_config(manifest)
564
+ imports = data.get("imports") if isinstance(data, dict) else None
565
+ if isinstance(imports, dict) and imports:
566
+ result = (candidate, imports)
567
+ break
568
+ _PACKAGE_IMPORTS_CACHE[key] = result
569
+ return result
570
+
571
+ def _match_subpath_import(raw: str, pattern: str) -> "tuple[int, str, bool] | None":
572
+ """Node semantics for an `imports` key: exact match, or a single `*` wildcard.
573
+
574
+ Stricter than _match_tsconfig_alias on purpose: `#utils` must NOT match
575
+ `#utils/x` (Node treats a non-wildcard key as an exact specifier). Returns
576
+ (specificity, captured, is_wildcard); lower specificity wins, longest
577
+ literal prefix first among wildcards, mirroring Node's pattern ranking.
578
+ """
579
+ if "*" in pattern:
580
+ if pattern.count("*") != 1:
581
+ return None
582
+ prefix, suffix = pattern.split("*", 1)
583
+ if not raw.startswith(prefix) or not raw.endswith(suffix):
584
+ return None
585
+ end = len(raw) - len(suffix) if suffix else len(raw)
586
+ if end < len(prefix):
587
+ return None
588
+ return -len(prefix), raw[len(prefix):end], True
589
+ if raw == pattern:
590
+ return -(len(pattern) + 1_000_000), "", False
591
+ return None
592
+
593
+ def _resolve_package_import(raw: str, start_dir: Path) -> "Path | None":
594
+ """Resolve a `#subpath` specifier through package.json `imports` to a local file.
595
+
596
+ Only local targets (`./…`) are followed: a value naming an external package
597
+ (`"#dep": "dep-node-native"`) is a third-party dependency and must keep the
598
+ existing external-reference behavior in _resolve_js_import_target.
599
+ """
600
+ if not raw.startswith("#"):
601
+ return None
602
+ loaded = _load_package_imports(start_dir)
603
+ if loaded is None:
604
+ return None
605
+ package_dir, imports = loaded
606
+ best: "tuple[int, str, bool, object] | None" = None
607
+ for pattern, value in imports.items():
608
+ if not isinstance(pattern, str) or not pattern.startswith("#"):
609
+ continue
610
+ match = _match_subpath_import(raw, pattern)
611
+ if match is None:
612
+ continue
613
+ specificity, captured, is_wildcard = match
614
+ if best is None or specificity < best[0]:
615
+ best = (specificity, captured, is_wildcard, value)
616
+ if best is None:
617
+ return None
618
+ _, captured, is_wildcard, value = best
619
+ target = _resolve_export_target(value)
620
+ if not isinstance(target, str) or not target.startswith("./"):
621
+ return None
622
+ if is_wildcard:
623
+ target = target.replace("*", captured, 1) if captured else target
624
+ candidate = Path(os.path.normpath(package_dir / target))
625
+ resolved = _resolve_js_import_path(candidate)
626
+ return resolved if resolved.is_file() else None
627
+
628
+ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> Path | None:
629
+ """Resolve a JS/TS module path or specifier to a local source file.
630
+
631
+ With a Path argument this preserves the path-based helper API used by
632
+ import-extension tests. With a string plus start_dir it resolves JS/TS
633
+ module specifiers including relative paths, tsconfig aliases, and workspace
634
+ packages.
635
+ """
636
+ if isinstance(raw, Path):
637
+ return _resolve_js_import_path(raw)
638
+ if start_dir is None:
639
+ return _resolve_js_import_path(Path(raw))
640
+ if raw.startswith("."):
641
+ return _resolve_js_import_path(start_dir / raw)
642
+
643
+ aliases = _load_tsconfig_aliases(start_dir)
644
+ hit = _resolve_tsconfig_alias(raw, aliases,
645
+ base_url=_load_tsconfig_base_url(start_dir))
646
+ if hit is not None:
647
+ return _resolve_js_import_path(hit)
648
+
649
+ # Node subpath imports (`#services/foo` via package.json `imports`). Tried
650
+ # after tsconfig `paths` so an explicit alias keeps precedence (#1269).
651
+ if raw.startswith("#"):
652
+ hit = _resolve_package_import(raw, start_dir)
653
+ if hit is not None:
654
+ return hit
655
+
656
+ workspace_hit = _resolve_workspace_import(raw, start_dir)
657
+ if workspace_hit is not None:
658
+ return workspace_hit
659
+
660
+ # Unmapped `@/` project-root convention fallback (#3357).
661
+ # Active ONLY when NO tsconfig.json or jsconfig.json exists anywhere in the upward tree.
662
+ if raw.startswith("@/") and _find_js_config(start_dir) is None:
663
+ subpath = raw[2:]
664
+ if subpath:
665
+ anchor = _find_js_project_anchor(start_dir)
666
+ if (anchor / "src").is_dir():
667
+ cand = _resolve_js_import_path(anchor / "src" / subpath)
668
+ if cand.is_file():
669
+ return cand
670
+ cand = _resolve_js_import_path(anchor / subpath)
671
+ if cand.is_file():
672
+ return cand
673
+
674
+ return None
675
+
676
+ def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None":
677
+ """Resolve a JS/TS import path string to (target_nid, resolved_path).
678
+
679
+ Handles relative paths, tsconfig path aliases, workspace packages, and
680
+ bare/scoped imports.
681
+ Returns None if `raw` is empty.
682
+ """
683
+ if not raw:
684
+ return None
685
+ resolved_path = _resolve_js_module_path(raw, Path(str_path).parent)
686
+ if resolved_path is not None:
687
+ return _make_id(str(resolved_path)), resolved_path
688
+ module_name = raw.split("/")[-1]
689
+ if not module_name:
690
+ return None
691
+ # Unresolved: relative/absolute, tsconfig-alias and workspace resolution have
692
+ # all run and failed, so this is an external package (or a dangling local
693
+ # path). Namespace the id with the "ref" prefix — the J-4 convention already
694
+ # used for tsconfig `extends`/`$ref` externals — so it can NEVER collapse to
695
+ # the same _make_id as a local file/symbol node. Without it, the bare
696
+ # last-segment id (e.g. "tailwindcss/colors" -> "colors") collides with any
697
+ # unrelated local file of that stem via build.py's pre-migration alias index,
698
+ # producing a confident (EXTRACTED) cross-language phantom imports_from edge
699
+ # (#1638). The ref-namespaced target has no node, so build drops it as an
700
+ # external reference — the correct outcome for a third-party import.
701
+ return _make_id("ref", raw), None
702
+
703
+ def _resolve_c_include_path(raw: str, str_path: str) -> "Path | None":
704
+ """Resolve a quoted #include path to a real file on disk.
705
+
706
+ Searches relative to the including file's directory. Returns None for
707
+ system headers (<...>) or paths that don't exist on disk.
708
+ """
709
+ if not raw:
710
+ return None
711
+ candidate = _resolve_cached((Path(str_path).parent / raw))
712
+ if candidate.is_file():
713
+ return candidate
714
+ return None
715
+
716
+ def _resolve_lua_import_target(raw_module: str, str_path: str) -> str:
717
+ """Resolve a Lua require() module name to a node id.
718
+
719
+ Lua module names use dots as path separators: `require("pkg.b")` looks for
720
+ `pkg/b.lua` (or `pkg/b/init.lua`) relative to a package root. We probe the
721
+ importing file's directory and walk upward looking for a matching file on
722
+ disk; if found, the returned id matches the file node id `_extract_generic`
723
+ assigns to that file (`_make_id(str(path))`), so the edge lands on a real
724
+ node. When nothing matches, fall back to `_make_id` of the full dotted
725
+ module name so cross-file resolution can still complete via the symbol
726
+ resolution pass instead of dropping the edge entirely (#1075).
727
+ """
728
+ if not raw_module:
729
+ return ""
730
+ rel = raw_module.replace(".", "/")
731
+ try:
732
+ start_dir = Path(str_path).parent
733
+ except Exception:
734
+ start_dir = None
735
+ if start_dir is not None:
736
+ probe = start_dir
737
+ # Walk up a few levels so requires from nested files still resolve when
738
+ # the package root is above the importing file.
739
+ for _ in range(6):
740
+ for suffix in (".lua", ".luau"):
741
+ cand = probe / f"{rel}{suffix}"
742
+ if cand.is_file():
743
+ return _make_id(str(cand))
744
+ for suffix in (".lua", ".luau"):
745
+ cand = probe / rel / f"init{suffix}"
746
+ if cand.is_file():
747
+ return _make_id(str(cand))
748
+ if probe.parent == probe:
749
+ break
750
+ probe = probe.parent
751
+ return _make_id(raw_module)
752
+
753
+ _VUE_SCRIPT_RE = re.compile(
754
+ r"""(<script\b(?:"[^"]*"|'[^']*'|[^>"'])*>)([\s\S]*?)(</script\s*>)""",
755
+ re.IGNORECASE,
756
+ )
757
+
758
+ _VUE_SCRIPT_LANG_RE = re.compile(
759
+ r"""\blang\s*=\s*['"]?([A-Za-z]+)['"]?""", re.IGNORECASE
760
+ )
761
+
762
+ def _vue_mask_non_script(src: str) -> tuple[str, str | None]:
763
+ """Blank everything outside ``<script>`` bodies, keeping ``\\r``/``\\n``.
764
+
765
+ Replaces template/style/tags with spaces so a JS/TS grammar sees only the
766
+ script, while preserved newlines keep line numbers accurate. Returns
767
+ ``(masked_source, lang)``; ``lang`` is the first block's declared ``lang``.
768
+ """
769
+ def _blank(s: str) -> str:
770
+ return re.sub(r"[^\r\n]", " ", s)
771
+
772
+ out: list[str] = []
773
+ pos = 0
774
+ lang: str | None = None
775
+ for m in _VUE_SCRIPT_RE.finditer(src):
776
+ out.append(_blank(src[pos:m.start()])) # markup/style before this block
777
+ out.append(_blank(m.group(1))) # <script …> open tag
778
+ out.append(m.group(2)) # script body, verbatim
779
+ out.append(_blank(m.group(3))) # </script> close tag
780
+ pos = m.end()
781
+ if lang is None:
782
+ lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1))
783
+ if lang_m:
784
+ lang = lang_m.group(1).lower()
785
+ out.append(_blank(src[pos:]))
786
+ return "".join(out), lang
787
+
788
+ @functools.lru_cache(maxsize=65536)
789
+ def _cached_source_key(source_file: str, root_str: str, _cwd: str) -> str:
790
+ """Resolve-and-relativize one source path, memoized (#perf).
791
+
792
+ ``_resolve_cached(Path)`` walks the path through ``nt._getfinalpathname`` /
793
+ ``readlink`` syscalls, and ``_disambiguate_colliding_node_ids`` calls
794
+ ``_source_key`` once per node, per edge endpoint and per raw_call — tens
795
+ of thousands of calls for a few hundred distinct ``source_file`` strings.
796
+ On a 364-file corpus this pass alone spent 15s (34% of a sequential
797
+ extract) re-resolving the same strings. ``_cwd`` is part of the key
798
+ because a relative ``source_file`` resolves against the current working
799
+ directory, so a chdir between calls must miss the cache rather than
800
+ replay a stale resolution.
801
+ """
802
+ source_path = Path(source_file)
803
+ try:
804
+ return str(source_path.resolve().relative_to(root_str))
805
+ except Exception:
806
+ return str(source_path)
807
+
808
+
809
+ def _source_key(source_file: str, root: Path) -> str:
810
+ if not source_file:
811
+ return ""
812
+ return _cached_source_key(source_file, str(root), os.getcwd())
813
+
814
+ def _node_disambiguation_source_key(node: dict, root: Path) -> str:
815
+ source_file = str(node.get("source_file", ""))
816
+ if source_file:
817
+ return _source_key(source_file, root)
818
+ return _source_key(str(node.get("origin_file", "")), root)
819
+
820
+ def _disambiguate_colliding_node_ids(
821
+ nodes: list[dict],
822
+ edges: list[dict],
823
+ raw_calls: list[dict],
824
+ root: Path,
825
+ ) -> None:
826
+ """Rewrite only colliding node IDs, using source path as the disambiguator.
827
+
828
+ Module anchor nodes (#1327) are exempt: ``import CoreKit`` from three files
829
+ yields three ``type=module`` nodes with the same id but different
830
+ source_files. Those are the *same* module, not distinct same-named symbols,
831
+ so they must collapse to one shared node — disambiguating them by path would
832
+ scatter a single module across N file-qualified duplicates.
833
+ """
834
+ by_id: dict[str, list[dict]] = {}
835
+ for node in nodes:
836
+ if node.get("type") in ("module", "namespace"):
837
+ continue
838
+ nid = node.get("id")
839
+ if isinstance(nid, str) and nid:
840
+ by_id.setdefault(nid, []).append(node)
841
+
842
+ remap: dict[tuple[str, str], str] = {}
843
+ ambiguous_ids: set[str] = set()
844
+ for old_id, group in by_id.items():
845
+ source_keys = {_node_disambiguation_source_key(node, root) for node in group}
846
+ if len(group) < 2 or len(source_keys) < 2:
847
+ continue
848
+ ambiguous_ids.add(old_id)
849
+ # Salt the colliding id with the *path* it came from. The naive salt is
850
+ # ``_make_id(source_key, old_id)`` — source_key is the raw repo-relative
851
+ # path. But _make_id collapses every separator, so two DISTINCT paths
852
+ # whose only difference is a separator-vs-inner-punctuation swap
853
+ # (``a/b/c.md`` vs ``a.b/c.md``, ``foo/bar_baz.md`` vs ``foo_bar/baz.md``)
854
+ # normalize to the SAME salted id and still collide (#1522 — the residual
855
+ # of #1504 the 0.9.0 full-path stem didn't reach). When that happens,
856
+ # append a short stable hash of the *raw* source_key, which IS injective
857
+ # over distinct paths, so the colliders separate. Computed in code from
858
+ # source_file (never trusted from the LLM), so AST↔semantic parity holds.
859
+ naive: dict[str, str] = {} # source_key -> _make_id(source_key, old_id)
860
+ for source_key in source_keys:
861
+ if source_key:
862
+ naive[source_key] = _make_id(source_key, old_id)
863
+ # source_keys that, after normalization, are not unique among themselves.
864
+ seen: dict[str, int] = {}
865
+ for nid in naive.values():
866
+ seen[nid] = seen.get(nid, 0) + 1
867
+ needs_hash = {sk for sk, nid in naive.items() if seen.get(nid, 0) > 1}
868
+ for node in group:
869
+ source_key = _node_disambiguation_source_key(node, root)
870
+ if not source_key:
871
+ continue
872
+ if source_key in needs_hash:
873
+ salt = hashlib.sha1(source_key.encode("utf-8")).hexdigest()[:6]
874
+ new_id = _make_id(source_key, old_id, salt)
875
+ else:
876
+ new_id = naive.get(source_key) or _make_id(source_key, old_id)
877
+ remap[(old_id, source_key)] = new_id
878
+ if new_id != old_id:
879
+ node["id"] = new_id
880
+
881
+ if not remap:
882
+ # No colliding ids to salt apart, but the transient `target_file` hint an
883
+ # importer stamps on every resolved import (#1814) still has to be dropped
884
+ # here — this early exit skips the edge loop below, so without it a
885
+ # non-colliding import would carry its absolute path into graph.json.
886
+ for edge in edges:
887
+ edge.pop("target_file", None)
888
+ return
889
+
890
+ unambiguous_remaps: dict[str, str] = {}
891
+ for old_id, group in by_id.items():
892
+ if old_id in ambiguous_ids:
893
+ continue
894
+ candidates = {
895
+ node["id"] for node in group
896
+ if isinstance(node.get("id"), str) and node["id"] != old_id
897
+ }
898
+ if len(candidates) == 1:
899
+ unambiguous_remaps[old_id] = next(iter(candidates))
900
+
901
+ # A C/ObjC/C++ `#include "foo.h"` / `#import "foo.h"` resolves to the header's
902
+ # file node, but `foo.h` and its sibling `foo.c`/`foo.m`/`foo.cpp` collapse to
903
+ # the same `foo` file id, so disambiguation salts them apart by path. A
904
+ # cross-file import edge from a THIRD file carries neither salt's source_key, so
905
+ # the (target, edge_source_key) lookup misses and the edge dangles on the now
906
+ # dead `foo` id. Repoint those import edges to the HEADER variant (the include
907
+ # always targeted the header), keyed by the original colliding id (#1475).
908
+ _HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx")
909
+ header_remaps: dict[str, str] = {}
910
+ for old_id in ambiguous_ids:
911
+ for node in by_id.get(old_id, []):
912
+ sk = _node_disambiguation_source_key(node, root)
913
+ if sk and Path(sk).suffix.lower() in _HEADER_SUFFIXES:
914
+ new_id = remap.get((old_id, sk))
915
+ if new_id:
916
+ header_remaps[old_id] = new_id
917
+ break
918
+
919
+ for edge in edges:
920
+ edge_source_key = _source_key(str(edge.get("source_file", "")), root)
921
+ source_key = (edge.get("source", ""), edge_source_key)
922
+ # An import/re-export edge's target is a FILE node that can collapse with a
923
+ # same-basename cross-extension sibling (foo.ts vs foo.mjs, #1814). Keying
924
+ # its target salt by the IMPORTER's own source_file mis-points it back at the
925
+ # importer's variant (a self-loop). When the emitter stamped the resolved
926
+ # target file, key the target salt by THAT file so the salt lands on the
927
+ # correct sibling. Generalizes the #1475 C/ObjC header carve-out (below) to
928
+ # every language and to re_exports. `pop` it as we consume it: this is the
929
+ # hint's only reader, and its absolute path must not persist into graph.json.
930
+ target_file = edge.pop("target_file", None)
931
+ if target_file and edge.get("relation") in ("imports", "imports_from", "re_exports"):
932
+ target_edge_key = _source_key(str(target_file), root)
933
+ else:
934
+ target_edge_key = edge_source_key
935
+ target_key = (edge.get("target", ""), target_edge_key)
936
+ if source_key in remap:
937
+ edge["source"] = remap[source_key]
938
+ elif edge.get("source") in unambiguous_remaps:
939
+ edge["source"] = unambiguous_remaps[str(edge["source"])]
940
+ # imports/imports_from always target a header file, so they must resolve to
941
+ # the header variant BEFORE the same-source-file salt is considered. Keying
942
+ # the import target by the importer's own source file mis-points a `.m`
943
+ # importing its own `.h` back at itself (self-loop), and is wrong for any
944
+ # cross-file import whose importer shares the colliding id (#1475).
945
+ if (edge.get("relation") in ("imports", "imports_from")
946
+ and edge.get("target") in header_remaps):
947
+ edge["target"] = header_remaps[str(edge["target"])]
948
+ elif target_key in remap:
949
+ edge["target"] = remap[target_key]
950
+ elif edge.get("target") in unambiguous_remaps:
951
+ edge["target"] = unambiguous_remaps[str(edge["target"])]
952
+
953
+ for raw_call in raw_calls:
954
+ call_source_key = _source_key(str(raw_call.get("source_file", "")), root)
955
+ caller_key = (raw_call.get("caller_nid", ""), call_source_key)
956
+ if caller_key in remap:
957
+ raw_call["caller_nid"] = remap[caller_key]
958
+ elif raw_call.get("caller_nid") in unambiguous_remaps:
959
+ raw_call["caller_nid"] = unambiguous_remaps[str(raw_call["caller_nid"])]
960
+
961
+ def _is_type_like_definition(node: dict) -> bool:
962
+ if node.get("type") == "namespace":
963
+ return False
964
+ label = str(node.get("label", "")).strip()
965
+ if not label:
966
+ return False
967
+ if label.endswith(")") or label.startswith("."):
968
+ return False
969
+ if "." in label:
970
+ return False
971
+ return node.get("file_type") == "code"
972
+
973
+ @functools.lru_cache(maxsize=65536)
974
+ def _cached_realpath(path_str: str, _cwd: str) -> Path:
975
+ return Path(path_str).resolve()
976
+
977
+
978
+ def _resolve_cached(path: "Path | str") -> Path:
979
+ """``Path.resolve()`` with a per-(path, cwd) memo (#perf).
980
+
981
+ The symbol-resolution passes resolve the same few hundred corpus paths
982
+ once per FACT — per import, per export, per use, per node — which on
983
+ Windows walks ``nt._getfinalpathname`` every time: 44k resolve calls and
984
+ ~15s of a sequential 364-file extract after the source-key memo alone.
985
+ Results are stable within a run (files do not move mid-extract); the cwd
986
+ is part of the key so a relative path resolved after a chdir misses the
987
+ cache instead of replaying a stale answer. Raises exactly like
988
+ ``Path.resolve()`` — callers keep their own try/except — and an
989
+ exception is never cached.
990
+ """
991
+ return _cached_realpath(str(path), os.getcwd())
992
+
993
+
994
+ def _js_source_path(source_file: str, root: Path) -> Path | None:
995
+ if not source_file:
996
+ return None
997
+ path = Path(source_file)
998
+ if not path.is_absolute():
999
+ path = root / path
1000
+ try:
1001
+ return _resolve_cached(path)
1002
+ except Exception:
1003
+ return path
1004
+
1005
+ def _apply_symbol_resolution_facts(
1006
+ paths: list[Path],
1007
+ nodes: list[dict],
1008
+ edges: list[dict],
1009
+ root: Path,
1010
+ facts: _SymbolResolutionFacts,
1011
+ ) -> None:
1012
+ """Apply language-provided import/export/use facts to graph edges."""
1013
+ if not (
1014
+ facts.declarations
1015
+ or facts.imports
1016
+ or facts.aliases
1017
+ or facts.exports
1018
+ or facts.star_exports
1019
+ or facts.namespace_exports
1020
+ or facts.uses
1021
+ or facts.module_imports
1022
+ ):
1023
+ return
1024
+
1025
+ path_by_resolved = {_resolve_cached(path): path for path in paths}
1026
+ source_file_id = {_resolve_cached(path): _make_id(str(path)) for path in paths}
1027
+ symbol_nodes: dict[tuple[Path, str], str] = {}
1028
+ # Member nodes (`.method()` labels) share their bare name with top-level
1029
+ # symbols once the leading dot is stripped. A module can only re-export
1030
+ # top-level bindings, so a star-export walk must never bind an imported
1031
+ # name to a class/interface member (#3436). Track those keys separately
1032
+ # and never let a member shadow a same-named top-level symbol.
1033
+ member_symbol_keys: set[tuple[Path, str]] = set()
1034
+ for node in nodes:
1035
+ source_path = _js_source_path(str(node.get("source_file", "")), root)
1036
+ if source_path is None:
1037
+ continue
1038
+ raw_label = str(node.get("label", "")).strip()
1039
+ label = raw_label.strip("()").lstrip(".")
1040
+ if not label or not node.get("id"):
1041
+ continue
1042
+ key = (source_path, label)
1043
+ if raw_label.startswith("."):
1044
+ if key in symbol_nodes:
1045
+ continue
1046
+ member_symbol_keys.add(key)
1047
+ else:
1048
+ member_symbol_keys.discard(key)
1049
+ symbol_nodes[key] = str(node["id"])
1050
+
1051
+ def ensure_symbol_node(path: Path, name: str, line: int) -> str:
1052
+ resolved_path = _resolve_cached(path)
1053
+ existing = symbol_nodes.get((resolved_path, name))
1054
+ if existing is not None:
1055
+ return existing
1056
+ node_id = _make_id(_file_stem(path), name)
1057
+ symbol_nodes[(resolved_path, name)] = node_id
1058
+ nodes.append({
1059
+ "id": node_id,
1060
+ "label": name,
1061
+ "file_type": "code",
1062
+ "source_file": str(path),
1063
+ "source_location": f"L{line}",
1064
+ })
1065
+ return node_id
1066
+
1067
+ existing_edges = {
1068
+ (
1069
+ str(edge.get("source")),
1070
+ str(edge.get("target")),
1071
+ str(edge.get("relation")),
1072
+ str(edge.get("context") or ""),
1073
+ )
1074
+ for edge in edges
1075
+ }
1076
+
1077
+ def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path, target_file: str | None = None, local_alias: str | None = None, type_only: bool = False) -> None:
1078
+ key = (source, target, relation, context or "")
1079
+ if key in existing_edges:
1080
+ return
1081
+ existing_edges.add(key)
1082
+ edge = {
1083
+ "source": source,
1084
+ "target": target,
1085
+ "relation": relation,
1086
+ "context": context,
1087
+ "confidence": "EXTRACTED",
1088
+ "source_file": str(source_path),
1089
+ "source_location": f"L{line}",
1090
+ "weight": 1.0,
1091
+ }
1092
+ # A re-export edge's target is a FILE node that can collapse with a
1093
+ # same-basename cross-extension sibling; stamp the resolved target file so
1094
+ # the id-disambiguation salt is keyed by the TARGET, not the importer (#1814).
1095
+ if target_file is not None:
1096
+ edge["target_file"] = target_file
1097
+ # The local name this import bound in the importing file, when it differs
1098
+ # from the target's own name (`from pkg import mod as alias`) -- lets the
1099
+ # cross-file member-call resolver match `alias.func()` (#2082).
1100
+ if local_alias is not None:
1101
+ edge["local_alias"] = local_alias
1102
+ # Erased at compile time (#3123): Import Cycles skips these edges.
1103
+ if type_only:
1104
+ edge["type_only"] = True
1105
+ edges.append(edge)
1106
+
1107
+ for declaration in facts.declarations:
1108
+ ensure_symbol_node(declaration.file_path, declaration.name, declaration.line)
1109
+
1110
+ local_aliases_by_file: dict[Path, dict[str, tuple[Path, str]]] = {}
1111
+ for import_fact in facts.imports:
1112
+ file_path = _resolve_cached(import_fact.file_path)
1113
+ local_aliases_by_file.setdefault(file_path, {})[import_fact.local_name] = (
1114
+ _resolve_cached(import_fact.target_path),
1115
+ import_fact.imported_name,
1116
+ )
1117
+
1118
+ pending_aliases_by_file: dict[Path, list[_SymbolAliasFact]] = {}
1119
+ for alias_fact in facts.aliases:
1120
+ pending_aliases_by_file.setdefault(_resolve_cached(alias_fact.file_path), []).append(alias_fact)
1121
+
1122
+ for file_path, aliases in pending_aliases_by_file.items():
1123
+ local_aliases = local_aliases_by_file.setdefault(file_path, {})
1124
+ changed = True
1125
+ while changed:
1126
+ changed = False
1127
+ for alias_fact in aliases:
1128
+ if alias_fact.alias in local_aliases:
1129
+ continue
1130
+ origin = local_aliases.get(alias_fact.target_name)
1131
+ if origin is not None:
1132
+ local_aliases[alias_fact.alias] = origin
1133
+ changed = True
1134
+
1135
+ named_exports_by_file: dict[Path, dict[str, tuple[Path, str]]] = {}
1136
+ export_origins: dict[tuple[Path, str], set[tuple[Path, str]]] = {}
1137
+ star_exports_by_file: dict[Path, list[Path]] = {}
1138
+
1139
+ for star_fact in facts.star_exports:
1140
+ source_path = _resolve_cached(star_fact.file_path)
1141
+ target_path = _resolve_cached(star_fact.target_path)
1142
+ star_exports_by_file.setdefault(source_path, []).append(target_path)
1143
+ source_id = source_file_id.get(source_path)
1144
+ if source_id is not None:
1145
+ add_edge(
1146
+ source_id,
1147
+ _make_id(str(path_by_resolved.get(target_path, target_path))),
1148
+ "re_exports",
1149
+ "export",
1150
+ star_fact.line,
1151
+ star_fact.file_path,
1152
+ target_file=str(path_by_resolved.get(target_path, target_path)),
1153
+ type_only=star_fact.type_only,
1154
+ )
1155
+
1156
+ for namespace_fact in facts.namespace_exports:
1157
+ source_path = _resolve_cached(namespace_fact.file_path)
1158
+ target_path = _resolve_cached(namespace_fact.target_path)
1159
+ namespace_id = ensure_symbol_node(
1160
+ namespace_fact.file_path,
1161
+ namespace_fact.exported_name,
1162
+ namespace_fact.line,
1163
+ )
1164
+ named_exports_by_file.setdefault(source_path, {})[
1165
+ namespace_fact.exported_name
1166
+ ] = (source_path, namespace_fact.exported_name)
1167
+ source_id = source_file_id.get(source_path)
1168
+ if source_id is not None:
1169
+ add_edge(
1170
+ source_id,
1171
+ namespace_id,
1172
+ "contains",
1173
+ "namespace_export",
1174
+ namespace_fact.line,
1175
+ namespace_fact.file_path,
1176
+ )
1177
+ add_edge(
1178
+ source_id,
1179
+ _make_id(str(path_by_resolved.get(target_path, target_path))),
1180
+ "re_exports",
1181
+ "export",
1182
+ namespace_fact.line,
1183
+ namespace_fact.file_path,
1184
+ target_file=str(path_by_resolved.get(target_path, target_path)),
1185
+ type_only=namespace_fact.type_only,
1186
+ )
1187
+
1188
+ for export_fact in facts.exports:
1189
+ file_path = _resolve_cached(export_fact.file_path)
1190
+ origin: tuple[Path, str] | None = None
1191
+ if export_fact.target_path is not None and export_fact.target_name is not None:
1192
+ origin = (_resolve_cached(export_fact.target_path), export_fact.target_name)
1193
+ elif export_fact.local_name is not None:
1194
+ origin = local_aliases_by_file.get(file_path, {}).get(export_fact.local_name)
1195
+ if origin is None and (file_path, export_fact.local_name) in symbol_nodes:
1196
+ origin = (file_path, export_fact.local_name)
1197
+ if origin is None:
1198
+ # An explicit export remains evidence even when extraction did not
1199
+ # materialize its binding. Do not mistake it for an absent export.
1200
+ if export_fact.local_name is not None:
1201
+ export_origins.setdefault((file_path, export_fact.exported_name), set()).add(
1202
+ (file_path, export_fact.local_name)
1203
+ )
1204
+ continue
1205
+ export_origins.setdefault((file_path, export_fact.exported_name), set()).add(origin)
1206
+ named_exports_by_file.setdefault(file_path, {})[export_fact.exported_name] = origin
1207
+ if origin[0] != file_path:
1208
+ source_id = source_file_id.get(file_path)
1209
+ if source_id is not None:
1210
+ add_edge(
1211
+ source_id,
1212
+ _make_id(str(path_by_resolved.get(origin[0], origin[0]))),
1213
+ "re_exports",
1214
+ "export",
1215
+ export_fact.line,
1216
+ export_fact.file_path,
1217
+ target_file=str(path_by_resolved.get(origin[0], origin[0])),
1218
+ type_only=export_fact.type_only,
1219
+ )
1220
+
1221
+ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tuple[Path, str]] | None = None) -> tuple[Path, str]:
1222
+ target_path = _resolve_cached(target_path)
1223
+ key = (target_path, imported_name)
1224
+ if seen is None:
1225
+ seen = set()
1226
+ if key in seen:
1227
+ return key
1228
+ seen.add(key)
1229
+ origin = named_exports_by_file.get(target_path, {}).get(imported_name)
1230
+ if origin is not None:
1231
+ return resolve_exported_origin(origin[0], origin[1], seen)
1232
+ for star_target in star_exports_by_file.get(target_path, []):
1233
+ star_key = (star_target, imported_name)
1234
+ if star_key in symbol_nodes and star_key not in member_symbol_keys:
1235
+ return star_key
1236
+ resolved = resolve_exported_origin(star_target, imported_name, seen)
1237
+ if resolved in symbol_nodes and resolved not in member_symbol_keys:
1238
+ return resolved
1239
+ return key
1240
+
1241
+ def exported_candidates(
1242
+ key: tuple[Path, str], seen: frozenset[tuple[Path, str]]
1243
+ ) -> tuple[set[tuple[Path, str]], bool]:
1244
+ # Distinguish a cycle cutoff from an absent export. A cycle contributes
1245
+ # no origin; a named export of an unrepresented binding remains unknown.
1246
+ if key in seen:
1247
+ return set(), True
1248
+ seen = seen | {key}
1249
+ origins = export_origins.get(key)
1250
+ candidates: set[tuple[Path, str]] = set()
1251
+ cyclic = False
1252
+ if origins is None:
1253
+ for path in star_exports_by_file.get(key[0], []):
1254
+ branch, branch_cyclic = exported_candidates((path, key[1]), seen)
1255
+ candidates.update(branch)
1256
+ cyclic |= branch_cyclic
1257
+ return candidates, cyclic
1258
+ for origin in origins:
1259
+ if origin == key:
1260
+ candidates.add(origin)
1261
+ else:
1262
+ branch, branch_cyclic = exported_candidates(origin, seen)
1263
+ candidates.update(branch)
1264
+ cyclic |= branch_cyclic
1265
+ if not branch and not branch_cyclic:
1266
+ candidates.add(origin)
1267
+ return candidates, cyclic
1268
+
1269
+ # The structural extractor names the immediate barrel's symbol. Resolve
1270
+ # that exact authored export site through the shared import/export facts,
1271
+ # including `import {x}; export {x}` bridges with no local declaration.
1272
+ export_sites: dict[tuple[Path, str, Path], list[_SymbolExportFact]] = {}
1273
+ for export_fact in facts.exports:
1274
+ if export_fact.target_path is not None and export_fact.target_name is not None:
1275
+ site = (
1276
+ _resolve_cached(export_fact.file_path),
1277
+ f"L{export_fact.line}",
1278
+ _resolve_cached(export_fact.target_path),
1279
+ )
1280
+ export_sites.setdefault(site, []).append(export_fact)
1281
+ owned_ids = {node.get("id") for node in nodes}
1282
+ for edge in edges:
1283
+ if edge.get("relation") != "re_exports" or edge.get("target") in owned_ids:
1284
+ continue
1285
+ target_file = edge.get("target_file")
1286
+ source_path = _js_source_path(str(edge.get("source_file", "")), root)
1287
+ if not target_file or source_path is None:
1288
+ continue
1289
+ target_path = Path(target_file)
1290
+ site = (source_path, str(edge.get("source_location", "")), _resolve_cached(target_path))
1291
+ for export_fact in export_sites.get(site, []):
1292
+ expected = _make_id(_file_stem(target_path), export_fact.target_name)
1293
+ if edge.get("target") != expected:
1294
+ continue
1295
+ candidates, _ = exported_candidates(
1296
+ (_resolve_cached(export_fact.target_path), export_fact.target_name), frozenset()
1297
+ )
1298
+ if len(candidates) == 1:
1299
+ origin = next(iter(candidates))
1300
+ target_id = symbol_nodes.get(origin)
1301
+ if target_id in owned_ids:
1302
+ edge["target"] = target_id
1303
+ edge["target_file"] = str(path_by_resolved.get(origin[0], origin[0]))
1304
+ break
1305
+
1306
+ for import_fact in facts.imports:
1307
+ source_id = source_file_id.get(_resolve_cached(import_fact.file_path))
1308
+ if source_id is None:
1309
+ continue
1310
+ origin_path, origin_symbol = resolve_exported_origin(
1311
+ import_fact.target_path,
1312
+ import_fact.imported_name,
1313
+ )
1314
+ target_id = symbol_nodes.get((origin_path, origin_symbol))
1315
+ if target_id is None:
1316
+ continue
1317
+ add_edge(
1318
+ source_id,
1319
+ target_id,
1320
+ "imports",
1321
+ "import",
1322
+ import_fact.line,
1323
+ import_fact.file_path,
1324
+ )
1325
+
1326
+ # #1146: emit file-to-file imports_from edges for package-form submodule imports.
1327
+ for from_path, to_path, line, local_name in facts.module_imports:
1328
+ try:
1329
+ from_rel = from_path.relative_to(root)
1330
+ to_rel = to_path.relative_to(root)
1331
+ except ValueError:
1332
+ continue
1333
+ source_id = _make_id(_file_stem(from_rel))
1334
+ target_id = _make_id(_file_stem(to_rel))
1335
+ add_edge(
1336
+ source_id, target_id, "imports_from", "submodule_import", line, from_path,
1337
+ local_alias=local_name if local_name != to_path.stem else None,
1338
+ )
1339
+
1340
+ # #2262 producer guard: never emit a `calls` use-edge from a source id
1341
+ # that owns no node. All node appends (ensure_symbol_node, declarations,
1342
+ # namespace exports) happened above, so the owned set is complete here.
1343
+ # A node-less caller id can never be canonicalized by the extract()
1344
+ # remaps (they learn only from nodes), so an absolute-derived one would
1345
+ # leak the machine/scan-path slug into the edge source. Reattribute the
1346
+ # edge to the caller's FILE node — the true file-level dependency
1347
+ # survives, and the file id is exactly what the #2231 remap
1348
+ # canonicalizes — or drop it when no file node id is available.
1349
+ owned = {str(n.get("id")) for n in nodes}
1350
+ for use_fact in facts.uses:
1351
+ file_path = _resolve_cached(use_fact.file_path)
1352
+ target_id = None
1353
+ unresolved_origin = local_aliases_by_file.get(file_path, {}).get(use_fact.local_name)
1354
+ if unresolved_origin is not None:
1355
+ origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin)
1356
+ target_id = symbol_nodes.get((origin_path, origin_symbol))
1357
+ if target_id is None and use_fact.relation in ("inherits", "implements"):
1358
+ # Same-file fallback for HERITAGE only: a base declared in the same
1359
+ # file (`class X extends Y`, `interface A extends B`) has no import
1360
+ # alias, so resolve it directly against the file's own symbol nodes.
1361
+ # Scoped to heritage because same-file calls/uses already resolve via
1362
+ # the dedicated call-graph pass; widening this would duplicate those
1363
+ # edges. Import resolution still takes precedence (#1095).
1364
+ target_id = symbol_nodes.get((file_path, use_fact.local_name))
1365
+ if target_id is None:
1366
+ continue
1367
+ source_id = use_fact.source_id
1368
+ # Structural walking can omit named-function-local declarations while
1369
+ # materializing callback-local ones. Only actual node ownership decides
1370
+ # whether a type relationship has a represented source declaration.
1371
+ if use_fact.relation in ("inherits", "implements", "references") and source_id not in owned:
1372
+ continue
1373
+ if use_fact.relation == "calls" and source_id not in owned:
1374
+ source_id = source_file_id.get(file_path)
1375
+ if source_id is None:
1376
+ continue
1377
+ add_edge(
1378
+ source_id,
1379
+ target_id,
1380
+ use_fact.relation,
1381
+ use_fact.context,
1382
+ use_fact.line,
1383
+ use_fact.file_path,
1384
+ )
1385
+
1386
+ def _parse_js_tree(path: Path):
1387
+ try:
1388
+ from tree_sitter import Language, Parser
1389
+ # .vue embeds the script in non-JS markup; mask it out and parse the
1390
+ # <script> with TS.
1391
+ vue_lang: str | None = None
1392
+ if path.suffix == ".vue":
1393
+ masked, vue_lang = _vue_mask_non_script(
1394
+ path.read_text(encoding="utf-8", errors="replace")
1395
+ )
1396
+ source = masked.encode("utf-8")
1397
+ else:
1398
+ source = path.read_bytes()
1399
+ use_ts = path.suffix in (".ts", ".mts", ".cts") or (
1400
+ path.suffix == ".vue" and vue_lang not in ("js", "jsx")
1401
+ )
1402
+ if path.suffix == ".tsx":
1403
+ # .tsx must use the JSX-aware TSX grammar, mirroring the engine's
1404
+ # _TSX_CONFIG (ts_language_fn="language_tsx"). Parsing .tsx with
1405
+ # language_typescript misparses JSX, and tree-sitter's error
1406
+ # recovery floats nested arrow components up to top level —
1407
+ # _js_top_level_function_bodies then mints caller ids for callers
1408
+ # that own no node, leaking absolute-path slugs into calls-edge
1409
+ # sources (#2262).
1410
+ import tree_sitter_typescript as tstypescript
1411
+ language = Language(tstypescript.language_tsx())
1412
+ elif use_ts:
1413
+ import tree_sitter_typescript as tstypescript
1414
+ language = Language(tstypescript.language_typescript())
1415
+ else:
1416
+ import tree_sitter_javascript as tsjavascript
1417
+ language = Language(tsjavascript.language())
1418
+ parser = Parser(language)
1419
+ return source, parser.parse(source).root_node
1420
+ except Exception:
1421
+ return None
1422
+
1423
+ def _walk_js_tree(node):
1424
+ # Iterative DFS avoids Python's O(depth) generator-chain overhead.
1425
+ # Recursive yield-from creates one generator frame per level — at 26+
1426
+ # levels deep each leaf's value had to propagate through 26 frames.
1427
+ stack = [node]
1428
+ while stack:
1429
+ n = stack.pop()
1430
+ yield n
1431
+ stack.extend(reversed(n.children))
1432
+
1433
+ def _js_module_specifier(node, source: bytes) -> str | None:
1434
+ source_node = node.child_by_field_name("source")
1435
+ if source_node is None:
1436
+ for child in node.children:
1437
+ if child.type == "string":
1438
+ source_node = child
1439
+ break
1440
+ if source_node is None:
1441
+ return None
1442
+ raw = _read_text(source_node, source).strip()
1443
+ return raw.strip("'\"`") or None
1444
+
1445
+ def _js_named_specifiers(node, source: bytes, specifier_type: str) -> list[tuple[str, str]]:
1446
+ pairs: list[tuple[str, str]] = []
1447
+ for child in _walk_js_tree(node):
1448
+ if child.type != specifier_type:
1449
+ continue
1450
+ name_node = child.child_by_field_name("name")
1451
+ if name_node is None:
1452
+ continue
1453
+ alias_node = child.child_by_field_name("alias")
1454
+ name = _read_text(name_node, source)
1455
+ exposed = _read_text(alias_node, source) if alias_node is not None else name
1456
+ if name and exposed:
1457
+ pairs.append((name, exposed))
1458
+ return pairs
1459
+
1460
+ def _js_export_clause(node):
1461
+ for child in node.children:
1462
+ if child.type == "export_clause":
1463
+ return child
1464
+ return None
1465
+
1466
+ def _js_export_statement_is_star(node) -> bool:
1467
+ return any(child.type == "*" for child in node.children)
1468
+
1469
+ def _js_namespace_export_name(node, source: bytes) -> str | None:
1470
+ for child in node.children:
1471
+ if child.type != "namespace_export":
1472
+ continue
1473
+ for sub in child.children:
1474
+ if sub.type == "identifier":
1475
+ return _read_text(sub, source) or None
1476
+ return None
1477
+
1478
+ def _js_lexical_aliases(node, source: bytes) -> list[tuple[str, str]]:
1479
+ aliases: list[tuple[str, str]] = []
1480
+ if node.type != "lexical_declaration":
1481
+ return aliases
1482
+ for child in node.children:
1483
+ if child.type != "variable_declarator":
1484
+ continue
1485
+ name_node = child.child_by_field_name("name")
1486
+ value_node = child.child_by_field_name("value")
1487
+ if (
1488
+ name_node is not None
1489
+ and value_node is not None
1490
+ and value_node.type in ("identifier", "type_identifier")
1491
+ ):
1492
+ aliases.append((_read_text(name_node, source), _read_text(value_node, source)))
1493
+ return aliases
1494
+
1495
+ def _js_exported_declaration_names(node, source: bytes) -> list[str]:
1496
+ names: list[str] = []
1497
+ declaration = node.child_by_field_name("declaration")
1498
+ if declaration is None:
1499
+ return names
1500
+
1501
+ if declaration.type in ("lexical_declaration", "variable_declaration"):
1502
+ # Preserve legacy aggregate-pattern facts for identifier-valued lexical
1503
+ # declarations; individual destructured bindings are a separate feature.
1504
+ names.extend(alias for alias, _target in _js_lexical_aliases(declaration, source))
1505
+ for declarator in declaration.named_children:
1506
+ if declarator.type != "variable_declarator":
1507
+ continue
1508
+ name_node = declarator.child_by_field_name("name")
1509
+ if name_node is not None and name_node.type == "identifier":
1510
+ name = _read_text(name_node, source)
1511
+ if name not in names:
1512
+ names.append(name)
1513
+ return names
1514
+
1515
+ if declaration.type in (
1516
+ "class_declaration",
1517
+ "abstract_class_declaration",
1518
+ "interface_declaration",
1519
+ "type_alias_declaration",
1520
+ "function_declaration",
1521
+ ):
1522
+ name_node = declaration.child_by_field_name("name")
1523
+ if name_node is not None:
1524
+ names.append(_read_text(name_node, source))
1525
+ return names
1526
+
1527
+ def _js_default_import_name(node, source: bytes) -> str | None:
1528
+ """Local binding of a default import: the `Foo` in `import Foo from './x'`.
1529
+
1530
+ The default binding is a bare identifier child of the import_clause (named
1531
+ imports live in a `named_imports` node, namespace imports in a
1532
+ `namespace_import` node), so it is also picked up from the mixed form
1533
+ `import Foo, { Bar } from './x'`.
1534
+ """
1535
+ for child in node.children:
1536
+ if child.type == "import_clause":
1537
+ for sub in child.children:
1538
+ if sub.type == "identifier":
1539
+ return _read_text(sub, source)
1540
+ return None
1541
+
1542
+ def _js_default_export_name(node, source: bytes) -> str | None:
1543
+ """Local name of a default export, or None for anonymous defaults.
1544
+
1545
+ Handles `export default class Foo {}`, `export default function foo() {}`,
1546
+ `export default abstract class Foo {}` (name on the `declaration` field) and
1547
+ `export default Foo` (an identifier on the `value` field). Anonymous defaults
1548
+ (`export default class {}`, `export default {...}`) have no resolvable symbol
1549
+ and return None.
1550
+ """
1551
+ if not any(child.type == "default" for child in node.children):
1552
+ return None
1553
+ declaration = node.child_by_field_name("declaration")
1554
+ if declaration is not None:
1555
+ name_node = declaration.child_by_field_name("name")
1556
+ return _read_text(name_node, source) if name_node is not None else None
1557
+ value = node.child_by_field_name("value")
1558
+ if value is not None and value.type == "identifier":
1559
+ return _read_text(value, source)
1560
+ return None
1561
+
1562
+ def _js_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]:
1563
+ bodies: list[tuple[str, object]] = []
1564
+ stem = _file_stem(path)
1565
+ for node in root_node.children:
1566
+ if node.type == "function_declaration":
1567
+ name_node = node.child_by_field_name("name")
1568
+ body = node.child_by_field_name("body")
1569
+ if name_node is not None and body is not None:
1570
+ bodies.append((_make_id(stem, _read_text(name_node, source)), body))
1571
+ continue
1572
+ if node.type != "lexical_declaration":
1573
+ continue
1574
+ for child in node.children:
1575
+ if child.type != "variable_declarator":
1576
+ continue
1577
+ name_node = child.child_by_field_name("name")
1578
+ value_node = child.child_by_field_name("value")
1579
+ if (
1580
+ name_node is not None
1581
+ and value_node is not None
1582
+ and value_node.type == "arrow_function"
1583
+ ):
1584
+ bodies.append((_make_id(stem, _read_text(name_node, source)), value_node))
1585
+ return bodies
1586
+
1587
+ def _js_call_identifier(node, source: bytes) -> str | None:
1588
+ if node.type != "call_expression":
1589
+ return None
1590
+ function_node = node.child_by_field_name("function")
1591
+ if function_node is None:
1592
+ for child in node.children:
1593
+ if child.is_named:
1594
+ function_node = child
1595
+ break
1596
+ if function_node is not None and function_node.type in ("identifier", "type_identifier"):
1597
+ return _read_text(function_node, source)
1598
+ return None
1599
+
1600
+ _JS_PRIMITIVE_TYPES = frozenset({
1601
+ "string", "number", "boolean", "any", "unknown", "void", "never",
1602
+ "object", "null", "undefined", "bigint", "symbol", "this",
1603
+ })
1604
+
1605
+ def _ts_heritage_clause_entries(clause_node, source: bytes) -> list[str]:
1606
+ """Return base/interface type names from an extends_clause or implements_clause."""
1607
+ out: list[str] = []
1608
+ for child in clause_node.children:
1609
+ if not child.is_named:
1610
+ continue
1611
+ if child.type in ("identifier", "type_identifier"):
1612
+ name = _read_text(child, source)
1613
+ if name:
1614
+ out.append(name)
1615
+ elif child.type == "generic_type":
1616
+ name_node = child.child_by_field_name("name")
1617
+ if name_node is None:
1618
+ for sub in child.children:
1619
+ if sub.type in ("type_identifier", "nested_type_identifier", "identifier"):
1620
+ name_node = sub
1621
+ break
1622
+ if name_node is not None:
1623
+ text = _read_text(name_node, source).rsplit(".", 1)[-1]
1624
+ if text:
1625
+ out.append(text)
1626
+ elif child.type == "nested_type_identifier":
1627
+ text = _read_text(child, source).rsplit(".", 1)[-1]
1628
+ if text:
1629
+ out.append(text)
1630
+ return out
1631
+
1632
+ def _ts_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
1633
+ """Walk a TS type annotation tree; append (name, role) tuples.
1634
+
1635
+ role is 'type' for the outermost type position and 'generic_arg' for entries
1636
+ that appear inside `type_arguments`.
1637
+ """
1638
+ if node is None:
1639
+ return
1640
+ t = node.type
1641
+ if t == "type_annotation":
1642
+ for c in node.children:
1643
+ if c.is_named:
1644
+ _ts_collect_type_refs(c, source, generic, out)
1645
+ return
1646
+ if t in ("type_identifier", "identifier"):
1647
+ name = _read_text(node, source)
1648
+ if name and name not in _JS_PRIMITIVE_TYPES:
1649
+ out.append((name, "generic_arg" if generic else "type"))
1650
+ return
1651
+ if t == "nested_type_identifier":
1652
+ tail = _read_text(node, source).rsplit(".", 1)[-1]
1653
+ if tail and tail not in _JS_PRIMITIVE_TYPES:
1654
+ out.append((tail, "generic_arg" if generic else "type"))
1655
+ return
1656
+ if t == "generic_type":
1657
+ name_node = node.child_by_field_name("name")
1658
+ if name_node is not None:
1659
+ text = _read_text(name_node, source).rsplit(".", 1)[-1]
1660
+ if text and text not in _JS_PRIMITIVE_TYPES:
1661
+ out.append((text, "generic_arg" if generic else "type"))
1662
+ else:
1663
+ for c in node.children:
1664
+ if c.type in ("type_identifier", "nested_type_identifier"):
1665
+ text = _read_text(c, source).rsplit(".", 1)[-1]
1666
+ if text and text not in _JS_PRIMITIVE_TYPES:
1667
+ out.append((text, "generic_arg" if generic else "type"))
1668
+ break
1669
+ for c in node.children:
1670
+ if c.type == "type_arguments":
1671
+ for sub in c.children:
1672
+ if sub.is_named:
1673
+ _ts_collect_type_refs(sub, source, True, out)
1674
+ return
1675
+ if node.is_named:
1676
+ for c in node.children:
1677
+ if c.is_named:
1678
+ _ts_collect_type_refs(c, source, generic, out)
1679
+
1680
+ def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str,
1681
+ facts: _SymbolResolutionFacts) -> None:
1682
+ """Emit type-relation and type-reference use facts for a class declaration node."""
1683
+ line = class_node.start_point[0] + 1
1684
+ for child in class_node.children:
1685
+ if child.type == "class_heritage":
1686
+ saw_clause = False
1687
+ for clause in child.children:
1688
+ if clause.type == "extends_clause":
1689
+ saw_clause = True
1690
+ for name in _ts_heritage_clause_entries(clause, source):
1691
+ facts.uses.append(
1692
+ _SymbolUseFact(path, class_nid, name, "inherits", "type",
1693
+ clause.start_point[0] + 1)
1694
+ )
1695
+ elif clause.type == "implements_clause":
1696
+ saw_clause = True
1697
+ for name in _ts_heritage_clause_entries(clause, source):
1698
+ facts.uses.append(
1699
+ _SymbolUseFact(path, class_nid, name, "implements", "type",
1700
+ clause.start_point[0] + 1)
1701
+ )
1702
+ if not saw_clause:
1703
+ # The JavaScript grammar carries the base directly under
1704
+ # class_heritage (`extends Base` -> [extends, identifier]) with no
1705
+ # extends_clause/implements_clause wrapper like the TypeScript
1706
+ # grammar. Treat the heritage node itself as the extends clause so
1707
+ # `class Derived extends Base {}` in a .js file still emits an
1708
+ # inherits edge. Mirrors the extends_type_clause branch below.
1709
+ for name in _ts_heritage_clause_entries(child, source):
1710
+ facts.uses.append(
1711
+ _SymbolUseFact(path, class_nid, name, "inherits", "type",
1712
+ child.start_point[0] + 1)
1713
+ )
1714
+ elif child.type == "extends_type_clause":
1715
+ # Interface heritage (`interface A extends B, C`) is an
1716
+ # extends_type_clause node, NOT a class_heritage. Its base entries
1717
+ # are the same node types extends_clause holds, so the helper is
1718
+ # reusable. Without this branch interface inheritance is dropped (#1095).
1719
+ for name in _ts_heritage_clause_entries(child, source):
1720
+ facts.uses.append(
1721
+ _SymbolUseFact(path, class_nid, name, "inherits", "type",
1722
+ child.start_point[0] + 1)
1723
+ )
1724
+
1725
+ body = class_node.child_by_field_name("body")
1726
+ if body is None:
1727
+ return
1728
+
1729
+ for member in body.children:
1730
+ m_line = member.start_point[0] + 1
1731
+ if member.type in ("method_definition", "method_signature", "abstract_method_signature"):
1732
+ name_node = member.child_by_field_name("name")
1733
+ if name_node is None:
1734
+ continue
1735
+ method_name = _read_text(name_node, source)
1736
+ method_nid = _make_id(class_nid, method_name)
1737
+ params = member.child_by_field_name("parameters")
1738
+ if params is not None:
1739
+ for p in params.children:
1740
+ if p.type not in ("required_parameter", "optional_parameter"):
1741
+ continue
1742
+ type_anno = p.child_by_field_name("type")
1743
+ if type_anno is None:
1744
+ continue
1745
+ refs: list[tuple[str, str]] = []
1746
+ _ts_collect_type_refs(type_anno, source, False, refs)
1747
+ for name, role in refs:
1748
+ ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
1749
+ facts.uses.append(
1750
+ _SymbolUseFact(path, method_nid, name, "references", ctx, m_line)
1751
+ )
1752
+ return_type = member.child_by_field_name("return_type")
1753
+ if return_type is not None:
1754
+ refs = []
1755
+ _ts_collect_type_refs(return_type, source, False, refs)
1756
+ for name, role in refs:
1757
+ ctx = "generic_arg" if role == "generic_arg" else "return_type"
1758
+ facts.uses.append(
1759
+ _SymbolUseFact(path, method_nid, name, "references", ctx, m_line)
1760
+ )
1761
+ elif member.type in ("public_field_definition", "property_signature"):
1762
+ type_anno = None
1763
+ for c in member.children:
1764
+ if c.type == "type_annotation":
1765
+ type_anno = c
1766
+ break
1767
+ if type_anno is None:
1768
+ continue
1769
+ refs = []
1770
+ _ts_collect_type_refs(type_anno, source, False, refs)
1771
+ for name, role in refs:
1772
+ ctx = "generic_arg" if role == "generic_arg" else "field"
1773
+ facts.uses.append(
1774
+ _SymbolUseFact(path, class_nid, name, "references", ctx, m_line)
1775
+ )
1776
+
1777
+ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolutionFacts) -> None:
1778
+ js_paths = [
1779
+ path for path in paths
1780
+ if path.suffix in _JS_CACHE_BYPASS_SUFFIXES
1781
+ ]
1782
+ if not js_paths:
1783
+ return
1784
+
1785
+ trees: dict[Path, tuple[bytes, object]] = {}
1786
+
1787
+ for path in js_paths:
1788
+ resolved_path = _resolve_cached(path)
1789
+ parsed = _parse_js_tree(path)
1790
+ if parsed is None:
1791
+ continue
1792
+ source, root_node = parsed
1793
+ trees[resolved_path] = parsed
1794
+
1795
+ for node in _walk_js_tree(root_node):
1796
+ if node.type == "export_statement":
1797
+ for name in _js_exported_declaration_names(node, source):
1798
+ facts.declarations.append(
1799
+ _SymbolDeclarationFact(path, name, node.start_point[0] + 1)
1800
+ )
1801
+
1802
+ if node.type != "import_statement":
1803
+ continue
1804
+ raw_module = _js_module_specifier(node, source)
1805
+ if raw_module is None:
1806
+ continue
1807
+ target_path = _resolve_js_module_path(raw_module, path.parent)
1808
+ if target_path is None:
1809
+ continue
1810
+ target_path = _resolve_cached(target_path)
1811
+ for imported_name, local_name in _js_named_specifiers(node, source, "import_specifier"):
1812
+ facts.imports.append(
1813
+ _SymbolImportFact(
1814
+ path,
1815
+ local_name,
1816
+ target_path,
1817
+ imported_name,
1818
+ node.start_point[0] + 1,
1819
+ )
1820
+ )
1821
+ default_local = _js_default_import_name(node, source)
1822
+ if default_local is not None:
1823
+ facts.imports.append(
1824
+ _SymbolImportFact(
1825
+ path,
1826
+ default_local,
1827
+ target_path,
1828
+ "default",
1829
+ node.start_point[0] + 1,
1830
+ )
1831
+ )
1832
+
1833
+ for node in _walk_js_tree(root_node):
1834
+ for alias, target in _js_lexical_aliases(node, source):
1835
+ facts.aliases.append(
1836
+ _SymbolAliasFact(path, alias, target, node.start_point[0] + 1)
1837
+ )
1838
+
1839
+ for path in js_paths:
1840
+ resolved_path = _resolve_cached(path)
1841
+ parsed = trees.get(resolved_path)
1842
+ if parsed is None:
1843
+ continue
1844
+ source, root_node = parsed
1845
+
1846
+ for node in _walk_js_tree(root_node):
1847
+ if node.type != "export_statement":
1848
+ continue
1849
+
1850
+ raw_module = _js_module_specifier(node, source)
1851
+ export_clause = _js_export_clause(node)
1852
+ # `export type { X } from ...` / `export type * from ...`: the
1853
+ # statement-level `type` keyword is a bare anonymous child; the
1854
+ # default binding NAMED type sits inside the clause instead (#3123).
1855
+ stmt_type_only = any(
1856
+ child.type == "type" and not child.is_named
1857
+ for child in node.children
1858
+ )
1859
+ if raw_module is not None:
1860
+ target_path = _resolve_js_module_path(raw_module, path.parent)
1861
+ if target_path is None:
1862
+ continue
1863
+ target_path = _resolve_cached(target_path)
1864
+ namespace_name = _js_namespace_export_name(node, source)
1865
+ if namespace_name is not None:
1866
+ facts.namespace_exports.append(
1867
+ _NamespaceExportFact(
1868
+ path,
1869
+ namespace_name,
1870
+ target_path,
1871
+ node.start_point[0] + 1,
1872
+ type_only=stmt_type_only,
1873
+ )
1874
+ )
1875
+ elif _js_export_statement_is_star(node):
1876
+ facts.star_exports.append(
1877
+ _StarExportFact(path, target_path, node.start_point[0] + 1,
1878
+ type_only=stmt_type_only)
1879
+ )
1880
+ if export_clause is not None:
1881
+ for original_name, exported_name in _js_named_specifiers(
1882
+ export_clause, source, "export_specifier"
1883
+ ):
1884
+ facts.exports.append(
1885
+ _SymbolExportFact(
1886
+ path,
1887
+ exported_name,
1888
+ node.start_point[0] + 1,
1889
+ target_path=target_path,
1890
+ target_name=original_name,
1891
+ type_only=stmt_type_only,
1892
+ )
1893
+ )
1894
+ continue
1895
+
1896
+ if export_clause is not None:
1897
+ for local_name, exported_name in _js_named_specifiers(
1898
+ export_clause, source, "export_specifier"
1899
+ ):
1900
+ facts.exports.append(
1901
+ _SymbolExportFact(
1902
+ path,
1903
+ exported_name,
1904
+ node.start_point[0] + 1,
1905
+ local_name=local_name,
1906
+ )
1907
+ )
1908
+ continue
1909
+
1910
+ for exported_name in _js_exported_declaration_names(node, source):
1911
+ facts.exports.append(
1912
+ _SymbolExportFact(
1913
+ path,
1914
+ exported_name,
1915
+ node.start_point[0] + 1,
1916
+ local_name=exported_name,
1917
+ )
1918
+ )
1919
+
1920
+ # `export default class Foo {}` / `export default foo` exposes the
1921
+ # symbol under the name "default"; record that so a default import
1922
+ # (imported_name="default") resolves to it. `export { X as default }`
1923
+ # is already handled via the export_clause path above.
1924
+ default_name = _js_default_export_name(node, source)
1925
+ if default_name is not None:
1926
+ facts.exports.append(
1927
+ _SymbolExportFact(
1928
+ path,
1929
+ "default",
1930
+ node.start_point[0] + 1,
1931
+ local_name=default_name,
1932
+ )
1933
+ )
1934
+
1935
+ for path in js_paths:
1936
+ resolved_path = _resolve_cached(path)
1937
+ parsed = trees.get(resolved_path)
1938
+ if parsed is None:
1939
+ continue
1940
+ source, root_node = parsed
1941
+ for source_id, body in _js_top_level_function_bodies(path, root_node, source):
1942
+ for node in _walk_js_tree(body):
1943
+ imported_name = _js_call_identifier(node, source)
1944
+ if imported_name is None:
1945
+ continue
1946
+ facts.uses.append(
1947
+ _SymbolUseFact(
1948
+ path,
1949
+ source_id,
1950
+ imported_name,
1951
+ "calls",
1952
+ "call",
1953
+ node.start_point[0] + 1,
1954
+ )
1955
+ )
1956
+
1957
+ for path in js_paths:
1958
+ resolved_path = _resolve_cached(path)
1959
+ parsed = trees.get(resolved_path)
1960
+ if parsed is None:
1961
+ continue
1962
+ source, root_node = parsed
1963
+ stem = _file_stem(path)
1964
+ for node in _walk_js_tree(root_node):
1965
+ if node.type not in (
1966
+ "class_declaration",
1967
+ "abstract_class_declaration",
1968
+ "interface_declaration",
1969
+ ):
1970
+ continue
1971
+ name_node = node.child_by_field_name("name")
1972
+ if name_node is None:
1973
+ continue
1974
+ class_name = _read_text(name_node, source)
1975
+ if not class_name:
1976
+ continue
1977
+ class_nid = _make_id(stem, class_name)
1978
+ _ts_walk_class_members(node, source, path, class_nid, facts)
1979
+
1980
+ @functools.lru_cache(maxsize=2048)
1981
+ def _parse_python_tree_cached(path_str: str, _mtime_ns: int, _size: int):
1982
+ import tree_sitter_python as tspython
1983
+ from tree_sitter import Language, Parser
1984
+ source = Path(path_str).read_bytes()
1985
+ parser = Parser(Language(tspython.language()))
1986
+ return source, parser.parse(source).root_node
1987
+
1988
+
1989
+ def _parse_python_tree(path: Path):
1990
+ """Parse one Python file to ``(source, root_node)``, memoized (#perf).
1991
+
1992
+ The Python symbol-resolution facts pass and the cross-file import pass each
1993
+ parse the entire ``.py`` corpus, back to back, from the main process after
1994
+ the workers return — so every file was tree-sitter-parsed (and read from
1995
+ disk) twice for no reason. Keying the memo on ``(path, mtime_ns, size)``
1996
+ lets the second pass reuse the first pass's tree while still re-parsing a
1997
+ file that changed between runs (watch mode). Both passes only read the
1998
+ tree, so sharing one parse is behaviour-preserving. Returns ``None`` on any
1999
+ error, exactly as before — callers already treat that as "skip this file".
2000
+ """
2001
+ try:
2002
+ st = path.stat()
2003
+ return _parse_python_tree_cached(str(path), st.st_mtime_ns, st.st_size)
2004
+ except Exception:
2005
+ return None
2006
+
2007
+ def _walk_python_tree(node):
2008
+ """Preorder walk of a tree-sitter tree, iteratively.
2009
+
2010
+ The recursive ``yield from`` form built one suspended generator frame per
2011
+ ancestor and re-propagated every node up the whole chain — ~25M frame
2012
+ resumptions on a 364-file corpus for ~2.8M actual nodes. An explicit stack
2013
+ yields each node exactly once in the identical preorder (children pushed
2014
+ reversed so the first child pops first). Same rewrite, same reasoning as
2015
+ ``_walk_js_tree`` above.
2016
+ """
2017
+ stack = [node]
2018
+ while stack:
2019
+ current = stack.pop()
2020
+ yield current
2021
+ stack.extend(reversed(current.children))
2022
+
2023
+ def _python_import_from_module(node, source: bytes) -> tuple[int, str] | None:
2024
+ level = 0
2025
+ module_name = ""
2026
+ for child in node.children:
2027
+ if child.type == "import":
2028
+ break
2029
+ if child.type == "relative_import":
2030
+ raw = _read_text(child, source)
2031
+ level = len(raw) - len(raw.lstrip("."))
2032
+ remainder = raw.lstrip(".")
2033
+ if remainder:
2034
+ module_name = remainder
2035
+ for sub in child.children:
2036
+ if sub.type == "dotted_name":
2037
+ module_name = _read_text(sub, source)
2038
+ elif child.type == "dotted_name":
2039
+ module_name = _read_text(child, source)
2040
+ if level == 0 and not module_name:
2041
+ return None
2042
+ return level, module_name
2043
+
2044
+ def _python_imported_names(node, source: bytes) -> list[tuple[str, str]]:
2045
+ names: list[tuple[str, str]] = []
2046
+ past_import = False
2047
+ for child in node.children:
2048
+ if child.type == "import":
2049
+ past_import = True
2050
+ continue
2051
+ if not past_import:
2052
+ continue
2053
+ if child.type == "dotted_name":
2054
+ name = _read_text(child, source)
2055
+ names.append((name, name.split(".")[-1]))
2056
+ elif child.type == "aliased_import":
2057
+ name_node = child.child_by_field_name("name")
2058
+ alias_node = child.child_by_field_name("alias")
2059
+ if name_node is None:
2060
+ continue
2061
+ name = _read_text(name_node, source)
2062
+ local = _read_text(alias_node, source) if alias_node is not None else name.split(".")[-1]
2063
+ names.append((name, local))
2064
+ return names
2065
+
2066
+ def _probe_python_module_candidate(candidate: Path) -> Path | None:
2067
+ """Resolve one module-path candidate to a .py file (dir+__init__, exact, or
2068
+ with a .py suffix), or None."""
2069
+ if candidate.is_dir():
2070
+ init_path = candidate / "__init__.py"
2071
+ if init_path.is_file():
2072
+ return init_path
2073
+ if candidate.is_file():
2074
+ return candidate
2075
+ if not candidate.name:
2076
+ return None
2077
+ py_candidate = candidate.with_suffix(".py")
2078
+ if py_candidate.is_file():
2079
+ return py_candidate
2080
+ return None
2081
+
2082
+
2083
+ def _resolve_python_module_path(module_name: str, current_path: Path, root: Path, level: int) -> Path | None:
2084
+ if level > 0:
2085
+ base = current_path.parent
2086
+ for _ in range(level - 1):
2087
+ base = base.parent
2088
+ candidate = base / module_name.replace(".", "/") if module_name else base
2089
+ return _probe_python_module_candidate(candidate)
2090
+
2091
+ # Absolute import. Probe the scan root first (unchanged for the common
2092
+ # root-is-package-root layout), then walk up from the importing file toward
2093
+ # the root so a `src/` (or otherwise nested) package root resolves regardless
2094
+ # of where the scan started — `import pkg.mod` from src/pkg/app.py must find
2095
+ # src/pkg/mod.py whether the scan root is the repo or src/ (#2072). Mirrors
2096
+ # the upward walk already used for Lua (_resolve_lua_import_target, #1075).
2097
+ rel = module_name.replace(".", "/")
2098
+ hit = _probe_python_module_candidate(root / rel)
2099
+ if hit is not None:
2100
+ return hit
2101
+ for anc in current_path.parents:
2102
+ try:
2103
+ anc.relative_to(root)
2104
+ except ValueError:
2105
+ break # left the scan root; stop walking up
2106
+ if anc == root:
2107
+ continue # already probed root/rel above
2108
+ # Only probe sys.path-root candidates — dirs that are NOT themselves part
2109
+ # of a package. Probing a package dir would resolve an absolute
2110
+ # `from helpers import x` to a sibling in the current package (Python-2
2111
+ # implicit-relative semantics), fabricating edges to what may be an
2112
+ # external dependency (#2072 review). A src-layout root (src/, no
2113
+ # __init__.py) is still probed.
2114
+ if (anc / "__init__.py").is_file():
2115
+ continue
2116
+ cand = _probe_python_module_candidate(anc / rel)
2117
+ if cand is not None:
2118
+ return cand
2119
+ return None
2120
+
2121
+ def _resolve_python_namespace_dir(module_name: str, current_path: Path, root: Path, level: int) -> "Path | None":
2122
+ """The directory a ``from <module> import ...`` names when that module is a
2123
+ PEP 420 namespace package: a directory under the scan root with no
2124
+ ``__init__.py``. ``_resolve_python_module_path`` returns None for it (there is
2125
+ no module file to probe), so a package that omits ``__init__.py`` -- which
2126
+ ``python -m pkg.mod`` runs without complaint -- had every ``from . import
2127
+ sibling`` dropped whole, and with it every ``sibling.func()`` call the #1883
2128
+ module arm would otherwise have resolved: the most-called functions in such a
2129
+ repo carried in-degree 0. Mirrors that resolver's walk (relative base, then
2130
+ scan root, then sys.path-root ancestors) and returns only a directory that
2131
+ exists inside the root."""
2132
+ def _namespace(candidate: Path) -> "Path | None":
2133
+ if not candidate.is_dir() or (candidate / "__init__.py").is_file():
2134
+ return None
2135
+ try:
2136
+ _resolve_cached(candidate).relative_to(_resolve_cached(root))
2137
+ except ValueError:
2138
+ return None
2139
+ return candidate
2140
+
2141
+ if level > 0:
2142
+ base = current_path.parent
2143
+ for _ in range(level - 1):
2144
+ base = base.parent
2145
+ return _namespace(base / module_name.replace(".", "/") if module_name else base)
2146
+ if not module_name:
2147
+ return None
2148
+ rel = module_name.replace(".", "/")
2149
+ hit = _namespace(root / rel)
2150
+ if hit is not None:
2151
+ return hit
2152
+ for anc in current_path.parents:
2153
+ try:
2154
+ anc.relative_to(root)
2155
+ except ValueError:
2156
+ break # left the scan root; stop walking up
2157
+ if anc == root or (anc / "__init__.py").is_file():
2158
+ continue # root already probed; a package dir is not a sys.path root (#2072)
2159
+ hit = _namespace(anc / rel)
2160
+ if hit is not None:
2161
+ return hit
2162
+ return None
2163
+
2164
+ def _python_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]:
2165
+ bodies: list[tuple[str, object]] = []
2166
+ stem = _file_stem(path)
2167
+ for node in root_node.children:
2168
+ if node.type != "function_definition":
2169
+ continue
2170
+ name_node = node.child_by_field_name("name")
2171
+ body = node.child_by_field_name("body")
2172
+ if name_node is not None and body is not None:
2173
+ bodies.append((_make_id(stem, _read_text(name_node, source)), body))
2174
+ return bodies
2175
+
2176
+ def _python_call_identifier(node, source: bytes) -> str | None:
2177
+ if node.type != "call":
2178
+ return None
2179
+ function_node = node.child_by_field_name("function")
2180
+ if function_node is not None and function_node.type == "identifier":
2181
+ return _read_text(function_node, source)
2182
+ return None
2183
+
2184
+ def _collect_python_symbol_resolution_facts(
2185
+ paths: list[Path],
2186
+ root: Path,
2187
+ facts: _SymbolResolutionFacts,
2188
+ ) -> None:
2189
+ py_paths = [path for path in paths if path.suffix == ".py"]
2190
+ if not py_paths:
2191
+ return
2192
+
2193
+ trees: dict[Path, tuple[bytes, object]] = {}
2194
+ for path in py_paths:
2195
+ parsed = _parse_python_tree(path)
2196
+ if parsed is None:
2197
+ continue
2198
+ source, root_node = parsed
2199
+ trees[_resolve_cached(path)] = parsed
2200
+
2201
+ for node in _walk_python_tree(root_node):
2202
+ if node.type != "import_from_statement":
2203
+ continue
2204
+ module = _python_import_from_module(node, source)
2205
+ if module is None:
2206
+ continue
2207
+ level, module_name = module
2208
+ target_path = _resolve_python_module_path(module_name, path, root, level)
2209
+ if target_path is not None:
2210
+ # #1146: `from pkg import submod` — if the target is a package
2211
+ # (__init__.py) and an imported name matches a submodule file on
2212
+ # disk, emit a file-level import edge to that submodule rather
2213
+ # than only to the package.
2214
+ pkg_dir = target_path.parent if target_path.name == "__init__.py" else None
2215
+ else:
2216
+ # A PEP 420 namespace package: the module names a directory with
2217
+ # no __init__.py, so there is no module file to resolve to, but
2218
+ # the names it imports can still be submodule files on disk.
2219
+ # Without this branch `from . import brain` in such a package
2220
+ # emitted nothing, and `brain.think()` never became an edge.
2221
+ pkg_dir = _resolve_python_namespace_dir(module_name, path, root, level)
2222
+ if pkg_dir is None:
2223
+ continue
2224
+ for imported_name, local_name in _python_imported_names(node, source):
2225
+ line = node.start_point[0] + 1
2226
+ if pkg_dir is not None:
2227
+ sub_py = pkg_dir / f"{imported_name}.py"
2228
+ sub_pkg = pkg_dir / imported_name / "__init__.py"
2229
+ submodule = sub_py if sub_py.is_file() else (sub_pkg if sub_pkg.is_file() else None)
2230
+ if submodule is not None:
2231
+ facts.module_imports.append((path, submodule, line, local_name))
2232
+ continue
2233
+ if target_path is None:
2234
+ continue # a namespace package owns no symbols of its own to bind
2235
+ facts.imports.append(
2236
+ _SymbolImportFact(path, local_name, target_path, imported_name, line)
2237
+ )
2238
+ if path.name == "__init__.py":
2239
+ facts.exports.append(
2240
+ _SymbolExportFact(
2241
+ path,
2242
+ local_name,
2243
+ line,
2244
+ target_path=target_path,
2245
+ target_name=imported_name,
2246
+ )
2247
+ )
2248
+
2249
+ for path in py_paths:
2250
+ parsed = trees.get(_resolve_cached(path))
2251
+ if parsed is None:
2252
+ continue
2253
+ source, root_node = parsed
2254
+ for source_id, body in _python_top_level_function_bodies(path, root_node, source):
2255
+ for node in _walk_python_tree(body):
2256
+ imported_name = _python_call_identifier(node, source)
2257
+ if imported_name is None:
2258
+ continue
2259
+ facts.uses.append(
2260
+ _SymbolUseFact(
2261
+ path,
2262
+ source_id,
2263
+ imported_name,
2264
+ "calls",
2265
+ "call",
2266
+ node.start_point[0] + 1,
2267
+ )
2268
+ )
2269
+
2270
+ def _augment_symbol_resolution_edges(
2271
+ paths: list[Path],
2272
+ nodes: list[dict],
2273
+ edges: list[dict],
2274
+ root: Path,
2275
+ ) -> None:
2276
+ facts = _SymbolResolutionFacts()
2277
+ _collect_js_symbol_resolution_facts(paths, facts)
2278
+ _collect_python_symbol_resolution_facts(paths, root, facts)
2279
+ _apply_symbol_resolution_facts(paths, nodes, edges, root, facts)
2280
+
2281
+ def _resolve_cross_file_imports(
2282
+ per_file: list[dict],
2283
+ paths: list[Path],
2284
+ all_nodes: list[dict] | None = None,
2285
+ all_edges: list[dict] | None = None,
2286
+ ) -> list[dict]:
2287
+ """
2288
+ Two-pass import resolution: turn file-level imports into class-level edges.
2289
+
2290
+ Pass 1 - build a global map: class/function name → node_id, per stem.
2291
+ Pass 2 - for each `from .module import Name`, look up Name in the global
2292
+ map and add a direct INFERRED edge from each class in the
2293
+ importing file to the imported entity.
2294
+
2295
+ This turns:
2296
+ auth.py --imports_from--> models.py (obvious, filtered out)
2297
+ Into:
2298
+ DigestAuth --uses--> Response [INFERRED] (cross-file, interesting!)
2299
+ BasicAuth --uses--> Request [INFERRED]
2300
+ """
2301
+ try:
2302
+ import tree_sitter_python # noqa: F401 (availability check only)
2303
+ except ImportError:
2304
+ return []
2305
+
2306
+ # Pass 1: _file_stem(path) → {ClassName: node_id}
2307
+ # Keyed by directory-qualified stem (e.g. "auth_models") to avoid collisions
2308
+ # when multiple files share the same filename in different directories.
2309
+ # A secondary bare-stem index handles absolute imports where only the module
2310
+ # name is known — first writer wins when names collide (inherently ambiguous).
2311
+ stem_to_entities: dict[str, dict[str, str]] = {}
2312
+ bare_to_qualified: dict[str, str] = {}
2313
+ for file_result in per_file:
2314
+ for node in file_result.get("nodes", []):
2315
+ src = node.get("source_file", "")
2316
+ if not src:
2317
+ continue
2318
+ src_path = Path(src)
2319
+ fq_stem = _file_stem(src_path)
2320
+ label = node.get("label", "")
2321
+ nid = node.get("id", "")
2322
+ # Index class-level entities only. Function/method labels end in "()"
2323
+ # so are excluded by the `endswith(")")` filter; file nodes end in ".py";
2324
+ # private/internal labels start with "_"; rationale nodes carry
2325
+ # file_type=="rationale" and must never participate in cross-file
2326
+ # import resolution (#563).
2327
+ if (
2328
+ label
2329
+ and not label.endswith((")", ".py"))
2330
+ and "_" not in label[:1]
2331
+ and node.get("file_type") != "rationale"
2332
+ ):
2333
+ stem_to_entities.setdefault(fq_stem, {})[label] = nid
2334
+ if src_path.stem not in bare_to_qualified:
2335
+ bare_to_qualified[src_path.stem] = fq_stem
2336
+
2337
+ # Pass 2: for each file, find `from .X import A, B, C`, then attribute the
2338
+ # `uses` edge to the specific local symbol (class OR function) whose body
2339
+ # actually references the imported name — not to every class that merely
2340
+ # shares the file (#2652). The edge is anchored at the real reference, not
2341
+ # the import line, so `source_location` points at genuine corroboration.
2342
+ new_edges: list[dict] = []
2343
+ node_by_id = {node["id"]: node for node in all_nodes if node.get("id")} if all_nodes else {}
2344
+ repointed_from: set[str] = set()
2345
+ TYPE_REPOINT_RELATIONS = frozenset({"references", "inherits", "implements", "extends"})
2346
+
2347
+ for file_result, path in zip(per_file, paths):
2348
+ str_path = str(path)
2349
+ file_srcs = {n.get("source_file") for n in file_result.get("nodes", []) if n.get("source_file")}
2350
+ file_srcs.add(str_path)
2351
+ file_srcs.add(path.as_posix())
2352
+
2353
+ # Map each local symbol (class or function) to its node id, keyed by the
2354
+ # bare symbol name. Function labels end in "()"; the file node ends in
2355
+ # ".py"; rationale nodes never import (#563). First writer wins on a
2356
+ # name collision (inherently ambiguous within a file).
2357
+ name_to_nid: dict[str, str] = {}
2358
+ for n in file_result.get("nodes", []):
2359
+ if n.get("source_file") != str_path or n.get("file_type") == "rationale":
2360
+ continue
2361
+ label = n.get("label", "")
2362
+ if not label or label.endswith(".py"):
2363
+ continue
2364
+ sym_name = label[:-2] if label.endswith("()") else label
2365
+ if sym_name and sym_name not in name_to_nid:
2366
+ name_to_nid[sym_name] = n["id"]
2367
+ if not name_to_nid:
2368
+ continue
2369
+
2370
+ # Parse imports from this file (shared with the facts pass via the
2371
+ # mtime-keyed memo, so each .py is parsed once across both passes).
2372
+ parsed = _parse_python_tree(path)
2373
+ if parsed is None:
2374
+ continue
2375
+ source, root_node = parsed
2376
+
2377
+ # local_name -> target node id (local_name honours `import X as Y`, so a
2378
+ # reference to the alias in the body still attributes correctly).
2379
+ import_targets: dict[str, str] = {}
2380
+ # referenced name -> {source symbol nid: first reference line}
2381
+ ref_sources: dict[str, dict[str, int]] = {}
2382
+
2383
+ def _text(n) -> str:
2384
+ return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
2385
+
2386
+ def resolve_import(node) -> None:
2387
+ # Find the module name - handles both absolute and relative imports.
2388
+ # Relative: `from .models import X` → relative_import → dotted_name
2389
+ # Absolute: `from models import X` → module_name field
2390
+ # target_fq is the directory-qualified stem used as the key in
2391
+ # stem_to_entities. Relative imports are resolved exactly via the
2392
+ # importing file's directory; absolute imports fall back to the
2393
+ # bare-stem secondary index (first-writer-wins when names collide).
2394
+ target_fq: str | None = None
2395
+ for child in node.children:
2396
+ if child.type == "relative_import":
2397
+ prefix_text = ""
2398
+ dotted_text = ""
2399
+ for sub in child.children:
2400
+ if sub.type == "import_prefix":
2401
+ prefix_text = _text(sub)
2402
+ elif sub.type == "dotted_name":
2403
+ dotted_text = _text(sub)
2404
+ dots = prefix_text.count(".") if prefix_text else 1
2405
+ cur_dir = path.parent
2406
+ for _ in range(dots - 1):
2407
+ cur_dir = cur_dir.parent
2408
+ if dotted_text:
2409
+ candidate = cur_dir.joinpath(*dotted_text.split(".")).with_suffix(".py")
2410
+ else:
2411
+ candidate = cur_dir / "__init__.py"
2412
+ target_fq = _file_stem(candidate)
2413
+ break
2414
+ if child.type == "dotted_name" and target_fq is None:
2415
+ dotted_name = _text(child)
2416
+ dotted_as_path = "/".join(dotted_name.split("."))
2417
+ if dotted_as_path in stem_to_entities:
2418
+ target_fq = dotted_as_path
2419
+ else:
2420
+ suffix_matches = [
2421
+ fq for fq in stem_to_entities
2422
+ if fq.endswith(f"/{dotted_as_path}") or fq.endswith(f"\\{dotted_as_path}")
2423
+ ]
2424
+ if len(suffix_matches) == 1:
2425
+ target_fq = suffix_matches[0]
2426
+ else:
2427
+ bare = dotted_name.split(".")[-1]
2428
+ target_fq = bare_to_qualified.get(bare)
2429
+
2430
+ if not target_fq or target_fq not in stem_to_entities:
2431
+ return
2432
+
2433
+ # Imported names come AFTER the 'import' keyword token. For
2434
+ # `import X as Y` the target is found via X but the body uses Y.
2435
+ past_import_kw = False
2436
+ for child in node.children:
2437
+ if child.type == "import":
2438
+ past_import_kw = True
2439
+ continue
2440
+ if not past_import_kw:
2441
+ continue
2442
+ imported_name: str | None = None
2443
+ local_name: str | None = None
2444
+ if child.type == "dotted_name":
2445
+ imported_name = local_name = _text(child)
2446
+ elif child.type == "aliased_import":
2447
+ name_node = child.child_by_field_name("name")
2448
+ alias_node = child.child_by_field_name("alias")
2449
+ if name_node is not None:
2450
+ imported_name = _text(name_node)
2451
+ local_name = _text(alias_node) if alias_node is not None else imported_name
2452
+ if not imported_name or not local_name:
2453
+ continue
2454
+ tgt_nid = stem_to_entities[target_fq].get(imported_name)
2455
+ if tgt_nid:
2456
+ import_targets[local_name] = tgt_nid
2457
+
2458
+ def visit(node, current_nid: str | None) -> None:
2459
+ # Identifiers inside an import statement are the import itself, not a
2460
+ # real use — resolve the import here and don't descend into it.
2461
+ if node.type == "import_from_statement":
2462
+ resolve_import(node)
2463
+ return
2464
+ # Attribute references to the top-level symbol that contains them: a
2465
+ # class is a unit (a reference inside one of its methods counts for
2466
+ # the class, matching the documented DigestAuth->Response edge), and
2467
+ # a module-level function is its own source. Only set at module scope
2468
+ # (current_nid is None) so nested defs never override the container.
2469
+ if current_nid is None and node.type in ("class_definition", "function_definition"):
2470
+ name_node = node.child_by_field_name("name")
2471
+ if name_node is not None:
2472
+ mapped = name_to_nid.get(_text(name_node))
2473
+ if mapped is not None:
2474
+ current_nid = mapped
2475
+ if node.type == "identifier" and current_nid is not None:
2476
+ slot = ref_sources.setdefault(_text(node), {})
2477
+ slot.setdefault(current_nid, node.start_point[0] + 1)
2478
+ for child in node.children:
2479
+ visit(child, current_nid)
2480
+
2481
+ visit(root_node, None)
2482
+
2483
+ for name, tgt_nid in import_targets.items():
2484
+ for src_nid, line in ref_sources.get(name, {}).items():
2485
+ if src_nid == tgt_nid:
2486
+ continue
2487
+ new_edges.append({
2488
+ "source": src_nid,
2489
+ "target": tgt_nid,
2490
+ "relation": "uses",
2491
+ "confidence": "INFERRED",
2492
+ # 0.95 = "direct structural evidence (named cross-file
2493
+ # reference)" from the extraction-spec rubric, which is
2494
+ # exactly what this edge is: a name this file imports, then
2495
+ # references. Omitting the score entirely fell through to the
2496
+ # 0.5 default the same rubric forbids outright (#2813).
2497
+ "confidence_score": 0.95,
2498
+ "source_file": str_path,
2499
+ "source_location": f"L{line}",
2500
+ "weight": 0.8,
2501
+ })
2502
+
2503
+ # Repoint AST type-reference and inheritance edges from sourceless stubs
2504
+ # to the exact imported target definition (#3252).
2505
+ if all_edges and import_targets:
2506
+ for edge in all_edges:
2507
+ if edge.get("source_file") not in file_srcs:
2508
+ continue
2509
+ if edge.get("relation") not in TYPE_REPOINT_RELATIONS:
2510
+ continue
2511
+ tgt = edge.get("target")
2512
+ tgt_node = node_by_id.get(tgt)
2513
+ if not tgt_node or tgt_node.get("source_file"):
2514
+ continue
2515
+ stub_label = tgt_node.get("label", "")
2516
+ resolved_id = import_targets.get(stub_label)
2517
+ if resolved_id and resolved_id != tgt:
2518
+ edge["target"] = resolved_id
2519
+ repointed_from.add(tgt)
2520
+
2521
+ if all_nodes is not None and all_edges is not None and repointed_from:
2522
+ still_referenced = {e.get("source") for e in all_edges} | {e.get("target") for e in all_edges}
2523
+ all_nodes[:] = [
2524
+ node for node in all_nodes
2525
+ if node.get("id") not in repointed_from or node.get("id") in still_referenced
2526
+ ]
2527
+
2528
+ return new_edges
2529
+
2530
+ _DECLDEF_HEADER_SUFFIXES = frozenset({".h", ".hpp", ".hh", ".hxx"})
2531
+
2532
+ _DECLDEF_IMPL_SUFFIXES = frozenset({".m", ".mm", ".cpp", ".cc", ".cxx", ".c"})
2533
+
2534
+ def _decldef_class_stem(source_file: str) -> tuple[str, str] | None:
2535
+ """Return ``(dir, base_stem)`` for a header/impl source file, else None.
2536
+
2537
+ The base stem strips an ObjC category suffix (``Foo+Cat.m`` -> ``Foo``) so a
2538
+ category implementation pairs with its ``Foo.h`` declaration. Files with an
2539
+ extension that is neither a header nor an impl extension return None and are
2540
+ never considered for the merge.
2541
+ """
2542
+ if not source_file:
2543
+ return None
2544
+ p = Path(source_file)
2545
+ suffix = p.suffix.lower()
2546
+ if suffix not in _DECLDEF_HEADER_SUFFIXES and suffix not in _DECLDEF_IMPL_SUFFIXES:
2547
+ return None
2548
+ stem = p.stem.split("+", 1)[0] # ObjC category: Foo+Cat -> Foo
2549
+ if not stem:
2550
+ return None
2551
+ return (str(p.parent), stem)
2552
+
2553
+ def _source_stem(node: dict) -> str:
2554
+ """Filename stem of a node's ``source_file`` (``""`` when it has none)."""
2555
+ return Path(str(node.get("source_file", ""))).stem
2556
+
2557
+
2558
+ def _merge_decl_def_classes(
2559
+ all_nodes: list[dict],
2560
+ all_edges: list[dict],
2561
+ ) -> None:
2562
+ """Merge a class (and its methods) declared in a header with its definition in
2563
+ a sibling impl file into ONE node, for C/C++/ObjC (#1547, #1556).
2564
+
2565
+ A class declared in ``Foo.h`` (``class Foo`` / ``@interface Foo``) and defined
2566
+ in the sibling ``Foo.cpp`` / ``Foo.m`` (``@implementation Foo``, plus — after
2567
+ the C++ qualified-name fix — out-of-class method definitions ``Foo::bar``)
2568
+ produces TWO nodes per symbol. Both are keyed off the file *stem*, and
2569
+ ``_file_stem`` drops the extension, so the header symbol and its impl
2570
+ counterpart get the IDENTICAL id and differ only in ``source_file`` and label
2571
+ (the C++ def label is ``Foo::bar()`` vs the decl's ``bar``; the ObjC impl class
2572
+ label equals the interface's). Left alone, ``_disambiguate_colliding_node_ids``
2573
+ SPLITS those id-collisions apart by path, fragmenting one class into two def
2574
+ nodes — which then trips every resolver's single-definition god-node guard
2575
+ (``len(defs) != 1`` -> bail), cascading into lost .h<->.m/.cpp linkage and dead
2576
+ cross-file calls.
2577
+
2578
+ This pass runs BEFORE disambiguation and collapses each such id-collision to
2579
+ ONE node — the header (declaration) variant, consistent with the #1475
2580
+ header_remaps direction — so disambiguation sees a single source_file per id
2581
+ and leaves it alone, and the downstream resolvers see ONE definition. Because
2582
+ the colliding nodes already share an id, no edge re-pointing is needed: every
2583
+ edge that referenced the impl symbol already points at the surviving id. We
2584
+ only drop the redundant duplicate node and prefer the header's label — but
2585
+ the dropped impl node's provenance is preserved on the survivor as
2586
+ ``definition_file`` / ``definition_location``, so the definition site is
2587
+ still reachable from the merged node.
2588
+
2589
+ GOD-NODE GUARDS (false merges are the main risk):
2590
+
2591
+ * Collapse fires ONLY when every node in an id-collision group comes from a
2592
+ SIBLING header/impl set — same directory, same base stem (ObjC categories
2593
+ ``Foo+Cat.m`` compare by the stem before ``+``), header extension paired
2594
+ with impl extension — AND the group contains exactly ONE header file.
2595
+ * Two unrelated ``class Logger`` in DIFFERENT directories never collide on id
2596
+ (the id embeds the full file stem / directory path), so they are never
2597
+ grouped and never merge. Two same-named classes in the SAME directory but
2598
+ different base stems likewise key to different ids. Any id-collision that
2599
+ is NOT a clean single-header sibling set is left untouched for
2600
+ disambiguation to split (the conservative default).
2601
+
2602
+ The class and its method/field members fold in together: members are keyed
2603
+ ``_make_id(class_id, name)`` (ObjC) or, for an out-of-class C++ definition,
2604
+ ``_make_id(stem, "Foo::bar")`` which normalizes to the same id as the in-class
2605
+ member ``_make_id(class_id, "bar")``. So every decl/def member pair is itself an
2606
+ id-collision across the same sibling file set and collapses by the same rule.
2607
+ """
2608
+ # Group every code node by id, recording the distinct source files involved.
2609
+ by_id: dict[str, list[dict]] = {}
2610
+ for n in all_nodes:
2611
+ if n.get("file_type") != "code":
2612
+ continue
2613
+ nid = n.get("id")
2614
+ sf = str(n.get("source_file", ""))
2615
+ if not isinstance(nid, str) or not nid or not sf:
2616
+ continue
2617
+ by_id.setdefault(nid, []).append(n)
2618
+
2619
+ # Identify, per surviving id, which node to keep (header preferred). We can't
2620
+ # mutate all_nodes mid-scan, so collect a set of node object ids to drop.
2621
+ drop_objs: set[int] = set()
2622
+ for nid, group in by_id.items():
2623
+ if len(group) < 2:
2624
+ continue
2625
+ # The distinct source files of this collision must form a clean sibling
2626
+ # header/impl set with exactly one header. Each file must parse as a
2627
+ # header/impl file (others -> bail), share one directory + base stem.
2628
+ sibling_keys: set[tuple[str, str]] = set()
2629
+ headers: list[dict] = []
2630
+ ok = True
2631
+ for node in group:
2632
+ sf = str(node.get("source_file", ""))
2633
+ ds = _decldef_class_stem(sf)
2634
+ if ds is None:
2635
+ ok = False
2636
+ break
2637
+ sibling_keys.add(ds)
2638
+ if Path(sf).suffix.lower() in _DECLDEF_HEADER_SUFFIXES:
2639
+ headers.append(node)
2640
+ if not ok:
2641
+ continue
2642
+ # All from one (dir, base_stem) sibling family. Pick the declaring header.
2643
+ # Usually there is exactly one. An ObjC class whose members are split across
2644
+ # categories has several (`Foo.h`, `Foo+Cat.h`) — fold those too, keeping the
2645
+ # BASE header (the stem with no `+`), or the lowest-sorting category header
2646
+ # when the base class lives outside the corpus (`NSString+Trim.h`). Two
2647
+ # NON-category headers still bail to disambiguation, as before, so an
2648
+ # unrelated `Foo.h` / `Foo.hpp` pair is untouched.
2649
+ if len(sibling_keys) != 1 or not headers:
2650
+ continue
2651
+ if len(headers) == 1:
2652
+ keeper = headers[0]
2653
+ else:
2654
+ base_headers = [h for h in headers if "+" not in _source_stem(h)]
2655
+ if len(base_headers) > 1:
2656
+ continue
2657
+ keeper = base_headers[0] if base_headers else min(headers, key=_source_stem)
2658
+ # The keeper is the DECLARATION, so without this the graph reports the
2659
+ # header as the symbol's only location and the definition site — the file
2660
+ # and line a reader actually wants — is discarded with the dropped node.
2661
+ # Recorded as separate attributes: the survivor's id, label and
2662
+ # source_file are untouched, so no existing graph is re-keyed, no edge
2663
+ # moves, and the single-definition guarantee downstream resolvers rely on
2664
+ # is unchanged. Chosen deterministically (lowest source_file, then
2665
+ # location) so an ObjC class whose members are split across several
2666
+ # category impls does not depend on node arrival order.
2667
+ impls = sorted(
2668
+ (n for n in group
2669
+ if n is not keeper
2670
+ and Path(str(n.get("source_file", ""))).suffix.lower()
2671
+ in _DECLDEF_IMPL_SUFFIXES),
2672
+ key=lambda n: (str(n.get("source_file", "")),
2673
+ str(n.get("source_location", ""))),
2674
+ )
2675
+ if impls:
2676
+ definition = impls[0]
2677
+ if definition.get("source_file"):
2678
+ keeper["definition_file"] = definition["source_file"]
2679
+ if definition.get("source_location"):
2680
+ keeper["definition_location"] = definition["source_location"]
2681
+
2682
+ for node in group:
2683
+ if node is not keeper:
2684
+ drop_objs.add(id(node))
2685
+
2686
+ if not drop_objs:
2687
+ return
2688
+
2689
+ # Drop the redundant duplicate nodes. The surviving (header) node keeps its
2690
+ # own label/source_file (and now carries the impl's definition_file/
2691
+ # definition_location); edges are unchanged because the id is identical. Then
2692
+ # de-dup any now-identical edges (e.g. the impl file's `contains`/`method`
2693
+ # edge that duplicates the header's after the collapse).
2694
+ all_nodes[:] = [n for n in all_nodes if id(n) not in drop_objs]
2695
+
2696
+ seen_keys: set[tuple] = set()
2697
+ rewritten: list[dict] = []
2698
+ for e in all_edges:
2699
+ src = e.get("source")
2700
+ tgt = e.get("target")
2701
+ if src == tgt:
2702
+ continue
2703
+ k = (src, tgt, e.get("relation"), e.get("context"))
2704
+ if k in seen_keys:
2705
+ continue
2706
+ seen_keys.add(k)
2707
+ rewritten.append(e)
2708
+ all_edges[:] = rewritten
2709
+
2710
+ def _resolve_cross_file_java_imports(
2711
+ per_file: list[dict],
2712
+ paths: list[Path],
2713
+ ) -> list[dict]:
2714
+ """Two-pass Java import resolution.
2715
+
2716
+ Pass 1: build a global index {ClassName: [(node_id, package), ...]} across
2717
+ all Java nodes (packages come from a re-parse; node metadata doesn't carry
2718
+ them).
2719
+ Pass 2: re-parse each Java file; for every `import a.b.C;`, resolve C against
2720
+ the index, skipping candidates whose defining file declares a different
2721
+ package — an external `org.springframework.stereotype.Component` must not
2722
+ link to a local `com.example.model.Component` (#2504). Wildcard and stdlib
2723
+ imports produce no edge.
2724
+ """
2725
+ try:
2726
+ import tree_sitter_java as tsjava
2727
+ from tree_sitter import Language, Parser
2728
+ except ImportError:
2729
+ return []
2730
+
2731
+ language = Language(tsjava.language())
2732
+ parser = Parser(language)
2733
+
2734
+ # Pre-pass: declared package per source_file string (and parsed trees for
2735
+ # pass 2, so each file is only parsed once).
2736
+ parsed: dict[str, tuple[bytes, object]] = {}
2737
+ pkg_by_src: dict[str, str] = {}
2738
+ for path, file_result in zip(paths, per_file):
2739
+ try:
2740
+ source = path.read_bytes()
2741
+ tree = parser.parse(source)
2742
+ except Exception:
2743
+ continue
2744
+ parsed[str(path)] = (source, tree)
2745
+ pkg = ""
2746
+ for child in tree.root_node.children:
2747
+ if child.type == "package_declaration":
2748
+ pkg = _read_text(child, source).strip()[len("package"):].strip().rstrip(";").strip()
2749
+ break
2750
+ pkg_by_src[str(path)] = pkg
2751
+ for node in file_result.get("nodes", []):
2752
+ src = node.get("source_file")
2753
+ if src:
2754
+ pkg_by_src.setdefault(src, pkg)
2755
+
2756
+ def _pkg_matches(imp_pkg: str, tgt_pkg: str) -> bool:
2757
+ if imp_pkg == tgt_pkg:
2758
+ return True
2759
+ # `import p.Outer.Inner` against a nested type defined in package p:
2760
+ # the leftover segments must all be type-like (uppercase-first), which
2761
+ # conventional lowercase external packages can never satisfy.
2762
+ if tgt_pkg:
2763
+ if not imp_pkg.startswith(tgt_pkg + "."):
2764
+ return False
2765
+ rest = imp_pkg[len(tgt_pkg) + 1:]
2766
+ else:
2767
+ rest = imp_pkg
2768
+ return bool(rest) and all(seg[:1].isupper() for seg in rest.split("."))
2769
+
2770
+ # Pass 1: class-name → (node_id, package) index (only internal,
2771
+ # uppercase-starting names)
2772
+ name_to_ids: dict[str, list[tuple[str, str]]] = {}
2773
+ for file_result in per_file:
2774
+ for node in file_result.get("nodes", []):
2775
+ label = node.get("label", "")
2776
+ nid = node.get("id", "")
2777
+ src = node.get("source_file", "")
2778
+ if not label or not nid or not src:
2779
+ continue
2780
+ if label.endswith(")") or label.endswith(".java"):
2781
+ continue
2782
+ if not label[0].isalpha() or not label[0].isupper():
2783
+ continue
2784
+ name_to_ids.setdefault(label, []).append((nid, pkg_by_src.get(src, "")))
2785
+
2786
+ # Pass 2: resolve imports to real node IDs
2787
+ new_edges: list[dict] = []
2788
+ seen_pairs: set[tuple[str, str]] = set()
2789
+ for path in paths:
2790
+ file_nid = _make_id(str(path))
2791
+ entry = parsed.get(str(path))
2792
+ if entry is None:
2793
+ continue
2794
+ source, tree = entry
2795
+
2796
+ def walk(n) -> None:
2797
+ if n.type == "import_declaration":
2798
+ raw = _read_text(n, source).strip()
2799
+ body = raw[len("import"):].strip().rstrip(";").strip()
2800
+ if body.startswith("static "):
2801
+ body = body[len("static "):].strip()
2802
+ if body.endswith(".*"):
2803
+ return
2804
+ parts = body.split(".")
2805
+ if not parts:
2806
+ return
2807
+ last = parts[-1]
2808
+ imp_pkg = ".".join(parts[:-1])
2809
+ if last and last[0].islower() and len(parts) >= 2:
2810
+ last = parts[-2]
2811
+ imp_pkg = ".".join(parts[:-2])
2812
+ at_line = n.start_point[0] + 1
2813
+ for tgt_nid, tgt_pkg in name_to_ids.get(last, []):
2814
+ if tgt_nid == file_nid:
2815
+ continue
2816
+ if not _pkg_matches(imp_pkg, tgt_pkg):
2817
+ continue
2818
+ key = (file_nid, tgt_nid)
2819
+ if key in seen_pairs:
2820
+ continue
2821
+ seen_pairs.add(key)
2822
+ new_edges.append({
2823
+ "source": file_nid,
2824
+ "target": tgt_nid,
2825
+ "relation": "imports",
2826
+ "confidence": "EXTRACTED",
2827
+ "confidence_score": 1.0,
2828
+ "source_file": str(path),
2829
+ "source_location": f"L{at_line}",
2830
+ "weight": 1.0,
2831
+ })
2832
+ for child in n.children:
2833
+ walk(child)
2834
+
2835
+ walk(tree.root_node)
2836
+
2837
+ return new_edges
2838
+
2839
+
2840
+ def _go_import_path_for_file(
2841
+ source_file: str | Path,
2842
+ root: Path,
2843
+ module_cache: dict[Path, str | None] | None = None,
2844
+ ) -> str | None:
2845
+ """Return the canonical Go import path for a source file inside a module."""
2846
+ cache = module_cache if module_cache is not None else {}
2847
+ path = Path(source_file)
2848
+ if not path.is_absolute():
2849
+ path = root / path
2850
+ try:
2851
+ directory = _resolve_cached(path).parent
2852
+ except OSError:
2853
+ directory = path.absolute().parent
2854
+
2855
+ module_dir: Path | None = None
2856
+ module_path: str | None = None
2857
+ for candidate in (directory, *directory.parents):
2858
+ if candidate in cache:
2859
+ cached = cache[candidate]
2860
+ if cached:
2861
+ module_dir, module_path = candidate, cached
2862
+ break
2863
+ go_mod = candidate / "go.mod"
2864
+ if not go_mod.is_file():
2865
+ continue
2866
+ try:
2867
+ match = re.search(
2868
+ r"(?m)^\s*module\s+([^\s]+)",
2869
+ go_mod.read_text(encoding="utf-8"),
2870
+ )
2871
+ except (OSError, UnicodeError):
2872
+ match = None
2873
+ module_dir = candidate
2874
+ module_path = match.group(1) if match else None
2875
+ cache[candidate] = module_path
2876
+ break
2877
+
2878
+ if not module_dir or not module_path:
2879
+ return None
2880
+ try:
2881
+ relative_dir = directory.relative_to(module_dir)
2882
+ except ValueError:
2883
+ return None
2884
+ suffix = relative_dir.as_posix()
2885
+ return module_path if suffix == "." else f"{module_path}/{suffix}"
2886
+
2887
+
2888
+ def _resolve_go_type_references(
2889
+ per_file: list[dict],
2890
+ paths: list[Path],
2891
+ all_nodes: list[dict],
2892
+ all_edges: list[dict],
2893
+ root: Path,
2894
+ resolution_context_nodes: list[dict] | None = None,
2895
+ resolution_context_edges: list[dict] | None = None,
2896
+ ) -> None:
2897
+ """Resolve qualified Go types through aliases and exact module paths."""
2898
+ imports_by_file: dict[str, dict[str, str]] = {}
2899
+ actual_path_by_file: dict[str, Path] = {}
2900
+ for path, result in zip(paths, per_file):
2901
+ imports = result.get("go_imports") or {}
2902
+ for node in result.get("nodes", []):
2903
+ source_file = node.get("source_file")
2904
+ if source_file:
2905
+ imports_by_file[str(source_file)] = imports
2906
+ actual_path_by_file[str(source_file)] = path
2907
+
2908
+ if not imports_by_file:
2909
+ return
2910
+
2911
+ definition_nodes = all_nodes + (resolution_context_nodes or [])
2912
+ definition_edges = all_edges + (resolution_context_edges or [])
2913
+ contained = {edge.get("target") for edge in definition_edges
2914
+ if edge.get("relation") == "contains"}
2915
+ module_cache: dict[Path, str | None] = {}
2916
+ fqn_to_ids: dict[str, list[str]] = {}
2917
+ for node in definition_nodes:
2918
+ source_file = str(node.get("source_file") or "")
2919
+ label = str(node.get("label") or "")
2920
+ nid = node.get("id")
2921
+ if (not source_file or not label or not nid or nid not in contained
2922
+ or not _is_type_like_definition(node)):
2923
+ continue
2924
+ actual_path = actual_path_by_file.get(source_file, Path(source_file))
2925
+ package_path = _go_import_path_for_file(actual_path, root, module_cache)
2926
+ if package_path:
2927
+ fqn_to_ids.setdefault(f"{package_path}.{label}", []).append(nid)
2928
+
2929
+ qualified_stubs = {
2930
+ node["id"]: str(node.get("label") or "")
2931
+ for node in all_nodes
2932
+ if node.get("id") and not node.get("source_file")
2933
+ and "." in str(node.get("label") or "")
2934
+ }
2935
+ if not qualified_stubs:
2936
+ return
2937
+
2938
+ node_ids = {node.get("id") for node in all_nodes if node.get("id")}
2939
+ external_stub_ids: dict[str, str] = {}
2940
+ new_nodes: list[dict] = []
2941
+
2942
+ def external_stub(fqn: str) -> str:
2943
+ existing = external_stub_ids.get(fqn)
2944
+ if existing:
2945
+ return existing
2946
+ nid = _make_id("go", "type", fqn)
2947
+ if nid not in node_ids:
2948
+ new_nodes.append({
2949
+ "id": nid,
2950
+ "label": fqn,
2951
+ "file_type": "code",
2952
+ "source_file": "",
2953
+ "source_location": "",
2954
+ })
2955
+ node_ids.add(nid)
2956
+ external_stub_ids[fqn] = nid
2957
+ return nid
2958
+
2959
+ repointed_from: set[str] = set()
2960
+ for edge in all_edges:
2961
+ if edge.get("relation") not in {"references", "embeds"}:
2962
+ continue
2963
+ target = edge.get("target")
2964
+ qualified = qualified_stubs.get(target)
2965
+ if not qualified:
2966
+ continue
2967
+ alias, _, type_name = qualified.rpartition(".")
2968
+ import_path = imports_by_file.get(
2969
+ str(edge.get("source_file") or ""), {}
2970
+ ).get(alias)
2971
+ if not import_path or not type_name:
2972
+ continue
2973
+ fqn = f"{import_path}.{type_name}"
2974
+ candidates = fqn_to_ids.get(fqn, [])
2975
+ edge["target"] = candidates[0] if len(candidates) == 1 else external_stub(fqn)
2976
+ repointed_from.add(str(target))
2977
+
2978
+ if new_nodes:
2979
+ all_nodes.extend(new_nodes)
2980
+
2981
+ if not repointed_from:
2982
+ return
2983
+ referenced = {endpoint for edge in all_edges
2984
+ for endpoint in (edge.get("source"), edge.get("target"))}
2985
+ all_nodes[:] = [
2986
+ node for node in all_nodes
2987
+ if node.get("id") not in repointed_from or node.get("id") in referenced
2988
+ ]
2989
+
2990
+
2991
+ def _resolve_java_type_references(
2992
+ per_file: list[dict],
2993
+ paths: list[Path],
2994
+ all_nodes: list[dict],
2995
+ all_edges: list[dict],
2996
+ ) -> None:
2997
+ """Re-point dangling Java ``implements``/``inherits`` edges to the real
2998
+ definition, using the referencing file's ``import`` statements (+ package)
2999
+ for exact disambiguation.
3000
+
3001
+ Cross-file type references resolve by bare name and fall back to a no-source
3002
+ "shadow" stub. ``_rewire_unique_stub_nodes`` repairs that only when the name
3003
+ is globally unique; when two packages define a same-named type it bails, so
3004
+ the ``implements`` edge stays stuck on the shadow node and the real interface
3005
+ is wrongly isolated (#1318). An ``import com.a.handler.AIResponseHandler``
3006
+ names the exact package, so it disambiguates where bare-name matching cannot.
3007
+
3008
+ Mutates ``all_nodes``/``all_edges`` in place. Runs after id-disambiguation so
3009
+ target ids are final, but BEFORE ``_rewire_unique_stub_nodes`` (#2504): the
3010
+ rewire itself manufactures a false merge when the bare stub for an EXTERNAL
3011
+ import (``org.springframework.stereotype.Component``) collapses onto the
3012
+ only internal class with that simple name. References proven external by an
3013
+ explicit import are re-pointed to an FQN-labeled sourceless stub the
3014
+ bare-label rewire cannot collapse; references with no import/package facts
3015
+ are left untouched so the legacy unique-label rewire keeps handling plain
3016
+ same-package/default-package corpora (mirrors the PHP #1923 fix).
3017
+ """
3018
+ try:
3019
+ import tree_sitter_java as tsjava
3020
+ from tree_sitter import Language, Parser
3021
+ except ImportError:
3022
+ return
3023
+
3024
+ language = Language(tsjava.language())
3025
+ parser = Parser(language)
3026
+
3027
+ # package + simple-name->FQN imports, keyed by the source_file string the
3028
+ # file's own nodes use (so it matches edge/node source_file exactly).
3029
+ pkg_by_file: dict[str, str] = {}
3030
+ imports_by_file: dict[str, dict[str, str]] = {}
3031
+ for path, result in zip(paths, per_file):
3032
+ srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
3033
+ if not srcs:
3034
+ continue
3035
+ try:
3036
+ source = path.read_bytes()
3037
+ tree = parser.parse(source)
3038
+ except Exception:
3039
+ continue
3040
+ pkg = ""
3041
+ imps: dict[str, str] = {}
3042
+
3043
+ def walk(n) -> None:
3044
+ nonlocal pkg
3045
+ if n.type == "package_declaration":
3046
+ pkg = _read_text(n, source).strip()[len("package"):].strip().rstrip(";").strip()
3047
+ elif n.type == "import_declaration":
3048
+ body = _read_text(n, source).strip()[len("import"):].strip().rstrip(";").strip()
3049
+ if body.startswith("static "):
3050
+ body = body[len("static "):].strip()
3051
+ if body.endswith(".*") or "." not in body:
3052
+ return
3053
+ simple = body.split(".")[-1]
3054
+ if simple and simple[0].isupper():
3055
+ imps[simple] = body
3056
+ for child in n.children:
3057
+ walk(child)
3058
+
3059
+ walk(tree.root_node)
3060
+ for s in srcs:
3061
+ pkg_by_file[s] = pkg
3062
+ imports_by_file[s] = imps
3063
+
3064
+ # FQN (package.Class or package.Outer.Inner) -> definition node id, for
3065
+ # type-like defs with a source. Nested declarations need their containing
3066
+ # type path because qualified annotation references preserve it.
3067
+ fqn_to_id: dict[str, str] = {}
3068
+ node_by_id = {
3069
+ node.get("id"): node for node in all_nodes if node.get("id")
3070
+ }
3071
+ type_parent_by_id: dict[str, str] = {}
3072
+ for edge in all_edges:
3073
+ if edge.get("relation") != "contains":
3074
+ continue
3075
+ child = node_by_id.get(edge.get("target"))
3076
+ parent = node_by_id.get(edge.get("source"))
3077
+ if not child or not parent:
3078
+ continue
3079
+ child_src = child.get("source_file", "")
3080
+ parent_label = parent.get("label", "")
3081
+ if (
3082
+ child_src
3083
+ and parent.get("source_file") == child_src
3084
+ and parent_label[:1].isupper()
3085
+ and not parent_label.endswith(".java")
3086
+ ):
3087
+ type_parent_by_id[child["id"]] = parent["id"]
3088
+
3089
+ for node in all_nodes:
3090
+ label = node.get("label", "")
3091
+ src = node.get("source_file", "")
3092
+ nid = node.get("id", "")
3093
+ if not (label and src and nid) or src not in pkg_by_file:
3094
+ continue
3095
+ if not label[:1].isupper() or label.endswith(")") or label.endswith(".java"):
3096
+ continue
3097
+ pkg = pkg_by_file[src]
3098
+ fqn_to_id.setdefault(f"{pkg}.{label}" if pkg else label, nid)
3099
+ type_path = [label]
3100
+ seen = {nid}
3101
+ parent_id = type_parent_by_id.get(nid)
3102
+ while parent_id and parent_id not in seen:
3103
+ seen.add(parent_id)
3104
+ parent = node_by_id[parent_id]
3105
+ type_path.append(parent["label"])
3106
+ parent_id = type_parent_by_id.get(parent_id)
3107
+ if len(type_path) > 1:
3108
+ nested_name = ".".join(reversed(type_path))
3109
+ fqn_to_id.setdefault(
3110
+ f"{pkg}.{nested_name}" if pkg else nested_name,
3111
+ nid,
3112
+ )
3113
+
3114
+ # Shadow stubs: no source_file, type-like label. Dotted labels are included
3115
+ # for qualified inline annotations (`@com.example.anno.Loggable`), which the
3116
+ # engine mints with their full dotted name so a same-named local class can't
3117
+ # absorb them (#2504).
3118
+ stub_label: dict[str, str] = {
3119
+ node["id"]: node.get("label", "")
3120
+ for node in all_nodes
3121
+ if node.get("id") and not node.get("source_file")
3122
+ and (node.get("label", "")[:1].isupper() or "." in node.get("label", ""))
3123
+ }
3124
+ if not stub_label:
3125
+ return
3126
+
3127
+ # `imports` is included so the file-level import edge that also lands on the
3128
+ # shadow stub gets re-pointed too, leaving the stub unreferenced (and dropped).
3129
+ # External/stdlib imports never resolve (no internal def / same-package match),
3130
+ # so their edges correctly stay on their stub. `references` (field/parameter/
3131
+ # return-type uses) is included so a cross-module reference to a same-named
3132
+ # class doesn't dangle on a sourceless phantom node when two packages define
3133
+ # the same simple name — the node itself survives with a path-scoped id, but
3134
+ # the reference must point at the RIGHT one (#1744). Mirrors the C# resolver,
3135
+ # whose REPOINT set already covers `references`.
3136
+ REPOINT_RELATIONS = {"implements", "inherits", "extends", "imports", "references"}
3137
+
3138
+ node_ids = {n.get("id") for n in all_nodes if n.get("id")}
3139
+ external_stub_ids: dict[str, str] = {}
3140
+ new_nodes: list[dict] = []
3141
+
3142
+ def _external_stub(fqn: str) -> str:
3143
+ nid = external_stub_ids.get(fqn)
3144
+ if nid:
3145
+ return nid
3146
+ nid = _make_id(fqn)
3147
+ if nid not in node_ids:
3148
+ new_nodes.append({
3149
+ "id": nid,
3150
+ "label": fqn,
3151
+ "file_type": "code",
3152
+ "source_file": "",
3153
+ "source_location": "",
3154
+ })
3155
+ node_ids.add(nid)
3156
+ external_stub_ids[fqn] = nid
3157
+ return nid
3158
+
3159
+ repointed_from: set[str] = set()
3160
+ for edge in all_edges:
3161
+ if edge.get("relation") not in REPOINT_RELATIONS:
3162
+ continue
3163
+ tgt = edge.get("target")
3164
+ label = stub_label.get(tgt)
3165
+ if not label:
3166
+ continue
3167
+ ref_file = edge.get("source_file", "")
3168
+ if "." in label:
3169
+ # FQN-labeled stub (qualified inline annotation): resolve it against
3170
+ # the internal definitions; an external FQN stays parked as-is.
3171
+ resolved = fqn_to_id.get(label)
3172
+ if resolved and resolved != tgt:
3173
+ edge["target"] = resolved
3174
+ repointed_from.add(tgt)
3175
+ continue
3176
+ fqn = imports_by_file.get(ref_file, {}).get(label)
3177
+ if fqn:
3178
+ resolved = fqn_to_id.get(fqn)
3179
+ if resolved is None:
3180
+ # `import p.Outer.Inner`: strip trailing type-like segments to
3181
+ # find the defining package of an internal nested type.
3182
+ head = fqn.split(".")[:-1]
3183
+ while resolved is None and head and head[-1][:1].isupper():
3184
+ head.pop()
3185
+ resolved = fqn_to_id.get(".".join(head + [label]))
3186
+ if resolved is None:
3187
+ # Explicit import with no internal definition: proven EXTERNAL.
3188
+ # Park the edge on an FQN-labeled stub the bare-name rewire
3189
+ # cannot collapse onto a same-named local class (#2504 — this
3190
+ # is the Java counterpart of the PHP #1923 fix).
3191
+ edge["target"] = _external_stub(fqn)
3192
+ repointed_from.add(tgt)
3193
+ continue
3194
+ else: # same-package reference (no explicit import)
3195
+ pkg = pkg_by_file.get(ref_file, "")
3196
+ resolved = fqn_to_id.get(f"{pkg}.{label}" if pkg else label)
3197
+ if resolved and resolved != tgt:
3198
+ edge["target"] = resolved
3199
+ repointed_from.add(tgt)
3200
+
3201
+ if new_nodes:
3202
+ all_nodes.extend(new_nodes)
3203
+
3204
+ # Bare imported and inline-qualified annotation references can start on
3205
+ # different stubs, then converge on one source-backed node above. Collapse
3206
+ # only indistinguishable Java attribute-reference facts after that rewire.
3207
+ seen_attribute_refs: set[tuple] = set()
3208
+ deduped_edges: list[dict] = []
3209
+ for edge in all_edges:
3210
+ if (
3211
+ edge.get("relation") == "references"
3212
+ and edge.get("context") == "attribute"
3213
+ and edge.get("source_file", "") in pkg_by_file
3214
+ ):
3215
+ key = (
3216
+ edge.get("source"),
3217
+ edge.get("target"),
3218
+ edge.get("relation"),
3219
+ edge.get("context"),
3220
+ edge.get("source_file"),
3221
+ edge.get("source_location"),
3222
+ )
3223
+ if key in seen_attribute_refs:
3224
+ continue
3225
+ seen_attribute_refs.add(key)
3226
+ deduped_edges.append(edge)
3227
+ all_edges[:] = deduped_edges
3228
+
3229
+ if not repointed_from:
3230
+ return
3231
+
3232
+ # Drop shadow stubs that no edge references anymore.
3233
+ still_referenced: set[str] = set()
3234
+ for edge in all_edges:
3235
+ still_referenced.add(edge.get("source"))
3236
+ still_referenced.add(edge.get("target"))
3237
+ all_nodes[:] = [
3238
+ node for node in all_nodes
3239
+ if node.get("id") not in repointed_from or node.get("id") in still_referenced
3240
+ ]
3241
+
3242
+
3243
+ _PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in")
3244
+ _PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"})
3245
+
3246
+
3247
+ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str:
3248
+ """Resolve a raw (possibly qualified) PHP class reference to an FQN.
3249
+
3250
+ PHP name-resolution for class names:
3251
+ \\A\\B -> absolute: A\\B
3252
+ A\\B -> first segment through the `use` map (group-prefix semantics),
3253
+ else relative to the current namespace
3254
+ B -> `use` map, else current namespace (class names do NOT fall
3255
+ back to the global namespace)
3256
+ """
3257
+ raw = raw.strip()
3258
+ if raw.startswith("\\"):
3259
+ return raw[1:]
3260
+ if "\\" in raw:
3261
+ first, rest = raw.split("\\", 1)
3262
+ mapped = uses.get(first.lower())
3263
+ if mapped:
3264
+ return f"{mapped}\\{rest}"
3265
+ return f"{ns}\\{raw}" if ns else raw
3266
+ mapped = uses.get(raw.lower())
3267
+ if mapped:
3268
+ return mapped
3269
+ return f"{ns}\\{raw}" if ns else raw
3270
+
3271
+
3272
+ def _resolve_php_type_references(
3273
+ per_file: list[dict],
3274
+ paths: list[Path],
3275
+ all_nodes: list[dict],
3276
+ all_edges: list[dict],
3277
+ ) -> None:
3278
+ """Disambiguate PHP inherits/implements/mixes_in/imports/references targets
3279
+ using each file's ``namespace`` declaration and ``use`` imports (#1923).
3280
+
3281
+ Mirrors ``_resolve_java_type_references`` (a re-parse pass), but MUST run
3282
+ BEFORE ``_rewire_unique_stub_nodes``: the false edge is manufactured by the
3283
+ rewire itself — a bare ``Page`` stub collapses onto the only internal class
3284
+ labeled ``Page`` even though the referencing file ``use``d a different
3285
+ namespace (``Filament\\Pages\\Page`` vs ``App\\Models\\Page``). References
3286
+ proven external by a ``use`` FQN or a qualified name are re-pointed to an
3287
+ FQN-labeled sourceless stub, which the bare-label rewire cannot collapse.
3288
+ References with no namespace facts are left untouched so the unique-label
3289
+ rewire keeps handling plain (non-namespaced) PHP as before.
3290
+ """
3291
+ try:
3292
+ import tree_sitter_php as tsphp
3293
+ from tree_sitter import Language, Parser
3294
+ except ImportError:
3295
+ return
3296
+
3297
+ lang_fn = getattr(tsphp, "language_php", None) or getattr(tsphp, "language", None)
3298
+ if lang_fn is None:
3299
+ return
3300
+ language = Language(lang_fn())
3301
+ parser = Parser(language)
3302
+
3303
+ ns_by_file: dict[str, str] = {}
3304
+ uses_by_file: dict[str, dict[str, str]] = {} # lower alias -> FQN
3305
+ raw_by_file: dict[str, dict[tuple[str, str], str | None]] = {} # (relation, lower bare) -> raw | None(ambiguous)
3306
+
3307
+ for path, result in zip(paths, per_file):
3308
+ srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
3309
+ if not srcs:
3310
+ continue
3311
+ try:
3312
+ source = path.read_bytes()
3313
+ tree = parser.parse(source)
3314
+ except Exception:
3315
+ continue
3316
+
3317
+ namespaces: list[str] = []
3318
+ uses: dict[str, str] = {}
3319
+ raws: dict[tuple[str, str], str | None] = {}
3320
+
3321
+ def _record_raw(relation: str, raw: str) -> None:
3322
+ bare = raw.rsplit("\\", 1)[-1].strip().lower()
3323
+ if not bare:
3324
+ return
3325
+ key = (relation, bare)
3326
+ if key in raws and raws[key] != raw:
3327
+ raws[key] = None # e.g. `implements A\I, B\I` — never guess
3328
+ else:
3329
+ raws.setdefault(key, raw)
3330
+
3331
+ def _record_use_clause(clause, prefix: str) -> None:
3332
+ target = None
3333
+ alias = None
3334
+ saw_as = False
3335
+ for c in clause.children:
3336
+ if c.type in ("function", "const"):
3337
+ return # not a class import
3338
+ if c.type == "as":
3339
+ saw_as = True
3340
+ elif c.type in ("qualified_name", "name"):
3341
+ if saw_as:
3342
+ alias = _read_text(c, source)
3343
+ elif target is None:
3344
+ target = _read_text(c, source)
3345
+ if not target:
3346
+ return
3347
+ fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\")
3348
+ key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower()
3349
+ if key:
3350
+ uses.setdefault(key, fqn)
3351
+
3352
+ def walk(n) -> None:
3353
+ t = n.type
3354
+ if t == "namespace_definition":
3355
+ for c in n.children:
3356
+ if c.type == "namespace_name":
3357
+ namespaces.append(_read_text(c, source))
3358
+ break
3359
+ elif t == "namespace_use_declaration":
3360
+ prefix = ""
3361
+ group = None
3362
+ for c in n.children:
3363
+ if c.type == "namespace_name":
3364
+ prefix = _read_text(c, source) # group-use prefix
3365
+ elif c.type == "namespace_use_group":
3366
+ group = c
3367
+ elif c.type == "namespace_use_clause":
3368
+ _record_use_clause(c, "")
3369
+ if group is not None:
3370
+ for c in group.children:
3371
+ if c.type == "namespace_use_clause":
3372
+ _record_use_clause(c, prefix)
3373
+ return
3374
+ elif t == "class_declaration":
3375
+ for child in n.children:
3376
+ if child.type == "base_clause":
3377
+ for sub in child.children:
3378
+ if sub.type in ("name", "qualified_name"):
3379
+ _record_raw("inherits", _read_text(sub, source))
3380
+ elif child.type == "class_interface_clause":
3381
+ for sub in child.children:
3382
+ if sub.type in ("name", "qualified_name"):
3383
+ _record_raw("implements", _read_text(sub, source))
3384
+ elif child.type == "declaration_list":
3385
+ for member in child.children:
3386
+ if member.type != "use_declaration":
3387
+ continue
3388
+ for sub in member.children:
3389
+ if sub.type in ("name", "qualified_name"):
3390
+ _record_raw("mixes_in", _read_text(sub, source))
3391
+ for child in n.children:
3392
+ walk(child)
3393
+
3394
+ walk(tree.root_node)
3395
+ if len(set(namespaces)) > 1:
3396
+ continue # multi-namespace file (PSR-1 violation): keep legacy behavior
3397
+ ns = namespaces[0] if namespaces else ""
3398
+ for s in srcs:
3399
+ ns_by_file[s] = ns
3400
+ uses_by_file[s] = uses
3401
+ raw_by_file[s] = raws
3402
+
3403
+ if not ns_by_file:
3404
+ return
3405
+
3406
+ # lower FQN -> definition node id (PHP class names are case-insensitive).
3407
+ fqn_to_id: dict[str, str] = {}
3408
+ for node in all_nodes:
3409
+ label = node.get("label", "")
3410
+ src = node.get("source_file", "")
3411
+ nid = node.get("id", "")
3412
+ if not (label and src and nid) or src not in ns_by_file:
3413
+ continue
3414
+ if label.endswith(")") or "." in label: # methods / file nodes
3415
+ continue
3416
+ ns = ns_by_file[src]
3417
+ fqn = f"{ns}\\{label}" if ns else label
3418
+ fqn_to_id.setdefault(fqn.lower(), nid)
3419
+
3420
+ node_ids = {n.get("id") for n in all_nodes if n.get("id")}
3421
+ stub_label: dict[str, str] = {
3422
+ n["id"]: n.get("label", "")
3423
+ for n in all_nodes
3424
+ if n.get("id") and not n.get("source_file") and n.get("label")
3425
+ }
3426
+
3427
+ external_stub_ids: dict[str, str] = {}
3428
+ new_nodes: list[dict] = []
3429
+
3430
+ def _external_stub(fqn: str) -> str:
3431
+ key = fqn.lower()
3432
+ nid = external_stub_ids.get(key)
3433
+ if nid:
3434
+ return nid
3435
+ nid = _make_id(fqn)
3436
+ if nid not in node_ids:
3437
+ new_nodes.append({
3438
+ "id": nid,
3439
+ "label": fqn,
3440
+ "file_type": "code",
3441
+ "source_file": "",
3442
+ "source_location": "",
3443
+ })
3444
+ node_ids.add(nid)
3445
+ external_stub_ids[key] = nid
3446
+ return nid
3447
+
3448
+ repointed_from: set[str] = set()
3449
+ for edge in all_edges:
3450
+ relation = edge.get("relation")
3451
+ if relation not in _PHP_REPOINT_RELATIONS:
3452
+ continue
3453
+ ref_file = edge.get("source_file", "")
3454
+ if ref_file not in ns_by_file:
3455
+ continue
3456
+ tgt = edge.get("target")
3457
+ label = stub_label.get(tgt)
3458
+ uses = uses_by_file.get(ref_file, {})
3459
+ if not label and relation == "imports":
3460
+ label = next((alias for alias in uses if _make_id(alias) == tgt), "")
3461
+ if not label:
3462
+ continue
3463
+ bare = label.strip().lower()
3464
+ ns = ns_by_file[ref_file]
3465
+
3466
+ raw = None
3467
+ if relation in _PHP_SUPERTYPE_RELATIONS:
3468
+ raw = raw_by_file.get(ref_file, {}).get((relation, bare))
3469
+
3470
+ explicit = False
3471
+ if raw and "\\" in raw:
3472
+ fqn = _php_fqn_from_raw(raw, ns, uses)
3473
+ explicit = True
3474
+ elif bare in uses:
3475
+ fqn = uses[bare]
3476
+ explicit = True
3477
+ elif ns:
3478
+ fqn = f"{ns}\\{label}"
3479
+ else:
3480
+ continue # no namespace facts: legacy unique-label rewire applies
3481
+
3482
+ resolved = fqn_to_id.get(fqn.lower())
3483
+ if resolved and resolved != tgt:
3484
+ edge["target"] = resolved
3485
+ repointed_from.add(tgt)
3486
+ elif explicit and resolved is None:
3487
+ # Proven external: park the edge on an FQN-labeled stub the
3488
+ # bare-name rewire cannot collapse (this is the #1923 fix).
3489
+ edge["target"] = _external_stub(fqn)
3490
+ repointed_from.add(tgt)
3491
+ # non-explicit miss: leave the bare stub for the legacy rewire
3492
+
3493
+ if new_nodes:
3494
+ all_nodes.extend(new_nodes)
3495
+ if not repointed_from:
3496
+ return
3497
+
3498
+ still_referenced: set[str] = set()
3499
+ for edge in all_edges:
3500
+ still_referenced.add(edge.get("source"))
3501
+ still_referenced.add(edge.get("target"))
3502
+ all_nodes[:] = [
3503
+ n for n in all_nodes
3504
+ if n.get("id") not in repointed_from or n.get("id") in still_referenced
3505
+ ]
3506
+
3507
+
3508
+ _pascal_unit_cache: dict[str, dict[str, str]] = {}
3509
+
3510
+ _pascal_class_stem_cache: dict[str, dict[str, str]] = {} # root_key → {stem_lower: _file_stem}
3511
+
3512
+ def _pascal_project_root(from_path: Path) -> Path:
3513
+ """Return the highest ancestor directory that looks like a Pascal project root.
3514
+
3515
+ Walks up the directory tree and tracks the topmost directory that:
3516
+ - is NOT a filesystem root (e.g. D:/, C:/, /)
3517
+ - has at least 2 .pas files OR at least 1 .dpr file as direct children
3518
+
3519
+ The minimum-2 threshold avoids treating a level as the root just because a
3520
+ single stray .pas file was copied there. The filesystem-root exclusion
3521
+ prevents overshoot on drives that have a stray file directly at D:/.
3522
+
3523
+ Falls back to from_path.parent if nothing better is found.
3524
+ """
3525
+ best = from_path.parent
3526
+ current = from_path.parent
3527
+ for _ in range(12):
3528
+ if len(current.parts) <= 1:
3529
+ break # never use a filesystem root (D:/, C:/, /)
3530
+ pas_count = sum(1 for _ in current.glob("*.pas"))
3531
+ dpr_count = sum(1 for _ in current.glob("*.dpr"))
3532
+ if pas_count >= 2 or dpr_count >= 1:
3533
+ best = current
3534
+ parent = current.parent
3535
+ if parent == current:
3536
+ break
3537
+ current = parent
3538
+ return best
3539
+
3540
+ def _pascal_resolve_unit(from_path: Path, unit_name: str) -> str:
3541
+ """Resolve a Pascal unit name to the graphify node ID of its source file.
3542
+
3543
+ Scans all Pascal files under the project root (the highest ancestor that
3544
+ directly contains .pas/.dpr files) and returns _make_id(str(matched_path)).
3545
+ Result is cached per project root so the rglob runs at most once per
3546
+ project. Falls back to _make_id(unit_name) for units not found on disk
3547
+ (e.g. standard RTL units like SysUtils, Windows).
3548
+ """
3549
+ root = _pascal_project_root(from_path)
3550
+ root_key = str(root)
3551
+ if root_key not in _pascal_unit_cache:
3552
+ unit_map: dict[str, str] = {}
3553
+ for ext in (".pas", ".pp", ".dpr", ".dpk", ".inc"):
3554
+ for f in root.rglob("*" + ext):
3555
+ unit_map[f.stem.lower()] = _make_id(str(f))
3556
+ _pascal_unit_cache[root_key] = unit_map
3557
+ return _pascal_unit_cache[root_key].get(unit_name.lower(), _make_id(unit_name))
3558
+
3559
+ def _pascal_resolve_class(from_path: Path, class_name: str) -> str | None:
3560
+ """Resolve a Pascal class/interface name to the node ID of its defining file's class node.
3561
+
3562
+ Pascal convention: TFooBar is defined in FooBar.pas, IFooBar in FooBar.pas.
3563
+ Strips the leading T/I prefix, finds the file, and returns
3564
+ _make_id(_file_stem(found_file), class_name).
3565
+
3566
+ Returns None when no matching file is found on disk (RTL, stdlib, or
3567
+ unconventionally-named class — caller should create a stub node).
3568
+ """
3569
+ prefix = class_name[:1]
3570
+ unit_name = class_name[1:] if prefix in ("T", "I") else class_name
3571
+
3572
+ root = _pascal_project_root(from_path)
3573
+ root_key = str(root)
3574
+ if root_key not in _pascal_class_stem_cache:
3575
+ stem_map: dict[str, str] = {}
3576
+ for ext in (".pas", ".pp", ".dpr", ".dpk"):
3577
+ for f in root.rglob("*" + ext):
3578
+ stem_map[f.stem.lower()] = _file_stem(f)
3579
+ _pascal_class_stem_cache[root_key] = stem_map
3580
+
3581
+ file_stem = _pascal_class_stem_cache[root_key].get(unit_name.lower())
3582
+ if file_stem:
3583
+ return _make_id(file_stem, class_name)
3584
+ return None