@khoavu882/doflow 0.0.0-stage → 2.0.0-beta.4

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 (409) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/bin/doflow.js +44 -0
  4. package/core/.antigravity-plugin/hooks.json +39 -0
  5. package/core/.antigravity-plugin/mcp_config.json +18 -0
  6. package/core/.antigravity-plugin/plugin.json +31 -0
  7. package/core/.claude-plugin/marketplace.json +20 -0
  8. package/core/.claude-plugin/plugin.json +21 -0
  9. package/core/.codex-plugin/marketplace.json +20 -0
  10. package/core/.codex-plugin/plugin.json +35 -0
  11. package/core/.plugin/plugin.json +19 -0
  12. package/core/harnesses/antigravity/hooks/pre-implementation-gate.sh +9 -0
  13. package/core/harnesses/antigravity/hooks/stop-check.sh +10 -0
  14. package/core/harnesses/claude/hooks/lib.sh +304 -0
  15. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +20 -0
  16. package/core/harnesses/claude/hooks/permission-audit.sh +47 -0
  17. package/core/harnesses/claude/hooks/post-compact.sh +74 -0
  18. package/core/harnesses/claude/hooks/post-edit-lint.sh +36 -0
  19. package/core/harnesses/claude/hooks/post-tool-failure.sh +47 -0
  20. package/core/harnesses/claude/hooks/pre-bash-guard.sh +20 -0
  21. package/core/harnesses/claude/hooks/pre-compact.sh +43 -0
  22. package/core/harnesses/claude/hooks/pre-implement-gate.sh +21 -0
  23. package/core/harnesses/claude/hooks/session-end.sh +89 -0
  24. package/core/harnesses/claude/hooks/session-start.sh +5 -0
  25. package/core/harnesses/claude/hooks/skill-config-audit.sh +39 -0
  26. package/core/harnesses/claude/hooks/stop-check.sh +5 -0
  27. package/core/harnesses/claude/hooks/subagent-audit.sh +7 -0
  28. package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -0
  29. package/core/harnesses/claude/settings/keybindings.json +20 -0
  30. package/core/harnesses/claude/settings/settings.json +201 -0
  31. package/core/harnesses/claude/statusline/doflow-statusline.sh +49 -0
  32. package/core/harnesses/codex/agents/core-implementer.toml +7 -0
  33. package/core/harnesses/codex/agents/quality-guardian.toml +8 -0
  34. package/core/harnesses/codex/agents/research-writer.toml +8 -0
  35. package/core/harnesses/codex/agents/spec-analyst.toml +8 -0
  36. package/core/harnesses/codex/agents/system-architect.toml +9 -0
  37. package/core/harnesses/codex/config/config.toml +10 -0
  38. package/core/harnesses/codex/hooks/hooks.json +122 -0
  39. package/core/harnesses/codex/hooks/lib.sh +335 -0
  40. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +18 -0
  41. package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +52 -0
  42. package/core/harnesses/codex/hooks/post-edit-lint.sh +6 -0
  43. package/core/harnesses/codex/hooks/pre-bash-guard.sh +18 -0
  44. package/core/harnesses/codex/hooks/pre-compact.impl.sh +46 -0
  45. package/core/harnesses/codex/hooks/pre-compact.sh +9 -0
  46. package/core/harnesses/codex/hooks/pre-implement-gate.sh +19 -0
  47. package/core/harnesses/codex/hooks/session-end.impl.sh +92 -0
  48. package/core/harnesses/codex/hooks/session-end.sh +5 -0
  49. package/core/harnesses/codex/hooks/session-start.sh +5 -0
  50. package/core/harnesses/codex/hooks/stop-check.sh +8 -0
  51. package/core/harnesses/codex/hooks/subagent-audit.sh +8 -0
  52. package/core/harnesses/codex/hooks/user-prompt-submit.sh +6 -0
  53. package/core/harnesses/gemini/hooks/hooks.json +77 -0
  54. package/core/harnesses/gemini/hooks/lib.sh +302 -0
  55. package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +21 -0
  56. package/core/harnesses/gemini/hooks/post-edit-lint.sh +39 -0
  57. package/core/harnesses/gemini/hooks/pre-bash-guard.sh +21 -0
  58. package/core/harnesses/gemini/hooks/pre-compact.sh +38 -0
  59. package/core/harnesses/gemini/hooks/pre-implement-gate.sh +22 -0
  60. package/core/harnesses/gemini/hooks/session-end.impl.sh +89 -0
  61. package/core/harnesses/gemini/hooks/session-end.sh +5 -0
  62. package/core/harnesses/gemini/hooks/session-start.sh +5 -0
  63. package/core/harnesses/gemini/settings/adapter-defaults.json +4 -0
  64. package/core/harnesses/kiro/agents/doflow.json +58 -0
  65. package/core/harnesses/kiro/hooks/doflow.json +53 -0
  66. package/core/harnesses/kiro/hooks/lib.sh +324 -0
  67. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +9 -0
  68. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +9 -0
  69. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +9 -0
  70. package/core/harnesses/kiro/hooks/session-start.sh +9 -0
  71. package/core/harnesses/kiro/hooks/stop-check.sh +43 -0
  72. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +39 -0
  73. package/core/harnesses/shared/hooks/adapters/antigravity.js +111 -0
  74. package/core/harnesses/shared/hooks/adapters/arg-fields.js +19 -0
  75. package/core/harnesses/shared/hooks/adapters/gemini.js +114 -0
  76. package/core/harnesses/shared/hooks/hooks.json +47 -0
  77. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +91 -0
  78. package/core/harnesses/shared/hooks/policies/capture-failure.sh +97 -0
  79. package/core/harnesses/shared/hooks/policies/deny-json.sh +29 -0
  80. package/core/harnesses/shared/hooks/policies/lib.sh +501 -0
  81. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +21 -0
  82. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +91 -0
  83. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +328 -0
  84. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +496 -0
  85. package/core/harnesses/shared/hooks/policies/prompt-nudge.jq +131 -0
  86. package/core/harnesses/shared/hooks/policies/session-context.sh +141 -0
  87. package/core/harnesses/shared/hooks/policies/stop-check.sh +264 -0
  88. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +46 -0
  89. package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +235 -0
  90. package/core/harnesses/shared/hooks/stream-hook-runner.js +590 -0
  91. package/core/harnesses/shared/locator/doflow-run +87 -0
  92. package/core/registry/advice.json +101 -0
  93. package/core/registry/assets.json +792 -0
  94. package/core/registry/capabilities.json +120 -0
  95. package/core/registry/contracts.json +189 -0
  96. package/core/registry/external-tools.json +191 -0
  97. package/core/registry/harnesses.json +2062 -0
  98. package/core/registry/lifecycle.json +231 -0
  99. package/core/registry/mcp.json +18 -0
  100. package/core/registry/models.json +79 -0
  101. package/core/registry/readiness-templates.json +118 -0
  102. package/core/registry/routes.json +47 -0
  103. package/core/registry/storage.json +1061 -0
  104. package/core/registry/verification.json +258 -0
  105. package/core/registry/workflows.json +790 -0
  106. package/core/shared/agent-specs/core-implementer.md +73 -0
  107. package/core/shared/agent-specs/quality-guardian.md +28 -0
  108. package/core/shared/agent-specs/research-writer.md +31 -0
  109. package/core/shared/agent-specs/spec-analyst.md +33 -0
  110. package/core/shared/agent-specs/system-architect.md +29 -0
  111. package/core/shared/guidance/DOFLOW_CORE.md +8 -0
  112. package/core/shared/guidance/FLAGS.md +25 -0
  113. package/core/shared/guidance/PRINCIPLES.md +61 -0
  114. package/core/shared/guidance/VERSION +1 -0
  115. package/core/shared/guidance/mcp/MCP_Context7.md +18 -0
  116. package/core/shared/guidance/mcp/MCP_Sequential.md +17 -0
  117. package/core/shared/guidance/modes/MODE_Brainstorming.md +46 -0
  118. package/core/shared/guidance/modes/MODE_DeepResearch.md +69 -0
  119. package/core/shared/guidance/modes/MODE_Introspection.md +33 -0
  120. package/core/shared/guidance/modes/MODE_Orchestration.md +57 -0
  121. package/core/shared/guidance/modes/MODE_Task_Management.md +74 -0
  122. package/core/shared/guidance/pointers/claude-gemini.md +2 -0
  123. package/core/shared/guidance/pointers/codex.md +6 -0
  124. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +373 -0
  125. package/core/shared/guidance/references/ARTIFACT_VOICE.md +104 -0
  126. package/core/shared/guidance/references/CONSTITUTION_BASE.md +47 -0
  127. package/core/shared/guidance/references/DIAGRAM_GUIDE.md +161 -0
  128. package/core/shared/guidance/references/DOFLOW_CHAIN.md +87 -0
  129. package/core/shared/guidance/references/EVIDENCE_LEDGER.md +93 -0
  130. package/core/shared/guidance/references/MODEL_SELECTION.md +74 -0
  131. package/core/shared/guidance/references/ORCHESTRATION.md +36 -0
  132. package/core/shared/guidance/references/RESEARCH_CONFIG.md +21 -0
  133. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +81 -0
  134. package/core/shared/guidance/rules/RULE_01_SAFETY.md +25 -0
  135. package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +23 -0
  136. package/core/shared/guidance/rules/RULE_03_QUALITY.md +14 -0
  137. package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +46 -0
  138. package/core/shared/scripts/doflow/assets/mermaid-theme.json +46 -0
  139. package/core/shared/scripts/doflow/bash/do-exec-paths.sh +167 -0
  140. package/core/shared/scripts/doflow/bash/do-git-state.sh +546 -0
  141. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +208 -0
  142. package/core/shared/scripts/doflow/bash/do-paths.sh +347 -0
  143. package/core/shared/scripts/doflow/bash/do-prereqs.sh +66 -0
  144. package/core/shared/scripts/doflow/bash/do-review-package.sh +127 -0
  145. package/core/shared/scripts/doflow/bash/do-task-brief.sh +698 -0
  146. package/core/shared/scripts/doflow/bash/render-audit.sh +224 -0
  147. package/core/shared/scripts/doflow/bash/render-diagrams.sh +177 -0
  148. package/core/shared/scripts/doflow/bash/render-puml.sh +223 -0
  149. package/core/shared/scripts/doflow/bash/sync-context.sh +66 -0
  150. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +526 -0
  151. package/core/shared/scripts/doflow/bin/doflow-run +592 -0
  152. package/core/shared/skills/do/SKILL.md +128 -0
  153. package/core/shared/skills/do/references/estimation.md +13 -0
  154. package/core/shared/skills/do/references/pm_routing.md +25 -0
  155. package/core/shared/skills/do/references/tool_matrix.md +52 -0
  156. package/core/shared/skills/do-brainstorm/SKILL.md +252 -0
  157. package/core/shared/skills/do-code-review/README.md +49 -0
  158. package/core/shared/skills/do-code-review/SKILL.md +333 -0
  159. package/core/shared/skills/do-code-review/assets/sample_c_clean.c +71 -0
  160. package/core/shared/skills/do-code-review/assets/sample_c_smells.c +66 -0
  161. package/core/shared/skills/do-code-review/assets/sample_cpp_shift.cpp +19 -0
  162. package/core/shared/skills/do-code-review/assets/sample_csharp_clean.cs +100 -0
  163. package/core/shared/skills/do-code-review/assets/sample_csharp_smells.cs +78 -0
  164. package/core/shared/skills/do-code-review/assets/sample_java_clean.java +55 -0
  165. package/core/shared/skills/do-code-review/assets/sample_java_smells.java +56 -0
  166. package/core/shared/skills/do-code-review/assets/sample_json_clean.json +7 -0
  167. package/core/shared/skills/do-code-review/assets/sample_markdown_clean.md +30 -0
  168. package/core/shared/skills/do-code-review/assets/sample_markdown_smells.md +29 -0
  169. package/core/shared/skills/do-code-review/assets/sample_shell_heredoc.sh +38 -0
  170. package/core/shared/skills/do-code-review/assets/sample_shell_smells.sh +15 -0
  171. package/core/shared/skills/do-code-review/assets/sample_typescript_strings.ts +36 -0
  172. package/core/shared/skills/do-code-review/assets/sample_yaml_smells.yaml +6 -0
  173. package/core/shared/skills/do-code-review/content-types/config.md +83 -0
  174. package/core/shared/skills/do-code-review/content-types/markdown.md +132 -0
  175. package/core/shared/skills/do-code-review/expected_outputs/sample_c_clean_quality.json +79 -0
  176. package/core/shared/skills/do-code-review/expected_outputs/sample_c_smells_quality.json +169 -0
  177. package/core/shared/skills/do-code-review/expected_outputs/sample_cpp_shift_quality.json +49 -0
  178. package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_clean_quality.json +149 -0
  179. package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_smells_quality.json +157 -0
  180. package/core/shared/skills/do-code-review/expected_outputs/sample_java_clean_quality.json +67 -0
  181. package/core/shared/skills/do-code-review/expected_outputs/sample_java_smells_quality.json +97 -0
  182. package/core/shared/skills/do-code-review/expected_outputs/sample_json_clean_quality.json +33 -0
  183. package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_clean_quality.json +1 -0
  184. package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_smells_quality.json +16 -0
  185. package/core/shared/skills/do-code-review/expected_outputs/sample_shell_heredoc_quality.json +54 -0
  186. package/core/shared/skills/do-code-review/expected_outputs/sample_shell_smells_quality.json +91 -0
  187. package/core/shared/skills/do-code-review/expected_outputs/sample_typescript_strings_quality.json +60 -0
  188. package/core/shared/skills/do-code-review/expected_outputs/sample_yaml_smells_quality.json +46 -0
  189. package/core/shared/skills/do-code-review/languages/c.md +103 -0
  190. package/core/shared/skills/do-code-review/languages/cpp.md +103 -0
  191. package/core/shared/skills/do-code-review/languages/csharp.md +97 -0
  192. package/core/shared/skills/do-code-review/languages/dart.md +108 -0
  193. package/core/shared/skills/do-code-review/languages/go.md +92 -0
  194. package/core/shared/skills/do-code-review/languages/java.md +103 -0
  195. package/core/shared/skills/do-code-review/languages/kotlin.md +88 -0
  196. package/core/shared/skills/do-code-review/languages/php.md +109 -0
  197. package/core/shared/skills/do-code-review/languages/python.md +94 -0
  198. package/core/shared/skills/do-code-review/languages/ruby.md +105 -0
  199. package/core/shared/skills/do-code-review/languages/rust.md +99 -0
  200. package/core/shared/skills/do-code-review/languages/shell.md +90 -0
  201. package/core/shared/skills/do-code-review/languages/swift.md +88 -0
  202. package/core/shared/skills/do-code-review/languages/typescript.md +97 -0
  203. package/core/shared/skills/do-code-review/review-policy.json +47 -0
  204. package/core/shared/skills/do-code-review/rules/universal.md +49 -0
  205. package/core/shared/skills/do-code-review/scripts/code_quality_checker.py +1696 -0
  206. package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +532 -0
  207. package/core/shared/skills/do-code-review/scripts/pr_analyzer.py +620 -0
  208. package/core/shared/skills/do-code-review/scripts/review_report_generator.py +505 -0
  209. package/core/shared/skills/do-constitution/SKILL.md +72 -0
  210. package/core/shared/skills/do-design/SKILL.md +212 -0
  211. package/core/shared/skills/do-diagnose/SKILL.md +156 -0
  212. package/core/shared/skills/do-diagnose/references/code_audit.md +15 -0
  213. package/core/shared/skills/do-diagnose/references/refactoring.md +12 -0
  214. package/core/shared/skills/do-diagnose/references/root_cause.md +17 -0
  215. package/core/shared/skills/do-document/SKILL.md +81 -0
  216. package/core/shared/skills/do-document/references/api-reference.md +42 -0
  217. package/core/shared/skills/do-document/references/deep_research.md +18 -0
  218. package/core/shared/skills/do-document/references/embedded-research.md +11 -0
  219. package/core/shared/skills/do-document/references/implementation-flow.md +105 -0
  220. package/core/shared/skills/do-document/references/index_generator.md +9 -0
  221. package/core/shared/skills/do-document/references/user-guide.md +22 -0
  222. package/core/shared/skills/do-execute-plan/SKILL.md +261 -0
  223. package/core/shared/skills/do-execute-plan/references/handoff_record.md +57 -0
  224. package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +92 -0
  225. package/core/shared/skills/do-execute-plan/references/readiness_gate.md +155 -0
  226. package/core/shared/skills/do-execute-plan/references/run_position.md +63 -0
  227. package/core/shared/skills/do-execute-plan/references/scaffold.md +441 -0
  228. package/core/shared/skills/do-execute-plan/references/task_attempts.md +41 -0
  229. package/core/shared/skills/do-flow/SKILL.md +186 -0
  230. package/core/shared/skills/do-flow/references/task_classes.md +134 -0
  231. package/core/shared/skills/do-git/SKILL.md +115 -0
  232. package/core/shared/skills/do-git/references/hotfix-ritual.md +44 -0
  233. package/core/shared/skills/do-git/references/lifecycle-policy.md +54 -0
  234. package/core/shared/skills/do-git/references/release-ritual.md +64 -0
  235. package/core/shared/skills/do-implement/SKILL.md +222 -0
  236. package/core/shared/skills/do-plan/SKILL.md +219 -0
  237. package/core/shared/skills/do-test/SKILL.md +113 -0
  238. package/core/shared/templates/doflow/constitution-template.md +28 -0
  239. package/core/shared/templates/doflow/data-model-template.md +169 -0
  240. package/core/shared/templates/doflow/design-template.md +193 -0
  241. package/core/shared/templates/doflow/external-contract-template.md +35 -0
  242. package/core/shared/templates/doflow/intent-template.md +45 -0
  243. package/core/shared/templates/doflow/plan-template.md +151 -0
  244. package/core/shared/templates/doflow/question-log-template.md +51 -0
  245. package/core/shared/templates/doflow/requirement-template.md +126 -0
  246. package/core/shared/templates/doflow/specs-template.md +55 -0
  247. package/core/shared/templates/doflow/state-template.md +59 -0
  248. package/core/shared/workflows/do-flow-chain.md +17 -0
  249. package/package.json +48 -4
  250. package/src/adapters/antigravity/index.js +631 -0
  251. package/src/adapters/claude/index.js +548 -0
  252. package/src/adapters/claude/mcp.js +94 -0
  253. package/src/adapters/codex/agents.js +208 -0
  254. package/src/adapters/codex/config.js +272 -0
  255. package/src/adapters/codex/hooks.js +203 -0
  256. package/src/adapters/codex/index.js +707 -0
  257. package/src/adapters/codex/mcp.js +271 -0
  258. package/src/adapters/codex/native-projection.js +34 -0
  259. package/src/adapters/copilot/index.js +413 -0
  260. package/src/adapters/copy-tree.js +620 -0
  261. package/src/adapters/gemini/hooks.js +210 -0
  262. package/src/adapters/gemini/index.js +390 -0
  263. package/src/adapters/hook-commands.js +60 -0
  264. package/src/adapters/index.js +106 -0
  265. package/src/adapters/kiro/index.js +310 -0
  266. package/src/adapters/mcp-entries.js +331 -0
  267. package/src/adapters/opencode/index.js +466 -0
  268. package/src/adapters/pi/index.js +279 -0
  269. package/src/adapters/pi/json-members.js +185 -0
  270. package/src/adapters/pi/mcp.js +373 -0
  271. package/src/adapters/resolve-source.js +19 -0
  272. package/src/cli/commands/install.js +218 -0
  273. package/src/cli/commands/list-backups.js +21 -0
  274. package/src/cli/commands/reconcile.js +120 -0
  275. package/src/cli/commands/remove.js +82 -0
  276. package/src/cli/commands/rollback.js +172 -0
  277. package/src/cli/commands/self-update.js +33 -0
  278. package/src/cli/commands/status.js +138 -0
  279. package/src/cli/commands/tools.js +92 -0
  280. package/src/cli/commands/update.js +162 -0
  281. package/src/cli/index.js +568 -0
  282. package/src/cli/runtime-commands.js +281 -0
  283. package/src/cli/shared.js +218 -0
  284. package/src/helper/bash-helper.js +34 -0
  285. package/src/helper/git.js +24 -0
  286. package/src/helper/guardrails.js +49 -0
  287. package/src/helper/harness-paths.js +140 -0
  288. package/src/helper/marker-merge.js +116 -0
  289. package/src/helper/prompt.js +61 -0
  290. package/src/helper/repo-root.js +29 -0
  291. package/src/helper/settings-merge.js +151 -0
  292. package/src/helper/settings-scope.js +33 -0
  293. package/src/helper/toml.js +244 -0
  294. package/src/install/backup-set.js +149 -0
  295. package/src/install/backup.js +724 -0
  296. package/src/install/context.js +58 -0
  297. package/src/install/manifest.js +145 -0
  298. package/src/install/mcp.js +254 -0
  299. package/src/install/paths.js +84 -0
  300. package/src/install/targets.js +90 -0
  301. package/src/install/tool-lifecycle.js +151 -0
  302. package/src/lifecycle/bash-availability.js +25 -0
  303. package/src/lifecycle/index.js +812 -0
  304. package/src/lifecycle/mcp-index.js +28 -0
  305. package/src/lifecycle/policies.js +73 -0
  306. package/src/lifecycle/view.js +175 -0
  307. package/src/registry/index.js +611 -0
  308. package/src/release/build-package.js +91 -0
  309. package/src/release/changelog-section.js +70 -0
  310. package/src/release/sync-plugin-versions.js +66 -0
  311. package/src/runtime/advice/cli.js +283 -0
  312. package/src/runtime/advice/client.js +324 -0
  313. package/src/runtime/advice/config.js +189 -0
  314. package/src/runtime/advice/doctor.js +169 -0
  315. package/src/runtime/advice/endpoint-guard.js +134 -0
  316. package/src/runtime/advice/kinds.js +154 -0
  317. package/src/runtime/advice/providers/ollama-systemone.js +209 -0
  318. package/src/runtime/advice/record.js +166 -0
  319. package/src/runtime/advice/registry.js +170 -0
  320. package/src/runtime/advice/strength.js +66 -0
  321. package/src/runtime/brief-tools.js +342 -0
  322. package/src/runtime/c4-project.js +371 -0
  323. package/src/runtime/capability-router.js +484 -0
  324. package/src/runtime/checkouts.js +170 -0
  325. package/src/runtime/claims.js +468 -0
  326. package/src/runtime/cli-result.js +55 -0
  327. package/src/runtime/cli.js +678 -0
  328. package/src/runtime/command-detect.js +516 -0
  329. package/src/runtime/context-pack.js +492 -0
  330. package/src/runtime/decision-register.js +493 -0
  331. package/src/runtime/evidence-ledger.js +338 -0
  332. package/src/runtime/failure/capture.js +272 -0
  333. package/src/runtime/failure/classifier.js +68 -0
  334. package/src/runtime/failure/cli.js +178 -0
  335. package/src/runtime/failure/home.js +64 -0
  336. package/src/runtime/failure/store.js +226 -0
  337. package/src/runtime/feature-resolve.js +79 -0
  338. package/src/runtime/freshness.js +177 -0
  339. package/src/runtime/health.js +863 -0
  340. package/src/runtime/history-compactor.js +237 -0
  341. package/src/runtime/implementation-gate.js +321 -0
  342. package/src/runtime/inventory/coverage.js +218 -0
  343. package/src/runtime/inventory/identity.js +65 -0
  344. package/src/runtime/inventory/index.js +705 -0
  345. package/src/runtime/inventory/precedence.js +134 -0
  346. package/src/runtime/inventory/read-scopes.js +131 -0
  347. package/src/runtime/inventory/siblings.js +192 -0
  348. package/src/runtime/knowledge/chunker.js +124 -0
  349. package/src/runtime/knowledge/hybrid.js +149 -0
  350. package/src/runtime/knowledge/index-store.js +145 -0
  351. package/src/runtime/knowledge/retrieval.js +194 -0
  352. package/src/runtime/leak-scan.js +185 -0
  353. package/src/runtime/lifecycle/cli.js +276 -0
  354. package/src/runtime/lifecycle/event-store.js +340 -0
  355. package/src/runtime/lifecycle/fold.js +353 -0
  356. package/src/runtime/lifecycle/followup.js +432 -0
  357. package/src/runtime/lifecycle/goal.js +249 -0
  358. package/src/runtime/lifecycle/intent-writer.js +86 -0
  359. package/src/runtime/lifecycle/overview.js +292 -0
  360. package/src/runtime/lifecycle/release.js +241 -0
  361. package/src/runtime/lifecycle/report-store.js +221 -0
  362. package/src/runtime/lifecycle/retention.js +212 -0
  363. package/src/runtime/lifecycle/root.js +71 -0
  364. package/src/runtime/lifecycle/status.js +353 -0
  365. package/src/runtime/lifecycle/store-upkeep.js +393 -0
  366. package/src/runtime/locator-resolve.js +158 -0
  367. package/src/runtime/mask.js +210 -0
  368. package/src/runtime/mcp-consultations.js +412 -0
  369. package/src/runtime/mcp-reach.js +157 -0
  370. package/src/runtime/model-router.js +153 -0
  371. package/src/runtime/outcome.js +467 -0
  372. package/src/runtime/reach.js +148 -0
  373. package/src/runtime/readiness-record.js +150 -0
  374. package/src/runtime/readiness.js +435 -0
  375. package/src/runtime/recovery.js +282 -0
  376. package/src/runtime/research-request.js +231 -0
  377. package/src/runtime/retrieval-plan.js +578 -0
  378. package/src/runtime/scaffold/artifacts.js +217 -0
  379. package/src/runtime/scaffold/fingerprint.js +56 -0
  380. package/src/runtime/scaffold/generate.js +911 -0
  381. package/src/runtime/scaffold/languages.js +245 -0
  382. package/src/runtime/storage/cli.js +64 -0
  383. package/src/runtime/storage/engine.js +249 -0
  384. package/src/runtime/storage/registry.js +130 -0
  385. package/src/runtime/storage/roots.js +63 -0
  386. package/src/runtime/storage/rules.js +282 -0
  387. package/src/runtime/storage/upkeep.js +243 -0
  388. package/src/runtime/storage/work-status.js +326 -0
  389. package/src/runtime/task-attempts.js +415 -0
  390. package/src/runtime/task-classifier.js +550 -0
  391. package/src/runtime/task-scope.js +130 -0
  392. package/src/runtime/task-state.js +176 -0
  393. package/src/runtime/trace/indicators.js +235 -0
  394. package/src/runtime/trace/ledger.js +626 -0
  395. package/src/runtime/trace/render.js +76 -0
  396. package/src/runtime/trace/views.js +459 -0
  397. package/src/runtime/verification/contract-runner.js +341 -0
  398. package/src/runtime/verification/engine.js +1461 -0
  399. package/src/runtime/verification/registry.js +130 -0
  400. package/src/runtime/verification/scope-bound.js +418 -0
  401. package/src/runtime/verification/test-selection.js +172 -0
  402. package/src/runtime/workflow-engine.js +588 -0
  403. package/src/runtime/workflow-orchestrator.js +953 -0
  404. package/src/runtime/worktree.js +340 -0
  405. package/src/state/index.js +193 -0
  406. package/src/state/lockfile.js +136 -0
  407. package/src/state/run-lock.js +408 -0
  408. package/types/node-commonjs.d.ts +68 -0
  409. package/types/runtime.d.ts +91 -0
