graphite-code 0.4.0__tar.gz → 0.5.1__tar.gz

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 (256) hide show
  1. {graphite_code-0.4.0 → graphite_code-0.5.1}/CHANGELOG.md +134 -0
  2. {graphite_code-0.4.0 → graphite_code-0.5.1}/PKG-INFO +1 -1
  3. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/__init__.py +41 -41
  4. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/activation.py +3 -1
  5. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/extract/ast.py +206 -29
  6. graphite_code-0.5.1/src/graphite/io.py +76 -0
  7. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/overlays.py +2 -1
  8. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/conftest.py +202 -146
  9. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_activation.py +20 -0
  10. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_arrow_and_constructor_binding.py +172 -172
  11. graphite_code-0.5.1/tests/test_atomic_write.py +168 -0
  12. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_file_set.py +110 -110
  13. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_call_graph.py +932 -932
  14. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_class_field_arrow_binding.py +130 -130
  15. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_destructured_binding.py +183 -183
  16. graphite_code-0.5.1/tests/test_dispatch_evidence.py +277 -0
  17. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_external_calls.py +548 -539
  18. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_go_rust.py +73 -32
  19. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hardening.py +12 -12
  20. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_method_dispatch_scope.py +294 -290
  21. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_monorepo.py +199 -199
  22. graphite_code-0.5.1/tests/test_node_identity.py +139 -0
  23. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_overlays.py +19 -0
  24. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_python_resolver.py +22 -22
  25. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_storage.py +2421 -2408
  26. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_typescript_resolver.py +274 -274
  27. graphite_code-0.4.0/src/graphite/io.py +0 -32
  28. {graphite_code-0.4.0 → graphite_code-0.5.1}/.gitignore +0 -0
  29. {graphite_code-0.4.0 → graphite_code-0.5.1}/ARCHITECTURE.md +0 -0
  30. {graphite_code-0.4.0 → graphite_code-0.5.1}/CONTRIBUTING.md +0 -0
  31. {graphite_code-0.4.0 → graphite_code-0.5.1}/LICENSE +0 -0
  32. {graphite_code-0.4.0 → graphite_code-0.5.1}/README.md +0 -0
  33. {graphite_code-0.4.0 → graphite_code-0.5.1}/RELEASING.md +0 -0
  34. {graphite_code-0.4.0 → graphite_code-0.5.1}/pyproject.toml +0 -0
  35. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/__main__.py +0 -0
  36. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/_cleanup_worker.py +0 -0
  37. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/agent_hooks.py +0 -0
  38. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/agent_settings.py +0 -0
  39. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/analyze.py +0 -0
  40. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/answer_contract.py +0 -0
  41. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/bootstrap.py +0 -0
  42. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/buildlock.py +0 -0
  43. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cache.py +0 -0
  44. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/channel.py +0 -0
  45. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cli.py +0 -0
  46. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cluster.py +0 -0
  47. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/config.py +0 -0
  48. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/context.py +0 -0
  49. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/daemon.py +0 -0
  50. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/daemon_health.py +0 -0
  51. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/debt.py +0 -0
  52. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/dependency_install.py +0 -0
  53. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/detach.py +0 -0
  54. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/doctor.py +0 -0
  55. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/doctor_probes.py +0 -0
  56. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/engine_identity.py +0 -0
  57. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/__init__.py +0 -0
  58. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/html.py +0 -0
  59. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/json.py +0 -0
  60. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/md.py +0 -0
  61. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/extract/__init__.py +0 -0
  62. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/freshness.py +0 -0
  63. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/git.py +0 -0
  64. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/graph.py +0 -0
  65. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/graph_io.py +0 -0
  66. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/health.py +0 -0
  67. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hook_entry.py +0 -0
  68. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hookinstall.py +0 -0
  69. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hookshim.py +0 -0
  70. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/incident_ledger.py +0 -0
  71. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ingest.py +0 -0
  72. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/init.py +0 -0
  73. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/listing.py +0 -0
  74. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/llm.py +0 -0
  75. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/llm_probe.py +0 -0
  76. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/mcp.py +0 -0
  77. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/mcp_server.py +0 -0
  78. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/natural_query.py +0 -0
  79. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/probe_process.py +0 -0
  80. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/probe_workspace.py +0 -0
  81. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/process_contracts.py +0 -0
  82. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/provider_observer.py +0 -0
  83. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/query.py +0 -0
  84. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/query_plan.py +0 -0
  85. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/replacement_audit.py +0 -0
  86. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/resolve.py +0 -0
  87. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/review.py +0 -0
  88. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/__init__.py +0 -0
  89. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/approval.py +0 -0
  90. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/classifier.py +0 -0
  91. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/claude_executor.py +0 -0
  92. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/claude_probe.py +0 -0
  93. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/cli_identity.py +0 -0
  94. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/codex_executor.py +0 -0
  95. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/codex_probe.py +0 -0
  96. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/context_builder.py +0 -0
  97. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/contracts.py +0 -0
  98. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/diff_policy.py +0 -0
  99. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/edit_apply.py +0 -0
  100. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/effort.py +0 -0
  101. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle.py +0 -0
  102. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_operator.py +0 -0
  103. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_service.py +0 -0
  104. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_storage.py +0 -0
  105. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/ollama_executor.py +0 -0
  106. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/ollama_probe.py +0 -0
  107. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/openrouter_executor.py +0 -0
  108. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/openrouter_probe.py +0 -0
  109. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/policy.py +0 -0
  110. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/probe_runner.py +0 -0
  111. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/process_runner.py +0 -0
  112. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/profiles.py +0 -0
  113. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/prompt.py +0 -0
  114. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/registry.py +0 -0
  115. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/route_pool.py +0 -0
  116. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/route_pool_execution.py +0 -0
  117. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/schema_validation.py +0 -0
  118. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/service.py +0 -0
  119. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/settings.py +0 -0
  120. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/shadow.py +0 -0
  121. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/storage.py +0 -0
  122. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/telemetry.py +0 -0
  123. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/worktree.py +0 -0
  124. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_edit.py +0 -0
  125. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_executor.py +0 -0
  126. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_probe.py +0 -0
  127. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/savings.py +0 -0
  128. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ts_bridge.py +0 -0
  129. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ts_resolver.mjs +0 -0
  130. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/typescript_activation.py +0 -0
  131. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/usage_ledger.py +0 -0
  132. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/validation.py +0 -0
  133. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/watch.py +0 -0
  134. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_job.py +0 -0
  135. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_startup.py +0 -0
  136. {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_task.py +0 -0
  137. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/data/sqloracle/blindspots.py.txt +0 -0
  138. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/data/sqloracle/round69.py.txt +0 -0
  139. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fake_clis/fake_cli.py +0 -0
  140. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
  141. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
  142. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
  143. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
  144. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_activation_backstop.py +0 -0
  145. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_hook_activation.py +0 -0
  146. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_hooks.py +0 -0
  147. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_settings.py +0 -0
  148. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_answer_contract.py +0 -0
  149. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_bootstrap.py +0 -0
  150. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_build_detach.py +0 -0
  151. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_buildlock.py +0 -0
  152. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_engine_identity.py +0 -0
  153. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_pruning.py +0 -0
  154. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_cli.py +0 -0
  155. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_core.py +0 -0
  156. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_lock.py +0 -0
  157. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_mcp.py +0 -0
  158. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_register.py +0 -0
  159. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_report.py +0 -0
  160. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_status.py +0 -0
  161. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cli_channel.py +0 -0
  162. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cli_version.py +0 -0
  163. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_context.py +0 -0
  164. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cwd_relative_config.py +0 -0
  165. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon.py +0 -0
  166. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_activation.py +0 -0
  167. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_build_lock.py +0 -0
  168. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_engine_staleness.py +0 -0
  169. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_health.py +0 -0
  170. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_health_activation.py +0 -0
  171. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_debt.py +0 -0
  172. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_determinism.py +0 -0
  173. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor.py +0 -0
  174. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_agent_hooks.py +0 -0
  175. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_hooks.py +0 -0
  176. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_managed_docs.py +0 -0
  177. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_documentation.py +0 -0
  178. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_edit_apply.py +0 -0
  179. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_engine_identity.py +0 -0
  180. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_git_security.py +0 -0
  181. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_graph_io.py +0 -0
  182. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_graph_provider_isolation.py +0 -0
  183. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_health.py +0 -0
  184. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hook_entry.py +0 -0
  185. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hook_template.py +0 -0
  186. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hookinstall.py +0 -0
  187. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hookshim.py +0 -0
  188. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_html_security.py +0 -0
  189. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_incident_ledger.py +0 -0
  190. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init.py +0 -0
  191. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_activation_doctrine.py +0 -0
  192. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_hooks.py +0 -0
  193. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_validation_exit.py +0 -0
  194. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_lifecycle_operator.py +0 -0
  195. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_listing.py +0 -0
  196. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_listing_surfaces.py +0 -0
  197. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_llm.py +0 -0
  198. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_mcp.py +0 -0
  199. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_natural_query.py +0 -0
  200. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_path_leak_fixture.py +0 -0
  201. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_probe_diagnostics.py +0 -0
  202. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_probe_workspace.py +0 -0
  203. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_claude_probe.py +0 -0
  204. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_codex_probe.py +0 -0
  205. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle.py +0 -0
  206. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle_service.py +0 -0
  207. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle_storage.py +0 -0
  208. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_observer.py +0 -0
  209. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_ollama_probe.py +0 -0
  210. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_openrouter_probe.py +0 -0
  211. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_probe_runner.py +0 -0
  212. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_public_surface.py +0 -0
  213. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_published_schemas.py +0 -0
  214. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_query_plan.py +0 -0
  215. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_reliability.py +0 -0
  216. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_replacement_audit.py +0 -0
  217. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_resolve.py +0 -0
  218. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_review.py +0 -0
  219. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_route_pool.py +0 -0
  220. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_approval.py +0 -0
  221. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_benchmark.py +0 -0
  222. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_classifier.py +0 -0
  223. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_claude_executor.py +0 -0
  224. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli.py +0 -0
  225. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli_contracts.py +0 -0
  226. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli_recovery.py +0 -0
  227. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_codex_executor.py +0 -0
  228. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_context.py +0 -0
  229. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_contracts.py +0 -0
  230. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_diff_policy.py +0 -0
  231. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_executor.py +0 -0
  232. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_openrouter_executor.py +0 -0
  233. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_policy.py +0 -0
  234. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_process_runner.py +0 -0
  235. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_profiles.py +0 -0
  236. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_registry.py +0 -0
  237. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_schema_validation.py +0 -0
  238. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_security.py +0 -0
  239. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_service.py +0 -0
  240. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_shadow.py +0 -0
  241. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_telemetry.py +0 -0
  242. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_worktree.py +0 -0
  243. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_zai_executor.py +0 -0
  244. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_zai_probe.py +0 -0
  245. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_savings.py +0 -0
  246. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_search.py +0 -0
  247. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_smoke.py +0 -0
  248. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_sqloracle.py +0 -0
  249. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_typescript_activation.py +0 -0
  250. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_usage_ledger.py +0 -0
  251. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_verify_artifact.py +0 -0
  252. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_verify_published_release.py +0 -0
  253. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_watch.py +0 -0
  254. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_windows_startup.py +0 -0
  255. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_windows_task.py +0 -0
  256. {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_zai_edit.py +0 -0
@@ -12,6 +12,140 @@ machine-checkable identity; the version is for humans.
12
12
 
13
13
  [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
14
14
 
15
+ ## [0.5.1] — 2026-08-27
16
+
17
+ ### Fixed
18
+
19
+ **A reader holding the graph open made the writer fail on Windows** (#59).
20
+ `MoveFileEx` cannot replace a file another handle holds open without
21
+ `FILE_SHARE_DELETE`, and neither `open()` nor `os.open()` grants that share
22
+ mode — so graphite's own readers (`graph_io`, the MCP server, a hook running
23
+ `query`) made `atomic_write_text`'s single `os.replace` raise `WinError 5` for
24
+ the length of a read. Three occurrences in 1,585 daemon builds; the daemon
25
+ masked each by rebuilding next cycle, while a hook- or agent-driven `build`
26
+ exited 1 and left the previous graph in place. The rename is now retried with
27
+ bounded backoff (2 s) on Windows only — on POSIX an EACCES from rename is the
28
+ directory's permissions, which no wait changes. Reproduced by a test that holds
29
+ the target open in a thread: red on the old writer, green now.
30
+
31
+ The writer's cleanup path could also **mask the error that mattered**: a temp
32
+ file held by an indexer made `unlink` raise inside `finally: raise`, so the one
33
+ `WinError 32` in the log named only the temp file and nothing else. Cleanup
34
+ failures are now suppressed and the original error propagates.
35
+
36
+ The same retry now backs every temp-file-then-rename writer in graphite, not
37
+ only the graph's: `activation._atomic_write` (the marker every hook refreshes
38
+ and the daemon reads each discovery cycle — a lost refresh was silent, because
39
+ `mark_active` is fail-open) and `overlays._atomic_write_secure`, through one
40
+ public `io.replace_file`. Neither had been observed failing; both had the
41
+ same single `os.replace`.
42
+
43
+ ## [0.5.0] — 2026-08-26
44
+
45
+ ### Fixed
46
+
47
+ **Distinct definitions collapsed into one node id** (#57, #58). Two independent
48
+ mechanisms, one silent symptom: the losing definition vanished from the graph
49
+ entirely, every edge naming either landed on whichever survived, and nothing in
50
+ the answer signalled it — a contaminated `callers` list grades exactly like a
51
+ clean one, and an empty one for the vanished name reads as a trustworthy absence.
52
+
53
+ `_make_id` had **five** lossy operations — `strip("_.")` per part, `[^\w]+ -> _`,
54
+ `_+ -> _`, `casefold()` and truncation — so within one file `Path`, `path` and
55
+ `_path` were one id. Measured on this repo: **5 collisions, 6 definitions absent**.
56
+ `routing/storage.py` defines `initialize` (L950) and `_initialize` (L3965); the
57
+ graph kept `initialize`, and `self._initialize()` was recorded as a call to it.
58
+
59
+ Removing a normalisation does not fix this, which is worth knowing before trying:
60
+ drop the strip and `path`/`_path` are *still* merged, because the `_+` collapse
61
+ puts them back; drop both and `path`/`Path` remain merged by the casefold. An
62
+ ambiguous input now gets a short **hex** discriminator — the one alphabet that
63
+ survives both. A canonical input keeps its plain, readable id.
64
+
65
+ `_file_node_id` built its id from `path.stem`, **discarding the extension**, so
66
+ `index.ts` and `index.js`, or a component and its stylesheet, were one node — the
67
+ shape that most affects the TypeScript consumers. It also inherited the
68
+ sanitisation collisions: `src/graphite/init.py` (791 lines) and
69
+ `src/graphite/__init__.py` (41) were one node, so the larger module had no file
70
+ node at all and its 21 symbols hung off the smaller one. Both are fixed; file ids
71
+ now carry the full filename.
72
+
73
+ **Every node id in the graph changes.** Consumers re-extract on the engine
74
+ fingerprint anyway, so the rebuild costs nothing extra — but any node id written
75
+ down in a document or a script must be re-read from the new graph. Both
76
+ docstrings on `_file_node_id` claimed the old scheme kept every file distinct;
77
+ both were false and are rewritten rather than appended to.
78
+
79
+ Known residual, measured at zero occurrences here: a path separator and a literal
80
+ underscore are still indistinguishable, so `a/b.py` and `a_b.py` would collide.
81
+ Closing it would mean discriminating every path containing an underscore.
82
+
83
+
84
+ **Method dispatch overruled the call classifier, in every language** (#56).
85
+ `_call_confidence` tags an edge `EXTERNAL_CALL` only when the call *provably*
86
+ leaves the repo — an attributable receiver root bound by an import that did not
87
+ resolve in-repo, or an `_EXTERNAL_GLOBALS` name no in-repo binding shadows.
88
+ `_resolve_method_dispatch` then re-pointed that same edge to an in-repo definition
89
+ by bare method name alone, producing an edge whose `confidence` said the call left
90
+ the repo and whose `target` was a function inside it.
91
+
92
+ The #54 reachability gate cannot catch these: the import that makes the definition
93
+ "reachable" is real and irrelevant, because the receiver is `os`. Externality is
94
+ evidence that gate never consults.
95
+
96
+ Measured on this repo, two builds through one interpreter differing only in this
97
+ pass: **42 `calls` edges removed, every one `EXTERNAL_CALL`, none `LOCAL_CALL`.**
98
+ Every removed edge read at its source line is a standard-library call —
99
+ `os.close`/`os.open`/`os.write`/`os.kill` (24), `subprocess.run` (8),
100
+ `threading.Event` (4), Node's `path.resolve` (2), `sys.stdin.read` (1).
101
+ `os.kill(pid, 0)` in one test was bound to **three** different in-repo `kill`s.
102
+
103
+ callers resolve 5 decision_grade → 3 decision_grade
104
+ callers kill 4 decision_grade → 1 decision_grade
105
+ callers event 6 decision_grade → 2 decision_grade
106
+ callers run 3 decision_grade → 0 ADVISORY
107
+
108
+ The last line is the honest-answer contract working: the answer is now empty *and
109
+ says so*, rather than confidently naming three wrong callers.
110
+
111
+ It is a **refusal to re-point, not a deletion**. The edge stays where it already
112
+ pointed, so `health.py` can go on excluding it from the resolution denominator —
113
+ it can only exclude what is present. **The ratio therefore does not move**
114
+ (`calls.python` 0.977 → 0.977, total 9427 → 9387, external 3044 → 3076): under
115
+ schema 3 these edges were already excluded as external, so the fix shifts them
116
+ from `total` to `external` in the same step. Grading this on health would have
117
+ read as "no effect" — the trap #54's first acceptance criterion fell into,
118
+ arriving from the other direction.
119
+
120
+ **Dispatch could cross a language boundary** (#56). No FFI is modelled, so a
121
+ JavaScript call site cannot reach a Python `def` — but the name match ran across
122
+ the whole node set with no notion of language, and did:
123
+ `path.resolve(input.root)` in `src/graphite/ts_resolver.mjs` bound to a `resolve`
124
+ defined in `tests/test_git_security.py`, at `decision_grade`. Dispatch is now
125
+ confined to one interop **family** (`javascript`/`typescript`/`jsx`/`tsx` together;
126
+ every other language its own), never to the raw `LANGUAGE_BY_EXT` label — `.ts`
127
+ and `.tsx` are different labels and one ecosystem. Unlike the #54 gate this is an
128
+ exact invariant rather than a proxy, so it needs no per-language census: it removes
129
+ only bindings that are impossible. **Zero marginal effect on this repo** — both
130
+ crossings here were also `EXTERNAL_CALL` and already gone — which is why its
131
+ fixture makes the crossing a `LOCAL_CALL`.
132
+
133
+ **A Rust call on an unnameable receiver was a false external** (#56).
134
+ `_simple_rust_value` returns `None` for anything but an identifier or `self`, so
135
+ `build().format()` reached `_call_confidence` as the bare name `format`, which is
136
+ in `_EXTERNAL_GLOBALS`. The Rust site passed no `attributable=`, so the edge came
137
+ out `EXTERNAL_CALL` — excused from the health denominator without ever being shown
138
+ to leave the repo. That is #14 mechanism A, which Python and TS/JS were threaded
139
+ for and Rust was not. Go is deliberately unchanged: its `selector_expression`
140
+ branch builds the call name from the operand's raw source text, so nothing here
141
+ reaches its bare-name fallback, and a guard nothing can trip is not a guard.
142
+
143
+ ### Still open
144
+
145
+ The #54 reachability gate remains Python-only. That is a separate, weaker
146
+ question — bindings that are *possible but unlikely* in duck-typed code — and it
147
+ still owes the per-language measurement `_dispatch_is_gated` describes.
148
+
15
149
  ## [0.4.0] — 2026-08-14
16
150
 
17
151
  **A minor bump because consumer graphs change, not because an API did.** Both fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphite-code
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Local-first, zero-LLM knowledge graph extraction for codebases.
5
5
  Project-URL: Homepage, https://github.com/jared0565/graphite
6
6
  Project-URL: Repository, https://github.com/jared0565/graphite
@@ -1,41 +1,41 @@
1
- """Graphite — local-first, zero-LLM knowledge graph extraction.
2
-
3
- **The supported surface is the CLI, the MCP server, and the published JSON
4
- schemas — not this package's Python API.**
5
-
6
- That is a deliberate boundary, not an omission. `docs/agent-integration.md` is
7
- the consumer contract and every example in it is `graphite <command> --json`;
8
- the payload shapes are pinned in `docs/schemas/` and held in lockstep with live
9
- output by tests. Nothing documented asks a consumer to import anything from
10
- here, so `__all__` stays at the one name that is genuinely public.
11
-
12
- Importing internals (`graphite.query`, `graphite.routing.*`, …) is therefore
13
- supported by nobody: those move without notice, and an editable install means
14
- they move the moment a file is saved.
15
-
16
- The other half of the surface is a set of MODULE PATHS rather than objects.
17
- `graphite.mcp` is written into every onboarded repo's `.mcp.json` and
18
- `graphite.hook_entry` into its `.githooks/` trampolines, so renaming either
19
- breaks consumers that will never see the change — and because those artifacts
20
- are already on disk, fixing the generator afterwards does not repair them. They
21
- are pinned by `tests/test_public_surface.py` for that reason.
22
- """
23
- from __future__ import annotations
24
-
25
- #: The only object this package promises. See the module docstring for why.
26
- __all__ = ["__version__"]
27
-
28
- #: THE version. `pyproject.toml` reads this file rather than the other way
29
- #: round (`[tool.hatch.version]`), so there is exactly one place to set it.
30
- #:
31
- #: Through 0.2.1 this was doubly load-bearing: every consumer on this machine
32
- #: ran graphite from one shared editable install, so the source tree WAS the
33
- #: deployment and a saved file was live everywhere with no build and no
34
- #: boundary. 0.3.0 is the first version consumers install as a built wheel, so
35
- #: this string and the installed metadata are now set together by a release
36
- #: rather than drifting apart between them.
37
- #:
38
- #: Coarse and hand-maintained by construction. It is a release label, not
39
- #: evidence that a given fix is present -- to answer that, survey for the marker
40
- #: the fix introduced, or compare `graphite --version` fingerprints.
41
- __version__ = "0.4.0"
1
+ """Graphite — local-first, zero-LLM knowledge graph extraction.
2
+
3
+ **The supported surface is the CLI, the MCP server, and the published JSON
4
+ schemas — not this package's Python API.**
5
+
6
+ That is a deliberate boundary, not an omission. `docs/agent-integration.md` is
7
+ the consumer contract and every example in it is `graphite <command> --json`;
8
+ the payload shapes are pinned in `docs/schemas/` and held in lockstep with live
9
+ output by tests. Nothing documented asks a consumer to import anything from
10
+ here, so `__all__` stays at the one name that is genuinely public.
11
+
12
+ Importing internals (`graphite.query`, `graphite.routing.*`, …) is therefore
13
+ supported by nobody: those move without notice, and an editable install means
14
+ they move the moment a file is saved.
15
+
16
+ The other half of the surface is a set of MODULE PATHS rather than objects.
17
+ `graphite.mcp` is written into every onboarded repo's `.mcp.json` and
18
+ `graphite.hook_entry` into its `.githooks/` trampolines, so renaming either
19
+ breaks consumers that will never see the change — and because those artifacts
20
+ are already on disk, fixing the generator afterwards does not repair them. They
21
+ are pinned by `tests/test_public_surface.py` for that reason.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ #: The only object this package promises. See the module docstring for why.
26
+ __all__ = ["__version__"]
27
+
28
+ #: THE version. `pyproject.toml` reads this file rather than the other way
29
+ #: round (`[tool.hatch.version]`), so there is exactly one place to set it.
30
+ #:
31
+ #: Through 0.2.1 this was doubly load-bearing: every consumer on this machine
32
+ #: ran graphite from one shared editable install, so the source tree WAS the
33
+ #: deployment and a saved file was live everywhere with no build and no
34
+ #: boundary. 0.3.0 is the first version consumers install as a built wheel, so
35
+ #: this string and the installed metadata are now set together by a release
36
+ #: rather than drifting apart between them.
37
+ #:
38
+ #: Coarse and hand-maintained by construction. It is a release label, not
39
+ #: evidence that a given fix is present -- to answer that, survey for the marker
40
+ #: the fix introduced, or compare `graphite --version` fingerprints.
41
+ __version__ = "0.5.1"
@@ -21,6 +21,8 @@ from dataclasses import dataclass
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
24
+ from .io import replace_file
25
+
24
26
  ACTIVATION_TTL_SECONDS: float = 3600.0
25
27
  ENV_STATE_DIR = "GRAPHITE_STATE_DIR"
26
28
  ENV_DAEMON_CHILD = "GRAPHITE_DAEMON_CHILD"
@@ -148,7 +150,7 @@ def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
148
150
  try:
149
151
  with os.fdopen(handle, "w", encoding="utf-8") as fh:
150
152
  json.dump(payload, fh)
151
- os.replace(tmp, path)
153
+ replace_file(tmp, path)
152
154
  except Exception:
153
155
  try:
154
156
  tmp.unlink(missing_ok=True)
@@ -1,13 +1,14 @@
1
1
  """Deterministic structural extraction via tree-sitter."""
2
2
  from __future__ import annotations
3
3
 
4
+ import hashlib
4
5
  import importlib
5
6
  import re
6
7
  import sys
7
8
  import unicodedata
8
9
  from dataclasses import dataclass, field
9
10
  from pathlib import Path
10
- from typing import Any, Collection, Final
11
+ from typing import Any, Collection, Final, Sequence
11
12
 
12
13
  from ..cache import Cache
13
14
  from ..config import Config
@@ -153,28 +154,100 @@ _RESOLVER_LANGUAGES: Final = frozenset({"python", "javascript", "typescript", "t
153
154
  _MAX_ID_LEN = 120
154
155
 
155
156
 
157
+ #: Length, in hex characters, of the ambiguity discriminator appended by
158
+ #: `_make_id`. Hex is the only alphabet that survives BOTH normalisations that
159
+ #: caused #57: `_+ -> _` cannot collapse it (it holds no underscores) and
160
+ #: `casefold()` cannot flatten it (it holds no case). A marker built from
161
+ #: anything else would be eaten by the very code it exists to defeat.
162
+ _ID_DISCRIMINATOR_LEN: Final = 6
163
+
164
+
165
+ def _identity_preserving_form(parts: Sequence[str]) -> str:
166
+ """The id these parts would produce if the only loss were separator identity.
167
+
168
+ Applies `[^\\w] -> _` one character at a time — deliberately NOT per run, so
169
+ `a.b` and `a..b` stay distinct here even though the real pipeline collapses
170
+ both. Everything else the sanitiser does is skipped.
171
+
172
+ Comparing the real id against this answers the only question that matters:
173
+ did the sanitiser throw away something that distinguished this input from a
174
+ different one? If it did, the id is ambiguous and needs a discriminator.
175
+ """
176
+ return "_".join(
177
+ re.sub(r"[^\w]", "_", unicodedata.normalize("NFKC", p), flags=re.UNICODE)
178
+ for p in parts
179
+ )
180
+
181
+
156
182
  def _make_id(*parts: str) -> str:
157
- combined = "_".join(p.strip("_.") for p in parts if p)
158
- combined = unicodedata.normalize("NFKC", combined)
183
+ """Node id for a file or a symbol, ambiguous inputs discriminated (#57).
184
+
185
+ The sanitiser has FIVE lossy operations — `strip("_.")` per part,
186
+ `[^\\w]+ -> _`, `_+ -> _`, `casefold()` and truncation — and each of them
187
+ merged real definitions. Measured on this repo before the fix: 5 same-file
188
+ collisions and 6 definitions absent from the graph, including
189
+ `routing/storage.py`'s `initialize` (L950) swallowing `_initialize` (L3965),
190
+ so `self._initialize()` was recorded as a call to an unrelated method.
191
+
192
+ Removing a normalisation does not fix this, which is worth knowing before
193
+ trying: drop the strip and `path`/`_path` are STILL merged, because the
194
+ `_+` collapse two lines down puts them back together; drop both and
195
+ `path`/`Path` remain merged by the casefold. Only an appended discriminator
196
+ works, and only one made of hex.
197
+
198
+ A canonical input keeps its plain, readable id — the discriminator is for
199
+ ambiguity, not decoration, and hashing everything would cost a far larger
200
+ migration for no extra correctness.
201
+
202
+ Known residual: two DIFFERENT part tuples can still produce one id when the
203
+ only difference is a separator versus a literal underscore — `a/b.py` and
204
+ `a_b.py` both reach `a_b_py`, and both are ambiguity-free by this test.
205
+ Closing it would mean discriminating every path containing an underscore,
206
+ which is most of `tests/`. Measured occurrences in this repo: zero.
207
+ """
208
+ kept = [p for p in parts if p]
209
+ combined = unicodedata.normalize("NFKC", "_".join(p.strip("_.") for p in kept))
159
210
  cleaned = re.sub(r"[^\w]+", "_", combined, flags=re.UNICODE)
160
211
  cleaned = re.sub(r"_+", "_", cleaned)
161
212
  cleaned = cleaned.strip("_").casefold()
162
- if len(cleaned) > _MAX_ID_LEN:
163
- cleaned = cleaned[:_MAX_ID_LEN]
164
- return cleaned
213
+ if cleaned == _identity_preserving_form(kept) and len(cleaned) <= _MAX_ID_LEN:
214
+ return cleaned
215
+ # Hashed over the exact input with a separator no name can contain, so the
216
+ # marker distinguishes ("a_b", "c") from ("a", "b_c") even though both
217
+ # sanitise alike.
218
+ marker = hashlib.blake2s(
219
+ "\x00".join(kept).encode("utf-8"), digest_size=_ID_DISCRIMINATOR_LEN // 2
220
+ ).hexdigest()
221
+ return f"{cleaned[: _MAX_ID_LEN - len(marker) - 1].rstrip('_')}_{marker}"
165
222
 
166
223
 
167
224
  def _file_node_id(rel_path: str) -> str:
168
- """Stable node id for a file: a slug of the FULL repo-relative path.
169
-
170
- Using only the parent dir + stem (the pre-v4 scheme) silently merged any
171
- two files sharing that tail a monorepo with `apps/worker/src/db/queries.ts`
172
- and `apps/workers/booking/src/db/queries.ts` got ONE `db_queries` node and
173
- every symbol in both files collided. Full-path ids keep every file (and
174
- therefore every symbol id derived from the file id) distinct.
225
+ """Stable node id for a file: a slug of the FULL repo-relative path AND name.
226
+
227
+ Two claims this docstring used to make were false, and both were disproved
228
+ on graphite's own source rather than in theory.
229
+
230
+ It said full-path ids "keep every file distinct". They did not. `ARAMID.md`
231
+ and `aramid.toml` both reached `aramid` (case), and `src/graphite/init.py`
232
+ and `src/graphite/__init__.py` both reached `src_graphite_init` (underscore
233
+ strip plus collapse) — so a 791-line module had NO file node of its own and
234
+ its 21 symbols hung off a 41-line `__init__.py`. `_make_id`'s discriminator
235
+ is what fixes that half.
236
+
237
+ It also built the id from `path.stem`, silently DISCARDING the extension, so
238
+ `index.ts` and `index.js`, or `Button.tsx` and its `Button.css`, were one
239
+ node (#58). That is not a sanitisation loss and no discriminator could fix
240
+ it — the two inputs were already identical by the time `_make_id` saw them.
241
+ The extension is part of a file's identity and is now kept.
242
+
243
+ The original reason for full-path ids stands: parent-dir + stem merged any
244
+ two files sharing that tail, so a monorepo with `apps/worker/src/db/queries.ts`
245
+ and `apps/workers/booking/src/db/queries.ts` got ONE `db_queries` node.
175
246
  """
176
247
  path = Path(rel_path)
177
- parts = [*path.parts[:-1], path.stem]
248
+ # `path.parts` already ends in the full filename; the old scheme replaced
249
+ # that last element with `path.stem` and lost the suffix with it.
250
+ parts = list(path.parts)
178
251
  return _make_id(".".join(p for p in parts if p not in (".", "")))
179
252
 
180
253
 
@@ -1675,9 +1748,17 @@ def _extract_rust(
1675
1748
  if func is not None:
1676
1749
  called: str | None
1677
1750
  member: str | None = None
1751
+ # False when `called` degrades to the bare method name because the
1752
+ # receiver could not be named -- `build().format()`, `(a + b).test()`.
1753
+ # Such a name says nothing about where the call goes, so classifying
1754
+ # it against _EXTERNAL_GLOBALS produces a false external (#14
1755
+ # mechanism A, threaded for Python at :1414 and TS/JS at :474 and
1756
+ # missed here until #56).
1757
+ attributable = True
1678
1758
  if func.type == "field_expression":
1679
1759
  value = _simple_rust_value(func.child_by_field_name("value"), _text)
1680
1760
  field = _text(func.child_by_field_name("field"))
1761
+ attributable = bool(value and field)
1681
1762
  called = _short_name(f"{value}.{field}" if value and field else field)
1682
1763
  member = field
1683
1764
  elif func.type == "scoped_identifier":
@@ -1691,7 +1772,10 @@ def _extract_rust(
1691
1772
  else:
1692
1773
  called = _call_target_name(func, source)
1693
1774
  if called and called not in _LANGUAGE_BUILTIN_GLOBALS and should_keep_call_target(called):
1694
- edge = _edge(scope_id, _resolve_call(file_id, called), "calls", rel_path, _line(node), confidence=_call_confidence(called))
1775
+ edge = _edge(
1776
+ scope_id, _resolve_call(file_id, called), "calls", rel_path, _line(node),
1777
+ confidence=_call_confidence(called, attributable=attributable),
1778
+ )
1695
1779
  if member:
1696
1780
  edge["_member"] = member
1697
1781
  result.edges.append(edge)
@@ -1887,10 +1971,58 @@ _MAX_METHOD_DISPATCH_CANDIDATES = 3
1887
1971
  # whose imports bind to a file node".
1888
1972
  _DISPATCH_GATED_LANGUAGES = frozenset({"python"})
1889
1973
 
1974
+ # Languages that share one runtime and can therefore hold one another's objects.
1975
+ # Dispatch may cross freely INSIDE a family and never BETWEEN two of them.
1976
+ #
1977
+ # Only entries that MERGE distinct `LANGUAGE_BY_EXT` labels belong here; anything
1978
+ # absent is its own family (see `_interop_family`), so a new extractor is isolated
1979
+ # by default rather than silently pooled with an existing ecosystem.
1980
+ _INTEROP_FAMILY_BY_LANGUAGE: Final = {
1981
+ "javascript": "ecmascript",
1982
+ "typescript": "ecmascript",
1983
+ "jsx": "ecmascript",
1984
+ "tsx": "ecmascript",
1985
+ }
1986
+
1987
+
1988
+ def _interop_family(source_file: str | None) -> str | None:
1989
+ """Which call-compatible ecosystem this file belongs to, or None if unknown.
1990
+
1991
+ An EXACT invariant, unlike the reachability proxy in `_dispatch_is_gated`:
1992
+ graphite models no FFI, so a JavaScript call site cannot reach a Python `def`
1993
+ under any typing discipline. That is why this gate needs no per-language
1994
+ measurement before being applied everywhere -- it removes only bindings that
1995
+ are impossible, never ones that are merely unlikely.
1996
+
1997
+ Scoped to a FAMILY rather than to the raw language label because
1998
+ `LANGUAGE_BY_EXT` maps `.ts -> typescript` and `.tsx -> tsx`: a plain label
1999
+ comparison would delete every call from a `.ts` module into a `.tsx`
2000
+ component, which is ordinary code in every React repo graphite is pointed at.
2001
+
2002
+ Resolved through `LANGUAGE_BY_EXT` for the same reason `_dispatch_is_gated` is
2003
+ -- a private suffix list would agree with the extractor only by coincidence.
2004
+
2005
+ FAILS OPEN on `None`: a file whose extension the extractor does not classify
2006
+ has no family, and dispatch from or to it is left exactly as it was.
2007
+ """
2008
+ if not source_file:
2009
+ return None
2010
+ language = LANGUAGE_BY_EXT.get(Path(source_file).suffix.casefold())
2011
+ if language is None:
2012
+ return None
2013
+ return _INTEROP_FAMILY_BY_LANGUAGE.get(language, language)
2014
+
1890
2015
 
1891
2016
  def _dispatch_is_gated(source_file: str | None) -> bool:
1892
2017
  """Whether method dispatch from this file is restricted to reachable definitions.
1893
2018
 
2019
+ ONE OF THREE FILTERS, and the only optional one. Do not read "not gated here"
2020
+ as "dispatch from this file is unconstrained": the evidence gate (never
2021
+ re-point a proven-EXTERNAL_CALL) and the interop-family gate (never re-point
2022
+ across `_interop_family`) both apply in EVERY language, gated or not. They are
2023
+ exact -- an externality proof and a "no FFI is modelled" invariant -- so they
2024
+ needed no per-language measurement. This one is a proxy, which is why it does.
2025
+
1894
2026
  Resolved through `LANGUAGE_BY_EXT` -- the same table `collect_files` uses to pick
1895
2027
  an extractor -- so the gate cannot disagree with the walk that produced the edge.
1896
2028
  A private suffix list here would agree with that table only by coincidence: `.py`
@@ -1908,12 +2040,14 @@ def _dispatch_is_gated(source_file: str | None) -> bool:
1908
2040
  every sampled member was a false binding, and added back ~350 real ones that the
1909
2041
  ambiguity cap had been abandoning.
1910
2042
 
1911
- TypeScript/JavaScript are NOT gated, on evidence rather than caution.
1912
- `test_member_call_ambiguous_small_set_links_to_all` pins the documented
2043
+ TypeScript/JavaScript are NOT gated BY THIS FILTER, on evidence rather than
2044
+ caution. `test_member_call_ambiguous_small_set_links_to_all` pins the documented
1913
2045
  small-ambiguous-set fan-out using `x: any` and no import at all -- a shape that
1914
2046
  is idiomatic in TS and that this gate would delete. Rust is ungated for the
1915
2047
  duller reason that this repo contains none, so nothing here could measure it.
1916
2048
  Extending the allowlist is a per-language measurement, not a one-line edit.
2049
+ (The two exact filters above still constrain TS/JS dispatch -- #56 removed a
2050
+ `.mjs` call bound to a Python `def` without any help from this gate.)
1917
2051
 
1918
2052
  Go could not be gated even with evidence: its in-repo imports target a
1919
2053
  synthesized PACKAGE id (`example_com_repo_store`) that never equals the file
@@ -1963,6 +2097,10 @@ def _resolve_method_dispatch(
1963
2097
  file_of_node: dict[str, str] = {
1964
2098
  n["id"]: _file_node_id(n["source_file"]) for n in nodes if n.get("source_file")
1965
2099
  }
2100
+ # Which interop family each definition belongs to, for the family gate below.
2101
+ family_of_node: dict[str, str | None] = {
2102
+ n["id"]: _interop_family(n["source_file"]) for n in nodes if n.get("source_file")
2103
+ }
1966
2104
  imports_by_file: dict[str, set[str]] = {}
1967
2105
  for e in edges:
1968
2106
  if e.get("relation") == "imports" and e.get("source") and e.get("target"):
@@ -1983,7 +2121,43 @@ def _resolve_method_dispatch(
1983
2121
  if not method or e.get("relation") != "calls":
1984
2122
  out.append(e)
1985
2123
  continue
1986
- candidates = methods_by_name.get(method.casefold())
2124
+ # Evidence gate (#56), applied BEFORE the name lookup and in every language.
2125
+ # `_call_confidence` tags EXTERNAL_CALL only when the call PROVABLY leaves
2126
+ # the repo: an attributable receiver root bound by an import that did not
2127
+ # resolve in-repo, or an `_EXTERNAL_GLOBALS` name no in-repo binding shadows.
2128
+ # An unnameable receiver returns LOCAL_CALL precisely so a bare method name
2129
+ # can never be classified external (#14 mechanism A). Re-pointing such an
2130
+ # edge by bare method name overrules evidence with a guess -- and did, 52
2131
+ # times on graphite's own graph: `os.close(fd)` bound to an in-repo `close`,
2132
+ # `sys.stdin.read()` to `cache.py::read`, `os.kill(pid, 0)` to three
2133
+ # different in-repo `kill`s, `path.resolve()` in a .mjs file to a `resolve`
2134
+ # defined in a PYTHON test.
2135
+ #
2136
+ # The #54 gate below cannot catch these: the import that makes the
2137
+ # definition "reachable" is real and irrelevant, because the receiver is
2138
+ # `os`. Externality is evidence that gate never consults.
2139
+ if e.get("confidence") == "EXTERNAL_CALL":
2140
+ candidates: set[str] | None = None
2141
+ else:
2142
+ candidates = methods_by_name.get(method.casefold())
2143
+ # Interop-family gate (#56). An EXACT invariant rather than a proxy:
2144
+ # graphite models no FFI, so a JavaScript call site cannot reach a Python
2145
+ # `def` under any typing discipline. Measured live before this existed --
2146
+ # `path.resolve(input.root)` in `src/graphite/ts_resolver.mjs` bound to a
2147
+ # `resolve` defined in `tests/test_git_security.py`, and reported at
2148
+ # decision_grade.
2149
+ #
2150
+ # Applies in EVERY language, gated or not, because it removes only bindings
2151
+ # that are impossible; the per-language census the reachability gate below
2152
+ # still waits on is about bindings that are merely unlikely. Fails open on
2153
+ # either side: an unclassified extension has no family and is left alone.
2154
+ if candidates:
2155
+ caller_family = _interop_family(e.get("source_file"))
2156
+ if caller_family is not None:
2157
+ candidates = {
2158
+ c for c in candidates
2159
+ if family_of_node.get(c) in (None, caller_family)
2160
+ }
1987
2161
  # Reachability gate (#54). A name-only match let `Path(...).resolve()` bind
1988
2162
  # to a test double's `resolve`, and `f.write(text)` to a `write` defined in
1989
2163
  # a test file -- 232 and 30 false callers respectively, graded
@@ -2002,17 +2176,20 @@ def _resolve_method_dispatch(
2002
2176
  if file_of_node.get(c) == caller_file or file_of_node.get(c) in reachable
2003
2177
  }
2004
2178
  if not candidates or len(candidates) > _MAX_METHOD_DISPATCH_CANDIDATES:
2005
- # Member call that resolves to no known definition. Keep it when
2006
- # the target is a real node, or when the call was already
2007
- # classified EXTERNAL_CALL. That classification does NOT require
2008
- # an attributable receiver: _call_confidence tests the call's
2009
- # classified root, and _call_target_name (:603-619) falls back to
2010
- # the bare method name whenever _simple_object_name (:585-600)
2011
- # can't stringify the receiver -- so a member call with an
2012
- # unresolvable receiver is ALSO kept when its bare method name
2013
- # alone collides with _EXTERNAL_GLOBALS (e.g. `/re/.test(x)`,
2014
- # `"{}".format(x)`), even though the receiver was never proven
2015
- # external.
2179
+ # Member call that resolves to no known definition -- or one the
2180
+ # evidence gate above refused to resolve. Keep it when the target
2181
+ # is a real node, or when the call was classified EXTERNAL_CALL.
2182
+ #
2183
+ # The EXTERNAL_CALL arm is what makes the evidence gate a REFUSAL TO
2184
+ # RE-POINT rather than a deletion: the edge stays, pointing where it
2185
+ # already pointed, so health.py can go on excluding it from the
2186
+ # resolution denominator. It can only exclude what is present.
2187
+ #
2188
+ # (This comment used to justify that arm by saying EXTERNAL_CALL "does
2189
+ # NOT require an attributable receiver". That was already false when it
2190
+ # was written: _call_confidence:139 returns LOCAL_CALL when
2191
+ # `attributable` is False, which is #14 mechanism A. The Rust site was
2192
+ # the one place the argument still held, and it was threaded in fefe350.)
2016
2193
  #
2017
2194
  # Everything else still DROPS: `c.json()`, `db.prepare()`,
2018
2195
  # `stmt.bind()` -- unattributable receivers whose bare method name
@@ -0,0 +1,76 @@
1
+ """Safe file-writing helpers for Graphite artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ # `MoveFileEx` cannot replace a file that another handle holds open without
13
+ # FILE_SHARE_DELETE, and neither `open()` nor `os.open()` grants that share
14
+ # mode. So on Windows every graphite reader -- graph_io, the MCP server, a hook
15
+ # running `query` -- makes `os.replace` fail with WinError 5 for the length of
16
+ # its read. Seen three times in the daemon log; the daemon masks it by retrying
17
+ # next cycle, a hook- or agent-driven `build` fails outright. Readers hold the
18
+ # file for milliseconds: wait them out, bounded. On POSIX an EACCES from rename
19
+ # is the directory's permissions, which no wait will change, so the retry is
20
+ # gated there rather than merely harmless.
21
+ _RETRY_REPLACE_ON_ACCESS_DENIED = os.name == "nt"
22
+ _REPLACE_RETRY_SECONDS = 2.0
23
+ _REPLACE_RETRY_INITIAL_DELAY = 0.01
24
+ _REPLACE_RETRY_MAX_DELAY = 0.2
25
+ # Module-local aliases so tests can drive the loop without freezing the
26
+ # process-wide clock (#45).
27
+ _monotonic = time.monotonic
28
+ _sleep = time.sleep
29
+
30
+
31
+ def replace_file(src: Path, dst: Path) -> None:
32
+ """`os.replace`, waiting out a reader that holds `dst` open on Windows.
33
+
34
+ Every temp-file-then-rename writer in graphite goes through here so the
35
+ retry lives in one place; see the module comment for why it exists.
36
+ """
37
+ if not _RETRY_REPLACE_ON_ACCESS_DENIED:
38
+ os.replace(src, dst)
39
+ return
40
+ deadline = _monotonic() + _REPLACE_RETRY_SECONDS
41
+ delay = _REPLACE_RETRY_INITIAL_DELAY
42
+ while True:
43
+ try:
44
+ os.replace(src, dst)
45
+ return
46
+ except PermissionError:
47
+ if _monotonic() >= deadline:
48
+ raise
49
+ _sleep(delay)
50
+ delay = min(delay * 2, _REPLACE_RETRY_MAX_DELAY)
51
+
52
+
53
+ def atomic_write_text(path: Path, text: str) -> None:
54
+ """Atomically replace a text file after fsyncing the temporary file."""
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
57
+ tmp_path = Path(tmp_name)
58
+ try:
59
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
60
+ f.write(text)
61
+ f.flush()
62
+ os.fsync(f.fileno())
63
+ replace_file(tmp_path, path)
64
+ except Exception:
65
+ # The cleanup's own failure must not replace the error that matters:
66
+ # a WinError 32 on the temp file once stood in for whatever the
67
+ # replace raised, and the log line named only the temp file.
68
+ with contextlib.suppress(OSError):
69
+ tmp_path.unlink(missing_ok=True)
70
+ raise
71
+
72
+
73
+ def atomic_write_json(path: Path, data: Any, *, indent: int | None = 2) -> None:
74
+ """Atomically write JSON with deterministic UTF-8 encoding."""
75
+ text = json.dumps(data, ensure_ascii=False, indent=indent)
76
+ atomic_write_text(path, text)