octocode-cli 1.2.6 → 1.2.8

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 (303) hide show
  1. package/LICENSE +21 -63
  2. package/README.md +85 -142
  3. package/out/octocode-cli.js +7063 -6934
  4. package/package.json +8 -6
  5. package/skills/README.md +97 -120
  6. package/skills/octocode-code-engineer/.claude/settings.local.json +18 -0
  7. package/skills/octocode-code-engineer/.octocode/rfc/RFC-code-engineer-weakness-fixes.md +255 -0
  8. package/skills/octocode-code-engineer/.plan/VALIDATED_PLAN.md +223 -0
  9. package/skills/octocode-code-engineer/README.md +178 -0
  10. package/skills/octocode-code-engineer/SKILL.md +418 -0
  11. package/skills/octocode-code-engineer/coverage/architecture.ts.html +7828 -0
  12. package/skills/octocode-code-engineer/coverage/ast-helpers.ts.html +211 -0
  13. package/skills/octocode-code-engineer/coverage/ast-search.ts.html +1795 -0
  14. package/skills/octocode-code-engineer/coverage/base.css +224 -0
  15. package/skills/octocode-code-engineer/coverage/block-navigation.js +87 -0
  16. package/skills/octocode-code-engineer/coverage/cache.ts.html +376 -0
  17. package/skills/octocode-code-engineer/coverage/cli.ts.html +982 -0
  18. package/skills/octocode-code-engineer/coverage/clover.xml +3217 -0
  19. package/skills/octocode-code-engineer/coverage/collect-effects.ts.html +664 -0
  20. package/skills/octocode-code-engineer/coverage/collect-input-sources.ts.html +577 -0
  21. package/skills/octocode-code-engineer/coverage/collect-performance.ts.html +331 -0
  22. package/skills/octocode-code-engineer/coverage/collect-prototype-pollution.ts.html +421 -0
  23. package/skills/octocode-code-engineer/coverage/collect-security.ts.html +604 -0
  24. package/skills/octocode-code-engineer/coverage/collect-test-profile.ts.html +589 -0
  25. package/skills/octocode-code-engineer/coverage/coverage-final.json +30 -0
  26. package/skills/octocode-code-engineer/coverage/dependencies.ts.html +997 -0
  27. package/skills/octocode-code-engineer/coverage/dependency-summary.ts.html +688 -0
  28. package/skills/octocode-code-engineer/coverage/discovery.ts.html +322 -0
  29. package/skills/octocode-code-engineer/coverage/favicon.png +0 -0
  30. package/skills/octocode-code-engineer/coverage/graph-analytics.ts.html +1510 -0
  31. package/skills/octocode-code-engineer/coverage/index.html +536 -0
  32. package/skills/octocode-code-engineer/coverage/index.ts.html +826 -0
  33. package/skills/octocode-code-engineer/coverage/metrics.ts.html +553 -0
  34. package/skills/octocode-code-engineer/coverage/pipeline.ts.html +2044 -0
  35. package/skills/octocode-code-engineer/coverage/prettify.css +1 -0
  36. package/skills/octocode-code-engineer/coverage/prettify.js +2 -0
  37. package/skills/octocode-code-engineer/coverage/report-analysis.ts.html +1570 -0
  38. package/skills/octocode-code-engineer/coverage/report-writer.ts.html +1102 -0
  39. package/skills/octocode-code-engineer/coverage/security-detectors.ts.html +1747 -0
  40. package/skills/octocode-code-engineer/coverage/semantic-detectors.ts.html +2152 -0
  41. package/skills/octocode-code-engineer/coverage/semantic.ts.html +1897 -0
  42. package/skills/octocode-code-engineer/coverage/sort-arrow-sprite.png +0 -0
  43. package/skills/octocode-code-engineer/coverage/sorter.js +210 -0
  44. package/skills/octocode-code-engineer/coverage/summary-md.ts.html +1222 -0
  45. package/skills/octocode-code-engineer/coverage/test-quality-detectors.ts.html +1039 -0
  46. package/skills/octocode-code-engineer/coverage/tree-sitter-analyzer.ts.html +955 -0
  47. package/skills/octocode-code-engineer/coverage/ts-analyzer.ts.html +1213 -0
  48. package/skills/octocode-code-engineer/coverage/types.ts.html +2473 -0
  49. package/skills/octocode-code-engineer/coverage/utils.ts.html +820 -0
  50. package/skills/octocode-code-engineer/eslint.config.mjs +54 -0
  51. package/skills/octocode-code-engineer/minify-scripts.mjs +32 -0
  52. package/skills/octocode-code-engineer/package.json +54 -0
  53. package/skills/octocode-code-engineer/references/agent-ast-reading-rfc.md +95 -0
  54. package/skills/octocode-code-engineer/references/architecture-techniques.md +121 -0
  55. package/skills/octocode-code-engineer/references/ast-search.md +210 -0
  56. package/skills/octocode-code-engineer/references/ast-tree-search.md +151 -0
  57. package/skills/octocode-code-engineer/references/cli-reference.md +167 -0
  58. package/skills/octocode-code-engineer/references/concepts.md +107 -0
  59. package/skills/octocode-code-engineer/references/finding-categories.md +128 -0
  60. package/skills/octocode-code-engineer/references/improvement-roadmap.md +304 -0
  61. package/skills/octocode-code-engineer/references/output-files.md +144 -0
  62. package/skills/octocode-code-engineer/references/playbooks.md +204 -0
  63. package/skills/octocode-code-engineer/references/present-results.md +136 -0
  64. package/skills/octocode-code-engineer/references/tool-workflows.md +566 -0
  65. package/skills/octocode-code-engineer/references/validate-investigate.md +225 -0
  66. package/skills/octocode-code-engineer/scripts/analysis/dependencies.js +1 -0
  67. package/skills/octocode-code-engineer/scripts/analysis/dependency-summary.js +1 -0
  68. package/skills/octocode-code-engineer/scripts/analysis/discovery.js +1 -0
  69. package/skills/octocode-code-engineer/scripts/analysis/graph-analytics.js +1 -0
  70. package/skills/octocode-code-engineer/scripts/analysis/semantic.js +1 -0
  71. package/skills/octocode-code-engineer/scripts/ast/helpers.js +1 -0
  72. package/skills/octocode-code-engineer/scripts/ast/metrics.js +1 -0
  73. package/skills/octocode-code-engineer/scripts/ast/search.js +2 -0
  74. package/skills/octocode-code-engineer/scripts/ast/tree-search.js +2 -0
  75. package/skills/octocode-code-engineer/scripts/ast/tree-sitter.js +1 -0
  76. package/skills/octocode-code-engineer/scripts/ast/ts-analyzer.js +1 -0
  77. package/skills/octocode-code-engineer/scripts/collectors/chains.js +1 -0
  78. package/skills/octocode-code-engineer/scripts/collectors/effects.js +1 -0
  79. package/skills/octocode-code-engineer/scripts/collectors/input-sources.js +1 -0
  80. package/skills/octocode-code-engineer/scripts/collectors/performance.js +1 -0
  81. package/skills/octocode-code-engineer/scripts/collectors/prototype-pollution.js +1 -0
  82. package/skills/octocode-code-engineer/scripts/collectors/security.js +1 -0
  83. package/skills/octocode-code-engineer/scripts/collectors/test-profile.js +1 -0
  84. package/skills/octocode-code-engineer/scripts/common/is-direct-run.js +1 -0
  85. package/skills/octocode-code-engineer/scripts/common/utils.js +1 -0
  86. package/skills/octocode-code-engineer/scripts/detectors/code-quality.js +1 -0
  87. package/skills/octocode-code-engineer/scripts/detectors/cohesion.js +1 -0
  88. package/skills/octocode-code-engineer/scripts/detectors/coupling.js +1 -0
  89. package/skills/octocode-code-engineer/scripts/detectors/cycle.js +1 -0
  90. package/skills/octocode-code-engineer/scripts/detectors/dead-code.js +1 -0
  91. package/skills/octocode-code-engineer/scripts/detectors/import-style.js +1 -0
  92. package/skills/octocode-code-engineer/scripts/detectors/index.js +1 -0
  93. package/skills/octocode-code-engineer/scripts/detectors/security.js +1 -0
  94. package/skills/octocode-code-engineer/scripts/detectors/semantic.js +1 -0
  95. package/skills/octocode-code-engineer/scripts/detectors/shared.js +1 -0
  96. package/skills/octocode-code-engineer/scripts/detectors/test-quality.js +1 -0
  97. package/skills/octocode-code-engineer/scripts/index.js +1 -0
  98. package/skills/octocode-code-engineer/scripts/pipeline/cache.js +1 -0
  99. package/skills/octocode-code-engineer/scripts/pipeline/cli.js +1 -0
  100. package/skills/octocode-code-engineer/scripts/pipeline/main.js +2 -0
  101. package/skills/octocode-code-engineer/scripts/reporting/analysis.js +1 -0
  102. package/skills/octocode-code-engineer/scripts/reporting/summary-md.js +1 -0
  103. package/skills/octocode-code-engineer/scripts/reporting/writer.js +1 -0
  104. package/skills/octocode-code-engineer/scripts/types/constants.js +1 -0
  105. package/skills/octocode-code-engineer/scripts/types/index.js +1 -0
  106. package/skills/octocode-code-engineer/scripts/types/interfaces.js +1 -0
  107. package/skills/octocode-code-engineer/src/analysis/dependencies.test.ts +545 -0
  108. package/skills/octocode-code-engineer/src/analysis/dependencies.ts +406 -0
  109. package/skills/octocode-code-engineer/src/analysis/dependency-summary.test.ts +566 -0
  110. package/skills/octocode-code-engineer/src/analysis/dependency-summary.ts +257 -0
  111. package/skills/octocode-code-engineer/src/analysis/discovery.test.ts +420 -0
  112. package/skills/octocode-code-engineer/src/analysis/discovery.ts +87 -0
  113. package/skills/octocode-code-engineer/src/analysis/graph-analytics.test.ts +449 -0
  114. package/skills/octocode-code-engineer/src/analysis/graph-analytics.ts +534 -0
  115. package/skills/octocode-code-engineer/src/analysis/semantic.test.ts +1533 -0
  116. package/skills/octocode-code-engineer/src/analysis/semantic.ts +830 -0
  117. package/skills/octocode-code-engineer/src/ast/helpers.test.ts +185 -0
  118. package/skills/octocode-code-engineer/src/ast/helpers.ts +62 -0
  119. package/skills/octocode-code-engineer/src/ast/metrics.test.ts +304 -0
  120. package/skills/octocode-code-engineer/src/ast/metrics.ts +204 -0
  121. package/skills/octocode-code-engineer/src/ast/search.test.ts +647 -0
  122. package/skills/octocode-code-engineer/src/ast/search.ts +648 -0
  123. package/skills/octocode-code-engineer/src/ast/tree-search.test.ts +199 -0
  124. package/skills/octocode-code-engineer/src/ast/tree-search.ts +392 -0
  125. package/skills/octocode-code-engineer/src/ast/tree-sitter.test.ts +407 -0
  126. package/skills/octocode-code-engineer/src/ast/tree-sitter.ts +402 -0
  127. package/skills/octocode-code-engineer/src/ast/ts-analyzer.test.ts +1864 -0
  128. package/skills/octocode-code-engineer/src/ast/ts-analyzer.ts +509 -0
  129. package/skills/octocode-code-engineer/src/collectors/chains.ts +74 -0
  130. package/skills/octocode-code-engineer/src/collectors/effects.test.ts +490 -0
  131. package/skills/octocode-code-engineer/src/collectors/effects.ts +332 -0
  132. package/skills/octocode-code-engineer/src/collectors/input-sources.test.ts +144 -0
  133. package/skills/octocode-code-engineer/src/collectors/input-sources.ts +196 -0
  134. package/skills/octocode-code-engineer/src/collectors/performance.test.ts +82 -0
  135. package/skills/octocode-code-engineer/src/collectors/performance.ts +141 -0
  136. package/skills/octocode-code-engineer/src/collectors/prototype-pollution.test.ts +55 -0
  137. package/skills/octocode-code-engineer/src/collectors/prototype-pollution.ts +162 -0
  138. package/skills/octocode-code-engineer/src/collectors/security.test.ts +124 -0
  139. package/skills/octocode-code-engineer/src/collectors/security.ts +309 -0
  140. package/skills/octocode-code-engineer/src/collectors/test-profile.test.ts +97 -0
  141. package/skills/octocode-code-engineer/src/collectors/test-profile.ts +269 -0
  142. package/skills/octocode-code-engineer/src/common/is-direct-run.test.ts +32 -0
  143. package/skills/octocode-code-engineer/src/common/is-direct-run.ts +13 -0
  144. package/skills/octocode-code-engineer/src/common/utils.test.ts +463 -0
  145. package/skills/octocode-code-engineer/src/common/utils.ts +304 -0
  146. package/skills/octocode-code-engineer/src/detectors/code-quality.ts +966 -0
  147. package/skills/octocode-code-engineer/src/detectors/cohesion.ts +539 -0
  148. package/skills/octocode-code-engineer/src/detectors/coupling.ts +323 -0
  149. package/skills/octocode-code-engineer/src/detectors/cycle.ts +349 -0
  150. package/skills/octocode-code-engineer/src/detectors/dead-code.ts +320 -0
  151. package/skills/octocode-code-engineer/src/detectors/import-style.ts +376 -0
  152. package/skills/octocode-code-engineer/src/detectors/index.test.ts +3061 -0
  153. package/skills/octocode-code-engineer/src/detectors/index.ts +88 -0
  154. package/skills/octocode-code-engineer/src/detectors/security.test.ts +882 -0
  155. package/skills/octocode-code-engineer/src/detectors/security.ts +821 -0
  156. package/skills/octocode-code-engineer/src/detectors/semantic.ts +758 -0
  157. package/skills/octocode-code-engineer/src/detectors/shared.ts +49 -0
  158. package/skills/octocode-code-engineer/src/detectors/test-quality.test.ts +388 -0
  159. package/skills/octocode-code-engineer/src/detectors/test-quality.ts +367 -0
  160. package/skills/octocode-code-engineer/src/index.test.ts +4425 -0
  161. package/skills/octocode-code-engineer/src/index.ts +403 -0
  162. package/skills/octocode-code-engineer/src/pipeline/cache.test.ts +199 -0
  163. package/skills/octocode-code-engineer/src/pipeline/cache.ts +130 -0
  164. package/skills/octocode-code-engineer/src/pipeline/cli.test.ts +493 -0
  165. package/skills/octocode-code-engineer/src/pipeline/cli.ts +344 -0
  166. package/skills/octocode-code-engineer/src/pipeline/main.test.ts +174 -0
  167. package/skills/octocode-code-engineer/src/pipeline/main.ts +1074 -0
  168. package/skills/octocode-code-engineer/src/pipeline.test.ts +84 -0
  169. package/skills/octocode-code-engineer/src/reporting/analysis.test.ts +782 -0
  170. package/skills/octocode-code-engineer/src/reporting/analysis.ts +688 -0
  171. package/skills/octocode-code-engineer/src/reporting/output-contract.test.ts +463 -0
  172. package/skills/octocode-code-engineer/src/reporting/summary-md.test.ts +421 -0
  173. package/skills/octocode-code-engineer/src/reporting/summary-md.ts +714 -0
  174. package/skills/octocode-code-engineer/src/reporting/writer.ts +430 -0
  175. package/skills/octocode-code-engineer/src/sanity.test.ts +47 -0
  176. package/skills/octocode-code-engineer/src/types/constants.ts +248 -0
  177. package/skills/octocode-code-engineer/src/types/index.ts +80 -0
  178. package/skills/octocode-code-engineer/src/types/interfaces.ts +682 -0
  179. package/skills/octocode-code-engineer/tsconfig.json +17 -0
  180. package/skills/octocode-code-engineer/vitest.config.ts +8 -0
  181. package/skills/octocode-documentation-writer/README.md +113 -0
  182. package/skills/octocode-documentation-writer/SKILL.md +886 -0
  183. package/skills/octocode-documentation-writer/references/agent-discovery-analysis.md +453 -0
  184. package/skills/octocode-documentation-writer/references/agent-documentation-writer.md +255 -0
  185. package/skills/octocode-documentation-writer/references/agent-engineer-questions.md +247 -0
  186. package/skills/octocode-documentation-writer/references/agent-orchestrator.md +370 -0
  187. package/skills/octocode-documentation-writer/references/agent-qa-validator.md +227 -0
  188. package/skills/octocode-documentation-writer/references/agent-researcher.md +250 -0
  189. package/skills/octocode-documentation-writer/schemas/analysis-schema.json +886 -0
  190. package/skills/octocode-documentation-writer/schemas/discovery-tasks.json +96 -0
  191. package/skills/octocode-documentation-writer/schemas/documentation-structure.json +373 -0
  192. package/skills/octocode-documentation-writer/schemas/partial-discovery-schema.json +102 -0
  193. package/skills/octocode-documentation-writer/schemas/partial-research-schema.json +98 -0
  194. package/skills/octocode-documentation-writer/schemas/qa-results-schema.json +113 -0
  195. package/skills/octocode-documentation-writer/schemas/questions-schema.json +228 -0
  196. package/skills/octocode-documentation-writer/schemas/research-schema.json +104 -0
  197. package/skills/octocode-documentation-writer/schemas/state-schema.json +222 -0
  198. package/skills/octocode-documentation-writer/schemas/work-assignments-schema.json +74 -0
  199. package/skills/octocode-plan/SKILL.md +122 -116
  200. package/skills/octocode-prompt-optimizer/SKILL.md +617 -0
  201. package/skills/octocode-pull-request-reviewer/README.md +249 -0
  202. package/skills/octocode-pull-request-reviewer/SKILL.md +479 -0
  203. package/skills/octocode-pull-request-reviewer/references/dependency-check.md +74 -0
  204. package/skills/octocode-pull-request-reviewer/references/domain-reviewers.md +24 -0
  205. package/skills/octocode-pull-request-reviewer/references/execution-lifecycle.md +441 -0
  206. package/skills/octocode-pull-request-reviewer/references/flow-analysis-protocol.md +64 -0
  207. package/skills/octocode-pull-request-reviewer/references/output-template.md +174 -0
  208. package/skills/octocode-pull-request-reviewer/references/parallel-agent-protocol.md +182 -0
  209. package/skills/octocode-pull-request-reviewer/references/review-guidelines.md +26 -0
  210. package/skills/octocode-pull-request-reviewer/references/verification-checklist.md +40 -0
  211. package/skills/octocode-research/.claude/settings.local.json +46 -0
  212. package/skills/octocode-research/.octocode/plan/code-review-fixes/plan.md +312 -0
  213. package/skills/octocode-research/.octocode/plan/code-review-fixes/research.md +212 -0
  214. package/skills/octocode-research/.octocode/plans/NODE_SERVER_START_PLAN.md +755 -0
  215. package/skills/octocode-research/.octocode/research/code-review/research.md +371 -0
  216. package/skills/octocode-research/.octocode/review/IMPROVEMENTS.md +391 -0
  217. package/skills/octocode-research/.octocode/review/REVIEW_PLAN.md +289 -0
  218. package/skills/octocode-research/.octocode/review/REVIEW_REPORT.md +356 -0
  219. package/skills/octocode-research/AGENTS.md +349 -0
  220. package/skills/octocode-research/README.md +494 -0
  221. package/skills/octocode-research/SKILL.md +652 -274
  222. package/skills/octocode-research/docs/API_REFERENCE.md +562 -0
  223. package/skills/octocode-research/docs/ARCHITECTURE.md +554 -0
  224. package/skills/octocode-research/docs/FLOWS.md +577 -0
  225. package/skills/octocode-research/docs/OVERVIEW.md +564 -0
  226. package/skills/octocode-research/docs/SERVER_FLOWS.md +631 -0
  227. package/skills/octocode-research/ecosystem.config.cjs +88 -0
  228. package/skills/octocode-research/eslint.config.mjs +27 -0
  229. package/skills/octocode-research/package.json +84 -0
  230. package/skills/octocode-research/references/GUARDRAILS.md +40 -0
  231. package/skills/octocode-research/references/PARALLEL_AGENT_PROTOCOL.md +178 -0
  232. package/skills/octocode-research/references/roast-prompt.md +149 -0
  233. package/skills/octocode-research/scripts/server-init.d.ts +2 -0
  234. package/skills/octocode-research/scripts/server-init.js +2 -0
  235. package/skills/octocode-research/scripts/server.d.ts +8 -0
  236. package/skills/octocode-research/scripts/server.js +445 -0
  237. package/skills/octocode-research/src/__tests__/integration/circuitBreaker.test.ts +205 -0
  238. package/skills/octocode-research/src/__tests__/integration/routes.test.ts +374 -0
  239. package/skills/octocode-research/src/__tests__/unit/circuitBreaker.test.ts +245 -0
  240. package/skills/octocode-research/src/__tests__/unit/errorHandler.test.ts +183 -0
  241. package/skills/octocode-research/src/__tests__/unit/httpPreprocess.test.ts +157 -0
  242. package/skills/octocode-research/src/__tests__/unit/logger.test.ts +143 -0
  243. package/skills/octocode-research/src/__tests__/unit/queryParser.test.ts +130 -0
  244. package/skills/octocode-research/src/__tests__/unit/responseBuilder.test.ts +469 -0
  245. package/skills/octocode-research/src/__tests__/unit/retry.test.ts +205 -0
  246. package/skills/octocode-research/src/index.ts +186 -0
  247. package/skills/octocode-research/src/mcpCache.ts +49 -0
  248. package/skills/octocode-research/src/middleware/errorHandler.ts +65 -0
  249. package/skills/octocode-research/src/middleware/logger.ts +61 -0
  250. package/skills/octocode-research/src/middleware/queryParser.ts +115 -0
  251. package/skills/octocode-research/src/middleware/readiness.ts +17 -0
  252. package/skills/octocode-research/src/routes/github.ts +197 -0
  253. package/skills/octocode-research/src/routes/local.ts +175 -0
  254. package/skills/octocode-research/src/routes/lsp.ts +177 -0
  255. package/skills/octocode-research/src/routes/package.ts +127 -0
  256. package/skills/octocode-research/src/routes/prompts.ts +138 -0
  257. package/skills/octocode-research/src/routes/tools.ts +677 -0
  258. package/skills/octocode-research/src/server-init.ts +363 -0
  259. package/skills/octocode-research/src/server.ts +285 -0
  260. package/skills/octocode-research/src/types/errorGuards.ts +151 -0
  261. package/skills/octocode-research/src/types/express.d.ts +76 -0
  262. package/skills/octocode-research/src/types/guards.ts +98 -0
  263. package/skills/octocode-research/src/types/mcp.ts +119 -0
  264. package/skills/octocode-research/src/types/responses.ts +199 -0
  265. package/skills/octocode-research/src/types/toolTypes.ts +33 -0
  266. package/skills/octocode-research/src/utils/asyncTimeout.ts +116 -0
  267. package/skills/octocode-research/src/utils/circuitBreaker.ts +492 -0
  268. package/skills/octocode-research/src/utils/colors.ts +53 -0
  269. package/skills/octocode-research/src/utils/errorQueue.ts +71 -0
  270. package/skills/octocode-research/src/utils/logEmoji.ts +103 -0
  271. package/skills/octocode-research/src/utils/logger.ts +413 -0
  272. package/skills/octocode-research/src/utils/resilience.ts +169 -0
  273. package/skills/octocode-research/src/utils/responseBuilder.ts +495 -0
  274. package/skills/octocode-research/src/utils/responseFactory.ts +100 -0
  275. package/skills/octocode-research/src/utils/responseParser.ts +272 -0
  276. package/skills/octocode-research/src/utils/retry.ts +280 -0
  277. package/skills/octocode-research/src/utils/routeFactory.ts +117 -0
  278. package/skills/octocode-research/src/utils/url.ts +20 -0
  279. package/skills/octocode-research/src/validation/httpPreprocess.ts +155 -0
  280. package/skills/octocode-research/src/validation/index.ts +2 -0
  281. package/skills/octocode-research/src/validation/schemas.ts +578 -0
  282. package/skills/octocode-research/src/validation/toolCallSchema.ts +132 -0
  283. package/skills/octocode-research/tsconfig.json +21 -0
  284. package/skills/octocode-research/tsdown.config.ts +42 -0
  285. package/skills/octocode-research/vitest.config.ts +20 -0
  286. package/skills/octocode-researcher/SKILL.md +461 -0
  287. package/skills/octocode-researcher/references/fallbacks.md +120 -0
  288. package/skills/{octocode-local-search → octocode-researcher}/references/tool-reference.md +132 -49
  289. package/skills/{octocode-local-search → octocode-researcher}/references/workflow-patterns.md +204 -4
  290. package/skills/octocode-rfc-generator/SKILL.md +223 -0
  291. package/skills/octocode-rfc-generator/references/rfc-template.md +193 -0
  292. package/skills/octocode-roast/SKILL.md +63 -21
  293. package/skills/octocode-implement/SKILL.md +0 -293
  294. package/skills/octocode-implement/references/execution-phases.md +0 -317
  295. package/skills/octocode-implement/references/tool-reference.md +0 -403
  296. package/skills/octocode-implement/references/workflow-patterns.md +0 -385
  297. package/skills/octocode-local-search/SKILL.md +0 -449
  298. package/skills/octocode-pr-review/SKILL.md +0 -391
  299. package/skills/octocode-pr-review/references/domain-reviewers.md +0 -105
  300. package/skills/octocode-pr-review/references/execution-lifecycle.md +0 -116
  301. package/skills/octocode-pr-review/references/research-flows.md +0 -75
  302. package/skills/octocode-research/references/tool-reference.md +0 -304
  303. package/skills/octocode-research/references/workflow-patterns.md +0 -325
