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,637 @@
1
+ """html — moved verbatim from graphify/export.py."""
2
+ from __future__ import annotations
3
+
4
+ from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
5
+ from pathlib import Path
6
+ import html as _html
7
+ from graphify.analyze import _node_community_map
8
+ from graphify.paths import write_text_atomic
9
+ import json
10
+ import networkx as nx
11
+ from graphify.security import sanitize_label
12
+
13
+
14
+ MAX_NODES_FOR_VIZ = 5_000
15
+ _HTML_STALE_MARKER = ".graph.html.stale"
16
+
17
+ def _viz_node_limit() -> int:
18
+ """Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
19
+
20
+ Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
21
+ Set to 0 to disable HTML viz unconditionally (useful for CI runners).
22
+ """
23
+ import os
24
+ raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
25
+ if raw is None or not raw.strip():
26
+ return MAX_NODES_FOR_VIZ
27
+ try:
28
+ return int(raw)
29
+ except ValueError:
30
+ return MAX_NODES_FOR_VIZ
31
+
32
+ def _html_styles() -> str:
33
+ return """<style>
34
+ * { box-sizing: border-box; margin: 0; padding: 0; }
35
+ body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
36
+ #graph { flex: 1; }
37
+ #sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
38
+ #search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
39
+ #search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
40
+ #search:focus { border-color: #4E79A7; }
41
+ #search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
42
+ .search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
43
+ .search-item:hover { background: #2a2a4e; }
44
+ #info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
45
+ #info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
46
+ #info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
47
+ #info-content .field { margin-bottom: 5px; }
48
+ #info-content .field b { color: #e0e0e0; }
49
+ #info-content .empty { color: #555; font-style: italic; }
50
+ .neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
51
+ .neighbor-link:hover { background: #2a2a4e; }
52
+ #neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
53
+ #legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
54
+ #legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
55
+ .legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
56
+ .legend-item:hover { background: #2a2a4e; padding-left: 4px; }
57
+ .legend-item.dimmed { opacity: 0.35; }
58
+ .legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
59
+ .legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
+ .legend-count { color: #666; font-size: 11px; }
61
+ #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
62
+ #legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
63
+ #legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
64
+ #legend-controls label:hover { color: #e0e0e0; }
65
+ .legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
66
+ .legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
67
+ .legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
68
+ #select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
69
+ #select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
70
+ </style>"""
71
+
72
+ def _hyperedge_script(hyperedges_json: str) -> str:
73
+ return f"""<script>
74
+ // Render hyperedges as shaded regions
75
+ const hyperedges = {hyperedges_json};
76
+ // afterDrawing passes ctx already transformed to network coordinate space.
77
+ // Draw node positions raw — no manual pan/zoom/DPR math needed.
78
+
79
+ // Andrew's monotone chain. Returns the hull in counter-clockwise order, which
80
+ // is what the perimeter must be traced in. Collinear and duplicate points
81
+ // collapse to the extremes, so degenerate member sets render as a segment
82
+ // rather than a zero-area crossed path.
83
+ function convexHull(pts) {{
84
+ const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
85
+ if (p.length < 3) return p;
86
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
87
+ const build = seq => {{
88
+ const out = [];
89
+ for (const q of seq) {{
90
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
91
+ out.push(q);
92
+ }}
93
+ out.pop();
94
+ return out;
95
+ }};
96
+ const hull = build(p).concat(build(p.slice().reverse()));
97
+ return hull.length >= 3 ? hull : p;
98
+ }}
99
+ network.on('afterDrawing', function(ctx) {{
100
+ hyperedges.forEach(h => {{
101
+ const positions = h.nodes
102
+ .map(nid => network.getPositions([nid])[nid])
103
+ .filter(p => p !== undefined);
104
+ if (positions.length < 2) return;
105
+ ctx.save();
106
+ ctx.globalAlpha = 0.12;
107
+ ctx.fillStyle = '#6366f1';
108
+ ctx.strokeStyle = '#6366f1';
109
+ ctx.lineWidth = 2;
110
+ ctx.beginPath();
111
+ // Centroid and expanded hull in network coordinates.
112
+ // The perimeter must follow hull order, not h.nodes order: tracing the
113
+ // raw member order self-intersects whenever the layout does not happen
114
+ // to place members in angular order, filling as crossed wedges.
115
+ const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
116
+ const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
117
+ const hull = convexHull(positions);
118
+ const expanded = hull.map(p => ({{
119
+ x: cx + (p.x - cx) * 1.15,
120
+ y: cy + (p.y - cy) * 1.15
121
+ }}));
122
+ ctx.moveTo(expanded[0].x, expanded[0].y);
123
+ expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
124
+ ctx.closePath();
125
+ ctx.fill();
126
+ ctx.globalAlpha = 0.4;
127
+ ctx.stroke();
128
+ // Label
129
+ ctx.globalAlpha = 0.8;
130
+ ctx.fillStyle = '#4f46e5';
131
+ ctx.font = 'bold 11px sans-serif';
132
+ ctx.textAlign = 'center';
133
+ ctx.fillText(h.label, cx, cy - 5);
134
+ ctx.restore();
135
+ }});
136
+ }});
137
+ </script>"""
138
+
139
+ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
140
+ return f"""<script>
141
+ const RAW_NODES = {nodes_json};
142
+ const RAW_EDGES = {edges_json};
143
+ const LEGEND = {legend_json};
144
+
145
+ // HTML-escape helper — prevents XSS when injecting graph data into innerHTML
146
+ function esc(s) {{
147
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
148
+ }}
149
+
150
+ // Build vis datasets
151
+ const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
152
+ id: n.id, label: n.label, color: n.color, size: n.size,
153
+ font: n.font, title: n.title,
154
+ _community: n.community, _community_name: n.community_name,
155
+ _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
156
+ }})));
157
+
158
+ const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
159
+ id: i, from: e.from, to: e.to,
160
+ label: '',
161
+ title: e.title,
162
+ dashes: e.dashes,
163
+ width: e.width,
164
+ color: e.color,
165
+ arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
166
+ }})));
167
+
168
+ const container = document.getElementById('graph');
169
+ const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
170
+ physics: {{
171
+ enabled: true,
172
+ solver: 'forceAtlas2Based',
173
+ forceAtlas2Based: {{
174
+ gravitationalConstant: -60,
175
+ centralGravity: 0.005,
176
+ springLength: 120,
177
+ springConstant: 0.08,
178
+ damping: 0.4,
179
+ avoidOverlap: 0.8,
180
+ }},
181
+ stabilization: {{ iterations: 200, fit: true }},
182
+ }},
183
+ interaction: {{
184
+ hover: true,
185
+ tooltipDelay: 100,
186
+ hideEdgesOnDrag: true,
187
+ navigationButtons: false,
188
+ keyboard: false,
189
+ }},
190
+ nodes: {{ shape: 'dot', borderWidth: 1.5 }},
191
+ edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
192
+ }});
193
+
194
+ network.once('stabilizationIterationsDone', () => {{
195
+ network.setOptions({{ physics: {{ enabled: false }} }});
196
+ }});
197
+
198
+ function showInfo(nodeId) {{
199
+ const n = nodesDS.get(nodeId);
200
+ if (!n) return;
201
+ const neighborIds = network.getConnectedNodes(nodeId);
202
+ const neighborItems = neighborIds.map(nid => {{
203
+ const nb = nodesDS.get(nid);
204
+ const color = nb ? nb.color.background : '#555';
205
+ return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" data-nid="${{esc(nid)}}">${{esc(nb ? nb.label : nid)}}</span>`;
206
+ }}).join('');
207
+ document.getElementById('info-content').innerHTML = `
208
+ <div class="field"><b>${{esc(n.label)}}</b></div>
209
+ <div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
210
+ <div class="field">Community: ${{esc(n._community_name)}}</div>
211
+ <div class="field">Source: ${{esc(n._source_file || '-')}}</div>
212
+ <div class="field">Degree: ${{n._degree}}</div>
213
+ ${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
214
+ `;
215
+ }}
216
+
217
+ function focusNode(nodeId) {{
218
+ network.focus(nodeId, {{ scale: 1.4, animation: true }});
219
+ network.selectNodes([nodeId]);
220
+ showInfo(nodeId);
221
+ }}
222
+
223
+ // Neighbor links use a data attribute + one delegated listener rather than an
224
+ // inline onclick. A node id/label sourced from a document or a scraped URL
225
+ // (graphify add) can contain a double-quote; dropping the stringified id
226
+ // unescaped into a quoted onclick both broke every link and allowed a hostile
227
+ // source to inject an event handler into the local report (stored XSS, #1838).
228
+ // esc() on data-nid keeps the value inside the attribute; the listener reads it
229
+ // back verbatim. Bound to document so it survives the innerHTML rebuild that
230
+ // recreates #neighbors-list on each showInfo().
231
+ document.addEventListener('click', e => {{
232
+ const el = e.target.closest('.neighbor-link');
233
+ if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid);
234
+ }});
235
+
236
+ // Track hovered node — hover detection is more reliable than click params
237
+ let hoveredNodeId = null;
238
+ network.on('hoverNode', params => {{
239
+ hoveredNodeId = params.node;
240
+ container.style.cursor = 'pointer';
241
+ }});
242
+ network.on('blurNode', () => {{
243
+ hoveredNodeId = null;
244
+ container.style.cursor = 'default';
245
+ }});
246
+ container.addEventListener('click', () => {{
247
+ if (hoveredNodeId !== null) {{
248
+ showInfo(hoveredNodeId);
249
+ network.selectNodes([hoveredNodeId]);
250
+ }}
251
+ }});
252
+ network.on('click', params => {{
253
+ if (params.nodes.length > 0) {{
254
+ showInfo(params.nodes[0]);
255
+ }} else if (hoveredNodeId === null) {{
256
+ document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
257
+ }}
258
+ }});
259
+
260
+ const searchInput = document.getElementById('search');
261
+ const searchResults = document.getElementById('search-results');
262
+ searchInput.addEventListener('input', () => {{
263
+ const q = searchInput.value.toLowerCase().trim();
264
+ searchResults.innerHTML = '';
265
+ if (!q) {{ searchResults.style.display = 'none'; return; }}
266
+ const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
267
+ if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
268
+ searchResults.style.display = 'block';
269
+ matches.forEach(n => {{
270
+ const el = document.createElement('div');
271
+ el.className = 'search-item';
272
+ el.textContent = n.label;
273
+ el.style.borderLeft = `3px solid ${{n.color.background}}`;
274
+ el.style.paddingLeft = '8px';
275
+ el.onclick = () => {{
276
+ network.focus(n.id, {{ scale: 1.5, animation: true }});
277
+ network.selectNodes([n.id]);
278
+ showInfo(n.id);
279
+ searchResults.style.display = 'none';
280
+ searchInput.value = '';
281
+ }};
282
+ searchResults.appendChild(el);
283
+ }});
284
+ }});
285
+ document.addEventListener('click', e => {{
286
+ if (!searchResults.contains(e.target) && e.target !== searchInput)
287
+ searchResults.style.display = 'none';
288
+ }});
289
+
290
+ const hiddenCommunities = new Set();
291
+
292
+ const selectAllCb = document.getElementById('select-all-cb');
293
+
294
+ function updateSelectAllState() {{
295
+ const total = LEGEND.length;
296
+ const hidden = hiddenCommunities.size;
297
+ selectAllCb.checked = hidden === 0;
298
+ selectAllCb.indeterminate = hidden > 0 && hidden < total;
299
+ }}
300
+
301
+ function toggleAllCommunities(hide) {{
302
+ document.querySelectorAll('.legend-item').forEach(item => {{
303
+ hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
304
+ }});
305
+ document.querySelectorAll('.legend-cb').forEach(cb => {{
306
+ cb.checked = !hide;
307
+ }});
308
+ LEGEND.forEach(c => {{
309
+ if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
310
+ }});
311
+ const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }}));
312
+ nodesDS.update(updates);
313
+ updateSelectAllState();
314
+ }}
315
+
316
+ const legendEl = document.getElementById('legend');
317
+ LEGEND.forEach(c => {{
318
+ const item = document.createElement('div');
319
+ item.className = 'legend-item';
320
+ const cb = document.createElement('input');
321
+ cb.type = 'checkbox';
322
+ cb.className = 'legend-cb';
323
+ cb.checked = true;
324
+ cb.addEventListener('change', (e) => {{
325
+ e.stopPropagation();
326
+ if (cb.checked) {{
327
+ hiddenCommunities.delete(c.cid);
328
+ item.classList.remove('dimmed');
329
+ }} else {{
330
+ hiddenCommunities.add(c.cid);
331
+ item.classList.add('dimmed');
332
+ }}
333
+ const updates = RAW_NODES
334
+ .filter(n => n.community === c.cid)
335
+ .map(n => ({{ id: n.id, hidden: !cb.checked }}));
336
+ nodesDS.update(updates);
337
+ updateSelectAllState();
338
+ }});
339
+ item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
340
+ <span class="legend-label">${{c.label}}</span>
341
+ <span class="legend-count">${{c.count}}</span>`;
342
+ item.prepend(cb);
343
+ item.onclick = (e) => {{
344
+ if (e.target === cb) return;
345
+ cb.checked = !cb.checked;
346
+ cb.dispatchEvent(new Event('change'));
347
+ }};
348
+ legendEl.appendChild(item);
349
+ }});
350
+ </script>"""
351
+
352
+
353
+ def _html_document_title(output_path: str) -> str:
354
+ """Return a portable label for the graph.html <title>.
355
+
356
+ Tracked artifacts must not embed the generator host absolute path
357
+ (regression of #433; reported again as #2598 on Windows). Keep from the
358
+ configured output-dir bare name (``graphify-out`` / ``GRAPHIFY_OUT``
359
+ basename) onward — portable in every case; otherwise fall back to a
360
+ cwd-relative label, and finally the filename only.
361
+ """
362
+ from graphify.paths import GRAPHIFY_OUT_NAME
363
+
364
+ raw = str(output_path).replace("\\", "/")
365
+ # Drop Windows drive prefix so Path parts are comparable on any OS.
366
+ if len(raw) >= 3 and raw[1] == ":" and raw[0].isalpha() and raw[2] == "/":
367
+ raw = raw[2:] # "/Users/..." style after drive strip
368
+ p = Path(raw)
369
+
370
+ parts = list(Path(raw).parts)
371
+ # Path("C:/Users/..") on POSIX may keep "C:" as first part — strip it.
372
+ if parts and len(parts[0]) == 2 and parts[0][1] == ":" and parts[0][0].isalpha():
373
+ parts = parts[1:]
374
+ # Prefer keeping from the output-dir marker onward: portable in every
375
+ # case, whereas a cwd-relative path still leaks host/user segments when
376
+ # the graph is built from a directory ABOVE the project (#2598 follow-up).
377
+ marker = GRAPHIFY_OUT_NAME
378
+ for i, part in enumerate(parts):
379
+ if part == marker or part.startswith("graphify-out"):
380
+ return "/".join(parts[i:])
381
+
382
+ # No standard out-dir marker (fully custom output path): fall back to a
383
+ # cwd-relative label when the target is under cwd, else the bare filename.
384
+ try:
385
+ resolved = p if p.is_absolute() else (Path.cwd() / p)
386
+ rel = resolved.resolve().relative_to(Path.cwd().resolve())
387
+ label = rel.as_posix()
388
+ if label and label != ".":
389
+ return label
390
+ except (ValueError, OSError, RuntimeError):
391
+ pass
392
+
393
+ name = p.name
394
+ return name if name else "graph.html"
395
+
396
+ def to_html(
397
+ G: nx.Graph,
398
+ communities: dict[int, list[str]],
399
+ output_path: str,
400
+ community_labels: dict[int, str] | None = None,
401
+ member_counts: dict[int, int] | None = None,
402
+ node_limit: int | None = None,
403
+ learning_overlay: dict | None = None,
404
+ ) -> bool:
405
+ """Generate an interactive vis.js HTML visualization of the graph.
406
+
407
+ Features: node size by degree, click-to-inspect panel, search box,
408
+ community filter, physics clustering by community, confidence-styled edges.
409
+ Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
410
+
411
+ If member_counts is provided (aggregated community view), node sizes are
412
+ based on community member counts rather than graph degree.
413
+
414
+ If node_limit is set and the graph exceeds it, automatically builds an
415
+ aggregated community-level meta-graph instead of raising ValueError.
416
+
417
+ Returns True when the output was written. Returns False when an aggregated
418
+ view would contain fewer than two communities and is intentionally skipped.
419
+ """
420
+ limit = node_limit if node_limit is not None else _viz_node_limit()
421
+ if G.number_of_nodes() > limit:
422
+ if node_limit is not None:
423
+ # Build aggregated community meta-graph
424
+ from collections import Counter as _Counter
425
+ import networkx as _nx
426
+ print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...")
427
+ node_to_community = {nid: cid for cid, members in communities.items() for nid in members}
428
+ meta = _nx.Graph()
429
+ for cid, members in communities.items():
430
+ meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))
431
+ edge_counts = _Counter()
432
+ for u, v in G.edges():
433
+ cu, cv = node_to_community.get(u), node_to_community.get(v)
434
+ if cu is not None and cv is not None and cu != cv:
435
+ edge_counts[(min(cu, cv), max(cu, cv))] += 1
436
+ for (cu, cv), w in edge_counts.items():
437
+ meta.add_edge(str(cu), str(cv), weight=w,
438
+ relation=f"{w} cross-community edges", confidence="AGGREGATED")
439
+ if meta.number_of_nodes() <= 1:
440
+ print("Single community - aggregated view not useful. Skipping graph.html.")
441
+ return False
442
+ meta_communities = {cid: [str(cid)] for cid in communities}
443
+ mc = {cid: len(members) for cid, members in communities.items()}
444
+ # Remap hyperedges from semantic node IDs to community IDs
445
+ raw_hyperedges = G.graph.get("hyperedges", [])
446
+ if raw_hyperedges:
447
+ remapped = []
448
+ for he in raw_hyperedges:
449
+ he_members = he.get("nodes", [])
450
+ comm_ids, seen = [], set()
451
+ for nid in he_members:
452
+ c = node_to_community.get(nid)
453
+ if c is None:
454
+ continue
455
+ s = str(c)
456
+ if s in seen:
457
+ continue
458
+ seen.add(s)
459
+ comm_ids.append(s)
460
+ if len(comm_ids) < 2:
461
+ continue
462
+ remapped.append({
463
+ "id": he.get("id", ""),
464
+ "label": he.get("label") or he.get("relation", "").replace("_", " "),
465
+ "nodes": comm_ids,
466
+ })
467
+ meta.graph["hyperedges"] = remapped
468
+ written = to_html(meta, meta_communities, output_path,
469
+ community_labels=community_labels, member_counts=mc)
470
+ if not written:
471
+ return False
472
+ print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
473
+ print("Tip: run with --obsidian for full node-level detail.")
474
+ return True
475
+ raise ValueError(
476
+ f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
477
+ f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
478
+ f"or reduce input size."
479
+ )
480
+
481
+ node_community = _node_community_map(communities)
482
+ degree = dict(G.degree())
483
+ max_deg = max(degree.values(), default=1) or 1
484
+ max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1
485
+
486
+ # Work-memory overlay (derived sidecar). When not passed explicitly, load it
487
+ # best-effort from the sibling .graphify_learning.json next to the output
488
+ # graph.html (which lives beside graph.json). Empty/missing => no learning
489
+ # fields, so the un-annotated render is byte-identical to pre-feature.
490
+ if learning_overlay is None:
491
+ learning_overlay = {}
492
+ try:
493
+ from graphify.reflect import load_learning_overlay as _llo
494
+ learning_overlay = _llo(Path(output_path))
495
+ except Exception:
496
+ learning_overlay = {}
497
+ # Status -> ring color. preferred=green, contested=amber. Tentative gets no
498
+ # ring (it's not yet trustworthy enough to highlight in the map).
499
+ _RING = {"preferred": "#22c55e", "contested": "#f59e0b"}
500
+
501
+ # Build nodes list for vis.js
502
+ vis_nodes = []
503
+ for node_id, data in G.nodes(data=True):
504
+ cid = node_community.get(node_id, 0)
505
+ color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
506
+ label = sanitize_label(data.get("label", node_id))
507
+ deg = degree.get(node_id, 1)
508
+ if member_counts:
509
+ mc = member_counts.get(cid, 1)
510
+ size = 10 + 30 * (mc / max_mc)
511
+ font_size = 12
512
+ else:
513
+ size = 10 + 30 * (deg / max_deg)
514
+ # Only show label for high-degree nodes by default; others show on hover
515
+ font_size = 12 if deg >= max_deg * 0.15 else 0
516
+ node = {
517
+ "id": node_id,
518
+ "label": label,
519
+ "color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
520
+ "size": round(size, 1),
521
+ "font": {"size": font_size, "color": "#ffffff"},
522
+ "title": _html.escape(label),
523
+ "community": cid,
524
+ "community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
525
+ "source_file": sanitize_label(str(data.get("source_file") or "")),
526
+ "file_type": data.get("file_type", ""),
527
+ "degree": deg,
528
+ }
529
+ # Conditional learning fields — only present for annotated nodes, so
530
+ # un-annotated output keeps the exact pre-feature node dict shape.
531
+ entry = learning_overlay.get(str(node_id)) if learning_overlay else None
532
+ if entry:
533
+ status = sanitize_label(str(entry.get("status", "")))
534
+ stale = bool(entry.get("stale"))
535
+ node["learning_status"] = status
536
+ node["learning_stale"] = stale
537
+ ring = _RING.get(status)
538
+ if ring:
539
+ # Status-colored ring via the border; stale => desaturated +
540
+ # dashed (vis.js supports per-node `shapeProperties.borderDashes`).
541
+ if stale:
542
+ ring = "#9ca3af"
543
+ node["shapeProperties"] = {"borderDashes": [4, 4]}
544
+ node["borderWidth"] = 3
545
+ node["color"] = {
546
+ "background": color, "border": ring,
547
+ "highlight": {"background": "#ffffff", "border": ring},
548
+ }
549
+ # Lesson line appended to the hover title.
550
+ if status == "contested":
551
+ lesson = f"Lesson: contested (useful {entry.get('uses', 0)} / dead-end {entry.get('neg', 0)})"
552
+ elif status == "preferred":
553
+ lesson = f"Lesson: preferred source ({entry.get('uses', 0)} useful, score={entry.get('score', 0)})"
554
+ else:
555
+ lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
556
+ if stale:
557
+ lesson += " [code changed — re-verify]"
558
+ node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
559
+ vis_nodes.append(node)
560
+
561
+ # Build edges list. Restore original edge direction from _src/_tgt
562
+ # (stashed by build.py for exactly this reason): undirected NetworkX
563
+ # canonicalizes endpoint order, which would otherwise flip the arrow
564
+ # for `calls` and `rationale_for` in the rendered graph (#563).
565
+ vis_edges = []
566
+ for u, v, data in G.edges(data=True):
567
+ confidence = data.get("confidence", "EXTRACTED")
568
+ relation = data.get("relation", "")
569
+ true_src = data.get("_src", u)
570
+ true_tgt = data.get("_tgt", v)
571
+ vis_edges.append({
572
+ "from": true_src,
573
+ "to": true_tgt,
574
+ "label": relation,
575
+ "title": _html.escape(f"{relation} [{confidence}]"),
576
+ "dashes": confidence != "EXTRACTED",
577
+ "width": 2 if confidence == "EXTRACTED" else 1,
578
+ "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
579
+ "confidence": confidence,
580
+ })
581
+
582
+ # Build community legend data
583
+ legend_data = []
584
+ for cid in sorted((community_labels or {}).keys()):
585
+ color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
586
+ lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}")))
587
+ n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
588
+ legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
589
+
590
+ # Escape </script> sequences so embedded JSON cannot break out of the script tag
591
+ def _js_safe(obj) -> str:
592
+ return json.dumps(obj).replace("</", "<\\/")
593
+
594
+ nodes_json = _js_safe(vis_nodes)
595
+ edges_json = _js_safe(vis_edges)
596
+ legend_json = _js_safe(legend_data)
597
+ hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
598
+ title = _html.escape(sanitize_label(_html_document_title(output_path)))
599
+ stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"
600
+
601
+ html = f"""<!DOCTYPE html>
602
+ <html lang="en">
603
+ <head>
604
+ <meta charset="UTF-8">
605
+ <title>graphify - {title}</title>
606
+ <script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
607
+ integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
608
+ crossorigin="anonymous"></script>
609
+ {_html_styles()}
610
+ </head>
611
+ <body>
612
+ <div id="graph"></div>
613
+ <div id="sidebar">
614
+ <div id="search-wrap">
615
+ <input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
616
+ <div id="search-results"></div>
617
+ </div>
618
+ <div id="info-panel">
619
+ <h3>Node Info</h3>
620
+ <div id="info-content"><span class="empty">Click a node to inspect it</span></div>
621
+ </div>
622
+ <div id="legend-wrap">
623
+ <h3>Communities</h3>
624
+ <div id="legend-controls">
625
+ <label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
626
+ </div>
627
+ <div id="legend"></div>
628
+ </div>
629
+ <div id="stats">{stats}</div>
630
+ </div>
631
+ {_html_script(nodes_json, edges_json, legend_json)}
632
+ {_hyperedge_script(hyperedges_json)}
633
+ </body>
634
+ </html>"""
635
+
636
+ write_text_atomic(output_path, html)
637
+ return True