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/report.py ADDED
@@ -0,0 +1,346 @@
1
+ # generate GRAPH_REPORT.md - the human-readable audit trail
2
+ from __future__ import annotations
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+ import networkx as nx
7
+
8
+
9
+ def _portable_root_label(root: str) -> str:
10
+ """Portable label for the report header — the project directory basename.
11
+
12
+ GRAPH_REPORT.md is a tracked artifact in practice, so its header must not
13
+ bake the generator host's absolute path into the file: the same graph would
14
+ otherwise produce different bytes on different machines and leak the build
15
+ machine's directory layout into git history (#2628, same class as #2598).
16
+
17
+ Taking the basename strips any leading absolute path without touching the
18
+ filesystem, and makes `graphify update .`, `graphify update ./proj`, and
19
+ `graphify update /abs/path/proj` all label the header `proj`. Only the
20
+ degenerate `.`/``/`..` cases need a cwd resolve to recover the real name;
21
+ if even that fails, fall back to the raw value.
22
+ """
23
+ raw = str(root).replace("\\", "/")
24
+ name = Path(raw).name
25
+ if name in ("", ".", ".."):
26
+ try:
27
+ name = Path(raw).resolve().name
28
+ except (OSError, RuntimeError):
29
+ name = ""
30
+ return name or raw
31
+
32
+
33
+ def _safe_community_name(label: str) -> str:
34
+ """Mirrors export.safe_name so community hub filenames and report wikilinks always agree."""
35
+ cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
36
+ cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
37
+ return cleaned or "unnamed"
38
+
39
+
40
+ def load_learning_for_report(graph_path) -> dict | None:
41
+ """Assemble the report's work-memory inputs from sibling artifacts.
42
+
43
+ Reads the ``.graphify_learning.json`` overlay (preferred sources) next to
44
+ ``graph_path`` and re-aggregates the memory docs for the query-scoped
45
+ dead-ends. Best-effort: returns None if neither is available, so the report
46
+ simply omits the section. Never raises.
47
+ """
48
+ from pathlib import Path as _Path
49
+ try:
50
+ gp = _Path(graph_path)
51
+ from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons
52
+ overlay = load_learning_overlay(gp)
53
+ dead_ends: list[dict] = []
54
+ mem = gp.parent / "memory"
55
+ if mem.is_dir():
56
+ agg = aggregate_lessons(load_memory_docs(mem))
57
+ dead_ends = agg.get("dead_ends", [])
58
+ if not overlay and not dead_ends:
59
+ return None
60
+ return {"overlay": overlay, "dead_ends": dead_ends}
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None:
66
+ """Append the ``## Work-memory lessons`` section, or nothing when empty."""
67
+ if not learning:
68
+ return
69
+ overlay = learning.get("overlay") or {}
70
+ dead_ends = learning.get("dead_ends") or []
71
+ preferred = [
72
+ (nid, e) for nid, e in overlay.items()
73
+ if isinstance(e, dict) and e.get("status") == "preferred"
74
+ ]
75
+ # Most-corroborated first (uses desc), then by score, then id for stability.
76
+ preferred.sort(key=lambda kv: (-kv[1].get("uses", 0),
77
+ -float(kv[1].get("score", 0) or 0), kv[0]))
78
+ if not preferred and not dead_ends:
79
+ return
80
+ lines += ["", "## Work-memory lessons"]
81
+ if preferred:
82
+ lines += ["", "**Preferred sources** — corroborated by past sessions; start here."]
83
+ for nid, e in preferred[:top_n]:
84
+ label = e.get("label") or nid
85
+ stale = " _(code changed — re-verify)_" if e.get("stale") else ""
86
+ lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, "
87
+ f"score={e.get('score', 0)}){stale}")
88
+ if dead_ends:
89
+ lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."]
90
+ for d in dead_ends:
91
+ nodes = ", ".join(f"`{n}`" for n in d.get("nodes", []))
92
+ lines.append(f"- \"{d.get('question', '')}\""
93
+ + (f" -> {nodes}" if nodes else ""))
94
+
95
+
96
+ def generate(
97
+ G: nx.Graph,
98
+ communities: dict[int, list[str]],
99
+ cohesion_scores: dict[int, float],
100
+ community_labels: dict[int, str],
101
+ god_node_list: list[dict],
102
+ surprise_list: list[dict],
103
+ detection_result: dict,
104
+ token_cost: dict,
105
+ root: str,
106
+ suggested_questions: list[dict] | None = None,
107
+ min_community_size: int = 3,
108
+ built_at_commit: str | None = None,
109
+ learning: dict | None = None,
110
+ obsidian: bool = False,
111
+ ) -> str:
112
+ today = date.today().isoformat()
113
+
114
+ # JSON deserialization produces string keys; normalize to int so .get(cid) works.
115
+ if community_labels:
116
+ community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
117
+
118
+ confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
119
+ total = len(confidences) or 1
120
+ ext_pct = round(confidences.count("EXTRACTED") / total * 100)
121
+ inf_pct = round(confidences.count("INFERRED") / total * 100)
122
+ amb_pct = round(confidences.count("AMBIGUOUS") / total * 100)
123
+
124
+ inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"]
125
+ inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges]
126
+ inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None
127
+
128
+ lines = [
129
+ f"# Graph Report - {_portable_root_label(root)} ({today})",
130
+ "",
131
+ "## Corpus Check",
132
+ ]
133
+ if detection_result.get("warning"):
134
+ lines.append(f"- {detection_result['warning']}")
135
+ else:
136
+ lines += [
137
+ f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words",
138
+ "- Verdict: corpus is large enough that graph structure adds value.",
139
+ ]
140
+
141
+ from .analyze import _is_file_node as _ifn
142
+
143
+ def _real_count(nodes) -> int:
144
+ return sum(1 for n in nodes if not _ifn(G, n))
145
+
146
+ non_empty = {cid: nodes for cid, nodes in communities.items()
147
+ if any(not _ifn(G, n) for n in nodes)}
148
+ # One predicate for every figure the report prints about itself (#3148):
149
+ # "thin" is 0 < real < min_community_size, and "shown" is what the render
150
+ # loop below actually renders (real >= min_community_size) - previously
151
+ # shown was total-thin, which also counted communities with ZERO real
152
+ # nodes that the loop skips, overstating the count (#2129's residual).
153
+ thin_count_summary = sum(
154
+ 1 for nodes in communities.values()
155
+ if 0 < _real_count(nodes) < min_community_size
156
+ )
157
+ shown_count = sum(
158
+ 1 for nodes in communities.values()
159
+ if _real_count(nodes) >= min_community_size
160
+ )
161
+
162
+ lines += [
163
+ "",
164
+ "## Summary",
165
+ f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
166
+ + (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""),
167
+ f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS"
168
+ + (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""),
169
+ f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
170
+ ]
171
+
172
+ if built_at_commit:
173
+ lines += [
174
+ "",
175
+ "## Graph Freshness",
176
+ f"- Built from commit: `{built_at_commit[:8]}`",
177
+ "- Run `git rev-parse HEAD` and compare to check if the graph is stale.",
178
+ "- Run `graphify update .` after code changes (no API cost).",
179
+ ]
180
+
181
+ # Community hub navigation. The `_COMMUNITY_*.md` notes these wikilinks target
182
+ # are only created by the opt-in `--obsidian` export, and the report is written
183
+ # at build time (before any export runs), so emitting wikilinks by default left
184
+ # every link dangling — polluting an Obsidian vault's graph view and rendering as
185
+ # literal brackets everywhere else (#1712). Emit wikilinks only when the caller
186
+ # signals Obsidian output; otherwise a plain list, which navigates nowhere-to-break.
187
+ if non_empty:
188
+ lines += ["", "## Community Hubs (Navigation)"]
189
+ for cid in non_empty:
190
+ label = community_labels.get(cid, f"Community {cid}")
191
+ if obsidian:
192
+ safe = _safe_community_name(label)
193
+ lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
194
+ else:
195
+ lines.append(f"- {label}")
196
+
197
+ lines += [
198
+ "",
199
+ "## God Nodes (most connected - your core abstractions)",
200
+ ]
201
+ for i, node in enumerate(god_node_list, 1):
202
+ lines.append(f"{i}. `{node['label']}` - {node['degree']} edges")
203
+
204
+ lines += ["", "## Surprising Connections (you probably didn't know these)"]
205
+ if surprise_list:
206
+ for s in surprise_list:
207
+ relation = s.get("relation", "related_to")
208
+ note = s.get("note", "")
209
+ files = s.get("source_files", ["", ""])
210
+ conf = s.get("confidence", "EXTRACTED")
211
+ cscore = s.get("confidence_score")
212
+ if conf == "INFERRED" and cscore is not None:
213
+ conf_tag = f"INFERRED {cscore:.2f}"
214
+ else:
215
+ conf_tag = conf
216
+ sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else ""
217
+ lines += [
218
+ f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}",
219
+ f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""),
220
+ ]
221
+ else:
222
+ lines.append("- None detected - all connections are within the same source files.")
223
+
224
+ # Circular imports surfaced from file-level dependency graph. Only meaningful
225
+ # for code — a documents-only corpus has no imports, so the section is pure
226
+ # noise there ("None detected" on every run). Emit it only when the graph
227
+ # actually contains code (#1657).
228
+ _has_code = any(
229
+ d.get("file_type") == "code" for _, d in G.nodes(data=True)
230
+ ) or any(
231
+ d.get("relation") in ("imports", "imports_from")
232
+ for *_e, d in G.edges(data=True)
233
+ )
234
+ if _has_code:
235
+ from .analyze import find_import_cycles
236
+ cycles = find_import_cycles(G)
237
+ lines += ["", "## Import Cycles"]
238
+ if cycles:
239
+ for c in cycles:
240
+ cycle = c.get("cycle", [])
241
+ length = c.get("length", len(cycle))
242
+ if not cycle:
243
+ continue
244
+ cycle_path = " -> ".join(cycle + [cycle[0]])
245
+ lines.append(f"- {length}-file cycle: `{cycle_path}`")
246
+ else:
247
+ lines.append("- None detected.")
248
+
249
+ hyperedges = G.graph.get("hyperedges", [])
250
+ if hyperedges:
251
+ lines += ["", "## Hyperedges (group relationships)"]
252
+ for h in hyperedges:
253
+ node_labels = ", ".join(h.get("nodes", []))
254
+ conf = h.get("confidence", "INFERRED")
255
+ cscore = h.get("confidence_score")
256
+ conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
257
+ lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")
258
+
259
+ lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"]
260
+ for cid, nodes in communities.items():
261
+ label = community_labels.get(cid, f"Community {cid}")
262
+ score = cohesion_scores.get(cid, 0.0)
263
+ # Filter method/function stubs from display - they're structural noise
264
+ real_nodes = [n for n in nodes if not _ifn(G, n)]
265
+ if not real_nodes:
266
+ continue
267
+ if len(real_nodes) < min_community_size:
268
+ continue
269
+ display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
270
+ suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
271
+ lines += [
272
+ "",
273
+ f"### Community {cid} - \"{label}\"",
274
+ f"Cohesion: {score:.2f}",
275
+ f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
276
+ ]
277
+
278
+ ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
279
+ if ambiguous:
280
+ lines += ["", "## Ambiguous Edges - Review These"]
281
+ for u, v, d in ambiguous:
282
+ ul = G.nodes[u].get("label", u)
283
+ vl = G.nodes[v].get("label", v)
284
+ lines += [
285
+ f"- `{ul}` → `{vl}` [AMBIGUOUS]",
286
+ f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
287
+ ]
288
+
289
+ # --- Gaps section ---
290
+ from .analyze import _is_file_node, _is_concept_node
291
+
292
+ isolated = [
293
+ n for n in G.nodes()
294
+ if G.degree(n) <= 1
295
+ and not _is_file_node(G, n)
296
+ and not _is_concept_node(G, n)
297
+ and G.nodes[n].get("file_type") != "rationale"
298
+ ]
299
+ # Same threshold the Summary and Communities headers used (#3148): this
300
+ # was a hardcoded 3, so with --min-community-size anything else the count
301
+ # here disagreed with the label text beside it, which already printed
302
+ # min_community_size.
303
+ thin_communities = {
304
+ cid: nodes for cid, nodes in communities.items()
305
+ if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < min_community_size
306
+ }
307
+ gap_count = len(isolated) + len(thin_communities)
308
+
309
+ if gap_count > 0 or amb_pct > 20:
310
+ lines += ["", "## Knowledge Gaps"]
311
+ if isolated:
312
+ isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
313
+ suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
314
+ raw_isolated = sum(1 for n in G.nodes() if G.degree(n) <= 1)
315
+ lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
316
+ lines.append(
317
+ " These have ≤1 connection - possible missing edges or undocumented components. "
318
+ f"(Counts symbols only; {raw_isolated} node(s) total have ≤1 connection when "
319
+ "file, concept and rationale nodes are included.)"
320
+ )
321
+ if thin_communities:
322
+ lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
323
+ if amb_pct > 20:
324
+ lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
325
+
326
+ # --- Work-memory lessons (derived overlay) ---
327
+ # Preferred sources come from the .graphify_learning.json sidecar; the
328
+ # query-scoped dead-ends come from the reflect aggregate. Section omitted
329
+ # entirely when neither is present, so a graph with no work-memory is
330
+ # byte-identical to the pre-feature report.
331
+ _learning_section(lines, learning)
332
+
333
+ if suggested_questions:
334
+ lines += ["", "## Suggested Questions"]
335
+ no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
336
+ if no_signal:
337
+ lines.append(f"_{suggested_questions[0]['why']}_")
338
+ else:
339
+ lines.append("_Questions this graph is uniquely positioned to answer:_")
340
+ lines.append("")
341
+ for q in suggested_questions:
342
+ if q.get("question"):
343
+ lines.append(f"- **{q['question']}**")
344
+ lines.append(f" _{q['why']}_")
345
+
346
+ return "\n".join(lines)
@@ -0,0 +1,85 @@
1
+ """Registry for cross-file, language-specific resolution passes.
2
+
3
+ Some call/reference edges can only be resolved with language-specific knowledge
4
+ (receiver typing, qualified member calls, framework conventions). Historically
5
+ these ran as a hand-wired sequence of suffix-gated
6
+ ``if <lang>_paths: try: _resolve_<lang>(...)`` blocks at the tail of
7
+ ``extract.extract()``. That pattern is the de-facto extension point for
8
+ per-language resolution; this module formalizes it so a new language plugs in by
9
+ registering one ``LanguageResolver`` instead of editing ``extract()``'s body.
10
+
11
+ This module deliberately knows nothing about any specific language — languages
12
+ register themselves (see ``extract.py``), keeping the dependency direction one
13
+ way (``extract`` → ``resolver_registry``) and this seam small and reviewable on
14
+ its own, separate from the multi-thousand-line ``extract`` module.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Callable
24
+
25
+ _LOG = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LanguageResolver:
30
+ """One cross-file, language-specific resolution pass.
31
+
32
+ ``resolve`` has the signature ``(per_file, all_nodes, all_edges) -> None`` and
33
+ mutates ``all_nodes`` / ``all_edges`` in place, matching the existing
34
+ member-call resolvers. ``suffixes`` gates activation: the pass runs only when
35
+ the corpus contains at least one file with one of these extensions.
36
+ """
37
+
38
+ name: str
39
+ suffixes: frozenset
40
+ resolve: Callable
41
+
42
+
43
+ # Module-level registry, populated by callers via register(). Ordered: resolvers
44
+ # run in registration order, preserving any required sequencing between passes.
45
+ _REGISTRY: list[LanguageResolver] = []
46
+
47
+
48
+ def register(resolver: LanguageResolver) -> LanguageResolver:
49
+ """Append a resolver to the global registry and return it (for inline use)."""
50
+ _REGISTRY.append(resolver)
51
+ return resolver
52
+
53
+
54
+ def registered_resolvers() -> list[LanguageResolver]:
55
+ """Return a copy of the registered resolvers, in registration order."""
56
+ return list(_REGISTRY)
57
+
58
+
59
+ def run_language_resolvers(
60
+ paths: Sequence[Path],
61
+ per_file: list[dict],
62
+ all_nodes: list[dict],
63
+ all_edges: list[dict],
64
+ *,
65
+ resolvers: Sequence[LanguageResolver] | None = None,
66
+ ) -> None:
67
+ """Run every resolver whose suffix appears in ``paths``.
68
+
69
+ Behaviorally identical to the prior hand-wired sequence of suffix-gated,
70
+ try/except-wrapped passes: same activation rule (suffix present), same
71
+ failure handling (log a warning and continue to the next pass), same
72
+ execution order (registration order).
73
+
74
+ ``resolvers`` defaults to the global registry; tests pass an explicit list to
75
+ exercise the driver in isolation.
76
+ """
77
+ active = _REGISTRY if resolvers is None else resolvers
78
+ suffixes_present = {p.suffix for p in paths}
79
+ for resolver in active:
80
+ if not (resolver.suffixes & suffixes_present):
81
+ continue
82
+ try:
83
+ resolver.resolve(per_file, all_nodes, all_edges)
84
+ except Exception as exc:
85
+ _LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
@@ -0,0 +1,242 @@
1
+ """Type-aware cross-file resolution for Ruby member calls.
2
+
3
+ Ruby has no type annotations and reuses method names heavily, so resolving
4
+ ``obj.method()`` by globally-unique name is both lossy (drops on collision) and
5
+ unsafe (can attach to the wrong same-named method). This resolver instead uses
6
+ the receiver's *type*, inferred at extraction time from local
7
+ ``var = ClassName.new`` bindings and carried on each member-call raw_call as
8
+ ``receiver_type``.
9
+
10
+ It resolves two shapes, both at EXTRACTED (1.0) confidence and only when the
11
+ target is certain (single owning class, single owned method) — bail otherwise:
12
+
13
+ * ``Processor.new`` -> a ``calls`` edge to the ``Processor`` class
14
+ * ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to ``Processor#run``
15
+
16
+ Registered into graphify.resolver_registry and run by extract() after id
17
+ disambiguation, so node ids and raw_call caller_nids are final.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Any
24
+
25
+
26
+ def _key(label: str) -> str:
27
+ """Normalize a class/method label to a comparison key (drop punctuation)."""
28
+ return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
29
+
30
+
31
+ # A Ruby class/module container node is labelled with a constant path, bare or
32
+ # ``::``-qualified (``Processor``, ``Billing::Rounding``); methods end in ``()``
33
+ # and files in ``.rb``. Lets us register method-less containers (a
34
+ # ``Class.new(StandardError)`` error class, an empty module) that have no
35
+ # `method` edge to be found by.
36
+ _BARE_CONST_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$")
37
+
38
+
39
+ def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
40
+ calls: list[dict] = []
41
+ for result in per_file:
42
+ if not isinstance(result, dict):
43
+ continue
44
+ for rc in result.get("raw_calls", []):
45
+ if not isinstance(rc, dict):
46
+ continue
47
+ sf = str(rc.get("source_file", ""))
48
+ if sf.endswith((".rb", ".rake")):
49
+ calls.append(rc)
50
+ return calls
51
+
52
+
53
+ def resolve_ruby_member_calls(
54
+ per_file: list[dict],
55
+ all_nodes: list[dict],
56
+ all_edges: list[dict],
57
+ ) -> None:
58
+ """Resolve Ruby ``Class.new`` and typed ``var.method`` calls by receiver type.
59
+
60
+ Purely additive: only emits edges the shared (name-based) call pass skips
61
+ because they are member calls. Each emission requires a single owning class
62
+ (god-node guard) so an ambiguous class name resolves to nothing rather than a
63
+ wrong edge.
64
+ """
65
+ node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
66
+
67
+ # class label key -> [class node ids]; (class_node_id, method_key) -> method id
68
+ class_def_nids: dict[str, list[str]] = {}
69
+ method_index: dict[tuple[str, str], str] = {}
70
+ for e in all_edges:
71
+ if e.get("relation") != "method":
72
+ continue
73
+ src, tgt = e.get("source"), e.get("target")
74
+ cnode = node_by_id.get(src)
75
+ if cnode is not None:
76
+ clabel = str(cnode.get("label", ""))
77
+ class_def_nids.setdefault(_key(clabel), []).append(str(src))
78
+ # A nested/compact declaration labels the node fully qualified
79
+ # (`Billing::Processor`), but its receivers reference the bare last
80
+ # segment (`Processor.new`), so index that too — the unique-match
81
+ # guard below still bails on genuine collisions (#2302).
82
+ if "::" in clabel:
83
+ class_def_nids.setdefault(_key(clabel.split("::")[-1]), []).append(str(src))
84
+ tnode = node_by_id.get(tgt)
85
+ if tnode is not None:
86
+ method_name = str(tnode.get("label", "")).strip("()").lstrip(".")
87
+ method_index[(str(src), method_name)] = str(tgt)
88
+ # Also register class/module container nodes that own no `method` edge — a
89
+ # method-less `Class.new(StandardError)` or an empty module — so a constant
90
+ # receiver still resolves to a real node (#1640/#1634). External base stubs
91
+ # carry an empty source_file, so the `.rb` filter keeps them out.
92
+ for n in all_nodes:
93
+ nid = n.get("id")
94
+ sf = str(n.get("source_file", ""))
95
+ label = str(n.get("label", ""))
96
+ if nid and sf.endswith((".rb", ".rake")) and _BARE_CONST_RE.match(label):
97
+ class_def_nids.setdefault(_key(label), []).append(str(nid))
98
+ if "::" in label:
99
+ class_def_nids.setdefault(_key(label.split("::")[-1]), []).append(str(nid))
100
+ for k in list(class_def_nids):
101
+ class_def_nids[k] = sorted(set(class_def_nids[k]))
102
+
103
+ def _segment_path(label: str) -> list[str]:
104
+ return [s.strip().lower() for s in str(label).split("::") if s.strip()]
105
+
106
+ # Fully-qualified and last-segment views of the same definitions, for the
107
+ # scoped mixin lookup: `include Foo::Bar` must match a `Foo::Bar` label as a
108
+ # whole path, never just its tail.
109
+ fq_label_map: dict[tuple[str, ...], list[str]] = {}
110
+ last_segment_map: dict[str, list[str]] = {}
111
+ for nid in sorted({nid for nids in class_def_nids.values() for nid in nids}):
112
+ segs = _segment_path(str(node_by_id.get(nid, {}).get("label", "")))
113
+ if segs:
114
+ fq_label_map.setdefault(tuple(segs), []).append(nid)
115
+ last_segment_map.setdefault(segs[-1], []).append(nid)
116
+
117
+ existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
118
+
119
+ def _unique_class(name: str) -> str | None:
120
+ nids = class_def_nids.get(_key(name), [])
121
+ return nids[0] if len(nids) == 1 else None
122
+
123
+ def _class_by_const_path(raw: str) -> str | None:
124
+ """Resolve a qualified constant receiver (``Billing::Processor``) to one class.
125
+
126
+ Matches on the constant path rather than its tail: a class qualifies when its
127
+ own label ends with the referenced segments, so ``Billing::Processor`` still
128
+ finds an ``App::Billing::Processor`` while ``ActiveRecord::Base`` no longer
129
+ binds to an unrelated ``Thing::Base`` (#3078). A leading ``::`` pins the
130
+ reference to top level, so it must match the label whole. Ambiguous, or
131
+ matching nothing in the corpus (the usual case for a framework constant) ->
132
+ no edge, never a guess.
133
+ """
134
+ segs = tuple(_segment_path(raw))
135
+ if not segs:
136
+ return None
137
+ if raw.strip().startswith("::"):
138
+ nids = fq_label_map.get(segs, [])
139
+ return nids[0] if len(nids) == 1 else None
140
+ hits = {nid for path, nids in fq_label_map.items()
141
+ if len(path) >= len(segs) and path[-len(segs):] == segs
142
+ for nid in nids}
143
+ return next(iter(hits)) if len(hits) == 1 else None
144
+
145
+ def _emit(caller: str, target: str, rc: dict[str, Any],
146
+ relation: str = "calls", context: str = "call") -> None:
147
+ if not caller or not target or caller == target:
148
+ return
149
+ if (caller, target) in existing_pairs:
150
+ return
151
+ existing_pairs.add((caller, target))
152
+ all_edges.append({
153
+ "source": caller,
154
+ "target": target,
155
+ "relation": relation,
156
+ "context": context,
157
+ "confidence": "EXTRACTED",
158
+ "confidence_score": 1.0,
159
+ "source_file": rc.get("source_file", ""),
160
+ "source_location": rc.get("source_location"),
161
+ "weight": 1.0,
162
+ })
163
+
164
+ # `include`/`extend`/`prepend <Const>` mixins (#1668): resolve the module
165
+ # reference lexically, the way Ruby constant lookup works (#2302) — try the
166
+ # reference under each enclosing scope of the including class, innermost
167
+ # first, then top level (a leading `::` pins it to top level). A qualified
168
+ # external like `ActiveSupport::Concern` matches no in-corpus path and
169
+ # produces no edge; an unqualified reference with no lexical match falls
170
+ # back to a globally unique last segment, under the same single-definition
171
+ # god-node guard. Ambiguous at any step -> bail, no wrong edge.
172
+ for rc in _ruby_raw_calls(per_file):
173
+ if not rc.get("is_mixin"):
174
+ continue
175
+ caller = str(rc.get("caller_nid", ""))
176
+ module_name = rc.get("callee")
177
+ if not caller or not module_name:
178
+ continue
179
+ raw_ref = str(module_name)
180
+ absolute = raw_ref.startswith("::")
181
+ ref_segs = _segment_path(raw_ref)
182
+ caller_node = node_by_id.get(caller)
183
+ caller_segs = [] if absolute else _segment_path(
184
+ str(caller_node.get("label", "")) if caller_node else "")
185
+ target: str | None = None
186
+ for i in range(len(caller_segs), -1, -1):
187
+ nids = fq_label_map.get(tuple(caller_segs[:i] + ref_segs), [])
188
+ if len(nids) == 1:
189
+ target = nids[0]
190
+ break
191
+ if len(nids) > 1:
192
+ break # reopened/ambiguous definition: bail
193
+ if target is None and len(ref_segs) == 1 and not absolute:
194
+ nids = last_segment_map.get(ref_segs[0], [])
195
+ if len(nids) == 1:
196
+ target = nids[0]
197
+ if target is not None:
198
+ _emit(caller, target, rc, relation="mixes_in", context="mixin")
199
+
200
+ for rc in _ruby_raw_calls(per_file):
201
+ if not rc.get("is_member_call"):
202
+ continue
203
+ caller = str(rc.get("caller_nid", ""))
204
+ callee = rc.get("callee")
205
+ if not caller or not callee:
206
+ continue
207
+
208
+ # Constant receiver: `Processor.new` (instantiation) or `Service.call` /
209
+ # `Model.where` (singleton / class method). The bare method name would
210
+ # collide with unrelated same-named methods, so we resolve by the
211
+ # receiver's class under the single-owning-class god-node guard.
212
+ receiver = rc.get("receiver")
213
+ # `lstrip(":")` so a top-level-pinned `::Processor.call` is still recognised
214
+ # as a constant receiver now that the whole path is captured (#3078).
215
+ if receiver and str(receiver).lstrip(":")[:1].isupper():
216
+ recv_raw = str(receiver)
217
+ class_nid = (_class_by_const_path(recv_raw) if "::" in recv_raw
218
+ else _unique_class(recv_raw))
219
+ if class_nid is not None:
220
+ if callee == "new":
221
+ _emit(caller, class_nid, rc)
222
+ else:
223
+ # Emit to the singleton/instance method the class owns
224
+ # (`def self.call`, which the extractor indexes); otherwise
225
+ # to the class node itself, so inherited/dynamic class methods
226
+ # like ActiveRecord `where`/`find_by` still give correct
227
+ # blast-radius. An ambiguous receiver bails to nothing.
228
+ method_nid = method_index.get((class_nid, str(callee)))
229
+ _emit(caller, method_nid or class_nid, rc)
230
+ continue
231
+
232
+ # `p.run` where p's type is known -> edge to that class's method.
233
+ receiver_type = rc.get("receiver_type")
234
+ if not receiver_type:
235
+ continue
236
+ class_nid = _unique_class(str(receiver_type))
237
+ if class_nid is None:
238
+ continue
239
+ method_nid = method_index.get((class_nid, str(callee)))
240
+ if method_nid is None:
241
+ continue
242
+ _emit(caller, method_nid, rc)