savepoint 1.0.1 → 1.0.3

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 (278) hide show
  1. package/.claude/settings.local.json +15 -1
  2. package/.golangci.yml +11 -0
  3. package/.savepoint/Design.md +52 -46
  4. package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
  5. package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
  6. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
  7. package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
  8. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
  9. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
  10. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
  11. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
  12. package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
  13. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
  14. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
  15. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
  16. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
  17. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
  18. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
  19. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
  20. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
  21. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md +124 -0
  22. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
  23. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
  24. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
  25. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
  26. package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
  27. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  28. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +45 -0
  29. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
  30. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
  31. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
  32. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
  33. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
  34. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Audit.md +167 -0
  35. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
  36. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
  37. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
  38. package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
  39. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  40. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +54 -0
  41. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +45 -0
  42. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +40 -0
  43. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +47 -0
  44. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +98 -0
  45. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +33 -0
  46. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +62 -0
  47. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  48. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  49. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  50. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  51. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  52. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  53. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  54. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  55. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  56. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  57. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  58. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  59. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  60. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  61. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  62. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  63. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  64. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  65. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  66. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  67. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  68. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  69. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  70. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  71. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  72. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  73. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  74. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  75. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  76. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  77. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  78. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  79. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  80. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  81. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  82. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  83. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  84. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  85. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  86. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  87. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  88. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  89. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  90. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  91. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  92. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  93. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  94. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  95. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  96. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  97. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  98. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  99. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  100. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  101. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  102. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  103. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  104. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  105. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  106. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  107. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  108. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  109. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  110. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  111. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  112. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  113. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  114. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
  115. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  116. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
  117. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  118. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  119. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  120. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  121. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
  122. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  123. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  124. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  125. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  126. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  127. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  128. package/.savepoint/releases/v1.1/v1.1-PRD.md +139 -0
  129. package/.savepoint/router.md +29 -108
  130. package/AGENTS.md +69 -111
  131. package/Makefile +19 -3
  132. package/README.md +6 -6
  133. package/agent-skills/savepoint-audit/SKILL.md +87 -35
  134. package/agent-skills/savepoint-build-task/SKILL.md +9 -4
  135. package/agent-skills/savepoint-create-plan/SKILL.md +10 -5
  136. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  137. package/agent-skills/savepoint-draft-prd/SKILL.md +8 -3
  138. package/agent-skills/savepoint-system-design/SKILL.md +8 -3
  139. package/agent_skills_test.go +91 -0
  140. package/cmd/board.go +59 -0
  141. package/cmd/board_test.go +137 -0
  142. package/cmd/doctor.go +53 -0
  143. package/cmd/doctor_test.go +146 -0
  144. package/cmd/init.go +63 -0
  145. package/cmd/init_test.go +104 -0
  146. package/internal/board/board.go +69 -49
  147. package/internal/board/board_test.go +83 -67
  148. package/internal/board/card.go +71 -20
  149. package/internal/board/card_test.go +141 -12
  150. package/internal/board/column.go +77 -11
  151. package/internal/board/column_test.go +63 -13
  152. package/internal/board/detail.go +107 -72
  153. package/internal/board/detail_test.go +117 -26
  154. package/internal/board/epic_panel.go +211 -18
  155. package/internal/board/epic_panel_test.go +637 -14
  156. package/internal/board/help.go +1 -0
  157. package/internal/board/help_test.go +1 -0
  158. package/internal/board/integration_test.go +266 -0
  159. package/internal/board/interfaces.go +65 -0
  160. package/internal/board/interfaces_test.go +114 -0
  161. package/internal/board/io.go +93 -0
  162. package/internal/board/layout.go +12 -2
  163. package/internal/board/layout_test.go +17 -0
  164. package/internal/board/model.go +130 -52
  165. package/internal/board/plain.go +88 -0
  166. package/internal/board/plain_test.go +117 -0
  167. package/internal/board/release.go +1 -9
  168. package/internal/board/release_test.go +6 -6
  169. package/internal/board/render_policy_test.go +77 -0
  170. package/internal/board/status.go +23 -0
  171. package/internal/board/theme.go +24 -0
  172. package/internal/board/theme_test.go +31 -0
  173. package/internal/board/transitions.go +113 -88
  174. package/internal/board/transitions_test.go +164 -141
  175. package/internal/board/tui.go +32 -0
  176. package/internal/board/update.go +472 -94
  177. package/internal/board/update_test.go +447 -0
  178. package/internal/board/util.go +76 -0
  179. package/internal/board/view.go +139 -22
  180. package/internal/board/view_test.go +171 -3
  181. package/internal/board/watch.go +57 -9
  182. package/internal/buildtool/main.go +211 -0
  183. package/internal/buildtool/main_test.go +46 -0
  184. package/internal/data/config.go +17 -3
  185. package/internal/data/config_test.go +49 -0
  186. package/internal/data/discover.go +26 -0
  187. package/internal/data/discover_test.go +34 -10
  188. package/internal/data/errors.go +4 -0
  189. package/internal/data/lifecycle.go +13 -6
  190. package/internal/data/lifecycle_test.go +14 -11
  191. package/internal/data/parser.go +29 -6
  192. package/internal/data/parser_test.go +66 -7
  193. package/internal/data/task.go +1 -0
  194. package/internal/data/write.go +85 -11
  195. package/internal/data/write_test.go +167 -0
  196. package/internal/doctor/checks.go +567 -0
  197. package/internal/doctor/checks_test.go +716 -0
  198. package/internal/doctor/gates.go +193 -0
  199. package/internal/doctor/gates_test.go +166 -0
  200. package/internal/doctor/interfaces.go +64 -0
  201. package/internal/doctor/interfaces_test.go +104 -0
  202. package/internal/doctor/repairs.go +80 -0
  203. package/internal/doctor/repairs_test.go +81 -0
  204. package/internal/doctor/report.go +157 -0
  205. package/internal/doctor/report_test.go +89 -0
  206. package/internal/init/clipboard.go +146 -0
  207. package/internal/init/clipboard_test.go +74 -0
  208. package/internal/init/install.go +16 -0
  209. package/internal/init/integration_test.go +197 -0
  210. package/internal/init/prompt.go +14 -0
  211. package/internal/init/prompt_test.go +77 -0
  212. package/internal/init/scaffold.go +59 -0
  213. package/internal/init/scaffold_test.go +179 -0
  214. package/internal/init/template_freshness_test.go +56 -0
  215. package/internal/init/validate.go +85 -0
  216. package/internal/init/validate_test.go +141 -0
  217. package/internal/init/write.go +73 -0
  218. package/internal/init/write_test.go +91 -0
  219. package/internal/styles/palette.go +3 -3
  220. package/internal/styles/styles.go +39 -12
  221. package/internal/styles/styles_test.go +133 -0
  222. package/internal/testutil/fixture.go +113 -0
  223. package/internal/testutil/fs.go +26 -0
  224. package/main.go +107 -1
  225. package/package.json +2 -2
  226. package/project-audit/audit_report_glm_5.1.md +411 -0
  227. package/project-audit/audit_report_opus_4.6 +406 -0
  228. package/project-audit/consolidated-audit-report.md +456 -0
  229. package/savepoint +0 -0
  230. package/templates/project/.savepoint/Design.md +2 -2
  231. package/templates/project/.savepoint/router.md +15 -14
  232. package/templates/project/AGENTS.md +56 -98
  233. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  234. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  235. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  236. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  237. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  238. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  239. package/templates/prompts/audit-reconciliation.prompt.md +35 -30
  240. package/templates/prompts/design.prompt.md +3 -1
  241. package/templates/prompts/epic-design.prompt.md +3 -3
  242. package/templates/prompts/task-breakdown.prompt.md +1 -1
  243. package/templates/prompts/task-building.prompt.md +1 -1
  244. package/templates/prompts/task-planning.prompt.md +1 -1
  245. package/.savepoint/audit/E01-go-setup/proposals.md +0 -166
  246. package/.savepoint/audit/E01-go-setup/snapshot.md +0 -71
  247. package/.savepoint/audit/E01-scaffolding/proposals/AGENTS.md +0 -66
  248. package/.savepoint/audit/E01-scaffolding/proposals/Design.md +0 -210
  249. package/.savepoint/audit/E01-scaffolding/proposals/epic-Design.md +0 -117
  250. package/.savepoint/audit/E01-scaffolding/proposals/quality-review.md +0 -101
  251. package/.savepoint/audit/E01-scaffolding/snapshot.md +0 -54
  252. package/.savepoint/audit/E02-data-model/snapshot.md +0 -128
  253. package/.savepoint/audit/E02-data-readers/proposals.md +0 -123
  254. package/.savepoint/audit/E02-data-readers/snapshot.md +0 -54
  255. package/.savepoint/audit/E03-board-tui-core/proposals.md +0 -146
  256. package/.savepoint/audit/E03-board-tui-core/snapshot.md +0 -57
  257. package/.savepoint/audit/E03-cli-foundation/snapshot.md +0 -106
  258. package/.savepoint/audit/E04-board-components/proposals.md +0 -118
  259. package/.savepoint/audit/E04-board-components/snapshot.md +0 -77
  260. package/.savepoint/audit/E04-templates-and-prompts/snapshot.md +0 -115
  261. package/.savepoint/audit/E05-init-command/snapshot.md +0 -125
  262. package/.savepoint/audit/E05-phase-transitions/proposals.md +0 -83
  263. package/.savepoint/audit/E05-phase-transitions/snapshot.md +0 -36
  264. package/.savepoint/audit/E06-atari-noir-layout/proposals.md +0 -130
  265. package/.savepoint/audit/E06-atari-noir-layout/snapshot.md +0 -84
  266. package/.savepoint/audit/E06-tui-board/snapshot.md +0 -64
  267. package/.savepoint/audit/E07-audit-pipeline/snapshot.md +0 -165
  268. package/.savepoint/audit/E08-board-workflow-cleanup/snapshot.md +0 -65
  269. package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
  270. package/ink-cli-ui-design.zip +0 -0
  271. package/main.exe +0 -0
  272. package/savepoint.exe +0 -0
  273. /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
  274. /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
  275. /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
  276. /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
  277. /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
  278. /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
