savepoint 1.0.2 → 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 (233) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.golangci.yml +11 -0
  3. package/.savepoint/Design.md +37 -36
  4. package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
  5. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
  6. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
  7. package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
  8. package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
  9. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
  10. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
  11. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
  12. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
  13. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
  14. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
  15. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
  16. package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
  17. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
  18. package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
  19. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
  20. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
  21. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
  22. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
  23. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
  24. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
  25. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
  26. package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
  27. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
  28. package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
  29. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
  30. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
  31. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
  32. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
  33. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
  34. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
  35. package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
  36. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
  37. package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
  38. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
  39. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
  40. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
  41. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
  42. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
  43. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
  44. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
  45. package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
  46. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
  47. package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
  48. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
  49. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
  50. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
  51. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
  52. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
  53. package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
  54. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
  55. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
  56. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
  57. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
  58. package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
  59. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
  60. package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
  61. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
  62. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
  63. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
  64. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
  65. package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
  66. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
  67. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
  68. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
  69. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
  70. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
  71. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
  72. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
  73. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
  74. package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
  75. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
  76. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
  77. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
  78. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
  79. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
  80. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
  81. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
  82. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
  83. package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
  84. package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
  85. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
  86. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
  87. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
  88. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
  89. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
  90. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
  91. package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
  92. package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
  93. package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
  94. package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
  95. package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
  96. package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
  97. package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
  98. package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
  99. package/.savepoint/router.md +9 -16
  100. package/AGENTS.md +38 -23
  101. package/README.md +0 -1
  102. package/agent-skills/savepoint-audit/SKILL.md +86 -34
  103. package/agent-skills/savepoint-build-task/SKILL.md +7 -2
  104. package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
  105. package/agent-skills/savepoint-create-task/SKILL.md +44 -31
  106. package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
  107. package/agent-skills/savepoint-system-design/SKILL.md +7 -2
  108. package/agent_skills_test.go +91 -0
  109. package/cmd/board.go +59 -0
  110. package/cmd/board_test.go +137 -0
  111. package/cmd/doctor.go +53 -0
  112. package/cmd/doctor_test.go +146 -0
  113. package/cmd/init.go +63 -0
  114. package/cmd/init_test.go +104 -0
  115. package/internal/board/board.go +40 -36
  116. package/internal/board/board_test.go +27 -82
  117. package/internal/board/card.go +43 -23
  118. package/internal/board/card_test.go +41 -5
  119. package/internal/board/column.go +44 -13
  120. package/internal/board/column_test.go +5 -2
  121. package/internal/board/detail.go +0 -47
  122. package/internal/board/epic_panel.go +118 -22
  123. package/internal/board/epic_panel_test.go +302 -17
  124. package/internal/board/help.go +1 -0
  125. package/internal/board/help_test.go +1 -0
  126. package/internal/board/integration_test.go +266 -0
  127. package/internal/board/interfaces.go +65 -0
  128. package/internal/board/interfaces_test.go +114 -0
  129. package/internal/board/io.go +93 -0
  130. package/internal/board/model.go +79 -118
  131. package/internal/board/plain.go +88 -0
  132. package/internal/board/plain_test.go +117 -0
  133. package/internal/board/release.go +1 -9
  134. package/internal/board/release_test.go +6 -6
  135. package/internal/board/status.go +4 -4
  136. package/internal/board/theme.go +24 -0
  137. package/internal/board/theme_test.go +31 -0
  138. package/internal/board/transitions.go +113 -88
  139. package/internal/board/transitions_test.go +164 -141
  140. package/internal/board/tui.go +32 -0
  141. package/internal/board/update.go +325 -215
  142. package/internal/board/update_test.go +299 -18
  143. package/internal/board/util.go +76 -0
  144. package/internal/board/view.go +31 -28
  145. package/internal/board/view_test.go +12 -2
  146. package/internal/board/watch.go +35 -5
  147. package/internal/buildtool/main.go +2 -10
  148. package/internal/buildtool/main_test.go +46 -0
  149. package/internal/data/config.go +17 -3
  150. package/internal/data/config_test.go +49 -0
  151. package/internal/data/discover.go +26 -0
  152. package/internal/data/discover_test.go +34 -10
  153. package/internal/data/errors.go +4 -0
  154. package/internal/data/lifecycle.go +13 -6
  155. package/internal/data/lifecycle_test.go +14 -11
  156. package/internal/data/parser.go +21 -6
  157. package/internal/data/parser_test.go +31 -7
  158. package/internal/data/task.go +0 -9
  159. package/internal/data/write.go +85 -11
  160. package/internal/data/write_test.go +167 -0
  161. package/internal/doctor/checks.go +567 -0
  162. package/internal/doctor/checks_test.go +716 -0
  163. package/internal/doctor/gates.go +193 -0
  164. package/internal/doctor/gates_test.go +166 -0
  165. package/internal/doctor/interfaces.go +64 -0
  166. package/internal/doctor/interfaces_test.go +104 -0
  167. package/internal/doctor/repairs.go +80 -0
  168. package/internal/doctor/repairs_test.go +81 -0
  169. package/internal/doctor/report.go +157 -0
  170. package/internal/doctor/report_test.go +89 -0
  171. package/internal/init/clipboard.go +146 -0
  172. package/internal/init/clipboard_test.go +74 -0
  173. package/internal/init/install.go +16 -0
  174. package/internal/init/integration_test.go +197 -0
  175. package/internal/init/prompt.go +14 -0
  176. package/internal/init/prompt_test.go +77 -0
  177. package/internal/init/scaffold.go +59 -0
  178. package/internal/init/scaffold_test.go +179 -0
  179. package/internal/init/template_freshness_test.go +56 -0
  180. package/internal/init/validate.go +85 -0
  181. package/internal/init/validate_test.go +141 -0
  182. package/internal/init/write.go +73 -0
  183. package/internal/init/write_test.go +91 -0
  184. package/internal/styles/styles_test.go +133 -0
  185. package/internal/testutil/fixture.go +113 -0
  186. package/internal/testutil/fs.go +26 -0
  187. package/main.go +101 -4
  188. package/package.json +2 -2
  189. package/project-audit/audit_report_glm_5.1.md +411 -0
  190. package/project-audit/audit_report_opus_4.6 +406 -0
  191. package/project-audit/consolidated-audit-report.md +456 -0
  192. package/savepoint +0 -0
  193. package/templates/project/.savepoint/Design.md +2 -2
  194. package/templates/project/.savepoint/router.md +10 -10
  195. package/templates/project/AGENTS.md +33 -21
  196. package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
  197. package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
  198. package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
  199. package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
  200. package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
  201. package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
  202. package/templates/prompts/audit-reconciliation.prompt.md +33 -28
  203. package/templates/prompts/design.prompt.md +3 -1
  204. package/.savepoint/audit/v1/E01/proposals.md +0 -168
  205. package/.savepoint/audit/v1/E01/snapshot.md +0 -78
  206. package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
  207. package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
  208. package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
  209. package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
  210. package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
  211. package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
  212. package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
  213. package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
  214. package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
  215. package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
  216. package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
  217. package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
  218. package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
  219. package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
  220. package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
  221. package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
  222. package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
  223. package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
  224. package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
  225. package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
  226. package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
  227. package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
  228. package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
  229. package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
  230. package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
  231. package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
  232. package/ink-cli-ui-design.zip +0 -0
  233. package/savepoint.exe +0 -0
