graphitect 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/watch.py ADDED
@@ -0,0 +1,2280 @@
1
+ # monitor a folder and auto-trigger --update when files change
2
+ from __future__ import annotations
3
+ import contextlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import posixpath
8
+ import re
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Callable
13
+
14
+ # Single source of truth in graphify.paths (#1423); re-exported as _GRAPHIFY_OUT.
15
+ from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT, is_absolute_any_platform
16
+
17
+ logger = logging.getLogger(__name__)
18
+ _PENDING_FILENAME = ".pending_changes"
19
+ _PENDING_DRAIN_MAX_PASSES = 20
20
+
21
+
22
+ def _queue_pending(out_dir: Path, changed_paths: list[Path]) -> None:
23
+ """Append ``changed_paths`` to ``out_dir/.pending_changes`` (one per line).
24
+
25
+ Used by a post-commit hook process that cannot acquire ``_rebuild_lock``
26
+ so its change set is not silently dropped (#1059). The lock-holding
27
+ process drains this file before and after its rebuild and merges the
28
+ contents with its own change set.
29
+
30
+ Opened in append mode so concurrent writers do not clobber each other on
31
+ POSIX; each ``write()`` of a small payload is effectively atomic. A
32
+ trailing newline is always written so partial-line corruption stays
33
+ confined to the offending entry and is skipped on drain.
34
+ """
35
+ if not changed_paths:
36
+ return
37
+ out_dir.mkdir(parents=True, exist_ok=True)
38
+ pending = out_dir / _PENDING_FILENAME
39
+ payload = "".join(f"{os.fspath(p)}\n" for p in changed_paths)
40
+ with open(pending, "a", encoding="utf-8") as fh:
41
+ fh.write(payload)
42
+
43
+
44
+ def _drain_pending(out_dir: Path) -> list[Path]:
45
+ """Read + unlink ``out_dir/.pending_changes`` and return deduplicated paths.
46
+
47
+ Returns an empty list if the file does not exist. Empty/whitespace lines
48
+ are silently skipped so a partial concurrent write that left only a
49
+ fragment cannot poison the merge.
50
+ """
51
+ pending = out_dir / _PENDING_FILENAME
52
+ if not pending.exists():
53
+ return []
54
+ try:
55
+ raw = pending.read_text(encoding="utf-8")
56
+ except OSError:
57
+ return []
58
+ # Unlink BEFORE returning so a crash between read and process retains the
59
+ # data in the next caller's view via the lines we are about to return —
60
+ # i.e. losing the file after reading is fine, losing it before would be a
61
+ # bug. Use missing_ok to tolerate a racing drain on platforms where
62
+ # rename/unlink may interleave.
63
+ with contextlib.suppress(FileNotFoundError):
64
+ pending.unlink()
65
+ seen: set[str] = set()
66
+ out: list[Path] = []
67
+ for line in raw.splitlines():
68
+ s = line.strip()
69
+ if not s or s in seen:
70
+ continue
71
+ seen.add(s)
72
+ out.append(Path(s))
73
+ return out
74
+
75
+
76
+ # Build options that must survive into later rebuilds. The initial `extract`
77
+ # scan honours `--exclude`, but `update`/`watch`/hook rebuilds re-run detect()
78
+ # and would silently re-include excluded paths unless the patterns are persisted
79
+ # (#1886). We store them beside the graph so any rebuild driver can re-apply them.
80
+ _BUILD_CONFIG_FILENAME = ".graphify_build.json"
81
+
82
+
83
+ def _write_build_config(
84
+ out_dir: Path,
85
+ *,
86
+ excludes: "list[str] | None",
87
+ gitignore: bool | None = None,
88
+ ) -> None:
89
+ """Persist corpus-shaping options under ``out_dir``.
90
+
91
+ Best effort and non clobbering: omitted options retain their existing values.
92
+ """
93
+ if not excludes and gitignore is None:
94
+ return
95
+ try:
96
+ out_dir.mkdir(parents=True, exist_ok=True)
97
+ path = out_dir / _BUILD_CONFIG_FILENAME
98
+ try:
99
+ config = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
100
+ except (OSError, json.JSONDecodeError):
101
+ config = {}
102
+ if not isinstance(config, dict):
103
+ config = {}
104
+ if excludes:
105
+ config["excludes"] = list(excludes)
106
+ if gitignore is not None:
107
+ config["gitignore"] = gitignore
108
+ path.write_text(json.dumps(config), encoding="utf-8")
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def _read_build_excludes(out_dir: Path) -> list[str]:
114
+ """Return the persisted ``--exclude`` patterns for this graph, or []."""
115
+ try:
116
+ path = out_dir / _BUILD_CONFIG_FILENAME
117
+ if path.is_file():
118
+ cfg = json.loads(path.read_text(encoding="utf-8"))
119
+ ex = cfg.get("excludes") if isinstance(cfg, dict) else None
120
+ if isinstance(ex, list):
121
+ return [str(x) for x in ex if isinstance(x, str) and x]
122
+ except (OSError, json.JSONDecodeError):
123
+ pass
124
+ return []
125
+
126
+
127
+ def _read_build_gitignore(out_dir: Path) -> bool:
128
+ """Return whether rebuilds should honor VCS ignore files (default True)."""
129
+ try:
130
+ path = out_dir / _BUILD_CONFIG_FILENAME
131
+ if path.is_file():
132
+ cfg = json.loads(path.read_text(encoding="utf-8"))
133
+ if isinstance(cfg, dict) and isinstance(cfg.get("gitignore"), bool):
134
+ return cfg["gitignore"]
135
+ except (OSError, json.JSONDecodeError):
136
+ pass
137
+ return True
138
+
139
+
140
+ def _merge_changed_paths(*sources: "list[Path] | None") -> list[Path]:
141
+ """Concatenate path lists, preserving order and dropping duplicates.
142
+
143
+ Used to combine a hook process's own ``changed_paths`` with the drained
144
+ contents of ``.pending_changes`` so the lock-holding rebuild covers
145
+ every queued commit's worth of files (#1059).
146
+ """
147
+ seen: set[str] = set()
148
+ out: list[Path] = []
149
+ for src in sources:
150
+ if not src:
151
+ continue
152
+ for p in src:
153
+ key = os.fspath(p)
154
+ if key in seen:
155
+ continue
156
+ seen.add(key)
157
+ out.append(p)
158
+ return out
159
+
160
+
161
+ @contextlib.contextmanager
162
+ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
163
+ """Per-repo advisory lock around a rebuild.
164
+
165
+ Yields True if acquired, False if another rebuild is already running and
166
+ ``blocking`` is False. Uses fcntl.flock so the lock is released
167
+ automatically if the process is killed (no stale-lock cleanup needed).
168
+
169
+ While the lock is held, ``.rebuild.lock`` contains the owning PID followed
170
+ by a newline so external pollers (publish scripts, etc.) can read it.
171
+ On successful release the file is unlinked so downstream tooling that
172
+ waits for the lock to clear by polling for its absence unblocks promptly.
173
+
174
+ Falls back to a no-op yield(True) on platforms without fcntl (Windows).
175
+ """
176
+ try:
177
+ import fcntl
178
+ except ImportError:
179
+ yield True
180
+ return
181
+
182
+ out_dir.mkdir(parents=True, exist_ok=True)
183
+ lock_path = out_dir / ".rebuild.lock"
184
+ # "a+" creates the file if missing without truncating an existing holder's
185
+ # PID payload — important because another process may have already written
186
+ # its PID before we attempt the flock.
187
+ fh = open(lock_path, "a+", encoding="utf-8")
188
+ acquired = False
189
+ try:
190
+ flags = fcntl.LOCK_EX if blocking else (fcntl.LOCK_EX | fcntl.LOCK_NB)
191
+ try:
192
+ fcntl.flock(fh.fileno(), flags)
193
+ except BlockingIOError:
194
+ yield False
195
+ return
196
+ acquired = True
197
+ # Replace any prior owner's PID with ours so external readers see a
198
+ # single parseable line, not a digit-concatenation across rebuilds.
199
+ try:
200
+ fh.seek(0)
201
+ fh.truncate()
202
+ fh.write(f"{os.getpid()}\n")
203
+ fh.flush()
204
+ except OSError:
205
+ pass
206
+ yield True
207
+ finally:
208
+ if acquired:
209
+ try:
210
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
211
+ except OSError:
212
+ pass
213
+ fh.close()
214
+ # Signal "rebuild done" by removing the lock file. Only the holder
215
+ # unlinks; a non-acquiring caller leaves the existing lock in place.
216
+ if acquired:
217
+ with contextlib.suppress(OSError):
218
+ lock_path.unlink()
219
+
220
+
221
+ def _apply_resource_limits() -> None:
222
+ """Best-effort nice + memory cap. Called from inline hook scripts.
223
+
224
+ GRAPHIFY_REBUILD_MEMORY_LIMIT_MB caps RSS-ish memory. Uses RLIMIT_DATA on
225
+ macOS (RLIMIT_AS is unreliable under Apple's libmalloc) and RLIMIT_AS on
226
+ Linux. Silently skips if the platform doesn't support it.
227
+ """
228
+ try:
229
+ os.nice(10)
230
+ except (OSError, AttributeError):
231
+ pass
232
+ mb = os.environ.get("GRAPHIFY_REBUILD_MEMORY_LIMIT_MB", "").strip()
233
+ if not mb:
234
+ return
235
+ try:
236
+ limit = int(mb) * 1024 * 1024
237
+ except ValueError:
238
+ return
239
+ try:
240
+ import resource
241
+ which = resource.RLIMIT_DATA if sys.platform == "darwin" else resource.RLIMIT_AS
242
+ soft, hard = resource.getrlimit(which)
243
+ new_hard = hard if hard != resource.RLIM_INFINITY and hard < limit else limit
244
+ resource.setrlimit(which, (limit, new_hard))
245
+ except (ImportError, ValueError, OSError):
246
+ pass
247
+
248
+
249
+ def _git_head(cwd: Path | str | None = None) -> str | None:
250
+ """Return current git HEAD commit hash, or None outside a repo.
251
+
252
+ ``cwd`` selects the repository to ask (#2316). Without it the command
253
+ inherits the caller's working directory, so `graphify update <target>`
254
+ stamped the *invoking* repo's commit into the target's graph.json — the
255
+ same CWD-anchoring mistake as the manifest path, but writing wrong
256
+ provenance rather than a misplaced file.
257
+ """
258
+ import subprocess as _sp
259
+ try:
260
+ r = _sp.run(
261
+ ["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3,
262
+ cwd=str(cwd) if cwd is not None else None,
263
+ )
264
+ return r.stdout.strip() if r.returncode == 0 else None
265
+ except Exception:
266
+ return None
267
+
268
+
269
+ from graphify.detect import (
270
+ CODE_EXTENSIONS,
271
+ DOC_EXTENSIONS,
272
+ PAPER_EXTENSIONS,
273
+ IMAGE_EXTENSIONS,
274
+ _load_graphifyignore,
275
+ _is_ignored,
276
+ )
277
+
278
+ _WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS
279
+ _CODE_EXTENSIONS = CODE_EXTENSIONS
280
+
281
+
282
+ def _report_root_label(watch_path: Path) -> str:
283
+ if watch_path.is_absolute():
284
+ return watch_path.name or str(watch_path)
285
+ return Path.cwd().name if watch_path == Path(".") else str(watch_path)
286
+
287
+
288
+ def _is_relative_to(path: Path, root: Path) -> bool:
289
+ try:
290
+ path.relative_to(root)
291
+ return True
292
+ except ValueError:
293
+ return False
294
+
295
+
296
+ def _changed_path_candidates(raw: Path, *, change_root: Path, watch_root: Path) -> list[Path]:
297
+ """Return plausible absolute locations for a hook-provided changed path.
298
+
299
+ Git hooks pass paths relative to the repository root. Watch callers may
300
+ also pass paths relative to the watched root. Keep both interpretations so
301
+ a graph rooted at ``src`` accepts ``src/app.py`` and ``app.py``.
302
+ """
303
+ if raw.is_absolute():
304
+ lexical = Path(os.path.abspath(raw))
305
+ resolved = raw.resolve()
306
+ return [lexical] if lexical == resolved else [lexical, resolved]
307
+
308
+ candidates: list[Path] = []
309
+ seen: set[str] = set()
310
+ for base in (change_root, watch_root):
311
+ lexical = Path(os.path.abspath(base / raw))
312
+ for cand in (lexical, lexical.resolve()):
313
+ key = os.fspath(cand)
314
+ if key in seen:
315
+ continue
316
+ seen.add(key)
317
+ candidates.append(cand)
318
+ return candidates
319
+
320
+
321
+ # Every stored path that names a file in the scanned tree. ``definition_file``
322
+ # is the implementation site recorded when a C/C++/ObjC declaration and its
323
+ # definition merge into one node; it must stay repo-relative like its sibling
324
+ # ``source_file`` so a graph built on one machine reads on another.
325
+ _PORTABLE_PATH_KEYS = ("source_file", "definition_file")
326
+
327
+
328
+ def _relativize_source_files(payload: dict, root: Path, *, scope: Path | None = None) -> None:
329
+ for bucket in ("nodes", "edges", "hyperedges"):
330
+ for item in payload.get(bucket, []):
331
+ for key in _PORTABLE_PATH_KEYS:
332
+ source = item.get(key)
333
+ if not source:
334
+ continue
335
+ source_path = Path(source)
336
+ if not source_path.is_absolute():
337
+ continue
338
+ try:
339
+ resolved = source_path.resolve()
340
+ if scope is not None and not _is_relative_to(resolved, scope):
341
+ continue
342
+ item[key] = resolved.relative_to(root).as_posix()
343
+ except ValueError:
344
+ continue
345
+
346
+
347
+ def _rebase_relative_source_files(payload: dict, source_root: Path, target_root: Path) -> None:
348
+ """Rebase cache-root-relative source paths onto the project root."""
349
+ if source_root == target_root:
350
+ return
351
+ for bucket in ("nodes", "edges", "hyperedges"):
352
+ for item in payload.get(bucket, []):
353
+ for key in _PORTABLE_PATH_KEYS:
354
+ source = item.get(key)
355
+ if not source or Path(source).is_absolute():
356
+ continue
357
+ try:
358
+ item[key] = (source_root / source).relative_to(target_root).as_posix()
359
+ except ValueError:
360
+ continue
361
+
362
+
363
+ class _StoredSourcePaths:
364
+ """Resolve source_file values across current and legacy graph roots."""
365
+
366
+ def __init__(
367
+ self,
368
+ existing: dict,
369
+ *,
370
+ out: Path,
371
+ project_root: Path,
372
+ watch_root: Path,
373
+ normalize_source,
374
+ ) -> None:
375
+ self.project_root = project_root
376
+ self.watch_root = watch_root
377
+ self._normalize_source = normalize_source
378
+ self.existing_source_root = project_root
379
+ relative_marker_prefix: str | None = None
380
+
381
+ root_marker = out / ".graphify_root"
382
+ if root_marker.exists():
383
+ try:
384
+ saved_root = Path(root_marker.read_text(encoding="utf-8-sig").strip())
385
+ if saved_root.is_absolute():
386
+ # #2603: the marker holds the SCAN root, but stored
387
+ # source_file values are relative to the BUILD's cwd
388
+ # (the skill builds from the repo root scoped to a
389
+ # subfolder). Trusting the marker blindly re-anchors
390
+ # "src/mod.py" under src/, doubling the path; every
391
+ # unchanged source is then judged deleted and evicted,
392
+ # collapsing the graph. Only adopt an anchor the stored
393
+ # paths actually resolve under; when none does, keep the
394
+ # marker (previous behavior) so a fully-deleted corpus
395
+ # still evicts.
396
+ resolved = saved_root.resolve()
397
+ if self._anchors_stored_sources(existing, resolved):
398
+ self.existing_source_root = resolved
399
+ else:
400
+ # In the #2603 hook path watch_path is the absolute
401
+ # marker, so project_root == watch_root == the bad
402
+ # marker and the first candidate also fails to anchor;
403
+ # Path.cwd() (the repo root a post-commit hook runs from)
404
+ # is the load-bearing rescue candidate here. Keep both so
405
+ # a relative-watch_path invocation is also covered.
406
+ for candidate in (self.project_root, Path.cwd().resolve()):
407
+ if self._anchors_stored_sources(existing, candidate):
408
+ self.existing_source_root = candidate
409
+ break
410
+ else:
411
+ self.existing_source_root = resolved
412
+ else:
413
+ invocation_root = Path.cwd().resolve()
414
+ if (invocation_root / saved_root).resolve() == watch_root:
415
+ self.existing_source_root = invocation_root
416
+ relative_marker_prefix = posixpath.normpath(saved_root.as_posix())
417
+ except (OSError, ValueError):
418
+ pass
419
+
420
+ self.legacy_watch_relative = False
421
+ if relative_marker_prefix not in (None, "."):
422
+ has_project_relative_source = False
423
+ for bucket in ("nodes", "links", "edges", "hyperedges"):
424
+ for item in existing.get(bucket, []):
425
+ stored = normalize_source(item.get("source_file"))
426
+ if not stored or Path(stored).is_absolute():
427
+ continue
428
+ normalized = posixpath.normpath(stored)
429
+ if (
430
+ normalized == relative_marker_prefix
431
+ or normalized.startswith(relative_marker_prefix + "/")
432
+ ):
433
+ has_project_relative_source = True
434
+ break
435
+ if has_project_relative_source:
436
+ break
437
+ self.legacy_watch_relative = not has_project_relative_source
438
+
439
+ def _anchors_stored_sources(self, existing: dict, root: Path, sample: int = 25) -> bool:
440
+ """Whether stored relative source_file paths resolve under ``root``.
441
+
442
+ Samples the first ``sample`` relative entries: the first hit accepts
443
+ the anchor; ``sample`` consecutive misses reject it. A graph with no
444
+ relative sources returns True (any anchor is harmless there). The
445
+ bound keeps the check O(sample) on large graphs; its known limit is a
446
+ commit that deletes ``sample``-plus files whose nodes happen to sort
447
+ first — the anchor then falls back to the marker, matching the
448
+ pre-fix behavior (no worse).
449
+ """
450
+ checked = 0
451
+ for bucket in ("nodes", "links", "edges", "hyperedges"):
452
+ for item in existing.get(bucket, []):
453
+ raw = item.get("source_file") if isinstance(item, dict) else None
454
+ stored = self._normalize_source(raw) if raw else None
455
+ if not stored or is_absolute_any_platform(stored):
456
+ continue
457
+ checked += 1
458
+ if (root / Path(posixpath.normpath(stored))).exists():
459
+ return True
460
+ if checked >= sample:
461
+ return False
462
+ return checked == 0
463
+
464
+ def normalize(self, source_file: str | None) -> str | None:
465
+ normalized = self._normalize_source(source_file, str(self.project_root))
466
+ return posixpath.normpath(normalized) if normalized else normalized
467
+
468
+ def absolute_identity(self, source_file: str | None, root: Path) -> str | None:
469
+ normalized = self._normalize_source(source_file)
470
+ if not normalized:
471
+ return normalized
472
+ source_path = Path(posixpath.normpath(normalized))
473
+ if not source_path.is_absolute():
474
+ source_path = root / source_path
475
+ return Path(os.path.abspath(source_path)).as_posix()
476
+
477
+ def identity(self, source_file: str | None) -> str | None:
478
+ normalized = self._normalize_source(source_file)
479
+ if normalized and not Path(normalized).is_absolute() and self.legacy_watch_relative:
480
+ return self.absolute_identity(normalized, self.watch_root)
481
+ return self.absolute_identity(normalized, self.existing_source_root)
482
+
483
+ def in_watch_root(self, source_file: str | None) -> bool:
484
+ identity = self.identity(source_file)
485
+ return bool(identity) and _is_relative_to(Path(identity), self.watch_root)
486
+
487
+ def is_evicted(self, item: dict, identities: set[str]) -> bool:
488
+ return self.identity(item.get("source_file")) in identities
489
+
490
+ def rebase_preserved(self, item: dict) -> None:
491
+ identity = self.identity(item.get("source_file"))
492
+ if not identity:
493
+ return
494
+ identity_path = Path(identity)
495
+ if not _is_relative_to(identity_path, self.watch_root):
496
+ normalized = self.normalize(item.get("source_file"))
497
+ if normalized:
498
+ item["source_file"] = normalized
499
+ return
500
+ try:
501
+ item["source_file"] = identity_path.relative_to(self.project_root).as_posix()
502
+ except ValueError:
503
+ item["source_file"] = identity
504
+
505
+
506
+ # A source_file that is a URL/virtual scheme (gdoc://, s3://, http://, ...) rather
507
+ # than a filesystem path: its on-disk existence is meaningless, so it must never be
508
+ # evicted by the disk-absence sweep. Matched with a regex, NOT a literal "://",
509
+ # because path normalization on the write side (Path.as_posix) collapses the double
510
+ # slash to one — a stored "gdoc://x" reads back as "gdoc:/x" on the next update, and
511
+ # a literal "://" check would then miss it and wrongly evict the node (#2051 follow-up).
512
+ # The scheme is required to be 2+ chars so a Windows drive letter (C:/...) is not
513
+ # misread as a remote source.
514
+ _REMOTE_SOURCE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]+://?")
515
+
516
+
517
+ def _is_remote_source(source_file: str) -> bool:
518
+ return bool(_REMOTE_SOURCE_RE.match(source_file))
519
+
520
+
521
+ def _reconcile_markdown_links(
522
+ result: dict,
523
+ preserved_edges: list[dict],
524
+ preserved_nodes: list[dict],
525
+ code_files: list[Path],
526
+ extract_targets: list[Path],
527
+ full_rebuild: bool,
528
+ project_root: Path,
529
+ source_paths,
530
+ ) -> list[dict]:
531
+ """Reconcile Markdown links without losing semantic-backed targets.
532
+
533
+ Semantic-backed documents are deliberately skipped by the AST quick scan
534
+ (#1915/#1954), so their canonical file node can be absent. Re-point an
535
+ authored link only when both files have unique representatives. If either
536
+ side is ambiguous, retain the existing AST edge instead of guessing or
537
+ deleting it. A link removed from its owning Markdown source is pruned.
538
+ """
539
+ from graphify.build import _is_ast_tier
540
+ from graphify.extract import _file_node_id, _safe_extract_with_xaml_root
541
+ from graphify.extractors.base import _make_id
542
+ from graphify.extractors.markdown import extract_markdown
543
+
544
+ all_nodes = result.get("nodes", []) + preserved_nodes
545
+ nodes_by_source: dict[str, list[dict]] = {}
546
+ for node in all_nodes:
547
+ if source_file := node.get("source_file"):
548
+ normalized = source_paths.normalize(source_file)
549
+ nodes_by_source.setdefault(normalized, []).append(node)
550
+
551
+ representatives: dict[str, str | None] = {}
552
+ for source_file, nodes in nodes_by_source.items():
553
+ canonical_id = _file_node_id(Path(source_file))
554
+ canonical = [node for node in nodes if node["id"] == canonical_id]
555
+ pages = [node for node in nodes if node.get("node_kind") == "page"]
556
+ semantic_documents = [
557
+ node
558
+ for node in nodes
559
+ if node.get("file_type") == "document" and node.get("_origin") != "ast"
560
+ ]
561
+ if len(canonical) == 1:
562
+ representatives[source_file] = canonical[0]["id"]
563
+ elif len(pages) == 1:
564
+ representatives[source_file] = pages[0]["id"]
565
+ elif len(semantic_documents) == 1:
566
+ representatives[source_file] = semantic_documents[0]["id"]
567
+ else:
568
+ representatives[source_file] = None
569
+
570
+ markdown_files = code_files if full_rebuild else extract_targets
571
+ markdown_files = [path for path in markdown_files if path.suffix.lower() == ".md"]
572
+ parsed_sources: set[str] = set()
573
+ authored_links: set[tuple[str, str]] = set()
574
+ authored_raw_pairs: set[frozenset[str]] = set()
575
+ unresolved_authored_sites: dict[tuple[str, str], set[str]] = {}
576
+ resolved_raw_pairs: dict[frozenset[str], frozenset[str]] = {}
577
+ desired_edges: list[tuple[str, str, dict, str, str]] = []
578
+ node_sources = {
579
+ node["id"]: source_paths.normalize(node["source_file"])
580
+ for node in all_nodes
581
+ if node.get("source_file")
582
+ }
583
+
584
+ for markdown_file in markdown_files:
585
+ try:
586
+ relative_source = markdown_file.relative_to(project_root)
587
+ except ValueError:
588
+ relative_source = markdown_file
589
+ source_file = source_paths.normalize(str(relative_source))
590
+ parsed_sources.add(source_file)
591
+ source_rep = representatives.get(source_file)
592
+
593
+ extraction = _safe_extract_with_xaml_root(
594
+ extract_markdown, markdown_file, project_root
595
+ )
596
+ for edge in extraction.get("edges", []):
597
+ if edge.get("relation") != "references":
598
+ continue
599
+ if not edge.get("target_file"):
600
+ source_location = str(edge.get("source_location") or "")
601
+ if source_location:
602
+ unresolved_authored_sites.setdefault(
603
+ (source_file, source_location), set()
604
+ ).add(str(edge.get("target") or ""))
605
+ continue
606
+ target_path = Path(edge["target_file"])
607
+ try:
608
+ target_path = target_path.relative_to(project_root)
609
+ except ValueError:
610
+ pass
611
+ target_file = source_paths.normalize(str(target_path))
612
+ authored_links.add((source_file, target_file))
613
+ raw_pair = frozenset(
614
+ (_file_node_id(Path(source_file)), _file_node_id(Path(target_file)))
615
+ )
616
+ authored_raw_pairs.add(raw_pair)
617
+
618
+ target_rep = representatives.get(target_file)
619
+ if not source_rep or not target_rep:
620
+ logger.debug(
621
+ "Preserving ambiguous Markdown link %s -> %s",
622
+ source_file,
623
+ target_file,
624
+ )
625
+ continue
626
+ if source_rep != target_rep:
627
+ resolved_raw_pairs[raw_pair] = frozenset((source_rep, target_rep))
628
+ desired_edges.append(
629
+ (source_rep, target_rep, edge, source_file, target_file)
630
+ )
631
+
632
+ desired_file_pairs = {
633
+ frozenset((source_file, target_file))
634
+ for _, _, _, source_file, target_file in desired_edges
635
+ }
636
+ desired_reps_by_file_pair = {
637
+ frozenset((source_file, target_file)): frozenset((source_rep, target_rep))
638
+ for source_rep, target_rep, _, source_file, target_file in desired_edges
639
+ }
640
+
641
+ def _matches_unresolved_target(
642
+ raw_target: str, owner: str, target_source: str
643
+ ) -> bool:
644
+ candidate = project_root / Path(owner).parent / Path(target_source).name
645
+ return raw_target == _make_id(str(candidate))
646
+
647
+ def _keep_edge(edge: dict) -> bool:
648
+ if not (_is_ast_tier(edge) and edge.get("relation") == "references"):
649
+ return True
650
+ owner = source_paths.normalize(edge.get("source_file"))
651
+ if owner not in parsed_sources:
652
+ return True
653
+
654
+ unresolved_targets = unresolved_authored_sites.get(
655
+ (owner, str(edge.get("source_location") or "")), set()
656
+ )
657
+ if unresolved_targets:
658
+ target_sources = {
659
+ source
660
+ for endpoint in (edge.get("source"), edge.get("target"))
661
+ if (source := node_sources.get(endpoint)) and source != owner
662
+ }
663
+ if any(
664
+ _matches_unresolved_target(raw_target, owner, target_source)
665
+ for raw_target in unresolved_targets
666
+ for target_source in target_sources
667
+ ):
668
+ return True
669
+
670
+ raw_pair = frozenset((edge.get("source"), edge.get("target")))
671
+ if raw_pair in authored_raw_pairs:
672
+ desired_pair = resolved_raw_pairs.get(raw_pair)
673
+ return desired_pair is None or raw_pair == desired_pair
674
+
675
+ endpoint_files = {
676
+ node_sources.get(edge.get("source")),
677
+ node_sources.get(edge.get("target")),
678
+ }
679
+ endpoint_files.discard(None)
680
+ targets = endpoint_files - {owner}
681
+ if len(targets) == 1:
682
+ target_file = targets.pop()
683
+ elif edge.get("target_file"):
684
+ target_path = Path(edge["target_file"])
685
+ try:
686
+ target_path = target_path.relative_to(project_root)
687
+ except ValueError:
688
+ pass
689
+ target_file = source_paths.normalize(str(target_path))
690
+ else:
691
+ return True
692
+
693
+ file_pair = frozenset((owner, target_file))
694
+ if (owner, target_file) not in authored_links:
695
+ return False
696
+ if file_pair not in desired_file_pairs:
697
+ return True
698
+ return raw_pair == desired_reps_by_file_pair[file_pair]
699
+
700
+ result["edges"] = [edge for edge in result.get("edges", []) if _keep_edge(edge)]
701
+ preserved_edges = [edge for edge in preserved_edges if _keep_edge(edge)]
702
+ existing_pairs = {
703
+ frozenset((edge.get("source"), edge.get("target")))
704
+ for edge in result["edges"] + preserved_edges
705
+ }
706
+ for source_rep, target_rep, edge, source_file, _ in desired_edges:
707
+ pair = frozenset((source_rep, target_rep))
708
+ # graphify uses a simple graph. Never overwrite another relation that
709
+ # already occupies the representative pair.
710
+ if pair in existing_pairs:
711
+ continue
712
+ reconciled = edge.copy()
713
+ reconciled.pop("target_file", None)
714
+ reconciled.update(
715
+ source=source_rep,
716
+ target=target_rep,
717
+ source_file=source_file,
718
+ _origin="ast",
719
+ )
720
+ result["edges"].append(reconciled)
721
+ existing_pairs.add(pair)
722
+
723
+ return preserved_edges
724
+
725
+
726
+ def _reconcile_existing_graph(
727
+ existing_graph: Path,
728
+ result: dict,
729
+ *,
730
+ out: Path,
731
+ project_root: Path,
732
+ watch_root: Path,
733
+ code_files: list[Path],
734
+ extract_targets: list[Path],
735
+ full_rebuild: bool,
736
+ deleted_paths: set[str],
737
+ deleted_source_identities: set[str],
738
+ is_ignored_always: Callable[[Path], bool] | None = None,
739
+ is_ignored_full: Callable[[Path], bool] | None = None,
740
+ ) -> tuple[dict, dict]:
741
+ """Merge fresh extraction with preserved graph entries and evict stale sources.
742
+
743
+ ``is_ignored_always``/``is_ignored_full`` (#2495) report whether a LIVE
744
+ ignore rule matches a path, mirroring the detect() call that produced
745
+ ``code_files`` (detect.ignored_predicate). ``is_ignored_always`` covers
746
+ .graphifyignore + persisted ``--exclude`` patterns — unambiguous
747
+ graph-level intent, honored on every rebuild. ``is_ignored_full`` adds
748
+ .gitignore-driven rules, honored only on a full rebuild (an explicit
749
+ ``graphify update``) so the incremental/hook path keeps preserving a
750
+ deliberately-graphed .gitignore'd tree (#1795).
751
+ """
752
+ existing_graph_data: dict = {}
753
+ if not existing_graph.exists():
754
+ return result, existing_graph_data
755
+
756
+ # Fail-closed load (#2251): reuse build._load_existing_graph, which raises
757
+ # ValueError when the file exceeds the size cap and RuntimeError when it
758
+ # exists but cannot be parsed. Those failures must PROPAGATE to the caller
759
+ # — swallowing them here left existing_graph_data == {}, which _check_shrink
760
+ # reads as "no baseline, write allowed", so the hook overwrote a graph it
761
+ # merely failed to READ. A missing file (None) keeps first-build behavior:
762
+ # reconcile proceeds with an empty baseline and the write is allowed.
763
+ from graphify.build import _load_existing_graph
764
+ if _load_existing_graph(existing_graph) is None:
765
+ return result, existing_graph_data
766
+ # Cap + parse validated above. Reload as the full dict — reconcile needs
767
+ # top-level keys (hyperedges, per-node community for _node_community_map,
768
+ # topology compare) the (nodes, edges, hyperedges) tuple does not carry.
769
+ # A failure here (e.g. a race rewriting the file) still propagates,
770
+ # staying fail-closed.
771
+ existing = json.loads(existing_graph.read_text(encoding="utf-8"))
772
+ existing_graph_data = existing
773
+
774
+ # Backfill tier provenance on legacy items (#2334), mirroring
775
+ # build._load_existing_graph (this reconcile path loads the raw dict
776
+ # separately, so the backfill there does not reach it). Stamping preserved
777
+ # items means the graph self-heals on this write.
778
+ from graphify.build import _is_ast_tier
779
+ for _bucket in ("nodes", "links", "edges"):
780
+ for _item in existing.get(_bucket, []):
781
+ if isinstance(_item, dict):
782
+ _item.setdefault("_origin", "ast" if _is_ast_tier(_item) else "semantic")
783
+
784
+ try:
785
+ from graphify.build import _norm_source_file as _nsf
786
+ from graphify.extract import _get_extractor
787
+ source_paths = _StoredSourcePaths(
788
+ existing,
789
+ out=out,
790
+ project_root=project_root,
791
+ watch_root=watch_root,
792
+ normalize_source=_nsf,
793
+ )
794
+ new_ast_ids = {n["id"] for n in result["nodes"]}
795
+ current_sources = {
796
+ source_paths.absolute_identity(str(path), project_root) for path in code_files
797
+ }
798
+ rebuilt_source_identities = {
799
+ source_paths.absolute_identity(str(path), project_root) for path in extract_targets
800
+ }
801
+ node_evicted_source_identities = set(deleted_source_identities)
802
+ hyperedge_evicted_source_identities = set(deleted_source_identities)
803
+ # Deletion evicts edges regardless of tier; re-extraction only owns a
804
+ # source's AST-tier edges (checked per-edge below, #1865).
805
+ edge_evicted_source_identities = set(deleted_source_identities)
806
+ if not full_rebuild:
807
+ node_evicted_source_identities.update(rebuilt_source_identities)
808
+
809
+ # Reconcile every rebuild against the current watched corpus. Hook change
810
+ # lists can contain only a rename destination, so explicit paths alone
811
+ # cannot identify the stale source. Keep the comparison scoped to the
812
+ # watched root so subfolder updates preserve records outside that subtree.
813
+ #
814
+ # Fail-closed eviction: a source identity missing from the corpus is only
815
+ # DELETION evidence when the file is actually gone from disk. A file that
816
+ # still exists but stopped being collected was *excluded*, and treating
817
+ # that as deletion silently mass-evicts good nodes. Evicting an alive
818
+ # source therefore needs POSITIVE evidence — a live ignore rule matching
819
+ # the path (#2495) — not mere corpus absence: a filter regression, an
820
+ # extractor loss, or the sensitive-file heuristic still preserves, with
821
+ # a loud message (#1795).
822
+ excluded_alive_files: set[str] = set()
823
+ excluded_alive_nodes = 0
824
+ newly_ignored_files: set[str] = set()
825
+ newly_ignored_nodes = 0
826
+ _alive_cache: dict[str, bool] = {}
827
+ _ignored_cache: dict[str, bool] = {}
828
+
829
+ def _ignored_now(identity: str) -> bool:
830
+ """True when a live ignore rule matches this alive, corpus-absent source.
831
+
832
+ .graphifyignore/--exclude matches evict on every rebuild;
833
+ .gitignore-driven matches only on a full rebuild (see docstring).
834
+ """
835
+ ignored = _ignored_cache.get(identity)
836
+ if ignored is None:
837
+ target = Path(identity)
838
+ ignored = bool(
839
+ (is_ignored_always is not None and is_ignored_always(target))
840
+ or (
841
+ full_rebuild
842
+ and is_ignored_full is not None
843
+ and is_ignored_full(target)
844
+ )
845
+ )
846
+ _ignored_cache[identity] = ignored
847
+ return ignored
848
+ for node in existing.get("nodes", []):
849
+ source_file = node.get("source_file")
850
+ if not source_file or _is_remote_source(source_file):
851
+ continue # sourceless stub or remote/virtual source: never evict
852
+ identity = source_paths.identity(source_file)
853
+ if not source_paths.in_watch_root(source_file):
854
+ continue
855
+ if _get_extractor(Path(source_file)) is None:
856
+ # Non-AST source (semantic doc/paper/image — .txt/.pdf/.png/...):
857
+ # never present in current_sources (built from AST-extractable
858
+ # code_files), so corpus absence is meaningless. Deletion
859
+ # evidence here is disk absence — otherwise its semantic nodes
860
+ # are preserved forever and returned as authoritative even after
861
+ # the file is deleted (#2051) — or a live ignore rule matching
862
+ # the alive file (#2495), the same positive evidence the AST
863
+ # branch below requires. A present-but-unexcluded-but-
864
+ # unextractable file stays preserved (alive -> skip).
865
+ if identity:
866
+ alive = _alive_cache.get(identity)
867
+ if alive is None:
868
+ alive = Path(identity).exists()
869
+ _alive_cache[identity] = alive
870
+ ignored = alive and _ignored_now(identity)
871
+ if ignored:
872
+ newly_ignored_files.add(identity)
873
+ newly_ignored_nodes += 1
874
+ if not alive or ignored:
875
+ normalized = source_paths.normalize(source_file)
876
+ if normalized:
877
+ deleted_paths.add(normalized)
878
+ node_evicted_source_identities.add(identity)
879
+ edge_evicted_source_identities.add(identity)
880
+ hyperedge_evicted_source_identities.add(identity)
881
+ continue
882
+ if identity not in current_sources:
883
+ if identity:
884
+ alive = _alive_cache.get(identity)
885
+ if alive is None:
886
+ alive = Path(identity).exists()
887
+ _alive_cache[identity] = alive
888
+ if alive:
889
+ if _ignored_now(identity):
890
+ # Intentionally excluded by a live ignore rule:
891
+ # deliberate graph-level intent, so treat it exactly
892
+ # like a deletion (#2495) — fall through to evict.
893
+ newly_ignored_files.add(identity)
894
+ newly_ignored_nodes += 1
895
+ else:
896
+ excluded_alive_files.add(identity)
897
+ excluded_alive_nodes += 1
898
+ continue
899
+ normalized = source_paths.normalize(source_file)
900
+ if normalized:
901
+ deleted_paths.add(normalized)
902
+ if identity:
903
+ node_evicted_source_identities.add(identity)
904
+ edge_evicted_source_identities.add(identity)
905
+ hyperedge_evicted_source_identities.add(identity)
906
+ if newly_ignored_files:
907
+ print(
908
+ f"[graphify watch] pruned {newly_ignored_nodes} node(s) from "
909
+ f"{len(newly_ignored_files)} newly-ignored file(s) "
910
+ "(matched by a live ignore rule while absent from the scan corpus)."
911
+ )
912
+ if excluded_alive_files:
913
+ print(
914
+ f"[graphify watch] fail-closed: kept {excluded_alive_nodes} node(s) "
915
+ f"from {len(excluded_alive_files)} file(s) that left the scan corpus "
916
+ "but still exist on disk and match no current ignore rule (filters "
917
+ "changed?). Add them to .graphifyignore if the exclusion is intentional."
918
+ )
919
+
920
+ # A full re-extraction owns the AST nodes of every source it actually
921
+ # re-extracted (extract_targets, via rebuilt_source_identities) — NOT
922
+ # every AST node under watch_root: a semantic-backed doc is excluded
923
+ # from extract_targets (#1915), so its existing AST layer is not
924
+ # regenerated this run and dropping it would be data loss (#2333,
925
+ # COEXIST — the AST and semantic layers of a file coexist).
926
+ # Incremental extraction owns only nodes from rebuilt or deleted
927
+ # sources. Semantic-tier nodes (per _is_ast_tier) remain preserved.
928
+ preserved_nodes = [
929
+ node
930
+ for node in existing.get("nodes", [])
931
+ if node["id"] not in new_ast_ids
932
+ and not (
933
+ _is_ast_tier(node)
934
+ and (
935
+ (
936
+ not node.get("source_file")
937
+ and (full_rebuild or not code_files)
938
+ )
939
+ or (
940
+ full_rebuild
941
+ and source_paths.is_evicted(node, rebuilt_source_identities)
942
+ )
943
+ )
944
+ )
945
+ and not source_paths.is_evicted(node, node_evicted_source_identities)
946
+ ]
947
+ all_ids = new_ast_ids | {node["id"] for node in preserved_nodes}
948
+
949
+ # Edges are owned by source_file, but ownership is tier-scoped: the AST
950
+ # pass replaces a re-extracted source's AST edges, while that source's
951
+ # semantic/LLM edges — which the AST pass cannot regenerate — survive
952
+ # until a semantic re-extraction supersedes them. Same provenance rule
953
+ # the node reconciliation above applies via _origin (#1865). Deletion
954
+ # eviction stays provenance-blind.
955
+ preserved_edges = [
956
+ edge
957
+ for edge in existing.get("links", existing.get("edges", []))
958
+ if edge.get("source") in all_ids
959
+ and edge.get("target") in all_ids
960
+ and not source_paths.is_evicted(edge, edge_evicted_source_identities)
961
+ and not (
962
+ _is_ast_tier(edge)
963
+ and source_paths.is_evicted(edge, rebuilt_source_identities)
964
+ )
965
+ ]
966
+
967
+ preserved_edges = _reconcile_markdown_links(
968
+ result,
969
+ preserved_edges,
970
+ preserved_nodes,
971
+ code_files,
972
+ extract_targets,
973
+ full_rebuild,
974
+ project_root,
975
+ source_paths,
976
+ )
977
+
978
+ new_hyperedge_ids = {
979
+ edge.get("id") for edge in result.get("hyperedges", []) if edge.get("id")
980
+ }
981
+ preserved_hyperedges = []
982
+ for edge in existing.get("hyperedges", []):
983
+ members = edge.get("nodes", edge.get("members", edge.get("node_ids", [])))
984
+ if edge.get("id") in new_hyperedge_ids or source_paths.is_evicted(
985
+ edge, hyperedge_evicted_source_identities
986
+ ):
987
+ continue
988
+ if isinstance(members, list) and any(member not in all_ids for member in members):
989
+ continue
990
+ preserved_hyperedges.append(edge)
991
+
992
+ for item in preserved_nodes + preserved_edges + preserved_hyperedges:
993
+ source_paths.rebase_preserved(item)
994
+
995
+ return {
996
+ "nodes": result["nodes"] + preserved_nodes,
997
+ "edges": result["edges"] + preserved_edges,
998
+ "hyperedges": result.get("hyperedges", []) + preserved_hyperedges,
999
+ "input_tokens": 0,
1000
+ "output_tokens": 0,
1001
+ }, existing_graph_data
1002
+ except Exception as exc:
1003
+ # Post-load reconciliation failure: fall back to the fresh extraction
1004
+ # while keeping the loaded baseline, so _check_shrink still guards the
1005
+ # write against a collapse. Say so — this used to be silent (#2251).
1006
+ print(
1007
+ "[graphify watch] reconcile of existing graph failed "
1008
+ f"({exc.__class__.__name__}: {exc}); proceeding with fresh "
1009
+ "extraction only.",
1010
+ file=sys.stderr,
1011
+ )
1012
+ return result, existing_graph_data
1013
+
1014
+
1015
+ def _node_community_map(graph_data: dict) -> dict[str, int]:
1016
+ out: dict[str, int] = {}
1017
+ for node in graph_data.get("nodes", []):
1018
+ node_id = node.get("id")
1019
+ cid = node.get("community")
1020
+ if node_id is None or cid is None:
1021
+ continue
1022
+ try:
1023
+ out[str(node_id)] = int(cid)
1024
+ except (TypeError, ValueError):
1025
+ print(
1026
+ f"[graphify watch] Skipping node with invalid community id: "
1027
+ f"node_id={node_id!r} community={cid!r}",
1028
+ file=sys.stderr,
1029
+ )
1030
+ continue
1031
+ return out
1032
+
1033
+
1034
+ def _canonical_graph_for_compare(graph_data: dict) -> dict:
1035
+ canonical = dict(graph_data)
1036
+ canonical.pop("built_at_commit", None)
1037
+ # A missing "directed" key means the same thing as "directed": false
1038
+ # everywhere else in the codebase (#2342's --no-cluster path only started
1039
+ # writing the key once it began inheriting it from the existing graph).
1040
+ # Normalise so an old graph.json without the key doesn't register as
1041
+ # "changed" against a freshly-written candidate that now carries
1042
+ # "directed": false explicitly.
1043
+ canonical["directed"] = bool(canonical.get("directed", False))
1044
+ for key in ("nodes", "links", "edges", "hyperedges"):
1045
+ if key in canonical and isinstance(canonical[key], list):
1046
+ canonical[key] = sorted(
1047
+ canonical[key],
1048
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
1049
+ )
1050
+ return canonical
1051
+
1052
+
1053
+ def _canonical_topology_for_compare(graph_data: dict) -> dict:
1054
+ canonical = dict(graph_data)
1055
+ canonical.pop("built_at_commit", None)
1056
+
1057
+ nodes = canonical.get("nodes")
1058
+ if isinstance(nodes, list):
1059
+ norm_nodes = []
1060
+ for node in nodes:
1061
+ if not isinstance(node, dict):
1062
+ continue
1063
+ n = dict(node)
1064
+ n.pop("community", None)
1065
+ n.pop("community_name", None)
1066
+ n.pop("norm_label", None)
1067
+ norm_nodes.append(n)
1068
+ canonical["nodes"] = sorted(
1069
+ norm_nodes,
1070
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
1071
+ )
1072
+
1073
+ for key in ("links", "edges"):
1074
+ items = canonical.get(key)
1075
+ if not isinstance(items, list):
1076
+ continue
1077
+ norm_edges = []
1078
+ for edge in items:
1079
+ if not isinstance(edge, dict):
1080
+ continue
1081
+ e = dict(edge)
1082
+ # to_json writes _src/_tgt as the canonical directed endpoints and
1083
+ # overwrites source/target with them before serialising, so the
1084
+ # on-disk graph has no _src/_tgt. The candidate topology (fresh from
1085
+ # node_link_data) still has them. Popping and reassigning here makes
1086
+ # both sides comparable: existing gets no-op pops (None), candidate
1087
+ # gets source/target overwritten from _src/_tgt — same result.
1088
+ true_src = e.pop("_src", None)
1089
+ true_tgt = e.pop("_tgt", None)
1090
+ if true_src is not None and true_tgt is not None:
1091
+ e["source"] = true_src
1092
+ e["target"] = true_tgt
1093
+ e.pop("confidence_score", None)
1094
+ norm_edges.append(e)
1095
+ canonical[key] = sorted(
1096
+ norm_edges,
1097
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
1098
+ )
1099
+
1100
+ hyperedges = canonical.get("hyperedges")
1101
+ if isinstance(hyperedges, list):
1102
+ canonical["hyperedges"] = sorted(
1103
+ hyperedges,
1104
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
1105
+ )
1106
+
1107
+ return canonical
1108
+
1109
+
1110
+ def _topology_from_graph(G) -> dict:
1111
+ from networkx.readwrite import json_graph
1112
+ try:
1113
+ data = json_graph.node_link_data(G, edges="links")
1114
+ except TypeError:
1115
+ data = json_graph.node_link_data(G)
1116
+ data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
1117
+ return data
1118
+
1119
+
1120
+ def _check_shrink(
1121
+ force: bool,
1122
+ existing_data: dict,
1123
+ new_data: dict,
1124
+ tmp: "Path | None" = None,
1125
+ *,
1126
+ had_explicit_deletions: bool = False,
1127
+ rebuilt_sources: "set[str] | None" = None,
1128
+ failed_sources: "set[str] | None" = None,
1129
+ ) -> bool:
1130
+ """Return True (ok to proceed) or False (shrink refused).
1131
+
1132
+ When False, cleans up *tmp* if provided and prints a warning to stderr.
1133
+
1134
+ The shrink-guard exists to catch SILENT shrinkage from failed extraction
1135
+ chunks (a half-written semantic pass leaving thousands of nodes
1136
+ unaccounted for). When ``had_explicit_deletions`` is True, the caller
1137
+ has declared which files were removed (e.g. the post-commit hook saw
1138
+ a ``D`` in ``git diff --name-only``) and a smaller graph is the expected
1139
+ outcome — skip the guard so legitimate refactors don't require ``--force``.
1140
+
1141
+ ``rebuilt_sources`` (when given) is the set of source files re-extracted this
1142
+ run. A net shrink is legitimate — not a failed chunk — when every *lost* node
1143
+ belonged to one of those files (a symbol removed from a re-extracted file) or
1144
+ carries no source_file. Only an unexplained loss (a node from a file we did
1145
+ NOT touch — e.g. a dropped semantic/doc node) refuses the write. This lets a
1146
+ plain ``graphify update`` after deleting a function refresh the graph without
1147
+ ``--force`` (#1116 left stale nodes write-blocked even though build dropped them).
1148
+ Files in ``failed_sources`` never account for lost nodes: extraction did not
1149
+ complete, so their disappearance is the silent shrink this guard protects.
1150
+ """
1151
+ if force or not existing_data:
1152
+ return True
1153
+ if had_explicit_deletions and rebuilt_sources is None:
1154
+ # Legacy callers declare deletions but pass no rebuilt_sources, so the
1155
+ # per-source accounting below can't run — keep the wholesale bypass for
1156
+ # them. When rebuilt_sources IS given, deleted paths are folded into it
1157
+ # (see call site), so genuine deletions still pass the _accounted check
1158
+ # while an unexplained loss (a present-but-unextractable file wrongly
1159
+ # routed to _add_deleted_source, or a dropped semantic node) is still
1160
+ # caught rather than being waved through by the mere presence of any
1161
+ # deletion in the change set (#2056).
1162
+ return True
1163
+ existing_nodes = existing_data.get("nodes", [])
1164
+ new_nodes = new_data.get("nodes", [])
1165
+ if len(new_nodes) >= len(existing_nodes):
1166
+ return True
1167
+ if rebuilt_sources is not None:
1168
+ from graphify.build import _norm_source_file
1169
+ new_ids = {n.get("id") for n in new_nodes}
1170
+ lost = [n for n in existing_nodes if n.get("id") not in new_ids]
1171
+
1172
+ def _accounted(n: dict) -> bool:
1173
+ sf = n.get("source_file")
1174
+ if sf and failed_sources and _norm_source_file(sf) in failed_sources:
1175
+ return False
1176
+ return (not sf
1177
+ or sf in rebuilt_sources
1178
+ or _norm_source_file(sf) in rebuilt_sources)
1179
+ if all(_accounted(n) for n in lost):
1180
+ return True
1181
+ if tmp is not None:
1182
+ tmp.unlink(missing_ok=True)
1183
+ print(
1184
+ f"[graphify] WARNING: new graph has {len(new_nodes)} nodes but existing "
1185
+ f"graph.json has {len(existing_nodes)}. Refusing to overwrite — you may be "
1186
+ f"missing chunk files from a previous session. "
1187
+ f"Pass --force to override.",
1188
+ file=sys.stderr,
1189
+ )
1190
+ return False
1191
+
1192
+
1193
+ def _report_for_compare(report_text: str) -> str:
1194
+ return re.sub(r"^- Built from commit: `[^`]+`\n?", "", report_text, flags=re.MULTILINE)
1195
+
1196
+
1197
+ def _json_text(data: dict) -> str:
1198
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
1199
+
1200
+
1201
+ def _stabilize_rebuild_cwd(watch_path: Path) -> bool:
1202
+ """Ensure relative rebuild paths have a usable CWD before queue/lock setup.
1203
+
1204
+ Detached git hooks can inherit a transient working directory that is deleted
1205
+ before the background rebuild starts. In that state Path.cwd(),
1206
+ Path('.').resolve(), and relative graphify-out mkdirs raise FileNotFoundError
1207
+ before the normal rebuild error handling can run. Hooks that know the repo
1208
+ root export GRAPHIFY_REPO_ROOT so the rebuild can recover by chdir'ing there.
1209
+ """
1210
+ if watch_path.is_absolute():
1211
+ return True
1212
+
1213
+ repo_root = os.environ.get("GRAPHIFY_REPO_ROOT", "").strip()
1214
+ if repo_root and Path(repo_root).is_dir():
1215
+ try:
1216
+ os.chdir(repo_root)
1217
+ return True
1218
+ except OSError:
1219
+ pass
1220
+
1221
+ try:
1222
+ Path.cwd()
1223
+ return True
1224
+ except FileNotFoundError:
1225
+ print(
1226
+ "[graphify watch] Rebuild failed: current working directory "
1227
+ "no longer exists and GRAPHIFY_REPO_ROOT is not set."
1228
+ )
1229
+ return False
1230
+
1231
+
1232
+ def _reconcile_graph_html(out: Path, graph_data: dict) -> str | None:
1233
+ """Reconcile missing, stale, or explicitly disabled HTML visualization.
1234
+
1235
+ The unchanged-topology update path deliberately avoids clustering and
1236
+ rewriting core artifacts. HTML is independently derivable, so rebuild it
1237
+ from the communities and names already stored in graph.json when absent or
1238
+ marked stale by a prior failed render.
1239
+
1240
+ Returns ``"rendered"`` or ``"removed"`` when it changed the HTML state,
1241
+ otherwise ``None``.
1242
+ """
1243
+ html_target = out / "graph.html"
1244
+ from graphify.exporters.html import _HTML_STALE_MARKER, _viz_node_limit
1245
+ stale_marker = out / _HTML_STALE_MARKER
1246
+
1247
+ def clear_stale_marker() -> None:
1248
+ try:
1249
+ stale_marker.unlink(missing_ok=True)
1250
+ except OSError as exc:
1251
+ print(
1252
+ "[graphify watch] graph.html stale marker could not be cleared; "
1253
+ f"regeneration may be retried: {exc}"
1254
+ )
1255
+
1256
+ limit = _viz_node_limit()
1257
+ if limit <= 0:
1258
+ changed = html_target.exists() or stale_marker.exists()
1259
+ html_target.unlink(missing_ok=True)
1260
+ clear_stale_marker()
1261
+ return "removed" if changed else None
1262
+ if html_target.exists() and not stale_marker.exists():
1263
+ return None
1264
+
1265
+ had_html = html_target.exists()
1266
+
1267
+ node_communities = _node_community_map(graph_data)
1268
+ communities: dict[int, list[str]] = {}
1269
+ for node_id, cid in node_communities.items():
1270
+ communities.setdefault(cid, []).append(node_id)
1271
+
1272
+ labels: dict[int, str] = {}
1273
+ for node in graph_data.get("nodes", []):
1274
+ cid = node_communities.get(str(node.get("id")))
1275
+ name = node.get("community_name")
1276
+ if cid is not None and isinstance(name, str) and name:
1277
+ labels.setdefault(cid, name)
1278
+
1279
+ try:
1280
+ from graphify.export import to_html
1281
+ from graphify.paths import load_node_link_graph
1282
+
1283
+ persisted_graph = load_node_link_graph(graph_data)
1284
+ written = to_html(
1285
+ persisted_graph,
1286
+ communities,
1287
+ str(html_target),
1288
+ community_labels=labels or None,
1289
+ node_limit=limit,
1290
+ )
1291
+ except Exception as exc:
1292
+ if had_html:
1293
+ print(
1294
+ "[graphify watch] Stale graph.html left unchanged; "
1295
+ f"regeneration will be retried: {exc}"
1296
+ )
1297
+ else:
1298
+ print(f"[graphify watch] Missing graph.html could not be regenerated: {exc}")
1299
+ return None
1300
+
1301
+ if not written or not html_target.exists():
1302
+ if had_html:
1303
+ print(
1304
+ "[graphify watch] Stale graph.html left unchanged; "
1305
+ "no useful community view was generated."
1306
+ )
1307
+ else:
1308
+ print("[graphify watch] Missing graph.html has no useful community view; skipped.")
1309
+ return None
1310
+ clear_stale_marker()
1311
+ return "rendered"
1312
+
1313
+
1314
+ def _rebuild_code(
1315
+ watch_path: Path,
1316
+ *,
1317
+ changed_paths: list[Path] | None = None,
1318
+ follow_symlinks: bool = False,
1319
+ force: bool = False,
1320
+ no_cluster: bool = False,
1321
+ acquire_lock: bool = True,
1322
+ block_on_lock: bool = False,
1323
+ ) -> bool:
1324
+ """Re-run AST extraction + build + optional cluster + report for code files. No LLM needed.
1325
+
1326
+ When ``force`` is True the node-count safety check in ``to_json`` is bypassed
1327
+ so the rebuilt graph overwrites graph.json even if it has fewer nodes.
1328
+ Use this after refactors that legitimately delete code.
1329
+
1330
+ When ``changed_paths`` is provided, only those files are re-extracted; nodes
1331
+ for unchanged files are preserved from the existing graph. Deleted paths
1332
+ in ``changed_paths`` (paths that no longer exist on disk) are dropped from
1333
+ the preserved set. When ``changed_paths`` is None the full code corpus is
1334
+ re-extracted (used by the watcher and post-checkout hook).
1335
+
1336
+ ``acquire_lock`` (default True) takes a non-blocking per-repo flock around
1337
+ the rebuild so concurrent post-commit hooks across multiple repos do not
1338
+ pile up. Returns False with a log line if the lock is held. Pass
1339
+ ``block_on_lock=True`` to wait instead of skip (used by the interactive
1340
+ ``graphify update`` CLI).
1341
+
1342
+ ``no_cluster`` skips community detection and writes raw merged extraction
1343
+ JSON to graphify-out/graph.json (mirrors ``extract --no-cluster``).
1344
+
1345
+ Returns True on success, False on error or skipped-due-to-lock.
1346
+ """
1347
+ if not _stabilize_rebuild_cwd(watch_path):
1348
+ return False
1349
+
1350
+ out = watch_path / _GRAPHIFY_OUT
1351
+ if acquire_lock:
1352
+ # #1059: incremental (changed_paths is not None) hooks must not drop
1353
+ # their change set when another rebuild is already running. Queue
1354
+ # before attempting the lock so a non-blocking failure still records
1355
+ # the work; the lock-holder drains the queue and merges it in. Full-
1356
+ # corpus rebuilds skip the queue entirely — they already cover every
1357
+ # file, so there is nothing to merge.
1358
+ if changed_paths is not None and not block_on_lock:
1359
+ _queue_pending(out, list(changed_paths))
1360
+ with _rebuild_lock(out, blocking=block_on_lock) as got:
1361
+ if not got:
1362
+ print("[graphify watch] Rebuild already in progress for "
1363
+ f"{watch_path.resolve()} - changes queued.")
1364
+ return False
1365
+ # Lock acquired. Drain anything queued by earlier contenders
1366
+ # (including, importantly, the paths we just queued ourselves)
1367
+ # and merge with our own change set so a single rebuild covers
1368
+ # everything outstanding.
1369
+ if changed_paths is not None:
1370
+ merged = _merge_changed_paths(changed_paths, _drain_pending(out))
1371
+ else:
1372
+ # Full-corpus rebuild supersedes any queued incremental work.
1373
+ _drain_pending(out)
1374
+ merged = None
1375
+ ok = _rebuild_code(
1376
+ watch_path,
1377
+ changed_paths=merged,
1378
+ follow_symlinks=follow_symlinks,
1379
+ force=force,
1380
+ no_cluster=no_cluster,
1381
+ acquire_lock=False,
1382
+ )
1383
+ # Late-arrival drain: another hook may have queued work while we
1384
+ # were rebuilding. Loop up to _PENDING_DRAIN_MAX_PASSES times so a
1385
+ # storm of commits eventually quiesces without livelocking. A full
1386
+ # rebuild already saw everything, so skip this for changed_paths is None.
1387
+ if merged is not None:
1388
+ for _ in range(_PENDING_DRAIN_MAX_PASSES):
1389
+ late = _drain_pending(out)
1390
+ if not late:
1391
+ break
1392
+ ok = _rebuild_code(
1393
+ watch_path,
1394
+ changed_paths=late,
1395
+ follow_symlinks=follow_symlinks,
1396
+ force=force,
1397
+ no_cluster=no_cluster,
1398
+ acquire_lock=False,
1399
+ ) and ok
1400
+ return ok
1401
+
1402
+ watch_root = watch_path.resolve()
1403
+ # project_root stays CWD-anchored for a relative invocation on purpose: the
1404
+ # persisted graph rehomes source_file across invocation styles against it
1405
+ # (tests/test_watch.py:1389, :1428). The manifest is a different artifact
1406
+ # with a different anchor — see the save_manifest calls below.
1407
+ project_root = Path.cwd().resolve() if not watch_path.is_absolute() else watch_root
1408
+ report_root = _report_root_label(watch_path)
1409
+ try:
1410
+ from graphify.extract import extract, _get_extractor
1411
+ from graphify.detect import detect
1412
+ from graphify.build import build_from_json, _is_ast_tier, _norm_source_file as _nsf
1413
+ from graphify.cluster import cluster, remap_communities_to_previous, score_all
1414
+ from graphify.analyze import god_nodes, surprising_connections, suggest_questions
1415
+ from graphify.report import generate
1416
+ from graphify.export import to_json
1417
+ from graphify.security import check_graph_file_size_cap
1418
+
1419
+ # Re-apply the excludes the initial extract recorded, so an update/watch/
1420
+ # hook rebuild does not silently re-include deliberately excluded paths
1421
+ # (#1886).
1422
+ _persisted_excludes = _read_build_excludes(out)
1423
+ _gitignore_enabled = _read_build_gitignore(out)
1424
+ detected = detect(
1425
+ watch_path, follow_symlinks=follow_symlinks,
1426
+ extra_excludes=_persisted_excludes or None,
1427
+ gitignore=_gitignore_enabled,
1428
+ )
1429
+ code_files = [Path(f) for f in detected['files']['code']]
1430
+
1431
+ # #2495: hand reconcile the same ignore decisions the detect() call
1432
+ # above made, so a newly-ignored file that still exists on disk is
1433
+ # purged from the graph instead of preserved forever by the fail-closed
1434
+ # keep. Two tiers: .graphifyignore + persisted --exclude patterns are
1435
+ # unambiguous graph-level intent (evict on every rebuild), while
1436
+ # .gitignore-driven exclusion is honored only on a full rebuild — the
1437
+ # incremental/hook path keeps preserving a deliberately-graphed
1438
+ # .gitignore'd tree (#1795).
1439
+ from graphify.detect import ignored_predicate
1440
+ _ignored_always = ignored_predicate(
1441
+ watch_root, extra_excludes=_persisted_excludes or None, gitignore=False,
1442
+ )
1443
+ _ignored_full = ignored_predicate(
1444
+ watch_root, extra_excludes=_persisted_excludes or None, gitignore=True,
1445
+ ) if _gitignore_enabled else _ignored_always
1446
+
1447
+ # Include document files that have AST extractors (e.g. .md, .mdx, .qmd)
1448
+ ast_doc_files: list[Path] = []
1449
+ for doc_file in detected['files'].get('document', []):
1450
+ p = Path(doc_file)
1451
+ if _get_extractor(p) is not None:
1452
+ code_files.append(p)
1453
+ ast_doc_files.append(p)
1454
+
1455
+ existing_graph = out / "graph.json"
1456
+ if not code_files and not existing_graph.exists():
1457
+ print("[graphify watch] No code files found - nothing to rebuild.")
1458
+ return False
1459
+
1460
+ # #1915: a document that already carries SEMANTIC (LLM) nodes in the
1461
+ # existing graph must not ALSO be AST-quick-scanned — otherwise every
1462
+ # rebuild mints heading nodes on top of the preserved semantic nodes
1463
+ # and the doc is represented twice (~4x graph bloat vs the CLI update
1464
+ # path, which AST-extracts only code). A semantic-backed doc is never
1465
+ # re-quick-scanned, and any AST layer it already carries coexists and
1466
+ # is preserved rather than regenerated (#2333, COEXIST); the
1467
+ # quick-scan stays as a fallback for docs with no semantic layer (the
1468
+ # no-LLM doc-structure feature, #09b33b7) and for brand-new docs the
1469
+ # graph has never seen. These docs stay in ``code_files`` so
1470
+ # corpus membership (#1795 fail-closed deletion evidence) and the
1471
+ # shrink accounting below still cover them — a previously-bloated
1472
+ # graph must be allowed to self-heal on a full rebuild without the
1473
+ # shrink-guard refusing the smaller write.
1474
+ semantic_doc_files: set[Path] = set()
1475
+ if ast_doc_files and existing_graph.exists():
1476
+ try:
1477
+ check_graph_file_size_cap(existing_graph)
1478
+ prior = json.loads(existing_graph.read_text(encoding="utf-8"))
1479
+ prior_paths = _StoredSourcePaths(
1480
+ prior,
1481
+ out=out,
1482
+ project_root=project_root,
1483
+ watch_root=watch_root,
1484
+ normalize_source=_nsf,
1485
+ )
1486
+ # Semantic doc nodes lack the AST origin marker. Gate on the
1487
+ # doc-shaped subset of the six-value file_type enum
1488
+ # (document/concept/rationale/paper AND code) rather than
1489
+ # "document" alone: per the extraction spec, a doc full of named
1490
+ # concepts may be represented with ONLY concept/rationale
1491
+ # nodes and no separate "document" node — that's still
1492
+ # evidence of a semantic layer, not a marker-less AST node
1493
+ # (#1954). "code" is included too (#2014): the semantic pass
1494
+ # legitimately mints code-typed nodes for symbols surfaced from
1495
+ # WITHIN a doc (llm.py `_bind_node_evidence`), and it cannot be
1496
+ # confused with a pre-#1865 marker-less AST code node — those are
1497
+ # sourced from code files, which never intersect ast_doc_files
1498
+ # below, whereas the AST quick-scan of a doc only ever mints
1499
+ # "document" nodes (extractors/markdown.py). "image" stays out.
1500
+ semantic_doc_identities: set[str] = set()
1501
+ for node in prior.get("nodes", []):
1502
+ # _is_ast_tier, not a raw _origin check (#2334): a legacy
1503
+ # unstamped AST heading node (source_location "L<n>") must
1504
+ # not fake a semantic layer, or the doc would be excluded
1505
+ # from the AST quick-scan forever.
1506
+ if _is_ast_tier(node):
1507
+ continue
1508
+ if node.get("file_type") not in (
1509
+ "document", "concept", "rationale", "paper", "code"
1510
+ ):
1511
+ continue
1512
+ identity = prior_paths.identity(node.get("source_file"))
1513
+ if identity:
1514
+ semantic_doc_identities.add(identity)
1515
+ if semantic_doc_identities:
1516
+ semantic_doc_files = {
1517
+ p for p in ast_doc_files
1518
+ if prior_paths.absolute_identity(str(p), project_root)
1519
+ in semantic_doc_identities
1520
+ }
1521
+ except Exception:
1522
+ semantic_doc_files = set()
1523
+
1524
+ # Incremental path: when the caller passed an explicit change list,
1525
+ # extract only changed-and-still-existing files. Deleted paths are
1526
+ # tracked separately so their stale nodes can be evicted below.
1527
+ deleted_paths: set[str] = set()
1528
+ deleted_source_identities: set[str] = set()
1529
+ def _add_deleted_source(path: Path) -> None:
1530
+ deleted_source_identities.add(Path(os.path.abspath(path)).as_posix())
1531
+ for root in (project_root, watch_root):
1532
+ deleted_paths.add(_nsf(str(path), str(root)) or str(path))
1533
+
1534
+ if changed_paths is not None:
1535
+ code_set = {Path(os.path.abspath(p)) for p in code_files}
1536
+ # #1915: semantic-backed docs are never AST-quick-scanned; their
1537
+ # semantic nodes are the sole representation. Mirroring #1865's
1538
+ # tier-scoped edge rule at the node level, they also must NOT
1539
+ # enter extract_targets (hence rebuilt/node-evicted identities) on
1540
+ # an incremental rebuild, or their semantic nodes would be wiped.
1541
+ semantic_doc_set = {Path(os.path.abspath(p)) for p in semantic_doc_files}
1542
+ wanted: list[Path] = []
1543
+ change_root = Path.cwd().resolve()
1544
+ for raw in changed_paths:
1545
+ candidates = _changed_path_candidates(
1546
+ raw,
1547
+ change_root=change_root,
1548
+ watch_root=watch_root,
1549
+ )
1550
+ tracked = next((cand for cand in candidates if cand.exists() and cand in code_set), None)
1551
+ if tracked is not None:
1552
+ if tracked not in wanted and tracked not in semantic_doc_set:
1553
+ wanted.append(tracked)
1554
+ continue
1555
+
1556
+ existing_in_root = next(
1557
+ (
1558
+ cand for cand in candidates
1559
+ if cand.exists() and _is_relative_to(cand, watch_root)
1560
+ ),
1561
+ None,
1562
+ )
1563
+ if existing_in_root is not None:
1564
+ # The path exists under the watched root but detect filtered
1565
+ # it out of code_set (no AST extractor, excluded, or
1566
+ # sensitive). Existence is NOT deletion evidence (#2056): the
1567
+ # file may carry semantic (LLM) nodes an AST rebuild cannot
1568
+ # regenerate, and mis-routing it to _add_deleted_source both
1569
+ # evicts those nodes AND sets had_explicit_deletions, which
1570
+ # disables the shrink guard that would otherwise catch the
1571
+ # loss. Preserve it — a genuine deletion still evicts via the
1572
+ # branch below, the corpus sweep evicts a truly-gone non-AST
1573
+ # source, and a deliberate exclusion is purged by the
1574
+ # corpus sweep's ignore-rule check (#2495).
1575
+ continue
1576
+
1577
+ deleted_in_root = next(
1578
+ (cand for cand in candidates if _is_relative_to(cand, watch_root)),
1579
+ None,
1580
+ )
1581
+ if deleted_in_root is not None:
1582
+ # File was deleted or renamed away inside the watched root.
1583
+ # Evict preserved nodes that still claim this source path.
1584
+ _add_deleted_source(deleted_in_root)
1585
+ if not wanted and not deleted_paths:
1586
+ print("[graphify watch] No tracked code files in change set - skipping rebuild.")
1587
+ return True
1588
+ extract_targets = wanted
1589
+ else:
1590
+ # Full rebuild: skip the AST quick-scan for semantic-backed docs
1591
+ # (#1915). They remain in code_files for corpus membership and
1592
+ # shrink accounting, but because they are not extract targets the
1593
+ # full-rebuild AST ownership rule (scoped to
1594
+ # rebuilt_source_identities, #2333 COEXIST) leaves their existing
1595
+ # AST heading layer intact alongside the semantic layer.
1596
+ extract_targets = [p for p in code_files if p not in semantic_doc_files]
1597
+
1598
+ # #2406: an incremental rebuild parses only the changed files, so the
1599
+ # cross-file resolvers could not see a callee living in an unchanged
1600
+ # file and every changed->unchanged `calls` edge disappeared (reconcile
1601
+ # evicts the old one as AST-tier output of a re-extracted source, and
1602
+ # nothing regenerates it). Hand extract() read-only resolution context:
1603
+ # the persisted AST nodes of files this run is NOT re-extracting —
1604
+ # including their `_callable`/`_callable_class` markers, so the
1605
+ # indirect_call guard keeps working (#2438) — plus their contains/method
1606
+ # edges, which the member-call resolvers walk (#2437).
1607
+ #
1608
+ # Scoping rules, in order of importance:
1609
+ # * AST-tier only — semantic/LLM nodes are not symbol definitions.
1610
+ # * never a file in extract_targets (its fresh nodes are authoritative)
1611
+ # nor a deleted one (its persisted symbols are gone).
1612
+ # * only sources still in the scanned corpus, so a renamed/removed file
1613
+ # cannot stay a resolver target.
1614
+ # extract() uses these purely to widen the resolvers' indexes; nothing
1615
+ # is parsed, mutated, or emitted from them (see extract()'s docstring).
1616
+ resolution_context_nodes: list[dict] = []
1617
+ resolution_context_edges: list[dict] = []
1618
+ if changed_paths is not None and existing_graph.exists():
1619
+ try:
1620
+ check_graph_file_size_cap(existing_graph)
1621
+ ctx_graph = json.loads(existing_graph.read_text(encoding="utf-8"))
1622
+ ctx_paths = _StoredSourcePaths(
1623
+ ctx_graph,
1624
+ out=out,
1625
+ project_root=project_root,
1626
+ watch_root=watch_root,
1627
+ normalize_source=_nsf,
1628
+ )
1629
+ ctx_live = {
1630
+ ctx_paths.absolute_identity(str(p), project_root) for p in code_files
1631
+ }
1632
+ ctx_live -= {
1633
+ ctx_paths.absolute_identity(str(p), project_root) for p in extract_targets
1634
+ }
1635
+ ctx_live -= deleted_source_identities
1636
+ ctx_live.discard(None)
1637
+ for node in ctx_graph.get("nodes", []):
1638
+ if not node.get("id") or not _is_ast_tier(node):
1639
+ continue
1640
+ source_file = node.get("source_file")
1641
+ if not source_file or ctx_paths.identity(source_file) not in ctx_live:
1642
+ continue
1643
+ ctx_node = {
1644
+ "id": node["id"],
1645
+ "label": node.get("label"),
1646
+ "source_file": source_file,
1647
+ "file_type": node.get("file_type"),
1648
+ "type": node.get("type"),
1649
+ }
1650
+ # #2438: the persisted callability markers are the only
1651
+ # thing that lets an unchanged target pass the
1652
+ # indirect_call guard — never re-derived from the label.
1653
+ for marker in ("_callable", "_callable_class"):
1654
+ if node.get(marker):
1655
+ ctx_node[marker] = node[marker]
1656
+ resolution_context_nodes.append(ctx_node)
1657
+ # #2437: the member-call resolvers map receiver type -> owning
1658
+ # class -> method through contains/method edges; hand over the
1659
+ # unchanged corpus's, scoped exactly like the nodes above so a
1660
+ # deleted/re-extracted file's edges can never resurrect.
1661
+ for edge in ctx_graph.get("links", ctx_graph.get("edges", [])):
1662
+ if edge.get("relation") not in ("contains", "method"):
1663
+ continue
1664
+ if not _is_ast_tier(edge):
1665
+ continue
1666
+ source_file = edge.get("source_file")
1667
+ if not source_file or ctx_paths.identity(source_file) not in ctx_live:
1668
+ continue
1669
+ resolution_context_edges.append({
1670
+ "source": edge.get("source"),
1671
+ "target": edge.get("target"),
1672
+ "relation": edge.get("relation"),
1673
+ "source_file": source_file,
1674
+ })
1675
+ except Exception:
1676
+ # Unreadable/oversized graph: resolve with the changed batch only
1677
+ # (pre-#2406 behavior). Reconcile below still fails closed on it.
1678
+ resolution_context_nodes = []
1679
+ resolution_context_edges = []
1680
+
1681
+ commit = _git_head(cwd=watch_root)
1682
+ result = extract(
1683
+ extract_targets,
1684
+ cache_root=watch_root,
1685
+ resolution_context_nodes=resolution_context_nodes or None,
1686
+ resolution_context_edges=resolution_context_edges or None,
1687
+ ) if extract_targets else {
1688
+ "nodes": [], "edges": [], "hyperedges": [],
1689
+ "input_tokens": 0, "output_tokens": 0,
1690
+ }
1691
+ _rebase_relative_source_files(result, watch_root, project_root)
1692
+
1693
+ # #2543: AST sources that failed this run (error result, or extractor
1694
+ # present but zero nodes) must not be stamped kind="ast" below, and any
1695
+ # prior stamp must be blanked (clear_ast) — otherwise the incremental
1696
+ # gate reports them unchanged forever and only deleting graphify-out/
1697
+ # recovers. Mirrors the extract CLI's _stamped_manifest_files handling.
1698
+ _failed_ast_sources = set(result.get("failed_sources") or [])
1699
+
1700
+ def _ast_manifest_files() -> dict[str, list[str]]:
1701
+ """detected["files"] minus this run's failed AST sources (#2543).
1702
+
1703
+ Only the STAMPED set shrinks; scan_corpus at the save sites stays
1704
+ the raw detect output so #1908 pruning is unaffected.
1705
+ """
1706
+ if not _failed_ast_sources:
1707
+ return detected["files"]
1708
+ failed_res = set(_failed_ast_sources)
1709
+ for p in _failed_ast_sources:
1710
+ try:
1711
+ failed_res.add(str(Path(p).resolve()))
1712
+ except (OSError, RuntimeError):
1713
+ pass
1714
+
1715
+ def _failed(f: str) -> bool:
1716
+ if f in failed_res:
1717
+ return True
1718
+ try:
1719
+ return str(Path(f).resolve()) in failed_res
1720
+ except (OSError, RuntimeError):
1721
+ return False
1722
+
1723
+ return {
1724
+ ftype: [f for f in flist if not _failed(f)]
1725
+ for ftype, flist in detected["files"].items()
1726
+ }
1727
+
1728
+ # Preserve semantic nodes/edges from a previous full run.
1729
+ # AST-only rebuild replaces nodes for changed files; everything else is kept.
1730
+ # Filter by node ID membership in the new AST output, not by file_type —
1731
+ # INFERRED/AMBIGUOUS nodes extracted from code files also carry file_type="code"
1732
+ # and would be wrongly dropped by a file_type-based filter.
1733
+ # When the caller supplied changed_paths, also evict preserved nodes whose
1734
+ # source_file matches a path that was changed (re-extracted) or deleted —
1735
+ # otherwise the old nodes for those files would survive forever.
1736
+ try:
1737
+ result, existing_graph_data = _reconcile_existing_graph(
1738
+ existing_graph,
1739
+ result,
1740
+ out=out,
1741
+ project_root=project_root,
1742
+ watch_root=watch_root,
1743
+ code_files=code_files,
1744
+ extract_targets=extract_targets,
1745
+ full_rebuild=changed_paths is None,
1746
+ deleted_paths=deleted_paths,
1747
+ deleted_source_identities=deleted_source_identities,
1748
+ is_ignored_always=_ignored_always,
1749
+ is_ignored_full=_ignored_full,
1750
+ )
1751
+ except (RuntimeError, ValueError) as exc:
1752
+ # Existing graph present but unreadable — over the size cap
1753
+ # (ValueError) or unparseable JSON (RuntimeError, both via
1754
+ # build._load_existing_graph). Refuse to overwrite a graph we
1755
+ # merely failed to READ (#2251), mirroring the CLI's fail-closed
1756
+ # contract (#2169). --force deliberately does NOT bypass this:
1757
+ # force means "accept a shrink", not "clobber an unreadable
1758
+ # graph".
1759
+ print(f"error: {exc}", file=sys.stderr)
1760
+ return False
1761
+
1762
+ _relativize_source_files(result, project_root, scope=watch_root)
1763
+ # Source files re-extracted this run — their symbol sets may legitimately
1764
+ # shrink (a removed function), so the shrink-guard should not block the
1765
+ # write when every lost node belongs to one of them (or a deleted file).
1766
+ _rebuilt_root = str(project_root)
1767
+ if changed_paths is None:
1768
+ rebuilt_sources = {
1769
+ _nsf(str(p.relative_to(project_root)), _rebuilt_root)
1770
+ for p in code_files if p.is_relative_to(project_root)
1771
+ }
1772
+ else:
1773
+ rebuilt_sources = {(_nsf(str(p), _rebuilt_root) or str(p)) for p in extract_targets}
1774
+ rebuilt_sources |= set(deleted_paths)
1775
+ failed_sources = {
1776
+ _nsf(source, _rebuilt_root) or source
1777
+ for source in _failed_ast_sources
1778
+ }
1779
+ out.mkdir(exist_ok=True)
1780
+
1781
+ if no_cluster:
1782
+ # Normalise to "links" key so schema is consistent with the full clustered path.
1783
+ # Dedupe parallel edges (the clustered path's DiGraph collapses them implicitly);
1784
+ # without it, --no-cluster + repeated `update` accumulate duplicates and edge
1785
+ # counts diverge across build modes (#1317).
1786
+ from graphify.build import (
1787
+ dedupe_edges as _dedupe_edges,
1788
+ dedupe_nodes as _dedupe_nodes,
1789
+ disambiguate_file_labels_in_nodes as _disamb_labels,
1790
+ )
1791
+ raw_nodes = _dedupe_nodes(result.get("nodes", []))
1792
+ _disamb_labels(raw_nodes)
1793
+ candidate_graph_data = {
1794
+ **{k: v for k, v in result.items() if k not in ("edges", "nodes")},
1795
+ "nodes": raw_nodes,
1796
+ "links": _dedupe_edges(result.get("edges", [])),
1797
+ # Inherit the existing graph's directed flag (#2342) so
1798
+ # `graphify update --no-cluster` can't silently drop it -
1799
+ # `result` (the raw merged extraction) never carries one.
1800
+ "directed": bool((existing_graph_data or {}).get("directed", False)),
1801
+ }
1802
+ candidate_graph_text = _json_text(candidate_graph_data)
1803
+ same_graph = False
1804
+ if existing_graph.exists():
1805
+ try:
1806
+ check_graph_file_size_cap(existing_graph)
1807
+ existing_payload = json.loads(existing_graph.read_text(encoding="utf-8"))
1808
+ except Exception as exc:
1809
+ # A load failure is NOT "graph changed" (#2251): refuse to
1810
+ # overwrite a graph we merely failed to read. Normally
1811
+ # unreachable — the reconcile load above already failed
1812
+ # closed — but a race rewriting the file can land here.
1813
+ print(
1814
+ f"error: Cannot read {existing_graph}: {exc}. "
1815
+ "Refusing to overwrite; delete the file and run a "
1816
+ "full rebuild.",
1817
+ file=sys.stderr,
1818
+ )
1819
+ return False
1820
+ try:
1821
+ same_graph = (
1822
+ json.dumps(_canonical_graph_for_compare(existing_payload), sort_keys=True, ensure_ascii=False)
1823
+ == json.dumps(_canonical_graph_for_compare(candidate_graph_data), sort_keys=True, ensure_ascii=False)
1824
+ )
1825
+ except Exception:
1826
+ same_graph = False
1827
+ if not same_graph:
1828
+ if not _check_shrink(
1829
+ force, existing_graph_data, candidate_graph_data,
1830
+ had_explicit_deletions=bool(deleted_paths),
1831
+ rebuilt_sources=rebuilt_sources,
1832
+ failed_sources=failed_sources,
1833
+ ):
1834
+ return False
1835
+ from graphify.export import backup_if_protected as _backup
1836
+ _backup(out)
1837
+ # Atomic replace via tmp file, matching the clustered path: a
1838
+ # crash mid-write must not leave a truncated graph.json.
1839
+ graph_tmp = out / ".graph.tmp.json"
1840
+ graph_tmp.write_text(candidate_graph_text, encoding="utf-8")
1841
+ graph_tmp.replace(existing_graph)
1842
+
1843
+ # Write the user-supplied path only after the candidate graph is
1844
+ # accepted, so a refused shrink cannot mismatch graph and marker.
1845
+ (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8")
1846
+
1847
+ try:
1848
+ from graphify.detect import save_manifest
1849
+ # detected["files"] is a FULL detect of the watched root, so
1850
+ # pass it as the scan corpus too: rows for files that left the
1851
+ # scan but still exist on disk (newly excluded) are pruned
1852
+ # instead of surviving as phantom "deleted" entries (#1908).
1853
+ # Failed AST sources are dropped from the stamped set and
1854
+ # their prior hashes blanked (#2543).
1855
+ save_manifest(
1856
+ _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
1857
+ kind="ast", root=watch_root,
1858
+ scan_corpus={f for _fl in detected["files"].values() for f in _fl},
1859
+ clear_ast=_failed_ast_sources or None,
1860
+ )
1861
+ except Exception:
1862
+ pass
1863
+
1864
+ if same_graph:
1865
+ print("[graphify watch] No code-graph changes detected (--no-cluster); outputs left untouched.")
1866
+ else:
1867
+ print(
1868
+ "[graphify watch] Rebuilt (no clustering): "
1869
+ f"{len(candidate_graph_data.get('nodes', []))} nodes, "
1870
+ f"{len(candidate_graph_data.get('links', []))} edges"
1871
+ )
1872
+ print(f"[graphify watch] graph.json updated in {out}")
1873
+ return True
1874
+
1875
+ detection = {
1876
+ "files": {"code": [str(f) for f in code_files], "document": [], "paper": [], "image": []},
1877
+ "total_files": len(code_files),
1878
+ "total_words": detected.get("total_words", 0),
1879
+ }
1880
+
1881
+ # Inherit the existing graph's directed flag (#2342) so `graphify
1882
+ # update` can't silently downgrade a directed graph to undirected -
1883
+ # build_from_json defaults to directed=False otherwise.
1884
+ G = build_from_json(result, directed=bool((existing_graph_data or {}).get("directed", False)))
1885
+ candidate_topology = _topology_from_graph(G)
1886
+ if existing_graph_data:
1887
+ try:
1888
+ same_topology = (
1889
+ json.dumps(_canonical_topology_for_compare(existing_graph_data), sort_keys=True, ensure_ascii=False)
1890
+ == json.dumps(_canonical_topology_for_compare(candidate_topology), sort_keys=True, ensure_ascii=False)
1891
+ )
1892
+ except Exception:
1893
+ same_topology = False
1894
+ if same_topology:
1895
+ try:
1896
+ from graphify.detect import save_manifest
1897
+ # Full-scan save: prune excluded-but-alive rows (#1908);
1898
+ # leave failed AST sources unstamped (#2543).
1899
+ save_manifest(
1900
+ _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
1901
+ kind="ast", root=watch_root,
1902
+ scan_corpus={f for _fl in detected["files"].values() for f in _fl},
1903
+ clear_ast=_failed_ast_sources or None,
1904
+ )
1905
+ except Exception:
1906
+ pass
1907
+ html_action = _reconcile_graph_html(out, existing_graph_data)
1908
+ if html_action == "rendered":
1909
+ print(
1910
+ "[graphify watch] No code-graph topology changes detected; "
1911
+ "regenerated missing or stale graph.html."
1912
+ )
1913
+ elif html_action == "removed":
1914
+ print(
1915
+ "[graphify watch] No code-graph topology changes detected; "
1916
+ "removed graph.html because HTML visualization is disabled."
1917
+ )
1918
+ else:
1919
+ print("[graphify watch] No code-graph topology changes detected; outputs left untouched.")
1920
+ return True
1921
+
1922
+ communities = cluster(G)
1923
+ previous_node_community = _node_community_map(existing_graph_data)
1924
+ if previous_node_community:
1925
+ communities = remap_communities_to_previous(communities, previous_node_community)
1926
+ cohesion = score_all(G, communities)
1927
+ gods = god_nodes(G)
1928
+ surprises = surprising_connections(G, communities)
1929
+ labels_file = out / ".graphify_labels.json"
1930
+ sig_file = out / (".graphify_labels.json" + ".sig")
1931
+ try:
1932
+ raw = json.loads(labels_file.read_text(encoding="utf-8")) if labels_file.exists() else {}
1933
+ # Skip persisted "Community N" placeholders so the hub-fill below
1934
+ # replaces them instead of perpetuating them on every rebuild (#2073).
1935
+ labels = {
1936
+ int(k): v for k, v in raw.items()
1937
+ if int(k) in communities and v != f"Community {int(k)}"
1938
+ }
1939
+ except Exception:
1940
+ raw = {}
1941
+ labels = {}
1942
+ # A saved label belongs to a cid, but re-clustering reassigns cids: after a
1943
+ # rebuild that adds nodes, cid 30 can cover a completely different community
1944
+ # and its old name is then simply wrong. Validate every reused label against
1945
+ # the membership signature saved beside the labels — the same guard the
1946
+ # cluster-only path applies — and drop any whose community changed so the
1947
+ # hub-fill below renames it, deterministically and correct-by-construction.
1948
+ # Without this, an incremental `graphify update` launders stale names into
1949
+ # labels.json as though they were current (#label-stale).
1950
+ from graphify.cluster import community_member_sigs
1951
+ cur_sigs = community_member_sigs(communities)
1952
+ saved_sigs: dict[int, str] = {}
1953
+ if sig_file.exists():
1954
+ try:
1955
+ saved_sigs = {
1956
+ int(k): v for k, v in
1957
+ json.loads(sig_file.read_text(encoding="utf-8")).items()
1958
+ if isinstance(v, str)
1959
+ }
1960
+ except Exception:
1961
+ saved_sigs = {}
1962
+ if saved_sigs:
1963
+ # Precise: the signature tells us exactly which communities changed.
1964
+ stale = {cid for cid in labels if saved_sigs.get(cid) != cur_sigs.get(cid)}
1965
+ else:
1966
+ # No sidecar (labels predate it). A differing community COUNT means the
1967
+ # labels describe a different clustering, so no cid's label is trustworthy;
1968
+ # an equal count is the best available "unchanged" signal.
1969
+ stale = set(labels) if len(raw) != len(communities) else set()
1970
+ for cid in stale:
1971
+ del labels[cid]
1972
+ missing = {cid: members for cid, members in communities.items() if cid not in labels}
1973
+ if missing:
1974
+ # Deterministic hub name (highest-degree member) beats a bare "Community N"
1975
+ # placeholder for any community without a saved label.
1976
+ from graphify.cluster import label_communities_by_hub
1977
+ labels.update(label_communities_by_hub(G, missing))
1978
+ if stale:
1979
+ print(
1980
+ f"[graphify watch] community set changed since labeling "
1981
+ f"({len(raw)} saved labels, {len(communities)} communities now; "
1982
+ f"renamed {len(stale)} community(ies) by their hub). "
1983
+ f"Run `graphify label` to refresh names with the LLM.",
1984
+ file=sys.stderr,
1985
+ )
1986
+ questions = suggest_questions(G, communities, labels)
1987
+ from graphify.report import load_learning_for_report as _llfr
1988
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection,
1989
+ {"input": 0, "output": 0}, report_root, suggested_questions=questions,
1990
+ built_at_commit=commit, learning=_llfr(out / "graph.json"))
1991
+ report_path = out / "GRAPH_REPORT.md"
1992
+ labels_json = json.dumps({str(k): v for k, v in sorted(labels.items())}, ensure_ascii=False, indent=2) + "\n"
1993
+ graph_tmp = out / ".graph.tmp.json"
1994
+ json_written = to_json(G, communities, str(graph_tmp), force=True, built_at_commit=commit, community_labels=labels)
1995
+ if not json_written:
1996
+ return False
1997
+ candidate_graph_data = json.loads(graph_tmp.read_text(encoding="utf-8"))
1998
+ same_graph = False
1999
+ same_report = False
2000
+ if existing_graph.exists():
2001
+ try:
2002
+ check_graph_file_size_cap(existing_graph)
2003
+ existing_payload = json.loads(existing_graph.read_text(encoding="utf-8"))
2004
+ except Exception as exc:
2005
+ # A load failure is NOT "graph changed" (#2251): refuse to
2006
+ # overwrite a graph we merely failed to read. Normally
2007
+ # unreachable — the reconcile load above already failed
2008
+ # closed — but a race rewriting the file can land here.
2009
+ graph_tmp.unlink(missing_ok=True)
2010
+ print(
2011
+ f"error: Cannot read {existing_graph}: {exc}. "
2012
+ "Refusing to overwrite; delete the file and run a "
2013
+ "full rebuild.",
2014
+ file=sys.stderr,
2015
+ )
2016
+ return False
2017
+ try:
2018
+ same_graph = (
2019
+ json.dumps(_canonical_graph_for_compare(existing_payload), sort_keys=True, ensure_ascii=False)
2020
+ == json.dumps(_canonical_graph_for_compare(candidate_graph_data), sort_keys=True, ensure_ascii=False)
2021
+ )
2022
+ except Exception:
2023
+ same_graph = False
2024
+ if report_path.exists():
2025
+ old_report = report_path.read_text(encoding="utf-8")
2026
+ same_report = _report_for_compare(old_report) == _report_for_compare(report)
2027
+ no_change = same_graph and same_report
2028
+ if no_change:
2029
+ graph_tmp.unlink(missing_ok=True)
2030
+ print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.")
2031
+ else:
2032
+ if not _check_shrink(
2033
+ force, existing_graph_data, candidate_graph_data,
2034
+ tmp=graph_tmp,
2035
+ had_explicit_deletions=bool(deleted_paths),
2036
+ rebuilt_sources=rebuilt_sources,
2037
+ failed_sources=failed_sources,
2038
+ ):
2039
+ return False
2040
+ from graphify.exporters.html import _HTML_STALE_MARKER
2041
+ # Mark before graph.json advances so an interruption cannot leave a
2042
+ # previous visualization looking current to the fast path.
2043
+ (out / _HTML_STALE_MARKER).touch()
2044
+ from graphify.export import backup_if_protected as _backup
2045
+ _backup(out)
2046
+ graph_tmp.replace(existing_graph)
2047
+ report_path.write_text(report, encoding="utf-8")
2048
+ labels_file.write_text(labels_json, encoding="utf-8")
2049
+ # Keep the membership signatures in step with the labels we just wrote.
2050
+ # Skipping this was the other half of the stale-label bug: labels.json
2051
+ # advanced every rebuild while the sidecar kept describing an older
2052
+ # clustering, so the guard above had nothing accurate to check against.
2053
+ sig_file.write_text(
2054
+ json.dumps({str(k): v for k, v in cur_sigs.items()}), encoding="utf-8")
2055
+
2056
+ (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8")
2057
+
2058
+ try:
2059
+ from graphify.detect import save_manifest
2060
+ # Full-scan save: prune excluded-but-alive rows (#1908);
2061
+ # leave failed AST sources unstamped (#2543).
2062
+ save_manifest(
2063
+ _ast_manifest_files(), manifest_path=str(out / "manifest.json"),
2064
+ kind="ast", root=watch_root,
2065
+ scan_corpus={f for _fl in detected["files"].values() for f in _fl},
2066
+ clear_ast=_failed_ast_sources or None,
2067
+ )
2068
+ except Exception:
2069
+ pass
2070
+
2071
+ # Reconcile from the persisted graph. The stale marker was written
2072
+ # before graph.json advanced, so a failed or interrupted atomic render
2073
+ # remains retryable from the unchanged-topology fast path.
2074
+ html_written = False
2075
+ if not no_change:
2076
+ html_action = _reconcile_graph_html(out, candidate_graph_data)
2077
+ html_written = html_action == "rendered"
2078
+
2079
+ # Regenerate callflow HTML if the user previously generated one —
2080
+ # opt-in by existence so users who never ran callflow-html aren't affected.
2081
+ callflow_files = list(out.glob("*-callflow.html"))
2082
+ if callflow_files and not no_change:
2083
+ try:
2084
+ from graphify.callflow_html import write_callflow_html
2085
+ for cf in callflow_files:
2086
+ write_callflow_html(
2087
+ graph=out / "graph.json",
2088
+ report=out / "GRAPH_REPORT.md",
2089
+ labels=out / ".graphify_labels.json",
2090
+ output=cf,
2091
+ verbose=False,
2092
+ )
2093
+ except Exception as cf_err:
2094
+ print(f"[graphify watch] callflow HTML update skipped: {cf_err}")
2095
+
2096
+ if not no_change:
2097
+ print(f"[graphify watch] Rebuilt: {G.number_of_nodes()} nodes, "
2098
+ f"{G.number_of_edges()} edges, {len(communities)} communities")
2099
+ products = "graph.json" + (", graph.html" if html_written else "") + " and GRAPH_REPORT.md"
2100
+ if callflow_files:
2101
+ products += f", {len(callflow_files)} callflow HTML"
2102
+ print(f"[graphify watch] {products} updated in {out}")
2103
+ return True
2104
+
2105
+ except Exception as exc:
2106
+ print(f"[graphify watch] Rebuild failed: {exc}")
2107
+ return False
2108
+
2109
+
2110
+ def check_update(watch_path: Path) -> bool:
2111
+ """Check for pending semantic update flag and notify the user if set.
2112
+
2113
+ Cron-safe: always returns True so cron jobs do not alarm.
2114
+ Non-code file changes (docs, papers, images) require LLM-backed
2115
+ re-extraction via `/graphify --update` — this function only signals
2116
+ that the update is needed.
2117
+ """
2118
+ flag = Path(watch_path) / _GRAPHIFY_OUT / "needs_update"
2119
+ if flag.exists():
2120
+ print(f"[graphify check-update] Pending non-code changes in {watch_path}.")
2121
+ print("[graphify check-update] Run `/graphify --update` to apply semantic re-extraction.")
2122
+ return True
2123
+
2124
+
2125
+ def _notify_only(watch_path: Path) -> None:
2126
+ """Write a flag file and print a notification (fallback for non-code-only corpora)."""
2127
+ flag = watch_path / _GRAPHIFY_OUT / "needs_update"
2128
+ flag.parent.mkdir(parents=True, exist_ok=True)
2129
+ flag.write_text("1", encoding="utf-8")
2130
+ print(f"\n[graphify watch] New or changed files detected in {watch_path}")
2131
+ print("[graphify watch] Non-code files changed - semantic re-extraction requires LLM.")
2132
+ print("[graphify watch] Run `/graphify --update` in Claude Code to update the graph.")
2133
+ print(f"[graphify watch] Flag written to {flag}")
2134
+
2135
+
2136
+ def _has_non_code(changed_paths: list[Path]) -> bool:
2137
+ return any(p.suffix.lower() not in _CODE_EXTENSIONS for p in changed_paths)
2138
+
2139
+
2140
+ def _batch_triggers_rebuild(batch: list[Path]) -> bool:
2141
+ """True when a debounced watch batch needs an immediate rebuild.
2142
+
2143
+ Code changes always rebuild (AST extraction needs no LLM). Deletions of
2144
+ ANY watched file also rebuild: eviction needs no LLM either — the full
2145
+ corpus reconcile drops nodes whose source is gone from disk. Without
2146
+ this, a doc-only deletion batch would sit behind the needs_update flag
2147
+ until the next code event or a manual `graphify update` (#2580).
2148
+ """
2149
+ has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch)
2150
+ has_deletion = any(not p.exists() for p in batch)
2151
+ return has_code or has_deletion
2152
+
2153
+
2154
+ _READ_ONLY_EVENT_TYPES = frozenset({"opened", "closed_no_write"})
2155
+
2156
+
2157
+ def _is_read_only_event(event) -> bool:
2158
+ """True for watchdog events that mean a file was merely READ, not changed.
2159
+
2160
+ On Linux, inotify (watchdog >= 2.3) reports ``opened`` and, since watchdog 4,
2161
+ ``closed_no_write`` for every file open/close — including the watcher's own
2162
+ AST rebuild reading the tree, hook guards stat-ing sources, and editors or
2163
+ agents reading files. Counting those as changes makes the watcher re-trigger
2164
+ itself forever ("N file(s) changed" while nothing was modified), burn CPU and
2165
+ keep re-writing the ``needs_update`` flag. Only creation, modification, move,
2166
+ deletion and close-after-write are changes; macOS (PollingObserver) never
2167
+ emits these, so the filter is a no-op there.
2168
+ """
2169
+ return getattr(event, "event_type", None) in _READ_ONLY_EVENT_TYPES
2170
+
2171
+
2172
+ def _batch_needs_llm_flag(batch: list[Path]) -> bool:
2173
+ """True when the batch contains a non-code file that still exists on disk.
2174
+
2175
+ Only surviving non-code files need the needs_update flag (LLM-backed
2176
+ re-extraction); deleted ones are already handled by the rebuild's
2177
+ reconcile sweep, so a pure-deletion batch must not leave a stale flag.
2178
+ """
2179
+ return _has_non_code([p for p in batch if p.exists()])
2180
+
2181
+
2182
+ def watch(watch_path: Path, debounce: float = 3.0) -> None:
2183
+ """
2184
+ Watch watch_path for new or modified files and auto-update the graph.
2185
+
2186
+ For code-only changes: re-runs AST extraction + rebuild immediately (no LLM).
2187
+ For doc/paper/image changes: writes a needs_update flag and notifies the user
2188
+ to run /graphify --update (LLM extraction required).
2189
+
2190
+ debounce: seconds to wait after the last change before triggering (avoids
2191
+ running on every keystroke when many files are saved at once).
2192
+ """
2193
+ try:
2194
+ from watchdog.observers import Observer
2195
+ from watchdog.observers.polling import PollingObserver
2196
+ from watchdog.events import FileSystemEventHandler
2197
+ except ImportError as e:
2198
+ raise ImportError("watchdog not installed. Run: pip install watchdog") from e
2199
+
2200
+ last_trigger: float = 0.0
2201
+ pending: bool = False
2202
+ changed: set[Path] = set()
2203
+
2204
+ # Load .graphifyignore patterns ONCE at startup so the handler does not
2205
+ # re-parse the file on every filesystem event. Watchdog's handler runs on
2206
+ # the observer thread and is invoked for every event the OS delivers
2207
+ # (Time Machine writes, Docker/Colima VM I/O, Spotlight indexing, …) —
2208
+ # without this short-circuit a busy volume can saturate a CPU core
2209
+ # discarding events one extension at a time. (gh-928)
2210
+ watch_root_for_ignore = watch_path.resolve()
2211
+ ignore_patterns = _load_graphifyignore(
2212
+ watch_root_for_ignore,
2213
+ gitignore=_read_build_gitignore(watch_path / _GRAPHIFY_OUT),
2214
+ )
2215
+
2216
+ class Handler(FileSystemEventHandler):
2217
+ def on_any_event(self, event):
2218
+ nonlocal last_trigger, pending
2219
+ if event.is_directory or _is_read_only_event(event):
2220
+ return
2221
+ path = Path(os.fsdecode(event.src_path))
2222
+ # Check .graphifyignore BEFORE the extension/dotfile/out filters so
2223
+ # the cheapest short-circuit for users with broad ignore patterns
2224
+ # (node_modules/, .venv/, build/, …) fires first. _is_ignored
2225
+ # tolerates absolute paths outside watch_root via its internal
2226
+ # relative_to guard, so a stray symlinked event won't raise.
2227
+ if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
2228
+ return
2229
+ if path.suffix.lower() not in _WATCHED_EXTENSIONS:
2230
+ return
2231
+ try:
2232
+ filter_parts = path.relative_to(watch_root_for_ignore).parts
2233
+ except ValueError:
2234
+ filter_parts = path.parts
2235
+ if any(part.startswith(".") for part in filter_parts):
2236
+ return
2237
+ if _GRAPHIFY_OUT in filter_parts:
2238
+ return
2239
+ last_trigger = time.monotonic()
2240
+ pending = True
2241
+ changed.add(path)
2242
+
2243
+ handler = Handler()
2244
+ # Use polling observer on macOS — FSEvents can miss rapid saves in some editors
2245
+ observer = PollingObserver() if sys.platform == "darwin" else Observer()
2246
+ observer.schedule(handler, str(watch_path), recursive=True)
2247
+ observer.start()
2248
+
2249
+ print(f"[graphify watch] Watching {watch_path.resolve()} - press Ctrl+C to stop")
2250
+ print(f"[graphify watch] Code changes rebuild graph automatically. "
2251
+ f"Doc/image changes require /graphify --update.")
2252
+ print(f"[graphify watch] Debounce: {debounce}s")
2253
+
2254
+ try:
2255
+ while True:
2256
+ time.sleep(0.5)
2257
+ if pending and (time.monotonic() - last_trigger) >= debounce:
2258
+ pending = False
2259
+ batch = list(changed)
2260
+ changed.clear()
2261
+ print(f"\n[graphify watch] {len(batch)} file(s) changed")
2262
+ if _batch_triggers_rebuild(batch):
2263
+ _rebuild_code(watch_path)
2264
+ if _batch_needs_llm_flag(batch):
2265
+ _notify_only(watch_path)
2266
+ except KeyboardInterrupt:
2267
+ print("\n[graphify watch] Stopped.")
2268
+ finally:
2269
+ observer.stop()
2270
+ observer.join()
2271
+
2272
+
2273
+ if __name__ == "__main__":
2274
+ import argparse
2275
+ parser = argparse.ArgumentParser(description="Watch a folder and auto-update the graphify graph")
2276
+ parser.add_argument("path", nargs="?", default=".", help="Folder to watch (default: .)")
2277
+ parser.add_argument("--debounce", type=float, default=3.0,
2278
+ help="Seconds to wait after last change before updating (default: 3)")
2279
+ args = parser.parse_args()
2280
+ watch(Path(args.path), debounce=args.debounce)