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.
Files changed (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,3416 @@
1
+ """Unified code intelligence service.
2
+
3
+ Used by both the MCP server (server.py) and HTTP API (api/app.py) as a
4
+ single source of truth for the shared code-intelligence surface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ from collections import Counter, deque
15
+ from contextvars import ContextVar
16
+ from dataclasses import replace
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ from attocode_intel._internal.integrations.utilities.token_estimate import estimate_tokens
21
+ from attocode_intel.config import CodeIntelConfig
22
+ from attocode_intel.project_dir import cache_folder
23
+ from attocode_intel.query_ranking import (
24
+ broad_rank_enabled,
25
+ hit_evidence,
26
+ next_search_top_k,
27
+ query_diagnostics,
28
+ )
29
+
30
+ # Seconds that a search waits for warming indexes before it reports "warming". A search over
31
+ # many workspaces sets a short wait, so that one large workspace cannot stall the others.
32
+ SEARCH_WARMUP_WAIT: ContextVar[float] = ContextVar("search_warmup_wait", default=15.0)
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
37
+ from attocode_intel._internal.integrations.context.ast_service import ASTService
38
+ from attocode_intel._internal.integrations.context.code_analyzer import CodeAnalyzer
39
+ from attocode_intel._internal.integrations.context.codebase_context import (
40
+ CodebaseContextManager,
41
+ )
42
+ from attocode_intel._internal.integrations.context.hierarchical_explorer import (
43
+ HierarchicalExplorer,
44
+ )
45
+ from attocode_intel._internal.integrations.context.memory_store import MemoryStore
46
+ from attocode_intel._internal.integrations.context.reranker import RankingProvider
47
+ from attocode_intel._internal.integrations.context.semantic_search import (
48
+ ContextAssemblyConfig,
49
+ SearchScoringConfig,
50
+ SemanticSearchManager,
51
+ )
52
+ from attocode_intel._internal.integrations.context.systemone_ranker import (
53
+ SystemOneChoiceReranker,
54
+ )
55
+ from attocode_intel._internal.integrations.context.temporal_coupling import (
56
+ TemporalCouplingAnalyzer,
57
+ )
58
+ from attocode_intel._internal.integrations.lsp.client import LSPManager
59
+ from attocode_intel._internal.integrations.security.scanner import SecurityScanner
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Registry of service instances by project_dir
64
+ _instances: dict[str, CodeIntelService] = {}
65
+ _instances_lock = threading.Lock()
66
+
67
+
68
+ class CodeIntelService:
69
+ """Unified code intelligence service wrapping all tool operations."""
70
+
71
+ def __init__(self, project_dir: str, config: CodeIntelConfig | None = None) -> None:
72
+ self._project_dir = os.path.realpath(project_dir)
73
+ with contextlib.suppress(OSError): # A read-only project still answers.
74
+ cache_folder(self._project_dir)
75
+ self._config = config or CodeIntelConfig(project_dir=self._project_dir)
76
+ self._scoring_config: SearchScoringConfig | None = None # Optional override
77
+ self._context_config: ContextAssemblyConfig | None = None # Optional override
78
+
79
+ # Lazy singletons
80
+ self._init_lock = threading.Lock()
81
+ self._ast_service: ASTService | None = None
82
+ self._context_mgr: CodebaseContextManager | None = None
83
+ self._code_analyzer: CodeAnalyzer | None = None
84
+ self._lsp_manager: LSPManager | None = None
85
+ self._explorer: HierarchicalExplorer | None = None
86
+ self._security_scanner: SecurityScanner | None = None
87
+ self._semantic_search: SemanticSearchManager | None = None
88
+ self._local_reranker: RankingProvider | None = None
89
+ self._local_reranker_status = "not_configured"
90
+ self._systemone_ranker: SystemOneChoiceReranker | None = None
91
+ self._ranking_provider = (self._config.ranking_provider
92
+ if config is not None else
93
+ os.environ.get("ATTOCODE_INTEL_RANKING_PROVIDER", "")) or "auto"
94
+ self._systemone_status = "not_configured"
95
+ self._memory_store: MemoryStore | None = None
96
+ self._temporal_analyzer: TemporalCouplingAnalyzer | None = None
97
+ self._lsp_auto_started: bool = False
98
+
99
+ # Both an absolute local directory and its pinned fingerprint are
100
+ # required. Prewarming happens off the request thread; search never
101
+ # fetches weights or waits for cold model loading.
102
+ reranker_path = (self._config.local_reranker_path
103
+ or os.environ.get("ATTOCODE_INTEL_RERANKER_PATH", ""))
104
+ reranker_sha = (self._config.local_reranker_sha256
105
+ or os.environ.get("ATTOCODE_INTEL_RERANKER_SHA256", ""))
106
+ if self._ranking_provider not in {"auto", "off", "local_cross_encoder", "systemone"}:
107
+ self._systemone_status = "invalid_config"
108
+ logger.warning("Unknown ranking provider; deterministic ranking remains active")
109
+ elif self._ranking_provider == "systemone":
110
+ try:
111
+ from attocode_intel._internal.integrations.context.systemone_ranker import (
112
+ SystemOneChoiceReranker,
113
+ )
114
+
115
+ environment = os.environ if config is None else {}
116
+ remote_opt_in = (self._config.ranking_allow_remote if config is not None else
117
+ environment.get("ATTOCODE_INTEL_RANKING_ALLOW_REMOTE", "").lower()
118
+ in {"1", "true", "yes", "on"})
119
+ remote_workspace = (self._config.ranking_remote_workspace or
120
+ environment.get("ATTOCODE_INTEL_RANKING_REMOTE_WORKSPACE", ""))
121
+ timeout_ms = (self._config.ranking_timeout_ms if config is not None else
122
+ int(environment.get("ATTOCODE_INTEL_RANKING_TIMEOUT_MS", "0")))
123
+ self._systemone_ranker = SystemOneChoiceReranker(
124
+ self._config.ranking_endpoint or environment.get("ATTOCODE_INTEL_RANKING_ENDPOINT", ""),
125
+ model=self._config.ranking_model or environment.get("ATTOCODE_INTEL_RANKING_MODEL", ""),
126
+ auth_env=self._config.ranking_auth_env or environment.get("ATTOCODE_INTEL_RANKING_AUTH_ENV", ""),
127
+ allow_remote=(remote_opt_in and os.path.isabs(remote_workspace) and
128
+ os.path.realpath(remote_workspace) == self._project_dir),
129
+ service_mode=self._config.is_service_mode,
130
+ timeout_seconds=None if timeout_ms == 0 else timeout_ms / 1000,
131
+ max_candidates=(self._config.ranking_max_candidates if config is not None else
132
+ int(environment.get("ATTOCODE_INTEL_RANKING_MAX_CANDIDATES", "24"))),
133
+ )
134
+ self._systemone_status = "configured"
135
+ except (ValueError, OverflowError):
136
+ self._systemone_status = "invalid_config"
137
+ logger.warning("Invalid SystemOne ranking configuration; deterministic ranking remains active")
138
+ if self._ranking_provider in {"auto", "local_cross_encoder"}:
139
+ if bool(reranker_path) != bool(reranker_sha):
140
+ self._local_reranker_status = "incomplete_config"
141
+ logger.warning("Local reranker requires both path and SHA-256; deterministic ranking remains active")
142
+ if reranker_path and reranker_sha:
143
+ try:
144
+ from attocode_intel._internal.integrations.context.reranker import (
145
+ LocalCrossEncoderReranker,
146
+ )
147
+ self._local_reranker = LocalCrossEncoderReranker(reranker_path, reranker_sha)
148
+ self._local_reranker.start_prewarm()
149
+ self._local_reranker_status = "loading"
150
+ except ValueError:
151
+ logger.warning("Invalid local reranker configuration; deterministic ranking remains active")
152
+ self._local_reranker_status = "invalid_config"
153
+
154
+ @classmethod
155
+ def get_instance(cls, project_dir: str, config: CodeIntelConfig | None = None) -> CodeIntelService:
156
+ """Get or create a service instance for the given project directory."""
157
+ abs_dir = os.path.realpath(project_dir)
158
+ if abs_dir not in _instances:
159
+ with _instances_lock:
160
+ if abs_dir not in _instances:
161
+ _instances[abs_dir] = cls(abs_dir, config)
162
+ return _instances[abs_dir]
163
+
164
+ @classmethod
165
+ def _reset_instances(cls) -> None:
166
+ """Clear all cached instances. For test isolation only."""
167
+ with _instances_lock:
168
+ _instances.clear()
169
+
170
+ @property
171
+ def project_dir(self) -> str:
172
+ return self._project_dir
173
+
174
+ # ------------------------------------------------------------------
175
+ # Lazy initializers
176
+ # ------------------------------------------------------------------
177
+
178
+ def _get_ast_service(self, *, indexing_depth: str = "auto") -> ASTService:
179
+ if self._ast_service is None:
180
+ with self._init_lock:
181
+ if self._ast_service is None:
182
+ from attocode_intel._internal.integrations.context.ast_service import ASTService
183
+ from attocode_intel.request_context import current_request
184
+ svc = ASTService(self._project_dir) if current_request.get() else ASTService.get_instance(self._project_dir)
185
+ if current_request.get():
186
+ svc._context_mgr.max_files = int(os.environ.get("ATTOCODE_INTEL_MAX_FILES", "100000"))
187
+ if not svc.initialized:
188
+ logger.info("Initializing ASTService (skeleton)...")
189
+ from attocode_intel.telemetry import measure
190
+ with measure("indexing"):
191
+ svc.initialize_skeleton(indexing_depth=indexing_depth)
192
+ # Start background hydration for non-small repos
193
+ if (svc._hydration_state
194
+ and svc._hydration_state.phase != "ready"):
195
+ svc.start_hydration()
196
+ self._ast_service = svc
197
+ # Share the ASTService's context manager to avoid
198
+ # double file discovery on large repos
199
+ if self._context_mgr is None and svc._context_mgr._files:
200
+ self._context_mgr = svc._context_mgr
201
+ return self._ast_service
202
+
203
+ def _get_context_mgr(self) -> CodebaseContextManager:
204
+ if self._context_mgr is None:
205
+ # Trigger AST service init first — it does file discovery
206
+ # and we reuse its context manager
207
+ self._get_ast_service()
208
+ if self._context_mgr is None:
209
+ with self._init_lock:
210
+ if self._context_mgr is None:
211
+ from attocode_intel._internal.integrations.context.codebase_context import (
212
+ CodebaseContextManager,
213
+ )
214
+
215
+ mgr = CodebaseContextManager(root_dir=self._project_dir)
216
+ mgr.discover_files()
217
+ self._context_mgr = mgr
218
+ return self._context_mgr
219
+
220
+ def _get_code_analyzer(self) -> CodeAnalyzer:
221
+ if self._code_analyzer is None:
222
+ with self._init_lock:
223
+ if self._code_analyzer is None:
224
+ from attocode_intel._internal.integrations.context.code_analyzer import (
225
+ CodeAnalyzer,
226
+ )
227
+
228
+ self._code_analyzer = CodeAnalyzer()
229
+ return self._code_analyzer
230
+
231
+ def _get_lsp_manager(self) -> LSPManager:
232
+ if self._lsp_manager is None:
233
+ # AST initialization takes the same non-reentrant lock.
234
+ ast_svc = self._get_ast_service()
235
+ with self._init_lock:
236
+ if self._lsp_manager is None:
237
+ from attocode_intel._internal.integrations.lsp.client import (
238
+ LSPConfig,
239
+ LSPManager,
240
+ )
241
+
242
+ config = LSPConfig(
243
+ enabled=True,
244
+ root_uri=Path(self._project_dir).resolve().as_uri(),
245
+ )
246
+ mgr = LSPManager(config=config)
247
+ # Wire LSP results back into the cross-ref index
248
+ try:
249
+ mgr.on_result_callback = ast_svc.ingest_lsp_results
250
+ except Exception:
251
+ pass # ASTService may not be ready yet
252
+ # auto_start is async, will be called on first LSP request
253
+ self._lsp_manager = mgr
254
+ self._lsp_auto_started = False
255
+ return self._lsp_manager
256
+
257
+ def _get_explorer(self) -> HierarchicalExplorer:
258
+ if self._explorer is None:
259
+ # Initialize deps outside the lock to avoid reentrant deadlock
260
+ ctx = self._get_context_mgr()
261
+ ast_svc = self._get_ast_service()
262
+ with self._init_lock:
263
+ if self._explorer is None:
264
+ from attocode_intel._internal.integrations.context.hierarchical_explorer import (
265
+ HierarchicalExplorer,
266
+ )
267
+
268
+ self._explorer = HierarchicalExplorer(ctx, ast_service=ast_svc)
269
+ return self._explorer
270
+
271
+ def _get_security_scanner(self) -> SecurityScanner:
272
+ if self._security_scanner is None:
273
+ with self._init_lock:
274
+ if self._security_scanner is None:
275
+ from attocode_intel._internal.integrations.security.scanner import (
276
+ SecurityScanner,
277
+ )
278
+
279
+ self._security_scanner = SecurityScanner(root_dir=self._project_dir)
280
+ return self._security_scanner
281
+
282
+ def _get_semantic_search(self) -> SemanticSearchManager:
283
+ if self._semantic_search is None:
284
+ with self._init_lock:
285
+ if self._semantic_search is None:
286
+ from attocode_intel._internal.integrations.context.semantic_search import (
287
+ SemanticSearchManager,
288
+ )
289
+
290
+ kwargs: dict[str, object] = {"root_dir": self._project_dir}
291
+ if self._scoring_config is not None:
292
+ kwargs["scoring_config"] = self._scoring_config
293
+ self._semantic_search = SemanticSearchManager(**kwargs)
294
+ return self._semantic_search
295
+
296
+ def _task_file_scores(self, task_hint: str, top_k: int = 80) -> dict[str, float]:
297
+ """Rank files by source-aware local candidates without requiring vectors.
298
+
299
+ These are presentation weights, never evidence that a graph edge or an
300
+ exact symbol definition does not exist.
301
+ """
302
+ if not task_hint:
303
+ return {}
304
+ try:
305
+ results = self._get_semantic_search().search_candidates(
306
+ task_hint, top_k=self._retrieval_depth(top_k), tests_last=False)
307
+ if self._ranking_provider == "systemone":
308
+ results, _ranking = self._rank_search_results(task_hint, results, len(results))
309
+ except Exception:
310
+ logger.debug("Task-aware file scoring unavailable", exc_info=True)
311
+ return {}
312
+ scores: dict[str, float] = {}
313
+ for rank, result in enumerate(results):
314
+ scores[result.file_path] = max(
315
+ scores.get(result.file_path, 0.0), 1.0 / (1.0 + rank / 10.0),
316
+ )
317
+ return scores
318
+
319
+ def _rerank_excerpt(self, result, query: str) -> str:
320
+ """Supply a bounded, current source span to an optional local model."""
321
+ header = f"{result.file_path} | {result.chunk_type} {result.name}\n"
322
+ root = Path(self._project_dir).resolve()
323
+ path = (root / result.file_path).resolve()
324
+ if not path.is_relative_to(root):
325
+ return header + result.text[:1800]
326
+ try:
327
+ before = path.stat()
328
+ if before.st_size > 2_000_000:
329
+ return header + result.text[:1800]
330
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
331
+ after = path.stat()
332
+ if (before.st_mtime_ns, before.st_ctime_ns, before.st_size) != (
333
+ after.st_mtime_ns, after.st_ctime_ns, after.st_size
334
+ ):
335
+ return header + result.text[:1800]
336
+ except OSError:
337
+ return header + result.text[:1800]
338
+ start = max(1, result.start_line)
339
+ if not result.start_line:
340
+ try:
341
+ symbols = self._get_ast_service().get_file_symbols(result.file_path)
342
+ matching = [item for item in symbols
343
+ if item.name == result.name or item.qualified_name == result.name]
344
+ if matching:
345
+ start = matching[0].start_line
346
+ except Exception:
347
+ pass
348
+ if start == 1 and result.chunk_type == "file":
349
+ from attocode_intel.focused_evidence import task_terms, terms
350
+ positive, _ = task_terms(query)
351
+ for index, line in enumerate(lines):
352
+ if positive & terms(line):
353
+ start = max(1, index - 3)
354
+ break
355
+ return header + "\n".join(lines[start - 1:start + 39])[:1800]
356
+
357
+ def _rerank_file_excerpt(self, result, query: str) -> str | None:
358
+ """Current, query-focused file evidence for a shortlist decision."""
359
+ from attocode_intel.focused_evidence import file_excerpt
360
+
361
+ root = Path(self._project_dir).resolve()
362
+ path = (root / result.file_path).resolve()
363
+ if not path.is_relative_to(root) or not path.is_file():
364
+ return None
365
+ try:
366
+ before = path.stat()
367
+ if before.st_size > 2_000_000:
368
+ return None
369
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
370
+ after = path.stat()
371
+ if (before.st_mtime_ns, before.st_ctime_ns, before.st_size) != (
372
+ after.st_mtime_ns, after.st_ctime_ns, after.st_size
373
+ ):
374
+ return None
375
+ except OSError:
376
+ return None
377
+ return file_excerpt(lines, result.file_path, query)
378
+
379
+ def _rank_systemone_results(self, query: str, results: list, top_k: int,
380
+ ranking: dict) -> tuple[list, dict]:
381
+ """Rank distinct files while keeping each file's best source hit."""
382
+ fallback = results[:top_k]
383
+ model = self._systemone_ranker
384
+ if model is None:
385
+ ranking["fallback_reason"] = self._systemone_status
386
+ return fallback, ranking
387
+ ranking["model_status"] = model.status
388
+ ranking["model"] = model.model
389
+ ranking["remote"] = model.remote
390
+ if not results or top_k <= 0:
391
+ return fallback, ranking
392
+ representatives = {}
393
+ for result in results:
394
+ representatives.setdefault(result.file_path, result)
395
+ if len(representatives) == model.max_candidates:
396
+ break
397
+ if len(representatives) < 2:
398
+ ranking["fallback_reason"] = "single_candidate"
399
+ return fallback, ranking
400
+ candidates = []
401
+ for index, result in enumerate(representatives.values()):
402
+ excerpt = self._rerank_file_excerpt(result, query)
403
+ if excerpt is None:
404
+ ranking["fallback_reason"] = "evidence_unavailable"
405
+ return fallback, ranking
406
+ candidates.append((str(index), excerpt, result.score))
407
+ started = time.monotonic()
408
+ outcome = model.rerank_result(query, candidates, top_k=len(candidates))
409
+ ranking["rerank_elapsed_ms"] = round((time.monotonic() - started) * 1000, 2)
410
+ if not outcome.reranked:
411
+ ranking["fallback_reason"] = outcome.fallback_reason
412
+ return fallback, ranking
413
+ source_rows = list(representatives.values())
414
+ ordered = [source_rows[int(candidate_id)] for candidate_id, _excerpt, _score
415
+ in outcome.candidates]
416
+ seen_files = set(representatives)
417
+ duplicates = []
418
+ for row in results:
419
+ if row is representatives.get(row.file_path):
420
+ continue
421
+ if row.file_path in seen_files:
422
+ duplicates.append(row)
423
+ else:
424
+ ordered.append(row)
425
+ seen_files.add(row.file_path)
426
+ ordered.extend(duplicates)
427
+ # File-level probabilities determine order, not result score. Keep the
428
+ # original retrieval scores rather than presenting choice probabilities
429
+ # as calibrated per-result confidence.
430
+ ranking.update({"method": "systemone_choice", "reranked_count": len(candidates),
431
+ "score_semantics": "retrieval_score_preserved"})
432
+ return ordered[:top_k], ranking
433
+
434
+ def _retrieval_depth(self, top_k: int) -> int:
435
+ """Chunks to retrieve before ranking; a model ranker needs enough distinct files."""
436
+ if self._ranking_provider != "systemone" or self._systemone_ranker is None:
437
+ return max(top_k, 24)
438
+ # ponytail: ~8 chunks per distinct file on the 2026-10-08 blind pools
439
+ # (400 chunks -> median 48 files); lexical retrieval stayed under 150 ms.
440
+ return max(top_k, 24, 8 * self._systemone_ranker.max_candidates)
441
+
442
+ def _rank_search_results(self, query: str, results: list, top_k: int,
443
+ file_filter: str = "") -> tuple[list, dict]:
444
+ """Optional local model order with a complete deterministic fallback."""
445
+ limited = results[:top_k]
446
+ ranking = {"method": "source_aware_deterministic", "returned_count": len(limited),
447
+ "model_status": self._local_reranker_status,
448
+ "broad_ranker": "experimental_on" if broad_rank_enabled() else "off",
449
+ "query": query_diagnostics(query, results, file_filter)}
450
+ if self._ranking_provider == "systemone":
451
+ ranking["provider"] = "systemone"
452
+ return self._rank_systemone_results(query, results, top_k, ranking)
453
+ if self._ranking_provider not in {"auto", "local_cross_encoder"}:
454
+ ranking["provider"] = self._ranking_provider
455
+ ranking["model_status"] = ("off" if self._ranking_provider == "off"
456
+ else self._systemone_status)
457
+ return limited, ranking
458
+ model = self._local_reranker
459
+ if model is None or not results:
460
+ return limited, ranking
461
+ ranking["model_status"] = model.status
462
+ if not model.is_available:
463
+ ranking["fallback_reason"] = model.status
464
+ return limited, ranking
465
+ candidates = [(str(index), self._rerank_excerpt(result, query), result.score)
466
+ for index, result in enumerate(results[:model.max_candidates])]
467
+ outcome = model.rerank_result(query, candidates, top_k=min(top_k, len(candidates)))
468
+ if not outcome.reranked:
469
+ ranking["fallback_reason"] = outcome.fallback_reason
470
+ return limited, ranking
471
+ ranked = [replace(results[int(item_id)], score=score)
472
+ for item_id, _excerpt, score in outcome.candidates]
473
+ if len(ranked) < top_k:
474
+ ranked.extend(results[len(candidates):top_k])
475
+ ranking.update({"method": "local_cross_encoder", "model_status": model.status,
476
+ "reranked_count": len(candidates)})
477
+ return ranked[:top_k], ranking
478
+
479
+ def set_scoring_config(self, config: SearchScoringConfig) -> None:
480
+ """Set search scoring config for meta-harness optimization.
481
+
482
+ Must be called before the first search. If the SemanticSearchManager
483
+ is already initialized, updates it in place.
484
+ """
485
+ with self._init_lock:
486
+ self._scoring_config = config
487
+ if self._semantic_search is not None:
488
+ self._semantic_search.scoring_config = config
489
+
490
+ def set_context_config(self, config: ContextAssemblyConfig) -> None:
491
+ """Set context assembly config for meta-harness optimization."""
492
+ self._context_config = config
493
+
494
+ def _get_memory_store(self) -> MemoryStore:
495
+ if self._memory_store is None:
496
+ with self._init_lock:
497
+ if self._memory_store is None:
498
+ from attocode_intel._internal.integrations.context.memory_store import (
499
+ MemoryStore,
500
+ )
501
+
502
+ self._memory_store = MemoryStore(self._project_dir)
503
+ return self._memory_store
504
+
505
+ def _health_snapshot(self) -> dict[str, object]:
506
+ """Return discovery/index health for status endpoints."""
507
+ discovered_files = 0
508
+ indexed_files = 0
509
+ degradation_reasons: list[str] = []
510
+
511
+ try:
512
+ ctx = self._get_context_mgr()
513
+ discovered = getattr(ctx, "_files", None)
514
+ if isinstance(discovered, list):
515
+ discovered_files = len(discovered)
516
+ if discovered_files == 0:
517
+ refreshed = ctx.discover_files()
518
+ discovered_files = len(refreshed)
519
+ except Exception as exc:
520
+ degradation_reasons.append(f"context_error:{type(exc).__name__}")
521
+
522
+ try:
523
+ svc = self._get_ast_service()
524
+ stats = svc._store.stats() if hasattr(svc, "_store") else {}
525
+ indexed_files = int(
526
+ stats.get("files", 0)
527
+ or stats.get("files_indexed", 0)
528
+ or len(getattr(svc, "_ast_cache", {}))
529
+ )
530
+ except Exception as exc:
531
+ degradation_reasons.append(f"ast_error:{type(exc).__name__}")
532
+
533
+ if discovered_files == 0:
534
+ degradation_reasons.append("no_files_discovered")
535
+ if indexed_files == 0:
536
+ degradation_reasons.append("no_files_indexed")
537
+
538
+ return {
539
+ "health_status": "degraded" if degradation_reasons else "healthy",
540
+ "discovery_count": discovered_files,
541
+ "ast_indexed_files": indexed_files,
542
+ "degradation_reason": ", ".join(degradation_reasons),
543
+ "active_backend": "local",
544
+ }
545
+
546
+ def hydration_status(self) -> dict:
547
+ """Return current progressive hydration state."""
548
+ result: dict = {"phase": "unknown", "tier": "unknown"}
549
+ try:
550
+ ast_svc = self._get_ast_service()
551
+ result = ast_svc.hydration_snapshot()
552
+ except Exception:
553
+ pass
554
+
555
+ # Add semantic search coverage
556
+ try:
557
+ sem = self._get_semantic_search()
558
+ progress = sem.get_index_progress()
559
+ result["embedding_coverage"] = round(progress.coverage, 3)
560
+ result["embedding_status"] = progress.status
561
+ except Exception:
562
+ result["embedding_coverage"] = 0.0
563
+
564
+ return result
565
+
566
+ # ------------------------------------------------------------------
567
+ # Tool operations — same signatures as server.py tool functions
568
+ # ------------------------------------------------------------------
569
+
570
+ def reindex(self, *, force: bool = False, embeddings: bool = False) -> dict:
571
+ """Re-index the codebase. Returns stats.
572
+
573
+ Rebuilds the AST/symbol index. When ``embeddings`` is True, also
574
+ synchronously rebuilds the semantic-search vector index (otherwise
575
+ embeddings are built lazily on first search). The synchronous build is
576
+ what callers like the search-quality eval need so results reflect the
577
+ current chunking/embedder config rather than a stale or absent index.
578
+ """
579
+ svc = self._get_ast_service()
580
+ svc.initialize(force=force)
581
+ stats = svc._store.stats() if hasattr(svc, "_store") else {}
582
+ result = {"mode": "full" if force else "incremental", **stats}
583
+ if embeddings:
584
+ chunks = self._get_semantic_search().index()
585
+ result["embedded_chunks"] = chunks
586
+ return result
587
+
588
+ def readiness_report_data(
589
+ self,
590
+ phases: list[int] | None = None,
591
+ scope: str = "",
592
+ tracer_bullets: bool = True,
593
+ ) -> dict:
594
+ """Return structured readiness report data."""
595
+ from attocode_intel.readiness import ReadinessEngine
596
+
597
+ engine = ReadinessEngine(project_dir=self._project_dir)
598
+ report = engine.run(phases=phases, scope=scope, tracer_bullets=tracer_bullets)
599
+ return report.to_dict()
600
+
601
+ def readiness_report(
602
+ self,
603
+ *,
604
+ phases: list[int] | None = None,
605
+ scope: str = "",
606
+ tracer_bullets: bool = True,
607
+ min_severity: str = "info",
608
+ ) -> str:
609
+ """Return formatted text readiness report."""
610
+ from attocode_intel.readiness import ReadinessEngine, ReadinessSeverity
611
+
612
+ engine = ReadinessEngine(project_dir=self._project_dir)
613
+ report = engine.run(
614
+ phases=phases,
615
+ scope=scope,
616
+ tracer_bullets=tracer_bullets,
617
+ )
618
+
619
+ severity_order = [
620
+ ReadinessSeverity.PASS,
621
+ ReadinessSeverity.INFO,
622
+ ReadinessSeverity.WARNING,
623
+ ReadinessSeverity.CRITICAL,
624
+ ]
625
+ try:
626
+ min_idx = severity_order.index(ReadinessSeverity(min_severity))
627
+ except (ValueError, KeyError):
628
+ min_idx = 0
629
+
630
+ for phase_result in report.phase_results:
631
+ phase_result.findings = [
632
+ finding
633
+ for finding in phase_result.findings
634
+ if severity_order.index(finding.severity) >= min_idx
635
+ ]
636
+ return engine.format_report(report)
637
+
638
+ def _get_temporal_analyzer(self) -> TemporalCouplingAnalyzer:
639
+ if self._temporal_analyzer is None:
640
+ from attocode_intel._internal.integrations.context.temporal_coupling import (
641
+ TemporalCouplingAnalyzer,
642
+ )
643
+ self._temporal_analyzer = TemporalCouplingAnalyzer(
644
+ project_dir=self._project_dir,
645
+ )
646
+ return self._temporal_analyzer
647
+
648
+ def change_coupling_data(
649
+ self, file: str, *, days: int = 90, min_coupling: float = 0.3, top_k: int = 20,
650
+ ) -> dict:
651
+ """Get change coupling data for a file."""
652
+ analyzer = self._get_temporal_analyzer()
653
+ results = analyzer.get_change_coupling(
654
+ file, days=days, min_coupling=min_coupling, top_k=top_k,
655
+ )
656
+ return {
657
+ "file": file,
658
+ "days": days,
659
+ "results": [
660
+ {
661
+ "path": e.path,
662
+ "coupling_score": e.coupling_score,
663
+ "co_changes": e.co_changes,
664
+ "individual_changes": e.individual_changes,
665
+ }
666
+ for e in results
667
+ ],
668
+ }
669
+
670
+ def churn_hotspots_data(self, *, days: int = 90, top_n: int = 20) -> dict:
671
+ """Get churn hotspot data."""
672
+ analyzer = self._get_temporal_analyzer()
673
+ results = analyzer.get_churn_hotspots(days=days, top_n=top_n)
674
+ return {
675
+ "days": days,
676
+ "results": [
677
+ {
678
+ "path": e.path,
679
+ "commits": e.commits,
680
+ "authors": e.authors,
681
+ "lines_added": e.lines_added,
682
+ "lines_removed": e.lines_removed,
683
+ "churn_score": e.churn_score,
684
+ }
685
+ for e in results
686
+ ],
687
+ }
688
+
689
+ def merge_risk_data(self, files: list[str], *, days: int = 90) -> dict:
690
+ """Get merge risk predictions."""
691
+ analyzer = self._get_temporal_analyzer()
692
+ svc = self._get_ast_service()
693
+ results = analyzer.get_merge_risk(
694
+ files,
695
+ days=days,
696
+ dep_graph_forward=svc.index.file_dependencies,
697
+ dep_graph_reverse=svc.index.file_dependents,
698
+ )
699
+ max_conf = max((e.confidence for e in results), default=0.0)
700
+ risk_level = "high" if max_conf >= 0.7 else "medium" if max_conf >= 0.4 else "low"
701
+ return {
702
+ "files": files,
703
+ "days": days,
704
+ "risk_level": risk_level,
705
+ "predictions": [
706
+ {
707
+ "path": e.path,
708
+ "reason": e.reason,
709
+ "confidence": e.confidence,
710
+ "coupling_score": e.coupling_score,
711
+ "structural_distance": e.structural_distance,
712
+ }
713
+ for e in results
714
+ ],
715
+ }
716
+
717
+ def code_evolution(self, path: str, symbol: str = "", since: str = "", max_results: int = 20) -> str:
718
+ from attocode_intel.tools.history_tools import code_evolution as _code_evolution
719
+
720
+ return _code_evolution(path=path, symbol=symbol, since=since, max_results=max_results)
721
+
722
+ def recent_changes(self, days: int = 7, path: str = "", top_n: int = 20) -> str:
723
+ from attocode_intel.tools.history_tools import recent_changes as _recent_changes
724
+
725
+ return _recent_changes(days=days, path=path, top_n=top_n)
726
+
727
+ def repo_map(self, *, include_symbols: bool = True, max_tokens: int = 6000) -> str:
728
+ ctx = self._get_context_mgr()
729
+ repo = ctx.get_repo_map(include_symbols=include_symbols, max_tokens=max_tokens)
730
+ lines = [repo.tree, ""]
731
+ lines.append(
732
+ f"({repo.total_files} files, {repo.total_lines:,} lines, "
733
+ f"{len(repo.languages)} languages)"
734
+ )
735
+ return "\n".join(lines)
736
+
737
+ # ------------------------------------------------------------------
738
+ # Structured data methods (v2 API — return dicts, not formatted text)
739
+ # ------------------------------------------------------------------
740
+
741
+ def symbols_data(self, path: str) -> list[dict]:
742
+ """Return raw symbol locations for a file."""
743
+ svc = self._get_ast_service()
744
+ svc.ensure_file_parsed(path)
745
+ locs = svc.get_file_symbols(path)
746
+ return [
747
+ {
748
+ "kind": loc.kind, "name": loc.name,
749
+ "qualified_name": loc.qualified_name,
750
+ "file_path": loc.file_path,
751
+ "start_line": loc.start_line, "end_line": loc.end_line,
752
+ }
753
+ for loc in sorted(locs, key=lambda s: s.start_line)
754
+ ]
755
+
756
+ def search_symbols_data(self, name: str, limit: int = 30, kind: str = "",
757
+ task_hint: str = "") -> list[dict]:
758
+ """Return raw symbol search results with scores."""
759
+ svc = self._get_ast_service()
760
+ scored = svc.search_symbol(name, limit=max(limit * 4, 80) if task_hint else limit,
761
+ kind_filter=kind)
762
+ if task_hint:
763
+ task_scores = self._task_file_scores(task_hint)
764
+ # Exact/fuzzy match quality remains primary. Context only breaks
765
+ # equally matched definitions, never hides a valid definition.
766
+ scored = sorted(scored, key=lambda row: (
767
+ -row[1], -task_scores.get(row[0].file_path, 0.0),
768
+ row[0].file_path, row[0].start_line,
769
+ ))[:limit]
770
+ return [
771
+ {
772
+ "kind": loc.kind, "name": loc.name,
773
+ "qualified_name": loc.qualified_name,
774
+ "file_path": loc.file_path,
775
+ "start_line": loc.start_line, "end_line": loc.end_line,
776
+ "score": round(score, 3),
777
+ }
778
+ for loc, score in scored
779
+ ]
780
+
781
+ def dependencies_data(self, path: str) -> dict:
782
+ """Return structured dependency data."""
783
+ svc = self._get_ast_service()
784
+ svc.ensure_file_parsed(path)
785
+ deps = svc.get_dependencies(path)
786
+ dependents = svc.get_dependents(path)
787
+ return {
788
+ "path": path,
789
+ "imports": sorted(deps),
790
+ "imported_by": sorted(dependents),
791
+ }
792
+
793
+ def impact_analysis_data(self, changed_files: list[str]) -> dict:
794
+ """Return structured impact analysis with BFS depth layers."""
795
+ svc = self._get_ast_service()
796
+ # BFS with depth tracking
797
+ visited: set[str] = set(changed_files)
798
+ frontier = set(changed_files)
799
+ layers: list[dict] = []
800
+ current_depth = 0
801
+ while frontier:
802
+ current_depth += 1
803
+ next_frontier: set[str] = set()
804
+ for f in frontier:
805
+ for dep in svc.get_dependents(f):
806
+ if dep not in visited:
807
+ visited.add(dep)
808
+ next_frontier.add(dep)
809
+ if next_frontier:
810
+ layers.append({"depth": current_depth, "files": sorted(next_frontier)})
811
+ frontier = next_frontier
812
+
813
+ impacted = sorted(visited - set(changed_files))
814
+ return {
815
+ "changed_files": changed_files,
816
+ "impacted_files": impacted,
817
+ "total_impacted": len(impacted),
818
+ "interpretation": "potential_graph_reachability",
819
+ "absence_proven": False,
820
+ "layers": layers,
821
+ }
822
+
823
+ def cross_references_data(self, symbol_name: str) -> dict:
824
+ """Return structured cross-reference data."""
825
+ svc = self._get_ast_service()
826
+ definitions = svc.find_symbol(symbol_name)
827
+ for definition in definitions:
828
+ svc.ensure_references_indexed(definition.file_path)
829
+ references = svc.get_callers(symbol_name)
830
+ return {
831
+ "symbol": symbol_name,
832
+ "definitions": [
833
+ {
834
+ "kind": loc.kind, "name": loc.name,
835
+ "qualified_name": loc.qualified_name,
836
+ "file_path": loc.file_path,
837
+ "start_line": loc.start_line, "end_line": loc.end_line,
838
+ }
839
+ for loc in definitions
840
+ ],
841
+ "references": [
842
+ {"ref_kind": ref.ref_kind, "file_path": ref.file_path, "line": ref.line,
843
+ "source": ref.source, "symbol": ref.symbol_name}
844
+ for ref in references
845
+ ],
846
+ "total_references": len(references),
847
+ "ambiguous": len({(d.file_path, d.qualified_name) for d in definitions}) > 1,
848
+ }
849
+
850
+ def call_graph_data(
851
+ self,
852
+ symbol: str,
853
+ *,
854
+ direction: str = "callees",
855
+ depth: int = 1,
856
+ ) -> dict:
857
+ """Return structured call-graph data for ``symbol``.
858
+
859
+ ``direction`` is "callees" (forward) or "callers" (reverse).
860
+ ``depth`` caps the BFS hops; results from intermediate hops are
861
+ included alongside the final frontier.
862
+ """
863
+ if direction not in {"callers", "callees"} or depth < 1:
864
+ raise ValueError("call_graph requires callers/callees direction and depth >= 1")
865
+ svc = self._get_ast_service()
866
+ definitions = svc.find_symbol(symbol)
867
+ # Ensure the file(s) containing the symbol have references parsed
868
+ # so call-edges are populated on demand for the first invocation.
869
+ for loc in definitions:
870
+ try:
871
+ svc.ensure_references_indexed(loc.file_path)
872
+ except Exception:
873
+ pass
874
+
875
+ candidates: set[str] = {symbol}
876
+ for loc in definitions:
877
+ if loc.qualified_name:
878
+ candidates.add(loc.qualified_name)
879
+
880
+ edges: set[str] = set()
881
+ for caller_name in candidates:
882
+ if direction == "callers":
883
+ edges.update(svc.get_callers_of(caller_name, depth=depth))
884
+ else:
885
+ edges.update(svc.get_callees_of(caller_name, depth=depth))
886
+
887
+ return {
888
+ "symbol": symbol,
889
+ "direction": direction,
890
+ "depth": depth,
891
+ "definitions": [
892
+ {
893
+ "kind": loc.kind, "name": loc.name,
894
+ "qualified_name": loc.qualified_name,
895
+ "file_path": loc.file_path,
896
+ "start_line": loc.start_line, "end_line": loc.end_line,
897
+ }
898
+ for loc in definitions
899
+ ],
900
+ "edges": sorted(edges),
901
+ "total_edges": len(edges),
902
+ }
903
+
904
+ def call_graph(
905
+ self,
906
+ symbol: str,
907
+ *,
908
+ direction: str = "callees",
909
+ depth: int = 1,
910
+ ) -> str:
911
+ """Human-readable call-graph traversal for ``symbol``."""
912
+ if direction not in ("callees", "callers"):
913
+ return f"Error: direction must be 'callees' or 'callers', got '{direction}'"
914
+ if depth < 1:
915
+ return "Error: depth must be >= 1"
916
+
917
+ data = self.call_graph_data(symbol, direction=direction, depth=depth)
918
+
919
+ verb = "calls" if direction == "callees" else "is called by"
920
+ lines = [f"Call graph for '{symbol}' (direction={direction}, depth={depth}):"]
921
+
922
+ defs = data["definitions"]
923
+ if defs:
924
+ lines.append(f"\n Definitions ({len(defs)}):")
925
+ for loc in defs:
926
+ lines.append(
927
+ f" {loc['kind']} {loc['qualified_name'] or loc['name']} "
928
+ f"in {loc['file_path']}:{loc['start_line']}"
929
+ )
930
+ else:
931
+ lines.append("\n Definitions: (none found — symbol may be external or unindexed)")
932
+
933
+ edges = data["edges"]
934
+ lines.append(f"\n {symbol} {verb} ({len(edges)}):")
935
+ if edges:
936
+ for edge in edges[:200]:
937
+ lines.append(f" {edge}")
938
+ if len(edges) > 200:
939
+ lines.append(f" ... and {len(edges) - 200} more")
940
+ else:
941
+ lines.append(" (none)")
942
+ return "\n".join(lines)
943
+
944
+ def file_analysis_data(self, path: str) -> dict:
945
+ """Return structured file analysis."""
946
+ analyzer = self._get_code_analyzer()
947
+ if not os.path.isabs(path):
948
+ path = os.path.join(self._project_dir, path)
949
+ result = analyzer.analyze_file(path)
950
+ return {
951
+ "path": result.path,
952
+ "language": result.language,
953
+ "line_count": result.line_count,
954
+ "imports": list(result.imports),
955
+ "exports": list(result.exports),
956
+ "chunks": [
957
+ {
958
+ "kind": chunk.kind, "name": chunk.name,
959
+ "parent": chunk.parent, "signature": chunk.signature,
960
+ "start_line": chunk.start_line, "end_line": chunk.end_line,
961
+ }
962
+ for chunk in result.chunks
963
+ ],
964
+ }
965
+
966
+ def hotspots_data(self, top_n: int = 15) -> dict:
967
+ """Return structured hotspot data."""
968
+ from attocode_intel.helpers import (
969
+ _compute_file_metrics,
970
+ _compute_function_hotspots,
971
+ _get_churn_scores,
972
+ )
973
+
974
+ ctx = self._get_context_mgr()
975
+ files = ctx._files
976
+ if not files:
977
+ return {"file_hotspots": [], "function_hotspots": [], "orphan_files": []}
978
+
979
+ svc = self._get_ast_service()
980
+ index = svc._index
981
+ # Background hydration may add entries while analysis iterates the cache.
982
+ ast_cache = svc._ast_cache.copy()
983
+ churn_scores = _get_churn_scores(self._project_dir, files)
984
+
985
+ all_metrics = _compute_file_metrics(files, index, ast_cache, churn_scores)
986
+ all_metrics.sort(key=lambda m: m.composite, reverse=True)
987
+
988
+ def _fm_dict(m):
989
+ return {
990
+ "path": m.path, "line_count": m.line_count,
991
+ "symbol_count": m.symbol_count, "public_symbols": m.public_symbols,
992
+ "fan_in": m.fan_in, "fan_out": m.fan_out,
993
+ "density": m.density, "composite": m.composite,
994
+ "categories": m.categories,
995
+ }
996
+
997
+ fn_hotspots = _compute_function_hotspots(ast_cache, top_n=10)
998
+ orphans = [
999
+ m for m in all_metrics
1000
+ if m.fan_in == 0 and m.fan_out == 0 and m.line_count >= 20
1001
+ and not any(fi.is_test for fi in files if fi.relative_path == m.path)
1002
+ ]
1003
+
1004
+ return {
1005
+ "file_hotspots": [_fm_dict(m) for m in all_metrics[:top_n]],
1006
+ "function_hotspots": [
1007
+ {
1008
+ "file_path": fm.file_path, "name": fm.name,
1009
+ "line_count": fm.line_count, "param_count": fm.param_count,
1010
+ "is_public": fm.is_public, "has_return_type": fm.has_return_type,
1011
+ }
1012
+ for fm in fn_hotspots
1013
+ ],
1014
+ "orphan_files": [_fm_dict(m) for m in orphans[:10]],
1015
+ }
1016
+
1017
+ def conventions_data(self, sample_size: int = 50, path: str = "") -> dict:
1018
+ """Return structured convention stats."""
1019
+ from attocode_intel.helpers import _analyze_conventions
1020
+
1021
+ svc = self._get_ast_service()
1022
+ ast_cache = svc._ast_cache.copy()
1023
+ if not ast_cache:
1024
+ return {"sample_size": 0, "path": path, "stats": {}, "dir_stats": {}}
1025
+
1026
+ ctx = self._get_context_mgr()
1027
+ files = ctx._files
1028
+ path_prefix = path.rstrip("/") + "/" if path else ""
1029
+
1030
+ if path_prefix:
1031
+ candidates = sorted(
1032
+ [fi for fi in files if fi.relative_path in ast_cache
1033
+ and fi.relative_path.startswith(path_prefix)],
1034
+ key=lambda fi: fi.importance, reverse=True,
1035
+ )
1036
+ sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
1037
+ else:
1038
+ candidates = sorted(
1039
+ [fi for fi in files if fi.relative_path in ast_cache],
1040
+ key=lambda fi: fi.importance, reverse=True,
1041
+ )
1042
+ sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
1043
+
1044
+ if not sample_rels:
1045
+ return {"sample_size": 0, "path": path, "stats": {}, "dir_stats": {}}
1046
+
1047
+ stats = _analyze_conventions(ast_cache, sample_rels)
1048
+ # Convert Counter objects to plain dicts for serialization
1049
+ stats["decorator_counts"] = dict(stats["decorator_counts"])
1050
+ stats["base_classes"] = dict(stats["base_classes"])
1051
+ stats.pop("files_per_dir", None)
1052
+ # Convert tuple pairs to dicts for Pydantic v2
1053
+ stats["exception_classes"] = [
1054
+ {"name": name, "bases": bases}
1055
+ for name, bases in stats.get("exception_classes", [])
1056
+ ]
1057
+
1058
+ dir_stats: dict[str, dict] = {}
1059
+ if not path_prefix:
1060
+ dir_groups: dict[str, list[str]] = {}
1061
+ for rel in sample_rels:
1062
+ parts = rel.split("/")
1063
+ dirname = parts[0] if len(parts) > 1 else "(root)"
1064
+ dir_groups.setdefault(dirname, []).append(rel)
1065
+ for dirname, dir_rels in dir_groups.items():
1066
+ if len(dir_rels) >= 3:
1067
+ ds = _analyze_conventions(ast_cache, dir_rels)
1068
+ ds["decorator_counts"] = dict(ds["decorator_counts"])
1069
+ ds["base_classes"] = dict(ds["base_classes"])
1070
+ ds.pop("files_per_dir", None)
1071
+ ds["exception_classes"] = [
1072
+ {"name": name, "bases": bases}
1073
+ for name, bases in ds.get("exception_classes", [])
1074
+ ]
1075
+ dir_stats[dirname] = ds
1076
+
1077
+ return {
1078
+ "sample_size": len(sample_rels),
1079
+ "path": path,
1080
+ "stats": stats,
1081
+ "dir_stats": dir_stats,
1082
+ }
1083
+
1084
+ def dependency_graph_data(self, start_file: str, depth: int = 2) -> dict:
1085
+ """Return structured dependency graph."""
1086
+ svc = self._get_ast_service()
1087
+ rel = svc._to_rel(start_file)
1088
+
1089
+ def _bfs(get_neighbors):
1090
+ visited: set[str] = set()
1091
+ queue: deque[tuple[str, int]] = deque([(rel, 0)])
1092
+ nodes = []
1093
+ while queue:
1094
+ current, d = queue.popleft()
1095
+ if current in visited or d > depth:
1096
+ continue
1097
+ visited.add(current)
1098
+ if d > 0:
1099
+ nodes.append({"path": current, "depth": d})
1100
+ for n in sorted(get_neighbors(current)):
1101
+ if n not in visited:
1102
+ queue.append((n, d + 1))
1103
+ return nodes
1104
+
1105
+ return {
1106
+ "start_file": rel,
1107
+ "depth": depth,
1108
+ "forward": _bfs(svc.get_dependencies),
1109
+ "reverse": _bfs(svc.get_dependents),
1110
+ }
1111
+
1112
+ def graph_query_data(
1113
+ self, file: str, edge_type: str = "IMPORTS",
1114
+ direction: str = "outbound", depth: int = 2,
1115
+ ) -> dict:
1116
+ """Return structured graph query result."""
1117
+ svc = self._get_ast_service()
1118
+ rel = svc._to_rel(file)
1119
+ depth = min(depth, 5)
1120
+ use_dependents = edge_type == "IMPORTED_BY" or direction == "inbound"
1121
+
1122
+ visited: set[str] = set()
1123
+ queue: deque[tuple[str, int]] = deque([(rel, 0)])
1124
+ hops_by_depth: dict[int, list[str]] = {}
1125
+ while queue:
1126
+ current, d = queue.popleft()
1127
+ if current in visited or d > depth:
1128
+ continue
1129
+ visited.add(current)
1130
+ if d > 0:
1131
+ hops_by_depth.setdefault(d, []).append(current)
1132
+ neighbors = svc.get_dependents(current) if use_dependents else svc.get_dependencies(current)
1133
+ for n in sorted(neighbors):
1134
+ if n not in visited:
1135
+ queue.append((n, d + 1))
1136
+
1137
+ return {
1138
+ "root": rel,
1139
+ "direction": "inbound" if use_dependents else "outbound",
1140
+ "depth": depth,
1141
+ "hops": [{"depth": d, "files": fs} for d, fs in sorted(hops_by_depth.items())],
1142
+ "total_reachable": len(visited) - 1,
1143
+ }
1144
+
1145
+ def graph_dsl_data(self, query: str) -> dict:
1146
+ """Execute a graph DSL query and return structured results.
1147
+
1148
+ Uses ``GraphQueryParser`` to parse the query and
1149
+ ``GraphQueryExecutor`` to run it against the dependency graph.
1150
+ """
1151
+ from attocode_intel.graph_query_parser import (
1152
+ GraphQueryExecutor,
1153
+ GraphQueryParser,
1154
+ )
1155
+
1156
+ parser = GraphQueryParser()
1157
+ try:
1158
+ ast = parser.parse(query)
1159
+ except ValueError as exc:
1160
+ return {"error": str(exc), "query": query, "results": []}
1161
+
1162
+ ctx = self._get_context_mgr()
1163
+ dep_graph = ctx.dependency_graph
1164
+ files = ctx._files
1165
+
1166
+ # If the context manager hasn't discovered files yet, initialize
1167
+ if not files:
1168
+ ctx.discover_files()
1169
+ files = ctx._files
1170
+ dep_graph = ctx.dependency_graph
1171
+
1172
+ executor = GraphQueryExecutor()
1173
+ try:
1174
+ results = executor.execute(ast, dep_graph, files)
1175
+ except Exception as exc:
1176
+ return {"error": f"Execution error: {exc}", "query": query, "results": []}
1177
+
1178
+ return {
1179
+ "query": query,
1180
+ "results": results,
1181
+ "total": len(results),
1182
+ "truncated": len(results) >= executor.MAX_RESULTS,
1183
+ }
1184
+
1185
+ def find_related_data(self, file: str, top_k: int = 10,
1186
+ task_hint: str = "") -> dict:
1187
+ """Return structured related-files result."""
1188
+ svc = self._get_ast_service()
1189
+ rel = svc._to_rel(file)
1190
+ idx = svc._index
1191
+
1192
+ neighbors: Counter[str] = Counter()
1193
+ direct_deps = idx.get_dependencies(rel)
1194
+ direct_importers = idx.get_dependents(rel)
1195
+ all_direct = direct_deps | direct_importers
1196
+
1197
+ for n in all_direct:
1198
+ neighbors[n] += 3
1199
+ for n in all_direct:
1200
+ for nn in idx.get_dependencies(n):
1201
+ if nn != rel:
1202
+ neighbors[nn] += 1
1203
+ for nn in idx.get_dependents(n):
1204
+ if nn != rel:
1205
+ neighbors[nn] += 1
1206
+
1207
+ my_deps = idx.get_dependencies(rel)
1208
+ if my_deps:
1209
+ for other_file in set(neighbors.keys()):
1210
+ other_deps_set = idx.file_dependencies.get(other_file, set())
1211
+ if other_deps_set:
1212
+ overlap = len(my_deps & other_deps_set)
1213
+ if overlap > 0:
1214
+ union = len(my_deps | other_deps_set)
1215
+ neighbors[other_file] += round((overlap / union if union else 0) * 5)
1216
+
1217
+ if task_hint:
1218
+ task_scores = self._task_file_scores(task_hint)
1219
+ top = sorted(neighbors.items(), key=lambda row: (
1220
+ -(row[1] + 3.0 * task_scores.get(row[0], 0.0)),
1221
+ -row[1], row[0],
1222
+ ))[:top_k]
1223
+ else:
1224
+ top = neighbors.most_common(top_k)
1225
+ return {
1226
+ "file": rel,
1227
+ "related": [
1228
+ {
1229
+ "path": path,
1230
+ "score": score,
1231
+ **({"task_relevance": round(task_scores.get(path, 0.0), 3)}
1232
+ if task_hint else {}),
1233
+ "relation_type": "direct" if path in all_direct else "transitive",
1234
+ }
1235
+ for path, score in top
1236
+ ],
1237
+ }
1238
+
1239
+ def community_detection_data(
1240
+ self, min_community_size: int = 3, max_communities: int = 20,
1241
+ ) -> dict:
1242
+ """Return structured community detection result."""
1243
+ svc = self._get_ast_service()
1244
+ idx = svc._index
1245
+
1246
+ all_files: set[str] = set()
1247
+ all_files.update(idx.file_dependencies.keys())
1248
+ all_files.update(idx.file_dependents.keys())
1249
+ all_files.update(f for deps in idx.file_dependencies.values() for f in deps)
1250
+
1251
+ adj: dict[str, set[str]] = {f: set() for f in all_files}
1252
+ weights: dict[tuple[str, str], float] = {}
1253
+ for src, deps in idx.file_dependencies.items():
1254
+ for tgt in deps:
1255
+ adj.setdefault(src, set()).add(tgt)
1256
+ adj.setdefault(tgt, set()).add(src)
1257
+ key = (min(src, tgt), max(src, tgt))
1258
+ is_bidi = tgt in idx.file_dependencies and src in idx.file_dependencies.get(tgt, set())
1259
+ weights[key] = 2.0 if is_bidi else 1.0
1260
+
1261
+ try:
1262
+ from attocode_intel.community import louvain_communities as _louvain
1263
+ communities, modularity_score = _louvain(all_files, adj, weights)
1264
+ method = "louvain"
1265
+ except ImportError:
1266
+ from attocode_intel.community import bfs_connected_components
1267
+ communities, modularity_score = bfs_connected_components(all_files, adj)
1268
+ method = "connected-components"
1269
+
1270
+ communities.sort(key=len, reverse=True)
1271
+ communities = [c for c in communities if len(c) >= min_community_size][:max_communities]
1272
+
1273
+ # Detect trivial results: single community or near-zero modularity
1274
+ is_trivial = len(communities) <= 1 or modularity_score < 0.05
1275
+
1276
+ if is_trivial:
1277
+ # Fallback: directory-based module analysis for sparse graphs
1278
+ return self._directory_based_module_analysis(svc, adj, method, modularity_score)
1279
+
1280
+ result_communities = []
1281
+ for i, community in enumerate(communities, 1):
1282
+ from collections import Counter as _Counter
1283
+ dirs = [os.path.dirname(f) for f in community if os.path.dirname(f)]
1284
+ theme = _Counter(dirs).most_common(1)[0][0] if dirs else "(root)"
1285
+ internal_edges = sum(
1286
+ 1 for f in community for n in adj.get(f, set()) if n in community
1287
+ ) // 2
1288
+ external_edges = sum(
1289
+ 1 for f in community for n in adj.get(f, set()) if n not in community
1290
+ )
1291
+
1292
+ def _int_deg(f: str, _community: set[str] = community) -> int:
1293
+ return sum(1 for n in adj.get(f, set()) if n in _community)
1294
+
1295
+ hub = max(community, key=_int_deg)
1296
+ result_communities.append({
1297
+ "id": i,
1298
+ "files": sorted(community),
1299
+ "size": len(community),
1300
+ "theme": theme,
1301
+ "internal_edges": internal_edges,
1302
+ "external_edges": external_edges,
1303
+ "hub": hub,
1304
+ "hub_internal_degree": _int_deg(hub),
1305
+ })
1306
+
1307
+ return {
1308
+ "method": method,
1309
+ "modularity": round(modularity_score, 3),
1310
+ "communities": result_communities,
1311
+ }
1312
+
1313
+ def _directory_based_module_analysis(
1314
+ self, svc, adj: dict[str, set[str]], method: str, modularity_score: float,
1315
+ ) -> dict:
1316
+ """Fallback module analysis using directory structure when graph is sparse."""
1317
+ from collections import Counter as _Counter
1318
+
1319
+ ast_cache = svc._ast_cache.copy()
1320
+
1321
+ # Group all indexed files by top-level directory
1322
+ dir_groups: dict[str, list[str]] = {}
1323
+ for rel_path in ast_cache:
1324
+ parts = rel_path.split("/")
1325
+ top_dir = parts[0] if len(parts) > 1 else "(root)"
1326
+ dir_groups.setdefault(top_dir, []).append(rel_path)
1327
+
1328
+ # Build directory modules sorted by file count descending
1329
+ modules = []
1330
+ for i, (dir_name, file_list) in enumerate(
1331
+ sorted(dir_groups.items(), key=lambda x: len(x[1]), reverse=True), 1,
1332
+ ):
1333
+ total_symbols = 0
1334
+ file_symbol_counts: list[tuple[str, int]] = []
1335
+ for f in file_list:
1336
+ file_ast = ast_cache.get(f)
1337
+ sc = file_ast.symbol_count if file_ast else 0
1338
+ total_symbols += sc
1339
+ file_symbol_counts.append((f, sc))
1340
+
1341
+ # Key files: top 3 by symbol count
1342
+ file_symbol_counts.sort(key=lambda x: x[1], reverse=True)
1343
+ key_files = [
1344
+ {"path": path, "symbols": count}
1345
+ for path, count in file_symbol_counts[:3]
1346
+ ]
1347
+
1348
+ modules.append({
1349
+ "id": i,
1350
+ "directory": dir_name,
1351
+ "files": sorted(file_list),
1352
+ "file_count": len(file_list),
1353
+ "total_symbols": total_symbols,
1354
+ "key_files": key_files,
1355
+ })
1356
+
1357
+ # Hub files: top 5 by total edge count (incoming + outgoing)
1358
+ edge_counts: _Counter = _Counter()
1359
+ for node, neighbors in adj.items():
1360
+ edge_counts[node] = len(neighbors)
1361
+ # Also count files from ast_cache that may not be in adj
1362
+ for f in ast_cache:
1363
+ if f not in edge_counts:
1364
+ edge_counts[f] = 0
1365
+
1366
+ hub_files = []
1367
+ for path, degree in edge_counts.most_common(5):
1368
+ file_ast = ast_cache.get(path)
1369
+ hub_files.append({
1370
+ "path": path,
1371
+ "cross_references": degree,
1372
+ "symbols": file_ast.symbol_count if file_ast else 0,
1373
+ })
1374
+
1375
+ return {
1376
+ "method": f"{method}+directory-fallback",
1377
+ "modularity": round(modularity_score, 3),
1378
+ "note": "Graph too sparse for meaningful community detection; using directory-based module analysis",
1379
+ "modules": modules,
1380
+ "hub_files": hub_files,
1381
+ "total_files": len(ast_cache),
1382
+ "total_directories": len(dir_groups),
1383
+ }
1384
+
1385
+ def distill_data(
1386
+ self,
1387
+ files: list[str] | None = None,
1388
+ depth: int = 1,
1389
+ level: str = "signatures",
1390
+ max_tokens: int = 4000,
1391
+ ) -> dict:
1392
+ """Return structured distillation result.
1393
+
1394
+ Args:
1395
+ files: Specific file paths to distill. None = auto-select by importance.
1396
+ depth: Dependency graph expansion hops (default 1, max 3).
1397
+ level: Distillation level — "full", "signatures", or "structure".
1398
+ max_tokens: Token budget (default 4000).
1399
+ """
1400
+ import os
1401
+ from collections import deque
1402
+
1403
+ # Level: full — delegate to repo_map
1404
+ if level == "full":
1405
+ ctx = self._get_context_mgr()
1406
+ repo = ctx.get_repo_map(include_symbols=True, max_tokens=max_tokens)
1407
+ return {
1408
+ "level": level,
1409
+ "text": repo.tree,
1410
+ "total_files": repo.total_files,
1411
+ "total_lines": repo.total_lines,
1412
+ "languages": repo.languages,
1413
+ }
1414
+
1415
+ ctx = self._get_context_mgr()
1416
+ all_files = ctx._files
1417
+ if not all_files:
1418
+ return {"level": level, "text": "", "files_included": 0}
1419
+
1420
+ svc = self._get_ast_service()
1421
+ ast_cache = svc._ast_cache.copy()
1422
+ dep_graph = ctx.dependency_graph
1423
+ depth = min(depth, 3)
1424
+
1425
+ # File selection
1426
+ if files is not None:
1427
+ project_dir = ctx.root_dir
1428
+ center_rels: list[str] = []
1429
+ for f in files:
1430
+ if os.path.isabs(f):
1431
+ try:
1432
+ rel = os.path.relpath(f, project_dir)
1433
+ except ValueError:
1434
+ rel = f
1435
+ else:
1436
+ rel = f
1437
+ center_rels.append(rel)
1438
+
1439
+ if depth > 0 and dep_graph is not None:
1440
+ visited: set[str] = set(center_rels)
1441
+ queue: deque[tuple[str, int]] = deque()
1442
+ for f in center_rels:
1443
+ queue.append((f, 0))
1444
+ while queue:
1445
+ current, d = queue.popleft()
1446
+ if d >= depth:
1447
+ continue
1448
+ for dep in dep_graph.get_imports(current):
1449
+ if dep not in visited:
1450
+ visited.add(dep)
1451
+ queue.append((dep, d + 1))
1452
+ for dep in dep_graph.get_importers(current):
1453
+ if dep not in visited:
1454
+ visited.add(dep)
1455
+ queue.append((dep, d + 1))
1456
+ selected = sorted(visited)
1457
+ else:
1458
+ selected = sorted(set(center_rels))
1459
+ else:
1460
+ sorted_files = sorted(all_files, key=lambda fi: fi.importance, reverse=True)
1461
+ avg_chars = 80 if level == "signatures" else 40
1462
+ max_files_estimate = int(max_tokens * 3.5 / avg_chars)
1463
+ selected = [fi.relative_path for fi in sorted_files[:max_files_estimate]]
1464
+
1465
+ if level == "signatures":
1466
+ from attocode_intel.tools.distill_tools import _extract_signatures
1467
+
1468
+ text = _extract_signatures(ast_cache, selected, max_tokens)
1469
+ elif level == "structure":
1470
+ from attocode_intel.tools.distill_tools import _build_structure
1471
+
1472
+ text = _build_structure(all_files, dep_graph, selected, max_tokens)
1473
+ else:
1474
+ return {"level": level, "error": f"Unknown level: '{level}'", "files_included": 0}
1475
+
1476
+ return {
1477
+ "level": level,
1478
+ "text": text,
1479
+ "files_included": len(selected),
1480
+ "estimated_tokens": estimate_tokens(text) if text else 0,
1481
+ }
1482
+
1483
+ @staticmethod
1484
+ def _search_after_warmup(mgr: SemanticSearchManager, run: Callable[[], list]) -> list:
1485
+ """Run a search. When the lexical indexes are still warming, wait once and run it again.
1486
+
1487
+ The first tool call of a server starts the warm-up. If the first search
1488
+ comes before the indexes are ready, it waits up to SEARCH_WARMUP_WAIT
1489
+ (15 seconds by default). Most workspaces are ready in under 10 seconds.
1490
+ """
1491
+ candidates = run()
1492
+ diagnostics = getattr(mgr, "candidate_diagnostics", None)
1493
+ if not candidates and diagnostics is not None and diagnostics().get("status") == "warming":
1494
+ mgr.wait_for_body_index(timeout=SEARCH_WARMUP_WAIT.get())
1495
+ candidates = run()
1496
+ return candidates
1497
+
1498
+ def semantic_search_data(self, query: str, top_k: int = 10, file_filter: str = "", branch: str = "") -> dict:
1499
+ """Return structured semantic search results.
1500
+
1501
+ Args:
1502
+ query: Natural language search query.
1503
+ top_k: Number of results to return.
1504
+ file_filter: Optional glob pattern to filter files.
1505
+ branch: Optional branch name for scoping (service mode pass-through;
1506
+ local mode automatically scopes to working-directory files).
1507
+ """
1508
+ mgr = self._get_semantic_search()
1509
+ candidates = self._search_after_warmup(
1510
+ mgr, lambda: mgr.search(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
1511
+ results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
1512
+ ranking["candidate_pool_count"] = len(candidates)
1513
+ if hasattr(mgr, "candidate_diagnostics"):
1514
+ ranking["index"] = mgr.candidate_diagnostics()
1515
+ index_status = ranking.get("index", {}).get("status")
1516
+ warming = index_status == "warming"
1517
+ follow_up = (next_search_top_k(len(candidates), top_k, len(results))
1518
+ if index_status == "ready" else None)
1519
+ response = {
1520
+ "query": query,
1521
+ "results": [
1522
+ {
1523
+ "file_path": r.file_path,
1524
+ "score": r.score,
1525
+ "snippet": r.text,
1526
+ "line": r.start_line or None,
1527
+ "end_line": r.end_line or None,
1528
+ **hit_evidence(query, r),
1529
+ }
1530
+ for r in results
1531
+ ],
1532
+ "total": len(results),
1533
+ "status": "warming" if warming else "ready",
1534
+ "ranking": ranking,
1535
+ }
1536
+ if follow_up is not None:
1537
+ arguments = {"query": query, "top_k": follow_up, "file_filter": file_filter}
1538
+ if branch:
1539
+ arguments["branch"] = branch
1540
+ response["follow_up"] = {
1541
+ "tool": "semantic_search", "arguments": arguments,
1542
+ "reruns_current_index": True,
1543
+ }
1544
+ return response
1545
+
1546
+ def code_evolution_data(
1547
+ self,
1548
+ path: str,
1549
+ symbol: str = "",
1550
+ since: str = "",
1551
+ max_results: int = 20,
1552
+ ) -> dict:
1553
+ """Return structured code evolution data for a file or symbol.
1554
+
1555
+ Uses git subprocess in local mode. In service mode, queries the
1556
+ Commit + CommitFileStat tables.
1557
+
1558
+ Args:
1559
+ path: File path (relative to project root or absolute).
1560
+ symbol: Optional symbol name to filter.
1561
+ since: Optional date filter (e.g. "2024-01-01").
1562
+ max_results: Maximum number of commits to return.
1563
+ """
1564
+ from attocode_intel.tools.history_tools import (
1565
+ _filter_by_symbol,
1566
+ _parse_evolution_output,
1567
+ _run_git,
1568
+ )
1569
+
1570
+ if os.path.isabs(path):
1571
+ try:
1572
+ rel_path = os.path.relpath(path, self._project_dir)
1573
+ except ValueError:
1574
+ rel_path = path
1575
+ else:
1576
+ rel_path = path
1577
+
1578
+ git_args = [
1579
+ "log", "--follow", "--numstat",
1580
+ "--format=%H|%an|%ae|%aI|%s",
1581
+ f"-{max_results * 2}",
1582
+ ]
1583
+ if since:
1584
+ git_args.append(f"--since={since}")
1585
+ git_args.extend(["--", rel_path])
1586
+
1587
+ raw = _run_git(git_args, self._project_dir)
1588
+ commits = _parse_evolution_output(raw)
1589
+
1590
+ if symbol:
1591
+ commits = _filter_by_symbol(commits, symbol)
1592
+ commits = commits[:max_results]
1593
+
1594
+ return {
1595
+ "path": rel_path,
1596
+ "symbol": symbol,
1597
+ "since": since,
1598
+ "commits": [
1599
+ {
1600
+ "sha": c["sha"],
1601
+ "author": c["author"],
1602
+ "email": c["email"],
1603
+ "date": c["date"],
1604
+ "subject": c["subject"],
1605
+ "files": c["files"],
1606
+ }
1607
+ for c in commits
1608
+ ],
1609
+ "total": len(commits),
1610
+ }
1611
+
1612
+ def recent_changes_data(
1613
+ self,
1614
+ days: int = 7,
1615
+ path: str = "",
1616
+ top_n: int = 20,
1617
+ ) -> dict:
1618
+ """Return structured recent changes data.
1619
+
1620
+ Uses git subprocess in local mode. In service mode, queries the
1621
+ Commit + CommitFileStat tables.
1622
+
1623
+ Args:
1624
+ days: Look back this many days.
1625
+ path: Optional path prefix filter.
1626
+ top_n: Number of top files to return.
1627
+ """
1628
+ from collections import defaultdict
1629
+
1630
+ from attocode_intel.tools.history_tools import _run_git
1631
+
1632
+ git_args = [
1633
+ "log",
1634
+ f"--since={days} days ago",
1635
+ "--numstat",
1636
+ "--format=%H|%aI|%s",
1637
+ ]
1638
+ if path:
1639
+ git_args.extend(["--", path])
1640
+
1641
+ raw = _run_git(git_args, self._project_dir)
1642
+
1643
+ file_stats: dict[str, dict] = defaultdict(
1644
+ lambda: {"commits": 0, "added": 0, "removed": 0, "last_date": ""}
1645
+ )
1646
+ commit_count = 0
1647
+ current_date = ""
1648
+
1649
+ for line in raw.splitlines():
1650
+ line = line.strip()
1651
+ if not line:
1652
+ continue
1653
+ if "|" in line and line.count("|") >= 2:
1654
+ parts = line.split("|", 2)
1655
+ if len(parts) == 3 and len(parts[0]) >= 7:
1656
+ commit_count += 1
1657
+ current_date = parts[1][:10] if len(parts[1]) >= 10 else parts[1]
1658
+ continue
1659
+ if "\t" in line:
1660
+ parts = line.split("\t")
1661
+ if len(parts) >= 3:
1662
+ try:
1663
+ added = int(parts[0]) if parts[0] != "-" else 0
1664
+ removed = int(parts[1]) if parts[1] != "-" else 0
1665
+ except ValueError:
1666
+ continue
1667
+ file_path = parts[2]
1668
+ if " => " in file_path:
1669
+ file_path = file_path.split(" => ")[-1].rstrip("}")
1670
+ if "{" in file_path:
1671
+ file_path = file_path.replace("{", "").replace("}", "")
1672
+ stats = file_stats[file_path]
1673
+ stats["commits"] += 1
1674
+ stats["added"] += added
1675
+ stats["removed"] += removed
1676
+ if current_date and (not stats["last_date"] or current_date > stats["last_date"]):
1677
+ stats["last_date"] = current_date
1678
+
1679
+ ranked = sorted(
1680
+ file_stats.items(),
1681
+ key=lambda kv: (kv[1]["commits"], kv[1]["added"] + kv[1]["removed"]),
1682
+ reverse=True,
1683
+ )[:top_n]
1684
+
1685
+ return {
1686
+ "days": days,
1687
+ "path": path,
1688
+ "commit_count": commit_count,
1689
+ "total_files_changed": len(file_stats),
1690
+ "files": [
1691
+ {
1692
+ "path": fpath,
1693
+ "commits": s["commits"],
1694
+ "added": s["added"],
1695
+ "removed": s["removed"],
1696
+ "last_date": s["last_date"],
1697
+ }
1698
+ for fpath, s in ranked
1699
+ ],
1700
+ }
1701
+
1702
+ def security_scan_data(self, mode: str = "full", path: str = "") -> dict:
1703
+ """Return structured security scan results."""
1704
+ scanner = self._get_security_scanner()
1705
+ report = scanner.scan(mode=mode, path=path)
1706
+ findings = [
1707
+ {
1708
+ "severity": str(finding.severity),
1709
+ "category": str(finding.category),
1710
+ "file_path": finding.file_path,
1711
+ "line": finding.line,
1712
+ "message": finding.message,
1713
+ "suggestion": finding.recommendation,
1714
+ }
1715
+ for finding in report.findings
1716
+ ]
1717
+ return {
1718
+ "mode": mode,
1719
+ "path": path,
1720
+ "findings": findings,
1721
+ "total_findings": len(findings),
1722
+ "summary": report.summary,
1723
+ }
1724
+
1725
+ async def _ensure_lsp_started(self) -> None:
1726
+ """Start LSP servers on first async call."""
1727
+ if not self._lsp_auto_started:
1728
+ self._lsp_auto_started = True
1729
+ lsp = self._get_lsp_manager()
1730
+ try:
1731
+ await lsp.auto_start()
1732
+ except Exception:
1733
+ import logging
1734
+ logging.getLogger(__name__).warning(
1735
+ "LSP auto_start failed for %s — language servers may not be installed",
1736
+ self._project_dir,
1737
+ )
1738
+
1739
+ async def lsp_definition_data(self, file: str, line: int, col: int = 0) -> dict:
1740
+ """Return structured LSP definition result."""
1741
+ await self._ensure_lsp_started()
1742
+ lsp = self._get_lsp_manager()
1743
+ if not lsp.has_clients():
1744
+ return {"location": None, "error": "No language server running. Install pyright or typescript-language-server."}
1745
+ if not os.path.isabs(file):
1746
+ file = os.path.join(self._project_dir, file)
1747
+ try:
1748
+ loc = await lsp.get_definition(file, line, col)
1749
+ except Exception as e:
1750
+ return {"location": None, "error": f"LSP error: {e}"}
1751
+ if loc is None:
1752
+ return {"location": None, "error": "No definition found at this location"}
1753
+ uri = loc.uri
1754
+ if uri.startswith("file://"):
1755
+ uri = uri[7:]
1756
+ try:
1757
+ uri = os.path.relpath(uri, self._project_dir)
1758
+ except ValueError:
1759
+ pass
1760
+ return {
1761
+ "location": {
1762
+ "file": uri,
1763
+ "line": loc.range.start.line + 1,
1764
+ "col": loc.range.start.character + 1,
1765
+ },
1766
+ "error": None,
1767
+ }
1768
+
1769
+ async def lsp_references_data(
1770
+ self, file: str, line: int, col: int = 0, include_declaration: bool = True,
1771
+ ) -> dict:
1772
+ """Return structured LSP references."""
1773
+ await self._ensure_lsp_started()
1774
+ lsp = self._get_lsp_manager()
1775
+ if not lsp.has_clients():
1776
+ return {"locations": [], "total": 0, "error": "No language server running. Install pyright or typescript-language-server."}
1777
+ if not os.path.isabs(file):
1778
+ file = os.path.join(self._project_dir, file)
1779
+ try:
1780
+ locs = await lsp.get_references(file, line, col, include_declaration=include_declaration)
1781
+ except Exception as e:
1782
+ return {"locations": [], "total": 0, "error": f"LSP error: {e}"}
1783
+ locations = []
1784
+ for loc in locs:
1785
+ uri = loc.uri
1786
+ if uri.startswith("file://"):
1787
+ uri = uri[7:]
1788
+ try:
1789
+ uri = os.path.relpath(uri, self._project_dir)
1790
+ except ValueError:
1791
+ pass
1792
+ locations.append({
1793
+ "file": uri,
1794
+ "line": loc.range.start.line + 1,
1795
+ "col": loc.range.start.character + 1,
1796
+ })
1797
+ return {"locations": locations, "total": len(locs), "error": None}
1798
+
1799
+ async def lsp_hover_data(self, file: str, line: int, col: int = 0) -> dict:
1800
+ """Return structured LSP hover info."""
1801
+ await self._ensure_lsp_started()
1802
+ lsp = self._get_lsp_manager()
1803
+ if not lsp.has_clients():
1804
+ return {"content": None, "file": file, "line": line, "col": col,
1805
+ "error": "No language server running. Install pyright or typescript-language-server."}
1806
+ if not os.path.isabs(file):
1807
+ file = os.path.join(self._project_dir, file)
1808
+ try:
1809
+ info = await lsp.get_hover(file, line, col)
1810
+ except Exception as e:
1811
+ return {"content": None, "file": file, "line": line, "col": col,
1812
+ "error": f"LSP error: {e}"}
1813
+ return {"content": info, "file": file, "line": line, "col": col, "error": None}
1814
+
1815
+ def lsp_diagnostics_data(self, file: str) -> dict:
1816
+ """Return structured LSP diagnostics."""
1817
+ lsp = self._get_lsp_manager()
1818
+ if not lsp.has_clients():
1819
+ return {"file": file, "diagnostics": [], "total": 0,
1820
+ "error": "No language server running. Install pyright or typescript-language-server."}
1821
+ if not os.path.isabs(file):
1822
+ file = os.path.join(self._project_dir, file)
1823
+ try:
1824
+ diags = lsp.get_diagnostics(file)
1825
+ except Exception as e:
1826
+ return {"file": file, "diagnostics": [], "total": 0,
1827
+ "error": f"LSP error: {e}"}
1828
+ return {
1829
+ "file": file,
1830
+ "diagnostics": [
1831
+ {
1832
+ "severity": d.severity,
1833
+ "source": d.source,
1834
+ "code": str(d.code) if d.code else None,
1835
+ "line": d.range.start.line + 1,
1836
+ "col": d.range.start.character + 1,
1837
+ "message": d.message,
1838
+ }
1839
+ for d in diags
1840
+ ],
1841
+ "total": len(diags),
1842
+ "error": None,
1843
+ }
1844
+
1845
+ async def lsp_completions_data(
1846
+ self, file: str, line: int, col: int = 0, limit: int = 20,
1847
+ ) -> dict:
1848
+ """Return structured LSP completion suggestions at position."""
1849
+ await self._ensure_lsp_started()
1850
+ lsp = self._get_lsp_manager()
1851
+ if not lsp.has_clients():
1852
+ return {
1853
+ "file": file, "line": line, "col": col,
1854
+ "completions": [], "total": 0,
1855
+ "error": "No language server running. Install pyright or typescript-language-server.",
1856
+ }
1857
+ if not os.path.isabs(file):
1858
+ file = os.path.join(self._project_dir, file)
1859
+ try:
1860
+ items = await lsp.get_completions(file, line, col)
1861
+ except Exception as e:
1862
+ return {"file": file, "line": line, "col": col, "completions": [], "total": 0,
1863
+ "error": f"LSP error: {e}"}
1864
+ return {
1865
+ "file": file, "line": line, "col": col,
1866
+ "completions": [
1867
+ {
1868
+ "label": c.label,
1869
+ "kind": c.kind,
1870
+ "detail": c.detail,
1871
+ "documentation": c.documentation,
1872
+ "insert_text": c.insert_text,
1873
+ }
1874
+ for c in items[:limit]
1875
+ ],
1876
+ "total": len(items),
1877
+ "error": None,
1878
+ }
1879
+
1880
+ async def lsp_workspace_symbol_data(
1881
+ self, query: str, limit: int = 30,
1882
+ ) -> dict:
1883
+ """Return structured LSP workspace symbol search results."""
1884
+ await self._ensure_lsp_started()
1885
+ lsp = self._get_lsp_manager()
1886
+ if not lsp.has_clients():
1887
+ return {
1888
+ "query": query, "symbols": [], "total": 0,
1889
+ "error": "No language server running. Install pyright or typescript-language-server.",
1890
+ }
1891
+ try:
1892
+ symbols = await lsp.search_symbols(query, max_results=limit)
1893
+ except Exception as e:
1894
+ return {"query": query, "symbols": [], "total": 0, "error": f"LSP error: {e}"}
1895
+ return {
1896
+ "query": query,
1897
+ "symbols": [
1898
+ {
1899
+ "name": s.name,
1900
+ "kind": s.kind,
1901
+ "file": s.uri[7:] if s.uri.startswith("file://") else s.uri,
1902
+ "line": s.range.start.line + 1,
1903
+ "container": s.container_name,
1904
+ "detail": s.detail,
1905
+ }
1906
+ for s in symbols
1907
+ ],
1908
+ "total": len(symbols),
1909
+ "error": None,
1910
+ }
1911
+
1912
+ async def lsp_incoming_calls_data(
1913
+ self, file: str, line: int, col: int = 0,
1914
+ ) -> dict:
1915
+ """Return structured LSP incoming call hierarchy."""
1916
+ await self._ensure_lsp_started()
1917
+ lsp = self._get_lsp_manager()
1918
+ if not lsp.has_clients():
1919
+ return {
1920
+ "symbol": "", "file": file, "line": line, "col": col,
1921
+ "callers": [], "total": 0,
1922
+ "error": "No language server running. Install pyright or typescript-language-server.",
1923
+ }
1924
+ if not os.path.isabs(file):
1925
+ file = os.path.join(self._project_dir, file)
1926
+ try:
1927
+ client = lsp._get_client_for_file(file) # type: ignore[attr-defined]
1928
+ if not client:
1929
+ return {
1930
+ "symbol": "", "file": file, "line": line, "col": col,
1931
+ "callers": [], "total": 0,
1932
+ "error": f"No LSP server for {file}",
1933
+ }
1934
+ uri = lsp._to_uri(file) # type: ignore[attr-defined]
1935
+ prepared = await client.prepare_call_hierarchy(uri, line, col)
1936
+ if not prepared:
1937
+ return {
1938
+ "symbol": "", "file": file, "line": line, "col": col,
1939
+ "callers": [], "total": 0,
1940
+ "error": "No callable symbol at this position",
1941
+ }
1942
+ incoming = await client.incoming_calls(prepared[0])
1943
+ except Exception as e:
1944
+ return {"symbol": "", "file": file, "line": line, "col": col,
1945
+ "callers": [], "total": 0, "error": f"LSP error: {e}"}
1946
+ symbol_name = prepared[0].get("name", "?")
1947
+ callers = []
1948
+ for call in incoming:
1949
+ from_loc = call.get("from", {})
1950
+ from_uri = from_loc.get("uri", "")
1951
+ if from_uri.startswith("file://"):
1952
+ from_uri = from_uri[7:]
1953
+ from_range = from_loc.get("range", {})
1954
+ from_start = from_range.get("start", {})
1955
+ callers.append({
1956
+ "name": from_loc.get("name", "?"),
1957
+ "container": from_loc.get("containerName"),
1958
+ "file": from_uri,
1959
+ "line": from_start.get("line", 0) + 1,
1960
+ "col": from_start.get("character", 0) + 1,
1961
+ })
1962
+ return {
1963
+ "symbol": symbol_name,
1964
+ "file": file, "line": line, "col": col,
1965
+ "callers": callers, "total": len(callers),
1966
+ "error": None,
1967
+ }
1968
+
1969
+ async def lsp_outgoing_calls_data(
1970
+ self, file: str, line: int, col: int = 0,
1971
+ ) -> dict:
1972
+ """Return structured LSP outgoing call hierarchy."""
1973
+ await self._ensure_lsp_started()
1974
+ lsp = self._get_lsp_manager()
1975
+ if not lsp.has_clients():
1976
+ return {
1977
+ "symbol": "", "file": file, "line": line, "col": col,
1978
+ "callees": [], "total": 0,
1979
+ "error": "No language server running. Install pyright or typescript-language-server.",
1980
+ }
1981
+ if not os.path.isabs(file):
1982
+ file = os.path.join(self._project_dir, file)
1983
+ try:
1984
+ client = lsp._get_client_for_file(file) # type: ignore[attr-defined]
1985
+ if not client:
1986
+ return {
1987
+ "symbol": "", "file": file, "line": line, "col": col,
1988
+ "callees": [], "total": 0,
1989
+ "error": f"No LSP server for {file}",
1990
+ }
1991
+ uri = lsp._to_uri(file) # type: ignore[attr-defined]
1992
+ prepared = await client.prepare_call_hierarchy(uri, line, col)
1993
+ if not prepared:
1994
+ return {
1995
+ "symbol": "", "file": file, "line": line, "col": col,
1996
+ "callees": [], "total": 0,
1997
+ "error": "No callable symbol at this position",
1998
+ }
1999
+ outgoing = await client.outgoing_calls(prepared[0])
2000
+ except Exception as e:
2001
+ return {"symbol": "", "file": file, "line": line, "col": col,
2002
+ "callees": [], "total": 0, "error": f"LSP error: {e}"}
2003
+ symbol_name = prepared[0].get("name", "?")
2004
+ callees = []
2005
+ for call in outgoing:
2006
+ to_loc = call.get("to", {})
2007
+ to_uri = to_loc.get("uri", "")
2008
+ if to_uri.startswith("file://"):
2009
+ to_uri = to_uri[7:]
2010
+ to_range = to_loc.get("range", {})
2011
+ to_start = to_range.get("start", {})
2012
+ callees.append({
2013
+ "name": to_loc.get("name", "?"),
2014
+ "container": to_loc.get("containerName"),
2015
+ "file": to_uri,
2016
+ "line": to_start.get("line", 0) + 1,
2017
+ "col": to_start.get("character", 0) + 1,
2018
+ })
2019
+ return {
2020
+ "symbol": symbol_name,
2021
+ "file": file, "line": line, "col": col,
2022
+ "callees": callees, "total": len(callees),
2023
+ "error": None,
2024
+ }
2025
+
2026
+ def repo_stats_data(self) -> dict:
2027
+ """Return aggregate repository statistics."""
2028
+ ctx = self._get_context_mgr()
2029
+ files = ctx._files
2030
+ svc = self._get_ast_service()
2031
+ idx = svc._index
2032
+
2033
+ total_lines = sum(fi.line_count for fi in files)
2034
+ languages: Counter[str] = Counter()
2035
+ for fi in files:
2036
+ if fi.language:
2037
+ languages[fi.language] += 1
2038
+
2039
+ symbol_count = sum(len(syms) for syms in idx.file_symbols.values())
2040
+ dep_count = sum(len(deps) for deps in idx.file_dependencies.values())
2041
+
2042
+ return {
2043
+ "file_count": len(files),
2044
+ "total_lines": total_lines,
2045
+ "symbol_count": symbol_count,
2046
+ "dependency_count": dep_count,
2047
+ "languages": dict(languages),
2048
+ }
2049
+
2050
+ def dead_code_data(
2051
+ self,
2052
+ scope: str = "",
2053
+ entry_points: list[str] | None = None,
2054
+ level: str = "symbol",
2055
+ min_confidence: float = 0.5,
2056
+ top_n: int = 30,
2057
+ ) -> dict:
2058
+ """Return structured dead-code detection results.
2059
+
2060
+ Args:
2061
+ scope: Restrict analysis to files under this directory prefix.
2062
+ entry_points: Additional names/paths to treat as entry points.
2063
+ level: ``"symbol"``, ``"file"``, or ``"module"``.
2064
+ min_confidence: Minimum confidence threshold (0.0-1.0).
2065
+ top_n: Maximum results to return.
2066
+ """
2067
+ from attocode_intel.tools.dead_code_tools import (
2068
+ _find_dead_files,
2069
+ _find_dead_modules,
2070
+ _find_dead_symbols,
2071
+ )
2072
+
2073
+ svc = self._get_ast_service()
2074
+ index = svc._index
2075
+ ast_cache = svc._ast_cache.copy()
2076
+
2077
+ ctx = self._get_context_mgr()
2078
+ all_file_paths = {fi.relative_path for fi in ctx._files}
2079
+
2080
+ ep_set = set(entry_points) if entry_points else set()
2081
+
2082
+ if scope and not scope.endswith("/"):
2083
+ scope = scope + "/"
2084
+
2085
+ min_confidence = max(0.0, min(1.0, min_confidence))
2086
+ top_n = max(1, min(200, top_n))
2087
+
2088
+ if level == "symbol":
2089
+ items = _find_dead_symbols(
2090
+ index, ast_cache, self._project_dir, scope, ep_set,
2091
+ min_confidence, top_n,
2092
+ )
2093
+ elif level == "file":
2094
+ items = _find_dead_files(
2095
+ index, all_file_paths, scope, ep_set,
2096
+ min_confidence, top_n, self._project_dir,
2097
+ )
2098
+ elif level == "module":
2099
+ items = _find_dead_modules(
2100
+ index, all_file_paths, scope, ep_set,
2101
+ min_confidence, top_n, self._project_dir,
2102
+ )
2103
+ else:
2104
+ items = []
2105
+
2106
+ # Aggregate stats
2107
+ total_definitions = sum(len(v) for v in index.definitions.values())
2108
+ total_files = len(all_file_paths)
2109
+
2110
+ return {
2111
+ "level": level,
2112
+ "scope": scope,
2113
+ "items": items,
2114
+ "stats": {
2115
+ "total_definitions": total_definitions,
2116
+ "total_files": total_files,
2117
+ "dead_count": len(items),
2118
+ "min_confidence": min_confidence,
2119
+ },
2120
+ }
2121
+
2122
+ def symbols(self, path: str) -> str:
2123
+ svc = self._get_ast_service()
2124
+ svc.ensure_file_parsed(path)
2125
+ locs = svc.get_file_symbols(path)
2126
+ if not locs:
2127
+ return f"No symbols found in {path}"
2128
+ lines = [f"Symbols in {path}:"]
2129
+ for loc in sorted(locs, key=lambda s: s.start_line):
2130
+ lines.append(f" {loc.kind} {loc.qualified_name} (L{loc.start_line}-{loc.end_line})")
2131
+ return "\n".join(lines)
2132
+
2133
+ def search_symbols(self, name: str, limit: int = 30, kind: str = "",
2134
+ task_hint: str = "") -> str:
2135
+ svc = self._get_ast_service()
2136
+ scored = svc.search_symbol(name, limit=max(limit * 4, 80) if task_hint else limit,
2137
+ kind_filter=kind)
2138
+ if task_hint:
2139
+ task_scores = self._task_file_scores(task_hint)
2140
+ scored = sorted(scored, key=lambda row: (
2141
+ -row[1], -task_scores.get(row[0].file_path, 0.0),
2142
+ row[0].file_path, row[0].start_line,
2143
+ ))[:limit]
2144
+ if not scored:
2145
+ return f"No definitions found for '{name}'"
2146
+ lines = [f"Definitions matching '{name}' ({len(scored)} results):"]
2147
+ for loc, score in scored:
2148
+ lines.append(
2149
+ f" [score {score:.3f}] {loc.kind} {loc.qualified_name} "
2150
+ f"in {loc.file_path}:{loc.start_line}-{loc.end_line}"
2151
+ )
2152
+ return "\n".join(lines)
2153
+
2154
+ def dependencies(self, path: str) -> str:
2155
+ svc = self._get_ast_service()
2156
+ svc.ensure_file_parsed(path)
2157
+ deps = svc.get_dependencies(path)
2158
+ dependents = svc.get_dependents(path)
2159
+ lines = [f"Dependencies for {path}:"]
2160
+ lines.append(f"\n Imports from ({len(deps)} files):")
2161
+ if deps:
2162
+ for d in sorted(deps):
2163
+ lines.append(f" {d}")
2164
+ else:
2165
+ lines.append(" (none)")
2166
+ lines.append(f"\n Imported by ({len(dependents)} files):")
2167
+ if dependents:
2168
+ for d in sorted(dependents):
2169
+ lines.append(f" {d}")
2170
+ else:
2171
+ lines.append(" (none)")
2172
+ return "\n".join(lines)
2173
+
2174
+ def impact_analysis(self, changed_files: list[str]) -> str:
2175
+ svc = self._get_ast_service()
2176
+ impacted = svc.get_impact(changed_files)
2177
+ if not impacted:
2178
+ return f"No indexed relationships found for {', '.join(changed_files)}. Missing relationships do not prove absence of impact."
2179
+ lines = [f"Impact analysis for {', '.join(changed_files)}:"]
2180
+ lines.append(f"\n {len(impacted)} potentially related files (static graph reachability):")
2181
+ for f in sorted(impacted):
2182
+ lines.append(f" {f}")
2183
+ return "\n".join(lines)
2184
+
2185
+ def cross_references(self, symbol_name: str) -> str:
2186
+ svc = self._get_ast_service()
2187
+ # On-demand: ensure references are indexed for files containing this symbol
2188
+ for loc in svc.find_symbol(symbol_name):
2189
+ svc.ensure_references_indexed(loc.file_path)
2190
+ definitions = svc.find_symbol(symbol_name)
2191
+ references = svc.get_callers(symbol_name)
2192
+ lines = [f"Cross-references for '{symbol_name}':"]
2193
+ lines.append(f"\n Definitions ({len(definitions)}):")
2194
+ if definitions:
2195
+ for loc in definitions:
2196
+ lines.append(
2197
+ f" {loc.kind} {loc.qualified_name} "
2198
+ f"in {loc.file_path}:{loc.start_line}"
2199
+ )
2200
+ else:
2201
+ lines.append(" (none found)")
2202
+ lines.append(f"\n References ({len(references)}):")
2203
+ if references:
2204
+ for ref in references[:100]:
2205
+ lines.append(f" [{ref.ref_kind}] {ref.file_path}:{ref.line}")
2206
+ if len(references) > 100:
2207
+ lines.append(f" ... and {len(references) - 100} more")
2208
+ else:
2209
+ lines.append(" (none found)")
2210
+ return "\n".join(lines)
2211
+
2212
+ def file_analysis(self, path: str) -> str:
2213
+ analyzer = self._get_code_analyzer()
2214
+ if not os.path.isabs(path):
2215
+ path = os.path.join(self._project_dir, path)
2216
+ result = analyzer.analyze_file(path)
2217
+ lines = [f"Analysis of {result.path}:"]
2218
+ lines.append(f" Language: {result.language}")
2219
+ lines.append(f" Lines: {result.line_count}")
2220
+ if result.imports:
2221
+ lines.append(f"\n Imports ({len(result.imports)}):")
2222
+ for imp in result.imports:
2223
+ lines.append(f" {imp}")
2224
+ if result.exports:
2225
+ lines.append(f"\n Exports ({len(result.exports)}):")
2226
+ for exp in result.exports:
2227
+ lines.append(f" {exp}")
2228
+ if result.chunks:
2229
+ lines.append(f"\n Code chunks ({len(result.chunks)}):")
2230
+ for chunk in result.chunks:
2231
+ sig = f" — {chunk.signature}" if chunk.signature else ""
2232
+ parent = f" (in {chunk.parent})" if chunk.parent else ""
2233
+ lines.append(
2234
+ f" {chunk.kind} {chunk.name}{parent}{sig} "
2235
+ f"L{chunk.start_line}-{chunk.end_line}"
2236
+ )
2237
+ return "\n".join(lines)
2238
+
2239
+ def dependency_graph(self, start_file: str, depth: int = 2) -> str:
2240
+ data = self.dependency_graph_data(start_file, depth)
2241
+ rel = data["start_file"]
2242
+ lines = [f"Dependency graph for {rel} (depth={depth}):"]
2243
+
2244
+ lines.append("\n Imports (forward):")
2245
+ if data["forward"]:
2246
+ for node in data["forward"]:
2247
+ indent = " " + " " * node["depth"]
2248
+ lines.append(f"{indent}{node['path']}")
2249
+ else:
2250
+ lines.append(" (none)")
2251
+
2252
+ lines.append("\n Imported by (reverse):")
2253
+ if data["reverse"]:
2254
+ for node in data["reverse"]:
2255
+ indent = " " + " " * node["depth"]
2256
+ lines.append(f"{indent}{node['path']}")
2257
+ else:
2258
+ lines.append(" (none)")
2259
+
2260
+ return "\n".join(lines)
2261
+
2262
+ def graph_query(
2263
+ self,
2264
+ file: str,
2265
+ edge_type: str = "IMPORTS",
2266
+ direction: str = "outbound",
2267
+ depth: int = 2,
2268
+ ) -> str:
2269
+ valid_edge_types = {"IMPORTS", "IMPORTED_BY"}
2270
+ valid_directions = {"outbound", "inbound"}
2271
+ if edge_type not in valid_edge_types:
2272
+ return f"Error: invalid edge_type '{edge_type}'. Must be one of: {', '.join(sorted(valid_edge_types))}"
2273
+ if direction not in valid_directions:
2274
+ return f"Error: invalid direction '{direction}'. Must be one of: {', '.join(sorted(valid_directions))}"
2275
+
2276
+ data = self.graph_query_data(file, edge_type, direction, depth)
2277
+ label = "importers" if data["direction"] == "inbound" else "imports"
2278
+ lines = [f"Graph query: {data['root']} ({label}, depth={data['depth']})"]
2279
+ if not data["hops"]:
2280
+ lines.append(" (no results)")
2281
+ else:
2282
+ for hop in data["hops"]:
2283
+ d = hop["depth"]
2284
+ lines.append(f"\n Hop {d}:")
2285
+ for f_path in hop["files"]:
2286
+ lines.append(f" {'>' * d} {f_path}")
2287
+ lines.append(f"\nTotal: {data['total_reachable']} files reachable")
2288
+ return "\n".join(lines)
2289
+
2290
+ def graph_dsl(self, query: str) -> str:
2291
+ """Execute a graph DSL query and return formatted text."""
2292
+ data = self.graph_dsl_data(query)
2293
+ if "error" in data and data["error"]:
2294
+ return f"Error: {data['error']}"
2295
+ results = data.get("results", [])
2296
+ if not results:
2297
+ return f"No results for query: {query}"
2298
+ lines = [f"Graph DSL results ({len(results)} matches):"]
2299
+ for i, row in enumerate(results, 1):
2300
+ parts = [f"{k}={v}" for k, v in row.items()]
2301
+ lines.append(f" {i:3d}. {', '.join(parts)}")
2302
+ if data.get("truncated"):
2303
+ lines.append(f"\n (results truncated at {data['total']})")
2304
+ return "\n".join(lines)
2305
+
2306
+ def find_related(self, file: str, top_k: int = 10,
2307
+ task_hint: str = "") -> str:
2308
+ svc = self._get_ast_service()
2309
+ rel = svc._to_rel(file)
2310
+ idx = svc._index
2311
+
2312
+ if rel not in idx.file_symbols and rel not in idx.file_dependencies:
2313
+ return f"Error: file '{rel}' not found in the project index."
2314
+
2315
+ data = self.find_related_data(file, top_k, task_hint=task_hint)
2316
+ lines = [f"Files related to {data['file']}:"]
2317
+ if not data["related"]:
2318
+ lines.append(" (no related files found)")
2319
+ else:
2320
+ for item in data["related"]:
2321
+ lines.append(f" [{item['score']:>3}] {item['path']} ({item['relation_type']})")
2322
+ return "\n".join(lines)
2323
+
2324
+ def community_detection(self, min_community_size: int = 3, max_communities: int = 20) -> str:
2325
+ data = self.community_detection_data(min_community_size, max_communities)
2326
+
2327
+ # Directory-based fallback format
2328
+ if "modules" in data:
2329
+ lines = [
2330
+ f"Architecture module analysis ({data['method']}): "
2331
+ f"{data['total_directories']} directory modules, "
2332
+ f"{data['total_files']} total files "
2333
+ f"(modularity={data['modularity']:.3f})"
2334
+ ]
2335
+ if data.get("note"):
2336
+ lines.append(f" Note: {data['note']}")
2337
+
2338
+ for mod in data["modules"]:
2339
+ lines.append(
2340
+ f"\n Module {mod['id']}: directory '{mod['directory']}' "
2341
+ f"— {mod['file_count']} files, {mod['total_symbols']} symbols"
2342
+ )
2343
+ for kf in mod.get("key_files", []):
2344
+ lines.append(f" key file: {kf['path']} ({kf['symbols']} symbols)")
2345
+ sample = mod["files"][:5]
2346
+ for f in sample:
2347
+ lines.append(f" - {f}")
2348
+ if mod["file_count"] > 5:
2349
+ lines.append(f" ... and {mod['file_count'] - 5} more")
2350
+
2351
+ if data.get("hub_files"):
2352
+ lines.append("\n Hub Files (most cross-references):")
2353
+ for hf in data["hub_files"]:
2354
+ lines.append(
2355
+ f" hub: {hf['path']} "
2356
+ f"({hf['cross_references']} cross-references, {hf['symbols']} symbols)"
2357
+ )
2358
+
2359
+ return "\n".join(lines)
2360
+
2361
+ # Standard community format
2362
+ lines = [
2363
+ f"Community detection ({data['method']}): {len(data['communities'])} communities "
2364
+ f"(min size {min_community_size}, modularity={data['modularity']:.3f})"
2365
+ ]
2366
+ for comm in data["communities"]:
2367
+ lines.append(f"\n Community {comm['id']} ({comm['size']} files) — theme: {comm['theme']}")
2368
+ lines.append(f" Internal edges: {comm['internal_edges']}, External edges: {comm['external_edges']}")
2369
+ lines.append(f" Hub: {comm['hub']} (internal degree {comm['hub_internal_degree']})")
2370
+ sample = comm["files"][:5]
2371
+ for f in sample:
2372
+ lines.append(f" - {f}")
2373
+ if comm["size"] > 5:
2374
+ lines.append(f" ... and {comm['size'] - 5} more")
2375
+ return "\n".join(lines)
2376
+
2377
+ def relevant_context(
2378
+ self,
2379
+ files: list[str],
2380
+ depth: int = 1,
2381
+ max_tokens: int = 4000,
2382
+ include_symbols: bool = True,
2383
+ task_hint: str = "",
2384
+ ) -> str:
2385
+ from attocode_intel._internal.integrations.context.semantic_search import (
2386
+ ContextAssemblyConfig,
2387
+ )
2388
+
2389
+ cc = self._context_config or ContextAssemblyConfig()
2390
+
2391
+ svc = self._get_ast_service()
2392
+ ctx = self._get_context_mgr()
2393
+ all_files = {fi.relative_path: fi for fi in ctx._files}
2394
+
2395
+ depth = min(depth, cc.max_depth)
2396
+ center_rels: list[str] = []
2397
+ for f in files:
2398
+ rel = os.path.normpath(svc._to_rel(f))
2399
+ if rel in all_files:
2400
+ center_rels.append(rel)
2401
+ if not center_rels:
2402
+ return "No valid files provided."
2403
+
2404
+ visited: dict[str, tuple[int, str]] = {}
2405
+ queue: deque[tuple[str, int, str]] = deque()
2406
+ for rel in center_rels:
2407
+ visited[rel] = (0, "center")
2408
+ queue.append((rel, 0, "center"))
2409
+
2410
+ while queue:
2411
+ current, d, _rel_type = queue.popleft()
2412
+ if d >= depth:
2413
+ continue
2414
+ for dep in svc.get_dependencies(current):
2415
+ if dep not in visited:
2416
+ relationship = "imported-by-center" if d == 0 else "transitive-import"
2417
+ visited[dep] = (d + 1, relationship)
2418
+ queue.append((dep, d + 1, relationship))
2419
+ for dep in svc.get_dependents(current):
2420
+ if dep not in visited:
2421
+ relationship = "imports-center" if d == 0 else "transitive-importer"
2422
+ visited[dep] = (d + 1, relationship)
2423
+ queue.append((dep, d + 1, relationship))
2424
+
2425
+ task_scores = self._task_file_scores(task_hint) if task_hint else {}
2426
+
2427
+ def _sort_key(item):
2428
+ rel, (dist, _) = item
2429
+ fi = all_files.get(rel)
2430
+ importance = fi.importance if fi else 0.0
2431
+ return (dist, -task_scores.get(rel, 0.0), -importance, rel)
2432
+
2433
+ sorted_files = sorted(visited.items(), key=_sort_key)
2434
+ sections: list[str] = []
2435
+ token_est = 0
2436
+
2437
+ for rel, (dist, relationship) in sorted_files:
2438
+ fi = all_files.get(rel)
2439
+ if include_symbols and fi is not None:
2440
+ svc.ensure_file_parsed(rel)
2441
+ file_ast = svc._ast_cache.get(rel)
2442
+ lang = fi.language if fi else ""
2443
+ line_count = fi.line_count if fi else 0
2444
+ importance = fi.importance if fi else 0.0
2445
+
2446
+ header = f"{' ' * dist}{rel}"
2447
+ meta = f" {lang}, {line_count}L, importance={importance:.2f}, {relationship}"
2448
+ file_section = [header, meta]
2449
+
2450
+ if include_symbols and file_ast:
2451
+ max_sym = cc.center_symbol_cap if dist == 0 else cc.neighbor_symbol_cap
2452
+ sym_lines: list[str] = []
2453
+ functions = sorted(file_ast.functions, key=lambda fn: (fn.is_nested, fn.name.startswith("_"), fn.start_line))
2454
+ # Keep a representative API surface when functions and types coexist.
2455
+ classes = sorted(file_ast.classes, key=lambda cls: (-len(cls.methods), cls.start_line))
2456
+ fn_limit = max_sym if not classes else max(1, max_sym // 2)
2457
+ for fn in functions[:fn_limit]:
2458
+ params = ", ".join(p.name for p in fn.parameters[:cc.param_preview_limit])
2459
+ ret = f" -> {fn.return_type}" if fn.return_type else ""
2460
+ sym_lines.append(f" fn {fn.name}({params}){ret}")
2461
+ for cls in classes[:max_sym - len(sym_lines)]:
2462
+ bases = f"({', '.join(cls.bases[:cc.base_preview_limit])})" if cls.bases else ""
2463
+ methods_preview = ", ".join(m.name for m in cls.methods[:cc.method_preview_limit])
2464
+ sym_lines.append(f" class {cls.name}{bases}: {methods_preview}")
2465
+ remaining = max_sym - len(sym_lines)
2466
+ if remaining < 0:
2467
+ sym_lines = sym_lines[:max_sym]
2468
+ sym_lines.append(" ... and more")
2469
+ file_section.extend(sym_lines)
2470
+
2471
+ section_text = "\n".join(file_section)
2472
+ section_tokens = estimate_tokens(section_text)
2473
+ if token_est + section_tokens > max_tokens and sections:
2474
+ sections.append(f" ... and {len(sorted_files) - len(sections)} more files (truncated)")
2475
+ break
2476
+ sections.append(section_text)
2477
+ token_est += section_tokens
2478
+
2479
+ header_text = (
2480
+ f"Subgraph capsule for {', '.join(center_rels)} "
2481
+ f"(depth={depth}, {len(visited)} files):\n"
2482
+ )
2483
+ return header_text + "\n".join(sections)
2484
+
2485
+ def project_summary(self, max_tokens: int = 4000) -> str:
2486
+ # Import the synthesis helpers from helpers module
2487
+ from attocode_intel.helpers import (
2488
+ _classify_layers,
2489
+ _detect_build_system,
2490
+ _detect_project_name,
2491
+ _detect_tech_stack,
2492
+ _find_entry_points,
2493
+ _find_hub_files,
2494
+ _summarize_directories,
2495
+ )
2496
+
2497
+ ctx = self._get_context_mgr()
2498
+ files = ctx._files
2499
+ if not files:
2500
+ return "No files discovered in this project."
2501
+
2502
+ repo = ctx.get_repo_map(include_symbols=False, max_tokens=500)
2503
+ svc = self._get_ast_service()
2504
+ index = svc._index
2505
+ ast_cache = svc._ast_cache.copy()
2506
+
2507
+ sections: list[tuple[str, str, int]] = []
2508
+
2509
+ name = _detect_project_name(self._project_dir)
2510
+ top_langs = sorted(repo.languages.items(), key=lambda x: -x[1])[:8]
2511
+ lang_str = ", ".join(f"{lang} ({count})" for lang, count in top_langs)
2512
+ identity = (
2513
+ f"Project: {name}\n"
2514
+ f"Files: {repo.total_files}, Lines: {repo.total_lines:,}\n"
2515
+ f"Languages: {lang_str or 'unknown'}"
2516
+ )
2517
+ sections.append(("Overview", identity, 10))
2518
+
2519
+ entries = _find_entry_points(files, index)
2520
+ if entries:
2521
+ entry_lines = [f" {path} — {reason}" for path, reason in entries[:20]]
2522
+ sections.append(("Entry Points", "\n".join(entry_lines), 9))
2523
+
2524
+ if index.file_dependents:
2525
+ hubs = _find_hub_files(files, index, top_n=10)
2526
+ if hubs:
2527
+ hub_lines = [f" {path} (fan-in={fi}, fan-out={fo})" for path, fi, fo in hubs]
2528
+ sections.append(("Core Files (by dependents)", "\n".join(hub_lines), 8))
2529
+
2530
+ dirs = _summarize_directories(files)
2531
+ total_files = len(files)
2532
+ dir_lines = []
2533
+ for d, count, loc in dirs[:15]:
2534
+ pct = count / total_files * 100 if total_files else 0
2535
+ if d in ("site", "docs", "doc", ".git") and pct < 10:
2536
+ continue
2537
+ dir_lines.append(f" {d}/ — {count} files, {loc:,} lines ({pct:.0f}%)")
2538
+ if dir_lines:
2539
+ sections.append(("Directory Layout", "\n".join(dir_lines), 7))
2540
+
2541
+ if index.file_dependents:
2542
+ layers = _classify_layers(files, index)
2543
+ layer_lines = []
2544
+ for layer_name, layer_files in layers.items():
2545
+ if layer_files:
2546
+ examples = ", ".join(layer_files[:5])
2547
+ more = f" (+{len(layer_files) - 5})" if len(layer_files) > 5 else ""
2548
+ layer_lines.append(f" {layer_name}: {len(layer_files)} files — {examples}{more}")
2549
+ if layer_lines:
2550
+ sections.append(("Dependency Layers", "\n".join(layer_lines), 5))
2551
+
2552
+ if ast_cache:
2553
+ stack = _detect_tech_stack(ast_cache)
2554
+ if stack:
2555
+ sections.append(("Tech Stack", " " + ", ".join(stack), 6))
2556
+
2557
+ test_files = [f for f in files if f.is_test]
2558
+ if test_files:
2559
+ has_prefix = any("test_" in os.path.basename(f.relative_path) for f in test_files)
2560
+ test_pat = "test_*.py" if has_prefix else "*_test.py"
2561
+ sections.append(("Tests", f" {len(test_files)} test files (pattern: {test_pat})", 4))
2562
+
2563
+ build = _detect_build_system(files)
2564
+ if build != "unknown":
2565
+ sections.append(("Build System", f" {build}", 3))
2566
+
2567
+ sections.sort(key=lambda x: x[2], reverse=True)
2568
+ output_parts: list[str] = []
2569
+ token_est = 0
2570
+ for header, text, _prio in sections:
2571
+ section_text = f"## {header}\n{text}"
2572
+ section_tokens = estimate_tokens(section_text)
2573
+ if token_est + section_tokens > max_tokens and output_parts:
2574
+ break
2575
+ output_parts.append(section_text)
2576
+ token_est += section_tokens
2577
+ return "\n\n".join(output_parts)
2578
+
2579
+ def bootstrap(self, task_hint: str = "", max_tokens: int = 8000, indexing_depth: str = "auto") -> str:
2580
+ from attocode_intel._internal.integrations.context.semantic_search import (
2581
+ ContextAssemblyConfig,
2582
+ )
2583
+ from attocode_intel._internal.integrations.utilities.token_estimate import count_tokens
2584
+ from attocode_intel.helpers import _analyze_conventions, _format_conventions
2585
+ from attocode_intel.output import bounded_text
2586
+
2587
+ cc = self._context_config or ContextAssemblyConfig()
2588
+
2589
+ ctx = self._get_context_mgr()
2590
+ ctx.refresh_excluded_checkout_roots()
2591
+ files = ctx._files
2592
+ checkout_guidance = ""
2593
+ if ctx.excluded_checkout_roots or ctx.excluded_checkout_roots_truncated:
2594
+ paths = "\n".join(
2595
+ f" {relative} (workspace={Path(self._project_dir, relative)})"
2596
+ for relative in ctx.excluded_checkout_roots
2597
+ )
2598
+ more = ("\n Scan incomplete; other ignored Git checkouts may exist."
2599
+ if ctx.excluded_checkout_roots_truncated else "")
2600
+ checkout_guidance = (
2601
+ "## Git checkouts excluded from this workspace\n"
2602
+ f"{paths}{more}\nSelect a checkout as workspace to index its files."
2603
+ )
2604
+ if not files:
2605
+ if checkout_guidance:
2606
+ return bounded_text("## Overview\nNo files discovered in this project.\n\n"
2607
+ + checkout_guidance, max_tokens)[0]
2608
+ return "No files discovered in this project."
2609
+
2610
+ total_files = len(files)
2611
+ import os
2612
+ _file_cap = int(os.environ.get("ATTOCODE_FILE_CAP", str(cc.large_repo_threshold)))
2613
+ if total_files < cc.small_repo_threshold:
2614
+ size_tier = "small"
2615
+ elif total_files < _file_cap:
2616
+ size_tier = "medium"
2617
+ else:
2618
+ size_tier = "large"
2619
+
2620
+ if task_hint:
2621
+ summary_budget = int(max_tokens * cc.summary_ratio)
2622
+ structure_budget = int(max_tokens * cc.structure_ratio)
2623
+ conventions_budget = int(max_tokens * cc.conventions_ratio)
2624
+ search_budget = int(max_tokens * cc.search_ratio)
2625
+ else:
2626
+ summary_budget = int(max_tokens * cc.summary_ratio_no_hint)
2627
+ structure_budget = int(max_tokens * cc.structure_ratio_no_hint)
2628
+ conventions_budget = int(max_tokens * cc.conventions_ratio_no_hint)
2629
+ search_budget = 0
2630
+
2631
+ sections: list[str] = []
2632
+ if task_hint:
2633
+ try:
2634
+ mgr = self._get_semantic_search()
2635
+ candidates = self._search_after_warmup(mgr, lambda: mgr.search_candidates(
2636
+ task_hint, top_k=max(50, cc.bootstrap_search_top_k * 10, self._retrieval_depth(0)), file_filter="",
2637
+ ))
2638
+ results, _ranking = self._rank_search_results(
2639
+ task_hint, candidates, cc.bootstrap_search_top_k,
2640
+ )
2641
+ if results:
2642
+ heading = f"## Relevant Code for: {task_hint}\n"
2643
+ advisory = (
2644
+ "Broad query matches multiple components; add a component or path hint.\n"
2645
+ if _ranking["query"]["ambiguous"] else ""
2646
+ )
2647
+ selected = ""
2648
+ for count in range(1, len(results) + 1):
2649
+ candidate = advisory + mgr.format_results(results[:count], query=task_hint)
2650
+ if count < len(results):
2651
+ candidate = candidate.replace(
2652
+ f"results ({count}):", f"results ({count} of {len(results)}):", 1)
2653
+ if count_tokens(heading + candidate) > search_budget:
2654
+ break
2655
+ selected = candidate
2656
+ if not selected:
2657
+ first = advisory + mgr.format_results(results[:1], query=task_hint)
2658
+ selected = (first if count_tokens(heading + first) <= max_tokens else
2659
+ "Top match exceeds max_tokens; narrow the task or increase the budget.")
2660
+ sections.append(heading + selected)
2661
+ else:
2662
+ diagnostics = mgr.candidate_diagnostics()
2663
+ if diagnostics.get("status") == "warming":
2664
+ sections.append(
2665
+ f"## Relevant Code for: {task_hint}\n"
2666
+ "Search index warming; retry shortly. Missing results do not prove absence."
2667
+ )
2668
+ else:
2669
+ summary_budget += search_budget // 2
2670
+ structure_budget += search_budget - search_budget // 2
2671
+ sections.append(f"## Relevant Code for: {task_hint}\nNo matches in this workspace.")
2672
+ except Exception as exc:
2673
+ logger.warning("bootstrap semantic-search step failed", exc_info=True)
2674
+ summary_budget += search_budget // 2
2675
+ structure_budget += search_budget - search_budget // 2
2676
+ sections.append(
2677
+ "## Relevant Code\n"
2678
+ f"Skipped due to search backend error: {type(exc).__name__}: {exc}"
2679
+ )
2680
+
2681
+ sections.append(self.project_summary(max_tokens=summary_budget))
2682
+ if checkout_guidance:
2683
+ sections.append(checkout_guidance)
2684
+
2685
+ if size_tier == "small":
2686
+ map_text = self.repo_map(include_symbols=True, max_tokens=structure_budget)
2687
+ sections.append(f"## Repository Map\n{map_text}")
2688
+ elif size_tier == "medium":
2689
+ map_text = self.repo_map(include_symbols=True, max_tokens=int(structure_budget * cc.medium_structure_map_ratio))
2690
+ hs_text = "Use hotspots for a focused complexity report after indexing completes."
2691
+ sections.append(f"## Repository Map\n{map_text}")
2692
+ sections.append(f"## Hotspots\n{hs_text}")
2693
+ else:
2694
+ explorer = self._get_explorer()
2695
+ root_result = explorer.explore("", max_items=cc.explore_max_items, importance_threshold=cc.explore_importance_threshold)
2696
+ explore_text = explorer.format_result(root_result)
2697
+ hs_text = "Use hotspots for a focused complexity report after indexing completes."
2698
+ sections.append(f"## Top-Level Structure\n{explore_text}")
2699
+ sections.append(f"## Hotspots\n{hs_text}")
2700
+
2701
+ svc = self._get_ast_service()
2702
+ ast_cache = svc._ast_cache.copy()
2703
+ if ast_cache:
2704
+ candidates = sorted(
2705
+ [fi for fi in files if fi.relative_path in ast_cache],
2706
+ key=lambda fi: fi.importance,
2707
+ reverse=True,
2708
+ )
2709
+ sample_rels = [fi.relative_path for fi in candidates[:cc.conventions_sample_size]]
2710
+ if sample_rels:
2711
+ stats = _analyze_conventions(ast_cache, sample_rels)
2712
+ conv_text = _format_conventions(stats)
2713
+ conv_chars = conventions_budget * 4
2714
+ if len(conv_text) > conv_chars:
2715
+ conv_text = conv_text[:conv_chars] + "\n ..."
2716
+ sections.append(f"## Conventions\n{conv_text}")
2717
+
2718
+ if size_tier == "small":
2719
+ guidance = (
2720
+ "## Navigation Guidance\n"
2721
+ "Small codebase — the repo map above shows everything.\n"
2722
+ "Next: `symbols(file)` or `file_analysis(file)` on files of interest."
2723
+ )
2724
+ elif size_tier == "medium":
2725
+ guidance = (
2726
+ "## Navigation Guidance\n"
2727
+ "Medium codebase — use `explore_codebase(dir)` to drill into directories.\n"
2728
+ "For specific symbols: `search_symbols(name)` or `semantic_search(query)`.\n"
2729
+ "Before modifying: `impact_analysis([files])` to check blast radius."
2730
+ )
2731
+ else:
2732
+ guidance = (
2733
+ "## Navigation Guidance\n"
2734
+ "Large codebase — do NOT request full `repo_map`, it wastes tokens.\n"
2735
+ "Use `explore_codebase(dir)` to drill down level by level.\n"
2736
+ "For specific symbols: `search_symbols(name)` or `semantic_search(query)`.\n"
2737
+ "Use `relevant_context([file])` to understand a file with its neighbors.\n"
2738
+ "Before modifying: `impact_analysis([files])` to check blast radius."
2739
+ )
2740
+ sections.append(guidance)
2741
+ return bounded_text("\n\n".join(sections), max_tokens)[0]
2742
+
2743
+ def hotspots(self, top_n: int = 15) -> str:
2744
+ from attocode_intel.helpers import (
2745
+ _compute_file_metrics,
2746
+ _compute_function_hotspots,
2747
+ _get_churn_scores,
2748
+ )
2749
+
2750
+ ctx = self._get_context_mgr()
2751
+ files = ctx._files
2752
+ if not files:
2753
+ return "No files discovered in this project."
2754
+
2755
+ svc = self._get_ast_service()
2756
+ index = svc._index
2757
+ ast_cache = svc._ast_cache.copy()
2758
+ churn_scores = _get_churn_scores(self._project_dir, files)
2759
+
2760
+ all_metrics = _compute_file_metrics(files, index, ast_cache, churn_scores)
2761
+ if not all_metrics:
2762
+ return "No analyzable files found."
2763
+
2764
+ all_metrics.sort(key=lambda m: m.composite, reverse=True)
2765
+ lines = [f"Top {min(top_n, len(all_metrics))} hotspots by complexity/coupling:\n"]
2766
+ for i, m in enumerate(all_metrics[:top_n], 1):
2767
+ tags = f" [{', '.join(m.categories)}]" if m.categories else ""
2768
+ lines.append(
2769
+ f" {i:2d}. {m.path}\n"
2770
+ f" {m.line_count} lines, {m.symbol_count} symbols, "
2771
+ f"pub={m.public_symbols}, "
2772
+ f"fan-in={m.fan_in}, fan-out={m.fan_out}, "
2773
+ f"density={m.density}%, score={m.composite}{tags}"
2774
+ )
2775
+
2776
+ fn_hotspots = _compute_function_hotspots(ast_cache, top_n=10)
2777
+ if fn_hotspots:
2778
+ lines.append("\nLongest functions:")
2779
+ for i, fm in enumerate(fn_hotspots, 1):
2780
+ pub_mark = "" if fm.is_public else " (private)"
2781
+ ret_mark = "" if fm.has_return_type else " [no return type]"
2782
+ lines.append(
2783
+ f" {i:2d}. {fm.name} — {fm.line_count} lines, "
2784
+ f"{fm.param_count} params{pub_mark}{ret_mark}\n"
2785
+ f" {fm.file_path}"
2786
+ )
2787
+
2788
+ orphans = [
2789
+ m for m in all_metrics
2790
+ if m.fan_in == 0 and m.fan_out == 0 and m.line_count >= 20
2791
+ and not any(fi.is_test for fi in files if fi.relative_path == m.path)
2792
+ ]
2793
+ if orphans:
2794
+ lines.append(f"\nOrphan files (no imports/importers, {len(orphans)} found):")
2795
+ for m in orphans[:10]:
2796
+ lines.append(f" {m.path} ({m.line_count} lines, {m.symbol_count} symbols)")
2797
+ if len(orphans) > 10:
2798
+ lines.append(f" ... and {len(orphans) - 10} more")
2799
+ return "\n".join(lines)
2800
+
2801
+ def conventions(self, sample_size: int = 50, path: str = "") -> str:
2802
+ from attocode_intel.helpers import _analyze_conventions, _format_conventions
2803
+
2804
+ svc = self._get_ast_service()
2805
+ ast_cache = svc._ast_cache.copy()
2806
+ if not ast_cache:
2807
+ return "No files parsed — cannot detect conventions."
2808
+
2809
+ ctx = self._get_context_mgr()
2810
+ files = ctx._files
2811
+ path_prefix = path.rstrip("/") + "/" if path else ""
2812
+
2813
+ if path_prefix:
2814
+ scoped_candidates = sorted(
2815
+ [
2816
+ fi for fi in files
2817
+ if fi.relative_path in ast_cache
2818
+ and fi.relative_path.startswith(path_prefix)
2819
+ ],
2820
+ key=lambda fi: fi.importance,
2821
+ reverse=True,
2822
+ )
2823
+ scoped_rels = [fi.relative_path for fi in scoped_candidates[:sample_size]]
2824
+ if not scoped_rels:
2825
+ return f"No parsed files found in '{path}'."
2826
+
2827
+ scoped_stats = _analyze_conventions(ast_cache, scoped_rels)
2828
+ global_candidates = sorted(
2829
+ [fi for fi in files if fi.relative_path in ast_cache],
2830
+ key=lambda fi: fi.importance,
2831
+ reverse=True,
2832
+ )
2833
+ global_rels = [fi.relative_path for fi in global_candidates[:sample_size]]
2834
+ global_stats = _analyze_conventions(ast_cache, global_rels)
2835
+
2836
+ header = f"Conventions in {path}/ ({len(scoped_rels)} files):\n"
2837
+ scoped_text = _format_conventions(scoped_stats)
2838
+
2839
+ comparison_parts: list[str] = []
2840
+ scoped_fn = scoped_stats["total_functions"]
2841
+ global_fn = global_stats["total_functions"]
2842
+ if scoped_fn > 0 and global_fn > 0:
2843
+ scoped_type_pct = scoped_stats["typed_return"] / scoped_fn * 100
2844
+ global_type_pct = global_stats["typed_return"] / global_fn * 100
2845
+ if abs(scoped_type_pct - global_type_pct) > 10:
2846
+ comparison_parts.append(
2847
+ f" Type hints: {scoped_type_pct:.0f}% here vs {global_type_pct:.0f}% project-wide"
2848
+ )
2849
+ scoped_doc_pct = scoped_stats["has_docstring_fn"] / scoped_fn * 100
2850
+ global_doc_pct = global_stats["has_docstring_fn"] / global_fn * 100
2851
+ if abs(scoped_doc_pct - global_doc_pct) > 10:
2852
+ comparison_parts.append(
2853
+ f" Docstrings: {scoped_doc_pct:.0f}% here vs {global_doc_pct:.0f}% project-wide"
2854
+ )
2855
+ scoped_async_pct = scoped_stats["async_count"] / scoped_fn * 100
2856
+ global_async_pct = global_stats["async_count"] / global_fn * 100
2857
+ if abs(scoped_async_pct - global_async_pct) > 10:
2858
+ comparison_parts.append(
2859
+ f" Async: {scoped_async_pct:.0f}% here vs {global_async_pct:.0f}% project-wide"
2860
+ )
2861
+
2862
+ if comparison_parts:
2863
+ header += scoped_text + "\n\nDivergence from project conventions:\n" + "\n".join(comparison_parts)
2864
+ else:
2865
+ header += scoped_text + "\n\n(Matches project-wide conventions.)"
2866
+ return header
2867
+
2868
+ candidates = sorted(
2869
+ [fi for fi in files if fi.relative_path in ast_cache],
2870
+ key=lambda fi: fi.importance,
2871
+ reverse=True,
2872
+ )
2873
+ sample_rels = [fi.relative_path for fi in candidates[:sample_size]]
2874
+ if not sample_rels:
2875
+ return "No parsed files available for convention analysis."
2876
+
2877
+ stats = _analyze_conventions(ast_cache, sample_rels)
2878
+ dir_groups: dict[str, list[str]] = {}
2879
+ for rel in sample_rels:
2880
+ parts = rel.split("/")
2881
+ dirname = parts[0] if len(parts) > 1 else "(root)"
2882
+ dir_groups.setdefault(dirname, []).append(rel)
2883
+
2884
+ dir_stats: dict[str, dict] = {}
2885
+ for dirname, dir_rels in dir_groups.items():
2886
+ if len(dir_rels) >= 3:
2887
+ dir_stats[dirname] = _analyze_conventions(ast_cache, dir_rels)
2888
+
2889
+ header = f"Conventions detected across {len(sample_rels)} files:\n"
2890
+ return header + _format_conventions(stats, dir_stats=dir_stats)
2891
+
2892
+ async def lsp_definition(self, file: str, line: int, col: int = 0) -> str:
2893
+ lsp = self._get_lsp_manager()
2894
+ if not os.path.isabs(file):
2895
+ file = os.path.join(self._project_dir, file)
2896
+ try:
2897
+ loc = await lsp.get_definition(file, line, col)
2898
+ except Exception as e:
2899
+ return f"LSP not available: {e}"
2900
+ if loc is None:
2901
+ return f"No definition found at {file}:{line}:{col}"
2902
+ uri = loc.uri
2903
+ if uri.startswith("file://"):
2904
+ uri = uri[7:]
2905
+ try:
2906
+ uri = os.path.relpath(uri, self._project_dir)
2907
+ except ValueError:
2908
+ pass
2909
+ return f"Definition: {uri}:{loc.range.start.line + 1}:{loc.range.start.character + 1}"
2910
+
2911
+ async def lsp_references(
2912
+ self, file: str, line: int, col: int = 0, include_declaration: bool = True,
2913
+ ) -> str:
2914
+ lsp = self._get_lsp_manager()
2915
+ if not os.path.isabs(file):
2916
+ file = os.path.join(self._project_dir, file)
2917
+ try:
2918
+ locs = await lsp.get_references(file, line, col, include_declaration=include_declaration)
2919
+ except Exception as e:
2920
+ return f"LSP not available: {e}"
2921
+ if not locs:
2922
+ return f"No references found at {file}:{line}:{col}"
2923
+ lines = [f"References ({len(locs)}):"]
2924
+ for loc in locs[:100]:
2925
+ uri = loc.uri
2926
+ if uri.startswith("file://"):
2927
+ uri = uri[7:]
2928
+ try:
2929
+ uri = os.path.relpath(uri, self._project_dir)
2930
+ except ValueError:
2931
+ pass
2932
+ lines.append(f" {uri}:{loc.range.start.line + 1}:{loc.range.start.character + 1}")
2933
+ if len(locs) > 50:
2934
+ lines.append(f" ... and {len(locs) - 50} more")
2935
+ return "\n".join(lines)
2936
+
2937
+ async def lsp_hover(self, file: str, line: int, col: int = 0) -> str:
2938
+ lsp = self._get_lsp_manager()
2939
+ if not os.path.isabs(file):
2940
+ file = os.path.join(self._project_dir, file)
2941
+ try:
2942
+ info = await lsp.get_hover(file, line, col)
2943
+ except Exception as e:
2944
+ return f"LSP not available: {e}"
2945
+ if info is None:
2946
+ return f"No hover information at {file}:{line}:{col}"
2947
+ return f"Hover at {file}:{line}:{col}:\n{info}"
2948
+
2949
+ def lsp_diagnostics(self, file: str) -> str:
2950
+ lsp = self._get_lsp_manager()
2951
+ if not os.path.isabs(file):
2952
+ file = os.path.join(self._project_dir, file)
2953
+ try:
2954
+ diags = lsp.get_diagnostics(file)
2955
+ except Exception as e:
2956
+ return f"LSP not available: {e}"
2957
+ if not diags:
2958
+ return f"No diagnostics for {file}"
2959
+ lines = [f"Diagnostics ({len(diags)}):"]
2960
+ for d in diags[:30]:
2961
+ source = f" [{d.source}]" if d.source else ""
2962
+ code = f" ({d.code})" if d.code else ""
2963
+ lines.append(
2964
+ f" [{d.severity}]{source}{code} "
2965
+ f"L{d.range.start.line + 1}:{d.range.start.character + 1}: "
2966
+ f"{d.message}"
2967
+ )
2968
+ if len(diags) > 30:
2969
+ lines.append(f" ... and {len(diags) - 30} more")
2970
+ return "\n".join(lines)
2971
+
2972
+ async def lsp_completions(self, file: str, line: int, col: int = 0, limit: int = 20) -> str:
2973
+ """Get completion suggestions at position."""
2974
+ result = await self.lsp_completions_data(file, line, col, limit)
2975
+ if result["error"]:
2976
+ return f"LSP completions error: {result['error']}"
2977
+ items = result["completions"]
2978
+ if not items:
2979
+ return f"No completions at {file}:{line}:{col}"
2980
+ lines = [f"Completions at {file}:{line}:{col} ({result['total']} available, showing top {len(items)}):"]
2981
+ for c in items:
2982
+ detail = f" — {c['detail']}" if c["detail"] else ""
2983
+ docs = f"\n {c['documentation'][:80]}" if c["documentation"] else ""
2984
+ lines.append(f" [{c['kind']}] {c['label']}{detail}{docs}")
2985
+ return "\n".join(lines)
2986
+
2987
+ async def lsp_workspace_symbol(self, query: str, limit: int = 30) -> str:
2988
+ """Search workspace symbols by name across all language servers."""
2989
+ result = await self.lsp_workspace_symbol_data(query, limit)
2990
+ if result["error"]:
2991
+ return f"Workspace symbol search error: {result['error']}"
2992
+ symbols = result["symbols"]
2993
+ if not symbols:
2994
+ return f"No symbols matching '{query}'"
2995
+ lines = [f"Symbols matching '{query}' ({result['total']}):"]
2996
+ for s in symbols:
2997
+ container = f" ({s['container']})" if s["container"] else ""
2998
+ detail = f" — {s['detail']}" if s["detail"] else ""
2999
+ lines.append(f" [{s['kind']}] {s['name']}{detail}{container} at {s['file']}:{s['line']}")
3000
+ return "\n".join(lines)
3001
+
3002
+ async def lsp_incoming_calls(self, file: str, line: int, col: int = 0) -> str:
3003
+ """Find what calls the symbol at position."""
3004
+ result = await self.lsp_incoming_calls_data(file, line, col)
3005
+ if result["error"]:
3006
+ return f"Incoming calls error: {result['error']}"
3007
+ callers = result["callers"]
3008
+ if not callers:
3009
+ return f"No callers found for {result['symbol']} at {file}:{line}:{col}"
3010
+ lines = [f"Called by ({result['total']} locations):"]
3011
+ for c in callers:
3012
+ container = f"{c['container']}." if c["container"] else ""
3013
+ lines.append(f" ← {container}{c['name']} at {c['file']}:{c['line']}:{c['col']}")
3014
+ return "\n".join(lines)
3015
+
3016
+ async def lsp_outgoing_calls(self, file: str, line: int, col: int = 0) -> str:
3017
+ """Find what the symbol at position calls."""
3018
+ result = await self.lsp_outgoing_calls_data(file, line, col)
3019
+ if result["error"]:
3020
+ return f"Outgoing calls error: {result['error']}"
3021
+ callees = result["callees"]
3022
+ if not callees:
3023
+ return f"{result['symbol']} calls nothing at {file}:{line}:{col}"
3024
+ lines = [f"Calls ({result['total']} locations):"]
3025
+ for c in callees:
3026
+ container = f"{c['container']}." if c["container"] else ""
3027
+ lines.append(f" → {container}{c['name']} at {c['file']}:{c['line']}:{c['col']}")
3028
+ return "\n".join(lines)
3029
+
3030
+ def explore_codebase(
3031
+ self, path: str = "", max_items: int = 30,
3032
+ importance_threshold: float = 0.3, task_hint: str = "",
3033
+ ) -> str:
3034
+ explorer = self._get_explorer()
3035
+ task_scores = self._task_file_scores(task_hint) if task_hint else None
3036
+ result = explorer.explore(path, max_items=max_items,
3037
+ importance_threshold=importance_threshold,
3038
+ task_scores=task_scores)
3039
+ return explorer.format_result(result)
3040
+
3041
+ def security_scan(self, mode: str = "full", path: str = "") -> str:
3042
+ scanner = self._get_security_scanner()
3043
+ report = scanner.scan(mode=mode, path=path)
3044
+ return scanner.format_report(report)
3045
+
3046
+ def semantic_search(self, query: str, top_k: int = 10,
3047
+ file_filter: str = "", branch: str = "", mode: str = "auto") -> str:
3048
+ mgr = self._get_semantic_search()
3049
+
3050
+ if mode == "keyword":
3051
+ candidates = self._search_after_warmup(
3052
+ mgr, lambda: mgr.search_candidates(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
3053
+ results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
3054
+ if not results and mgr.candidate_diagnostics().get("status") == "warming":
3055
+ return "Search index warming; retry shortly. Missing results do not prove absence."
3056
+ note = "Broad query matches multiple components; narrow with file_filter or a component name.\n" if ranking["query"]["ambiguous"] else ""
3057
+ next_k = next_search_top_k(len(candidates), top_k, len(results))
3058
+ if next_k and mgr.candidate_diagnostics().get("status") == "ready":
3059
+ note += f"More retrieved candidates: rerun with top_k={next_k} (current index).\n"
3060
+ return note + mgr.format_results(results, query=query)
3061
+
3062
+ candidates = self._search_after_warmup(
3063
+ mgr, lambda: mgr.search(query, top_k=self._retrieval_depth(top_k), file_filter=file_filter))
3064
+ results, ranking = self._rank_search_results(query, candidates, top_k, file_filter)
3065
+ if not results and mgr.candidate_diagnostics().get("status") == "warming":
3066
+ return "Search index warming; retry shortly. Missing results do not prove absence."
3067
+ note = "Broad query matches multiple components; narrow with file_filter or a component name.\n" if ranking["query"]["ambiguous"] else ""
3068
+ next_k = next_search_top_k(len(candidates), top_k, len(results))
3069
+ if next_k and mgr.candidate_diagnostics().get("status") == "ready":
3070
+ note += f"More retrieved candidates: rerun with top_k={next_k} (current index).\n"
3071
+ return note + mgr.format_results(results, query=query)
3072
+
3073
+ def semantic_search_status(self) -> str:
3074
+ progress = self.indexing_status()
3075
+ lines = [
3076
+ "Semantic search status:",
3077
+ f" Provider: {progress.get('provider', '')}",
3078
+ f" Available: {progress.get('available', False)}",
3079
+ f" Status: {progress.get('status', 'idle')}",
3080
+ (
3081
+ " Coverage: "
3082
+ f"{float(progress.get('coverage', 0.0)):.0%} "
3083
+ f"({progress.get('indexed_files', 0)}/{progress.get('total_files', 0)} files)"
3084
+ ),
3085
+ f" Failed: {progress.get('failed_files', 0)}",
3086
+ f" Vector search active: {progress.get('vector_search_active', False)}",
3087
+ f" Health: {progress.get('health_status', 'unknown')}",
3088
+ ]
3089
+ if progress.get("degraded_reason"):
3090
+ lines.append(f" Degradation reason: {progress['degraded_reason']}")
3091
+ if progress.get("last_error"):
3092
+ lines.append(f" Last error: {progress['last_error']}")
3093
+ if progress.get("elapsed_seconds", 0):
3094
+ lines.append(f" Elapsed: {float(progress['elapsed_seconds']):.1f}s")
3095
+ return "\n".join(lines)
3096
+
3097
+ def start_indexing(self) -> dict:
3098
+ """Start background embedding indexing."""
3099
+ mgr = self._get_semantic_search()
3100
+ progress = mgr.start_background_indexing()
3101
+ result = {
3102
+ "provider": mgr.provider_name,
3103
+ "available": mgr.is_available,
3104
+ "status": progress.status,
3105
+ "total_files": progress.total_files,
3106
+ "indexed_files": progress.indexed_files,
3107
+ "failed_files": progress.failed_files,
3108
+ "coverage": progress.coverage,
3109
+ "elapsed_seconds": progress.elapsed_seconds,
3110
+ "vector_search_active": mgr.is_index_ready(),
3111
+ "last_error": getattr(progress, "last_error", ""),
3112
+ "degraded_reason": getattr(progress, "degraded_reason", ""),
3113
+ }
3114
+ result.update(self._health_snapshot())
3115
+ return result
3116
+
3117
+ def indexing_status(self) -> dict:
3118
+ """Get current indexing status."""
3119
+ mgr = self._get_semantic_search()
3120
+ progress = mgr.get_index_progress()
3121
+ result = {
3122
+ "provider": mgr.provider_name,
3123
+ "available": mgr.is_available,
3124
+ "status": progress.status,
3125
+ "total_files": progress.total_files,
3126
+ "indexed_files": progress.indexed_files,
3127
+ "failed_files": progress.failed_files,
3128
+ "coverage": progress.coverage,
3129
+ "elapsed_seconds": progress.elapsed_seconds,
3130
+ "vector_search_active": mgr.is_index_ready(),
3131
+ "last_error": getattr(progress, "last_error", ""),
3132
+ "degraded_reason": getattr(progress, "degraded_reason", ""),
3133
+ }
3134
+ result.update(self._health_snapshot())
3135
+ return result
3136
+
3137
+ def recall(self, query: str, scope: str = "", max_results: int = 10) -> str:
3138
+ store = self._get_memory_store()
3139
+ results = store.recall(query, scope=scope, max_results=max_results)
3140
+ if not results:
3141
+ return "No relevant learnings found for this project."
3142
+ lines = [f"## Project Learnings ({len(results)} relevant)\n"]
3143
+ for r in results:
3144
+ lines.append(f"- **[{r['type']}]** (confidence: {r['confidence']:.0%}, id: {r['id']})")
3145
+ lines.append(f" {r['description']}")
3146
+ if r["details"]:
3147
+ lines.append(f" _{r['details']}_")
3148
+ for r in results:
3149
+ try:
3150
+ store.record_applied(r["id"])
3151
+ except Exception:
3152
+ logger.debug("Failed to record_applied for learning %d", r["id"])
3153
+ return "\n".join(lines)
3154
+
3155
+ def record_learning(
3156
+ self,
3157
+ type: str, # noqa: A002
3158
+ description: str,
3159
+ details: str = "",
3160
+ scope: str = "",
3161
+ confidence: float = 0.7,
3162
+ ) -> str:
3163
+ store = self._get_memory_store()
3164
+ try:
3165
+ learning_id = store.add(
3166
+ type=type, description=description,
3167
+ details=details, scope=scope, confidence=confidence,
3168
+ )
3169
+ except ValueError as e:
3170
+ return f"Error: {e}"
3171
+ return f"Recorded learning #{learning_id}: [{type}] {description}"
3172
+
3173
+ def learning_feedback(self, learning_id: int, helpful: bool) -> str:
3174
+ store = self._get_memory_store()
3175
+ store.record_feedback(learning_id, helpful)
3176
+ action = "boosted" if helpful else "reduced"
3177
+ return f"Feedback recorded — confidence {action} for learning #{learning_id}."
3178
+
3179
+ def list_learnings(self, status: str = "active", type: str = "", scope: str = "") -> str: # noqa: A002
3180
+ store = self._get_memory_store()
3181
+ results = store.list_all(status=status, type=type or None)
3182
+ if scope:
3183
+ results = [r for r in results if r["scope"].startswith(scope) or r["scope"] == ""]
3184
+ if not results:
3185
+ return "No learnings found matching the filters."
3186
+ lines = [f"## Learnings ({len(results)} total)\n"]
3187
+ lines.append("| ID | Type | Description | Confidence | Applied | Scope |")
3188
+ lines.append("|---|---|---|---|---|---|")
3189
+ for r in results:
3190
+ desc = r["description"][:60] + ("..." if len(r["description"]) > 60 else "")
3191
+ lines.append(
3192
+ f"| {r['id']} | {r['type']} | {desc} "
3193
+ f"| {r['confidence']:.0%} | {r['apply_count']}x "
3194
+ f"| {r['scope'] or '(global)'} |"
3195
+ )
3196
+ return "\n".join(lines)
3197
+
3198
+ def dead_code(
3199
+ self,
3200
+ scope: str = "",
3201
+ entry_points: list[str] | None = None,
3202
+ level: str = "symbol",
3203
+ min_confidence: float = 0.5,
3204
+ top_n: int = 30,
3205
+ ) -> str:
3206
+ from attocode_intel.tools.dead_code_tools import dead_code as _dead_code
3207
+
3208
+ return _dead_code(
3209
+ scope=scope,
3210
+ entry_points=entry_points,
3211
+ level=level,
3212
+ min_confidence=min_confidence,
3213
+ top_n=top_n,
3214
+ )
3215
+
3216
+ def distill(
3217
+ self,
3218
+ files: list[str] | None = None,
3219
+ depth: int = 1,
3220
+ level: str = "signatures",
3221
+ max_tokens: int = 4000,
3222
+ ) -> str:
3223
+ from attocode_intel.tools.distill_tools import distill as _distill
3224
+
3225
+ return _distill(files=files, depth=depth, level=level, max_tokens=max_tokens)
3226
+
3227
+ def change_coupling(self, file: str, *, days: int = 90, min_coupling: float = 0.3, top_k: int = 20) -> str:
3228
+ data = self.change_coupling_data(file=file, days=days, min_coupling=min_coupling, top_k=top_k)
3229
+ related = data.get("related", [])
3230
+ if not related:
3231
+ return f"No coupled files found for {file}."
3232
+ lines = [f"Files frequently changing with {file} ({len(related)} results):"]
3233
+ for item in related:
3234
+ lines.append(
3235
+ f" {item['path']} coupling={item['coupling']:.2f} "
3236
+ f"co_changes={item['co_changes']}"
3237
+ )
3238
+ return "\n".join(lines)
3239
+
3240
+ def churn_hotspots(self, *, days: int = 90, top_n: int = 20) -> str:
3241
+ data = self.churn_hotspots_data(days=days, top_n=top_n)
3242
+ files = data.get("files", [])
3243
+ if not files:
3244
+ return "No churn hotspots found."
3245
+ lines = [f"Top churn hotspots over the last {days} day(s):"]
3246
+ for item in files:
3247
+ lines.append(
3248
+ f" {item['path']} commits={item['commit_count']} "
3249
+ f"lines_changed={item['line_churn']}"
3250
+ )
3251
+ return "\n".join(lines)
3252
+
3253
+ def merge_risk(self, files: list[str], *, days: int = 90) -> str:
3254
+ data = self.merge_risk_data(files=files, days=days)
3255
+ preds = data.get("predictions", [])
3256
+ if not preds:
3257
+ return f"No additional merge-risk files predicted for {', '.join(files)}."
3258
+ lines = [f"Predicted merge-risk files for {', '.join(files)}:"]
3259
+ for item in preds:
3260
+ source = item.get("source") or item.get("reason", "unknown")
3261
+ lines.append(
3262
+ f" {item['path']} confidence={item['confidence']:.2f} "
3263
+ f"source={source}"
3264
+ )
3265
+ return "\n".join(lines)
3266
+
3267
+ def repo_map_ranked(self, task_context: str = "", token_budget: int = 1024, exclude_tests: bool = True) -> str:
3268
+ from attocode_intel.tools.analysis_tools import repo_map_ranked as _repo_map_ranked
3269
+
3270
+ return _repo_map_ranked(
3271
+ task_context=task_context,
3272
+ token_budget=token_budget,
3273
+ exclude_tests=exclude_tests,
3274
+ )
3275
+
3276
+ def bug_scan(self, base_branch: str = "main", min_confidence: float = 0.5) -> str:
3277
+ from attocode_intel.tools.analysis_tools import bug_scan as _bug_scan
3278
+
3279
+ return _bug_scan(base_branch=base_branch, min_confidence=min_confidence)
3280
+
3281
+ def fast_search(
3282
+ self,
3283
+ pattern: str,
3284
+ path: str = "",
3285
+ max_results: int = 50,
3286
+ case_insensitive: bool = False,
3287
+ selectivity_threshold: float = 0.10,
3288
+ explain: bool = False,
3289
+ ) -> str:
3290
+ from attocode_intel.tools.search_tools import fast_search as _fast_search
3291
+
3292
+ return _fast_search(
3293
+ pattern=pattern,
3294
+ path=path,
3295
+ max_results=max_results,
3296
+ case_insensitive=case_insensitive,
3297
+ selectivity_threshold=selectivity_threshold,
3298
+ explain=explain,
3299
+ )
3300
+
3301
+ async def lsp_enrich(self, files: list[str]) -> str:
3302
+ from attocode_intel.tools.lsp_tools import lsp_enrich as _lsp_enrich
3303
+
3304
+ return await _lsp_enrich(files)
3305
+
3306
+ def record_adr(
3307
+ self,
3308
+ title: str,
3309
+ context: str,
3310
+ decision: str,
3311
+ consequences: str = "",
3312
+ related_files: list[str] | None = None,
3313
+ tags: list[str] | None = None,
3314
+ ) -> str:
3315
+ from attocode_intel.tools.adr_tools import record_adr as _record_adr
3316
+
3317
+ return _record_adr(
3318
+ title=title,
3319
+ context=context,
3320
+ decision=decision,
3321
+ consequences=consequences,
3322
+ related_files=related_files,
3323
+ tags=tags,
3324
+ )
3325
+
3326
+ def list_adrs(self, status: str = "", tag: str = "", search: str = "") -> str:
3327
+ from attocode_intel.tools.adr_tools import list_adrs as _list_adrs
3328
+
3329
+ return _list_adrs(status=status, tag=tag, search=search)
3330
+
3331
+ def get_adr(self, number: int) -> str:
3332
+ from attocode_intel.tools.adr_tools import get_adr as _get_adr
3333
+
3334
+ return _get_adr(number)
3335
+
3336
+ def update_adr_status(self, number: int, status: str, superseded_by: int | None = None) -> str:
3337
+ from attocode_intel.tools.adr_tools import update_adr_status as _update_adr_status
3338
+
3339
+ return _update_adr_status(number=number, status=status, superseded_by=superseded_by)
3340
+
3341
+ def notify_file_changed(self, files: list[str]) -> str:
3342
+ if not files:
3343
+ return "No files specified."
3344
+ svc = self._get_ast_service()
3345
+ updated = 0
3346
+ for f in files:
3347
+ try:
3348
+ p = Path(f)
3349
+ rel = os.path.relpath(str(p), self._project_dir) if p.is_absolute() else str(p)
3350
+ rel = os.path.normpath(rel)
3351
+ if rel.startswith(".."):
3352
+ continue
3353
+ svc.notify_file_changed(rel)
3354
+ try:
3355
+ smgr = self._get_semantic_search()
3356
+ abs_path = os.path.join(self._project_dir, rel)
3357
+ smgr.invalidate_file(abs_path)
3358
+ except Exception:
3359
+ pass
3360
+ updated += 1
3361
+ except Exception as exc:
3362
+ logger.debug("notify_file_changed: error for %s: %s", f, exc)
3363
+ return f"Updated {updated} file(s). AST index refreshed."
3364
+
3365
+ # ------------------------------------------------------------------
3366
+ # Rule-based analysis (PerfInsights-inspired)
3367
+ # ------------------------------------------------------------------
3368
+
3369
+ def analyze(
3370
+ self,
3371
+ files: list[str] | None = None,
3372
+ path: str = "",
3373
+ language: str = "",
3374
+ category: str = "",
3375
+ severity: str = "",
3376
+ pack: str = "",
3377
+ min_confidence: float = 0.5,
3378
+ max_findings: int = 50,
3379
+ ) -> str:
3380
+ from attocode_intel.tools.rule_tools import _analyze_impl
3381
+
3382
+ return _analyze_impl(
3383
+ files=files, path=path, language=language, category=category,
3384
+ severity=severity, pack=pack, min_confidence=min_confidence,
3385
+ max_findings=max_findings, project_dir=self._project_dir,
3386
+ )
3387
+
3388
+ def list_rules(
3389
+ self,
3390
+ language: str = "",
3391
+ category: str = "",
3392
+ severity: str = "",
3393
+ pack: str = "",
3394
+ verbose: bool = False,
3395
+ ) -> str:
3396
+ from attocode_intel.tools.rule_tools import list_rules as _list_rules
3397
+
3398
+ return _list_rules(
3399
+ language=language, category=category, severity=severity,
3400
+ pack=pack, verbose=verbose,
3401
+ )
3402
+
3403
+ def list_packs(self) -> str:
3404
+ from attocode_intel.tools.rule_tools import list_packs as _list_packs
3405
+
3406
+ return _list_packs()
3407
+
3408
+ def register_rule(self, yaml_content: str) -> str:
3409
+ from attocode_intel.tools.rule_tools import register_rule as _register_rule
3410
+
3411
+ return _register_rule(yaml_content=yaml_content)
3412
+
3413
+ def install_pack(self, name: str) -> str:
3414
+ from attocode_intel.tools.rule_tools import install_pack as _install_pack
3415
+
3416
+ return _install_pack(name=name)