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,761 @@
|
|
|
1
|
+
"""Graph query DSL parser and executor for dependency graph queries.
|
|
2
|
+
|
|
3
|
+
Supports a simple Cypher-inspired syntax for traversing the import graph::
|
|
4
|
+
|
|
5
|
+
MATCH "src/api/app.py" -[IMPORTS*1..3]-> target RETURN target
|
|
6
|
+
MATCH file <-[IMPORTED_BY]- caller WHERE caller.language = "python" RETURN caller
|
|
7
|
+
MATCH a -[IMPORTS]-> b -[IMPORTS]-> c RETURN a, c
|
|
8
|
+
|
|
9
|
+
Architecture:
|
|
10
|
+
- ``GraphQueryParser.parse(query) -> GraphQueryAST`` — regex tokenizer + recursive descent
|
|
11
|
+
- ``GraphQueryExecutor.execute(ast, dep_graph, files) -> list[dict]`` — BFS with depth constraints
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import re
|
|
18
|
+
from collections import deque
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# AST dataclasses
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class NodePattern:
|
|
29
|
+
"""A node in the MATCH pattern — either a concrete file or a variable."""
|
|
30
|
+
|
|
31
|
+
variable: str # Variable name (e.g. "target", "a") or empty for anonymous
|
|
32
|
+
file_pattern: str | None = None # Quoted literal path or glob (None = any file)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class EdgePattern:
|
|
37
|
+
"""A directed edge between two node patterns."""
|
|
38
|
+
|
|
39
|
+
source: int # Index into GraphQueryAST.nodes
|
|
40
|
+
target: int # Index into GraphQueryAST.nodes
|
|
41
|
+
edge_type: str # "IMPORTS" or "IMPORTED_BY"
|
|
42
|
+
min_depth: int = 1 # Minimum hops
|
|
43
|
+
max_depth: int = 1 # Maximum hops
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class WhereClause:
|
|
48
|
+
"""A single WHERE filter condition: variable.field op value."""
|
|
49
|
+
|
|
50
|
+
variable: str
|
|
51
|
+
field_name: str # "language", "line_count", "importance", "path", "fan_in", "fan_out"
|
|
52
|
+
operator: str # "=", "!=", ">", "<", ">=", "<=", "LIKE"
|
|
53
|
+
value: str # String value (numeric comparisons will be cast)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class ReturnSpec:
|
|
58
|
+
"""A variable (and optional field) to return."""
|
|
59
|
+
|
|
60
|
+
variable: str
|
|
61
|
+
field_name: str | None = None # If None, return the file path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class GraphQueryAST:
|
|
66
|
+
"""Parsed representation of a graph DSL query."""
|
|
67
|
+
|
|
68
|
+
nodes: list[NodePattern] = field(default_factory=list)
|
|
69
|
+
edges: list[EdgePattern] = field(default_factory=list)
|
|
70
|
+
where_clauses: list[WhereClause] = field(default_factory=list)
|
|
71
|
+
return_specs: list[ReturnSpec] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Tokenizer
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
# Token types
|
|
79
|
+
_TOKEN_PATTERNS = [
|
|
80
|
+
("KEYWORD", r"\b(?:MATCH|WHERE|RETURN|AND|OR|LIKE|COUNT)\b"),
|
|
81
|
+
("EDGE_RIGHT", r"-\["), # -[
|
|
82
|
+
("EDGE_RIGHT_CLOSE", r"\]->"), # ]->
|
|
83
|
+
("EDGE_LEFT", r"<-\["), # <-[
|
|
84
|
+
("EDGE_LEFT_CLOSE", r"\]-"), # ]-
|
|
85
|
+
("RANGE", r"\*(\d+)\.\.(\d+)"), # *1..3
|
|
86
|
+
("DEPTH_SINGLE", r"\*(\d+)"), # *2 (single depth = min..max same)
|
|
87
|
+
("OP", r"[!><]=?|="), # =, !=, >, <, >=, <=
|
|
88
|
+
("DOT", r"\."),
|
|
89
|
+
("COMMA", r","),
|
|
90
|
+
("QUOTED", r'"([^"]*)"'), # "quoted string"
|
|
91
|
+
("QUOTED_SINGLE", r"'([^']*)'"), # 'quoted string'
|
|
92
|
+
("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"),
|
|
93
|
+
("NUMBER", r"\d+(?:\.\d+)?"),
|
|
94
|
+
("WS", r"\s+"),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
_TOKEN_RE = re.compile("|".join(f"(?P<{name}>{pat})" for name, pat in _TOKEN_PATTERNS))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class Token:
|
|
102
|
+
"""A single token from the query string."""
|
|
103
|
+
|
|
104
|
+
kind: str
|
|
105
|
+
value: str
|
|
106
|
+
pos: int # Character position in original query
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tokenize(query: str) -> list[Token]:
|
|
110
|
+
"""Split a query string into tokens."""
|
|
111
|
+
tokens: list[Token] = []
|
|
112
|
+
for m in _TOKEN_RE.finditer(query):
|
|
113
|
+
kind = m.lastgroup
|
|
114
|
+
if kind == "WS":
|
|
115
|
+
continue
|
|
116
|
+
value = m.group()
|
|
117
|
+
tokens.append(Token(kind=kind, value=value, pos=m.start())) # type: ignore[arg-type]
|
|
118
|
+
return tokens
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Parser
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
class GraphQueryParser:
|
|
126
|
+
"""Parse a graph DSL query string into a ``GraphQueryAST``."""
|
|
127
|
+
|
|
128
|
+
def parse(self, query: str) -> GraphQueryAST:
|
|
129
|
+
"""Parse *query* and return the AST.
|
|
130
|
+
|
|
131
|
+
Raises ``ValueError`` on syntax errors with a descriptive message.
|
|
132
|
+
"""
|
|
133
|
+
tokens = _tokenize(query.strip())
|
|
134
|
+
if not tokens:
|
|
135
|
+
raise ValueError("Empty query")
|
|
136
|
+
|
|
137
|
+
self._tokens = tokens
|
|
138
|
+
self._pos = 0
|
|
139
|
+
self._ast = GraphQueryAST()
|
|
140
|
+
|
|
141
|
+
self._parse_match()
|
|
142
|
+
self._parse_optional_where()
|
|
143
|
+
self._parse_return()
|
|
144
|
+
|
|
145
|
+
if self._pos < len(self._tokens):
|
|
146
|
+
tok = self._tokens[self._pos]
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Unexpected token '{tok.value}' at position {tok.pos} "
|
|
149
|
+
f"(expected end of query)"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return self._ast
|
|
153
|
+
|
|
154
|
+
# -- Helpers --
|
|
155
|
+
|
|
156
|
+
def _peek(self) -> Token | None:
|
|
157
|
+
if self._pos < len(self._tokens):
|
|
158
|
+
return self._tokens[self._pos]
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _advance(self) -> Token:
|
|
162
|
+
if self._pos >= len(self._tokens):
|
|
163
|
+
raise ValueError("Unexpected end of query")
|
|
164
|
+
tok = self._tokens[self._pos]
|
|
165
|
+
self._pos += 1
|
|
166
|
+
return tok
|
|
167
|
+
|
|
168
|
+
def _expect(self, kind: str, value: str | None = None) -> Token:
|
|
169
|
+
tok = self._advance()
|
|
170
|
+
if tok.kind != kind or (value is not None and tok.value.upper() != value.upper()):
|
|
171
|
+
expected = f"'{value}'" if value else kind
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"Expected {expected} at position {tok.pos}, got '{tok.value}'"
|
|
174
|
+
)
|
|
175
|
+
return tok
|
|
176
|
+
|
|
177
|
+
def _expect_keyword(self, keyword: str) -> Token:
|
|
178
|
+
return self._expect("KEYWORD", keyword)
|
|
179
|
+
|
|
180
|
+
# -- MATCH clause --
|
|
181
|
+
|
|
182
|
+
def _parse_match(self) -> None:
|
|
183
|
+
self._expect_keyword("MATCH")
|
|
184
|
+
# Parse first node
|
|
185
|
+
self._parse_node()
|
|
186
|
+
|
|
187
|
+
# Parse alternating edge-node pairs
|
|
188
|
+
while self._pos < len(self._tokens):
|
|
189
|
+
tok = self._peek()
|
|
190
|
+
if tok is None:
|
|
191
|
+
break
|
|
192
|
+
# Check for edge start: -[ or <-[
|
|
193
|
+
if tok.kind in ("EDGE_RIGHT", "EDGE_LEFT"):
|
|
194
|
+
src_idx = len(self._ast.nodes) - 1
|
|
195
|
+
edge = self._parse_edge()
|
|
196
|
+
self._parse_node()
|
|
197
|
+
tgt_idx = len(self._ast.nodes) - 1
|
|
198
|
+
edge.source = src_idx
|
|
199
|
+
edge.target = tgt_idx
|
|
200
|
+
self._ast.edges.append(edge)
|
|
201
|
+
else:
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
def _parse_node(self) -> None:
|
|
205
|
+
"""Parse a node pattern: variable, "quoted path", or both."""
|
|
206
|
+
tok = self._peek()
|
|
207
|
+
if tok is None:
|
|
208
|
+
raise ValueError("Expected node pattern, got end of query")
|
|
209
|
+
|
|
210
|
+
variable = ""
|
|
211
|
+
file_pattern: str | None = None
|
|
212
|
+
|
|
213
|
+
if tok.kind in ("QUOTED", "QUOTED_SINGLE"):
|
|
214
|
+
self._advance()
|
|
215
|
+
# Extract the inner string (without quotes)
|
|
216
|
+
file_pattern = self._extract_quoted(tok)
|
|
217
|
+
elif tok.kind == "IDENT" and tok.value.upper() not in (
|
|
218
|
+
"MATCH", "WHERE", "RETURN", "AND", "OR", "LIKE", "COUNT",
|
|
219
|
+
):
|
|
220
|
+
self._advance()
|
|
221
|
+
variable = tok.value
|
|
222
|
+
else:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Expected variable name or quoted file path at position {tok.pos}, "
|
|
225
|
+
f"got '{tok.value}'"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self._ast.nodes.append(NodePattern(variable=variable, file_pattern=file_pattern))
|
|
229
|
+
|
|
230
|
+
def _parse_edge(self) -> EdgePattern:
|
|
231
|
+
"""Parse an edge pattern: -[TYPE*min..max]-> or <-[TYPE*min..max]-"""
|
|
232
|
+
tok = self._advance()
|
|
233
|
+
is_left = tok.kind == "EDGE_LEFT" # <-[...]- means reverse direction
|
|
234
|
+
|
|
235
|
+
# Parse edge type
|
|
236
|
+
edge_type = "IMPORTS"
|
|
237
|
+
min_depth = 1
|
|
238
|
+
max_depth = 1
|
|
239
|
+
|
|
240
|
+
# Read the edge contents (type and optional depth)
|
|
241
|
+
tok = self._peek()
|
|
242
|
+
if tok is not None and tok.kind == "IDENT":
|
|
243
|
+
self._advance()
|
|
244
|
+
edge_type = tok.value.upper()
|
|
245
|
+
if edge_type not in ("IMPORTS", "IMPORTED_BY"):
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"Unknown edge type '{edge_type}' at position {tok.pos}. "
|
|
248
|
+
f"Must be IMPORTS or IMPORTED_BY"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Check for depth range: *min..max or *depth
|
|
252
|
+
tok = self._peek()
|
|
253
|
+
if tok is not None and tok.kind == "RANGE":
|
|
254
|
+
self._advance()
|
|
255
|
+
m = re.match(r"\*(\d+)\.\.(\d+)", tok.value)
|
|
256
|
+
if m:
|
|
257
|
+
min_depth = int(m.group(1))
|
|
258
|
+
max_depth = int(m.group(2))
|
|
259
|
+
if min_depth < 0 or max_depth < min_depth:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"Invalid depth range *{min_depth}..{max_depth} "
|
|
262
|
+
f"at position {tok.pos}"
|
|
263
|
+
)
|
|
264
|
+
max_depth = min(max_depth, 10) # Safety cap
|
|
265
|
+
elif tok is not None and tok.kind == "DEPTH_SINGLE":
|
|
266
|
+
self._advance()
|
|
267
|
+
m = re.match(r"\*(\d+)", tok.value)
|
|
268
|
+
if m:
|
|
269
|
+
d = int(m.group(1))
|
|
270
|
+
min_depth = d
|
|
271
|
+
max_depth = d
|
|
272
|
+
max_depth = min(max_depth, 10)
|
|
273
|
+
|
|
274
|
+
# Close the edge
|
|
275
|
+
if is_left:
|
|
276
|
+
self._expect("EDGE_LEFT_CLOSE") # ]-
|
|
277
|
+
else:
|
|
278
|
+
self._expect("EDGE_RIGHT_CLOSE") # ]->
|
|
279
|
+
|
|
280
|
+
# For left-pointing edges (<-[TYPE]-), swap the semantics:
|
|
281
|
+
# <-[IMPORTS]- means "is imported by" = follow reverse graph
|
|
282
|
+
# <-[IMPORTED_BY]- means "imports" = follow forward graph
|
|
283
|
+
if is_left:
|
|
284
|
+
if edge_type == "IMPORTS":
|
|
285
|
+
edge_type = "IMPORTED_BY"
|
|
286
|
+
elif edge_type == "IMPORTED_BY":
|
|
287
|
+
edge_type = "IMPORTS"
|
|
288
|
+
|
|
289
|
+
return EdgePattern(
|
|
290
|
+
source=-1, # Will be set by caller
|
|
291
|
+
target=-1,
|
|
292
|
+
edge_type=edge_type,
|
|
293
|
+
min_depth=min_depth,
|
|
294
|
+
max_depth=max_depth,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# -- WHERE clause --
|
|
298
|
+
|
|
299
|
+
def _parse_optional_where(self) -> None:
|
|
300
|
+
tok = self._peek()
|
|
301
|
+
if tok is None or tok.kind != "KEYWORD" or tok.value.upper() != "WHERE":
|
|
302
|
+
return
|
|
303
|
+
self._advance() # consume WHERE
|
|
304
|
+
|
|
305
|
+
self._parse_where_condition()
|
|
306
|
+
while self._pos < len(self._tokens):
|
|
307
|
+
tok = self._peek()
|
|
308
|
+
if tok is not None and tok.kind == "KEYWORD" and tok.value.upper() == "AND":
|
|
309
|
+
self._advance()
|
|
310
|
+
self._parse_where_condition()
|
|
311
|
+
else:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
def _parse_where_condition(self) -> None:
|
|
315
|
+
"""Parse: variable.field op value"""
|
|
316
|
+
var_tok = self._advance()
|
|
317
|
+
if var_tok.kind != "IDENT":
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"Expected variable name in WHERE clause at position {var_tok.pos}, "
|
|
320
|
+
f"got '{var_tok.value}'"
|
|
321
|
+
)
|
|
322
|
+
variable = var_tok.value
|
|
323
|
+
|
|
324
|
+
self._expect("DOT")
|
|
325
|
+
|
|
326
|
+
field_tok = self._advance()
|
|
327
|
+
if field_tok.kind != "IDENT":
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"Expected field name after '.' at position {field_tok.pos}, "
|
|
330
|
+
f"got '{field_tok.value}'"
|
|
331
|
+
)
|
|
332
|
+
field_name = field_tok.value
|
|
333
|
+
|
|
334
|
+
# Operator: =, !=, >, <, >=, <=, or LIKE
|
|
335
|
+
op_tok = self._peek()
|
|
336
|
+
if op_tok is not None and op_tok.kind == "KEYWORD" and op_tok.value.upper() == "LIKE":
|
|
337
|
+
self._advance()
|
|
338
|
+
operator = "LIKE"
|
|
339
|
+
elif op_tok is not None and op_tok.kind == "OP":
|
|
340
|
+
self._advance()
|
|
341
|
+
operator = op_tok.value
|
|
342
|
+
else:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"Expected comparison operator at position "
|
|
345
|
+
f"{op_tok.pos if op_tok else 'end'}, "
|
|
346
|
+
f"got '{op_tok.value if op_tok else 'end of query'}'"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Value: quoted string or number or identifier
|
|
350
|
+
val_tok = self._advance()
|
|
351
|
+
if val_tok.kind in ("QUOTED", "QUOTED_SINGLE"):
|
|
352
|
+
value = self._extract_quoted(val_tok)
|
|
353
|
+
elif val_tok.kind == "NUMBER":
|
|
354
|
+
value = val_tok.value
|
|
355
|
+
elif val_tok.kind == "IDENT":
|
|
356
|
+
value = val_tok.value
|
|
357
|
+
else:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Expected value in WHERE clause at position {val_tok.pos}, "
|
|
360
|
+
f"got '{val_tok.value}'"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
valid_fields = {
|
|
364
|
+
"language", "line_count", "importance", "path",
|
|
365
|
+
"fan_in", "fan_out", "is_test", "is_config",
|
|
366
|
+
}
|
|
367
|
+
if field_name not in valid_fields:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"Unknown field '{field_name}' in WHERE clause. "
|
|
370
|
+
f"Valid fields: {', '.join(sorted(valid_fields))}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
self._ast.where_clauses.append(WhereClause(
|
|
374
|
+
variable=variable,
|
|
375
|
+
field_name=field_name,
|
|
376
|
+
operator=operator,
|
|
377
|
+
value=value,
|
|
378
|
+
))
|
|
379
|
+
|
|
380
|
+
# -- RETURN clause --
|
|
381
|
+
|
|
382
|
+
def _parse_return(self) -> None:
|
|
383
|
+
self._expect_keyword("RETURN")
|
|
384
|
+
|
|
385
|
+
self._parse_return_item()
|
|
386
|
+
while self._pos < len(self._tokens):
|
|
387
|
+
tok = self._peek()
|
|
388
|
+
if tok is not None and tok.kind == "COMMA":
|
|
389
|
+
self._advance()
|
|
390
|
+
self._parse_return_item()
|
|
391
|
+
else:
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
def _parse_return_item(self) -> None:
|
|
395
|
+
"""Parse: variable or variable.field or COUNT(variable)"""
|
|
396
|
+
tok = self._peek()
|
|
397
|
+
|
|
398
|
+
# COUNT(variable)
|
|
399
|
+
if tok is not None and tok.kind == "KEYWORD" and tok.value.upper() == "COUNT":
|
|
400
|
+
self._advance()
|
|
401
|
+
# We treat COUNT as a special return — just return "count" field
|
|
402
|
+
self._ast.return_specs.append(ReturnSpec(variable="*", field_name="count"))
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
var_tok = self._advance()
|
|
406
|
+
if var_tok.kind != "IDENT":
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"Expected variable name in RETURN clause at position {var_tok.pos}, "
|
|
409
|
+
f"got '{var_tok.value}'"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
field_name: str | None = None
|
|
413
|
+
dot_tok = self._peek()
|
|
414
|
+
if dot_tok is not None and dot_tok.kind == "DOT":
|
|
415
|
+
self._advance()
|
|
416
|
+
field_tok = self._advance()
|
|
417
|
+
if field_tok.kind != "IDENT":
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"Expected field name after '.' at position {field_tok.pos}, "
|
|
420
|
+
f"got '{field_tok.value}'"
|
|
421
|
+
)
|
|
422
|
+
field_name = field_tok.value
|
|
423
|
+
|
|
424
|
+
self._ast.return_specs.append(ReturnSpec(
|
|
425
|
+
variable=var_tok.value,
|
|
426
|
+
field_name=field_name,
|
|
427
|
+
))
|
|
428
|
+
|
|
429
|
+
# -- Utilities --
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _extract_quoted(tok: Token) -> str:
|
|
433
|
+
"""Extract the inner value from a QUOTED or QUOTED_SINGLE token."""
|
|
434
|
+
v = tok.value
|
|
435
|
+
if v.startswith('"') and v.endswith('"'):
|
|
436
|
+
return v[1:-1]
|
|
437
|
+
if v.startswith("'") and v.endswith("'"):
|
|
438
|
+
return v[1:-1]
|
|
439
|
+
return v
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# Executor
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
class GraphQueryExecutor:
|
|
447
|
+
"""Execute a parsed ``GraphQueryAST`` against a dependency graph.
|
|
448
|
+
|
|
449
|
+
Uses the same ``DependencyGraph`` (forward/reverse dicts) and
|
|
450
|
+
``FileInfo`` list that the existing graph_query tool uses.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
MAX_RESULTS = 100
|
|
454
|
+
|
|
455
|
+
def execute(
|
|
456
|
+
self,
|
|
457
|
+
ast: GraphQueryAST,
|
|
458
|
+
dep_graph: Any, # DependencyGraph with forward/reverse dicts
|
|
459
|
+
files: list[Any], # list[FileInfo]
|
|
460
|
+
) -> list[dict[str, Any]]:
|
|
461
|
+
"""Execute the query and return a list of result dicts.
|
|
462
|
+
|
|
463
|
+
Each result dict maps variable names to file paths (or field values
|
|
464
|
+
if a specific field is requested in RETURN).
|
|
465
|
+
"""
|
|
466
|
+
# Build file metadata lookup
|
|
467
|
+
file_meta: dict[str, dict[str, Any]] = {}
|
|
468
|
+
for fi in files:
|
|
469
|
+
rel = fi.relative_path
|
|
470
|
+
file_meta[rel] = {
|
|
471
|
+
"path": rel,
|
|
472
|
+
"language": fi.language,
|
|
473
|
+
"line_count": fi.line_count,
|
|
474
|
+
"importance": fi.importance,
|
|
475
|
+
"is_test": fi.is_test,
|
|
476
|
+
"is_config": fi.is_config,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
# Compute fan_in / fan_out lazily
|
|
480
|
+
all_known_files = set(file_meta.keys())
|
|
481
|
+
if dep_graph is not None:
|
|
482
|
+
for src in dep_graph.forward:
|
|
483
|
+
all_known_files.add(src)
|
|
484
|
+
all_known_files.update(dep_graph.forward[src])
|
|
485
|
+
for tgt in dep_graph.reverse:
|
|
486
|
+
all_known_files.add(tgt)
|
|
487
|
+
all_known_files.update(dep_graph.reverse[tgt])
|
|
488
|
+
|
|
489
|
+
for f in all_known_files:
|
|
490
|
+
if f not in file_meta:
|
|
491
|
+
file_meta[f] = {
|
|
492
|
+
"path": f,
|
|
493
|
+
"language": "",
|
|
494
|
+
"line_count": 0,
|
|
495
|
+
"importance": 0.0,
|
|
496
|
+
"is_test": False,
|
|
497
|
+
"is_config": False,
|
|
498
|
+
}
|
|
499
|
+
if dep_graph is not None:
|
|
500
|
+
file_meta[f]["fan_in"] = len(dep_graph.reverse.get(f, set()))
|
|
501
|
+
file_meta[f]["fan_out"] = len(dep_graph.forward.get(f, set()))
|
|
502
|
+
else:
|
|
503
|
+
file_meta[f]["fan_in"] = 0
|
|
504
|
+
file_meta[f]["fan_out"] = 0
|
|
505
|
+
|
|
506
|
+
# Resolve the query as a chain of node-edge-node patterns
|
|
507
|
+
# Start with bindings for the first node
|
|
508
|
+
bindings = self._resolve_start_node(ast.nodes[0], file_meta)
|
|
509
|
+
|
|
510
|
+
# For each edge, expand bindings via BFS
|
|
511
|
+
for edge in ast.edges:
|
|
512
|
+
bindings = self._traverse_edge(edge, ast.nodes, bindings, dep_graph, file_meta)
|
|
513
|
+
if not bindings:
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
# Apply WHERE filters
|
|
517
|
+
for clause in ast.where_clauses:
|
|
518
|
+
bindings = self._apply_where(clause, bindings, file_meta)
|
|
519
|
+
|
|
520
|
+
# Build return dicts
|
|
521
|
+
results = self._build_results(ast.return_specs, ast.nodes, bindings, file_meta)
|
|
522
|
+
|
|
523
|
+
# Deduplicate
|
|
524
|
+
seen: set[str] = set()
|
|
525
|
+
unique_results: list[dict[str, Any]] = []
|
|
526
|
+
for r in results:
|
|
527
|
+
key = str(sorted(r.items()))
|
|
528
|
+
if key not in seen:
|
|
529
|
+
seen.add(key)
|
|
530
|
+
unique_results.append(r)
|
|
531
|
+
|
|
532
|
+
return unique_results[:self.MAX_RESULTS]
|
|
533
|
+
|
|
534
|
+
# -- Internal --
|
|
535
|
+
|
|
536
|
+
def _resolve_start_node(
|
|
537
|
+
self,
|
|
538
|
+
node: NodePattern,
|
|
539
|
+
file_meta: dict[str, dict[str, Any]],
|
|
540
|
+
) -> list[dict[str, str]]:
|
|
541
|
+
"""Resolve the starting node to a list of variable bindings.
|
|
542
|
+
|
|
543
|
+
Each binding is a dict mapping variable names to file paths.
|
|
544
|
+
"""
|
|
545
|
+
if node.file_pattern is not None:
|
|
546
|
+
# Concrete file or glob pattern
|
|
547
|
+
matched = self._match_files(node.file_pattern, file_meta)
|
|
548
|
+
if not matched:
|
|
549
|
+
return []
|
|
550
|
+
var = node.variable or "_anon_0"
|
|
551
|
+
return [{var: f} for f in matched]
|
|
552
|
+
else:
|
|
553
|
+
# Unbound variable — starts from all known files
|
|
554
|
+
var = node.variable or "_anon_0"
|
|
555
|
+
return [{var: f} for f in file_meta]
|
|
556
|
+
|
|
557
|
+
def _traverse_edge(
|
|
558
|
+
self,
|
|
559
|
+
edge: EdgePattern,
|
|
560
|
+
nodes: list[NodePattern],
|
|
561
|
+
bindings: list[dict[str, str]],
|
|
562
|
+
dep_graph: Any,
|
|
563
|
+
file_meta: dict[str, dict[str, Any]],
|
|
564
|
+
) -> list[dict[str, str]]:
|
|
565
|
+
"""Expand each binding by traversing the edge via BFS."""
|
|
566
|
+
if dep_graph is None:
|
|
567
|
+
return []
|
|
568
|
+
|
|
569
|
+
src_node = nodes[edge.source]
|
|
570
|
+
tgt_node = nodes[edge.target]
|
|
571
|
+
src_var = src_node.variable or f"_anon_{edge.source}"
|
|
572
|
+
tgt_var = tgt_node.variable or f"_anon_{edge.target}"
|
|
573
|
+
|
|
574
|
+
use_reverse = edge.edge_type == "IMPORTED_BY"
|
|
575
|
+
|
|
576
|
+
new_bindings: list[dict[str, str]] = []
|
|
577
|
+
for binding in bindings:
|
|
578
|
+
src_file = binding.get(src_var)
|
|
579
|
+
if src_file is None:
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
# BFS from src_file with depth constraints
|
|
583
|
+
reachable = self._bfs(
|
|
584
|
+
src_file, dep_graph, use_reverse,
|
|
585
|
+
edge.min_depth, edge.max_depth,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# If target node has a file_pattern, filter reachable
|
|
589
|
+
if tgt_node.file_pattern is not None:
|
|
590
|
+
pattern_matches = set(self._match_files(tgt_node.file_pattern, file_meta))
|
|
591
|
+
reachable = reachable & pattern_matches
|
|
592
|
+
|
|
593
|
+
for target_file in reachable:
|
|
594
|
+
new_binding = dict(binding)
|
|
595
|
+
new_binding[tgt_var] = target_file
|
|
596
|
+
new_bindings.append(new_binding)
|
|
597
|
+
|
|
598
|
+
return new_bindings
|
|
599
|
+
|
|
600
|
+
def _bfs(
|
|
601
|
+
self,
|
|
602
|
+
start: str,
|
|
603
|
+
dep_graph: Any,
|
|
604
|
+
use_reverse: bool,
|
|
605
|
+
min_depth: int,
|
|
606
|
+
max_depth: int,
|
|
607
|
+
) -> set[str]:
|
|
608
|
+
"""BFS traversal from *start*, returning files at depth [min_depth, max_depth]."""
|
|
609
|
+
graph = dep_graph.reverse if use_reverse else dep_graph.forward
|
|
610
|
+
|
|
611
|
+
visited: set[str] = set()
|
|
612
|
+
result: set[str] = set()
|
|
613
|
+
queue: deque[tuple[str, int]] = deque([(start, 0)])
|
|
614
|
+
|
|
615
|
+
while queue:
|
|
616
|
+
current, depth = queue.popleft()
|
|
617
|
+
if current in visited:
|
|
618
|
+
continue
|
|
619
|
+
visited.add(current)
|
|
620
|
+
|
|
621
|
+
if min_depth <= depth <= max_depth and current != start:
|
|
622
|
+
result.add(current)
|
|
623
|
+
|
|
624
|
+
if depth < max_depth:
|
|
625
|
+
neighbors = graph.get(current, set())
|
|
626
|
+
for neighbor in neighbors:
|
|
627
|
+
if neighbor not in visited:
|
|
628
|
+
queue.append((neighbor, depth + 1))
|
|
629
|
+
|
|
630
|
+
return result
|
|
631
|
+
|
|
632
|
+
def _match_files(
|
|
633
|
+
self,
|
|
634
|
+
pattern: str,
|
|
635
|
+
file_meta: dict[str, dict[str, Any]],
|
|
636
|
+
) -> list[str]:
|
|
637
|
+
"""Match files against a literal path or glob pattern."""
|
|
638
|
+
# Exact match first
|
|
639
|
+
if pattern in file_meta:
|
|
640
|
+
return [pattern]
|
|
641
|
+
|
|
642
|
+
# Try glob matching
|
|
643
|
+
matched = [f for f in file_meta if fnmatch.fnmatch(f, pattern)]
|
|
644
|
+
if matched:
|
|
645
|
+
return sorted(matched)
|
|
646
|
+
|
|
647
|
+
# Try suffix matching (user might omit leading path components)
|
|
648
|
+
suffix_matched = [f for f in file_meta if f.endswith(pattern)]
|
|
649
|
+
if suffix_matched:
|
|
650
|
+
return sorted(suffix_matched)
|
|
651
|
+
|
|
652
|
+
return []
|
|
653
|
+
|
|
654
|
+
def _apply_where(
|
|
655
|
+
self,
|
|
656
|
+
clause: WhereClause,
|
|
657
|
+
bindings: list[dict[str, str]],
|
|
658
|
+
file_meta: dict[str, dict[str, Any]],
|
|
659
|
+
) -> list[dict[str, str]]:
|
|
660
|
+
"""Filter bindings by a WHERE condition."""
|
|
661
|
+
result: list[dict[str, str]] = []
|
|
662
|
+
for binding in bindings:
|
|
663
|
+
file_path = binding.get(clause.variable)
|
|
664
|
+
if file_path is None:
|
|
665
|
+
continue
|
|
666
|
+
meta = file_meta.get(file_path, {})
|
|
667
|
+
field_val = meta.get(clause.field_name)
|
|
668
|
+
if field_val is None:
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
if self._eval_condition(field_val, clause.operator, clause.value):
|
|
672
|
+
result.append(binding)
|
|
673
|
+
|
|
674
|
+
return result
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _eval_condition(field_val: Any, operator: str, raw_value: str) -> bool:
|
|
678
|
+
"""Evaluate a single comparison condition."""
|
|
679
|
+
if operator == "LIKE":
|
|
680
|
+
# LIKE uses fnmatch-style globbing
|
|
681
|
+
return fnmatch.fnmatch(str(field_val), raw_value)
|
|
682
|
+
|
|
683
|
+
# Try numeric comparison
|
|
684
|
+
if isinstance(field_val, (int, float)):
|
|
685
|
+
try:
|
|
686
|
+
cmp_val = float(raw_value)
|
|
687
|
+
except ValueError:
|
|
688
|
+
return str(field_val) == raw_value
|
|
689
|
+
if operator == "=":
|
|
690
|
+
return field_val == cmp_val
|
|
691
|
+
if operator == "!=":
|
|
692
|
+
return field_val != cmp_val
|
|
693
|
+
if operator == ">":
|
|
694
|
+
return field_val > cmp_val
|
|
695
|
+
if operator == "<":
|
|
696
|
+
return field_val < cmp_val
|
|
697
|
+
if operator == ">=":
|
|
698
|
+
return field_val >= cmp_val
|
|
699
|
+
if operator == "<=":
|
|
700
|
+
return field_val <= cmp_val
|
|
701
|
+
|
|
702
|
+
# Boolean comparison
|
|
703
|
+
if isinstance(field_val, bool):
|
|
704
|
+
cmp_val_bool = raw_value.lower() in ("true", "1", "yes")
|
|
705
|
+
if operator == "=":
|
|
706
|
+
return field_val == cmp_val_bool
|
|
707
|
+
if operator == "!=":
|
|
708
|
+
return field_val != cmp_val_bool
|
|
709
|
+
|
|
710
|
+
# String comparison
|
|
711
|
+
str_val = str(field_val)
|
|
712
|
+
if operator == "=":
|
|
713
|
+
return str_val == raw_value
|
|
714
|
+
if operator == "!=":
|
|
715
|
+
return str_val != raw_value
|
|
716
|
+
if operator == ">":
|
|
717
|
+
return str_val > raw_value
|
|
718
|
+
if operator == "<":
|
|
719
|
+
return str_val < raw_value
|
|
720
|
+
if operator == ">=":
|
|
721
|
+
return str_val >= raw_value
|
|
722
|
+
if operator == "<=":
|
|
723
|
+
return str_val <= raw_value
|
|
724
|
+
|
|
725
|
+
return False
|
|
726
|
+
|
|
727
|
+
def _build_results(
|
|
728
|
+
self,
|
|
729
|
+
return_specs: list[ReturnSpec],
|
|
730
|
+
nodes: list[NodePattern],
|
|
731
|
+
bindings: list[dict[str, str]],
|
|
732
|
+
file_meta: dict[str, dict[str, Any]],
|
|
733
|
+
) -> list[dict[str, Any]]:
|
|
734
|
+
"""Build the final result dicts from bindings and return specs."""
|
|
735
|
+
# Handle COUNT special case
|
|
736
|
+
if any(r.field_name == "count" and r.variable == "*" for r in return_specs):
|
|
737
|
+
return [{"count": len(bindings)}]
|
|
738
|
+
|
|
739
|
+
results: list[dict[str, Any]] = []
|
|
740
|
+
for binding in bindings:
|
|
741
|
+
row: dict[str, Any] = {}
|
|
742
|
+
for spec in return_specs:
|
|
743
|
+
file_path = binding.get(spec.variable)
|
|
744
|
+
if file_path is None:
|
|
745
|
+
# Try to find the variable by node index
|
|
746
|
+
for i, node in enumerate(nodes):
|
|
747
|
+
if node.variable == spec.variable:
|
|
748
|
+
file_path = binding.get(node.variable)
|
|
749
|
+
break
|
|
750
|
+
if file_path is None:
|
|
751
|
+
row[spec.variable] = None
|
|
752
|
+
continue
|
|
753
|
+
|
|
754
|
+
if spec.field_name is not None:
|
|
755
|
+
meta = file_meta.get(file_path, {})
|
|
756
|
+
row[f"{spec.variable}.{spec.field_name}"] = meta.get(spec.field_name)
|
|
757
|
+
else:
|
|
758
|
+
row[spec.variable] = file_path
|
|
759
|
+
results.append(row)
|
|
760
|
+
|
|
761
|
+
return results
|