@huicai/cli 2026.804.0 → 2026.1019.0

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 (486) hide show
  1. package/LICENSE +391 -32
  2. package/README.md +176 -10
  3. package/bin/huicai.js +166 -73
  4. package/package.json +27 -8
  5. package/skills/ab-testing/trigger-cases.json +30 -0
  6. package/skills/academic-writing/trigger-cases.json +30 -0
  7. package/skills/accounting-ops/trigger-cases.json +30 -0
  8. package/skills/ai-agent/trigger-cases.json +30 -0
  9. package/skills/ai-aiops/trigger-cases.json +30 -0
  10. package/skills/ai-coding/trigger-cases.json +30 -0
  11. package/skills/ai-compliance/trigger-cases.json +30 -0
  12. package/skills/ai-eval/trigger-cases.json +30 -0
  13. package/skills/ai-llm-app/trigger-cases.json +30 -0
  14. package/skills/ai-mlops/trigger-cases.json +30 -0
  15. package/skills/ai-multimodal/trigger-cases.json +30 -0
  16. package/skills/ai-prompt/trigger-cases.json +30 -0
  17. package/skills/ai-rag/trigger-cases.json +30 -0
  18. package/skills/anomaly-detection/trigger-cases.json +30 -0
  19. package/skills/api-design/trigger-cases.json +31 -0
  20. package/skills/api-integration/trigger-cases.json +31 -0
  21. package/skills/arch-design/trigger-cases.json +12 -0
  22. package/skills/arch-diagram/SKILL.md +118 -0
  23. package/skills/arch-diagram/trigger-cases.json +30 -0
  24. package/skills/arch-distributed/trigger-cases.json +30 -0
  25. package/skills/arch-multitenant/trigger-cases.json +30 -0
  26. package/skills/audio-brief/SKILL.md +65 -0
  27. package/skills/audio-brief/trigger-cases.json +30 -0
  28. package/skills/audit-assist/trigger-cases.json +30 -0
  29. package/skills/audit-chain/trigger-cases.json +31 -0
  30. package/skills/banking-credit/trigger-cases.json +30 -0
  31. package/skills/brand-design/trigger-cases.json +30 -0
  32. package/skills/business-plan/trigger-cases.json +30 -0
  33. package/skills/capital-ops/trigger-cases.json +30 -0
  34. package/skills/cicd-pipeline/trigger-cases.json +31 -0
  35. package/skills/code-archaeology/SKILL.md +55 -0
  36. package/skills/code-archaeology/trigger-cases.json +9 -0
  37. package/skills/code-quality/SKILL.md +138 -0
  38. package/skills/code-quality/references/languages.md +107 -0
  39. package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
  40. package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
  41. package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
  42. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
  43. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
  44. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
  45. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
  46. package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
  47. package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
  48. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
  49. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
  50. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
  51. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
  52. package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
  53. package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
  54. package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
  55. package/skills/code-quality/scripts/code_battery.py +1072 -0
  56. package/skills/code-quality/scripts/code_check.py +1269 -0
  57. package/skills/code-quality/scripts/code_csharp.py +371 -0
  58. package/skills/code-quality/scripts/code_fix.py +201 -0
  59. package/skills/code-quality/scripts/code_java.py +695 -0
  60. package/skills/code-quality/scripts/code_jsts.py +313 -0
  61. package/skills/code-quality/scripts/code_lens.py +1079 -0
  62. package/skills/code-quality/scripts/code_python.py +536 -0
  63. package/skills/code-quality/scripts/code_testplan.py +239 -0
  64. package/skills/code-quality/trigger-cases.json +45 -0
  65. package/skills/code-review/SKILL.md +24 -0
  66. package/skills/code-review/trigger-cases.json +35 -0
  67. package/skills/commit-and-pr/trigger-cases.json +31 -0
  68. package/skills/content-creation/trigger-cases.json +30 -0
  69. package/skills/corporate-governance/trigger-cases.json +30 -0
  70. package/skills/crawl-chain/trigger-cases.json +31 -0
  71. package/skills/customer-service/trigger-cases.json +30 -0
  72. package/skills/data-analysis/trigger-cases.json +30 -0
  73. package/skills/data-chain/SKILL.md +1 -1
  74. package/skills/data-chain/trigger-cases.json +31 -0
  75. package/skills/data-governance/trigger-cases.json +30 -0
  76. package/skills/data-report-pro/trigger-cases.json +30 -0
  77. package/skills/data-streaming/trigger-cases.json +30 -0
  78. package/skills/data-wrangling/SKILL.md +1 -1
  79. package/skills/data-wrangling/trigger-cases.json +30 -0
  80. package/skills/db-es/trigger-cases.json +30 -0
  81. package/skills/db-migration/trigger-cases.json +30 -0
  82. package/skills/db-mysql/trigger-cases.json +30 -0
  83. package/skills/db-postgres/trigger-cases.json +30 -0
  84. package/skills/db-redis/trigger-cases.json +30 -0
  85. package/skills/debug-root-cause/trigger-cases.json +30 -0
  86. package/skills/dependency-audit/SKILL.md +56 -0
  87. package/skills/dependency-audit/trigger-cases.json +7 -0
  88. package/skills/dependency-upgrade/trigger-cases.json +31 -0
  89. package/skills/dev-cpp/trigger-cases.json +30 -0
  90. package/skills/dev-cross-platform/trigger-cases.json +30 -0
  91. package/skills/dev-csharp/SKILL.md +23 -0
  92. package/skills/dev-csharp/trigger-cases.json +30 -0
  93. package/skills/dev-frontend/SKILL.md +19 -0
  94. package/skills/dev-frontend/trigger-cases.json +30 -0
  95. package/skills/dev-go/trigger-cases.json +30 -0
  96. package/skills/dev-harmony/trigger-cases.json +30 -0
  97. package/skills/dev-java/SKILL.md +20 -0
  98. package/skills/dev-java/trigger-cases.json +30 -0
  99. package/skills/dev-mobile/trigger-cases.json +30 -0
  100. package/skills/dev-node/trigger-cases.json +30 -0
  101. package/skills/dev-php/trigger-cases.json +30 -0
  102. package/skills/dev-python/SKILL.md +21 -0
  103. package/skills/dev-python/trigger-cases.json +30 -0
  104. package/skills/dev-rust/trigger-cases.json +30 -0
  105. package/skills/dev-toolchain/trigger-cases.json +31 -0
  106. package/skills/doc-chain/trigger-cases.json +31 -0
  107. package/skills/doc-compare/trigger-cases.json +30 -0
  108. package/skills/docx-author/SKILL.md +127 -25
  109. package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
  110. package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
  111. package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
  112. package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
  113. package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
  114. package/skills/docx-author/scripts/docx_battery.py +586 -0
  115. package/skills/docx-author/scripts/docx_build.py +995 -0
  116. package/skills/docx-author/scripts/docx_check.py +1306 -0
  117. package/skills/docx-author/scripts/docx_doc.py +14 -154
  118. package/skills/docx-author/scripts/docx_layout.py +490 -0
  119. package/skills/docx-author/scripts/zh_prose.py +639 -0
  120. package/skills/docx-author/trigger-cases.json +58 -0
  121. package/skills/echo-brief/SKILL.md +84 -0
  122. package/skills/echo-brief/references/echo-format.md +68 -0
  123. package/skills/echo-brief/trigger-cases.json +40 -0
  124. package/skills/ecommerce-ops/trigger-cases.json +30 -0
  125. package/skills/education-training/trigger-cases.json +30 -0
  126. package/skills/ehs-safety/trigger-cases.json +30 -0
  127. package/skills/email-writing/trigger-cases.json +30 -0
  128. package/skills/eng-ai/trigger-cases.json +30 -0
  129. package/skills/eng-architecture/trigger-cases.json +31 -0
  130. package/skills/eng-automation/trigger-cases.json +31 -0
  131. package/skills/eng-collaboration/trigger-cases.json +30 -0
  132. package/skills/eng-complex-env/trigger-cases.json +30 -0
  133. package/skills/eng-data-platform/trigger-cases.json +31 -0
  134. package/skills/eng-database/trigger-cases.json +31 -0
  135. package/skills/eng-develop/trigger-cases.json +30 -0
  136. package/skills/eng-docs/SKILL.md +24 -0
  137. package/skills/eng-docs/trigger-cases.json +30 -0
  138. package/skills/eng-efficiency/trigger-cases.json +30 -0
  139. package/skills/eng-infra/trigger-cases.json +30 -0
  140. package/skills/eng-integration/trigger-cases.json +31 -0
  141. package/skills/eng-legacy/trigger-cases.json +31 -0
  142. package/skills/eng-observability/trigger-cases.json +31 -0
  143. package/skills/eng-ops/trigger-cases.json +31 -0
  144. package/skills/eng-performance/trigger-cases.json +31 -0
  145. package/skills/eng-planning/trigger-cases.json +31 -0
  146. package/skills/eng-quality/SKILL.md +8 -0
  147. package/skills/eng-quality/trigger-cases.json +31 -0
  148. package/skills/eng-release/trigger-cases.json +31 -0
  149. package/skills/eng-reliability/trigger-cases.json +31 -0
  150. package/skills/eng-requirements/trigger-cases.json +12 -0
  151. package/skills/eng-research/trigger-cases.json +30 -0
  152. package/skills/eng-security/trigger-cases.json +31 -0
  153. package/skills/eng-ux/trigger-cases.json +30 -0
  154. package/skills/engineering-chain/trigger-cases.json +30 -0
  155. package/skills/env-offline/trigger-cases.json +31 -0
  156. package/skills/env-private-deploy/trigger-cases.json +31 -0
  157. package/skills/env-xinchuang/trigger-cases.json +31 -0
  158. package/skills/equipment-tpm/trigger-cases.json +30 -0
  159. package/skills/esg-report/trigger-cases.json +30 -0
  160. package/skills/event-management/trigger-cases.json +30 -0
  161. package/skills/excel-analysis-chain/SKILL.md +17 -4
  162. package/skills/excel-analysis-chain/trigger-cases.json +31 -0
  163. package/skills/explain-codebase/trigger-cases.json +11 -0
  164. package/skills/file-ingest/trigger-cases.json +30 -0
  165. package/skills/finance-analysis/trigger-cases.json +31 -0
  166. package/skills/finance-chain/trigger-cases.json +31 -0
  167. package/skills/financial-modeling/trigger-cases.json +31 -0
  168. package/skills/find-simplifications/SKILL.md +43 -0
  169. package/skills/find-simplifications/trigger-cases.json +23 -0
  170. package/skills/foreign-trade/trigger-cases.json +30 -0
  171. package/skills/funnel-cohort/trigger-cases.json +30 -0
  172. package/skills/gov-document/trigger-cases.json +30 -0
  173. package/skills/gui-change-evidence/SKILL.md +47 -0
  174. package/skills/gui-change-evidence/trigger-cases.json +23 -0
  175. package/skills/hr-chain/trigger-cases.json +31 -0
  176. package/skills/hr-operations/trigger-cases.json +30 -0
  177. package/skills/info-disclosure/trigger-cases.json +31 -0
  178. package/skills/infra-gateway/trigger-cases.json +31 -0
  179. package/skills/infra-k8s/trigger-cases.json +31 -0
  180. package/skills/infra-mq/trigger-cases.json +30 -0
  181. package/skills/insurance-advisory/trigger-cases.json +30 -0
  182. package/skills/internal-audit/trigger-cases.json +30 -0
  183. package/skills/ip-management/trigger-cases.json +31 -0
  184. package/skills/ipo-listing/trigger-cases.json +31 -0
  185. package/skills/it-audit/trigger-cases.json +31 -0
  186. package/skills/itsm-ops/trigger-cases.json +31 -0
  187. package/skills/knowledge-sop/trigger-cases.json +31 -0
  188. package/skills/legal-chain/trigger-cases.json +31 -0
  189. package/skills/legal-contract/trigger-cases.json +30 -0
  190. package/skills/litigation-support/trigger-cases.json +31 -0
  191. package/skills/logistics-warehouse/trigger-cases.json +30 -0
  192. package/skills/long-doc-analysis/trigger-cases.json +31 -0
  193. package/skills/management-accounting/trigger-cases.json +30 -0
  194. package/skills/manufacturing-chain/trigger-cases.json +31 -0
  195. package/skills/manufacturing-quality/trigger-cases.json +30 -0
  196. package/skills/marketing-analytics/trigger-cases.json +31 -0
  197. package/skills/marketing-chain/trigger-cases.json +31 -0
  198. package/skills/meeting-minutes/trigger-cases.json +30 -0
  199. package/skills/metrics-system/trigger-cases.json +31 -0
  200. package/skills/negotiation-prep/trigger-cases.json +30 -0
  201. package/skills/obs-prometheus/trigger-cases.json +31 -0
  202. package/skills/office-automation/trigger-cases.json +31 -0
  203. package/skills/okr-review/trigger-cases.json +30 -0
  204. package/skills/ops-fleet/trigger-cases.json +31 -0
  205. package/skills/ops-linux/trigger-cases.json +31 -0
  206. package/skills/optimization-solver/trigger-cases.json +30 -0
  207. package/skills/patent-disclosure/SKILL.md +105 -0
  208. package/skills/patent-disclosure/references/figures.md +110 -0
  209. package/skills/patent-disclosure/references/outline.md +162 -0
  210. package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
  211. package/skills/patent-disclosure/trigger-cases.json +31 -0
  212. package/skills/patent-drafting/SKILL.md +108 -0
  213. package/skills/patent-drafting/trigger-cases.json +31 -0
  214. package/skills/patent-portfolio/SKILL.md +81 -0
  215. package/skills/patent-portfolio/trigger-cases.json +31 -0
  216. package/skills/pdf-toolkit/trigger-cases.json +31 -0
  217. package/skills/perf-optimize/trigger-cases.json +31 -0
  218. package/skills/pivot-crosstab/trigger-cases.json +30 -0
  219. package/skills/policy-analysis/trigger-cases.json +31 -0
  220. package/skills/portfolio-analysis/trigger-cases.json +31 -0
  221. package/skills/ppt-chain/SKILL.md +14 -6
  222. package/skills/ppt-chain/trigger-cases.json +31 -0
  223. package/skills/pptx-author/SKILL.md +86 -33
  224. package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
  225. package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
  226. package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
  227. package/skills/pptx-author/scripts/pptx_battery.py +178 -0
  228. package/skills/pptx-author/scripts/pptx_build.py +119 -0
  229. package/skills/pptx-author/scripts/pptx_check.py +1182 -0
  230. package/skills/pptx-author/scripts/pptx_deck.py +736 -145
  231. package/skills/pptx-author/scripts/pptx_layout.py +384 -0
  232. package/skills/pptx-author/trigger-cases.json +15 -0
  233. package/skills/pr-comms/trigger-cases.json +30 -0
  234. package/skills/procurement-supply/trigger-cases.json +31 -0
  235. package/skills/product-design/trigger-cases.json +31 -0
  236. package/skills/production-planning/trigger-cases.json +31 -0
  237. package/skills/project-management/trigger-cases.json +31 -0
  238. package/skills/project-proposal/trigger-cases.json +31 -0
  239. package/skills/prose-standard/SKILL.md +53 -0
  240. package/skills/prose-standard/trigger-cases.json +23 -0
  241. package/skills/qa-api-automation/trigger-cases.json +31 -0
  242. package/skills/qa-mobile-automation/trigger-cases.json +31 -0
  243. package/skills/qa-ui-automation/trigger-cases.json +31 -0
  244. package/skills/quant-analysis/trigger-cases.json +31 -0
  245. package/skills/rag-triage/SKILL.md +64 -0
  246. package/skills/rag-triage/evals.json +57 -0
  247. package/skills/rag-triage/references/triage-playbook.md +75 -0
  248. package/skills/rag-triage/trigger-cases.json +31 -0
  249. package/skills/real-estate-property/trigger-cases.json +30 -0
  250. package/skills/refactor-safely/SKILL.md +1 -1
  251. package/skills/refactor-safely/trigger-cases.json +30 -0
  252. package/skills/regression-forecast/trigger-cases.json +31 -0
  253. package/skills/release-checklist/trigger-cases.json +31 -0
  254. package/skills/report-craft/SKILL.md +76 -0
  255. package/skills/report-craft/references/feasibility.md +118 -0
  256. package/skills/report-craft/references/review-checklist.md +51 -0
  257. package/skills/report-craft/references/visuals.md +114 -0
  258. package/skills/report-craft/references/writing.md +80 -0
  259. package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
  260. package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
  261. package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
  262. package/skills/report-craft/scripts/chart_render.py +1222 -0
  263. package/skills/report-craft/scripts/fin_model.py +602 -0
  264. package/skills/report-craft/scripts/project_facts.py +580 -0
  265. package/skills/report-craft/scripts/report_battery.py +349 -0
  266. package/skills/report-craft/trigger-cases.json +15 -0
  267. package/skills/retail-store-ops/trigger-cases.json +30 -0
  268. package/skills/risk-compliance/trigger-cases.json +31 -0
  269. package/skills/scope-creep/SKILL.md +45 -0
  270. package/skills/scope-creep/trigger-cases.json +7 -0
  271. package/skills/sec-auth/trigger-cases.json +31 -0
  272. package/skills/sec-compliance/trigger-cases.json +31 -0
  273. package/skills/securities-chain/trigger-cases.json +31 -0
  274. package/skills/securities-research/trigger-cases.json +31 -0
  275. package/skills/security-audit/trigger-cases.json +31 -0
  276. package/skills/slides-html/SKILL.md +25 -10
  277. package/skills/slides-html/trigger-cases.json +11 -0
  278. package/skills/sql-analysis/SKILL.md +110 -0
  279. package/skills/sql-analysis/trigger-cases.json +34 -0
  280. package/skills/sql-tuning/trigger-cases.json +31 -0
  281. package/skills/statistical-testing/trigger-cases.json +31 -0
  282. package/skills/strategy-planning/trigger-cases.json +30 -0
  283. package/skills/supply-chain/trigger-cases.json +31 -0
  284. package/skills/tax-compliance/trigger-cases.json +30 -0
  285. package/skills/tech-doc/SKILL.md +262 -0
  286. package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
  287. package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
  288. package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
  289. package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
  290. package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
  291. package/skills/tech-doc/scripts/archive_battery.py +860 -0
  292. package/skills/tech-doc/scripts/archive_build.py +599 -0
  293. package/skills/tech-doc/scripts/archive_check.py +1307 -0
  294. package/skills/tech-doc/scripts/archive_spec.py +499 -0
  295. package/skills/tech-doc/scripts/doc_templates.py +624 -0
  296. package/skills/tech-doc/trigger-cases.json +57 -0
  297. package/skills/tech-research/trigger-cases.json +31 -0
  298. package/skills/tender-bidding/trigger-cases.json +31 -0
  299. package/skills/translation-pro/trigger-cases.json +30 -0
  300. package/skills/treasury-mgmt/trigger-cases.json +31 -0
  301. package/skills/trim-cot-leakage/SKILL.md +58 -0
  302. package/skills/trim-cot-leakage/trigger-cases.json +23 -0
  303. package/skills/web-craft/SKILL.md +103 -0
  304. package/skills/web-craft/references/craft-checklist.md +75 -0
  305. package/skills/web-craft/references/design-direction.md +122 -0
  306. package/skills/web-craft/references/h5-mobile.md +100 -0
  307. package/skills/web-craft/references/motion.md +136 -0
  308. package/skills/web-craft/references/recipes.md +74 -0
  309. package/skills/web-craft/templates/wedding-invitation.html +1180 -0
  310. package/skills/web-craft/trigger-cases.json +14 -0
  311. package/skills/web-scraping/trigger-cases.json +34 -0
  312. package/skills/web-search/SKILL.md +11 -10
  313. package/skills/web-search/trigger-cases.json +31 -0
  314. package/skills/weekly-report/SKILL.md +134 -0
  315. package/skills/weekly-report/trigger-cases.json +11 -0
  316. package/skills/write-tests/trigger-cases.json +31 -0
  317. package/skills/xlsx-analysis/SKILL.md +143 -77
  318. package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
  319. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
  320. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
  321. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
  322. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
  323. package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
  324. package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
  325. package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
  326. package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
  327. package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
  328. package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
  329. package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
  330. package/skills/xlsx-analysis/trigger-cases.json +59 -0
  331. package/squads/hc-defense.md +47 -0
  332. package/squads/hc-rdarchive.md +110 -0
  333. package/squads/hc-redteam.md +50 -0
  334. package/squads/hc-weekly.md +125 -0
  335. package/src/account.js +1 -0
  336. package/src/acp.js +1 -0
  337. package/src/advisor.js +1 -1
  338. package/src/agent.js +1 -1
  339. package/src/api.js +1 -1
  340. package/src/archaeology.js +1 -0
  341. package/src/ask-ui.js +1 -0
  342. package/src/assets-import.js +1 -1
  343. package/src/autofix.js +1 -1
  344. package/src/automation-tools.js +1 -0
  345. package/src/automation.js +1 -1
  346. package/src/autorun.js +1 -1
  347. package/src/banner.js +1 -1
  348. package/src/capability-probe.js +1 -0
  349. package/src/checkpoints.js +1 -1
  350. package/src/code-tools.js +1 -0
  351. package/src/codeindex.js +1 -1
  352. package/src/codemode.js +1 -0
  353. package/src/command-bridge.js +1 -0
  354. package/src/command-ui.js +1 -0
  355. package/src/commands.js +1 -1
  356. package/src/compaction.js +1 -1
  357. package/src/computer.js +1 -0
  358. package/src/config.js +1 -1
  359. package/src/consult.js +1 -1
  360. package/src/context-report.js +1 -1
  361. package/src/context-window.js +1 -0
  362. package/src/context.js +1 -1
  363. package/src/crew.js +1 -0
  364. package/src/crush.js +1 -0
  365. package/src/daemon.js +1 -1
  366. package/src/decisions.js +1 -1
  367. package/src/depaudit.js +1 -0
  368. package/src/device.js +1 -1
  369. package/src/diagram-draw.js +1 -0
  370. package/src/diagram-layouts.js +1 -0
  371. package/src/diagram-raster.js +1 -0
  372. package/src/diagram.js +1 -0
  373. package/src/dictation.js +1 -0
  374. package/src/diffview.js +1 -1
  375. package/src/doctor.js +1 -1
  376. package/src/docx-tools.js +1 -0
  377. package/src/errkind.js +1 -0
  378. package/src/evidence.js +1 -0
  379. package/src/executors.js +1 -1
  380. package/src/expert-catalog.js +1 -1
  381. package/src/experts-store.js +1 -1
  382. package/src/experts.js +1 -1
  383. package/src/external-agents.js +1 -0
  384. package/src/forks.js +1 -1
  385. package/src/fs-observe.js +1 -0
  386. package/src/gitflow.js +1 -1
  387. package/src/goalround.js +1 -0
  388. package/src/hooks.js +1 -1
  389. package/src/input.js +1 -1
  390. package/src/insights.js +1 -1
  391. package/src/jobs.js +1 -0
  392. package/src/kbsync.js +1 -1
  393. package/src/lsp.js +1 -0
  394. package/src/mcp-apps.js +1 -0
  395. package/src/mcp.js +1 -1
  396. package/src/memory.js +1 -1
  397. package/src/notify.js +1 -0
  398. package/src/permissions.js +1 -1
  399. package/src/plan-limits.js +1 -0
  400. package/src/policy.js +1 -0
  401. package/src/pptx-tools.js +1 -0
  402. package/src/presence.js +1 -0
  403. package/src/privatedir.js +1 -0
  404. package/src/pyruntime.js +1 -1
  405. package/src/radar.js +1 -0
  406. package/src/render.js +1 -1
  407. package/src/repl.js +1 -1
  408. package/src/report-tools.js +1 -0
  409. package/src/routing.js +1 -0
  410. package/src/sandbox-probe.js +1 -0
  411. package/src/sandbox-win-launcher.mjs +156 -0
  412. package/src/sandbox.js +1 -0
  413. package/src/scan.js +1 -1
  414. package/src/schedule.js +1 -1
  415. package/src/scopecreep.js +1 -0
  416. package/src/sdk.js +1 -0
  417. package/src/selection.js +1 -0
  418. package/src/session-events.js +1 -0
  419. package/src/session.js +1 -1
  420. package/src/sessionindex.js +1 -0
  421. package/src/sessionlog.js +1 -0
  422. package/src/skill-catalog.js +1 -0
  423. package/src/skill-lint.js +1 -0
  424. package/src/skill-optimize.js +1 -0
  425. package/src/skill-scan.js +1 -0
  426. package/src/skills.js +1 -1
  427. package/src/spill.js +1 -0
  428. package/src/squad.js +1 -1
  429. package/src/subagent.js +1 -1
  430. package/src/surface-state.js +1 -0
  431. package/src/tapd.js +1 -0
  432. package/src/terminal-select.js +1 -0
  433. package/src/terminal.js +1 -0
  434. package/src/test-runner.js +1 -0
  435. package/src/todo.js +1 -1
  436. package/src/toolpipe.js +1 -0
  437. package/src/tools.js +1 -1
  438. package/src/toon.js +1 -0
  439. package/src/transcript.js +1 -1
  440. package/src/tui/app.js +1 -1
  441. package/src/tui/command-menu.js +1 -0
  442. package/src/tui/progress.js +1 -0
  443. package/src/tui/scroll.js +1 -0
  444. package/src/tui/welcome.js +1 -0
  445. package/src/tui/workbench.js +1 -0
  446. package/src/ui-spec.js +1 -0
  447. package/src/uitree.js +1 -0
  448. package/src/uninstall.js +1 -1
  449. package/src/vision.js +1 -0
  450. package/src/visual.js +1 -0
  451. package/src/voice-form.js +1 -0
  452. package/src/voice.js +1 -0
  453. package/src/watch.js +1 -1
  454. package/src/web-audit.js +1 -0
  455. package/src/web-cdp.js +1 -0
  456. package/src/web-inspect.js +1 -0
  457. package/src/websearch.js +1 -0
  458. package/src/weekly.js +1 -0
  459. package/src/workflow.js +1 -0
  460. package/src/worklog.js +1 -0
  461. package/src/xlsx-tools.js +1 -0
  462. package/ui/command-picker.js +79 -0
  463. package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
  464. package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
  465. package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
  466. package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
  467. package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
  468. package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
  469. package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
  470. package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
  471. package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
  472. package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
  473. package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
  474. package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
  475. package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
  476. package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
  477. package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
  478. package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
  479. package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
  480. package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
  481. package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
  482. package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
  483. package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
  484. package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
  485. package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
  486. package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
