savepoint 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +20 -0
- package/.savepoint/Design.md +4 -3
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Audit.md +272 -0
- package/.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md +25 -8
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T001-benchmarks.md +11 -11
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T002-fuzz-targets.md +15 -9
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T003-debug-flag.md +11 -11
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T004-dist-checksums.md +9 -9
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T005-windows-targets.md +11 -11
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T006-abbreviation-splitting.md +9 -9
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T007-root-test-allowlist.md +15 -10
- package/.savepoint/releases/v1.1/epics/E15-hardening/tasks/T008-ci-and-release-automation.md +46 -0
- package/.savepoint/router.md +4 -4
- package/AGENTS.md +2 -2
- package/Makefile +3 -1
- package/agent_skills_test.go +1 -1
- package/internal/board/board.go +4 -0
- package/internal/board/card_test.go +33 -0
- package/internal/board/column.go +43 -14
- package/internal/board/column_test.go +71 -0
- package/internal/board/debug.go +26 -0
- package/internal/board/debug_test.go +108 -0
- package/internal/board/detail.go +33 -0
- package/internal/board/detail_test.go +48 -0
- package/internal/board/epic_panel.go +2 -0
- package/internal/board/update.go +19 -0
- package/internal/board/update_test.go +27 -0
- package/internal/board/view_test.go +62 -0
- package/internal/board/watch.go +6 -0
- package/internal/buildtool/main.go +44 -6
- package/internal/buildtool/main_test.go +178 -0
- package/internal/data/fuzz_test.go +75 -0
- package/internal/data/parser.go +3 -2
- package/internal/data/testdata/fuzz/FuzzSplitFrontmatterBody/68eb66b0fe91e7e3 +2 -0
- package/internal/data/write.go +9 -6
- package/main.go +24 -5
- package/package.json +1 -1
- package/savepoint +0 -0
- /package/project-audit/{audit_report_opus_4.6 → audit_report_opus_4.6.md} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
|
|
@@ -301,6 +302,67 @@ func TestRenderNextActivityLine_truncatesAtNarrowWidth(t *testing.T) {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
func BenchmarkCalculateLayout_narrow(b *testing.B) {
|
|
306
|
+
b.ReportAllocs()
|
|
307
|
+
for b.Loop() {
|
|
308
|
+
CalculateLayout(60, 24)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func BenchmarkCalculateLayout_standard(b *testing.B) {
|
|
313
|
+
b.ReportAllocs()
|
|
314
|
+
for b.Loop() {
|
|
315
|
+
CalculateLayout(80, 24)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func BenchmarkCalculateLayout_wide(b *testing.B) {
|
|
320
|
+
b.ReportAllocs()
|
|
321
|
+
for b.Loop() {
|
|
322
|
+
CalculateLayout(120, 24)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func BenchmarkCalculateLayout_extraWide(b *testing.B) {
|
|
327
|
+
b.ReportAllocs()
|
|
328
|
+
for b.Loop() {
|
|
329
|
+
CalculateLayout(220, 50)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
func BenchmarkView_empty(b *testing.B) {
|
|
334
|
+
m := NewModel(nil, "v1", "E03")
|
|
335
|
+
m.Width = 120
|
|
336
|
+
m.Height = 40
|
|
337
|
+
b.ReportAllocs()
|
|
338
|
+
for b.Loop() {
|
|
339
|
+
m.View()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
func BenchmarkView_withTasks(b *testing.B) {
|
|
344
|
+
tasks := make([]data.Task, 15)
|
|
345
|
+
stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
|
|
346
|
+
cols := []data.ColumnType{data.ColumnPlanned, data.ColumnInProgress, data.ColumnDone}
|
|
347
|
+
for i := range tasks {
|
|
348
|
+
tasks[i] = data.Task{
|
|
349
|
+
ID: fmt.Sprintf("E06-layout/T%03d-task-slug", i+1),
|
|
350
|
+
Title: fmt.Sprintf("Task %d with a reasonable title length", i+1),
|
|
351
|
+
Column: cols[i%3],
|
|
352
|
+
Stage: stages[i%3],
|
|
353
|
+
Release: "v1.1",
|
|
354
|
+
Epic: "E06-layout",
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
m := NewModel(tasks, "v1.1", "E06")
|
|
358
|
+
m.Width = 120
|
|
359
|
+
m.Height = 40
|
|
360
|
+
b.ReportAllocs()
|
|
361
|
+
for b.Loop() {
|
|
362
|
+
m.View()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
304
366
|
func TestView_narrowShowsSingleColumn(t *testing.T) {
|
|
305
367
|
m := NewModel(nil, "v1", "E03")
|
|
306
368
|
m.Width = 60
|
package/internal/board/watch.go
CHANGED
|
@@ -52,6 +52,7 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
|
52
52
|
if !ok {
|
|
53
53
|
return nil
|
|
54
54
|
}
|
|
55
|
+
debugf("watcher: event %s", event)
|
|
55
56
|
watchCreatedDir(w, event)
|
|
56
57
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
57
58
|
drain:
|
|
@@ -62,11 +63,13 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
|
62
63
|
timer.Stop()
|
|
63
64
|
return nil
|
|
64
65
|
}
|
|
66
|
+
debugf("watcher: event %s", event)
|
|
65
67
|
watchCreatedDir(w, event)
|
|
66
68
|
case <-timer.C:
|
|
67
69
|
break drain
|
|
68
70
|
}
|
|
69
71
|
}
|
|
72
|
+
debugf("watcher: emitting fileChangeMsg")
|
|
70
73
|
return fileChangeMsg{}
|
|
71
74
|
case _, ok := <-w.Errors:
|
|
72
75
|
if !ok {
|
|
@@ -79,10 +82,13 @@ func watchFiles(w *fsnotify.Watcher) tea.Cmd {
|
|
|
79
82
|
|
|
80
83
|
func reloadTasks(root string, deps ModelDependencies) tea.Cmd {
|
|
81
84
|
return func() tea.Msg {
|
|
85
|
+
debugf("reload: starting task reload from %q", root)
|
|
82
86
|
tasks, releases, releaseEpics, epicStatuses, err := loadBoardData(root, deps.Discoverer, deps.Parser)
|
|
83
87
|
if err != nil {
|
|
88
|
+
debugf("reload: error: %v", err)
|
|
84
89
|
return errorMsg{message: "reload failed: " + err.Error()}
|
|
85
90
|
}
|
|
91
|
+
debugf("reload: loaded %d tasks", len(tasks))
|
|
86
92
|
routerState, _ := readRouterState(root, deps.RouterReader)
|
|
87
93
|
return reloadMsg{tasks: tasks, releases: releases, releaseEpics: releaseEpics, epicStatuses: epicStatuses, routerState: routerState}
|
|
88
94
|
}
|
|
@@ -3,15 +3,17 @@ package main
|
|
|
3
3
|
import (
|
|
4
4
|
"archive/tar"
|
|
5
5
|
"compress/gzip"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
6
8
|
"errors"
|
|
7
9
|
"flag"
|
|
8
10
|
"fmt"
|
|
9
11
|
"io"
|
|
10
12
|
"os"
|
|
11
13
|
"os/exec"
|
|
12
|
-
"strings"
|
|
13
14
|
"path/filepath"
|
|
14
15
|
"runtime"
|
|
16
|
+
"strings"
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
type target struct {
|
|
@@ -24,6 +26,8 @@ var targets = []target{
|
|
|
24
26
|
{os: "linux", arch: "arm64"},
|
|
25
27
|
{os: "darwin", arch: "amd64"},
|
|
26
28
|
{os: "darwin", arch: "arm64"},
|
|
29
|
+
{os: "windows", arch: "amd64"},
|
|
30
|
+
{os: "windows", arch: "arm64"},
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
var versionOverride string
|
|
@@ -42,7 +46,7 @@ func run(args []string) error {
|
|
|
42
46
|
return err
|
|
43
47
|
}
|
|
44
48
|
if flags.NArg() != 1 {
|
|
45
|
-
return errors.New("usage: go run ./internal/buildtool [-version vX.Y.Z] <build|clean|build-linux|build-darwin|build-all|dist|smoke-test>")
|
|
49
|
+
return errors.New("usage: go run ./internal/buildtool [-version vX.Y.Z] <build|clean|build-linux|build-darwin|build-windows|build-all|dist|smoke-test>")
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
switch flags.Arg(0) {
|
|
@@ -54,6 +58,8 @@ func run(args []string) error {
|
|
|
54
58
|
return buildMatching("linux")
|
|
55
59
|
case "build-darwin":
|
|
56
60
|
return buildMatching("darwin")
|
|
61
|
+
case "build-windows":
|
|
62
|
+
return buildMatching("windows")
|
|
57
63
|
case "build-all":
|
|
58
64
|
return buildAll()
|
|
59
65
|
case "dist":
|
|
@@ -99,8 +105,15 @@ func buildAll() error {
|
|
|
99
105
|
return nil
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
func executableName(goos string) string {
|
|
109
|
+
if goos == "windows" {
|
|
110
|
+
return "savepoint.exe"
|
|
111
|
+
}
|
|
112
|
+
return "savepoint"
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
func buildTarget(target target) error {
|
|
103
|
-
output := filepath.Join("dist", target.os+"-"+target.arch,
|
|
116
|
+
output := filepath.Join("dist", target.os+"-"+target.arch, executableName(target.os))
|
|
104
117
|
return runGoBuild(output, target.os, target.arch)
|
|
105
118
|
}
|
|
106
119
|
|
|
@@ -123,13 +136,39 @@ func dist() error {
|
|
|
123
136
|
if err := buildAll(); err != nil {
|
|
124
137
|
return err
|
|
125
138
|
}
|
|
139
|
+
var archives []string
|
|
126
140
|
for _, target := range targets {
|
|
127
141
|
name := "savepoint-" + version() + "-" + target.os + "-" + target.arch + ".tar.gz"
|
|
128
|
-
source := filepath.Join("dist", target.os+"-"+target.arch,
|
|
142
|
+
source := filepath.Join("dist", target.os+"-"+target.arch, executableName(target.os))
|
|
129
143
|
archive := filepath.Join("dist", name)
|
|
130
|
-
if err := writeTarGz(archive, source,
|
|
144
|
+
if err := writeTarGz(archive, source, executableName(target.os)); err != nil {
|
|
131
145
|
return err
|
|
132
146
|
}
|
|
147
|
+
archives = append(archives, archive)
|
|
148
|
+
}
|
|
149
|
+
return writeChecksums(filepath.Join("dist", "checksums.txt"), archives)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func writeChecksums(dest string, archives []string) error {
|
|
153
|
+
var lines strings.Builder
|
|
154
|
+
for _, path := range archives {
|
|
155
|
+
f, err := os.Open(path)
|
|
156
|
+
if err != nil {
|
|
157
|
+
return fmt.Errorf("checksum open %s: %w", path, err)
|
|
158
|
+
}
|
|
159
|
+
h := sha256.New()
|
|
160
|
+
if _, err := io.Copy(h, f); err != nil {
|
|
161
|
+
f.Close()
|
|
162
|
+
return fmt.Errorf("checksum read %s: %w", path, err)
|
|
163
|
+
}
|
|
164
|
+
f.Close()
|
|
165
|
+
lines.WriteString(hex.EncodeToString(h.Sum(nil)))
|
|
166
|
+
lines.WriteString(" ")
|
|
167
|
+
lines.WriteString(filepath.Base(path))
|
|
168
|
+
lines.WriteString("\n")
|
|
169
|
+
}
|
|
170
|
+
if err := os.WriteFile(dest, []byte(lines.String()), 0o644); err != nil {
|
|
171
|
+
return fmt.Errorf("write checksums: %w", err)
|
|
133
172
|
}
|
|
134
173
|
return nil
|
|
135
174
|
}
|
|
@@ -208,4 +247,3 @@ func localExecutable() string {
|
|
|
208
247
|
}
|
|
209
248
|
return "savepoint"
|
|
210
249
|
}
|
|
211
|
-
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
package main
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"archive/tar"
|
|
5
|
+
"compress/gzip"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
8
|
+
"io"
|
|
4
9
|
"os"
|
|
10
|
+
"path/filepath"
|
|
5
11
|
"runtime"
|
|
12
|
+
"strings"
|
|
6
13
|
"testing"
|
|
7
14
|
)
|
|
8
15
|
|
|
@@ -32,6 +39,177 @@ func TestVersion_fallback(t *testing.T) {
|
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
func TestWriteChecksums(t *testing.T) {
|
|
43
|
+
dir := t.TempDir()
|
|
44
|
+
|
|
45
|
+
content := []byte("fake archive content")
|
|
46
|
+
archive := filepath.Join(dir, "savepoint-v1.0.0-linux-amd64.tar.gz")
|
|
47
|
+
if err := os.WriteFile(archive, content, 0o644); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dest := filepath.Join(dir, "checksums.txt")
|
|
52
|
+
if err := writeChecksums(dest, []string{archive}); err != nil {
|
|
53
|
+
t.Fatalf("writeChecksums: %v", err)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
got, err := os.ReadFile(dest)
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
h := sha256.Sum256(content)
|
|
62
|
+
wantHash := hex.EncodeToString(h[:])
|
|
63
|
+
wantLine := wantHash + " savepoint-v1.0.0-linux-amd64.tar.gz"
|
|
64
|
+
|
|
65
|
+
lines := strings.Split(strings.TrimSpace(string(got)), "\n")
|
|
66
|
+
if len(lines) != 1 {
|
|
67
|
+
t.Fatalf("expected 1 line, got %d", len(lines))
|
|
68
|
+
}
|
|
69
|
+
if lines[0] != wantLine {
|
|
70
|
+
t.Errorf("line = %q, want %q", lines[0], wantLine)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestWriteChecksums_multiple(t *testing.T) {
|
|
75
|
+
dir := t.TempDir()
|
|
76
|
+
|
|
77
|
+
names := []string{"a.tar.gz", "b.tar.gz"}
|
|
78
|
+
var paths []string
|
|
79
|
+
for _, name := range names {
|
|
80
|
+
p := filepath.Join(dir, name)
|
|
81
|
+
if err := os.WriteFile(p, []byte(name), 0o644); err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
paths = append(paths, p)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dest := filepath.Join(dir, "checksums.txt")
|
|
88
|
+
if err := writeChecksums(dest, paths); err != nil {
|
|
89
|
+
t.Fatalf("writeChecksums: %v", err)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
got, err := os.ReadFile(dest)
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
lines := strings.Split(strings.TrimSpace(string(got)), "\n")
|
|
97
|
+
if len(lines) != 2 {
|
|
98
|
+
t.Fatalf("expected 2 lines, got %d: %s", len(lines), got)
|
|
99
|
+
}
|
|
100
|
+
for i, name := range names {
|
|
101
|
+
h := sha256.Sum256([]byte(name))
|
|
102
|
+
want := hex.EncodeToString(h[:]) + " " + name
|
|
103
|
+
if lines[i] != want {
|
|
104
|
+
t.Errorf("line[%d] = %q, want %q", i, lines[i], want)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestWriteChecksums_missingFile(t *testing.T) {
|
|
110
|
+
dir := t.TempDir()
|
|
111
|
+
dest := filepath.Join(dir, "checksums.txt")
|
|
112
|
+
err := writeChecksums(dest, []string{filepath.Join(dir, "nonexistent.tar.gz")})
|
|
113
|
+
if err == nil {
|
|
114
|
+
t.Error("expected error for missing archive, got nil")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func TestTargets_includesWindows(t *testing.T) {
|
|
119
|
+
var gotAMD64, gotARM64 bool
|
|
120
|
+
for _, tgt := range targets {
|
|
121
|
+
if tgt.os != "windows" {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
switch tgt.arch {
|
|
125
|
+
case "amd64":
|
|
126
|
+
gotAMD64 = true
|
|
127
|
+
case "arm64":
|
|
128
|
+
gotARM64 = true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if !gotAMD64 {
|
|
132
|
+
t.Error("targets missing windows/amd64")
|
|
133
|
+
}
|
|
134
|
+
if !gotARM64 {
|
|
135
|
+
t.Error("targets missing windows/arm64")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestTargets_preservesLinuxDarwin(t *testing.T) {
|
|
140
|
+
want := map[string]bool{
|
|
141
|
+
"linux/amd64": false,
|
|
142
|
+
"linux/arm64": false,
|
|
143
|
+
"darwin/amd64": false,
|
|
144
|
+
"darwin/arm64": false,
|
|
145
|
+
}
|
|
146
|
+
for _, tgt := range targets {
|
|
147
|
+
key := tgt.os + "/" + tgt.arch
|
|
148
|
+
if _, ok := want[key]; ok {
|
|
149
|
+
want[key] = true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for key, found := range want {
|
|
153
|
+
if !found {
|
|
154
|
+
t.Errorf("targets missing %s", key)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func TestExecutableName(t *testing.T) {
|
|
160
|
+
if got := executableName("windows"); got != "savepoint.exe" {
|
|
161
|
+
t.Errorf("executableName(windows) = %q, want savepoint.exe", got)
|
|
162
|
+
}
|
|
163
|
+
if got := executableName("linux"); got != "savepoint" {
|
|
164
|
+
t.Errorf("executableName(linux) = %q, want savepoint", got)
|
|
165
|
+
}
|
|
166
|
+
if got := executableName("darwin"); got != "savepoint" {
|
|
167
|
+
t.Errorf("executableName(darwin) = %q, want savepoint", got)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestWriteTarGzPreservesWindowsExecutableName(t *testing.T) {
|
|
172
|
+
dir := t.TempDir()
|
|
173
|
+
source := filepath.Join(dir, "savepoint.exe")
|
|
174
|
+
if err := os.WriteFile(source, []byte("binary"), 0o755); err != nil {
|
|
175
|
+
t.Fatal(err)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
archive := filepath.Join(dir, "savepoint-windows-amd64.tar.gz")
|
|
179
|
+
if err := writeTarGz(archive, source, executableName("windows")); err != nil {
|
|
180
|
+
t.Fatalf("writeTarGz: %v", err)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
f, err := os.Open(archive)
|
|
184
|
+
if err != nil {
|
|
185
|
+
t.Fatal(err)
|
|
186
|
+
}
|
|
187
|
+
defer f.Close()
|
|
188
|
+
|
|
189
|
+
gz, err := gzip.NewReader(f)
|
|
190
|
+
if err != nil {
|
|
191
|
+
t.Fatal(err)
|
|
192
|
+
}
|
|
193
|
+
defer gz.Close()
|
|
194
|
+
|
|
195
|
+
tr := tar.NewReader(gz)
|
|
196
|
+
header, err := tr.Next()
|
|
197
|
+
if err != nil {
|
|
198
|
+
t.Fatal(err)
|
|
199
|
+
}
|
|
200
|
+
if header.Name != "savepoint.exe" {
|
|
201
|
+
t.Fatalf("archive member = %q, want savepoint.exe", header.Name)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
content, err := io.ReadAll(tr)
|
|
205
|
+
if err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
if string(content) != "binary" {
|
|
209
|
+
t.Fatalf("archive content = %q, want binary", content)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
35
213
|
func TestLocalExecutable(t *testing.T) {
|
|
36
214
|
got := localExecutable()
|
|
37
215
|
if runtime.GOOS == "windows" {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package data
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
func FuzzExtractFrontmatter(f *testing.F) {
|
|
8
|
+
seeds := []string{
|
|
9
|
+
"---\nid: E01/T001\nstatus: planned\n---\nbody",
|
|
10
|
+
"---\n---\n",
|
|
11
|
+
"---\n\n---\n",
|
|
12
|
+
"---\nid: test\n---",
|
|
13
|
+
"",
|
|
14
|
+
"# no frontmatter",
|
|
15
|
+
"---\nid: [broken\n---\n",
|
|
16
|
+
"---\nname: héllo wörld\n---\n",
|
|
17
|
+
"---\nid: test\nstatus: in_progress\nphase: build\n---\nbody content",
|
|
18
|
+
"---\r\nid: test\r\n---\r\nbody",
|
|
19
|
+
}
|
|
20
|
+
for _, s := range seeds {
|
|
21
|
+
f.Add(s)
|
|
22
|
+
}
|
|
23
|
+
f.Fuzz(func(t *testing.T, content string) {
|
|
24
|
+
_, _ = extractFrontmatter(content)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func FuzzParseFrontmatter(f *testing.F) {
|
|
29
|
+
seeds := []string{
|
|
30
|
+
"---\nid: E01/T001\nstatus: planned\n---\nbody",
|
|
31
|
+
"---\n---\n",
|
|
32
|
+
"---\nid: [broken\n---\n",
|
|
33
|
+
"---\nname: héllo\n---\n",
|
|
34
|
+
"",
|
|
35
|
+
"no frontmatter",
|
|
36
|
+
"---\ntags: [a, b, c]\n---\n",
|
|
37
|
+
"---\nnested:\n key: val\n---\n",
|
|
38
|
+
}
|
|
39
|
+
for _, s := range seeds {
|
|
40
|
+
f.Add(s)
|
|
41
|
+
}
|
|
42
|
+
f.Fuzz(func(t *testing.T, content string) {
|
|
43
|
+
p := NewParser()
|
|
44
|
+
_, _ = p.ParseFrontmatter(content)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func FuzzSplitFrontmatterBody(f *testing.F) {
|
|
49
|
+
seeds := []string{
|
|
50
|
+
"---\nid: E01/T001\nstatus: planned\n---\nbody",
|
|
51
|
+
"---\n---\n",
|
|
52
|
+
"---\nkey: value\n---",
|
|
53
|
+
"",
|
|
54
|
+
"# no frontmatter",
|
|
55
|
+
"---\nid: test\nstatus: in_progress\n---\n\n## Section\n\nContent.",
|
|
56
|
+
"---\nid: test\n---\n\nbody with unicode: 日本語",
|
|
57
|
+
}
|
|
58
|
+
for _, s := range seeds {
|
|
59
|
+
f.Add(s)
|
|
60
|
+
}
|
|
61
|
+
f.Fuzz(func(t *testing.T, content string) {
|
|
62
|
+
yamlStr, body, err := SplitFrontmatterBody(content)
|
|
63
|
+
if err != nil {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
reconstructed := "---\n" + yamlStr + "\n---" + body
|
|
67
|
+
_, body2, err2 := SplitFrontmatterBody(reconstructed)
|
|
68
|
+
if err2 != nil {
|
|
69
|
+
t.Errorf("round-trip SplitFrontmatterBody failed on reconstructed: %v", err2)
|
|
70
|
+
}
|
|
71
|
+
if body2 != body {
|
|
72
|
+
t.Errorf("round-trip body mismatch: got %q, want %q", body2, body)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
package/internal/data/parser.go
CHANGED
|
@@ -88,9 +88,10 @@ type taskFrontmatter struct {
|
|
|
88
88
|
Progress Progress `yaml:"progress"`
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// normalizeLineEndings replaces Windows
|
|
91
|
+
// normalizeLineEndings replaces Windows (CRLF) and legacy Mac (CR) line endings with LF.
|
|
92
92
|
func normalizeLineEndings(s string) string {
|
|
93
|
-
|
|
93
|
+
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
94
|
+
return strings.ReplaceAll(s, "\r", "\n")
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
func extractFrontmatter(content string) (string, error) {
|
package/internal/data/write.go
CHANGED
|
@@ -40,17 +40,20 @@ func UpdateLastAudited(path, value string) error {
|
|
|
40
40
|
// SplitFrontmatterBody splits content into frontmatter YAML and body.
|
|
41
41
|
func SplitFrontmatterBody(content string) (yamlStr string, body string, err error) {
|
|
42
42
|
normalized := normalizeLineEndings(content)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
if !strings.HasPrefix(normalized, "---\n") {
|
|
44
|
+
return "", "", ErrNoFrontmatter
|
|
45
|
+
}
|
|
46
|
+
end := strings.Index(normalized[4:], "\n---")
|
|
47
|
+
if end == -1 {
|
|
48
|
+
return "", "", ErrNoClosingFrontmatter
|
|
46
49
|
}
|
|
47
|
-
|
|
48
|
-
bodyStart :=
|
|
50
|
+
yamlStr = strings.TrimSpace(normalized[4 : 4+end])
|
|
51
|
+
bodyStart := 4 + end + 4 // "---\n" + yaml + "\n---"
|
|
49
52
|
body = ""
|
|
50
53
|
if bodyStart < len(normalized) {
|
|
51
54
|
body = normalized[bodyStart:]
|
|
52
55
|
}
|
|
53
|
-
return
|
|
56
|
+
return yamlStr, body, nil
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
func updateFrontmatterField(path, key, value string) error {
|
package/main.go
CHANGED
|
@@ -22,19 +22,24 @@ var projectTemplates embed.FS
|
|
|
22
22
|
var version = "dev"
|
|
23
23
|
|
|
24
24
|
func main() {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
args, debug := stripDebugFlag(os.Args[1:])
|
|
26
|
+
if debug || os.Getenv("SAVEPOINT_DEBUG") != "" {
|
|
27
|
+
board.SetDebug(true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if len(args) > 0 {
|
|
31
|
+
switch args[0] {
|
|
27
32
|
case "--version":
|
|
28
33
|
fmt.Println(version)
|
|
29
34
|
os.Exit(0)
|
|
30
35
|
case "init":
|
|
31
|
-
if err := cmd.RunInit(context.Background(),
|
|
36
|
+
if err := cmd.RunInit(context.Background(), args[1:], os.Stdout, initRunner); err != nil {
|
|
32
37
|
fmt.Fprintln(os.Stderr, err)
|
|
33
38
|
os.Exit(1)
|
|
34
39
|
}
|
|
35
40
|
os.Exit(0)
|
|
36
41
|
case "board":
|
|
37
|
-
if err := cmd.RunBoard(context.Background(),
|
|
42
|
+
if err := cmd.RunBoard(context.Background(), args[1:], os.Stdout, func(opts cmd.BoardOptions) error {
|
|
38
43
|
return board.RunWithFilters(opts.Release, opts.Epic)
|
|
39
44
|
}); err != nil {
|
|
40
45
|
fmt.Fprintln(os.Stderr, err)
|
|
@@ -42,7 +47,7 @@ func main() {
|
|
|
42
47
|
}
|
|
43
48
|
os.Exit(0)
|
|
44
49
|
case "doctor":
|
|
45
|
-
code, err := cmd.RunDoctor(context.Background(),
|
|
50
|
+
code, err := cmd.RunDoctor(context.Background(), args[1:], os.Stdout, func(opts cmd.DoctorOptions) (int, error) {
|
|
46
51
|
return runDoctorChecks(opts)
|
|
47
52
|
})
|
|
48
53
|
if err != nil {
|
|
@@ -56,6 +61,20 @@ func main() {
|
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// stripDebugFlag removes --debug from args and reports whether it was present.
|
|
65
|
+
func stripDebugFlag(args []string) ([]string, bool) {
|
|
66
|
+
out := make([]string, 0, len(args))
|
|
67
|
+
found := false
|
|
68
|
+
for _, a := range args {
|
|
69
|
+
if a == "--debug" {
|
|
70
|
+
found = true
|
|
71
|
+
} else {
|
|
72
|
+
out = append(out, a)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out, found
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
func runDoctorChecks(opts cmd.DoctorOptions) (int, error) {
|
|
60
79
|
discover := data.NewDiscover()
|
|
61
80
|
root, err := discover.FindSavepointRoot(".")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "savepoint",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "It’s a simple, file-based state machine and cinematic Terminal UI (TUI) designed to force you—and your agent (Claude, Cursor, Aider, Gemini)—to slow down, write down what you're actually building, and check your work before moving on.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"board",
|
package/savepoint
DELETED
|
Binary file
|
|
File without changes
|