@@ -0,0 +1,4425 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+
7
+ import { isLikelyEntrypoint } from './detectors/index.js';
8
+ import {
9
+ ARCHITECTURE_CATEGORIES,
10
+ CODE_QUALITY_CATEGORIES,
11
+ DEAD_CODE_CATEGORIES,
12
+ SECURITY_CATEGORIES,
13
+ TEST_QUALITY_CATEGORIES,
14
+ buildIssueCatalog,
15
+ categoryBreakdown,
16
+ collectTagCloud,
17
+ computeDependencyCriticalPaths,
18
+ computeDependencyCycles,
19
+ computeHealthScore,
20
+ diverseTopRecommendations,
21
+ diversifyFindings,
22
+ generateSummaryMd,
23
+ severityBreakdown,
24
+ writeMultiFileReport,
25
+ } from './index.js';
26
+ import { DEFAULT_OPTS, PILLAR_CATEGORIES } from './types/index.js';
27
+
28
+ import type { FullReport } from './index.js';
29
+ import type {
30
+ DependencyProfile,
31
+ DependencyState,
32
+ DependencySummary,
33
+ DuplicateGroup,
34
+ FileCriticality,
35
+ FileEntry,
36
+ Finding,
37
+ FunctionEntry,
38
+ } from './types/index.js';
39
+
40
+ function emptyState(): DependencyState {
41
+ return {
42
+ files: new Set(),
43
+ outgoing: new Map(),
44
+ incoming: new Map(),
45
+ incomingFromTests: new Map(),
46
+ incomingFromProduction: new Map(),
47
+ externalCounts: new Map(),
48
+ unresolvedCounts: new Map(),
49
+ declaredExportsByFile: new Map(),
50
+ importedSymbolsByFile: new Map(),
51
+ reExportsByFile: new Map(),
52
+ };
53
+ }
54
+
55
+ function addEdge(
56
+ state: DependencyState,
57
+ from: string,
58
+ to: string,
59
+ isTest = false
60
+ ): void {
61
+ state.files.add(from);
62
+ state.files.add(to);
63
+ if (!state.outgoing.has(from)) state.outgoing.set(from, new Set());
64
+ state.outgoing.get(from)!.add(to);
65
+ if (!state.incoming.has(to)) state.incoming.set(to, new Set());
66
+ state.incoming.get(to)!.add(from);
67
+ if (isTest) {
68
+ if (!state.incomingFromTests.has(to))
69
+ state.incomingFromTests.set(to, new Set());
70
+ state.incomingFromTests.get(to)!.add(from);
71
+ } else {
72
+ if (!state.incomingFromProduction.has(to))
73
+ state.incomingFromProduction.set(to, new Set());
74
+ state.incomingFromProduction.get(to)!.add(from);
75
+ }
76
+ }
77
+
78
+ const emptyProfile: DependencyProfile = {
79
+ internalDependencies: [],
80
+ externalDependencies: [],
81
+ unresolvedDependencies: [],
82
+ declaredExports: [],
83
+ importedSymbols: [],
84
+ reExports: [],
85
+ };
86
+
87
+ function makeFn(overrides: Partial<FunctionEntry> = {}): FunctionEntry {
88
+ return {
89
+ kind: 'FunctionDeclaration',
90
+ name: 'fn',
91
+ nameHint: 'fn',
92
+ file: 'src/a.ts',
93
+ lineStart: 1,
94
+ lineEnd: 10,
95
+ columnStart: 1,
96
+ columnEnd: 1,
97
+ statementCount: 5,
98
+ complexity: 1,
99
+ maxBranchDepth: 0,
100
+ maxLoopDepth: 0,
101
+ returns: 1,
102
+ awaits: 0,
103
+ calls: 0,
104
+ loops: 0,
105
+ lengthLines: 10,
106
+ cognitiveComplexity: 0,
107
+ ...overrides,
108
+ };
109
+ }
110
+
111
+ function makeFile(overrides: Partial<FileEntry> = {}): FileEntry {
112
+ return {
113
+ package: 'test',
114
+ file: 'src/a.ts',
115
+ parseEngine: 'typescript',
116
+ nodeCount: 50,
117
+ kindCounts: {},
118
+ functions: [],
119
+ flows: [],
120
+ dependencyProfile: emptyProfile,
121
+ ...overrides,
122
+ };
123
+ }
124
+
125
+ const testOpts = { ...DEFAULT_OPTS, root: '/repo', findingsLimit: 1000 };
126
+
127
+ function minimalDepSummary(
128
+ overrides: Partial<DependencySummary> = {}
129
+ ): DependencySummary {
130
+ return {
131
+ totalModules: 0,
132
+ totalEdges: 0,
133
+ unresolvedEdgeCount: 0,
134
+ externalDependencyFiles: 0,
135
+ rootsCount: 0,
136
+ leavesCount: 0,
137
+ roots: [],
138
+ leaves: [],
139
+ criticalModules: [],
140
+ testOnlyModules: [],
141
+ unresolvedSample: [],
142
+ outgoingTop: [],
143
+ inboundTop: [],
144
+ cycles: [],
145
+ criticalPaths: [],
146
+ ...overrides,
147
+ };
148
+ }
149
+
150
+ describe('isLikelyEntrypoint', () => {
151
+ it('matches index files', () => {
152
+ expect(isLikelyEntrypoint('src/index.ts')).toBe(true);
153
+ expect(isLikelyEntrypoint('packages/foo/src/index.tsx')).toBe(true);
154
+ expect(isLikelyEntrypoint('index.js')).toBe(true);
155
+ });
156
+
157
+ it('matches main, app, server, cli', () => {
158
+ expect(isLikelyEntrypoint('src/main.ts')).toBe(true);
159
+ expect(isLikelyEntrypoint('src/app.ts')).toBe(true);
160
+ expect(isLikelyEntrypoint('src/server.ts')).toBe(true);
161
+ expect(isLikelyEntrypoint('src/cli.ts')).toBe(true);
162
+ });
163
+
164
+ it('rejects non-entrypoint files', () => {
165
+ expect(isLikelyEntrypoint('src/utils.ts')).toBe(false);
166
+ expect(isLikelyEntrypoint('src/helper.ts')).toBe(false);
167
+ expect(isLikelyEntrypoint('src/index-utils.ts')).toBe(false);
168
+ });
169
+
170
+ it('is case insensitive', () => {
171
+ expect(isLikelyEntrypoint('src/Index.ts')).toBe(true);
172
+ expect(isLikelyEntrypoint('src/MAIN.ts')).toBe(true);
173
+ });
174
+ });
175
+
176
+ describe('computeDependencyCycles', () => {
177
+ it('returns empty for acyclic graph', () => {
178
+ const state = emptyState();
179
+ addEdge(state, 'a.ts', 'b.ts');
180
+ addEdge(state, 'b.ts', 'c.ts');
181
+ expect(computeDependencyCycles(state)).toEqual([]);
182
+ });
183
+
184
+ it('detects simple 2-node cycle', () => {
185
+ const state = emptyState();
186
+ addEdge(state, 'a.ts', 'b.ts');
187
+ addEdge(state, 'b.ts', 'a.ts');
188
+ const cycles = computeDependencyCycles(state);
189
+ expect(cycles.length).toBe(1);
190
+ expect(cycles[0].nodeCount).toBe(2);
191
+ });
192
+
193
+ it('detects 3-node cycle', () => {
194
+ const state = emptyState();
195
+ addEdge(state, 'a.ts', 'b.ts');
196
+ addEdge(state, 'b.ts', 'c.ts');
197
+ addEdge(state, 'c.ts', 'a.ts');
198
+ const cycles = computeDependencyCycles(state);
199
+ expect(cycles.length).toBe(1);
200
+ expect(cycles[0].nodeCount).toBe(3);
201
+ });
202
+
203
+ it('detects multiple cycles', () => {
204
+ const state = emptyState();
205
+ addEdge(state, 'a.ts', 'b.ts');
206
+ addEdge(state, 'b.ts', 'a.ts');
207
+ addEdge(state, 'c.ts', 'd.ts');
208
+ addEdge(state, 'd.ts', 'c.ts');
209
+ const cycles = computeDependencyCycles(state);
210
+ expect(cycles.length).toBe(2);
211
+ });
212
+
213
+ it('deduplicates same cycle found from different start', () => {
214
+ const state = emptyState();
215
+ addEdge(state, 'a.ts', 'b.ts');
216
+ addEdge(state, 'b.ts', 'a.ts');
217
+ const cycles = computeDependencyCycles(state);
218
+ expect(cycles.length).toBe(1);
219
+ });
220
+
221
+ it('returns cycles sorted by nodeCount descending', () => {
222
+ const state = emptyState();
223
+ addEdge(state, 'a.ts', 'b.ts');
224
+ addEdge(state, 'b.ts', 'a.ts');
225
+ addEdge(state, 'x.ts', 'y.ts');
226
+ addEdge(state, 'y.ts', 'z.ts');
227
+ addEdge(state, 'z.ts', 'x.ts');
228
+ const cycles = computeDependencyCycles(state);
229
+ expect(cycles[0].nodeCount).toBeGreaterThanOrEqual(
230
+ cycles[cycles.length - 1].nodeCount
231
+ );
232
+ });
233
+ });
234
+
235
+ describe('computeDependencyCriticalPaths', () => {
236
+ it('returns empty for isolated files', () => {
237
+ const state = emptyState();
238
+ state.files.add('a.ts');
239
+ const critMap = new Map<string, FileCriticality>();
240
+ critMap.set('a.ts', {
241
+ file: 'a.ts',
242
+ complexityRisk: 1,
243
+ highComplexityFunctions: 0,
244
+ functionCount: 1,
245
+ flows: 0,
246
+ score: 5,
247
+ });
248
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
249
+ expect(paths).toEqual([]);
250
+ });
251
+
252
+ it('finds longest weighted path', () => {
253
+ const state = emptyState();
254
+ addEdge(state, 'a.ts', 'b.ts');
255
+ addEdge(state, 'b.ts', 'c.ts');
256
+ const critMap = new Map<string, FileCriticality>();
257
+ critMap.set('a.ts', {
258
+ file: 'a.ts',
259
+ complexityRisk: 1,
260
+ highComplexityFunctions: 0,
261
+ functionCount: 1,
262
+ flows: 0,
263
+ score: 100,
264
+ });
265
+ critMap.set('b.ts', {
266
+ file: 'b.ts',
267
+ complexityRisk: 1,
268
+ highComplexityFunctions: 0,
269
+ functionCount: 1,
270
+ flows: 0,
271
+ score: 50,
272
+ });
273
+ critMap.set('c.ts', {
274
+ file: 'c.ts',
275
+ complexityRisk: 1,
276
+ highComplexityFunctions: 0,
277
+ functionCount: 1,
278
+ flows: 0,
279
+ score: 10,
280
+ });
281
+
282
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
283
+ expect(paths.length).toBeGreaterThan(0);
284
+ expect(paths[0].path).toContain('a.ts');
285
+ expect(paths[0].length).toBe(3);
286
+ });
287
+
288
+ it('handles cycles without infinite loop', () => {
289
+ const state = emptyState();
290
+ addEdge(state, 'a.ts', 'b.ts');
291
+ addEdge(state, 'b.ts', 'a.ts');
292
+ const critMap = new Map<string, FileCriticality>();
293
+ critMap.set('a.ts', {
294
+ file: 'a.ts',
295
+ complexityRisk: 1,
296
+ highComplexityFunctions: 0,
297
+ functionCount: 1,
298
+ flows: 0,
299
+ score: 10,
300
+ });
301
+ critMap.set('b.ts', {
302
+ file: 'b.ts',
303
+ complexityRisk: 1,
304
+ highComplexityFunctions: 0,
305
+ functionCount: 1,
306
+ flows: 0,
307
+ score: 10,
308
+ });
309
+
310
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
311
+ expect(paths.length).toBeGreaterThan(0);
312
+ expect(paths.some(p => p.containsCycle)).toBe(true);
313
+ });
314
+
315
+ it('respects deepLinkTopN limit', () => {
316
+ const state = emptyState();
317
+ for (let i = 0; i < 20; i++) {
318
+ const from = `src/m${i}.ts`;
319
+ const to = `src/m${i + 1}.ts`;
320
+ addEdge(state, from, to);
321
+ }
322
+ const critMap = new Map<string, FileCriticality>();
323
+ for (const file of state.files) {
324
+ critMap.set(file, {
325
+ file,
326
+ complexityRisk: 1,
327
+ highComplexityFunctions: 0,
328
+ functionCount: 1,
329
+ flows: 0,
330
+ score: 5,
331
+ });
332
+ }
333
+ const paths = computeDependencyCriticalPaths(state, critMap, {
334
+ ...testOpts,
335
+ deepLinkTopN: 3,
336
+ });
337
+ expect(paths.length).toBeLessThanOrEqual(3);
338
+ });
339
+ });
340
+
341
+ describe('buildIssueCatalog', () => {
342
+ describe('duplicate findings', () => {
343
+ it('creates duplicate-function-body findings', () => {
344
+ const dups: DuplicateGroup[] = [
345
+ {
346
+ hash: 'abc',
347
+ signature: 'handleError',
348
+ kind: 'ArrowFunction',
349
+ occurrences: 4,
350
+ filesCount: 3,
351
+ locations: Array.from({ length: 4 }, (_, i) => ({
352
+ kind: 'ArrowFunction',
353
+ name: 'handleError',
354
+ nameHint: 'handleError',
355
+ file: `src/file${i}.ts`,
356
+ lineStart: 1,
357
+ lineEnd: 10,
358
+ columnStart: 1,
359
+ columnEnd: 1,
360
+ statementCount: 8,
361
+ complexity: 3,
362
+ maxBranchDepth: 1,
363
+ maxLoopDepth: 0,
364
+ returns: 1,
365
+ awaits: 0,
366
+ calls: 2,
367
+ loops: 0,
368
+ lengthLines: 10,
369
+ cognitiveComplexity: 2,
370
+ hash: 'abc',
371
+ metrics: {
372
+ complexity: 3,
373
+ maxBranchDepth: 1,
374
+ maxLoopDepth: 0,
375
+ returns: 1,
376
+ awaits: 0,
377
+ calls: 2,
378
+ loops: 0,
379
+ },
380
+ })),
381
+ },
382
+ ];
383
+ const { findings } = buildIssueCatalog(
384
+ dups,
385
+ [],
386
+ [],
387
+ minimalDepSummary(),
388
+ emptyState(),
389
+ testOpts
390
+ );
391
+ const dupFindings = findings.filter(
392
+ f => f.category === 'duplicate-function-body'
393
+ );
394
+ expect(dupFindings.length).toBe(1);
395
+ expect(dupFindings[0].title).toContain('handleError');
396
+ });
397
+
398
+ it('assigns severity based on occurrence count', () => {
399
+ const makeDup = (occurrences: number): DuplicateGroup => ({
400
+ hash: 'x',
401
+ signature: 'fn',
402
+ kind: 'FunctionDeclaration',
403
+ occurrences,
404
+ filesCount: occurrences,
405
+ locations: Array.from({ length: occurrences }, (_, i) => ({
406
+ kind: 'FunctionDeclaration',
407
+ name: 'fn',
408
+ nameHint: 'fn',
409
+ file: `f${i}.ts`,
410
+ lineStart: 1,
411
+ lineEnd: 5,
412
+ columnStart: 1,
413
+ columnEnd: 1,
414
+ statementCount: 6,
415
+ complexity: 1,
416
+ maxBranchDepth: 0,
417
+ maxLoopDepth: 0,
418
+ returns: 0,
419
+ awaits: 0,
420
+ calls: 0,
421
+ loops: 0,
422
+ lengthLines: 5,
423
+ cognitiveComplexity: 0,
424
+ hash: 'x',
425
+ metrics: {
426
+ complexity: 1,
427
+ maxBranchDepth: 0,
428
+ maxLoopDepth: 0,
429
+ returns: 0,
430
+ awaits: 0,
431
+ calls: 0,
432
+ loops: 0,
433
+ },
434
+ })),
435
+ });
436
+
437
+ const low = buildIssueCatalog(
438
+ [makeDup(2)],
439
+ [],
440
+ [],
441
+ minimalDepSummary(),
442
+ emptyState(),
443
+ testOpts
444
+ );
445
+ const med = buildIssueCatalog(
446
+ [makeDup(3)],
447
+ [],
448
+ [],
449
+ minimalDepSummary(),
450
+ emptyState(),
451
+ testOpts
452
+ );
453
+ const high = buildIssueCatalog(
454
+ [makeDup(6)],
455
+ [],
456
+ [],
457
+ minimalDepSummary(),
458
+ emptyState(),
459
+ testOpts
460
+ );
461
+
462
+ expect(low.findings[0].severity).toBe('low');
463
+ expect(med.findings[0].severity).toBe('medium');
464
+ expect(high.findings[0].severity).toBe('high');
465
+ });
466
+ });
467
+
468
+ describe('function-optimization findings', () => {
469
+ it('flags high-complexity functions', () => {
470
+ const files = [
471
+ makeFile({
472
+ functions: [makeFn({ complexity: 35, name: 'complexFn' })],
473
+ }),
474
+ ];
475
+ const { findings } = buildIssueCatalog(
476
+ [],
477
+ [],
478
+ files,
479
+ minimalDepSummary(),
480
+ emptyState(),
481
+ testOpts
482
+ );
483
+ const optFindings = findings.filter(
484
+ f => f.category === 'function-optimization'
485
+ );
486
+ expect(optFindings.length).toBe(1);
487
+ expect(optFindings[0].title).toContain('complexFn');
488
+ });
489
+
490
+ it('flags deeply nested functions', () => {
491
+ const files = [
492
+ makeFile({
493
+ functions: [makeFn({ maxBranchDepth: 8, name: 'deepFn' })],
494
+ }),
495
+ ];
496
+ const { findings } = buildIssueCatalog(
497
+ [],
498
+ [],
499
+ files,
500
+ minimalDepSummary(),
501
+ emptyState(),
502
+ testOpts
503
+ );
504
+ expect(findings.some(f => f.category === 'function-optimization')).toBe(
505
+ true
506
+ );
507
+ });
508
+
509
+ it('flags large functions', () => {
510
+ const files = [
511
+ makeFile({
512
+ functions: [makeFn({ statementCount: 30, name: 'bigFn' })],
513
+ }),
514
+ ];
515
+ const { findings } = buildIssueCatalog(
516
+ [],
517
+ [],
518
+ files,
519
+ minimalDepSummary(),
520
+ emptyState(),
521
+ testOpts
522
+ );
523
+ expect(findings.some(f => f.category === 'function-optimization')).toBe(
524
+ true
525
+ );
526
+ });
527
+
528
+ it('skips clean functions', () => {
529
+ const files = [
530
+ makeFile({
531
+ functions: [
532
+ makeFn({
533
+ complexity: 5,
534
+ maxBranchDepth: 2,
535
+ maxLoopDepth: 1,
536
+ statementCount: 10,
537
+ }),
538
+ ],
539
+ }),
540
+ ];
541
+ const { findings } = buildIssueCatalog(
542
+ [],
543
+ [],
544
+ files,
545
+ minimalDepSummary(),
546
+ emptyState(),
547
+ testOpts
548
+ );
549
+ expect(
550
+ findings.filter(f => f.category === 'function-optimization')
551
+ ).toEqual([]);
552
+ });
553
+ });
554
+
555
+ describe('dead code findings', () => {
556
+ it('detects orphan modules (no inbound or outbound)', () => {
557
+ const state = emptyState();
558
+ state.files.add('src/dead.ts');
559
+ const depSummary = minimalDepSummary({ roots: ['src/dead.ts'] });
560
+ const { findings } = buildIssueCatalog(
561
+ [],
562
+ [],
563
+ [],
564
+ depSummary,
565
+ state,
566
+ testOpts
567
+ );
568
+ expect(
569
+ findings.some(
570
+ f => f.category === 'orphan-module' && f.file === 'src/dead.ts'
571
+ )
572
+ ).toBe(true);
573
+ });
574
+
575
+ it('skips entrypoints from orphan-module detection', () => {
576
+ const state = emptyState();
577
+ state.files.add('src/index.ts');
578
+ const depSummary = minimalDepSummary({ roots: ['src/index.ts'] });
579
+ const { findings } = buildIssueCatalog(
580
+ [],
581
+ [],
582
+ [],
583
+ depSummary,
584
+ state,
585
+ testOpts
586
+ );
587
+ expect(
588
+ findings.some(
589
+ f => f.category === 'orphan-module' && f.file === 'src/index.ts'
590
+ )
591
+ ).toBe(false);
592
+ });
593
+
594
+ it('detects dead exports', () => {
595
+ const state = emptyState();
596
+ state.files.add('src/lib.ts');
597
+ state.declaredExportsByFile.set('src/lib.ts', [
598
+ { name: 'usedFn', kind: 'value' },
599
+ { name: 'deadFn', kind: 'value', lineStart: 10, lineEnd: 15 },
600
+ ]);
601
+ state.importedSymbolsByFile.set('src/consumer.ts', [
602
+ {
603
+ sourceModule: './lib',
604
+ resolvedModule: 'src/lib.ts',
605
+ importedName: 'usedFn',
606
+ localName: 'usedFn',
607
+ isTypeOnly: false,
608
+ },
609
+ ]);
610
+ addEdge(state, 'src/consumer.ts', 'src/lib.ts');
611
+ const depSummary = minimalDepSummary();
612
+ const { findings } = buildIssueCatalog(
613
+ [],
614
+ [],
615
+ [],
616
+ depSummary,
617
+ state,
618
+ testOpts
619
+ );
620
+ const deadExports = findings.filter(f => f.category === 'dead-export');
621
+ expect(deadExports.some(f => f.title.includes('deadFn'))).toBe(true);
622
+ expect(deadExports.some(f => f.title.includes('usedFn'))).toBe(false);
623
+ });
624
+
625
+ it('skips exports consumed via namespace import (*)', () => {
626
+ const state = emptyState();
627
+ state.files.add('src/lib.ts');
628
+ state.declaredExportsByFile.set('src/lib.ts', [
629
+ { name: 'foo', kind: 'value' },
630
+ ]);
631
+ state.importedSymbolsByFile.set('src/consumer.ts', [
632
+ {
633
+ sourceModule: './lib',
634
+ resolvedModule: 'src/lib.ts',
635
+ importedName: '*',
636
+ localName: 'lib',
637
+ isTypeOnly: false,
638
+ },
639
+ ]);
640
+ const depSummary = minimalDepSummary();
641
+ const { findings } = buildIssueCatalog(
642
+ [],
643
+ [],
644
+ [],
645
+ depSummary,
646
+ state,
647
+ testOpts
648
+ );
649
+ expect(
650
+ findings.some(
651
+ f => f.category === 'dead-export' && f.title.includes('foo')
652
+ )
653
+ ).toBe(false);
654
+ });
655
+ });
656
+
657
+ describe('re-export findings', () => {
658
+ it('detects dead re-exports', () => {
659
+ const state = emptyState();
660
+ state.files.add('src/index.ts');
661
+ state.reExportsByFile.set('src/index.ts', [
662
+ {
663
+ sourceModule: './a',
664
+ resolvedModule: 'src/a.ts',
665
+ exportedAs: 'deadSymbol',
666
+ importedName: 'deadSymbol',
667
+ isStar: false,
668
+ isTypeOnly: false,
669
+ lineStart: 1,
670
+ lineEnd: 1,
671
+ },
672
+ ]);
673
+ const depSummary = minimalDepSummary();
674
+ const { findings } = buildIssueCatalog(
675
+ [],
676
+ [],
677
+ [],
678
+ depSummary,
679
+ state,
680
+ testOpts
681
+ );
682
+ expect(findings.some(f => f.category === 'dead-re-export')).toBe(true);
683
+ });
684
+
685
+ it('detects re-export duplication', () => {
686
+ const state = emptyState();
687
+ state.files.add('src/barrel.ts');
688
+ state.reExportsByFile.set('src/barrel.ts', [
689
+ {
690
+ sourceModule: './a',
691
+ resolvedModule: 'src/a.ts',
692
+ exportedAs: 'Foo',
693
+ importedName: 'Foo',
694
+ isStar: false,
695
+ isTypeOnly: false,
696
+ },
697
+ {
698
+ sourceModule: './b',
699
+ resolvedModule: 'src/b.ts',
700
+ exportedAs: 'Foo',
701
+ importedName: 'Foo',
702
+ isStar: false,
703
+ isTypeOnly: false,
704
+ },
705
+ ]);
706
+ const depSummary = minimalDepSummary();
707
+ const { findings } = buildIssueCatalog(
708
+ [],
709
+ [],
710
+ [],
711
+ depSummary,
712
+ state,
713
+ testOpts
714
+ );
715
+ expect(findings.some(f => f.category === 're-export-duplication')).toBe(
716
+ true
717
+ );
718
+ });
719
+
720
+ it('detects shadowed re-exports', () => {
721
+ const state = emptyState();
722
+ state.files.add('src/barrel.ts');
723
+ state.declaredExportsByFile.set('src/barrel.ts', [
724
+ { name: 'Conflict', kind: 'value' },
725
+ ]);
726
+ state.reExportsByFile.set('src/barrel.ts', [
727
+ {
728
+ sourceModule: './a',
729
+ resolvedModule: 'src/a.ts',
730
+ exportedAs: 'Conflict',
731
+ importedName: 'Conflict',
732
+ isStar: false,
733
+ isTypeOnly: false,
734
+ },
735
+ ]);
736
+ const depSummary = minimalDepSummary();
737
+ const { findings } = buildIssueCatalog(
738
+ [],
739
+ [],
740
+ [],
741
+ depSummary,
742
+ state,
743
+ testOpts
744
+ );
745
+ expect(findings.some(f => f.category === 're-export-shadowed')).toBe(
746
+ true
747
+ );
748
+ });
749
+ });
750
+
751
+ describe('dependency findings', () => {
752
+ it('creates cycle findings', () => {
753
+ const depSummary = minimalDepSummary({
754
+ cycles: [{ path: ['a.ts', 'b.ts', 'a.ts'], nodeCount: 2 }],
755
+ });
756
+ const { findings } = buildIssueCatalog(
757
+ [],
758
+ [],
759
+ [],
760
+ depSummary,
761
+ emptyState(),
762
+ testOpts
763
+ );
764
+ expect(findings.some(f => f.category === 'dependency-cycle')).toBe(true);
765
+ });
766
+
767
+ it('creates critical-path findings above score threshold', () => {
768
+ const depSummary = minimalDepSummary({
769
+ criticalPaths: [
770
+ {
771
+ start: 'a.ts',
772
+ path: ['a.ts', 'b.ts', 'c.ts'],
773
+ score: 300,
774
+ length: 3,
775
+ containsCycle: false,
776
+ },
777
+ ],
778
+ });
779
+ const { findings } = buildIssueCatalog(
780
+ [],
781
+ [],
782
+ [],
783
+ depSummary,
784
+ emptyState(),
785
+ testOpts
786
+ );
787
+ expect(
788
+ findings.some(f => f.category === 'dependency-critical-path')
789
+ ).toBe(true);
790
+ });
791
+
792
+ it('skips critical-path findings below score threshold', () => {
793
+ const depSummary = minimalDepSummary({
794
+ criticalPaths: [
795
+ {
796
+ start: 'a.ts',
797
+ path: ['a.ts', 'b.ts'],
798
+ score: 10,
799
+ length: 2,
800
+ containsCycle: false,
801
+ },
802
+ ],
803
+ });
804
+ const { findings } = buildIssueCatalog(
805
+ [],
806
+ [],
807
+ [],
808
+ depSummary,
809
+ emptyState(),
810
+ testOpts
811
+ );
812
+ expect(
813
+ findings.some(f => f.category === 'dependency-critical-path')
814
+ ).toBe(false);
815
+ });
816
+
817
+ it('creates test-only module findings', () => {
818
+ const depSummary = minimalDepSummary({
819
+ testOnlyModules: [
820
+ {
821
+ file: 'src/test-helper.ts',
822
+ outboundCount: 0,
823
+ inboundCount: 1,
824
+ inboundFromProduction: 0,
825
+ inboundFromTests: 1,
826
+ externalDependencyCount: 0,
827
+ unresolvedDependencyCount: 0,
828
+ },
829
+ ],
830
+ });
831
+ const { findings } = buildIssueCatalog(
832
+ [],
833
+ [],
834
+ [],
835
+ depSummary,
836
+ emptyState(),
837
+ testOpts
838
+ );
839
+ expect(findings.some(f => f.category === 'dependency-test-only')).toBe(
840
+ true
841
+ );
842
+ });
843
+ });
844
+
845
+ describe('finding limits and sorting', () => {
846
+ it('respects findingsLimit', () => {
847
+ const files = [
848
+ makeFile({
849
+ functions: Array.from({ length: 50 }, (_, i) =>
850
+ makeFn({
851
+ complexity: 40,
852
+ name: `fn${i}`,
853
+ })
854
+ ),
855
+ }),
856
+ ];
857
+ const opts = { ...testOpts, findingsLimit: 5 };
858
+ const { findings } = buildIssueCatalog(
859
+ [],
860
+ [],
861
+ files,
862
+ minimalDepSummary(),
863
+ emptyState(),
864
+ opts
865
+ );
866
+ expect(findings.length).toBeLessThanOrEqual(5);
867
+ });
868
+
869
+ it('sorts findings by severity descending', () => {
870
+ const depSummary = minimalDepSummary({
871
+ cycles: [{ path: ['a.ts', 'b.ts', 'a.ts'], nodeCount: 2 }],
872
+ testOnlyModules: [
873
+ {
874
+ file: 'src/t.ts',
875
+ outboundCount: 0,
876
+ inboundCount: 1,
877
+ inboundFromProduction: 0,
878
+ inboundFromTests: 1,
879
+ externalDependencyCount: 0,
880
+ unresolvedDependencyCount: 0,
881
+ },
882
+ ],
883
+ });
884
+ const { findings } = buildIssueCatalog(
885
+ [],
886
+ [],
887
+ [],
888
+ depSummary,
889
+ emptyState(),
890
+ testOpts
891
+ );
892
+ if (findings.length >= 2) {
893
+ const severityOrder: Record<string, number> = {
894
+ critical: 4,
895
+ high: 3,
896
+ medium: 2,
897
+ low: 1,
898
+ info: 0,
899
+ };
900
+ for (let i = 1; i < findings.length; i++) {
901
+ expect(
902
+ severityOrder[findings[i - 1].severity]
903
+ ).toBeGreaterThanOrEqual(severityOrder[findings[i].severity]);
904
+ }
905
+ }
906
+ });
907
+
908
+ it('assigns unique IDs to each finding', () => {
909
+ const files = [
910
+ makeFile({
911
+ functions: [
912
+ makeFn({ complexity: 40, name: 'fn1' }),
913
+ makeFn({ complexity: 40, name: 'fn2' }),
914
+ ],
915
+ }),
916
+ ];
917
+ const { findings } = buildIssueCatalog(
918
+ [],
919
+ [],
920
+ files,
921
+ minimalDepSummary(),
922
+ emptyState(),
923
+ testOpts
924
+ );
925
+ const ids = findings.map(f => f.id);
926
+ expect(new Set(ids).size).toBe(ids.length);
927
+ });
928
+
929
+ it('tracks findings per file', () => {
930
+ const files = [
931
+ makeFile({
932
+ file: 'src/hot.ts',
933
+ functions: [
934
+ makeFn({ complexity: 40, name: 'fn1', file: 'src/hot.ts' }),
935
+ ],
936
+ }),
937
+ ];
938
+ const { byFile } = buildIssueCatalog(
939
+ [],
940
+ [],
941
+ files,
942
+ minimalDepSummary(),
943
+ emptyState(),
944
+ testOpts
945
+ );
946
+ expect(byFile.get('src/hot.ts')?.length).toBeGreaterThan(0);
947
+ });
948
+ });
949
+
950
+ describe('features filtering', () => {
951
+ it('filters findings by features set', () => {
952
+ const depSummary = minimalDepSummary({
953
+ cycles: [{ path: ['a.ts', 'b.ts', 'a.ts'], nodeCount: 2 }],
954
+ testOnlyModules: [
955
+ {
956
+ file: 'src/t.ts',
957
+ outboundCount: 0,
958
+ inboundCount: 1,
959
+ inboundFromProduction: 0,
960
+ inboundFromTests: 1,
961
+ externalDependencyCount: 0,
962
+ unresolvedDependencyCount: 0,
963
+ },
964
+ ],
965
+ });
966
+ const state = emptyState();
967
+ state.files.add('src/lib.ts');
968
+ state.declaredExportsByFile.set('src/lib.ts', [
969
+ { name: 'deadFn', kind: 'value', lineStart: 10, lineEnd: 15 },
970
+ ]);
971
+ const files = [
972
+ makeFile({
973
+ functions: [makeFn({ complexity: 40, name: 'complexFn' })],
974
+ }),
975
+ ];
976
+ const optsAll = { ...testOpts, features: null };
977
+ const optsArchOnly = {
978
+ ...testOpts,
979
+ features: new Set(['dependency-cycle', 'dependency-test-only']),
980
+ };
981
+
982
+ const { findings: allFindings } = buildIssueCatalog(
983
+ [],
984
+ [],
985
+ files,
986
+ depSummary,
987
+ state,
988
+ optsAll
989
+ );
990
+ const { findings: filteredFindings } = buildIssueCatalog(
991
+ [],
992
+ [],
993
+ files,
994
+ depSummary,
995
+ state,
996
+ optsArchOnly
997
+ );
998
+
999
+ expect(allFindings.some(f => f.category === 'dependency-cycle')).toBe(
1000
+ true
1001
+ );
1002
+ expect(allFindings.some(f => f.category === 'dependency-test-only')).toBe(
1003
+ true
1004
+ );
1005
+ expect(allFindings.some(f => f.category === 'dead-export')).toBe(true);
1006
+ expect(
1007
+ allFindings.some(f => f.category === 'function-optimization')
1008
+ ).toBe(true);
1009
+
1010
+ expect(
1011
+ filteredFindings.every(
1012
+ f =>
1013
+ f.category === 'dependency-cycle' ||
1014
+ f.category === 'dependency-test-only'
1015
+ )
1016
+ ).toBe(true);
1017
+ expect(filteredFindings.some(f => f.category === 'dead-export')).toBe(
1018
+ false
1019
+ );
1020
+ expect(
1021
+ filteredFindings.some(f => f.category === 'function-optimization')
1022
+ ).toBe(false);
1023
+ });
1024
+ });
1025
+
1026
+ describe('architecture integration', () => {
1027
+ it('includes SDP violation findings from architecture module', () => {
1028
+ const state = emptyState();
1029
+ for (let i = 0; i < 10; i++) {
1030
+ const f = `src/dep${i}.ts`;
1031
+ state.files.add(f);
1032
+ addEdge(state, f, 'src/stable.ts');
1033
+ }
1034
+ addEdge(state, 'src/stable.ts', 'src/unstable.ts');
1035
+ for (let i = 0; i < 10; i++) {
1036
+ const f = `src/lib${i}.ts`;
1037
+ state.files.add(f);
1038
+ addEdge(state, 'src/unstable.ts', f);
1039
+ }
1040
+ const { findings } = buildIssueCatalog(
1041
+ [],
1042
+ [],
1043
+ [],
1044
+ minimalDepSummary(),
1045
+ state,
1046
+ testOpts
1047
+ );
1048
+ expect(
1049
+ findings.some(f => f.category === 'architecture-sdp-violation')
1050
+ ).toBe(true);
1051
+ });
1052
+
1053
+ it('includes orphan-module findings', () => {
1054
+ const state = emptyState();
1055
+ state.files.add('src/orphan.ts');
1056
+ addEdge(state, 'src/a.ts', 'src/b.ts');
1057
+ const { findings } = buildIssueCatalog(
1058
+ [],
1059
+ [],
1060
+ [],
1061
+ minimalDepSummary(),
1062
+ state,
1063
+ testOpts
1064
+ );
1065
+ expect(findings.some(f => f.category === 'orphan-module')).toBe(true);
1066
+ });
1067
+ });
1068
+ });
1069
+
1070
+ describe('category group constants', () => {
1071
+ const ALL_CATEGORIES = [
1072
+ 'dependency-cycle',
1073
+ 'dependency-critical-path',
1074
+ 'dependency-test-only',
1075
+ 'architecture-sdp-violation',
1076
+ 'high-coupling',
1077
+ 'god-module-coupling',
1078
+ 'orphan-module',
1079
+ 'unreachable-module',
1080
+ 'layer-violation',
1081
+ 'low-cohesion',
1082
+ 'duplicate-function-body',
1083
+ 'duplicate-flow-structure',
1084
+ 'function-optimization',
1085
+ 'cognitive-complexity',
1086
+ 'god-module',
1087
+ 'god-function',
1088
+ 'halstead-effort',
1089
+ 'low-maintainability',
1090
+ 'excessive-parameters',
1091
+ 'unsafe-any',
1092
+ 'empty-catch',
1093
+ 'switch-no-default',
1094
+ 'dead-export',
1095
+ 'dead-re-export',
1096
+ 're-export-duplication',
1097
+ 're-export-shadowed',
1098
+ 'unused-npm-dependency',
1099
+ 'package-boundary-violation',
1100
+ 'barrel-explosion',
1101
+ 'distance-from-main-sequence',
1102
+ 'feature-envy',
1103
+ 'untested-critical-code',
1104
+ 'over-abstraction',
1105
+ 'concrete-dependency',
1106
+ 'circular-type-dependency',
1107
+ 'unused-parameter',
1108
+ 'deep-override-chain',
1109
+ 'interface-compliance',
1110
+ 'unused-import',
1111
+ 'orphan-implementation',
1112
+ 'shotgun-surgery',
1113
+ 'move-to-caller',
1114
+ 'narrowable-type',
1115
+ 'type-assertion-escape',
1116
+ 'promise-misuse',
1117
+ 'missing-error-boundary',
1118
+ ];
1119
+
1120
+ it('every known category belongs to exactly one group', () => {
1121
+ for (const cat of ALL_CATEGORIES) {
1122
+ const inArch = ARCHITECTURE_CATEGORIES.has(cat);
1123
+ const inQual = CODE_QUALITY_CATEGORIES.has(cat);
1124
+ const inDead = DEAD_CODE_CATEGORIES.has(cat);
1125
+ const count = [inArch, inQual, inDead].filter(Boolean).length;
1126
+ expect(count).toBe(1);
1127
+ }
1128
+ });
1129
+
1130
+ it('groups have no overlap', () => {
1131
+ for (const cat of ARCHITECTURE_CATEGORIES) {
1132
+ expect(CODE_QUALITY_CATEGORIES.has(cat)).toBe(false);
1133
+ expect(DEAD_CODE_CATEGORIES.has(cat)).toBe(false);
1134
+ }
1135
+ for (const cat of CODE_QUALITY_CATEGORIES) {
1136
+ expect(DEAD_CODE_CATEGORIES.has(cat)).toBe(false);
1137
+ }
1138
+ });
1139
+
1140
+ it('all categories are covered across all pillars', () => {
1141
+ const total = Object.values(PILLAR_CATEGORIES).flat().length;
1142
+ const setTotal =
1143
+ ARCHITECTURE_CATEGORIES.size +
1144
+ CODE_QUALITY_CATEGORIES.size +
1145
+ DEAD_CODE_CATEGORIES.size +
1146
+ SECURITY_CATEGORIES.size +
1147
+ TEST_QUALITY_CATEGORIES.size;
1148
+ expect(setTotal).toBe(total);
1149
+ });
1150
+
1151
+ it('architecture group has 27 categories', () => {
1152
+ expect(ARCHITECTURE_CATEGORIES.size).toBe(28);
1153
+ });
1154
+
1155
+ it('code quality group has expected categories', () => {
1156
+ expect(CODE_QUALITY_CATEGORIES.size).toBe(26);
1157
+ });
1158
+
1159
+ it('dead code group has 11 categories', () => {
1160
+ expect(DEAD_CODE_CATEGORIES.size).toBe(12);
1161
+ });
1162
+
1163
+ it('security group has 10 categories', () => {
1164
+ expect(SECURITY_CATEGORIES.size).toBe(12);
1165
+ });
1166
+
1167
+ it('test quality group has 8 categories', () => {
1168
+ expect(TEST_QUALITY_CATEGORIES.size).toBe(8);
1169
+ });
1170
+ });
1171
+
1172
+ describe('severityBreakdown', () => {
1173
+ it('returns zero counts for empty findings', () => {
1174
+ const result = severityBreakdown([]);
1175
+ expect(result).toEqual({
1176
+ critical: 0,
1177
+ high: 0,
1178
+ medium: 0,
1179
+ low: 0,
1180
+ info: 0,
1181
+ });
1182
+ });
1183
+
1184
+ it('counts each severity correctly', () => {
1185
+ const findings = [
1186
+ { severity: 'high' },
1187
+ { severity: 'high' },
1188
+ { severity: 'medium' },
1189
+ { severity: 'critical' },
1190
+ ] as Finding[];
1191
+ const result = severityBreakdown(findings);
1192
+ expect(result.critical).toBe(1);
1193
+ expect(result.high).toBe(2);
1194
+ expect(result.medium).toBe(1);
1195
+ expect(result.low).toBe(0);
1196
+ });
1197
+ });
1198
+
1199
+ describe('categoryBreakdown', () => {
1200
+ it('returns empty object for empty findings', () => {
1201
+ expect(categoryBreakdown([])).toEqual({});
1202
+ });
1203
+
1204
+ it('counts each category correctly', () => {
1205
+ const findings = [
1206
+ { category: 'dead-export' },
1207
+ { category: 'dead-export' },
1208
+ { category: 'dependency-cycle' },
1209
+ ] as Finding[];
1210
+ const result = categoryBreakdown(findings);
1211
+ expect(result['dead-export']).toBe(2);
1212
+ expect(result['dependency-cycle']).toBe(1);
1213
+ });
1214
+ });
1215
+
1216
+ describe('diversifyFindings', () => {
1217
+ type DraftFinding = Omit<Finding, 'id'> & { id?: string };
1218
+
1219
+ const makeDraft = (
1220
+ severity: string,
1221
+ category: string,
1222
+ idx: number
1223
+ ): DraftFinding => ({
1224
+ severity: severity as Finding['severity'],
1225
+ category,
1226
+ file: `${category}-${idx}.ts`,
1227
+ lineStart: 1,
1228
+ lineEnd: 1,
1229
+ title: `${category} finding ${idx}`,
1230
+ reason: 'test',
1231
+ files: [`${category}-${idx}.ts`],
1232
+ suggestedFix: { strategy: 'test', steps: ['step1'] },
1233
+ });
1234
+
1235
+ it('returns all findings when limit >= length', () => {
1236
+ const input = [makeDraft('high', 'a', 1), makeDraft('high', 'b', 1)];
1237
+ expect(diversifyFindings(input, 10)).toBe(input); // same reference
1238
+ expect(diversifyFindings(input, 2)).toBe(input);
1239
+ });
1240
+
1241
+ it('returns all findings when limit is Infinity', () => {
1242
+ const input = [makeDraft('high', 'a', 1)];
1243
+ expect(diversifyFindings(input, Infinity)).toBe(input);
1244
+ });
1245
+
1246
+ it('round-robins across categories instead of taking all from one', () => {
1247
+ const input = [
1248
+ ...Array.from({ length: 10 }, (_, i) =>
1249
+ makeDraft('high', 'await-in-loop', i)
1250
+ ),
1251
+ makeDraft('high', 'dead-export', 1),
1252
+ makeDraft('high', 'dead-export', 2),
1253
+ ];
1254
+ const result = diversifyFindings(input, 5);
1255
+ expect(result).toHaveLength(5);
1256
+ const categories = new Set(result.map(f => f.category));
1257
+ expect(categories.size).toBe(2);
1258
+ expect(categories.has('await-in-loop')).toBe(true);
1259
+ expect(categories.has('dead-export')).toBe(true);
1260
+ });
1261
+
1262
+ it('prioritizes categories by highest severity', () => {
1263
+ const input = [
1264
+ makeDraft('critical', 'security', 1),
1265
+ makeDraft('high', 'quality', 1),
1266
+ makeDraft('high', 'quality', 2),
1267
+ makeDraft('medium', 'dead-code', 1),
1268
+ makeDraft('medium', 'dead-code', 2),
1269
+ ];
1270
+ const result = diversifyFindings(input, 3);
1271
+ expect(result).toHaveLength(3);
1272
+ expect(result[0].category).toBe('security');
1273
+ expect(result[1].category).toBe('quality');
1274
+ expect(result[2].category).toBe('dead-code');
1275
+ });
1276
+
1277
+ it('continues round-robin when some categories are exhausted', () => {
1278
+ const input = [
1279
+ makeDraft('high', 'a', 1),
1280
+ makeDraft('high', 'b', 1),
1281
+ makeDraft('high', 'b', 2),
1282
+ makeDraft('high', 'b', 3),
1283
+ ];
1284
+ const result = diversifyFindings(input, 3);
1285
+ expect(result).toHaveLength(3);
1286
+ expect(result.filter(f => f.category === 'a')).toHaveLength(1);
1287
+ expect(result.filter(f => f.category === 'b')).toHaveLength(2);
1288
+ });
1289
+
1290
+ it('handles empty input', () => {
1291
+ expect(diversifyFindings([], 5)).toEqual([]);
1292
+ });
1293
+
1294
+ it('handles single category (no diversity possible)', () => {
1295
+ const input = Array.from({ length: 10 }, (_, i) =>
1296
+ makeDraft('high', 'only-cat', i)
1297
+ );
1298
+ const result = diversifyFindings(input, 3);
1299
+ expect(result).toHaveLength(3);
1300
+ expect(result.every(f => f.category === 'only-cat')).toBe(true);
1301
+ });
1302
+
1303
+ it('handles limit of 1', () => {
1304
+ const input = [makeDraft('critical', 'a', 1), makeDraft('high', 'b', 1)];
1305
+ const result = diversifyFindings(input, 1);
1306
+ expect(result).toHaveLength(1);
1307
+ expect(result[0].severity).toBe('critical');
1308
+ });
1309
+
1310
+ it('preserves severity order within each category', () => {
1311
+ const input = [
1312
+ makeDraft('critical', 'a', 1),
1313
+ makeDraft('high', 'a', 2),
1314
+ makeDraft('medium', 'a', 3),
1315
+ makeDraft('critical', 'b', 1),
1316
+ makeDraft('high', 'b', 2),
1317
+ ];
1318
+ const result = diversifyFindings(input, 4);
1319
+ const aFindings = result.filter(f => f.category === 'a');
1320
+ expect(aFindings[0].severity).toBe('critical');
1321
+ expect(aFindings[1].severity).toBe('high');
1322
+ });
1323
+ });
1324
+
1325
+ describe('diverseTopRecommendations', () => {
1326
+ const makeFinding = (
1327
+ id: string,
1328
+ severity: string,
1329
+ category: string
1330
+ ): Finding => ({
1331
+ id,
1332
+ severity: severity as Finding['severity'],
1333
+ category,
1334
+ file: 'test.ts',
1335
+ lineStart: 1,
1336
+ lineEnd: 1,
1337
+ title: `Test ${id}`,
1338
+ reason: 'test',
1339
+ files: ['test.ts'],
1340
+ suggestedFix: { strategy: 'test', steps: ['step1'] },
1341
+ impact: 'test',
1342
+ });
1343
+
1344
+ it('limits findings per category', () => {
1345
+ const findings = [
1346
+ makeFinding('1', 'high', 'dead-export'),
1347
+ makeFinding('2', 'high', 'dead-export'),
1348
+ makeFinding('3', 'high', 'dead-export'),
1349
+ makeFinding('4', 'high', 'cognitive-complexity'),
1350
+ makeFinding('5', 'high', 'cognitive-complexity'),
1351
+ makeFinding('6', 'high', 'cognitive-complexity'),
1352
+ ];
1353
+ const result = diverseTopRecommendations(findings, 10, 2);
1354
+ expect(result).toHaveLength(4);
1355
+ expect(result.filter(f => f.category === 'dead-export')).toHaveLength(2);
1356
+ expect(
1357
+ result.filter(f => f.category === 'cognitive-complexity')
1358
+ ).toHaveLength(2);
1359
+ });
1360
+
1361
+ it('respects total limit', () => {
1362
+ const findings = Array.from({ length: 30 }, (_, i) =>
1363
+ makeFinding(`${i}`, 'high', `cat-${i % 10}`)
1364
+ );
1365
+ const result = diverseTopRecommendations(findings, 5, 2);
1366
+ expect(result).toHaveLength(5);
1367
+ });
1368
+
1369
+ it('returns empty for empty input', () => {
1370
+ expect(diverseTopRecommendations([], 10, 2)).toHaveLength(0);
1371
+ });
1372
+
1373
+ it('uses maxPerCategory=1 to force maximum diversity', () => {
1374
+ const findings = [
1375
+ makeFinding('1', 'critical', 'a'),
1376
+ makeFinding('2', 'critical', 'a'),
1377
+ makeFinding('3', 'high', 'b'),
1378
+ makeFinding('4', 'high', 'b'),
1379
+ makeFinding('5', 'medium', 'c'),
1380
+ ];
1381
+ const result = diverseTopRecommendations(findings, 10, 1);
1382
+ expect(result).toHaveLength(3);
1383
+ expect(new Set(result.map(f => f.category)).size).toBe(3);
1384
+ });
1385
+ });
1386
+
1387
+ describe('writeMultiFileReport', () => {
1388
+ let tmpDir: string;
1389
+
1390
+ beforeEach(() => {
1391
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-'));
1392
+ });
1393
+
1394
+ afterEach(() => {
1395
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1396
+ });
1397
+
1398
+ function makeReport(overrides: Partial<FullReport> = {}): FullReport {
1399
+ return {
1400
+ generatedAt: '2026-03-17T00:00:00.000Z',
1401
+ repoRoot: '/repo',
1402
+ options: {},
1403
+ parser: { requested: 'auto', effective: 'typescript' },
1404
+ summary: {
1405
+ totalFiles: 10,
1406
+ totalFunctions: 50,
1407
+ totalFlows: 200,
1408
+ totalDependencyFiles: 12,
1409
+ totalPackages: 2,
1410
+ },
1411
+ fileInventory: [],
1412
+ duplicateFlows: {
1413
+ duplicatedFunctions: [],
1414
+ duplicatedControlFlow: [],
1415
+ totalFunctionGroups: 0,
1416
+ totalFlowGroups: 0,
1417
+ },
1418
+ dependencyGraph: minimalDepSummary(),
1419
+ dependencyFindings: [],
1420
+ agentOutput: {
1421
+ totalFindings: 0,
1422
+ highPriority: 0,
1423
+ mediumPriority: 0,
1424
+ lowPriority: 0,
1425
+ topRecommendations: [],
1426
+ filesWithIssues: [],
1427
+ },
1428
+ optimizationOpportunities: [],
1429
+ optimizationFindings: [],
1430
+ parseErrors: [],
1431
+ ...overrides,
1432
+ };
1433
+ }
1434
+
1435
+ function makeFindings(
1436
+ ...categories: Array<{ category: string; severity: string }>
1437
+ ): Finding[] {
1438
+ return categories.map((c, i) => ({
1439
+ id: `AST-ISSUE-${i}`,
1440
+ category: c.category,
1441
+ severity: c.severity as Finding['severity'],
1442
+ file: `src/file${i}.ts`,
1443
+ lineStart: 1,
1444
+ lineEnd: 10,
1445
+ title: `Finding: ${c.category}`,
1446
+ reason: 'test reason',
1447
+ files: [`src/file${i}.ts`],
1448
+ suggestedFix: { strategy: 'fix it', steps: ['step 1'] },
1449
+ }));
1450
+ }
1451
+
1452
+ it('creates all 7 expected files (6 json + summary.md)', () => {
1453
+ const outDir = path.join(tmpDir, 'scan');
1454
+ writeMultiFileReport(
1455
+ outDir,
1456
+ makeReport(),
1457
+ { ...DEFAULT_OPTS, graph: false },
1458
+ emptyState(),
1459
+ minimalDepSummary(),
1460
+ new Map()
1461
+ );
1462
+ const files = fs.readdirSync(outDir).sort();
1463
+ expect(files).toEqual([
1464
+ 'architecture.json',
1465
+ 'code-quality.json',
1466
+ 'dead-code.json',
1467
+ 'file-inventory.json',
1468
+ 'findings.json',
1469
+ 'summary.json',
1470
+ 'summary.md',
1471
+ ]);
1472
+ });
1473
+
1474
+ it('includes graph.md when graph option is true', () => {
1475
+ const outDir = path.join(tmpDir, 'scan-graph');
1476
+ writeMultiFileReport(
1477
+ outDir,
1478
+ makeReport(),
1479
+ { ...DEFAULT_OPTS, graph: true },
1480
+ emptyState(),
1481
+ minimalDepSummary(),
1482
+ new Map()
1483
+ );
1484
+ expect(fs.existsSync(path.join(outDir, 'graph.md'))).toBe(true);
1485
+ });
1486
+
1487
+ it('includes ast-trees.txt in compact text format when astTrees are present', () => {
1488
+ const outDir = path.join(tmpDir, 'scan-trees');
1489
+ const tree = {
1490
+ kind: 'SourceFile',
1491
+ startLine: 1,
1492
+ endLine: 10,
1493
+ children: [
1494
+ { kind: 'ImportDeclaration', startLine: 1, endLine: 1, children: [] },
1495
+ {
1496
+ kind: 'FunctionDeclaration',
1497
+ startLine: 3,
1498
+ endLine: 8,
1499
+ children: [
1500
+ {
1501
+ kind: 'Block',
1502
+ startLine: 3,
1503
+ endLine: 8,
1504
+ children: [],
1505
+ truncated: true,
1506
+ },
1507
+ ],
1508
+ },
1509
+ ],
1510
+ };
1511
+ const report = makeReport({
1512
+ astTrees: [{ package: 'test', file: 'a.ts', tree }],
1513
+ });
1514
+ writeMultiFileReport(
1515
+ outDir,
1516
+ report,
1517
+ { ...DEFAULT_OPTS },
1518
+ emptyState(),
1519
+ minimalDepSummary(),
1520
+ new Map()
1521
+ );
1522
+ const txtPath = path.join(outDir, 'ast-trees.txt');
1523
+ expect(fs.existsSync(txtPath)).toBe(true);
1524
+ const content = fs.readFileSync(txtPath, 'utf8');
1525
+ expect(content).toContain('## test — a.ts');
1526
+ expect(content).toContain('SourceFile[1:10]');
1527
+ expect(content).toContain(' ImportDeclaration[1]');
1528
+ expect(content).toContain(' FunctionDeclaration[3:8]');
1529
+ expect(content).toContain(' Block[3:8] ...');
1530
+ expect(content).not.toContain('"kind"');
1531
+ });
1532
+
1533
+ it('routes architecture findings into architecture.json', () => {
1534
+ const findings = makeFindings(
1535
+ { category: 'dependency-cycle', severity: 'high' },
1536
+ { category: 'architecture-sdp-violation', severity: 'medium' },
1537
+ { category: 'dead-export', severity: 'high' }
1538
+ );
1539
+ const outDir = path.join(tmpDir, 'scan-arch');
1540
+ writeMultiFileReport(
1541
+ outDir,
1542
+ makeReport({ optimizationFindings: findings }),
1543
+ DEFAULT_OPTS,
1544
+ emptyState(),
1545
+ minimalDepSummary(),
1546
+ new Map()
1547
+ );
1548
+ const archData = JSON.parse(
1549
+ fs.readFileSync(path.join(outDir, 'architecture.json'), 'utf8')
1550
+ );
1551
+ expect(archData.findingsCount).toBe(2);
1552
+ expect(
1553
+ archData.findings.every((f: Finding) =>
1554
+ ARCHITECTURE_CATEGORIES.has(f.category)
1555
+ )
1556
+ ).toBe(true);
1557
+ });
1558
+
1559
+ it('routes code quality findings into code-quality.json', () => {
1560
+ const findings = makeFindings(
1561
+ { category: 'function-optimization', severity: 'high' },
1562
+ { category: 'cognitive-complexity', severity: 'medium' },
1563
+ { category: 'orphan-module', severity: 'medium' }
1564
+ );
1565
+ const outDir = path.join(tmpDir, 'scan-qual');
1566
+ writeMultiFileReport(
1567
+ outDir,
1568
+ makeReport({ optimizationFindings: findings }),
1569
+ DEFAULT_OPTS,
1570
+ emptyState(),
1571
+ minimalDepSummary(),
1572
+ new Map()
1573
+ );
1574
+ const qualData = JSON.parse(
1575
+ fs.readFileSync(path.join(outDir, 'code-quality.json'), 'utf8')
1576
+ );
1577
+ expect(qualData.findingsCount).toBe(2);
1578
+ expect(
1579
+ qualData.findings.every((f: Finding) =>
1580
+ CODE_QUALITY_CATEGORIES.has(f.category)
1581
+ )
1582
+ ).toBe(true);
1583
+ });
1584
+
1585
+ it('routes dead code findings into dead-code.json', () => {
1586
+ const findings = makeFindings(
1587
+ { category: 'dead-export', severity: 'high' },
1588
+ { category: 'unused-npm-dependency', severity: 'low' },
1589
+ { category: 'barrel-explosion', severity: 'medium' },
1590
+ { category: 'dependency-cycle', severity: 'high' }
1591
+ );
1592
+ const outDir = path.join(tmpDir, 'scan-dead');
1593
+ writeMultiFileReport(
1594
+ outDir,
1595
+ makeReport({ optimizationFindings: findings }),
1596
+ DEFAULT_OPTS,
1597
+ emptyState(),
1598
+ minimalDepSummary(),
1599
+ new Map()
1600
+ );
1601
+ const deadData = JSON.parse(
1602
+ fs.readFileSync(path.join(outDir, 'dead-code.json'), 'utf8')
1603
+ );
1604
+ expect(deadData.findingsCount).toBe(3);
1605
+ expect(
1606
+ deadData.findings.every((f: Finding) =>
1607
+ DEAD_CODE_CATEGORIES.has(f.category)
1608
+ )
1609
+ ).toBe(true);
1610
+ });
1611
+
1612
+ it('findings.json contains ALL findings', () => {
1613
+ const findings = makeFindings(
1614
+ { category: 'dependency-cycle', severity: 'high' },
1615
+ { category: 'dead-export', severity: 'medium' },
1616
+ { category: 'function-optimization', severity: 'high' }
1617
+ );
1618
+ const outDir = path.join(tmpDir, 'scan-all');
1619
+ writeMultiFileReport(
1620
+ outDir,
1621
+ makeReport({ optimizationFindings: findings }),
1622
+ DEFAULT_OPTS,
1623
+ emptyState(),
1624
+ minimalDepSummary(),
1625
+ new Map()
1626
+ );
1627
+ const findingsData = JSON.parse(
1628
+ fs.readFileSync(path.join(outDir, 'findings.json'), 'utf8')
1629
+ );
1630
+ expect(findingsData.totalFindings).toBe(3);
1631
+ });
1632
+
1633
+ it('summary.json contains outputFiles index', () => {
1634
+ const outDir = path.join(tmpDir, 'scan-idx');
1635
+ writeMultiFileReport(
1636
+ outDir,
1637
+ makeReport(),
1638
+ DEFAULT_OPTS,
1639
+ emptyState(),
1640
+ minimalDepSummary(),
1641
+ new Map()
1642
+ );
1643
+ const summaryData = JSON.parse(
1644
+ fs.readFileSync(path.join(outDir, 'summary.json'), 'utf8')
1645
+ );
1646
+ expect(summaryData.outputFiles).toBeDefined();
1647
+ expect(summaryData.outputFiles.summary).toBe('summary.json');
1648
+ expect(summaryData.outputFiles.architecture).toBe('architecture.json');
1649
+ expect(summaryData.outputFiles.deadCode).toBe('dead-code.json');
1650
+ expect(summaryData.outputFiles.summaryMd).toBe('summary.md');
1651
+ });
1652
+
1653
+ it('file-inventory.json contains fileInventory and fileCount', () => {
1654
+ const fileEntries = [
1655
+ makeFile({ file: 'src/a.ts' }),
1656
+ makeFile({ file: 'src/b.ts' }),
1657
+ ];
1658
+ const outDir = path.join(tmpDir, 'scan-inv');
1659
+ writeMultiFileReport(
1660
+ outDir,
1661
+ makeReport({ fileInventory: fileEntries }),
1662
+ DEFAULT_OPTS,
1663
+ emptyState(),
1664
+ minimalDepSummary(),
1665
+ new Map()
1666
+ );
1667
+ const invData = JSON.parse(
1668
+ fs.readFileSync(path.join(outDir, 'file-inventory.json'), 'utf8')
1669
+ );
1670
+ expect(invData.fileCount).toBe(2);
1671
+ expect(invData.fileInventory.length).toBe(2);
1672
+ });
1673
+
1674
+ it('architecture.json includes severityBreakdown and categoryBreakdown', () => {
1675
+ const findings = makeFindings(
1676
+ { category: 'dependency-cycle', severity: 'high' },
1677
+ { category: 'dependency-cycle', severity: 'high' },
1678
+ { category: 'high-coupling', severity: 'medium' }
1679
+ );
1680
+ const outDir = path.join(tmpDir, 'scan-arch-meta');
1681
+ writeMultiFileReport(
1682
+ outDir,
1683
+ makeReport({ optimizationFindings: findings }),
1684
+ DEFAULT_OPTS,
1685
+ emptyState(),
1686
+ minimalDepSummary(),
1687
+ new Map()
1688
+ );
1689
+ const archData = JSON.parse(
1690
+ fs.readFileSync(path.join(outDir, 'architecture.json'), 'utf8')
1691
+ );
1692
+ expect(archData.severityBreakdown.high).toBe(2);
1693
+ expect(archData.severityBreakdown.medium).toBe(1);
1694
+ expect(archData.categoryBreakdown['dependency-cycle']).toBe(2);
1695
+ expect(archData.categoryBreakdown['high-coupling']).toBe(1);
1696
+ });
1697
+
1698
+ it('code-quality.json includes severityBreakdown and categoryBreakdown', () => {
1699
+ const findings = makeFindings(
1700
+ { category: 'function-optimization', severity: 'high' },
1701
+ { category: 'god-module', severity: 'high' },
1702
+ { category: 'cognitive-complexity', severity: 'medium' }
1703
+ );
1704
+ const outDir = path.join(tmpDir, 'scan-qual-meta');
1705
+ writeMultiFileReport(
1706
+ outDir,
1707
+ makeReport({ optimizationFindings: findings }),
1708
+ DEFAULT_OPTS,
1709
+ emptyState(),
1710
+ minimalDepSummary(),
1711
+ new Map()
1712
+ );
1713
+ const qualData = JSON.parse(
1714
+ fs.readFileSync(path.join(outDir, 'code-quality.json'), 'utf8')
1715
+ );
1716
+ expect(qualData.severityBreakdown.high).toBe(2);
1717
+ expect(qualData.categoryBreakdown['function-optimization']).toBe(1);
1718
+ expect(qualData.categoryBreakdown['god-module']).toBe(1);
1719
+ });
1720
+
1721
+ it('dead-code.json includes severityBreakdown and categoryBreakdown', () => {
1722
+ const findings = makeFindings(
1723
+ { category: 'dead-export', severity: 'high' },
1724
+ { category: 'dead-export', severity: 'medium' },
1725
+ { category: 'unused-npm-dependency', severity: 'low' }
1726
+ );
1727
+ const outDir = path.join(tmpDir, 'scan-dead-meta');
1728
+ writeMultiFileReport(
1729
+ outDir,
1730
+ makeReport({ optimizationFindings: findings }),
1731
+ DEFAULT_OPTS,
1732
+ emptyState(),
1733
+ minimalDepSummary(),
1734
+ new Map()
1735
+ );
1736
+ const deadData = JSON.parse(
1737
+ fs.readFileSync(path.join(outDir, 'dead-code.json'), 'utf8')
1738
+ );
1739
+ expect(deadData.severityBreakdown.high).toBe(1);
1740
+ expect(deadData.severityBreakdown.medium).toBe(1);
1741
+ expect(deadData.severityBreakdown.low).toBe(1);
1742
+ expect(deadData.categoryBreakdown['dead-export']).toBe(2);
1743
+ expect(deadData.categoryBreakdown['unused-npm-dependency']).toBe(1);
1744
+ });
1745
+
1746
+ it('all json files have generatedAt timestamp', () => {
1747
+ const outDir = path.join(tmpDir, 'scan-ts');
1748
+ writeMultiFileReport(
1749
+ outDir,
1750
+ makeReport(),
1751
+ DEFAULT_OPTS,
1752
+ emptyState(),
1753
+ minimalDepSummary(),
1754
+ new Map()
1755
+ );
1756
+ for (const file of [
1757
+ 'summary.json',
1758
+ 'architecture.json',
1759
+ 'code-quality.json',
1760
+ 'dead-code.json',
1761
+ 'file-inventory.json',
1762
+ 'findings.json',
1763
+ ]) {
1764
+ const data = JSON.parse(fs.readFileSync(path.join(outDir, file), 'utf8'));
1765
+ expect(data.generatedAt).toBe('2026-03-17T00:00:00.000Z');
1766
+ }
1767
+ });
1768
+
1769
+ it('returns correct outputFiles mapping', () => {
1770
+ const outDir = path.join(tmpDir, 'scan-ret');
1771
+ const result = writeMultiFileReport(
1772
+ outDir,
1773
+ makeReport(),
1774
+ { ...DEFAULT_OPTS, graph: true },
1775
+ emptyState(),
1776
+ minimalDepSummary(),
1777
+ new Map()
1778
+ );
1779
+ expect(result.summary).toBe('summary.json');
1780
+ expect(result.architecture).toBe('architecture.json');
1781
+ expect(result.codeQuality).toBe('code-quality.json');
1782
+ expect(result.deadCode).toBe('dead-code.json');
1783
+ expect(result.fileInventory).toBe('file-inventory.json');
1784
+ expect(result.findings).toBe('findings.json');
1785
+ expect(result.graph).toBe('graph.md');
1786
+ expect(result.summaryMd).toBe('summary.md');
1787
+ });
1788
+ });
1789
+
1790
+ describe('generateSummaryMd', () => {
1791
+ const fakeDir = '/tmp/nonexistent-scan-dir';
1792
+
1793
+ function makeReportForMd(overrides: Partial<FullReport> = {}): FullReport {
1794
+ return {
1795
+ generatedAt: '2026-03-17T00:00:00.000Z',
1796
+ repoRoot: '/repo',
1797
+ options: {},
1798
+ parser: { requested: 'auto', effective: 'typescript' },
1799
+ summary: {
1800
+ totalFiles: 42,
1801
+ totalFunctions: 318,
1802
+ totalFlows: 1204,
1803
+ totalDependencyFiles: 50,
1804
+ totalPackages: 3,
1805
+ },
1806
+ fileInventory: [],
1807
+ duplicateFlows: {},
1808
+ dependencyGraph: minimalDepSummary({
1809
+ totalModules: 42,
1810
+ totalEdges: 187,
1811
+ cycles: [{ path: ['a', 'b', 'a'], nodeCount: 2 }],
1812
+ criticalPaths: [],
1813
+ }),
1814
+ dependencyFindings: [],
1815
+ agentOutput: {
1816
+ totalFindings: 5,
1817
+ highPriority: 2,
1818
+ mediumPriority: 2,
1819
+ lowPriority: 1,
1820
+ topRecommendations: [
1821
+ {
1822
+ severity: 'high',
1823
+ title: 'Fix cycle',
1824
+ file: 'src/a.ts',
1825
+ category: 'dependency-cycle',
1826
+ },
1827
+ ],
1828
+ filesWithIssues: [],
1829
+ },
1830
+ optimizationOpportunities: [],
1831
+ optimizationFindings: [],
1832
+ parseErrors: [],
1833
+ ...overrides,
1834
+ };
1835
+ }
1836
+
1837
+ it('produces markdown with all major sections', () => {
1838
+ const md = generateSummaryMd({
1839
+ dir: fakeDir,
1840
+ report: makeReportForMd(),
1841
+ outputFiles: { summary: 'summary.json' },
1842
+ architectureFindings: [],
1843
+ codeQualityFindings: [],
1844
+ deadCodeFindings: [],
1845
+ });
1846
+ expect(md).toContain('# Code Quality Scan Report');
1847
+ expect(md).toContain('## Scan Scope');
1848
+ expect(md).toContain('## Findings Overview');
1849
+ expect(md).toContain('## Health Scores');
1850
+ expect(md).toContain('## Architecture Health');
1851
+ expect(md).toContain('## Code Quality');
1852
+ expect(md).toContain('## Dead Code & Hygiene');
1853
+ expect(md).toContain('## Output Files');
1854
+ });
1855
+
1856
+ it('includes file counts from summary', () => {
1857
+ const md = generateSummaryMd({
1858
+ dir: fakeDir,
1859
+ report: makeReportForMd(),
1860
+ outputFiles: {},
1861
+ architectureFindings: [],
1862
+ codeQualityFindings: [],
1863
+ deadCodeFindings: [],
1864
+ });
1865
+ expect(md).toContain('42');
1866
+ expect(md).toContain('318');
1867
+ expect(md).toContain('1204');
1868
+ });
1869
+
1870
+ it('includes severity counts', () => {
1871
+ const findings: Finding[] = [
1872
+ {
1873
+ id: '1',
1874
+ severity: 'high',
1875
+ category: 'dependency-cycle',
1876
+ file: 'a',
1877
+ lineStart: 1,
1878
+ lineEnd: 1,
1879
+ title: 't',
1880
+ reason: 'r',
1881
+ files: [],
1882
+ suggestedFix: { strategy: 's', steps: [] },
1883
+ },
1884
+ {
1885
+ id: '2',
1886
+ severity: 'medium',
1887
+ category: 'dead-export',
1888
+ file: 'b',
1889
+ lineStart: 1,
1890
+ lineEnd: 1,
1891
+ title: 't',
1892
+ reason: 'r',
1893
+ files: [],
1894
+ suggestedFix: { strategy: 's', steps: [] },
1895
+ },
1896
+ ];
1897
+ const md = generateSummaryMd({
1898
+ dir: fakeDir,
1899
+ report: makeReportForMd({ optimizationFindings: findings }),
1900
+ outputFiles: {},
1901
+ architectureFindings: [findings[0]],
1902
+ codeQualityFindings: [],
1903
+ deadCodeFindings: [findings[1]],
1904
+ });
1905
+ expect(md).toContain('| High | 1 |');
1906
+ expect(md).toContain('| Medium | 1 |');
1907
+ expect(md).toContain('| **Total** | **2** |');
1908
+ });
1909
+
1910
+ it('includes dependency graph metrics', () => {
1911
+ const md = generateSummaryMd({
1912
+ dir: fakeDir,
1913
+ report: makeReportForMd(),
1914
+ outputFiles: {},
1915
+ architectureFindings: [],
1916
+ codeQualityFindings: [],
1917
+ deadCodeFindings: [],
1918
+ });
1919
+ expect(md).toContain('| Modules | 42 |');
1920
+ expect(md).toContain('| Import edges | 187 |');
1921
+ expect(md).toContain('| Cycles | 1 |');
1922
+ });
1923
+
1924
+ it('includes category breakdowns per section', () => {
1925
+ const archFindings = [
1926
+ { category: 'dependency-cycle', severity: 'high' },
1927
+ { category: 'dependency-cycle', severity: 'high' },
1928
+ { category: 'high-coupling', severity: 'medium' },
1929
+ ] as Finding[];
1930
+ const md = generateSummaryMd({
1931
+ dir: fakeDir,
1932
+ report: makeReportForMd(),
1933
+ outputFiles: {},
1934
+ architectureFindings: archFindings,
1935
+ codeQualityFindings: [],
1936
+ deadCodeFindings: [],
1937
+ });
1938
+ expect(md).toContain('`dependency-cycle`: 2');
1939
+ expect(md).toContain('`high-coupling`: 1');
1940
+ });
1941
+
1942
+ it('includes top recommendations', () => {
1943
+ const md = generateSummaryMd({
1944
+ dir: fakeDir,
1945
+ report: makeReportForMd(),
1946
+ outputFiles: {},
1947
+ architectureFindings: [],
1948
+ codeQualityFindings: [],
1949
+ deadCodeFindings: [],
1950
+ });
1951
+ expect(md).toContain('## Top Recommendations');
1952
+ expect(md).toContain('Fix cycle');
1953
+ expect(md).toContain('src/a.ts');
1954
+ });
1955
+
1956
+ it('includes parse errors when present', () => {
1957
+ const report = makeReportForMd({
1958
+ parseErrors: [{ file: 'bad.ts', message: 'Unexpected token' }],
1959
+ });
1960
+ const md = generateSummaryMd({
1961
+ dir: fakeDir,
1962
+ report,
1963
+ outputFiles: {},
1964
+ architectureFindings: [],
1965
+ codeQualityFindings: [],
1966
+ deadCodeFindings: [],
1967
+ });
1968
+ expect(md).toContain('## Parse Errors');
1969
+ expect(md).toContain('bad.ts');
1970
+ expect(md).toContain('Unexpected token');
1971
+ });
1972
+
1973
+ it('does not include parse errors section when none exist', () => {
1974
+ const md = generateSummaryMd({
1975
+ dir: fakeDir,
1976
+ report: makeReportForMd(),
1977
+ outputFiles: {},
1978
+ architectureFindings: [],
1979
+ codeQualityFindings: [],
1980
+ deadCodeFindings: [],
1981
+ });
1982
+ expect(md).not.toContain('## Parse Errors');
1983
+ });
1984
+
1985
+ it('links output files in the table', () => {
1986
+ const outputFiles = {
1987
+ summary: 'summary.json',
1988
+ architecture: 'architecture.json',
1989
+ summaryMd: 'summary.md',
1990
+ };
1991
+ const md = generateSummaryMd({
1992
+ dir: fakeDir,
1993
+ report: makeReportForMd(),
1994
+ outputFiles,
1995
+ architectureFindings: [],
1996
+ codeQualityFindings: [],
1997
+ deadCodeFindings: [],
1998
+ });
1999
+ expect(md).toContain('[`summary.json`](./summary.json)');
2000
+ expect(md).toContain('[`architecture.json`](./architecture.json)');
2001
+ expect(md).toContain('[`summary.md`](./summary.md)');
2002
+ });
2003
+
2004
+ it('shows file sizes when files exist', () => {
2005
+ const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'summary-size-'));
2006
+ try {
2007
+ fs.writeFileSync(
2008
+ path.join(realDir, 'architecture.json'),
2009
+ '{"x":1}',
2010
+ 'utf8'
2011
+ );
2012
+ fs.writeFileSync(
2013
+ path.join(realDir, 'big.json'),
2014
+ 'x'.repeat(2048),
2015
+ 'utf8'
2016
+ );
2017
+ const outputFiles = {
2018
+ architecture: 'architecture.json',
2019
+ big: 'big.json',
2020
+ };
2021
+ const md = generateSummaryMd({
2022
+ dir: realDir,
2023
+ report: makeReportForMd(),
2024
+ outputFiles,
2025
+ architectureFindings: [],
2026
+ codeQualityFindings: [],
2027
+ deadCodeFindings: [],
2028
+ });
2029
+ expect(md).toContain('| Size |');
2030
+ expect(md).toMatch(/\d+(\.\d+)?\s*(B|KB|MB)/);
2031
+ } finally {
2032
+ fs.rmSync(realDir, { recursive: true, force: true });
2033
+ }
2034
+ });
2035
+ });
2036
+
2037
+ describe('computeHealthScore', () => {
2038
+ it('returns 100 for no findings', () => {
2039
+ expect(computeHealthScore([], 50)).toBe(100);
2040
+ });
2041
+
2042
+ it('returns 100 for empty repo', () => {
2043
+ expect(computeHealthScore([], 0)).toBe(100);
2044
+ });
2045
+
2046
+ it('penalizes critical findings heavily', () => {
2047
+ const findings = [
2048
+ { severity: 'critical' } as Finding,
2049
+ { severity: 'critical' } as Finding,
2050
+ ];
2051
+ const score = computeHealthScore(findings, 10);
2052
+ expect(score).toBeLessThan(70);
2053
+ });
2054
+
2055
+ it('penalizes proportional to file count', () => {
2056
+ const findings = [{ severity: 'high' } as Finding];
2057
+ const smallRepo = computeHealthScore(findings, 5);
2058
+ const largeRepo = computeHealthScore(findings, 100);
2059
+ expect(largeRepo).toBeGreaterThan(smallRepo);
2060
+ });
2061
+
2062
+ it('keeps extreme cases near the floor', () => {
2063
+ const findings = Array.from(
2064
+ { length: 100 },
2065
+ () => ({ severity: 'critical' }) as Finding
2066
+ );
2067
+ expect(computeHealthScore(findings, 1)).toBeLessThanOrEqual(1);
2068
+ });
2069
+ });
2070
+
2071
+ describe('collectTagCloud', () => {
2072
+ it('returns empty for no findings', () => {
2073
+ expect(collectTagCloud([])).toEqual([]);
2074
+ });
2075
+
2076
+ it('returns empty when findings have no tags', () => {
2077
+ const findings = [{ tags: undefined } as unknown as Finding];
2078
+ expect(collectTagCloud(findings)).toEqual([]);
2079
+ });
2080
+
2081
+ it('counts and sorts tags by frequency', () => {
2082
+ const findings = [
2083
+ { tags: ['coupling', 'architecture'] } as unknown as Finding,
2084
+ { tags: ['coupling', 'change-risk'] } as unknown as Finding,
2085
+ { tags: ['dead-code'] } as unknown as Finding,
2086
+ ];
2087
+ const cloud = collectTagCloud(findings);
2088
+ expect(cloud[0]).toEqual({ tag: 'coupling', count: 2 });
2089
+ expect(cloud.length).toBe(4);
2090
+ });
2091
+ });
2092
+
2093
+ describe('end-to-end output validation', () => {
2094
+ it('produces valid summary.md with all sections', async () => {
2095
+ const { execSync } = await import('node:child_process');
2096
+ const dir = '/tmp/cq-test-' + Date.now();
2097
+ const scriptPath = path.join(process.cwd(), 'scripts', 'index.js');
2098
+ const monorepoRoot = path.join(process.cwd(), '..', '..');
2099
+ try {
2100
+ execSync(
2101
+ `node "${scriptPath}" --root "${monorepoRoot}" --out "${dir}" --no-tree`,
2102
+ { cwd: process.cwd(), encoding: 'utf8', timeout: 30000 }
2103
+ );
2104
+
2105
+ expect(fs.existsSync(`${dir}/summary.md`)).toBe(true);
2106
+ expect(fs.existsSync(`${dir}/summary.json`)).toBe(true);
2107
+ expect(fs.existsSync(`${dir}/architecture.json`)).toBe(true);
2108
+ expect(fs.existsSync(`${dir}/code-quality.json`)).toBe(true);
2109
+ expect(fs.existsSync(`${dir}/dead-code.json`)).toBe(true);
2110
+ expect(fs.existsSync(`${dir}/findings.json`)).toBe(true);
2111
+ expect(fs.existsSync(`${dir}/file-inventory.json`)).toBe(true);
2112
+
2113
+ const summary = fs.readFileSync(`${dir}/summary.md`, 'utf8');
2114
+ expect(summary).toContain('## Scan Scope');
2115
+ expect(summary).toContain('## Findings Overview');
2116
+ expect(summary).toContain('## Architecture Health');
2117
+ expect(summary).toContain('## Code Quality');
2118
+ expect(summary).toContain('## Dead Code & Hygiene');
2119
+ expect(summary).toContain('## Output Files');
2120
+
2121
+ expect(summary).toContain('`dependency-cycle`');
2122
+ expect(summary).toContain('`dead-export`');
2123
+ expect(summary).toContain('`cognitive-complexity`');
2124
+
2125
+ const findingsData = JSON.parse(
2126
+ fs.readFileSync(`${dir}/findings.json`, 'utf8')
2127
+ );
2128
+ expect(findingsData.optimizationFindings).toBeDefined();
2129
+ expect(Array.isArray(findingsData.optimizationFindings)).toBe(true);
2130
+
2131
+ for (const f of findingsData.optimizationFindings.slice(0, 10)) {
2132
+ expect(f.id).toBeDefined();
2133
+ expect(f.severity).toBeDefined();
2134
+ expect(f.category).toBeDefined();
2135
+ expect(f.file).toBeDefined();
2136
+ expect(f.lineStart).toBeDefined();
2137
+ expect(f.lineEnd).toBeDefined();
2138
+ expect(f.title).toBeDefined();
2139
+ expect(f.reason).toBeDefined();
2140
+ expect(f.suggestedFix).toBeDefined();
2141
+ expect(f.suggestedFix.strategy).toBeDefined();
2142
+ expect(f.suggestedFix.steps).toBeDefined();
2143
+ }
2144
+ } finally {
2145
+ try {
2146
+ execSync(`rm -rf "${dir}"`, { encoding: 'utf8' });
2147
+ } catch {
2148
+ void 0;
2149
+ }
2150
+ }
2151
+ }, 30000);
2152
+ });
2153
+
2154
+ describe('new AST detectors via buildIssueCatalog', () => {
2155
+ const testOpts = { ...DEFAULT_OPTS, findingsLimit: 500, includeTests: false };
2156
+
2157
+ function makeEntry(
2158
+ file: string,
2159
+ overrides: Partial<FileEntry> = {}
2160
+ ): FileEntry {
2161
+ return {
2162
+ package: 'test-pkg',
2163
+ file,
2164
+ parseEngine: 'typescript',
2165
+ nodeCount: 0,
2166
+ kindCounts: {},
2167
+ functions: [],
2168
+ flows: [],
2169
+ dependencyProfile: {
2170
+ internalDependencies: [],
2171
+ externalDependencies: [],
2172
+ unresolvedDependencies: [],
2173
+ declaredExports: [],
2174
+ importedSymbols: [],
2175
+ reExports: [],
2176
+ },
2177
+ ...overrides,
2178
+ };
2179
+ }
2180
+
2181
+ it('detects type-assertion-escape from pre-collected data', () => {
2182
+ const entry = makeEntry('src/risky.ts', {
2183
+ typeAssertionEscapes: {
2184
+ asAny: [
2185
+ { file: 'src/risky.ts', lineStart: 5, lineEnd: 5 },
2186
+ { file: 'src/risky.ts', lineStart: 10, lineEnd: 10 },
2187
+ ],
2188
+ doubleAssertion: [{ file: 'src/risky.ts', lineStart: 15, lineEnd: 15 }],
2189
+ nonNull: [{ file: 'src/risky.ts', lineStart: 20, lineEnd: 20 }],
2190
+ },
2191
+ });
2192
+ const { findings } = buildIssueCatalog(
2193
+ [],
2194
+ [],
2195
+ [entry],
2196
+ minimalDepSummary(),
2197
+ emptyState(),
2198
+ testOpts
2199
+ );
2200
+ const escapes = findings.filter(
2201
+ f => f.category === 'type-assertion-escape'
2202
+ );
2203
+ expect(escapes.length).toBe(1);
2204
+ expect(escapes[0].title).toContain('4');
2205
+ expect(escapes[0].severity).toBe('medium');
2206
+ });
2207
+
2208
+ it('detects high-severity type-assertion-escape', () => {
2209
+ const entry = makeEntry('src/bad.ts', {
2210
+ typeAssertionEscapes: {
2211
+ asAny: [
2212
+ { file: 'src/bad.ts', lineStart: 1, lineEnd: 1 },
2213
+ { file: 'src/bad.ts', lineStart: 2, lineEnd: 2 },
2214
+ { file: 'src/bad.ts', lineStart: 3, lineEnd: 3 },
2215
+ { file: 'src/bad.ts', lineStart: 4, lineEnd: 4 },
2216
+ ],
2217
+ doubleAssertion: [],
2218
+ nonNull: [],
2219
+ },
2220
+ });
2221
+ const { findings } = buildIssueCatalog(
2222
+ [],
2223
+ [],
2224
+ [entry],
2225
+ minimalDepSummary(),
2226
+ emptyState(),
2227
+ testOpts
2228
+ );
2229
+ const escapes = findings.filter(
2230
+ f => f.category === 'type-assertion-escape'
2231
+ );
2232
+ expect(escapes[0].severity).toBe('high');
2233
+ });
2234
+
2235
+ it('detects missing-error-boundary from pre-collected data', () => {
2236
+ const entry = makeEntry('src/api.ts', {
2237
+ unprotectedAsync: [
2238
+ { name: 'fetchData', awaitCount: 5, lineStart: 10, lineEnd: 20 },
2239
+ ],
2240
+ });
2241
+ const { findings } = buildIssueCatalog(
2242
+ [],
2243
+ [],
2244
+ [entry],
2245
+ minimalDepSummary(),
2246
+ emptyState(),
2247
+ testOpts
2248
+ );
2249
+ const errors = findings.filter(
2250
+ f => f.category === 'missing-error-boundary'
2251
+ );
2252
+ expect(errors.length).toBe(1);
2253
+ expect(errors[0].title).toContain('fetchData');
2254
+ expect(errors[0].severity).toBe('high');
2255
+ });
2256
+
2257
+ it('detects promise-misuse from pre-collected data', () => {
2258
+ const entry = makeEntry('src/svc.ts', {
2259
+ asyncWithoutAwait: [{ name: 'doNothing', lineStart: 5, lineEnd: 10 }],
2260
+ });
2261
+ const { findings } = buildIssueCatalog(
2262
+ [],
2263
+ [],
2264
+ [entry],
2265
+ minimalDepSummary(),
2266
+ emptyState(),
2267
+ testOpts
2268
+ );
2269
+ const misuse = findings.filter(f => f.category === 'promise-misuse');
2270
+ expect(misuse.length).toBe(1);
2271
+ expect(misuse[0].title).toContain('doNothing');
2272
+ expect(misuse[0].severity).toBe('medium');
2273
+ });
2274
+
2275
+ it('skips test files for all new detectors', () => {
2276
+ const entry = makeEntry('src/__tests__/foo.test.ts', {
2277
+ typeAssertionEscapes: {
2278
+ asAny: [
2279
+ { file: 'src/__tests__/foo.test.ts', lineStart: 1, lineEnd: 1 },
2280
+ ],
2281
+ doubleAssertion: [],
2282
+ nonNull: [],
2283
+ },
2284
+ unprotectedAsync: [
2285
+ { name: 'testFn', awaitCount: 1, lineStart: 1, lineEnd: 5 },
2286
+ ],
2287
+ asyncWithoutAwait: [{ name: 'mockFn', lineStart: 1, lineEnd: 5 }],
2288
+ });
2289
+ const { findings } = buildIssueCatalog(
2290
+ [],
2291
+ [],
2292
+ [entry],
2293
+ minimalDepSummary(),
2294
+ emptyState(),
2295
+ testOpts
2296
+ );
2297
+ expect(
2298
+ findings.filter(f =>
2299
+ [
2300
+ 'type-assertion-escape',
2301
+ 'missing-error-boundary',
2302
+ 'promise-misuse',
2303
+ ].includes(f.category)
2304
+ )
2305
+ ).toHaveLength(0);
2306
+ });
2307
+
2308
+ it('detects import-side-effect-risk for shared library with top-level sync-io', () => {
2309
+ const state = emptyState();
2310
+ for (let i = 0; i < 10; i++) {
2311
+ addEdge(state, `src/consumer${i}.ts`, 'src/shared-lib.ts');
2312
+ }
2313
+ const entry = makeEntry('src/shared-lib.ts', {
2314
+ topLevelEffects: [
2315
+ {
2316
+ kind: 'sync-io',
2317
+ lineStart: 5,
2318
+ lineEnd: 5,
2319
+ detail: 'fs.readFileSync()',
2320
+ weight: 5,
2321
+ confidence: 'high',
2322
+ },
2323
+ {
2324
+ kind: 'timer',
2325
+ lineStart: 8,
2326
+ lineEnd: 8,
2327
+ detail: 'setInterval()',
2328
+ weight: 4,
2329
+ confidence: 'high',
2330
+ },
2331
+ ],
2332
+ });
2333
+ const { findings } = buildIssueCatalog(
2334
+ [],
2335
+ [],
2336
+ [entry],
2337
+ minimalDepSummary(),
2338
+ state,
2339
+ testOpts
2340
+ );
2341
+ const sideEffects = findings.filter(
2342
+ f => f.category === 'import-side-effect-risk'
2343
+ );
2344
+ expect(sideEffects.length).toBe(1);
2345
+ expect(sideEffects[0].severity).toBe('high');
2346
+ expect(sideEffects[0].reason).toContain('fan-in=10');
2347
+ });
2348
+
2349
+ it('discounts entrypoint role for import-side-effect-risk', () => {
2350
+ const entry = makeEntry('src/index.ts', {
2351
+ topLevelEffects: [
2352
+ {
2353
+ kind: 'process-handler',
2354
+ lineStart: 10,
2355
+ lineEnd: 10,
2356
+ detail: 'process.on()',
2357
+ weight: 4,
2358
+ confidence: 'high',
2359
+ },
2360
+ ],
2361
+ });
2362
+ const { findings } = buildIssueCatalog(
2363
+ [],
2364
+ [],
2365
+ [entry],
2366
+ minimalDepSummary(),
2367
+ emptyState(),
2368
+ testOpts
2369
+ );
2370
+ const sideEffects = findings.filter(
2371
+ f => f.category === 'import-side-effect-risk'
2372
+ );
2373
+ expect(sideEffects).toHaveLength(0);
2374
+ });
2375
+
2376
+ it('flags side-effect-only imports in high fan-in modules', () => {
2377
+ const state = emptyState();
2378
+ for (let i = 0; i < 20; i++) {
2379
+ addEdge(state, `src/consumer${i}.ts`, 'src/barrel.ts');
2380
+ }
2381
+ const entry = makeEntry('src/barrel.ts', {
2382
+ topLevelEffects: [
2383
+ {
2384
+ kind: 'side-effect-import',
2385
+ lineStart: 1,
2386
+ lineEnd: 1,
2387
+ detail: "import './init'",
2388
+ weight: 3,
2389
+ confidence: 'medium',
2390
+ },
2391
+ {
2392
+ kind: 'side-effect-import',
2393
+ lineStart: 2,
2394
+ lineEnd: 2,
2395
+ detail: "import './polyfill'",
2396
+ weight: 3,
2397
+ confidence: 'medium',
2398
+ },
2399
+ ],
2400
+ });
2401
+ const { findings } = buildIssueCatalog(
2402
+ [],
2403
+ [],
2404
+ [entry],
2405
+ minimalDepSummary(),
2406
+ state,
2407
+ testOpts
2408
+ );
2409
+ const sideEffects = findings.filter(
2410
+ f => f.category === 'import-side-effect-risk'
2411
+ );
2412
+ expect(sideEffects.length).toBe(1);
2413
+ expect(sideEffects[0].reason).toContain('fan-in=20');
2414
+ expect(sideEffects[0].severity).toBe('high');
2415
+ });
2416
+
2417
+ it('skips modules with no top-level effects', () => {
2418
+ const entry = makeEntry('src/clean.ts');
2419
+ const { findings } = buildIssueCatalog(
2420
+ [],
2421
+ [],
2422
+ [entry],
2423
+ minimalDepSummary(),
2424
+ emptyState(),
2425
+ testOpts
2426
+ );
2427
+ const sideEffects = findings.filter(
2428
+ f => f.category === 'import-side-effect-risk'
2429
+ );
2430
+ expect(sideEffects).toHaveLength(0);
2431
+ });
2432
+
2433
+ it('detects critical severity for exec-sync at top level with high fan-in', () => {
2434
+ const state = emptyState();
2435
+ for (let i = 0; i < 25; i++) {
2436
+ addEdge(state, `src/consumer${i}.ts`, 'src/danger.ts');
2437
+ }
2438
+ const depSummary = minimalDepSummary({
2439
+ criticalPaths: [
2440
+ {
2441
+ start: 'src/danger.ts',
2442
+ path: ['src/danger.ts', 'src/core.ts'],
2443
+ score: 100,
2444
+ length: 2,
2445
+ containsCycle: false,
2446
+ },
2447
+ ],
2448
+ });
2449
+ const entry = makeEntry('src/danger.ts', {
2450
+ topLevelEffects: [
2451
+ {
2452
+ kind: 'exec-sync',
2453
+ lineStart: 3,
2454
+ lineEnd: 3,
2455
+ detail: 'execSync()',
2456
+ weight: 8,
2457
+ confidence: 'high',
2458
+ },
2459
+ ],
2460
+ });
2461
+ const { findings } = buildIssueCatalog(
2462
+ [],
2463
+ [],
2464
+ [entry],
2465
+ depSummary,
2466
+ state,
2467
+ testOpts
2468
+ );
2469
+ const sideEffects = findings.filter(
2470
+ f => f.category === 'import-side-effect-risk'
2471
+ );
2472
+ expect(sideEffects.length).toBe(1);
2473
+ expect(sideEffects[0].severity).toBe('critical');
2474
+ });
2475
+
2476
+ it('skips test files for import-side-effect-risk', () => {
2477
+ const entry = makeEntry('src/__tests__/setup.test.ts', {
2478
+ topLevelEffects: [
2479
+ {
2480
+ kind: 'sync-io',
2481
+ lineStart: 1,
2482
+ lineEnd: 1,
2483
+ detail: 'fs.readFileSync()',
2484
+ weight: 5,
2485
+ confidence: 'high',
2486
+ },
2487
+ ],
2488
+ });
2489
+ const { findings } = buildIssueCatalog(
2490
+ [],
2491
+ [],
2492
+ [entry],
2493
+ minimalDepSummary(),
2494
+ emptyState(),
2495
+ testOpts
2496
+ );
2497
+ const sideEffects = findings.filter(
2498
+ f => f.category === 'import-side-effect-risk'
2499
+ );
2500
+ expect(sideEffects).toHaveLength(0);
2501
+ });
2502
+ });
2503
+
2504
+ function makeFinding(override: Partial<Finding> = {}): Finding {
2505
+ return {
2506
+ id: 'AST-ISSUE-0001',
2507
+ severity: 'medium',
2508
+ category: 'function-optimization',
2509
+ file: 'src/a.ts',
2510
+ lineStart: 1,
2511
+ lineEnd: 10,
2512
+ title: 'Test',
2513
+ reason: 'test',
2514
+ files: ['src/a.ts'],
2515
+ suggestedFix: { strategy: 's', steps: ['s'] },
2516
+ ...override,
2517
+ };
2518
+ }
2519
+
2520
+ describe('computeDependencyCycles (additional)', () => {
2521
+ it('detects self-loop (A->A)', () => {
2522
+ const state = emptyState();
2523
+ addEdge(state, 'a.ts', 'a.ts');
2524
+ const cycles = computeDependencyCycles(state);
2525
+ expect(cycles.length).toBe(1);
2526
+ expect(cycles[0].nodeCount).toBe(1);
2527
+ expect(cycles[0].path).toContain('a.ts');
2528
+ });
2529
+ });
2530
+
2531
+ describe('computeDependencyCriticalPaths (additional)', () => {
2532
+ it('returns longest path by weighted score for simple chain', () => {
2533
+ const state = emptyState();
2534
+ addEdge(state, 'root.ts', 'mid.ts');
2535
+ addEdge(state, 'mid.ts', 'leaf.ts');
2536
+ const critMap = new Map<string, FileCriticality>();
2537
+ critMap.set('root.ts', {
2538
+ file: 'root.ts',
2539
+ complexityRisk: 1,
2540
+ highComplexityFunctions: 0,
2541
+ functionCount: 1,
2542
+ flows: 0,
2543
+ score: 5,
2544
+ });
2545
+ critMap.set('mid.ts', {
2546
+ file: 'mid.ts',
2547
+ complexityRisk: 1,
2548
+ highComplexityFunctions: 0,
2549
+ functionCount: 1,
2550
+ flows: 0,
2551
+ score: 50,
2552
+ });
2553
+ critMap.set('leaf.ts', {
2554
+ file: 'leaf.ts',
2555
+ complexityRisk: 1,
2556
+ highComplexityFunctions: 0,
2557
+ functionCount: 1,
2558
+ flows: 0,
2559
+ score: 100,
2560
+ });
2561
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
2562
+ expect(paths.length).toBeGreaterThan(0);
2563
+ expect(paths[0].path).toEqual(['root.ts', 'mid.ts', 'leaf.ts']);
2564
+ expect(paths[0].score).toBe(155);
2565
+ expect(paths[0].length).toBe(3);
2566
+ });
2567
+ });
2568
+
2569
+ describe('diversifyFindings (additional)', () => {
2570
+ it('interleaves multiple categories when limit is below total', () => {
2571
+ const input = [
2572
+ makeFinding({ category: 'dead-export', severity: 'high' }),
2573
+ makeFinding({ category: 'dead-export', severity: 'high' }),
2574
+ makeFinding({ category: 'dependency-cycle', severity: 'high' }),
2575
+ makeFinding({ category: 'function-optimization', severity: 'medium' }),
2576
+ ];
2577
+ const result = diversifyFindings(input, 3);
2578
+ expect(result).toHaveLength(3);
2579
+ const categories = result.map(f => f.category);
2580
+ expect(new Set(categories).size).toBeGreaterThanOrEqual(2);
2581
+ });
2582
+ });
2583
+
2584
+ describe('diverseTopRecommendations (additional)', () => {
2585
+ it('honors maxPerCategory when one category dominates', () => {
2586
+ const findings = Array.from({ length: 20 }, (_, i) =>
2587
+ makeFinding({ id: `f-${i}`, category: 'dead-export', severity: 'high' })
2588
+ );
2589
+ findings.push(
2590
+ makeFinding({
2591
+ id: 'other',
2592
+ category: 'dependency-cycle',
2593
+ severity: 'high',
2594
+ })
2595
+ );
2596
+ const result = diverseTopRecommendations(findings, 10, 2);
2597
+ expect(result.filter(f => f.category === 'dead-export')).toHaveLength(2);
2598
+ expect(result.filter(f => f.category === 'dependency-cycle')).toHaveLength(
2599
+ 1
2600
+ );
2601
+ });
2602
+ });
2603
+
2604
+ describe('severityBreakdown and categoryBreakdown (additional)', () => {
2605
+ it('severityBreakdown includes info severity', () => {
2606
+ const findings = [
2607
+ makeFinding({ severity: 'info' }),
2608
+ makeFinding({ severity: 'info' }),
2609
+ ];
2610
+ const result = severityBreakdown(findings);
2611
+ expect(result.info).toBe(2);
2612
+ });
2613
+
2614
+ it('categoryBreakdown handles unknown categories', () => {
2615
+ const findings = [makeFinding({ category: 'custom-cat' })];
2616
+ const result = categoryBreakdown(findings);
2617
+ expect(result['custom-cat']).toBe(1);
2618
+ });
2619
+ });
2620
+
2621
+ describe('buildIssueCatalog (additional paths)', () => {
2622
+ it('respects noDiversify option (no round-robin)', () => {
2623
+ const depSummary = minimalDepSummary({
2624
+ cycles: [{ path: ['a.ts', 'b.ts', 'a.ts'], nodeCount: 2 }],
2625
+ testOnlyModules: [
2626
+ {
2627
+ file: 'src/t.ts',
2628
+ outboundCount: 0,
2629
+ inboundCount: 1,
2630
+ inboundFromProduction: 0,
2631
+ inboundFromTests: 1,
2632
+ externalDependencyCount: 0,
2633
+ unresolvedDependencyCount: 0,
2634
+ },
2635
+ ],
2636
+ });
2637
+ const state = emptyState();
2638
+ state.files.add('src/lib.ts');
2639
+ state.declaredExportsByFile.set('src/lib.ts', [
2640
+ { name: 'deadFn', kind: 'value', lineStart: 10, lineEnd: 15 },
2641
+ ]);
2642
+ const files = [
2643
+ makeFile({ functions: [makeFn({ complexity: 40, name: 'complexFn' })] }),
2644
+ ];
2645
+ const optsNoDiv = { ...testOpts, findingsLimit: 3, noDiversify: true };
2646
+ const { findings } = buildIssueCatalog(
2647
+ [],
2648
+ [],
2649
+ files,
2650
+ depSummary,
2651
+ state,
2652
+ optsNoDiv
2653
+ );
2654
+ expect(findings.length).toBeLessThanOrEqual(3);
2655
+ });
2656
+
2657
+ it('triggers await-in-loop from awaitInLoopLocations', () => {
2658
+ const entry = makeFile({
2659
+ file: 'src/async.ts',
2660
+ awaitInLoopLocations: [
2661
+ { file: 'src/async.ts', lineStart: 10, lineEnd: 12 },
2662
+ ],
2663
+ });
2664
+ const { findings } = buildIssueCatalog(
2665
+ [],
2666
+ [],
2667
+ [entry],
2668
+ minimalDepSummary(),
2669
+ emptyState(),
2670
+ testOpts
2671
+ );
2672
+ expect(findings.some(f => f.category === 'await-in-loop')).toBe(true);
2673
+ });
2674
+
2675
+ it('triggers sync-io from syncIoCalls', () => {
2676
+ const entry = makeFile({
2677
+ file: 'src/io.ts',
2678
+ syncIoCalls: [{ name: 'readFileSync', lineStart: 5, lineEnd: 5 }],
2679
+ });
2680
+ const { findings } = buildIssueCatalog(
2681
+ [],
2682
+ [],
2683
+ [entry],
2684
+ minimalDepSummary(),
2685
+ emptyState(),
2686
+ testOpts
2687
+ );
2688
+ expect(findings.some(f => f.category === 'sync-io')).toBe(true);
2689
+ });
2690
+
2691
+ it('triggers uncleared-timer from timerCalls (setInterval without cleanup)', () => {
2692
+ const entry = makeFile({
2693
+ file: 'src/timers.ts',
2694
+ timerCalls: [
2695
+ { kind: 'setInterval', lineStart: 8, lineEnd: 8, hasCleanup: false },
2696
+ ],
2697
+ });
2698
+ const { findings } = buildIssueCatalog(
2699
+ [],
2700
+ [],
2701
+ [entry],
2702
+ minimalDepSummary(),
2703
+ emptyState(),
2704
+ testOpts
2705
+ );
2706
+ expect(findings.some(f => f.category === 'uncleared-timer')).toBe(true);
2707
+ });
2708
+
2709
+ it('triggers listener-leak-risk from listenerRegistrations without removals', () => {
2710
+ const entry = makeFile({
2711
+ file: 'src/events.ts',
2712
+ listenerRegistrations: [
2713
+ { file: 'src/events.ts', lineStart: 15, lineEnd: 15 },
2714
+ ],
2715
+ listenerRemovals: [],
2716
+ });
2717
+ const { findings } = buildIssueCatalog(
2718
+ [],
2719
+ [],
2720
+ [entry],
2721
+ minimalDepSummary(),
2722
+ emptyState(),
2723
+ testOpts
2724
+ );
2725
+ expect(findings.some(f => f.category === 'listener-leak-risk')).toBe(true);
2726
+ });
2727
+
2728
+ it('respects findingsLimit truncation', () => {
2729
+ const files = Array.from({ length: 30 }, (_, i) =>
2730
+ makeFile({
2731
+ file: `src/f${i}.ts`,
2732
+ functions: [makeFn({ complexity: 40, name: `fn${i}` })],
2733
+ })
2734
+ );
2735
+ const opts = { ...testOpts, findingsLimit: 8 };
2736
+ const { findings, totalBeforeTruncation } = buildIssueCatalog(
2737
+ [],
2738
+ [],
2739
+ files,
2740
+ minimalDepSummary(),
2741
+ emptyState(),
2742
+ opts
2743
+ );
2744
+ expect(findings.length).toBeLessThanOrEqual(8);
2745
+ expect(totalBeforeTruncation).toBeGreaterThanOrEqual(findings.length);
2746
+ });
2747
+ });
2748
+
2749
+ describe('writeMultiFileReport (additional)', () => {
2750
+ let tmpDir: string;
2751
+
2752
+ beforeEach(() => {
2753
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-add-'));
2754
+ });
2755
+
2756
+ afterEach(() => {
2757
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2758
+ });
2759
+
2760
+ function makeMinimalReport(overrides: Partial<FullReport> = {}): FullReport {
2761
+ return {
2762
+ generatedAt: '2026-03-17T00:00:00.000Z',
2763
+ repoRoot: '/repo',
2764
+ options: {},
2765
+ parser: { requested: 'auto', effective: 'typescript' },
2766
+ summary: {
2767
+ totalFiles: 5,
2768
+ totalFunctions: 20,
2769
+ totalFlows: 50,
2770
+ totalDependencyFiles: 5,
2771
+ totalPackages: 1,
2772
+ },
2773
+ fileInventory: [],
2774
+ duplicateFlows: {
2775
+ duplicatedFunctions: [],
2776
+ duplicatedControlFlow: [],
2777
+ totalFunctionGroups: 0,
2778
+ totalFlowGroups: 0,
2779
+ },
2780
+ dependencyGraph: minimalDepSummary(),
2781
+ dependencyFindings: [],
2782
+ agentOutput: {
2783
+ totalFindings: 0,
2784
+ highPriority: 0,
2785
+ mediumPriority: 0,
2786
+ lowPriority: 0,
2787
+ topRecommendations: [],
2788
+ filesWithIssues: [],
2789
+ },
2790
+ optimizationOpportunities: [],
2791
+ optimizationFindings: [],
2792
+ parseErrors: [],
2793
+ ...overrides,
2794
+ };
2795
+ }
2796
+
2797
+ it('creates summary.md with expected content', () => {
2798
+ const outDir = path.join(tmpDir, 'scan');
2799
+ writeMultiFileReport(
2800
+ outDir,
2801
+ makeMinimalReport(),
2802
+ { ...DEFAULT_OPTS, graph: false },
2803
+ emptyState(),
2804
+ minimalDepSummary(),
2805
+ new Map()
2806
+ );
2807
+ const summaryPath = path.join(outDir, 'summary.md');
2808
+ expect(fs.existsSync(summaryPath)).toBe(true);
2809
+ const content = fs.readFileSync(summaryPath, 'utf8');
2810
+ expect(content).toContain('# Code Quality Scan Report');
2811
+ expect(content).toContain('## Scan Scope');
2812
+ expect(content).toContain('## Findings Overview');
2813
+ });
2814
+
2815
+ it('creates findings.json with expected structure', () => {
2816
+ const outDir = path.join(tmpDir, 'scan');
2817
+ const findings = [
2818
+ makeFinding({ id: '1', category: 'dead-export', severity: 'high' }),
2819
+ makeFinding({
2820
+ id: '2',
2821
+ category: 'dependency-cycle',
2822
+ severity: 'medium',
2823
+ }),
2824
+ ];
2825
+ writeMultiFileReport(
2826
+ outDir,
2827
+ makeMinimalReport({ optimizationFindings: findings }),
2828
+ DEFAULT_OPTS,
2829
+ emptyState(),
2830
+ minimalDepSummary(),
2831
+ new Map()
2832
+ );
2833
+ const findingsPath = path.join(outDir, 'findings.json');
2834
+ expect(fs.existsSync(findingsPath)).toBe(true);
2835
+ const data = JSON.parse(fs.readFileSync(findingsPath, 'utf8'));
2836
+ expect(data.totalFindings).toBe(2);
2837
+ expect(Array.isArray(data.optimizationFindings)).toBe(true);
2838
+ expect(data.optimizationFindings.length).toBe(2);
2839
+ });
2840
+
2841
+ it('summary.md includes Analysis Signals when reportAnalysis is provided', () => {
2842
+ const outDir = path.join(tmpDir, 'scan');
2843
+ const reportAnalysis: import('./reporting/analysis.js').ReportAnalysisSummary =
2844
+ {
2845
+ graphSignals: [],
2846
+ astSignals: [],
2847
+ combinedSignals: [],
2848
+ strongestGraphSignal: {
2849
+ kind: 'cycle',
2850
+ lens: 'graph',
2851
+ title: 'Cycle',
2852
+ summary: 'Cycle detected',
2853
+ confidence: 'high',
2854
+ score: 80,
2855
+ files: [],
2856
+ categories: [],
2857
+ evidence: {},
2858
+ },
2859
+ strongestAstSignal: null,
2860
+ combinedInterpretation: {
2861
+ kind: 'hybrid',
2862
+ lens: 'hybrid',
2863
+ title: 'Hybrid',
2864
+ summary: 'Combined view',
2865
+ confidence: 'medium',
2866
+ score: 60,
2867
+ files: [],
2868
+ categories: [],
2869
+ evidence: {},
2870
+ },
2871
+ recommendedValidation: {
2872
+ summary: 'Validate with LSP',
2873
+ tools: ['lspFindReferences', 'lspGotoDefinition'],
2874
+ },
2875
+ investigationPrompts: ['Check cycle impact'],
2876
+ };
2877
+ writeMultiFileReport(
2878
+ outDir,
2879
+ makeMinimalReport({ reportAnalysis }),
2880
+ DEFAULT_OPTS,
2881
+ emptyState(),
2882
+ minimalDepSummary(),
2883
+ new Map()
2884
+ );
2885
+ const summaryPath = path.join(outDir, 'summary.md');
2886
+ const content = fs.readFileSync(summaryPath, 'utf8');
2887
+ expect(content).toContain('## Analysis Signals');
2888
+ expect(content).toContain('Cycle detected');
2889
+ expect(content).toContain('Combined view');
2890
+ expect(content).toContain('Validate with LSP');
2891
+ expect(content).toContain('Check cycle impact');
2892
+ });
2893
+
2894
+ it('summary.md includes structural layout alert for mega-folder signal', () => {
2895
+ const outDir = path.join(tmpDir, 'scan-mega');
2896
+ const reportAnalysis: import('./reporting/analysis.js').ReportAnalysisSummary =
2897
+ {
2898
+ graphSignals: [
2899
+ {
2900
+ kind: 'mega-folder-cluster',
2901
+ lens: 'graph',
2902
+ title: 'Mega folder concentration',
2903
+ summary:
2904
+ 'src/core concentrates 42 files (54.0% of analyzed production files), which is a structural decomposition risk.',
2905
+ confidence: 'high',
2906
+ score: 180,
2907
+ files: ['src/core/a.ts'],
2908
+ categories: ['mega-folder'],
2909
+ evidence: {
2910
+ folderPath: 'src/core',
2911
+ fileCount: 42,
2912
+ concentration: 0.54,
2913
+ },
2914
+ },
2915
+ ],
2916
+ astSignals: [],
2917
+ combinedSignals: [],
2918
+ strongestGraphSignal: {
2919
+ kind: 'mega-folder-cluster',
2920
+ lens: 'graph',
2921
+ title: 'Mega folder concentration',
2922
+ summary: 'src/core concentrates 42 files',
2923
+ confidence: 'high',
2924
+ score: 180,
2925
+ files: ['src/core/a.ts'],
2926
+ categories: ['mega-folder'],
2927
+ evidence: { folderPath: 'src/core' },
2928
+ },
2929
+ strongestAstSignal: null,
2930
+ combinedInterpretation: null,
2931
+ recommendedValidation: null,
2932
+ investigationPrompts: [
2933
+ 'Plan decomposition for src/core into smaller domain folders before adding more files there.',
2934
+ ],
2935
+ };
2936
+ writeMultiFileReport(
2937
+ outDir,
2938
+ makeMinimalReport({ reportAnalysis }),
2939
+ DEFAULT_OPTS,
2940
+ emptyState(),
2941
+ minimalDepSummary(),
2942
+ new Map()
2943
+ );
2944
+ const content = fs.readFileSync(path.join(outDir, 'summary.md'), 'utf8');
2945
+ expect(content).toContain('Structural Layout Alert');
2946
+ expect(content).toContain('src/core concentrates 42 files');
2947
+ });
2948
+ });
2949
+
2950
+ describe('writeMultiFileReport comprehensive', () => {
2951
+ let tmpDir: string;
2952
+
2953
+ beforeEach(() => {
2954
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'idx-test-'));
2955
+ });
2956
+
2957
+ afterEach(() => {
2958
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2959
+ });
2960
+
2961
+ function makeFullReport(overrides: Partial<FullReport> = {}): FullReport {
2962
+ const fileEntry = makeFile({ file: 'src/main.ts' });
2963
+ const depState = emptyState();
2964
+ addEdge(depState, 'src/a.ts', 'src/b.ts');
2965
+ addEdge(depState, 'src/b.ts', 'src/c.ts');
2966
+ addEdge(depState, 'src/c.ts', 'src/a.ts');
2967
+ const depSummary = minimalDepSummary({
2968
+ totalModules: 3,
2969
+ totalEdges: 3,
2970
+ cycles: [
2971
+ {
2972
+ path: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/a.ts'],
2973
+ nodeCount: 3,
2974
+ },
2975
+ ],
2976
+ criticalPaths: [
2977
+ {
2978
+ start: 'src/a.ts',
2979
+ path: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
2980
+ score: 200,
2981
+ length: 3,
2982
+ containsCycle: true,
2983
+ },
2984
+ ],
2985
+ criticalModules: [
2986
+ {
2987
+ file: 'src/a.ts',
2988
+ inboundCount: 1,
2989
+ outboundCount: 1,
2990
+ inboundFromProduction: 1,
2991
+ inboundFromTests: 0,
2992
+ externalDependencyCount: 0,
2993
+ unresolvedDependencyCount: 0,
2994
+ score: 50,
2995
+ riskBand: 'medium',
2996
+ },
2997
+ ],
2998
+ outgoingTop: [{ file: 'src/a.ts', count: 1, score: 50 }],
2999
+ inboundTop: [{ file: 'src/c.ts', count: 1, score: 50 }],
3000
+ });
3001
+ return {
3002
+ generatedAt: '2026-03-18T00:00:00.000Z',
3003
+ repoRoot: '/repo',
3004
+ options: {},
3005
+ parser: { requested: 'auto', effective: 'typescript' },
3006
+ summary: {
3007
+ totalFiles: 5,
3008
+ totalFunctions: 20,
3009
+ totalFlows: 80,
3010
+ totalDependencyFiles: 5,
3011
+ totalPackages: 1,
3012
+ },
3013
+ fileInventory: [fileEntry],
3014
+ duplicateFlows: {
3015
+ duplicatedFunctions: [],
3016
+ duplicatedControlFlow: [],
3017
+ totalFunctionGroups: 0,
3018
+ totalFlowGroups: 0,
3019
+ },
3020
+ dependencyGraph: depSummary,
3021
+ dependencyFindings: [],
3022
+ agentOutput: {
3023
+ totalFindings: 0,
3024
+ highPriority: 0,
3025
+ mediumPriority: 0,
3026
+ lowPriority: 0,
3027
+ topRecommendations: [],
3028
+ filesWithIssues: [],
3029
+ },
3030
+ optimizationOpportunities: [],
3031
+ optimizationFindings: [],
3032
+ parseErrors: [{ file: 'bad.ts', message: 'Unexpected token' }],
3033
+ ...overrides,
3034
+ };
3035
+ }
3036
+
3037
+ function makeDepStateWithEdges(): {
3038
+ state: ReturnType<typeof emptyState>;
3039
+ summary: DependencySummary;
3040
+ } {
3041
+ const state = emptyState();
3042
+ addEdge(state, 'src/a.ts', 'src/b.ts');
3043
+ addEdge(state, 'src/b.ts', 'src/c.ts');
3044
+ addEdge(state, 'src/c.ts', 'src/a.ts');
3045
+ const summary = minimalDepSummary({
3046
+ totalModules: 3,
3047
+ totalEdges: 3,
3048
+ cycles: [
3049
+ {
3050
+ path: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/a.ts'],
3051
+ nodeCount: 3,
3052
+ },
3053
+ ],
3054
+ criticalPaths: [
3055
+ {
3056
+ start: 'src/a.ts',
3057
+ path: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
3058
+ score: 150,
3059
+ length: 3,
3060
+ containsCycle: true,
3061
+ },
3062
+ ],
3063
+ criticalModules: [
3064
+ {
3065
+ file: 'src/a.ts',
3066
+ inboundCount: 1,
3067
+ outboundCount: 1,
3068
+ inboundFromProduction: 1,
3069
+ inboundFromTests: 0,
3070
+ externalDependencyCount: 0,
3071
+ unresolvedDependencyCount: 0,
3072
+ score: 60,
3073
+ riskBand: 'high',
3074
+ },
3075
+ ],
3076
+ outgoingTop: [{ file: 'src/a.ts', count: 1, score: 60 }],
3077
+ inboundTop: [{ file: 'src/c.ts', count: 1, score: 60 }],
3078
+ });
3079
+ return { state, summary };
3080
+ }
3081
+
3082
+ it('creates ALL output files including graph.md when options.graph=true', () => {
3083
+ const outDir = path.join(tmpDir, 'scan-graph');
3084
+ const { state, summary } = makeDepStateWithEdges();
3085
+ const report = makeFullReport();
3086
+ writeMultiFileReport(
3087
+ outDir,
3088
+ report,
3089
+ { ...DEFAULT_OPTS, graph: true },
3090
+ state,
3091
+ summary,
3092
+ new Map()
3093
+ );
3094
+ expect(fs.existsSync(path.join(outDir, 'graph.md'))).toBe(true);
3095
+ const graphContent = fs.readFileSync(path.join(outDir, 'graph.md'), 'utf8');
3096
+ expect(graphContent).toContain('graph LR');
3097
+ expect(graphContent).toContain('Dependency Cycles');
3098
+ expect(graphContent).toContain('Critical Dependency Chains');
3099
+ expect(graphContent).toContain('Total modules');
3100
+ });
3101
+
3102
+ it('includes sccClusters and packageGraphSummary when options.graphAdvanced=true', () => {
3103
+ const outDir = path.join(tmpDir, 'scan-adv');
3104
+ const { state, summary } = makeDepStateWithEdges();
3105
+ const report = makeFullReport();
3106
+ writeMultiFileReport(
3107
+ outDir,
3108
+ report,
3109
+ { ...DEFAULT_OPTS, graph: true, graphAdvanced: true },
3110
+ state,
3111
+ summary,
3112
+ new Map()
3113
+ );
3114
+ const archData = JSON.parse(
3115
+ fs.readFileSync(path.join(outDir, 'architecture.json'), 'utf8')
3116
+ );
3117
+ expect(archData.sccClusters).toBeDefined();
3118
+ expect(Array.isArray(archData.sccClusters)).toBe(true);
3119
+ expect(archData.packageGraphSummary).toBeDefined();
3120
+ });
3121
+
3122
+ it('creates ast-trees.txt when report.astTrees is set', () => {
3123
+ const outDir = path.join(tmpDir, 'scan-trees');
3124
+ const tree = {
3125
+ kind: 'SourceFile',
3126
+ startLine: 1,
3127
+ endLine: 20,
3128
+ children: [
3129
+ {
3130
+ kind: 'FunctionDeclaration',
3131
+ startLine: 5,
3132
+ endLine: 15,
3133
+ children: [],
3134
+ },
3135
+ ],
3136
+ };
3137
+ const report = makeFullReport({
3138
+ astTrees: [{ package: 'test', file: 'src/foo.ts', tree }],
3139
+ });
3140
+ writeMultiFileReport(
3141
+ outDir,
3142
+ report,
3143
+ DEFAULT_OPTS,
3144
+ emptyState(),
3145
+ minimalDepSummary(),
3146
+ new Map()
3147
+ );
3148
+ const txtPath = path.join(outDir, 'ast-trees.txt');
3149
+ expect(fs.existsSync(txtPath)).toBe(true);
3150
+ const content = fs.readFileSync(txtPath, 'utf8');
3151
+ expect(content).toContain('## test — src/foo.ts');
3152
+ expect(content).toContain('SourceFile');
3153
+ expect(content).toContain('FunctionDeclaration');
3154
+ });
3155
+
3156
+ it('creates security.json when security findings present', () => {
3157
+ const outDir = path.join(tmpDir, 'scan-sec');
3158
+ const securityFindings = [
3159
+ makeFinding({
3160
+ id: 's1',
3161
+ category: 'hardcoded-secret',
3162
+ severity: 'high',
3163
+ file: 'src/keys.ts',
3164
+ }),
3165
+ ];
3166
+ const report = makeFullReport({ optimizationFindings: securityFindings });
3167
+ writeMultiFileReport(
3168
+ outDir,
3169
+ report,
3170
+ DEFAULT_OPTS,
3171
+ emptyState(),
3172
+ minimalDepSummary(),
3173
+ new Map()
3174
+ );
3175
+ expect(fs.existsSync(path.join(outDir, 'security.json'))).toBe(true);
3176
+ const secData = JSON.parse(
3177
+ fs.readFileSync(path.join(outDir, 'security.json'), 'utf8')
3178
+ );
3179
+ expect(secData.findingsCount).toBe(1);
3180
+ expect(secData.findings[0].category).toBe('hardcoded-secret');
3181
+ });
3182
+
3183
+ it('creates test-quality.json when test quality findings present', () => {
3184
+ const outDir = path.join(tmpDir, 'scan-test');
3185
+ const testFindings = [
3186
+ makeFinding({
3187
+ id: 't1',
3188
+ category: 'low-assertion-density',
3189
+ severity: 'medium',
3190
+ file: 'src/foo.test.ts',
3191
+ }),
3192
+ ];
3193
+ const report = makeFullReport({ optimizationFindings: testFindings });
3194
+ writeMultiFileReport(
3195
+ outDir,
3196
+ report,
3197
+ DEFAULT_OPTS,
3198
+ emptyState(),
3199
+ minimalDepSummary(),
3200
+ new Map()
3201
+ );
3202
+ expect(fs.existsSync(path.join(outDir, 'test-quality.json'))).toBe(true);
3203
+ const tqData = JSON.parse(
3204
+ fs.readFileSync(path.join(outDir, 'test-quality.json'), 'utf8')
3205
+ );
3206
+ expect(tqData.findingsCount).toBe(1);
3207
+ });
3208
+
3209
+ it('verifies summary.json contains outputFiles index', () => {
3210
+ const outDir = path.join(tmpDir, 'scan-sum');
3211
+ const { state, summary } = makeDepStateWithEdges();
3212
+ writeMultiFileReport(
3213
+ outDir,
3214
+ makeFullReport(),
3215
+ { ...DEFAULT_OPTS, graph: true },
3216
+ state,
3217
+ summary,
3218
+ new Map()
3219
+ );
3220
+ const summaryData = JSON.parse(
3221
+ fs.readFileSync(path.join(outDir, 'summary.json'), 'utf8')
3222
+ );
3223
+ expect(summaryData.outputFiles.summary).toBe('summary.json');
3224
+ expect(summaryData.outputFiles.findings).toBe('findings.json');
3225
+ expect(summaryData.outputFiles.architecture).toBe('architecture.json');
3226
+ expect(summaryData.outputFiles.codeQuality).toBe('code-quality.json');
3227
+ expect(summaryData.outputFiles.deadCode).toBe('dead-code.json');
3228
+ expect(summaryData.outputFiles.fileInventory).toBe('file-inventory.json');
3229
+ expect(summaryData.outputFiles.summaryMd).toBe('summary.md');
3230
+ expect(summaryData.outputFiles.graph).toBe('graph.md');
3231
+ });
3232
+
3233
+ it('verifies summary.md contains expected sections', () => {
3234
+ const outDir = path.join(tmpDir, 'scan-md');
3235
+ const report = makeFullReport();
3236
+ writeMultiFileReport(
3237
+ outDir,
3238
+ report,
3239
+ DEFAULT_OPTS,
3240
+ emptyState(),
3241
+ minimalDepSummary(),
3242
+ new Map()
3243
+ );
3244
+ const md = fs.readFileSync(path.join(outDir, 'summary.md'), 'utf8');
3245
+ expect(md).toContain('# Code Quality Scan Report');
3246
+ expect(md).toContain('## Scan Scope');
3247
+ expect(md).toContain('## Findings Overview');
3248
+ expect(md).toContain('## Health Scores');
3249
+ expect(md).toContain('## Architecture Health');
3250
+ expect(md).toContain('## Code Quality');
3251
+ expect(md).toContain('## Dead Code & Hygiene');
3252
+ expect(md).toContain('## Output Files');
3253
+ expect(md).toContain('## Parse Errors');
3254
+ expect(md).toContain('bad.ts');
3255
+ expect(md).toContain('Unexpected token');
3256
+ });
3257
+
3258
+ it('works with options.flow=true (enriches file inventory and findings)', () => {
3259
+ const outDir = path.join(tmpDir, 'scan-flow');
3260
+ const report = makeFullReport({
3261
+ fileInventory: [
3262
+ makeFile({
3263
+ file: 'src/a.ts',
3264
+ flows: [
3265
+ {
3266
+ kind: 'flow',
3267
+ file: 'src/a.ts',
3268
+ lineStart: 1,
3269
+ lineEnd: 5,
3270
+ columnStart: 1,
3271
+ columnEnd: 1,
3272
+ statementCount: 3,
3273
+ },
3274
+ ],
3275
+ }),
3276
+ ],
3277
+ });
3278
+ writeMultiFileReport(
3279
+ outDir,
3280
+ report,
3281
+ { ...DEFAULT_OPTS, flow: true },
3282
+ emptyState(),
3283
+ minimalDepSummary(),
3284
+ new Map()
3285
+ );
3286
+ const invData = JSON.parse(
3287
+ fs.readFileSync(path.join(outDir, 'file-inventory.json'), 'utf8')
3288
+ );
3289
+ expect(invData.fileInventory).toBeDefined();
3290
+ expect(invData.fileCount).toBe(1);
3291
+ });
3292
+
3293
+ it('uses report.graphAnalytics when provided (skips computeGraphAnalytics)', () => {
3294
+ const outDir = path.join(tmpDir, 'scan-precomputed');
3295
+ const graphAnalytics = {
3296
+ sccClusters: [
3297
+ {
3298
+ id: 'c1',
3299
+ files: ['src/a.ts'],
3300
+ nodeCount: 1,
3301
+ edgeCount: 0,
3302
+ entryEdges: 0,
3303
+ exitEdges: 0,
3304
+ hubFiles: [],
3305
+ },
3306
+ ],
3307
+ chokepoints: [],
3308
+ packageGraphSummary: {
3309
+ packageCount: 1,
3310
+ edgeCount: 0,
3311
+ packages: [],
3312
+ hotspots: [],
3313
+ },
3314
+ articulationPoints: [],
3315
+ bridgeEdges: [],
3316
+ };
3317
+ const report = makeFullReport({ graphAnalytics });
3318
+ writeMultiFileReport(
3319
+ outDir,
3320
+ report,
3321
+ { ...DEFAULT_OPTS, graphAdvanced: true },
3322
+ emptyState(),
3323
+ minimalDepSummary(),
3324
+ new Map()
3325
+ );
3326
+ const archData = JSON.parse(
3327
+ fs.readFileSync(path.join(outDir, 'architecture.json'), 'utf8')
3328
+ );
3329
+ expect(archData.sccClusters).toHaveLength(1);
3330
+ expect(archData.sccClusters[0].id).toBe('c1');
3331
+ });
3332
+ });
3333
+
3334
+ describe('buildIssueCatalog detector paths', () => {
3335
+ const opts = {
3336
+ ...DEFAULT_OPTS,
3337
+ root: '/repo',
3338
+ findingsLimit: 500,
3339
+ anyThreshold: 5,
3340
+ halsteadEffortThreshold: 500_000,
3341
+ maintainabilityIndexThreshold: 20,
3342
+ };
3343
+
3344
+ it('detects dead exports via declaredExportsByFile without consumedFromModule', () => {
3345
+ const state = emptyState();
3346
+ state.files.add('src/lib.ts');
3347
+ state.declaredExportsByFile.set('src/lib.ts', [
3348
+ { name: 'usedFn', kind: 'value' },
3349
+ { name: 'deadExport', kind: 'value', lineStart: 20, lineEnd: 25 },
3350
+ ]);
3351
+ state.importedSymbolsByFile.set('src/consumer.ts', [
3352
+ {
3353
+ sourceModule: './lib',
3354
+ resolvedModule: 'src/lib.ts',
3355
+ importedName: 'usedFn',
3356
+ localName: 'usedFn',
3357
+ isTypeOnly: false,
3358
+ },
3359
+ ]);
3360
+ addEdge(state, 'src/consumer.ts', 'src/lib.ts');
3361
+ const { findings } = buildIssueCatalog(
3362
+ [],
3363
+ [],
3364
+ [],
3365
+ minimalDepSummary(),
3366
+ state,
3367
+ opts
3368
+ );
3369
+ expect(
3370
+ findings.some(
3371
+ f => f.category === 'dead-export' && f.title.includes('deadExport')
3372
+ )
3373
+ ).toBe(true);
3374
+ });
3375
+
3376
+ it('detects dead re-exports when reExport not consumed', () => {
3377
+ const state = emptyState();
3378
+ state.files.add('src/barrel.ts');
3379
+ state.reExportsByFile.set('src/barrel.ts', [
3380
+ {
3381
+ sourceModule: './a',
3382
+ resolvedModule: 'src/a.ts',
3383
+ exportedAs: 'unusedReExport',
3384
+ importedName: 'unusedReExport',
3385
+ isStar: false,
3386
+ isTypeOnly: false,
3387
+ lineStart: 1,
3388
+ lineEnd: 1,
3389
+ },
3390
+ ]);
3391
+ const { findings } = buildIssueCatalog(
3392
+ [],
3393
+ [],
3394
+ [],
3395
+ minimalDepSummary(),
3396
+ state,
3397
+ opts
3398
+ );
3399
+ expect(findings.some(f => f.category === 'dead-re-export')).toBe(true);
3400
+ });
3401
+
3402
+ it('detects namespace import (importedName=*)', () => {
3403
+ const state = emptyState();
3404
+ state.files.add('src/consumer.ts');
3405
+ state.importedSymbolsByFile.set('src/consumer.ts', [
3406
+ {
3407
+ sourceModule: 'lodash',
3408
+ resolvedModule: 'node_modules/lodash',
3409
+ importedName: '*',
3410
+ localName: '_',
3411
+ isTypeOnly: false,
3412
+ },
3413
+ ]);
3414
+ const { findings } = buildIssueCatalog(
3415
+ [],
3416
+ [],
3417
+ [],
3418
+ minimalDepSummary(),
3419
+ state,
3420
+ opts
3421
+ );
3422
+ expect(findings.some(f => f.category === 'namespace-import')).toBe(true);
3423
+ });
3424
+
3425
+ it('detects CommonJS in ESM (localName=require)', () => {
3426
+ const state = emptyState();
3427
+ state.files.add('src/mixed.ts');
3428
+ state.importedSymbolsByFile.set('src/mixed.ts', [
3429
+ {
3430
+ sourceModule: 'createRequire',
3431
+ resolvedModule: 'node:module',
3432
+ importedName: 'createRequire',
3433
+ localName: 'require',
3434
+ isTypeOnly: false,
3435
+ },
3436
+ ]);
3437
+ const { findings } = buildIssueCatalog(
3438
+ [],
3439
+ [],
3440
+ [],
3441
+ minimalDepSummary(),
3442
+ state,
3443
+ opts
3444
+ );
3445
+ expect(findings.some(f => f.category === 'commonjs-in-esm')).toBe(true);
3446
+ });
3447
+
3448
+ it('detects export-star leak (isStar=true)', () => {
3449
+ const state = emptyState();
3450
+ state.files.add('src/barrel.ts');
3451
+ state.reExportsByFile.set('src/barrel.ts', [
3452
+ {
3453
+ sourceModule: './internal',
3454
+ resolvedModule: 'src/internal.ts',
3455
+ exportedAs: '*',
3456
+ importedName: '*',
3457
+ isStar: true,
3458
+ isTypeOnly: false,
3459
+ lineStart: 1,
3460
+ lineEnd: 1,
3461
+ },
3462
+ ]);
3463
+ const { findings } = buildIssueCatalog(
3464
+ [],
3465
+ [],
3466
+ [],
3467
+ minimalDepSummary(),
3468
+ state,
3469
+ opts
3470
+ );
3471
+ expect(findings.some(f => f.category === 'export-star-leak')).toBe(true);
3472
+ });
3473
+
3474
+ it('detects unsafe-any when anyCount > threshold', () => {
3475
+ const files = [makeFile({ file: 'src/loose.ts', anyCount: 12 })];
3476
+ const { findings } = buildIssueCatalog(
3477
+ [],
3478
+ [],
3479
+ files,
3480
+ minimalDepSummary(),
3481
+ emptyState(),
3482
+ opts
3483
+ );
3484
+ expect(
3485
+ findings.some(
3486
+ f => f.category === 'unsafe-any' && f.file === 'src/loose.ts'
3487
+ )
3488
+ ).toBe(true);
3489
+ });
3490
+
3491
+ it('detects high Halstead effort when effort > threshold', () => {
3492
+ const fn = makeFn({
3493
+ file: 'src/hard.ts',
3494
+ halstead: {
3495
+ operators: 50,
3496
+ operands: 100,
3497
+ distinctOperators: 20,
3498
+ distinctOperands: 30,
3499
+ vocabulary: 50,
3500
+ length: 150,
3501
+ volume: 800,
3502
+ difficulty: 10,
3503
+ effort: 600_000,
3504
+ time: 30,
3505
+ estimatedBugs: 1,
3506
+ },
3507
+ });
3508
+ const files = [makeFile({ file: 'src/hard.ts', functions: [fn] })];
3509
+ const { findings } = buildIssueCatalog(
3510
+ [],
3511
+ [],
3512
+ files,
3513
+ minimalDepSummary(),
3514
+ emptyState(),
3515
+ opts
3516
+ );
3517
+ expect(findings.some(f => f.category === 'halstead-effort')).toBe(true);
3518
+ });
3519
+
3520
+ it('detects low maintainability when maintainabilityIndex < threshold', () => {
3521
+ const fn = makeFn({ file: 'src/bad.ts', maintainabilityIndex: 15 });
3522
+ const files = [makeFile({ file: 'src/bad.ts', functions: [fn] })];
3523
+ const { findings } = buildIssueCatalog(
3524
+ [],
3525
+ [],
3526
+ files,
3527
+ minimalDepSummary(),
3528
+ emptyState(),
3529
+ opts
3530
+ );
3531
+ expect(findings.some(f => f.category === 'low-maintainability')).toBe(true);
3532
+ });
3533
+
3534
+ it('detects unbounded-collection when loops>=2, calls>=5, maxLoopDepth>=2', () => {
3535
+ const fn = makeFn({
3536
+ file: 'src/collect.ts',
3537
+ loops: 3,
3538
+ calls: 8,
3539
+ maxLoopDepth: 3,
3540
+ });
3541
+ const files = [makeFile({ file: 'src/collect.ts', functions: [fn] })];
3542
+ const { findings } = buildIssueCatalog(
3543
+ [],
3544
+ [],
3545
+ files,
3546
+ minimalDepSummary(),
3547
+ emptyState(),
3548
+ opts
3549
+ );
3550
+ expect(findings.some(f => f.category === 'unbounded-collection')).toBe(
3551
+ true
3552
+ );
3553
+ });
3554
+ });
3555
+
3556
+ describe('computeDependencyCycles comprehensive', () => {
3557
+ it('detects triangle cycle A->B->C->A', () => {
3558
+ const state = emptyState();
3559
+ addEdge(state, 'a.ts', 'b.ts');
3560
+ addEdge(state, 'b.ts', 'c.ts');
3561
+ addEdge(state, 'c.ts', 'a.ts');
3562
+ const cycles = computeDependencyCycles(state);
3563
+ expect(cycles.length).toBe(1);
3564
+ expect(cycles[0].nodeCount).toBe(3);
3565
+ expect(cycles[0].path).toContain('a.ts');
3566
+ expect(cycles[0].path).toContain('b.ts');
3567
+ expect(cycles[0].path).toContain('c.ts');
3568
+ });
3569
+
3570
+ it('detects multiple disjoint cycles', () => {
3571
+ const state = emptyState();
3572
+ addEdge(state, 'a.ts', 'b.ts');
3573
+ addEdge(state, 'b.ts', 'a.ts');
3574
+ addEdge(state, 'x.ts', 'y.ts');
3575
+ addEdge(state, 'y.ts', 'z.ts');
3576
+ addEdge(state, 'z.ts', 'x.ts');
3577
+ const cycles = computeDependencyCycles(state);
3578
+ expect(cycles.length).toBe(2);
3579
+ });
3580
+
3581
+ it('returns empty for linear chain (no cycles)', () => {
3582
+ const state = emptyState();
3583
+ addEdge(state, 'a.ts', 'b.ts');
3584
+ addEdge(state, 'b.ts', 'c.ts');
3585
+ addEdge(state, 'c.ts', 'd.ts');
3586
+ expect(computeDependencyCycles(state)).toEqual([]);
3587
+ });
3588
+
3589
+ it('detects self-loop A->A', () => {
3590
+ const state = emptyState();
3591
+ addEdge(state, 'a.ts', 'a.ts');
3592
+ const cycles = computeDependencyCycles(state);
3593
+ expect(cycles.length).toBe(1);
3594
+ expect(cycles[0].nodeCount).toBe(1);
3595
+ expect(cycles[0].path).toContain('a.ts');
3596
+ });
3597
+ });
3598
+
3599
+ describe('computeDependencyCriticalPaths comprehensive', () => {
3600
+ it('returns single path root->leaf for linear chain', () => {
3601
+ const state = emptyState();
3602
+ addEdge(state, 'root.ts', 'mid.ts');
3603
+ addEdge(state, 'mid.ts', 'leaf.ts');
3604
+ const critMap = new Map<string, FileCriticality>();
3605
+ critMap.set('root.ts', {
3606
+ file: 'root.ts',
3607
+ complexityRisk: 1,
3608
+ highComplexityFunctions: 0,
3609
+ functionCount: 1,
3610
+ flows: 0,
3611
+ score: 10,
3612
+ });
3613
+ critMap.set('mid.ts', {
3614
+ file: 'mid.ts',
3615
+ complexityRisk: 1,
3616
+ highComplexityFunctions: 0,
3617
+ functionCount: 1,
3618
+ flows: 0,
3619
+ score: 20,
3620
+ });
3621
+ critMap.set('leaf.ts', {
3622
+ file: 'leaf.ts',
3623
+ complexityRisk: 1,
3624
+ highComplexityFunctions: 0,
3625
+ functionCount: 1,
3626
+ flows: 0,
3627
+ score: 30,
3628
+ });
3629
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
3630
+ expect(paths.length).toBeGreaterThan(0);
3631
+ expect(paths[0].path).toEqual(['root.ts', 'mid.ts', 'leaf.ts']);
3632
+ expect(paths[0].length).toBe(3);
3633
+ });
3634
+
3635
+ it('handles branching paths and returns highest weighted path', () => {
3636
+ const state = emptyState();
3637
+ addEdge(state, 'root.ts', 'branch-a.ts');
3638
+ addEdge(state, 'root.ts', 'branch-b.ts');
3639
+ addEdge(state, 'branch-a.ts', 'leaf-a.ts');
3640
+ addEdge(state, 'branch-b.ts', 'leaf-b.ts');
3641
+ const critMap = new Map<string, FileCriticality>();
3642
+ critMap.set('root.ts', {
3643
+ file: 'root.ts',
3644
+ complexityRisk: 1,
3645
+ highComplexityFunctions: 0,
3646
+ functionCount: 1,
3647
+ flows: 0,
3648
+ score: 5,
3649
+ });
3650
+ critMap.set('branch-a.ts', {
3651
+ file: 'branch-a.ts',
3652
+ complexityRisk: 1,
3653
+ highComplexityFunctions: 0,
3654
+ functionCount: 1,
3655
+ flows: 0,
3656
+ score: 100,
3657
+ });
3658
+ critMap.set('branch-b.ts', {
3659
+ file: 'branch-b.ts',
3660
+ complexityRisk: 1,
3661
+ highComplexityFunctions: 0,
3662
+ functionCount: 1,
3663
+ flows: 0,
3664
+ score: 10,
3665
+ });
3666
+ critMap.set('leaf-a.ts', {
3667
+ file: 'leaf-a.ts',
3668
+ complexityRisk: 1,
3669
+ highComplexityFunctions: 0,
3670
+ functionCount: 1,
3671
+ flows: 0,
3672
+ score: 5,
3673
+ });
3674
+ critMap.set('leaf-b.ts', {
3675
+ file: 'leaf-b.ts',
3676
+ complexityRisk: 1,
3677
+ highComplexityFunctions: 0,
3678
+ functionCount: 1,
3679
+ flows: 0,
3680
+ score: 5,
3681
+ });
3682
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
3683
+ expect(paths.length).toBeGreaterThan(0);
3684
+ expect(paths[0].path).toContain('branch-a.ts');
3685
+ expect(paths[0].path[0]).toBe('root.ts');
3686
+ });
3687
+
3688
+ it('returns empty for empty state (no files)', () => {
3689
+ const state = emptyState();
3690
+ const critMap = new Map<string, FileCriticality>();
3691
+ const paths = computeDependencyCriticalPaths(state, critMap, testOpts);
3692
+ expect(paths).toEqual([]);
3693
+ });
3694
+ });
3695
+
3696
+ describe('generateSummaryMd comprehensive', () => {
3697
+ const fakeDir = '/tmp/nonexistent-scan-dir';
3698
+
3699
+ function makeReportForMd(overrides: Partial<FullReport> = {}): FullReport {
3700
+ return {
3701
+ generatedAt: '2026-03-17T00:00:00.000Z',
3702
+ repoRoot: '/repo',
3703
+ options: {},
3704
+ parser: { requested: 'auto', effective: 'typescript' },
3705
+ summary: {
3706
+ totalFiles: 42,
3707
+ totalFunctions: 318,
3708
+ totalFlows: 1204,
3709
+ totalDependencyFiles: 50,
3710
+ totalPackages: 3,
3711
+ },
3712
+ fileInventory: [],
3713
+ duplicateFlows: {},
3714
+ dependencyGraph: minimalDepSummary({
3715
+ totalModules: 42,
3716
+ totalEdges: 187,
3717
+ cycles: [{ path: ['a', 'b', 'a'], nodeCount: 2 }],
3718
+ criticalPaths: [],
3719
+ }),
3720
+ dependencyFindings: [],
3721
+ agentOutput: {
3722
+ totalFindings: 5,
3723
+ highPriority: 2,
3724
+ mediumPriority: 2,
3725
+ lowPriority: 1,
3726
+ topRecommendations: [
3727
+ {
3728
+ severity: 'high',
3729
+ title: 'Fix cycle',
3730
+ file: 'src/a.ts',
3731
+ category: 'dependency-cycle',
3732
+ },
3733
+ ],
3734
+ filesWithIssues: [],
3735
+ },
3736
+ optimizationOpportunities: [],
3737
+ optimizationFindings: [],
3738
+ parseErrors: [],
3739
+ ...overrides,
3740
+ };
3741
+ }
3742
+
3743
+ it('always includes Security section', () => {
3744
+ const securityFindings: Finding[] = [
3745
+ makeFinding({
3746
+ id: 's1',
3747
+ category: 'hardcoded-secret',
3748
+ severity: 'high',
3749
+ file: 'src/keys.ts',
3750
+ }),
3751
+ ];
3752
+ const md = generateSummaryMd({
3753
+ dir: fakeDir,
3754
+ report: makeReportForMd(),
3755
+ outputFiles: {},
3756
+ architectureFindings: [],
3757
+ codeQualityFindings: [],
3758
+ deadCodeFindings: [],
3759
+ securityFindings,
3760
+ });
3761
+ expect(md).toContain('## Security');
3762
+ expect(md).toContain('security.json');
3763
+ expect(md).toContain('hardcoded-secret');
3764
+ });
3765
+
3766
+ it('always includes Test Quality section', () => {
3767
+ const testQualityFindings: Finding[] = [
3768
+ makeFinding({
3769
+ id: 't1',
3770
+ category: 'low-assertion-density',
3771
+ severity: 'medium',
3772
+ file: 'src/foo.test.ts',
3773
+ }),
3774
+ ];
3775
+ const md = generateSummaryMd({
3776
+ dir: fakeDir,
3777
+ report: makeReportForMd(),
3778
+ outputFiles: {},
3779
+ architectureFindings: [],
3780
+ codeQualityFindings: [],
3781
+ deadCodeFindings: [],
3782
+ testQualityFindings,
3783
+ });
3784
+ expect(md).toContain('## Test Quality');
3785
+ expect(md).toContain('test-quality.json');
3786
+ expect(md).toContain('low-assertion-density');
3787
+ });
3788
+
3789
+ it('shows empty pillar sections when no findings were emitted for that pillar', () => {
3790
+ const md = generateSummaryMd({
3791
+ dir: fakeDir,
3792
+ report: makeReportForMd(),
3793
+ outputFiles: {},
3794
+ architectureFindings: [],
3795
+ codeQualityFindings: [],
3796
+ deadCodeFindings: [],
3797
+ securityFindings: [],
3798
+ testQualityFindings: [],
3799
+ });
3800
+ expect(md).toContain('## Security');
3801
+ expect(md).toContain('no `security.json` written for this scan');
3802
+ expect(md).toContain('## Test Quality');
3803
+ expect(md).toContain('no `test-quality.json` written for this scan');
3804
+ });
3805
+
3806
+ it('shows scope when scope option is set', () => {
3807
+ const md = generateSummaryMd({
3808
+ dir: fakeDir,
3809
+ report: makeReportForMd(),
3810
+ outputFiles: {},
3811
+ architectureFindings: [],
3812
+ codeQualityFindings: [],
3813
+ deadCodeFindings: [],
3814
+ scope: ['/repo/src/a.ts', '/repo/src/b.ts'],
3815
+ root: '/repo',
3816
+ });
3817
+ expect(md).toContain('## Scan Scope');
3818
+ expect(md).toContain('Scoped scan');
3819
+ });
3820
+
3821
+ it('shows scopeSymbols when scopeSymbols set with scope', () => {
3822
+ const scopeSymbols = new Map<string, string[]>();
3823
+ scopeSymbols.set('/repo/src/lib.ts', ['foo', 'bar']);
3824
+ const md = generateSummaryMd({
3825
+ dir: fakeDir,
3826
+ report: makeReportForMd(),
3827
+ outputFiles: {},
3828
+ architectureFindings: [],
3829
+ codeQualityFindings: [],
3830
+ deadCodeFindings: [],
3831
+ scope: ['/repo/src/lib.ts'],
3832
+ root: '/repo',
3833
+ scopeSymbols,
3834
+ });
3835
+ expect(md).toContain('Scoped scan');
3836
+ expect(md).toContain('lib.ts');
3837
+ });
3838
+
3839
+ it('shows reportAnalysis signals when provided', () => {
3840
+ const reportAnalysis: import('./reporting/analysis.js').ReportAnalysisSummary =
3841
+ {
3842
+ graphSignals: [],
3843
+ astSignals: [],
3844
+ combinedSignals: [],
3845
+ strongestGraphSignal: {
3846
+ kind: 'cycle',
3847
+ lens: 'graph',
3848
+ title: 'Cycle',
3849
+ summary: 'Cycle detected',
3850
+ confidence: 'high',
3851
+ score: 80,
3852
+ files: [],
3853
+ categories: [],
3854
+ evidence: {},
3855
+ },
3856
+ strongestAstSignal: {
3857
+ kind: 'complexity',
3858
+ lens: 'ast',
3859
+ title: 'Complex',
3860
+ summary: 'High complexity',
3861
+ confidence: 'medium',
3862
+ score: 60,
3863
+ files: [],
3864
+ categories: [],
3865
+ evidence: {},
3866
+ },
3867
+ combinedInterpretation: {
3868
+ kind: 'hybrid',
3869
+ lens: 'hybrid',
3870
+ title: 'Hybrid',
3871
+ summary: 'Combined view',
3872
+ confidence: 'medium',
3873
+ score: 60,
3874
+ files: [],
3875
+ categories: [],
3876
+ evidence: {},
3877
+ },
3878
+ recommendedValidation: {
3879
+ summary: 'Validate with LSP',
3880
+ tools: ['lspFindReferences', 'lspGotoDefinition'],
3881
+ },
3882
+ investigationPrompts: ['Check cycle impact', 'Verify complexity'],
3883
+ };
3884
+ const md = generateSummaryMd({
3885
+ dir: fakeDir,
3886
+ report: makeReportForMd(),
3887
+ outputFiles: {},
3888
+ architectureFindings: [],
3889
+ codeQualityFindings: [],
3890
+ deadCodeFindings: [],
3891
+ reportAnalysis,
3892
+ });
3893
+ expect(md).toContain('## Analysis Signals');
3894
+ expect(md).toContain('Cycle detected');
3895
+ expect(md).toContain('High complexity');
3896
+ expect(md).toContain('Combined view');
3897
+ expect(md).toContain('Validate with LSP');
3898
+ expect(md).toContain('Check cycle impact');
3899
+ });
3900
+
3901
+ it('shows semantic enabled message when semanticEnabled=true', () => {
3902
+ const md = generateSummaryMd({
3903
+ dir: fakeDir,
3904
+ report: makeReportForMd(),
3905
+ outputFiles: {},
3906
+ architectureFindings: [],
3907
+ codeQualityFindings: [],
3908
+ deadCodeFindings: [],
3909
+ semanticEnabled: true,
3910
+ });
3911
+ expect(md).toContain('Semantic analysis');
3912
+ expect(md).toContain('14 additional categories');
3913
+ });
3914
+
3915
+ it('shows activeFeatures filter when present', () => {
3916
+ const md = generateSummaryMd({
3917
+ dir: fakeDir,
3918
+ report: makeReportForMd(),
3919
+ outputFiles: {},
3920
+ architectureFindings: [
3921
+ makeFinding({ category: 'dependency-cycle' }),
3922
+ makeFinding({ category: 'dead-export' }),
3923
+ ],
3924
+ codeQualityFindings: [],
3925
+ deadCodeFindings: [],
3926
+ activeFeatures: new Set(['dependency-cycle']),
3927
+ });
3928
+ expect(md).toContain('Features filter');
3929
+ expect(md).toContain('dependency-cycle');
3930
+ expect(md).toContain('*(skipped)*');
3931
+ expect(md).toContain('| Security | — | skipped |');
3932
+ });
3933
+
3934
+ it('shows truncated message when totalBeforeTruncation > findings length', () => {
3935
+ const report = makeReportForMd({
3936
+ optimizationFindings: [],
3937
+ agentOutput: {
3938
+ totalFindings: 5,
3939
+ totalBeforeTruncation: 20,
3940
+ highPriority: 2,
3941
+ mediumPriority: 2,
3942
+ lowPriority: 1,
3943
+ topRecommendations: [],
3944
+ filesWithIssues: [],
3945
+ droppedCategories: ['dead-export', 'function-optimization'],
3946
+ },
3947
+ });
3948
+ const md = generateSummaryMd({
3949
+ dir: fakeDir,
3950
+ report,
3951
+ outputFiles: {},
3952
+ architectureFindings: [],
3953
+ codeQualityFindings: [],
3954
+ deadCodeFindings: [],
3955
+ });
3956
+ expect(md).toContain('Truncated');
3957
+ expect(md).toContain('20');
3958
+ expect(md).toContain('dead-export');
3959
+ });
3960
+
3961
+ it('shows Change Risk Hotspots when hotFiles provided', () => {
3962
+ const hotFiles: import('./types/index.js').HotFile[] = [
3963
+ {
3964
+ file: 'src/core.ts',
3965
+ riskScore: 85,
3966
+ fanIn: 10,
3967
+ fanOut: 5,
3968
+ complexityScore: 50,
3969
+ exportCount: 8,
3970
+ inCycle: true,
3971
+ onCriticalPath: true,
3972
+ },
3973
+ ];
3974
+ const md = generateSummaryMd({
3975
+ dir: fakeDir,
3976
+ report: makeReportForMd(),
3977
+ outputFiles: {},
3978
+ architectureFindings: [],
3979
+ codeQualityFindings: [],
3980
+ deadCodeFindings: [],
3981
+ hotFiles,
3982
+ });
3983
+ expect(md).toContain('## Change Risk Hotspots');
3984
+ expect(md).toContain('src/core.ts');
3985
+ expect(md).toContain('85');
3986
+ });
3987
+
3988
+ it('shows Top Concern Tags when findings have tags', () => {
3989
+ const findings: Finding[] = [
3990
+ { ...makeFinding({ id: '1' }), tags: ['coupling', 'architecture'] },
3991
+ { ...makeFinding({ id: '2' }), tags: ['coupling'] },
3992
+ ];
3993
+ const md = generateSummaryMd({
3994
+ dir: fakeDir,
3995
+ report: makeReportForMd({ optimizationFindings: findings }),
3996
+ outputFiles: {},
3997
+ architectureFindings: findings,
3998
+ codeQualityFindings: [],
3999
+ deadCodeFindings: [],
4000
+ });
4001
+ expect(md).toContain('## Top Concern Tags');
4002
+ expect(md).toContain('coupling');
4003
+ });
4004
+
4005
+ it('formatFileSize: shows B for small files, KB for medium, MB for large (via outputFiles)', () => {
4006
+ const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'summary-size-'));
4007
+ try {
4008
+ fs.writeFileSync(
4009
+ path.join(realDir, 'small.json'),
4010
+ 'x'.repeat(100),
4011
+ 'utf8'
4012
+ );
4013
+ fs.writeFileSync(
4014
+ path.join(realDir, 'medium.json'),
4015
+ 'x'.repeat(2048),
4016
+ 'utf8'
4017
+ );
4018
+ fs.writeFileSync(
4019
+ path.join(realDir, 'large.json'),
4020
+ 'x'.repeat(2 * 1024 * 1024),
4021
+ 'utf8'
4022
+ );
4023
+ const outputFiles = {
4024
+ small: 'small.json',
4025
+ medium: 'medium.json',
4026
+ large: 'large.json',
4027
+ };
4028
+ const md = generateSummaryMd({
4029
+ dir: realDir,
4030
+ report: makeReportForMd(),
4031
+ outputFiles,
4032
+ architectureFindings: [],
4033
+ codeQualityFindings: [],
4034
+ deadCodeFindings: [],
4035
+ });
4036
+ expect(md).toContain('| Size |');
4037
+ expect(md).toMatch(/\d+\s*B/);
4038
+ expect(md).toMatch(/\d+(\.\d+)?\s*KB/);
4039
+ expect(md).toMatch(/\d+(\.\d+)?\s*MB/);
4040
+ } finally {
4041
+ fs.rmSync(realDir, { recursive: true, force: true });
4042
+ }
4043
+ });
4044
+
4045
+ it('includes AST Trees section when outputFiles.astTrees present', () => {
4046
+ const outputFiles = { astTrees: 'ast-trees.txt' };
4047
+ const scanDir = path.join(
4048
+ fakeDir,
4049
+ '.octocode',
4050
+ 'scan',
4051
+ '2026-03-19T00-00-00-000Z'
4052
+ );
4053
+ const md = generateSummaryMd({
4054
+ dir: scanDir,
4055
+ report: makeReportForMd(),
4056
+ outputFiles,
4057
+ architectureFindings: [],
4058
+ codeQualityFindings: [],
4059
+ deadCodeFindings: [],
4060
+ root: fakeDir,
4061
+ });
4062
+ expect(md).toContain('## AST Trees');
4063
+ expect(md).toContain('ast-trees.txt');
4064
+ expect(md).toContain('Run these commands from the skill directory.');
4065
+ expect(md).toContain('node scripts/ast/tree-search.js');
4066
+ expect(md).toContain(
4067
+ '.octocode/scan/2026-03-19T00-00-00-000Z/ast-trees.txt'
4068
+ );
4069
+ expect(md).toContain('--limit 25');
4070
+ expect(md).toContain('Raw text fallback');
4071
+ expect(md).not.toContain('grep "^##" ast-trees.txt');
4072
+ });
4073
+
4074
+ it('skips depGraph block when dependencyGraph is undefined', () => {
4075
+ const report = makeReportForMd({
4076
+ dependencyGraph: undefined as unknown as DependencySummary,
4077
+ });
4078
+ const md = generateSummaryMd({
4079
+ dir: fakeDir,
4080
+ report,
4081
+ outputFiles: {},
4082
+ architectureFindings: [],
4083
+ codeQualityFindings: [],
4084
+ deadCodeFindings: [],
4085
+ });
4086
+ expect(md).toContain('## Architecture Health');
4087
+ expect(md).not.toContain('| Modules |');
4088
+ });
4089
+
4090
+ it('handles reportAnalysis with null strongestGraphSignal and strongestAstSignal', () => {
4091
+ const reportAnalysis: import('./reporting/analysis.js').ReportAnalysisSummary =
4092
+ {
4093
+ graphSignals: [],
4094
+ astSignals: [],
4095
+ combinedSignals: [],
4096
+ strongestGraphSignal: null,
4097
+ strongestAstSignal: null,
4098
+ combinedInterpretation: {
4099
+ kind: 'hybrid',
4100
+ lens: 'hybrid',
4101
+ title: 'Hybrid',
4102
+ summary: 'No combined interpretation available yet.',
4103
+ confidence: 'low',
4104
+ score: 0,
4105
+ files: [],
4106
+ categories: [],
4107
+ evidence: {},
4108
+ },
4109
+ recommendedValidation: null,
4110
+ investigationPrompts: [],
4111
+ };
4112
+ const md = generateSummaryMd({
4113
+ dir: fakeDir,
4114
+ report: makeReportForMd(),
4115
+ outputFiles: {},
4116
+ architectureFindings: [],
4117
+ codeQualityFindings: [],
4118
+ deadCodeFindings: [],
4119
+ reportAnalysis,
4120
+ });
4121
+ expect(md).toContain('No dominant graph signal');
4122
+ expect(md).toContain('No dominant AST signal');
4123
+ expect(md).toContain('No combined interpretation available yet');
4124
+ });
4125
+
4126
+ it('shows dash for file size when output file does not exist', () => {
4127
+ const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'summary-missing-'));
4128
+ try {
4129
+ const outputFiles = {
4130
+ existing: 'existing.json',
4131
+ missing: 'nonexistent.json',
4132
+ };
4133
+ fs.writeFileSync(path.join(realDir, 'existing.json'), '{}', 'utf8');
4134
+ const md = generateSummaryMd({
4135
+ dir: realDir,
4136
+ report: makeReportForMd(),
4137
+ outputFiles,
4138
+ architectureFindings: [],
4139
+ codeQualityFindings: [],
4140
+ deadCodeFindings: [],
4141
+ });
4142
+ expect(md).toContain('| Size |');
4143
+ expect(md).toContain('—');
4144
+ } finally {
4145
+ fs.rmSync(realDir, { recursive: true, force: true });
4146
+ }
4147
+ });
4148
+ });
4149
+
4150
+ describe('diversifyFindings edge cases', () => {
4151
+ it('returns empty when limit=0', () => {
4152
+ const input = [
4153
+ makeFinding({ category: 'a', severity: 'high' }),
4154
+ makeFinding({ category: 'b', severity: 'high' }),
4155
+ ];
4156
+ const result = diversifyFindings(input, 0);
4157
+ expect(result).toEqual([]);
4158
+ });
4159
+
4160
+ it('returns single finding when limit=1', () => {
4161
+ const input = [
4162
+ makeFinding({ category: 'critical-cat', severity: 'critical' }),
4163
+ makeFinding({ category: 'high-cat', severity: 'high' }),
4164
+ ];
4165
+ const result = diversifyFindings(input, 1);
4166
+ expect(result).toHaveLength(1);
4167
+ expect(result[0].severity).toBe('critical');
4168
+ });
4169
+
4170
+ it('handles all same category (no diversity possible)', () => {
4171
+ const input = Array.from({ length: 10 }, (_, i) =>
4172
+ makeFinding({ id: `f-${i}`, category: 'only-cat', severity: 'high' })
4173
+ );
4174
+ const result = diversifyFindings(input, 5);
4175
+ expect(result).toHaveLength(5);
4176
+ expect(result.every(f => f.category === 'only-cat')).toBe(true);
4177
+ });
4178
+
4179
+ it('handles many categories with 1 finding each', () => {
4180
+ const input = [
4181
+ makeFinding({ id: '1', category: 'cat-a', severity: 'high' }),
4182
+ makeFinding({ id: '2', category: 'cat-b', severity: 'high' }),
4183
+ makeFinding({ id: '3', category: 'cat-c', severity: 'medium' }),
4184
+ makeFinding({ id: '4', category: 'cat-d', severity: 'medium' }),
4185
+ makeFinding({ id: '5', category: 'cat-e', severity: 'low' }),
4186
+ ];
4187
+ const result = diversifyFindings(input, 3);
4188
+ expect(result).toHaveLength(3);
4189
+ const categories = new Set(result.map(f => f.category));
4190
+ expect(categories.size).toBe(3);
4191
+ });
4192
+ });
4193
+
4194
+ describe('diverseTopRecommendations edge cases', () => {
4195
+ it('maxPerCategory=1 forces maximum diversity', () => {
4196
+ const findings = [
4197
+ makeFinding({ id: '1', category: 'dead-export', severity: 'critical' }),
4198
+ makeFinding({ id: '2', category: 'dead-export', severity: 'high' }),
4199
+ makeFinding({ id: '3', category: 'dependency-cycle', severity: 'high' }),
4200
+ makeFinding({
4201
+ id: '4',
4202
+ category: 'function-optimization',
4203
+ severity: 'medium',
4204
+ }),
4205
+ ];
4206
+ const result = diverseTopRecommendations(findings, 10, 1);
4207
+ expect(result).toHaveLength(3);
4208
+ expect(new Set(result.map(f => f.category)).size).toBe(3);
4209
+ });
4210
+
4211
+ it('all findings same category returns up to maxPerCategory', () => {
4212
+ const findings = Array.from({ length: 10 }, (_, i) =>
4213
+ makeFinding({ id: `f-${i}`, category: 'only-cat', severity: 'high' })
4214
+ );
4215
+ const result = diverseTopRecommendations(findings, 10, 2);
4216
+ expect(result).toHaveLength(2);
4217
+ expect(result.every(f => f.category === 'only-cat')).toBe(true);
4218
+ });
4219
+
4220
+ it('exactly at limit returns correct count', () => {
4221
+ const findings = [
4222
+ makeFinding({ id: '1', category: 'a', severity: 'high' }),
4223
+ makeFinding({ id: '2', category: 'b', severity: 'high' }),
4224
+ makeFinding({ id: '3', category: 'c', severity: 'high' }),
4225
+ ];
4226
+ const result = diverseTopRecommendations(findings, 3, 2);
4227
+ expect(result).toHaveLength(3);
4228
+ });
4229
+ });
4230
+
4231
+ describe('buildIssueCatalog detector paths via buildIssueCatalog', () => {
4232
+ const opts = { ...DEFAULT_OPTS, root: '/repo', findingsLimit: 500 };
4233
+
4234
+ it('detectDistanceFromMainSequence: state with Zone of Pain (concrete+stable) triggers finding', () => {
4235
+ const state = emptyState();
4236
+ state.files.add('src/lib.ts');
4237
+ state.declaredExportsByFile.set('src/lib.ts', [
4238
+ { name: 'a', kind: 'value' },
4239
+ { name: 'b', kind: 'value' },
4240
+ { name: 'c', kind: 'value' },
4241
+ { name: 'd', kind: 'value' },
4242
+ { name: 'e', kind: 'value' },
4243
+ ]);
4244
+ for (let i = 0; i < 7; i++) {
4245
+ state.files.add(`src/dep${i}.ts`);
4246
+ addEdge(state, `src/dep${i}.ts`, 'src/lib.ts');
4247
+ }
4248
+ addEdge(state, 'src/lib.ts', 'src/dep0.ts');
4249
+ addEdge(state, 'src/lib.ts', 'src/dep1.ts');
4250
+ const { findings } = buildIssueCatalog(
4251
+ [],
4252
+ [],
4253
+ [],
4254
+ minimalDepSummary(),
4255
+ state,
4256
+ opts
4257
+ );
4258
+ expect(
4259
+ findings.some(f => f.category === 'distance-from-main-sequence')
4260
+ ).toBe(true);
4261
+ });
4262
+
4263
+ it('detectFeatureEnvy: file importing heavily from one module triggers finding', () => {
4264
+ const state = emptyState();
4265
+ state.files.add('src/envious.ts');
4266
+ state.files.add('src/target.ts');
4267
+ state.importedSymbolsByFile.set('src/envious.ts', [
4268
+ {
4269
+ sourceModule: './target',
4270
+ resolvedModule: 'src/target.ts',
4271
+ importedName: 'a',
4272
+ localName: 'a',
4273
+ isTypeOnly: false,
4274
+ },
4275
+ {
4276
+ sourceModule: './target',
4277
+ resolvedModule: 'src/target.ts',
4278
+ importedName: 'b',
4279
+ localName: 'b',
4280
+ isTypeOnly: false,
4281
+ },
4282
+ {
4283
+ sourceModule: './target',
4284
+ resolvedModule: 'src/target.ts',
4285
+ importedName: 'c',
4286
+ localName: 'c',
4287
+ isTypeOnly: false,
4288
+ },
4289
+ {
4290
+ sourceModule: './target',
4291
+ resolvedModule: 'src/target.ts',
4292
+ importedName: 'd',
4293
+ localName: 'd',
4294
+ isTypeOnly: false,
4295
+ },
4296
+ {
4297
+ sourceModule: './target',
4298
+ resolvedModule: 'src/target.ts',
4299
+ importedName: 'e',
4300
+ localName: 'e',
4301
+ isTypeOnly: false,
4302
+ },
4303
+ {
4304
+ sourceModule: './other',
4305
+ resolvedModule: 'src/other.ts',
4306
+ importedName: 'x',
4307
+ localName: 'x',
4308
+ isTypeOnly: false,
4309
+ },
4310
+ ]);
4311
+ addEdge(state, 'src/envious.ts', 'src/target.ts');
4312
+ addEdge(state, 'src/envious.ts', 'src/other.ts');
4313
+ const { findings } = buildIssueCatalog(
4314
+ [],
4315
+ [],
4316
+ [],
4317
+ minimalDepSummary(),
4318
+ state,
4319
+ opts
4320
+ );
4321
+ expect(findings.some(f => f.category === 'feature-envy')).toBe(true);
4322
+ });
4323
+
4324
+ it('detectUntestedCriticalCode: hot file with no test imports triggers finding', () => {
4325
+ const state = emptyState();
4326
+ addEdge(state, 'src/hot.ts', 'src/consumer.ts');
4327
+ addEdge(state, 'src/consumer.ts', 'src/hot.ts');
4328
+ const depSummary = minimalDepSummary({
4329
+ cycles: [
4330
+ { path: ['src/hot.ts', 'src/consumer.ts', 'src/hot.ts'], nodeCount: 2 },
4331
+ ],
4332
+ criticalPaths: [
4333
+ {
4334
+ start: 'src/hot.ts',
4335
+ path: ['src/hot.ts', 'src/consumer.ts'],
4336
+ score: 100,
4337
+ length: 2,
4338
+ containsCycle: true,
4339
+ },
4340
+ ],
4341
+ });
4342
+ const critMap = new Map<string, import('./types/index.js').FileCriticality>();
4343
+ critMap.set('src/hot.ts', {
4344
+ file: 'src/hot.ts',
4345
+ complexityRisk: 1,
4346
+ highComplexityFunctions: 0,
4347
+ functionCount: 1,
4348
+ flows: 0,
4349
+ score: 80,
4350
+ });
4351
+ const { findings } = buildIssueCatalog(
4352
+ [],
4353
+ [],
4354
+ [],
4355
+ depSummary,
4356
+ state,
4357
+ opts,
4358
+ {},
4359
+ {},
4360
+ critMap
4361
+ );
4362
+ expect(findings.some(f => f.category === 'untested-critical-code')).toBe(
4363
+ true
4364
+ );
4365
+ });
4366
+
4367
+ it('detectDuplicateFlowStructures: control duplicates with occurrences >= flowDupThreshold', () => {
4368
+ const controlDuplicates: import('./types/index.js').RedundantFlowGroup[] = [
4369
+ {
4370
+ kind: 'IfElseChain',
4371
+ occurrences: 5,
4372
+ filesCount: 3,
4373
+ locations: [
4374
+ {
4375
+ kind: 'IfStatement',
4376
+ file: 'src/a.ts',
4377
+ lineStart: 10,
4378
+ lineEnd: 20,
4379
+ columnStart: 1,
4380
+ columnEnd: 1,
4381
+ hash: 'x',
4382
+ statementCount: 5,
4383
+ },
4384
+ {
4385
+ kind: 'IfStatement',
4386
+ file: 'src/b.ts',
4387
+ lineStart: 15,
4388
+ lineEnd: 25,
4389
+ columnStart: 1,
4390
+ columnEnd: 1,
4391
+ hash: 'x',
4392
+ statementCount: 5,
4393
+ },
4394
+ ],
4395
+ },
4396
+ ];
4397
+ const optsWithFlow = { ...opts, flowDupThreshold: 3 };
4398
+ const { findings } = buildIssueCatalog(
4399
+ [],
4400
+ controlDuplicates,
4401
+ [],
4402
+ minimalDepSummary(),
4403
+ emptyState(),
4404
+ optsWithFlow
4405
+ );
4406
+ expect(findings.some(f => f.category === 'duplicate-flow-structure')).toBe(
4407
+ true
4408
+ );
4409
+ });
4410
+
4411
+ it('detectLayerViolations: layerOrder triggers when lower layer imports from upper', () => {
4412
+ const state = emptyState();
4413
+ addEdge(state, 'src/repository/db.ts', 'src/service/handler.ts');
4414
+ const optsWithLayers = { ...opts, layerOrder: ['service', 'repository'] };
4415
+ const { findings } = buildIssueCatalog(
4416
+ [],
4417
+ [],
4418
+ [],
4419
+ minimalDepSummary(),
4420
+ state,
4421
+ optsWithLayers
4422
+ );
4423
+ expect(findings.some(f => f.category === 'layer-violation')).toBe(true);
4424
+ });
4425
+ });