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,320 @@
|
|
|
1
|
+
"""CI-enforceable rule runner.
|
|
2
|
+
|
|
3
|
+
Provides a synchronous, exit-code-driven interface for running rules
|
|
4
|
+
in CI/CD pipelines. Supports diff-only scanning, SARIF output, and
|
|
5
|
+
GitHub Actions annotations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from attocode_intel.rules.model import EnrichedFinding, RuleSeverity
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Severity ordering for threshold comparison
|
|
21
|
+
_SEV_ORDER = {
|
|
22
|
+
RuleSeverity.CRITICAL: 0,
|
|
23
|
+
RuleSeverity.HIGH: 1,
|
|
24
|
+
RuleSeverity.MEDIUM: 2,
|
|
25
|
+
RuleSeverity.LOW: 3,
|
|
26
|
+
RuleSeverity.INFO: 4,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class CIResult:
|
|
32
|
+
"""Result of a CI scan run."""
|
|
33
|
+
|
|
34
|
+
findings: list[EnrichedFinding]
|
|
35
|
+
exit_code: int = 0
|
|
36
|
+
files_scanned: int = 0
|
|
37
|
+
rules_applied: int = 0
|
|
38
|
+
findings_above_threshold: int = 0
|
|
39
|
+
confidence: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def passed(self) -> bool:
|
|
43
|
+
return self.exit_code == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class CIConfig:
|
|
48
|
+
"""Configuration for CI scans, loadable from .attocode/ci.yaml."""
|
|
49
|
+
|
|
50
|
+
fail_on: RuleSeverity = RuleSeverity.HIGH
|
|
51
|
+
baseline: str = "" # git ref for diff-only
|
|
52
|
+
exclude_rules: list[str] = field(default_factory=list)
|
|
53
|
+
sarif_output: str = "" # path to write SARIF
|
|
54
|
+
min_confidence: float = 0.5
|
|
55
|
+
max_findings: int = 200
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_ci_config(project_dir: str) -> CIConfig:
|
|
59
|
+
"""Load CI config from .attocode/ci.yaml if it exists."""
|
|
60
|
+
config_path = Path(project_dir) / ".attocode" / "ci.yaml"
|
|
61
|
+
if not config_path.is_file():
|
|
62
|
+
return CIConfig()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
import yaml # type: ignore[import-untyped]
|
|
66
|
+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return CIConfig()
|
|
69
|
+
|
|
70
|
+
cfg = CIConfig()
|
|
71
|
+
if "fail_on" in data:
|
|
72
|
+
sev = str(data["fail_on"]).lower()
|
|
73
|
+
if sev in {s.value for s in RuleSeverity}:
|
|
74
|
+
cfg.fail_on = RuleSeverity(sev)
|
|
75
|
+
cfg.baseline = str(data.get("baseline", ""))
|
|
76
|
+
cfg.exclude_rules = list(data.get("exclude_rules", []))
|
|
77
|
+
cfg.sarif_output = str(data.get("sarif_output", ""))
|
|
78
|
+
cfg.min_confidence = float(data.get("min_confidence", 0.5))
|
|
79
|
+
cfg.max_findings = int(data.get("max_findings", 200))
|
|
80
|
+
return cfg
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
logger.warning("Failed to load CI config: %s", exc)
|
|
83
|
+
return CIConfig()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_changed_files(
|
|
87
|
+
base_ref: str,
|
|
88
|
+
project_dir: str,
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
"""Get files changed since *base_ref* using git diff."""
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", base_ref],
|
|
94
|
+
capture_output=True, text=True, cwd=project_dir,
|
|
95
|
+
timeout=30,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
logger.warning("git diff failed: %s", result.stderr.strip())
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
files = []
|
|
102
|
+
for line in result.stdout.strip().splitlines():
|
|
103
|
+
abs_path = os.path.join(project_dir, line)
|
|
104
|
+
if os.path.isfile(abs_path):
|
|
105
|
+
files.append(abs_path)
|
|
106
|
+
return files
|
|
107
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
108
|
+
logger.warning("Failed to get changed files: %s", exc)
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_changed_lines(
|
|
113
|
+
base_ref: str,
|
|
114
|
+
file_path: str,
|
|
115
|
+
project_dir: str,
|
|
116
|
+
) -> set[int]:
|
|
117
|
+
"""Get line numbers that changed in *file_path* since *base_ref*."""
|
|
118
|
+
try:
|
|
119
|
+
rel_path = os.path.relpath(file_path, project_dir)
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["git", "diff", "-U0", base_ref, "--", rel_path],
|
|
122
|
+
capture_output=True, text=True, cwd=project_dir,
|
|
123
|
+
timeout=30,
|
|
124
|
+
)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return set()
|
|
127
|
+
|
|
128
|
+
import re as _re
|
|
129
|
+
_HUNK_RE = _re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
130
|
+
|
|
131
|
+
changed: set[int] = set()
|
|
132
|
+
for line in result.stdout.splitlines():
|
|
133
|
+
m = _HUNK_RE.match(line)
|
|
134
|
+
if m:
|
|
135
|
+
start = int(m.group(1))
|
|
136
|
+
count = int(m.group(2)) if m.group(2) is not None else 1
|
|
137
|
+
changed.update(range(start, start + count))
|
|
138
|
+
return changed
|
|
139
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
140
|
+
return set()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CIRunner:
|
|
144
|
+
"""Run rules in CI mode with exit codes and structured output."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, project_dir: str, config: CIConfig | None = None) -> None:
|
|
147
|
+
self.project_dir = project_dir
|
|
148
|
+
self.config = config or load_ci_config(project_dir)
|
|
149
|
+
|
|
150
|
+
def run(
|
|
151
|
+
self,
|
|
152
|
+
files: list[str] | None = None,
|
|
153
|
+
path: str = "",
|
|
154
|
+
language: str = "",
|
|
155
|
+
category: str = "",
|
|
156
|
+
*,
|
|
157
|
+
diff_only: bool = False,
|
|
158
|
+
) -> CIResult:
|
|
159
|
+
"""Execute rules in CI mode.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
files: Explicit file list to scan.
|
|
163
|
+
path: Directory to scan (relative to project root).
|
|
164
|
+
language: Filter rules to this language.
|
|
165
|
+
category: Filter by category.
|
|
166
|
+
diff_only: Only report findings on changed lines since baseline.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
CIResult with findings and exit code.
|
|
170
|
+
"""
|
|
171
|
+
from attocode_intel.rules.registry import RuleRegistry
|
|
172
|
+
from attocode_intel.rules.loader import load_builtin_rules, load_user_rules
|
|
173
|
+
from attocode_intel.rules.packs.pack_loader import load_all_packs
|
|
174
|
+
from attocode_intel.rules.executor import execute_rules
|
|
175
|
+
from attocode_intel.rules.enricher import enrich_findings
|
|
176
|
+
from attocode_intel.rules.filters.pipeline import run_pipeline
|
|
177
|
+
|
|
178
|
+
# Build registry
|
|
179
|
+
reg = RuleRegistry()
|
|
180
|
+
reg.register_many(load_builtin_rules())
|
|
181
|
+
pack_manifests, pack_rules = load_all_packs(self.project_dir)
|
|
182
|
+
reg.register_many(pack_rules)
|
|
183
|
+
user_rules = load_user_rules(self.project_dir)
|
|
184
|
+
reg.register_many(user_rules)
|
|
185
|
+
|
|
186
|
+
# Query applicable rules
|
|
187
|
+
from attocode_intel import confidence
|
|
188
|
+
from attocode_intel.confidence.settings import workspace
|
|
189
|
+
|
|
190
|
+
with workspace(self.project_dir):
|
|
191
|
+
threshold = confidence.rule_threshold(self.config.min_confidence)
|
|
192
|
+
rules = reg.query(
|
|
193
|
+
language=language,
|
|
194
|
+
category=category,
|
|
195
|
+
min_confidence=threshold,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Exclude rules from config
|
|
199
|
+
if self.config.exclude_rules:
|
|
200
|
+
exclude = set(self.config.exclude_rules)
|
|
201
|
+
rules = [r for r in rules if r.qualified_id not in exclude and r.id not in exclude]
|
|
202
|
+
|
|
203
|
+
# Collect files
|
|
204
|
+
if diff_only and self.config.baseline:
|
|
205
|
+
file_list = files or get_changed_files(self.config.baseline, self.project_dir)
|
|
206
|
+
else:
|
|
207
|
+
from attocode_intel.tools.rule_tools import _collect_files
|
|
208
|
+
file_list = _collect_files(files, path, self.project_dir)
|
|
209
|
+
|
|
210
|
+
if not file_list or not rules:
|
|
211
|
+
return CIResult(findings=[], files_scanned=len(file_list or []), rules_applied=len(rules))
|
|
212
|
+
|
|
213
|
+
# Execute
|
|
214
|
+
findings = execute_rules(file_list, rules, project_dir=self.project_dir)
|
|
215
|
+
findings = run_pipeline(findings, min_confidence=self.config.min_confidence,
|
|
216
|
+
project_dir=self.project_dir)
|
|
217
|
+
enrich_findings(findings, project_dir=self.project_dir)
|
|
218
|
+
|
|
219
|
+
# Diff-only filtering: only keep findings on changed lines
|
|
220
|
+
if diff_only and self.config.baseline:
|
|
221
|
+
changed_lines_cache: dict[str, set[int]] = {}
|
|
222
|
+
filtered = []
|
|
223
|
+
for f in findings:
|
|
224
|
+
abs_path = f.file if os.path.isabs(f.file) else os.path.join(self.project_dir, f.file)
|
|
225
|
+
if abs_path not in changed_lines_cache:
|
|
226
|
+
changed_lines_cache[abs_path] = get_changed_lines(
|
|
227
|
+
self.config.baseline, abs_path, self.project_dir,
|
|
228
|
+
)
|
|
229
|
+
if f.line in changed_lines_cache[abs_path]:
|
|
230
|
+
filtered.append(f)
|
|
231
|
+
findings = filtered
|
|
232
|
+
|
|
233
|
+
# Cap findings
|
|
234
|
+
findings = findings[:self.config.max_findings]
|
|
235
|
+
|
|
236
|
+
# Compute exit code based on threshold
|
|
237
|
+
threshold = _SEV_ORDER.get(self.config.fail_on, 1)
|
|
238
|
+
above = [f for f in findings if _SEV_ORDER.get(RuleSeverity(f.severity), 4) <= threshold]
|
|
239
|
+
|
|
240
|
+
result = CIResult(
|
|
241
|
+
findings=findings,
|
|
242
|
+
exit_code=1 if above else 0,
|
|
243
|
+
files_scanned=len(file_list),
|
|
244
|
+
rules_applied=len(rules),
|
|
245
|
+
findings_above_threshold=len(above),
|
|
246
|
+
confidence={k: v for k, v in confidence.last_report().items() if k != "findings"},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Write SARIF if configured
|
|
250
|
+
if self.config.sarif_output:
|
|
251
|
+
self._write_sarif(findings)
|
|
252
|
+
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
def _write_sarif(self, findings: list[EnrichedFinding]) -> None:
|
|
256
|
+
"""Write findings as SARIF to the configured output path."""
|
|
257
|
+
from attocode_intel.rules.sarif import findings_to_sarif, sarif_to_json
|
|
258
|
+
|
|
259
|
+
sarif = findings_to_sarif(findings)
|
|
260
|
+
output_path = Path(self.config.sarif_output)
|
|
261
|
+
if not output_path.is_absolute():
|
|
262
|
+
output_path = Path(self.project_dir) / output_path
|
|
263
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
output_path.write_text(sarif_to_json(sarif), encoding="utf-8")
|
|
265
|
+
logger.info("SARIF output written to %s", output_path)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def format_github_annotations(findings: list[EnrichedFinding]) -> str:
|
|
269
|
+
"""Format findings as GitHub Actions annotation commands.
|
|
270
|
+
|
|
271
|
+
Output format: ``::error file=path,line=N::message``
|
|
272
|
+
"""
|
|
273
|
+
_LEVEL = {
|
|
274
|
+
RuleSeverity.CRITICAL: "error",
|
|
275
|
+
RuleSeverity.HIGH: "error",
|
|
276
|
+
RuleSeverity.MEDIUM: "warning",
|
|
277
|
+
RuleSeverity.LOW: "notice",
|
|
278
|
+
RuleSeverity.INFO: "notice",
|
|
279
|
+
}
|
|
280
|
+
lines: list[str] = []
|
|
281
|
+
for f in findings:
|
|
282
|
+
level = _LEVEL.get(RuleSeverity(f.severity), "warning")
|
|
283
|
+
msg = f"[{f.rule_id}] {f.description}"
|
|
284
|
+
# GitHub Actions requires percent-encoding for %, \r, \n in messages
|
|
285
|
+
msg = msg.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
286
|
+
lines.append(f"::{level} file={f.file},line={f.line}::{msg}")
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def format_ci_summary(result: CIResult) -> str:
|
|
291
|
+
"""Format a CI result as a human-readable summary."""
|
|
292
|
+
status = "PASS" if result.passed else "FAIL"
|
|
293
|
+
lines = [
|
|
294
|
+
f"## Rule Analysis: {status}",
|
|
295
|
+
f"Files scanned: {result.files_scanned} | Rules applied: {result.rules_applied}",
|
|
296
|
+
f"Findings: {len(result.findings)} total, {result.findings_above_threshold} above threshold",
|
|
297
|
+
]
|
|
298
|
+
audit = result.confidence
|
|
299
|
+
if audit and audit.get("scorer") != "off":
|
|
300
|
+
lines.append(
|
|
301
|
+
f"Confidence: {audit['scorer']} ({audit['mode']}); "
|
|
302
|
+
f"{audit['estimated']}/{audit['total']} scored, {audit['fallback']} fallback, "
|
|
303
|
+
f"{audit['skipped']} skipped by cap (baseline retained)."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if result.findings:
|
|
307
|
+
# Group by severity
|
|
308
|
+
by_sev: dict[str, int] = {}
|
|
309
|
+
for f in result.findings:
|
|
310
|
+
by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
|
|
311
|
+
lines.append("Breakdown: " + ", ".join(f"{s}: {c}" for s, c in sorted(by_sev.items())))
|
|
312
|
+
|
|
313
|
+
# Top findings
|
|
314
|
+
lines.append("")
|
|
315
|
+
for f in result.findings[:10]:
|
|
316
|
+
lines.append(f"- [{f.severity}] {f.file}:{f.line} — {f.description[:80]}")
|
|
317
|
+
if len(result.findings) > 10:
|
|
318
|
+
lines.append(f"... and {len(result.findings) - 10} more")
|
|
319
|
+
|
|
320
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Boolean pattern combinators for composite rule matching.
|
|
2
|
+
|
|
3
|
+
Enables Semgrep-style ``pattern-either``, ``pattern-not``, and
|
|
4
|
+
``pattern-inside`` composition without requiring AST parsing.
|
|
5
|
+
|
|
6
|
+
All combinators operate on a ``MatchContext`` that provides the
|
|
7
|
+
current line, surrounding lines, and file content for scope analysis.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from attocode_intel.rules.metavar import compile_metavar_pattern, has_metavars
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Match context
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class MatchContext:
|
|
26
|
+
"""Context passed to pattern nodes during evaluation."""
|
|
27
|
+
|
|
28
|
+
line: str # current line being evaluated
|
|
29
|
+
line_no: int # 1-based line number
|
|
30
|
+
all_lines: list[str] # full file content (0-indexed)
|
|
31
|
+
captures: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Pattern node hierarchy
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PatternNode(ABC):
|
|
40
|
+
"""Abstract base for all pattern nodes in a composite rule."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
44
|
+
"""Return True if this pattern matches in the given context."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class RegexNode(PatternNode):
|
|
49
|
+
"""Matches a single line against a compiled regex."""
|
|
50
|
+
|
|
51
|
+
pattern: re.Pattern[str]
|
|
52
|
+
metavar_names: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
55
|
+
m = self.pattern.search(ctx.line)
|
|
56
|
+
if m and self.metavar_names:
|
|
57
|
+
ctx.captures.update(
|
|
58
|
+
{k: v for k, v in m.groupdict().items() if v is not None}
|
|
59
|
+
)
|
|
60
|
+
return bool(m)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class EitherNode(PatternNode):
|
|
65
|
+
"""OR combinator — matches if ANY child matches."""
|
|
66
|
+
|
|
67
|
+
children: list[PatternNode]
|
|
68
|
+
|
|
69
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
70
|
+
return any(child.evaluate(ctx) for child in self.children)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class AllNode(PatternNode):
|
|
75
|
+
"""AND combinator — matches if ALL children match the same line."""
|
|
76
|
+
|
|
77
|
+
children: list[PatternNode]
|
|
78
|
+
|
|
79
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
80
|
+
return all(child.evaluate(ctx) for child in self.children)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class NotNode(PatternNode):
|
|
85
|
+
"""Negation — matches if the child does NOT match the current line."""
|
|
86
|
+
|
|
87
|
+
child: PatternNode
|
|
88
|
+
|
|
89
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
90
|
+
return not self.child.evaluate(ctx)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Function definition patterns — used as scope boundaries
|
|
94
|
+
_FUNC_DEF_RE = re.compile(
|
|
95
|
+
r"^(?:def |func |function |fn |"
|
|
96
|
+
r"(?:public|private|protected|internal)\s+(?:static\s+)?\w+\s+\w+\(|"
|
|
97
|
+
r"(?:export\s+)?(?:async\s+)?function\s)"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _indent_level(line: str) -> int:
|
|
102
|
+
"""Return the number of leading whitespace characters."""
|
|
103
|
+
return len(line) - len(line.lstrip())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class InsideNode(PatternNode):
|
|
108
|
+
"""Scope constraint — matches only if a scope pattern is found above.
|
|
109
|
+
|
|
110
|
+
Scans backward from the current line looking for a line that matches
|
|
111
|
+
the scope pattern within ``max_distance`` lines. Uses two heuristics
|
|
112
|
+
to avoid cross-scope false positives:
|
|
113
|
+
|
|
114
|
+
1. The scope line must have *less* indentation than the current line
|
|
115
|
+
(the current line is "inside" the scope block).
|
|
116
|
+
2. Scanning stops at function definition boundaries to prevent
|
|
117
|
+
matching scope patterns from preceding functions.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
scope_pattern: re.Pattern[str]
|
|
121
|
+
max_distance: int = 50
|
|
122
|
+
|
|
123
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
124
|
+
cur_indent = _indent_level(ctx.line)
|
|
125
|
+
start = max(0, ctx.line_no - 1 - self.max_distance)
|
|
126
|
+
end = ctx.line_no - 1 # exclude current line (0-indexed)
|
|
127
|
+
for i in range(end - 1, start - 1, -1):
|
|
128
|
+
scan_line = ctx.all_lines[i]
|
|
129
|
+
stripped = scan_line.strip()
|
|
130
|
+
if not stripped:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check scope pattern BEFORE function boundary
|
|
134
|
+
# (the func def line itself may be the scope pattern)
|
|
135
|
+
if self.scope_pattern.search(scan_line):
|
|
136
|
+
if _indent_level(scan_line) < cur_indent:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# Stop at function boundaries (different scope)
|
|
140
|
+
if _FUNC_DEF_RE.match(stripped):
|
|
141
|
+
return False
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(slots=True)
|
|
146
|
+
class NotInsideNode(PatternNode):
|
|
147
|
+
"""Inverse scope — matches only if the scope pattern is NOT found above."""
|
|
148
|
+
|
|
149
|
+
scope_pattern: re.Pattern[str]
|
|
150
|
+
max_distance: int = 50
|
|
151
|
+
|
|
152
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
153
|
+
cur_indent = _indent_level(ctx.line)
|
|
154
|
+
start = max(0, ctx.line_no - 1 - self.max_distance)
|
|
155
|
+
end = ctx.line_no - 1
|
|
156
|
+
for i in range(end - 1, start - 1, -1):
|
|
157
|
+
scan_line = ctx.all_lines[i]
|
|
158
|
+
stripped = scan_line.strip()
|
|
159
|
+
if not stripped:
|
|
160
|
+
continue
|
|
161
|
+
# Check scope pattern BEFORE function boundary
|
|
162
|
+
# (the func def line itself may be the scope pattern)
|
|
163
|
+
if self.scope_pattern.search(scan_line):
|
|
164
|
+
if _indent_level(scan_line) < cur_indent:
|
|
165
|
+
return False
|
|
166
|
+
if _FUNC_DEF_RE.match(stripped):
|
|
167
|
+
return True # hit function boundary — not inside
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Composite pattern (wraps primary + constraints)
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(slots=True)
|
|
177
|
+
class CompositePattern:
|
|
178
|
+
"""A primary pattern with boolean constraints.
|
|
179
|
+
|
|
180
|
+
The primary pattern must match the current line. Then all constraints
|
|
181
|
+
(pattern-not, pattern-inside, pattern-not-inside) are evaluated.
|
|
182
|
+
All constraints must pass for the composite to match.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
primary: PatternNode
|
|
186
|
+
constraints: list[PatternNode] = field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
def evaluate(self, ctx: MatchContext) -> bool:
|
|
189
|
+
if not self.primary.evaluate(ctx):
|
|
190
|
+
return False
|
|
191
|
+
return all(c.evaluate(ctx) for c in self.constraints)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Builder — parses YAML structure into pattern tree
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _compile_pattern(pattern_str: str) -> tuple[re.Pattern[str], list[str]]:
|
|
200
|
+
"""Compile a pattern string, handling metavariables if present."""
|
|
201
|
+
if has_metavars(pattern_str):
|
|
202
|
+
return compile_metavar_pattern(pattern_str)
|
|
203
|
+
return re.compile(pattern_str), []
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_composite_from_yaml(data: list[dict] | dict) -> CompositePattern | None:
|
|
207
|
+
"""Build a CompositePattern from YAML ``patterns`` structure.
|
|
208
|
+
|
|
209
|
+
Supported YAML keys within the patterns list:
|
|
210
|
+
- ``pattern``: primary regex (first one found becomes primary)
|
|
211
|
+
- ``pattern-either``: list of alternative patterns (becomes primary)
|
|
212
|
+
- ``pattern-not``: line must NOT match this regex
|
|
213
|
+
- ``pattern-inside``: a scope pattern must exist above the current line
|
|
214
|
+
- ``pattern-not-inside``: a scope pattern must NOT exist above
|
|
215
|
+
|
|
216
|
+
Example YAML:
|
|
217
|
+
patterns:
|
|
218
|
+
- pattern: 'fmt\\.Sprintf\\s*\\('
|
|
219
|
+
- pattern-inside: 'for\\s.*\\{'
|
|
220
|
+
- pattern-not: '// nolint'
|
|
221
|
+
"""
|
|
222
|
+
if isinstance(data, dict):
|
|
223
|
+
# Single pattern-either at top level
|
|
224
|
+
if "pattern-either" in data:
|
|
225
|
+
either_pats = data["pattern-either"]
|
|
226
|
+
if not isinstance(either_pats, list):
|
|
227
|
+
return None
|
|
228
|
+
children = []
|
|
229
|
+
all_metavars: list[str] = []
|
|
230
|
+
for p in either_pats:
|
|
231
|
+
pat_str = str(p)
|
|
232
|
+
compiled, mvars = _compile_pattern(pat_str)
|
|
233
|
+
children.append(RegexNode(pattern=compiled, metavar_names=mvars))
|
|
234
|
+
all_metavars.extend(mvars)
|
|
235
|
+
return CompositePattern(primary=EitherNode(children=children))
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
if not isinstance(data, list):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
primary: PatternNode | None = None
|
|
242
|
+
constraints: list[PatternNode] = []
|
|
243
|
+
all_metavars: list[str] = []
|
|
244
|
+
|
|
245
|
+
for item in data:
|
|
246
|
+
if not isinstance(item, dict):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if "pattern" in item:
|
|
250
|
+
pat_str = str(item["pattern"])
|
|
251
|
+
compiled, mvars = _compile_pattern(pat_str)
|
|
252
|
+
if primary is None:
|
|
253
|
+
primary = RegexNode(pattern=compiled, metavar_names=mvars)
|
|
254
|
+
all_metavars.extend(mvars)
|
|
255
|
+
else:
|
|
256
|
+
# Additional patterns act as AND constraints
|
|
257
|
+
constraints.append(RegexNode(pattern=compiled, metavar_names=mvars))
|
|
258
|
+
|
|
259
|
+
elif "pattern-either" in item:
|
|
260
|
+
either_pats = item["pattern-either"]
|
|
261
|
+
if isinstance(either_pats, list):
|
|
262
|
+
children = []
|
|
263
|
+
for p in either_pats:
|
|
264
|
+
c, mv = _compile_pattern(str(p))
|
|
265
|
+
children.append(RegexNode(pattern=c, metavar_names=mv))
|
|
266
|
+
all_metavars.extend(mv)
|
|
267
|
+
node = EitherNode(children=children)
|
|
268
|
+
if primary is None:
|
|
269
|
+
primary = node
|
|
270
|
+
else:
|
|
271
|
+
constraints.append(node)
|
|
272
|
+
|
|
273
|
+
elif "pattern-not" in item:
|
|
274
|
+
pat_str = str(item["pattern-not"])
|
|
275
|
+
compiled, mvars = _compile_pattern(pat_str)
|
|
276
|
+
constraints.append(NotNode(child=RegexNode(pattern=compiled, metavar_names=mvars)))
|
|
277
|
+
|
|
278
|
+
elif "pattern-inside" in item:
|
|
279
|
+
pat_str = str(item["pattern-inside"])
|
|
280
|
+
compiled, _ = _compile_pattern(pat_str)
|
|
281
|
+
max_dist = int(item.get("max-distance", 50))
|
|
282
|
+
constraints.append(InsideNode(scope_pattern=compiled, max_distance=max_dist))
|
|
283
|
+
|
|
284
|
+
elif "pattern-not-inside" in item:
|
|
285
|
+
pat_str = str(item["pattern-not-inside"])
|
|
286
|
+
compiled, _ = _compile_pattern(pat_str)
|
|
287
|
+
max_dist = int(item.get("max-distance", 50))
|
|
288
|
+
constraints.append(NotInsideNode(scope_pattern=compiled, max_distance=max_dist))
|
|
289
|
+
|
|
290
|
+
if primary is None:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
return CompositePattern(primary=primary, constraints=constraints)
|