@@ -0,0 +1,1696 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Code Quality Checker
4
+
5
+ Analyzes source code for quality issues, code smells, complexity metrics,
6
+ and SOLID principle violations.
7
+
8
+ Usage:
9
+ python code_quality_checker.py /path/to/file.py
10
+ python code_quality_checker.py /path/to/directory --recursive
11
+ python code_quality_checker.py . --language typescript --json
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional
21
+
22
+
23
+ # Language-specific file extensions.
24
+ # `c` is declared before `cpp` so plain `.h` resolves to C, matching the
25
+ # dispatch table in SKILL.md. C++ headers use `.hpp` / `.hh` / `.hxx`.
26
+ LANGUAGE_EXTENSIONS = {
27
+ "python": [".py"],
28
+ "typescript": [".ts", ".tsx"],
29
+ "javascript": [".js", ".jsx", ".mjs"],
30
+ "go": [".go"],
31
+ "swift": [".swift"],
32
+ "kotlin": [".kt", ".kts"],
33
+ "csharp": [".cs", ".csx", ".razor", ".cshtml"],
34
+ "java": [".java"],
35
+ "c": [".c", ".h"],
36
+ "cpp": [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
37
+ "rust": [".rs"],
38
+ "ruby": [".rb", ".rake", ".gemspec", ".ru"],
39
+ "php": [".php", ".phtml"],
40
+ "dart": [".dart"],
41
+ "shell": [".sh", ".bash", ".zsh"],
42
+ "yaml": [".yaml", ".yml"],
43
+ "json": [".json"],
44
+ }
45
+
46
+ # Languages analysed as declarative structure rather than as code. Functions, classes, cyclomatic
47
+ # complexity and SOLID have no meaning here, and reporting them anyway is how a fabricated score
48
+ # gets attached to a file nobody analysed — the defect this coverage work exists to remove.
49
+ DECLARATIVE_LANGUAGES = {"yaml", "json"}
50
+
51
+ # Code smell thresholds
52
+ THRESHOLDS = {
53
+ "long_function_lines": 50,
54
+ "too_many_parameters": 5,
55
+ "high_complexity": 10,
56
+ "god_class_methods": 20,
57
+ "max_imports": 15,
58
+ "max_declarative_depth": 8,
59
+ }
60
+
61
+ # Feature 030 — the review policy resolves from three tiers instead of living in prose.
62
+ #
63
+ # THRESHOLDS above stays the literal source for the built-in tier (IC-001): it is copied, never
64
+ # duplicated, so the lowest tier cannot drift from the code that reads it. The shipped default
65
+ # (review-policy.json beside this scripts/ directory) and an optional per-repo file overlay it, and
66
+ # load_policy() writes the merged result back into THRESHOLDS before any analysis runs — which is why
67
+ # the eight existing THRESHOLDS[...] reads need no edit and cannot be missed.
68
+ #
69
+ # Why a file at all: SKILL.md used to transcribe these numbers and drifted, listing five of six and
70
+ # denying a nesting-depth check the analyzer performs for declarative files. A document that copies a
71
+ # value is a copy that goes stale; a document that names a file is not.
72
+
73
+ # The label each threshold is known by in documentation. Lives here rather than in the guard that
74
+ # reads it, so adding a threshold does not mean editing the guard too.
75
+ THRESHOLD_LABELS = {
76
+ "long_function_lines": "Long function",
77
+ "too_many_parameters": "Too many params",
78
+ "high_complexity": "High complexity",
79
+ "god_class_methods": "God class",
80
+ "max_imports": "Too many imports",
81
+ "max_declarative_depth": "Nesting depth",
82
+ }
83
+
84
+ DEFAULT_SEVERITIES = ["low", "medium", "high"]
85
+
86
+ # Bands a reviewer reads a score against. Declared, never applied: the verdict stays a reading of the
87
+ # code, and automating it would make a score authoritative over that reading.
88
+ DEFAULT_VERDICT_BANDS = [
89
+ {"verdict": "Approve", "minScore": 90, "maxHighFindings": 0},
90
+ {"verdict": "Approve with suggestions", "minScore": 75, "maxHighFindings": 2},
91
+ {"verdict": "Request changes", "minScore": 50, "maxHighFindings": None},
92
+ {"verdict": "Block", "minScore": 0, "maxHighFindings": None},
93
+ ]
94
+
95
+ # Empty because the analyzer excludes nothing today. Inventing a default exclusion here would change
96
+ # behaviour under cover of a refactor.
97
+ DEFAULT_EXCLUSIONS = []
98
+
99
+ POLICY_GROUPS = ("thresholds", "thresholdLabels", "severities", "verdictBands", "exclusions")
100
+ SHIPPED_POLICY_FILENAME = "review-policy.json"
101
+ REPO_POLICY_RELPATH = Path("agent-docs") / "review-policy.json"
102
+
103
+
104
+ def _policy_error(message: str) -> None:
105
+ """Stop rather than fall back. A policy that silently reverts to defaults applies one policy
106
+ while its author believes another is in force, which is worse than having no file."""
107
+ print(f"Error: {message}", file=sys.stderr)
108
+ sys.exit(2)
109
+
110
+
111
+ def _check_thresholds(value, source: str) -> None:
112
+ if not isinstance(value, dict):
113
+ _policy_error(f"{source}: 'thresholds' must be an object")
114
+ for key, number in value.items():
115
+ if key not in THRESHOLDS:
116
+ _policy_error(f"{source}: unknown threshold '{key}' — the analyzer implements "
117
+ f"{', '.join(sorted(THRESHOLDS))}")
118
+ # bool is an int in Python, and `true` is not a threshold.
119
+ if not isinstance(number, int) or isinstance(number, bool) or number <= 0:
120
+ _policy_error(f"{source}: threshold '{key}' must be a positive integer, got {number!r}")
121
+
122
+
123
+ def _check_threshold_labels(value, source: str) -> None:
124
+ if not isinstance(value, dict) or any(
125
+ not isinstance(k, str) or not isinstance(v, str) for k, v in value.items()):
126
+ _policy_error(f"{source}: 'thresholdLabels' must be an object of strings")
127
+ for key in value:
128
+ if key not in THRESHOLDS:
129
+ _policy_error(f"{source}: label declared for unknown threshold '{key}'")
130
+
131
+
132
+ def _check_string_list(group: str):
133
+ def check(value, source: str) -> None:
134
+ if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
135
+ _policy_error(f"{source}: '{group}' must be a list of strings")
136
+ return check
137
+
138
+
139
+ def _check_verdict_bands(value, source: str) -> None:
140
+ if not isinstance(value, list) or any(not isinstance(v, dict) for v in value):
141
+ _policy_error(f"{source}: 'verdictBands' must be a list of objects")
142
+
143
+
144
+ # One validator per group, looked up rather than branched through. The first version of this was a
145
+ # single nested ladder at complexity 27, which this skill's own analyzer flagged at high severity —
146
+ # a validator whose shape is hard to read is the wrong place to economise.
147
+ GROUP_VALIDATORS = {
148
+ "thresholds": _check_thresholds,
149
+ "thresholdLabels": _check_threshold_labels,
150
+ "severities": _check_string_list("severities"),
151
+ "exclusions": _check_string_list("exclusions"),
152
+ "verdictBands": _check_verdict_bands,
153
+ }
154
+
155
+
156
+ def _validate_policy_fragment(fragment: Dict, source: str) -> None:
157
+ if not isinstance(fragment, dict):
158
+ _policy_error(f"{source}: top level must be an object")
159
+ for group, value in fragment.items():
160
+ if group == "description":
161
+ continue
162
+ validator = GROUP_VALIDATORS.get(group)
163
+ if validator is None:
164
+ _policy_error(f"{source}: unknown policy group '{group}' "
165
+ f"(known: {', '.join(POLICY_GROUPS)})")
166
+ validator(value, source)
167
+
168
+
169
+ def _read_policy_file(path: Path) -> Dict:
170
+ try:
171
+ fragment = json.loads(path.read_text(encoding="utf-8"))
172
+ except json.JSONDecodeError as err:
173
+ _policy_error(f"{path}: does not parse as JSON — {err}")
174
+ _validate_policy_fragment(fragment, str(path))
175
+ return fragment
176
+
177
+
178
+ def _trace_path(path: Path) -> str:
179
+ """Relative to the working directory where possible. The fixtures compare emitted JSON across
180
+ machines, and an absolute path would make every recorded output machine-specific — the same
181
+ reason finding locations are already emitted relative."""
182
+ try:
183
+ return str(path.relative_to(Path.cwd()))
184
+ except ValueError:
185
+ return str(path)
186
+
187
+
188
+ def _walk_up_for_policy(start: Path) -> Optional[Path]:
189
+ current = start if start.is_dir() else start.parent
190
+ for directory in [current, *current.parents]:
191
+ candidate = directory / REPO_POLICY_RELPATH
192
+ if candidate.is_file():
193
+ return candidate
194
+ return None
195
+
196
+
197
+ def find_repo_policy(start: Path) -> Optional[Path]:
198
+ """First agent-docs/review-policy.json found walking upward, mirroring the runtime seam's own
199
+ upward search for .doflow/. First hit wins, and the trace names which file won.
200
+
201
+ The analysed path is searched first, because when it sits in a repository that repository's own
202
+ policy is the one under review. Only when that search finds nothing does the working directory
203
+ get a turn — which is what makes `cd my-repo && ... /tmp/patch.py` apply my-repo's policy instead
204
+ of silently applying none. A target inside a policy-bearing repo is therefore never overridden by
205
+ wherever the reviewer happened to be standing."""
206
+ found = _walk_up_for_policy(start)
207
+ if found is not None:
208
+ return found
209
+ return _walk_up_for_policy(Path.cwd())
210
+
211
+
212
+ def load_policy(start: Path):
213
+ """Resolve the policy from three tiers, lowest precedence first. Returns (policy, tiers)."""
214
+ policy = {
215
+ "thresholds": dict(THRESHOLDS),
216
+ "severities": list(DEFAULT_SEVERITIES),
217
+ "verdictBands": [dict(b) for b in DEFAULT_VERDICT_BANDS],
218
+ "exclusions": list(DEFAULT_EXCLUSIONS),
219
+ "thresholdLabels": dict(THRESHOLD_LABELS),
220
+ }
221
+ tiers = ["built-in"]
222
+
223
+ shipped = Path(__file__).resolve().parent.parent / SHIPPED_POLICY_FILENAME
224
+ for source in (shipped, find_repo_policy(start)):
225
+ if source is None or not source.is_file():
226
+ continue
227
+ fragment = _read_policy_file(source)
228
+ # Per key within a group, never deeper: groups hold scalars and flat lists, so there is no
229
+ # nested case, and validation above refuses a shape that would need one.
230
+ for group in POLICY_GROUPS:
231
+ if group not in fragment:
232
+ continue
233
+ if group in ("thresholds", "thresholdLabels"):
234
+ policy[group].update(fragment[group])
235
+ else:
236
+ policy[group] = fragment[group]
237
+ tiers.append(_trace_path(source))
238
+
239
+ return policy, tiers
240
+
241
+
242
+ def get_file_extension(filepath: Path) -> str:
243
+ """Get file extension."""
244
+ return filepath.suffix.lower()
245
+
246
+
247
+ def detect_language(filepath: Path) -> Optional[str]:
248
+ """Detect programming language from file extension."""
249
+ ext = get_file_extension(filepath)
250
+ for lang, extensions in LANGUAGE_EXTENSIONS.items():
251
+ if ext in extensions:
252
+ return lang
253
+ return None
254
+
255
+
256
+ def read_file_content(filepath: Path) -> str:
257
+ """Read file content safely."""
258
+ try:
259
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
260
+ return f.read()
261
+ except Exception:
262
+ return ""
263
+
264
+
265
+ # Only these languages have heredocs. Everything else spells `<<` as an operator — a stream insert
266
+ # in C++, a bit shift in Java, an array append in Ruby's non-heredoc use — and must never be scanned
267
+ # for one. See `_blank_heredocs` for what happens when it is.
268
+ _HEREDOC_LANGUAGES = frozenset({"shell", "ruby", "php"})
269
+
270
+ # A bare terminator must follow the delimiter immediately: `cat <<EOF`, never `cat << EOF`. That one
271
+ # character of strictness is what separates a heredoc from `cout << total`, which always has the
272
+ # space. The quoted forms may keep it, since `<< 'EOF'` is not a shift in any language.
273
+ _HEREDOC_OPENER = re.compile(
274
+ r"""<<<?[-~]?(?:\s*(['"])([A-Za-z_][A-Za-z0-9_]*)\1|([A-Za-z_][A-Za-z0-9_]*))"""
275
+ )
276
+
277
+
278
+ def _heredoc_end(lines: List[str], start: int, word: str) -> Optional[int]:
279
+ """
280
+ Index of the line terminating a heredoc opened at `start`, or None if it never terminates.
281
+
282
+ Whitespace around the terminator is ignored, which is more lenient than any single language
283
+ allows. Leniency here can only end a body early, and ending early under-blanks — the safe
284
+ direction. Returning None is what makes an opener that is really an operator harmless.
285
+ """
286
+ for j in range(start + 1, len(lines)):
287
+ if lines[j].strip() == word:
288
+ return j
289
+ return None
290
+
291
+
292
+ def _blank_heredocs(content: str) -> str:
293
+ """
294
+ Blank the body of every heredoc that actually terminates, preserving newlines.
295
+
296
+ A heredoc body is prose, SQL or JSON far more often than it is code, and the keyword scan
297
+ charges every `and`, `or`, `if` and `case` in it as a branch. A shell function whose entire
298
+ body is `cat <<'EOF' ... EOF` printing a usage message scored complexity 17 against a
299
+ threshold of 10 — a false `high_complexity` smell on a function with no branch at all.
300
+
301
+ Heredocs were previously parked as needing a tokenizer, alongside regex literals and PHP's
302
+ `<?php` boundary. That grouping was wrong for this one form: a heredoc *declares its own
303
+ terminator at the opener*, so finding the end needs no grammar, only the word the opener
304
+ already named. Recognising the opener is the bounded part, not recognising the end.
305
+
306
+ Bounded exactly like `_blank_spanning`, and in the same direction: an opener whose terminator
307
+ never appears in the remaining lines is treated as not an opener, leaving the text untouched.
308
+ Every bound below fails toward under-blanking, because over-blanking is what manufactures a
309
+ false pass.
310
+
311
+ Recognised: `<<WORD`, `<<-WORD`, `<<~WORD` (Ruby) and `<<<WORD` (PHP), quoted or bare. The
312
+ terminator is matched on its own line ignoring surrounding whitespace — more lenient than any
313
+ single language, which can only end a body early and so under-blank. Lines beginning with `#`
314
+ are skipped as openers, so a comment mentioning a heredoc cannot start one.
315
+
316
+ **Only called for `_HEREDOC_LANGUAGES`, and that gate is load-bearing rather than tidy.** An
317
+ earlier version ran for every language on the reasoning that a `<<` operator would find no
318
+ terminator and be left alone. That reasoning was wrong: it checked only that an opener with no
319
+ closer cannot blank to end-of-file, never that a *spurious* opener could meet a *spurious*
320
+ closer part-way down. `std::cout << total` followed anywhere below by a line reading `total`
321
+ blanked every branch between them, taking a C++ function from complexity 7 to 1 — a false pass,
322
+ the one failure this module treats as unacceptable. The gate removes the case; requiring a bare
323
+ terminator to touch the delimiter (`<<EOF`, never `<< EOF`) removes it a second time.
324
+
325
+ The opener's own line is code and is kept; only the body and the terminator line are blanked.
326
+ """
327
+ lines = content.split("\n")
328
+ out = list(lines)
329
+ i = 0
330
+ while i < len(lines):
331
+ line = lines[i]
332
+ if line.lstrip().startswith("#"):
333
+ i += 1
334
+ continue
335
+ # A single command may open several bodies: `cmd <<A <<B`. They fill in the order opened.
336
+ words = [m.group(2) or m.group(3) for m in _HEREDOC_OPENER.finditer(line)]
337
+ cursor = i
338
+ for word in words:
339
+ end = _heredoc_end(lines, cursor, word)
340
+ if end is None:
341
+ # No terminator anywhere below: not a heredoc. Leave this line and stop here.
342
+ break
343
+ for j in range(cursor + 1, end + 1):
344
+ out[j] = " " * len(lines[j])
345
+ cursor = end
346
+ i = cursor + 1 if cursor > i else i + 1
347
+ return "\n".join(out)
348
+
349
+
350
+ def strip_comments(content: str, language: Optional[str] = None) -> str:
351
+ """
352
+ Remove comment text so prose is not measured as code.
353
+
354
+ The keyword scan below matches \\band\\b and \\bor\\b — Python operators — against raw text,
355
+ case-insensitively. A documented function was therefore charged for every "and" and "or" in its
356
+ own prose, so the better a function was commented the worse it scored. Blank lines replace the
357
+ removed text rather than deleting it, so any line number derived from this string still lines up
358
+ with the original file.
359
+
360
+ Deliberately not handled: an "and"/"or" inside a string literal still counts. That is a separate
361
+ defect from the reported one, and stripping strings correctly needs a real tokenizer.
362
+ """
363
+ # Heredocs first of all: their bodies are arbitrary text, so a stray backtick or quote inside
364
+ # one would otherwise pair with real code below and let `_blank_spanning` erase across it.
365
+ # Gated on the language, and defaulting to *not* blanking: a caller that passes nothing gets the
366
+ # under-blanking behaviour, so a future call site that forgets the argument cannot reintroduce
367
+ # the false pass that gating exists to prevent.
368
+ if language in _HEREDOC_LANGUAGES:
369
+ content = _blank_heredocs(content)
370
+ # Block comments next, preserving newline count so line numbers survive.
371
+ content = re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), content, flags=re.DOTALL)
372
+ # Multi-line forms first, since a line-scoped pass cannot see them. Each is blanked in place with
373
+ # newlines preserved, so the line-scoped pass below then sees ordinary blank content.
374
+ for opener in ('\"\"\"', "'''", '`'):
375
+ content = _blank_spanning(content, opener)
376
+
377
+ out = []
378
+ for line in content.split("\n"):
379
+ stripped = line.lstrip()
380
+ if stripped.startswith("//") or stripped.startswith("*") or stripped.startswith("#"):
381
+ out.append("")
382
+ else:
383
+ out.append(re.sub(r"\s//.*$", "", line))
384
+ return "\n".join(out)
385
+
386
+
387
+ def _blank_spanning(content: str, delim: str) -> str:
388
+ """
389
+ Blank the interior of every `delim`-fenced block that actually closes, preserving newlines.
390
+
391
+ Bounded the same way as the line-scoped pass: an opener with no matching closer in the rest of
392
+ the text is left exactly as it is. Over-blanking is the failure that matters — one unterminated
393
+ delimiter silently erasing the remainder of a file would turn a branch-heavy function into a
394
+ clean one, which is the false pass this whole analyser thread exists to remove.
395
+ """
396
+ out, i, n = [], 0, len(content)
397
+ while i < n:
398
+ start = content.find(delim, i)
399
+ if start == -1:
400
+ out.append(content[i:])
401
+ break
402
+ end = content.find(delim, start + len(delim))
403
+ if end == -1:
404
+ # No closer anywhere: not a fenced block. Leave the rest untouched.
405
+ out.append(content[i:])
406
+ break
407
+ inner = content[start + len(delim):end]
408
+ out.append(content[i:start] + delim + re.sub(r"[^\n]", " ", inner) + delim)
409
+ i = end + len(delim)
410
+ return "".join(out)
411
+
412
+
413
+ def strip_string_literals(content: str) -> str:
414
+ """
415
+ Blank the contents of string literals so text inside them is not measured as code.
416
+
417
+ `strip_comments` fixed prose in comments being counted as control flow. The same defect exists
418
+ for strings: `const msg = "error or warning";` contributes a branch for the word `or`.
419
+
420
+ **This cannot be made fully correct without a tokenizer**, and none is available — the repository
421
+ has zero runtime dependencies and Python's `tokenize` only understands Python. Regex literals in
422
+ JS and PHP's `<?php` boundary need parse context this does not have. Triple-quotes and heredocs
423
+ do not, and are handled — see `_blank_spanning` and `_blank_heredocs`.
424
+
425
+ So the rule is bounded rather than complete: scanning is **line-scoped**, and a quote that does
426
+ not close on its own line is retroactively treated as not a string, leaving the rest of that line
427
+ untouched. That direction is deliberate. The failure that matters is not under-counting a branch;
428
+ it is a phantom unterminated string blanking the remainder of a file and turning a forty-branch
429
+ function into a clean one. Under-blanking loses a little accuracy. Over-blanking manufactures a
430
+ false pass, which is the whole defect class this analyser work exists to remove.
431
+
432
+ Length and newlines are preserved, so any line number derived from the result still lines up
433
+ with the original file.
434
+
435
+ Three multi-line forms are handled by carrying state across lines, in `strip_comments`, which
436
+ runs first: Python/JS triple-quoted blocks, JS backtick templates (`_blank_spanning`) and
437
+ heredocs (`_blank_heredocs`). All three are bounded the same way — an opener with no closer
438
+ anywhere in the remaining text is treated as not an opener, so an odd quote can never blank the
439
+ tail of a file. A heredoc's terminator is caller-chosen but the opener declares it, so it needs
440
+ no parsing; what is still unhandled is JS regex literals and PHP's `<?php` boundary, where
441
+ finding the end genuinely does require the grammar.
442
+ """
443
+ out = []
444
+ for line in content.split("\n"):
445
+ i, n, buf = 0, len(line), []
446
+ while i < n:
447
+ ch = line[i]
448
+ if ch in "\"'`":
449
+ # Look for the matching close on this line, honouring backslash escapes.
450
+ j, closed = i + 1, False
451
+ while j < n:
452
+ if line[j] == "\\":
453
+ j += 2
454
+ continue
455
+ if line[j] == ch:
456
+ closed = True
457
+ break
458
+ j += 1
459
+ if closed:
460
+ buf.append(ch + " " * (j - i - 1) + ch)
461
+ i = j + 1
462
+ continue
463
+ # Unclosed on this line: bounded failure. Treat it as not a string and stop
464
+ # scanning this line rather than blanking to end-of-file.
465
+ buf.append(line[i:])
466
+ i = n
467
+ continue
468
+ buf.append(ch)
469
+ i += 1
470
+ out.append("".join(buf))
471
+ return "\n".join(out)
472
+
473
+
474
+ def trim_trailing_comment_block(body: str) -> str:
475
+ """
476
+ Drop a documentation comment sitting at the end of a sliced function body.
477
+
478
+ `find_functions` slices a body from one function match to the *next* one, so whatever documents
479
+ the next function landed inside this one — inflating its line count and its complexity. The
480
+ comment belongs to what follows, not to what precedes it.
481
+ """
482
+ lines = body.split("\n")
483
+ end = len(lines)
484
+ while end > 0 and lines[end - 1].strip() == "":
485
+ end -= 1
486
+ # Walk back over a contiguous run of comment lines at the tail.
487
+ start = end
488
+ while start > 0:
489
+ candidate = lines[start - 1].strip()
490
+ if candidate.startswith(("//", "/*", "*", "#")) or candidate == "":
491
+ start -= 1
492
+ else:
493
+ break
494
+ return "\n".join(lines[:start]) if start < end else "\n".join(lines[:end])
495
+
496
+
497
+ def calculate_cyclomatic_complexity(content: str, language: Optional[str] = None) -> int:
498
+ """
499
+ Estimate cyclomatic complexity based on control flow keywords.
500
+
501
+ Comments and string literals are stripped first: counting keywords in either made prose a
502
+ penalty. String stripping is bounded, not complete — see `strip_string_literals`.
503
+ """
504
+ content = strip_string_literals(strip_comments(content, language))
505
+ complexity = 1 # Base complexity
506
+
507
+ # Control flow patterns that increase complexity
508
+ patterns = [
509
+ r"\bif\b",
510
+ r"\belif\b",
511
+ r"\belse\b",
512
+ r"\bfor\b",
513
+ r"\bwhile\b",
514
+ r"\bcase\b",
515
+ r"\bcatch\b",
516
+ r"\bexcept\b",
517
+ r"\band\b",
518
+ r"\bor\b",
519
+ r"\|\|",
520
+ r"&&"
521
+ ]
522
+
523
+ for pattern in patterns:
524
+ matches = re.findall(pattern, content, re.IGNORECASE)
525
+ complexity += len(matches)
526
+
527
+ return complexity
528
+
529
+
530
+ def count_lines(content: str) -> Dict[str, int]:
531
+ """Count different types of lines in code."""
532
+ lines = content.split("\n")
533
+ total = len(lines)
534
+ blank = sum(1 for line in lines if not line.strip())
535
+ comment = 0
536
+
537
+ for line in lines:
538
+ stripped = line.strip()
539
+ if stripped.startswith("#") or stripped.startswith("//"):
540
+ comment += 1
541
+ elif stripped.startswith("/*") or stripped.startswith("'''") or stripped.startswith('"""'):
542
+ comment += 1
543
+
544
+ code = total - blank - comment
545
+
546
+ return {
547
+ "total": total,
548
+ "code": code,
549
+ "blank": blank,
550
+ "comment": comment
551
+ }
552
+
553
+
554
+ def find_functions(content: str, language: str) -> List[Dict]:
555
+ """Find function definitions and their metrics."""
556
+ functions = []
557
+
558
+ # Language-specific function patterns
559
+ patterns = {
560
+ "python": r"def\s+(\w+)\s*\(([^)]*)\)",
561
+ # Both shell spellings: `name() {` and `function name {`.
562
+ "shell": r"(?:function\s+(\w+)\s*(?:\(\s*\))?|(\w+)\s*\(\s*\))\s*\{",
563
+ "typescript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
564
+ "javascript": r"(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>)",
565
+ "go": r"func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(([^)]*)\)",
566
+ "swift": r"func\s+(\w+)\s*\(([^)]*)\)",
567
+ "kotlin": r"fun\s+(\w+)\s*\(([^)]*)\)",
568
+ # C#: require at least one method modifier (public/private/etc. or static/async/...)
569
+ # to distinguish declarations from invocations.
570
+ "csharp": (
571
+ r"(?:(?:public|private|protected|internal|static|async|virtual|"
572
+ r"override|sealed|abstract|partial|new|readonly|extern)\s+)+"
573
+ r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)"
574
+ ),
575
+ # Java: require at least one method modifier to distinguish
576
+ # declarations from invocations (mirrors the C# approach).
577
+ "java": (
578
+ r"(?:(?:public|private|protected|static|final|abstract|"
579
+ r"synchronized|native|default|strictfp)\s+)+"
580
+ r"(?:[\w<>?,\s\[\]\.]+?\s+)?(\w+)\s*\(([^)]*)\)"
581
+ ),
582
+ # C: require an opening brace after the parens so prototypes and
583
+ # call sites don't get matched. Return type / qualifiers come first.
584
+ # Skip C control-flow keywords that look like function calls.
585
+ "c": (
586
+ r"^(?:static\s+|inline\s+|extern\s+|const\s+|unsigned\s+|"
587
+ r"signed\s+|volatile\s+|register\s+)*"
588
+ r"(?:[\w\*]+\s+\**)+"
589
+ r"(?!(?:if|while|for|switch|return|sizeof)\b)"
590
+ r"(\w+)\s*\(([^)]*)\)\s*\{"
591
+ ),
592
+ # C++: like C but also catches `ClassName::method(...)` definitions
593
+ # and template return types like `std::vector<int>`.
594
+ "cpp": (
595
+ r"^(?:static\s+|inline\s+|extern\s+|const\s+|virtual\s+|"
596
+ r"explicit\s+|constexpr\s+|noexcept\s+)*"
597
+ r"(?:[\w:\*<>&,\s]+\s+\**)+"
598
+ r"(?!(?:if|while|for|switch|return|sizeof)\b)"
599
+ r"(\w+)(?:::\w+)?\s*\(([^)]*)\)\s*(?:const\s*)?"
600
+ r"(?:noexcept\s*)?(?:override\s*)?(?:final\s*)?\{"
601
+ ),
602
+ # Rust: `fn` keyword is always present and unambiguous.
603
+ "rust": (
604
+ r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?"
605
+ r"(?:extern\s+\"[^\"]+\"\s+)?fn\s+(\w+)\s*"
606
+ r"(?:<[^>]+>)?\s*\(([^)]*)\)"
607
+ ),
608
+ # Ruby: `def` keyword; params may be parenthesised or bare.
609
+ "ruby": (
610
+ r"def\s+(?:self\.)?(\w+[?!=]?)(?:\s*\(([^)]*)\)|\s*$|\s+\w)"
611
+ ),
612
+ # PHP: `function` keyword is always present.
613
+ "php": (
614
+ r"(?:(?:public|private|protected|static|abstract|final)\s+)*"
615
+ r"function\s+(\w+)\s*\(([^)]*)\)"
616
+ ),
617
+ # Dart: typed return followed by name and parens. Constructors
618
+ # (where name matches enclosing class) are not specially handled.
619
+ "dart": (
620
+ r"^\s*(?:static\s+|external\s+)*"
621
+ r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+"
622
+ r"(\w+)\s*\(([^)]*)\)\s*(?:async\*?\s*|sync\*?\s*)?\{"
623
+ ),
624
+ }
625
+
626
+ pattern = patterns.get(language, patterns["python"])
627
+ matches = re.finditer(pattern, content, re.MULTILINE)
628
+
629
+ for match in matches:
630
+ name = next((g for g in match.groups() if g), "anonymous")
631
+ params_str = match.group(2) if len(match.groups()) > 1 and match.group(2) else ""
632
+
633
+ # Count parameters
634
+ params = [p.strip() for p in params_str.split(",") if p.strip()]
635
+ param_count = len(params)
636
+
637
+ # Estimate function length
638
+ start_pos = match.end()
639
+ remaining = content[start_pos:]
640
+
641
+ next_func = re.search(pattern, remaining)
642
+ if next_func:
643
+ func_body = remaining[:next_func.start()]
644
+ else:
645
+ func_body = remaining[:min(2000, len(remaining))]
646
+
647
+ # The slice runs to the next function's *declaration*, so it swallows that function's
648
+ # documentation comment. Give it back before measuring anything.
649
+ func_body = trim_trailing_comment_block(func_body)
650
+
651
+ line_count = len(func_body.split("\n"))
652
+ complexity = calculate_cyclomatic_complexity(func_body, language)
653
+
654
+ functions.append({
655
+ "name": name,
656
+ "parameters": param_count,
657
+ "lines": line_count,
658
+ "complexity": complexity
659
+ })
660
+
661
+ return functions
662
+
663
+
664
+ def find_classes(content: str, language: str) -> List[Dict]:
665
+ """Find class definitions and their metrics."""
666
+ classes = []
667
+
668
+ patterns = {
669
+ "python": r"class\s+(\w+)",
670
+ "typescript": r"class\s+(\w+)",
671
+ "javascript": r"class\s+(\w+)",
672
+ "go": r"type\s+(\w+)\s+struct",
673
+ "swift": r"class\s+(\w+)",
674
+ "kotlin": r"class\s+(\w+)",
675
+ "csharp": r"(?:class|struct|record|interface)\s+(\w+)",
676
+ "java": r"(?:class|interface|enum|record)\s+(\w+)",
677
+ # C has no classes; `struct` and `typedef struct` are the closest.
678
+ "c": r"(?:typedef\s+)?struct\s+(\w+)",
679
+ "cpp": r"(?:class|struct)\s+(\w+)",
680
+ # Rust uses `struct`, `enum`, `trait`, `union` for type definitions.
681
+ # `impl` blocks attach methods but are not type defs themselves.
682
+ "rust": r"(?:pub(?:\([^)]+\))?\s+)?(?:struct|enum|trait|union)\s+(\w+)",
683
+ "ruby": r"(?:class|module)\s+(\w+)",
684
+ "php": (
685
+ r"(?:abstract\s+|final\s+)?"
686
+ r"(?:class|interface|trait|enum)\s+(\w+)"
687
+ ),
688
+ # Dart 3 class modifiers: final / interface / base / sealed / mixin.
689
+ "dart": (
690
+ r"(?:abstract\s+|sealed\s+|final\s+|base\s+|interface\s+)?"
691
+ r"(?:class|mixin|enum|extension)\s+(\w+)"
692
+ ),
693
+ }
694
+
695
+ pattern = patterns.get(language, patterns["python"])
696
+ matches = re.finditer(pattern, content)
697
+
698
+ for match in matches:
699
+ name = match.group(1)
700
+
701
+ start_pos = match.end()
702
+ remaining = content[start_pos:]
703
+
704
+ next_class = re.search(pattern, remaining)
705
+ if next_class:
706
+ class_body = remaining[:next_class.start()]
707
+ else:
708
+ class_body = remaining
709
+
710
+ # Count methods
711
+ method_patterns = {
712
+ "python": r"def\s+\w+\s*\(",
713
+ "typescript": r"(?:public|private|protected)?\s*\w+\s*\([^)]*\)\s*[:{]",
714
+ "javascript": r"\w+\s*\([^)]*\)\s*\{",
715
+ "go": r"func\s+\(",
716
+ "swift": r"func\s+\w+",
717
+ "kotlin": r"fun\s+\w+",
718
+ "csharp": (
719
+ r"(?:(?:public|private|protected|internal|static|async|virtual|"
720
+ r"override|sealed|abstract|partial)\s+)+"
721
+ r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\("
722
+ ),
723
+ "java": (
724
+ r"(?:(?:public|private|protected|static|final|abstract|"
725
+ r"synchronized|native|default|strictfp)\s+)+"
726
+ r"(?:[\w<>?,\s\[\]\.]+?\s+)?\w+\s*\("
727
+ ),
728
+ # C has no classes; struct members are typically function pointers
729
+ # rather than methods. Use the function definition pattern.
730
+ "c": (
731
+ r"^(?:static\s+|inline\s+)*(?:[\w\*]+\s+\**)+"
732
+ r"(?!(?:if|while|for|switch|return|sizeof)\b)"
733
+ r"\w+\s*\([^)]*\)\s*\{"
734
+ ),
735
+ "cpp": (
736
+ r"^(?:static\s+|inline\s+|virtual\s+|explicit\s+|"
737
+ r"constexpr\s+)*(?:[\w:\*<>&,\s]+\s+\**)+"
738
+ r"(?!(?:if|while|for|switch|return|sizeof)\b)"
739
+ r"\w+(?:::\w+)?\s*\([^)]*\)"
740
+ ),
741
+ "rust": (
742
+ r"(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?(?:unsafe\s+)?"
743
+ r"fn\s+\w+"
744
+ ),
745
+ "ruby": r"def\s+(?:self\.)?\w+[?!=]?",
746
+ "php": (
747
+ r"(?:(?:public|private|protected|static|abstract|final)\s+)*"
748
+ r"function\s+\w+\s*\("
749
+ ),
750
+ "dart": (
751
+ r"^\s*(?:static\s+|external\s+)*"
752
+ r"(?:Future<[^>]*>|Stream<[^>]*>|void|[\w<>?,\s]+?)\s+"
753
+ r"\w+\s*\([^)]*\)\s*(?:async\*?\s*|sync\*?\s*)?\{"
754
+ ),
755
+ }
756
+ method_pattern = method_patterns.get(language, method_patterns["python"])
757
+ methods = len(re.findall(method_pattern, class_body))
758
+
759
+ classes.append({
760
+ "name": name,
761
+ "methods": methods,
762
+ "lines": len(class_body.split("\n"))
763
+ })
764
+
765
+ return classes
766
+
767
+
768
+ def check_code_smells(content: str, functions: List[Dict], classes: List[Dict]) -> List[Dict]:
769
+ """Check for code smells in the content."""
770
+ smells = []
771
+
772
+ # Long functions
773
+ for func in functions:
774
+ if func["lines"] > THRESHOLDS["long_function_lines"]:
775
+ smells.append({
776
+ "type": "long_function",
777
+ "severity": "medium",
778
+ "message": f"Function '{func['name']}' has {func['lines']} lines (max: {THRESHOLDS['long_function_lines']})",
779
+ "location": func["name"]
780
+ })
781
+
782
+ # Too many parameters
783
+ for func in functions:
784
+ if func["parameters"] > THRESHOLDS["too_many_parameters"]:
785
+ smells.append({
786
+ "type": "too_many_parameters",
787
+ "severity": "low",
788
+ "message": f"Function '{func['name']}' has {func['parameters']} parameters (max: {THRESHOLDS['too_many_parameters']})",
789
+ "location": func["name"]
790
+ })
791
+
792
+ # High complexity
793
+ for func in functions:
794
+ if func["complexity"] > THRESHOLDS["high_complexity"]:
795
+ severity = "high" if func["complexity"] > 20 else "medium"
796
+ smells.append({
797
+ "type": "high_complexity",
798
+ "severity": severity,
799
+ "message": f"Function '{func['name']}' has complexity {func['complexity']} (max: {THRESHOLDS['high_complexity']})",
800
+ "location": func["name"]
801
+ })
802
+
803
+ # God classes
804
+ for cls in classes:
805
+ if cls["methods"] > THRESHOLDS["god_class_methods"]:
806
+ smells.append({
807
+ "type": "god_class",
808
+ "severity": "high",
809
+ "message": f"Class '{cls['name']}' has {cls['methods']} methods (max: {THRESHOLDS['god_class_methods']})",
810
+ "location": cls["name"]
811
+ })
812
+
813
+ # Magic numbers
814
+ magic_pattern = r"\b(?<![.\"\'])\d{3,}\b(?!\.\d)"
815
+ for i, line in enumerate(content.split("\n"), 1):
816
+ if line.strip().startswith(("#", "//", "import", "from")):
817
+ continue
818
+ matches = re.findall(magic_pattern, line)
819
+ for match in matches[:1]: # One per line
820
+ smells.append({
821
+ "type": "magic_number",
822
+ "severity": "low",
823
+ "message": f"Magic number {match} should be a named constant",
824
+ "location": f"line {i}"
825
+ })
826
+
827
+ # Commented code patterns
828
+ commented_code_pattern = r"^\s*[#//]+\s*(if|for|while|def|function|class|const|let|var)\s"
829
+ for i, line in enumerate(content.split("\n"), 1):
830
+ if re.match(commented_code_pattern, line, re.IGNORECASE):
831
+ smells.append({
832
+ "type": "commented_code",
833
+ "severity": "low",
834
+ "message": "Commented-out code should be removed",
835
+ "location": f"line {i}"
836
+ })
837
+
838
+ return smells
839
+
840
+
841
+ def _strip_csharp_comments(content: str) -> str:
842
+ """Remove // line comments and /* */ block comments so regex detectors
843
+ don't match keywords inside prose."""
844
+ no_block = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
845
+ no_line = re.sub(r"//[^\n]*", "", no_block)
846
+ return no_line
847
+
848
+
849
+ def check_csharp_specific_smells(content: str) -> List[Dict]:
850
+ """C# / .NET-specific code smells documented in SKILL.md."""
851
+ smells: List[Dict] = []
852
+ content = _strip_csharp_comments(content)
853
+
854
+ # async void (event handler exception only — caller must justify)
855
+ for match in re.finditer(r"\basync\s+void\s+(\w+)\s*\(", content):
856
+ smells.append({
857
+ "type": "csharp_async_void",
858
+ "severity": "high",
859
+ "message": (
860
+ f"'async void {match.group(1)}' — only safe for event handlers; "
861
+ "prefer 'async Task'"
862
+ ),
863
+ "location": match.group(1),
864
+ })
865
+
866
+ # Blocking on async: .Result, .Wait(), .GetAwaiter().GetResult()
867
+ for match in re.finditer(
868
+ r"\.(?:Result\b|Wait\(\)|GetAwaiter\(\)\.GetResult\(\))", content
869
+ ):
870
+ smells.append({
871
+ "type": "csharp_blocking_async",
872
+ "severity": "high",
873
+ "message": (
874
+ "Blocking call on async operation ('.Result' / '.Wait()' / "
875
+ "'.GetAwaiter().GetResult()') — can deadlock in ASP.NET contexts"
876
+ ),
877
+ "location": f"offset {match.start()}",
878
+ })
879
+
880
+ # Bare catch / catch (Exception) that swallows
881
+ swallow_pattern = re.compile(
882
+ r"catch\s*(?:\(\s*(?:System\.)?Exception(?:\s+\w+)?\s*\))?\s*\{\s*\}"
883
+ )
884
+ for match in swallow_pattern.finditer(content):
885
+ smells.append({
886
+ "type": "csharp_swallowed_exception",
887
+ "severity": "high",
888
+ "message": "Empty catch block swallows exceptions silently",
889
+ "location": f"offset {match.start()}",
890
+ })
891
+
892
+ # IDisposable instantiated but not in `using` — heuristic: `new SomethingClient(`
893
+ # / `new SomethingStream(` / `new SqlConnection(` outside a `using` line.
894
+ disposable_hint = re.compile(
895
+ r"^(?!\s*using\b)\s*(?:var|[\w<>]+)\s+\w+\s*=\s*new\s+"
896
+ r"(\w*(?:Stream|Connection|Reader|Writer|Client|Context|Command))\s*\(",
897
+ re.MULTILINE,
898
+ )
899
+ for match in disposable_hint.finditer(content):
900
+ smells.append({
901
+ "type": "csharp_undisposed_idisposable",
902
+ "severity": "medium",
903
+ "message": (
904
+ f"'{match.group(1)}' looks like IDisposable but is not wrapped in "
905
+ "'using' / 'using var'"
906
+ ),
907
+ "location": f"offset {match.start()}",
908
+ })
909
+
910
+ # HttpClient instantiated with `new` inside a method body (socket exhaustion)
911
+ httpclient_inline = re.compile(r"new\s+HttpClient\s*\(\s*\)")
912
+ for match in httpclient_inline.finditer(content):
913
+ smells.append({
914
+ "type": "csharp_new_httpclient",
915
+ "severity": "medium",
916
+ "message": (
917
+ "'new HttpClient()' — prefer IHttpClientFactory or a long-lived "
918
+ "static instance to avoid socket exhaustion"
919
+ ),
920
+ "location": f"offset {match.start()}",
921
+ })
922
+
923
+ # Missing await: `Task.Run(` / async method call assigned but never awaited.
924
+ # Heuristic: a statement ending in `Async()` or `Async(...)` followed by `;`
925
+ # with no `await` keyword on the same line.
926
+ for line_no, line in enumerate(content.split("\n"), 1):
927
+ stripped = line.strip()
928
+ if not stripped or stripped.startswith(("//", "/*", "*")):
929
+ continue
930
+ if re.search(r"\b\w+Async\s*\([^)]*\)\s*;\s*$", stripped) and "await " not in stripped:
931
+ # Skip `return ...Async();` (forwarding the Task is legitimate)
932
+ if stripped.startswith("return "):
933
+ continue
934
+ smells.append({
935
+ "type": "csharp_missing_await",
936
+ "severity": "medium",
937
+ "message": "Async method called without 'await' — Task is discarded",
938
+ "location": f"line {line_no}",
939
+ })
940
+
941
+ # Unnecessary `using` directives — heuristic: `using` directive whose
942
+ # namespace tail isn't referenced anywhere else in the file.
943
+ using_directives = re.findall(
944
+ r"^using\s+(?:static\s+)?([A-Z]\w*(?:\.\w+)*)\s*;", content, re.MULTILINE
945
+ )
946
+ body = re.sub(r"^using\s+[^;]+;\s*$", "", content, flags=re.MULTILINE)
947
+ for ns in using_directives:
948
+ tail = ns.split(".")[-1]
949
+ if not re.search(rf"\b{re.escape(tail)}\b", body):
950
+ smells.append({
951
+ "type": "csharp_unused_using",
952
+ "severity": "low",
953
+ "message": f"'using {ns};' appears unused",
954
+ "location": ns,
955
+ })
956
+
957
+ return smells
958
+
959
+
960
+ def check_java_specific_smells(content: str) -> List[Dict]:
961
+ """Java-specific code smells documented in languages/java.md."""
962
+ smells: List[Dict] = []
963
+ # Java comment syntax matches C#, so the same stripper applies.
964
+ content = _strip_csharp_comments(content)
965
+
966
+ # Empty catch block — swallows the exception silently.
967
+ for match in re.finditer(r"catch\s*\([^)]*\)\s*\{\s*\}", content):
968
+ smells.append({
969
+ "type": "java_empty_catch",
970
+ "severity": "high",
971
+ "message": "Empty catch block swallows exceptions silently",
972
+ "location": f"offset {match.start()}",
973
+ })
974
+
975
+ # printStackTrace() as error handling — use a logger instead.
976
+ for match in re.finditer(r"\.printStackTrace\s*\(\s*\)", content):
977
+ smells.append({
978
+ "type": "java_print_stack_trace",
979
+ "severity": "medium",
980
+ "message": (
981
+ "'printStackTrace()' is not real error handling — log via a "
982
+ "proper logger or rethrow with context"
983
+ ),
984
+ "location": f"offset {match.start()}",
985
+ })
986
+
987
+ # InterruptedException caught without restoring the interrupt flag.
988
+ for match in re.finditer(
989
+ r"catch\s*\(\s*InterruptedException\s+(\w+)\s*\)\s*\{(.*?)\}",
990
+ content,
991
+ re.DOTALL,
992
+ ):
993
+ if "interrupt()" not in match.group(2):
994
+ smells.append({
995
+ "type": "java_swallowed_interrupt",
996
+ "severity": "high",
997
+ "message": (
998
+ "InterruptedException caught without "
999
+ "'Thread.currentThread().interrupt()' — breaks cooperative "
1000
+ "cancellation"
1001
+ ),
1002
+ "location": f"offset {match.start()}",
1003
+ })
1004
+
1005
+ # Closeable resource instantiated outside try-with-resources (leak heuristic).
1006
+ resource_hint = re.compile(
1007
+ r"^(?!\s*try\b)\s*(?:final\s+)?[\w<>\[\]]+\s+\w+\s*=\s*new\s+"
1008
+ r"(\w*(?:InputStream|OutputStream|Reader|Writer|Stream|Connection))\s*\(",
1009
+ re.MULTILINE,
1010
+ )
1011
+ for match in resource_hint.finditer(content):
1012
+ smells.append({
1013
+ "type": "java_unclosed_resource",
1014
+ "severity": "medium",
1015
+ "message": (
1016
+ f"'{match.group(1)}' looks like an AutoCloseable but is not in a "
1017
+ "try-with-resources statement"
1018
+ ),
1019
+ "location": f"offset {match.start()}",
1020
+ })
1021
+
1022
+ # Heavy object built per use instead of shared as a singleton.
1023
+ # A `static` field assignment is the recommended singleton form — skip it.
1024
+ heavy_object = re.compile(
1025
+ r"^(?!.*\bstatic\b).*\bnew\s+(ObjectMapper|Gson)\s*\(\s*\)",
1026
+ re.MULTILINE,
1027
+ )
1028
+ for match in heavy_object.finditer(content):
1029
+ smells.append({
1030
+ "type": "java_per_use_heavy_object",
1031
+ "severity": "medium",
1032
+ "message": (
1033
+ f"'new {match.group(1)}()' is expensive — share a singleton "
1034
+ "instance instead of constructing per call"
1035
+ ),
1036
+ "location": f"offset {match.start()}",
1037
+ })
1038
+
1039
+ return smells
1040
+
1041
+
1042
+ def check_c_specific_smells(content: str) -> List[Dict]:
1043
+ """C-specific code smells documented in languages/c.md.
1044
+
1045
+ Focuses on memory-safety and command/format-string patterns that the
1046
+ CERT C Coding Standard and the CWE catalogue rank as the
1047
+ highest-impact footguns. C uses the same line/block comment syntax as
1048
+ C# and Java, so the existing comment stripper applies.
1049
+ """
1050
+ smells: List[Dict] = []
1051
+ content = _strip_csharp_comments(content)
1052
+
1053
+ # 1. Banned functions — no bounds check on any of them.
1054
+ banned = {
1055
+ "gets": "no bounds check, removed from C11 (CWE-242)",
1056
+ "strcpy": "no bounds check — prefer strncpy or strlcpy",
1057
+ "strcat": "no bounds check — prefer strncat or strlcat",
1058
+ "sprintf": "no bounds check — prefer snprintf",
1059
+ "vsprintf": "no bounds check — prefer vsnprintf",
1060
+ }
1061
+ for fn, reason in banned.items():
1062
+ for m in re.finditer(rf"\b{fn}\s*\(", content):
1063
+ smells.append({
1064
+ "type": f"c_banned_{fn}",
1065
+ "severity": "high",
1066
+ "message": f"'{fn}()' is unsafe: {reason}",
1067
+ "location": f"offset {m.start()}",
1068
+ })
1069
+
1070
+ # 2. Format-string vulnerability — printf/syslog called with a bare
1071
+ # identifier as the format argument (CWE-134). Skip when the first
1072
+ # arg is a string literal.
1073
+ for fn in ("printf", "syslog"):
1074
+ pattern = rf"\b{fn}\s*\(\s*(?!\")(\w+)\s*[,\)]"
1075
+ for m in re.finditer(pattern, content):
1076
+ smells.append({
1077
+ "type": "c_format_string",
1078
+ "severity": "high",
1079
+ "message": (
1080
+ f"'{fn}({m.group(1)})' uses a non-literal format string "
1081
+ "— CWE-134 format string vulnerability"
1082
+ ),
1083
+ "location": f"offset {m.start()}",
1084
+ })
1085
+
1086
+ # 3. Unbounded scanf — `%s` without a width specifier invites overflow.
1087
+ scanf_call = re.compile(
1088
+ r"\b(?:scanf|fscanf|sscanf)\s*\(\s*[^)]*?\"([^\"]*)\""
1089
+ )
1090
+ for m in scanf_call.finditer(content):
1091
+ fmt = m.group(1)
1092
+ if "%s" in fmt and not re.search(r"%\d+s", fmt):
1093
+ smells.append({
1094
+ "type": "c_unbounded_scanf",
1095
+ "severity": "high",
1096
+ "message": (
1097
+ "scanf '%s' without a width specifier — unbounded read "
1098
+ "can overflow the destination buffer"
1099
+ ),
1100
+ "location": f"offset {m.start()}",
1101
+ })
1102
+
1103
+ # 4. malloc / calloc / realloc result dereferenced without a NULL check
1104
+ # within 5 lines. CWE-690.
1105
+ lines = content.split("\n")
1106
+ malloc_assign = re.compile(
1107
+ r"^\s*(?:[\w\*]+\s+)?\*?(\w+)\s*=\s*\(?[\w\s\*]*\)?\s*"
1108
+ r"(?:m|c|re)alloc\s*\("
1109
+ )
1110
+ for i, line in enumerate(lines):
1111
+ m = malloc_assign.match(line)
1112
+ if not m:
1113
+ continue
1114
+ var = m.group(1)
1115
+ window = "\n".join(lines[i + 1 : i + 6])
1116
+ null_check = re.compile(
1117
+ rf"\bif\s*\([^)]*(?:{re.escape(var)}\s*==\s*NULL"
1118
+ rf"|NULL\s*==\s*{re.escape(var)}"
1119
+ rf"|!\s*{re.escape(var)}\b"
1120
+ rf"|{re.escape(var)}\s*!=\s*NULL)"
1121
+ )
1122
+ if not null_check.search(window):
1123
+ smells.append({
1124
+ "type": "c_malloc_unchecked",
1125
+ "severity": "medium",
1126
+ "message": (
1127
+ f"'{var}' from malloc/calloc/realloc is not NULL-checked "
1128
+ "within 5 lines — dereferencing NULL is UB (CWE-690)"
1129
+ ),
1130
+ "location": f"line {i + 1}",
1131
+ })
1132
+
1133
+ # 5. free(p) without setting p to NULL on the next real line.
1134
+ # CWE-416 use-after-free guardrail.
1135
+ free_call = re.compile(r"^\s*free\s*\(\s*(\w+)\s*\)\s*;")
1136
+ for i, line in enumerate(lines):
1137
+ m = free_call.match(line)
1138
+ if not m:
1139
+ continue
1140
+ var = m.group(1)
1141
+ for j in range(i + 1, min(i + 3, len(lines))):
1142
+ nxt = lines[j].strip()
1143
+ if not nxt:
1144
+ continue
1145
+ if re.match(rf"^{re.escape(var)}\s*=\s*NULL\s*;", nxt):
1146
+ break
1147
+ smells.append({
1148
+ "type": "c_free_without_null",
1149
+ "severity": "low",
1150
+ "message": (
1151
+ f"'free({var})' not followed by '{var} = NULL;' — "
1152
+ "dangling pointer can be reused (CWE-416)"
1153
+ ),
1154
+ "location": f"line {i + 1}",
1155
+ })
1156
+ break
1157
+
1158
+ # 6. system() with a non-string-literal argument — command injection.
1159
+ system_pattern = re.compile(r"\bsystem\s*\(\s*(?!\"|NULL\b)(\w+)\s*\)")
1160
+ for m in system_pattern.finditer(content):
1161
+ smells.append({
1162
+ "type": "c_system_non_literal",
1163
+ "severity": "high",
1164
+ "message": (
1165
+ f"'system({m.group(1)})' with a non-literal argument — "
1166
+ "command injection (CWE-78); use execve with validated args"
1167
+ ),
1168
+ "location": f"offset {m.start()}",
1169
+ })
1170
+
1171
+ return smells
1172
+
1173
+
1174
+ def check_solid_violations(content: str) -> List[Dict]:
1175
+ """Check for potential SOLID principle violations."""
1176
+ violations = []
1177
+
1178
+ # OCP: Type checking instead of polymorphism
1179
+ type_checks = len(re.findall(r"isinstance\(|type\(.*\)\s*==|typeof\s+\w+\s*===", content))
1180
+ if type_checks > 2:
1181
+ violations.append({
1182
+ "principle": "OCP",
1183
+ "name": "Open/Closed Principle",
1184
+ "severity": "medium",
1185
+ "message": f"Found {type_checks} type checks - consider using polymorphism"
1186
+ })
1187
+
1188
+ # LSP/ISP: NotImplementedError
1189
+ not_impl = len(re.findall(r"raise\s+NotImplementedError|not\s+implemented", content, re.IGNORECASE))
1190
+ if not_impl:
1191
+ violations.append({
1192
+ "principle": "LSP/ISP",
1193
+ "name": "Liskov/Interface Segregation",
1194
+ "severity": "low",
1195
+ "message": f"Found {not_impl} unimplemented methods - may indicate oversized interface"
1196
+ })
1197
+
1198
+ # DIP: Too many direct imports
1199
+ imports = len(re.findall(r"^(?:import|from)\s+", content, re.MULTILINE))
1200
+ if imports > THRESHOLDS["max_imports"]:
1201
+ violations.append({
1202
+ "principle": "DIP",
1203
+ "name": "Dependency Inversion Principle",
1204
+ "severity": "low",
1205
+ "message": f"File has {imports} imports - consider dependency injection"
1206
+ })
1207
+
1208
+ return violations
1209
+
1210
+
1211
+ def calculate_quality_score(
1212
+ line_metrics: Dict,
1213
+ functions: List[Dict],
1214
+ classes: List[Dict],
1215
+ smells: List[Dict],
1216
+ violations: List[Dict]
1217
+ ) -> int:
1218
+ """Calculate overall quality score (0-100)."""
1219
+ score = 100
1220
+
1221
+ # Deduct for code smells
1222
+ for smell in smells:
1223
+ if smell["severity"] == "high":
1224
+ score -= 10
1225
+ elif smell["severity"] == "medium":
1226
+ score -= 5
1227
+ elif smell["severity"] == "low":
1228
+ score -= 2
1229
+
1230
+ # Deduct for SOLID violations
1231
+ for violation in violations:
1232
+ if violation["severity"] == "high":
1233
+ score -= 8
1234
+ elif violation["severity"] == "medium":
1235
+ score -= 4
1236
+ elif violation["severity"] == "low":
1237
+ score -= 2
1238
+
1239
+ # Bonus for good comment ratio (10-30%)
1240
+ if line_metrics["total"] > 0:
1241
+ comment_ratio = line_metrics["comment"] / line_metrics["total"]
1242
+ if 0.1 <= comment_ratio <= 0.3:
1243
+ score += 5
1244
+
1245
+ # Bonus for reasonable function sizes
1246
+ if functions:
1247
+ avg_lines = sum(f["lines"] for f in functions) / len(functions)
1248
+ if avg_lines < 30:
1249
+ score += 5
1250
+
1251
+ return max(0, min(100, score))
1252
+
1253
+
1254
+ def get_grade(score: int) -> str:
1255
+ """Convert score to letter grade."""
1256
+ if score >= 90:
1257
+ return "A"
1258
+ elif score >= 80:
1259
+ return "B"
1260
+ elif score >= 70:
1261
+ return "C"
1262
+ elif score >= 60:
1263
+ return "D"
1264
+ else:
1265
+ return "F"
1266
+
1267
+
1268
+ def display_path(path: Path) -> str:
1269
+ """The form a path takes in emitted output: relative to the working directory.
1270
+
1271
+ Input is resolved to an absolute path so filesystem checks are unambiguous, but an
1272
+ absolute path in the *output* is machine-specific. That has two costs: committed
1273
+ fixtures under expected_outputs/ can only ever match the machine that produced them,
1274
+ and findings here cannot be matched against pr_analyzer.py's git-relative paths when
1275
+ review_report_generator.py merges both into one report.
1276
+
1277
+ os.path.relpath rather than Path.relative_to: the latter raises ValueError for a
1278
+ target outside the working directory, which is a supported input here.
1279
+ """
1280
+ return os.path.relpath(str(path), os.getcwd())
1281
+
1282
+
1283
+ def check_shell_specific_smells(content: str) -> List[Dict]:
1284
+ """Shell has real control flow, so it is analysed as code — but its failure modes are its own."""
1285
+ smells = []
1286
+ lines = content.split("\n")
1287
+
1288
+ if lines and lines[0].startswith("#!") and "set -" not in content:
1289
+ smells.append({
1290
+ "type": "no_error_handling",
1291
+ "severity": "high",
1292
+ "message": "Script has no 'set -e'/'set -u'/'set -o pipefail' — a failing command is "
1293
+ "silently ignored and the script continues with bad state",
1294
+ "location": "line 1",
1295
+ })
1296
+
1297
+ for i, line in enumerate(lines, 1):
1298
+ stripped = line.strip()
1299
+ if stripped.startswith("#"):
1300
+ continue
1301
+ # Unquoted expansion of a variable that may hold spaces or globs.
1302
+ if re.search(r"(?<![\"'\w])\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?![\"'\w])", stripped) and re.search(
1303
+ r"^\s*(?:rm|cp|mv|cat|source|\.|cd|mkdir|test|\[)\s", stripped
1304
+ ):
1305
+ if '"' not in stripped:
1306
+ smells.append({
1307
+ "type": "unquoted_expansion",
1308
+ "severity": "high",
1309
+ "message": "Unquoted variable expansion in a command that acts on paths — a "
1310
+ "value containing spaces or globs will not do what it looks like",
1311
+ "location": f"line {i}",
1312
+ })
1313
+ if re.search(r"\bcd\s+[^&|;]+$", stripped) and "||" not in stripped and "set -e" not in content:
1314
+ smells.append({
1315
+ "type": "unchecked_cd",
1316
+ "severity": "medium",
1317
+ "message": "'cd' without '|| exit' or 'set -e' — the rest of the script runs in the "
1318
+ "wrong directory if it fails",
1319
+ "location": f"line {i}",
1320
+ })
1321
+
1322
+ return smells
1323
+
1324
+
1325
+ def analyze_declarative(content: str, language: str) -> Dict:
1326
+ """
1327
+ Structural analysis for YAML and JSON.
1328
+
1329
+ Reports parse validity, nesting depth, duplicate keys and plain-text secrets. Deliberately
1330
+ reports no cyclomatic complexity, no SOLID verdict and no function count: those readings do not
1331
+ exist for declarative data, and inventing them is exactly the false-confidence defect this
1332
+ coverage work removes.
1333
+ """
1334
+ smells = []
1335
+ lines = content.split("\n")
1336
+
1337
+ parse_error = None
1338
+ if language == "json":
1339
+ try:
1340
+ json.loads(content)
1341
+ except ValueError as exc:
1342
+ parse_error = str(exc)
1343
+ else:
1344
+ seen_at_indent = {}
1345
+ for i, line in enumerate(lines, 1):
1346
+ match = re.match(r"^(\s*)([A-Za-z_][\w.-]*):", line)
1347
+ if not match:
1348
+ continue
1349
+ indent, key = len(match.group(1)), match.group(2)
1350
+ bucket = seen_at_indent.setdefault(indent, {})
1351
+ if key in bucket:
1352
+ smells.append({
1353
+ "type": "duplicate_key",
1354
+ "severity": "high",
1355
+ "message": f"Key '{key}' is defined twice at the same level — the later value "
1356
+ f"silently wins (first seen at line {bucket[key]})",
1357
+ "location": f"line {i}",
1358
+ })
1359
+ else:
1360
+ bucket[key] = i
1361
+
1362
+ if parse_error:
1363
+ smells.append({
1364
+ "type": "parse_error",
1365
+ "severity": "high",
1366
+ "message": f"File does not parse as {language}: {parse_error}",
1367
+ "location": "line 1",
1368
+ })
1369
+
1370
+ max_depth = 0
1371
+ for line in lines:
1372
+ if not line.strip() or line.strip().startswith("#"):
1373
+ continue
1374
+ indent = len(line) - len(line.lstrip())
1375
+ max_depth = max(max_depth, indent // 2)
1376
+ if max_depth > THRESHOLDS["max_declarative_depth"]:
1377
+ smells.append({
1378
+ "type": "deep_nesting",
1379
+ "severity": "medium",
1380
+ "message": f"Nesting reaches depth {max_depth} (threshold "
1381
+ f"{THRESHOLDS['max_declarative_depth']}) — deeply nested config is hard to "
1382
+ f"review and easy to mis-indent",
1383
+ "location": "file",
1384
+ })
1385
+
1386
+ for i, line in enumerate(lines, 1):
1387
+ if re.search(r"(?i)\b(password|secret|token|api[_-]?key|private[_-]?key)\b\s*[:=]\s*['\"]?[^\s'\"{}$]{8,}", line):
1388
+ smells.append({
1389
+ "type": "plaintext_secret",
1390
+ "severity": "high",
1391
+ "message": "A credential-shaped key carries a literal value — secrets belong in a "
1392
+ "secret store, not in a committed config file",
1393
+ "location": f"line {i}",
1394
+ })
1395
+
1396
+ return {"smells": smells, "max_depth": max_depth, "parses": parse_error is None}
1397
+
1398
+
1399
+ def analyze_file(filepath: Path) -> Dict:
1400
+ """Analyze a single file for code quality."""
1401
+ language = detect_language(filepath)
1402
+ if not language:
1403
+ return {"error": f"Unsupported file type: {filepath.suffix}"}
1404
+
1405
+ content = read_file_content(filepath)
1406
+ if not content:
1407
+ return {"error": f"Could not read file: {filepath}"}
1408
+
1409
+ if language in DECLARATIVE_LANGUAGES:
1410
+ line_metrics = count_lines(content)
1411
+ declarative = analyze_declarative(content, language)
1412
+ smells = declarative["smells"]
1413
+ # Scored on the same 100-point scale so a mixed change compares, but from structural
1414
+ # findings only — there is no complexity or SOLID term to include here.
1415
+ score = max(0, 100 - sum(
1416
+ 15 if sm["severity"] == "high" else 8 if sm["severity"] == "medium" else 3
1417
+ for sm in smells
1418
+ ))
1419
+ return {
1420
+ "file": display_path(filepath),
1421
+ "language": language,
1422
+ "analysis_kind": "declarative",
1423
+ "metrics": {
1424
+ "lines": line_metrics,
1425
+ "max_depth": declarative["max_depth"],
1426
+ "parses": declarative["parses"],
1427
+ },
1428
+ "smells": smells,
1429
+ "solid_violations": [],
1430
+ "quality_score": score,
1431
+ "grade": get_grade(score),
1432
+ }
1433
+
1434
+ line_metrics = count_lines(content)
1435
+ functions = find_functions(content, language)
1436
+ classes = find_classes(content, language)
1437
+ smells = check_code_smells(content, functions, classes)
1438
+ if language == "csharp":
1439
+ smells.extend(check_csharp_specific_smells(content))
1440
+ if language == "java":
1441
+ smells.extend(check_java_specific_smells(content))
1442
+ if language == "c":
1443
+ smells.extend(check_c_specific_smells(content))
1444
+ if language == "shell":
1445
+ smells.extend(check_shell_specific_smells(content))
1446
+ violations = check_solid_violations(content)
1447
+ score = calculate_quality_score(line_metrics, functions, classes, smells, violations)
1448
+
1449
+ return {
1450
+ "file": display_path(filepath),
1451
+ "language": language,
1452
+ "metrics": {
1453
+ "lines": line_metrics,
1454
+ "functions": len(functions),
1455
+ "classes": len(classes),
1456
+ "avg_complexity": round(sum(f["complexity"] for f in functions) / max(1, len(functions)), 1)
1457
+ },
1458
+ "quality_score": score,
1459
+ "grade": get_grade(score),
1460
+ "smells": smells,
1461
+ "solid_violations": violations,
1462
+ "function_details": functions[:10],
1463
+ "class_details": classes[:10]
1464
+ }
1465
+
1466
+
1467
+ def analyze_directory(
1468
+ dir_path: Path,
1469
+ recursive: bool = True,
1470
+ language: Optional[str] = None
1471
+ ) -> Dict:
1472
+ """
1473
+ Analyze all files in a directory, reporting what was NOT analysed as prominently as what was.
1474
+
1475
+ This function used to enumerate only the extensions it recognised and then drop any per-file
1476
+ error before averaging (`if "error" not in result`). A change of nineteen OpenAPI files and two
1477
+ TypeScript files therefore reported "Files Analyzed: 2" with a score computed from the two, and
1478
+ the nineteen appeared nowhere. The skill's own Review Contract already forbids that — "a file
1479
+ the derivation includes but the review never opened is reported as not reviewed; it is never
1480
+ dropped from the list" — so this is the deterministic tool being made to obey a policy the
1481
+ bundle already publishes, not a new policy.
1482
+
1483
+ Every file the walk sees ends up in exactly one of `files` or `skipped`. `coverage` is
1484
+ "complete" only when nothing was skipped.
1485
+ """
1486
+ results = []
1487
+ skipped = []
1488
+
1489
+ pattern = "**/*" if recursive else "*"
1490
+ selected_extensions = set(LANGUAGE_EXTENSIONS.get(language, [])) if language else None
1491
+
1492
+ for filepath in sorted(dir_path.glob(pattern)):
1493
+ if not filepath.is_file():
1494
+ continue
1495
+ parts = filepath.parts
1496
+ if "node_modules" in parts or ".git" in parts:
1497
+ continue
1498
+
1499
+ detected = detect_language(filepath)
1500
+ if selected_extensions is not None and get_file_extension(filepath) not in selected_extensions:
1501
+ # An explicit --language narrowed the scope. Still reported: the caller chose it, but a
1502
+ # reader of the report did not, and a narrowed run that looks like a full one is the
1503
+ # same false confidence in a different costume.
1504
+ skipped.append({"file": display_path(filepath), "reason": f"excluded by --language {language}"})
1505
+ continue
1506
+ if not detected:
1507
+ skipped.append({
1508
+ "file": display_path(filepath),
1509
+ "reason": f"no analyser for '{filepath.suffix or filepath.name}'",
1510
+ })
1511
+ continue
1512
+
1513
+ result = analyze_file(filepath)
1514
+ if "error" in result:
1515
+ skipped.append({"file": display_path(filepath), "reason": result["error"]})
1516
+ continue
1517
+ results.append(result)
1518
+
1519
+ skipped_by_reason: Dict[str, int] = {}
1520
+ for item in skipped:
1521
+ skipped_by_reason[item["reason"]] = skipped_by_reason.get(item["reason"], 0) + 1
1522
+
1523
+ base = {
1524
+ "directory": display_path(dir_path),
1525
+ "files_analyzed": len(results),
1526
+ "files_skipped": len(skipped),
1527
+ "coverage": "complete" if not skipped else "partial",
1528
+ "skipped": skipped,
1529
+ "skipped_by_reason": skipped_by_reason,
1530
+ }
1531
+
1532
+ if not results:
1533
+ # Previously an error string, which discarded the skipped list with it — the caller was told
1534
+ # "No supported files found" and could not see WHICH files those were.
1535
+ return {
1536
+ **base,
1537
+ "average_score": None,
1538
+ "overall_grade": None,
1539
+ "total_code_smells": 0,
1540
+ "total_solid_violations": 0,
1541
+ "files": [],
1542
+ }
1543
+
1544
+ total_score = sum(r["quality_score"] for r in results)
1545
+ avg_score = total_score / len(results)
1546
+
1547
+ return {
1548
+ **base,
1549
+ "average_score": round(avg_score, 1),
1550
+ "overall_grade": get_grade(int(avg_score)),
1551
+ "total_code_smells": sum(len(r["smells"]) for r in results),
1552
+ "total_solid_violations": sum(len(r["solid_violations"]) for r in results),
1553
+ "files": sorted(results, key=lambda x: x["quality_score"]),
1554
+ }
1555
+
1556
+
1557
+ def print_report(analysis: Dict) -> None:
1558
+ """Print human-readable analysis report."""
1559
+ if "error" in analysis:
1560
+ print(f"Error: {analysis['error']}")
1561
+ return
1562
+
1563
+ print("=" * 60)
1564
+ print("CODE QUALITY REPORT")
1565
+ print("=" * 60)
1566
+
1567
+ if "file" in analysis:
1568
+ print(f"\nFile: {analysis['file']}")
1569
+ print(f"Language: {analysis['language']}")
1570
+ print(f"Quality Score: {analysis['quality_score']}/100 ({analysis['grade']})")
1571
+
1572
+ metrics = analysis["metrics"]
1573
+ print(f"\nLines: {metrics['lines']['total']} ({metrics['lines']['code']} code, {metrics['lines']['comment']} comments)")
1574
+ if analysis.get("analysis_kind") == "declarative":
1575
+ # No function, class or complexity line here on purpose: those readings do not exist for
1576
+ # declarative data, and printing a zero would read as "analysed and found none".
1577
+ print(f"Max nesting depth: {metrics['max_depth']}")
1578
+ print(f"Parses: {'yes' if metrics['parses'] else 'no'}")
1579
+ print("Analysed as declarative structure — complexity and SOLID do not apply.")
1580
+ else:
1581
+ print(f"Functions: {metrics['functions']}")
1582
+ print(f"Classes: {metrics['classes']}")
1583
+ print(f"Avg Complexity: {metrics['avg_complexity']}")
1584
+
1585
+ if analysis["smells"]:
1586
+ print("\n--- CODE SMELLS ---")
1587
+ for smell in analysis["smells"][:10]:
1588
+ print(f" [{smell['severity'].upper()}] {smell['message']} ({smell['location']})")
1589
+
1590
+ if analysis["solid_violations"]:
1591
+ print("\n--- SOLID VIOLATIONS ---")
1592
+ for v in analysis["solid_violations"]:
1593
+ print(f" [{v['principle']}] {v['message']}")
1594
+ else:
1595
+ print(f"\nDirectory: {analysis['directory']}")
1596
+ print(f"Files Analyzed: {analysis['files_analyzed']}")
1597
+ print(f"Files Skipped: {analysis['files_skipped']}")
1598
+ if analysis["average_score"] is None:
1599
+ print("Average Score: n/a — nothing in this directory was analysed")
1600
+ else:
1601
+ print(f"Average Score: {analysis['average_score']}/100 ({analysis['overall_grade']})"
1602
+ f" — over the {analysis['files_analyzed']} analysed file(s) only")
1603
+ print(f"Total Code Smells: {analysis['total_code_smells']}")
1604
+ print(f"Total SOLID Violations: {analysis['total_solid_violations']}")
1605
+
1606
+ # The coverage line comes before the findings, not after: a reader who stops at the score
1607
+ # must already have been told what the score does not cover.
1608
+ if analysis["coverage"] == "partial":
1609
+ print(f"\n*** COVERAGE: PARTIAL — {analysis['files_skipped']} file(s) were not analysed. ***")
1610
+ print("*** This verdict describes the analysed files only, not the whole change. ***")
1611
+ else:
1612
+ print("\nCoverage: complete — every file in scope was analysed.")
1613
+
1614
+ if analysis["files"]:
1615
+ print("\n--- FILES BY QUALITY ---")
1616
+ for f in analysis["files"][:10]:
1617
+ print(f" {f['quality_score']:3d}/100 [{f['grade']}] {f['file']}")
1618
+
1619
+ if analysis["skipped"]:
1620
+ print("\n--- SKIPPED (not analysed) ---")
1621
+ for reason, count in sorted(analysis["skipped_by_reason"].items(), key=lambda kv: -kv[1]):
1622
+ print(f" {count:3d} x {reason}")
1623
+ for item in analysis["skipped"][:20]:
1624
+ print(f" {item['file']} — {item['reason']}")
1625
+ if len(analysis["skipped"]) > 20:
1626
+ print(f" ... and {len(analysis['skipped']) - 20} more (use --json for the full list)")
1627
+
1628
+ print("\n" + "=" * 60)
1629
+
1630
+
1631
+ def main():
1632
+ parser = argparse.ArgumentParser(
1633
+ description="Analyze code quality, smells, and SOLID violations"
1634
+ )
1635
+ parser.add_argument(
1636
+ "path",
1637
+ help="File or directory to analyze"
1638
+ )
1639
+ parser.add_argument(
1640
+ "--recursive", "-r",
1641
+ action="store_true",
1642
+ default=True,
1643
+ help="Recursively analyze directories (default: true)"
1644
+ )
1645
+ parser.add_argument(
1646
+ "--language", "-l",
1647
+ choices=list(LANGUAGE_EXTENSIONS.keys()),
1648
+ help="Filter by programming language"
1649
+ )
1650
+ parser.add_argument(
1651
+ "--json",
1652
+ action="store_true",
1653
+ help="Output in JSON format"
1654
+ )
1655
+ parser.add_argument(
1656
+ "--output", "-o",
1657
+ help="Write output to file"
1658
+ )
1659
+
1660
+ args = parser.parse_args()
1661
+
1662
+ target = Path(args.path).resolve()
1663
+
1664
+ if not target.exists():
1665
+ print(f"Error: Path does not exist: {target}", file=sys.stderr)
1666
+ sys.exit(1)
1667
+
1668
+ # Resolve the policy before any analysis runs, and write the merged thresholds back into
1669
+ # THRESHOLDS so every check reads the resolved values where it already read the dict (IC-004).
1670
+ policy, policy_tiers = load_policy(target)
1671
+ THRESHOLDS.update(policy["thresholds"])
1672
+
1673
+ if target.is_file():
1674
+ analysis = analyze_file(target)
1675
+ else:
1676
+ analysis = analyze_directory(target, args.recursive, args.language)
1677
+
1678
+ # IC-005: every report states the policy it applied, so a surprising finding is traceable to the
1679
+ # tier that produced it rather than guessed at.
1680
+ analysis["policy"] = {"tiers": policy_tiers, "thresholds": dict(policy["thresholds"])}
1681
+
1682
+ if args.json:
1683
+ output = json.dumps(analysis, indent=2, default=str)
1684
+ if args.output:
1685
+ with open(args.output, "w") as f:
1686
+ f.write(output)
1687
+ print(f"Results written to {args.output}")
1688
+ else:
1689
+ print(output)
1690
+ else:
1691
+ print(f"Policy tiers applied: {' -> '.join(policy_tiers)}")
1692
+ print_report(analysis)
1693
+
1694
+
1695
+ if __name__ == "__main__":
1696
+ main()