@@ -0,0 +1,1269 @@
1
+ # 代码体检:把「生成完代码之后人该做的那遍排查」做成机器能重复做的事。
2
+ #
3
+ # 与 docx_check / xlsx_check 同构,纪律也一样:
4
+ # ① 只在 code_lens 的掩码视图上找模式——字符串和注释里的 `==`、SQL、password 不是缺陷;
5
+ # ② 每加一条规则,code_battery 里必须配一个**故意做坏的样本**断言它会响,
6
+ # 再配一个合规样本断言它**不响**(只会说「未发现问题」的体检器比没有更糟);
7
+ # ③ 标了 fixable 的规则,code_fix 必须真的能修——门槛与 check 对齐,
8
+ # 靠 FIXABLE 这一个集合两边共用来保证,不靠两处各写一遍的自觉;
9
+ # ④ 解析失败要如实上报「这个文件只做了降级检查」,不许静默跳过。
10
+ #
11
+ # 七类规则,对应「代码写完要排查什么」:
12
+ # logic 逻辑对不对 —— 空捕获、恒真条件、可变默认参、死代码、循环内 await…
13
+ # comment 注释可不可信 —— 文档与实现不符、@param 对不上、注释掉的代码、空洞注释
14
+ # readability 读不读得懂 —— 函数过长、嵌套过深、圈复杂度、参数过多、命名
15
+ # structure 结构清不清楚 —— 重复代码块、硬编码路径、调试残留
16
+ # test 测试作不作数 —— 无断言、弱断言、only/skip 残留、公共函数无覆盖
17
+ # security 安不安全 —— 硬编码密钥、SQL 拼接、命令注入、eval
18
+ # consistency 前后一不一致 —— 同名函数签名漂移、导出与实现不符
19
+ #
20
+ # 严重度:error=必修(会出事/会骗人)· warn=建议(该改但不拦交付)· info=提示(风格与偏好)
21
+
22
+ import os
23
+ import re
24
+ import sys
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import code_lens as L # noqa: E402
28
+ import code_java as J # noqa: E402
29
+ import code_python as PY # noqa: E402
30
+ import code_jsts as JT # noqa: E402
31
+ import code_csharp as CS # noqa: E402
32
+
33
+ LEVELS = ('error', 'warn', 'info')
34
+
35
+ # 可自动修复的规则(code_fix 从这里读,两边永远对齐)。
36
+ # 只收「改法唯一、不改变行为、不需要理解业务」的:加个 === 、删个 it.only。
37
+ # 「函数太长」这种没有唯一改法的**绝不**列进来——工具不能标它做不到的自动修。
38
+ # 门槛:改完之后行为要么完全不变,要么是**恢复本该有的行为**。
39
+ # 刻意不收的两条,理由写在这里免得以后有人「顺手补上」:
40
+ # logic.loose-eq `==`→`===` 会改变行为("1" == 1 真,"1" === 1 假),代码若正依赖它,一键修完当场出故障;
41
+ # logic.self-assign `x = x` 删掉看似无害,但它多半是「漏写了 this.」的现场证据,删了等于抹掉线索、bug 还在。
42
+ # 这两条报给人判断。工具绝不标它做不到的「可自动修」——
43
+ # 一个会自动改坏代码的修复器,比没有修复器危险,因为人会停止复核它的改动。
44
+ FIXABLE = {
45
+ 'test.only-left', # 删 .only:恢复「跑全部用例」
46
+ 'readability.trailing-space', # 去行尾空白(字符串内部的不动)
47
+ 'comment.commented-code', # 删被注释掉的死代码:注释不参与执行
48
+ 'structure.debug-left', # 删调试输出:仅限单行、参数里没有函数调用的
49
+ 'logic.empty-finally', # 删空 finally:空块本就没有任何作用
50
+ }
51
+
52
+
53
+ class Issue:
54
+ __slots__ = ('rule', 'level', 'path', 'line', 'msg', 'hint', 'unit', 'excerpt')
55
+
56
+ def __init__(self, rule, level, path, line, msg, hint='', unit=None, excerpt=''):
57
+ self.rule = rule
58
+ self.level = level
59
+ self.path = path
60
+ self.line = line
61
+ self.msg = msg
62
+ self.hint = hint
63
+ self.unit = unit
64
+ self.excerpt = excerpt
65
+
66
+ @property
67
+ def fixable(self):
68
+ return self.rule in FIXABLE
69
+
70
+ def to_dict(self):
71
+ return {'rule': self.rule, 'level': self.level, 'path': self.path, 'line': self.line,
72
+ 'msg': self.msg, 'hint': self.hint, 'unit': self.unit, 'fixable': self.fixable,
73
+ 'excerpt': self.excerpt}
74
+
75
+
76
+ class Ctx:
77
+ """一次体检的上下文:阈值、已知的测试文件、跨文件索引。"""
78
+
79
+ def __init__(self, **kw):
80
+ self.max_lines = kw.get('max_lines', 80) # 函数行数上限
81
+ self.max_depth = kw.get('max_depth', 4) # 嵌套深度上限
82
+ self.max_cx = kw.get('max_cx', 15) # 圈复杂度上限
83
+ self.max_params = kw.get('max_params', 6)
84
+ self.max_file_lines = kw.get('max_file_lines', 1200)
85
+ self.max_line_len = kw.get('max_line_len', 160)
86
+ self.strict = kw.get('strict', False) # 严格模式:warn 提到 error
87
+ self.tested_names = kw.get('tested_names', set()) # 测试里出现过的标识符
88
+ self.has_tests = kw.get('has_tests', False)
89
+ self.only = set(kw.get('only') or []) # 只跑这些类
90
+ self.skip = set(kw.get('skip') or [])
91
+
92
+
93
+ _RE_IGNORE = re.compile(r'(?:code-inspect|inspect)-ignore(?:\s*:\s*|\s+)([\w.,\- ]*)')
94
+
95
+
96
+ def _ignored(lens, line, rule):
97
+ """人是否在这一行(或上一行)就地豁免了这条规则。
98
+
99
+ 写法:`// code-inspect-ignore: logic.empty-catch 这里的失败不影响主流程`
100
+ 不写规则名则豁免该行全部规则。豁免要求写在紧邻处,且鼓励顺手写下理由——
101
+ 一个没有豁免出口的检查器,遇到第一条「确实误报但改不动」的规则就会被整个关掉。
102
+ """
103
+ for probe in (lens.line(line), lens.line(line - 1)):
104
+ m = _RE_IGNORE.search(probe or '')
105
+ if not m:
106
+ continue
107
+ names = [x.strip() for x in (m.group(1) or '').replace(' ', ',').split(',') if x.strip()]
108
+ if not names or rule in names or rule.split('.')[0] in names:
109
+ return True
110
+ return False
111
+
112
+
113
+ def _add(out, ctx, rule, level, lens, line, msg, hint='', unit=None):
114
+ cat = rule.split('.')[0]
115
+ if _ignored(lens, line, rule):
116
+ return
117
+ # 同一条规则在同一行只报一次。
118
+ # 多条正则命中同一处是常态(`expect(true).toBe(true)` 同时符合两种弱断言形态),
119
+ # 重复的条目会让人以为问题比实际更多,也让「必修 N 条」这个数字失去意义。
120
+ for prev in out:
121
+ if prev.rule == rule and prev.line == line and prev.path == lens.path:
122
+ return
123
+ if ctx.only and cat not in ctx.only:
124
+ return
125
+ if cat in ctx.skip:
126
+ return
127
+ if ctx.strict and level == 'warn':
128
+ level = 'error'
129
+ out.append(Issue(rule, level, lens.path, line, msg, hint, unit,
130
+ excerpt=lens.line(line).strip()[:160]))
131
+
132
+
133
+ def _unit_of(lens, line):
134
+ u = lens.unit_at(line)
135
+ return u.label if u else None
136
+
137
+
138
+ # ══ 逻辑类 ══════════════════════════════════════════════════════════════════
139
+ # 空捕获:JS `catch (e) {}` · Java `catch (E e) { }` · Python `except: pass`
140
+ _RE_EMPTY_CATCH_JS = re.compile(r'\bcatch\s*(?:\([^)]*\))?\s*\{\s*\}')
141
+ _RE_EMPTY_FINALLY = re.compile(r'\bfinally\s*\{\s*\}')
142
+ # 恒真/恒假:if (true) · while(1) · if (1 == 1) · if (x == x)
143
+ _RE_ALWAYS = re.compile(r'\b(?:if|while)\s*\(\s*(true|false|1|0)\s*\)')
144
+ # 两侧必须是**完整**表达式:前不粘 `.`(否则 `i.level == level` 会从 level 起匹配上),
145
+ # 后不粘 `.` `(` `[`(否则 `ln != ln.rstrip()` 会被当成自我比较)。
146
+ _RE_SELF_CMP = re.compile(
147
+ r'(?<![\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*(===|==|!==|!=)\s*\1(?![\w$.(\[])')
148
+ _RE_SELF_ASSIGN = re.compile(r'^\s*([A-Za-z_$][\w$.]*)\s*=\s*\1\s*;?\s*$', re.M)
149
+ # 松散比较(排除与 null/undefined 的惯用写法 x == null)
150
+ _RE_LOOSE_EQ = re.compile(r'(?<![=!<>])(==|!=)(?!=)')
151
+ _RE_ASSIGN_IN_COND = re.compile(r'\b(?:if|while)\s*\(\s*[^=()]*[^=!<>+\-*/%&|^]=(?!=)[^=]')
152
+ _RE_FLOAT_EQ = re.compile(r'(?:===|==)\s*-?\d+\.\d+|-?\d+\.\d+\s*(?:===|==)')
153
+ _RE_AWAIT_IN_LOOP = re.compile(r'\b(?:for|while)\s*\([^)]*\)\s*\{[^{}]*\bawait\b', re.S)
154
+ # ⚠ 这一条必须在**原文**上跑:掩码视图把字符串连引号一起抹成了空格,
155
+ # 而「一边是字符串字面量」正是这条规则最可靠的判据。在掩码上跑的话,
156
+ # `emp.getStatus() == "ACTIVE"` 这种最典型的写法一条也抓不到。
157
+ # 最强信号:`表达式 == "字面量"`(或反过来)——不管左边是变量还是方法调用,
158
+ # 只要另一边是字符串字面量,比的就一定是引用而不是内容。
159
+ # 只认两种**确定**的形态,不猜:
160
+ # ① 一边是字符串字面量 —— 那另一边不管是变量还是方法调用,比的都是引用;
161
+ # ② 两边都是本文件里声明成 String 的变量。
162
+ # 曾经有过第三种「名字里带 Code/Name/Str 就算字符串」的猜测分支,
163
+ # 结果 `this.code == ResultCode.SUCCESS.getCode()`(两边都是 int)被报成错误——
164
+ # 靠命名猜类型,猜错的代价是让人不再相信这条规则。
165
+ _RE_JAVA_STR_LITERAL_EQ = re.compile(
166
+ r'([\w$.]+(?:\s*\([^()]*\))?)\s*(==|!=)\s*"[^"]*"'
167
+ r'|"[^"]*"\s*(==|!=)\s*([\w$.]+(?:\s*\([^()]*\))?)')
168
+ _RE_JAVA_STR_VAR_EQ = re.compile(r'\b(\w+)\s*(==|!=)\s*(\w+)\b')
169
+
170
+
171
+ def _logic_swallowed_errors(lens, ctx, out):
172
+ """异常被吞掉的几种形态:空 catch、空 finally、裸 except、except-pass。
173
+
174
+ 单列一组是因为它们共享同一条判据——「块里有没有写下为什么」。
175
+ """
176
+ code, lang = lens.code, lens.lang
177
+ is_js = lang in ('js', 'ts', 'vue')
178
+
179
+ # ── 空捕获:错误被吞掉,故障现场当场消失 ──
180
+ if is_js or lang == 'java':
181
+ for m in _RE_EMPTY_CATCH_JS.finditer(code):
182
+ ln = code.count('\n', 0, m.start()) + 1
183
+ # 掩码把注释抹成了空格,所以 `catch { /* 说明 */ }` 在纯代码视图里看着是空的。
184
+ # 回原文同一段偏移上看:块里写了注释,就是「我知道这里忽略了什么」的证据。
185
+ # 原先要求注释里出现「忽略/ignore」等特定词——写注释的人不会照着谁的词表写,
186
+ # 那条判据在真实项目上一口气刷出上百条假必修。
187
+ if re.search(r'/\*|//', lens.text[m.start():m.end()]):
188
+ continue
189
+ if re.search(r'(忽略|ignore|intentional|no-?op|不处理|故意)', lens.line(ln) + lens.line(ln + 1), re.I):
190
+ continue
191
+ _add(out, ctx, 'logic.empty-catch', 'error', lens, ln,
192
+ '捕获了异常却什么都不做,故障会被静默吞掉',
193
+ '至少记一条日志(含原始异常),或注释写明为什么可以忽略;真不需要就别 try',
194
+ _unit_of(lens, ln))
195
+ for m in _RE_EMPTY_FINALLY.finditer(code):
196
+ ln = code.count('\n', 0, m.start()) + 1
197
+ _add(out, ctx, 'logic.empty-finally', 'warn', lens, ln, '空的 finally 块没有任何作用', '删掉它',
198
+ _unit_of(lens, ln))
199
+ if lang == 'py':
200
+ for i, ln in enumerate(lens.code_lines, 1):
201
+ if re.match(r'^\s*except\s*:\s*$', ln):
202
+ _add(out, ctx, 'logic.bare-except', 'warn', lens, i,
203
+ '裸 except 会连 KeyboardInterrupt / SystemExit 一起吞掉',
204
+ '写明要捕获的异常类型,至少用 except Exception', _unit_of(lens, i))
205
+ m_exc = re.match(r'^\s*except\s*([\w.,() ]*)\s*(?:as\s+\w+)?\s*:\s*$', ln)
206
+ if m_exc:
207
+ nxt = lens.code_line(i + 1).strip()
208
+ has_note = '#' in (lens.line(i + 1) or '') or '#' in (lens.line(i) or '')
209
+ if nxt != 'pass' or has_note:
210
+ continue
211
+ # 按「吞掉了多大范围」分级:
212
+ # except Exception: pass —— 连编程错误(NameError、TypeError)一起吞,是真危险;
213
+ # except KeyError: pass —— 捕的是一个具体的、可预期的情况,意图基本写在类型名上了。
214
+ # 不分级的话,一个成熟 Python 仓库会报出几十条必修,而其中大半是「取不到就用默认」
215
+ # 这种合理降级——必修项一多,人就不再逐条看了。
216
+ caught = (m_exc.group(1) or '').strip()
217
+ broad = (not caught) or bool(re.search(r'\b(Exception|BaseException)\b', caught))
218
+ if broad:
219
+ _add(out, ctx, 'logic.empty-catch', 'error', lens, i,
220
+ f'`except {caught or "(裸 except)"}` 捕获范围很宽却直接 pass —— '
221
+ '连 NameError、TypeError 这类编程错误也一起吞了,改坏了都不会有人知道',
222
+ '至少 log 一条(含异常内容);真要忽略就把异常类型缩窄到具体那一个,'
223
+ '并注释写明为什么可以忽略', _unit_of(lens, i))
224
+ else:
225
+ _add(out, ctx, 'logic.empty-catch-narrow', 'warn', lens, i,
226
+ f'`except {caught}` 之后直接 pass,没有说明为什么可以忽略',
227
+ '补一句注释说明这个异常在什么情况下出现、忽略它为什么是安全的——'
228
+ '异常类型说明了「捕什么」,注释要说明「为什么不用管」', _unit_of(lens, i))
229
+
230
+
231
+ def _logic_constant_conditions(lens, ctx, out):
232
+ """分支其实是常量:恒真/恒假条件、自己和自己比、自己赋值给自己、条件里写了赋值。
233
+
234
+ 共同点是「这段代码的走向与运行时数据无关」,而作者显然不是这个意思。
235
+ """
236
+ code = lens.code
237
+
238
+ # ── 恒真/恒假条件:分支永远只走一边,另一边是死的 ──
239
+ for m in _RE_ALWAYS.finditer(code):
240
+ ln = code.count('\n', 0, m.start()) + 1
241
+ word = m.group(1)
242
+ if re.match(r'\bwhile', m.group(0)) and word in ('true', '1'):
243
+ continue # while(true) 是合法的事件循环写法
244
+ _add(out, ctx, 'logic.constant-condition', 'error', lens, ln,
245
+ f'条件恒为 {word},分支永远只走一边', '把真实条件写进去,或删掉这层判断', _unit_of(lens, ln))
246
+ for m in _RE_SELF_CMP.finditer(code):
247
+ ln = code.count('\n', 0, m.start()) + 1
248
+ _add(out, ctx, 'logic.self-compare', 'error', lens, ln,
249
+ f'`{m.group(1)}` 在和自己比较,结果是常量',
250
+ '多半是写错了变量名——对照上下文确认该比的是谁', _unit_of(lens, ln))
251
+ for m in _RE_SELF_ASSIGN.finditer(code):
252
+ ln = code.count('\n', 0, m.start()) + 1
253
+ _add(out, ctx, 'logic.self-assign', 'warn', lens, ln,
254
+ f'`{m.group(1)}` 赋值给自己,这一行没有效果', '删掉,或确认是不是漏写了 this./参数名',
255
+ _unit_of(lens, ln))
256
+
257
+ # ── 条件里做赋值:`if (x = 1)` 恒真,且悄悄改了 x ──
258
+ for m in _RE_ASSIGN_IN_COND.finditer(code):
259
+ ln = code.count('\n', 0, m.start()) + 1
260
+ _add(out, ctx, 'logic.assign-in-condition', 'error', lens, ln,
261
+ '条件表达式里出现了赋值 `=`(不是比较 `==`)',
262
+ '要比较就写 === / ==;确实要在条件里赋值,用额外括号写明意图', _unit_of(lens, ln))
263
+
264
+
265
+ def _logic_language_traps(lens, ctx, out):
266
+ """各语言特有的坑:JS 松散比较、Python 可变默认参、循环内 await、
267
+ Java 用 == 比字符串、浮点直接比相等。
268
+
269
+ 它们不靠通用模式识别,全都依赖「这是什么语言」,所以单独一组。
270
+ """
271
+ code, lang = lens.code, lens.lang
272
+ is_js = lang in ('js', 'ts', 'vue')
273
+
274
+ # ── JS 松散比较:== 会做类型转换,'' == 0 为真 ──
275
+ if is_js:
276
+ for i, ln in enumerate(lens.code_lines, 1):
277
+ for m in _RE_LOOSE_EQ.finditer(ln):
278
+ seg = ln[max(0, m.start() - 24):m.end() + 24]
279
+ if re.search(r'[!=]=\s*null|null\s*[!=]=|[!=]=\s*undefined|undefined\s*[!=]=', seg):
280
+ continue # x == null 同时判 null 与 undefined,是公认惯用法
281
+ _add(out, ctx, 'logic.loose-eq', 'warn', lens, i,
282
+ f'用了松散比较 `{m.group(1)}`,会发生隐式类型转换(`"" == 0` 为真)',
283
+ f'改成 `{m.group(1)[0]}==`(严格比较)', _unit_of(lens, i))
284
+ break
285
+
286
+ # ── Python 可变默认参数:默认值在定义时求值一次,会跨调用累积 ──
287
+ if lang == 'py' and getattr(lens, '_ast', None) is not None:
288
+ import ast
289
+ for node in ast.walk(lens._ast):
290
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
291
+ for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x]:
292
+ if isinstance(d, (ast.List, ast.Dict, ast.Set)) or (
293
+ isinstance(d, ast.Call) and getattr(d.func, 'id', '') in ('list', 'dict', 'set')):
294
+ _add(out, ctx, 'logic.mutable-default', 'error', lens, d.lineno,
295
+ f'函数 `{node.name}` 用可变对象做默认参数,它在定义时只求值一次、会跨调用累积',
296
+ '改成 `=None`,进函数体后再 `x = x or []`', node.name)
297
+
298
+ # ── 循环里 await:把本可并发的请求排成了队 ──
299
+ if is_js:
300
+ for m in _RE_AWAIT_IN_LOOP.finditer(code):
301
+ ln = code.count('\n', 0, m.start()) + 1
302
+ body = m.group(0)
303
+ if re.search(r'\b(sleep|delay|wait|retry|poll|排队|顺序)\b', body, re.I):
304
+ continue # 明确要顺序执行的(重试、轮询、限速)
305
+ _add(out, ctx, 'logic.await-in-loop', 'warn', lens, ln,
306
+ '循环体里 await:N 个请求会串行等待,总耗时是各次之和',
307
+ '互相独立就收集成数组交给 Promise.all 并发;确实要顺序执行,注释说明原因',
308
+ _unit_of(lens, ln))
309
+
310
+ # ── Java 用 == 比字符串:比的是引用地址,不是内容 ──
311
+ if lang == 'java':
312
+ # 本文件里明确声明成 String 的名字(字段、局部变量、形参都在内)
313
+ str_vars = {f['name'] for f in lens.fields if f['type'].strip() == 'String'}
314
+ str_vars |= set(re.findall(r'\bString\s+(\w+)', code))
315
+ for i, ln in enumerate(lens.lines, 1): # 原文,不是掩码视图(理由见正则上方)
316
+ if 'null' in ln:
317
+ continue
318
+ hit = bool(_RE_JAVA_STR_LITERAL_EQ.search(ln))
319
+ if not hit:
320
+ m = _RE_JAVA_STR_VAR_EQ.search(lens.code_line(i))
321
+ hit = bool(m and m.group(1) in str_vars and m.group(3) in str_vars)
322
+ if hit:
323
+ _add(out, ctx, 'logic.java-string-eq', 'error', lens, i,
324
+ '用 `==` 比较字符串,比的是对象引用而不是内容',
325
+ '改用 `equals()`;防空指针写成 `Objects.equals(a, b)`', _unit_of(lens, i))
326
+
327
+ # ── 浮点数直接比相等:0.1+0.2 !== 0.3 ──
328
+ for m in _RE_FLOAT_EQ.finditer(code):
329
+ ln = code.count('\n', 0, m.start()) + 1
330
+ _add(out, ctx, 'logic.float-eq', 'warn', lens, ln,
331
+ '浮点数直接比相等,二进制表示误差会让它在该相等时不等',
332
+ '改成比较差的绝对值小于容差;金额一律用整数分或定点小数', _unit_of(lens, ln))
333
+
334
+
335
+ def _logic_dead_code(lens, ctx, out):
336
+ """return / throw / break 之后还写了语句——那些语句永远执行不到。
337
+
338
+ 难点全在**排除跨行表达式**(括号未闭合、行尾是运算符、下一行是续行、
339
+ 多行模板串被掩码抹空),排除不干净这条规则会在成熟项目上刷出几十条假必修。
340
+ """
341
+ # ── return / throw 之后的死代码 ──
342
+ for u in lens.functions():
343
+ body_lines = u.body.split('\n')
344
+ for i, ln in enumerate(body_lines[:-1]):
345
+ s = ln.strip()
346
+ if not re.match(r'^(return\b|throw\b|break\b|continue\b|raise\b)', s):
347
+ continue
348
+ if s.endswith('{') or s.endswith('\\'):
349
+ continue
350
+ # 跨行表达式的三种形态,都不是死代码:
351
+ # ① 括号没闭合:`return bool(\n ...\n)`(掩码抹空了字符串,括号计数可靠)
352
+ # ② 本行以运算符结尾:`return a +`
353
+ # ③ 下一行以运算符/点/逗号开头:`return a\n || b`
354
+ # 只查①会漏掉②③——而 `|| xxx` 这种续行在真实代码里遍地都是,
355
+ # 漏查的后果是整个 dead-code 规则在成熟项目上刷出几十条假必修。
356
+ if (s.count('(') != s.count(')') or s.count('[') != s.count(']')
357
+ or s.count('{') != s.count('}')):
358
+ continue
359
+ if re.search(r'([|&+\-*/%?:.,=]|\b(and|or|not|in|is)\b)\s*$', s):
360
+ continue
361
+ # 跨行字面量:`return \`…几十行模板…\`` 掩码后只剩 `return`,
362
+ # 而真正的返回值还在下面几十行里。拿原文比一下就知道这一行被抹掉过东西。
363
+ raw_here = lens.line(u.start + i)
364
+ if raw_here.strip() and len(raw_here.strip()) > len(s) + 1:
365
+ continue
366
+ nxt = ''
367
+ for j in range(i + 1, len(body_lines)):
368
+ if body_lines[j].strip():
369
+ nxt = body_lines[j].strip()
370
+ break
371
+ if not nxt or nxt.startswith(('}', ')', ']', 'else', 'catch', 'finally', 'case', 'default:', 'elif', 'except')):
372
+ continue
373
+ if re.match(r'^\s*(\?\?|\|\||&&|=>|[|&+\-*/%?:.,])', nxt):
374
+ continue # 下一行是续行,不是新语句
375
+ cur_indent = len(ln) - len(ln.lstrip())
376
+ nxt_indent = len(body_lines[j]) - len(body_lines[j].lstrip())
377
+ if nxt_indent < cur_indent:
378
+ continue # 同级块结束,不是死代码
379
+ _add(out, ctx, 'logic.dead-code', 'error', lens, u.start + i + 1,
380
+ f'`{s.split()[0]}` 之后还有语句,它永远执行不到',
381
+ '删掉死代码,或检查是不是把它放错了位置', u.label)
382
+ break
383
+
384
+
385
+ def check_logic(lens, ctx, out):
386
+ """逻辑类体检:会出事的那些写法,不是风格问题。
387
+
388
+ 按子类分成四组而不是写成一个长函数——加新规则时知道该放进哪一组,
389
+ 而一个一百多行的 check_logic 只会让人在末尾继续追加。
390
+ Java 的语言陷阱另在 code_java 里(拆箱、精度、并发、资源、集合那一大类,
391
+ 通用规则一条也看不见,混在一起只会让两边都难读)。
392
+ """
393
+ for rule_group in (_logic_swallowed_errors, _logic_constant_conditions,
394
+ _logic_language_traps, _logic_dead_code):
395
+ rule_group(lens, ctx, out)
396
+ if lens.lang == 'py':
397
+ # Python 有标准库 ast,事实最准——能判类型层级、作用域、名字绑定,
398
+ # 而这几样恰恰是 Python 最难查的那批坑(共享可变、闭包延迟绑定、except 顺序)。
399
+ PY.check_python_traps(lens, ctx, out, _add)
400
+ PY.check_python_data_and_security(lens, ctx, out, _add)
401
+ PY.check_python_pitfalls(lens, ctx, out, _add)
402
+ if lens.lang in ('js', 'ts', 'vue') or lens.is_vue:
403
+ # JS/TS 没有可用的本地解析器,判据一律取确定形态;
404
+ # Vue 的 <template> 是纯标记,另走一套按标签的检查。
405
+ JT.check_js_traps(lens, ctx, out, _add)
406
+ JT.check_ts_escapes(lens, ctx, out, _add)
407
+ JT.check_vue(lens, ctx, out, _add)
408
+ if lens.lang == 'csharp':
409
+ # C# 与 Java 同走「掩码 + 括号配对」,但坑不一样:
410
+ # async void 无法捕获异常、.Result 死锁、throw ex 重置堆栈,都是 C# 独有的形态。
411
+ CS.check_csharp_traps(lens, ctx, out, _add)
412
+ CS.check_csharp_linq_and_ef(lens, ctx, out, _add)
413
+ CS.check_csharp_pitfalls(lens, ctx, out, _add)
414
+ if lens.lang == 'java':
415
+ J.check_java_traps(lens, ctx, out, _add)
416
+ J.check_java_framework(lens, ctx, out, _add)
417
+ J.check_java_doc_and_test(lens, ctx, out, _add, _doc_text)
418
+ J.check_java_pitfalls(lens, ctx, out, _add)
419
+
420
+
421
+ # ══ 注释类 ══════════════════════════════════════════════════════════════════
422
+ # 这一类是「注释优化」的机器部分。最要紧的不是「注释够不够多」,
423
+ # 而是**注释说的和代码做的是不是一回事**——
424
+ # 一条过期的注释比没有注释危害大得多:它会让下一个读者(和下一次的模型)
425
+ # 基于错误的前提去改代码,而错误前提是看不见的。
426
+ _RE_JSDOC_PARAM = re.compile(r'@param\s*(?:\{[^}]*\}\s*)?\[?([A-Za-z_$][\w$.]*)')
427
+ _RE_PY_PARAM = re.compile(r'^\s*:param\s+(?:\w+\s+)?([A-Za-z_]\w*)\s*:', re.M)
428
+ _RE_RETURN_TAG = re.compile(r'@returns?\b|^\s*:returns?:|^\s*Returns?:\s*$', re.M)
429
+ _RE_TODO = re.compile(r'^[\s*#/─\-—·]*(TODO|FIXME|待办|待定|待补)\b', re.I | re.M)
430
+ _RE_TODO_CASED = re.compile(r'^[\s*#/─\-—·]*(XXX|HACK)\b', re.M)
431
+ # 「看起来像被注释掉的代码」:注释内容里有赋值/调用/结束分号/控制结构
432
+ _RE_LOOKS_CODE = re.compile(
433
+ r'(^\s*(if|for|while|return|const|let|var|def|class|function|public|private|import|await)\b'
434
+ r'|[;{}]\s*$|^\s*[\w.$\[\]]+\s*=[^=]|\w+\([^)]*\)\s*;?\s*$)')
435
+ _DOC_START = re.compile(r'^\s*(/\*\*?|\*|//|#|"""|\'\'\')\s?')
436
+
437
+
438
+ def _doc_text(raw):
439
+ """把注释块剥成纯文本(去掉 /** * // # 这些记号)。"""
440
+ if not raw:
441
+ return ''
442
+ lines = []
443
+ for ln in str(raw).split('\n'):
444
+ ln = _DOC_START.sub('', ln)
445
+ lines.append(re.sub(r'\*/\s*$', '', ln).rstrip())
446
+ return '\n'.join(lines).strip()
447
+
448
+
449
+ def _params_declared(doc, lang):
450
+ """从文档注释里读出它声称的形参名。"""
451
+ if lang == 'py':
452
+ names = set(_RE_PY_PARAM.findall(doc))
453
+ m = re.search(r'^\s*(?:Args|参数)\s*:\s*$', doc, re.M)
454
+ if m: # Google 风格:Args: 下面每行 `name: 说明`
455
+ for ln in doc[m.end():].split('\n'):
456
+ if re.match(r'^\s*(Returns?|Raises?|Yields?|返回|异常)\s*:', ln):
457
+ break
458
+ mm = re.match(r'^\s{2,}\*?\s*([A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:', ln)
459
+ if mm:
460
+ names.add(mm.group(1))
461
+ return names
462
+ return set(_RE_JSDOC_PARAM.findall(doc))
463
+
464
+
465
+ def _comment_doc_vs_code(lens, ctx, out):
466
+ """文档说的和代码做的是不是一回事:形参对不对得上、有没有返回值、
467
+ 名字是不是从别的函数抄来的、该写文档的有没有写、长函数里有没有一句解释。
468
+
469
+ 这是整套体检里最有价值的一组——**过期的注释比没有注释危害大**:
470
+ 它让下一个读者(和下一次的模型)基于错误的前提去改代码,而错误前提是看不见的。
471
+ """
472
+ comment_lines = lens.comment_lines()
473
+ lang = lens.lang
474
+
475
+ for u in lens.functions():
476
+ doc = _doc_text(u.doc)
477
+ # ── C05 文档形参与真实形参对不上 ──
478
+ if doc and ('@param' in doc or ':param' in doc or re.search(r'^\s*(Args|参数)\s*:', doc, re.M)):
479
+ declared = _params_declared(doc, lang)
480
+ fields = set(u.destructured)
481
+ has_destructure = '{}' in u.params
482
+ actual = {p.lstrip('*') for p in u.params} | {'self', 'cls'} | fields
483
+ # JSDoc 记录解构参数的惯例是 `@param {object} p` + `@param p.command`:
484
+ # · 带点的取末段,对照解构字段;
485
+ # · 不带点的那个根名(这里的 p)在签名里根本不存在,是惯例的一部分,放过。
486
+ # 不认这套惯例,每一份写得规范的解构文档都会被判成「写了不存在的形参」——
487
+ # 而这恰恰是最认真写注释的那批代码。
488
+ ghost = []
489
+ for d in declared:
490
+ if '.' in d:
491
+ root, leaf = d.split('.')[0], d.split('.')[-1]
492
+ if root in actual:
493
+ continue # `p` 是真实形参,文档在描述它的字段——签名验证不了,不猜
494
+ if has_destructure and fields and leaf not in fields:
495
+ ghost.append(d)
496
+ elif not has_destructure:
497
+ ghost.append(d)
498
+ continue
499
+ if d in actual:
500
+ continue
501
+ if has_destructure and any(x.startswith(d + '.') for x in declared):
502
+ continue # 它是解构对象的根名,下面用 d.xxx 记录了字段
503
+ ghost.append(d)
504
+ ghost = sorted(set(ghost))
505
+ if ghost:
506
+ _add(out, ctx, 'comment.param-mismatch', 'error', lens, u.start,
507
+ f'`{u.label}` 的文档写了形参 {"、".join(ghost)},但函数签名里没有',
508
+ '形参改过名/删过,注释没跟着改——按现在的签名更新文档,别留一份骗人的说明',
509
+ u.label)
510
+ leafs = {d.split('.')[-1] for d in declared}
511
+ real_params = [p for p in u.params if p != '{}']
512
+ missing = sorted(p for p in real_params
513
+ if p not in ('self', 'cls') and not p.startswith('*')
514
+ and p not in declared and p not in leafs)
515
+ if declared and missing and len(real_params) > 1:
516
+ _add(out, ctx, 'comment.param-missing', 'warn', lens, u.start,
517
+ f'`{u.label}` 的文档漏了形参 {"、".join(missing)}(其它形参都写了)',
518
+ '补上,或者都不写——只写一半会让读者以为漏掉的那个不重要', u.label)
519
+ # ── C05b 文档声称有返回值,函数却从不返回 ──
520
+ if doc and _RE_RETURN_TAG.search(doc) and u.kind != 'class':
521
+ has_ret = any(re.search(r'\breturn\s+\S', ln) for ln in u.body.split('\n'))
522
+ if not has_ret and not u.is_async and 'yield' not in u.body:
523
+ _add(out, ctx, 'comment.return-mismatch', 'error', lens, u.start,
524
+ f'`{u.label}` 的文档写了返回值,但函数体里没有任何 `return 值`',
525
+ '要么补上返回,要么把文档里的返回值说明删掉', u.label)
526
+ # ── C07 文档里写的函数名和实际函数名不一样(复制粘贴没改) ──
527
+ if doc:
528
+ m = re.search(r'^\s*(?:函数\s*)?([A-Za-z_$][\w$]{3,})\s*(?:\(\)|:|:)\s', doc)
529
+ if m and m.group(1) != u.name and m.group(1).lower() not in ('param', 'return', 'returns',
530
+ 'example', 'note', 'todo', 'see'):
531
+ other = next((x for x in lens.units if x.name == m.group(1)), None)
532
+ if other is not None: # 确实是本文件里**另一个**函数的名字 → 复制粘贴残留
533
+ _add(out, ctx, 'comment.name-mismatch', 'error', lens, u.start,
534
+ f'`{u.label}` 的文档开头写的是 `{m.group(1)}`,多半是从那个函数复制过来忘了改',
535
+ '把文档改成描述这个函数自己在做什么', u.label)
536
+ # ── C01 对外可见的函数没有文档 ──
537
+ if not doc and u.is_public and not lens.is_test and u.lines >= 8:
538
+ _add(out, ctx, 'comment.missing-doc', 'warn', lens, u.start,
539
+ f'`{u.label}` 对外可见却没有文档注释({u.lines} 行)',
540
+ '写清楚三件事:做什么、参数含义与约束、返回值/异常;「怎么做」看代码就行,不必写',
541
+ u.label)
542
+ # ── C06 长函数里一条注释都没有 ──
543
+ if u.lines >= 40:
544
+ inner = sum(1 for n in range(u.start, u.end + 1) if n in comment_lines)
545
+ if inner == 0:
546
+ _add(out, ctx, 'comment.no-inline', 'warn', lens, u.start,
547
+ f'`{u.label}` 有 {u.lines} 行却没有一条注释',
548
+ '不必逐行注释——在每个「为什么这么做」的转折处写一句:'
549
+ '选了这个方案而不是那个的原因、绕开了哪个坑、这段的前提是什么', u.label)
550
+
551
+
552
+ def _comment_commented_out(lens, ctx, out):
553
+ """被注释掉的代码:版本库记得住旧代码,留着只会让人不敢动周围的逻辑。"""
554
+ # ── C03 被注释掉的代码 ──
555
+ run_start, run = None, []
556
+ for a, b, kind in sorted(lens.comments):
557
+ ln = lens.text.count('\n', 0, a) + 1
558
+ raw = _doc_text(lens.text[a:b])
559
+ if not raw:
560
+ continue
561
+ first = raw.split('\n')[0].strip()
562
+ # 含中文=这是在讲解,不是被注释掉的代码。真注释掉的死代码不会夹着说明文字。
563
+ looks = bool(_RE_LOOKS_CODE.search(first)) and not re.search(r'[\u4e00-\u9fff]', first)
564
+ if looks:
565
+ if run_start is None:
566
+ run_start = ln
567
+ run.append(ln)
568
+ else:
569
+ if len(run) >= 3:
570
+ _add(out, ctx, 'comment.commented-code', 'warn', lens, run_start,
571
+ f'第 {run_start}–{run[-1]} 行是被注释掉的代码({len(run)} 处)',
572
+ '删掉它——版本库记得住旧代码,注释掉的死代码只会让人不敢动周围的逻辑', None)
573
+ run_start, run = None, []
574
+ if len(run) >= 3:
575
+ _add(out, ctx, 'comment.commented-code', 'warn', lens, run_start,
576
+ f'第 {run_start}–{run[-1]} 行是被注释掉的代码({len(run)} 处)',
577
+ '删掉它——版本库记得住旧代码,注释掉的死代码只会让人不敢动周围的逻辑', None)
578
+
579
+
580
+ def _comment_echo(lens, ctx, out):
581
+ """把下一行代码念了一遍的注释——没有新增信息,纯噪声。"""
582
+ # ── C02 空洞注释:注释内容≈它下面那行代码,等于把代码念了一遍 ──
583
+ for a, b, kind in lens.comments:
584
+ if kind == 'doc':
585
+ continue
586
+ ln = lens.text.count('\n', 0, a) + 1
587
+ txt = _doc_text(lens.text[a:b])
588
+ if not txt or len(txt) > 40 or '\n' in txt:
589
+ continue
590
+ nxt = lens.code_line(ln + 1).strip() or lens.code_line(ln).strip()
591
+ if not nxt:
592
+ continue
593
+ toks = set(re.findall(r'[A-Za-z_$][\w$]{2,}', txt.lower()))
594
+ code_toks = set(re.findall(r'[A-Za-z_$][\w$]{2,}', nxt.lower()))
595
+ if toks and toks <= code_toks and len(toks) >= 1 and len(txt) < 30:
596
+ _add(out, ctx, 'comment.echo', 'info', lens, ln,
597
+ '这条注释只是把下一行代码念了一遍,没有新增信息',
598
+ '删掉,或改写成「为什么」——代码写了做什么,注释该写为什么这么做', _unit_of(lens, ln))
599
+
600
+
601
+ def _comment_todo(lens, ctx, out):
602
+ """没有主、没有期限的 TODO:等于永远不会有人做。"""
603
+ # ── C04 TODO 没有主和期限,等于永远不会有人做 ──
604
+ for a, b, kind in lens.comments:
605
+ raw = lens.text[a:b]
606
+ m = _RE_TODO.search(_doc_text(raw)) or _RE_TODO_CASED.search(_doc_text(raw))
607
+ if not m:
608
+ continue
609
+ ln = lens.text.count('\n', 0, a) + 1
610
+ tail = raw[m.end():m.end() + 120]
611
+ if re.search(r'[((]\s*[\w.@一-龥-]+\s*[))]|@\w+|\d{4}-\d{2}|#\d+', tail):
612
+ continue # 有责任人或单号或日期,算合格
613
+ _add(out, ctx, 'comment.todo-orphan', 'info', lens, ln,
614
+ f'`{m.group(1)}` 没有写清楚谁来做、什么时候做',
615
+ '补成 `TODO(姓名, 2026-10-01): 具体要做什么`,或直接建个单号;无主 TODO 会永久沉底',
616
+ _unit_of(lens, ln))
617
+
618
+
619
+ def check_comment(lens, ctx, out):
620
+ """注释类体检:注释可不可信,而不是注释够不够多。"""
621
+ for rule_group in (_comment_doc_vs_code, _comment_commented_out,
622
+ _comment_echo, _comment_todo):
623
+ rule_group(lens, ctx, out)
624
+
625
+
626
+ # ══ 可读性 ══════════════════════════════════════════════════════════════════
627
+ # 阈值是「该停下来看一眼」的信号,不是「必须拆」的判决。所以除了超得离谱的,
628
+ # 一律 warn/info:把风格偏好判成必修,人就会开始整体无视这个工具。
629
+ _LOOP_VARS = {'i', 'j', 'k', 'n', 'm', 'x', 'y', 'z', '_', 'e', 'f', 'a', 'b', 'c', 'd', 't', 'v', 'q'}
630
+ # 参数名的白名单要比循环变量窄得多:`for (i…)` 里的 i 一望而知,
631
+ # 但一个叫 `q` 的参数在四十行之后没人记得它是 query 还是 queue。
632
+ _OK_SHORT_PARAMS = {'i', 'j', 'k', 'n', 'x', 'y', 'z', '_', 'e'}
633
+ _RE_MAGIC = re.compile(r'(?<![\w.$])(?<!\d)(\d{3,})(?![\w.])')
634
+ _SAFE_MAGIC = {'100', '200', '201', '204', '301', '302', '304', '400', '401', '403', '404',
635
+ '409', '422', '429', '500', '502', '503', '504', '1000', '1024', '360', '255',
636
+ '2026', '3600', '8080'}
637
+
638
+
639
+ def check_readability(lens, ctx, out):
640
+ limit = ctx.max_lines * 2 if lens.is_test else ctx.max_lines
641
+ for u in lens.functions():
642
+ if u.lines > limit:
643
+ _add(out, ctx, 'readability.long-function', 'warn', lens, u.start,
644
+ f'`{u.label}` 有 {u.lines} 行(建议 ≤{limit})',
645
+ '找函数里的「小节」——每一段开头你想写注释的地方,通常就是该抽成独立函数的地方', u.label)
646
+ if u.depth > ctx.max_depth:
647
+ _add(out, ctx, 'readability.deep-nesting', 'warn', lens, u.start,
648
+ f'`{u.label}` 嵌套深达 {u.depth} 层(建议 ≤{ctx.max_depth})',
649
+ '用卫语句提前 return 把异常分支甩掉,主流程回到最外层;或把内层整块抽成函数', u.label)
650
+ if u.complexity > ctx.max_cx:
651
+ _add(out, ctx, 'readability.high-complexity', 'warn', lens, u.start,
652
+ f'`{u.label}` 圈复杂度 {u.complexity}(建议 ≤{ctx.max_cx}),'
653
+ f'要覆盖它至少需要 {u.complexity} 个测试用例',
654
+ '按职责拆分;一长串 if-else 分派可以换成查表/多态', u.label)
655
+ real = [p for p in u.params if p not in ('self', 'cls')]
656
+ if len(real) > ctx.max_params:
657
+ _add(out, ctx, 'readability.too-many-params', 'warn', lens, u.start,
658
+ f'`{u.label}` 有 {len(real)} 个参数(建议 ≤{ctx.max_params})',
659
+ '把同进同出的几个打包成一个选项对象/结构体,调用处也更好读', u.label)
660
+ short = [p for p in real if len(p) == 1 and p not in _OK_SHORT_PARAMS]
661
+ if short and u.lines > 15:
662
+ _add(out, ctx, 'readability.cryptic-param', 'info', lens, u.start,
663
+ f'`{u.label}` 的参数名 {"、".join(short)} 只有一个字母,函数却有 {u.lines} 行',
664
+ '起个说得出含义的名字——读的人不必再往回翻', u.label)
665
+
666
+ if len(lens.lines) > ctx.max_file_lines:
667
+ _add(out, ctx, 'readability.long-file', 'info', lens, 1,
668
+ f'文件 {len(lens.lines)} 行(建议 ≤{ctx.max_file_lines})',
669
+ '按职责拆分模块;先看有没有一组函数只服务于同一件事,它们通常能整体搬走', None)
670
+
671
+ for i, ln in enumerate(lens.lines, 1):
672
+ if len(ln) > ctx.max_line_len:
673
+ _add(out, ctx, 'readability.long-line', 'info', lens, i,
674
+ f'第 {i} 行长 {len(ln)} 字符(建议 ≤{ctx.max_line_len})', '折行或抽出中间变量',
675
+ _unit_of(lens, i))
676
+ break # 一个文件报一次就够,不刷屏
677
+ for i, ln in enumerate(lens.lines, 1):
678
+ if ln != ln.rstrip():
679
+ _add(out, ctx, 'readability.trailing-space', 'info', lens, i, '行尾有多余空白',
680
+ '删掉(编辑器一般可配置保存时自动清理)', None)
681
+ break
682
+
683
+ if not lens.is_test:
684
+ seen_lines = set()
685
+ for i, ln in enumerate(lens.code_lines, 1):
686
+ if len(seen_lines) > 60:
687
+ break
688
+ for m in _RE_MAGIC.finditer(ln):
689
+ num = m.group(1)
690
+ if num in _SAFE_MAGIC or i in seen_lines:
691
+ continue
692
+ if re.search(r'(version|port|timeout|ms|width|height|size|len|max|min)', ln, re.I):
693
+ continue # 上下文自带含义的不算魔法数字
694
+ seen_lines.add(i)
695
+ _add(out, ctx, 'readability.magic-number', 'info', lens, i,
696
+ f'字面量 `{num}` 没有名字,读的人不知道它代表什么',
697
+ '提成有名字的常量,名字本身就是注释', _unit_of(lens, i))
698
+ break
699
+
700
+
701
+ # ══ 结构 ════════════════════════════════════════════════════════════════════
702
+ # 两道后顾断言都是治误报的:盘符前面不能紧挨字母(否则每个 http:// / https:// 的「p:/」「s:/」都像 Windows 盘符),
703
+ # /home 这类目录前面不能紧挨主机名(否则 https://site.com/home/x 也像本机路径)。
704
+ # 实测修之前:内联 SVG 的 xmlns、data:image/svg+xml、fetch('https://…') 全被报成「写死了本机绝对路径」。
705
+ _RE_HARD_PATH = re.compile(r'(?:(?<![\w.-])/(?:Users|home|var|opt|etc|tmp)/[\w./-]{4,}|(?<![A-Za-z0-9])[A-Za-z]:[\\/]{1,2}[\w\\/.-]{4,})')
706
+ _RE_DEBUG_JS = re.compile(r'\bconsole\.(log|debug|dir|trace)\s*\(')
707
+ _RE_DEBUG_PY = re.compile(r'^\s*print\s*\(')
708
+ _RE_DEBUG_JAVA = re.compile(r'\bSystem\.(out|err)\.print')
709
+
710
+
711
+ def check_structure(lens, ctx, out):
712
+ lang = lens.lang
713
+ # ── 硬编码的本机绝对路径:换台机器就崩 ──
714
+ for a, b, q, raw in lens.strings:
715
+ if _RE_HARD_PATH.search(raw):
716
+ ln = lens.text.count('\n', 0, a) + 1
717
+ if lens.is_test or re.search(r'(示例|example|e\.g\.|比如|如 )', lens.line(ln)):
718
+ continue
719
+ _add(out, ctx, 'structure.hardcoded-path', 'warn', lens, ln,
720
+ '写死了本机绝对路径,换一台机器/换一个用户就找不到',
721
+ '改成配置项、环境变量,或基于项目根目录拼相对路径', _unit_of(lens, ln))
722
+
723
+ # ── 调试输出残留 ──
724
+ path_s = str(lens.path).replace('\\', '/')
725
+ if not lens.is_test and not re.search(r'(bin|cli|scripts?|tools?)/', path_s):
726
+ pat = {'py': _RE_DEBUG_PY, 'java': _RE_DEBUG_JAVA}.get(lang, _RE_DEBUG_JS)
727
+ hits = [i for i, ln in enumerate(lens.code_lines, 1) if pat.search(ln)]
728
+ if hits:
729
+ _add(out, ctx, 'structure.debug-left', 'info', lens, hits[0],
730
+ f'有 {len(hits)} 处调试输出残留(第 {"、".join(map(str, hits[:6]))} 行)',
731
+ '删掉,或换成项目的日志组件并给合适的级别', _unit_of(lens, hits[0]))
732
+
733
+ # ── 重复代码块:连续 ≥6 行完全相同(忽略缩进差异) ──
734
+ norm = [re.sub(r'\s+', ' ', ln.strip()) for ln in lens.code_lines]
735
+ window = 6
736
+ seen = {}
737
+ reported = 0
738
+ for i in range(len(norm) - window + 1):
739
+ chunk = norm[i:i + window]
740
+ if sum(1 for c in chunk if c) < window or sum(len(c) for c in chunk) < 90:
741
+ continue
742
+ key = ''.join(chunk)
743
+ if key in seen:
744
+ if i - seen[key] < window:
745
+ continue
746
+ reported += 1
747
+ if reported <= 3:
748
+ _add(out, ctx, 'structure.duplicate-block', 'warn', lens, i + 1,
749
+ f'第 {i + 1} 行起的 {window} 行与第 {seen[key] + 1} 行起的完全相同',
750
+ '抽成一个函数——重复的代码意味着改的时候要记得改两处,而人总会忘一处', None)
751
+ else:
752
+ seen[key] = i
753
+
754
+
755
+ # ══ 测试 ════════════════════════════════════════════════════════════════════
756
+ _RE_ASSERT = re.compile(
757
+ r'\b(assert\w*|expect|should|verify|toBe\w*|toEqual|toThrow|toHaveBeen\w*|'
758
+ r'deepStrictEqual|strictEqual|notStrictEqual|ok|fail|match)\b')
759
+ # 拆成一组独立正则,不拼成一个大 alternation:
760
+ # 合并写法里的反向引用编号会随着前面加了几个组而整体漂移,
761
+ # 改一条就可能悄悄改坏另一条——这种错编译期不报,跑起来才发现规则从来没响过。
762
+ _WEAK_ASSERTS = [
763
+ re.compile(r'\bassert\s*\(\s*(?:true|True|1)\s*[,)]'),
764
+ re.compile(r'\bassertTrue\s*\(\s*true\s*\)'),
765
+ re.compile(r'\bself\.assertTrue\s*\(\s*True\s*\)'),
766
+ re.compile(r'\bexpect\s*\(\s*(true|1)\s*\)\s*\.\s*to\w*\s*\(\s*\1\s*\)'),
767
+ re.compile(r'\bassertEquals\s*\(\s*(\d+)\s*,\s*\1\s*\)'),
768
+ re.compile(r'\bexpect\s*\(\s*([\w.]+)\s*\)\s*\.\s*toBe\s*\(\s*\1\s*\)'),
769
+ ]
770
+ # 两个形态各有用处:BLOCK 在**原文**上定位带标题的测试块(要求引号),
771
+ # CALL 在**掩码视图**上判断块里还有没有嵌套的测试块(引号已被抹掉,不能要求它)。
772
+ _RE_TEST_BLOCK = re.compile(r'(?<![\w.$])(?:it|test)\s*(?:\.\s*\w+\s*)?\(\s*[\'"`]')
773
+ _RE_TEST_CALL = re.compile(r'(?<![\w.$])(?:it|test)\s*(?:\.\s*\w+\s*)?\(')
774
+
775
+
776
+ def _match_paren(code, lparen):
777
+ """从 lparen 处的 '(' 找到配对的 ')';找不到返回 -1。
778
+
779
+ 在掩码视图上配对——测试标题里带括号('读到 (空) 时返回默认值')是常事,
780
+ 在原文上数括号会把块的边界算错,块内容一错,「有没有断言」就判反了。
781
+ """
782
+ depth = 0
783
+ for i in range(lparen, len(code)):
784
+ if code[i] == '(':
785
+ depth += 1
786
+ elif code[i] == ')':
787
+ depth -= 1
788
+ if depth == 0:
789
+ return i
790
+ return -1
791
+
792
+
793
+ _RE_ONLY = re.compile(r'\b(?:it|test|describe|context)\s*\.\s*only\s*\(|\bfdescribe\s*\(|\bfit\s*\(')
794
+ _RE_SKIP = re.compile(r'\b(?:it|test|describe)\s*\.\s*skip\s*\(|@(?:Disabled|Ignore)\b|@pytest\.mark\.skip')
795
+
796
+
797
+ def check_test(lens, ctx, out):
798
+ if not lens.is_test:
799
+ return
800
+ code = lens.code
801
+ # ── .only 残留:CI 会只跑这一个用例,其余全部静默跳过 ──
802
+ for m in _RE_ONLY.finditer(code):
803
+ ln = code.count('\n', 0, m.start()) + 1
804
+ _add(out, ctx, 'test.only-left', 'error', lens, ln,
805
+ '`.only` 残留:整个文件会**只跑这一个用例**,其余全部被跳过且不报错',
806
+ '删掉 `.only`——本地调试用完必须清掉,否则 CI 绿得毫无意义', _unit_of(lens, ln))
807
+ for m in _RE_SKIP.finditer(code):
808
+ ln = code.count('\n', 0, m.start()) + 1
809
+ if re.search(r'(理由|because|reason|待|#\d+)', lens.line(ln) + lens.line(ln - 1), re.I):
810
+ continue
811
+ _add(out, ctx, 'test.skip-no-reason', 'warn', lens, ln,
812
+ '跳过了测试但没有写原因', '注明为什么跳过、什么条件下恢复,或建个单号跟踪', _unit_of(lens, ln))
813
+ # ── 弱断言:断言恒成立,等于没测 ──
814
+ for pat in _WEAK_ASSERTS:
815
+ for m in pat.finditer(code):
816
+ ln = code.count('\n', 0, m.start()) + 1
817
+ _add(out, ctx, 'test.weak-assert', 'error', lens, ln,
818
+ '这个断言恒为真,无论被测代码对错它都会通过',
819
+ '断言真实的输出值/状态变化;断不出东西说明这个用例还没想清楚要验证什么', _unit_of(lens, ln))
820
+ # ── JS 主流写法 it('…', () => {…}) 不是「函数声明」,解析器不会把它当 unit,
821
+ # 但它恰恰是 JS/TS 项目里绝大多数测试的形状。只靠 lens.functions() 遍历,
822
+ # 这条规则在真实的 JS 测试文件上永远不会响——而报告照样显示「已体检」。
823
+ # ⚠ 定位必须在**原文**上做:掩码视图把字符串连同引号一起抹成了空格,
824
+ # 所以 `it\s*\(\s*['"]` 这种依赖引号的模式在 code 上永远匹配不到——
825
+ # 规则加了等于没加,而报告照样显示「已体检」。
826
+ # 掩码与原文等长,偏移量通用:定位用 text,配对与内容判断用 code。
827
+ for m in _RE_TEST_BLOCK.finditer(lens.text):
828
+ if lens.in_literal(m.start()):
829
+ continue # 写在字符串里的测试代码样本,不是本文件的测试
830
+ lparen = lens.text.index('(', m.start())
831
+ close = _match_paren(code, lparen)
832
+ if close < 0:
833
+ continue
834
+ block = code[lparen:close]
835
+ inner = block[block.find(',') + 1:] if ',' in block else ''
836
+ if not inner.strip():
837
+ continue
838
+ if _RE_TEST_CALL.search(inner):
839
+ continue # 外层 describe/嵌套块,断言在内层各 it 里
840
+ if not _RE_ASSERT.search(inner):
841
+ ln = lens.text.count('\n', 0, m.start()) + 1
842
+ named = re.search(r'[\'"`]([^\'"`]*)', lens.text[m.start():m.start() + 200])
843
+ title = (named.group(1).strip() if named else '') or '(未命名)'
844
+ _add(out, ctx, 'test.no-assert', 'error', lens, ln,
845
+ f'测试「{title}」里没有任何断言——它只要不抛异常就算通过',
846
+ '补上对返回值/状态/副作用的断言;只想确认「不抛异常」也要显式写出来', None)
847
+
848
+ # ── 没有断言的测试:跑过了就算过,其实什么都没验证 ──
849
+ for u in lens.functions():
850
+ head = lens.line(u.start) + ' ' + lens.line(u.start - 1)
851
+ looks_test = (u.name or '').startswith('test') or '@Test' in head
852
+ if not looks_test:
853
+ continue
854
+ if not _RE_ASSERT.search(u.body):
855
+ _add(out, ctx, 'test.no-assert', 'error', lens, u.start,
856
+ f'测试 `{u.label}` 里没有任何断言——它只要不抛异常就算通过',
857
+ '补上对返回值/状态/副作用的断言;只想确认「不抛异常」也要显式写出来', u.label)
858
+ if re.match(r'^(test_?\d+|测试\d*|tmp|foo|bar)$', u.name or ''):
859
+ _add(out, ctx, 'test.vague-name', 'info', lens, u.start,
860
+ f'测试名 `{u.name}` 说不出它在验证什么',
861
+ '用「条件_预期」命名,如 `空输入时返回默认值`——失败时光看名字就知道坏了什么', u.label)
862
+
863
+
864
+ def check_coverage(lens, ctx, out):
865
+ """公共函数有没有被测试提到过。
866
+
867
+ 口径是「测试文件里有没有出现这个名字」——这是覆盖的**下界**,不是真覆盖率。
868
+ 所以只报 warn,且报告里必须写明口径;装成精确覆盖率会骗人。
869
+ """
870
+ if lens.is_test or not ctx.has_tests:
871
+ return
872
+ for u in lens.functions():
873
+ if not u.is_public or u.lines < 6 or u.name in ('(匿名)', 'constructor', '__init__'):
874
+ continue
875
+ if u.name in ctx.tested_names:
876
+ continue
877
+ _add(out, ctx, 'test.uncovered', 'warn', lens, u.start,
878
+ f'`{u.label}` 对外可见({u.lines} 行,圈复杂度 {u.complexity})但测试里从没出现过这个名字',
879
+ f'至少补一个正常路径 + 一个边界用例;圈复杂度 {u.complexity} 说明它有 {u.complexity} 条路要走',
880
+ u.label)
881
+
882
+
883
+ # ══ 安全 ════════════════════════════════════════════════════════════════════
884
+ # 这一类误报代价特别高:安全告警一旦被判定为「又在乱叫」,人会连真的那条一起无视。
885
+ # 所以每条规则都带一组排除:占位符、示例、从环境变量取值、纯提示文案。
886
+ # 词表刻意不收裸 `pass` 和裸 `auth`:前者会撞上 Python 的 pass 关键字,
887
+ # 后者会撞上 authState / authorize / authorized 这类遍地都是的普通标识符。
888
+ # 密钥告警一旦被当成噪声,人会连真的那条一起无视——宁可漏一点,不可吵。
889
+ _SECRET_NAME = re.compile(
890
+ r'\b(pass(?:wd|word)|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key'
891
+ r'|credential|auth[_-]?(?:token|key|secret)|appkey|app[_-]?secret|signature)\b', re.I)
892
+ # 明显不是真密钥的值:占位、模板、从配置读、太短
893
+ _PLACEHOLDER = re.compile(
894
+ r'^\s*$|^(x{3,}|\*{3,}|\.{3,}|-{3,})$|your|example|sample|placeholder|dummy|test|demo|fake'
895
+ r'|change[_-]?me|<[^>]+>|\$\{|\{\{|%[sd]|\bnull\b|\bnone\b|请填写|填入|示例|占位',
896
+ re.I)
897
+ _RE_ENV_READ = re.compile(r'(process\.env|os\.getenv|os\.environ|System\.getenv|getProperty|'
898
+ r'@Value|config\.|settings\.|\.env\b)', re.I)
899
+ # SQL 语句特征(掩码后看不到字符串,所以这条必须在**字符串原文**上跑)
900
+ _RE_SQL = re.compile(r'\b(SELECT\s+.+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|WHERE\s+\w+\s*=)\b', re.I)
901
+ _RE_EXEC = re.compile(r'\b(exec|execSync|spawn|spawnSync|system|popen|Runtime\.getRuntime\(\)\.exec'
902
+ r'|ProcessBuilder|os\.system|subprocess\.(?:call|run|Popen|check_output))\s*\(')
903
+ # 右边界同样要卡死:只管左边界的话,`evalService` / `EvalService` 里的 eval 全中招
904
+ # (实测在一个 Spring 后端上一口气刷出 31 条假告警,而 Java 根本没有 eval 语法)。
905
+ _RE_EVAL = re.compile(r'(?<![\w.$])(?:window\.)?(eval)\s*\(|(?<![\w.$])(new\s+Function)\s*\('
906
+ r'|(?<![\w.$])(setTimeout|setInterval)\s*\(\s*[\'"`]')
907
+ # Java 的动态执行是另一套入口
908
+ _RE_EVAL_JAVA = re.compile(r'\b(ScriptEngine|GroovyShell|GroovyClassLoader|Nashorn|'
909
+ r'javax\.script|Class\.forName|MethodHandles\.lookup)\b')
910
+
911
+
912
+ def _line_of(lens, off):
913
+ return lens.text.count('\n', 0, off) + 1
914
+
915
+
916
+ def _sec_level(lens):
917
+ """安全问题在测试代码里降一级。
918
+
919
+ 测试代码不上生产,把它判成「必修」会让必修项列表里混进一堆不影响线上的东西,
920
+ 真正该先改的那条就被埋了。但仍然要报——测试里的注入一样能把 CI 环境打穿。
921
+ """
922
+ return 'warn' if lens.is_test else 'error'
923
+
924
+
925
+ def check_security(lens, ctx, out):
926
+ lang = lens.lang
927
+ # ── 硬编码密钥 ──
928
+ for a, b, q, raw in lens.strings:
929
+ val = raw.strip('\'"`')
930
+ if len(val) < 8 or _PLACEHOLDER.search(val):
931
+ continue
932
+ ln = _line_of(lens, a)
933
+ line_txt = lens.line(ln)
934
+ if _RE_ENV_READ.search(line_txt):
935
+ continue # 值是从环境/配置来的,字面量只是键名或兜底
936
+ # 必须是**紧邻的赋值**:`apiKey: "…"` / `password = "…"` / `"token": "…"`。
937
+ # 放宽成「本行里出现过密钥词」的话,`{ ...authHeaders(apiKey), 'content-type': ct }`
938
+ # 这种一行里提到 apiKey 的正常代码,后面每个字符串都会被报成硬编码密钥。
939
+ col = a - lens.text.rfind('\n', 0, a) - 1
940
+ before = line_txt[:col].rstrip()
941
+ if not re.search(r'[\'"`]?\s*[:=]\s*$', before):
942
+ continue
943
+ name_part = re.sub(r'[\'"`]?\s*[:=]\s*$', '', before)
944
+ owner = re.search(r'([\w$\-]+)[\'"`]?\s*$', name_part)
945
+ if not owner or not _SECRET_NAME.fullmatch(owner.group(1)):
946
+ continue
947
+ if re.search(r'(提示|说明|错误|message|msg|label|title|描述|请|不要|禁止)', before, re.I):
948
+ continue # 这是在讲密钥,不是在存密钥
949
+ _add(out, ctx, 'security.hardcoded-secret', _sec_level(lens), lens, ln,
950
+ '疑似把密钥/口令直接写进了代码',
951
+ '挪到环境变量或密钥管理服务;已经提交过的必须视同泄露,去后台轮换一次', _unit_of(lens, ln))
952
+
953
+ # ── SQL 拼接:参数拼进语句就是注入 ──
954
+ for a, b, q, raw in lens.strings:
955
+ if not _RE_SQL.search(raw):
956
+ continue
957
+ ln = _line_of(lens, a)
958
+ line_txt = lens.line(ln)
959
+ joined = False
960
+ if lang in ('js', 'ts', 'vue'):
961
+ joined = bool(re.search(r'\$\{', raw)) or bool(re.search(r'[\'"]\s*\+|\+\s*[\'"]', line_txt))
962
+ elif lang == 'py':
963
+ joined = bool(re.search(r'^\s*f[\'"]', raw.lstrip()) or re.search(r'\{[\w.\[\]]+\}', raw)) or \
964
+ bool(re.search(r'%\s*[\(\w]|\.format\s*\(|[\'"]\s*\+', line_txt))
965
+ elif lang == 'java':
966
+ joined = bool(re.search(r'[\'"]\s*\+|\+\s*[\'"]', line_txt)) or 'String.format' in line_txt
967
+ if not joined:
968
+ continue
969
+ if re.search(r'\?|:\w+|#\{|%s.*(execute|prepare)', line_txt):
970
+ continue # 用了占位符的参数化写法
971
+ _add(out, ctx, 'security.sql-concat', _sec_level(lens), lens, ln,
972
+ 'SQL 语句里拼接了变量——这是 SQL 注入的标准入口',
973
+ '改用参数化查询(? / :name / #{})把值作为参数传入;表名等确实不能参数化的,用白名单校验',
974
+ _unit_of(lens, ln))
975
+
976
+ # ── 命令注入:拼接出来的命令字符串 ──
977
+ for m in _RE_EXEC.finditer(lens.code):
978
+ ln = _line_of(lens, m.start())
979
+ seg = lens.line(ln)
980
+ if not re.search(r'[\'"`]\s*\+|\+\s*[\'"`]|\$\{|%s|\.format\s*\(|f[\'"]', seg):
981
+ continue
982
+ if re.search(r'shell\s*=\s*False|\[\s*[\'"]', seg):
983
+ continue # 数组参数形式不经 shell,没有注入面
984
+ _add(out, ctx, 'security.command-injection', _sec_level(lens), lens, ln,
985
+ '把变量拼进了要执行的命令字符串',
986
+ '改成数组/列表形式传参(不经 shell 解析);必须走 shell 时对参数做转义', _unit_of(lens, ln))
987
+
988
+ # ── 动态执行:把字符串当代码跑 ──
989
+ pat = _RE_EVAL_JAVA if lang == 'java' else _RE_EVAL
990
+ for m in pat.finditer(lens.code):
991
+ ln = _line_of(lens, m.start())
992
+ what = next((g for g in m.groups() if g), m.group(0)).strip()
993
+ _add(out, ctx, 'security.eval', 'warn', lens, ln,
994
+ f'用了 `{what}`,它会把字符串当代码/类名执行',
995
+ ('反射与脚本引擎的入参若来自外部,等同于任意代码执行——'
996
+ '入参必须走白名单,且不要让调用方决定类名/方法名') if lang == 'java'
997
+ else '几乎总有替代:查表分派、JSON.parse、显式的函数引用', _unit_of(lens, ln))
998
+
999
+ # ── 用不安全随机数生成令牌 ──
1000
+ for i, ln in enumerate(lens.code_lines, 1):
1001
+ if re.search(r'(Math\.random|random\.random|new Random\(\)|rand\(\))', ln) and \
1002
+ _SECRET_NAME.search(ln):
1003
+ _add(out, ctx, 'security.weak-random', 'warn', lens, i,
1004
+ '用普通随机数生成令牌/密钥,它是可预测的',
1005
+ 'JS 用 crypto.randomUUID/randomBytes,Python 用 secrets,Java 用 SecureRandom',
1006
+ _unit_of(lens, i))
1007
+
1008
+
1009
+ # ══ 一致性 ══════════════════════════════════════════════════════════════════
1010
+ def check_consistency(lenses, ctx, out):
1011
+ """跨文件的一致性。单文件体检看不见这些——它们只有把多个文件放在一起才成立。"""
1012
+ by_name = {}
1013
+ for lens in lenses:
1014
+ # Java 跳过:同名不同参在 Java 里是**方法重载**,是语言特性不是缺陷;
1015
+ # 而 Controller 从上下文取 userId 再转调 Service 的分层写法,天然就是「同名多一个参」。
1016
+ # 实测在一个 Spring 后端上这条规则报出 151 条,无一为真。
1017
+ if lens.lang == 'java':
1018
+ continue
1019
+ for u in lens.functions():
1020
+ if u.name in ('(匿名)', 'constructor', '__init__') or not u.is_public:
1021
+ continue
1022
+ by_name.setdefault(u.name, []).append((lens, u))
1023
+ for name, items in by_name.items():
1024
+ if len(items) < 2:
1025
+ continue
1026
+ sigs = {len([p for p in u.params if p not in ('self', 'cls')]) for _, u in items}
1027
+ if len(sigs) > 1 and len(items) <= 4:
1028
+ where = '、'.join(f'{os.path.basename(str(l.path))}:{u.start}({len(u.params)}参)'
1029
+ for l, u in items)
1030
+ lens0, u0 = items[0]
1031
+ _add(out, ctx, 'consistency.signature-drift', 'info', lens0, u0.start,
1032
+ f'`{name}` 在多处定义但参数个数不一致:{where}',
1033
+ '确认是两件不同的事(那就改名),还是改了一处忘了另一处', name)
1034
+
1035
+
1036
+ # ══ 编排与报告 ══════════════════════════════════════════════════════════════
1037
+ CATEGORY_CN = {
1038
+ 'logic': '逻辑', 'comment': '注释', 'readability': '可读性', 'structure': '结构',
1039
+ 'test': '测试', 'security': '安全', 'consistency': '一致性', 'framework': '框架用法',
1040
+ }
1041
+ LEVEL_CN = {'error': '必修', 'warn': '建议', 'info': '提示'}
1042
+
1043
+
1044
+ def _collect_tested_names(paths):
1045
+ """把测试文件里出现过的标识符收成一个集合,用于判「有没有被测到」。
1046
+
1047
+ 口径故意放得很宽(只要名字在测试里出现过就算),因为这条规则宁可漏报也不能误报:
1048
+ 对着一个其实测过的函数说「没测」,人只会关掉这条规则。
1049
+ """
1050
+ names = set()
1051
+ found = False
1052
+ for p in paths:
1053
+ if not L.is_test_path(p):
1054
+ continue
1055
+ found = True
1056
+ try:
1057
+ with open(p, 'r', encoding='utf-8', errors='replace') as f:
1058
+ txt = f.read()
1059
+ except OSError:
1060
+ continue
1061
+ names.update(re.findall(r'[A-Za-z_$][\w$]{2,}', txt))
1062
+ return names, found
1063
+
1064
+
1065
+ def check_files(paths, **opts):
1066
+ """体检一组文件,返回 (issues, lenses, stats)。"""
1067
+ paths = [p for p in paths if os.path.isfile(p)]
1068
+ tested, has_tests = _collect_tested_names(paths)
1069
+ ctx = Ctx(tested_names=tested, has_tests=has_tests, **opts)
1070
+ issues, lenses = [], []
1071
+ degraded = []
1072
+ for p in paths:
1073
+ try:
1074
+ lens = L.read_lens(p)
1075
+ except OSError as e:
1076
+ degraded.append((p, f'读不到:{e}'))
1077
+ continue
1078
+ lenses.append(lens)
1079
+ if not lens.parse_ok:
1080
+ degraded.append((p, lens.parse_error or '解析失败'))
1081
+ check_logic(lens, ctx, issues)
1082
+ check_comment(lens, ctx, issues)
1083
+ check_readability(lens, ctx, issues)
1084
+ check_structure(lens, ctx, issues)
1085
+ check_test(lens, ctx, issues)
1086
+ check_coverage(lens, ctx, issues)
1087
+ check_security(lens, ctx, issues)
1088
+ check_consistency(lenses, ctx, issues)
1089
+ stats = {
1090
+ 'files': len(lenses),
1091
+ 'loc': sum(l.loc for l in lenses),
1092
+ 'units': sum(len(l.functions()) for l in lenses),
1093
+ 'langs': sorted({l.lang for l in lenses}),
1094
+ 'degraded': degraded,
1095
+ 'has_tests': has_tests,
1096
+ 'test_files': sum(1 for l in lenses if l.is_test),
1097
+ }
1098
+ issues.sort(key=lambda x: (LEVELS.index(x.level), str(x.path), x.line))
1099
+ return issues, lenses, stats
1100
+
1101
+
1102
+ def counts_of(issues):
1103
+ c = {k: 0 for k in LEVELS}
1104
+ for i in issues:
1105
+ c[i.level] += 1
1106
+ return c
1107
+
1108
+
1109
+ def render_report(issues, stats, root='.', max_show=60, title='代码体检'):
1110
+ """渲染成给模型和人都能直接读的文本报告。
1111
+
1112
+ 刻意不做成「一堆告警的罗列」:先给结论与必修项,再按类别归并,
1113
+ 最后才是明细。一份看完不知道该先改哪一条的报告,等于没出。
1114
+ """
1115
+ c = counts_of(issues)
1116
+ lines = []
1117
+ head = (f'【{title}】{stats["files"]} 个文件 / {stats["loc"]} 行有效代码 / '
1118
+ f'{stats["units"]} 个函数 · 语言 {"、".join(stats["langs"]) or "—"}')
1119
+ lines.append(head)
1120
+ if c['error'] == 0 and c['warn'] == 0 and c['info'] == 0:
1121
+ scope = '逻辑/注释/可读性/结构/测试/安全/一致性'
1122
+ frameworks = []
1123
+ if 'java' in stats['langs']:
1124
+ frameworks.append('Spring·MyBatis')
1125
+ if 'py' in stats['langs']:
1126
+ frameworks.append('pandas')
1127
+ if frameworks:
1128
+ scope += f'/框架用法({"·".join(frameworks)})'
1129
+ lines.append(f'结论:未发现问题(本体检覆盖 {scope})。')
1130
+ else:
1131
+ lines.append(f'结论:必修 {c["error"]} · 建议 {c["warn"]} · 提示 {c["info"]}'
1132
+ + (' ← 必修项清零前不要说「已完成」' if c['error'] else ''))
1133
+ if stats['degraded']:
1134
+ lines.append(f'⚠ 有 {len(stats["degraded"])} 个文件只做了降级检查(解析未成功),这些文件的逻辑类规则没有跑:')
1135
+ for p, why in stats['degraded'][:5]:
1136
+ lines.append(f' · {os.path.relpath(p, root)}:{why}')
1137
+ if not stats['has_tests']:
1138
+ lines.append('ℹ 这批文件里没有测试文件,因此「测试覆盖」一类未参与体检。')
1139
+
1140
+ by_cat = {}
1141
+ for i in issues:
1142
+ by_cat.setdefault(i.rule.split('.')[0], []).append(i)
1143
+ if by_cat:
1144
+ seg = []
1145
+ for cat in ('logic', 'framework', 'security', 'test', 'comment',
1146
+ 'readability', 'structure', 'consistency'):
1147
+ if cat in by_cat:
1148
+ n = len(by_cat[cat])
1149
+ e = sum(1 for x in by_cat[cat] if x.level == 'error')
1150
+ seg.append(f'{CATEGORY_CN[cat]} {n}' + (f'(必修{e})' if e else ''))
1151
+ lines.append('分类:' + ' · '.join(seg))
1152
+
1153
+ shown = 0
1154
+ for level in LEVELS:
1155
+ group = [i for i in issues if i.level == level]
1156
+ if not group:
1157
+ continue
1158
+ lines.append('')
1159
+ lines.append(f'── {LEVEL_CN[level]}({len(group)})' + ('─' * 40))
1160
+ for i in group:
1161
+ if shown >= max_show:
1162
+ lines.append(f' …… 还有 {len(issues) - shown} 条未展开'
1163
+ f'(用 --json 取全量,或按 --only 分类逐类处理)')
1164
+ return '\n'.join(lines)
1165
+ rel = os.path.relpath(str(i.path), root)
1166
+ where = f'{rel}:{i.line}' + (f' 〔{i.unit}〕' if i.unit else '')
1167
+ lines.append(f' [{i.rule}] {where}')
1168
+ if i.excerpt:
1169
+ lines.append(f' │ {i.excerpt}')
1170
+ lines.append(f' 问题:{i.msg}')
1171
+ if i.hint:
1172
+ lines.append(f' 改法:{i.hint}')
1173
+ shown += 1
1174
+ return '\n'.join(lines)
1175
+
1176
+
1177
+ def list_rules():
1178
+ """从本文件源码里现取规则清单,按类分组。
1179
+
1180
+ 刻意不维护一张手写的规则表:手写的那张迟早和实现对不上,
1181
+ 而一份「说有 47 条、实际 46 条」的清单会让人怀疑整个工具的其它数字。
1182
+ """
1183
+ here = os.path.dirname(os.path.abspath(__file__))
1184
+ found = {}
1185
+ # **动态发现**所有规则文件,不写死文件名。
1186
+ # 这个清单被写死过两次,每次加一门语言(code_java.py、code_python.py)就漏一次,
1187
+ # 症状是「--rules 说 46 条、压测台说 78 条」这类自相矛盾——
1188
+ # 同一个错误重复出现,说明问题不在忘了改,而在这个清单本来就不该由人维护。
1189
+ # 压测台的 declared_rules() 也直接调用本函数,全程只有这一处实现。
1190
+ for name in sorted(os.listdir(here)):
1191
+ if not (name.startswith('code_') and name.endswith('.py')):
1192
+ continue
1193
+ if name in ('code_battery.py', 'code_lens.py'):
1194
+ continue # 压测台与一把尺不声明规则
1195
+ path = os.path.join(here, name)
1196
+ if not os.path.exists(path):
1197
+ continue
1198
+ with open(path, encoding='utf-8') as f:
1199
+ text = f.read()
1200
+ for m in re.finditer(
1201
+ r"\b(?:_add|add)\(out, ctx, '([a-z]+\.[a-z-]+)',\s*([_a-z'()\w]+)", text):
1202
+ rule, level = m.group(1), m.group(2).strip("'")
1203
+ found.setdefault(rule, level.replace('_sec_level(lens)', 'error/warn'))
1204
+ out = {}
1205
+ for rule, level in sorted(found.items()):
1206
+ out.setdefault(rule.split('.')[0], []).append((rule, level))
1207
+ return out
1208
+
1209
+
1210
+ def main(argv):
1211
+ import json
1212
+ args = [a for a in argv if not a.startswith('--')]
1213
+ flags = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) for a in argv if a.startswith('--')}
1214
+ # 收多个目标:回合收尾自检会把本回合改过的文件一次性传进来,
1215
+ # 一次进程跑完全部,比每个文件起一次 Python 快一个数量级(也才谈得上「顺手查一遍」)。
1216
+ if flags.get('--rules'):
1217
+ groups = list_rules()
1218
+ total = sum(len(v) for v in groups.values())
1219
+ print(f'代码体检规则清单(共 {total} 条,从实现里现取)\n')
1220
+ for cat in ('logic', 'comment', 'readability', 'structure', 'test', 'security', 'consistency'):
1221
+ if cat not in groups:
1222
+ continue
1223
+ print(f'── {CATEGORY_CN[cat]} {cat}({len(groups[cat])})')
1224
+ for rule, level in groups[cat]:
1225
+ mark = {'error': '必修', 'warn': '建议', 'info': '提示'}.get(level, level)
1226
+ print(f' [{mark}] {rule}' + (' ← 可自动修' if rule in FIXABLE else ''))
1227
+ print()
1228
+ print('可自动修的只有:' + '、'.join(sorted(FIXABLE)))
1229
+ return 0
1230
+ targets = args or ['.']
1231
+ paths = []
1232
+ for t in targets:
1233
+ paths.extend(L.walk_files(t) if os.path.isdir(t) else [t])
1234
+ seen = set()
1235
+ paths = [p for p in paths if not (p in seen or seen.add(p))]
1236
+ first = targets[0]
1237
+ root = first if os.path.isdir(first) else (os.path.dirname(os.path.abspath(first)) or '.')
1238
+ if not paths:
1239
+ out = {'error': f'{first} 下没有找到可体检的源文件'}
1240
+ print(json.dumps(out, ensure_ascii=False))
1241
+ return 2
1242
+ opts = {}
1243
+ if flags.get('--strict'):
1244
+ opts['strict'] = True
1245
+ if isinstance(flags.get('--only'), str):
1246
+ opts['only'] = flags['--only'].split(',')
1247
+ if isinstance(flags.get('--skip'), str):
1248
+ opts['skip'] = flags['--skip'].split(',')
1249
+ for key, name in (('--max-lines', 'max_lines'), ('--max-depth', 'max_depth'),
1250
+ ('--max-cx', 'max_cx'), ('--max-params', 'max_params')):
1251
+ if isinstance(flags.get(key), str):
1252
+ opts[name] = int(flags[key])
1253
+ issues, lenses, stats = check_files(paths, **opts)
1254
+ if flags.get('--json'):
1255
+ print(json.dumps({
1256
+ 'counts': counts_of(issues),
1257
+ 'stats': {k: v for k, v in stats.items() if k != 'degraded'},
1258
+ 'degraded': [{'path': p, 'why': w} for p, w in stats['degraded']],
1259
+ 'issues': [i.to_dict() for i in issues],
1260
+ 'report': render_report(issues, stats, root),
1261
+ }, ensure_ascii=False))
1262
+ else:
1263
+ print(render_report(issues, stats, root,
1264
+ max_show=int(flags['--max']) if isinstance(flags.get('--max'), str) else 60))
1265
+ return 1 if counts_of(issues)['error'] else 0
1266
+
1267
+
1268
+ if __name__ == '__main__':
1269
+ sys.exit(main(sys.argv[1:]))