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,127 @@
|
|
|
1
|
+
"""Finding enricher — adds surrounding context for agent reasoning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from attocode_intel.rules.model import EnrichedFinding
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_CONTEXT_LINES = 10
|
|
16
|
+
|
|
17
|
+
# mtime-aware file cache: path -> (mtime, lines)
|
|
18
|
+
_file_cache: dict[str, tuple[float, tuple[str, ...]]] = {}
|
|
19
|
+
_file_cache_lock = threading.Lock()
|
|
20
|
+
_CACHE_MAX = 256
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_file_lines(file_path: str) -> tuple[str, ...]:
|
|
24
|
+
"""Read file lines with mtime-aware caching (invalidates on change)."""
|
|
25
|
+
try:
|
|
26
|
+
mtime = os.path.getmtime(file_path)
|
|
27
|
+
except OSError:
|
|
28
|
+
return ()
|
|
29
|
+
with _file_cache_lock:
|
|
30
|
+
cached = _file_cache.get(file_path)
|
|
31
|
+
if cached and cached[0] == mtime:
|
|
32
|
+
return cached[1]
|
|
33
|
+
# I/O outside lock
|
|
34
|
+
try:
|
|
35
|
+
lines = tuple(Path(file_path).read_text(encoding="utf-8", errors="replace").splitlines())
|
|
36
|
+
except OSError:
|
|
37
|
+
return ()
|
|
38
|
+
with _file_cache_lock:
|
|
39
|
+
_file_cache[file_path] = (mtime, lines)
|
|
40
|
+
if len(_file_cache) > _CACHE_MAX:
|
|
41
|
+
oldest = next(iter(_file_cache))
|
|
42
|
+
del _file_cache[oldest]
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Function-def patterns for enclosing function detection
|
|
47
|
+
_FUNC_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
48
|
+
"python": re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\("),
|
|
49
|
+
"javascript": re.compile(
|
|
50
|
+
r"(?:(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\()"
|
|
51
|
+
),
|
|
52
|
+
"typescript": re.compile(
|
|
53
|
+
r"(?:(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\()"
|
|
54
|
+
),
|
|
55
|
+
"go": re.compile(r"^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\("),
|
|
56
|
+
"rust": re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)"),
|
|
57
|
+
"java": re.compile(r"^\s*(?:public|private|protected|static|\s)*\s+\w+\s+(\w+)\s*\("),
|
|
58
|
+
"kotlin": re.compile(r"^\s*(?:fun|suspend\s+fun)\s+(\w+)"),
|
|
59
|
+
"ruby": re.compile(r"^\s*def\s+(\w+)"),
|
|
60
|
+
"csharp": re.compile(r"^\s*(?:public|private|protected|static|\s)*\s+\w+\s+(\w+)\s*\("),
|
|
61
|
+
"cpp": re.compile(r"^\s*(?:\w+\s+)*\w+\s+(\w+)\s*\("),
|
|
62
|
+
"c": re.compile(r"^\s*(?:\w+\s+)*\w+\s+(\w+)\s*\("),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_EXT_LANG: dict[str, str] = {
|
|
66
|
+
".py": "python", ".pyi": "python",
|
|
67
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript",
|
|
68
|
+
".ts": "typescript", ".tsx": "typescript",
|
|
69
|
+
".go": "go", ".rs": "rust", ".java": "java",
|
|
70
|
+
".kt": "kotlin", ".rb": "ruby", ".cs": "csharp",
|
|
71
|
+
".cpp": "cpp", ".hpp": "cpp", ".c": "c", ".h": "c",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _detect_language(file_path: str) -> str:
|
|
76
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
77
|
+
return _EXT_LANG.get(ext, "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_enclosing_function(lines: tuple[str, ...], target_line: int, language: str) -> str:
|
|
81
|
+
"""Find the function name enclosing target_line (1-indexed)."""
|
|
82
|
+
pat = _FUNC_PATTERNS.get(language)
|
|
83
|
+
if pat is None:
|
|
84
|
+
return ""
|
|
85
|
+
for i in range(min(target_line - 1, len(lines) - 1), -1, -1):
|
|
86
|
+
m = pat.search(lines[i])
|
|
87
|
+
if m:
|
|
88
|
+
for g in m.groups():
|
|
89
|
+
if g:
|
|
90
|
+
return g
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def enrich_findings(
|
|
95
|
+
findings: list[EnrichedFinding],
|
|
96
|
+
*,
|
|
97
|
+
project_dir: str = "",
|
|
98
|
+
context_lines: int = _CONTEXT_LINES,
|
|
99
|
+
) -> list[EnrichedFinding]:
|
|
100
|
+
"""Enrich findings with surrounding context and enclosing function.
|
|
101
|
+
|
|
102
|
+
Mutates findings in-place and returns them for chaining.
|
|
103
|
+
"""
|
|
104
|
+
for finding in findings:
|
|
105
|
+
if project_dir and not os.path.isabs(finding.file):
|
|
106
|
+
abs_path = os.path.join(project_dir, finding.file)
|
|
107
|
+
else:
|
|
108
|
+
abs_path = finding.file
|
|
109
|
+
|
|
110
|
+
lines = _read_file_lines(abs_path)
|
|
111
|
+
if not lines:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
idx = finding.line - 1 # 0-indexed
|
|
115
|
+
|
|
116
|
+
# Surrounding context
|
|
117
|
+
start = max(0, idx - context_lines)
|
|
118
|
+
end = min(len(lines), idx + context_lines + 1)
|
|
119
|
+
finding.context_before = list(lines[start:idx])
|
|
120
|
+
finding.context_after = list(lines[idx + 1:end])
|
|
121
|
+
|
|
122
|
+
# Enclosing function
|
|
123
|
+
lang = _detect_language(abs_path)
|
|
124
|
+
if lang and not finding.function_name:
|
|
125
|
+
finding.function_name = _find_enclosing_function(lines, finding.line, lang)
|
|
126
|
+
|
|
127
|
+
return findings
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Evolutionary rule optimisation (A8).
|
|
2
|
+
|
|
3
|
+
Genetic-programming-style search over rule space. The evolutionary loop
|
|
4
|
+
takes a seed rule (or population) plus a labelled fixture directory and
|
|
5
|
+
iteratively mutates / crosses over candidates, keeping the elite under a
|
|
6
|
+
fitness function (precision + recall + speed). Designed as the
|
|
7
|
+
optimisation layer that pairs with :mod:`rules.synthesis` (which
|
|
8
|
+
produces seeds) and :mod:`rules.testing` (which provides ground truth
|
|
9
|
+
via inline annotations).
|
|
10
|
+
|
|
11
|
+
This v1 focuses on regex Tier-1 rules. Structural / composite mutation
|
|
12
|
+
operators are deliberately scoped out — they need richer AST surgery.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import random
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field, replace
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from attocode_intel.rules.model import (
|
|
27
|
+
RuleSeverity,
|
|
28
|
+
RuleSource,
|
|
29
|
+
UnifiedRule,
|
|
30
|
+
)
|
|
31
|
+
from attocode_intel.rules.testing import RuleTestRunner
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Severity ordering used by SEVERITY_UP / SEVERITY_DOWN mutations.
|
|
37
|
+
_SEVERITY_LADDER: list[RuleSeverity] = [
|
|
38
|
+
RuleSeverity.INFO,
|
|
39
|
+
RuleSeverity.LOW,
|
|
40
|
+
RuleSeverity.MEDIUM,
|
|
41
|
+
RuleSeverity.HIGH,
|
|
42
|
+
RuleSeverity.CRITICAL,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Fitness
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class Fitness:
|
|
53
|
+
"""Per-rule fitness signal sourced from RuleTestRunner + timing.
|
|
54
|
+
|
|
55
|
+
Composite is a weighted blend; defaults match the roadmap brief
|
|
56
|
+
("weighted combination of precision, recall, CASTLE score, and
|
|
57
|
+
execution speed"). We omit CASTLE here since it isn't wired yet —
|
|
58
|
+
speed bonus and recall fill that slot.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
precision: float
|
|
62
|
+
recall: float
|
|
63
|
+
f1: float
|
|
64
|
+
speed_ms: float
|
|
65
|
+
composite: float
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compute_composite(
|
|
69
|
+
*,
|
|
70
|
+
precision: float,
|
|
71
|
+
recall: float,
|
|
72
|
+
speed_ms: float,
|
|
73
|
+
speed_budget_ms: float = 50.0,
|
|
74
|
+
) -> float:
|
|
75
|
+
"""Weighted blend: 50% precision + 35% recall + 15% speed bonus.
|
|
76
|
+
|
|
77
|
+
Speed bonus is ``max(0, 1 - speed_ms/budget)`` so anything under the
|
|
78
|
+
budget gets a positive contribution; over-budget rules drop to zero.
|
|
79
|
+
"""
|
|
80
|
+
speed_bonus = max(0.0, 1.0 - speed_ms / speed_budget_ms)
|
|
81
|
+
return 0.50 * precision + 0.35 * recall + 0.15 * speed_bonus
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def evaluate_rule(
|
|
85
|
+
rule: UnifiedRule,
|
|
86
|
+
*,
|
|
87
|
+
fixtures_dir: str,
|
|
88
|
+
project_dir: str = "",
|
|
89
|
+
) -> Fitness:
|
|
90
|
+
"""Run ``rule`` against the fixture corpus and compute fitness.
|
|
91
|
+
|
|
92
|
+
Annotations on fixture lines are ``# expect: <rule-id>`` (TP target)
|
|
93
|
+
and ``# ok: <rule-id>`` (negative — must NOT fire).
|
|
94
|
+
"""
|
|
95
|
+
runner = RuleTestRunner([rule], project_dir=project_dir or fixtures_dir)
|
|
96
|
+
t0 = time.monotonic()
|
|
97
|
+
suite = runner.run_test_suite(fixtures_dir)
|
|
98
|
+
elapsed_ms = (time.monotonic() - t0) * 1000.0
|
|
99
|
+
|
|
100
|
+
tp = sum(
|
|
101
|
+
len([a for a in fr.passed if a.kind == "expect"])
|
|
102
|
+
for fr in suite.file_results
|
|
103
|
+
)
|
|
104
|
+
fn = sum(
|
|
105
|
+
sum(1 for ann, _msg in fr.failed if ann.kind == "expect")
|
|
106
|
+
for fr in suite.file_results
|
|
107
|
+
)
|
|
108
|
+
fp = sum(len(fr.false_positives) for fr in suite.file_results)
|
|
109
|
+
|
|
110
|
+
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
|
111
|
+
recall = tp / (tp + fn) if (tp + fn) else 0.0
|
|
112
|
+
f1 = (
|
|
113
|
+
2 * precision * recall / (precision + recall)
|
|
114
|
+
if (precision + recall)
|
|
115
|
+
else 0.0
|
|
116
|
+
)
|
|
117
|
+
composite = _compute_composite(
|
|
118
|
+
precision=precision, recall=recall, speed_ms=elapsed_ms,
|
|
119
|
+
)
|
|
120
|
+
return Fitness(
|
|
121
|
+
precision=precision,
|
|
122
|
+
recall=recall,
|
|
123
|
+
f1=round(f1, 4),
|
|
124
|
+
speed_ms=round(elapsed_ms, 3),
|
|
125
|
+
composite=round(composite, 4),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Mutations
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class MutationOperator(StrEnum):
|
|
135
|
+
"""Atomic mutations applied to a rule. None of them invent new
|
|
136
|
+
pattern primitives — they only loosen / tighten what's already
|
|
137
|
+
there. Composing many small mutations across generations is what
|
|
138
|
+
gives the search its reach."""
|
|
139
|
+
|
|
140
|
+
REGEX_WIDEN = "regex_widen"
|
|
141
|
+
REGEX_NARROW = "regex_narrow"
|
|
142
|
+
SEVERITY_UP = "severity_up"
|
|
143
|
+
SEVERITY_DOWN = "severity_down"
|
|
144
|
+
CONFIDENCE_UP = "confidence_up"
|
|
145
|
+
CONFIDENCE_DOWN = "confidence_down"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _widen_regex(pattern_src: str) -> str | None:
|
|
149
|
+
"""Drop one ``\\b`` anchor (the most common form of over-tightening)."""
|
|
150
|
+
if r"\b" in pattern_src:
|
|
151
|
+
return pattern_src.replace(r"\b", "", 1)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _narrow_regex(pattern_src: str) -> str | None:
|
|
156
|
+
"""Add ``\\b`` boundaries on both sides if the pattern has no anchors."""
|
|
157
|
+
if pattern_src.startswith(r"\b") and pattern_src.endswith(r"\b"):
|
|
158
|
+
return None # already maximally anchored
|
|
159
|
+
out = pattern_src
|
|
160
|
+
if not out.startswith(r"\b"):
|
|
161
|
+
out = r"\b" + out
|
|
162
|
+
if not out.endswith(r"\b"):
|
|
163
|
+
out = out + r"\b"
|
|
164
|
+
return out if out != pattern_src else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _shift_severity(rule: UnifiedRule, *, direction: int) -> RuleSeverity | None:
|
|
168
|
+
try:
|
|
169
|
+
idx = _SEVERITY_LADDER.index(rule.severity)
|
|
170
|
+
except ValueError:
|
|
171
|
+
return None
|
|
172
|
+
new_idx = idx + direction
|
|
173
|
+
if 0 <= new_idx < len(_SEVERITY_LADDER) and new_idx != idx:
|
|
174
|
+
return _SEVERITY_LADDER[new_idx]
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def mutate(
|
|
179
|
+
rule: UnifiedRule,
|
|
180
|
+
operator: MutationOperator,
|
|
181
|
+
*,
|
|
182
|
+
rng: random.Random | None = None,
|
|
183
|
+
confidence_step: float = 0.1,
|
|
184
|
+
) -> UnifiedRule | None:
|
|
185
|
+
"""Apply ``operator`` to ``rule``. Returns the mutated copy or
|
|
186
|
+
``None`` when the mutation can't apply (e.g. widen on an already
|
|
187
|
+
anchorless pattern). The rule's id is preserved — the caller is
|
|
188
|
+
expected to re-id during crossover."""
|
|
189
|
+
rng = rng or random.Random()
|
|
190
|
+
|
|
191
|
+
if operator in (MutationOperator.REGEX_WIDEN, MutationOperator.REGEX_NARROW):
|
|
192
|
+
if rule.pattern is None:
|
|
193
|
+
return None
|
|
194
|
+
new_src: str | None
|
|
195
|
+
if operator == MutationOperator.REGEX_WIDEN:
|
|
196
|
+
new_src = _widen_regex(rule.pattern.pattern)
|
|
197
|
+
else:
|
|
198
|
+
new_src = _narrow_regex(rule.pattern.pattern)
|
|
199
|
+
if new_src is None:
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
new_re = re.compile(new_src)
|
|
203
|
+
except re.error:
|
|
204
|
+
return None
|
|
205
|
+
return replace(rule, pattern=new_re)
|
|
206
|
+
|
|
207
|
+
if operator == MutationOperator.SEVERITY_UP:
|
|
208
|
+
new_sev = _shift_severity(rule, direction=+1)
|
|
209
|
+
if new_sev is None:
|
|
210
|
+
return None
|
|
211
|
+
return replace(rule, severity=new_sev)
|
|
212
|
+
|
|
213
|
+
if operator == MutationOperator.SEVERITY_DOWN:
|
|
214
|
+
new_sev = _shift_severity(rule, direction=-1)
|
|
215
|
+
if new_sev is None:
|
|
216
|
+
return None
|
|
217
|
+
return replace(rule, severity=new_sev)
|
|
218
|
+
|
|
219
|
+
if operator == MutationOperator.CONFIDENCE_UP:
|
|
220
|
+
new_conf = min(1.0, round(rule.confidence + confidence_step, 4))
|
|
221
|
+
if new_conf == rule.confidence:
|
|
222
|
+
return None
|
|
223
|
+
return replace(rule, confidence=new_conf)
|
|
224
|
+
|
|
225
|
+
if operator == MutationOperator.CONFIDENCE_DOWN:
|
|
226
|
+
new_conf = max(0.0, round(rule.confidence - confidence_step, 4))
|
|
227
|
+
if new_conf == rule.confidence:
|
|
228
|
+
return None
|
|
229
|
+
return replace(rule, confidence=new_conf)
|
|
230
|
+
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Crossover
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def crossover(
|
|
240
|
+
parent_a: UnifiedRule,
|
|
241
|
+
parent_b: UnifiedRule,
|
|
242
|
+
*,
|
|
243
|
+
rng: random.Random | None = None,
|
|
244
|
+
child_id: str = "",
|
|
245
|
+
) -> UnifiedRule:
|
|
246
|
+
"""Combine two parents into a child rule. We splice scalar fields
|
|
247
|
+
50/50 and prefer parent A's pattern (the regex is the most
|
|
248
|
+
structural piece — random splicing across regex strings tends to
|
|
249
|
+
produce invalid syntax)."""
|
|
250
|
+
rng = rng or random.Random()
|
|
251
|
+
pick_a = lambda: rng.random() < 0.5 # noqa: E731 — local lambda
|
|
252
|
+
return UnifiedRule(
|
|
253
|
+
id=child_id or f"{parent_a.id}-x-{parent_b.id}",
|
|
254
|
+
name=parent_a.name if pick_a() else parent_b.name,
|
|
255
|
+
description=parent_a.description if pick_a() else parent_b.description,
|
|
256
|
+
severity=parent_a.severity if pick_a() else parent_b.severity,
|
|
257
|
+
category=parent_a.category if pick_a() else parent_b.category,
|
|
258
|
+
languages=list(parent_a.languages), # languages stay with A's pattern
|
|
259
|
+
pattern=parent_a.pattern,
|
|
260
|
+
structural_pattern=parent_a.structural_pattern,
|
|
261
|
+
cwe=parent_a.cwe if pick_a() else parent_b.cwe,
|
|
262
|
+
tags=list(set(parent_a.tags) | set(parent_b.tags)),
|
|
263
|
+
source=RuleSource.USER,
|
|
264
|
+
tier=parent_a.tier,
|
|
265
|
+
confidence=round((parent_a.confidence + parent_b.confidence) / 2, 4),
|
|
266
|
+
recommendation=parent_a.recommendation if pick_a() else parent_b.recommendation,
|
|
267
|
+
explanation=parent_a.explanation if pick_a() else parent_b.explanation,
|
|
268
|
+
pack=parent_a.pack,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Selection + evolution loop
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def tournament_select(
|
|
278
|
+
ranked: list[tuple[UnifiedRule, Fitness]],
|
|
279
|
+
*,
|
|
280
|
+
k: int = 3,
|
|
281
|
+
rng: random.Random | None = None,
|
|
282
|
+
) -> UnifiedRule:
|
|
283
|
+
"""Pick ``k`` random candidates and return the highest-composite one."""
|
|
284
|
+
rng = rng or random.Random()
|
|
285
|
+
if not ranked:
|
|
286
|
+
raise ValueError("cannot select from empty population")
|
|
287
|
+
sample = rng.sample(ranked, min(k, len(ranked)))
|
|
288
|
+
sample.sort(key=lambda x: -x[1].composite)
|
|
289
|
+
return sample[0][0]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(slots=True)
|
|
293
|
+
class EvolutionState:
|
|
294
|
+
"""Snapshot of an evolution run after termination."""
|
|
295
|
+
|
|
296
|
+
population: list[UnifiedRule]
|
|
297
|
+
fitnesses: list[Fitness]
|
|
298
|
+
best_rule: UnifiedRule
|
|
299
|
+
best_fitness: Fitness
|
|
300
|
+
generations_run: int
|
|
301
|
+
stopped_reason: str # "max_generations" | "plateau"
|
|
302
|
+
history: list[float] = field(default_factory=list) # best composite per gen
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def evolve(
|
|
306
|
+
seeds: list[UnifiedRule],
|
|
307
|
+
*,
|
|
308
|
+
fixtures_dir: str,
|
|
309
|
+
project_dir: str = "",
|
|
310
|
+
max_generations: int = 50,
|
|
311
|
+
population_size: int = 12,
|
|
312
|
+
elite_frac: float = 0.1,
|
|
313
|
+
plateau_gens: int = 5,
|
|
314
|
+
plateau_eps: float = 1e-3,
|
|
315
|
+
rng: random.Random | None = None,
|
|
316
|
+
log_path: str = "",
|
|
317
|
+
) -> EvolutionState:
|
|
318
|
+
"""Run the evolutionary loop. Caller supplies one or more seed rules;
|
|
319
|
+
we pad/trim to ``population_size`` via mutation. ``fixtures_dir`` is
|
|
320
|
+
a directory of inline-annotated source files (same format consumed
|
|
321
|
+
by the ``test_rules`` MCP tool)."""
|
|
322
|
+
if not seeds:
|
|
323
|
+
raise ValueError("evolve() requires at least one seed rule")
|
|
324
|
+
if max_generations < 1:
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"evolve() requires max_generations >= 1, got {max_generations}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
rng = rng or random.Random(42)
|
|
330
|
+
|
|
331
|
+
# Dedupe seeds by id — duplicates would just burn fitness evaluations
|
|
332
|
+
# on identical individuals before any mutation can diverge them.
|
|
333
|
+
deduped: dict[str, UnifiedRule] = {}
|
|
334
|
+
for s in seeds:
|
|
335
|
+
deduped.setdefault(s.id, s)
|
|
336
|
+
seeds = list(deduped.values())
|
|
337
|
+
|
|
338
|
+
# Initialise population: seed + mutated copies until we hit the size.
|
|
339
|
+
population: list[UnifiedRule] = list(seeds)[:population_size]
|
|
340
|
+
operators = list(MutationOperator)
|
|
341
|
+
while len(population) < population_size:
|
|
342
|
+
parent = rng.choice(seeds)
|
|
343
|
+
op = rng.choice(operators)
|
|
344
|
+
child = mutate(parent, op, rng=rng)
|
|
345
|
+
population.append(child or parent)
|
|
346
|
+
|
|
347
|
+
elite_count = max(1, int(population_size * elite_frac))
|
|
348
|
+
history: list[float] = []
|
|
349
|
+
audit_log: list[dict] = []
|
|
350
|
+
|
|
351
|
+
best_rule = population[0]
|
|
352
|
+
best_fitness = Fitness(0.0, 0.0, 0.0, 0.0, 0.0)
|
|
353
|
+
stopped_reason = "max_generations"
|
|
354
|
+
generations_run = 0
|
|
355
|
+
# Snapshot the last generation's evaluated population so the final
|
|
356
|
+
# ``EvolutionState`` carries fitnesses that actually correspond to
|
|
357
|
+
# the rules we report — and so we don't pay for re-evaluation after
|
|
358
|
+
# the loop ends. Updated each generation.
|
|
359
|
+
last_evaluated_rules: list[UnifiedRule] = list(population)
|
|
360
|
+
last_evaluated_fits: list[Fitness] = []
|
|
361
|
+
|
|
362
|
+
def _safe_evaluate(rule: UnifiedRule) -> Fitness:
|
|
363
|
+
"""Defensive wrapper — RuleTestRunner can raise on a malformed
|
|
364
|
+
fixture or a tree-sitter crash. Surface the failure as a
|
|
365
|
+
zero-fitness candidate so the loop continues and the audit log
|
|
366
|
+
captures the broken individual instead of crashing the run."""
|
|
367
|
+
try:
|
|
368
|
+
return evaluate_rule(
|
|
369
|
+
rule, fixtures_dir=fixtures_dir, project_dir=project_dir,
|
|
370
|
+
)
|
|
371
|
+
except Exception as exc: # noqa: BLE001 — we want all errors caught
|
|
372
|
+
logger.warning(
|
|
373
|
+
"evaluate_rule failed for %s: %s", rule.id, exc,
|
|
374
|
+
)
|
|
375
|
+
return Fitness(0.0, 0.0, 0.0, 0.0, 0.0)
|
|
376
|
+
|
|
377
|
+
for gen in range(max_generations):
|
|
378
|
+
generations_run = gen + 1
|
|
379
|
+
fitnesses = [_safe_evaluate(r) for r in population]
|
|
380
|
+
ranked = sorted(
|
|
381
|
+
zip(population, fitnesses, strict=True),
|
|
382
|
+
key=lambda x: -x[1].composite,
|
|
383
|
+
)
|
|
384
|
+
last_evaluated_rules = [r for r, _ in ranked]
|
|
385
|
+
last_evaluated_fits = [f for _, f in ranked]
|
|
386
|
+
gen_best_rule, gen_best_fit = ranked[0]
|
|
387
|
+
history.append(gen_best_fit.composite)
|
|
388
|
+
|
|
389
|
+
audit_log.append({
|
|
390
|
+
"generation": gen,
|
|
391
|
+
"best_id": gen_best_rule.id,
|
|
392
|
+
"best_pattern": (
|
|
393
|
+
gen_best_rule.pattern.pattern if gen_best_rule.pattern else ""
|
|
394
|
+
),
|
|
395
|
+
"best_composite": gen_best_fit.composite,
|
|
396
|
+
"best_precision": gen_best_fit.precision,
|
|
397
|
+
"best_recall": gen_best_fit.recall,
|
|
398
|
+
"population_mean_composite": round(
|
|
399
|
+
sum(f.composite for f in fitnesses) / len(fitnesses), 4,
|
|
400
|
+
),
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
if gen_best_fit.composite > best_fitness.composite:
|
|
404
|
+
best_rule = gen_best_rule
|
|
405
|
+
best_fitness = gen_best_fit
|
|
406
|
+
|
|
407
|
+
# Plateau check: best composite hasn't budged across
|
|
408
|
+
# ``plateau_gens`` consecutive generations. Require ≥2 entries
|
|
409
|
+
# in history so a single-point window can't trigger the stop.
|
|
410
|
+
if len(history) >= max(2, plateau_gens):
|
|
411
|
+
recent = history[-plateau_gens:]
|
|
412
|
+
if max(recent) - min(recent) < plateau_eps:
|
|
413
|
+
stopped_reason = "plateau"
|
|
414
|
+
break
|
|
415
|
+
|
|
416
|
+
# Build next generation: elite + tournament-selected children.
|
|
417
|
+
# Crossover children get unique IDs (parent_a-x-parent_b-gN) so
|
|
418
|
+
# the population keeps diverse identifiers across generations
|
|
419
|
+
# — without this every child collapses onto parent_a.id.
|
|
420
|
+
next_pop: list[UnifiedRule] = [r for r, _ in ranked[:elite_count]]
|
|
421
|
+
child_idx = 0
|
|
422
|
+
while len(next_pop) < population_size:
|
|
423
|
+
parent_a = tournament_select(ranked, k=3, rng=rng)
|
|
424
|
+
if rng.random() < 0.5 and len(ranked) > 1:
|
|
425
|
+
parent_b = tournament_select(ranked, k=3, rng=rng)
|
|
426
|
+
child_id = f"{parent_a.id}-x-{parent_b.id}-g{gen}-{child_idx}"
|
|
427
|
+
child = crossover(parent_a, parent_b, rng=rng, child_id=child_id)
|
|
428
|
+
else:
|
|
429
|
+
op = rng.choice(operators)
|
|
430
|
+
child = mutate(parent_a, op, rng=rng) or parent_a
|
|
431
|
+
next_pop.append(child)
|
|
432
|
+
child_idx += 1
|
|
433
|
+
population = next_pop
|
|
434
|
+
|
|
435
|
+
if log_path:
|
|
436
|
+
try:
|
|
437
|
+
Path(log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
with Path(log_path).open("a", encoding="utf-8") as fh:
|
|
439
|
+
for entry in audit_log:
|
|
440
|
+
fh.write(json.dumps(entry) + "\n")
|
|
441
|
+
except OSError as exc:
|
|
442
|
+
logger.warning("Failed to persist evolution log to %s: %s", log_path, exc)
|
|
443
|
+
|
|
444
|
+
return EvolutionState(
|
|
445
|
+
population=last_evaluated_rules,
|
|
446
|
+
fitnesses=last_evaluated_fits,
|
|
447
|
+
best_rule=best_rule,
|
|
448
|
+
best_fitness=best_fitness,
|
|
449
|
+
generations_run=generations_run,
|
|
450
|
+
stopped_reason=stopped_reason,
|
|
451
|
+
history=history,
|
|
452
|
+
)
|