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,187 @@
|
|
|
1
|
+
"""SARIF v2.1.0 output formatter for rule findings.
|
|
2
|
+
|
|
3
|
+
Produces Static Analysis Results Interchange Format (SARIF) JSON
|
|
4
|
+
compatible with GitHub Code Scanning, VS Code SARIF Viewer, and
|
|
5
|
+
Azure DevOps.
|
|
6
|
+
|
|
7
|
+
Reference: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from attocode_intel.rules.model import EnrichedFinding, RuleSeverity
|
|
16
|
+
|
|
17
|
+
# SARIF severity mapping
|
|
18
|
+
_LEVEL_MAP: dict[str, str] = {
|
|
19
|
+
RuleSeverity.CRITICAL: "error",
|
|
20
|
+
RuleSeverity.HIGH: "error",
|
|
21
|
+
RuleSeverity.MEDIUM: "warning",
|
|
22
|
+
RuleSeverity.LOW: "note",
|
|
23
|
+
RuleSeverity.INFO: "note",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_rule_descriptor(finding: EnrichedFinding) -> dict[str, Any]:
|
|
28
|
+
"""Build a SARIF reportingDescriptor for a rule."""
|
|
29
|
+
descriptor: dict[str, Any] = {
|
|
30
|
+
"id": finding.rule_id,
|
|
31
|
+
"shortDescription": {"text": finding.rule_name},
|
|
32
|
+
}
|
|
33
|
+
if finding.description:
|
|
34
|
+
descriptor["fullDescription"] = {"text": finding.description}
|
|
35
|
+
if finding.explanation:
|
|
36
|
+
descriptor["help"] = {
|
|
37
|
+
"text": finding.explanation,
|
|
38
|
+
"markdown": finding.explanation,
|
|
39
|
+
}
|
|
40
|
+
if finding.recommendation:
|
|
41
|
+
descriptor.setdefault("help", {})
|
|
42
|
+
help_md = descriptor["help"].get("markdown", "")
|
|
43
|
+
if help_md:
|
|
44
|
+
help_md += "\n\n**Recommendation:** " + finding.recommendation
|
|
45
|
+
else:
|
|
46
|
+
help_md = "**Recommendation:** " + finding.recommendation
|
|
47
|
+
descriptor["help"]["markdown"] = help_md
|
|
48
|
+
descriptor["help"]["text"] = descriptor["help"].get("text", "") + " " + finding.recommendation
|
|
49
|
+
|
|
50
|
+
# CWE relationship
|
|
51
|
+
if finding.cwe:
|
|
52
|
+
cwe_id = finding.cwe.replace("CWE-", "")
|
|
53
|
+
descriptor["relationships"] = [{
|
|
54
|
+
"target": {
|
|
55
|
+
"id": finding.cwe,
|
|
56
|
+
"guid": f"cwe-{cwe_id}",
|
|
57
|
+
"toolComponent": {"name": "CWE"},
|
|
58
|
+
},
|
|
59
|
+
"kinds": ["superset"],
|
|
60
|
+
}]
|
|
61
|
+
|
|
62
|
+
# Tags
|
|
63
|
+
tags = list(finding.tags)
|
|
64
|
+
if finding.cwe:
|
|
65
|
+
tags.append(f"external/cwe/{finding.cwe.lower()}")
|
|
66
|
+
tags.append(f"security-severity/{finding.severity}")
|
|
67
|
+
if tags:
|
|
68
|
+
descriptor["properties"] = {"tags": tags}
|
|
69
|
+
|
|
70
|
+
# Few-shot examples in property bag
|
|
71
|
+
if finding.examples:
|
|
72
|
+
descriptor.setdefault("properties", {})["fewShotExamples"] = [
|
|
73
|
+
{
|
|
74
|
+
"bad": ex.bad_code,
|
|
75
|
+
"good": ex.good_code,
|
|
76
|
+
"explanation": ex.explanation,
|
|
77
|
+
}
|
|
78
|
+
for ex in finding.examples
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
return descriptor
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_result(finding: EnrichedFinding) -> dict[str, Any]:
|
|
85
|
+
"""Build a SARIF result object from an EnrichedFinding."""
|
|
86
|
+
result: dict[str, Any] = {
|
|
87
|
+
"ruleId": finding.rule_id,
|
|
88
|
+
"level": _LEVEL_MAP.get(finding.severity, "warning"),
|
|
89
|
+
"message": {"text": finding.description},
|
|
90
|
+
"locations": [{
|
|
91
|
+
"physicalLocation": {
|
|
92
|
+
"artifactLocation": {
|
|
93
|
+
"uri": finding.file.replace("\\", "/"),
|
|
94
|
+
"uriBaseId": "%SRCROOT%",
|
|
95
|
+
},
|
|
96
|
+
"region": {
|
|
97
|
+
"startLine": finding.line,
|
|
98
|
+
"startColumn": 1,
|
|
99
|
+
"snippet": {"text": finding.code_snippet[:200]},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Function scope
|
|
106
|
+
if finding.function_name:
|
|
107
|
+
result["locations"][0]["logicalLocations"] = [{
|
|
108
|
+
"name": finding.function_name,
|
|
109
|
+
"kind": "function",
|
|
110
|
+
}]
|
|
111
|
+
|
|
112
|
+
# Confidence + captures in property bag
|
|
113
|
+
props: dict[str, Any] = {"confidence": finding.confidence}
|
|
114
|
+
if finding.captures:
|
|
115
|
+
props["captures"] = finding.captures
|
|
116
|
+
if finding.suggested_fix:
|
|
117
|
+
props["suggestedFix"] = finding.suggested_fix
|
|
118
|
+
result["properties"] = props
|
|
119
|
+
|
|
120
|
+
# Related findings as relatedLocations
|
|
121
|
+
if finding.related_findings:
|
|
122
|
+
result["relatedLocations"] = [
|
|
123
|
+
{"id": i, "message": {"text": rid}}
|
|
124
|
+
for i, rid in enumerate(finding.related_findings)
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def findings_to_sarif(
|
|
131
|
+
findings: list[EnrichedFinding],
|
|
132
|
+
*,
|
|
133
|
+
tool_name: str = "attocode-code-intel",
|
|
134
|
+
tool_version: str = "",
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Convert a list of EnrichedFinding into a SARIF v2.1.0 document.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
findings: Analysis findings to convert.
|
|
140
|
+
tool_name: Name of the analysis tool.
|
|
141
|
+
tool_version: Tool version string.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
SARIF JSON-compatible dict.
|
|
145
|
+
"""
|
|
146
|
+
# Deduplicate rules by rule_id
|
|
147
|
+
rules_by_id: dict[str, dict[str, Any]] = {}
|
|
148
|
+
for f in findings:
|
|
149
|
+
if f.rule_id not in rules_by_id:
|
|
150
|
+
rules_by_id[f.rule_id] = _build_rule_descriptor(f)
|
|
151
|
+
|
|
152
|
+
# Build rule index for ruleIndex references
|
|
153
|
+
rule_index: dict[str, int] = {}
|
|
154
|
+
rules_list = []
|
|
155
|
+
for i, (rid, desc) in enumerate(rules_by_id.items()):
|
|
156
|
+
rule_index[rid] = i
|
|
157
|
+
rules_list.append(desc)
|
|
158
|
+
|
|
159
|
+
# Build results
|
|
160
|
+
results = []
|
|
161
|
+
for f in findings:
|
|
162
|
+
result = _build_result(f)
|
|
163
|
+
result["ruleIndex"] = rule_index[f.rule_id]
|
|
164
|
+
results.append(result)
|
|
165
|
+
|
|
166
|
+
sarif: dict[str, Any] = {
|
|
167
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
|
168
|
+
"version": "2.1.0",
|
|
169
|
+
"runs": [{
|
|
170
|
+
"tool": {
|
|
171
|
+
"driver": {
|
|
172
|
+
"name": tool_name,
|
|
173
|
+
"version": tool_version,
|
|
174
|
+
"informationUri": "https://github.com/attocode/code-intel",
|
|
175
|
+
"rules": rules_list,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
"results": results,
|
|
179
|
+
}],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return sarif
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def sarif_to_json(sarif: dict[str, Any], *, indent: int = 2) -> str:
|
|
186
|
+
"""Serialize a SARIF document to JSON string."""
|
|
187
|
+
return json.dumps(sarif, indent=indent, ensure_ascii=False)
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Rule synthesis from positive / negative code samples.
|
|
2
|
+
|
|
3
|
+
Two paths:
|
|
4
|
+
|
|
5
|
+
* **Deterministic regex synthesis** — find the longest common contiguous
|
|
6
|
+
substring across positives, escape + word-boundary it, then validate
|
|
7
|
+
it doesn't match any negative. Cheap, no API needed, surprisingly
|
|
8
|
+
effective for shape-based patterns ("any call to ``eval``", "import
|
|
9
|
+
of a deprecated module", etc.).
|
|
10
|
+
* **LLM-assisted synthesis** — when the deterministic path can't find a
|
|
11
|
+
precise enough anchor, fall back to Claude. The prompt feeds samples
|
|
12
|
+
+ description + language; the response is parsed as attocode YAML and
|
|
13
|
+
validated against the same samples before being accepted.
|
|
14
|
+
|
|
15
|
+
Public entry points:
|
|
16
|
+
|
|
17
|
+
* :func:`synthesize_rule` — top-level dispatch (``mode="auto"|"regex"|"llm"``)
|
|
18
|
+
* :func:`synthesize_regex_rule` — deterministic only
|
|
19
|
+
|
|
20
|
+
Both return a :class:`SynthesisResult` with the candidate rule (or None)
|
|
21
|
+
plus diagnostic messages explaining what was tried.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
from attocode_intel.rules.model import (
|
|
32
|
+
RuleCategory,
|
|
33
|
+
RuleSeverity,
|
|
34
|
+
RuleSource,
|
|
35
|
+
RuleTier,
|
|
36
|
+
UnifiedRule,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Length floor for the longest common substring before we accept it as a
|
|
43
|
+
# regex anchor. Below this we'd be matching common English/code fragments
|
|
44
|
+
# (e.g. "if ", "()") and producing high-FP rules.
|
|
45
|
+
DEFAULT_MIN_ANCHOR_LEN = 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class SynthesisResult:
|
|
50
|
+
"""Outcome of a synthesis attempt — None rule means nothing usable."""
|
|
51
|
+
|
|
52
|
+
rule: UnifiedRule | None
|
|
53
|
+
method: str # "regex" | "llm" | "none"
|
|
54
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Deterministic regex synthesis
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lcs_2way(a: str, b: str) -> str:
|
|
63
|
+
"""Longest common contiguous substring of two strings (classic DP)."""
|
|
64
|
+
m, n = len(a), len(b)
|
|
65
|
+
if m == 0 or n == 0:
|
|
66
|
+
return ""
|
|
67
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
68
|
+
end_i = max_len = 0
|
|
69
|
+
for i in range(1, m + 1):
|
|
70
|
+
for j in range(1, n + 1):
|
|
71
|
+
if a[i - 1] == b[j - 1]:
|
|
72
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
73
|
+
if dp[i][j] > max_len:
|
|
74
|
+
max_len = dp[i][j]
|
|
75
|
+
end_i = i
|
|
76
|
+
return a[end_i - max_len:end_i]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lcs_across(strings: list[str]) -> str:
|
|
80
|
+
"""N-way longest common substring via pairwise reduction."""
|
|
81
|
+
if not strings:
|
|
82
|
+
return ""
|
|
83
|
+
if len(strings) == 1:
|
|
84
|
+
return strings[0]
|
|
85
|
+
base = strings[0]
|
|
86
|
+
for s in strings[1:]:
|
|
87
|
+
base = _lcs_2way(base, s)
|
|
88
|
+
if not base:
|
|
89
|
+
return ""
|
|
90
|
+
return base
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_word_char(ch: str) -> bool:
|
|
94
|
+
return bool(ch) and (ch.isalnum() or ch == "_")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_regex_pattern(common: str, positives: list[str] | None = None) -> str:
|
|
98
|
+
"""``re.escape`` plus word boundaries when they're safe to add.
|
|
99
|
+
|
|
100
|
+
We only attach ``\\b`` at an edge that is a word character AND whose
|
|
101
|
+
actual neighbour in every positive sample is either out-of-string or
|
|
102
|
+
a non-word character. Otherwise ``\\b`` would prevent the pattern
|
|
103
|
+
from matching its own positives (e.g. anchor ``xy_`` followed by
|
|
104
|
+
another word char like ``a`` has no boundary between them).
|
|
105
|
+
"""
|
|
106
|
+
pat = re.escape(common)
|
|
107
|
+
add_leading = False
|
|
108
|
+
add_trailing = False
|
|
109
|
+
|
|
110
|
+
if _is_word_char(common[:1]):
|
|
111
|
+
if positives:
|
|
112
|
+
add_leading = True
|
|
113
|
+
for p in positives:
|
|
114
|
+
idx = p.find(common)
|
|
115
|
+
if idx > 0 and _is_word_char(p[idx - 1]):
|
|
116
|
+
add_leading = False
|
|
117
|
+
break
|
|
118
|
+
else:
|
|
119
|
+
add_leading = True
|
|
120
|
+
|
|
121
|
+
if _is_word_char(common[-1:]):
|
|
122
|
+
if positives:
|
|
123
|
+
add_trailing = True
|
|
124
|
+
for p in positives:
|
|
125
|
+
idx = p.find(common)
|
|
126
|
+
end = idx + len(common)
|
|
127
|
+
if end < len(p) and _is_word_char(p[end]):
|
|
128
|
+
add_trailing = False
|
|
129
|
+
break
|
|
130
|
+
else:
|
|
131
|
+
add_trailing = True
|
|
132
|
+
|
|
133
|
+
if add_leading:
|
|
134
|
+
pat = r"\b" + pat
|
|
135
|
+
if add_trailing:
|
|
136
|
+
pat = pat + r"\b"
|
|
137
|
+
return pat
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def synthesize_regex_rule(
|
|
141
|
+
positives: list[str],
|
|
142
|
+
negatives: list[str],
|
|
143
|
+
*,
|
|
144
|
+
rule_id: str,
|
|
145
|
+
language: str,
|
|
146
|
+
description: str = "",
|
|
147
|
+
min_anchor_len: int = DEFAULT_MIN_ANCHOR_LEN,
|
|
148
|
+
) -> tuple[UnifiedRule | None, list[str]]:
|
|
149
|
+
"""Build a regex rule from common shape across ``positives``.
|
|
150
|
+
|
|
151
|
+
Returns ``(rule, diagnostics)``. ``rule`` is ``None`` when no anchor
|
|
152
|
+
that satisfies both positives and negatives can be found.
|
|
153
|
+
"""
|
|
154
|
+
diags: list[str] = []
|
|
155
|
+
if not positives:
|
|
156
|
+
diags.append("no positive samples provided")
|
|
157
|
+
return None, diags
|
|
158
|
+
|
|
159
|
+
common = _lcs_across(positives)
|
|
160
|
+
if len(common) < min_anchor_len:
|
|
161
|
+
diags.append(
|
|
162
|
+
f"longest common substring ({len(common)} chars) below "
|
|
163
|
+
f"min_anchor_len={min_anchor_len}"
|
|
164
|
+
)
|
|
165
|
+
return None, diags
|
|
166
|
+
|
|
167
|
+
pattern_str = _build_regex_pattern(common, positives=positives)
|
|
168
|
+
try:
|
|
169
|
+
compiled = re.compile(pattern_str)
|
|
170
|
+
except re.error as exc:
|
|
171
|
+
diags.append(f"regex compile failed: {exc}")
|
|
172
|
+
return None, diags
|
|
173
|
+
|
|
174
|
+
failed_pos = [p for p in positives if not compiled.search(p)]
|
|
175
|
+
if failed_pos:
|
|
176
|
+
diags.append(
|
|
177
|
+
f"synthesised pattern misses {len(failed_pos)}/{len(positives)} "
|
|
178
|
+
"positive(s) — anchor escaped a multi-byte boundary"
|
|
179
|
+
)
|
|
180
|
+
return None, diags
|
|
181
|
+
|
|
182
|
+
matched_neg = [n for n in negatives if compiled.search(n)]
|
|
183
|
+
if matched_neg:
|
|
184
|
+
diags.append(
|
|
185
|
+
f"pattern matches {len(matched_neg)}/{len(negatives)} negative "
|
|
186
|
+
"sample(s); common substring too generic for clean separation"
|
|
187
|
+
)
|
|
188
|
+
return None, diags
|
|
189
|
+
|
|
190
|
+
rule = UnifiedRule(
|
|
191
|
+
id=rule_id,
|
|
192
|
+
name=rule_id,
|
|
193
|
+
description=description or f"matches pattern {pattern_str}",
|
|
194
|
+
severity=RuleSeverity.MEDIUM,
|
|
195
|
+
category=RuleCategory.SUSPICIOUS,
|
|
196
|
+
languages=[language] if language else [],
|
|
197
|
+
pattern=compiled,
|
|
198
|
+
source=RuleSource.USER,
|
|
199
|
+
tier=RuleTier.REGEX,
|
|
200
|
+
confidence=0.6,
|
|
201
|
+
)
|
|
202
|
+
diags.append(f"synthesised regex pattern: {pattern_str}")
|
|
203
|
+
return rule, diags
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# LLM-assisted synthesis
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_LLM_SYNTH_PROMPT = """\
|
|
212
|
+
You generate a single static-analysis rule in attocode's YAML format.
|
|
213
|
+
|
|
214
|
+
Language: {language}
|
|
215
|
+
What this rule should detect: {description}
|
|
216
|
+
|
|
217
|
+
The rule MUST match every one of these positive samples (each is a single
|
|
218
|
+
line of code):
|
|
219
|
+
{positives}
|
|
220
|
+
|
|
221
|
+
The rule MUST NOT match any of these negative samples:
|
|
222
|
+
{negatives}
|
|
223
|
+
|
|
224
|
+
Output ONLY a YAML rule with these fields (no surrounding prose, no
|
|
225
|
+
markdown code fences):
|
|
226
|
+
|
|
227
|
+
- id: kebab-case identifier (use "{suggested_id}")
|
|
228
|
+
- message: short human-readable detection message
|
|
229
|
+
- severity: critical | high | medium | low | info
|
|
230
|
+
- category: correctness | suspicious | complexity | performance | style | security | deprecated
|
|
231
|
+
- languages: [{language}]
|
|
232
|
+
- pattern: a Python regex string. Use \\b word boundaries to avoid
|
|
233
|
+
over-matching. Keep the pattern minimal — match only the dangerous
|
|
234
|
+
shape, not the full samples literally.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _strip_code_fences(text: str) -> str:
|
|
239
|
+
"""Strip leading/trailing markdown fences if the LLM wrapped the YAML."""
|
|
240
|
+
cleaned = text.strip()
|
|
241
|
+
if not cleaned.startswith("```"):
|
|
242
|
+
return cleaned
|
|
243
|
+
lines = cleaned.splitlines()
|
|
244
|
+
# Drop the first fence line (```yaml or just ```)
|
|
245
|
+
body = lines[1:]
|
|
246
|
+
if body and body[-1].strip().startswith("```"):
|
|
247
|
+
body = body[:-1]
|
|
248
|
+
return "\n".join(body).strip()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _validate_llm_yaml(
|
|
252
|
+
yaml_text: str,
|
|
253
|
+
positives: list[str],
|
|
254
|
+
negatives: list[str],
|
|
255
|
+
) -> tuple[UnifiedRule | None, list[str]]:
|
|
256
|
+
"""Parse + validate an LLM YAML response. Returns (rule, diagnostics).
|
|
257
|
+
|
|
258
|
+
A response may contain one or several rules; we accept the first one
|
|
259
|
+
that parses and matches all positives without matching any negative.
|
|
260
|
+
"""
|
|
261
|
+
diags: list[str] = []
|
|
262
|
+
try:
|
|
263
|
+
import yaml
|
|
264
|
+
except ImportError:
|
|
265
|
+
return None, ["pyyaml not available — cannot parse LLM YAML"]
|
|
266
|
+
|
|
267
|
+
from attocode_intel.rules.loader import _parse_yaml_rule
|
|
268
|
+
|
|
269
|
+
cleaned = _strip_code_fences(yaml_text)
|
|
270
|
+
try:
|
|
271
|
+
data = yaml.safe_load(cleaned)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
return None, [f"YAML parse failed: {exc}"]
|
|
274
|
+
|
|
275
|
+
if isinstance(data, dict):
|
|
276
|
+
items = [data]
|
|
277
|
+
elif isinstance(data, list):
|
|
278
|
+
items = [d for d in data if isinstance(d, dict)]
|
|
279
|
+
else:
|
|
280
|
+
return None, [f"YAML must be dict or list, got {type(data).__name__}"]
|
|
281
|
+
|
|
282
|
+
if not items:
|
|
283
|
+
return None, ["LLM response contained no parseable rules"]
|
|
284
|
+
|
|
285
|
+
for i, item in enumerate(items):
|
|
286
|
+
rule = _parse_yaml_rule(item, source=RuleSource.USER, origin=f"llm[{i}]")
|
|
287
|
+
if rule is None:
|
|
288
|
+
diags.append(f"rule[{i}]: loader rejected the YAML")
|
|
289
|
+
continue
|
|
290
|
+
if rule.pattern is None:
|
|
291
|
+
diags.append(f"rule[{i}]: no regex pattern emitted")
|
|
292
|
+
continue
|
|
293
|
+
failed_pos = [p for p in positives if not rule.pattern.search(p)]
|
|
294
|
+
matched_neg = [n for n in negatives if rule.pattern.search(n)]
|
|
295
|
+
if failed_pos or matched_neg:
|
|
296
|
+
diags.append(
|
|
297
|
+
f"rule[{i}] failed validation: "
|
|
298
|
+
f"{len(failed_pos)}/{len(positives)} positive miss, "
|
|
299
|
+
f"{len(matched_neg)}/{len(negatives)} negative match"
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
diags.append(f"rule[{i}] validated against samples")
|
|
303
|
+
return rule, diags
|
|
304
|
+
|
|
305
|
+
diags.append("no LLM-generated rule passed validation")
|
|
306
|
+
return None, diags
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _default_llm_caller(prompt: str) -> str:
|
|
310
|
+
"""Resolve the project's shared LLM client lazily — keeps the
|
|
311
|
+
optional ``openai``/``anthropic`` import out of the import path for
|
|
312
|
+
callers that only want the deterministic synthesizer.
|
|
313
|
+
|
|
314
|
+
The default client lives under the dev-only ``eval/meta_harness/``
|
|
315
|
+
tree, which isn't bundled with the installed wheel. When that path
|
|
316
|
+
is unavailable we surface a clear error so the synthesizer's
|
|
317
|
+
``except`` arm can attach it as a diagnostic, instead of letting an
|
|
318
|
+
opaque ``ModuleNotFoundError`` bubble out of the MCP tool.
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
from eval.meta_harness._llm_client import call_llm, load_env
|
|
322
|
+
except ModuleNotFoundError as exc:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
"LLM synthesis requested but no llm_caller was configured "
|
|
325
|
+
"and the dev meta-harness client is not importable from this "
|
|
326
|
+
"process. Pass llm_caller=... explicitly when calling "
|
|
327
|
+
"synthesize_rule(mode='llm' or 'auto') from production."
|
|
328
|
+
) from exc
|
|
329
|
+
|
|
330
|
+
load_env()
|
|
331
|
+
return call_llm(prompt, max_tokens=2048)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Top-level dispatch
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def synthesize_rule(
|
|
340
|
+
positives: list[str],
|
|
341
|
+
negatives: list[str],
|
|
342
|
+
*,
|
|
343
|
+
language: str,
|
|
344
|
+
rule_id: str = "synth-rule",
|
|
345
|
+
description: str = "",
|
|
346
|
+
mode: str = "auto",
|
|
347
|
+
llm_caller: Callable[[str], str] | None = None,
|
|
348
|
+
) -> SynthesisResult:
|
|
349
|
+
"""Top-level synthesizer.
|
|
350
|
+
|
|
351
|
+
``mode``:
|
|
352
|
+
* ``"regex"`` — deterministic regex only.
|
|
353
|
+
* ``"llm"`` — call Claude only.
|
|
354
|
+
* ``"auto"`` — try regex first; fall back to LLM if it fails.
|
|
355
|
+
|
|
356
|
+
``llm_caller`` overrides the default LLM client (for testing without
|
|
357
|
+
network or to plug in a non-Claude provider).
|
|
358
|
+
"""
|
|
359
|
+
diags: list[str] = []
|
|
360
|
+
|
|
361
|
+
if mode not in ("regex", "llm", "auto"):
|
|
362
|
+
return SynthesisResult(
|
|
363
|
+
rule=None,
|
|
364
|
+
method="none",
|
|
365
|
+
diagnostics=[f"unknown mode {mode!r}"],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if mode in ("regex", "auto"):
|
|
369
|
+
rule, regex_diags = synthesize_regex_rule(
|
|
370
|
+
positives, negatives,
|
|
371
|
+
rule_id=rule_id, language=language, description=description,
|
|
372
|
+
)
|
|
373
|
+
diags.extend(regex_diags)
|
|
374
|
+
if rule is not None:
|
|
375
|
+
return SynthesisResult(rule=rule, method="regex", diagnostics=diags)
|
|
376
|
+
|
|
377
|
+
if mode in ("llm", "auto"):
|
|
378
|
+
caller = llm_caller or _default_llm_caller
|
|
379
|
+
prompt = _LLM_SYNTH_PROMPT.format(
|
|
380
|
+
language=language,
|
|
381
|
+
description=description or "(no description)",
|
|
382
|
+
positives="\n".join(f"- {p!r}" for p in positives),
|
|
383
|
+
negatives="\n".join(f"- {n!r}" for n in negatives) or "(none)",
|
|
384
|
+
suggested_id=rule_id,
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
response = caller(prompt)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
diags.append(f"LLM call failed: {exc}")
|
|
390
|
+
return SynthesisResult(rule=None, method="none", diagnostics=diags)
|
|
391
|
+
|
|
392
|
+
rule, llm_diags = _validate_llm_yaml(response, positives, negatives)
|
|
393
|
+
diags.extend(llm_diags)
|
|
394
|
+
if rule is not None:
|
|
395
|
+
return SynthesisResult(rule=rule, method="llm", diagnostics=diags)
|
|
396
|
+
|
|
397
|
+
return SynthesisResult(rule=None, method="none", diagnostics=diags)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def render_rule_yaml(rule: UnifiedRule) -> str:
|
|
401
|
+
"""Render a synthesised rule as a YAML snippet ready to paste into a
|
|
402
|
+
pack file. Order mirrors the convention used by the example packs."""
|
|
403
|
+
lines = [f"- id: {rule.id}"]
|
|
404
|
+
lines.append(f" name: {rule.name}")
|
|
405
|
+
if rule.description:
|
|
406
|
+
# Quote the message; rule loader treats ``message`` as the canonical key.
|
|
407
|
+
msg = rule.description.replace('"', '\\"')
|
|
408
|
+
lines.append(f' message: "{msg}"')
|
|
409
|
+
if rule.pattern is not None:
|
|
410
|
+
pat = rule.pattern.pattern.replace("'", "''")
|
|
411
|
+
lines.append(f" pattern: '{pat}'")
|
|
412
|
+
if rule.structural_pattern:
|
|
413
|
+
sp = rule.structural_pattern.replace("'", "''")
|
|
414
|
+
lines.append(f" structural_pattern: '{sp}'")
|
|
415
|
+
lines.append(f" severity: {rule.severity}")
|
|
416
|
+
lines.append(f" category: {rule.category}")
|
|
417
|
+
if rule.languages:
|
|
418
|
+
lines.append(f" languages: [{', '.join(rule.languages)}]")
|
|
419
|
+
lines.append(f" confidence: {rule.confidence}")
|
|
420
|
+
return "\n".join(lines)
|