@@ -0,0 +1,716 @@
1
+ package doctor
2
+
3
+ import (
4
+ "fmt"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/opencode/savepoint/internal/testutil"
10
+ )
11
+
12
+ // --- CheckConfig ---
13
+
14
+ func TestCheckConfigMissing(t *testing.T) {
15
+ root := t.TempDir()
16
+ err := CheckConfig(root)
17
+ if err == nil || !strings.Contains(err.Error(), "not found") {
18
+ t.Fatalf("CheckConfig() = %v, want not found error", err)
19
+ }
20
+ }
21
+
22
+ func TestCheckConfigInvalidYAML(t *testing.T) {
23
+ root := t.TempDir()
24
+ testutil.WriteFile(t, filepath.Join(root, "config.yml"), "theme: [broken")
25
+ err := CheckConfig(root)
26
+ if err == nil || !strings.Contains(err.Error(), "invalid YAML") {
27
+ t.Fatalf("CheckConfig() = %v, want invalid YAML error", err)
28
+ }
29
+ }
30
+
31
+ func TestCheckConfigMissingQualityGates(t *testing.T) {
32
+ root := t.TempDir()
33
+ testutil.WriteFile(t, filepath.Join(root, "config.yml"), "theme:\n bg: \"#000\"\n")
34
+ err := CheckConfig(root)
35
+ if err == nil || !strings.Contains(err.Error(), "quality_gates") {
36
+ t.Fatalf("CheckConfig() = %v, want quality_gates error", err)
37
+ }
38
+ }
39
+
40
+ func TestCheckConfigMissingTheme(t *testing.T) {
41
+ root := t.TempDir()
42
+ testutil.WriteFile(t, filepath.Join(root, "config.yml"), "quality_gates:\n block_on_failure: true\n")
43
+ err := CheckConfig(root)
44
+ if err == nil || !strings.Contains(err.Error(), "theme") {
45
+ t.Fatalf("CheckConfig() = %v, want theme error", err)
46
+ }
47
+ }
48
+
49
+ func TestCheckConfigValid(t *testing.T) {
50
+ root := t.TempDir()
51
+ testutil.WriteFile(t, filepath.Join(root, "config.yml"), "quality_gates:\n block_on_failure: true\ntheme:\n bg: \"#000\"\n")
52
+ if err := CheckConfig(root); err != nil {
53
+ t.Fatalf("CheckConfig() = %v, want nil", err)
54
+ }
55
+ }
56
+
57
+ // --- CheckRouter ---
58
+
59
+ func TestCheckRouterMissing(t *testing.T) {
60
+ root := t.TempDir()
61
+ err := CheckRouter(root, "")
62
+ if err == nil || !strings.Contains(err.Error(), "not found") {
63
+ t.Fatalf("CheckRouter() = %v, want not found error", err)
64
+ }
65
+ }
66
+
67
+ func TestCheckRouterInvalidStateBlock(t *testing.T) {
68
+ root := t.TempDir()
69
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), "# no state block")
70
+ err := CheckRouter(root, "")
71
+ if err == nil || !strings.Contains(err.Error(), "invalid state block") {
72
+ t.Fatalf("CheckRouter() = %v, want invalid state block error", err)
73
+ }
74
+ }
75
+
76
+ func TestCheckRouterPreImplementation(t *testing.T) {
77
+ root := t.TempDir()
78
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("pre-implementation", "none", "none"))
79
+ if err := CheckRouter(root, ""); err != nil {
80
+ t.Fatalf("CheckRouter() = %v, want nil", err)
81
+ }
82
+ }
83
+
84
+ func TestCheckRouterMissingReleaseDir(t *testing.T) {
85
+ root := t.TempDir()
86
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "none"))
87
+ err := CheckRouter(root, "")
88
+ if err == nil || !strings.Contains(err.Error(), "release") {
89
+ t.Fatalf("CheckRouter() = %v, want release directory error", err)
90
+ }
91
+ }
92
+
93
+ func TestCheckRouterMissingEpicDir(t *testing.T) {
94
+ root := t.TempDir()
95
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1"))
96
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E03-foo"))
97
+ err := CheckRouter(root, "")
98
+ if err == nil || !strings.Contains(err.Error(), "epic") {
99
+ t.Fatalf("CheckRouter() = %v, want epic directory error", err)
100
+ }
101
+ }
102
+
103
+ func TestCheckRouterValidWithDirs(t *testing.T) {
104
+ root := t.TempDir()
105
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1", "epics", "E03-foo"))
106
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E03-foo"))
107
+ if err := CheckRouter(root, ""); err != nil {
108
+ t.Fatalf("CheckRouter() = %v, want nil", err)
109
+ }
110
+ }
111
+
112
+ func TestCheckRouterEpicFilterSkip(t *testing.T) {
113
+ root := t.TempDir()
114
+ // release dir missing — would fail without filter
115
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E03-foo"))
116
+ // filter doesn't match router epic → skip dir checks
117
+ if err := CheckRouter(root, "E99-other"); err != nil {
118
+ t.Fatalf("CheckRouter() = %v, want nil (filter skip)", err)
119
+ }
120
+ }
121
+
122
+ // --- CheckStructure ---
123
+
124
+ func TestCheckStructure_MissingReleasesDir(t *testing.T) {
125
+ root := t.TempDir()
126
+ problems := CheckStructure(root, "")
127
+ if len(problems) != 1 || !strings.Contains(problems[0].Message, "releases directory not found") {
128
+ t.Fatalf("CheckStructure() = %v, want releases directory error", problems)
129
+ }
130
+ }
131
+
132
+ func TestCheckStructure_EmptyReleases(t *testing.T) {
133
+ root := t.TempDir()
134
+ testutil.MkdirAll(t, filepath.Join(root, "releases"))
135
+ problems := CheckStructure(root, "")
136
+ if len(problems) != 1 || !strings.Contains(problems[0].Message, "no release directories found") {
137
+ t.Fatalf("CheckStructure() = %v, want no releases error", problems)
138
+ }
139
+ }
140
+
141
+ func TestCheckStructure_MissingReleasePRD(t *testing.T) {
142
+ root := t.TempDir()
143
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1", "epics"))
144
+ problems := CheckStructure(root, "")
145
+ found := false
146
+ for _, p := range problems {
147
+ if strings.Contains(p.Message, "release PRD file not found") {
148
+ found = true
149
+ break
150
+ }
151
+ }
152
+ if !found {
153
+ t.Fatalf("CheckStructure() = %v, want release PRD file not found problem", problems)
154
+ }
155
+ }
156
+
157
+ func TestCheckStructure_ReleasePRDValid(t *testing.T) {
158
+ root := t.TempDir()
159
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1", "epics"))
160
+ testutil.WriteReleasePRD(t, filepath.Join(root, "releases", "v1"))
161
+ problems := CheckStructure(root, "")
162
+ for _, p := range problems {
163
+ if strings.Contains(p.File, "v1-PRD.md") {
164
+ t.Fatalf("CheckStructure() unexpected PRD problem: %v", p)
165
+ }
166
+ }
167
+ }
168
+
169
+ func TestCheckStructure_ReleasePRDCorruptYAML(t *testing.T) {
170
+ root := t.TempDir()
171
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1", "epics"))
172
+ testutil.WriteFile(t, filepath.Join(root, "releases", "v1", "v1-PRD.md"), "---\ntype: [broken\n---\n")
173
+ problems := CheckStructure(root, "")
174
+ found := false
175
+ for _, p := range problems {
176
+ if strings.Contains(p.File, "v1-PRD.md") && p.Line > 0 {
177
+ found = true
178
+ break
179
+ }
180
+ }
181
+ if !found {
182
+ t.Fatalf("CheckStructure() = %v, want corrupt YAML with line in v1-PRD.md", problems)
183
+ }
184
+ }
185
+
186
+ func TestCheckStructure_ValidEpicDetail(t *testing.T) {
187
+ root := t.TempDir()
188
+ releasePath := filepath.Join(root, "releases", "v1")
189
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
190
+ testutil.MkdirAll(t, epicPath)
191
+ testutil.WriteReleasePRD(t, releasePath)
192
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
193
+ problems := CheckStructure(root, "")
194
+ for _, p := range problems {
195
+ if strings.Contains(p.File, "Detail.md") {
196
+ t.Fatalf("CheckStructure() unexpected Detail.md problem: %v", p)
197
+ }
198
+ }
199
+ }
200
+
201
+ func TestCheckStructure_MissingEpicDetail(t *testing.T) {
202
+ root := t.TempDir()
203
+ releasePath := filepath.Join(root, "releases", "v1")
204
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
205
+ testutil.MkdirAll(t, epicPath)
206
+ testutil.WriteReleasePRD(t, releasePath)
207
+ problems := CheckStructure(root, "")
208
+ found := false
209
+ for _, p := range problems {
210
+ if strings.Contains(p.Message, "epic detail file not found") {
211
+ found = true
212
+ break
213
+ }
214
+ }
215
+ if !found {
216
+ t.Fatalf("CheckStructure() = %v, want epic detail file not found problem", problems)
217
+ }
218
+ }
219
+
220
+ func TestCheckStructure_ValidTask(t *testing.T) {
221
+ root := t.TempDir()
222
+ releasePath := filepath.Join(root, "releases", "v1")
223
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
224
+ tasksPath := filepath.Join(epicPath, "tasks")
225
+ testutil.MkdirAll(t, tasksPath)
226
+ testutil.WriteReleasePRD(t, releasePath)
227
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
228
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: E01-foo/T001-task\nstatus: planned\nobjective: \"Do the thing\"\ndepends_on: []\n---\n\n# T001: Task\n\n## Acceptance Criteria\n\n- It works\n")
229
+ problems := CheckStructure(root, "")
230
+ if len(problems) > 0 {
231
+ t.Fatalf("CheckStructure() = %v, want no problems", problems)
232
+ }
233
+ }
234
+
235
+ func TestCheckStructure_TaskMissingRequiredField(t *testing.T) {
236
+ root := t.TempDir()
237
+ releasePath := filepath.Join(root, "releases", "v1")
238
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
239
+ tasksPath := filepath.Join(epicPath, "tasks")
240
+ testutil.MkdirAll(t, tasksPath)
241
+ testutil.WriteReleasePRD(t, releasePath)
242
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
243
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: E01-foo/T001-task\nobjective: \"Do the thing\"\n---\n\n# T001: Task\n\n## Acceptance Criteria\n\n- It works\n")
244
+ problems := CheckStructure(root, "")
245
+ found := false
246
+ for _, p := range problems {
247
+ if strings.Contains(p.Message, "status") && strings.Contains(p.Message, "missing") {
248
+ found = true
249
+ break
250
+ }
251
+ }
252
+ if !found {
253
+ t.Fatalf("CheckStructure() = %v, want missing status field problem", problems)
254
+ }
255
+ }
256
+
257
+ func TestCheckStructure_TaskMissingAcceptanceCriteria(t *testing.T) {
258
+ root := t.TempDir()
259
+ releasePath := filepath.Join(root, "releases", "v1")
260
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
261
+ tasksPath := filepath.Join(epicPath, "tasks")
262
+ testutil.MkdirAll(t, tasksPath)
263
+ testutil.WriteReleasePRD(t, releasePath)
264
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
265
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: E01-foo/T001-task\nstatus: planned\nobjective: \"Do the thing\"\n---\n\n# T001: Task\n")
266
+ problems := CheckStructure(root, "")
267
+ found := false
268
+ for _, p := range problems {
269
+ if strings.Contains(p.Message, "Acceptance Criteria") {
270
+ found = true
271
+ break
272
+ }
273
+ }
274
+ if !found {
275
+ t.Fatalf("CheckStructure() = %v, want missing acceptance criteria problem", problems)
276
+ }
277
+ }
278
+
279
+ func TestCheckStructure_TaskCorruptYAML(t *testing.T) {
280
+ root := t.TempDir()
281
+ releasePath := filepath.Join(root, "releases", "v1")
282
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
283
+ tasksPath := filepath.Join(epicPath, "tasks")
284
+ testutil.MkdirAll(t, tasksPath)
285
+ testutil.WriteReleasePRD(t, releasePath)
286
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
287
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: \"unclosed\nstatus: planned\n---\n")
288
+ problems := CheckStructure(root, "")
289
+ foundLine := false
290
+ for _, p := range problems {
291
+ if strings.Contains(p.File, "T001-task.md") && p.Line > 0 {
292
+ foundLine = true
293
+ break
294
+ }
295
+ }
296
+ if !foundLine {
297
+ t.Fatalf("CheckStructure() = %v, want corrupt YAML with line number in task", problems)
298
+ }
299
+ }
300
+
301
+ func TestCheckStructure_EpicFilter(t *testing.T) {
302
+ root := t.TempDir()
303
+ releasePath := filepath.Join(root, "releases", "v1")
304
+ epic1Path := filepath.Join(releasePath, "epics", "E01-foo")
305
+ epic2Path := filepath.Join(releasePath, "epics", "E02-bar")
306
+ testutil.MkdirAll(t, epic1Path)
307
+ testutil.MkdirAll(t, epic2Path)
308
+ testutil.WriteReleasePRD(t, releasePath)
309
+ testutil.WriteFile(t, filepath.Join(epic1Path, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
310
+ // E02 has no detail file — should not appear when filtering to E01
311
+ problems := CheckStructure(root, "E01-foo")
312
+ for _, p := range problems {
313
+ if strings.Contains(p.Message, "E02") {
314
+ t.Fatalf("CheckStructure() with epicFilter=E01-foo should skip E02, got: %v", p)
315
+ }
316
+ }
317
+ }
318
+
319
+ func TestCheckStructure_EpicFilterByPrefix(t *testing.T) {
320
+ root := t.TempDir()
321
+ releasePath := filepath.Join(root, "releases", "v1")
322
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
323
+ testutil.MkdirAll(t, epicPath)
324
+ testutil.WriteReleasePRD(t, releasePath)
325
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
326
+ testutil.MkdirAll(t, filepath.Join(epicPath, "tasks"))
327
+ problems := CheckStructure(root, "E01")
328
+ if len(problems) > 0 {
329
+ t.Fatalf("CheckStructure() with epicFilter=E01 prefix = %v, want no problems", problems)
330
+ }
331
+ }
332
+
333
+ // --- CheckDependencies ---
334
+
335
+ func TestCheckDependencies_NoReleases(t *testing.T) {
336
+ root := t.TempDir()
337
+ problems := CheckDependencies(root, "")
338
+ if len(problems) == 0 {
339
+ t.Fatal("CheckDependencies() = no problems, want error about releases")
340
+ }
341
+ }
342
+
343
+ func TestCheckDependencies_NoDeps(t *testing.T) {
344
+ root := t.TempDir()
345
+ setupMinimalProject(t, root, "v1", "E01-foo", nil)
346
+ problems := CheckDependencies(root, "")
347
+ if len(problems) > 0 {
348
+ t.Fatalf("CheckDependencies() = %v, want no problems", problems)
349
+ }
350
+ }
351
+
352
+ func TestCheckDependencies_ValidDeps(t *testing.T) {
353
+ root := t.TempDir()
354
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
355
+ {id: "E01-foo/T001-task", deps: []string{}},
356
+ {id: "E01-foo/T002-task", deps: []string{"E01-foo/T001-task"}},
357
+ {id: "E01-foo/T003-task", deps: []string{"E01-foo/T002-task"}},
358
+ })
359
+ problems := CheckDependencies(root, "")
360
+ if len(problems) > 0 {
361
+ t.Fatalf("CheckDependencies() = %v, want no problems", problems)
362
+ }
363
+ }
364
+
365
+ func TestCheckDependencies_MissingDep(t *testing.T) {
366
+ root := t.TempDir()
367
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
368
+ {id: "E01-foo/T001-task", deps: []string{}},
369
+ {id: "E01-foo/T002-task", deps: []string{"E01-foo/T999-nonexistent"}},
370
+ })
371
+ problems := CheckDependencies(root, "")
372
+ found := false
373
+ for _, p := range problems {
374
+ if strings.Contains(p.Message, "non-existent") {
375
+ found = true
376
+ break
377
+ }
378
+ }
379
+ if !found {
380
+ t.Fatalf("CheckDependencies() = %v, want missing dependency problem", problems)
381
+ }
382
+ }
383
+
384
+ func TestCheckDependencies_DuplicateIDs(t *testing.T) {
385
+ root := t.TempDir()
386
+ releasePath := filepath.Join(root, "releases", "v1")
387
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
388
+ tasksPath := filepath.Join(epicPath, "tasks")
389
+ testutil.MkdirAll(t, tasksPath)
390
+ testutil.WriteReleasePRD(t, releasePath)
391
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
392
+
393
+ // Two epics, same task ID
394
+ epic2Path := filepath.Join(releasePath, "epics", "E02-bar")
395
+ tasks2Path := filepath.Join(epic2Path, "tasks")
396
+ testutil.MkdirAll(t, tasks2Path)
397
+ testutil.WriteFile(t, filepath.Join(epic2Path, "E02-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E02: Bar\n")
398
+
399
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: E01-foo/T001-task\nstatus: planned\nobjective: \"A\"\ndepends_on: []\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n")
400
+ testutil.WriteFile(t, filepath.Join(tasks2Path, "T001-task.md"), "---\nid: E01-foo/T001-task\nstatus: planned\nobjective: \"A\"\ndepends_on: []\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n")
401
+
402
+ problems := CheckDependencies(root, "")
403
+ found := false
404
+ for _, p := range problems {
405
+ if strings.Contains(p.Message, "duplicate task ID") {
406
+ found = true
407
+ break
408
+ }
409
+ }
410
+ if !found {
411
+ t.Fatalf("CheckDependencies() = %v, want duplicate task ID problem", problems)
412
+ }
413
+ }
414
+
415
+ func TestCheckDependencies_Cycle(t *testing.T) {
416
+ root := t.TempDir()
417
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
418
+ {id: "E01-foo/T001-task", deps: []string{"E01-foo/T003-task"}},
419
+ {id: "E01-foo/T002-task", deps: []string{"E01-foo/T001-task"}},
420
+ {id: "E01-foo/T003-task", deps: []string{"E01-foo/T002-task"}},
421
+ })
422
+ problems := CheckDependencies(root, "")
423
+ found := false
424
+ for _, p := range problems {
425
+ if strings.Contains(p.Message, "cycle") {
426
+ found = true
427
+ break
428
+ }
429
+ }
430
+ if !found {
431
+ t.Fatalf("CheckDependencies() = %v, want cycle problem", problems)
432
+ }
433
+ }
434
+
435
+ func TestCheckDependencies_CycleAccuratePath(t *testing.T) {
436
+ root := t.TempDir()
437
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
438
+ {id: "E01-foo/T001-task", deps: []string{"E01-foo/T002-task"}},
439
+ {id: "E01-foo/T002-task", deps: []string{"E01-foo/T003-task"}},
440
+ {id: "E01-foo/T003-task", deps: []string{"E01-foo/T001-task"}},
441
+ })
442
+ problems := CheckDependencies(root, "")
443
+ var cycleMsg string
444
+ for _, p := range problems {
445
+ if strings.Contains(p.Message, "cycle") {
446
+ cycleMsg = p.Message
447
+ break
448
+ }
449
+ }
450
+ if cycleMsg == "" {
451
+ t.Fatal("CheckDependencies() = no cycle problem, want one")
452
+ }
453
+ // The cycle path should contain T001, T002, T003 in the correct order
454
+ if !strings.Contains(cycleMsg, "T001") || !strings.Contains(cycleMsg, "T002") || !strings.Contains(cycleMsg, "T003") {
455
+ t.Fatalf("CheckDependencies() cycle path = %q, should contain all three tasks", cycleMsg)
456
+ }
457
+ // Each arrow should separate consecutive nodes in the cycle
458
+ if !strings.Contains(cycleMsg, "T001-task") || !strings.Contains(cycleMsg, "T002-task") || !strings.Contains(cycleMsg, "T003-task") {
459
+ t.Fatalf("CheckDependencies() cycle path = %q, should reference task files", cycleMsg)
460
+ }
461
+ }
462
+
463
+ func TestCheckDependencies_SelfReference(t *testing.T) {
464
+ root := t.TempDir()
465
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
466
+ {id: "E01-foo/T001-task", deps: []string{"E01-foo/T001-task"}},
467
+ })
468
+ problems := CheckDependencies(root, "")
469
+ found := false
470
+ for _, p := range problems {
471
+ if strings.Contains(p.Message, "cycle") {
472
+ found = true
473
+ break
474
+ }
475
+ }
476
+ if !found {
477
+ t.Fatalf("CheckDependencies() = %v, want cycle problem (self-reference)", problems)
478
+ }
479
+ }
480
+
481
+ func TestCheckDependencies_EpicFilter(t *testing.T) {
482
+ root := t.TempDir()
483
+ releasePath := filepath.Join(root, "releases", "v1")
484
+ testutil.MkdirAll(t, filepath.Join(releasePath, "epics", "E01-foo", "tasks"))
485
+ testutil.MkdirAll(t, filepath.Join(releasePath, "epics", "E02-bar", "tasks"))
486
+ testutil.WriteReleasePRD(t, releasePath)
487
+ testutil.WriteFile(t, filepath.Join(releasePath, "epics", "E01-foo", "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
488
+ testutil.WriteFile(t, filepath.Join(releasePath, "epics", "E02-bar", "E02-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E02: Bar\n")
489
+
490
+ // E02 has a missing dep — should be invisible with filter
491
+ taskE2 := `---\nid: E02-bar/T001-task\nstatus: planned\nobjective: \"B\"\ndepends_on: [\"E02-bar/T999-nonexistent\"]\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n`
492
+ testutil.WriteFile(t, filepath.Join(releasePath, "epics", "E01-foo", "tasks", "T001-task.md"), "---\nid: E01-foo/T001-task\nstatus: planned\nobjective: \"A\"\ndepends_on: []\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n")
493
+ testutil.WriteFile(t, filepath.Join(releasePath, "epics", "E02-bar", "tasks", "T001-task.md"), strings.ReplaceAll(taskE2, "\\n", "\n"))
494
+
495
+ problems := CheckDependencies(root, "E01-foo")
496
+ for _, p := range problems {
497
+ if strings.Contains(p.Message, "E02-bar") {
498
+ t.Fatalf("CheckDependencies() with epicFilter=E01-foo should skip E02, got: %v", p)
499
+ }
500
+ }
501
+ }
502
+
503
+ // --- CheckAuditState ---
504
+
505
+ func TestCheckAuditState_NoAuditFiles(t *testing.T) {
506
+ root := t.TempDir()
507
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v1", "epics", "E01-foo"))
508
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E01-foo"))
509
+ problems := CheckAuditState(root)
510
+ if len(problems) > 0 {
511
+ t.Fatalf("CheckAuditState() = %v, want no problems", problems)
512
+ }
513
+ }
514
+
515
+ func TestCheckAuditState_MatchesRouter(t *testing.T) {
516
+ root := t.TempDir()
517
+ epicPath := filepath.Join(root, "releases", "v1", "epics", "E01-foo")
518
+ testutil.MkdirAll(t, epicPath)
519
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Audit.md"), "---\ntype: audit-findings\n---\n\n# Audit\n")
520
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("audit-pending", "v1", "E01-foo"))
521
+ problems := CheckAuditState(root)
522
+ if len(problems) > 0 {
523
+ t.Fatalf("CheckAuditState() = %v, want no problems when router matches", problems)
524
+ }
525
+ }
526
+
527
+ func TestCheckAuditState_ProposalWithoutPending(t *testing.T) {
528
+ root := t.TempDir()
529
+ epicPath := filepath.Join(root, "releases", "v1", "epics", "E01-foo")
530
+ testutil.MkdirAll(t, epicPath)
531
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Audit.md"), "---\ntype: audit-findings\n---\n\n# Audit\n")
532
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E01-foo"))
533
+ problems := CheckAuditState(root)
534
+ if len(problems) != 1 {
535
+ t.Fatalf("CheckAuditState() = %v, want 1 problem (audit file without audit-pending)", problems)
536
+ }
537
+ if !strings.Contains(problems[0].Message, "audit proposal exists") {
538
+ t.Fatalf("CheckAuditState() = %v, want 'audit proposal exists' message", problems)
539
+ }
540
+ }
541
+
542
+ func TestCheckAuditState_DifferentEpicInRouter(t *testing.T) {
543
+ root := t.TempDir()
544
+ epic1Path := filepath.Join(root, "releases", "v1", "epics", "E01-foo")
545
+ epic2Path := filepath.Join(root, "releases", "v1", "epics", "E02-bar")
546
+ testutil.MkdirAll(t, epic1Path)
547
+ testutil.MkdirAll(t, epic2Path)
548
+ testutil.WriteFile(t, filepath.Join(epic1Path, "E01-Audit.md"), "---\ntype: audit-findings\n---\n\n# Audit\n")
549
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("audit-pending", "v1", "E02-bar"))
550
+ problems := CheckAuditState(root)
551
+ if len(problems) != 1 {
552
+ t.Fatalf("CheckAuditState() = %v, want 1 problem (E01 audit but E02 in router)", problems)
553
+ }
554
+ if !strings.Contains(problems[0].Message, "E01") {
555
+ t.Fatalf("CheckAuditState() = %v, want problem mentioning E01", problems)
556
+ }
557
+ }
558
+
559
+ func TestCheckAuditState_MultipleStale(t *testing.T) {
560
+ root := t.TempDir()
561
+ epic1Path := filepath.Join(root, "releases", "v1", "epics", "E01-foo")
562
+ epic2Path := filepath.Join(root, "releases", "v1", "epics", "E02-bar")
563
+ testutil.MkdirAll(t, epic1Path)
564
+ testutil.MkdirAll(t, epic2Path)
565
+ testutil.WriteFile(t, filepath.Join(epic1Path, "E01-Audit.md"), "---\ntype: audit-findings\n---\n\n# Audit\n")
566
+ testutil.WriteFile(t, filepath.Join(epic2Path, "E02-Audit.md"), "---\ntype: audit-findings\n---\n\n# Audit\n")
567
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), routerContent("task-building", "v1", "E03-baz"))
568
+ problems := CheckAuditState(root)
569
+ if len(problems) != 2 {
570
+ t.Fatalf("CheckAuditState() = %v, want 2 problems (both audit files stale)", problems)
571
+ }
572
+ }
573
+
574
+ // --- CheckOrphans ---
575
+
576
+ func TestCheckOrphans_NoOrphans(t *testing.T) {
577
+ root := t.TempDir()
578
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
579
+ {id: "E01-foo/T001-task", deps: []string{}},
580
+ {id: "E01-foo/T002-task", deps: []string{}},
581
+ })
582
+ problems := CheckOrphans(root)
583
+ if len(problems) > 0 {
584
+ t.Fatalf("CheckOrphans() = %v, want no problems", problems)
585
+ }
586
+ }
587
+
588
+ func TestCheckOrphans_TaskRefersNonexistentEpic(t *testing.T) {
589
+ root := t.TempDir()
590
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
591
+ {id: "E99-ghost/T001-task", deps: []string{}},
592
+ })
593
+ problems := CheckOrphans(root)
594
+ found := false
595
+ for _, p := range problems {
596
+ if strings.Contains(p.Message, "orphaned") && strings.Contains(p.Message, "E99-ghost") {
597
+ found = true
598
+ break
599
+ }
600
+ }
601
+ if !found {
602
+ t.Fatalf("CheckOrphans() = %v, want orphaned task problem for E99-ghost", problems)
603
+ }
604
+ }
605
+
606
+ func TestCheckOrphans_CrossReleaseEpicRef(t *testing.T) {
607
+ root := t.TempDir()
608
+ releasePath := filepath.Join(root, "releases", "v1")
609
+ epicPath := filepath.Join(releasePath, "epics", "E01-foo")
610
+ tasksPath := filepath.Join(epicPath, "tasks")
611
+ testutil.MkdirAll(t, tasksPath)
612
+ testutil.WriteReleasePRD(t, releasePath)
613
+ testutil.WriteFile(t, filepath.Join(epicPath, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
614
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T001-task.md"), "---\nid: E02-bar/T001-task\nstatus: planned\nobjective: \"Task\"\ndepends_on: []\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n")
615
+ // E02-bar does not exist in any release
616
+ problems := CheckOrphans(root)
617
+ found := false
618
+ for _, p := range problems {
619
+ if strings.Contains(p.Message, "orphaned") {
620
+ found = true
621
+ break
622
+ }
623
+ }
624
+ if !found {
625
+ t.Fatalf("CheckOrphans() = %v, want orphaned task problem for E02-bar", problems)
626
+ }
627
+ }
628
+
629
+ func TestCheckOrphans_ValidCrossReleaseRef(t *testing.T) {
630
+ root := t.TempDir()
631
+ releasePath := filepath.Join(root, "releases", "v1")
632
+ epic1Path := filepath.Join(releasePath, "epics", "E01-foo")
633
+ epic2Path := filepath.Join(releasePath, "epics", "E02-bar")
634
+ testutil.MkdirAll(t, filepath.Join(epic1Path, "tasks"))
635
+ testutil.MkdirAll(t, filepath.Join(epic2Path, "tasks"))
636
+ testutil.WriteReleasePRD(t, releasePath)
637
+ testutil.WriteFile(t, filepath.Join(epic1Path, "E01-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E01: Foo\n")
638
+ testutil.WriteFile(t, filepath.Join(epic2Path, "E02-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# E02: Bar\n")
639
+ testutil.WriteFile(t, filepath.Join(epic1Path, "tasks", "T001-task.md"), "---\nid: E02-bar/T001-task\nstatus: planned\nobjective: \"Task\"\ndepends_on: []\n---\n\n# T001\n\n## Acceptance Criteria\n\n- it works\n")
640
+ // E02-bar exists
641
+ problems := CheckOrphans(root)
642
+ for _, p := range problems {
643
+ if strings.Contains(p.Message, "orphaned") {
644
+ t.Fatalf("CheckOrphans() = %v, want no orphan problems for cross-epic ref that exists", problems)
645
+ }
646
+ }
647
+ }
648
+
649
+ func TestCheckOrphans_EmptyID(t *testing.T) {
650
+ root := t.TempDir()
651
+ setupMinimalProject(t, root, "v1", "E01-foo", []taskSpec{
652
+ {id: "E01-foo/T001-task", deps: []string{}},
653
+ })
654
+ // Write a task with empty ID
655
+ tasksPath := filepath.Join(root, "releases", "v1", "epics", "E01-foo", "tasks")
656
+ testutil.WriteFile(t, filepath.Join(tasksPath, "T002-bad.md"), "---\nstatus: planned\nobjective: \"No ID\"\ndepends_on: []\n---\n\n# T002\n\n## Acceptance Criteria\n\n- it works\n")
657
+ problems := CheckOrphans(root)
658
+ // Should not crash, should handle missing ID gracefully
659
+ if len(problems) > 0 {
660
+ // Only allow non-orphan problems (e.g. missing ID)
661
+ for _, p := range problems {
662
+ if strings.Contains(p.Message, "orphaned") {
663
+ t.Fatalf("CheckOrphans() = %v, want no orphan problems for task with missing ID", problems)
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ func TestCheckOrphans_NoReleasesDir(t *testing.T) {
670
+ root := t.TempDir()
671
+ problems := CheckOrphans(root)
672
+ // Should report releases dir problem, not crash
673
+ if len(problems) == 0 {
674
+ t.Fatal("CheckOrphans() = no problems, want error about missing releases")
675
+ }
676
+ }
677
+
678
+ // helpers
679
+
680
+ type taskSpec struct {
681
+ id string
682
+ deps []string
683
+ }
684
+
685
+ func setupMinimalProject(t *testing.T, root, releaseID, epicID string, tasks []taskSpec) {
686
+ t.Helper()
687
+ releasePath := filepath.Join(root, "releases", releaseID)
688
+ epicPath := filepath.Join(releasePath, "epics", epicID)
689
+ tasksPath := filepath.Join(epicPath, "tasks")
690
+ testutil.MkdirAll(t, tasksPath)
691
+
692
+ prefix := epicID
693
+ if idx := strings.IndexByte(epicID, '-'); idx != -1 {
694
+ prefix = epicID[:idx]
695
+ }
696
+
697
+ testutil.WriteReleasePRD(t, releasePath)
698
+ testutil.WriteFile(t, filepath.Join(epicPath, prefix+"-Detail.md"), "---\ntype: epic-design\nstatus: planned\n---\n\n# Epic\n")
699
+
700
+ for i, ts := range tasks {
701
+ depsYAML := "[]"
702
+ if len(ts.deps) > 0 {
703
+ quoted := make([]string, len(ts.deps))
704
+ for j, d := range ts.deps {
705
+ quoted[j] = fmt.Sprintf("%q", d)
706
+ }
707
+ depsYAML = "[" + strings.Join(quoted, ", ") + "]"
708
+ }
709
+ content := fmt.Sprintf("---\nid: %s\nstatus: planned\nobjective: \"Task %d\"\ndepends_on: %s\n---\n\n# T%03d\n\n## Acceptance Criteria\n\n- it works\n", ts.id, i, depsYAML, i+1)
710
+ testutil.WriteFile(t, filepath.Join(tasksPath, fmt.Sprintf("T%03d-task.md", i+1)), content)
711
+ }
712
+ }
713
+
714
+ func routerContent(state, release, epic string) string {
715
+ return "## Current state\n\n```yaml\nstate: " + state + "\nrelease: " + release + "\nepic: " + epic + "\ntask: none\nnext_action: \"\"\n```\n"
716
+ }