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.
- package/.claude/settings.local.json +12 -1
- package/.golangci.yml +11 -0
- package/.savepoint/Design.md +37 -36
- package/.savepoint/{audit/v1.1/E02-cross-platform-compatibility/proposals.md → releases/v1.1/epics/E02-cross-platform-compatibility/E02-Audit.md} +48 -38
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Audit.md +195 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +14 -1
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +3 -3
- package/.savepoint/{audit/v1.1/E04-epic-navigation/proposals.md → releases/v1.1/epics/E04-epic-navigation/E04-Audit.md} +65 -54
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Audit.md +237 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +25 -16
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +17 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +15 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +19 -5
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +11 -1
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +9 -6
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +29 -13
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Audit.md +56 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/E06-Detail.md +63 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T005-proposals.md +44 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T007-apply-close.md +35 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T009-integration.md +40 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T010-audit-file-migration.md +45 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T011-model-tab-state.md +26 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T012-epic-audit-render.md +33 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T013-handle-tab-keys.md +34 -0
- package/.savepoint/releases/v1.1/epics/E06-audit-command/tasks/T014-tab-indicator.md +33 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Audit.md +336 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/E07-Detail.md +61 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T001-cli-entrypoint.md +37 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T002-target-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T003-scaffold-writer.md +46 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T004-atomic-writes.md +27 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T005-magic-prompt.md +25 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T006-clipboard.md +26 -0
- package/.savepoint/releases/v1.1/epics/E07-init-command/tasks/T007-integration-test.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Audit.md +333 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/E08-Detail.md +68 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T001-cli-entrypoint.md +26 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T002-non-tty-fallback.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T003-tui-app-shell.md +28 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T004-board-model.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T005-detail-pane.md +27 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T006-status-transitions.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T007-theme-fallbacks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E08-board-command/tasks/T008-integration-test.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Audit.md +207 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/E09-Detail.md +65 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T001-cli-entrypoint.md +24 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T002-config-router-validation.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T003-structure-checks.md +29 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T004-dependency-checks.md +27 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T005-audit-orphan-checks.md +28 -0
- package/.savepoint/releases/v1.1/epics/E09-doctor-command/tasks/T006-quality-gates-report.md +31 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/E11-Detail.md +36 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T001-debug-logging.md +25 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T002-increase-debounce.md +21 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T003-error-handling.md +22 -0
- package/.savepoint/releases/v1.1/epics/E11-board-refresh-fix/tasks/T004-test-verify.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Audit.md +444 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/E12-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T001-default-phase.md +35 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T002-default-status.md +19 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T003-better-errors.md +29 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T004-validate-on-write.md +25 -0
- package/.savepoint/releases/v1.1/epics/E12-validation-fix/tasks/T005-tests.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Audit.md +118 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/E13-Detail.md +73 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T001-safe-cleanup.md +66 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T002-bug-fixes.md +35 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T003-centralize-duplication.md +60 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T004-infrastructure.md +33 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T005-decompose-update.md +37 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T006-async-io.md +40 -0
- package/.savepoint/releases/v1.1/epics/E13-audit-remediation/tasks/T007-test-coverage.md +37 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Audit.md +267 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/E14-Detail.md +54 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T001-group-model.md +39 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T002-data-interfaces.md +42 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T003-discover-orphans.md +33 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T004-epic-panel-headings.md +35 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T005-shell-tokenization.md +27 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T006-unify-enums.md +29 -0
- package/.savepoint/releases/v1.1/epics/E14-structural-improvements/tasks/T007-testutil-package.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +43 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +31 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +30 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +27 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +28 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +26 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +28 -0
- package/.savepoint/releases/v1.1/epics/_archived/T001-cli-entrypoint.md +25 -0
- package/.savepoint/releases/v1.1/epics/_archived/T002-quality-gates.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T003-snapshot.md +27 -0
- package/.savepoint/releases/v1.1/epics/_archived/T004-ai-reconcile.md +29 -0
- package/.savepoint/releases/v1.1/epics/_archived/T006-tui-review.md +31 -0
- package/.savepoint/releases/v1.1/epics/_archived/T008-skip-handling.md +34 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +67 -7
- package/.savepoint/router.md +9 -16
- package/AGENTS.md +38 -23
- package/README.md +0 -1
- package/agent-skills/savepoint-audit/SKILL.md +86 -34
- package/agent-skills/savepoint-build-task/SKILL.md +7 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +7 -2
- package/agent-skills/savepoint-create-task/SKILL.md +44 -31
- package/agent-skills/savepoint-draft-prd/SKILL.md +7 -2
- package/agent-skills/savepoint-system-design/SKILL.md +7 -2
- package/agent_skills_test.go +91 -0
- package/cmd/board.go +59 -0
- package/cmd/board_test.go +137 -0
- package/cmd/doctor.go +53 -0
- package/cmd/doctor_test.go +146 -0
- package/cmd/init.go +63 -0
- package/cmd/init_test.go +104 -0
- package/internal/board/board.go +40 -36
- package/internal/board/board_test.go +27 -82
- package/internal/board/card.go +43 -23
- package/internal/board/card_test.go +41 -5
- package/internal/board/column.go +44 -13
- package/internal/board/column_test.go +5 -2
- package/internal/board/detail.go +0 -47
- package/internal/board/epic_panel.go +118 -22
- package/internal/board/epic_panel_test.go +302 -17
- package/internal/board/help.go +1 -0
- package/internal/board/help_test.go +1 -0
- package/internal/board/integration_test.go +266 -0
- package/internal/board/interfaces.go +65 -0
- package/internal/board/interfaces_test.go +114 -0
- package/internal/board/io.go +93 -0
- package/internal/board/model.go +79 -118
- package/internal/board/plain.go +88 -0
- package/internal/board/plain_test.go +117 -0
- package/internal/board/release.go +1 -9
- package/internal/board/release_test.go +6 -6
- package/internal/board/status.go +4 -4
- package/internal/board/theme.go +24 -0
- package/internal/board/theme_test.go +31 -0
- package/internal/board/transitions.go +113 -88
- package/internal/board/transitions_test.go +164 -141
- package/internal/board/tui.go +32 -0
- package/internal/board/update.go +325 -215
- package/internal/board/update_test.go +299 -18
- package/internal/board/util.go +76 -0
- package/internal/board/view.go +31 -28
- package/internal/board/view_test.go +12 -2
- package/internal/board/watch.go +35 -5
- package/internal/buildtool/main.go +2 -10
- package/internal/buildtool/main_test.go +46 -0
- package/internal/data/config.go +17 -3
- package/internal/data/config_test.go +49 -0
- package/internal/data/discover.go +26 -0
- package/internal/data/discover_test.go +34 -10
- package/internal/data/errors.go +4 -0
- package/internal/data/lifecycle.go +13 -6
- package/internal/data/lifecycle_test.go +14 -11
- package/internal/data/parser.go +21 -6
- package/internal/data/parser_test.go +31 -7
- package/internal/data/task.go +0 -9
- package/internal/data/write.go +85 -11
- package/internal/data/write_test.go +167 -0
- package/internal/doctor/checks.go +567 -0
- package/internal/doctor/checks_test.go +716 -0
- package/internal/doctor/gates.go +193 -0
- package/internal/doctor/gates_test.go +166 -0
- package/internal/doctor/interfaces.go +64 -0
- package/internal/doctor/interfaces_test.go +104 -0
- package/internal/doctor/repairs.go +80 -0
- package/internal/doctor/repairs_test.go +81 -0
- package/internal/doctor/report.go +157 -0
- package/internal/doctor/report_test.go +89 -0
- package/internal/init/clipboard.go +146 -0
- package/internal/init/clipboard_test.go +74 -0
- package/internal/init/install.go +16 -0
- package/internal/init/integration_test.go +197 -0
- package/internal/init/prompt.go +14 -0
- package/internal/init/prompt_test.go +77 -0
- package/internal/init/scaffold.go +59 -0
- package/internal/init/scaffold_test.go +179 -0
- package/internal/init/template_freshness_test.go +56 -0
- package/internal/init/validate.go +85 -0
- package/internal/init/validate_test.go +141 -0
- package/internal/init/write.go +73 -0
- package/internal/init/write_test.go +91 -0
- package/internal/styles/styles_test.go +133 -0
- package/internal/testutil/fixture.go +113 -0
- package/internal/testutil/fs.go +26 -0
- package/main.go +101 -4
- package/package.json +2 -2
- package/project-audit/audit_report_glm_5.1.md +411 -0
- package/project-audit/audit_report_opus_4.6 +406 -0
- package/project-audit/consolidated-audit-report.md +456 -0
- package/savepoint +0 -0
- package/templates/project/.savepoint/Design.md +2 -2
- package/templates/project/.savepoint/router.md +10 -10
- package/templates/project/AGENTS.md +33 -21
- package/templates/project/agent-skills/savepoint-audit/SKILL.md +87 -0
- package/templates/project/agent-skills/savepoint-build-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-create-plan/SKILL.md +33 -0
- package/templates/project/agent-skills/savepoint-create-task/SKILL.md +44 -0
- package/templates/project/agent-skills/savepoint-draft-prd/SKILL.md +37 -0
- package/templates/project/agent-skills/savepoint-system-design/SKILL.md +38 -0
- package/templates/prompts/audit-reconciliation.prompt.md +33 -28
- package/templates/prompts/design.prompt.md +3 -1
- package/.savepoint/audit/v1/E01/proposals.md +0 -168
- package/.savepoint/audit/v1/E01/snapshot.md +0 -78
- package/.savepoint/audit/v1/E01-go-setup/proposals.md +0 -166
- package/.savepoint/audit/v1/E01-go-setup/snapshot.md +0 -71
- package/.savepoint/audit/v1/E01-scaffolding/proposals/AGENTS.md +0 -66
- package/.savepoint/audit/v1/E01-scaffolding/proposals/Design.md +0 -210
- package/.savepoint/audit/v1/E01-scaffolding/proposals/epic-Design.md +0 -117
- package/.savepoint/audit/v1/E01-scaffolding/proposals/quality-review.md +0 -101
- package/.savepoint/audit/v1/E01-scaffolding/snapshot.md +0 -54
- package/.savepoint/audit/v1/E02-data-model/snapshot.md +0 -128
- package/.savepoint/audit/v1/E02-data-readers/proposals.md +0 -123
- package/.savepoint/audit/v1/E02-data-readers/snapshot.md +0 -54
- package/.savepoint/audit/v1/E03-board-tui-core/proposals.md +0 -146
- package/.savepoint/audit/v1/E03-board-tui-core/snapshot.md +0 -57
- package/.savepoint/audit/v1/E03-cli-foundation/snapshot.md +0 -106
- package/.savepoint/audit/v1/E04-board-components/proposals.md +0 -118
- package/.savepoint/audit/v1/E04-board-components/snapshot.md +0 -77
- package/.savepoint/audit/v1/E04-templates-and-prompts/snapshot.md +0 -115
- package/.savepoint/audit/v1/E05-init-command/snapshot.md +0 -125
- package/.savepoint/audit/v1/E05-phase-transitions/proposals.md +0 -83
- package/.savepoint/audit/v1/E05-phase-transitions/snapshot.md +0 -36
- package/.savepoint/audit/v1/E06-atari-noir-layout/proposals.md +0 -130
- package/.savepoint/audit/v1/E06-atari-noir-layout/snapshot.md +0 -84
- package/.savepoint/audit/v1/E06-tui-board/snapshot.md +0 -64
- package/.savepoint/audit/v1/E07-audit-pipeline/snapshot.md +0 -165
- package/.savepoint/audit/v1/E08-board-workflow-cleanup/snapshot.md +0 -65
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +0 -41
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +0 -48
- package/ink-cli-ui-design.zip +0 -0
- package/savepoint.exe +0 -0
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"io"
|
|
10
10
|
"os"
|
|
11
11
|
"os/exec"
|
|
12
|
+
"strings"
|
|
12
13
|
"path/filepath"
|
|
13
14
|
"runtime"
|
|
14
15
|
)
|
|
@@ -196,7 +197,7 @@ func version() string {
|
|
|
196
197
|
cmd := exec.Command("git", "describe", "--tags", "--abbrev=0")
|
|
197
198
|
output, err := cmd.Output()
|
|
198
199
|
if err == nil && len(output) > 0 {
|
|
199
|
-
return string(
|
|
200
|
+
return strings.TrimSpace(string(output))
|
|
200
201
|
}
|
|
201
202
|
return "v0.0.0"
|
|
202
203
|
}
|
|
@@ -208,12 +209,3 @@ func localExecutable() string {
|
|
|
208
209
|
return "savepoint"
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
func trimSpace(value []byte) []byte {
|
|
212
|
-
for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == '\t' || value[len(value)-1] == ' ') {
|
|
213
|
-
value = value[:len(value)-1]
|
|
214
|
-
}
|
|
215
|
-
for len(value) > 0 && (value[0] == '\n' || value[0] == '\r' || value[0] == '\t' || value[0] == ' ') {
|
|
216
|
-
value = value[1:]
|
|
217
|
-
}
|
|
218
|
-
return value
|
|
219
|
-
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"runtime"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestVersion_override(t *testing.T) {
|
|
10
|
+
versionOverride = "v1.2.3"
|
|
11
|
+
defer func() { versionOverride = "" }()
|
|
12
|
+
if got := version(); got != "v1.2.3" {
|
|
13
|
+
t.Errorf("version() = %q, want %q", got, "v1.2.3")
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func TestVersion_env(t *testing.T) {
|
|
18
|
+
versionOverride = ""
|
|
19
|
+
os.Setenv("VERSION", "v2.0.0-env")
|
|
20
|
+
defer os.Unsetenv("VERSION")
|
|
21
|
+
if got := version(); got != "v2.0.0-env" {
|
|
22
|
+
t.Errorf("version() = %q, want %q", got, "v2.0.0-env")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestVersion_fallback(t *testing.T) {
|
|
27
|
+
versionOverride = ""
|
|
28
|
+
os.Unsetenv("VERSION")
|
|
29
|
+
got := version()
|
|
30
|
+
if got == "" {
|
|
31
|
+
t.Error("version() returned empty string")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestLocalExecutable(t *testing.T) {
|
|
36
|
+
got := localExecutable()
|
|
37
|
+
if runtime.GOOS == "windows" {
|
|
38
|
+
if got != "savepoint.exe" {
|
|
39
|
+
t.Errorf("localExecutable() = %q, want %q", got, "savepoint.exe")
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
if got != "savepoint" {
|
|
43
|
+
t.Errorf("localExecutable() = %q, want %q", got, "savepoint")
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/internal/data/config.go
CHANGED
|
@@ -16,8 +16,17 @@ type Theme struct {
|
|
|
16
16
|
Accents map[string]string `yaml:"accents"`
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
type QualityGates struct {
|
|
20
|
+
Lint *string `yaml:"lint"`
|
|
21
|
+
Typecheck *string `yaml:"typecheck"`
|
|
22
|
+
Test *string `yaml:"test"`
|
|
23
|
+
BlockOnFailure bool `yaml:"block_on_failure"`
|
|
24
|
+
Timeout string `yaml:"gate_timeout"`
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
type Config struct {
|
|
20
|
-
Theme
|
|
28
|
+
Theme Theme `yaml:"theme"`
|
|
29
|
+
QualityGates QualityGates `yaml:"quality_gates"`
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
var defaultTheme = Theme{
|
|
@@ -80,8 +89,13 @@ func fillThemeDefaults(theme Theme) Theme {
|
|
|
80
89
|
if theme.Text == "" {
|
|
81
90
|
theme.Text = defaultTheme.Text
|
|
82
91
|
}
|
|
83
|
-
if
|
|
84
|
-
theme.Accents =
|
|
92
|
+
if theme.Accents == nil {
|
|
93
|
+
theme.Accents = make(map[string]string)
|
|
94
|
+
}
|
|
95
|
+
for k, v := range defaultTheme.Accents {
|
|
96
|
+
if _, ok := theme.Accents[k]; !ok {
|
|
97
|
+
theme.Accents[k] = v
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
100
|
return theme
|
|
87
101
|
}
|
|
@@ -53,6 +53,55 @@ func TestConfigReaderRead(t *testing.T) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
func TestFillThemeDefaults_PartialAccents(t *testing.T) {
|
|
57
|
+
theme := Theme{
|
|
58
|
+
BG: "#000000",
|
|
59
|
+
Accents: map[string]string{"planned": "#ff0000"},
|
|
60
|
+
}
|
|
61
|
+
result := fillThemeDefaults(theme)
|
|
62
|
+
if result.Accents["planned"] != "#ff0000" {
|
|
63
|
+
t.Errorf("Accents[planned] = %v, want #ff0000 (user value preserved)", result.Accents["planned"])
|
|
64
|
+
}
|
|
65
|
+
if result.Accents["in_progress"] != defaultTheme.Accents["in_progress"] {
|
|
66
|
+
t.Errorf("Accents[in_progress] = %v, want default %v", result.Accents["in_progress"], defaultTheme.Accents["in_progress"])
|
|
67
|
+
}
|
|
68
|
+
if result.Accents["done"] != defaultTheme.Accents["done"] {
|
|
69
|
+
t.Errorf("Accents[done] = %v, want default %v", result.Accents["done"], defaultTheme.Accents["done"])
|
|
70
|
+
}
|
|
71
|
+
if result.Accents["blocked"] != defaultTheme.Accents["blocked"] {
|
|
72
|
+
t.Errorf("Accents[blocked] = %v, want default %v", result.Accents["blocked"], defaultTheme.Accents["blocked"])
|
|
73
|
+
}
|
|
74
|
+
if result.Accents["epic"] != defaultTheme.Accents["epic"] {
|
|
75
|
+
t.Errorf("Accents[epic] = %v, want default %v", result.Accents["epic"], defaultTheme.Accents["epic"])
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestFillThemeDefaults_NilAccents(t *testing.T) {
|
|
80
|
+
theme := Theme{
|
|
81
|
+
BG: "#000000",
|
|
82
|
+
Accents: nil,
|
|
83
|
+
}
|
|
84
|
+
result := fillThemeDefaults(theme)
|
|
85
|
+
for k, v := range defaultTheme.Accents {
|
|
86
|
+
if result.Accents[k] != v {
|
|
87
|
+
t.Errorf("Accents[%s] = %v, want default %v", k, result.Accents[k], v)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestFillThemeDefaults_EmptyAccents(t *testing.T) {
|
|
93
|
+
theme := Theme{
|
|
94
|
+
BG: "#000000",
|
|
95
|
+
Accents: map[string]string{},
|
|
96
|
+
}
|
|
97
|
+
result := fillThemeDefaults(theme)
|
|
98
|
+
for k, v := range defaultTheme.Accents {
|
|
99
|
+
if result.Accents[k] != v {
|
|
100
|
+
t.Errorf("Accents[%s] = %v, want default %v", k, result.Accents[k], v)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
56
105
|
func TestConfigReaderMalformedYAML(t *testing.T) {
|
|
57
106
|
tmpfile, err := os.CreateTemp("", "config-*.yml")
|
|
58
107
|
if err != nil {
|
|
@@ -85,6 +85,32 @@ func (d *Discover) ListReleases(root string) ([]ReleaseInfo, error) {
|
|
|
85
85
|
return releases, nil
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ListRootDirs returns sorted child directory names directly under root.
|
|
89
|
+
func (d *Discover) ListRootDirs(root string) ([]string, error) {
|
|
90
|
+
info, err := os.Stat(root)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return nil, err
|
|
93
|
+
}
|
|
94
|
+
if !info.IsDir() {
|
|
95
|
+
return nil, fmt.Errorf("%s is not a directory", root)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
entries, err := os.ReadDir(root)
|
|
99
|
+
if err != nil {
|
|
100
|
+
return nil, err
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
var dirs []string
|
|
104
|
+
for _, entry := range entries {
|
|
105
|
+
if entry.IsDir() {
|
|
106
|
+
dirs = append(dirs, entry.Name())
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
sort.Strings(dirs)
|
|
111
|
+
return dirs, nil
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
func (d *Discover) ListEpics(root, release string) ([]EpicInfo, error) {
|
|
89
115
|
epicsPath := filepath.Join(root, "releases", release, "epics")
|
|
90
116
|
info, err := os.Stat(epicsPath)
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
package data
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
-
"os"
|
|
5
4
|
"path/filepath"
|
|
6
5
|
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/opencode/savepoint/internal/testutil"
|
|
7
8
|
)
|
|
8
9
|
|
|
9
10
|
func TestFindSavepointRoot(t *testing.T) {
|
|
10
11
|
d := NewDiscover()
|
|
11
12
|
savepointRoot := createDiscoveryFixture(t)
|
|
12
13
|
start := filepath.Join(filepath.Dir(savepointRoot), "nested", "child")
|
|
13
|
-
|
|
14
|
-
t.Fatal(err)
|
|
15
|
-
}
|
|
14
|
+
testutil.MkdirAll(t, start)
|
|
16
15
|
|
|
17
16
|
root, err := d.FindSavepointRoot(start)
|
|
18
17
|
if err != nil {
|
|
@@ -40,6 +39,35 @@ func TestListReleases(t *testing.T) {
|
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
func TestListRootDirs(t *testing.T) {
|
|
43
|
+
d := NewDiscover()
|
|
44
|
+
root := t.TempDir()
|
|
45
|
+
testutil.MkdirAll(t, filepath.Join(root, "beta"))
|
|
46
|
+
testutil.MkdirAll(t, filepath.Join(root, "alpha"))
|
|
47
|
+
testutil.WriteFile(t, filepath.Join(root, "notes.txt"), "test")
|
|
48
|
+
|
|
49
|
+
dirs, err := d.ListRootDirs(root)
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatalf("ListRootDirs() error = %v", err)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if len(dirs) != 2 || dirs[0] != "alpha" || dirs[1] != "beta" {
|
|
55
|
+
t.Fatalf("ListRootDirs() = %v, want [alpha beta]", dirs)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestListRootDirsRejectsFile(t *testing.T) {
|
|
60
|
+
d := NewDiscover()
|
|
61
|
+
root := t.TempDir()
|
|
62
|
+
path := filepath.Join(root, "not-dir")
|
|
63
|
+
testutil.WriteFile(t, path, "test")
|
|
64
|
+
|
|
65
|
+
_, err := d.ListRootDirs(path)
|
|
66
|
+
if err == nil {
|
|
67
|
+
t.Fatal("ListRootDirs() error = nil, want not directory error")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
func TestListEpics(t *testing.T) {
|
|
44
72
|
d := NewDiscover()
|
|
45
73
|
root := createDiscoveryFixture(t)
|
|
@@ -86,9 +114,7 @@ func createDiscoveryFixture(t *testing.T) string {
|
|
|
86
114
|
filepath.Join(savepointRoot, "releases", "v2", "epics"),
|
|
87
115
|
}
|
|
88
116
|
for _, path := range paths {
|
|
89
|
-
|
|
90
|
-
t.Fatal(err)
|
|
91
|
-
}
|
|
117
|
+
testutil.MkdirAll(t, path)
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
files := []string{
|
|
@@ -97,9 +123,7 @@ func createDiscoveryFixture(t *testing.T) string {
|
|
|
97
123
|
filepath.Join(savepointRoot, "releases", "v1", "epics", "E02-data-readers", "tasks", "notes.txt"),
|
|
98
124
|
}
|
|
99
125
|
for _, file := range files {
|
|
100
|
-
|
|
101
|
-
t.Fatal(err)
|
|
102
|
-
}
|
|
126
|
+
testutil.WriteFile(t, file, "test")
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
return savepointRoot
|
package/internal/data/errors.go
CHANGED
|
@@ -6,4 +6,8 @@ var (
|
|
|
6
6
|
ErrNoFrontmatter = errors.New("no frontmatter found")
|
|
7
7
|
ErrNoClosingFrontmatter = errors.New("no closing frontmatter delimiter found")
|
|
8
8
|
ErrSavepointDirectoryMissing = errors.New(".savepoint directory not found")
|
|
9
|
+
ErrInvalidStatus = errors.New("invalid router state")
|
|
10
|
+
ErrMissingFrontmatter = errors.New("missing or invalid frontmatter")
|
|
11
|
+
ErrConfigNotFound = errors.New("configuration file not found")
|
|
12
|
+
ErrStructureProblem = errors.New("project structure problem")
|
|
9
13
|
)
|
|
@@ -2,17 +2,24 @@ package data
|
|
|
2
2
|
|
|
3
3
|
import "fmt"
|
|
4
4
|
|
|
5
|
-
func ValidateTaskLifecycle(task Task) error {
|
|
5
|
+
func ValidateTaskLifecycle(task *Task) error {
|
|
6
6
|
if !IsCanonicalColumn(task.Column) {
|
|
7
|
-
return fmt.Errorf("invalid
|
|
7
|
+
return fmt.Errorf("invalid status %q: use planned, in_progress, or done. Add 'status: planned' or 'status: in_progress' to task frontmatter", task.Column)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
if task.Column
|
|
11
|
-
|
|
10
|
+
if task.Column == ColumnInProgress {
|
|
11
|
+
if task.Stage == "" {
|
|
12
|
+
task.Stage = StageBuild
|
|
13
|
+
return nil
|
|
14
|
+
}
|
|
15
|
+
if !IsCanonicalStage(task.Stage) {
|
|
16
|
+
return fmt.Errorf("invalid phase %q: use build, test, or audit. Add 'phase: build' to task frontmatter", task.Stage)
|
|
17
|
+
}
|
|
18
|
+
return nil
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
if task.
|
|
15
|
-
return fmt.Errorf("
|
|
21
|
+
if task.Stage != "" {
|
|
22
|
+
return fmt.Errorf("phase field %q is only valid when status is in_progress. Remove 'phase' or change status to in_progress", task.Stage)
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
return nil
|
|
@@ -4,35 +4,38 @@ import "testing"
|
|
|
4
4
|
|
|
5
5
|
func TestValidateTaskLifecycle_allowsPlannedWithoutPhase(t *testing.T) {
|
|
6
6
|
task := Task{Column: ColumnPlanned}
|
|
7
|
-
if err := ValidateTaskLifecycle(task); err != nil {
|
|
7
|
+
if err := ValidateTaskLifecycle(&task); err != nil {
|
|
8
8
|
t.Fatalf("ValidateTaskLifecycle() error = %v", err)
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
func TestValidateTaskLifecycle_defaultsInProgressWithoutPhase(t *testing.T) {
|
|
13
|
+
task := Task{Column: ColumnInProgress}
|
|
14
|
+
if err := ValidateTaskLifecycle(&task); err != nil {
|
|
15
|
+
t.Fatalf("ValidateTaskLifecycle() error = %v", err)
|
|
16
|
+
}
|
|
17
|
+
if task.Stage != StageBuild {
|
|
18
|
+
t.Fatalf("Task.Stage = %q, want %q", task.Stage, StageBuild)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
func TestValidateTaskLifecycle_allowsInProgressWithPhase(t *testing.T) {
|
|
13
23
|
task := Task{Column: ColumnInProgress, Stage: StageAudit}
|
|
14
|
-
if err := ValidateTaskLifecycle(task); err != nil {
|
|
24
|
+
if err := ValidateTaskLifecycle(&task); err != nil {
|
|
15
25
|
t.Fatalf("ValidateTaskLifecycle() error = %v", err)
|
|
16
26
|
}
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
func TestValidateTaskLifecycle_rejectsUnknownStatus(t *testing.T) {
|
|
20
30
|
task := Task{Column: "review"}
|
|
21
|
-
if err := ValidateTaskLifecycle(task); err == nil {
|
|
31
|
+
if err := ValidateTaskLifecycle(&task); err == nil {
|
|
22
32
|
t.Fatal("ValidateTaskLifecycle() expected unknown status error")
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
func TestValidateTaskLifecycle_rejectsPhaseOutsideInProgress(t *testing.T) {
|
|
27
37
|
task := Task{Column: ColumnPlanned, Stage: StageBuild}
|
|
28
|
-
if err := ValidateTaskLifecycle(task); err == nil {
|
|
38
|
+
if err := ValidateTaskLifecycle(&task); err == nil {
|
|
29
39
|
t.Fatal("ValidateTaskLifecycle() expected phase/status error")
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
|
-
|
|
33
|
-
func TestValidateTaskLifecycle_rejectsInProgressWithoutCanonicalPhase(t *testing.T) {
|
|
34
|
-
task := Task{Column: ColumnInProgress}
|
|
35
|
-
if err := ValidateTaskLifecycle(task); err == nil {
|
|
36
|
-
t.Fatal("ValidateTaskLifecycle() expected missing phase error")
|
|
37
|
-
}
|
|
38
|
-
}
|
package/internal/data/parser.go
CHANGED
|
@@ -46,7 +46,7 @@ func (p *Parser) ParseTaskFile(path string, content string) (*Task, error) {
|
|
|
46
46
|
Epic: firstNonEmpty(fields.Epic, extractEpicFromID(fields.ID)),
|
|
47
47
|
Release: firstNonEmpty(fields.Release, "v1"),
|
|
48
48
|
Column: normalizeColumn(rawColumn),
|
|
49
|
-
Stage: firstStage(fields.
|
|
49
|
+
Stage: firstStage(fields.Phase, fields.Stage),
|
|
50
50
|
Priority: fields.Priority,
|
|
51
51
|
Points: fields.Points,
|
|
52
52
|
Tags: fields.Tags,
|
|
@@ -61,6 +61,10 @@ func (p *Parser) ParseTaskFile(path string, content string) (*Task, error) {
|
|
|
61
61
|
return nil, fmt.Errorf("parse error for %s: %w", path, err)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
if task.Column == ColumnInProgress && task.Stage == "" {
|
|
65
|
+
task.Stage = StageBuild
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
return task, nil
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -84,8 +88,13 @@ type taskFrontmatter struct {
|
|
|
84
88
|
Progress Progress `yaml:"progress"`
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
// normalizeLineEndings replaces Windows line endings with Unix line endings.
|
|
92
|
+
func normalizeLineEndings(s string) string {
|
|
93
|
+
return strings.ReplaceAll(s, "\r\n", "\n")
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
func extractFrontmatter(content string) (string, error) {
|
|
88
|
-
normalized :=
|
|
97
|
+
normalized := normalizeLineEndings(content)
|
|
89
98
|
if !strings.HasPrefix(normalized, "---\n") {
|
|
90
99
|
return "", ErrNoFrontmatter
|
|
91
100
|
}
|
|
@@ -139,9 +148,15 @@ const legacyTodoColumn ColumnType = "todo"
|
|
|
139
148
|
|
|
140
149
|
func validateParsedTaskLifecycle(rawColumn ColumnType, task Task) error {
|
|
141
150
|
if rawColumn != "" && rawColumn != legacyTodoColumn && !IsCanonicalColumn(rawColumn) {
|
|
142
|
-
return fmt.Errorf("invalid task status %q: use planned, in_progress, or done", rawColumn)
|
|
151
|
+
return fmt.Errorf("invalid task status %q: use planned, in_progress, or done. Add 'status: planned' or 'status: in_progress' to task frontmatter", rawColumn)
|
|
143
152
|
}
|
|
144
|
-
|
|
153
|
+
if task.Column == ColumnInProgress && !IsCanonicalStage(task.Stage) && task.Stage != "" {
|
|
154
|
+
return fmt.Errorf("invalid phase %q: use build, test, or audit. Add 'phase: build' to task frontmatter", task.Stage)
|
|
155
|
+
}
|
|
156
|
+
if task.Column != ColumnInProgress && task.Stage != "" {
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
return nil
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
func firstStage(values ...ProgressStage) ProgressStage {
|
|
@@ -163,7 +178,7 @@ func firstList(values ...[]string) []string {
|
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
func extractChecklistItems(content, heading string) []CheckItem {
|
|
166
|
-
normalized :=
|
|
181
|
+
normalized := normalizeLineEndings(content)
|
|
167
182
|
start := strings.Index(normalized, heading)
|
|
168
183
|
if start == -1 {
|
|
169
184
|
return nil
|
|
@@ -201,7 +216,7 @@ func extractChecklistItems(content, heading string) []CheckItem {
|
|
|
201
216
|
}
|
|
202
217
|
|
|
203
218
|
func extractChecklistSection(content, heading string) []string {
|
|
204
|
-
normalized :=
|
|
219
|
+
normalized := normalizeLineEndings(content)
|
|
205
220
|
start := strings.Index(normalized, heading)
|
|
206
221
|
if start == -1 {
|
|
207
222
|
return nil
|
|
@@ -146,7 +146,7 @@ objective: "Style the board"
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
func
|
|
149
|
+
func TestParseTaskFile_allowsPhaseOutsideInProgress(t *testing.T) {
|
|
150
150
|
p := NewParser()
|
|
151
151
|
content := `---
|
|
152
152
|
id: E06/T001
|
|
@@ -158,12 +158,12 @@ objective: "Style the board"
|
|
|
158
158
|
# Task`
|
|
159
159
|
|
|
160
160
|
_, err := p.ParseTaskFile("test.md", content)
|
|
161
|
-
if err
|
|
162
|
-
t.
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatalf("ParseTaskFile() error = %v, want no error for legacy phase field", err)
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
func
|
|
166
|
+
func TestParseTaskFile_includesDefaultBuildForInProgress(t *testing.T) {
|
|
167
167
|
p := NewParser()
|
|
168
168
|
content := `---
|
|
169
169
|
id: E06/T001
|
|
@@ -173,9 +173,33 @@ objective: "Style the board"
|
|
|
173
173
|
|
|
174
174
|
# Task`
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
if err
|
|
178
|
-
t.
|
|
176
|
+
task, err := p.ParseTaskFile("test.md", content)
|
|
177
|
+
if err != nil {
|
|
178
|
+
t.Fatalf("ParseTaskFile() error = %v", err)
|
|
179
|
+
}
|
|
180
|
+
if task.Stage != StageBuild {
|
|
181
|
+
t.Fatalf("ParseTaskFile() expected StageBuild default, got %q", task.Stage)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func TestParseTaskFile_prefersPhaseOverLegacyStage(t *testing.T) {
|
|
186
|
+
p := NewParser()
|
|
187
|
+
content := `---
|
|
188
|
+
id: E06/T001
|
|
189
|
+
status: in_progress
|
|
190
|
+
stage: build
|
|
191
|
+
phase: test
|
|
192
|
+
objective: "Style the board"
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
# Task`
|
|
196
|
+
|
|
197
|
+
task, err := p.ParseTaskFile("test.md", content)
|
|
198
|
+
if err != nil {
|
|
199
|
+
t.Fatalf("ParseTaskFile() error = %v", err)
|
|
200
|
+
}
|
|
201
|
+
if task.Stage != StageTest {
|
|
202
|
+
t.Fatalf("Task.Stage = %q, want test from phase", task.Stage)
|
|
179
203
|
}
|
|
180
204
|
}
|
|
181
205
|
|
package/internal/data/task.go
CHANGED
|
@@ -26,15 +26,6 @@ const (
|
|
|
26
26
|
StageAudit ProgressStage = "audit"
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
type TaskStatus string
|
|
30
|
-
|
|
31
|
-
const (
|
|
32
|
-
StatusPlanned TaskStatus = "planned"
|
|
33
|
-
StatusInProgress TaskStatus = "in_progress"
|
|
34
|
-
StatusDone TaskStatus = "done"
|
|
35
|
-
StatusAudited TaskStatus = "audited"
|
|
36
|
-
)
|
|
37
|
-
|
|
38
29
|
type Progress struct {
|
|
39
30
|
Stage ProgressStage `yaml:"stage"`
|
|
40
31
|
Started bool `yaml:"started"`
|
package/internal/data/write.go
CHANGED
|
@@ -11,9 +11,88 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
var ErrMtimeConflict = fmt.Errorf("file modified since last read")
|
|
14
|
+
var ErrProposalNotFound = fmt.Errorf("target text not found in file")
|
|
15
|
+
|
|
16
|
+
// ApplyProposal replaces the first occurrence of old with newText in the file at path.
|
|
17
|
+
func ApplyProposal(path, old, newText string) error {
|
|
18
|
+
content, err := os.ReadFile(path)
|
|
19
|
+
if err != nil {
|
|
20
|
+
return fmt.Errorf("read %s: %w", path, err)
|
|
21
|
+
}
|
|
22
|
+
normalized := normalizeLineEndings(string(content))
|
|
23
|
+
if !strings.Contains(normalized, old) {
|
|
24
|
+
return fmt.Errorf("%w: %s", ErrProposalNotFound, path)
|
|
25
|
+
}
|
|
26
|
+
updated := strings.Replace(normalized, old, newText, 1)
|
|
27
|
+
return os.WriteFile(path, []byte(updated), 0644)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// UpdateEpicStatus sets the status field in the frontmatter of an E##-Detail.md file.
|
|
31
|
+
func UpdateEpicStatus(path, status string) error {
|
|
32
|
+
return updateFrontmatterField(path, "status", status)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// UpdateLastAudited sets the last_audited field in the frontmatter of Design.md.
|
|
36
|
+
func UpdateLastAudited(path, value string) error {
|
|
37
|
+
return updateFrontmatterField(path, "last_audited", value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// SplitFrontmatterBody splits content into frontmatter YAML and body.
|
|
41
|
+
func SplitFrontmatterBody(content string) (yamlStr string, body string, err error) {
|
|
42
|
+
normalized := normalizeLineEndings(content)
|
|
43
|
+
raw, err := extractFrontmatter(normalized)
|
|
44
|
+
if err != nil {
|
|
45
|
+
return "", "", err
|
|
46
|
+
}
|
|
47
|
+
delimLen := 4
|
|
48
|
+
bodyStart := delimLen + len(raw) + delimLen
|
|
49
|
+
body = ""
|
|
50
|
+
if bodyStart < len(normalized) {
|
|
51
|
+
body = normalized[bodyStart:]
|
|
52
|
+
}
|
|
53
|
+
return raw, body, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func updateFrontmatterField(path, key, value string) error {
|
|
57
|
+
content, err := os.ReadFile(path)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return fmt.Errorf("read %s: %w", path, err)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
normalized := normalizeLineEndings(string(content))
|
|
63
|
+
|
|
64
|
+
raw, body, err := SplitFrontmatterBody(normalized)
|
|
65
|
+
if err != nil {
|
|
66
|
+
return fmt.Errorf("extract frontmatter: %w", err)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var doc yaml.Node
|
|
70
|
+
if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
|
|
71
|
+
return fmt.Errorf("parse yaml: %w", err)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
|
|
75
|
+
return fmt.Errorf("unexpected yaml structure")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
mapping := doc.Content[0]
|
|
79
|
+
if mapping.Kind != yaml.MappingNode {
|
|
80
|
+
return fmt.Errorf("frontmatter is not a mapping")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setMappingField(mapping, key, value)
|
|
84
|
+
|
|
85
|
+
out, err := yaml.Marshal(&doc)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return fmt.Errorf("marshal yaml: %w", err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
|
|
91
|
+
return os.WriteFile(path, []byte(newContent), 0644)
|
|
92
|
+
}
|
|
14
93
|
|
|
15
94
|
func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
|
|
16
|
-
if err := ValidateTaskLifecycle(
|
|
95
|
+
if err := ValidateTaskLifecycle(task); err != nil {
|
|
17
96
|
return err
|
|
18
97
|
}
|
|
19
98
|
|
|
@@ -31,9 +110,9 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
|
|
|
31
110
|
return fmt.Errorf("read %s: %w", path, err)
|
|
32
111
|
}
|
|
33
112
|
|
|
34
|
-
normalized :=
|
|
113
|
+
normalized := normalizeLineEndings(string(content))
|
|
35
114
|
|
|
36
|
-
raw, err :=
|
|
115
|
+
raw, body, err := SplitFrontmatterBody(normalized)
|
|
37
116
|
if err != nil {
|
|
38
117
|
return fmt.Errorf("extract frontmatter: %w", err)
|
|
39
118
|
}
|
|
@@ -56,8 +135,10 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
|
|
|
56
135
|
|
|
57
136
|
if task.Stage == "" {
|
|
58
137
|
removeMappingField(mapping, "phase")
|
|
138
|
+
removeMappingField(mapping, "stage")
|
|
59
139
|
} else {
|
|
60
140
|
setMappingField(mapping, "phase", string(task.Stage))
|
|
141
|
+
removeMappingField(mapping, "stage")
|
|
61
142
|
}
|
|
62
143
|
|
|
63
144
|
out, err := yaml.Marshal(&doc)
|
|
@@ -65,13 +146,6 @@ func WriteTaskStatus(path string, task *Task, expectedMtime time.Time) error {
|
|
|
65
146
|
return fmt.Errorf("marshal yaml: %w", err)
|
|
66
147
|
}
|
|
67
148
|
|
|
68
|
-
delimLen := 4
|
|
69
|
-
bodyStart := delimLen + len(raw) + delimLen
|
|
70
|
-
body := ""
|
|
71
|
-
if bodyStart < len(normalized) {
|
|
72
|
-
body = normalized[bodyStart:]
|
|
73
|
-
}
|
|
74
|
-
|
|
75
149
|
newContent := "---\n" + strings.TrimSpace(string(out)) + "\n---" + body
|
|
76
150
|
|
|
77
151
|
return os.WriteFile(path, []byte(newContent), 0644)
|
|
@@ -115,7 +189,7 @@ func WriteRouterState(root string, state *RouterState, expectedMtime time.Time)
|
|
|
115
189
|
return fmt.Errorf("read %s: %w", path, err)
|
|
116
190
|
}
|
|
117
191
|
|
|
118
|
-
normalized :=
|
|
192
|
+
normalized := normalizeLineEndings(string(content))
|
|
119
193
|
|
|
120
194
|
startIdx := strings.Index(normalized, stateBlockStart)
|
|
121
195
|
if startIdx == -1 {
|