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/detect.py ADDED
@@ -0,0 +1,2566 @@
1
+ # file discovery, type classification, and corpus health checks
2
+ from __future__ import annotations
3
+ import fnmatch
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import stat
9
+ import subprocess
10
+ import time
11
+ import unicodedata
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ from graphify.google_workspace import (
18
+ GOOGLE_WORKSPACE_EXTENSIONS,
19
+ convert_google_workspace_file,
20
+ google_workspace_enabled,
21
+ )
22
+ from graphify.paths import GRAPHIFY_OUT, out_path
23
+
24
+
25
+ class FileType(str, Enum):
26
+ CODE = "code"
27
+ DOCUMENT = "document"
28
+ PAPER = "paper"
29
+ IMAGE = "image"
30
+ VIDEO = "video"
31
+
32
+
33
+ _MANIFEST_PATH = str(out_path("manifest.json"))
34
+
35
+ #: Window in which a manifest row's own timestamp is too close to the file's
36
+ #: mtime for "mtime unchanged" to prove the content is unchanged. Coarse for
37
+ #: filesystems that round mtime to whole seconds; tight when real sub-second
38
+ #: precision is reported. Mirrors cache.py's `_MTIME_GRANULARITY_NS` (2s) —
39
+ #: the same racily-clean assumption at the hash-cache layer (#2466 / #2612);
40
+ #: keep the two in sync.
41
+ _MTIME_COARSE_S = 2.0
42
+ _MTIME_SUBSECOND_S = 0.05
43
+
44
+ CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource'}
45
+ DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
46
+ PAPER_EXTENSIONS = {'.pdf'}
47
+ IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
48
+ OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
49
+ VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
50
+
51
+ CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a graph"
52
+ CORPUS_UPPER_THRESHOLD = 500_000 # words - above this, warn about token cost
53
+ FILE_COUNT_UPPER = 500 # files - above this, warn about token cost
54
+
55
+ # Resource caps for parsing untrusted office/PDF files (F2). A corpus is
56
+ # attacker-controllable (graphify runs on cloned/shared folders), and .docx/.xlsx
57
+ # are zip+XML containers: a few-KB zip-bomb can decompress to gigabytes and
58
+ # OOM-kill the process at load_workbook/Document time. Screen the file before any
59
+ # parser touches it.
60
+ _OFFICE_MAX_RAW_BYTES = 50 * 1024 * 1024 # 50 MiB on-disk
61
+ _OFFICE_MAX_DECOMPRESSED_BYTES = 512 * 1024 * 1024 # 512 MiB total uncompressed
62
+ _OFFICE_MAX_COMPRESSION_RATIO = 200 # uncompressed : compressed
63
+
64
+
65
+ def _file_within_size_cap(path: Path, cap: int = _OFFICE_MAX_RAW_BYTES) -> bool:
66
+ """True if *path* exists and its on-disk size is within *cap*."""
67
+ try:
68
+ return path.stat().st_size <= cap
69
+ except OSError:
70
+ return False
71
+
72
+
73
+ def _zip_within_caps(path: Path) -> bool:
74
+ """Reject a zip-based office file that is a likely zip/XML bomb.
75
+
76
+ Two layers, because the zip central-directory sizes are attacker-controlled:
77
+ 1. A cheap pre-filter on the declared sizes (on-disk cap, summed-uncompressed
78
+ cap, compression ratio) that rejects an honest bomb without decompressing.
79
+ 2. An authoritative pass that stream-decompresses every member with a hard
80
+ byte ceiling, so a member that under-declares its size in the central
81
+ directory cannot expand past the cap undetected. Decompression is chunked
82
+ and bounded, so checking a bomb never materializes more than the ceiling.
83
+ """
84
+ import zipfile
85
+ if not _file_within_size_cap(path):
86
+ return False
87
+ try:
88
+ with zipfile.ZipFile(path) as zf:
89
+ infos = zf.infolist()
90
+ compressed = sum(i.compress_size for i in infos) or 1
91
+ declared = sum(i.file_size for i in infos)
92
+ if declared > _OFFICE_MAX_DECOMPRESSED_BYTES:
93
+ return False
94
+ if declared / compressed > _OFFICE_MAX_COMPRESSION_RATIO:
95
+ return False
96
+ total = 0
97
+ for info in infos:
98
+ with zf.open(info) as member:
99
+ while True:
100
+ chunk = member.read(1024 * 1024)
101
+ if not chunk:
102
+ break
103
+ total += len(chunk)
104
+ if total > _OFFICE_MAX_DECOMPRESSED_BYTES:
105
+ return False
106
+ except (zipfile.BadZipFile, OSError, EOFError):
107
+ return False
108
+ return True
109
+
110
+ # Dedicated credential-store directories: everything beneath them is sensitive,
111
+ # with no carve-out — a .py inside ~/.ssh or ~/.aws is tooling for key material,
112
+ # not a source package, and keys there are routinely extensionless.
113
+ # Both sets are checked against path.parts[:-1] (parents only) so a root-level
114
+ # file named "credentials" or "secrets" is not falsely flagged by this stage.
115
+ _CREDENTIAL_STORE_DIRS = frozenset({
116
+ ".ssh", ".gnupg", ".aws", ".gcloud",
117
+ })
118
+
119
+ # Bare-name directories that are as often legitimate source packages (Go
120
+ # internal/secrets, a credentials/ service module) as credential stores. Their
121
+ # contents are sensitive EXCEPT genuine programming-language source, mirroring
122
+ # the Stage 3 keyword carve-out (#1666) at the directory level (#1943).
123
+ _AMBIGUOUS_SENSITIVE_DIRS = frozenset({
124
+ "secrets", ".secrets", "credentials",
125
+ })
126
+
127
+ # Files that may contain secrets - skip silently. These patterns are specific
128
+ # (extensions, exact credential-store names) and always apply.
129
+ _SENSITIVE_PATTERNS = [
130
+ re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
131
+ re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
132
+ # SSH/GPG private keys. Left boundary + IGNORECASE so `grid_rsa` (alpha before
133
+ # `id_rsa`) and `ID_RSA` are handled correctly, not matched as a substring.
134
+ re.compile(r'(^|[^A-Za-z0-9])(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$', re.IGNORECASE),
135
+ re.compile(r'^secring(\.(gpg|pgp))?$', re.IGNORECASE), # GPG private keyring
136
+ # Auth/credential dotfiles that routinely hold tokens (#2106: .npmrc/.pypirc/
137
+ # .git-credentials/.boto were silently indexed before).
138
+ re.compile(r'(\.netrc|\.pgpass|\.htpasswd|\.npmrc|\.pypirc|\.git-credentials|\.boto)$', re.IGNORECASE),
139
+ # NOTE: aws_credentials/gcloud_credentials/service_account moved to the
140
+ # boundary-checked Stage 3 keyword path (#2106). The old unbounded
141
+ # `service.account` substring (regex `.` wildcard) matched real source like
142
+ # google/oauth2/service_account.py and prose like aws_credentials_rotation.md.
143
+ ]
144
+
145
+ # Committed dotenv / envrc templates — placeholders only, not live secrets.
146
+ # Stage 2's `.env.` regex otherwise treats these like `.env.local` (#2184).
147
+ _ENV_TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist")
148
+
149
+
150
+ def _is_env_template(name: str) -> bool:
151
+ """True for `.env.example` / `.envrc.sample` style committed templates (#2184)."""
152
+ lower = name.lower()
153
+ if not lower.endswith(_ENV_TEMPLATE_SUFFIXES):
154
+ return False
155
+ # Basename must still be an .env* / .envrc* file (not e.g. secrets.example).
156
+ return bool(re.match(r"\.(env|envrc)\.", lower))
157
+
158
+ # Generic keyword patterns - these only count when the keyword is LOAD-BEARING
159
+ # in the filename (see _generic_keyword_hit), because a keyword buried mid-phrase
160
+ # in a long descriptive slug names a topic, not a credential store:
161
+ # "token-economics-of-recall.md" is a note ABOUT tokens; "api_token.txt" IS one.
162
+ # Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt
163
+ # match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior
164
+ # is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because
165
+ # "i" after "token" is alpha and blocks the match).
166
+ # `token` is kept separate because its longer suffix "izer"/"ize" is the only
167
+ # common false-positive; other keywords have no such well-known derivatives.
168
+ _GENERIC_KEYWORD_PATTERNS = [
169
+ re.compile(r'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
170
+ re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', re.IGNORECASE),
171
+ # service_account / service-account / serviceaccount (GCP key files). In the
172
+ # keyword path so `service_account.py` (real source) is spared while
173
+ # `service-account.json` (a downloaded key) and bare names are still caught
174
+ # (#2106; was an unbounded Stage 2 substring). aws_credentials/gcloud_credentials
175
+ # are already covered by the `credential` keyword above.
176
+ re.compile(r'(?<![a-zA-Z0-9])service[._-]?account(?![a-zA-Z])', re.IGNORECASE),
177
+ ]
178
+
179
+ # Prose/note formats: a heavily-linked wiki article whose topic slug ends in a
180
+ # keyword (privacy-tokens.md, token-economics.md) is a document ABOUT the topic,
181
+ # not a credential store, so it must not be silently dropped (#2106). A BARE
182
+ # keyword name (secrets.md, token.md, passwords.md) still reads as a dump and
183
+ # stays excluded — see _is_prose_note.
184
+ _PROSE_EXTS = frozenset({".md", ".markdown", ".rst", ".org", ".adoc", ".tex"})
185
+
186
+ # Data/serialization extensions that commonly ARE secret stores when their name
187
+ # hits a generic keyword (credentials.json, secrets.yaml, token.toml) or they sit
188
+ # in an ambiguous sensitive dir (secrets/db.json). These stay subject to the
189
+ # Stage 1 ambiguous-dir drop and the Stage 3 keyword drop even though some route
190
+ # through the CODE path for manifest parsing — only real programming-language
191
+ # source is exempt (#1666, #1943).
192
+ _SECRET_PRONE_DATA_EXTS = frozenset({
193
+ ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".config",
194
+ ".xml", ".properties", ".env", ".txt",
195
+ # .tfvars is Terraform's canonical VALUES store (routinely holds real
196
+ # secrets), not source — keep it out of the graph even though it sits in
197
+ # CODE_EXTENSIONS. .tf/.hcl are genuine infra source and stay graphable.
198
+ ".tfvars",
199
+ })
200
+
201
+ # Word separators for the load-bearing check (underscore intentionally included;
202
+ # multi-word keywords like private_key are handled by the end-of-stem check,
203
+ # which runs before word counting).
204
+ _WORD_SPLIT = re.compile(r'[-_\s.]+') # '.' included so `token.economics.notes` counts as 3 words (#2106)
205
+
206
+
207
+ def _is_prose_note(path: Path) -> bool:
208
+ """A prose/note file (.md/.rst/...) whose stem is a multi-word topic slug is
209
+ exempt from the generic-keyword drop (#2106). A stem that IS exactly a bare
210
+ keyword (secrets / token / passwords) is NOT exempt — that still reads as a
211
+ credential dump."""
212
+ if path.suffix.lower() not in _PROSE_EXTS:
213
+ return False
214
+ stem = Path(path.name).stem.lstrip('.') or Path(path.name).stem
215
+ return not any(p.fullmatch(stem) for p in _GENERIC_KEYWORD_PATTERNS)
216
+
217
+
218
+ def _generic_keyword_hit(name: str) -> bool:
219
+ """True if a generic secret keyword appears load-bearing in the filename.
220
+
221
+ Secret-store files name their contents, and in English compounds the
222
+ content noun is the head, which comes last: "github-personal-access-token",
223
+ "api_token", "oauth_token". A keyword that is neither at the end of the
224
+ stem nor in a short (<=2 word) name is a topic word in a descriptive slug
225
+ ("token-economics-of-recall.md", "password-policy-discussion.md") and must
226
+ not cause the file to be silently dropped from the graph (#436, #718).
227
+ """
228
+ # Stem = name minus only the FINAL extension (not up to the first dot), so a
229
+ # multi-dot topic slug like `token.economics.notes.md` keeps all its words and
230
+ # doesn't collapse to a bare `token` (#2106). Leading dots stripped so
231
+ # dotfiles like `.token` keep their keyword.
232
+ stem = Path(name).stem.lstrip('.') or Path(name).stem
233
+ for pat in _GENERIC_KEYWORD_PATTERNS:
234
+ hit = False
235
+ for m in pat.finditer(stem):
236
+ hit = True
237
+ if m.end() == len(stem): # keyword ends the stem -> names the contents
238
+ return True
239
+ if hit and len([w for w in _WORD_SPLIT.split(stem) if w]) <= 2:
240
+ return True # short name like token_config.yaml / secret_handler.txt
241
+ return False
242
+
243
+ # Signals that a .md/.txt file is actually a converted academic paper
244
+ _PAPER_SIGNALS = [
245
+ re.compile(r'\barxiv\b', re.IGNORECASE),
246
+ re.compile(r'\bdoi\s*:', re.IGNORECASE),
247
+ re.compile(r'\babstract\b', re.IGNORECASE),
248
+ re.compile(r'\bproceedings\b', re.IGNORECASE),
249
+ re.compile(r'\bjournal\b', re.IGNORECASE),
250
+ re.compile(r'\bpreprint\b', re.IGNORECASE),
251
+ re.compile(r'\\cite\{'), # LaTeX citation
252
+ re.compile(r'\[\d+\]'), # Numbered citation [1], [23] (inline)
253
+ re.compile(r'\[\n\d+\n\]'), # Numbered citation spread across lines (markdown conversion)
254
+ re.compile(r'eq\.\s*\d+|equation\s+\d+', re.IGNORECASE),
255
+ re.compile(r'\d{4}\.\d{4,5}'), # arXiv ID like 1706.03762
256
+ re.compile(r'\bwe propose\b', re.IGNORECASE), # common academic phrasing
257
+ re.compile(r'\bliterature\b', re.IGNORECASE), # "from the literature"
258
+ ]
259
+ _PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper
260
+
261
+
262
+ def _is_graphable_source(path: Path) -> bool:
263
+ """True for genuine programming-language source — the only category exempt
264
+ from the ambiguous-dir (Stage 1, #1943) and generic-keyword (Stage 3, #1666)
265
+ drops. Data/serialization formats are NOT exempt even though some route
266
+ through the CODE path for manifest parsing: credentials.json / secrets.yaml
267
+ are exactly the stores those stages must keep catching.
268
+ """
269
+ return classify_file(path) == FileType.CODE and path.suffix.lower() not in _SECRET_PRONE_DATA_EXTS
270
+
271
+
272
+ def _is_sensitive(path: Path) -> bool:
273
+ """Return True if this file likely contains secrets and should be skipped."""
274
+ # Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes
275
+ # the filename itself so a root-level file named "credentials" is not falsely
276
+ # skipped — the name patterns in Stage 2 handle the filename). Dedicated
277
+ # credential stores drop everything unconditionally; ambiguous bare-name dirs
278
+ # (secrets/, credentials/) spare genuine source (#1943), which still falls
279
+ # through so Stages 2-3 screen its filename like anywhere else.
280
+ parents = path.parts[:-1]
281
+ # Lowercase the segment comparison so `Secrets/`/`SECRETS/` (real on
282
+ # case-insensitive macOS/Windows filesystems) are still caught (#2106).
283
+ if any(part.lower() in _CREDENTIAL_STORE_DIRS for part in parents):
284
+ return True
285
+ if any(part.lower() in _AMBIGUOUS_SENSITIVE_DIRS for part in parents) and not _is_graphable_source(path):
286
+ return True
287
+ # Stage 2: filename pattern match. Template suffixes (.example/.sample/…)
288
+ # on .env / .envrc are the usual "safe to commit" convention — keep them
289
+ # in the graph without opening a broad Stage 2 allowlist (#2184 / #1921).
290
+ name = path.name
291
+ if any(p.search(name) for p in _SENSITIVE_PATTERNS) and not _is_env_template(name):
292
+ return True
293
+ # Stage 3: generic keywords, only when load-bearing in the name. Do NOT let a
294
+ # bare name keyword silently drop a genuine programming-language source file:
295
+ # a .rb/.py named device_token or passwords_controller is a module, not a secret
296
+ # store (#1666). Data/config formats (.json, .yaml, .toml, ...) are deliberately
297
+ # NOT exempt even though .json routes through the CODE path for manifest parsing,
298
+ # because credentials.json / oauth_token.json / secrets.yaml are exactly the
299
+ # secret stores this stage must catch. The specific Stage 2 patterns (.env, .pem,
300
+ # id_rsa, ...) still apply to everything regardless of extension.
301
+ if _generic_keyword_hit(name):
302
+ # Genuine source AND multi-word prose notes are exempt; a bare-keyword
303
+ # name (secrets.md, token.txt) still drops (#1666, #2106).
304
+ return not (_is_graphable_source(path) or _is_prose_note(path))
305
+ return False
306
+
307
+
308
+ def _looks_like_paper(path: Path) -> bool:
309
+ """Heuristic: does this text file read like an academic paper?"""
310
+ try:
311
+ # Only scan first 3000 chars for speed
312
+ text = path.read_text(encoding="utf-8", errors="ignore")[:3000]
313
+ hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text))
314
+ return hits >= _PAPER_SIGNAL_THRESHOLD
315
+ except Exception:
316
+ return False
317
+
318
+
319
+ _ASSET_DIR_MARKERS = {".imageset", ".xcassets", ".appiconset", ".colorset", ".launchimage"}
320
+
321
+
322
+ _SHEBANG_CODE_INTERPRETERS = {
323
+ "python", "python3", "python2",
324
+ "ruby", "perl", "node", "nodejs",
325
+ "bash", "sh", "dash", "zsh", "fish", "ksh", "tcsh",
326
+ "lua", "php", "julia", "Rscript",
327
+ }
328
+
329
+
330
+ def _split_env_s(value: str, rest: list[str]) -> list[str]:
331
+ """Re-tokenize an `env -S`/`--split-string` packed command, prepending the
332
+ operand to any trailing args. Returns the unpacked argv."""
333
+ packed = " ".join([value, *rest]).strip()
334
+ return shlex.split(packed)
335
+
336
+
337
+ def _env_command_args(args: list[str], *, allow_split: bool = True) -> list[str]:
338
+ """Strip leading env(1) options and var assignments, return the trailing
339
+ command argv. Covers macOS/BSD and GNU coreutils env documented spellings.
340
+
341
+ POSIX/macOS short forms:
342
+ env [-0iv] [-C workdir] [-P utilpath] [-S string]
343
+ [-u name] [name=value ...] [utility [argument ...]]
344
+
345
+ GNU coreutils long/compact forms additionally supported:
346
+ --argv0=ARG / -a ARG / -aARG
347
+ --unset=NAME / --unset NAME / -u NAME / -uNAME
348
+ --chdir=DIR / --chdir DIR / -C DIR / -CDIR
349
+ --split-string=STRING / --split-string STRING
350
+ -S STRING / -SSTRING / -vS STRING / -vSSTRING
351
+ --ignore-environment / --null / --debug / --list-signal-handling
352
+ --default-signal[=SIG] / --ignore-signal[=SIG] / --block-signal[=SIG]
353
+
354
+ `-S` / `--split-string` payloads are themselves env-style argument lists
355
+ per the GNU shebang synopsis:
356
+ #!/usr/bin/env -[v]S[option]... [name=value]... command [args]...
357
+ so after splitting the payload we recursively re-parse it with
358
+ `allow_split=False` (a nested -S inside a split payload is rejected to
359
+ bound recursion).
360
+
361
+ Unknown hyphen-prefixed args yield [] (we refuse to guess whether
362
+ their next token is an interpreter or an operand).
363
+ """
364
+ i = 0
365
+ while i < len(args):
366
+ arg = args[i]
367
+
368
+ if arg == "--":
369
+ return args[i + 1:]
370
+
371
+ # Split-string forms: tokenize the packed payload, then re-parse it
372
+ # as env args (so leading assignments/flags inside the payload are
373
+ # skipped before the interpreter is identified).
374
+ if allow_split:
375
+ if arg == "-S":
376
+ if i + 1 >= len(args):
377
+ return []
378
+ return _env_command_args(
379
+ _split_env_s(" ".join(args[i + 1:]), []),
380
+ allow_split=False,
381
+ )
382
+ if arg.startswith("-S") and len(arg) > 2:
383
+ return _env_command_args(
384
+ _split_env_s(arg[2:], args[i + 1:]),
385
+ allow_split=False,
386
+ )
387
+ if arg == "-vS":
388
+ if i + 1 >= len(args):
389
+ return []
390
+ return _env_command_args(
391
+ _split_env_s(" ".join(args[i + 1:]), []),
392
+ allow_split=False,
393
+ )
394
+ if arg.startswith("-vS") and len(arg) > 3:
395
+ return _env_command_args(
396
+ _split_env_s(arg[3:], args[i + 1:]),
397
+ allow_split=False,
398
+ )
399
+ if arg.startswith("--split-string="):
400
+ return _env_command_args(
401
+ _split_env_s(arg.split("=", 1)[1], args[i + 1:]),
402
+ allow_split=False,
403
+ )
404
+ if arg == "--split-string":
405
+ if i + 1 >= len(args):
406
+ return []
407
+ return _env_command_args(
408
+ _split_env_s(args[i + 1], args[i + 2:]),
409
+ allow_split=False,
410
+ )
411
+
412
+ # Options with separate required operand
413
+ if arg in {"-u", "-C", "-P", "-a", "--unset", "--chdir", "--argv0"}:
414
+ if i + 2 > len(args):
415
+ return []
416
+ i += 2
417
+ continue
418
+
419
+ # Clumped short option + operand
420
+ if (
421
+ arg.startswith(("-u", "-C", "-P", "-a"))
422
+ and len(arg) > 2
423
+ and not arg.startswith("--")
424
+ ):
425
+ i += 1
426
+ continue
427
+
428
+ # Long option with `=` operand
429
+ if arg.startswith(("--unset=", "--chdir=", "--argv0=")):
430
+ i += 1
431
+ continue
432
+
433
+ # No-operand flags
434
+ if arg in {"-", "-i", "-0", "-v", "--ignore-environment", "--null",
435
+ "--debug", "--list-signal-handling"}:
436
+ i += 1
437
+ continue
438
+
439
+ # Signal-handling long flags (with or without =SIG operand — we treat
440
+ # them as no-effect for interpreter-resolution purposes)
441
+ if arg.startswith(("--default-signal", "--ignore-signal", "--block-signal")):
442
+ i += 1
443
+ continue
444
+
445
+ # Unknown hyphen-prefixed: refuse to guess
446
+ if arg.startswith("-"):
447
+ return []
448
+
449
+ # Inline NAME=value assignment
450
+ if "=" in arg:
451
+ i += 1
452
+ continue
453
+
454
+ # First non-option, non-assignment token starts the command argv
455
+ return args[i:]
456
+
457
+ return []
458
+
459
+
460
+ def _shebang_interpreter(path: Path) -> str | None:
461
+ """Return the interpreter name from a shebang line.
462
+
463
+ Handles forms that a naive parser misses:
464
+ - `#!/usr/bin/env -S python3 -u` (env -S split-args form, anywhere)
465
+ - `#!/usr/bin/env -i bash` (no-operand env flags)
466
+ - `#!/usr/bin/env -u VAR python3` (env options with operands)
467
+ - `#!/usr/bin/env -C /tmp python3` (env -C workdir)
468
+ - `#!/usr/bin/env -P /bin python3` (env -P utilpath)
469
+ - `#!/usr/bin/env DEBUG=1 python3` (inline var assignment)
470
+ - `#!"/usr/local/bin/python with spaces"` (shlex handles quotes)
471
+
472
+ Returns the basename of the resolved interpreter, or None if there is
473
+ no shebang / the file is unreadable / parsing fails.
474
+ """
475
+ try:
476
+ with path.open("rb") as f:
477
+ first = f.read(256)
478
+ if not first.startswith(b"#!"):
479
+ return None
480
+ line = first.split(b"\n")[0].decode(errors="replace")[2:].strip()
481
+ parts = shlex.split(line)
482
+ if not parts:
483
+ return None
484
+ interp = Path(parts[0]).name
485
+ if interp == "env":
486
+ env_args = _env_command_args(parts[1:])
487
+ if not env_args:
488
+ return None
489
+ interp = Path(env_args[0]).name
490
+ return interp
491
+ except (OSError, ValueError):
492
+ return None
493
+
494
+
495
+ def _shebang_file_type(path: Path) -> FileType | None:
496
+ """Peek at the first line of an extensionless file for a shebang."""
497
+ interp = _shebang_interpreter(path)
498
+ if interp in _SHEBANG_CODE_INTERPRETERS:
499
+ return FileType.CODE
500
+ return None
501
+
502
+
503
+ def classify_file(path: Path) -> FileType | None:
504
+ # Package manifests (apm.yml, pyproject.toml, Cargo.toml, go.mod, pom.xml) are parsed
505
+ # deterministically, so route them to the AST path (CODE) rather than the LLM
506
+ # document path — otherwise apm.yml (a .yml "document") would be LLM-extracted
507
+ # and a package would split into duplicate file-anchored nodes (#1377).
508
+ from graphify.manifest_ingest import is_package_manifest_path
509
+ if is_package_manifest_path(path):
510
+ return FileType.CODE
511
+ # Compound extensions must be checked before simple suffix lookup
512
+ if path.name.lower().endswith(".blade.php"):
513
+ return FileType.CODE
514
+ ext = path.suffix.lower()
515
+ if not ext:
516
+ return _shebang_file_type(path)
517
+ if ext in CODE_EXTENSIONS:
518
+ return FileType.CODE
519
+ if ext in PAPER_EXTENSIONS:
520
+ # PDFs inside Xcode asset catalogs are vector icons, not papers
521
+ if any(part.endswith(tuple(_ASSET_DIR_MARKERS)) for part in path.parts):
522
+ return None
523
+ return FileType.PAPER
524
+ if ext in IMAGE_EXTENSIONS:
525
+ return FileType.IMAGE
526
+ if ext in DOC_EXTENSIONS:
527
+ # Check if it's a converted paper
528
+ if _looks_like_paper(path):
529
+ return FileType.PAPER
530
+ return FileType.DOCUMENT
531
+ if ext in OFFICE_EXTENSIONS:
532
+ return FileType.DOCUMENT
533
+ if ext in GOOGLE_WORKSPACE_EXTENSIONS:
534
+ return FileType.DOCUMENT
535
+ if ext in VIDEO_EXTENSIONS:
536
+ return FileType.VIDEO
537
+ return None
538
+
539
+
540
+ def extract_pdf_text(path: Path) -> str:
541
+ """Extract plain text from a PDF file using pypdf."""
542
+ if not _file_within_size_cap(path):
543
+ return ""
544
+ try:
545
+ from pypdf import PdfReader
546
+ reader = PdfReader(str(path))
547
+ pages = []
548
+ for page in reader.pages:
549
+ text = page.extract_text()
550
+ if text:
551
+ pages.append(text)
552
+ return "\n".join(pages)
553
+ except Exception:
554
+ return ""
555
+
556
+
557
+ def docx_to_markdown(path: Path) -> str:
558
+ """Convert a .docx file to markdown text using python-docx."""
559
+ if not _zip_within_caps(path):
560
+ return ""
561
+ try:
562
+ from docx import Document
563
+ from docx.oxml.ns import qn
564
+ doc = Document(str(path))
565
+ lines = []
566
+ for para in doc.paragraphs:
567
+ style = para.style.name if para.style else ""
568
+ text = para.text.strip()
569
+ if not text:
570
+ lines.append("")
571
+ continue
572
+ if style.startswith("Heading 1"):
573
+ lines.append(f"# {text}")
574
+ elif style.startswith("Heading 2"):
575
+ lines.append(f"## {text}")
576
+ elif style.startswith("Heading 3"):
577
+ lines.append(f"### {text}")
578
+ elif style.startswith("List"):
579
+ lines.append(f"- {text}")
580
+ else:
581
+ lines.append(text)
582
+ # Tables
583
+ for table in doc.tables:
584
+ rows = [[cell.text.strip() for cell in row.cells] for row in table.rows]
585
+ if not rows:
586
+ continue
587
+ header = "| " + " | ".join(rows[0]) + " |"
588
+ sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
589
+ lines.extend([header, sep])
590
+ for row in rows[1:]:
591
+ lines.append("| " + " | ".join(row) + " |")
592
+ return "\n".join(lines)
593
+ except ImportError:
594
+ return ""
595
+ except Exception:
596
+ return ""
597
+
598
+
599
+ def xlsx_to_markdown(path: Path) -> str:
600
+ """Convert an .xlsx file to markdown text using openpyxl."""
601
+ if not _zip_within_caps(path):
602
+ return ""
603
+ try:
604
+ import openpyxl
605
+ wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
606
+ sections = []
607
+ for sheet_name in wb.sheetnames:
608
+ ws = wb[sheet_name]
609
+ rows = []
610
+ for row in ws.iter_rows(values_only=True):
611
+ if all(cell is None for cell in row):
612
+ continue
613
+ rows.append([str(cell) if cell is not None else "" for cell in row])
614
+ if not rows:
615
+ continue
616
+ sections.append(f"## Sheet: {sheet_name}")
617
+ if len(rows) >= 1:
618
+ header = "| " + " | ".join(rows[0]) + " |"
619
+ sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
620
+ sections.extend([header, sep])
621
+ for row in rows[1:]:
622
+ sections.append("| " + " | ".join(row) + " |")
623
+ wb.close()
624
+ return "\n".join(sections)
625
+ except ImportError:
626
+ return ""
627
+ except Exception:
628
+ return ""
629
+
630
+
631
+ def xlsx_extract_structure(path: Path) -> dict:
632
+ """Extract structural nodes (sheets, named tables, column headers) from an .xlsx file.
633
+
634
+ Returns a nodes/edges dict compatible with the graphify extract pipeline.
635
+ Used in addition to xlsx_to_markdown so Claude sees both structure and content.
636
+ """
637
+ def _nid(*parts: str) -> str:
638
+ return re.sub(r"[^a-z0-9_]", "_", "_".join(p.lower() for p in parts).strip("_"))
639
+
640
+ try:
641
+ import openpyxl
642
+ except ImportError:
643
+ return {"nodes": [], "edges": []}
644
+
645
+ try:
646
+ wb = openpyxl.load_workbook(str(path), read_only=False, data_only=True)
647
+ except Exception:
648
+ return {"nodes": [], "edges": []}
649
+
650
+ # F-035: typo fix — was `_re.sub` (NameError, but unreachable because the
651
+ # whole xlsx codepath is currently behind a feature flag / not yet wired
652
+ # into the dispatcher). Before re-enabling this path, re-audit it for
653
+ # zip/XML bombs (openpyxl is built on top of zipfile and lxml-style XML
654
+ # parsing — a malicious .xlsx can blow up memory at load_workbook time).
655
+ stem = re.sub(r"[^a-z0-9]", "_", path.stem.lower())
656
+ str_path = str(path)
657
+ file_nid = _nid(str_path)
658
+ nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "document",
659
+ "source_file": str_path, "source_location": None}]
660
+ edges: list[dict] = []
661
+ seen: set[str] = {file_nid}
662
+
663
+ def _add(nid: str, label: str) -> None:
664
+ if nid not in seen:
665
+ seen.add(nid)
666
+ nodes.append({"id": nid, "label": label, "file_type": "document",
667
+ "source_file": str_path, "source_location": None})
668
+
669
+ def _edge(src: str, tgt: str, relation: str) -> None:
670
+ edges.append({"source": src, "target": tgt, "relation": relation,
671
+ "confidence": "EXTRACTED", "source_file": str_path,
672
+ "source_location": None, "weight": 1.0})
673
+
674
+ for sheet_name in wb.sheetnames:
675
+ ws = wb[sheet_name]
676
+ sheet_nid = _nid(stem, sheet_name)
677
+ _add(sheet_nid, f"{sheet_name} (sheet)")
678
+ _edge(file_nid, sheet_nid, "contains")
679
+
680
+ # Named Excel Tables (ListObjects)
681
+ if hasattr(ws, "tables"):
682
+ for tbl in ws.tables.values():
683
+ tbl_nid = _nid(stem, sheet_name, tbl.name)
684
+ _add(tbl_nid, tbl.name)
685
+ _edge(sheet_nid, tbl_nid, "contains")
686
+ # Column headers from table header row
687
+ ref = tbl.ref # e.g. "A1:D10"
688
+ if ref:
689
+ try:
690
+ from openpyxl.utils import range_boundaries
691
+ min_col, min_row, max_col, _ = range_boundaries(ref)
692
+ header_row = list(ws.iter_rows(min_row=min_row, max_row=min_row,
693
+ min_col=min_col, max_col=max_col,
694
+ values_only=True))
695
+ if header_row:
696
+ for col_name in header_row[0]:
697
+ if col_name:
698
+ col_nid = _nid(stem, tbl.name, str(col_name))
699
+ _add(col_nid, str(col_name))
700
+ _edge(tbl_nid, col_nid, "contains")
701
+ except Exception:
702
+ pass
703
+ else:
704
+ # Fallback: first non-empty row as column headers
705
+ for row in ws.iter_rows(max_row=1, values_only=True):
706
+ for cell in row:
707
+ if cell:
708
+ col_nid = _nid(stem, sheet_name, str(cell))
709
+ _add(col_nid, str(cell))
710
+ _edge(sheet_nid, col_nid, "contains")
711
+ break
712
+
713
+ try:
714
+ wb.close()
715
+ except Exception:
716
+ pass
717
+
718
+ return {"nodes": nodes, "edges": edges}
719
+
720
+
721
+ def convert_office_file(path: Path, out_dir: Path, root: "Path | None" = None) -> Path | None:
722
+ """Convert a .docx or .xlsx to a markdown sidecar in out_dir.
723
+
724
+ Returns the path of the converted .md file, or None if conversion failed
725
+ or the required library is not installed.
726
+ """
727
+ ext = path.suffix.lower()
728
+ if ext == ".docx":
729
+ text = docx_to_markdown(path)
730
+ elif ext == ".xlsx":
731
+ text = xlsx_to_markdown(path)
732
+ else:
733
+ return None
734
+
735
+ if not text.strip():
736
+ return None
737
+
738
+ out_dir.mkdir(parents=True, exist_ok=True)
739
+ # Use a stable name derived from the original path to avoid collisions.
740
+ # Hash the path RELATIVE to the scan root, not the absolute path: the
741
+ # absolute form salts the name with the checkout location, so the same
742
+ # tracked .xlsx in two clones/worktrees emits two differently-named,
743
+ # byte-identical sidecars — unbounded duplicates when graphify-out/ is
744
+ # committed, each ingested as a distinct source doc (#2059). The relative
745
+ # path still disambiguates same-stem files in different directories.
746
+ # Normalize to NFC before hashing: on macOS (HFS+/APFS) os.walk/rglob return
747
+ # filenames in NFD, while Python string literals and directly-constructed
748
+ # Path objects are NFC, so the same source file would otherwise hash to
749
+ # different sidecar names across runs — making --update treat every Office
750
+ # file as new and re-extract it (#1226).
751
+ import hashlib
752
+ import unicodedata
753
+ if root is None:
754
+ # Default layout: out_dir is <root>/<graphify-out>/converted.
755
+ root = out_dir.parent.parent
756
+ try:
757
+ key = path.resolve().relative_to(Path(root).resolve()).as_posix()
758
+ except (ValueError, OSError):
759
+ # Not under the scan root (custom GRAPHIFY_OUT layouts, --include
760
+ # sources, direct API callers): keep the previous absolute form rather
761
+ # than guessing, so behavior is unchanged for those cases.
762
+ key = str(path.resolve())
763
+ normalized_path = unicodedata.normalize("NFC", key)
764
+ name_hash = hashlib.sha256(normalized_path.encode()).hexdigest()[:8]
765
+ out_path = out_dir / f"{path.stem}_{name_hash}.md"
766
+ # Skip re-writing only when the sidecar is present AND at least as new as the
767
+ # source. detect_incremental tracks the SIDECAR (not the Office source), so a
768
+ # sidecar that is never rewritten after the source changes leaves the doc
769
+ # reported "unchanged" forever and freezes the graph (#1649). Re-converting
770
+ # when the source is newer bumps the sidecar's mtime/content, which the
771
+ # incremental hash check then correctly picks up. An unchanged source keeps
772
+ # its (newer-or-equal) sidecar untouched so it never churns (#1226).
773
+ try:
774
+ if out_path.exists() and os.stat(_os_path(out_path)).st_mtime >= os.stat(_os_path(path)).st_mtime:
775
+ return out_path
776
+ except OSError:
777
+ if out_path.exists():
778
+ return out_path
779
+ out_path.write_text(
780
+ f"<!-- converted from {path.name} -->\n\n{text}",
781
+ encoding="utf-8",
782
+ )
783
+ return out_path
784
+
785
+
786
+ def count_words(path: Path) -> int:
787
+ try:
788
+ ext = path.suffix.lower()
789
+ if ext == ".pdf":
790
+ return len(extract_pdf_text(path).split())
791
+ if ext == ".docx":
792
+ return len(docx_to_markdown(path).split())
793
+ if ext == ".xlsx":
794
+ return len(xlsx_to_markdown(path).split())
795
+ # Only regular files may be opened. A repository can contain named
796
+ # pipes, sockets and device nodes, and `clone <github-url>` exists to
797
+ # point the scan at trees the operator did not write. open() on a FIFO
798
+ # with no writer BLOCKS FOREVER — it never raises, so the except below
799
+ # cannot help, and `graphify update` hangs with no output. os.stat
800
+ # follows symlinks on purpose: a link pointing at a FIFO blocks exactly
801
+ # like the FIFO itself.
802
+ if not stat.S_ISREG(os.stat(_os_path(path)).st_mode):
803
+ return 0
804
+ with open(_os_path(path), encoding="utf-8", errors="ignore") as f:
805
+ return len(f.read().split())
806
+ except Exception:
807
+ return 0
808
+
809
+
810
+ def _is_regular_file(path: Path) -> bool:
811
+ """True only for regular files (symlinks followed).
812
+
813
+ Named pipes, sockets and device nodes must never reach a reader:
814
+ ``open()`` on a FIFO with no writer blocks forever and never raises, so a
815
+ single ``pipe.py`` in a scanned repository hangs the run with no output.
816
+ A symlink is resolved deliberately — a link pointing at a FIFO blocks
817
+ exactly like the FIFO itself. A path that cannot be stat'ed is treated as
818
+ not readable rather than raising.
819
+ """
820
+ try:
821
+ return stat.S_ISREG(os.stat(path).st_mode)
822
+ except OSError:
823
+ return False
824
+
825
+
826
+ # Directory names to always skip - venvs, caches, build artifacts, deps
827
+ _SKIP_DIRS = {
828
+ "venv", ".venv", # "env"/".env"/"*_env" are gated on venv markers below (#2058)
829
+ "node_modules", "__pycache__", ".git",
830
+ "dist", "build", "target", # bare "out" is gated on build-output evidence below (#3347)
831
+ "site-packages", "lib64",
832
+ ".pytest_cache", ".mypy_cache", ".ruff_cache",
833
+ ".tox", ".nox", ".eggs", "*.egg-info", # nox is tox's successor, same .nox/ venv shape (#1804)
834
+ "graphify-out", # never treat the default output as source input (#524)
835
+ # Coverage/test-artefact dirs — generated, never architecturally meaningful
836
+ "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870);
837
+ # bare "coverage" is gated on report
838
+ # artefacts below (#2339)
839
+ "visual-tests", "visual-test", # Playwright/visual-regression bundles (#869)
840
+ "__snapshots__", # Jest/Vitest snapshot dir (unambiguous)
841
+ "storybook-static", # Storybook production build output
842
+ "dist-protected", # Protected dist variants (same noise as dist)
843
+ # Framework cache/build dirs — generated, never architecturally meaningful (#873)
844
+ ".next", ".nuxt", ".turbo", ".angular",
845
+ ".idea", ".cache", ".parcel-cache", ".svelte-kit", ".terraform", ".serverless",
846
+ ".graphify", # graphify's own extraction cache — never index self-generated data
847
+ ".obsidian", ".smart-env", # Obsidian vault metadata and plugin caches (#2493)
848
+ ".worktrees", # git worktree convention (#947) — sibling checkouts, always redundant
849
+ }
850
+
851
+ # Large generated files that are never useful to extract
852
+ _SKIP_FILES = {
853
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
854
+ "Cargo.lock", "poetry.lock", "Gemfile.lock",
855
+ "composer.lock", "go.sum", "go.work.sum",
856
+ # Removed allowlist config (#2112) — no longer consumed, so keep a leftover
857
+ # file out of the unclassified list instead of surfacing it as scan input.
858
+ ".graphifyinclude",
859
+ }
860
+
861
+ # A bare "snapshots" dir is a Jest/Vitest artifact only when it actually holds
862
+ # snapshot files or lives directly under a JS test root. Elsewhere it is often a
863
+ # real code namespace (e.g. Rails app/services/snapshots/), so pruning it by name
864
+ # silently dropped legitimate source from the graph (#1666). "__snapshots__" stays
865
+ # unconditionally pruned above; only the ambiguous bare name is gated here.
866
+ _JS_SNAPSHOT_TEST_ROOTS = frozenset({"__tests__", "__test__"})
867
+
868
+ # Files a coverage tool writes into its own output dir. Any one of them is proof
869
+ # the directory is generated: lcov (lcov.info), nyc/Istanbul (coverage-final.json,
870
+ # clover.xml, the lcov-report/ subtree), coverage.py (coverage.xml, .coverage),
871
+ # JaCoCo/Cobertura (jacoco.xml, cobertura-coverage.xml).
872
+ _COVERAGE_ARTIFACT_FILES = frozenset({
873
+ "lcov.info", "coverage-final.json", "coverage-summary.json",
874
+ "clover.xml", "coverage.xml", "cobertura-coverage.xml", "jacoco.xml",
875
+ ".coverage", "index.html",
876
+ })
877
+ _COVERAGE_ARTIFACT_DIRS = frozenset({"lcov-report", "html-report"})
878
+
879
+
880
+ def _has_coverage_artifacts(d: "Path") -> bool:
881
+ """True only when *d* holds files a coverage tool actually generated.
882
+
883
+ ``coverage`` is a legitimate package name (a Python package, a Go/Rust module,
884
+ a domain namespace), so pruning it by name alone silently drops real source —
885
+ an entire 5-module package in #2339, with its dependents left in the graph so
886
+ queries still returned plausible neighbours. Prune it only on real evidence,
887
+ mirroring the ``snapshots``/``env`` gating (#1666/#2058): a coverage report
888
+ file, or an Istanbul/lcov HTML report subtree.
889
+ """
890
+ try:
891
+ for name in _COVERAGE_ARTIFACT_FILES:
892
+ if (d / name).is_file():
893
+ return True
894
+ for name in _COVERAGE_ARTIFACT_DIRS:
895
+ if (d / name).is_dir():
896
+ return True
897
+ except OSError:
898
+ pass
899
+ return False
900
+
901
+
902
+ # Files only a compiler/bundler writes. Any one of them inside an ``out/``
903
+ # tree is proof the directory is generated output rather than source.
904
+ _BUILD_OUTPUT_SUFFIXES = (
905
+ ".class", ".jar", ".o", ".obj", ".exe", ".dll", ".pyc",
906
+ ".js.map", ".css.map", ".d.ts",
907
+ )
908
+
909
+
910
+ def _has_build_output_markers(d: "Path") -> bool:
911
+ """True only when *d* holds evidence a build tool generated it.
912
+
913
+ ``out`` is a mainstream SOURCE convention in hexagonal / ports-and-adapters
914
+ codebases — ``adapter/out/`` and ``port/out/`` hold the entire outbound
915
+ layer (persistence adapters, entities, outbound ports) — so pruning it by
916
+ name alone silently drops an architectural layer from the graph: 196 of
917
+ 655 .java files on the #3347 corpus, exit code 0, no warning. Prune only
918
+ on real generated-output evidence, mirroring the ``env``/``coverage``/
919
+ ``snapshots`` gating (#1666/#2058/#2339): the IntelliJ ``out/production``
920
+ layout, a Next.js static-export ``_next/`` tree, or compiled artifacts in
921
+ the top two directory levels. An ``out`` that cannot be verified is kept —
922
+ the same keep-on-doubt bias every other gated name has.
923
+ """
924
+ try:
925
+ if (d / "production").is_dir(): # IntelliJ IDEA compile output layout
926
+ return True
927
+ if (d / "_next").is_dir(): # `next export` static-site output
928
+ return True
929
+ subdirs: list = []
930
+ with os.scandir(d) as it:
931
+ for entry in it:
932
+ if entry.is_file() and entry.name.lower().endswith(_BUILD_OUTPUT_SUFFIXES):
933
+ return True
934
+ if entry.is_dir():
935
+ subdirs.append(entry.path)
936
+ # Probe one more level across EVERY subdirectory (a wide TS outDir may
937
+ # keep its compiled files only under later-sorted module dirs), bounded
938
+ # by total entries scanned rather than by subdirectory count so a
939
+ # pathological tree still costs O(1)-ish.
940
+ budget = 4000
941
+ for sub in subdirs:
942
+ if budget <= 0:
943
+ break
944
+ with os.scandir(sub) as it:
945
+ for entry in it:
946
+ budget -= 1
947
+ if entry.is_file() and entry.name.lower().endswith(_BUILD_OUTPUT_SUFFIXES):
948
+ return True
949
+ if budget <= 0:
950
+ break
951
+ except OSError:
952
+ pass
953
+ return False
954
+
955
+
956
+ def _has_venv_markers(d: "Path") -> bool:
957
+ """True only when *d* has actual virtualenv/conda structure on disk.
958
+
959
+ ``env``/``.env``/``*_env`` is a real source-directory convention (UVM/ASIC
960
+ verification trees, and others), so pruning it by name alone silently drops
961
+ legitimate source with no trace (#2058). Prune it only on real evidence: a
962
+ ``pyvenv.cfg``, an ``activate`` script, a ``lib/python*`` tree, or conda's
963
+ ``conda-meta/`` (``conda create -p ./env`` writes no pyvenv.cfg).
964
+ """
965
+ try:
966
+ if (d / "pyvenv.cfg").is_file():
967
+ return True
968
+ if (d / "bin" / "activate").is_file() or (d / "Scripts" / "activate").is_file():
969
+ return True
970
+ if next(d.glob("lib/python*"), None) is not None:
971
+ return True
972
+ if (d / "conda-meta").is_dir():
973
+ return True
974
+ except OSError:
975
+ pass
976
+ return False
977
+
978
+
979
+ def _is_noise_dir(part: str, parent: "Path | None" = None) -> bool:
980
+ """Return True if this directory name looks like a venv, cache, or dep dir."""
981
+ if part in _SKIP_DIRS:
982
+ return True
983
+ if part in ("env", ".env") or part.endswith("_env"):
984
+ # Ambiguous: a real venv OR a real source dir. Prune only on actual venv
985
+ # evidence, mirroring the "snapshots" gating (#1666/#2058).
986
+ if parent is None:
987
+ return False # cannot verify; keep a possibly-real code dir
988
+ return _has_venv_markers(parent / part)
989
+ if part == "coverage":
990
+ # Ambiguous: a generated report dir OR a real package named coverage.
991
+ # Prune only on actual coverage-artefact evidence (#2339).
992
+ if parent is None:
993
+ return False # cannot verify; keep a possibly-real code dir
994
+ return _has_coverage_artifacts(parent / part)
995
+ if part == "out":
996
+ # Ambiguous: compiler/bundler output (IntelliJ, `next export`, a TS
997
+ # outDir) OR the outbound layer of a hexagonal codebase
998
+ # (adapter/out/, port/out/). Prune only on actual build-output
999
+ # evidence (#3347).
1000
+ if parent is None:
1001
+ return False # cannot verify; keep a possibly-real code dir
1002
+ return _has_build_output_markers(parent / part)
1003
+ if part == "snapshots":
1004
+ # Prune only when it looks like an actual JS/Vitest snapshot dir.
1005
+ if parent is None:
1006
+ return False # cannot verify; keep a possibly-real code dir
1007
+ snap_dir = parent / part
1008
+ if parent.name in _JS_SNAPSHOT_TEST_ROOTS:
1009
+ return True
1010
+ try:
1011
+ if next(snap_dir.glob("*.snap"), None) is not None:
1012
+ return True
1013
+ except OSError:
1014
+ pass
1015
+ return False
1016
+ # Catch *_venv (unambiguous — "venv" is always a virtualenv signal). "*_env"
1017
+ # is gated on markers above (#2058), not pruned by name.
1018
+ if part.endswith("_venv"):
1019
+ return True
1020
+ if part.endswith(".egg-info"):
1021
+ return True
1022
+ # worktrees/ nested inside a dotted dir (e.g. .claude/worktrees/, .git/worktrees/)
1023
+ if part == "worktrees" and parent is not None and parent.name.startswith("."):
1024
+ return True
1025
+ return False
1026
+
1027
+
1028
+ _VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil")
1029
+
1030
+
1031
+ def _nfc(text: str) -> str:
1032
+ """Normalize text to NFC so ignore matching survives Unicode form drift.
1033
+
1034
+ macOS (APFS/HFS+) returns filenames in NFD: "ç" comes back as "c" +
1035
+ U+0327 COMBINING CEDILLA. Editors write ignore files in NFC, where the
1036
+ same "ç" is the single codepoint U+00E7. The two render identically and
1037
+ compare unequal, so a pattern like `Orçamento/` silently fails to exclude
1038
+ the directory it names — the files are scanned and, for docs/PDFs, sent
1039
+ to an LLM despite an explicit rule against it.
1040
+
1041
+ Both sides are normalized to NFC before any fnmatch call. NFC is the form
1042
+ Linux and Windows already use, so this is a no-op there and only repairs
1043
+ the macOS mismatch.
1044
+ """
1045
+ return unicodedata.normalize("NFC", text)
1046
+
1047
+
1048
+ def _parse_gitignore_line(raw: str) -> str:
1049
+ """Parse one raw line from a .graphifyignore file per gitignore spec.
1050
+
1051
+ - Strip newline chars
1052
+ - Strip inline comments (whitespace + # suffix), but only when # is
1053
+ preceded by whitespace — so path#with#hash.py is preserved
1054
+ - Unescape \\# to literal #
1055
+ - Remove trailing spaces unless escaped with backslash
1056
+ - Strip leading whitespace
1057
+ - Return empty string for blank lines and full-line comments
1058
+ """
1059
+ line = raw.rstrip("\n\r")
1060
+ line = line.lstrip()
1061
+ if not line or line.startswith("#"):
1062
+ return ""
1063
+ # Strip inline comments: require whitespace before # (gitignore extension)
1064
+ line = re.sub(r"\s+#+[^\\].*$", "", line)
1065
+ # Unescape \# → literal #
1066
+ line = line.replace("\\#", "#")
1067
+ # Remove unescaped trailing spaces (per gitignore spec)
1068
+ line = re.sub(r"(?<!\\) +$", "", line)
1069
+ return _nfc(line)
1070
+
1071
+
1072
+ def _find_vcs_root(start: Path) -> Path | None:
1073
+ """Walk upward from start; return the first directory containing a VCS marker."""
1074
+ current = start.resolve()
1075
+ home = Path.home()
1076
+ while True:
1077
+ if any((current / m).exists() for m in _VCS_MARKERS):
1078
+ return current
1079
+ parent = current.parent
1080
+ if parent == current or current == home:
1081
+ return None
1082
+ current = parent
1083
+
1084
+
1085
+ def _path_identity(path: Path) -> str:
1086
+ """Portable comparison key for an existing filesystem path."""
1087
+ return _nfc(os.path.normcase(os.path.abspath(os.fspath(path))))
1088
+
1089
+
1090
+ def _git_tracked_path_keys(root: Path) -> tuple[set[str], set[str]]:
1091
+ """Return tracked-file keys and their ancestor-directory keys under *root*.
1092
+
1093
+ Gitignore rules do not apply to paths already present in Git's index. Ask
1094
+ Git once per scan/predicate construction with NUL-delimited output so every
1095
+ valid filename is preserved. Missing Git, a non-Git VCS marker, command
1096
+ failure, and malformed output all fail closed to the historical ignore
1097
+ behavior rather than making discovery fail (#2759).
1098
+ """
1099
+ root = root.resolve()
1100
+ vcs_root = _find_vcs_root(root)
1101
+ if vcs_root is None or not (vcs_root / ".git").exists():
1102
+ return set(), set()
1103
+ try:
1104
+ proc = subprocess.run(
1105
+ ["git", "-C", str(vcs_root), "ls-files", "-z", "--cached"],
1106
+ stdin=subprocess.DEVNULL,
1107
+ stdout=subprocess.PIPE,
1108
+ stderr=subprocess.DEVNULL,
1109
+ check=False,
1110
+ timeout=30,
1111
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
1112
+ )
1113
+ except (OSError, subprocess.SubprocessError):
1114
+ return set(), set()
1115
+ if proc.returncode != 0:
1116
+ return set(), set()
1117
+
1118
+ tracked_files: set[str] = set()
1119
+ tracked_dirs: set[str] = set()
1120
+ for raw in proc.stdout.split(b"\0"):
1121
+ if not raw:
1122
+ continue
1123
+ path = Path(os.path.abspath(vcs_root / os.fsdecode(raw)))
1124
+ try:
1125
+ path.relative_to(root)
1126
+ except ValueError:
1127
+ continue
1128
+ # Deleted index entries and submodule gitlinks are not discoverable
1129
+ # files. Symlinks to regular files remain eligible; the existing
1130
+ # in-root target guard still decides whether they may enter the corpus.
1131
+ if not _is_regular_file(path):
1132
+ continue
1133
+ tracked_files.add(_path_identity(path))
1134
+ parent = path.parent
1135
+ while parent != root:
1136
+ parent_key = _path_identity(parent)
1137
+ if parent_key in tracked_dirs:
1138
+ break # its ancestors were added with the first file below it
1139
+ tracked_dirs.add(parent_key)
1140
+ parent = parent.parent
1141
+ return tracked_files, tracked_dirs
1142
+
1143
+
1144
+ def _git_info_exclude(vcs_root: Path) -> Path | None:
1145
+ """Resolve ``$GIT_DIR/info/exclude`` for the repo rooted at ``vcs_root``.
1146
+
1147
+ ``info/exclude`` is where git records local-only, uncommitted excludes — and
1148
+ where ``git worktree add`` writes nested worktree paths — so a repo can ignore
1149
+ a directory without any ``.gitignore`` entry. graphify only read
1150
+ ``.gitignore``/``.graphifyignore``, so it walked into those worktree copies and
1151
+ the graph exploded (#1810). Handles the linked-worktree/submodule case where
1152
+ ``.git`` is a file (``gitdir: <path>``) and the real excludes live in the
1153
+ shared common git dir. Returns None when there is no readable exclude file.
1154
+ """
1155
+ dot_git = vcs_root / ".git"
1156
+ git_dir: Path | None = None
1157
+ if dot_git.is_dir():
1158
+ git_dir = dot_git
1159
+ elif dot_git.is_file():
1160
+ try:
1161
+ content = dot_git.read_text(encoding="utf-8", errors="ignore").strip()
1162
+ except OSError:
1163
+ content = ""
1164
+ if content.startswith("gitdir:"):
1165
+ gd = Path(content[len("gitdir:"):].strip())
1166
+ if not gd.is_absolute():
1167
+ gd = (vcs_root / gd).resolve()
1168
+ git_dir = gd
1169
+ # A linked worktree's gitdir holds a `commondir` file pointing at the
1170
+ # shared git dir, where info/exclude actually lives.
1171
+ commondir = gd / "commondir"
1172
+ if commondir.exists():
1173
+ try:
1174
+ cd_raw = commondir.read_text(encoding="utf-8", errors="ignore").strip()
1175
+ except OSError:
1176
+ cd_raw = ""
1177
+ if cd_raw:
1178
+ cd = Path(cd_raw)
1179
+ git_dir = cd if cd.is_absolute() else (gd / cd).resolve()
1180
+ if git_dir is None:
1181
+ return None
1182
+ exclude = git_dir / "info" / "exclude"
1183
+ return exclude if exclude.is_file() else None
1184
+
1185
+
1186
+ _warned_ignore_encodings: set[str] = set()
1187
+
1188
+
1189
+ def _read_ignore_text(path: Path) -> str:
1190
+ """Read an ignore file, preferring UTF-8 but never silently dropping a rule.
1191
+
1192
+ These files were read with ``errors="ignore"``, which turns a mis-encoded
1193
+ byte into *no* byte. An ignore file saved in the host's ANSI codepage — the
1194
+ historical Notepad default on Windows, and still what ``Set-Content`` writes
1195
+ without ``-Encoding`` — is not valid UTF-8, so ``Or\xe7amento/`` decoded to
1196
+ the pattern ``Oramento/``. That matches nothing, and nothing said so: the
1197
+ directory was scanned despite an explicit exclusion, which for a rule
1198
+ covering documents or PDFs means they reach the semantic pass anyway.
1199
+
1200
+ So: UTF-8 (BOM-tolerant) first, since that is what the format should be and
1201
+ what every other reader here assumes. Only if that fails do we fall back to
1202
+ the host encoding, then to latin-1, which cannot fail and maps every byte to
1203
+ a codepoint — a rule spelled in some third encoding still comes out wrong,
1204
+ but it comes out *whole*, and the warning names the file so it is fixable.
1205
+ Decoding never raises, matching the previous contract.
1206
+ """
1207
+ raw = path.read_bytes()
1208
+ try:
1209
+ return raw.decode("utf-8-sig")
1210
+ except UnicodeDecodeError:
1211
+ pass
1212
+ # A BOM'd UTF-16 file (common from PowerShell `Set-Content` / Notepad "Unicode")
1213
+ # is not valid UTF-8, and latin-1 would map its interleaved NUL bytes to a
1214
+ # wall of \x00, garbling every rule. Decode it as UTF-16 by its BOM first.
1215
+ if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
1216
+ try:
1217
+ return raw.decode("utf-16")
1218
+ except UnicodeDecodeError:
1219
+ pass
1220
+ import locale
1221
+ import sys as _sys
1222
+ fallback = locale.getpreferredencoding(False) or "latin-1"
1223
+ for enc in (fallback, "latin-1"):
1224
+ try:
1225
+ text = raw.decode(enc)
1226
+ except (UnicodeDecodeError, LookupError):
1227
+ continue
1228
+ key = str(path)
1229
+ if key not in _warned_ignore_encodings:
1230
+ _warned_ignore_encodings.add(key)
1231
+ print(
1232
+ f"[graphify] WARNING: {path} is not valid UTF-8; read it as "
1233
+ f"{enc} instead. Re-save it as UTF-8 — patterns with non-ASCII "
1234
+ "characters may not match as written.",
1235
+ file=_sys.stderr,
1236
+ )
1237
+ return text
1238
+ return raw.decode("utf-8", errors="ignore")
1239
+
1240
+
1241
+ def _load_dir_own_ignore(d: Path, *, gitignore: bool = True) -> list[tuple[Path, str]]:
1242
+ """Read .gitignore/.graphifyignore directly inside *d* (not its ancestors).
1243
+
1244
+ Merges .gitignore and .graphifyignore for this one directory (#1363):
1245
+ .gitignore is read first and .graphifyignore last, so .graphifyignore
1246
+ patterns (including `!` negations) win on conflict via last-match-wins;
1247
+ adding a .graphifyignore can only ever exclude MORE, never re-include a
1248
+ .gitignore-excluded file (#945 kept: a dir with only a .gitignore still
1249
+ gets sensible defaults).
1250
+
1251
+ Shared by `_load_graphifyignore` (ancestor chain, loaded once before the
1252
+ scan) and the live os.walk loop in `detect()` (called per-directory as
1253
+ each descendant is visited), so nested ignore files *below* the scan
1254
+ root are honored too — previously only the scan root and its ancestors
1255
+ were read, so e.g. `vendor/sub/.gitignore` was silently ignored (#1206).
1256
+ """
1257
+ patterns: list[tuple[Path, str]] = []
1258
+ for fname in ((".gitignore", ".graphifyignore") if gitignore else (".graphifyignore",)):
1259
+ ignore_file = d / fname
1260
+ if ignore_file.exists():
1261
+ for raw in _read_ignore_text(ignore_file).splitlines():
1262
+ line = _parse_gitignore_line(raw)
1263
+ if line:
1264
+ patterns.append((d, line))
1265
+ return patterns
1266
+
1267
+
1268
+ def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Path, str]]:
1269
+ """Read .graphifyignore files and return (anchor_dir, pattern) pairs.
1270
+
1271
+ Patterns are returned outer-first so that inner (closer) rules are
1272
+ appended last and win via last-match-wins semantics — matching gitignore
1273
+ behavior exactly.
1274
+
1275
+ Walk ceiling: the nearest VCS root if inside a repo, otherwise the scan
1276
+ root itself (hermetic — no leakage across unrelated sibling projects).
1277
+
1278
+ Covers the scan root and its ancestors only — directories *below* the
1279
+ scan root are picked up live during the os.walk in `detect()` instead,
1280
+ since they aren't known until the walk reaches them (#1206).
1281
+ """
1282
+ root = root.resolve()
1283
+ ceiling = _find_vcs_root(root) or root
1284
+
1285
+ # Collect ancestor dirs from ceiling down to root (outer → inner)
1286
+ dirs: list[Path] = []
1287
+ current = root
1288
+ while True:
1289
+ dirs.append(current)
1290
+ if current == ceiling:
1291
+ break
1292
+ current = current.parent
1293
+ dirs.reverse() # ceiling first, scan root last
1294
+
1295
+ patterns: list[tuple[Path, str]] = []
1296
+
1297
+ # $GIT_DIR/info/exclude is repo-root-scoped and, per git, ranks below every
1298
+ # per-directory .gitignore/.graphifyignore — so load it first (lowest priority
1299
+ # under last-match-wins) anchored at the VCS root, letting a nearer `!`
1300
+ # re-include still override it (#1810).
1301
+ info_exclude = _git_info_exclude(ceiling) if gitignore else None
1302
+ if info_exclude is not None:
1303
+ for raw in _read_ignore_text(info_exclude).splitlines():
1304
+ line = _parse_gitignore_line(raw)
1305
+ if line:
1306
+ patterns.append((ceiling, line))
1307
+
1308
+ for d in dirs:
1309
+ patterns.extend(_load_dir_own_ignore(d, gitignore=gitignore))
1310
+ return patterns
1311
+
1312
+
1313
+ # Parsed-pattern cache: raw pattern string -> (negated, directory_only,
1314
+ # path_relative, stripped_pattern). Ignore patterns are re-evaluated for every
1315
+ # walked entry; parsing the same strings per entry per scan was pure waste.
1316
+ # Plain dict (no LRU): the universe of keys is the distinct pattern lines in
1317
+ # the corpus's ignore files, which is small and bounded per scan. A long-lived
1318
+ # `graphify watch` process spanning many repos could still accumulate keys over
1319
+ # time, so cap it and clear wholesale on overflow (parsing is cheap, so a rare
1320
+ # full re-fill costs nothing that matters).
1321
+ _PARSED_PATTERN_CACHE: dict[str, tuple[bool, bool, bool, str]] = {}
1322
+ _PARSED_PATTERN_CACHE_MAX = 100_000
1323
+
1324
+
1325
+ def _parse_ignore_pattern(pattern: str) -> tuple[bool, bool, bool, str]:
1326
+ """Split one gitignore-style pattern into its matching flags, cached.
1327
+
1328
+ Returns (negated, directory_only, path_relative, stripped). ``stripped``
1329
+ is empty for patterns that match nothing (bare "!", bare "/").
1330
+ """
1331
+ got = _PARSED_PATTERN_CACHE.get(pattern)
1332
+ if got is None:
1333
+ negated = pattern.startswith("!")
1334
+ raw = pattern[1:] if negated else pattern
1335
+ directory_only = raw.endswith("/")
1336
+ path_relative = "/" in raw.rstrip("/")
1337
+ got = (negated, directory_only, path_relative, raw.strip("/"))
1338
+ if len(_PARSED_PATTERN_CACHE) >= _PARSED_PATTERN_CACHE_MAX:
1339
+ _PARSED_PATTERN_CACHE.clear()
1340
+ _PARSED_PATTERN_CACHE[pattern] = got
1341
+ return got
1342
+
1343
+
1344
+ # PurePath.relative_to compares casefolded parts on Windows only; mirror that
1345
+ # exactly, but skip the normcase pass entirely where it is a no-op (POSIX).
1346
+ _CASEFOLD_PATHS = os.path.normcase("Aa") != "Aa"
1347
+
1348
+
1349
+ def _lexical_relative(
1350
+ target: Path, target_parts: tuple[str, ...], anchor: Path
1351
+ ) -> str | None:
1352
+ """String-space equivalent of ``_nfc(str(target.relative_to(anchor)).replace(os.sep, "/"))``.
1353
+
1354
+ Returns None where ``relative_to`` raises ValueError (target not under
1355
+ anchor). ``relative_to`` is purely lexical — a parts-prefix check — so this
1356
+ performs the same comparison on the already-parsed ``parts`` tuples instead
1357
+ of constructing a Path object (and, on 3.12+, walking ``parents``
1358
+ quadratically) per pattern per scanned entry. That construction was the
1359
+ dominant cost of large scans (see CHANGELOG: 76k-file vault, 50+ min).
1360
+ """
1361
+ anchor_parts = anchor.parts
1362
+ if not anchor_parts:
1363
+ # Anchor without parts (Path(".")): defer to pathlib for its exact
1364
+ # "." / ValueError semantics. Real anchors are directories with parts,
1365
+ # so the hot path never lands here.
1366
+ try:
1367
+ return _nfc(str(target.relative_to(anchor)).replace(os.sep, "/"))
1368
+ except ValueError:
1369
+ return None
1370
+ n = len(anchor_parts)
1371
+ if len(target_parts) < n:
1372
+ return None
1373
+ head = target_parts[:n]
1374
+ if head != anchor_parts and (
1375
+ not _CASEFOLD_PATHS
1376
+ or tuple(map(os.path.normcase, head))
1377
+ != tuple(map(os.path.normcase, anchor_parts))
1378
+ ):
1379
+ return None
1380
+ tail = target_parts[n:]
1381
+ if not tail:
1382
+ return "."
1383
+ return _nfc("/".join(tail))
1384
+
1385
+
1386
+ def _match_globstar_parts(
1387
+ path_parts: tuple[str, ...],
1388
+ pattern_parts: tuple[str, ...],
1389
+ path_idx: int,
1390
+ pattern_idx: int,
1391
+ memo: dict[tuple[int, int], bool],
1392
+ ) -> bool:
1393
+ """Recursive ``**``-aware segment match, memoized via an explicit dict.
1394
+
1395
+ Lifted out of ``_match_anchored_ignore_pattern`` (was a per-call
1396
+ ``@lru_cache`` closure): the decorated inner closure referenced itself, so
1397
+ every call leaked a reference cycle for the GC to reclaim on this hot path.
1398
+ A plain dict passed in avoids both the cycle and the per-call cache setup.
1399
+ """
1400
+ key = (path_idx, pattern_idx)
1401
+ cached = memo.get(key)
1402
+ if cached is not None:
1403
+ return cached
1404
+
1405
+ if pattern_idx == len(pattern_parts):
1406
+ result = path_idx == len(path_parts)
1407
+ else:
1408
+ part = pattern_parts[pattern_idx]
1409
+ if part == "**":
1410
+ if pattern_idx == len(pattern_parts) - 1:
1411
+ result = path_idx < len(path_parts)
1412
+ else:
1413
+ result = _match_globstar_parts(
1414
+ path_parts, pattern_parts, path_idx, pattern_idx + 1, memo
1415
+ ) or (
1416
+ path_idx < len(path_parts)
1417
+ and _match_globstar_parts(
1418
+ path_parts, pattern_parts, path_idx + 1, pattern_idx, memo
1419
+ )
1420
+ )
1421
+ else:
1422
+ result = (
1423
+ path_idx < len(path_parts)
1424
+ and fnmatch.fnmatchcase(path_parts[path_idx], part)
1425
+ and _match_globstar_parts(
1426
+ path_parts, pattern_parts, path_idx + 1, pattern_idx + 1, memo
1427
+ )
1428
+ )
1429
+ memo[key] = result
1430
+ return result
1431
+
1432
+
1433
+ def _match_anchored_ignore_pattern(path: str, pattern: str) -> bool:
1434
+ """Match an anchored gitignore pattern without letting ``*`` cross ``/``."""
1435
+ path_parts = tuple(path.split("/"))
1436
+ pattern_parts = tuple(pattern.split("/"))
1437
+ # Fast path: with no ``**`` the match is a straight segment-wise fnmatch of
1438
+ # equal-length paths, so skip the recursive matcher and its memo entirely.
1439
+ if "**" not in pattern_parts:
1440
+ if len(path_parts) != len(pattern_parts):
1441
+ return False
1442
+ return all(
1443
+ fnmatch.fnmatchcase(pp, qp) for pp, qp in zip(path_parts, pattern_parts)
1444
+ )
1445
+ return _match_globstar_parts(path_parts, pattern_parts, 0, 0, {})
1446
+
1447
+
1448
+ def _is_ignored(
1449
+ path: Path,
1450
+ root: Path,
1451
+ patterns: list[tuple[Path, str]],
1452
+ *,
1453
+ _cache: dict[Path, bool] | None = None,
1454
+ ) -> bool:
1455
+ """Return True if the path should be ignored per .graphifyignore patterns.
1456
+
1457
+ Uses gitignore last-match-wins semantics: all patterns are evaluated in
1458
+ order; the final matching pattern determines the result. Negation patterns
1459
+ (starting with !) un-ignore a previously ignored path.
1460
+
1461
+ Enforces gitignore's parent-exclusion rule: a ! pattern cannot re-include
1462
+ a file whose ancestor directory is already excluded.
1463
+
1464
+ _cache: optional dict shared across calls within the same scan. Ancestor
1465
+ directory results are memoised so files under the same subtree don't
1466
+ re-evaluate the same patterns repeatedly.
1467
+ """
1468
+ if not patterns:
1469
+ return False
1470
+
1471
+ root_nparts = len(root.parts)
1472
+
1473
+ def _eval(target: Path) -> bool:
1474
+ """Apply last-match-wins to a single target path.
1475
+
1476
+ Everything derivable from ``target`` alone — its parts, its relative
1477
+ path per anchor, the root-relative path, the split segments and their
1478
+ "/"-joined prefixes, the NFC name, the is_dir() stat — is computed at
1479
+ most ONCE per call, no matter how many patterns are evaluated. The
1480
+ previous shape rebuilt pathlib objects (``relative_to``) and re-split
1481
+ strings per PATTERN per entry, which pinned real scans for tens of
1482
+ minutes (see CHANGELOG).
1483
+ """
1484
+ if _cache is not None and target in _cache:
1485
+ return _cache[target]
1486
+
1487
+ target_parts = target.parts
1488
+ target_name_nfc: str | None = None
1489
+ target_is_dir: bool | None = None
1490
+ rel_root_known = False
1491
+ rel_root: str | None = None
1492
+ # rel string (or None = outside anchor) and part-count per distinct
1493
+ # anchor; patterns overwhelmingly share a handful of anchors.
1494
+ rel_by_anchor: dict[Path, tuple[str | None, int]] = {}
1495
+ # split segments + accumulated "/" prefixes per distinct rel string.
1496
+ segs_by_rel: dict[str, tuple[list[str], list[str]]] = {}
1497
+
1498
+ def _segments(rel: str) -> tuple[list[str], list[str]]:
1499
+ got = segs_by_rel.get(rel)
1500
+ if got is None:
1501
+ parts = rel.split("/")
1502
+ prefixes: list[str] = []
1503
+ acc = ""
1504
+ for part in parts:
1505
+ acc = part if not acc else acc + "/" + part
1506
+ prefixes.append(acc)
1507
+ got = (parts, prefixes)
1508
+ segs_by_rel[rel] = got
1509
+ return got
1510
+
1511
+ def _matches(rel: str, p: str, path_relative: bool) -> bool:
1512
+ nonlocal target_name_nfc
1513
+ if path_relative:
1514
+ return _match_anchored_ignore_pattern(rel, p)
1515
+ if fnmatch.fnmatch(rel, p):
1516
+ return True
1517
+ if target_name_nfc is None:
1518
+ target_name_nfc = _nfc(target.name)
1519
+ if fnmatch.fnmatch(target_name_nfc, p):
1520
+ return True
1521
+ parts, prefixes = _segments(rel)
1522
+ for part, prefix in zip(parts, prefixes):
1523
+ if fnmatch.fnmatch(part, p):
1524
+ return True
1525
+ if fnmatch.fnmatch(prefix, p):
1526
+ return True
1527
+ return False
1528
+
1529
+ result = False
1530
+ for anchor, pattern in patterns:
1531
+ negated, directory_only, path_relative, p = _parse_ignore_pattern(pattern)
1532
+ if not p:
1533
+ continue
1534
+
1535
+ # gitignore semantics: patterns from A/.gitignore apply ONLY to paths
1536
+ # under A. Matching non-anchored patterns against root-relative paths
1537
+ # let e.g. .hypothesis/.gitignore's bare "*" ignore the ENTIRE repo
1538
+ # (detect() returned 0 files). The anchor dir itself is exempt — an
1539
+ # ignore file governs its directory's contents, not the directory.
1540
+ cached_rel = rel_by_anchor.get(anchor)
1541
+ if cached_rel is None:
1542
+ cached_rel = (
1543
+ _lexical_relative(target, target_parts, anchor),
1544
+ len(anchor.parts),
1545
+ )
1546
+ rel_by_anchor[anchor] = cached_rel
1547
+ rel_anchor, anchor_nparts = cached_rel
1548
+ if rel_anchor is None:
1549
+ continue # target outside this pattern's anchor: cannot match
1550
+ matched = False
1551
+ if rel_anchor != ".":
1552
+ rel = rel_anchor
1553
+ if not path_relative and root_nparts > anchor_nparts:
1554
+ if not rel_root_known:
1555
+ rel_root_known = True
1556
+ rel_root = _lexical_relative(target, target_parts, root)
1557
+ if rel_root is not None:
1558
+ rel = rel_root
1559
+ matched = _matches(rel, p, path_relative=path_relative)
1560
+ if matched and directory_only:
1561
+ if target_is_dir is None:
1562
+ target_is_dir = target.is_dir()
1563
+ if not target_is_dir:
1564
+ matched = False
1565
+
1566
+ if matched:
1567
+ result = not negated # last match wins; ! flips to un-ignore
1568
+ if _cache is not None:
1569
+ _cache[target] = result
1570
+ return result
1571
+
1572
+ # Gitignore parent-exclusion rule: a ! re-include cannot rescue a file
1573
+ # whose ancestor directory is already excluded. Walk ancestors top-down;
1574
+ # if any ancestor is excluded, the file is excluded regardless of later
1575
+ # ! patterns targeting the file or a sub-path.
1576
+ try:
1577
+ rel_parts = path.relative_to(root).parts
1578
+ except ValueError:
1579
+ return _eval(path)
1580
+
1581
+ ancestor = root
1582
+ for part in rel_parts[:-1]:
1583
+ ancestor = ancestor / part
1584
+ if _eval(ancestor):
1585
+ return True
1586
+ return _eval(path)
1587
+
1588
+
1589
+ def _is_scan_ignored(
1590
+ path: Path,
1591
+ root: Path,
1592
+ patterns: list[tuple[Path, str]],
1593
+ explicit_patterns: list[tuple[Path, str]],
1594
+ tracked_files: set[str],
1595
+ tracked_dirs: set[str],
1596
+ *,
1597
+ cache: dict[Path, bool],
1598
+ explicit_cache: dict[Path, bool],
1599
+ ) -> bool:
1600
+ """Apply ignore rules while preserving Git-tracked paths.
1601
+
1602
+ ``patterns`` combines Git and graph-specific rules. ``explicit_patterns``
1603
+ contains only .graphifyignore/--exclude rules, which remain authoritative
1604
+ even for tracked files. A tracked file's ancestor directories are preserved
1605
+ from Git-only pruning so the walk can reach the file (#2759).
1606
+ """
1607
+ if not _is_ignored(path, root, patterns, _cache=cache):
1608
+ return False
1609
+ if _is_ignored(path, root, explicit_patterns, _cache=explicit_cache):
1610
+ return True
1611
+ identity = _path_identity(path)
1612
+ return identity not in tracked_files and identity not in tracked_dirs
1613
+
1614
+
1615
+ def ignored_predicate(
1616
+ root: Path,
1617
+ *,
1618
+ extra_excludes: list[str] | None = None,
1619
+ gitignore: bool = True,
1620
+ ) -> Callable[[Path], bool]:
1621
+ """Build a per-path predicate answering "would detect() exclude this path?".
1622
+
1623
+ Mirrors detect()'s ignore decisions for a single existing path WITHOUT
1624
+ re-walking the corpus, from the same machinery detect() uses: the ancestor
1625
+ .graphifyignore/.gitignore chain (_load_graphifyignore), CLI/persisted
1626
+ ``--exclude`` patterns appended last at the root anchor (#947), nested
1627
+ per-directory ignore files along the path's own lineage (#1206), the
1628
+ _is_noise_dir directory pruning, and _SKIP_FILES. The sensitive-file
1629
+ heuristic (_is_sensitive) is deliberately NOT included: callers use this
1630
+ predicate as positive evidence of a live ignore RULE (#2495), and a
1631
+ heuristic match is not user intent.
1632
+
1633
+ Nested patterns are loaded lazily, once per directory, into one shared
1634
+ pattern list. That accumulation cannot cross-contaminate results — a
1635
+ pattern only ever matches paths under its anchor directory, so patterns
1636
+ from a sibling subtree are inert — which is the same invariant detect()'s
1637
+ live os.walk relies on, and it keeps the shared _is_ignored cache valid.
1638
+ """
1639
+ root = root.resolve()
1640
+ patterns = _load_graphifyignore(root, gitignore=gitignore)
1641
+ explicit_patterns = _load_graphifyignore(root, gitignore=False)
1642
+ # Only shell out to git when .gitignore actually contributes patterns beyond
1643
+ # the explicit (.graphifyignore/--exclude) set: with no .gitignore in play,
1644
+ # nothing is dropped by gitignore and the tracked-file exemption is moot, so a
1645
+ # non-.gitignore corpus pays no `git ls-files` cost.
1646
+ tracked_files, tracked_dirs = (
1647
+ _git_tracked_path_keys(root)
1648
+ if gitignore and len(patterns) > len(explicit_patterns)
1649
+ else (set(), set())
1650
+ )
1651
+ if extra_excludes:
1652
+ for pat in extra_excludes:
1653
+ line = _parse_gitignore_line(pat)
1654
+ if line:
1655
+ patterns.append((root, line))
1656
+ explicit_patterns.append((root, line))
1657
+ cache: dict[Path, bool] = {}
1658
+ explicit_cache: dict[Path, bool] = {}
1659
+ # root's own ignore file is the last entry of _load_graphifyignore's chain.
1660
+ loaded_dirs: set[Path] = {root}
1661
+
1662
+ def _ignored(path: Path) -> bool:
1663
+ path = Path(os.path.abspath(path))
1664
+ try:
1665
+ rel_parts = path.relative_to(root).parts
1666
+ except ValueError:
1667
+ return False # outside the scan root: detect() never considered it
1668
+ if path.name in _SKIP_FILES:
1669
+ return True
1670
+ # Noise-dir pruning: os.walk never descends these, so anything beneath
1671
+ # one is excluded from the corpus regardless of ignore patterns.
1672
+ parent = root
1673
+ for part in rel_parts[:-1]:
1674
+ if _is_noise_dir(part, parent):
1675
+ return True
1676
+ parent = parent / part
1677
+ # Load ignore files along this path's own lineage — detect()'s walk
1678
+ # would have loaded exactly these before reaching the file (#1206).
1679
+ ancestor = root
1680
+ for part in rel_parts[:-1]:
1681
+ ancestor = ancestor / part
1682
+ if ancestor not in loaded_dirs:
1683
+ loaded_dirs.add(ancestor)
1684
+ patterns.extend(_load_dir_own_ignore(ancestor, gitignore=gitignore))
1685
+ explicit_patterns.extend(
1686
+ _load_dir_own_ignore(ancestor, gitignore=False)
1687
+ )
1688
+ return _is_scan_ignored(
1689
+ path,
1690
+ root,
1691
+ patterns,
1692
+ explicit_patterns,
1693
+ tracked_files,
1694
+ tracked_dirs,
1695
+ cache=cache,
1696
+ explicit_cache=explicit_cache,
1697
+ )
1698
+
1699
+ return _ignored
1700
+
1701
+
1702
+ def _auto_follow_symlinks(root: Path) -> bool:
1703
+ """Return whether ``root`` has any direct symlinked child.
1704
+
1705
+ Kept for callers that import the private helper, but detection no longer
1706
+ enables symlink following automatically. Following symlinks is now an
1707
+ explicit opt-in, and out-of-root symlink targets are never indexed.
1708
+ """
1709
+ try:
1710
+ for p in root.iterdir():
1711
+ if p.is_symlink():
1712
+ return True
1713
+ except (OSError, PermissionError):
1714
+ pass
1715
+ return False
1716
+
1717
+
1718
+ def _resolves_under_root(path: Path, root: Path) -> bool:
1719
+ """True when ``path`` resolves to a target inside ``root``."""
1720
+ try:
1721
+ path.resolve().relative_to(root.resolve())
1722
+ except (OSError, RuntimeError, ValueError):
1723
+ return False
1724
+ return True
1725
+
1726
+
1727
+ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None, gitignore: bool = True) -> dict:
1728
+ root = root.resolve()
1729
+ configured_out_dir = root / GRAPHIFY_OUT
1730
+ configured_out_names = {configured_out_dir.name}
1731
+ try:
1732
+ configured_out_dir = configured_out_dir.resolve()
1733
+ except (OSError, RuntimeError):
1734
+ configured_out_dir = configured_out_dir.absolute()
1735
+ configured_out_names.add(configured_out_dir.name)
1736
+ # .graphifyinclude support was removed (#2112): its loader and matchers had
1737
+ # no consumers, so the file has been a silent no-op since dot directories
1738
+ # became indexed by default (#873). Surface that once per scan so a
1739
+ # leftover allowlist file is not a silent behavior change.
1740
+ if (root / ".graphifyinclude").is_file():
1741
+ import sys as _sys
1742
+ print(
1743
+ "[graphify] WARNING: .graphifyinclude is no longer supported "
1744
+ "(it has been non-functional since dot directories became indexed "
1745
+ "by default); to re-include ignored paths, use ! negation patterns "
1746
+ "in .graphifyignore.",
1747
+ file=_sys.stderr,
1748
+ )
1749
+ if follow_symlinks is None:
1750
+ follow_symlinks = False
1751
+ google_workspace = google_workspace_enabled() if google_workspace is None else google_workspace
1752
+ files: dict[FileType, list[str]] = {
1753
+ FileType.CODE: [],
1754
+ FileType.DOCUMENT: [],
1755
+ FileType.PAPER: [],
1756
+ FileType.IMAGE: [],
1757
+ FileType.VIDEO: [],
1758
+ }
1759
+ total_words = 0
1760
+
1761
+ def _wc(path: Path) -> int:
1762
+ # Cache word counts against each file's stat signature so unchanged
1763
+ # PDFs/docx aren't re-parsed on every run just to size the corpus (#1656).
1764
+ # cache_root (when given, e.g. from `extract --out`) keeps this cache out
1765
+ # of the scanned corpus (#1747).
1766
+ from graphify import cache as _cache
1767
+ return _cache.cached_word_count(path, root, count_words, cache_root=cache_root)
1768
+
1769
+ skipped_sensitive: list[str] = []
1770
+ unclassified: list[str] = []
1771
+ # Files/dirs dropped by a .gitignore/.graphifyignore rule. Recorded so an
1772
+ # over-broad ignore (or a legitimately-ignored subtree) is visible instead
1773
+ # of silently vanishing from the graph (#1922). Directory-level entries keep
1774
+ # this bounded — a pruned `data/` is one entry, not one per contained file.
1775
+ ignored: list[str] = []
1776
+ pruned_noise: list[str] = []
1777
+ ignore_patterns = _load_graphifyignore(root, gitignore=gitignore)
1778
+ explicit_ignore_patterns = _load_graphifyignore(root, gitignore=False)
1779
+ # See ignored_predicate: skip the `git ls-files` subprocess when .gitignore
1780
+ # contributes no patterns, so a non-.gitignore corpus pays nothing for it.
1781
+ tracked_files, tracked_dirs = (
1782
+ _git_tracked_path_keys(root)
1783
+ if gitignore and len(ignore_patterns) > len(explicit_ignore_patterns)
1784
+ else (set(), set())
1785
+ )
1786
+ ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan
1787
+ explicit_ignore_cache: dict[Path, bool] = {}
1788
+ # CLI --exclude patterns are anchored at the scan root and appended last
1789
+ # so they win over any .graphifyignore/.gitignore rules (#947).
1790
+ if extra_excludes:
1791
+ for pat in extra_excludes:
1792
+ line = _parse_gitignore_line(pat)
1793
+ if line:
1794
+ ignore_patterns.append((root, line))
1795
+ explicit_ignore_patterns.append((root, line))
1796
+
1797
+ def _ignored_for_scan(path: Path) -> bool:
1798
+ return _is_scan_ignored(
1799
+ path,
1800
+ root,
1801
+ ignore_patterns,
1802
+ explicit_ignore_patterns,
1803
+ tracked_files,
1804
+ tracked_dirs,
1805
+ cache=ignore_cache,
1806
+ explicit_cache=explicit_ignore_cache,
1807
+ )
1808
+
1809
+ # Always include graphify-out/memory/ - query results filed back into the graph
1810
+ memory_dir = root / GRAPHIFY_OUT / "memory"
1811
+ scan_paths = [root]
1812
+ if memory_dir.exists():
1813
+ scan_paths.append(memory_dir)
1814
+
1815
+ seen: set[Path] = set()
1816
+ all_files: list[Path] = []
1817
+
1818
+ # os.walk swallows os.scandir errors by default (no onerror -> the failing
1819
+ # directory subtree is silently skipped). That turns a transient
1820
+ # PermissionError, or a directory created/deleted mid-walk (e.g. concurrent
1821
+ # writes racing the scan), into a partial file list and, downstream, a
1822
+ # silently partial graph.json. Record and surface every skipped directory
1823
+ # so an incomplete enumeration is visible rather than silent.
1824
+ walk_errors: list[str] = []
1825
+
1826
+ def _on_walk_error(err: OSError) -> None:
1827
+ import sys as _sys
1828
+ target = getattr(err, "filename", None) or "<unknown>"
1829
+ walk_errors.append(f"{target}: {err}")
1830
+ print(
1831
+ f"[graphify] WARNING: could not scan {target} ({err}); "
1832
+ f"its files are missing from this run's enumeration.",
1833
+ file=_sys.stderr,
1834
+ )
1835
+
1836
+ for scan_root in scan_paths:
1837
+ in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir))
1838
+ for dirpath, dirnames, filenames in os.walk(
1839
+ scan_root, followlinks=follow_symlinks, onerror=_on_walk_error
1840
+ ):
1841
+ dp = Path(dirpath)
1842
+ if follow_symlinks and os.path.islink(dirpath):
1843
+ real = os.path.realpath(dirpath)
1844
+ parent_real = os.path.realpath(os.path.dirname(dirpath))
1845
+ if parent_real == real or parent_real.startswith(real + os.sep):
1846
+ dirnames.clear()
1847
+ continue
1848
+ if not in_memory_tree:
1849
+ # dp == root was already loaded by _load_graphifyignore (root is
1850
+ # the last entry in its ancestor chain); every other directory
1851
+ # reached by the walk is a descendant below the scan root, whose
1852
+ # own .gitignore/.graphifyignore is unknown until we get here.
1853
+ # Load it now, before pruning dp's children, so a nested ignore
1854
+ # file governs its own subtree the same way git honors it (#1206).
1855
+ if dp != root:
1856
+ ignore_patterns.extend(_load_dir_own_ignore(dp, gitignore=gitignore))
1857
+ explicit_ignore_patterns.extend(
1858
+ _load_dir_own_ignore(dp, gitignore=False)
1859
+ )
1860
+ # Prune noise dirs in-place so os.walk never descends into them.
1861
+ # Dot dirs are allowed — users often want .github/, .claude/, etc.
1862
+ # Framework caches (.next, .nuxt, …) are caught by _is_noise_dir.
1863
+ # Negations need no special-casing here: _is_ignored already applies
1864
+ # last-match-wins (so `!dir/` un-ignores a directory and it won't be
1865
+ # pruned) and the gitignore parent-exclusion rule (a `!` cannot rescue
1866
+ # a file beneath an excluded dir), so descending an ignored directory to
1867
+ # look for a re-included file is never necessary. The previous blanket
1868
+ # `has_negation` disabled directory pruning for EVERY ignored dir whenever
1869
+ # any `!` rule existed — e.g. a single `!docs/**` made the walk descend
1870
+ # bin/, obj/, wwwroot/, generated/, … : a pathological slowdown on large
1871
+ # repos for no correctness gain.
1872
+ kept_dirs: list[str] = []
1873
+ for d in dirnames:
1874
+ child = dp / d
1875
+ is_configured_out = False
1876
+ if d in configured_out_names:
1877
+ try:
1878
+ is_configured_out = child.resolve() == configured_out_dir
1879
+ except (OSError, RuntimeError):
1880
+ pass
1881
+ if is_configured_out:
1882
+ pruned_noise.append(str(child) + os.sep)
1883
+ continue
1884
+ if _is_noise_dir(d, dp):
1885
+ # Record pruned-as-noise dirs so a wrongly-pruned real
1886
+ # source dir is at least traceable in the output rather
1887
+ # than vanishing silently (#2058).
1888
+ pruned_noise.append(str(child) + os.sep)
1889
+ continue
1890
+ # Directory-level pruning: ONE ignore evaluation excludes the
1891
+ # whole subtree — os.walk never descends, so a 29k-file
1892
+ # ignored dir costs one check, not one per contained file.
1893
+ if _ignored_for_scan(child):
1894
+ ignored.append(str(child) + os.sep)
1895
+ continue
1896
+ kept_dirs.append(d)
1897
+ dirnames[:] = kept_dirs
1898
+ if follow_symlinks:
1899
+ safe_dirs: list[str] = []
1900
+ for d in dirnames:
1901
+ child = dp / d
1902
+ if child.is_symlink() and not _resolves_under_root(child, root):
1903
+ skipped_sensitive.append(str(child) + " [symlink target outside scan root]")
1904
+ continue
1905
+ safe_dirs.append(d)
1906
+ dirnames[:] = safe_dirs
1907
+ for fname in filenames:
1908
+ if fname in _SKIP_FILES:
1909
+ continue
1910
+ p = dp / fname
1911
+ if p not in seen:
1912
+ seen.add(p)
1913
+ all_files.append(p)
1914
+
1915
+ all_files.sort(key=lambda p: str(p))
1916
+
1917
+ out_base = Path(cache_root).resolve() if cache_root is not None else root
1918
+ converted_dir = out_base / GRAPHIFY_OUT / "converted"
1919
+
1920
+ for p in all_files:
1921
+ # For memory dir files, skip hidden/noise filtering
1922
+ in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
1923
+ if not in_memory:
1924
+ # Skip files inside our own converted/ dir (avoid re-processing sidecars)
1925
+ if str(p).startswith(str(converted_dir)):
1926
+ continue
1927
+ if not in_memory and _ignored_for_scan(p):
1928
+ ignored.append(str(p))
1929
+ continue
1930
+ if not _resolves_under_root(p, root):
1931
+ skipped_sensitive.append(str(p) + " [symlink target outside scan root]")
1932
+ continue
1933
+ if not _is_regular_file(p):
1934
+ # A repository may contain named pipes, sockets and device nodes,
1935
+ # and `clone <github-url>` exists precisely to point the scan at
1936
+ # trees the operator did not write.
1937
+ #
1938
+ # This has to be caught HERE, at the one place where a path is
1939
+ # admitted to the corpus, rather than at each read: the readers
1940
+ # number in the hundreds across the extractors, and open() on a
1941
+ # FIFO with no writer BLOCKS FOREVER — it never raises, so their
1942
+ # try/except cannot help and the whole run hangs with no output.
1943
+ skipped_sensitive.append(str(p) + " [not a regular file]")
1944
+ continue
1945
+ if _is_sensitive(p):
1946
+ skipped_sensitive.append(str(p))
1947
+ continue
1948
+ ftype = classify_file(p)
1949
+ if not ftype:
1950
+ # Considered but unclassifiable: an extension not in any supported set,
1951
+ # or an extensionless, non-shebang file (Dockerfile, Gemfile, Makefile,
1952
+ # Rakefile, LICENSE, ...). Previously these left no trace at all — not
1953
+ # counted, not listed — so a user couldn't tell they were seen (#1692).
1954
+ unclassified.append(str(p))
1955
+ continue
1956
+ if ftype:
1957
+ if p.suffix.lower() in GOOGLE_WORKSPACE_EXTENSIONS:
1958
+ if not google_workspace:
1959
+ skipped_sensitive.append(
1960
+ str(p)
1961
+ + " [Google Workspace shortcut skipped - pass --google-workspace "
1962
+ "or set GRAPHIFY_GOOGLE_WORKSPACE=1]"
1963
+ )
1964
+ continue
1965
+ try:
1966
+ md_path = convert_google_workspace_file(p, converted_dir, xlsx_to_markdown=xlsx_to_markdown, root=root)
1967
+ except Exception as exc:
1968
+ skipped_sensitive.append(str(p) + f" [Google Workspace export failed: {exc}]")
1969
+ continue
1970
+ if md_path:
1971
+ if _ignored_for_scan(md_path):
1972
+ continue
1973
+ files[ftype].append(str(md_path))
1974
+ total_words += _wc(md_path)
1975
+ else:
1976
+ skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]")
1977
+ continue
1978
+ # Office files: convert to markdown sidecar so subagents can read them
1979
+ if p.suffix.lower() in OFFICE_EXTENSIONS:
1980
+ md_path = convert_office_file(p, converted_dir, root=root)
1981
+ if md_path:
1982
+ if _ignored_for_scan(md_path):
1983
+ continue
1984
+ files[ftype].append(str(md_path))
1985
+ total_words += _wc(md_path)
1986
+ else:
1987
+ # Conversion failed (library not installed) - skip with note
1988
+ skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]")
1989
+ continue
1990
+ files[ftype].append(str(p))
1991
+ if ftype != FileType.VIDEO:
1992
+ total_words += _wc(p)
1993
+
1994
+ for ftype in files:
1995
+ files[ftype].sort()
1996
+
1997
+ total_files = sum(len(v) for v in files.values())
1998
+ needs_graph = total_words >= CORPUS_WARN_THRESHOLD
1999
+
2000
+ # Determine warning - lower bound, upper bound, or sensitive files skipped
2001
+ warning: str | None = None
2002
+ if not needs_graph:
2003
+ warning = (
2004
+ f"Corpus is ~{total_words:,} words - fits in a single context window. "
2005
+ f"You may not need a graph."
2006
+ )
2007
+ elif total_words >= CORPUS_UPPER_THRESHOLD or total_files >= FILE_COUNT_UPPER:
2008
+ warning = (
2009
+ f"Large corpus: {total_files} files · ~{total_words:,} words. "
2010
+ f"Semantic extraction will be expensive (many Claude tokens). "
2011
+ f"Consider running on a subfolder."
2012
+ )
2013
+
2014
+ return {
2015
+ "files": {k.value: v for k, v in files.items()},
2016
+ "total_files": total_files,
2017
+ "total_words": total_words,
2018
+ "needs_graph": needs_graph,
2019
+ "warning": warning,
2020
+ "skipped_sensitive": skipped_sensitive,
2021
+ "unclassified": sorted(unclassified),
2022
+ "walk_errors": walk_errors,
2023
+ "ignored": sorted(ignored),
2024
+ "pruned_noise_dirs": sorted(pruned_noise),
2025
+ "graphifyignore_patterns": len(ignore_patterns),
2026
+ "scan_root": str(root.resolve()),
2027
+ }
2028
+
2029
+
2030
+ def _os_path(path: Path) -> str:
2031
+ r"""Return an OS path string safe for open()/stat() on Windows long paths.
2032
+
2033
+ On win32, paths longer than the legacy MAX_PATH (260 chars) are rejected by
2034
+ the plain file APIs unless prefixed with the extended-length marker ``\\?\``
2035
+ (which also requires a fully-qualified path). Without it, _md5_file /
2036
+ save_manifest / count_words silently fail to hash deeply-nested files, so
2037
+ their manifest entry never stabilizes and detect_incremental re-flags them
2038
+ as changed on every run (#1655). cache._normalize_path strips this prefix
2039
+ for stable KEYS; this adds it for I/O. Non-win32 and already-prefixed paths
2040
+ pass through unchanged.
2041
+ """
2042
+ import sys
2043
+ if sys.platform != "win32":
2044
+ return str(path)
2045
+ s = str(path)
2046
+ if s.startswith("\\\\?\\"):
2047
+ return s
2048
+ try:
2049
+ s = os.path.abspath(s) # \\?\ requires a fully-qualified path
2050
+ except Exception:
2051
+ return str(path)
2052
+ if s.startswith("\\\\"):
2053
+ # UNC share \\server\share -> \\?\UNC\server\share
2054
+ return "\\\\?\\UNC\\" + s[2:]
2055
+ return "\\\\?\\" + s
2056
+
2057
+
2058
+ def _md5_file(path: Path) -> str:
2059
+ """MD5 of file contents streamed in 64KB chunks — for change detection only."""
2060
+ import hashlib as _hl
2061
+ h = _hl.md5(usedforsecurity=False)
2062
+ try:
2063
+ with open(_os_path(path), "rb") as f:
2064
+ for chunk in iter(lambda: f.read(65536), b""):
2065
+ h.update(chunk)
2066
+ except OSError:
2067
+ return ""
2068
+ return h.hexdigest()
2069
+
2070
+
2071
+ def _stat_and_hash(path_str: str) -> tuple[str, float, str] | None:
2072
+ """Stat + MD5 a single file; returns None on OSError (e.g. deleted mid-run)."""
2073
+ try:
2074
+ p = Path(path_str)
2075
+ return path_str, os.stat(_os_path(p)).st_mtime, _md5_file(p)
2076
+ except OSError:
2077
+ return None
2078
+
2079
+
2080
+ def _nfc(s: str) -> str:
2081
+ """NFC-normalize a path string used as a manifest key.
2082
+
2083
+ On macOS, ``os.walk`` / ``getcwd`` yield NFD paths while path literals
2084
+ and many skill-substituted roots are NFC. Raw string compare then treats
2085
+ every file as both deleted and new, forcing a full re-extract (#2221).
2086
+ Same boundary as the Office sidecar hash fix (#1226).
2087
+ """
2088
+ import unicodedata
2089
+ return unicodedata.normalize("NFC", s)
2090
+
2091
+
2092
+ def _to_relative_for_storage(key: str, root: Path) -> str:
2093
+ """Return ``key`` as a forward-slash relative path from ``root``.
2094
+
2095
+ Keys outside ``root`` (out-of-tree symlinked sources, external --include
2096
+ paths) and already-relative keys pass through unchanged — mirrors the
2097
+ fallback in :func:`graphify.watch._relativize_source_files` so the
2098
+ on-disk artifact survives the round-trip even when some paths cannot be
2099
+ portably encoded.
2100
+
2101
+ Only ``root`` is resolved — the key itself is relativized symbolically
2102
+ so an in-root symlink (e.g. ``alias.py -> sub/target.py``) is stored
2103
+ under its own name. Resolving the key would point the stored entry at
2104
+ the symlink target, and the original key would then miss on reload and
2105
+ re-extract on every incremental run.
2106
+
2107
+ Both sides of ``relpath`` are NFC'd first: stamped keys may already be
2108
+ NFC while ``Path(root).resolve()`` is NFD on macOS, and a mixed-form
2109
+ compare would mark an in-root file as ``../…`` and keep it absolute
2110
+ (#2221 / #777).
2111
+ """
2112
+ p = Path(key)
2113
+ if not p.is_absolute():
2114
+ return key
2115
+ try:
2116
+ base = _nfc(str(Path(root).resolve()))
2117
+ rel = os.path.relpath(_nfc(str(p)), base)
2118
+ except (ValueError, OSError):
2119
+ return key # outside root (e.g. Windows cross-drive)
2120
+ # ``os.path.relpath`` happily produces ``../foo`` for paths outside
2121
+ # root; mirror the prior ``relative_to``-raises-ValueError semantics by
2122
+ # keeping out-of-root entries in their absolute form.
2123
+ if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"):
2124
+ return key
2125
+ return rel.replace(os.sep, "/")
2126
+
2127
+
2128
+ def _to_absolute_from_storage(key: str, root: Path) -> str:
2129
+ """Inverse of :func:`_to_relative_for_storage`.
2130
+
2131
+ Re-anchor a stored key against ``root``. Already-absolute keys
2132
+ (legacy manifests, out-of-root entries) pass through unchanged so
2133
+ that newly-loaded manifests from before this change remain readable.
2134
+ Uses ``Path(root).resolve()`` so the produced absolute path matches
2135
+ what :func:`detect` returns (which also resolves the scan root).
2136
+ NFC both sides so a relative key and an NFD-resolved root still join
2137
+ to the same string form the rest of the manifest path uses (#2221).
2138
+ """
2139
+ p = Path(key)
2140
+ if p.is_absolute():
2141
+ return str(p)
2142
+ # NFC the joined result so an NFD-resolved root + relative key lands on
2143
+ # the same form load_manifest / detect_incremental compare against.
2144
+ return _nfc(str(Path(root).resolve() / p))
2145
+
2146
+
2147
+ def load_manifest(
2148
+ manifest_path: str = _MANIFEST_PATH,
2149
+ *,
2150
+ root: Path | None = None,
2151
+ ) -> dict:
2152
+ """Load the manifest from a previous run. Returns {} on any error.
2153
+
2154
+ When ``root`` is provided, stored relative keys are re-anchored against
2155
+ it so callers see absolute paths regardless of on-disk format. Legacy
2156
+ manifests with absolute keys pass through unchanged, so a graphify-out/
2157
+ written by an older version (or by a caller that didn't supply ``root``
2158
+ to :func:`save_manifest`) remains readable.
2159
+
2160
+ Keys are NFC-normalized on load so a manifest written under one Unicode
2161
+ form still matches a scan that yields the other (#2221).
2162
+ """
2163
+ try:
2164
+ raw = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
2165
+ except Exception:
2166
+ return {}
2167
+ if not isinstance(raw, dict):
2168
+ return raw
2169
+ if root is None:
2170
+ return {_nfc(k): v for k, v in raw.items()}
2171
+ return {_nfc(_to_absolute_from_storage(k, root)): v for k, v in raw.items()}
2172
+
2173
+
2174
+ def save_manifest(
2175
+ files: dict[str, list[str]],
2176
+ manifest_path: str = _MANIFEST_PATH,
2177
+ *,
2178
+ kind: str = "both",
2179
+ root: Path | None = None,
2180
+ scan_corpus: set[str] | list[str] | None = None,
2181
+ clear_semantic: set[str] | list[str] | None = None,
2182
+ clear_ast: set[str] | list[str] | None = None,
2183
+ ) -> None:
2184
+ """Save current file mtimes + content hashes for change detection.
2185
+
2186
+ kind="ast" — written by `graphify update` (AST-only rebuild). Stamps
2187
+ ast_hash; preserves an existing semantic_hash only when
2188
+ the file content is unchanged (mtime + hash match).
2189
+ kind="semantic" — written by `graphify extract` after semantic extraction.
2190
+ Stamps semantic_hash; preserves existing ast_hash.
2191
+ kind="both" — full pipeline: stamps both hashes (default).
2192
+
2193
+ When ``root`` is provided, keys are relativized against it before write
2194
+ (forward-slash, posix-style) so the on-disk manifest is portable across
2195
+ machines and checkout locations (#777). Out-of-root entries are written
2196
+ as absolute so they continue to round-trip on the saving machine.
2197
+ When ``root`` is None the legacy absolute-keyed format is preserved.
2198
+
2199
+ ``scan_corpus`` (#1908): full-scan callers pass the COMPLETE detect
2200
+ corpus (absolute paths) so seeded rows for in-root files that are still
2201
+ alive on disk but no longer part of the scan (newly excluded via
2202
+ .graphifyignore/.gitignore/--exclude) are dropped instead of surviving
2203
+ forever and masquerading as deletions in detect_incremental. It must be
2204
+ the RAW detect output, not a stamp-filtered subset — pruning to a
2205
+ filtered set would erase rows the filter merely omitted (failed chunks,
2206
+ --code-only doc rows). Out-of-root entries are never pruned. Callers
2207
+ saving a SUBSET of files (changed_paths hooks, skill runbooks, #917)
2208
+ must leave this None so their untouched rows are preserved.
2209
+
2210
+ ``clear_semantic`` (#1948): files that were dispatched this run but
2211
+ produced no stamped output (e.g. the LLM omitted their chunk on a
2212
+ --force re-run) are absent from ``files``, so the seed loop below would
2213
+ otherwise copy their prior semantic_hash verbatim — masking the omission
2214
+ and making detect_incremental(kind="semantic") report them unchanged.
2215
+ Pass the set of such files (any path form ``scan_corpus`` accepts) to
2216
+ force their seeded semantic_hash to "" instead of inheriting it.
2217
+
2218
+ ``clear_ast`` (#2543): same idea for AST failures (missing optional extra,
2219
+ zero-node anomalous extract). Blanks BOTH ``ast_hash`` and
2220
+ ``semantic_hash`` on the seeded row so either detect_incremental kind
2221
+ re-queues the file after the failure is fixed, without deleting
2222
+ graphify-out/.
2223
+ """
2224
+ existing = load_manifest(manifest_path, root=root)
2225
+
2226
+ # Index both raw and NFC forms so scan/clear membership survives the
2227
+ # same NFC/NFD mismatch that breaks manifest lookups (#2221).
2228
+ def _path_index(paths: set[str] | list[str] | None) -> set[str] | None:
2229
+ if paths is None:
2230
+ return None
2231
+ indexed: set[str] = set()
2232
+ for p in paths:
2233
+ indexed.add(p)
2234
+ indexed.add(_nfc(p))
2235
+ return indexed
2236
+
2237
+ scan_set = _path_index(scan_corpus)
2238
+ clear_set = _path_index(clear_semantic)
2239
+ clear_ast_set = _path_index(clear_ast)
2240
+ try:
2241
+ root_res: Path | None = Path(root).resolve() if root is not None else None
2242
+ except (OSError, RuntimeError):
2243
+ root_res = Path(root) if root is not None else None
2244
+
2245
+ def _in_scan(path_str: str) -> bool:
2246
+ if path_str in scan_set or _nfc(path_str) in scan_set:
2247
+ return True
2248
+ try:
2249
+ resolved = str(Path(path_str).resolve())
2250
+ return resolved in scan_set or _nfc(resolved) in scan_set
2251
+ except (OSError, RuntimeError):
2252
+ return False
2253
+
2254
+ def _in_clear(path_str: str) -> bool:
2255
+ if clear_set is None:
2256
+ return False
2257
+ if path_str in clear_set or _nfc(path_str) in clear_set:
2258
+ return True
2259
+ try:
2260
+ resolved = str(Path(path_str).resolve())
2261
+ return resolved in clear_set or _nfc(resolved) in clear_set
2262
+ except (OSError, RuntimeError):
2263
+ return False
2264
+
2265
+ def _in_clear_ast(path_str: str) -> bool:
2266
+ if clear_ast_set is None:
2267
+ return False
2268
+ if path_str in clear_ast_set or _nfc(path_str) in clear_ast_set:
2269
+ return True
2270
+ try:
2271
+ resolved = str(Path(path_str).resolve())
2272
+ return resolved in clear_ast_set or _nfc(resolved) in clear_ast_set
2273
+ except (OSError, RuntimeError):
2274
+ return False
2275
+
2276
+ def _in_root(path_str: str) -> bool:
2277
+ # Without a root we cannot tell in-root from out-of-root; fail open
2278
+ # (keep the row) so out-of-root corpora are never pruned by accident.
2279
+ if root_res is None:
2280
+ return False
2281
+ p = Path(path_str)
2282
+ try:
2283
+ p.relative_to(root_res)
2284
+ return True
2285
+ except ValueError:
2286
+ pass
2287
+ try:
2288
+ p.resolve().relative_to(root_res)
2289
+ return True
2290
+ except (ValueError, OSError, RuntimeError):
2291
+ return False
2292
+
2293
+ def _normalise_entry(entry):
2294
+ if isinstance(entry, (int, float)):
2295
+ return {"mtime": entry, "ast_hash": "", "semantic_hash": ""}
2296
+ if isinstance(entry, dict) and "hash" in entry and "ast_hash" not in entry:
2297
+ return {"mtime": entry.get("mtime", 0), "ast_hash": entry["hash"], "semantic_hash": ""}
2298
+ if isinstance(entry, dict):
2299
+ return entry
2300
+ return None
2301
+
2302
+ # Seed from the existing manifest so incremental callers passing a subset
2303
+ # of files don't silently erase entries for untouched files (#917).
2304
+ # Prune entries whose file no longer exists on disk — those are genuine
2305
+ # deletions that detect_incremental() should treat as gone. When the
2306
+ # caller supplied the full scan corpus, additionally prune in-root rows
2307
+ # the scan no longer covers: those files were excluded, not deleted, and
2308
+ # keeping the row makes them look deleted on every future run (#1908).
2309
+ manifest: dict[str, dict] = {}
2310
+ for f, entry in existing.items():
2311
+ normalised = _normalise_entry(entry)
2312
+ if normalised is None:
2313
+ continue
2314
+ try:
2315
+ if not Path(f).exists():
2316
+ continue
2317
+ except OSError:
2318
+ continue
2319
+ if scan_set is not None and not _in_scan(f) and _in_root(f):
2320
+ continue # excluded-but-alive: drop the stale row (#1908)
2321
+ if clear_ast_set is not None and _in_clear_ast(f):
2322
+ # AST failure this run (missing extra / zero nodes, #2543): blank
2323
+ # both hashes so either detect_incremental kind re-queues.
2324
+ normalised = {**normalised, "ast_hash": "", "semantic_hash": ""}
2325
+ elif clear_set is not None and _in_clear(f):
2326
+ # Dispatched-but-omitted this run: don't inherit the stale
2327
+ # semantic_hash, or detect_incremental would call it unchanged (#1948).
2328
+ normalised = {**normalised, "semantic_hash": ""}
2329
+ manifest[f] = normalised
2330
+
2331
+ all_files = [f for file_list in files.values() for f in file_list]
2332
+ with ThreadPoolExecutor() as pool:
2333
+ raw = pool.map(_stat_and_hash, all_files)
2334
+ hashed: dict[str, tuple[float, str]] = {
2335
+ r[0]: (r[1], r[2]) for r in raw if r is not None
2336
+ }
2337
+
2338
+ for f in all_files:
2339
+ if f not in hashed:
2340
+ continue # file deleted between detect() and manifest write
2341
+ mtime, h = hashed[f]
2342
+ key = _nfc(f)
2343
+ prev = _normalise_entry(existing.get(key, {})) or {}
2344
+ if kind in ("ast", "both"):
2345
+ ast_h = h
2346
+ else:
2347
+ ast_h = prev.get("ast_hash", "")
2348
+ if kind in ("semantic", "both"):
2349
+ sem_h = h
2350
+ else:
2351
+ # Preserve semantic_hash only when content is unchanged
2352
+ sem_h = prev.get("semantic_hash", "") if h == prev.get("ast_hash", "") else ""
2353
+
2354
+ # Preserve previous seen timestamp if the entry's mtime and target hash(es)
2355
+ # are genuinely unchanged and no clear was requested for this file.
2356
+ prev_seen = prev.get("seen")
2357
+ is_unchanged = (
2358
+ isinstance(prev_seen, (int, float))
2359
+ and mtime == prev.get("mtime")
2360
+ and (ast_h == prev.get("ast_hash", "") if kind in ("ast", "both") else True)
2361
+ and (sem_h == prev.get("semantic_hash", "") if kind in ("semantic", "both") else True)
2362
+ and not _in_clear_ast(f)
2363
+ and not _in_clear(f)
2364
+ )
2365
+ entry: dict = {
2366
+ "mtime": mtime,
2367
+ "seen": prev_seen if is_unchanged else time.time(),
2368
+ "ast_hash": ast_h,
2369
+ "semantic_hash": sem_h,
2370
+ }
2371
+ manifest[key] = entry
2372
+ if root is not None:
2373
+ # Persist in portable form: forward-slash relative paths. Keys outside
2374
+ # ``root`` (out-of-tree symlinked corpora, --include sources) keep
2375
+ # their absolute form so the manifest round-trips on the saving
2376
+ # machine even when not every entry can be portably encoded.
2377
+ # NFC after relativize so on-disk keys match what load_manifest
2378
+ # re-anchors and compares against (#2221).
2379
+ manifest = {_nfc(_to_relative_for_storage(k, root)): v for k, v in manifest.items()}
2380
+ else:
2381
+ manifest = {_nfc(k): v for k, v in manifest.items()}
2382
+
2383
+ # Avoid rewriting manifest.json when the serialized payload is identical (#2838).
2384
+ manifest_p = Path(manifest_path)
2385
+ if manifest_p.is_file():
2386
+ try:
2387
+ disk_raw = json.loads(manifest_p.read_text(encoding="utf-8"))
2388
+ if isinstance(disk_raw, dict) and disk_raw == manifest:
2389
+ return
2390
+ except Exception:
2391
+ pass
2392
+
2393
+ from graphify.paths import write_json_atomic
2394
+ # Atomic write: a crash mid-write must not leave a truncated manifest that
2395
+ # detect_incremental then fails to parse.
2396
+ write_json_atomic(manifest_path, manifest, indent=2)
2397
+
2398
+
2399
+ def _mtime_may_hide_a_rewrite(current_mtime: float, stored: dict) -> bool:
2400
+ """Was this manifest row written in the same tick as the file it describes?
2401
+
2402
+ The incremental gate treats "mtime unchanged" as proof the content is
2403
+ unchanged. That is only true while the filesystem can distinguish the two
2404
+ writes: an edit keeping the file the same length and landing in the same
2405
+ timestamp tick moves neither size nor mtime, so the file silently skips
2406
+ re-extraction and the graph keeps serving the old content.
2407
+
2408
+ ``seen`` records when the row was stamped. If the file's mtime falls inside
2409
+ the same tick, this row cannot prove currency and the caller pays for one
2410
+ MD5. Every other row — the whole settled corpus, and any manifest written
2411
+ by an earlier run — keeps the free stat-only fastpath.
2412
+
2413
+ Rows predating ``seen`` are treated as safe: they necessarily come from an
2414
+ earlier process, where a later write would have had to move mtime.
2415
+ """
2416
+ seen = stored.get("seen")
2417
+ if not isinstance(seen, (int, float)):
2418
+ return False
2419
+ delta = float(seen) - float(current_mtime)
2420
+ if delta < 0:
2421
+ return False # file is newer than the row; the mtime check already fired
2422
+ # Derive granularity from the timestamp: a whole-second mtime means the
2423
+ # filesystem cannot separate writes inside that second.
2424
+ coarse = float(current_mtime).is_integer()
2425
+ return delta < (_MTIME_COARSE_S if coarse else _MTIME_SUBSECOND_S)
2426
+
2427
+
2428
+ def detect_incremental(
2429
+ root: Path,
2430
+ manifest_path: str = _MANIFEST_PATH,
2431
+ *,
2432
+ follow_symlinks: bool | None = None,
2433
+ google_workspace: bool | None = None,
2434
+ kind: str = "semantic",
2435
+ extra_excludes: list[str] | None = None,
2436
+ gitignore: bool = True,
2437
+ ) -> dict:
2438
+ """Like detect(), but returns only new or modified files since the last run.
2439
+
2440
+ kind="semantic" (default for extract): a file is "changed" when its
2441
+ semantic_hash is missing or its content has changed since the last
2442
+ semantic extraction pass. Use this for `graphify extract` so that
2443
+ files touched by `graphify update` (AST-only) are re-extracted
2444
+ semantically.
2445
+ kind="ast": a file is "changed" when its ast_hash is missing or its
2446
+ content has changed. Use this for `graphify update`.
2447
+
2448
+ Fast path: mtime unchanged + hash matches → unchanged (free, no disk IO
2449
+ beyond stat). Slow path: mtime bumped → compare MD5 against the relevant
2450
+ hash field before re-extracting.
2451
+
2452
+ Backwards compatible with legacy manifests storing plain float mtime values
2453
+ or {mtime, hash} dicts (treated as ast_hash only; semantic_hash = miss).
2454
+
2455
+ The ``follow_symlinks`` flag is forwarded to :func:`detect` so in-root
2456
+ symlinked sub-trees are scanned consistently between full and incremental
2457
+ runs. ``None`` (default) does not follow symlinked directories; callers must
2458
+ opt in explicitly, and resolved targets outside the scan root are skipped.
2459
+ """
2460
+ full = detect(
2461
+ root,
2462
+ follow_symlinks=follow_symlinks,
2463
+ google_workspace=google_workspace,
2464
+ extra_excludes=extra_excludes,
2465
+ gitignore=gitignore,
2466
+ )
2467
+ # Pass ``root`` so a manifest written with relative keys (post-#777) is
2468
+ # re-anchored to the absolute form the rest of this function compares
2469
+ # against. Legacy absolute-keyed manifests pass through unchanged.
2470
+ manifest = load_manifest(manifest_path, root=root)
2471
+
2472
+ if not manifest:
2473
+ # No previous run - treat everything as new
2474
+ full["incremental"] = True
2475
+ full["new_files"] = full["files"]
2476
+ full["unchanged_files"] = {k: [] for k in full["files"]}
2477
+ full["new_total"] = full["total_files"]
2478
+ full["deleted_files"] = []
2479
+ full["excluded_files"] = []
2480
+ return full
2481
+
2482
+ new_files: dict[str, list[str]] = {k: [] for k in full["files"]}
2483
+ unchanged_files: dict[str, list[str]] = {k: [] for k in full["files"]}
2484
+
2485
+ for ftype, file_list in full["files"].items():
2486
+ for f in file_list:
2487
+ # Manifest keys are NFC; scan paths may arrive NFD (#2221).
2488
+ stored = manifest.get(_nfc(f))
2489
+ try:
2490
+ current_mtime = os.stat(_os_path(Path(f))).st_mtime
2491
+ except Exception:
2492
+ current_mtime = 0
2493
+
2494
+ # Legacy manifest: plain float value stores only mtime.
2495
+ # Compare with `!=` so backwards mtime motion (git checkout of an
2496
+ # older commit, tarball restore, rsync --times) still triggers a
2497
+ # re-extract; the previous `>` silently kept the stale cache and
2498
+ # the graph drifted from disk (#1859). No stored hash means we
2499
+ # cannot verify content — any mtime delta forces a re-extract,
2500
+ # and the next save promotes the entry into the dict schema.
2501
+ if isinstance(stored, (int, float)):
2502
+ changed = current_mtime != stored
2503
+ elif isinstance(stored, dict):
2504
+ # Normalise legacy {mtime, hash} to new schema
2505
+ if "hash" in stored and "ast_hash" not in stored:
2506
+ stored = {"mtime": stored.get("mtime", 0), "ast_hash": stored["hash"], "semantic_hash": ""}
2507
+ hash_key = "semantic_hash" if kind == "semantic" else "ast_hash"
2508
+ stored_hash = stored.get(hash_key, "")
2509
+ # Missing semantic_hash means update ran but extract hasn't — always re-extract
2510
+ if not stored_hash:
2511
+ changed = True
2512
+ else:
2513
+ stored_mtime = stored.get("mtime")
2514
+ # Schema-drift guard (#1163): tolerate a nested {mtime: ...}
2515
+ # dict or any non-numeric value without crashing.
2516
+ if isinstance(stored_mtime, dict):
2517
+ stored_mtime = stored_mtime.get("mtime")
2518
+ if not isinstance(stored_mtime, (int, float)):
2519
+ stored_mtime = None
2520
+ if stored_mtime is None or current_mtime != stored_mtime:
2521
+ # mtime bumped — verify with content hash before re-extracting
2522
+ changed = _md5_file(Path(f)) != stored_hash
2523
+ elif _mtime_may_hide_a_rewrite(current_mtime, stored):
2524
+ # mtime is unchanged, but it was recorded in the same
2525
+ # filesystem tick the file was written in — a later
2526
+ # same-length edit lands in that tick without moving
2527
+ # mtime, and the file silently skips re-extraction
2528
+ # while the graph keeps serving the old content.
2529
+ # Only this narrow window pays for a content hash.
2530
+ changed = _md5_file(Path(f)) != stored_hash
2531
+ else:
2532
+ changed = False
2533
+ else:
2534
+ changed = True # unknown format, re-extract to be safe
2535
+
2536
+ if changed:
2537
+ new_files[ftype].append(f)
2538
+ else:
2539
+ unchanged_files[ftype].append(f)
2540
+
2541
+ # Manifest rows that left the corpus, split by disk existence (#1908):
2542
+ # a row whose file is gone from DISK is a genuine deletion (its cached
2543
+ # nodes are ghosts); a row whose file still exists but is out of the
2544
+ # current scan was EXCLUDED (ignore rules / --exclude changed) and must
2545
+ # not be reported as deleted. Mirrors the watch-side excluded-vs-deleted
2546
+ # distinction (#1795).
2547
+ current_files = {_nfc(f) for flist in full["files"].values() for f in flist}
2548
+ deleted_files: list[str] = []
2549
+ excluded_files: list[str] = []
2550
+ for f in manifest:
2551
+ if _nfc(f) in current_files:
2552
+ continue
2553
+ try:
2554
+ alive = Path(f).exists()
2555
+ except OSError:
2556
+ alive = False
2557
+ (excluded_files if alive else deleted_files).append(f)
2558
+
2559
+ new_total = sum(len(v) for v in new_files.values())
2560
+ full["incremental"] = True
2561
+ full["new_files"] = new_files
2562
+ full["unchanged_files"] = unchanged_files
2563
+ full["new_total"] = new_total
2564
+ full["deleted_files"] = deleted_files
2565
+ full["excluded_files"] = excluded_files
2566
+ return full