@@ -0,0 +1,193 @@
1
+ package doctor
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // GateResult holds the outcome of a single quality gate.
14
+ type GateResult struct {
15
+ Name string
16
+ Command string
17
+ Passed bool
18
+ ExitCode int
19
+ Output string
20
+ }
21
+
22
+ // RunQualityGates executes configured quality gates (lint, typecheck, test).
23
+ func RunQualityGates(root string, overrides ...DoctorDependencies) []GateResult {
24
+ deps := doctorDependencies(overrides)
25
+ configPath := filepath.Join(root, "config.yml")
26
+ cfg, err := deps.ConfigReader.Read(configPath)
27
+ if err != nil {
28
+ return []GateResult{{
29
+ Name: "config",
30
+ Command: "",
31
+ Passed: false,
32
+ Output: fmt.Sprintf("cannot read config: %v", err),
33
+ }}
34
+ }
35
+
36
+ timeout := 60 * time.Second
37
+ if cfg.QualityGates.Timeout != "" {
38
+ if d, err := time.ParseDuration(cfg.QualityGates.Timeout); err == nil {
39
+ timeout = d
40
+ }
41
+ }
42
+
43
+ var results []GateResult
44
+
45
+ if cfg.QualityGates.Lint != nil && *cfg.QualityGates.Lint != "" {
46
+ results = append(results, runGate("lint", *cfg.QualityGates.Lint, root, timeout))
47
+ }
48
+ if cfg.QualityGates.Typecheck != nil && *cfg.QualityGates.Typecheck != "" {
49
+ results = append(results, runGate("typecheck", *cfg.QualityGates.Typecheck, root, timeout))
50
+ }
51
+ if cfg.QualityGates.Test != nil && *cfg.QualityGates.Test != "" {
52
+ results = append(results, runGate("test", *cfg.QualityGates.Test, root, timeout))
53
+ }
54
+
55
+ if len(results) == 0 {
56
+ results = append(results, GateResult{
57
+ Name: "quality_gates",
58
+ Command: "",
59
+ Passed: true,
60
+ Output: "no quality gates configured",
61
+ })
62
+ }
63
+
64
+ return results
65
+ }
66
+
67
+ func runGate(name, command string, root string, timeout time.Duration) GateResult {
68
+ parts := splitCommand(command)
69
+ if len(parts) == 0 {
70
+ return GateResult{
71
+ Name: name,
72
+ Command: command,
73
+ Passed: false,
74
+ Output: "empty command",
75
+ }
76
+ }
77
+
78
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
79
+ defer cancel()
80
+
81
+ cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
82
+ cmd.Dir = root
83
+ cmd.Env = os.Environ()
84
+
85
+ output, err := cmd.CombinedOutput()
86
+ outStr := strings.TrimSpace(string(output))
87
+
88
+ switch {
89
+ case err != nil && ctx.Err() == context.DeadlineExceeded:
90
+ return GateResult{
91
+ Name: name,
92
+ Command: command,
93
+ Passed: false,
94
+ Output: fmt.Sprintf("timed out after %v", timeout),
95
+ }
96
+ case err != nil:
97
+ if exitErr, ok := err.(*exec.ExitError); ok {
98
+ return GateResult{
99
+ Name: name,
100
+ Command: command,
101
+ Passed: false,
102
+ ExitCode: exitErr.ExitCode(),
103
+ Output: outStr,
104
+ }
105
+ }
106
+ return GateResult{
107
+ Name: name,
108
+ Command: command,
109
+ Passed: false,
110
+ Output: fmt.Sprintf("failed to execute: %v", err),
111
+ }
112
+ default:
113
+ return GateResult{
114
+ Name: name,
115
+ Command: command,
116
+ Passed: true,
117
+ ExitCode: 0,
118
+ Output: outStr,
119
+ }
120
+ }
121
+ }
122
+
123
+ func splitCommand(command string) []string {
124
+ var parts []string
125
+ current := strings.Builder{}
126
+ inDoubleQuote := false
127
+ inSingleQuote := false
128
+ tokenStarted := false
129
+
130
+ flush := func() {
131
+ if tokenStarted || current.Len() > 0 {
132
+ parts = append(parts, current.String())
133
+ current.Reset()
134
+ tokenStarted = false
135
+ }
136
+ }
137
+
138
+ for i := 0; i < len(command); i++ {
139
+ c := command[i]
140
+
141
+ if inSingleQuote {
142
+ tokenStarted = true
143
+ if c == '\'' {
144
+ inSingleQuote = false
145
+ } else {
146
+ current.WriteByte(c)
147
+ }
148
+ continue
149
+ }
150
+
151
+ if inDoubleQuote {
152
+ tokenStarted = true
153
+ if c == '\\' && i+1 < len(command) {
154
+ next := command[i+1]
155
+ if next == '"' || next == '\\' || next == '$' || next == '`' {
156
+ i++
157
+ current.WriteByte(command[i])
158
+ continue
159
+ }
160
+ }
161
+ if c == '"' {
162
+ inDoubleQuote = false
163
+ continue
164
+ }
165
+ current.WriteByte(c)
166
+ continue
167
+ }
168
+
169
+ switch c {
170
+ case '\\':
171
+ tokenStarted = true
172
+ if i+1 < len(command) {
173
+ i++
174
+ current.WriteByte(command[i])
175
+ } else {
176
+ current.WriteByte(c)
177
+ }
178
+ case '"':
179
+ tokenStarted = true
180
+ inDoubleQuote = true
181
+ case '\'':
182
+ tokenStarted = true
183
+ inSingleQuote = true
184
+ case ' ', '\t', '\n', '\r':
185
+ flush()
186
+ default:
187
+ tokenStarted = true
188
+ current.WriteByte(c)
189
+ }
190
+ }
191
+ flush()
192
+ return parts
193
+ }
@@ -0,0 +1,166 @@
1
+ package doctor
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func writeConfig(t *testing.T, root, content string) {
11
+ t.Helper()
12
+ if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(content), 0644); err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ }
16
+
17
+ func TestRunQualityGates_NoConfig(t *testing.T) {
18
+ root := t.TempDir()
19
+ results := RunQualityGates(root)
20
+ if len(results) != 1 {
21
+ t.Fatalf("RunQualityGates() = %d results, want 1", len(results))
22
+ }
23
+ if !results[0].Passed {
24
+ t.Fatalf("RunQualityGates() = %v, want passed (default config has no gates)", results[0])
25
+ }
26
+ if !strings.Contains(results[0].Output, "no quality gates configured") {
27
+ t.Fatalf("RunQualityGates() output = %q, want 'no quality gates configured'", results[0].Output)
28
+ }
29
+ }
30
+
31
+ func TestRunQualityGates_AllNull(t *testing.T) {
32
+ root := t.TempDir()
33
+ writeConfig(t, root, "quality_gates:\n lint: null\n typecheck: null\n test: null\ntheme:\n bg: \"#000\"\n")
34
+
35
+ results := RunQualityGates(root)
36
+ if len(results) != 1 {
37
+ t.Fatalf("RunQualityGates() = %d results, want 1 (no gates configured)", len(results))
38
+ }
39
+ if !results[0].Passed {
40
+ t.Fatalf("RunQualityGates() = %v, want passed with 'no gates configured'", results[0])
41
+ }
42
+ if !strings.Contains(results[0].Output, "no quality gates configured") {
43
+ t.Fatalf("RunQualityGates() output = %q, want 'no quality gates configured'", results[0].Output)
44
+ }
45
+ }
46
+
47
+ func TestRunQualityGates_LintOnly(t *testing.T) {
48
+ root := t.TempDir()
49
+ writeConfig(t, root, "quality_gates:\n lint: \"go version\"\n typecheck: null\n test: null\ntheme:\n bg: \"#000\"\n")
50
+
51
+ results := RunQualityGates(root)
52
+ if len(results) != 1 {
53
+ t.Fatalf("RunQualityGates() = %d results, want 1 (lint only)", len(results))
54
+ }
55
+ if !results[0].Passed {
56
+ t.Fatalf("RunQualityGates() lint should pass: %v", results[0])
57
+ }
58
+ if results[0].Name != "lint" {
59
+ t.Fatalf("RunQualityGates()[0].Name = %q, want \"lint\"", results[0].Name)
60
+ }
61
+ }
62
+
63
+ func TestRunQualityGates_AllThree(t *testing.T) {
64
+ root := t.TempDir()
65
+ writeConfig(t, root, "quality_gates:\n lint: \"go version\"\n typecheck: \"go version\"\n test: \"go version\"\ntheme:\n bg: \"#000\"\n")
66
+
67
+ results := RunQualityGates(root)
68
+ if len(results) != 3 {
69
+ t.Fatalf("RunQualityGates() = %d results, want 3", len(results))
70
+ }
71
+ for _, r := range results {
72
+ if !r.Passed {
73
+ t.Fatalf("RunQualityGates() %s should pass: %v", r.Name, r)
74
+ }
75
+ }
76
+ }
77
+
78
+ func TestRunQualityGates_FailingCommand(t *testing.T) {
79
+ root := t.TempDir()
80
+ writeConfig(t, root, "quality_gates:\n lint: \"cmd-that-does-not-exist-12345\"\n typecheck: null\n test: null\ntheme:\n bg: \"#000\"\n")
81
+
82
+ results := RunQualityGates(root)
83
+ if len(results) != 1 {
84
+ t.Fatalf("RunQualityGates() = %d results, want 1", len(results))
85
+ }
86
+ if results[0].Passed {
87
+ t.Fatal("RunQualityGates() should fail for non-existent command")
88
+ }
89
+ }
90
+
91
+ func TestRunQualityGates_ExitCodeNonZero(t *testing.T) {
92
+ root := t.TempDir()
93
+ writeConfig(t, root, "quality_gates:\n lint: \"go vet\"\n typecheck: null\n test: null\ntheme:\n bg: \"#000\"\n")
94
+ // Write a bad Go file so go vet fails
95
+ badPath := filepath.Join(root, "bad.go")
96
+ os.WriteFile(badPath, []byte("package x\n\nfunc f() { return 1 }\n"), 0644)
97
+
98
+ results := RunQualityGates(root)
99
+ if len(results) != 1 {
100
+ t.Fatalf("RunQualityGates() = %d results, want 1", len(results))
101
+ }
102
+ if results[0].Passed {
103
+ t.Fatal("RunQualityGates() should fail when go vet fails")
104
+ }
105
+ }
106
+
107
+ func TestRunQualityGates_Timeout(t *testing.T) {
108
+ root := t.TempDir()
109
+ writeConfig(t, root, "quality_gates:\n lint: \"go test -test.run TestNonexistent -count=1 ./...\"\n gate_timeout: \"1ns\"\ntheme:\n bg: \"#000\"\n")
110
+
111
+ results := RunQualityGates(root)
112
+ if len(results) != 1 {
113
+ t.Fatalf("RunQualityGates() = %d results, want 1", len(results))
114
+ }
115
+ if results[0].Passed {
116
+ t.Fatal("RunQualityGates() should fail due to timeout")
117
+ }
118
+ if !strings.Contains(results[0].Output, "timed out") {
119
+ t.Fatalf("RunQualityGates() output = %q, want 'timed out'", results[0].Output)
120
+ }
121
+ }
122
+
123
+ func TestRunQualityGates_DefaultTimeout(t *testing.T) {
124
+ root := t.TempDir()
125
+ writeConfig(t, root, "quality_gates:\n lint: \"go version\"\n # no gate_timeout set — uses 60s default\ntheme:\n bg: \"#000\"\n")
126
+
127
+ results := RunQualityGates(root)
128
+ if len(results) != 1 {
129
+ t.Fatalf("RunQualityGates() = %d results, want 1", len(results))
130
+ }
131
+ if !results[0].Passed {
132
+ t.Fatalf("RunQualityGates() lint should pass with default timeout: %v", results[0])
133
+ }
134
+ }
135
+
136
+ func TestSplitCommand(t *testing.T) {
137
+ tests := []struct {
138
+ input string
139
+ want []string
140
+ }{
141
+ {"echo hello", []string{"echo", "hello"}},
142
+ {"go test ./...", []string{"go", "test", "./..."}},
143
+ {"\"c:\\program files\\go\\bin\\go\"", []string{"c:\\program files\\go\\bin\\go"}},
144
+ {"echo 'hello world'", []string{"echo", "hello world"}},
145
+ {"echo \"hello \\\"world\\\"\"", []string{"echo", "hello \"world\""}},
146
+ {"echo hello\\ world", []string{"echo", "hello world"}},
147
+ {"echo 'it''s'", []string{"echo", "its"}},
148
+ {"go test -run \"\" ./...", []string{"go", "test", "-run", "", "./..."}},
149
+ {"printf ''", []string{"printf", ""}},
150
+ {"echo trailing\\", []string{"echo", "trailing\\"}},
151
+ {"", nil},
152
+ {" ", nil},
153
+ }
154
+ for _, tt := range tests {
155
+ got := splitCommand(tt.input)
156
+ if len(got) != len(tt.want) {
157
+ t.Errorf("splitCommand(%q) = %v, want %v", tt.input, got, tt.want)
158
+ continue
159
+ }
160
+ for i := range got {
161
+ if got[i] != tt.want[i] {
162
+ t.Errorf("splitCommand(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
163
+ }
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,64 @@
1
+ package doctor
2
+
3
+ import "github.com/opencode/savepoint/internal/data"
4
+
5
+ // taskDiscoverer provides project traversal for doctor checks.
6
+ type taskDiscoverer interface {
7
+ ListRootDirs(root string) ([]string, error)
8
+ ListReleases(root string) ([]data.ReleaseInfo, error)
9
+ ListEpics(root, release string) ([]data.EpicInfo, error)
10
+ ListTasks(root, release, epic string) ([]data.TaskInfo, error)
11
+ }
12
+
13
+ // taskParser parses Savepoint frontmatter for doctor checks.
14
+ type taskParser interface {
15
+ ParseFrontmatter(content string) (map[string]any, error)
16
+ }
17
+
18
+ // configReader reads quality gate configuration.
19
+ type configReader interface {
20
+ Read(path string) (*data.Config, error)
21
+ }
22
+
23
+ // routerReader parses router state from router.md content.
24
+ type routerReader interface {
25
+ ReadState(content string) (*data.RouterState, error)
26
+ }
27
+
28
+ // DoctorDependencies contains doctor data-access dependencies.
29
+ type DoctorDependencies struct {
30
+ Discoverer taskDiscoverer
31
+ Parser taskParser
32
+ ConfigReader configReader
33
+ RouterReader routerReader
34
+ }
35
+
36
+ func defaultDoctorDependencies() DoctorDependencies {
37
+ return DoctorDependencies{
38
+ Discoverer: data.NewDiscover(),
39
+ Parser: data.NewParser(),
40
+ ConfigReader: data.NewConfigReader(),
41
+ RouterReader: data.NewRouterReader(),
42
+ }
43
+ }
44
+
45
+ func doctorDependencies(overrides []DoctorDependencies) DoctorDependencies {
46
+ deps := defaultDoctorDependencies()
47
+ if len(overrides) == 0 {
48
+ return deps
49
+ }
50
+ override := overrides[0]
51
+ if override.Discoverer != nil {
52
+ deps.Discoverer = override.Discoverer
53
+ }
54
+ if override.Parser != nil {
55
+ deps.Parser = override.Parser
56
+ }
57
+ if override.ConfigReader != nil {
58
+ deps.ConfigReader = override.ConfigReader
59
+ }
60
+ if override.RouterReader != nil {
61
+ deps.RouterReader = override.RouterReader
62
+ }
63
+ return deps
64
+ }
@@ -0,0 +1,104 @@
1
+ package doctor
2
+
3
+ import (
4
+ "path/filepath"
5
+ "testing"
6
+
7
+ "github.com/opencode/savepoint/internal/data"
8
+ "github.com/opencode/savepoint/internal/testutil"
9
+ )
10
+
11
+ type stubDoctorRouterReader struct {
12
+ state *data.RouterState
13
+ calls int
14
+ }
15
+
16
+ func (r *stubDoctorRouterReader) ReadState(content string) (*data.RouterState, error) {
17
+ r.calls++
18
+ return r.state, nil
19
+ }
20
+
21
+ type stubDoctorDiscoverer struct {
22
+ releases []data.ReleaseInfo
23
+ epics map[string][]data.EpicInfo
24
+ tasks map[string][]data.TaskInfo
25
+ calls int
26
+ }
27
+
28
+ func (d *stubDoctorDiscoverer) ListRootDirs(root string) ([]string, error) {
29
+ d.calls++
30
+ return nil, nil
31
+ }
32
+
33
+ func (d *stubDoctorDiscoverer) ListReleases(root string) ([]data.ReleaseInfo, error) {
34
+ d.calls++
35
+ return d.releases, nil
36
+ }
37
+
38
+ func (d *stubDoctorDiscoverer) ListEpics(root, release string) ([]data.EpicInfo, error) {
39
+ d.calls++
40
+ return d.epics[release], nil
41
+ }
42
+
43
+ func (d *stubDoctorDiscoverer) ListTasks(root, release, epic string) ([]data.TaskInfo, error) {
44
+ d.calls++
45
+ return d.tasks[release+"/"+epic], nil
46
+ }
47
+
48
+ type countingDoctorParser struct {
49
+ parser *data.Parser
50
+ calls int
51
+ }
52
+
53
+ func (p *countingDoctorParser) ParseFrontmatter(content string) (map[string]any, error) {
54
+ p.calls++
55
+ return p.parser.ParseFrontmatter(content)
56
+ }
57
+
58
+ func TestCheckRouterUsesInjectedRouterReader(t *testing.T) {
59
+ root := t.TempDir()
60
+ testutil.WriteFile(t, filepath.Join(root, "router.md"), "# intentionally not a router state block")
61
+ testutil.MkdirAll(t, filepath.Join(root, "releases", "v9", "epics", "E01-mock"))
62
+
63
+ reader := &stubDoctorRouterReader{state: &data.RouterState{
64
+ State: "task-building",
65
+ Release: "v9",
66
+ Epic: "E01-mock",
67
+ }}
68
+
69
+ if err := CheckRouter(root, "", DoctorDependencies{RouterReader: reader}); err != nil {
70
+ t.Fatalf("CheckRouter() with injected reader = %v, want nil", err)
71
+ }
72
+ if reader.calls != 1 {
73
+ t.Fatalf("ReadState calls = %d, want 1", reader.calls)
74
+ }
75
+ }
76
+
77
+ func TestCheckDependenciesUsesInjectedDiscovererAndParser(t *testing.T) {
78
+ root := t.TempDir()
79
+ taskPath := filepath.Join(root, "virtual", "T001-task.md")
80
+ testutil.MkdirAll(t, filepath.Dir(taskPath))
81
+ testutil.WriteFile(t, taskPath, "---\nid: E01-mock/T001-task\nstatus: planned\nobjective: Mock\ndepends_on: []\n---\n")
82
+
83
+ discoverer := &stubDoctorDiscoverer{
84
+ releases: []data.ReleaseInfo{{ID: "v9", Path: filepath.Join(root, "virtual-release")}},
85
+ epics: map[string][]data.EpicInfo{
86
+ "v9": {{ID: "E01-mock", Path: filepath.Join(root, "virtual-epic")}},
87
+ },
88
+ tasks: map[string][]data.TaskInfo{
89
+ "v9/E01-mock": {{ID: "T001-task", Path: taskPath}},
90
+ },
91
+ }
92
+ parser := &countingDoctorParser{parser: data.NewParser()}
93
+
94
+ problems := CheckDependencies(root, "", DoctorDependencies{Discoverer: discoverer, Parser: parser})
95
+ if len(problems) > 0 {
96
+ t.Fatalf("CheckDependencies() = %v, want no problems", problems)
97
+ }
98
+ if discoverer.calls == 0 {
99
+ t.Fatal("injected discoverer was not used")
100
+ }
101
+ if parser.calls != 1 {
102
+ t.Fatalf("ParseFrontmatter calls = %d, want 1", parser.calls)
103
+ }
104
+ }
@@ -0,0 +1,80 @@
1
+ package doctor
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/opencode/savepoint/internal/data"
9
+ )
10
+
11
+ func SuggestRepair(err error) string {
12
+ switch {
13
+ case errors.Is(err, data.ErrConfigNotFound):
14
+ return "Run `savepoint init` to scaffold a new project"
15
+ case errors.Is(err, data.ErrInvalidStatus):
16
+ return "Set router state to a recognized workflow state (see router.md State → action section)"
17
+ case errors.Is(err, data.ErrMissingFrontmatter):
18
+ return "Fix the YAML frontmatter between the --- delimiters"
19
+ case errors.Is(err, data.ErrStructureProblem):
20
+ return "Review the file and fix the reported issue"
21
+ }
22
+
23
+ msg := err.Error()
24
+ switch {
25
+ case strings.Contains(msg, "config.yml not found"):
26
+ return "Run `savepoint init` to scaffold a new project"
27
+ case strings.Contains(msg, "config.yml missing required field"):
28
+ return "Add the missing field to config.yml — see the project template for reference"
29
+ case strings.Contains(msg, "invalid YAML"):
30
+ return "Fix the YAML syntax error at the indicated line"
31
+ case strings.Contains(msg, "router.md not found"):
32
+ return "Run `savepoint init` to scaffold a new project"
33
+ case strings.Contains(msg, "unknown state"):
34
+ return "Set router state to a recognized workflow state (see router.md State → action section)"
35
+ case strings.Contains(msg, "release PRD file not found"):
36
+ return "Create a {release}-PRD.md file with frontmatter for the release"
37
+ case strings.Contains(msg, "release"):
38
+ return "Create the release directory at releases/<release-id>/"
39
+ case strings.Contains(msg, "epic") && strings.Contains(msg, "directory not found"):
40
+ return "Create the epic directory at releases/<release>/epics/<epic-id>/"
41
+ case strings.Contains(msg, "epic detail file not found"):
42
+ return "Create an E##-Detail.md with frontmatter for the epic"
43
+ case strings.Contains(msg, "invalid frontmatter"):
44
+ return "Fix the YAML frontmatter between the --- delimiters"
45
+ case strings.Contains(msg, "task missing required frontmatter field"):
46
+ return "Add the missing field to the task frontmatter"
47
+ case strings.Contains(msg, "missing ## Acceptance Criteria"):
48
+ return "Add an ## Acceptance Criteria section with checkable items"
49
+ case strings.Contains(msg, "depends_on must be a list"):
50
+ return "Change depends_on to a YAML list format"
51
+ case strings.Contains(msg, "references non-existent"):
52
+ return "Create the referenced task or remove the dependency"
53
+ case strings.Contains(msg, "duplicate task ID"):
54
+ return "Rename one of the tasks to have a unique ID"
55
+ case strings.Contains(msg, "dependency cycle"):
56
+ return "Break the circular dependency chain between tasks"
57
+ case strings.Contains(msg, "audit proposal exists"):
58
+ return "Set router state to audit-pending for the matching epic, or remove stale audit files"
59
+ case strings.Contains(msg, "orphaned"):
60
+ return "Move the task directory to the correct epic or create the referenced epic"
61
+ case strings.Contains(msg, "quality gate"):
62
+ return "Fix the issue reported by the quality gate tool"
63
+ default:
64
+ return "Review the file and fix the reported issue"
65
+ }
66
+ }
67
+
68
+ // GateSuggestion returns a command-specific repair hint.
69
+ func GateSuggestion(name string) string {
70
+ switch name {
71
+ case "lint":
72
+ return "Run `make lint` locally and fix reported issues"
73
+ case "typecheck":
74
+ return "Run `make typecheck` locally and fix type errors"
75
+ case "test":
76
+ return "Run `make test` locally and fix failing tests"
77
+ default:
78
+ return fmt.Sprintf("Run %q locally and fix reported issues", name)
79
+ }
80
+ }
@@ -0,0 +1,81 @@
1
+ package doctor
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/opencode/savepoint/internal/data"
10
+ )
11
+
12
+ func TestSuggestRepair(t *testing.T) {
13
+ tests := []struct {
14
+ err error
15
+ contains string
16
+ }{
17
+ {Problem{Message: "config.yml not found"}, "savepoint init"},
18
+ {Problem{Message: "config.yml missing required field: theme"}, "Add the missing field"},
19
+ {Problem{Message: "config.yml invalid YAML: yaml: line 3: could not find expected"}, "Fix the YAML syntax"},
20
+ {Problem{Message: "router.md not found"}, "savepoint init"},
21
+ {Problem{Message: "router.md unknown state \"bogus\""}, "Set router state to a recognized"},
22
+ {Problem{Message: "router.md release \"v99\" directory not found"}, "Create the release directory"},
23
+ {Problem{Message: "router.md epic \"E99-foo\" directory not found"}, "Create the epic directory"},
24
+ {Problem{Message: "release PRD file not found"}, "Create a {release}-PRD.md"},
25
+ {Problem{Message: "epic detail file not found"}, "Create an E##-Detail.md"},
26
+ {Problem{Message: "invalid frontmatter: yaml: line 5:"}, "Fix the YAML frontmatter"},
27
+ {Problem{Message: "task missing required frontmatter field: status"}, "Add the missing field"},
28
+ {Problem{Message: "task missing ## Acceptance Criteria section"}, "Add an ## Acceptance Criteria section"},
29
+ {Problem{Message: "task frontmatter field depends_on must be a list"}, "Change depends_on to a YAML list"},
30
+ {Problem{Message: "depends_on references non-existent task \"E99/T999\""}, "Create the referenced task"},
31
+ {Problem{Message: "duplicate task ID \"E01-foo/T001-task\" (first seen in"}, "Rename one of the tasks"},
32
+ {Problem{Message: "dependency cycle detected:"}, "Break the circular dependency chain"},
33
+ {Problem{Message: "audit proposal exists but router state is"}, "Set router state to audit-pending"},
34
+ {Problem{Message: "orphaned task: epic \"E99-ghost\" does not exist"}, "Move the task directory"},
35
+ {Problem{Message: "quality gate \"lint\" failed"}, "Fix the issue reported by the quality gate"},
36
+ {Problem{Message: "some random unknown problem"}, "Review the file and fix"},
37
+ }
38
+ for _, tt := range tests {
39
+ got := SuggestRepair(tt.err)
40
+ if !strings.Contains(got, tt.contains) {
41
+ t.Errorf("SuggestRepair(%q) = %q, want containing %q", tt.err.Error(), got, tt.contains)
42
+ }
43
+ }
44
+ }
45
+
46
+ func TestGateSuggestion(t *testing.T) {
47
+ tests := []struct {
48
+ name string
49
+ contains string
50
+ }{
51
+ {"lint", "make lint"},
52
+ {"typecheck", "make typecheck"},
53
+ {"test", "make test"},
54
+ {"custom", "Run \"custom\" locally"},
55
+ }
56
+ for _, tt := range tests {
57
+ got := GateSuggestion(tt.name)
58
+ if !strings.Contains(got, tt.contains) {
59
+ t.Errorf("GateSuggestion(%q) = %q, want containing %q", tt.name, got, tt.contains)
60
+ }
61
+ }
62
+ }
63
+
64
+ func TestSuggestRepair_typedErrors(t *testing.T) {
65
+ tests := []struct {
66
+ err error
67
+ contains string
68
+ }{
69
+ {fmt.Errorf("config.yml not found: %w", data.ErrConfigNotFound), "savepoint init"},
70
+ {fmt.Errorf("router.md not found: %w", data.ErrConfigNotFound), "savepoint init"},
71
+ }
72
+ for _, tt := range tests {
73
+ if !errors.Is(tt.err, data.ErrConfigNotFound) {
74
+ t.Fatalf("test error should wrap %v", data.ErrConfigNotFound)
75
+ }
76
+ got := SuggestRepair(tt.err)
77
+ if !strings.Contains(got, tt.contains) {
78
+ t.Errorf("SuggestRepair(%q) = %q, want containing %q", tt.err.Error(), got, tt.contains)
79
+ }
80
+ }
81
+ }