graphitect 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,556 @@
1
+ """Deterministic symbol indexing and conservative cross-file resolution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ import unicodedata
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from collections.abc import Sequence
11
+ from typing import Any
12
+
13
+ from graphify.ids import make_id as _shared_make_id
14
+ from graphify.paths import disambiguate_ambiguous_candidates
15
+ from graphify.security import sanitize_metadata
16
+
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ImportedSymbol:
21
+ """A Python imported name that can be used as deterministic resolution evidence."""
22
+
23
+ local_name: str
24
+ imported_name: str
25
+ module_stem: str
26
+ source_file: str
27
+ source_location: str
28
+
29
+
30
+ def normalise_callable_label(label: str) -> str:
31
+ """Normalize a node label into the key used for call resolution."""
32
+
33
+ return label.strip().strip("()").lstrip(".").lower()
34
+
35
+
36
+ def node_is_resolvable_symbol(node: dict[str, Any]) -> bool:
37
+ """Return True when a node is suitable for deterministic symbol lookup.
38
+
39
+ Requires ``file_type == "code"`` as the positive gate — only code-class
40
+ nodes participate as call targets. ``_EXCLUDED_FILE_TYPES`` is kept as
41
+ defensive-in-depth against legacy data, but the primary guard is the
42
+ positive code check. Document/paper/image/concept nodes (e.g. a Markdown
43
+ heading whose label happens to match a code identifier) MUST NOT become
44
+ callees for a raw code call.
45
+ """
46
+
47
+ if node.get("file_type") != "code":
48
+ return False
49
+ label = str(node.get("label", "")).strip()
50
+ if not label:
51
+ return False
52
+ if label.endswith((".py", ".js", ".ts", ".tsx", ".java", ".go", ".rs")):
53
+ return False
54
+ return bool(normalise_callable_label(label))
55
+
56
+
57
+ def build_label_index(nodes: list[dict[str, Any]]) -> dict[str, list[str]]:
58
+ """Build label -> node id list for conservative cross-file resolution."""
59
+
60
+ index: dict[str, list[str]] = {}
61
+ for node in nodes:
62
+ if not node_is_resolvable_symbol(node):
63
+ continue
64
+ node_id = node.get("id")
65
+ if not node_id:
66
+ continue
67
+ key = normalise_callable_label(str(node.get("label", "")))
68
+ if not key:
69
+ continue
70
+ index.setdefault(key, []).append(str(node_id))
71
+ return index
72
+
73
+
74
+ def existing_edge_pairs(edges: list[dict[str, Any]]) -> set[tuple[str, str, str]]:
75
+ """Return all existing source/target/relation edge triples.
76
+
77
+ Includes relation so that a prior "contains" or "method" edge does not
78
+ suppress a semantically distinct "calls" edge between the same endpoints (#F5).
79
+ """
80
+
81
+ triples: set[tuple[str, str, str]] = set()
82
+ for edge in edges:
83
+ source = edge.get("source")
84
+ target = edge.get("target")
85
+ relation = edge.get("relation", "")
86
+ if source and target:
87
+ triples.add((str(source), str(target), str(relation)))
88
+ return triples
89
+
90
+
91
+ def iter_raw_calls(per_file: Sequence[object]) -> list[dict[str, Any]]:
92
+ """Return raw calls from all per-file extraction fragments.
93
+
94
+ Parameter is ``Sequence[object]`` (not ``Sequence[dict[str, Any] | None]``)
95
+ because external extraction output may contain arbitrary deserialized
96
+ JSON. Defensive against malformed fragments: non-dict per-file entries
97
+ are skipped, non-list ``raw_calls`` are treated as empty, and non-dict
98
+ items inside the list are silently dropped. The downstream resolvers
99
+ assume every returned item is a dict and they expect this guarantee.
100
+ """
101
+
102
+ calls: list[dict[str, Any]] = []
103
+ for result in per_file:
104
+ if not isinstance(result, dict):
105
+ continue
106
+ raw_calls = result.get("raw_calls", [])
107
+ if not isinstance(raw_calls, list):
108
+ continue
109
+ for raw_call in raw_calls:
110
+ if isinstance(raw_call, dict):
111
+ calls.append(raw_call)
112
+ return calls
113
+
114
+
115
+ def _module_stem(module_name: str | None) -> str:
116
+ """Return the final module component used to match Graphify source stems."""
117
+
118
+ if not module_name:
119
+ return ""
120
+ return module_name.strip(".").split(".")[-1]
121
+
122
+
123
+ def parse_python_import_aliases(path: Path) -> dict[str, ImportedSymbol]:
124
+ """Parse deterministic Python import aliases from one source file.
125
+
126
+ Supported forms:
127
+ from helper import transform
128
+ from helper import transform as tx
129
+ from .helper import transform
130
+
131
+ The function deliberately does not resolve plain ``import helper`` member
132
+ calls because current raw call records do not preserve the receiver name from
133
+ ``helper.transform()``. That can be added later only after raw call facts are
134
+ extended to include the receiver expression.
135
+ """
136
+
137
+ try:
138
+ source = path.read_text(encoding="utf-8", errors="replace")
139
+ tree = ast.parse(source)
140
+ except (OSError, SyntaxError):
141
+ return {}
142
+
143
+ aliases: dict[str, ImportedSymbol] = {}
144
+ source_file = str(path)
145
+
146
+ # Only top-level `from ... import ...` statements count as file-wide
147
+ # evidence. Nested/function-local imports do NOT — they're only valid
148
+ # inside their lexical scope, and our raw-call records don't currently
149
+ # carry enough scope info to match the import site safely. Walking
150
+ # ast.walk(tree) would incorrectly justify calls in other scopes.
151
+ for node in tree.body:
152
+ if not isinstance(node, ast.ImportFrom):
153
+ continue
154
+ module_stem = _module_stem(node.module)
155
+ if not module_stem:
156
+ continue
157
+ for alias in node.names:
158
+ if alias.name == "*":
159
+ continue
160
+ local_name = alias.asname or alias.name
161
+ aliases[local_name] = ImportedSymbol(
162
+ local_name=local_name,
163
+ imported_name=alias.name,
164
+ module_stem=module_stem,
165
+ source_file=source_file,
166
+ source_location=f"L{getattr(node, 'lineno', 1)}",
167
+ )
168
+
169
+ return aliases
170
+
171
+
172
+ def _node_source_stem(node: dict[str, Any]) -> str:
173
+ """Return the stem of a node's source file."""
174
+
175
+ source_file = str(node.get("source_file", ""))
176
+ if not source_file:
177
+ return ""
178
+ return Path(source_file).stem
179
+
180
+
181
+ def build_python_symbol_index(nodes: list[dict[str, Any]]) -> dict[tuple[str, str], list[str]]:
182
+ """Build ``(module_stem, normalized_symbol_name) -> node_ids``.
183
+
184
+ This index is stricter than the global label index. It uses both the module
185
+ stem and the symbol label, which allows import evidence to resolve calls that
186
+ global label uniqueness alone cannot safely resolve.
187
+ """
188
+
189
+ index: dict[tuple[str, str], list[str]] = {}
190
+ for node in nodes:
191
+ if not node_is_resolvable_symbol(node):
192
+ continue
193
+ source_stem = _node_source_stem(node)
194
+ if not source_stem:
195
+ continue
196
+ label = normalise_callable_label(str(node.get("label", "")))
197
+ if not label:
198
+ continue
199
+ node_id = node.get("id")
200
+ if not node_id:
201
+ continue
202
+ index.setdefault((source_stem, label), []).append(str(node_id))
203
+ return index
204
+
205
+
206
+ def find_unique_python_symbol(
207
+ symbol_index: dict[tuple[str, str], list[str]],
208
+ imported: ImportedSymbol,
209
+ ) -> str | None:
210
+ """Resolve one imported symbol to exactly one Graphify node id."""
211
+
212
+ candidates = symbol_index.get((imported.module_stem, imported.imported_name.lower()), [])
213
+ if len(candidates) == 1:
214
+ return candidates[0]
215
+ return None
216
+
217
+
218
+ def resolve_python_import_guided_calls(
219
+ per_file: Sequence[object],
220
+ paths: Sequence[Path],
221
+ all_nodes: list[dict[str, Any]],
222
+ all_edges: list[dict[str, Any]],
223
+ ) -> list[dict[str, Any]]:
224
+ """Resolve raw Python calls using explicit import evidence.
225
+
226
+ Only ``from module import symbol [as alias]`` forms are handled. Member calls
227
+ remain skipped because the current raw call fact does not carry receiver
228
+ information.
229
+
230
+ Parameter ``per_file`` is ``Sequence[object]`` because external extraction
231
+ output may contain arbitrary deserialized JSON. Non-dict slots are
232
+ treated as empty fragments, and indices past ``len(per_file)`` are also
233
+ treated as empty (paths longer than per_file is tolerated).
234
+ """
235
+
236
+ symbol_index = build_python_symbol_index(all_nodes)
237
+ known_pairs = existing_edge_pairs(all_edges)
238
+ # Build result_by_file defensively:
239
+ # - skip indices past the end of per_file (paths shorter than per_file
240
+ # also OK; the zip-like behavior is what callers expect)
241
+ # - non-dict per_file slots fall back to the empty fragment so the
242
+ # downstream `.get("raw_calls", [])` lookup never raises
243
+ result_by_file: dict[str, dict[str, Any]] = {}
244
+ for index, path in enumerate(paths):
245
+ if path.suffix != ".py":
246
+ continue
247
+ slot: Any = per_file[index] if index < len(per_file) else None
248
+ result_by_file[str(path)] = slot if isinstance(slot, dict) else {"nodes": [], "edges": []}
249
+ resolved_edges: list[dict[str, Any]] = []
250
+
251
+ for path in paths:
252
+ if path.suffix != ".py":
253
+ continue
254
+ source_file = str(path)
255
+ aliases = parse_python_import_aliases(path)
256
+ if not aliases:
257
+ continue
258
+ file_result = result_by_file.get(source_file, {"raw_calls": []})
259
+ raw_calls = file_result.get("raw_calls", [])
260
+ if not isinstance(raw_calls, list):
261
+ continue
262
+ for raw_call in raw_calls:
263
+ if not isinstance(raw_call, dict):
264
+ continue
265
+ if raw_call.get("is_member_call"):
266
+ continue
267
+ callee = str(raw_call.get("callee", "")).strip()
268
+ if not callee:
269
+ continue
270
+ imported = aliases.get(callee)
271
+ if imported is None:
272
+ continue
273
+ target = find_unique_python_symbol(symbol_index, imported)
274
+ if target is None:
275
+ continue
276
+ caller = str(raw_call.get("caller_nid", ""))
277
+ if not caller or caller == target:
278
+ continue
279
+ pair = (caller, target, "calls")
280
+ if pair in known_pairs:
281
+ continue
282
+ known_pairs.add(pair)
283
+ resolved_edges.append(
284
+ {
285
+ "source": caller,
286
+ "target": target,
287
+ "relation": "calls",
288
+ "context": "import_guided_call",
289
+ "confidence": "EXTRACTED",
290
+ "confidence_score": 1.0,
291
+ "source_file": raw_call.get("source_file", source_file),
292
+ "source_location": raw_call.get("source_location") or imported.source_location,
293
+ "weight": 1.0,
294
+ "metadata": sanitize_metadata({
295
+ "resolver": "python_import_guided",
296
+ "local_name": imported.local_name,
297
+ "imported_name": imported.imported_name,
298
+ "module_stem": imported.module_stem,
299
+ "import_source_location": imported.source_location,
300
+ }),
301
+ }
302
+ )
303
+
304
+ return resolved_edges
305
+
306
+
307
+ def resolve_cross_file_raw_calls(
308
+ per_file: Sequence[dict[str, Any] | None],
309
+ all_nodes: list[dict[str, Any]],
310
+ all_edges: list[dict[str, Any]],
311
+ ) -> list[dict[str, Any]]:
312
+ """Resolve unqualified raw calls conservatively after all files are known.
313
+
314
+ This intentionally preserves Graphify's existing behavior:
315
+ - member calls are skipped;
316
+ - ambiguous labels are skipped;
317
+ - only a single unique candidate is emitted;
318
+ - emitted edges are INFERRED because the raw call alone is not import proof.
319
+ """
320
+
321
+ label_index = build_label_index(all_nodes)
322
+ known_pairs = existing_edge_pairs(all_edges)
323
+ # nid -> source_file, for the shared god-node tie-breakers (#1553) so a
324
+ # same-named test mock no longer erases a real cross-file call.
325
+ nid_to_source_file = {
326
+ str(n.get("id")): str(n.get("source_file", ""))
327
+ for n in all_nodes
328
+ if n.get("id")
329
+ }
330
+ resolved: list[dict[str, Any]] = []
331
+
332
+ for raw_call in iter_raw_calls(per_file):
333
+ callee = str(raw_call.get("callee", "")).strip()
334
+ if not callee:
335
+ continue
336
+ if raw_call.get("is_member_call"):
337
+ continue
338
+ candidates = label_index.get(callee.lower(), [])
339
+ if not candidates:
340
+ continue
341
+ if len(candidates) == 1:
342
+ target: str | None = candidates[0]
343
+ else:
344
+ # Ambiguous bare name. Apply the shared tie-breakers (non-test
345
+ # preference, then path proximity); resolve only if exactly one
346
+ # candidate survives, else preserve the god-node guard and skip.
347
+ target = disambiguate_ambiguous_candidates(
348
+ candidates,
349
+ {c: nid_to_source_file.get(c, "") for c in candidates},
350
+ str(raw_call.get("source_file", "")),
351
+ )
352
+ if target is None:
353
+ continue
354
+ caller = str(raw_call.get("caller_nid", ""))
355
+ if not caller:
356
+ continue
357
+ if target == caller:
358
+ continue
359
+ pair = (caller, target, "calls")
360
+ if pair in known_pairs:
361
+ continue
362
+ known_pairs.add(pair)
363
+ resolved.append(
364
+ {
365
+ "source": caller,
366
+ "target": target,
367
+ "relation": "calls",
368
+ "context": "call",
369
+ "confidence": "INFERRED",
370
+ # 0.85, not 0.8 — the extraction-spec rubric's INFERRED values
371
+ # are a discrete set and 0.8 is not one of them (#2813).
372
+ "confidence_score": 0.85,
373
+ "source_file": raw_call.get("source_file", ""),
374
+ "source_location": raw_call.get("source_location"),
375
+ "weight": 1.0,
376
+ }
377
+ )
378
+
379
+ return resolved
380
+
381
+
382
+ def _bash_make_id(*parts: str) -> str:
383
+ """Bash symbol node ID via the single shared recipe (#1378).
384
+
385
+ Previously an inline copy to dodge an import cycle; ``graphify.ids`` is
386
+ dependency-free, so it can be imported directly.
387
+ """
388
+ return _shared_make_id(*parts)
389
+
390
+
391
+ from graphify.extractors.base import _file_stem as _bash_file_stem # canonical recipe (no import cycle: base imports only graphify.ids)
392
+
393
+
394
+ def _file_node_id_for_path(path: Path, root: Path) -> str:
395
+ # Produce the canonical {parent_dir}_{stem} file-node ID that extract()'s
396
+ # id_remap generates (#1033), so bash `source` edges land on the real file
397
+ # node instead of an orphan. _bash_make_id / _bash_file_stem are exact copies
398
+ # of extract._make_id / extract._file_stem, so IDs match.
399
+ try:
400
+ rel = path.resolve().relative_to(root.resolve())
401
+ except ValueError:
402
+ return _bash_make_id(str(path)) # path outside root: hash absolute path as fallback
403
+ return _bash_make_id(_bash_file_stem(rel))
404
+
405
+
406
+ def resolve_bash_source_edges(
407
+ per_file: Sequence[dict | None],
408
+ paths: Sequence[Path],
409
+ root: Path,
410
+ existing_edges: list[dict] | None = None,
411
+ ) -> list[dict]:
412
+ """Resolve Bash source/import edges and source-backed function calls.
413
+
414
+ Defensive against malformed extraction fragments: non-dict ``per_file``
415
+ entries, missing ``bash_sources``/``raw_calls`` keys, non-dict items in
416
+ those lists, and missing/empty ``id`` / ``target_path`` / ``caller_nid``
417
+ fields all yield silent skips rather than ``KeyError``.
418
+
419
+ ``bash_sources[].target_path`` contract (Graphify static-analysis policy):
420
+ - Absolute paths: resolved as-is.
421
+ - Relative paths: resolved against the *source file's* directory
422
+ (i.e. ``Path(path).parent / target_path``).
423
+ NOTE: this is a deterministic static-analysis policy chosen by
424
+ Graphify, NOT bash runtime semantics. At runtime, ``source ./X``
425
+ is resolved against the shell's current working directory. We
426
+ prefer source-file-relative because static analysis cannot know
427
+ the future CWD; resolving against the file being analyzed gives
428
+ deterministic, reproducible edges across runs.
429
+ - Inputs of type ``str`` and ``pathlib.Path`` are processed.
430
+ Anything else is silently skipped.
431
+ """
432
+ path_by_index = [Path(p).resolve() for p in paths]
433
+ file_nid_by_path = {p: _file_node_id_for_path(p, root) for p in path_by_index} # resolved paths only
434
+
435
+ functions_by_file: dict[str, dict[str, str]] = {}
436
+ for result, path in zip(per_file, path_by_index):
437
+ if not isinstance(result, dict):
438
+ continue
439
+ file_nid = file_nid_by_path[path]
440
+ nodes = result.get("nodes", [])
441
+ if not isinstance(nodes, list):
442
+ continue
443
+ for node in nodes:
444
+ if not isinstance(node, dict):
445
+ continue
446
+ metadata = node.get("metadata", {})
447
+ if not isinstance(metadata, dict):
448
+ continue
449
+ if metadata.get("kind") != "bash_function":
450
+ continue
451
+ name = str(node.get("label", "")).removesuffix("()").strip()
452
+ node_id = node.get("id")
453
+ if not name or not node_id:
454
+ continue
455
+ functions_by_file.setdefault(file_nid, {})[name] = str(node_id)
456
+
457
+ sourced_files: dict[str, set[str]] = {}
458
+ resolved_edges: list[dict] = []
459
+ existing = existing_edge_pairs(existing_edges or [])
460
+
461
+ for result, path in zip(per_file, path_by_index):
462
+ if not isinstance(result, dict):
463
+ continue
464
+ src_file_nid = file_nid_by_path[path]
465
+ bash_sources = result.get("bash_sources", [])
466
+ if not isinstance(bash_sources, list):
467
+ continue
468
+ for source in bash_sources:
469
+ if not isinstance(source, dict):
470
+ continue
471
+ raw_target = source.get("target_path")
472
+ if not isinstance(raw_target, (str, Path)) or not str(raw_target).strip():
473
+ continue
474
+ # Relative paths resolve against the source file's directory —
475
+ # Graphify static-analysis policy (NOT bash runtime semantics;
476
+ # at runtime `source ./X` is CWD-relative, but static analysis
477
+ # can't know the future CWD, so we resolve relative to the
478
+ # file being analyzed for deterministic, reproducible edges).
479
+ candidate = Path(raw_target)
480
+ if not candidate.is_absolute():
481
+ candidate = path.parent / candidate
482
+ try:
483
+ target_path = candidate.resolve()
484
+ except (OSError, RuntimeError):
485
+ continue
486
+ target_file_nid = file_nid_by_path.get(target_path)
487
+ if target_file_nid is None:
488
+ continue
489
+ sourced_files.setdefault(src_file_nid, set()).add(target_file_nid)
490
+ key = (src_file_nid, target_file_nid, "imports_from")
491
+ if key in existing:
492
+ continue
493
+ existing.add(key)
494
+ resolved_edges.append(
495
+ {
496
+ "source": src_file_nid,
497
+ "target": target_file_nid,
498
+ "relation": "imports_from",
499
+ "context": "import",
500
+ "confidence": "EXTRACTED",
501
+ "confidence_score": 1.0,
502
+ "source_file": source.get("source_file", str(path)),
503
+ "source_location": source.get("source_location", ""),
504
+ "weight": 1.0,
505
+ }
506
+ )
507
+
508
+ for result, path in zip(per_file, path_by_index):
509
+ if not isinstance(result, dict):
510
+ continue
511
+ caller_file_nid = file_nid_by_path[path]
512
+ imported_file_ids = sourced_files.get(caller_file_nid, set())
513
+ if not imported_file_ids:
514
+ continue
515
+ raw_calls = result.get("raw_calls", [])
516
+ if not isinstance(raw_calls, list):
517
+ continue
518
+ for raw_call in raw_calls:
519
+ if not isinstance(raw_call, dict):
520
+ continue
521
+ if raw_call.get("language") != "bash":
522
+ continue
523
+ callee = raw_call.get("callee")
524
+ caller_nid = raw_call.get("caller_nid")
525
+ # callee must be a non-empty string — anything else (list, dict,
526
+ # int, None, …) is silently skipped to avoid TypeError on the
527
+ # `in functions_by_file[...]` membership check below.
528
+ if not isinstance(callee, str) or not callee or not caller_nid:
529
+ continue
530
+ matches = [
531
+ functions_by_file[file_nid][callee]
532
+ for file_nid in imported_file_ids
533
+ if callee in functions_by_file.get(file_nid, {})
534
+ ]
535
+ if len(matches) != 1:
536
+ continue
537
+ target = matches[0]
538
+ key = (str(caller_nid), target, "calls")
539
+ if key in existing:
540
+ continue
541
+ existing.add(key)
542
+ resolved_edges.append(
543
+ {
544
+ "source": str(caller_nid),
545
+ "target": target,
546
+ "relation": "calls",
547
+ "context": "call",
548
+ "confidence": "EXTRACTED",
549
+ "confidence_score": 1.0,
550
+ "source_file": raw_call.get("source_file", str(path)),
551
+ "source_location": raw_call.get("source_location", ""),
552
+ "weight": 1.0,
553
+ }
554
+ )
555
+
556
+ return resolved_edges