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,820 @@
|
|
|
1
|
+
"""Memory-mapped trigram inverted index for fast regex candidate pre-filtering.
|
|
2
|
+
|
|
3
|
+
Two-file mmap storage layout:
|
|
4
|
+
|
|
5
|
+
Lookup file (.attocode/index/trigrams.lookup):
|
|
6
|
+
Header : magic(u32) + version(u16) + entry_count(u32) + padding(u16) = 12 bytes
|
|
7
|
+
Entries: sorted by trigram_hash -- [trigram_hash:u32, postings_offset:u64,
|
|
8
|
+
postings_length:u32] = 16 bytes each
|
|
9
|
+
|
|
10
|
+
Postings file (.attocode/index/trigrams.postings):
|
|
11
|
+
Per posting list: [count:u32] + [file_id:u32] * count
|
|
12
|
+
|
|
13
|
+
File-ID mapping: SQLite table in .attocode/index/trigrams.db
|
|
14
|
+
trigram_files(file_id INTEGER PRIMARY KEY, path TEXT UNIQUE,
|
|
15
|
+
content_hash TEXT, mtime REAL)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import mmap
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import struct
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import zlib
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Literal, overload
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# QueryResult (explain mode)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class QueryResult:
|
|
41
|
+
"""Diagnostics returned by ``TrigramIndex.query(explain=True)``."""
|
|
42
|
+
|
|
43
|
+
candidates: list[str] | None
|
|
44
|
+
trigram_literals: list[str]
|
|
45
|
+
posting_sizes: list[int]
|
|
46
|
+
total_files: int
|
|
47
|
+
candidate_count: int
|
|
48
|
+
selectivity: float
|
|
49
|
+
threshold: float
|
|
50
|
+
threshold_triggered: bool
|
|
51
|
+
mode: str # "trigram-filtered" | "full-scan-threshold" | "full-scan-no-trigrams" | "no-index"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Constants
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_MAGIC: int = 0x54524933 # "TRI3" as little-endian u32
|
|
59
|
+
_VERSION: int = 1
|
|
60
|
+
_HEADER_SIZE: int = 12 # magic(4) + version(2) + entry_count(4) + pad(2)
|
|
61
|
+
_ENTRY_SIZE: int = 16 # hash(4) + offset(8) + length(4)
|
|
62
|
+
_MAX_FILE_SIZE: int = 1_000_000 # Skip files larger than 1 MB
|
|
63
|
+
_SKIP_EXTENSIONS: frozenset[str] = frozenset({
|
|
64
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
|
|
65
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
66
|
+
".pdf", ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z",
|
|
67
|
+
".pyc", ".pyo", ".so", ".dll", ".dylib", ".pyd",
|
|
68
|
+
".db", ".sqlite", ".sqlite3",
|
|
69
|
+
".bin", ".exe", ".o", ".a", ".obj",
|
|
70
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov", ".webm",
|
|
71
|
+
".lock",
|
|
72
|
+
})
|
|
73
|
+
_SKIP_DIRS: frozenset[str] = frozenset({
|
|
74
|
+
".git", ".hg", ".svn",
|
|
75
|
+
"node_modules", "__pycache__", ".venv", "venv", ".env",
|
|
76
|
+
".tox", "dist", "build", ".eggs", ".mypy_cache",
|
|
77
|
+
".pytest_cache", ".ruff_cache",
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Module-level helpers
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _trigram_hash(tri: bytes) -> int:
|
|
86
|
+
"""CRC32 of a 3-byte sequence, masked to u32."""
|
|
87
|
+
return zlib.crc32(tri) & 0xFFFFFFFF
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_trigrams_from_bytes(content: bytes) -> set[int]:
|
|
91
|
+
"""Return the set of unique trigram hashes present in *content*.
|
|
92
|
+
|
|
93
|
+
Trigrams containing control characters (except \\t, \\n, \\r which appear
|
|
94
|
+
in normal source) are skipped -- this acts as a lightweight binary guard.
|
|
95
|
+
"""
|
|
96
|
+
hashes: set[int] = set()
|
|
97
|
+
n = len(content)
|
|
98
|
+
for i in range(n - 2):
|
|
99
|
+
a, b, c = content[i], content[i + 1], content[i + 2]
|
|
100
|
+
# Allow: 0x09=\t, 0x0A=\n, 0x0D=\r; reject everything else < 0x20
|
|
101
|
+
if (
|
|
102
|
+
(a < 0x09 or (0x0E <= a <= 0x1F))
|
|
103
|
+
or (b < 0x09 or (0x0E <= b <= 0x1F))
|
|
104
|
+
or (c < 0x09 or (0x0E <= c <= 0x1F))
|
|
105
|
+
):
|
|
106
|
+
continue
|
|
107
|
+
hashes.add(zlib.crc32(content[i : i + 3]) & 0xFFFFFFFF)
|
|
108
|
+
return hashes
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_likely_binary(data: bytes, sample_size: int = 8192) -> bool:
|
|
112
|
+
"""Return True if *data* looks like binary content.
|
|
113
|
+
|
|
114
|
+
Checks the first *sample_size* bytes for control characters.
|
|
115
|
+
A ratio > 10% is a reliable indicator of binary data.
|
|
116
|
+
"""
|
|
117
|
+
sample = data[:sample_size]
|
|
118
|
+
if not sample:
|
|
119
|
+
return False
|
|
120
|
+
control_count = sum(
|
|
121
|
+
1 for b in sample
|
|
122
|
+
if b < 0x09 or (0x0E <= b <= 0x1F) or b == 0x7F
|
|
123
|
+
)
|
|
124
|
+
return control_count / len(sample) > 0.10
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# TrigramIndex
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@dataclass(slots=True)
|
|
132
|
+
class TrigramIndex:
|
|
133
|
+
"""Memory-mapped trigram inverted index for fast regex candidate filtering.
|
|
134
|
+
|
|
135
|
+
Usage::
|
|
136
|
+
|
|
137
|
+
idx = TrigramIndex(index_dir=".attocode/index")
|
|
138
|
+
if not idx.load():
|
|
139
|
+
stats = idx.build(project_dir=".")
|
|
140
|
+
candidates = idx.query("def grep_search")
|
|
141
|
+
# None -> caller falls back to full scan
|
|
142
|
+
# [] -> no files match (definitive fast exit)
|
|
143
|
+
# [str] -> relative paths of candidate files
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
index_dir: str
|
|
147
|
+
|
|
148
|
+
_db_path: str = field(default="", init=False, repr=False)
|
|
149
|
+
_lookup_path: str = field(default="", init=False, repr=False)
|
|
150
|
+
_postings_path: str = field(default="", init=False, repr=False)
|
|
151
|
+
|
|
152
|
+
_lookup_fd: int = field(default=-1, init=False, repr=False)
|
|
153
|
+
_postings_fd: int = field(default=-1, init=False, repr=False)
|
|
154
|
+
_lookup_mmap: mmap.mmap | None = field(default=None, init=False, repr=False)
|
|
155
|
+
_postings_mmap: mmap.mmap | None = field(default=None, init=False, repr=False)
|
|
156
|
+
|
|
157
|
+
_entry_count: int = field(default=0, init=False, repr=False)
|
|
158
|
+
_file_id_to_path: dict[int, str] = field(default_factory=dict, init=False, repr=False)
|
|
159
|
+
_path_to_file_id: dict[str, int] = field(default_factory=dict, init=False, repr=False)
|
|
160
|
+
|
|
161
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
|
162
|
+
_ready: bool = field(default=False, init=False, repr=False)
|
|
163
|
+
|
|
164
|
+
def __post_init__(self) -> None:
|
|
165
|
+
os.makedirs(self.index_dir, exist_ok=True)
|
|
166
|
+
self._db_path = os.path.join(self.index_dir, "trigrams.db")
|
|
167
|
+
self._lookup_path = os.path.join(self.index_dir, "trigrams.lookup")
|
|
168
|
+
self._postings_path = os.path.join(self.index_dir, "trigrams.postings")
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Public API
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def build(self, project_dir: str) -> dict[str, Any]:
|
|
175
|
+
"""Build the trigram index by walking *project_dir*.
|
|
176
|
+
|
|
177
|
+
Writes lookup and postings files atomically (write .tmp, os.replace).
|
|
178
|
+
Stores the file-ID mapping in SQLite.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
dict with keys: files_indexed, trigrams_count,
|
|
182
|
+
build_time_ms, index_size_bytes, loaded (bool).
|
|
183
|
+
"""
|
|
184
|
+
t0 = time.monotonic()
|
|
185
|
+
project_path = Path(project_dir).resolve()
|
|
186
|
+
|
|
187
|
+
with self._lock:
|
|
188
|
+
# Phase 1: enumerate candidate files
|
|
189
|
+
file_paths = self._enumerate_files(project_path)
|
|
190
|
+
|
|
191
|
+
# Phase 2: build inverted index
|
|
192
|
+
file_map: dict[str, int] = {} # rel_path -> file_id
|
|
193
|
+
postings: dict[int, list[int]] = {} # trigram_hash -> [file_ids]
|
|
194
|
+
content_hashes: dict[str, str] = {}
|
|
195
|
+
mtimes: dict[str, float] = {}
|
|
196
|
+
|
|
197
|
+
for file_id, rel_path in enumerate(file_paths):
|
|
198
|
+
abs_path = project_path / rel_path
|
|
199
|
+
try:
|
|
200
|
+
raw = abs_path.read_bytes()
|
|
201
|
+
except OSError:
|
|
202
|
+
continue
|
|
203
|
+
if _is_likely_binary(raw):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
file_map[rel_path] = file_id
|
|
207
|
+
try:
|
|
208
|
+
mtimes[rel_path] = abs_path.stat().st_mtime
|
|
209
|
+
except OSError:
|
|
210
|
+
mtimes[rel_path] = 0.0
|
|
211
|
+
content_hashes[rel_path] = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
|
|
212
|
+
|
|
213
|
+
for h in _extract_trigrams_from_bytes(raw):
|
|
214
|
+
if h not in postings:
|
|
215
|
+
postings[h] = []
|
|
216
|
+
postings[h].append(file_id)
|
|
217
|
+
|
|
218
|
+
# Phase 3: write index files atomically
|
|
219
|
+
self._write_index(postings, file_map)
|
|
220
|
+
|
|
221
|
+
# Phase 4: persist file map to SQLite
|
|
222
|
+
self._init_db()
|
|
223
|
+
self._save_file_map(file_map, content_hashes, mtimes)
|
|
224
|
+
|
|
225
|
+
# Phase 5: load the freshly built index into mmap
|
|
226
|
+
self._ready = False
|
|
227
|
+
self._close_mmap()
|
|
228
|
+
success = self._load_unlocked()
|
|
229
|
+
|
|
230
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
231
|
+
lookup_size = (
|
|
232
|
+
os.path.getsize(self._lookup_path)
|
|
233
|
+
if os.path.exists(self._lookup_path)
|
|
234
|
+
else 0
|
|
235
|
+
)
|
|
236
|
+
postings_size = (
|
|
237
|
+
os.path.getsize(self._postings_path)
|
|
238
|
+
if os.path.exists(self._postings_path)
|
|
239
|
+
else 0
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"files_indexed": len(file_map),
|
|
243
|
+
"trigrams_count": len(postings),
|
|
244
|
+
"build_time_ms": elapsed_ms,
|
|
245
|
+
"index_size_bytes": lookup_size + postings_size,
|
|
246
|
+
"loaded": success,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def load(self) -> bool:
|
|
250
|
+
"""Load an existing index from disk into memory-mapped buffers.
|
|
251
|
+
|
|
252
|
+
Returns True if the index was loaded and is ready for queries.
|
|
253
|
+
Returns False if files are missing, corrupt, or version-mismatched.
|
|
254
|
+
"""
|
|
255
|
+
with self._lock:
|
|
256
|
+
self._ready = False
|
|
257
|
+
self._close_mmap()
|
|
258
|
+
return self._load_unlocked()
|
|
259
|
+
|
|
260
|
+
@overload
|
|
261
|
+
def query(
|
|
262
|
+
self,
|
|
263
|
+
pattern: str,
|
|
264
|
+
*,
|
|
265
|
+
case_insensitive: bool = False,
|
|
266
|
+
selectivity_threshold: float = 0.10,
|
|
267
|
+
explain: Literal[False] = False,
|
|
268
|
+
) -> list[str] | None: ...
|
|
269
|
+
|
|
270
|
+
@overload
|
|
271
|
+
def query(
|
|
272
|
+
self,
|
|
273
|
+
pattern: str,
|
|
274
|
+
*,
|
|
275
|
+
case_insensitive: bool = False,
|
|
276
|
+
selectivity_threshold: float = 0.10,
|
|
277
|
+
explain: Literal[True],
|
|
278
|
+
) -> QueryResult: ...
|
|
279
|
+
|
|
280
|
+
def query(
|
|
281
|
+
self,
|
|
282
|
+
pattern: str,
|
|
283
|
+
*,
|
|
284
|
+
case_insensitive: bool = False,
|
|
285
|
+
selectivity_threshold: float = 0.10,
|
|
286
|
+
explain: bool = False,
|
|
287
|
+
) -> list[str] | None | QueryResult:
|
|
288
|
+
"""Return candidate file paths that MAY contain a match for *pattern*.
|
|
289
|
+
|
|
290
|
+
Extracts required trigrams from the regex, intersects their posting
|
|
291
|
+
lists, and maps file-IDs back to relative paths.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
pattern: Regex pattern to search for.
|
|
295
|
+
case_insensitive: Lowercase trigrams before matching.
|
|
296
|
+
selectivity_threshold: If the fraction of matching files exceeds
|
|
297
|
+
this value (0.0–1.0), the index is skipped and ``None`` is
|
|
298
|
+
returned so the caller falls back to a full scan. Default
|
|
299
|
+
is 0.10 (10%).
|
|
300
|
+
explain: When True, return a :class:`QueryResult` with full
|
|
301
|
+
diagnostics instead of a bare candidate list.
|
|
302
|
+
|
|
303
|
+
Returns (when *explain* is False):
|
|
304
|
+
None -- no trigrams extractable; caller must scan all files.
|
|
305
|
+
[] -- trigrams found but no file contains all of them.
|
|
306
|
+
[str] -- relative file paths that contain all required trigrams.
|
|
307
|
+
Returns (when *explain* is True):
|
|
308
|
+
:class:`QueryResult` with diagnostics.
|
|
309
|
+
"""
|
|
310
|
+
if not self._ready:
|
|
311
|
+
if explain:
|
|
312
|
+
return QueryResult(
|
|
313
|
+
candidates=None, trigram_literals=[], posting_sizes=[],
|
|
314
|
+
total_files=0, candidate_count=0, selectivity=0.0,
|
|
315
|
+
threshold=selectivity_threshold, threshold_triggered=False,
|
|
316
|
+
mode="no-index",
|
|
317
|
+
)
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
from attocode_intel._internal.integrations.context.trigram_regex import extract_required_trigrams
|
|
321
|
+
|
|
322
|
+
if explain:
|
|
323
|
+
result = extract_required_trigrams(
|
|
324
|
+
pattern, case_insensitive=case_insensitive, include_literals=True,
|
|
325
|
+
)
|
|
326
|
+
required, literals = result # type: ignore[misc]
|
|
327
|
+
else:
|
|
328
|
+
required = extract_required_trigrams(pattern, case_insensitive=case_insensitive)
|
|
329
|
+
literals = []
|
|
330
|
+
|
|
331
|
+
if not required:
|
|
332
|
+
if explain:
|
|
333
|
+
return QueryResult(
|
|
334
|
+
candidates=None, trigram_literals=[], posting_sizes=[],
|
|
335
|
+
total_files=len(self._file_id_to_path),
|
|
336
|
+
candidate_count=0, selectivity=0.0,
|
|
337
|
+
threshold=selectivity_threshold, threshold_triggered=False,
|
|
338
|
+
mode="full-scan-no-trigrams",
|
|
339
|
+
)
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
with self._lock:
|
|
343
|
+
if not self._ready:
|
|
344
|
+
if explain:
|
|
345
|
+
return QueryResult(
|
|
346
|
+
candidates=None, trigram_literals=literals, posting_sizes=[],
|
|
347
|
+
total_files=0, candidate_count=0, selectivity=0.0,
|
|
348
|
+
threshold=selectivity_threshold, threshold_triggered=False,
|
|
349
|
+
mode="no-index",
|
|
350
|
+
)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
if explain:
|
|
354
|
+
raw, posting_sizes = self._intersect_postings(
|
|
355
|
+
required, collect_sizes=True,
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
raw = self._intersect_postings(required)
|
|
359
|
+
posting_sizes = []
|
|
360
|
+
|
|
361
|
+
# Apply selectivity threshold.
|
|
362
|
+
# Only meaningful for large codebases — below 100 files,
|
|
363
|
+
# searching all candidates is trivially fast.
|
|
364
|
+
_MIN_FILES_FOR_THRESHOLD = 100
|
|
365
|
+
total_files = len(self._file_id_to_path)
|
|
366
|
+
candidates: list[str] | None = raw # type: ignore[assignment]
|
|
367
|
+
candidate_count = len(candidates) if candidates else 0
|
|
368
|
+
selectivity = (
|
|
369
|
+
candidate_count / total_files if total_files > 0 else 0.0
|
|
370
|
+
)
|
|
371
|
+
threshold_triggered = (
|
|
372
|
+
candidates is not None
|
|
373
|
+
and len(candidates) > 0
|
|
374
|
+
and total_files >= _MIN_FILES_FOR_THRESHOLD
|
|
375
|
+
and selectivity > selectivity_threshold
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if threshold_triggered:
|
|
379
|
+
candidates = None # signal caller to fall back to full scan
|
|
380
|
+
|
|
381
|
+
if explain:
|
|
382
|
+
if threshold_triggered:
|
|
383
|
+
mode = "full-scan-threshold"
|
|
384
|
+
elif candidates is None:
|
|
385
|
+
mode = "full-scan-no-trigrams"
|
|
386
|
+
else:
|
|
387
|
+
mode = "trigram-filtered"
|
|
388
|
+
return QueryResult(
|
|
389
|
+
candidates=candidates,
|
|
390
|
+
trigram_literals=literals,
|
|
391
|
+
posting_sizes=posting_sizes,
|
|
392
|
+
total_files=total_files,
|
|
393
|
+
candidate_count=candidate_count,
|
|
394
|
+
selectivity=selectivity,
|
|
395
|
+
threshold=selectivity_threshold,
|
|
396
|
+
threshold_triggered=threshold_triggered,
|
|
397
|
+
mode=mode,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return candidates
|
|
401
|
+
|
|
402
|
+
def update_file(self, rel_path: str, content: bytes) -> None:
|
|
403
|
+
"""Invalidate the index for *rel_path*.
|
|
404
|
+
|
|
405
|
+
Marks the index not-ready so callers fall back to brute force until
|
|
406
|
+
the next ``build()`` call rebuilds it.
|
|
407
|
+
"""
|
|
408
|
+
with self._lock:
|
|
409
|
+
self._ready = False
|
|
410
|
+
|
|
411
|
+
def remove_file(self, rel_path: str) -> None:
|
|
412
|
+
"""Remove *rel_path* from the in-memory ID mapping.
|
|
413
|
+
|
|
414
|
+
The mmap files are not modified; call ``build()`` for a full refresh.
|
|
415
|
+
"""
|
|
416
|
+
with self._lock:
|
|
417
|
+
fid = self._path_to_file_id.pop(rel_path, None)
|
|
418
|
+
if fid is not None:
|
|
419
|
+
self._file_id_to_path.pop(fid, None)
|
|
420
|
+
|
|
421
|
+
def is_ready(self) -> bool:
|
|
422
|
+
"""Return True if the index is loaded and ready for queries."""
|
|
423
|
+
return self._ready
|
|
424
|
+
|
|
425
|
+
def close(self) -> None:
|
|
426
|
+
"""Unmap memory regions and close all file descriptors."""
|
|
427
|
+
with self._lock:
|
|
428
|
+
self._close_mmap()
|
|
429
|
+
self._ready = False
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
# Internal: file enumeration
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _enumerate_files(project_path: Path) -> list[str]:
|
|
437
|
+
"""Walk *project_path* and return relative paths of indexable files."""
|
|
438
|
+
result: list[str] = []
|
|
439
|
+
for root, dirs, files in os.walk(project_path):
|
|
440
|
+
dirs[:] = sorted(
|
|
441
|
+
d for d in dirs
|
|
442
|
+
if d not in _SKIP_DIRS and not d.startswith(".")
|
|
443
|
+
)
|
|
444
|
+
for fname in sorted(files):
|
|
445
|
+
fpath = Path(root) / fname
|
|
446
|
+
if fpath.suffix.lower() in _SKIP_EXTENSIONS:
|
|
447
|
+
continue
|
|
448
|
+
if fname.startswith("."):
|
|
449
|
+
continue
|
|
450
|
+
try:
|
|
451
|
+
if fpath.stat().st_size > _MAX_FILE_SIZE:
|
|
452
|
+
continue
|
|
453
|
+
except OSError:
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
rel = str(fpath.relative_to(project_path))
|
|
457
|
+
except ValueError:
|
|
458
|
+
rel = str(fpath)
|
|
459
|
+
result.append(rel)
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
# Internal: index writing
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def _write_index(
|
|
467
|
+
self,
|
|
468
|
+
postings: dict[int, list[int]],
|
|
469
|
+
file_map: dict[str, int],
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Atomically write lookup and postings binary files."""
|
|
472
|
+
sorted_hashes = sorted(postings.keys())
|
|
473
|
+
n_entries = len(sorted_hashes)
|
|
474
|
+
|
|
475
|
+
# Build postings blob -- one posting list per unique trigram hash
|
|
476
|
+
postings_parts: list[bytes] = []
|
|
477
|
+
postings_offsets: dict[int, tuple[int, int]] = {}
|
|
478
|
+
current_offset = 0
|
|
479
|
+
|
|
480
|
+
for h in sorted_hashes:
|
|
481
|
+
file_ids = sorted(postings[h])
|
|
482
|
+
count = len(file_ids)
|
|
483
|
+
part = struct.pack(f"<I{count}I", count, *file_ids)
|
|
484
|
+
postings_offsets[h] = (current_offset, len(part))
|
|
485
|
+
postings_parts.append(part)
|
|
486
|
+
current_offset += len(part)
|
|
487
|
+
|
|
488
|
+
postings_blob = b"".join(postings_parts)
|
|
489
|
+
|
|
490
|
+
# Build lookup blob
|
|
491
|
+
# Header: magic(u32) + version(u16) + entry_count(u32) + padding(u16)
|
|
492
|
+
header = struct.pack("<IHIh", _MAGIC, _VERSION, n_entries, 0)
|
|
493
|
+
assert len(header) == _HEADER_SIZE
|
|
494
|
+
|
|
495
|
+
lookup_parts: list[bytes] = [header]
|
|
496
|
+
for h in sorted_hashes:
|
|
497
|
+
off, length = postings_offsets[h]
|
|
498
|
+
entry = struct.pack("<IQI", h, off, length)
|
|
499
|
+
assert len(entry) == _ENTRY_SIZE
|
|
500
|
+
lookup_parts.append(entry)
|
|
501
|
+
|
|
502
|
+
lookup_blob = b"".join(lookup_parts)
|
|
503
|
+
|
|
504
|
+
# Atomic write: write to .tmp, then os.replace
|
|
505
|
+
lookup_tmp = self._lookup_path + ".tmp"
|
|
506
|
+
postings_tmp = self._postings_path + ".tmp"
|
|
507
|
+
try:
|
|
508
|
+
with open(lookup_tmp, "wb") as f:
|
|
509
|
+
f.write(lookup_blob)
|
|
510
|
+
with open(postings_tmp, "wb") as f:
|
|
511
|
+
f.write(postings_blob)
|
|
512
|
+
os.replace(lookup_tmp, self._lookup_path)
|
|
513
|
+
os.replace(postings_tmp, self._postings_path)
|
|
514
|
+
except Exception:
|
|
515
|
+
for tmp in (lookup_tmp, postings_tmp):
|
|
516
|
+
try:
|
|
517
|
+
os.unlink(tmp)
|
|
518
|
+
except OSError:
|
|
519
|
+
pass
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
# Internal: loading
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
def _load_unlocked(self) -> bool:
|
|
527
|
+
"""Load index files. Caller must hold _lock."""
|
|
528
|
+
for path in (self._lookup_path, self._postings_path, self._db_path):
|
|
529
|
+
if not os.path.exists(path):
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
# Load file-ID mapping from SQLite
|
|
533
|
+
try:
|
|
534
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
535
|
+
try:
|
|
536
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
537
|
+
rows = conn.execute(
|
|
538
|
+
"SELECT file_id, path FROM trigram_files"
|
|
539
|
+
).fetchall()
|
|
540
|
+
finally:
|
|
541
|
+
conn.close()
|
|
542
|
+
except sqlite3.Error as exc:
|
|
543
|
+
logger.debug("trigram_index: DB read failed: %s", exc)
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
id_to_path: dict[int, str] = {}
|
|
547
|
+
path_to_id: dict[str, int] = {}
|
|
548
|
+
for fid, fpath in rows:
|
|
549
|
+
id_to_path[fid] = fpath
|
|
550
|
+
path_to_id[fpath] = fid
|
|
551
|
+
|
|
552
|
+
# mmap the lookup file
|
|
553
|
+
try:
|
|
554
|
+
lookup_fd = os.open(self._lookup_path, os.O_RDONLY)
|
|
555
|
+
lookup_size = os.path.getsize(self._lookup_path)
|
|
556
|
+
except OSError as exc:
|
|
557
|
+
logger.debug("trigram_index: cannot open lookup: %s", exc)
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
if lookup_size < _HEADER_SIZE:
|
|
561
|
+
os.close(lookup_fd)
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
lookup_mm = mmap.mmap(lookup_fd, 0, access=mmap.ACCESS_READ)
|
|
566
|
+
except OSError as exc:
|
|
567
|
+
os.close(lookup_fd)
|
|
568
|
+
logger.debug("trigram_index: mmap lookup failed: %s", exc)
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
# Validate header
|
|
572
|
+
try:
|
|
573
|
+
magic, version, entry_count, _pad = struct.unpack_from("<IHIh", lookup_mm, 0)
|
|
574
|
+
except struct.error as exc:
|
|
575
|
+
lookup_mm.close()
|
|
576
|
+
os.close(lookup_fd)
|
|
577
|
+
logger.debug("trigram_index: corrupt header: %s", exc)
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
if magic != _MAGIC or version != _VERSION:
|
|
581
|
+
lookup_mm.close()
|
|
582
|
+
os.close(lookup_fd)
|
|
583
|
+
return False
|
|
584
|
+
|
|
585
|
+
expected_lookup_size = _HEADER_SIZE + entry_count * _ENTRY_SIZE
|
|
586
|
+
if lookup_size < expected_lookup_size:
|
|
587
|
+
lookup_mm.close()
|
|
588
|
+
os.close(lookup_fd)
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
# mmap the postings file
|
|
592
|
+
postings_size = os.path.getsize(self._postings_path)
|
|
593
|
+
if postings_size == 0:
|
|
594
|
+
postings_fd = -1
|
|
595
|
+
postings_mm: mmap.mmap | None = None
|
|
596
|
+
else:
|
|
597
|
+
try:
|
|
598
|
+
postings_fd = os.open(self._postings_path, os.O_RDONLY)
|
|
599
|
+
postings_mm = mmap.mmap(postings_fd, 0, access=mmap.ACCESS_READ)
|
|
600
|
+
except OSError as exc:
|
|
601
|
+
lookup_mm.close()
|
|
602
|
+
os.close(lookup_fd)
|
|
603
|
+
logger.debug("trigram_index: mmap postings failed: %s", exc)
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
# Commit
|
|
607
|
+
self._close_mmap()
|
|
608
|
+
self._lookup_fd = lookup_fd
|
|
609
|
+
self._postings_fd = postings_fd
|
|
610
|
+
self._lookup_mmap = lookup_mm
|
|
611
|
+
self._postings_mmap = postings_mm
|
|
612
|
+
self._entry_count = entry_count
|
|
613
|
+
self._file_id_to_path = id_to_path
|
|
614
|
+
self._path_to_file_id = path_to_id
|
|
615
|
+
self._ready = True
|
|
616
|
+
|
|
617
|
+
logger.debug(
|
|
618
|
+
"trigram_index: loaded -- %d entries, %d files",
|
|
619
|
+
entry_count, len(id_to_path),
|
|
620
|
+
)
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
# ------------------------------------------------------------------
|
|
624
|
+
# Internal: query helpers
|
|
625
|
+
# ------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
@overload
|
|
628
|
+
def _intersect_postings(
|
|
629
|
+
self,
|
|
630
|
+
required_hashes: list[int],
|
|
631
|
+
*,
|
|
632
|
+
collect_sizes: Literal[False] = False,
|
|
633
|
+
) -> list[str] | None: ...
|
|
634
|
+
|
|
635
|
+
@overload
|
|
636
|
+
def _intersect_postings(
|
|
637
|
+
self,
|
|
638
|
+
required_hashes: list[int],
|
|
639
|
+
*,
|
|
640
|
+
collect_sizes: Literal[True],
|
|
641
|
+
) -> tuple[list[str] | None, list[int]]: ...
|
|
642
|
+
|
|
643
|
+
def _intersect_postings(
|
|
644
|
+
self,
|
|
645
|
+
required_hashes: list[int],
|
|
646
|
+
*,
|
|
647
|
+
collect_sizes: bool = False,
|
|
648
|
+
) -> list[str] | None | tuple[list[str] | None, list[int]]:
|
|
649
|
+
"""Intersect posting lists for all required trigram hashes.
|
|
650
|
+
|
|
651
|
+
When *collect_sizes* is True, returns ``(candidates, posting_sizes)``
|
|
652
|
+
where *posting_sizes[i]* is the length of the posting list for
|
|
653
|
+
*required_hashes[i]*.
|
|
654
|
+
"""
|
|
655
|
+
candidate_ids: set[int] | None = None
|
|
656
|
+
posting_sizes: list[int] = [] if collect_sizes else []
|
|
657
|
+
|
|
658
|
+
for h in required_hashes:
|
|
659
|
+
result = self._binary_search(h)
|
|
660
|
+
if result is None:
|
|
661
|
+
if collect_sizes:
|
|
662
|
+
posting_sizes.append(0)
|
|
663
|
+
# Fill remaining sizes for hashes not yet looked up
|
|
664
|
+
remaining = len(required_hashes) - len(posting_sizes)
|
|
665
|
+
posting_sizes.extend([0] * remaining)
|
|
666
|
+
return ([], posting_sizes)
|
|
667
|
+
return []
|
|
668
|
+
offset, length = result
|
|
669
|
+
file_ids = self._read_posting_list(offset, length)
|
|
670
|
+
if collect_sizes:
|
|
671
|
+
posting_sizes.append(len(file_ids))
|
|
672
|
+
if candidate_ids is None:
|
|
673
|
+
candidate_ids = set(file_ids)
|
|
674
|
+
else:
|
|
675
|
+
candidate_ids &= set(file_ids)
|
|
676
|
+
if not candidate_ids:
|
|
677
|
+
if collect_sizes:
|
|
678
|
+
# Fill remaining sizes with 0 for hashes not yet looked up
|
|
679
|
+
remaining = len(required_hashes) - len(posting_sizes)
|
|
680
|
+
posting_sizes.extend([0] * remaining)
|
|
681
|
+
return ([], posting_sizes)
|
|
682
|
+
return []
|
|
683
|
+
|
|
684
|
+
if candidate_ids is None:
|
|
685
|
+
return (None, posting_sizes) if collect_sizes else None
|
|
686
|
+
|
|
687
|
+
paths: list[str] = []
|
|
688
|
+
for fid in candidate_ids:
|
|
689
|
+
path = self._file_id_to_path.get(fid)
|
|
690
|
+
if path is not None:
|
|
691
|
+
paths.append(path)
|
|
692
|
+
return (paths, posting_sizes) if collect_sizes else paths
|
|
693
|
+
|
|
694
|
+
def _binary_search(self, trigram_hash: int) -> tuple[int, int] | None:
|
|
695
|
+
"""Binary search the lookup mmap for *trigram_hash*.
|
|
696
|
+
|
|
697
|
+
Returns (postings_offset, postings_length) on hit, None on miss.
|
|
698
|
+
"""
|
|
699
|
+
if self._lookup_mmap is None:
|
|
700
|
+
return None
|
|
701
|
+
lo, hi = 0, self._entry_count - 1
|
|
702
|
+
mm = self._lookup_mmap
|
|
703
|
+
while lo <= hi:
|
|
704
|
+
mid = (lo + hi) >> 1
|
|
705
|
+
offset = _HEADER_SIZE + mid * _ENTRY_SIZE
|
|
706
|
+
entry_hash = struct.unpack_from("<I", mm, offset)[0]
|
|
707
|
+
if entry_hash == trigram_hash:
|
|
708
|
+
postings_offset, postings_length = struct.unpack_from(
|
|
709
|
+
"<QI", mm, offset + 4
|
|
710
|
+
)
|
|
711
|
+
return (postings_offset, postings_length)
|
|
712
|
+
elif entry_hash < trigram_hash:
|
|
713
|
+
lo = mid + 1
|
|
714
|
+
else:
|
|
715
|
+
hi = mid - 1
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
def _read_posting_list(self, offset: int, length: int) -> list[int]:
|
|
719
|
+
"""Read and decode a posting list from the postings mmap."""
|
|
720
|
+
if self._postings_mmap is None:
|
|
721
|
+
return []
|
|
722
|
+
mm = self._postings_mmap
|
|
723
|
+
try:
|
|
724
|
+
raw = mm[offset : offset + length]
|
|
725
|
+
if len(raw) < 4:
|
|
726
|
+
return []
|
|
727
|
+
(count,) = struct.unpack_from("<I", raw, 0)
|
|
728
|
+
expected = 4 + count * 4
|
|
729
|
+
if len(raw) < expected:
|
|
730
|
+
return []
|
|
731
|
+
return list(struct.unpack_from(f"<{count}I", raw, 4))
|
|
732
|
+
except (struct.error, ValueError):
|
|
733
|
+
return []
|
|
734
|
+
|
|
735
|
+
# ------------------------------------------------------------------
|
|
736
|
+
# Internal: SQLite
|
|
737
|
+
# ------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
def _init_db(self) -> None:
|
|
740
|
+
"""Create the trigram_files table if it does not exist."""
|
|
741
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
742
|
+
try:
|
|
743
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
744
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
745
|
+
conn.execute("""
|
|
746
|
+
CREATE TABLE IF NOT EXISTS trigram_files (
|
|
747
|
+
file_id INTEGER PRIMARY KEY,
|
|
748
|
+
path TEXT NOT NULL UNIQUE,
|
|
749
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
750
|
+
mtime REAL NOT NULL DEFAULT 0
|
|
751
|
+
)
|
|
752
|
+
""")
|
|
753
|
+
conn.execute(
|
|
754
|
+
"CREATE INDEX IF NOT EXISTS ix_trigram_files_path "
|
|
755
|
+
"ON trigram_files(path)"
|
|
756
|
+
)
|
|
757
|
+
conn.commit()
|
|
758
|
+
finally:
|
|
759
|
+
conn.close()
|
|
760
|
+
|
|
761
|
+
def _save_file_map(
|
|
762
|
+
self,
|
|
763
|
+
file_map: dict[str, int],
|
|
764
|
+
content_hashes: dict[str, str],
|
|
765
|
+
mtimes: dict[str, float],
|
|
766
|
+
) -> None:
|
|
767
|
+
"""Persist the file-ID map to SQLite, replacing all previous rows."""
|
|
768
|
+
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
769
|
+
try:
|
|
770
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
771
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
772
|
+
conn.execute("DELETE FROM trigram_files")
|
|
773
|
+
conn.executemany(
|
|
774
|
+
"INSERT INTO trigram_files (file_id, path, content_hash, mtime) "
|
|
775
|
+
"VALUES (?, ?, ?, ?)",
|
|
776
|
+
[
|
|
777
|
+
(fid, rp, content_hashes.get(rp, ""), mtimes.get(rp, 0.0))
|
|
778
|
+
for rp, fid in file_map.items()
|
|
779
|
+
],
|
|
780
|
+
)
|
|
781
|
+
conn.commit()
|
|
782
|
+
finally:
|
|
783
|
+
conn.close()
|
|
784
|
+
|
|
785
|
+
# ------------------------------------------------------------------
|
|
786
|
+
# Internal: mmap lifecycle
|
|
787
|
+
# ------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
def _close_mmap(self) -> None:
|
|
790
|
+
"""Close mmaps and file descriptors. Caller must hold _lock."""
|
|
791
|
+
if self._lookup_mmap is not None:
|
|
792
|
+
try:
|
|
793
|
+
self._lookup_mmap.close()
|
|
794
|
+
except Exception:
|
|
795
|
+
pass
|
|
796
|
+
self._lookup_mmap = None
|
|
797
|
+
if self._postings_mmap is not None:
|
|
798
|
+
try:
|
|
799
|
+
self._postings_mmap.close()
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
self._postings_mmap = None
|
|
803
|
+
if self._lookup_fd >= 0:
|
|
804
|
+
try:
|
|
805
|
+
os.close(self._lookup_fd)
|
|
806
|
+
except OSError:
|
|
807
|
+
pass
|
|
808
|
+
self._lookup_fd = -1
|
|
809
|
+
if self._postings_fd >= 0:
|
|
810
|
+
try:
|
|
811
|
+
os.close(self._postings_fd)
|
|
812
|
+
except OSError:
|
|
813
|
+
pass
|
|
814
|
+
self._postings_fd = -1
|
|
815
|
+
|
|
816
|
+
def __del__(self) -> None:
|
|
817
|
+
try:
|
|
818
|
+
self.close()
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|