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/paths.py ADDED
@@ -0,0 +1,436 @@
1
+ """Single source of truth for the graphify output-directory name.
2
+
3
+ The output directory is ``graphify-out`` by default and overridable with the
4
+ ``GRAPHIFY_OUT`` env var (worktrees or shared-output setups, #686). It accepts a
5
+ relative name (``"graphify-out-feature"``) or an absolute path
6
+ (``"/shared/graphify-out"``).
7
+
8
+ This used to be duplicated as an identical ``_GRAPHIFY_OUT`` constant in
9
+ ``__main__``, ``cache``, and ``watch``, while ``security`` and ``callflow_html``
10
+ hardcoded the literal ``"graphify-out"`` and silently ignored the override
11
+ (#1423). Centralising it here keeps the name in one place. The value is read
12
+ once at import time, matching the previous per-module constants — set
13
+ ``GRAPHIFY_OUT`` before the process starts (the normal worktree/shared-output
14
+ flow) and every reader honours it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import stat
23
+ import tempfile
24
+ from pathlib import Path, PurePosixPath, PureWindowsPath
25
+
26
+ GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
27
+
28
+
29
+ def _atomic_replace(path: "str | Path", write_fn) -> None:
30
+ """Atomically replace ``path`` with content written by ``write_fn(f)``.
31
+
32
+ Writes a temp file in the SAME directory, then ``os.replace``s it into place
33
+ (an atomic rename on one filesystem). A process kill (SIGKILL/Ctrl-C), OOM, or
34
+ ENOSPC mid-write leaves the previous file intact — the destination is
35
+ untouched until the rename. This is NOT a power-loss durability guarantee:
36
+ there is no fsync (matching the rest of the codebase), so an OS/hardware crash
37
+ right after the rename can still expose unflushed bytes on some filesystems.
38
+ The temp file is removed if the write fails.
39
+
40
+ A symlinked destination is resolved first so the write goes THROUGH the link
41
+ to its target (rather than replacing the link with a regular file), keeping
42
+ the shared-output/worktree symlink setups this module documents working.
43
+ """
44
+ # Resolve symlinks so the temp lands on the target's filesystem (same-fs
45
+ # atomic rename) and the replace writes through the link, not over it.
46
+ real = Path(os.path.realpath(str(path)))
47
+ real.parent.mkdir(parents=True, exist_ok=True)
48
+ fd, tmp = tempfile.mkstemp(dir=str(real.parent), prefix=".gfy-", suffix=".tmp")
49
+ try:
50
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
51
+ write_fn(f)
52
+ # mkstemp creates the temp file 0600; match the destination's existing
53
+ # mode (or the umask default for a new file) so an atomic replace never
54
+ # silently tightens a previously group/world-readable output to
55
+ # owner-only. Best-effort — a chmod failure must not fail the write.
56
+ try:
57
+ mode = stat.S_IMODE(os.stat(real).st_mode)
58
+ except OSError:
59
+ umask = os.umask(0)
60
+ os.umask(umask)
61
+ mode = 0o666 & ~umask
62
+ try:
63
+ os.chmod(tmp, mode)
64
+ except OSError:
65
+ pass
66
+ try:
67
+ os.replace(tmp, str(real))
68
+ except PermissionError:
69
+ # Windows: os.replace fails (WinError 5/32) when the destination is
70
+ # briefly locked by another handle (antivirus, an open reader). Fall
71
+ # back to copy-then-delete, matching graphify.cache's atomic writer.
72
+ import shutil
73
+ shutil.copy2(tmp, str(real))
74
+ os.unlink(tmp)
75
+ except BaseException:
76
+ try:
77
+ os.unlink(tmp)
78
+ except OSError:
79
+ # The temp was chmod'd to match the destination above, so when the
80
+ # destination is read-only the temp is too — and Windows refuses to
81
+ # unlink a read-only file. Clear the bit and retry, or every failed
82
+ # write leaks a `.gfy-*.tmp` into the output directory.
83
+ try:
84
+ os.chmod(tmp, stat.S_IWRITE)
85
+ os.unlink(tmp)
86
+ except OSError:
87
+ pass
88
+ raise
89
+
90
+
91
+ def write_text_atomic(path: "str | Path", text: str) -> None:
92
+ """Atomically write ``text`` (UTF-8) to ``path``. See :func:`_atomic_replace`."""
93
+ _atomic_replace(path, lambda f: f.write(text))
94
+
95
+
96
+ def write_json_atomic(path: "str | Path", obj, *, indent: "int | None" = None, ensure_ascii: bool = True) -> None:
97
+ """Atomically write ``obj`` as JSON to ``path``, streaming the encode into the
98
+ temp file rather than materializing the whole string first (matters for very
99
+ large graphs). ``ensure_ascii`` mirrors ``json.dump`` so callers that emit raw
100
+ UTF-8 (non-ASCII labels/paths) keep byte-for-byte output. See :func:`_atomic_replace`."""
101
+ _atomic_replace(path, lambda f: json.dump(obj, f, indent=indent, ensure_ascii=ensure_ascii))
102
+
103
+ # Directory segments that, when they appear as a whole path component, mark the
104
+ # whole path as a test location. Matched against path *segments* (not raw
105
+ # substrings) so "src/contest.py" / "latest/x.py" / "src/greatest/x.py" do NOT
106
+ # match — only a segment that *equals* one of these names (case-insensitively).
107
+ _TEST_DIR_SEGMENTS = frozenset({"tests", "test", "spec", "specs", "__tests__"})
108
+
109
+ # Filename patterns marking a file as a test, matched against the *filename*
110
+ # only (case-insensitive). These are conventions across ecosystems:
111
+ # test_*.py pytest / unittest
112
+ # *_test.* Go / Python / Rust
113
+ # *.test.* JS/TS (jest, vitest)
114
+ # *.spec.* / *_spec.* Jasmine / RSpec / Karma
115
+ # *.Tests.ps1 PowerShell Pester
116
+ # *Test.java / *Tests.cs (case-sensitive convention, handled below)
117
+ _TEST_FILENAME_PATTERNS = (
118
+ re.compile(r"^test_.*", re.IGNORECASE),
119
+ re.compile(r".*_test\..+$", re.IGNORECASE),
120
+ re.compile(r".*\.test\..+$", re.IGNORECASE),
121
+ re.compile(r".*\.spec\..+$", re.IGNORECASE),
122
+ re.compile(r".*_spec\..+$", re.IGNORECASE),
123
+ re.compile(r".*\.tests\.ps1$", re.IGNORECASE),
124
+ # Java `FooTest.java` / `FooTests.java`, C# `FooTests.cs` style. Require an
125
+ # uppercase-led `Test`/`Tests` immediately before the extension so plain
126
+ # words like "greatest"/"contest.cs" do not match.
127
+ re.compile(r".*Test\.java$"),
128
+ re.compile(r".*Tests\.java$"),
129
+ re.compile(r".*Tests\.cs$"),
130
+ )
131
+
132
+
133
+ def _is_test_path(path: str) -> bool:
134
+ """Classify a source path as a test path (case-insensitive, segment-aware).
135
+
136
+ Shared by extract.py and symbol_resolution.py so cross-file call resolution
137
+ treats test mocks/stubs identically. A path is a test path when:
138
+ * any whole path segment equals a known test dir name
139
+ (``tests``/``test``/``spec``/``specs``/``__tests__``), or
140
+ * the filename matches a known test-file naming convention.
141
+
142
+ Conservative on purpose: matches segments/filenames, never raw substrings,
143
+ so ``latest.py``, ``src/contest.py`` and ``src/greatest/x.py`` are NON-test.
144
+ """
145
+ if not path:
146
+ return False
147
+ # Accept both POSIX and Windows separators regardless of host OS so the
148
+ # classifier is stable across the mixed paths that flow through extraction.
149
+ norm = str(path).replace("\\", "/")
150
+ pure = PurePosixPath(norm)
151
+ segments = list(pure.parts)
152
+ # Strip a leading drive/anchor segment (e.g. "C:/") that PureWindowsPath
153
+ # would surface; with the manual "\\"->"/" swap above PurePosixPath keeps
154
+ # the path body intact, but guard against a Windows drive embedded as a
155
+ # segment just in case.
156
+ for segment in segments:
157
+ if segment.lower() in _TEST_DIR_SEGMENTS:
158
+ return True
159
+ # A drive-letter colon segment like "c:" is never a test dir.
160
+ filename = pure.name
161
+ if not filename:
162
+ return False
163
+ for pattern in _TEST_FILENAME_PATTERNS:
164
+ if pattern.match(filename):
165
+ return True
166
+ return False
167
+
168
+
169
+ def _path_proximity_winner(call_site_file: str, candidate_files: dict[str, str]) -> str | None:
170
+ """Pick the candidate whose source file is closest to the call site.
171
+
172
+ ``candidate_files`` maps candidate id -> its source_file. Returns a single
173
+ winning candidate id, or ``None`` when no proximity tier yields a unique
174
+ winner. Tiers, in order:
175
+
176
+ 1. same file as the call site,
177
+ 2. same directory,
178
+ 3. longest common path-prefix (must be a strict, unique maximum).
179
+
180
+ Used only as a secondary tie-break after the test/non-test filter, so the
181
+ god-node guard still holds when proximity is genuinely ambiguous.
182
+ """
183
+ if not call_site_file:
184
+ return None
185
+ call_norm = str(call_site_file).replace("\\", "/")
186
+ call_dir = PurePosixPath(call_norm).parent
187
+
188
+ # Tier 1: exact same file.
189
+ same_file = [cid for cid, f in candidate_files.items()
190
+ if str(f).replace("\\", "/") == call_norm]
191
+ if len(same_file) == 1:
192
+ return same_file[0]
193
+ if len(same_file) > 1:
194
+ return None # genuinely ambiguous within one file; bail
195
+
196
+ # Tier 2: same directory.
197
+ same_dir = [cid for cid, f in candidate_files.items()
198
+ if PurePosixPath(str(f).replace("\\", "/")).parent == call_dir]
199
+ if len(same_dir) == 1:
200
+ return same_dir[0]
201
+ if len(same_dir) > 1:
202
+ return None
203
+
204
+ # Tier 3: longest common path-prefix, computed over path segments. The
205
+ # winner must be a strict unique maximum, else we bail (guard holds).
206
+ call_parts = call_dir.parts
207
+
208
+ def _common_prefix_len(f: str) -> int:
209
+ parts = PurePosixPath(str(f).replace("\\", "/")).parent.parts
210
+ n = 0
211
+ for a, b in zip(call_parts, parts):
212
+ if a != b:
213
+ break
214
+ n += 1
215
+ return n
216
+
217
+ scored = sorted(
218
+ ((cid, _common_prefix_len(f)) for cid, f in candidate_files.items()),
219
+ key=lambda kv: kv[1],
220
+ reverse=True,
221
+ )
222
+ if not scored:
223
+ return None
224
+ best = scored[0][1]
225
+ winners = [cid for cid, score in scored if score == best]
226
+ if len(winners) == 1 and best > 0:
227
+ return winners[0]
228
+ return None
229
+
230
+
231
+ def disambiguate_ambiguous_candidates(
232
+ candidates: list[str],
233
+ candidate_files: dict[str, str],
234
+ call_site_file: str,
235
+ ) -> str | None:
236
+ """Resolve an ambiguous bare-name call to one candidate, or ``None``.
237
+
238
+ Shared god-node tie-breaker (#1553) used by both the inline cross-file call
239
+ pass in ``extract.py`` and ``symbol_resolution.resolve_cross_file_raw_calls``
240
+ so the heuristics stay aligned across languages. ``candidates`` is the list
241
+ of node ids sharing the callee's name; ``candidate_files`` maps each id ->
242
+ its source_file. Returns the surviving candidate id only when exactly one
243
+ survives; otherwise ``None`` (caller keeps the god-node guard / ``continue``).
244
+
245
+ Tie-breakers, in order:
246
+ 1. NON-TEST preference. Classify the call site and each candidate as
247
+ test/non-test. When the call site is NON-test, drop test candidates.
248
+ When the call site IS a test file, prefer test-local candidates
249
+ (same file first, then any test candidate); fall back to the full set
250
+ only if no test candidate exists.
251
+ 2. PATH PROXIMITY over whatever survived step 1.
252
+ """
253
+ if not candidates:
254
+ return None
255
+ if len(candidates) == 1:
256
+ return candidates[0]
257
+
258
+ call_is_test = _is_test_path(call_site_file)
259
+ test_cands = [c for c in candidates if _is_test_path(candidate_files.get(c, ""))]
260
+ nontest_cands = [c for c in candidates if c not in set(test_cands)]
261
+
262
+ if call_is_test:
263
+ # Prefer a test-local definition (same file) first.
264
+ call_norm = str(call_site_file).replace("\\", "/")
265
+ same_file_test = [
266
+ c for c in test_cands
267
+ if str(candidate_files.get(c, "")).replace("\\", "/") == call_norm
268
+ ]
269
+ if len(same_file_test) == 1:
270
+ return same_file_test[0]
271
+ if test_cands:
272
+ survivors = test_cands
273
+ else:
274
+ survivors = nontest_cands or candidates
275
+ else:
276
+ # Non-test call site: drop test mocks/stubs entirely.
277
+ survivors = nontest_cands
278
+
279
+ if len(survivors) == 1:
280
+ return survivors[0]
281
+ if not survivors:
282
+ return None
283
+
284
+ # Step 2: path proximity over the survivors.
285
+ return _path_proximity_winner(
286
+ call_site_file,
287
+ {c: candidate_files.get(c, "") for c in survivors},
288
+ )
289
+
290
+ # Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by path
291
+ # guards that walk parents looking for the output directory by name.
292
+ GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT))
293
+
294
+
295
+ def out_path(*parts: str) -> Path:
296
+ """A path inside the configured output dir, e.g. ``out_path("cache")``.
297
+
298
+ ``Path(GRAPHIFY_OUT) / ...`` resolves correctly for both a relative name
299
+ ("graphify-out") and an absolute override ("/shared/graphify-out").
300
+ """
301
+ return Path(GRAPHIFY_OUT, *parts)
302
+
303
+
304
+ def default_graph_json() -> str:
305
+ """Default ``graph.json`` path under the configured output dir.
306
+
307
+ The package-wide fallback used by serve/build/benchmark/prs and the CLI read
308
+ commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where
309
+ the path is passed explicitly (#1423).
310
+ """
311
+ return str(out_path("graph.json"))
312
+
313
+
314
+ def is_absolute_any_platform(p: "str | Path | None") -> bool:
315
+ """Whether *p* is absolute under POSIX **or** Windows rules.
316
+
317
+ ``Path.is_absolute()`` and ``os.path.isabs()`` answer for the HOST os only,
318
+ which is the wrong question for a path that was *stored* — a ``source_file``
319
+ in ``graph.json``, a ``prune_sources`` entry, a cache key. Those travel
320
+ between machines (build in Docker/CI, update on a Windows workstation, or
321
+ the reverse), so the host's rules do not describe the string in hand:
322
+
323
+ - On Windows, ``WindowsPath("/home/ci/repo/docs/a.md").is_absolute()`` is
324
+ False — no drive letter — so a Linux-built graph's absolute paths read as
325
+ relative and get baked into node IDs or joined under the scan root (#2618).
326
+ - On POSIX, ``PosixPath("C:/Users/u/a.md").is_absolute()`` is False for the
327
+ mirror-image reason (#2197, #1789).
328
+
329
+ ``os.path.isabs`` is additionally not stable across supported interpreters:
330
+ Python 3.13 changed ``ntpath.isabs`` so a path starting with a single slash
331
+ is no longer absolute, where 3.10–3.12 said it was. The project supports
332
+ >=3.10, so a guard written on it silently means different things per version.
333
+
334
+ Answering for both platforms is the conservative choice for stored paths:
335
+ treating a path as absolute at worst declines to relativize it (the string is
336
+ kept as-is), whereas treating an absolute path as relative corrupts identity.
337
+ Covers drive-letter, UNC, and POSIX-root forms with either separator.
338
+
339
+ NOTE: this is for STORED/portable paths. Code resolving a path against the
340
+ real local filesystem (``cli``, ``detect``, ``hooks``) must keep using
341
+ ``Path.is_absolute()`` — there the host's rules are exactly right.
342
+ """
343
+ if not p:
344
+ return False
345
+ s = str(p)
346
+ return PurePosixPath(s).is_absolute() or PureWindowsPath(s).is_absolute()
347
+
348
+
349
+ # Legacy Windows path ceiling. Unless long-path support is enabled *and* every
350
+ # consumer opts in, the ENTIRE path — drive, directories, filename, and the
351
+ # terminating NUL — must fit in MAX_PATH (260) characters, so the usable budget
352
+ # is 259. POSIX has no equivalent whole-path ceiling in practice; its limit is
353
+ # per-component (NAME_MAX, conventionally 255 bytes).
354
+ _WINDOWS_MAX_PATH = 260
355
+
356
+ # Floor for the stem budget below. A directory deep enough to push the budget
357
+ # under this cannot host readable filenames anyway; keep enough room for
358
+ # _cap_filename's "_" + 8-char digest so a truncated stem stays collision-safe
359
+ # and deterministic rather than degenerating into a bare prefix.
360
+ _MIN_STEM_BUDGET = 16
361
+
362
+
363
+ def stem_filename_budget(output_dir: "str | Path", *, reserve: int = 0, limit: int = 200) -> int:
364
+ """Largest filename stem an exporter may write directly into ``output_dir``.
365
+
366
+ Exporters cap note/article filenames so they stay under the filesystem's
367
+ per-component limit (conventionally NAME_MAX=255 bytes, hence the 200
368
+ default). That is the right question on POSIX and the wrong one on Windows,
369
+ where the constraint is on the WHOLE path, not the component: a 200-char
370
+ stem under a perfectly ordinary vault directory such as
371
+ ``C:\\Users\\me\\projects\\svc\\graphify-out\\obsidian`` exceeds MAX_PATH and
372
+ the write dies with ``FileNotFoundError``, aborting the export mid-vault.
373
+
374
+ Returns ``limit`` unchanged on POSIX, so existing output is byte-for-byte
375
+ stable there. On Windows it returns the smaller of ``limit`` and whatever
376
+ still fits inside MAX_PATH once ``output_dir``, the separator, ``reserve``
377
+ (room for caller-added prefixes/collision suffixes) and the ``.md``
378
+ extension are accounted for.
379
+
380
+ The budget is a CHARACTER count, but callers that cap UTF-8 BYTES may pass
381
+ it straight through: a string's UTF-8 length is never below its character
382
+ length, so a byte-capped stem always satisfies the character ceiling too.
383
+ """
384
+ if os.name != "nt":
385
+ return limit
386
+ try:
387
+ base = os.path.abspath(str(output_dir))
388
+ except (OSError, ValueError):
389
+ return limit
390
+ # An extended-length path ("\\?\C:\...", "\\?\UNC\...") opts out of MAX_PATH
391
+ # entirely, so nothing needs shrinking.
392
+ if base.startswith("\\\\?\\"):
393
+ return limit
394
+ budget = (_WINDOWS_MAX_PATH - 1) - len(base) - len(os.sep) - reserve - len(".md")
395
+ return max(_MIN_STEM_BUDGET, min(limit, budget))
396
+
397
+
398
+ def nfc(s: str) -> str:
399
+ """NFC-normalize a path string.
400
+
401
+ macOS (HFS+/APFS) reports filenames in NFD while manifests, graph
402
+ ``source_file`` entries and user input are typically NFC. Comparing raw
403
+ strings makes the same file look like two different paths, so any path
404
+ membership test must normalize BOTH sides (#2210, #2221/#2224).
405
+ """
406
+ import unicodedata
407
+ return unicodedata.normalize("NFC", s)
408
+
409
+
410
+ def load_node_link_graph(path_or_data):
411
+ """Load a graphify graph.json into a networkx graph, accepting both writers.
412
+
413
+ The clustered writer stores edges under ``links`` (networkx's node-link
414
+ default); the raw ``--no-cluster`` writer stores them under ``edges``.
415
+ Consumers that call ``node_link_graph(data, edges="links")`` directly
416
+ raise ``KeyError: 'links'`` on a raw graph (#2212) — the ``except
417
+ TypeError`` fallback only covers old networkx without the ``edges``
418
+ kwarg, not the missing key. Normalize before parsing, same idiom as
419
+ affected.py/serve.py.
420
+
421
+ Accepts a path (size-cap-checked via the security module, then parsed)
422
+ or an already-parsed dict (no size check — the caller owns any cap).
423
+ """
424
+ from networkx.readwrite import json_graph
425
+ data = path_or_data
426
+ if not isinstance(data, dict):
427
+ p = Path(data)
428
+ from graphify.security import check_graph_file_size_cap # lazy: security imports paths
429
+ check_graph_file_size_cap(p)
430
+ data = json.loads(p.read_text(encoding="utf-8"))
431
+ if isinstance(data, dict) and "links" not in data and "edges" in data:
432
+ data = dict(data, links=data["edges"])
433
+ try:
434
+ return json_graph.node_link_graph(data, edges="links")
435
+ except TypeError: # networkx too old for the edges kwarg; default is "links"
436
+ return json_graph.node_link_graph(data)
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path, PurePosixPath
3
+ from graphify.extract import extract_sql
4
+
5
+
6
+ def _quote_ident(name: str) -> str:
7
+ """Double-quote a PostgreSQL identifier, escaping embedded double-quotes."""
8
+ return '"' + name.replace('"', '""') + '"'
9
+
10
+
11
+ def introspect_postgres(dsn: str | None = None) -> dict:
12
+ """Connect to PostgreSQL, reconstruct DDL, and extract via extract_sql()."""
13
+ try:
14
+ import psycopg
15
+ except ModuleNotFoundError:
16
+ raise ImportError(
17
+ "psycopg is required for --postgres. "
18
+ "Install with: pip install 'graphifyy[postgres]'"
19
+ )
20
+
21
+ try:
22
+ conn = psycopg.connect(dsn or "") # empty string = PG* env vars
23
+ except psycopg.OperationalError as exc:
24
+ # Sanitize: strip the DSN/credentials that psycopg may embed in the
25
+ # OperationalError message (e.g. "connection to server … failed: …\nDETAIL: …")
26
+ msg = str(exc).split("\n")[0]
27
+ raise ConnectionError(f"could not connect to PostgreSQL: {msg}") from None
28
+
29
+ try:
30
+ conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE")
31
+
32
+ # 1. Query tables
33
+ with conn.cursor() as cur:
34
+ cur.execute("""
35
+ SELECT table_schema, table_name, table_type
36
+ FROM information_schema.tables
37
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
38
+ ORDER BY table_schema, table_name;
39
+ """)
40
+ tables = cur.fetchall()
41
+
42
+ # 2. Query views
43
+ cur.execute("""
44
+ SELECT table_schema, table_name, view_definition
45
+ FROM information_schema.views
46
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
47
+ ORDER BY table_schema, table_name;
48
+ """)
49
+ views = cur.fetchall()
50
+
51
+ # 3. Query routines (functions/procedures), including language
52
+ cur.execute("""
53
+ SELECT routine_schema, routine_name, routine_type,
54
+ routine_definition, external_language
55
+ FROM information_schema.routines
56
+ WHERE routine_schema NOT IN ('pg_catalog', 'information_schema')
57
+ ORDER BY routine_schema, routine_name;
58
+ """)
59
+ routines = cur.fetchall()
60
+
61
+ # 4. Query foreign keys — grouped by constraint to handle composites.
62
+ # Read pg_catalog.pg_constraint, NOT information_schema.referential_
63
+ # constraints: that view only shows constraints where the current
64
+ # user has WRITE access to the referencing table (owner or a
65
+ # privilege other than SELECT), so a read-only introspection role
66
+ # sees zero FK rows while tables/views/routines all appear — the
67
+ # graph then silently loses every 'references' edge (#1746).
68
+ # pg_constraint is not privilege-filtered. It also keys constraints
69
+ # by oid rather than by name (constraint names are only unique per
70
+ # table, so the old name-based key_column_usage joins could
71
+ # cross-match same-named constraints on sibling tables).
72
+ cur.execute("""
73
+ SELECT
74
+ con.conname AS constraint_name,
75
+ ns.nspname AS table_schema,
76
+ rel.relname AS table_name,
77
+ (SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
78
+ FROM UNNEST(con.conkey) WITH ORDINALITY AS k(attnum, ord)
79
+ JOIN pg_catalog.pg_attribute att
80
+ ON att.attrelid = con.conrelid AND att.attnum = k.attnum
81
+ ) AS columns,
82
+ fns.nspname AS foreign_table_schema,
83
+ frel.relname AS foreign_table_name,
84
+ (SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
85
+ FROM UNNEST(con.confkey) WITH ORDINALITY AS k(attnum, ord)
86
+ JOIN pg_catalog.pg_attribute att
87
+ ON att.attrelid = con.confrelid AND att.attnum = k.attnum
88
+ ) AS foreign_columns
89
+ FROM pg_catalog.pg_constraint con
90
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
91
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
92
+ JOIN pg_catalog.pg_class frel ON frel.oid = con.confrelid
93
+ JOIN pg_catalog.pg_namespace fns ON fns.oid = frel.relnamespace
94
+ WHERE con.contype = 'f'
95
+ AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
96
+ ORDER BY ns.nspname, rel.relname, con.conname;
97
+ """)
98
+ fks = cur.fetchall()
99
+ finally:
100
+ conn.close()
101
+
102
+ ddl = []
103
+
104
+ # Tables — quote identifiers to handle reserved words, hyphens, mixed-case
105
+ for schema, name, ttype in tables:
106
+ if ttype == "BASE TABLE":
107
+ ddl.append(f"CREATE TABLE {_quote_ident(schema)}.{_quote_ident(name)} (id INT);")
108
+
109
+ # Views — real body if available, stub if NULL (permission denied)
110
+ for schema, name, body in views:
111
+ if body:
112
+ ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS {body};")
113
+ else:
114
+ ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS SELECT 1;")
115
+
116
+ # FK edges — one ALTER TABLE per constraint (handles composite FKs correctly).
117
+ # Emitted BEFORE the function DDL: routine bodies the grammar can't parse
118
+ # (notably C-language extension functions, whose "body" is just the C symbol
119
+ # name) put tree-sitter into error recovery that consumes the statements
120
+ # after them — with FKs last, every 'references' edge is silently lost on
121
+ # any DB with a common extension installed (#1854). FK statements only
122
+ # reference tables, which are emitted first, so this order is always safe.
123
+ for constraint_name, t_schema, t_name, cols, r_schema, r_name, r_cols in fks:
124
+ col_list = ", ".join(_quote_ident(c) for c in cols)
125
+ ref_col_list = ", ".join(_quote_ident(c) for c in r_cols)
126
+ ddl.append(
127
+ f"ALTER TABLE {_quote_ident(t_schema)}.{_quote_ident(t_name)} "
128
+ f"ADD CONSTRAINT {_quote_ident(constraint_name)} "
129
+ f"FOREIGN KEY ({col_list}) REFERENCES {_quote_ident(r_schema)}.{_quote_ident(r_name)}({ref_col_list});"
130
+ )
131
+
132
+ # Functions & Procedures — real body if available, stub if NULL
133
+ # Use $gfx$ as the dollar-quote tag to avoid collision with $$ inside bodies.
134
+ # Use external_language from the catalog; fall back to plpgsql if NULL/blank.
135
+ for schema, name, rtype, body, ext_lang in routines:
136
+ lang = (ext_lang or "plpgsql").lower()
137
+ fn_sig = f"{_quote_ident(schema)}.{_quote_ident(name)}()"
138
+ stub_body = "BEGIN SELECT 1; END;"
139
+ if rtype in ("FUNCTION", "PROCEDURE"):
140
+ actual_body = body if body else stub_body
141
+ # Represent PROCEDUREs as FUNCTION so tree-sitter-sql can parse them
142
+ ddl.append(
143
+ f"CREATE FUNCTION {fn_sig} RETURNS void"
144
+ f" AS $gfx$ {actual_body} $gfx$ LANGUAGE {lang};"
145
+ )
146
+
147
+ ddl_string = "\n".join(ddl)
148
+
149
+ # Determine host/dbname for virtual path DSN sanitization
150
+ info = psycopg.conninfo.conninfo_to_dict(dsn or "")
151
+ host = info.get("host", "localhost")
152
+ dbname = info.get("dbname", "db")
153
+ virtual_path = PurePosixPath(f"postgresql://{host}/{dbname}")
154
+
155
+ # Pass virtual path and in-memory DDL content to extract_sql
156
+ result = extract_sql(virtual_path, content=ddl_string)
157
+ # extract_sql() reports a missing grammar as an 'error' key with empty
158
+ # nodes/edges; surface it instead of letting the CLI print
159
+ # "PostgreSQL: 0 nodes" as if the database were empty.
160
+ if result.get("error"):
161
+ raise ImportError(
162
+ f"{result['error']} (required by --postgres; "
163
+ "install with: pip install 'graphifyy[postgres]')"
164
+ )
165
+ return result