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.
- {graphite_code-0.4.0 → graphite_code-0.5.1}/CHANGELOG.md +134 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/PKG-INFO +1 -1
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/__init__.py +41 -41
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/activation.py +3 -1
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/extract/ast.py +206 -29
- graphite_code-0.5.1/src/graphite/io.py +76 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/overlays.py +2 -1
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/conftest.py +202 -146
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_activation.py +20 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_arrow_and_constructor_binding.py +172 -172
- graphite_code-0.5.1/tests/test_atomic_write.py +168 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_file_set.py +110 -110
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_call_graph.py +932 -932
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_class_field_arrow_binding.py +130 -130
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_destructured_binding.py +183 -183
- graphite_code-0.5.1/tests/test_dispatch_evidence.py +277 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_external_calls.py +548 -539
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_go_rust.py +73 -32
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hardening.py +12 -12
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_method_dispatch_scope.py +294 -290
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_monorepo.py +199 -199
- graphite_code-0.5.1/tests/test_node_identity.py +139 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_overlays.py +19 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_python_resolver.py +22 -22
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_storage.py +2421 -2408
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_typescript_resolver.py +274 -274
- graphite_code-0.4.0/src/graphite/io.py +0 -32
- {graphite_code-0.4.0 → graphite_code-0.5.1}/.gitignore +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/ARCHITECTURE.md +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/CONTRIBUTING.md +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/LICENSE +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/README.md +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/RELEASING.md +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/pyproject.toml +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/__main__.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/_cleanup_worker.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/agent_hooks.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/agent_settings.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/analyze.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/answer_contract.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/bootstrap.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/buildlock.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cache.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/channel.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cli.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/cluster.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/config.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/context.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/daemon.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/daemon_health.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/debt.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/dependency_install.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/detach.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/doctor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/doctor_probes.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/engine_identity.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/__init__.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/html.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/json.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/export/md.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/extract/__init__.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/freshness.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/git.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/graph.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/graph_io.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/health.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hook_entry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hookinstall.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/hookshim.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/incident_ledger.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ingest.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/init.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/listing.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/llm.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/llm_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/mcp.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/mcp_server.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/natural_query.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/probe_process.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/probe_workspace.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/process_contracts.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/provider_observer.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/query.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/query_plan.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/replacement_audit.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/resolve.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/review.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/__init__.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/approval.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/classifier.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/claude_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/claude_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/cli_identity.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/codex_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/codex_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/context_builder.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/contracts.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/diff_policy.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/edit_apply.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/effort.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_operator.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_service.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/lifecycle_storage.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/ollama_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/ollama_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/openrouter_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/openrouter_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/policy.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/probe_runner.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/process_runner.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/profiles.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/prompt.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/registry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/route_pool.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/route_pool_execution.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/schema_validation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/service.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/settings.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/shadow.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/storage.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/telemetry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/worktree.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_edit.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/routing/zai_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/savings.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ts_bridge.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/ts_resolver.mjs +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/typescript_activation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/usage_ledger.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/validation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/watch.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_job.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_startup.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/src/graphite/windows_task.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/data/sqloracle/blindspots.py.txt +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/data/sqloracle/round69.py.txt +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fake_clis/fake_cli.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_activation_backstop.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_hook_activation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_hooks.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_agent_settings.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_answer_contract.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_bootstrap.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_build_detach.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_buildlock.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_engine_identity.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cache_pruning.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_cli.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_core.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_lock.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_mcp.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_register.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_report.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_channel_status.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cli_channel.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cli_version.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_context.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_cwd_relative_config.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_activation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_build_lock.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_engine_staleness.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_health.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_daemon_health_activation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_debt.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_determinism.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_agent_hooks.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_hooks.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_doctor_managed_docs.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_documentation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_edit_apply.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_engine_identity.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_git_security.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_graph_io.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_graph_provider_isolation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_health.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hook_entry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hook_template.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hookinstall.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_hookshim.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_html_security.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_incident_ledger.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_activation_doctrine.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_hooks.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_init_validation_exit.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_lifecycle_operator.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_listing.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_listing_surfaces.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_llm.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_mcp.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_natural_query.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_path_leak_fixture.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_probe_diagnostics.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_probe_workspace.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_claude_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_codex_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle_service.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_lifecycle_storage.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_observer.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_ollama_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_openrouter_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_provider_probe_runner.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_public_surface.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_published_schemas.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_query_plan.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_reliability.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_replacement_audit.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_resolve.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_review.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_route_pool.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_approval.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_benchmark.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_classifier.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_claude_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli_contracts.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_cli_recovery.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_codex_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_context.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_contracts.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_diff_policy.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_openrouter_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_policy.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_process_runner.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_profiles.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_registry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_schema_validation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_security.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_service.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_shadow.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_telemetry.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_worktree.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_zai_executor.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_routing_zai_probe.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_savings.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_search.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_smoke.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_sqloracle.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_typescript_activation.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_usage_ledger.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_verify_artifact.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_verify_published_release.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_watch.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_windows_startup.py +0 -0
- {graphite_code-0.4.0 → graphite_code-0.5.1}/tests/test_windows_task.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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)
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
2006
|
-
#
|
|
2007
|
-
#
|
|
2008
|
-
#
|
|
2009
|
-
#
|
|
2010
|
-
# the
|
|
2011
|
-
#
|
|
2012
|
-
#
|
|
2013
|
-
#
|
|
2014
|
-
#
|
|
2015
|
-
#
|
|
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)
|