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,579 @@
1
+ """Bash extractor. Moved verbatim from graphify/extract.py."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from graphify.extractors.base import _file_stem, _make_id, _read_text
10
+
11
+
12
+ # Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The
13
+ # canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes
14
+ # such a variable resolve to the script's own directory, so the literal suffix that
15
+ # follows (`lib/x.sh`) can be resolved against the sourcing file's own dir (#2079).
16
+ _BASH_LEADING_EXPANSION = re.compile(
17
+ r"^(?:(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/?)+"
18
+ )
19
+
20
+
21
+ def _bash_source_suffix(raw: str, allow_dotdot: bool = False) -> str | None:
22
+ """Return the literal path suffix of a variable-built `source` argument, or
23
+ None when the remainder is empty or still holds an expansion.
24
+
25
+ ``"${DIR}/lib/x.sh"`` -> ``"lib/x.sh"``.
26
+
27
+ When *allow_dotdot* is False (the default — the base is a script-dir guess),
28
+ ``..`` segments are rejected to prevent traversal outside the project tree.
29
+ When True (the base is a tracked var_bases entry — a known directory),
30
+ ``..`` is permitted and the caller normalises with ``os.path.normpath``.
31
+ """
32
+ suffix = _BASH_LEADING_EXPANSION.sub("", raw, count=1).lstrip("/")
33
+ if not suffix or "$" in suffix:
34
+ return None
35
+ if not allow_dotdot and ".." in suffix.split("/"):
36
+ return None
37
+ return suffix
38
+
39
+
40
+ def _within_tree(ceiling: Path, target: Path) -> bool:
41
+ """True if *target* is *ceiling* or lives beneath it, compared lexically
42
+ (normpath, no filesystem access).
43
+
44
+ A ``source`` path built with ``..`` (the ``$VAR/../lib`` idiom, #2596 form 4,
45
+ or a ``$(dirname …)`` prefix) must not be allowed to walk up to an arbitrary
46
+ host path: a corpus is attacker-controllable, and both the ``is_file()``
47
+ existence probe and the recorded absolute ``target_file`` on the emitted edge
48
+ are a corpus-side information leak (``source "$VAR/../../../../etc/passwd"``).
49
+ Callers gate the probe/emit on this so a target that escapes the allowed tree
50
+ is dropped before it is ever stat-ed. Defense in depth only:
51
+ ``resolve_bash_source_edges`` independently keeps a *resolved* edge only when
52
+ the target is itself a scanned corpus file."""
53
+ c = os.path.normpath(str(ceiling))
54
+ t = os.path.normpath(str(target))
55
+ return t == c or t.startswith(c + os.sep)
56
+
57
+
58
+ # Recognise ``$(dirname "$VAR")`` (or ``$(dirname "${VAR}")``) at the start of
59
+ # a ``source`` argument, capturing the variable name so the source resolver
60
+ # can treat the whole construct as ``var_bases[VAR].parent`` (#2596 form 3).
61
+ _BASH_SOURCE_DIRNAME = re.compile(
62
+ r'\$\(dirname\s+"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"\)'
63
+ )
64
+
65
+
66
+ # Name of the leading variable of a `source` argument: `${ROOT}/lib/x.sh` -> ROOT.
67
+ _BASH_LEADING_VAR = re.compile(
68
+ r"^\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}|^\$([A-Za-z_][A-Za-z0-9_]*)"
69
+ )
70
+
71
+ # The `$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)` idiom, capturing the
72
+ # trailing `/..` hops applied to the dirname result.
73
+ _BASH_DIRNAME_IDIOM = re.compile(r"dirname[^)]*\)((?:/\.\.)*)")
74
+
75
+
76
+ def _bash_assignment_base(value: str, script_dir: Path) -> Path | None:
77
+ """Resolve a top-level assignment's value to a directory, or None if untracked.
78
+
79
+ Covers the two forms that make a ``source "${VAR}/lib/x.sh"`` target knowable
80
+ statically (#2172):
81
+
82
+ * the script-dir idiom ``"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"``,
83
+ including any number of trailing ``/..`` hops (and the ``$0`` spelling)
84
+ * a literal path, absolute or relative to the script's own directory
85
+
86
+ Anything else -- a value built from other variables, or command substitution
87
+ we do not model -- is left untracked so the caller keeps its previous
88
+ script-dir guess rather than binding somewhere invented.
89
+ """
90
+ val = value.strip().strip("'\"")
91
+ if not val:
92
+ return None
93
+ if "dirname" in val and ("BASH_SOURCE" in val or "$0" in val):
94
+ m = _BASH_DIRNAME_IDIOM.search(val)
95
+ base = script_dir
96
+ for _ in range((m.group(1).count("..") if m else 0)):
97
+ base = base.parent
98
+ return base
99
+ if "$" in val or "`" in val:
100
+ return None
101
+ candidate = Path(val)
102
+ return candidate if candidate.is_absolute() else script_dir / candidate
103
+
104
+
105
+ def extract_bash(path: Path) -> dict:
106
+ """Extract functions, source imports, and cross-function calls from a .sh file."""
107
+ try:
108
+ import tree_sitter_bash as tsbash
109
+ from tree_sitter import Language, Parser
110
+ except ImportError:
111
+ return {"nodes": [], "edges": [], "error": "tree-sitter-bash not installed"}
112
+
113
+ try:
114
+ language = Language(tsbash.language())
115
+ parser = Parser(language)
116
+ source = path.read_bytes()
117
+ tree = parser.parse(source)
118
+ root = tree.root_node
119
+ except Exception as e:
120
+ return {"nodes": [], "edges": [], "error": str(e)}
121
+
122
+ stem = _file_stem(path)
123
+ str_path = str(path)
124
+ nodes: list[dict] = []
125
+ edges: list[dict] = []
126
+ # Cross-file resolution scaffolding consumed by resolve_bash_source_edges in
127
+ # the extract pipeline: `bash_sources` records which files this one `source`s,
128
+ # `raw_calls` records calls whose callee isn't defined in this file (candidate
129
+ # calls into a sourced library). The extractor sees one file at a time and so
130
+ # can't resolve these itself (#2141).
131
+ raw_calls: list[dict] = []
132
+ bash_sources: list[dict] = []
133
+ raw_seen: set[tuple[str, str]] = set()
134
+ seen_ids: set[str] = set()
135
+ function_bodies: list[tuple[str, Any]] = []
136
+ defined_functions: set[str] = set()
137
+
138
+ from graphify.security import sanitize_metadata # module-level cached import
139
+
140
+ def add_node(nid: str, label: str, line: int, kind: str = "code") -> None:
141
+ if nid and nid not in seen_ids:
142
+ seen_ids.add(nid)
143
+ nodes.append({"id": nid, "label": label, "file_type": "code",
144
+ "source_file": str_path, "source_location": f"L{line}",
145
+ "metadata": sanitize_metadata({"language": "bash", "kind": kind})}) # noqa: E501
146
+
147
+ def add_edge(src: str, tgt: str, relation: str, line: int,
148
+ confidence: str = "EXTRACTED", weight: float = 1.0,
149
+ context: str | None = None,
150
+ target_file: str | None = None) -> None:
151
+ if not src or not tgt or src == tgt:
152
+ return
153
+ edge = {"source": src, "target": tgt, "relation": relation,
154
+ "confidence": confidence, "source_file": str_path,
155
+ "source_location": f"L{line}", "weight": weight}
156
+ if context:
157
+ edge["context"] = context
158
+ # Transient resolved-target hint (#1814/#2169), mirroring the Python/
159
+ # markdown extractors: lets the extract() id-remap pass canonicalize a
160
+ # target minted from an absolute path even when the target file is not
161
+ # in the batch (incremental run) or lives out of root (#2243). Popped
162
+ # before anything persists, never shipped into graph.json.
163
+ if target_file is not None:
164
+ edge["target_file"] = target_file
165
+ edges.append(edge)
166
+
167
+ file_nid = _make_id(str(path))
168
+ # file_nid is fully path-derived and never produced by _make_id(stem, func_name),
169
+ # so appending "__entry" guarantees a distinct ID from any function node.
170
+ entry_nid = file_nid + "__entry"
171
+ add_node(file_nid, path.name, 1, kind="file")
172
+ add_node(entry_nid, f"{path.name} script", 1, kind="bash_entrypoint")
173
+ add_edge(file_nid, entry_nid, "contains", 1)
174
+
175
+ _BASH_SOURCE_COMMANDS = frozenset({"source", "."})
176
+ _BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"})
177
+ # Parent node types that mean a contained command is part of a substitution
178
+ # or expansion, not a real function call. Token-level filtering misses
179
+ # these because `$(build)` exposes `build` as a child command whose name
180
+ # token has no metacharacters — only the parent does.
181
+ _BASH_EXPANSION_PARENTS = frozenset({
182
+ "command_substitution",
183
+ "process_substitution",
184
+ })
185
+
186
+ def text(node) -> str:
187
+ return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
188
+
189
+ def is_inside_expansion(node) -> bool:
190
+ parent = node.parent
191
+ while parent is not None:
192
+ if parent.type in _BASH_EXPANSION_PARENTS:
193
+ return True
194
+ parent = parent.parent
195
+ return False
196
+
197
+ def _bash_call_in_command_allowed(cmd_node) -> bool:
198
+ """Whether a command node should emit call edges.
199
+
200
+ Bare ``$(fn)`` at script level and process substitutions stay suppressed
201
+ (#2141). Value capture via ``x=$(fn)`` is a real invocation (#2978).
202
+ """
203
+ parent = cmd_node.parent
204
+ saw_command_substitution = False
205
+ while parent is not None:
206
+ if parent.type == "process_substitution":
207
+ return False
208
+ if parent.type == "command_substitution":
209
+ saw_command_substitution = True
210
+ if parent.type == "variable_assignment" and saw_command_substitution:
211
+ return True
212
+ parent = parent.parent
213
+ return not saw_command_substitution
214
+
215
+ def literal(node) -> str | None:
216
+ # Token-level filter: rejects names containing shell metacharacters.
217
+ # Combined with `is_inside_expansion` for parent-context rejection.
218
+ raw = text(node).strip()
219
+ if not raw:
220
+ return None
221
+ if raw[0:1] in {"'", '"'} and raw[-1:] == raw[0]:
222
+ raw = raw[1:-1]
223
+ if any(token in raw for token in ("$", "`", "$(", "<(", ">", "|", ";", "&")):
224
+ return None
225
+ return raw
226
+
227
+ def _bash_func_name(node) -> str | None:
228
+ """Get the name from a function_definition node."""
229
+ # bash grammar: function_definition has a word child (the name)
230
+ for child in node.children:
231
+ if child.type == "word":
232
+ return literal(child)
233
+ return None
234
+
235
+ def walk_calls(body_node, func_nid: str, seen_calls: set) -> None:
236
+ if body_node is None:
237
+ return
238
+ for child in body_node.children:
239
+ if child.type == "function_definition":
240
+ # Skip nested function definitions — their bodies are walked
241
+ # separately, so we don't attribute their calls to the
242
+ # enclosing scope.
243
+ continue
244
+ if child.type == "command" and _bash_call_in_command_allowed(child):
245
+ cmd_name_node = child.child_by_field_name("name")
246
+ if cmd_name_node is None and child.children:
247
+ cmd_name_node = child.children[0]
248
+ if cmd_name_node:
249
+ name = literal(cmd_name_node)
250
+ # Defined-functions wins. Skip-lists for external commands
251
+ # would create false negatives when a user defines a
252
+ # function shadowing an external (`install`, `find`, etc.).
253
+ if name and name in defined_functions:
254
+ tgt = _make_id(stem, name)
255
+ key = (func_nid, tgt)
256
+ if tgt and key not in seen_calls:
257
+ seen_calls.add(key)
258
+ add_edge(func_nid, tgt, "calls",
259
+ child.start_point[0] + 1,
260
+ confidence="EXTRACTED", context="call")
261
+ elif (name and name not in _BASH_SOURCE_COMMANDS
262
+ and name not in _BASH_SCRIPT_RUNNERS):
263
+ # Callee isn't defined in this file — it may be a function
264
+ # from a sourced library. Record an unresolved raw_call for
265
+ # resolve_bash_source_edges to bind against the files this
266
+ # script `source`s. A callee that is not a sourced function
267
+ # (a genuine external command) matches nothing there and
268
+ # yields no edge, so this can't over-connect the graph (#2141).
269
+ raw_key = (func_nid, name)
270
+ if raw_key not in raw_seen:
271
+ raw_seen.add(raw_key)
272
+ raw_calls.append({
273
+ "language": "bash",
274
+ "callee": name,
275
+ "caller_nid": func_nid,
276
+ "source_file": str_path,
277
+ "source_location": f"L{child.start_point[0] + 1}",
278
+ })
279
+ walk_calls(child, func_nid, seen_calls)
280
+
281
+ def walk(node, parent_nid: str) -> None:
282
+ t = node.type
283
+ if t == "function_definition":
284
+ name = _bash_func_name(node)
285
+ if name:
286
+ fn_nid = _make_id(stem, name)
287
+ line = node.start_point[0] + 1
288
+ add_node(fn_nid, f"{name}()", line, kind="bash_function")
289
+ add_edge(parent_nid, fn_nid, "defines", line)
290
+ defined_functions.add(name)
291
+ # find the compound_statement body
292
+ body = None
293
+ for child in node.children:
294
+ if child.type == "compound_statement":
295
+ body = child
296
+ break
297
+ function_bodies.append((fn_nid, body))
298
+ # Recurse into the body so nested function definitions are discovered
299
+ # and added to function_bodies for the second-pass walk_calls.
300
+ if body is not None:
301
+ walk(body, fn_nid)
302
+ return
303
+
304
+ if t == "command":
305
+ if is_inside_expansion(node):
306
+ return
307
+ cmd_name_node = node.child_by_field_name("name")
308
+ if cmd_name_node is None and node.children:
309
+ cmd_name_node = node.children[0]
310
+ if cmd_name_node:
311
+ cmd = literal(cmd_name_node)
312
+ args = [c for c in node.children
313
+ if c.type in ("word", "string", "concatenation")
314
+ and c != cmd_name_node]
315
+ if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions:
316
+ # find the path argument (first word after command name)
317
+ if args:
318
+ raw = _read_text(args[0], source).strip().strip("'\"")
319
+ line = node.start_point[0] + 1
320
+ if raw.startswith((".", "/")):
321
+ resolved = (path.parent / raw).resolve()
322
+ # Only emit the edge if the target actually exists on
323
+ # disk — prevents graph pollution from crafted paths
324
+ # like `source ../../etc/passwd` that traverse outside
325
+ # the project tree (B-1).
326
+ if resolved.exists():
327
+ tgt_nid = _make_id(str(resolved))
328
+ add_edge(file_nid, tgt_nid, "imports_from", line,
329
+ context="import",
330
+ target_file=str(resolved))
331
+ # Record the sourced file so resolve_bash_source_edges
332
+ # can bind calls into its functions (#2141). Gated on
333
+ # existence like the edge above, so crafted traversal
334
+ # paths never enter the resolver's data.
335
+ bash_sources.append({
336
+ "target_path": raw,
337
+ "source_file": str_path,
338
+ "source_location": f"L{line}",
339
+ })
340
+ elif "$" in raw:
341
+ # Variable-built path, e.g. the ubiquitous
342
+ # `source "${BENCH_DIR}/lib/x.sh"` idiom. The raw text
343
+ # bakes the unexpanded `${VAR}` into the id, which
344
+ # matches no node and is dropped as a dangling edge
345
+ # (#2079). Strip the leading expansion(s) and resolve
346
+ # the literal suffix against the script's own dir;
347
+ # emit INFERRED (the expansion can't be proven
348
+ # statically) only when it resolves to a real file,
349
+ # never a dead id.
350
+
351
+ # Form 3 (#2596): `source "$(dirname "$VAR")/lib/x.sh"`
352
+ # — command substitution in the source argument.
353
+ # Recognise the dirname idiom on the source line the
354
+ # same way _bash_assignment_base does on assignment
355
+ # lines: treat `$(dirname "$VAR")` as
356
+ # `var_bases[VAR].parent` (falling back to
357
+ # `script_dir.parent` when VAR is untracked), then
358
+ # resolve the literal suffix against it.
359
+ dirname_match = _BASH_SOURCE_DIRNAME.match(raw)
360
+ if dirname_match:
361
+ var_name = dirname_match.group(1)
362
+ if var_name in var_bases:
363
+ base = var_bases[var_name].parent
364
+ else:
365
+ # Untracked var: fall back to the script's
366
+ # own directory, so dirname resolves to its
367
+ # parent — matching the existing untracked-var
368
+ # fallback semantics.
369
+ base = path.parent.parent
370
+ # Strip the $(dirname ...) prefix and any leading
371
+ # slash to get the literal suffix.
372
+ suffix = raw[dirname_match.end():].lstrip("/")
373
+ # The base is a guessed script dir, so reject a
374
+ # `..` suffix outright — same policy as
375
+ # _bash_source_suffix(allow_dotdot=False); without
376
+ # it, `$(dirname "$VAR")/../../../etc/passwd`
377
+ # resolves and gets probed/recorded (#2596).
378
+ if (suffix and "$" not in suffix
379
+ and ".." not in suffix.split("/")):
380
+ resolved = (base / suffix).resolve()
381
+ if resolved.is_file():
382
+ add_edge(file_nid, _make_id(str(resolved)),
383
+ "imports_from", line,
384
+ confidence="INFERRED", context="import",
385
+ target_file=str(resolved))
386
+ bash_sources.append({
387
+ "target_path": str(resolved),
388
+ "source_file": str_path,
389
+ "source_location": f"L{line}",
390
+ })
391
+ else:
392
+ # Check if the leading variable is tracked
393
+ # before deciding whether to allow `..` in
394
+ # the suffix (Form 4, #2596). When the base
395
+ # is a known var_bases entry, `..` is safe.
396
+ var_match = _BASH_LEADING_VAR.match(raw)
397
+ allow_dotdot = False
398
+ if var_match:
399
+ _vn = var_match.group(1) or var_match.group(2)
400
+ if _vn in var_bases:
401
+ allow_dotdot = True
402
+ suffix = _bash_source_suffix(raw, allow_dotdot=allow_dotdot)
403
+ if suffix:
404
+ # Resolve against the variable's tracked base
405
+ # when we know it, else fall back to the
406
+ # script's own directory as before (#2172).
407
+ base = path.parent
408
+ if var_match:
409
+ var_name = var_match.group(1) or var_match.group(2)
410
+ if var_name in var_bases:
411
+ base = var_bases[var_name]
412
+ if var_match and var_name in var_bases:
413
+ resolved = Path(os.path.normpath(base / suffix))
414
+ # A tracked base may reach a sibling via
415
+ # `$VAR/../lib`, so the ceiling is one
416
+ # level up — but `..` must not walk past
417
+ # it to an arbitrary host path (#2596).
418
+ ceiling = base.parent
419
+ else:
420
+ resolved = (base / suffix).resolve()
421
+ # Untracked base: `..` was already rejected
422
+ # by _bash_source_suffix, so the target is
423
+ # under base; the gate is belt-and-braces.
424
+ ceiling = base
425
+ if _within_tree(ceiling, resolved) and resolved.is_file():
426
+ add_edge(file_nid, _make_id(str(resolved)),
427
+ "imports_from", line,
428
+ confidence="INFERRED", context="import",
429
+ target_file=str(resolved))
430
+ # Integration (#2141 + #2079): record
431
+ # the resolved sourced file so calls
432
+ # into its functions resolve too, not
433
+ # just the source edge. target_path is
434
+ # absolute here; resolve_bash_source_edges
435
+ # takes it as-is.
436
+ bash_sources.append({
437
+ "target_path": str(resolved),
438
+ "source_file": str_path,
439
+ "source_location": f"L{line}",
440
+ })
441
+ else:
442
+ # Bare `source lib.sh` (no leading ./ or /). Bash
443
+ # itself resolves such a name via $PATH at runtime,
444
+ # but in practice the file sits next to the script,
445
+ # so bind it when a sibling of that name exists
446
+ # (#2171). Same existence gate as the ./-prefixed
447
+ # branch above: a name that resolves to nothing keeps
448
+ # the old opaque `imports` edge and records no
449
+ # bash_sources entry, so nothing is fabricated.
450
+ sibling: Path | None = None
451
+ if raw:
452
+ try:
453
+ candidate = path.parent / raw
454
+ if candidate.is_file():
455
+ sibling = candidate.resolve()
456
+ except OSError:
457
+ sibling = None
458
+ if sibling is not None:
459
+ # A bare `source lib.sh` resolves via $PATH at
460
+ # runtime; binding it to the sibling of that name
461
+ # is a heuristic, so mark it INFERRED (#2171).
462
+ add_edge(file_nid, _make_id(str(sibling)),
463
+ "imports_from", line,
464
+ confidence="INFERRED", context="import",
465
+ target_file=str(sibling))
466
+ bash_sources.append({
467
+ "target_path": raw,
468
+ "source_file": str_path,
469
+ "source_location": f"L{line}",
470
+ })
471
+ else:
472
+ tgt_nid = _make_id(raw)
473
+ if tgt_nid:
474
+ add_edge(file_nid, tgt_nid, "imports", line,
475
+ context="import")
476
+ elif cmd not in defined_functions:
477
+ raw = cmd if (cmd and cmd.endswith(".sh")) else None
478
+ if cmd and cmd in _BASH_SCRIPT_RUNNERS and args:
479
+ raw = literal(args[0])
480
+ if raw is None:
481
+ # The command name is itself an expansion, e.g.
482
+ # `"$script_dir/x.sh" --flag`. `literal()` rejects any
483
+ # candidate containing "$", so `cmd` is None and neither
484
+ # of the two branches above can see the path. Read the
485
+ # node's raw text the way the `source` branch does and
486
+ # strip the leading ${VAR}/ segments -- the same
487
+ # script-dir assumption already used for `source
488
+ # "$DIR/lib/x.sh"` (#2079). `_bash_source_suffix`
489
+ # rejects a remainder that still holds an expansion or
490
+ # a `..` segment, and the `resolved.is_file()` gate
491
+ # below means a wrong script-dir guess emits nothing.
492
+ cand = _read_text(cmd_name_node, source).strip().strip("'\"")
493
+ if cand.endswith(".sh") and "$" in cand:
494
+ raw = _bash_source_suffix(cand) or None
495
+ if raw and raw.endswith(".sh"):
496
+ resolved = (path.parent / raw).resolve()
497
+ if resolved.is_file():
498
+ target_path = resolved
499
+ if not path.is_absolute():
500
+ try:
501
+ target_path = resolved.relative_to(Path.cwd().resolve())
502
+ except ValueError:
503
+ pass
504
+ caller_nid = entry_nid if parent_nid == file_nid else parent_nid
505
+ # target_file lets the extract() remap loop learn
506
+ # this script's canonical id even when it is not in
507
+ # the batch; the "__entry"-suffixed endpoint is
508
+ # rewritten there via the suffix-aware entry
509
+ # registration (#2243).
510
+ add_edge(caller_nid, _make_id(str(target_path)) + "__entry",
511
+ "calls", node.start_point[0] + 1,
512
+ context="script_invocation",
513
+ target_file=str(target_path))
514
+ return
515
+
516
+ if t == "declaration_command":
517
+ # export/declare/readonly VAR=value at program level
518
+ if node.parent and node.parent.type == "program":
519
+ for child in node.children:
520
+ if child.type == "variable_assignment":
521
+ var_node = child.child_by_field_name("name")
522
+ if var_node:
523
+ var = _read_text(var_node, source).strip()
524
+ if var:
525
+ var_nid = _make_id(stem, var)
526
+ line = child.start_point[0] + 1
527
+ add_node(var_nid, var, line)
528
+ add_edge(file_nid, var_nid, "defines", line)
529
+ return
530
+
531
+ for child in node.children:
532
+ walk(child, parent_nid)
533
+
534
+ # Pre-pass: collect all defined function names so the source-command handler
535
+ # in walk() can detect user-defined functions that shadow 'source' / '.'
536
+ # regardless of definition order in the file.
537
+ def _prescan_functions(node) -> None:
538
+ if node.type == "function_definition":
539
+ name = _bash_func_name(node)
540
+ if name:
541
+ defined_functions.add(name)
542
+ for child in node.children:
543
+ _prescan_functions(child)
544
+ else:
545
+ for child in node.children:
546
+ _prescan_functions(child)
547
+
548
+ _prescan_functions(root)
549
+ # Bases for `source "${VAR}/lib/x.sh"` resolution (#2172). #2079 always resolved
550
+ # the literal suffix against the script's own directory, which is right for the
551
+ # `DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom but wrong whenever
552
+ # the variable points elsewhere -- e.g. ROOT=".../scripts/.." with a same-named
553
+ # decoy under the script dir bound to the decoy. Track top-level assignments so
554
+ # the real base is used; untracked variables keep the script-dir guess.
555
+ var_bases: dict[str, Path] = {}
556
+ for _assign in root.children:
557
+ if _assign.type != "variable_assignment":
558
+ continue
559
+ _name_node = _assign.child_by_field_name("name")
560
+ _value_node = _assign.child_by_field_name("value")
561
+ if _name_node is None or _value_node is None:
562
+ continue
563
+ _name = _read_text(_name_node, source).strip()
564
+ if not _name:
565
+ continue
566
+ _base = _bash_assignment_base(_read_text(_value_node, source), path.parent)
567
+ if _base is not None:
568
+ var_bases[_name] = _base
569
+
570
+ walk(root, file_nid)
571
+
572
+ # Second pass: cross-function calls
573
+ top_seen: set = set()
574
+ walk_calls(root, entry_nid, top_seen) # top-level calls attributed to the entrypoint
575
+ for fn_nid, body in function_bodies:
576
+ walk_calls(body, fn_nid, set())
577
+
578
+ return {"nodes": nodes, "edges": edges,
579
+ "raw_calls": raw_calls, "bash_sources": bash_sources}
@@ -0,0 +1,53 @@
1
+ """Laravel Blade template extractor. Moved verbatim from graphify/extract.py."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from graphify.extractors.base import _make_id
7
+
8
+
9
+ def extract_blade(path: Path) -> dict:
10
+ """Extract @include, <livewire:> components, and wire:click bindings from Blade templates."""
11
+ import re
12
+ try:
13
+ src = path.read_text(encoding="utf-8", errors="replace")
14
+ except OSError:
15
+ return {"error": f"cannot read {path}"}
16
+
17
+ file_nid = _make_id(str(path))
18
+ nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
19
+ "source_file": str(path), "source_location": None}]
20
+ edges = []
21
+
22
+ # @include('path.to.partial') or @include("path.to.partial")
23
+ for m in re.finditer(r"@include\(['\"]([^'\"]+)['\"]", src):
24
+ tgt = m.group(1).replace(".", "/")
25
+ tgt_nid = _make_id(tgt)
26
+ if tgt_nid not in {n["id"] for n in nodes}:
27
+ nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
28
+ "source_file": str(path), "source_location": None})
29
+ edges.append({"source": file_nid, "target": tgt_nid, "relation": "includes",
30
+ "confidence": "EXTRACTED", "confidence_score": 1.0,
31
+ "source_file": str(path), "source_location": None, "weight": 1.0})
32
+
33
+ # <livewire:component.name /> or <livewire:component.name>
34
+ for m in re.finditer(r"<livewire:([\w.\-]+)", src):
35
+ tgt_nid = _make_id(m.group(1))
36
+ if tgt_nid not in {n["id"] for n in nodes}:
37
+ nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
38
+ "source_file": str(path), "source_location": None})
39
+ edges.append({"source": file_nid, "target": tgt_nid, "relation": "uses_component",
40
+ "confidence": "EXTRACTED", "confidence_score": 1.0,
41
+ "source_file": str(path), "source_location": None, "weight": 1.0})
42
+
43
+ # wire:click="methodName"
44
+ for m in re.finditer(r'wire:click=["\']([^"\']+)["\']', src):
45
+ tgt_nid = _make_id(m.group(1))
46
+ if tgt_nid not in {n["id"] for n in nodes}:
47
+ nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
48
+ "source_file": str(path), "source_location": None})
49
+ edges.append({"source": file_nid, "target": tgt_nid, "relation": "binds_method",
50
+ "confidence": "EXTRACTED", "confidence_score": 1.0,
51
+ "source_file": str(path), "source_location": None, "weight": 1.0})
52
+
53
+ return {"nodes": nodes, "edges": edges}