attocode-code-intel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Blob-OID helpers — stable content-addressed identifiers for source files.
|
|
2
|
+
|
|
3
|
+
Every code-intel artifact that used to key on a file path (learnings,
|
|
4
|
+
ADRs, frecency) should really key on *the content of that file* — so a
|
|
5
|
+
rename doesn't orphan it, a vendored copy dedups across projects, and
|
|
6
|
+
git's own object store is the source of truth when available.
|
|
7
|
+
|
|
8
|
+
This module gives the rest of the codebase a uniform interface for
|
|
9
|
+
computing those identifiers, regardless of whether the project is a git
|
|
10
|
+
repo or a plain directory:
|
|
11
|
+
|
|
12
|
+
- ``"git:<sha1>"`` inside a git repo (computed via
|
|
13
|
+
``git hash-object``, which matches what
|
|
14
|
+
``git ls-files --stage`` would show)
|
|
15
|
+
- ``"sha256:<hex>"`` for non-git projects or for stdin-typed
|
|
16
|
+
content that has no on-disk path
|
|
17
|
+
|
|
18
|
+
The module also exposes:
|
|
19
|
+
|
|
20
|
+
- :func:`compute_blob_oids_batch` — fast bulk mode using
|
|
21
|
+
``git hash-object --stdin-paths``
|
|
22
|
+
- :func:`check_reachability` — given a set of git blob OIDs,
|
|
23
|
+
returns the subset that are still reachable from any ref
|
|
24
|
+
- :func:`is_git_repo` — cheap test
|
|
25
|
+
- A small SQLite ``blob_oid_cache`` keyed by ``(path, mtime)`` so
|
|
26
|
+
repeat calls on unchanged files are O(1)
|
|
27
|
+
|
|
28
|
+
All git subprocess calls are best-effort: if git isn't installed or the
|
|
29
|
+
repo is broken, we silently fall back to SHA-256.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import sqlite3
|
|
38
|
+
import subprocess
|
|
39
|
+
import threading
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Constants
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
BLOB_OID_CACHE_DIR = os.path.join(".attocode", "cache")
|
|
50
|
+
BLOB_OID_CACHE_FILE = "blob_oid_cache.db"
|
|
51
|
+
BLOB_OID_SCHEMA_VERSION = 1
|
|
52
|
+
|
|
53
|
+
# Subprocess timeouts (seconds). Git operations are expected to be fast
|
|
54
|
+
# but we don't want a stuck subprocess to freeze the whole MCP server.
|
|
55
|
+
_GIT_TIMEOUT_SECONDS = 10.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Cache
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class BlobOidCache:
|
|
65
|
+
"""SQLite-backed (path, mtime) → blob_oid cache.
|
|
66
|
+
|
|
67
|
+
Path is stored relative to the project directory so the same cache
|
|
68
|
+
file remains valid if the project is moved.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
project_dir: str
|
|
72
|
+
db_path: str = field(default="", repr=False)
|
|
73
|
+
_conn: sqlite3.Connection | None = field(default=None, repr=False)
|
|
74
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
if not self.db_path:
|
|
78
|
+
cache_dir = os.path.join(self.project_dir, BLOB_OID_CACHE_DIR)
|
|
79
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
80
|
+
self.db_path = os.path.join(cache_dir, BLOB_OID_CACHE_FILE)
|
|
81
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
82
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
83
|
+
self._create_tables()
|
|
84
|
+
|
|
85
|
+
def _create_tables(self) -> None:
|
|
86
|
+
assert self._conn is not None
|
|
87
|
+
self._conn.executescript(
|
|
88
|
+
"""
|
|
89
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
90
|
+
key TEXT PRIMARY KEY,
|
|
91
|
+
value TEXT NOT NULL
|
|
92
|
+
);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
94
|
+
rel_path TEXT PRIMARY KEY,
|
|
95
|
+
mtime REAL NOT NULL,
|
|
96
|
+
size INTEGER NOT NULL,
|
|
97
|
+
blob_oid TEXT NOT NULL,
|
|
98
|
+
updated_at REAL NOT NULL
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS ix_entries_oid ON entries(blob_oid);
|
|
101
|
+
"""
|
|
102
|
+
)
|
|
103
|
+
self._conn.execute(
|
|
104
|
+
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
105
|
+
(str(BLOB_OID_SCHEMA_VERSION),),
|
|
106
|
+
)
|
|
107
|
+
self._conn.commit()
|
|
108
|
+
|
|
109
|
+
def get(self, rel_path: str, mtime: float, size: int) -> str | None:
|
|
110
|
+
assert self._conn is not None
|
|
111
|
+
with self._lock:
|
|
112
|
+
row = self._conn.execute(
|
|
113
|
+
"SELECT blob_oid FROM entries WHERE rel_path = ? AND mtime = ? AND size = ?",
|
|
114
|
+
(rel_path, mtime, size),
|
|
115
|
+
).fetchone()
|
|
116
|
+
return row[0] if row else None
|
|
117
|
+
|
|
118
|
+
def put(self, rel_path: str, mtime: float, size: int, blob_oid: str) -> None:
|
|
119
|
+
import time as _time
|
|
120
|
+
assert self._conn is not None
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._conn.execute(
|
|
123
|
+
"""INSERT OR REPLACE INTO entries
|
|
124
|
+
(rel_path, mtime, size, blob_oid, updated_at)
|
|
125
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
126
|
+
(rel_path, mtime, size, blob_oid, _time.time()),
|
|
127
|
+
)
|
|
128
|
+
self._conn.commit()
|
|
129
|
+
|
|
130
|
+
def clear(self) -> int:
|
|
131
|
+
assert self._conn is not None
|
|
132
|
+
with self._lock:
|
|
133
|
+
cur = self._conn.execute("DELETE FROM entries")
|
|
134
|
+
self._conn.commit()
|
|
135
|
+
return cur.rowcount
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
if self._conn is not None:
|
|
139
|
+
self._conn.close()
|
|
140
|
+
self._conn = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Git detection
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_git_repo(project_dir: str) -> bool:
|
|
149
|
+
"""True if ``project_dir`` (or any parent) is inside a git work-tree.
|
|
150
|
+
|
|
151
|
+
Uses ``git rev-parse --is-inside-work-tree`` rather than checking for
|
|
152
|
+
a literal ``.git`` directory so submodules, worktrees, and ``.git``
|
|
153
|
+
file redirections all resolve correctly.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
158
|
+
cwd=project_dir,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
162
|
+
check=False,
|
|
163
|
+
)
|
|
164
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
165
|
+
return False
|
|
166
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Single / batch compute
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def compute_blob_oid(
|
|
175
|
+
path: str,
|
|
176
|
+
project_dir: str,
|
|
177
|
+
*,
|
|
178
|
+
cache: BlobOidCache | None = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Return the blob OID for one file.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
path: Absolute or project-relative path.
|
|
184
|
+
project_dir: Project root. Used to normalize ``path`` for caching
|
|
185
|
+
and as the ``cwd`` for git subprocess calls.
|
|
186
|
+
cache: Optional :class:`BlobOidCache` for memoization. If given,
|
|
187
|
+
hits/misses are consulted and populated.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
``"git:<sha1>"`` if ``project_dir`` is a git repo and the file is
|
|
191
|
+
readable; ``"sha256:<hex>"`` otherwise. Returns
|
|
192
|
+
``"sha256:missing:<path>"`` if the file can't be opened — callers
|
|
193
|
+
can treat that as a sentinel rather than crashing the index walk.
|
|
194
|
+
"""
|
|
195
|
+
# Normalize to absolute + relative forms.
|
|
196
|
+
if os.path.isabs(path):
|
|
197
|
+
abs_path = path
|
|
198
|
+
try:
|
|
199
|
+
rel_path = os.path.relpath(path, project_dir)
|
|
200
|
+
except ValueError:
|
|
201
|
+
rel_path = path
|
|
202
|
+
else:
|
|
203
|
+
rel_path = path
|
|
204
|
+
abs_path = os.path.join(project_dir, path)
|
|
205
|
+
|
|
206
|
+
# Missing file → sentinel.
|
|
207
|
+
try:
|
|
208
|
+
stat = os.stat(abs_path)
|
|
209
|
+
except OSError:
|
|
210
|
+
return f"sha256:missing:{rel_path}"
|
|
211
|
+
|
|
212
|
+
mtime = stat.st_mtime
|
|
213
|
+
size = stat.st_size
|
|
214
|
+
|
|
215
|
+
# Cache hit?
|
|
216
|
+
if cache is not None:
|
|
217
|
+
cached = cache.get(rel_path, mtime, size)
|
|
218
|
+
if cached is not None:
|
|
219
|
+
return cached
|
|
220
|
+
|
|
221
|
+
oid = _compute_blob_oid_uncached(abs_path, project_dir)
|
|
222
|
+
|
|
223
|
+
if cache is not None:
|
|
224
|
+
cache.put(rel_path, mtime, size, oid)
|
|
225
|
+
return oid
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _compute_blob_oid_uncached(abs_path: str, project_dir: str) -> str:
|
|
229
|
+
"""Single-file blob OID without cache consultation."""
|
|
230
|
+
if is_git_repo(project_dir):
|
|
231
|
+
try:
|
|
232
|
+
result = subprocess.run(
|
|
233
|
+
["git", "hash-object", "--", abs_path],
|
|
234
|
+
cwd=project_dir,
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
238
|
+
check=False,
|
|
239
|
+
)
|
|
240
|
+
if result.returncode == 0:
|
|
241
|
+
sha = result.stdout.strip()
|
|
242
|
+
if sha:
|
|
243
|
+
return f"git:{sha}"
|
|
244
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
245
|
+
logger.debug("git hash-object failed for %s: %s", abs_path, exc)
|
|
246
|
+
|
|
247
|
+
# Fallback — SHA-256 the raw bytes.
|
|
248
|
+
try:
|
|
249
|
+
with open(abs_path, "rb") as f:
|
|
250
|
+
h = hashlib.sha256()
|
|
251
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
252
|
+
h.update(chunk)
|
|
253
|
+
return f"sha256:{h.hexdigest()}"
|
|
254
|
+
except OSError:
|
|
255
|
+
return "sha256:unreadable"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def compute_blob_oids_batch(
|
|
259
|
+
paths: list[str],
|
|
260
|
+
project_dir: str,
|
|
261
|
+
*,
|
|
262
|
+
cache: BlobOidCache | None = None,
|
|
263
|
+
) -> dict[str, str]:
|
|
264
|
+
"""Compute blob OIDs for many files in one shot.
|
|
265
|
+
|
|
266
|
+
If ``project_dir`` is a git repo, dispatches a single
|
|
267
|
+
``git hash-object --stdin-paths`` subprocess call — ~100x faster than
|
|
268
|
+
calling :func:`compute_blob_oid` in a loop. Cache hits are resolved
|
|
269
|
+
locally before the subprocess so unchanged files don't cost anything.
|
|
270
|
+
|
|
271
|
+
Returns a mapping from the input path (as given, not necessarily
|
|
272
|
+
canonicalized) to the computed OID.
|
|
273
|
+
"""
|
|
274
|
+
results: dict[str, str] = {}
|
|
275
|
+
uncached: list[tuple[str, str, float, int]] = [] # (input_path, abs, mtime, size)
|
|
276
|
+
|
|
277
|
+
for path in paths:
|
|
278
|
+
if os.path.isabs(path):
|
|
279
|
+
abs_path = path
|
|
280
|
+
try:
|
|
281
|
+
rel_path = os.path.relpath(path, project_dir)
|
|
282
|
+
except ValueError:
|
|
283
|
+
rel_path = path
|
|
284
|
+
else:
|
|
285
|
+
rel_path = path
|
|
286
|
+
abs_path = os.path.join(project_dir, path)
|
|
287
|
+
try:
|
|
288
|
+
stat = os.stat(abs_path)
|
|
289
|
+
except OSError:
|
|
290
|
+
results[path] = f"sha256:missing:{rel_path}"
|
|
291
|
+
continue
|
|
292
|
+
if cache is not None:
|
|
293
|
+
cached = cache.get(rel_path, stat.st_mtime, stat.st_size)
|
|
294
|
+
if cached is not None:
|
|
295
|
+
results[path] = cached
|
|
296
|
+
continue
|
|
297
|
+
uncached.append((path, abs_path, stat.st_mtime, stat.st_size))
|
|
298
|
+
|
|
299
|
+
if not uncached:
|
|
300
|
+
return results
|
|
301
|
+
|
|
302
|
+
# Git batch mode if available.
|
|
303
|
+
if is_git_repo(project_dir):
|
|
304
|
+
try:
|
|
305
|
+
stdin_text = "\n".join(abs_p for _, abs_p, _, _ in uncached) + "\n"
|
|
306
|
+
result = subprocess.run(
|
|
307
|
+
["git", "hash-object", "--stdin-paths"],
|
|
308
|
+
cwd=project_dir,
|
|
309
|
+
input=stdin_text,
|
|
310
|
+
capture_output=True,
|
|
311
|
+
text=True,
|
|
312
|
+
timeout=max(_GIT_TIMEOUT_SECONDS, len(uncached) * 0.1),
|
|
313
|
+
check=False,
|
|
314
|
+
)
|
|
315
|
+
if result.returncode == 0:
|
|
316
|
+
out_lines = result.stdout.splitlines()
|
|
317
|
+
if len(out_lines) == len(uncached):
|
|
318
|
+
for (input_path, abs_path, mtime, size), sha in zip(
|
|
319
|
+
uncached, out_lines, strict=True,
|
|
320
|
+
):
|
|
321
|
+
oid = f"git:{sha.strip()}"
|
|
322
|
+
results[input_path] = oid
|
|
323
|
+
if cache is not None:
|
|
324
|
+
try:
|
|
325
|
+
rel = os.path.relpath(abs_path, project_dir)
|
|
326
|
+
except ValueError:
|
|
327
|
+
rel = input_path
|
|
328
|
+
cache.put(rel, mtime, size, oid)
|
|
329
|
+
return results
|
|
330
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
331
|
+
logger.debug("git hash-object --stdin-paths failed: %s", exc)
|
|
332
|
+
|
|
333
|
+
# Non-git fallback — compute sha256 per file.
|
|
334
|
+
for input_path, abs_path, mtime, size in uncached:
|
|
335
|
+
try:
|
|
336
|
+
with open(abs_path, "rb") as f:
|
|
337
|
+
h = hashlib.sha256()
|
|
338
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
339
|
+
h.update(chunk)
|
|
340
|
+
oid = f"sha256:{h.hexdigest()}"
|
|
341
|
+
except OSError:
|
|
342
|
+
oid = "sha256:unreadable"
|
|
343
|
+
results[input_path] = oid
|
|
344
|
+
if cache is not None:
|
|
345
|
+
try:
|
|
346
|
+
rel = os.path.relpath(abs_path, project_dir)
|
|
347
|
+
except ValueError:
|
|
348
|
+
rel = input_path
|
|
349
|
+
cache.put(rel, mtime, size, oid)
|
|
350
|
+
|
|
351
|
+
return results
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Reachability
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def check_reachability(
|
|
360
|
+
blob_oids: set[str],
|
|
361
|
+
project_dir: str,
|
|
362
|
+
) -> set[str]:
|
|
363
|
+
"""Return the subset of ``blob_oids`` that are still reachable in git.
|
|
364
|
+
|
|
365
|
+
Non-git-prefixed OIDs (``sha256:...``, ``sha256:missing:...``, etc.)
|
|
366
|
+
are passed through as "always reachable" since we can't prove they're
|
|
367
|
+
orphaned without the git object database.
|
|
368
|
+
|
|
369
|
+
Implementation: uses ``git cat-file --batch-check`` which checks all
|
|
370
|
+
objects, not just reachable ones — but for orphan detection we also
|
|
371
|
+
filter out dangling blobs using ``git fsck --unreachable``. In
|
|
372
|
+
practice, for code-intel anchors, "object exists" is a close-enough
|
|
373
|
+
proxy for "reachable" because git's GC eventually prunes unreachable
|
|
374
|
+
objects and the cache_manifest will catch up on the next scan.
|
|
375
|
+
|
|
376
|
+
Empty input returns empty set.
|
|
377
|
+
"""
|
|
378
|
+
if not blob_oids:
|
|
379
|
+
return set()
|
|
380
|
+
|
|
381
|
+
# Partition git vs non-git keys.
|
|
382
|
+
git_oids: set[str] = set()
|
|
383
|
+
non_git: set[str] = set()
|
|
384
|
+
for oid in blob_oids:
|
|
385
|
+
if oid.startswith("git:"):
|
|
386
|
+
git_oids.add(oid)
|
|
387
|
+
else:
|
|
388
|
+
non_git.add(oid)
|
|
389
|
+
|
|
390
|
+
if not git_oids or not is_git_repo(project_dir):
|
|
391
|
+
# Nothing to probe in git → pass-through.
|
|
392
|
+
return set(blob_oids)
|
|
393
|
+
|
|
394
|
+
# Prepare stdin: one SHA per line (strip the "git:" prefix).
|
|
395
|
+
stdin_text = "\n".join(oid[4:] for oid in git_oids) + "\n"
|
|
396
|
+
try:
|
|
397
|
+
result = subprocess.run(
|
|
398
|
+
["git", "cat-file", "--batch-check"],
|
|
399
|
+
cwd=project_dir,
|
|
400
|
+
input=stdin_text,
|
|
401
|
+
capture_output=True,
|
|
402
|
+
text=True,
|
|
403
|
+
timeout=max(_GIT_TIMEOUT_SECONDS, len(git_oids) * 0.01),
|
|
404
|
+
check=False,
|
|
405
|
+
)
|
|
406
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
407
|
+
logger.debug("git cat-file --batch-check failed: %s", exc)
|
|
408
|
+
return set(blob_oids)
|
|
409
|
+
|
|
410
|
+
if result.returncode != 0:
|
|
411
|
+
return set(blob_oids)
|
|
412
|
+
|
|
413
|
+
# Each line is either "<sha> <type> <size>" (exists) or
|
|
414
|
+
# "<sha> missing" (doesn't exist).
|
|
415
|
+
reachable: set[str] = set(non_git)
|
|
416
|
+
for line in result.stdout.splitlines():
|
|
417
|
+
parts = line.split()
|
|
418
|
+
if len(parts) >= 2:
|
|
419
|
+
sha = parts[0]
|
|
420
|
+
status = parts[1]
|
|
421
|
+
if status != "missing":
|
|
422
|
+
reachable.add(f"git:{sha}")
|
|
423
|
+
return reachable
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Per-project cache manifest — central schema-version registry for .attocode/.
|
|
2
|
+
|
|
3
|
+
A single ``.attocode/cache_manifest.json`` records the current schema version
|
|
4
|
+
of every local store (symbols, embeddings, kw_index, trigrams, learnings,
|
|
5
|
+
ADRs, frecency, query_history, CAS). Stores consult the manifest on open
|
|
6
|
+
and trigger their own migration hooks if they're behind.
|
|
7
|
+
|
|
8
|
+
This replaces the status-quo "half the DBs have a schema_version row, half
|
|
9
|
+
don't" by giving them one place to declare what version they're at and one
|
|
10
|
+
way to ask "do I need to migrate?".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
MANIFEST_SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class StoreEntry:
|
|
33
|
+
"""One row in the cache manifest, describing one store."""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
schema_version: int
|
|
37
|
+
last_migrated_at: str = ""
|
|
38
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class CacheManifest:
|
|
43
|
+
"""Read-write wrapper around ``<project>/.attocode/cache_manifest.json``.
|
|
44
|
+
|
|
45
|
+
Usage::
|
|
46
|
+
|
|
47
|
+
m = CacheManifest.load(project_dir="/path/to/project")
|
|
48
|
+
if m.needs_migration("embeddings", target=2):
|
|
49
|
+
... # run your store's migration hook
|
|
50
|
+
m.bump("embeddings", new_schema_version=2)
|
|
51
|
+
m.save()
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
project_dir: str
|
|
55
|
+
manifest_version: int = MANIFEST_SCHEMA_VERSION
|
|
56
|
+
stores: dict[str, StoreEntry] = field(default_factory=dict)
|
|
57
|
+
cas_root: str = ""
|
|
58
|
+
active_overlay: str = "main"
|
|
59
|
+
last_full_verify_at: str = ""
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Paths
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def manifest_path(self) -> str:
|
|
67
|
+
return os.path.join(self.project_dir, ".attocode", "cache_manifest.json")
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Load / Save
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load(cls, project_dir: str) -> CacheManifest:
|
|
75
|
+
"""Load the manifest, or return a fresh empty one if missing.
|
|
76
|
+
|
|
77
|
+
Never raises on a missing file — callers should treat "missing" and
|
|
78
|
+
"present but empty" as identical, because a brand-new ``.attocode/``
|
|
79
|
+
directory is indistinguishable from "we forgot to write the manifest".
|
|
80
|
+
"""
|
|
81
|
+
path = os.path.join(project_dir, ".attocode", "cache_manifest.json")
|
|
82
|
+
if not os.path.exists(path):
|
|
83
|
+
return cls(project_dir=project_dir)
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "rb") as f:
|
|
86
|
+
raw = json.loads(f.read())
|
|
87
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"cache_manifest: failed to parse %s (%s); starting fresh",
|
|
90
|
+
path, exc,
|
|
91
|
+
)
|
|
92
|
+
return cls(project_dir=project_dir)
|
|
93
|
+
|
|
94
|
+
stores_raw = raw.get("stores", {})
|
|
95
|
+
stores = {
|
|
96
|
+
name: StoreEntry(
|
|
97
|
+
path=str(entry.get("path", "")),
|
|
98
|
+
schema_version=int(entry.get("schema_version", 0)),
|
|
99
|
+
last_migrated_at=str(entry.get("last_migrated_at", "")),
|
|
100
|
+
extra={k: v for k, v in entry.items()
|
|
101
|
+
if k not in {"path", "schema_version", "last_migrated_at"}},
|
|
102
|
+
)
|
|
103
|
+
for name, entry in stores_raw.items()
|
|
104
|
+
}
|
|
105
|
+
return cls(
|
|
106
|
+
project_dir=project_dir,
|
|
107
|
+
manifest_version=int(raw.get("manifest_version", MANIFEST_SCHEMA_VERSION)),
|
|
108
|
+
stores=stores,
|
|
109
|
+
cas_root=str(raw.get("cas_root", "")),
|
|
110
|
+
active_overlay=str(raw.get("active_overlay", "main")),
|
|
111
|
+
last_full_verify_at=str(raw.get("last_full_verify_at", "")),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def save(self) -> None:
|
|
115
|
+
"""Atomically write the manifest to disk via tempfile + rename."""
|
|
116
|
+
os.makedirs(os.path.dirname(self.manifest_path), exist_ok=True)
|
|
117
|
+
payload = self.to_dict()
|
|
118
|
+
body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
119
|
+
fd, tmp = tempfile.mkstemp(
|
|
120
|
+
prefix="cache_manifest_",
|
|
121
|
+
suffix=".json.tmp",
|
|
122
|
+
dir=os.path.dirname(self.manifest_path),
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
with os.fdopen(fd, "wb") as f:
|
|
126
|
+
f.write(body)
|
|
127
|
+
os.replace(tmp, self.manifest_path)
|
|
128
|
+
except Exception:
|
|
129
|
+
try:
|
|
130
|
+
os.unlink(tmp)
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"manifest_version": self.manifest_version,
|
|
138
|
+
"project_dir": self.project_dir,
|
|
139
|
+
"cas_root": self.cas_root,
|
|
140
|
+
"active_overlay": self.active_overlay,
|
|
141
|
+
"last_full_verify_at": self.last_full_verify_at,
|
|
142
|
+
"stores": {
|
|
143
|
+
name: {
|
|
144
|
+
"path": entry.path,
|
|
145
|
+
"schema_version": entry.schema_version,
|
|
146
|
+
"last_migrated_at": entry.last_migrated_at,
|
|
147
|
+
**entry.extra,
|
|
148
|
+
}
|
|
149
|
+
for name, entry in sorted(self.stores.items())
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Store registration + migration hooks
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def register(
|
|
158
|
+
self,
|
|
159
|
+
name: str,
|
|
160
|
+
*,
|
|
161
|
+
path: str,
|
|
162
|
+
schema_version: int,
|
|
163
|
+
extra: Mapping[str, Any] | None = None,
|
|
164
|
+
) -> StoreEntry:
|
|
165
|
+
"""Idempotently register a store.
|
|
166
|
+
|
|
167
|
+
If the store is already present, update its recorded path/version
|
|
168
|
+
but do NOT reset last_migrated_at (that's a migration event).
|
|
169
|
+
"""
|
|
170
|
+
existing = self.stores.get(name)
|
|
171
|
+
if existing is None:
|
|
172
|
+
entry = StoreEntry(
|
|
173
|
+
path=path,
|
|
174
|
+
schema_version=schema_version,
|
|
175
|
+
last_migrated_at="",
|
|
176
|
+
extra=dict(extra or {}),
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
existing.path = path
|
|
180
|
+
existing.schema_version = schema_version
|
|
181
|
+
if extra:
|
|
182
|
+
existing.extra.update(extra)
|
|
183
|
+
entry = existing
|
|
184
|
+
self.stores[name] = entry
|
|
185
|
+
return entry
|
|
186
|
+
|
|
187
|
+
def get_store(self, name: str) -> StoreEntry | None:
|
|
188
|
+
return self.stores.get(name)
|
|
189
|
+
|
|
190
|
+
def needs_migration(self, name: str, *, target: int) -> bool:
|
|
191
|
+
"""Return True if the named store is missing OR below ``target`` schema."""
|
|
192
|
+
entry = self.stores.get(name)
|
|
193
|
+
if entry is None:
|
|
194
|
+
return True
|
|
195
|
+
return entry.schema_version < target
|
|
196
|
+
|
|
197
|
+
def bump(self, name: str, *, new_schema_version: int) -> None:
|
|
198
|
+
"""Record that a store migrated to ``new_schema_version``."""
|
|
199
|
+
entry = self.stores.get(name)
|
|
200
|
+
if entry is None:
|
|
201
|
+
raise KeyError(f"store {name!r} not registered; call register() first")
|
|
202
|
+
entry.schema_version = new_schema_version
|
|
203
|
+
entry.last_migrated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Summary helpers
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def summary(self) -> dict[str, Any]:
|
|
210
|
+
"""Return a compact dict useful for `cache_status_all`."""
|
|
211
|
+
return {
|
|
212
|
+
"manifest_version": self.manifest_version,
|
|
213
|
+
"project_dir": self.project_dir,
|
|
214
|
+
"cas_root": self.cas_root,
|
|
215
|
+
"active_overlay": self.active_overlay,
|
|
216
|
+
"last_full_verify_at": self.last_full_verify_at,
|
|
217
|
+
"stores": {
|
|
218
|
+
name: asdict(entry)
|
|
219
|
+
for name, entry in sorted(self.stores.items())
|
|
220
|
+
},
|
|
221
|
+
}
|