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,531 @@
|
|
|
1
|
+
"""Rule executor — runs rules against source files to produce findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from attocode_intel.rules.combinators import MatchContext
|
|
13
|
+
from attocode_intel.rules.metavar import (
|
|
14
|
+
apply_metavar_fix,
|
|
15
|
+
check_metavar_constraints,
|
|
16
|
+
interpolate_message,
|
|
17
|
+
)
|
|
18
|
+
from attocode_intel.rules.model import (
|
|
19
|
+
EnrichedFinding,
|
|
20
|
+
RuleTier,
|
|
21
|
+
UnifiedRule,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Per-rule subprocess timeout for ast-grep (seconds). Structural patterns
|
|
27
|
+
# can be expensive on huge files; cap to keep ``analyze`` responsive.
|
|
28
|
+
_AST_GREP_TIMEOUT = 30.0
|
|
29
|
+
|
|
30
|
+
# Languages where ast-grep ships a tree-sitter grammar. When a structural
|
|
31
|
+
# rule targets a language outside this set we either skip the rule (no
|
|
32
|
+
# fallback regex) or fall back to ``rule.pattern`` if the YAML defined one.
|
|
33
|
+
_AST_GREP_LANGS = frozenset({
|
|
34
|
+
"bash", "c", "cpp", "csharp", "css", "elixir", "go", "html", "java",
|
|
35
|
+
"javascript", "json", "kotlin", "lua", "php", "python", "ruby", "rust",
|
|
36
|
+
"scala", "shell", "swift", "tsx", "typescript", "yaml",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
# Extension to language mapping
|
|
40
|
+
_EXT_LANG: dict[str, str] = {
|
|
41
|
+
".py": "python", ".pyi": "python",
|
|
42
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
43
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
44
|
+
".go": "go",
|
|
45
|
+
".rs": "rust",
|
|
46
|
+
".java": "java",
|
|
47
|
+
".kt": "kotlin", ".kts": "kotlin",
|
|
48
|
+
".rb": "ruby",
|
|
49
|
+
".php": "php",
|
|
50
|
+
".swift": "swift",
|
|
51
|
+
".cs": "csharp",
|
|
52
|
+
".c": "c", ".h": "c",
|
|
53
|
+
".cpp": "cpp", ".hpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
|
|
54
|
+
".lua": "lua",
|
|
55
|
+
".sh": "shell", ".bash": "shell", ".zsh": "shell",
|
|
56
|
+
".scala": "scala",
|
|
57
|
+
".ex": "elixir", ".exs": "elixir",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Comment prefix detection per language
|
|
61
|
+
_COMMENT_PREFIX: dict[str, list[str]] = {
|
|
62
|
+
"python": ["#"],
|
|
63
|
+
"javascript": ["//"],
|
|
64
|
+
"typescript": ["//"],
|
|
65
|
+
"go": ["//"],
|
|
66
|
+
"rust": ["//"],
|
|
67
|
+
"java": ["//"],
|
|
68
|
+
"kotlin": ["//"],
|
|
69
|
+
"ruby": ["#"],
|
|
70
|
+
"php": ["//", "#"],
|
|
71
|
+
"swift": ["//"],
|
|
72
|
+
"csharp": ["//"],
|
|
73
|
+
"c": ["//"],
|
|
74
|
+
"cpp": ["//"],
|
|
75
|
+
"lua": ["--"],
|
|
76
|
+
"shell": ["#"],
|
|
77
|
+
"scala": ["//"],
|
|
78
|
+
"elixir": ["#"],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_language(file_path: str) -> str:
|
|
83
|
+
"""Detect language from file extension."""
|
|
84
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
85
|
+
return _EXT_LANG.get(ext, "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_comment_line(line: str, language: str) -> bool:
|
|
89
|
+
stripped = line.lstrip()
|
|
90
|
+
for prefix in _COMMENT_PREFIX.get(language, []):
|
|
91
|
+
if stripped.startswith(prefix):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ast_grep_available() -> bool:
|
|
97
|
+
"""Return True when an ``sg`` or ``ast-grep`` binary is on PATH."""
|
|
98
|
+
return shutil.which("sg") is not None or shutil.which("ast-grep") is not None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ast_grep_binary() -> str:
|
|
102
|
+
"""Resolve the ast-grep binary, preferring the short ``sg`` alias."""
|
|
103
|
+
return shutil.which("sg") or shutil.which("ast-grep") or "sg"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ast-grep's ``language`` field uses TitleCase ("Go", "Python") whereas
|
|
107
|
+
# our internal language tags are lowercase. Mapping covers the cases that
|
|
108
|
+
# differ from a naive ``.title()``.
|
|
109
|
+
_AST_GREP_LANG_NAMES: dict[str, str] = {
|
|
110
|
+
"javascript": "JavaScript",
|
|
111
|
+
"typescript": "TypeScript",
|
|
112
|
+
"tsx": "Tsx",
|
|
113
|
+
"csharp": "CSharp",
|
|
114
|
+
"cpp": "Cpp",
|
|
115
|
+
"html": "Html",
|
|
116
|
+
"css": "Css",
|
|
117
|
+
"json": "Json",
|
|
118
|
+
"yaml": "Yaml",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ast_grep_lang_name(lang: str) -> str:
|
|
123
|
+
return _AST_GREP_LANG_NAMES.get(lang, lang.title())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _yaml_quote(value: str) -> str:
|
|
127
|
+
"""Render ``value`` as a YAML double-quoted scalar with escapes.
|
|
128
|
+
|
|
129
|
+
Double-quoted scalars handle every printable + control character via
|
|
130
|
+
backslash escapes. Single-quoted scalars cannot represent literal
|
|
131
|
+
newlines or tabs, so synthesised / evolved patterns containing those
|
|
132
|
+
(which are legal ast-grep input) would corrupt the inline-rules YAML.
|
|
133
|
+
"""
|
|
134
|
+
escaped = (
|
|
135
|
+
value.replace("\\", "\\\\")
|
|
136
|
+
.replace('"', '\\"')
|
|
137
|
+
.replace("\n", "\\n")
|
|
138
|
+
.replace("\r", "\\r")
|
|
139
|
+
.replace("\t", "\\t")
|
|
140
|
+
)
|
|
141
|
+
return '"' + escaped + '"'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_inline_rule_yaml(rule: UnifiedRule, lang: str) -> str:
|
|
145
|
+
"""Build the minimal ast-grep YAML rule for ``sg scan --inline-rules``.
|
|
146
|
+
|
|
147
|
+
Used when the rule provides ``structural_context`` and/or
|
|
148
|
+
``structural_selector`` to disambiguate the parse.
|
|
149
|
+
|
|
150
|
+
ast-grep accepts two pattern shapes under ``rule.pattern``:
|
|
151
|
+
|
|
152
|
+
* a bare string — the simple pattern, no disambiguation
|
|
153
|
+
* an object with ``context`` (the full snippet that parses correctly)
|
|
154
|
+
and ``selector`` (the AST kind to extract from inside it). The plain
|
|
155
|
+
``pattern`` field on the object is *not* used when context drives
|
|
156
|
+
matching; including it makes the YAML invalid.
|
|
157
|
+
"""
|
|
158
|
+
parts: list[str] = [
|
|
159
|
+
f"id: {rule.id or 'inline'}",
|
|
160
|
+
f"language: {_ast_grep_lang_name(lang)}",
|
|
161
|
+
"rule:",
|
|
162
|
+
]
|
|
163
|
+
if rule.structural_context:
|
|
164
|
+
# Context without selector produces invalid ast-grep YAML — the
|
|
165
|
+
# parser needs a selector to know which AST kind to extract from
|
|
166
|
+
# the wrapping context. Loader-level validation rejects pack rules
|
|
167
|
+
# in this shape; this guard catches programmatically-constructed
|
|
168
|
+
# rules (synthesis / evolution / direct UnifiedRule(...) callers)
|
|
169
|
+
# so they fail loudly instead of silently no-op'ing.
|
|
170
|
+
if not rule.structural_selector:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"rule {rule.qualified_id!r} has structural_context but no "
|
|
173
|
+
"structural_selector; ast-grep needs both to extract a "
|
|
174
|
+
"node from the wrapping context"
|
|
175
|
+
)
|
|
176
|
+
parts.append(" pattern:")
|
|
177
|
+
parts.append(f" context: {_yaml_quote(rule.structural_context)}")
|
|
178
|
+
parts.append(f" selector: {rule.structural_selector}")
|
|
179
|
+
elif rule.structural_selector:
|
|
180
|
+
# Selector without context: pair it with the pattern as object.
|
|
181
|
+
parts.append(" pattern:")
|
|
182
|
+
parts.append(f" pattern: {_yaml_quote(rule.structural_pattern)}")
|
|
183
|
+
parts.append(f" selector: {rule.structural_selector}")
|
|
184
|
+
else:
|
|
185
|
+
parts.append(f" pattern: {_yaml_quote(rule.structural_pattern)}")
|
|
186
|
+
return "\n".join(parts) + "\n"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _execute_structural_rule(
|
|
190
|
+
rule: UnifiedRule,
|
|
191
|
+
files_by_lang: dict[str, list[str]],
|
|
192
|
+
*,
|
|
193
|
+
project_dir: str = "",
|
|
194
|
+
) -> list[EnrichedFinding]:
|
|
195
|
+
"""Run a single structural rule across applicable files via ast-grep.
|
|
196
|
+
|
|
197
|
+
Per language: shells out to ``sg run --pattern <pat> --lang <lang>
|
|
198
|
+
--json=stream <files...>`` and parses one match per JSON line. Files
|
|
199
|
+
in languages without an ast-grep grammar are silently skipped — the
|
|
200
|
+
caller is responsible for supplying ``rule.pattern`` if they want a
|
|
201
|
+
Tier-1 fallback for those languages.
|
|
202
|
+
|
|
203
|
+
Returns the list of (partially populated) ``EnrichedFinding`` objects.
|
|
204
|
+
"""
|
|
205
|
+
if not rule.structural_pattern:
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
# Determine which languages this rule applies to. An empty
|
|
209
|
+
# ``rule.languages`` means "all languages we have files for".
|
|
210
|
+
target_langs: list[str]
|
|
211
|
+
if rule.languages:
|
|
212
|
+
target_langs = [lang for lang in rule.languages if lang in files_by_lang]
|
|
213
|
+
else:
|
|
214
|
+
target_langs = list(files_by_lang.keys())
|
|
215
|
+
|
|
216
|
+
binary = _ast_grep_binary()
|
|
217
|
+
findings: list[EnrichedFinding] = []
|
|
218
|
+
|
|
219
|
+
for lang in target_langs:
|
|
220
|
+
if lang not in _AST_GREP_LANGS:
|
|
221
|
+
continue
|
|
222
|
+
files = files_by_lang.get(lang, [])
|
|
223
|
+
if not files:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# When the rule needs context/selector to disambiguate parsing
|
|
227
|
+
# (e.g. ``fmt.Println($X)`` in Go), shell out to ``sg scan
|
|
228
|
+
# --inline-rules`` with a YAML rule body. Otherwise the simpler
|
|
229
|
+
# ``sg run --pattern`` path is faster.
|
|
230
|
+
if rule.structural_context or rule.structural_selector:
|
|
231
|
+
inline_yaml = _build_inline_rule_yaml(rule, lang)
|
|
232
|
+
cmd = [
|
|
233
|
+
binary, "scan",
|
|
234
|
+
"--inline-rules", inline_yaml,
|
|
235
|
+
"--json=stream",
|
|
236
|
+
*files,
|
|
237
|
+
]
|
|
238
|
+
else:
|
|
239
|
+
cmd = [
|
|
240
|
+
binary, "run",
|
|
241
|
+
"--pattern", rule.structural_pattern,
|
|
242
|
+
"--lang", lang,
|
|
243
|
+
"--json=stream",
|
|
244
|
+
*files,
|
|
245
|
+
]
|
|
246
|
+
try:
|
|
247
|
+
result = subprocess.run(
|
|
248
|
+
cmd,
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
timeout=_AST_GREP_TIMEOUT,
|
|
252
|
+
check=False,
|
|
253
|
+
)
|
|
254
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"ast-grep failed for rule %s lang=%s: %s",
|
|
257
|
+
rule.qualified_id, lang, exc,
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# ast-grep returns non-zero when *no* matches are found. Only treat
|
|
262
|
+
# stderr-with-actual-content as an error signal.
|
|
263
|
+
if result.returncode not in (0, 1) and result.stderr.strip():
|
|
264
|
+
logger.warning(
|
|
265
|
+
"ast-grep error for rule %s lang=%s: %s",
|
|
266
|
+
rule.qualified_id, lang, result.stderr.strip()[:200],
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
for line in result.stdout.splitlines():
|
|
271
|
+
line = line.strip()
|
|
272
|
+
if not line:
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
match = json.loads(line)
|
|
276
|
+
except json.JSONDecodeError:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
file_path = match.get("file", "")
|
|
280
|
+
start = match.get("range", {}).get("start", {})
|
|
281
|
+
line_no = int(start.get("line", 0)) + 1 # ast-grep is 0-based
|
|
282
|
+
snippet = (match.get("lines") or "").rstrip()[:200]
|
|
283
|
+
|
|
284
|
+
captures: dict[str, str] = {}
|
|
285
|
+
metavars = match.get("metaVariables") or match.get("metavariables") or {}
|
|
286
|
+
singles = metavars.get("single") or {}
|
|
287
|
+
for var_name, info in singles.items():
|
|
288
|
+
text = info.get("text") if isinstance(info, dict) else None
|
|
289
|
+
if isinstance(text, str):
|
|
290
|
+
captures[var_name] = text
|
|
291
|
+
# ast-grep emits ``$$$X`` (variadic) bindings under ``multi``.
|
|
292
|
+
# Concatenate the segment texts so description templates that
|
|
293
|
+
# reference ``$X`` still resolve to something readable.
|
|
294
|
+
multis = metavars.get("multi") or {}
|
|
295
|
+
for var_name, segments in multis.items():
|
|
296
|
+
if var_name in captures or not isinstance(segments, list):
|
|
297
|
+
continue
|
|
298
|
+
parts = [
|
|
299
|
+
s.get("text", "") for s in segments
|
|
300
|
+
if isinstance(s, dict) and isinstance(s.get("text"), str)
|
|
301
|
+
]
|
|
302
|
+
if parts:
|
|
303
|
+
captures[var_name] = "".join(parts)
|
|
304
|
+
|
|
305
|
+
description = rule.description
|
|
306
|
+
if captures:
|
|
307
|
+
description = interpolate_message(description, captures)
|
|
308
|
+
|
|
309
|
+
suggested_fix = ""
|
|
310
|
+
if rule.fix:
|
|
311
|
+
if rule.fix.uses_metavars and captures:
|
|
312
|
+
sf_search, sf_replace = apply_metavar_fix(
|
|
313
|
+
rule.fix.search, rule.fix.replace, captures,
|
|
314
|
+
)
|
|
315
|
+
suggested_fix = f"{sf_search} → {sf_replace}"
|
|
316
|
+
else:
|
|
317
|
+
suggested_fix = f"{rule.fix.search} → {rule.fix.replace}"
|
|
318
|
+
|
|
319
|
+
rel_path = file_path
|
|
320
|
+
if project_dir:
|
|
321
|
+
try:
|
|
322
|
+
rel_path = os.path.relpath(file_path, project_dir)
|
|
323
|
+
except ValueError:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
findings.append(EnrichedFinding(
|
|
327
|
+
rule_id=rule.qualified_id,
|
|
328
|
+
rule_name=rule.name,
|
|
329
|
+
severity=rule.severity,
|
|
330
|
+
category=rule.category,
|
|
331
|
+
confidence=rule.confidence,
|
|
332
|
+
file=rel_path,
|
|
333
|
+
line=line_no,
|
|
334
|
+
code_snippet=snippet,
|
|
335
|
+
description=description,
|
|
336
|
+
explanation=rule.explanation,
|
|
337
|
+
recommendation=rule.recommendation,
|
|
338
|
+
examples=list(rule.examples),
|
|
339
|
+
suggested_fix=suggested_fix,
|
|
340
|
+
captures=captures,
|
|
341
|
+
cwe=rule.cwe,
|
|
342
|
+
pack=rule.pack,
|
|
343
|
+
tags=list(rule.tags),
|
|
344
|
+
))
|
|
345
|
+
return findings
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def execute_rules(
|
|
349
|
+
files: list[str],
|
|
350
|
+
rules: list[UnifiedRule],
|
|
351
|
+
*,
|
|
352
|
+
project_dir: str = "",
|
|
353
|
+
) -> list[EnrichedFinding]:
|
|
354
|
+
"""Execute rules against a list of files.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
files: Absolute file paths to scan.
|
|
358
|
+
rules: Rules to execute (should be pre-filtered to enabled only).
|
|
359
|
+
project_dir: Project root for relative path computation.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of EnrichedFinding (partially populated — use enricher for full context).
|
|
363
|
+
"""
|
|
364
|
+
findings: list[EnrichedFinding] = []
|
|
365
|
+
|
|
366
|
+
# ----- Tier 2: structural rules via ast-grep --------------------------
|
|
367
|
+
structural_rules = [
|
|
368
|
+
r for r in rules
|
|
369
|
+
if r.tier == RuleTier.STRUCTURAL and r.structural_pattern
|
|
370
|
+
]
|
|
371
|
+
if structural_rules:
|
|
372
|
+
if _ast_grep_available():
|
|
373
|
+
files_by_lang: dict[str, list[str]] = {}
|
|
374
|
+
for fp in files:
|
|
375
|
+
lang = detect_language(fp)
|
|
376
|
+
if lang:
|
|
377
|
+
files_by_lang.setdefault(lang, []).append(fp)
|
|
378
|
+
for rule in structural_rules:
|
|
379
|
+
findings.extend(_execute_structural_rule(
|
|
380
|
+
rule, files_by_lang, project_dir=project_dir,
|
|
381
|
+
))
|
|
382
|
+
else:
|
|
383
|
+
logger.info(
|
|
384
|
+
"ast-grep not on PATH; skipping %d structural rule(s). "
|
|
385
|
+
"Install with: cargo install ast-grep || brew install ast-grep",
|
|
386
|
+
len(structural_rules),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Pre-group rules by language for fast lookup. Tier-1 rules always run
|
|
390
|
+
# here; Tier-2 (structural) rules also run here as a regex fallback
|
|
391
|
+
# for languages where ast-grep can't help — either because the language
|
|
392
|
+
# has no ast-grep grammar, or because the binary is missing entirely.
|
|
393
|
+
# Roadmap Phase 1: "Fallback to regex Tier 1 for languages without
|
|
394
|
+
# ast-grep grammars".
|
|
395
|
+
ast_grep_ok = _ast_grep_available()
|
|
396
|
+
lang_rules: dict[str, list[UnifiedRule]] = {}
|
|
397
|
+
universal_rules: list[UnifiedRule] = []
|
|
398
|
+
|
|
399
|
+
for rule in rules:
|
|
400
|
+
if rule.pattern is None and rule.composite_pattern is None:
|
|
401
|
+
continue # no executable pattern
|
|
402
|
+
|
|
403
|
+
regex_langs: list[str]
|
|
404
|
+
if rule.tier == RuleTier.REGEX:
|
|
405
|
+
regex_langs = list(rule.languages)
|
|
406
|
+
regex_universal = not rule.languages
|
|
407
|
+
elif rule.tier == RuleTier.STRUCTURAL:
|
|
408
|
+
# Only run the regex fallback when ast-grep can't cover the rule.
|
|
409
|
+
if ast_grep_ok:
|
|
410
|
+
if not rule.languages:
|
|
411
|
+
continue # universal structural — ast-grep handled it
|
|
412
|
+
regex_langs = [
|
|
413
|
+
lang for lang in rule.languages
|
|
414
|
+
if lang not in _AST_GREP_LANGS
|
|
415
|
+
]
|
|
416
|
+
regex_universal = False
|
|
417
|
+
if not regex_langs:
|
|
418
|
+
continue
|
|
419
|
+
else:
|
|
420
|
+
# ast-grep missing — fall back to regex everywhere the rule
|
|
421
|
+
# was meant to apply.
|
|
422
|
+
regex_langs = list(rule.languages)
|
|
423
|
+
regex_universal = not rule.languages
|
|
424
|
+
else:
|
|
425
|
+
continue # plugin tier
|
|
426
|
+
|
|
427
|
+
if regex_universal:
|
|
428
|
+
universal_rules.append(rule)
|
|
429
|
+
else:
|
|
430
|
+
for lang in regex_langs:
|
|
431
|
+
lang_rules.setdefault(lang, []).append(rule)
|
|
432
|
+
|
|
433
|
+
for file_path in files:
|
|
434
|
+
lang = detect_language(file_path)
|
|
435
|
+
applicable = list(universal_rules)
|
|
436
|
+
if lang:
|
|
437
|
+
applicable.extend(lang_rules.get(lang, []))
|
|
438
|
+
if not applicable:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
content = Path(file_path).read_text(encoding="utf-8", errors="replace")
|
|
443
|
+
except OSError:
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
lines = content.splitlines()
|
|
447
|
+
rel_path = file_path
|
|
448
|
+
if project_dir:
|
|
449
|
+
try:
|
|
450
|
+
rel_path = os.path.relpath(file_path, project_dir)
|
|
451
|
+
except ValueError:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
for i, line in enumerate(lines):
|
|
455
|
+
line_no = i + 1
|
|
456
|
+
is_comment = _is_comment_line(line, lang)
|
|
457
|
+
|
|
458
|
+
for rule in applicable:
|
|
459
|
+
if is_comment and not rule.scan_comments:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
captures: dict[str, str] = {}
|
|
463
|
+
|
|
464
|
+
# --- Composite pattern path ---
|
|
465
|
+
if rule.composite_pattern is not None:
|
|
466
|
+
ctx = MatchContext(
|
|
467
|
+
line=line, line_no=line_no, all_lines=lines,
|
|
468
|
+
)
|
|
469
|
+
if not rule.composite_pattern.evaluate(ctx):
|
|
470
|
+
continue
|
|
471
|
+
captures = ctx.captures
|
|
472
|
+
|
|
473
|
+
# --- Simple pattern path ---
|
|
474
|
+
elif rule.pattern is not None:
|
|
475
|
+
match = rule.pattern.search(line)
|
|
476
|
+
if not match:
|
|
477
|
+
continue
|
|
478
|
+
if rule.metavars:
|
|
479
|
+
captures = {
|
|
480
|
+
k: v for k, v in match.groupdict().items()
|
|
481
|
+
if v is not None
|
|
482
|
+
}
|
|
483
|
+
if not check_metavar_constraints(
|
|
484
|
+
captures, rule.metavar_regex, rule.metavar_comparison,
|
|
485
|
+
):
|
|
486
|
+
continue
|
|
487
|
+
else:
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# Build description with metavar interpolation
|
|
491
|
+
description = rule.description
|
|
492
|
+
if captures:
|
|
493
|
+
description = interpolate_message(description, captures)
|
|
494
|
+
|
|
495
|
+
# Build suggested fix
|
|
496
|
+
suggested_fix = ""
|
|
497
|
+
if rule.fix:
|
|
498
|
+
if rule.fix.uses_metavars and captures:
|
|
499
|
+
sf_search, sf_replace = apply_metavar_fix(
|
|
500
|
+
rule.fix.search, rule.fix.replace, captures,
|
|
501
|
+
)
|
|
502
|
+
suggested_fix = f"{sf_search} \u2192 {sf_replace}"
|
|
503
|
+
else:
|
|
504
|
+
suggested_fix = f"{rule.fix.search} \u2192 {rule.fix.replace}"
|
|
505
|
+
|
|
506
|
+
findings.append(EnrichedFinding(
|
|
507
|
+
rule_id=rule.qualified_id,
|
|
508
|
+
rule_name=rule.name,
|
|
509
|
+
severity=rule.severity,
|
|
510
|
+
category=rule.category,
|
|
511
|
+
confidence=rule.confidence,
|
|
512
|
+
file=rel_path,
|
|
513
|
+
line=line_no,
|
|
514
|
+
# Provider redaction must see the complete literal before
|
|
515
|
+
# imposing a payload budget (a truncated key may not match).
|
|
516
|
+
code_snippet=line.rstrip(),
|
|
517
|
+
description=description,
|
|
518
|
+
explanation=rule.explanation,
|
|
519
|
+
recommendation=rule.recommendation,
|
|
520
|
+
examples=list(rule.examples),
|
|
521
|
+
suggested_fix=suggested_fix,
|
|
522
|
+
captures=dict(captures),
|
|
523
|
+
cwe=rule.cwe,
|
|
524
|
+
pack=rule.pack,
|
|
525
|
+
tags=list(rule.tags),
|
|
526
|
+
))
|
|
527
|
+
|
|
528
|
+
# Sort: severity first, then file, then line
|
|
529
|
+
_sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
530
|
+
findings.sort(key=lambda f: (_sev_order.get(f.severity, 9), f.file, f.line))
|
|
531
|
+
return findings
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pre-filter pipeline — orchestrates deterministic finding filters.
|
|
2
|
+
|
|
3
|
+
Reduces noise before the connected coding agent sees findings:
|
|
4
|
+
1. Dedup — remove exact duplicates and merge overlapping findings
|
|
5
|
+
2. Test file adjuster — lower severity for test/spec files
|
|
6
|
+
3. Confidence threshold — drop findings below minimum confidence
|
|
7
|
+
|
|
8
|
+
Deterministic by default. Setting ATTOCODE_FLAG_CONFIDENCE adds one model call
|
|
9
|
+
per finding between stages 2 and 3, which second-guesses the constants.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from attocode_intel.rules.model import EnrichedFinding, RuleSeverity
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Test file patterns
|
|
23
|
+
_TEST_FILE_PATTERNS = [
|
|
24
|
+
re.compile(r"test[_/]"),
|
|
25
|
+
re.compile(r"_test\.\w+$"),
|
|
26
|
+
re.compile(r"\.test\.\w+$"),
|
|
27
|
+
re.compile(r"\.spec\.\w+$"),
|
|
28
|
+
re.compile(r"__tests__/"),
|
|
29
|
+
re.compile(r"tests?/"),
|
|
30
|
+
re.compile(r"conftest\.py$"),
|
|
31
|
+
re.compile(r"fixtures?/"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_test_file(file_path: str) -> bool:
|
|
36
|
+
normalized = file_path.replace(os.sep, "/")
|
|
37
|
+
return any(p.search(normalized) for p in _TEST_FILE_PATTERNS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Severity demotion map for test files
|
|
41
|
+
_SEVERITY_DEMOTION = {
|
|
42
|
+
RuleSeverity.CRITICAL: RuleSeverity.HIGH,
|
|
43
|
+
RuleSeverity.HIGH: RuleSeverity.MEDIUM,
|
|
44
|
+
RuleSeverity.MEDIUM: RuleSeverity.LOW,
|
|
45
|
+
RuleSeverity.LOW: RuleSeverity.INFO,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dedup_findings(findings: list[EnrichedFinding]) -> list[EnrichedFinding]:
|
|
50
|
+
"""Remove exact duplicates and merge overlapping findings.
|
|
51
|
+
|
|
52
|
+
Two findings overlap if they're on the same file:line with the same
|
|
53
|
+
category. Keep the one with higher confidence.
|
|
54
|
+
"""
|
|
55
|
+
seen: dict[tuple[str, int, str], EnrichedFinding] = {}
|
|
56
|
+
for f in findings:
|
|
57
|
+
key = (f.file, f.line, str(f.category))
|
|
58
|
+
existing = seen.get(key)
|
|
59
|
+
if existing is None or f.confidence >= existing.confidence:
|
|
60
|
+
if existing is not None and existing.rule_id != f.rule_id:
|
|
61
|
+
f.related_findings = list(set(f.related_findings + [existing.rule_id]))
|
|
62
|
+
seen[key] = f
|
|
63
|
+
elif existing.rule_id != f.rule_id:
|
|
64
|
+
# f lost the confidence race — record its existence on the winner
|
|
65
|
+
existing.related_findings = list(set(existing.related_findings + [f.rule_id]))
|
|
66
|
+
|
|
67
|
+
return list(seen.values())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def adjust_test_file_severity(
|
|
71
|
+
findings: list[EnrichedFinding],
|
|
72
|
+
) -> list[EnrichedFinding]:
|
|
73
|
+
"""Lower severity for findings in test files."""
|
|
74
|
+
for f in findings:
|
|
75
|
+
if _is_test_file(f.file):
|
|
76
|
+
new_sev = _SEVERITY_DEMOTION.get(f.severity)
|
|
77
|
+
if new_sev:
|
|
78
|
+
f.severity = new_sev
|
|
79
|
+
f.confidence *= 0.8 # reduce confidence for test files
|
|
80
|
+
return findings
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def filter_by_confidence(
|
|
84
|
+
findings: list[EnrichedFinding],
|
|
85
|
+
min_confidence: float = 0.5,
|
|
86
|
+
) -> list[EnrichedFinding]:
|
|
87
|
+
"""Drop findings below confidence threshold."""
|
|
88
|
+
return [f for f in findings if f.confidence >= min_confidence]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_pipeline(
|
|
92
|
+
findings: list[EnrichedFinding],
|
|
93
|
+
*,
|
|
94
|
+
min_confidence: float = 0.5,
|
|
95
|
+
adjust_test_severity: bool = True,
|
|
96
|
+
project_dir: str = "",
|
|
97
|
+
) -> list[EnrichedFinding]:
|
|
98
|
+
"""Run the full deterministic pre-filter pipeline.
|
|
99
|
+
|
|
100
|
+
Pipeline stages:
|
|
101
|
+
1. Dedup overlapping findings
|
|
102
|
+
2. Adjust severity for test files
|
|
103
|
+
2b. Estimate p(true positive), if ATTOCODE_FLAG_CONFIDENCE is set
|
|
104
|
+
3. Apply confidence threshold
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
findings: Raw findings from executor.
|
|
108
|
+
min_confidence: Minimum confidence to keep (default 0.5).
|
|
109
|
+
adjust_test_severity: Whether to demote test file severity.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Filtered findings ready for the agent.
|
|
113
|
+
"""
|
|
114
|
+
original_count = len(findings)
|
|
115
|
+
|
|
116
|
+
# 1. Dedup
|
|
117
|
+
findings = dedup_findings(findings)
|
|
118
|
+
dedup_removed = original_count - len(findings)
|
|
119
|
+
|
|
120
|
+
# 2. Test file severity adjustment
|
|
121
|
+
if adjust_test_severity:
|
|
122
|
+
findings = adjust_test_file_severity(findings)
|
|
123
|
+
|
|
124
|
+
# 2b. Second-guess the constants (no-op unless ATTOCODE_FLAG_CONFIDENCE is set).
|
|
125
|
+
# Before the threshold on purpose: in live mode the threshold gates on
|
|
126
|
+
# the estimate rather than on the constant.
|
|
127
|
+
from attocode_intel import confidence
|
|
128
|
+
from attocode_intel.confidence.settings import workspace
|
|
129
|
+
|
|
130
|
+
with workspace(project_dir):
|
|
131
|
+
if confidence.settings.selected() != "off" and project_dir:
|
|
132
|
+
from attocode_intel.rules.enricher import enrich_findings
|
|
133
|
+
|
|
134
|
+
enrich_findings(findings, project_dir=project_dir)
|
|
135
|
+
confidence.score(findings, min_confidence=min_confidence)
|
|
136
|
+
|
|
137
|
+
# 3. Confidence threshold
|
|
138
|
+
findings = filter_by_confidence(findings, min_confidence)
|
|
139
|
+
conf_removed = original_count - dedup_removed - len(findings)
|
|
140
|
+
|
|
141
|
+
if dedup_removed > 0 or conf_removed > 0:
|
|
142
|
+
logger.info(
|
|
143
|
+
"Pre-filter: %d → %d findings (-%d dedup, -%d low-confidence)",
|
|
144
|
+
original_count, len(findings), dedup_removed, max(0, conf_removed),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Re-sort after filtering
|
|
148
|
+
_sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
149
|
+
findings.sort(key=lambda f: (_sev_order.get(f.severity, 9), f.file, f.line))
|
|
150
|
+
|
|
151
|
+
return findings
|