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
graphify/cluster.py ADDED
@@ -0,0 +1,409 @@
1
+ """Community detection on NetworkX graphs. Uses Leiden (graspologic) if available, falls back to Louvain (networkx). Splits oversized communities. Returns cohesion scores."""
2
+ from __future__ import annotations
3
+ import contextlib
4
+ import inspect
5
+ import io
6
+ import json
7
+ import sys
8
+ import networkx as nx
9
+
10
+
11
+ def _suppress_output():
12
+ """Context manager to suppress stdout/stderr during library calls.
13
+
14
+ graspologic's leiden() emits ANSI escape sequences (progress bars,
15
+ colored warnings) that corrupt PowerShell 5.1's scroll buffer on
16
+ Windows (see issue #19). Redirecting stdout/stderr to devnull during
17
+ the call prevents this without losing any graphify output.
18
+ """
19
+ return contextlib.redirect_stdout(io.StringIO())
20
+
21
+
22
+ def _native_leiden(stable: nx.Graph, resolution: float) -> dict[str, int] | None:
23
+ """Call graspologic_native.leiden() directly, bypassing graspologic's own
24
+ package import.
25
+
26
+ graspologic.partition.leiden() is a thin wrapper around exactly this
27
+ native (Rust) call. Importing the *package* — as opposed to the native
28
+ extension module it depends on — pulls in graspologic.layouts, which
29
+ imports umap, which imports pynndescent, which numba-JIT-compiles at
30
+ import time for a layout algorithm this function never calls: measured
31
+ at 7-19s of one-time import cost against a ~1s native call and a ~1.4s
32
+ full round trip (conversion + call + map-back) — see the "third update"
33
+ in GRAPHIFY_BUILD_PERF.md for the measurements this is based on.
34
+
35
+ Returns None (the caller falls through to the graspologic.partition.leiden
36
+ path, then to the networkx Louvain fallback) if graspologic_native isn't
37
+ installed, or if `stable` isn't the plain undirected, non-multigraph
38
+ input leiden actually supports — the same shape check
39
+ graspologic.partition.leiden itself makes before calling the same native
40
+ function.
41
+ """
42
+ try:
43
+ import graspologic_native as gn
44
+ except ImportError:
45
+ return None
46
+
47
+ if stable.is_directed() or stable.is_multigraph():
48
+ return None
49
+
50
+ # graspologic_native identifies nodes by their string form; two DISTINCT
51
+ # node objects that happen to stringify the same way would silently merge
52
+ # under it (this is exactly what graspologic.partition.leiden's own
53
+ # _IdentityMapper guards against). Graphify's own node IDs are already
54
+ # unique strings by construction — extractors/resolution.py's
55
+ # _disambiguate_colliding_node_ids salts any two distinct nodes that would
56
+ # otherwise share a string id before the graph is ever built — so this is
57
+ # a defensive check on an assumption that should never actually trip, not
58
+ # an expected path. One pass over the nodes, cheaper than an
59
+ # _IdentityMapper-style dict-store-per-edge-endpoint.
60
+ id_to_node: dict[str, object] = {}
61
+ for node in stable.nodes():
62
+ key = str(node)
63
+ existing = id_to_node.get(key)
64
+ if existing is not None and existing != node:
65
+ return None # let graspologic.partition.leiden's own check handle/raise on this
66
+ id_to_node[key] = node
67
+
68
+ edges = [
69
+ (str(u), str(v), float(attrs.get("weight", 1.0)))
70
+ for u, v, attrs in stable.edges(data=True)
71
+ ]
72
+
73
+ try:
74
+ old_stderr = sys.stderr
75
+ try:
76
+ sys.stderr = io.StringIO()
77
+ with _suppress_output():
78
+ _quality, native_partitions = gn.leiden(
79
+ edges=edges,
80
+ starting_communities=None,
81
+ resolution=resolution,
82
+ randomness=0.001,
83
+ iterations=1,
84
+ use_modularity=True,
85
+ seed=42,
86
+ trials=1,
87
+ )
88
+ finally:
89
+ sys.stderr = old_stderr
90
+ except Exception:
91
+ return None
92
+
93
+ return {id_to_node[node_id]: community for node_id, community in native_partitions.items()}
94
+
95
+
96
+ def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
97
+ """Run community detection. Returns {node_id: community_id}.
98
+
99
+ Tries Leiden (graspologic_native directly, then graspologic) first — best
100
+ quality. Falls back to Louvain (built into networkx) if neither is
101
+ installed.
102
+
103
+ resolution > 1.0 → more, smaller communities.
104
+ resolution < 1.0 → fewer, larger communities.
105
+
106
+ Output from graspologic is suppressed to prevent ANSI escape codes
107
+ from corrupting terminal scroll buffers on Windows PowerShell 5.1.
108
+ """
109
+ stable = nx.Graph()
110
+ stable.add_nodes_from(sorted(G.nodes(), key=str))
111
+ # Canonicalise the endpoint pair before sorting. On an undirected graph the
112
+ # (u, v) orientation each edge is yielded with comes from adjacency
113
+ # iteration, which follows CPython's per-process string-hash order - so the
114
+ # SAME edge appears as (A, B) in one run and (B, A) in the next. Sorting on
115
+ # the raw pair therefore does not canonicalise anything: the edge lands in a
116
+ # different position, `stable` is built in a different insertion order, and
117
+ # Louvain - order-sensitive even with a fixed seed - can return a different
118
+ # grouping. Measured on a 914-node graph: identical input, identical
119
+ # first-pass partition, but the cohesion-split pass produced 70 communities
120
+ # under PYTHONHASHSEED=1 and 69 under =2. Sorting the pair itself removes
121
+ # the dependency; for nx.Graph the orientation carries no meaning anyway.
122
+ edge_rows = sorted(
123
+ G.edges(data=True),
124
+ key=lambda row: (
125
+ *sorted((str(row[0]), str(row[1]))),
126
+ json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str),
127
+ ),
128
+ )
129
+ for src, tgt, attrs in edge_rows:
130
+ stable.add_edge(src, tgt, **attrs)
131
+
132
+ native_result = _native_leiden(stable, resolution)
133
+ if native_result is not None:
134
+ return native_result
135
+
136
+ try:
137
+ from graspologic.partition import leiden
138
+ lsig = inspect.signature(leiden).parameters
139
+ kwargs: dict = {}
140
+ if "random_seed" in lsig:
141
+ kwargs["random_seed"] = 42
142
+ if "trials" in lsig:
143
+ kwargs["trials"] = 1
144
+ if "resolution" in lsig:
145
+ kwargs["resolution"] = resolution
146
+ # Suppress graspologic output to prevent ANSI escape codes from
147
+ # corrupting PowerShell 5.1 scroll buffer (issue #19)
148
+ old_stderr = sys.stderr
149
+ try:
150
+ sys.stderr = io.StringIO()
151
+ with _suppress_output():
152
+ result = leiden(stable, **kwargs)
153
+ finally:
154
+ sys.stderr = old_stderr
155
+ return result
156
+ except ImportError:
157
+ pass
158
+
159
+ # Fallback: networkx louvain (available since networkx 2.7).
160
+ # Inspect kwargs to stay compatible across NetworkX versions — max_level
161
+ # was added in a later release and prevents hangs on large sparse graphs.
162
+ kwargs: dict = {"seed": 42, "threshold": 1e-4, "resolution": resolution}
163
+ if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
164
+ kwargs["max_level"] = 10
165
+ communities = nx.community.louvain_communities(stable, **kwargs)
166
+ return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
167
+
168
+
169
+ _MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
170
+ _MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
171
+ _COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
172
+ _COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
173
+
174
+
175
+ def label_communities_by_hub(
176
+ G: nx.Graph, communities: dict[int, list[str]]
177
+ ) -> dict[int, str]:
178
+ """Deterministic, LLM-free community labels: name each community after its
179
+ highest-degree member — the structural hub — so a report reads ``auth`` /
180
+ ``log_action`` instead of ``Community 70``. Degree is measured on the full graph
181
+ ``G``; ties break by node id for run-to-run stability. A community whose members
182
+ are all absent from ``G`` falls back to ``Community {cid}``.
183
+
184
+ Used as the default (no-backend) labeler; an LLM naming pass, when configured,
185
+ overrides these with richer names.
186
+ """
187
+ labels: dict[int, str] = {}
188
+ for cid, members in communities.items():
189
+ present = [n for n in members if n in G]
190
+ if not present:
191
+ labels[cid] = f"Community {cid}"
192
+ continue
193
+ # highest degree wins; ties broken by node id (ascending) for determinism
194
+ hub = min(present, key=lambda n: (-G.degree(n), str(n)))
195
+ name = str(G.nodes[hub].get("label") or hub).strip()
196
+ if name.endswith("()"):
197
+ name = name[:-2]
198
+ labels[cid] = name or f"Community {cid}"
199
+ return labels
200
+
201
+
202
+ def community_member_sigs(communities: dict[int, list[str]]) -> dict[int, str]:
203
+ """Per-community membership fingerprints: ``{cid: sha256(sorted member ids)}``.
204
+
205
+ Persisted next to ``.graphify_labels.json`` so a later ``cluster-only`` can tell
206
+ which communities actually changed since labeling. A cid whose members no longer
207
+ hash the same is a different community — reusing its old (LLM) label there is the
208
+ "stale label after re-scoping" bug this guards against. Deterministic; independent
209
+ of cid index, node order, and machine.
210
+ """
211
+ import hashlib
212
+
213
+ sigs: dict[int, str] = {}
214
+ for cid, members in communities.items():
215
+ h = hashlib.sha256()
216
+ for nid in sorted(str(n) for n in members):
217
+ h.update(nid.encode("utf-8", "replace"))
218
+ h.update(b"\x00")
219
+ sigs[cid] = h.hexdigest()[:16]
220
+ return sigs
221
+
222
+
223
+ def cluster(
224
+ G: nx.Graph,
225
+ resolution: float = 1.0,
226
+ exclude_hubs_percentile: float | None = None,
227
+ ) -> dict[int, list[str]]:
228
+ """Run Leiden community detection. Returns {community_id: [node_ids]}.
229
+
230
+ Community IDs are stable across runs: 0 = largest community after splitting.
231
+ Oversized communities (> 25% of graph nodes, min 10) are split by running
232
+ a second Leiden pass on the subgraph.
233
+
234
+ Accepts directed or undirected graphs. DiGraphs are converted to undirected
235
+ internally since Louvain/Leiden require undirected input.
236
+
237
+ resolution: passed to Leiden/Louvain. >1.0 = more smaller communities,
238
+ <1.0 = fewer larger communities. Default 1.0.
239
+ exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this
240
+ percentile are excluded from partitioning and reattached to their
241
+ majority-vote neighbour community afterwards. Useful for staging/utility
242
+ super-hubs that inflate god-node rankings (#919).
243
+ """
244
+ if G.number_of_nodes() == 0:
245
+ return {}
246
+ if G.is_directed():
247
+ G = G.to_undirected()
248
+ if G.number_of_edges() == 0:
249
+ return {i: [n] for i, n in enumerate(sorted(G.nodes))}
250
+
251
+ # Compute hub exclusion set before removing anything so degree is based on full graph
252
+ hub_nodes: set[str] = set()
253
+ if exclude_hubs_percentile is not None:
254
+ degrees = sorted(d for _, d in G.degree())
255
+ if degrees:
256
+ idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
257
+ threshold = degrees[idx]
258
+ hub_nodes = {n for n, d in G.degree() if d > threshold}
259
+
260
+ # Leiden warns and drops isolates - handle them separately
261
+ # Also exclude hub nodes from partitioning so they don't pull unrelated
262
+ # subsystems into the same community
263
+ excluded = hub_nodes
264
+ isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded]
265
+ connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded]
266
+ connected = G.subgraph(connected_nodes)
267
+
268
+ raw: dict[int, list[str]] = {}
269
+ if connected.number_of_nodes() > 0:
270
+ partition = _partition(connected, resolution=resolution)
271
+ for node, cid in partition.items():
272
+ raw.setdefault(cid, []).append(node)
273
+
274
+ # Each isolate becomes its own single-node community
275
+ next_cid = max(raw.keys(), default=-1) + 1
276
+ for node in isolates:
277
+ raw[next_cid] = [node]
278
+ next_cid += 1
279
+
280
+ # Reattach excluded hubs by majority-vote neighbour community
281
+ if hub_nodes:
282
+ node_community: dict[str, int] = {n: cid for cid, nodes in raw.items() for n in nodes}
283
+ for hub in sorted(hub_nodes):
284
+ votes: dict[int, int] = {}
285
+ for nb in G.neighbors(hub):
286
+ cid = node_community.get(nb)
287
+ if cid is not None:
288
+ votes[cid] = votes.get(cid, 0) + 1
289
+ if votes:
290
+ best = min(votes, key=lambda c: (-votes[c], c))
291
+ raw.setdefault(best, []).append(hub)
292
+ node_community[hub] = best
293
+ else:
294
+ raw[next_cid] = [hub]
295
+ node_community[hub] = next_cid
296
+ next_cid += 1
297
+
298
+ # Split oversized communities
299
+ max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION))
300
+ final_communities: list[list[str]] = []
301
+ for nodes in raw.values():
302
+ if len(nodes) > max_size:
303
+ final_communities.extend(_split_community(G, nodes))
304
+ else:
305
+ final_communities.append(nodes)
306
+
307
+ # Second pass: re-split low-cohesion communities caused by doc-hub nodes
308
+ # that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
309
+ second_pass: list[list[str]] = []
310
+ for nodes in final_communities:
311
+ if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
312
+ splits = _split_community(G, nodes)
313
+ second_pass.extend(splits if len(splits) > 1 else [nodes])
314
+ else:
315
+ second_pass.append(nodes)
316
+ final_communities = second_pass
317
+
318
+ # Re-index by size descending. The tuple(sorted(nodes)) tiebreak makes this a
319
+ # TOTAL order, so an identical grouping always gets identical community IDs.
320
+ # Without it, the hundreds of equal-sized small communities are ordered by the
321
+ # partitioner's (not seed-stable) enumeration order, so their integer IDs
322
+ # permute run-to-run - which reads as massive "community churn" in a per-node
323
+ # cid diff even though the actual grouping is reproducible (#1090 follow-up).
324
+ final_communities.sort(key=lambda nodes: (-len(nodes), tuple(sorted(map(str, nodes)))))
325
+ return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
326
+
327
+
328
+ def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]:
329
+ """Run a second Leiden pass on a community subgraph to split it further."""
330
+ subgraph = G.subgraph(nodes)
331
+ if subgraph.number_of_edges() == 0:
332
+ # No edges - split into individual nodes
333
+ return [[n] for n in sorted(nodes)]
334
+ try:
335
+ sub_partition = _partition(subgraph)
336
+ sub_communities: dict[int, list[str]] = {}
337
+ for node, cid in sub_partition.items():
338
+ sub_communities.setdefault(cid, []).append(node)
339
+ if len(sub_communities) <= 1:
340
+ return [sorted(nodes)]
341
+ return [sorted(v) for v in sub_communities.values()]
342
+ except Exception:
343
+ return [sorted(nodes)]
344
+
345
+
346
+ def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float:
347
+ """Ratio of actual intra-community edges to maximum possible."""
348
+ n = len(community_nodes)
349
+ if n <= 1:
350
+ return 1.0
351
+ subgraph = G.subgraph(community_nodes)
352
+ actual = subgraph.number_of_edges()
353
+ possible = n * (n - 1) / 2
354
+ return actual / possible if possible > 0 else 0.0
355
+
356
+
357
+ def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]:
358
+ return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()}
359
+
360
+
361
+ def remap_communities_to_previous(
362
+ communities: dict[int, list[str]],
363
+ previous_node_community: dict[str, int],
364
+ ) -> dict[int, list[str]]:
365
+ """Remap community IDs to maximize overlap with a previous assignment.
366
+
367
+ Uses greedy one-to-one matching by intersection size, then assigns fresh IDs
368
+ to unmatched communities in deterministic order (size desc, lexical tie-break).
369
+ """
370
+ if not communities:
371
+ return {}
372
+
373
+ new_sets = {cid: set(nodes) for cid, nodes in communities.items()}
374
+ old_sets: dict[int, set[str]] = {}
375
+ for node, old_cid in previous_node_community.items():
376
+ old_sets.setdefault(old_cid, set()).add(node)
377
+
378
+ overlaps: list[tuple[int, int, int]] = []
379
+ for old_cid, old_nodes in old_sets.items():
380
+ for new_cid, new_nodes in new_sets.items():
381
+ overlap = len(old_nodes & new_nodes)
382
+ if overlap > 0:
383
+ overlaps.append((overlap, old_cid, new_cid))
384
+ overlaps.sort(key=lambda x: (-x[0], x[1], x[2]))
385
+
386
+ new_to_final: dict[int, int] = {}
387
+ used_old_ids: set[int] = set()
388
+ matched_new_ids: set[int] = set()
389
+ for _overlap, old_cid, new_cid in overlaps:
390
+ if old_cid in used_old_ids or new_cid in matched_new_ids:
391
+ continue
392
+ new_to_final[new_cid] = old_cid
393
+ used_old_ids.add(old_cid)
394
+ matched_new_ids.add(new_cid)
395
+
396
+ unmatched = [cid for cid in communities if cid not in matched_new_ids]
397
+ unmatched.sort(key=lambda cid: (-len(communities[cid]), tuple(sorted(communities[cid]))))
398
+ next_id = 0
399
+ for new_cid in unmatched:
400
+ while next_id in used_old_ids:
401
+ next_id += 1
402
+ new_to_final[new_cid] = next_id
403
+ used_old_ids.add(next_id)
404
+ next_id += 1
405
+
406
+ remapped: dict[int, list[str]] = {}
407
+ for new_cid, nodes in communities.items():
408
+ remapped[new_to_final[new_cid]] = sorted(nodes)
409
+ return dict(sorted(remapped.items(), key=lambda kv: kv[0]))
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Build or query a graphify knowledge graph
3
+ ---
4
+
5
+ Invoke the `graphify` skill immediately.
6
+
7
+ Pass the full `/graphify` argument string through unchanged.
8
+ If no arguments were supplied, treat the target path as `.`.
9
+
10
+ Examples:
11
+ - `/graphify`
12
+ - `/graphify src --update`
13
+ - `/graphify query "what connects auth to billing?"`
14
+
15
+ Do not answer from raw files before handing off to the `graphify` skill.
@@ -0,0 +1,216 @@
1
+ """Finish member calls that cross a repository boundary in a merged graph (#3152).
2
+
3
+ A single-repo build can only bind ``obj.method()`` when the receiver's type is
4
+ declared in that same build. When the type lives in another repository the
5
+ resolver holds the receiver type and drops the call anyway, so ``graph.json`` —
6
+ the only artifact ``merge-graphs`` and ``global add`` consume — records nothing,
7
+ and no merge-time pass can recover what was never written down. A two-repo call
8
+ graph was therefore missing exactly the edges that make it a call graph: eight
9
+ edges when the code sits in one corpus, seven after merging the same code from
10
+ two repos.
11
+
12
+ The resolvers now park those calls on the caller node as
13
+ ``metadata.unresolved_calls`` entries (names only, no node ids — see
14
+ ``_park_unresolved_member_call``). This pass reads them back after the graphs are
15
+ composed and emits the ``calls`` edge when the receiver's type resolves to
16
+ exactly one declaration in another repo, keeping the single-definition guard the
17
+ single-repo resolvers use: an ambiguous name still fabricates nothing.
18
+
19
+ Edges only, no node merging or renaming, so the pass composes with the prefixing
20
+ and pruning already in place. Every edge it adds is tagged, and it clears its own
21
+ previous output before recomputing: ``global add`` composes one repo at a time
22
+ and revisits the same pairs on every add, and recomputing from the parked entries
23
+ keeps the result identical whether three repos arrived together or one at a time.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from collections import defaultdict
29
+ from typing import TYPE_CHECKING
30
+
31
+ if TYPE_CHECKING: # pragma: no cover - typing only
32
+ import networkx as nx
33
+
34
+ CROSS_REPO_CALL_MARKER = "_cross_repo_call"
35
+ UNRESOLVED_CALLS_KEY = "unresolved_calls"
36
+
37
+ # A parked entry names the language it was written in, and the declaration that
38
+ # answers it must be written in the same one: without this a Java `Greeter` binds
39
+ # just as happily to a Python class of the same name in another repo. Extend this
40
+ # map when another extractor starts parking calls.
41
+ _LANG_SUFFIXES: dict[str, frozenset[str]] = {
42
+ "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}),
43
+ "csharp": frozenset({".cs"}),
44
+ "java": frozenset({".java"}),
45
+ "swift": frozenset({".swift"}),
46
+ }
47
+
48
+ # A declaration owns its members through a `method` edge, except in C++, where an
49
+ # in-class declaration (`void bar();` in a header) is modelled as a field and
50
+ # carries `defines` instead. `method` wins when both name the same member.
51
+ _MEMBER_RELATIONS = ("defines", "method")
52
+
53
+
54
+ def _key(label: object) -> str:
55
+ """Normalize a node label or a parked name to its bare identifier.
56
+
57
+ Type labels are plain (``Greeter``) while method labels carry the extractor's
58
+ decoration (``.greet()``). Case is preserved: every language that parks calls
59
+ here is case-sensitive, and folding case would let `greeter` answer for
60
+ `Greeter`.
61
+ """
62
+ return str(label or "").strip().removeprefix(".").removesuffix("()")
63
+
64
+
65
+ def _suffix(source_file: object) -> str:
66
+ return os.path.splitext(str(source_file or ""))[1].lower()
67
+
68
+
69
+ def _parked_entries(data: dict) -> list[dict]:
70
+ metadata = data.get("metadata")
71
+ if not isinstance(metadata, dict):
72
+ return []
73
+ parked = metadata.get(UNRESOLVED_CALLS_KEY)
74
+ if not isinstance(parked, list):
75
+ return []
76
+ return [entry for entry in parked if isinstance(entry, dict)]
77
+
78
+
79
+ def _drop_previous_output(merged: "nx.Graph") -> None:
80
+ """Clear the edges this pass added on an earlier run.
81
+
82
+ Recompute-from-scratch is what makes an incremental ``global add`` agree with
83
+ a single ``merge-graphs`` of the same inputs, and it is also how a repo whose
84
+ types moved stops answering for calls it no longer owns.
85
+ """
86
+ stale = [(u, v) for u, v, data in merged.edges(data=True)
87
+ if data.get(CROSS_REPO_CALL_MARKER)]
88
+ merged.remove_edges_from(stale)
89
+
90
+
91
+ def _index_declarations(merged: "nx.Graph") -> tuple[dict[str, list[str]], set[str]]:
92
+ """Index sourced type declarations by bare name. Returns (index, id set)."""
93
+ by_name: dict[str, list[str]] = defaultdict(list)
94
+ type_ids: set[str] = set()
95
+ for node, data in merged.nodes(data=True):
96
+ if not data.get("_callable_class") or not data.get("source_file"):
97
+ continue
98
+ if not data.get("repo"):
99
+ continue
100
+ name = _key(data.get("label"))
101
+ if not name:
102
+ continue
103
+ by_name[name].append(node)
104
+ type_ids.add(node)
105
+ return by_name, type_ids
106
+
107
+
108
+ def _index_members(
109
+ merged: "nx.Graph", type_ids: set[str]
110
+ ) -> dict[str, dict[tuple[str, str], list[str]]]:
111
+ """Index each declaration's members by relation, then by name.
112
+
113
+ The merged graph is undirected, and a ``method`` edge carries no reliable
114
+ direction once composed, so the owner is identified as the endpoint that is a
115
+ type declaration. A nested declaration puts a type on both ends; that pair is
116
+ skipped rather than guessed at.
117
+
118
+ Kept per relation rather than pooled: ``defines`` covers fields as well as
119
+ C++'s in-class member declarations, so only a language that needs it may look
120
+ there, and only when no ``method`` of that name exists.
121
+ """
122
+ by_relation: dict[str, dict[tuple[str, str], list[str]]] = {
123
+ relation: defaultdict(list) for relation in _MEMBER_RELATIONS
124
+ }
125
+ for u, v, data in merged.edges(data=True):
126
+ relation = data.get("relation")
127
+ if relation not in by_relation:
128
+ continue
129
+ if u in type_ids and v not in type_ids:
130
+ owner, member = u, v
131
+ elif v in type_ids and u not in type_ids:
132
+ owner, member = v, u
133
+ else:
134
+ continue
135
+ name = _key(merged.nodes[member].get("label"))
136
+ if name:
137
+ by_relation[relation][(owner, name)].append(member)
138
+ return by_relation
139
+
140
+
141
+ def _member_relations(lang: str) -> tuple[str, ...]:
142
+ """Which owner→member relations may answer a call parked by ``lang``.
143
+
144
+ Only C++ models an in-class declaration (``void bar();`` in a header) as a
145
+ field, so only a C++ entry may fall back to ``defines``; for every other
146
+ language a ``defines`` target is a field, and a field cannot answer a call.
147
+ """
148
+ return ("method", "defines") if lang == "cpp" else ("method",)
149
+
150
+
151
+ def link_cross_repo_member_calls(merged: "nx.Graph") -> int:
152
+ """Emit `calls` edges for parked member calls another repo answers.
153
+
154
+ Returns the number of edges added. Idempotent: the pass drops its own earlier
155
+ output first, so re-merging or adding a repo twice cannot duplicate an edge.
156
+ """
157
+ _drop_previous_output(merged)
158
+ parked_nodes = [(node, data) for node, data in merged.nodes(data=True)
159
+ if _parked_entries(data)]
160
+ if not parked_nodes:
161
+ return 0
162
+
163
+ by_name, type_ids = _index_declarations(merged)
164
+ if not by_name:
165
+ return 0
166
+ members_by_relation = _index_members(merged, type_ids)
167
+
168
+ added = 0
169
+ for caller, caller_data in parked_nodes:
170
+ caller_repo = caller_data.get("repo")
171
+ if not caller_repo:
172
+ # Without a repo tag "another repo" has no meaning, and this pass
173
+ # deliberately never re-decides a call inside one repo.
174
+ continue
175
+ for entry in _parked_entries(caller_data):
176
+ lang = str(entry.get("lang") or "")
177
+ suffixes = _LANG_SUFFIXES.get(lang)
178
+ receiver_type = _key(entry.get("receiver_type"))
179
+ callee = _key(entry.get("callee"))
180
+ if not suffixes or not receiver_type or not callee:
181
+ continue
182
+ candidates = [
183
+ node for node in by_name.get(receiver_type, [])
184
+ if merged.nodes[node].get("repo") != caller_repo
185
+ and _suffix(merged.nodes[node].get("source_file")) in suffixes
186
+ ]
187
+ if len(candidates) != 1:
188
+ # The same guard the single-repo resolvers apply: two repos
189
+ # declaring the same name is an ambiguity, not a hit.
190
+ continue
191
+ targets: list[str] = []
192
+ for relation in _member_relations(lang):
193
+ targets = members_by_relation[relation].get((candidates[0], callee), [])
194
+ if targets:
195
+ break
196
+ if len(targets) != 1:
197
+ continue
198
+ target = targets[0]
199
+ if target == caller or merged.has_edge(caller, target):
200
+ continue
201
+ merged.add_edge(
202
+ caller,
203
+ target,
204
+ relation="calls",
205
+ context="cross_repo",
206
+ confidence="INFERRED",
207
+ confidence_score=0.8,
208
+ source_file=str(caller_data.get("source_file") or ""),
209
+ source_location=entry.get("line"),
210
+ weight=1.0,
211
+ _src=caller,
212
+ _tgt=target,
213
+ **{CROSS_REPO_CALL_MARKER: True},
214
+ )
215
+ added += 1
216
+ return added