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,313 @@
1
+ """Webhook endpoints for GitHub/GitLab/Bitbucket push events (service mode only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import logging
9
+ import uuid
10
+ from typing import TYPE_CHECKING
11
+
12
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
13
+ from pydantic import BaseModel
14
+ from sqlalchemy import select
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from attocode_intel.api.deps import get_db_session
18
+ from attocode_intel.db.models import Repository, WebhookConfig
19
+
20
+ if TYPE_CHECKING:
21
+ from cryptography.fernet import Fernet
22
+
23
+ router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _get_fernet() -> Fernet:
28
+ """Get Fernet instance using SECRET_KEY from config."""
29
+ from attocode_intel.crypto import _get_fernet as _shared_get_fernet
30
+
31
+ return _shared_get_fernet()
32
+
33
+
34
+ def _encrypt_secret(secret: str) -> str:
35
+ """Encrypt a webhook secret for storage."""
36
+ return _get_fernet().encrypt(secret.encode()).decode()
37
+
38
+
39
+ def _decrypt_secret(encrypted: str) -> str:
40
+ """Decrypt a stored webhook secret."""
41
+ return _get_fernet().decrypt(encrypted.encode()).decode()
42
+
43
+
44
+ class WebhookConfigRequest(BaseModel):
45
+ repo_id: str
46
+ provider: str # github|gitlab|bitbucket
47
+ secret: str
48
+ events: list[str] = ["push"]
49
+
50
+
51
+ class WebhookConfigResponse(BaseModel):
52
+ id: str
53
+ repo_id: str
54
+ provider: str
55
+ events: list[str]
56
+ active: bool
57
+
58
+
59
+ @router.post("/config", response_model=WebhookConfigResponse)
60
+ async def create_webhook_config(
61
+ req: WebhookConfigRequest,
62
+ session: AsyncSession = Depends(get_db_session),
63
+ ) -> WebhookConfigResponse:
64
+ """Create a webhook configuration for a repository."""
65
+ repo_result = await session.execute(
66
+ select(Repository).where(Repository.id == uuid.UUID(req.repo_id))
67
+ )
68
+ repo = repo_result.scalar_one_or_none()
69
+ if repo is None:
70
+ raise HTTPException(status_code=404, detail="Repository not found")
71
+
72
+ secret_hash = hashlib.sha256(req.secret.encode()).hexdigest()
73
+ secret_encrypted = _encrypt_secret(req.secret)
74
+ config = WebhookConfig(
75
+ repo_id=repo.id,
76
+ provider=req.provider,
77
+ secret_hash=secret_hash,
78
+ secret_encrypted=secret_encrypted,
79
+ events=req.events,
80
+ )
81
+ session.add(config)
82
+ await session.commit()
83
+ await session.refresh(config)
84
+
85
+ return WebhookConfigResponse(
86
+ id=str(config.id),
87
+ repo_id=str(config.repo_id),
88
+ provider=config.provider,
89
+ events=list(config.events),
90
+ active=config.active,
91
+ )
92
+
93
+
94
+ @router.post("/github")
95
+ async def github_webhook(
96
+ request: Request,
97
+ x_hub_signature_256: str | None = Header(None),
98
+ x_github_event: str | None = Header(None),
99
+ session: AsyncSession = Depends(get_db_session),
100
+ ) -> dict:
101
+ """Handle GitHub webhook events."""
102
+ body = await request.body()
103
+
104
+ # Parse payload
105
+ try:
106
+ payload = json.loads(body)
107
+ except json.JSONDecodeError:
108
+ raise HTTPException(status_code=400, detail="Invalid JSON payload")
109
+
110
+ # Identify repository
111
+ repo_url = payload.get("repository", {}).get("clone_url", "")
112
+ if not repo_url:
113
+ repo_url = payload.get("repository", {}).get("html_url", "")
114
+
115
+ if not repo_url:
116
+ raise HTTPException(status_code=400, detail="Cannot identify repository from payload")
117
+
118
+ # Find matching webhook config
119
+ result = await session.execute(
120
+ select(WebhookConfig).where(
121
+ WebhookConfig.provider == "github",
122
+ WebhookConfig.active == True, # noqa: E712
123
+ )
124
+ )
125
+ webhook = None
126
+ for wh in result.scalars():
127
+ # Verify the repo matches
128
+ repo_result = await session.execute(
129
+ select(Repository).where(Repository.id == wh.repo_id)
130
+ )
131
+ repo = repo_result.scalar_one_or_none()
132
+ if repo and repo.clone_url and repo_url in (repo.clone_url, repo.clone_url.rstrip(".git")):
133
+ webhook = wh
134
+ break
135
+
136
+ if webhook is None:
137
+ raise HTTPException(status_code=404, detail="No matching webhook config found")
138
+
139
+ # Verify signature using decrypted secret
140
+ if x_hub_signature_256:
141
+ if webhook.secret_encrypted:
142
+ secret = _decrypt_secret(webhook.secret_encrypted)
143
+ _verify_github_signature(body, x_hub_signature_256, secret)
144
+ else:
145
+ # Legacy fallback: no encrypted secret stored yet (pre-migration data)
146
+ logger.warning("Webhook %s has no encrypted secret; signature verification skipped", webhook.id)
147
+
148
+ # Log webhook received
149
+ from attocode_intel.audit import log_event
150
+
151
+ repo_result2 = await session.execute(
152
+ select(Repository).where(Repository.id == webhook.repo_id)
153
+ )
154
+ wh_repo = repo_result2.scalar_one_or_none()
155
+ if wh_repo:
156
+ await log_event(session, wh_repo.org_id, "webhook.received", repo_id=webhook.repo_id, detail={"provider": "github", "event": x_github_event or "push"})
157
+ await session.commit()
158
+
159
+ # Handle events
160
+ event = x_github_event or "push"
161
+ if event == "push":
162
+ ref = payload.get("ref", "")
163
+ branch = ref.removeprefix("refs/heads/")
164
+ before = payload.get("before", "")
165
+ after = payload.get("after", "")
166
+
167
+ logger.info("GitHub push: %s %s → %s", branch, before[:8], after[:8])
168
+
169
+ # Enqueue delta index job
170
+ try:
171
+ from arq import create_pool
172
+
173
+ from attocode_intel.workers.settings import get_redis_settings
174
+
175
+ pool = await create_pool(get_redis_settings())
176
+ await pool.enqueue_job(
177
+ "index_branch_delta",
178
+ str(webhook.repo_id),
179
+ branch,
180
+ before,
181
+ after,
182
+ )
183
+ await pool.aclose()
184
+ return {"status": "queued", "branch": branch, "job": "index_branch_delta"}
185
+ except Exception as e:
186
+ logger.warning("Failed to enqueue job: %s", e)
187
+ return {"status": "received", "branch": branch, "note": "Job queuing unavailable"}
188
+
189
+ elif event == "pull_request":
190
+ action = payload.get("action", "")
191
+ pr = payload.get("pull_request", {})
192
+ merged = pr.get("merged", False)
193
+
194
+ if action == "closed" and merged:
195
+ # Auto-trigger merge + delete source branch in DB
196
+ base_branch = pr.get("base", {}).get("ref", "")
197
+ head_branch = pr.get("head", {}).get("ref", "")
198
+
199
+ if base_branch and head_branch:
200
+ logger.info("GitHub PR merged: %s → %s", head_branch, base_branch)
201
+ try:
202
+ from attocode_intel.db.models import Branch
203
+ from attocode_intel.storage.branch_overlay import BranchOverlay
204
+
205
+ repo_result3 = await session.execute(
206
+ select(Repository).where(Repository.id == webhook.repo_id)
207
+ )
208
+ merge_repo = repo_result3.scalar_one_or_none()
209
+ if merge_repo:
210
+ src_result = await session.execute(
211
+ select(Branch).where(
212
+ Branch.repo_id == merge_repo.id, Branch.name == head_branch
213
+ )
214
+ )
215
+ tgt_result = await session.execute(
216
+ select(Branch).where(
217
+ Branch.repo_id == merge_repo.id, Branch.name == base_branch
218
+ )
219
+ )
220
+ src_branch = src_result.scalar_one_or_none()
221
+ tgt_branch = tgt_result.scalar_one_or_none()
222
+
223
+ if src_branch and tgt_branch:
224
+ overlay = BranchOverlay(session)
225
+ await overlay.merge_branch(
226
+ src_branch.id, tgt_branch.id, delete_source=True,
227
+ )
228
+ await session.commit()
229
+ return {
230
+ "status": "merged",
231
+ "source": head_branch,
232
+ "target": base_branch,
233
+ }
234
+ except Exception as e:
235
+ logger.warning("Auto-merge failed for PR: %s", e)
236
+
237
+ return {"status": "received", "event": "pull_request", "action": action}
238
+
239
+ return {"status": "ignored", "event": event}
240
+
241
+
242
+ @router.post("/gitlab")
243
+ async def gitlab_webhook(
244
+ request: Request,
245
+ x_gitlab_token: str | None = Header(None),
246
+ session: AsyncSession = Depends(get_db_session),
247
+ ) -> dict:
248
+ """Handle GitLab webhook events."""
249
+ body = await request.body()
250
+ payload = json.loads(body)
251
+
252
+ # GitLab uses a shared secret token
253
+ if not x_gitlab_token:
254
+ raise HTTPException(status_code=401, detail="X-Gitlab-Token header required")
255
+
256
+ token_hash = hashlib.sha256(x_gitlab_token.encode()).hexdigest()
257
+ result = await session.execute(
258
+ select(WebhookConfig).where(
259
+ WebhookConfig.provider == "gitlab",
260
+ WebhookConfig.secret_hash == token_hash,
261
+ WebhookConfig.active == True, # noqa: E712
262
+ )
263
+ )
264
+ webhook = result.scalar_one_or_none()
265
+ if webhook is None:
266
+ raise HTTPException(status_code=401, detail="Invalid token")
267
+
268
+ event_type = payload.get("object_kind", "push")
269
+ if event_type == "push":
270
+ ref = payload.get("ref", "")
271
+ branch = ref.removeprefix("refs/heads/")
272
+ before = payload.get("before", "")
273
+ after = payload.get("after", "")
274
+
275
+ logger.info("GitLab push: %s %s → %s", branch, before[:8], after[:8])
276
+ return {"status": "received", "branch": branch}
277
+
278
+ return {"status": "ignored", "event": event_type}
279
+
280
+
281
+ @router.post("/bitbucket")
282
+ async def bitbucket_webhook(
283
+ request: Request,
284
+ session: AsyncSession = Depends(get_db_session),
285
+ ) -> dict:
286
+ """Handle Bitbucket webhook events (Cloud)."""
287
+ body = await request.body()
288
+ payload = json.loads(body)
289
+
290
+ # Bitbucket Cloud uses a shared UUID key in the URL or body
291
+ event = request.headers.get("x-event-key", "repo:push")
292
+
293
+ if event == "repo:push":
294
+ changes = payload.get("push", {}).get("changes", [])
295
+ for change in changes:
296
+ new = change.get("new", {})
297
+ branch = new.get("name", "")
298
+ commit = new.get("target", {}).get("hash", "")
299
+ logger.info("Bitbucket push: %s → %s", branch, commit[:8])
300
+
301
+ return {"status": "received", "changes": len(changes)}
302
+
303
+ return {"status": "ignored", "event": event}
304
+
305
+
306
+ def _verify_github_signature(body: bytes, signature_header: str, secret: str) -> None:
307
+ """Verify GitHub webhook HMAC-SHA256 signature."""
308
+ if not signature_header.startswith("sha256="):
309
+ raise HTTPException(status_code=401, detail="Invalid signature format")
310
+
311
+ expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
312
+ if not hmac.compare_digest(f"sha256={expected}", signature_header):
313
+ raise HTTPException(status_code=401, detail="Invalid signature")
@@ -0,0 +1,116 @@
1
+ """WebSocket endpoints for real-time event streaming + presence (service mode only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import uuid
8
+
9
+ from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
10
+
11
+ router = APIRouter(tags=["websocket"])
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @router.websocket("/ws/repos/{repo_id}/events")
16
+ async def repo_events(
17
+ websocket: WebSocket,
18
+ repo_id: uuid.UUID,
19
+ token: str = Query(""),
20
+ last_event_id: str = Query("$", alias="last_event_id"),
21
+ ) -> None:
22
+ """Stream real-time events for a repository with bidirectional presence support.
23
+
24
+ Authenticate via query parameter `token` (JWT).
25
+ Pass `last_event_id` to replay missed events (e.g. after reconnect).
26
+ Use "$" for new events only, or "0" to replay all available events.
27
+
28
+ Outgoing events: index.started, index.progress, index.completed, index.failed,
29
+ presence.joined, presence.left, presence.file_changed.
30
+
31
+ Incoming messages:
32
+ {"type": "presence.update", "file": "src/main.py"}
33
+ {"type": "presence.heartbeat"}
34
+ """
35
+ # Validate auth
36
+ user_id = ""
37
+ user_name = ""
38
+ if token:
39
+ from attocode_intel.api.auth.jwt import decode_token
40
+
41
+ payload = decode_token(token)
42
+ if payload is None:
43
+ await websocket.close(code=4001, reason="Invalid token")
44
+ return
45
+ user_id = payload.get("sub", "")
46
+ user_name = payload.get("name", user_id)
47
+ else:
48
+ # Allow unauthenticated in development
49
+ from attocode_intel.api.deps import get_config
50
+
51
+ config = get_config()
52
+ if config.api_key or config.is_service_mode:
53
+ await websocket.close(code=4001, reason="Token required")
54
+ return
55
+
56
+ await websocket.accept()
57
+ session_id = str(uuid.uuid4())
58
+ repo_id_str = str(repo_id)
59
+ logger.info("WebSocket connected for repo %s (user=%s)", repo_id, user_id)
60
+
61
+ async def _pubsub_reader() -> None:
62
+ """Read events from Redis pubsub and forward to WebSocket."""
63
+ from attocode_intel.pubsub import subscribe
64
+
65
+ async for event in subscribe(repo_id_str, last_event_id=last_event_id):
66
+ await websocket.send_json(event)
67
+
68
+ async def _ws_reader() -> None:
69
+ """Read incoming messages from WebSocket for presence updates."""
70
+ while True:
71
+ try:
72
+ data = await websocket.receive_json()
73
+ except (WebSocketDisconnect, RuntimeError):
74
+ break
75
+
76
+ msg_type = data.get("type", "")
77
+ if msg_type == "presence.update":
78
+ file_path = data.get("file", "")
79
+ if user_id:
80
+ from attocode_intel.presence import set_presence
81
+
82
+ await set_presence(
83
+ repo_id_str, user_id, user_name, file_path, session_id,
84
+ )
85
+ elif msg_type == "presence.heartbeat":
86
+ if user_id:
87
+ from attocode_intel.presence import heartbeat
88
+
89
+ await heartbeat(repo_id_str, user_id, session_id)
90
+
91
+ try:
92
+ # Run both tasks concurrently
93
+ pubsub_task = asyncio.create_task(_pubsub_reader())
94
+ ws_task = asyncio.create_task(_ws_reader())
95
+ done, pending = await asyncio.wait(
96
+ [pubsub_task, ws_task], return_when=asyncio.FIRST_COMPLETED,
97
+ )
98
+ for task in pending:
99
+ task.cancel()
100
+ except WebSocketDisconnect:
101
+ logger.info("WebSocket disconnected for repo %s", repo_id)
102
+ except Exception as e:
103
+ logger.warning("WebSocket error for repo %s: %s", repo_id, e)
104
+ try:
105
+ await websocket.close(code=1011, reason="Internal error")
106
+ except Exception:
107
+ pass
108
+ finally:
109
+ # Clean up presence on disconnect
110
+ if user_id:
111
+ try:
112
+ from attocode_intel.presence import remove_presence
113
+
114
+ await remove_presence(repo_id_str, user_id, session_id)
115
+ except Exception:
116
+ logger.debug("Failed to remove presence on disconnect", exc_info=True)
@@ -0,0 +1,239 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Code Intelligence — Dependency Graph</title>
7
+ <script src="https://d3js.org/d3.v7.min.js"></script>
8
+ <style>
9
+ *{margin:0;padding:0;box-sizing:border-box}
10
+ body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;overflow:hidden}
11
+ #controls{position:fixed;top:12px;left:12px;z-index:10;display:flex;gap:8px;align-items:center;
12
+ background:#161b22;border:1px solid #30363d;border-radius:8px;padding:8px 12px;font-size:13px}
13
+ #controls label{color:#8b949e}
14
+ #controls input[type=range]{width:80px}
15
+ #controls input[type=text]{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;
16
+ border-radius:4px;padding:2px 8px;width:160px;font-size:13px}
17
+ #controls button{background:#238636;color:#fff;border:none;border-radius:4px;padding:4px 10px;
18
+ cursor:pointer;font-size:12px}
19
+ #controls button:hover{background:#2ea043}
20
+ #controls button.secondary{background:#30363d}
21
+ #controls button.secondary:hover{background:#484f58}
22
+ #info{position:fixed;bottom:12px;left:12px;z-index:10;background:#161b22;border:1px solid #30363d;
23
+ border-radius:8px;padding:8px 12px;font-size:12px;color:#8b949e;max-width:300px}
24
+ #tooltip{position:fixed;pointer-events:none;z-index:20;background:#1c2128;border:1px solid #30363d;
25
+ border-radius:6px;padding:8px 12px;font-size:12px;display:none;color:#c9d1d9;
26
+ box-shadow:0 4px 12px rgba(0,0,0,.4)}
27
+ #tooltip .tt-path{font-weight:bold;margin-bottom:4px}
28
+ #tooltip .tt-line{color:#8b949e}
29
+ svg{display:block;width:100vw;height:100vh}
30
+ .link{stroke-opacity:.4}
31
+ .link:hover{stroke-opacity:.8}
32
+ .node-circle{stroke-width:1.5;cursor:pointer}
33
+ .node-label{font-size:10px;fill:#8b949e;pointer-events:none;text-anchor:middle}
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div id="controls">
38
+ <label>Depth</label>
39
+ <input type="range" id="depthSlider" min="1" max="6" value="3">
40
+ <span id="depthVal">3</span>
41
+ <label>Max</label>
42
+ <input type="range" id="maxSlider" min="20" max="300" value="100" step="10">
43
+ <span id="maxVal">100</span>
44
+ <input type="text" id="searchBox" placeholder="Search files...">
45
+ <button id="goBtn">Reload</button>
46
+ <button id="resetBtn" class="secondary">Reset</button>
47
+ </div>
48
+ <div id="info">Loading graph data...</div>
49
+ <div id="tooltip">
50
+ <div class="tt-path"></div>
51
+ <div class="tt-line tt-lang"></div>
52
+ <div class="tt-line tt-lines"></div>
53
+ <div class="tt-line tt-imp"></div>
54
+ <div class="tt-line tt-comm"></div>
55
+ </div>
56
+ <svg></svg>
57
+
58
+ <script>
59
+ const LANG_COLORS = {
60
+ python:'#3572A5',typescript:'#3178c6',javascript:'#f1e05a',go:'#00ADD8',
61
+ rust:'#dea584',java:'#b07219',c:'#555555',cpp:'#f34b7d',ruby:'#701516',
62
+ swift:'#F05138',kotlin:'#A97BFF',html:'#e34c26',css:'#563d7c',
63
+ shell:'#89e051',yaml:'#cb171e',json:'#292929',markdown:'#083fa1',
64
+ };
65
+ const COMMUNITY_COLORS = d3.schemeTableau10;
66
+
67
+ let projectId = 'default';
68
+ let currentRoot = '';
69
+ let simulation, svg, g, linkG, nodeG, labelG;
70
+ let width = window.innerWidth, height = window.innerHeight;
71
+
72
+ function init() {
73
+ svg = d3.select('svg').attr('width', width).attr('height', height);
74
+
75
+ svg.append('defs').append('marker')
76
+ .attr('id','arrow').attr('viewBox','0 -4 8 8').attr('refX',18)
77
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
78
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#484f58');
79
+
80
+ g = svg.append('g');
81
+ linkG = g.append('g').attr('class','links');
82
+ nodeG = g.append('g').attr('class','nodes');
83
+ labelG = g.append('g').attr('class','labels');
84
+
85
+ const zoom = d3.zoom().scaleExtent([0.1, 8])
86
+ .on('zoom', e => g.attr('transform', e.transform));
87
+ svg.call(zoom);
88
+
89
+ const depthSlider = document.getElementById('depthSlider');
90
+ const maxSlider = document.getElementById('maxSlider');
91
+ const depthVal = document.getElementById('depthVal');
92
+ const maxVal = document.getElementById('maxVal');
93
+ depthSlider.oninput = () => { depthVal.textContent = depthSlider.value; };
94
+ maxSlider.oninput = () => { maxVal.textContent = maxSlider.value; };
95
+ document.getElementById('goBtn').onclick = () => fetchAndRender();
96
+ document.getElementById('resetBtn').onclick = () => {
97
+ currentRoot = '';
98
+ document.getElementById('searchBox').value = '';
99
+ fetchAndRender();
100
+ };
101
+ document.getElementById('searchBox').addEventListener('keydown', e => {
102
+ if (e.key === 'Enter') fetchAndRender();
103
+ });
104
+
105
+ fetchAndRender();
106
+ }
107
+
108
+ async function fetchAndRender() {
109
+ const depth = document.getElementById('depthSlider').value;
110
+ const maxNodes = document.getElementById('maxSlider').value;
111
+ const search = document.getElementById('searchBox').value.trim();
112
+
113
+ let root = currentRoot;
114
+ if (search) root = search;
115
+
116
+ const params = new URLSearchParams({ depth, max_nodes: maxNodes });
117
+ if (root) params.set('root', root);
118
+
119
+ const info = document.getElementById('info');
120
+ info.textContent = 'Loading...';
121
+
122
+ try {
123
+ const resp = await fetch('/api/v2/projects/' + encodeURIComponent(projectId) + '/graph-viz?' + params);
124
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
125
+ const data = await resp.json();
126
+ info.textContent = data.nodes.length + ' nodes, ' + data.links.length + ' edges' +
127
+ (data.communities.length ? ', ' + data.communities.length + ' communities' : '');
128
+ render(data);
129
+ } catch (err) {
130
+ info.textContent = 'Error: ' + err.message;
131
+ }
132
+ }
133
+
134
+ function render(data) {
135
+ const { nodes, links, communities } = data;
136
+ if (!nodes.length) return;
137
+
138
+ const commIds = [...new Set(nodes.map(n => n.group))].filter(g => g >= 0).sort();
139
+ const commColor = id => id < 0 ? '#484f58' : COMMUNITY_COLORS[commIds.indexOf(id) % COMMUNITY_COLORS.length];
140
+
141
+ const maxImp = d3.max(nodes, d => d.importance) || 1;
142
+ const r = d => 4 + 12 * (d.importance / maxImp);
143
+
144
+ const color = d => {
145
+ if (d.language && LANG_COLORS[d.language]) return LANG_COLORS[d.language];
146
+ return commColor(d.group);
147
+ };
148
+
149
+ if (simulation) simulation.stop();
150
+
151
+ linkG.selectAll('*').remove();
152
+ const linkSel = linkG.selectAll('line').data(links).enter().append('line')
153
+ .attr('class', 'link')
154
+ .attr('stroke', '#30363d')
155
+ .attr('stroke-width', 1)
156
+ .attr('marker-end', 'url(#arrow)');
157
+
158
+ nodeG.selectAll('*').remove();
159
+ const nodeSel = nodeG.selectAll('circle').data(nodes, d => d.id).enter().append('circle')
160
+ .attr('class', 'node-circle')
161
+ .attr('r', r)
162
+ .attr('fill', color)
163
+ .attr('stroke', d => d3.color(color(d)).brighter(0.8))
164
+ .call(drag());
165
+
166
+ labelG.selectAll('*').remove();
167
+ const labelSel = labelG.selectAll('text').data(nodes, d => d.id).enter().append('text')
168
+ .attr('class', 'node-label')
169
+ .attr('dy', d => r(d) + 12)
170
+ .text(d => {
171
+ const parts = d.id.split('/');
172
+ return parts[parts.length - 1];
173
+ });
174
+
175
+ // Tooltip via textContent (safe DOM updates)
176
+ const tooltip = document.getElementById('tooltip');
177
+ const ttPath = tooltip.querySelector('.tt-path');
178
+ const ttLang = tooltip.querySelector('.tt-lang');
179
+ const ttLines = tooltip.querySelector('.tt-lines');
180
+ const ttImp = tooltip.querySelector('.tt-imp');
181
+ const ttComm = tooltip.querySelector('.tt-comm');
182
+
183
+ nodeSel
184
+ .on('mouseover', (e, d) => {
185
+ tooltip.style.display = 'block';
186
+ ttPath.textContent = d.id;
187
+ ttLang.textContent = 'Language: ' + (d.language || '(unknown)');
188
+ ttLines.textContent = 'Lines: ' + d.lines;
189
+ ttImp.textContent = 'Importance: ' + d.importance;
190
+ ttComm.textContent = 'Community: ' + (d.group >= 0 ? d.group : 'none');
191
+ })
192
+ .on('mousemove', e => {
193
+ tooltip.style.left = (e.clientX + 12) + 'px';
194
+ tooltip.style.top = (e.clientY - 10) + 'px';
195
+ })
196
+ .on('mouseout', () => { tooltip.style.display = 'none'; })
197
+ .on('click', (e, d) => {
198
+ currentRoot = d.id;
199
+ document.getElementById('searchBox').value = d.id;
200
+ fetchAndRender();
201
+ });
202
+
203
+ simulation = d3.forceSimulation(nodes)
204
+ .force('link', d3.forceLink(links).id(d => d.id).distance(60))
205
+ .force('charge', d3.forceManyBody().strength(-120))
206
+ .force('center', d3.forceCenter(width / 2, height / 2))
207
+ .force('collision', d3.forceCollide().radius(d => r(d) + 2))
208
+ .on('tick', () => {
209
+ linkSel
210
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
211
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
212
+ nodeSel.attr('cx', d => d.x).attr('cy', d => d.y);
213
+ labelSel.attr('x', d => d.x).attr('y', d => d.y);
214
+ });
215
+ }
216
+
217
+ function drag() {
218
+ return d3.drag()
219
+ .on('start', (e, d) => {
220
+ if (!e.active) simulation.alphaTarget(0.3).restart();
221
+ d.fx = d.x; d.fy = d.y;
222
+ })
223
+ .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
224
+ .on('end', (e, d) => {
225
+ if (!e.active) simulation.alphaTarget(0);
226
+ d.fx = null; d.fy = null;
227
+ });
228
+ }
229
+
230
+ window.addEventListener('resize', () => {
231
+ width = window.innerWidth; height = window.innerHeight;
232
+ svg.attr('width', width).attr('height', height);
233
+ if (simulation) simulation.force('center', d3.forceCenter(width / 2, height / 2)).alpha(0.3).restart();
234
+ });
235
+
236
+ init();
237
+ </script>
238
+ </body>
239
+ </html>