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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: E15-hardening/T008-ci-and-release-automation
|
|
3
|
+
status: done
|
|
4
|
+
objective: Add repo-local CI coverage for build/test checks and keep the buildtool-driven release flow reproducible
|
|
5
|
+
depends_on: []
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# T008: Add CI and Release Automation Guardrails
|
|
9
|
+
|
|
10
|
+
## Context Files
|
|
11
|
+
|
|
12
|
+
- `Makefile` - local entrypoint for build, test, and dist commands
|
|
13
|
+
- `internal/buildtool/main.go` - build orchestration and packaging logic
|
|
14
|
+
- `.savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md` - epic scope and component map
|
|
15
|
+
|
|
16
|
+
## Acceptance Criteria
|
|
17
|
+
|
|
18
|
+
- [x] `.github/workflows/ci.yml` exists and runs on push and pull_request events
|
|
19
|
+
- [x] The CI workflow runs `go test ./...` and `make build` at minimum
|
|
20
|
+
- [x] The CI workflow uses the repo-local Makefile targets rather than duplicating buildtool logic inline
|
|
21
|
+
- [x] `Makefile` exposes a `ci` target that runs the local verification steps used by CI
|
|
22
|
+
- [x] Generated binaries and archives remain excluded from version control via `.gitignore`
|
|
23
|
+
|
|
24
|
+
## Implementation Plan
|
|
25
|
+
|
|
26
|
+
- [x] Add a GitHub Actions workflow for CI in `.github/workflows/ci.yml`
|
|
27
|
+
- [x] Add a `ci` Makefile target that wraps the local verification commands
|
|
28
|
+
- [x] Reuse existing `build`, `test`, and `smoke-test` targets instead of duplicating orchestration
|
|
29
|
+
- [x] Confirm artifact ignore rules still cover local binaries and archives
|
|
30
|
+
- [x] Run `make build && make test`
|
|
31
|
+
|
|
32
|
+
## Context Log
|
|
33
|
+
|
|
34
|
+
Files read:
|
|
35
|
+
- Makefile
|
|
36
|
+
- internal/buildtool/main.go
|
|
37
|
+
- .gitignore
|
|
38
|
+
- .savepoint/releases/v1.1/epics/E15-hardening/E15-Detail.md
|
|
39
|
+
- main.go
|
|
40
|
+
- go.mod
|
|
41
|
+
|
|
42
|
+
Estimated input tokens:
|
|
43
|
+
-
|
|
44
|
+
|
|
45
|
+
Notes:
|
|
46
|
+
-
|
package/.savepoint/router.md
CHANGED
|
@@ -12,11 +12,11 @@ Read `.savepoint/PRD.md` only for vision changes. Read `.savepoint/Design.md` on
|
|
|
12
12
|
## Current state
|
|
13
13
|
|
|
14
14
|
```yaml
|
|
15
|
-
state:
|
|
15
|
+
state: epic-design
|
|
16
16
|
release: v1.1
|
|
17
|
-
epic:
|
|
18
|
-
task:
|
|
19
|
-
next_action:
|
|
17
|
+
epic: ""
|
|
18
|
+
task: ""
|
|
19
|
+
next_action: Plan next epic.
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## State → action
|
package/AGENTS.md
CHANGED
|
@@ -85,8 +85,8 @@ make build && make test
|
|
|
85
85
|
| `main.go` | CLI entrypoint, --version |
|
|
86
86
|
| `cmd/` | CLI command arg parsing and dispatch for init, board, and doctor |
|
|
87
87
|
| `internal/init/` | Target validation, scaffold writing from templates |
|
|
88
|
-
| `internal/board/` | TUI board, overlays, epic sidebar, Next Activity line, router priority key, detail checklist rendering, status glyphs, forced color profile, async update I/O commands, shared board utilities |
|
|
89
|
-
| `internal/buildtool/` | Makefile helper, cross-compile, archives |
|
|
88
|
+
| `internal/board/` | TUI board, overlays, epic sidebar, Next Activity line, router priority key, detail checklist rendering, status glyphs, forced color profile, debug logging hooks, async update I/O commands, shared board utilities |
|
|
89
|
+
| `internal/buildtool/` | Makefile helper, cross-compile including Windows targets, archives, distribution checksums |
|
|
90
90
|
| `internal/doctor/` | Read-only project diagnostics, integrity checks, timed quality gate execution, report formatting, typed repair suggestions |
|
|
91
91
|
| `internal/data/` | Task/router models, frontmatter parsing/splitting, lifecycle validation/defaulting, discovery including root-dir traversal, unified task status constants, canonical write helpers |
|
|
92
92
|
| `internal/testutil/` | Shared Go test fixtures and filesystem helpers for internal package tests |
|
package/Makefile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: build test run clean build-linux build-darwin build-all dist smoke-test
|
|
1
|
+
.PHONY: build test run clean build-linux build-darwin build-all dist smoke-test ci
|
|
2
2
|
|
|
3
3
|
VERSION ?=
|
|
4
4
|
|
|
@@ -27,3 +27,5 @@ dist:
|
|
|
27
27
|
|
|
28
28
|
smoke-test:
|
|
29
29
|
go run ./internal/buildtool -version "$(VERSION)" smoke-test
|
|
30
|
+
|
|
31
|
+
ci: test build
|
package/agent_skills_test.go
CHANGED
package/internal/board/board.go
CHANGED
|
@@ -36,10 +36,12 @@ func newProjectModel(start, releaseFilter, epicFilter string) (Model, error) {
|
|
|
36
36
|
func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, deps ModelDependencies) (Model, error) {
|
|
37
37
|
deps = modelDependencies([]ModelDependencies{deps})
|
|
38
38
|
|
|
39
|
+
debugf("board init: finding savepoint root from %q", start)
|
|
39
40
|
root, err := deps.Discoverer.FindSavepointRoot(start)
|
|
40
41
|
if err != nil {
|
|
41
42
|
return Model{}, err
|
|
42
43
|
}
|
|
44
|
+
debugf("board init: root = %q", root)
|
|
43
45
|
|
|
44
46
|
routerState, err := readRouterState(root, deps.RouterReader)
|
|
45
47
|
if err != nil {
|
|
@@ -50,6 +52,7 @@ func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, de
|
|
|
50
52
|
if err != nil {
|
|
51
53
|
return Model{}, err
|
|
52
54
|
}
|
|
55
|
+
debugf("board init: loaded %d tasks across %d releases", len(tasks), len(releaseIDs))
|
|
53
56
|
|
|
54
57
|
preferredRelease := routerState.Release
|
|
55
58
|
if releaseFilter != "" {
|
|
@@ -78,6 +81,7 @@ func newProjectModelWithDependencies(start, releaseFilter, epicFilter string, de
|
|
|
78
81
|
return Model{}, err
|
|
79
82
|
}
|
|
80
83
|
model.Watcher = watcher
|
|
84
|
+
debugf("board init: file watcher started at %q", root)
|
|
81
85
|
|
|
82
86
|
return model, nil
|
|
83
87
|
}
|
|
@@ -252,3 +252,36 @@ func TestRenderCard_doneShowsDoneText(t *testing.T) {
|
|
|
252
252
|
t.Error("RenderCard missing DONE phase label")
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
func BenchmarkRenderCard_narrow(b *testing.B) {
|
|
257
|
+
task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
|
|
258
|
+
b.ReportAllocs()
|
|
259
|
+
for b.Loop() {
|
|
260
|
+
RenderCard(task, 24, false, nil)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func BenchmarkRenderCard_standard(b *testing.B) {
|
|
265
|
+
task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view", Stage: data.StageTest}
|
|
266
|
+
b.ReportAllocs()
|
|
267
|
+
for b.Loop() {
|
|
268
|
+
RenderCard(task, 40, false, nil)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func BenchmarkRenderCard_wide(b *testing.B) {
|
|
273
|
+
task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout for the board view with extra details", Stage: data.StageAudit}
|
|
274
|
+
b.ReportAllocs()
|
|
275
|
+
for b.Loop() {
|
|
276
|
+
RenderCard(task, 60, false, nil)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func BenchmarkRenderCard_focused(b *testing.B) {
|
|
281
|
+
task := data.Task{ID: "E06-atari-noir-layout/T004-component-refinement", Title: "Refine card layout", Stage: data.StageBuild}
|
|
282
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T004"}
|
|
283
|
+
b.ReportAllocs()
|
|
284
|
+
for b.Loop() {
|
|
285
|
+
RenderCard(task, 40, true, router)
|
|
286
|
+
}
|
|
287
|
+
}
|
package/internal/board/column.go
CHANGED
|
@@ -33,11 +33,6 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
|
|
|
33
33
|
contentBudget = 1
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
reserveAbove := 0
|
|
37
|
-
if offset > 0 {
|
|
38
|
-
reserveAbove = 1
|
|
39
|
-
}
|
|
40
|
-
|
|
41
36
|
type cardEntry struct {
|
|
42
37
|
card string
|
|
43
38
|
lines int
|
|
@@ -48,12 +43,17 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
|
|
|
48
43
|
cardEntries = append(cardEntries, cardEntry{card: c, lines: strings.Count(c, "\n") + 1})
|
|
49
44
|
}
|
|
50
45
|
|
|
46
|
+
// Standard window: fit as many cards as possible from the start of cardEntries.
|
|
47
|
+
reserveAbove := 0
|
|
48
|
+
if offset > 0 {
|
|
49
|
+
reserveAbove = 1
|
|
50
|
+
}
|
|
51
51
|
usedLines := reserveAbove
|
|
52
52
|
endIdx := 0
|
|
53
53
|
for endIdx < len(cardEntries) {
|
|
54
|
-
|
|
54
|
+
hasMore := (offset + endIdx + 1) < len(tasks)
|
|
55
55
|
bottomReserve := 0
|
|
56
|
-
if
|
|
56
|
+
if hasMore {
|
|
57
57
|
bottomReserve = 1
|
|
58
58
|
}
|
|
59
59
|
if usedLines+cardEntries[endIdx].lines+bottomReserve > contentBudget {
|
|
@@ -62,19 +62,49 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offs
|
|
|
62
62
|
usedLines += cardEntries[endIdx].lines
|
|
63
63
|
endIdx++
|
|
64
64
|
}
|
|
65
|
-
|
|
66
65
|
if endIdx == 0 && len(cardEntries) > 0 {
|
|
67
66
|
endIdx = 1
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Determine what portion of cardEntries to render.
|
|
70
|
+
renderStart := 0
|
|
71
|
+
renderEnd := endIdx
|
|
72
|
+
|
|
73
|
+
focusedRelIdx := focusedTask - offset
|
|
74
|
+
if focused && focusedRelIdx >= 0 && focusedRelIdx < len(cardEntries) && focusedRelIdx >= endIdx {
|
|
75
|
+
// Focused task is beyond the standard window.
|
|
76
|
+
// Anchor viewport at focused task: fill backward to use remaining budget.
|
|
77
|
+
bottomCost := 0
|
|
78
|
+
if focusedTask+1 < len(tasks) {
|
|
79
|
+
bottomCost = 1
|
|
80
|
+
}
|
|
81
|
+
cardsLines := cardEntries[focusedRelIdx].lines
|
|
82
|
+
newStart := focusedRelIdx
|
|
83
|
+
for newStart > 0 {
|
|
84
|
+
prev := cardEntries[newStart-1]
|
|
85
|
+
topCost := 1
|
|
86
|
+
if offset+newStart-1 == 0 {
|
|
87
|
+
topCost = 0
|
|
88
|
+
}
|
|
89
|
+
if cardsLines+prev.lines+topCost+bottomCost > contentBudget {
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
cardsLines += prev.lines
|
|
93
|
+
newStart--
|
|
94
|
+
}
|
|
95
|
+
renderStart = newStart
|
|
96
|
+
renderEnd = focusedRelIdx + 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
effectiveOffset := offset + renderStart
|
|
100
|
+
if effectiveOffset > 0 {
|
|
101
|
+
lines = append(lines, renderScrollIndicator("↑", effectiveOffset, "above"))
|
|
72
102
|
}
|
|
73
|
-
for i :=
|
|
103
|
+
for i := renderStart; i < renderEnd; i++ {
|
|
74
104
|
lines = append(lines, cardEntries[i].card)
|
|
75
105
|
}
|
|
76
|
-
if
|
|
77
|
-
remaining := len(tasks) - (offset +
|
|
106
|
+
if offset+renderEnd < len(tasks) {
|
|
107
|
+
remaining := len(tasks) - (offset + renderEnd)
|
|
78
108
|
lines = append(lines, renderScrollIndicator("↓", remaining, "more"))
|
|
79
109
|
}
|
|
80
110
|
}
|
|
@@ -124,4 +154,3 @@ func columnTitle(col data.ColumnType) string {
|
|
|
124
154
|
return strings.ToUpper(string(col))
|
|
125
155
|
}
|
|
126
156
|
}
|
|
127
|
-
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package board
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"strings"
|
|
5
6
|
"testing"
|
|
6
7
|
|
|
@@ -137,3 +138,73 @@ func TestVisibleColumnTaskLimitDefaultsToFourAtStandardHeight(t *testing.T) {
|
|
|
137
138
|
t.Errorf("visibleColumnTaskLimit(standard height) = %d, want 4", got)
|
|
138
139
|
}
|
|
139
140
|
}
|
|
141
|
+
|
|
142
|
+
func BenchmarkRenderColumn_empty(b *testing.B) {
|
|
143
|
+
b.ReportAllocs()
|
|
144
|
+
for b.Loop() {
|
|
145
|
+
RenderColumn(nil, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func BenchmarkRenderColumn_fewTasks(b *testing.B) {
|
|
150
|
+
tasks := []data.Task{
|
|
151
|
+
{ID: "E06/T001", Title: "First task", Column: data.ColumnPlanned, Stage: data.StageBuild},
|
|
152
|
+
{ID: "E06/T002", Title: "Second task", Column: data.ColumnPlanned, Stage: data.StageTest},
|
|
153
|
+
{ID: "E06/T003", Title: "Third task", Column: data.ColumnPlanned, Stage: data.StageAudit},
|
|
154
|
+
}
|
|
155
|
+
b.ReportAllocs()
|
|
156
|
+
for b.Loop() {
|
|
157
|
+
RenderColumn(tasks, data.ColumnPlanned, 30, 20, 0, 0, false, nil)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func BenchmarkRenderColumn_manyTasks(b *testing.B) {
|
|
162
|
+
tasks := make([]data.Task, 20)
|
|
163
|
+
stages := []data.ProgressStage{data.StageBuild, data.StageTest, data.StageAudit}
|
|
164
|
+
for i := range tasks {
|
|
165
|
+
tasks[i] = data.Task{
|
|
166
|
+
ID: fmt.Sprintf("E06/T%03d", i+1),
|
|
167
|
+
Title: fmt.Sprintf("Task number %d with a reasonable length title", i+1),
|
|
168
|
+
Column: data.ColumnPlanned,
|
|
169
|
+
Stage: stages[i%3],
|
|
170
|
+
Release: "v1.1",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
b.ReportAllocs()
|
|
174
|
+
for b.Loop() {
|
|
175
|
+
RenderColumn(tasks, data.ColumnPlanned, 40, 24, 0, 0, false, nil)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func BenchmarkRenderColumn_focused(b *testing.B) {
|
|
180
|
+
tasks := []data.Task{
|
|
181
|
+
{ID: "E06/T001", Title: "First task", Column: data.ColumnInProgress, Stage: data.StageBuild},
|
|
182
|
+
{ID: "E06/T002", Title: "Second task", Column: data.ColumnInProgress, Stage: data.StageTest},
|
|
183
|
+
{ID: "E06/T003", Title: "Third task", Column: data.ColumnInProgress, Stage: data.StageAudit},
|
|
184
|
+
}
|
|
185
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T001"}
|
|
186
|
+
b.ReportAllocs()
|
|
187
|
+
for b.Loop() {
|
|
188
|
+
RenderColumn(tasks, data.ColumnInProgress, 40, 20, 0, 0, true, router)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func TestRenderColumn_focusedLastTaskVisibleWhenScrolled(t *testing.T) {
|
|
193
|
+
// When scrolled (offset > 0) the top scroll indicator steals 1 line.
|
|
194
|
+
// The focused last-in-page task must still appear, not be cut off.
|
|
195
|
+
tasks := []data.Task{
|
|
196
|
+
{ID: "T1", Title: "Task one", Column: data.ColumnPlanned},
|
|
197
|
+
{ID: "T2", Title: "Task two", Column: data.ColumnPlanned},
|
|
198
|
+
{ID: "T3", Title: "Task three", Column: data.ColumnPlanned},
|
|
199
|
+
{ID: "T4", Title: "Task four", Column: data.ColumnPlanned},
|
|
200
|
+
{ID: "T5", Title: "Task five", Column: data.ColumnPlanned},
|
|
201
|
+
}
|
|
202
|
+
// offset=2, focusedTask=4 (last task), maxHeight=14 (24-line terminal)
|
|
203
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, 14, 2, 4, true, nil)
|
|
204
|
+
if !strings.Contains(got, "Task five") {
|
|
205
|
+
t.Errorf("focused last task must be visible when scrolled, got:\n%s", got)
|
|
206
|
+
}
|
|
207
|
+
if !strings.Contains(got, "↑") {
|
|
208
|
+
t.Error("scroll indicator above must appear when offset > 0")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"sync/atomic"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var debugEnabled atomic.Bool
|
|
10
|
+
|
|
11
|
+
// SetDebug enables or disables debug logging for the board package.
|
|
12
|
+
func SetDebug(enabled bool) {
|
|
13
|
+
debugEnabled.Store(enabled)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// DebugEnabled reports whether debug logging is active.
|
|
17
|
+
func DebugEnabled() bool {
|
|
18
|
+
return debugEnabled.Load()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func debugf(format string, args ...any) {
|
|
22
|
+
if !debugEnabled.Load() {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
fmt.Fprintf(os.Stderr, "[savepoint debug] "+format+"\n", args...)
|
|
26
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package board
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestSetDebugToggle(t *testing.T) {
|
|
13
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
14
|
+
|
|
15
|
+
if DebugEnabled() {
|
|
16
|
+
t.Fatal("debug should be off by default")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
SetDebug(true)
|
|
20
|
+
if !DebugEnabled() {
|
|
21
|
+
t.Fatal("debug should be on after SetDebug(true)")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
SetDebug(false)
|
|
25
|
+
if DebugEnabled() {
|
|
26
|
+
t.Fatal("debug should be off after SetDebug(false)")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestDebugfWritesToStderr(t *testing.T) {
|
|
31
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
32
|
+
|
|
33
|
+
r, w, err := os.Pipe()
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
orig := os.Stderr
|
|
38
|
+
os.Stderr = w
|
|
39
|
+
|
|
40
|
+
SetDebug(true)
|
|
41
|
+
debugf("test message %d", 42)
|
|
42
|
+
|
|
43
|
+
os.Stderr = orig
|
|
44
|
+
w.Close()
|
|
45
|
+
|
|
46
|
+
var buf bytes.Buffer
|
|
47
|
+
io.Copy(&buf, r)
|
|
48
|
+
r.Close()
|
|
49
|
+
|
|
50
|
+
out := buf.String()
|
|
51
|
+
if !strings.Contains(out, "test message 42") {
|
|
52
|
+
t.Fatalf("expected debug output, got: %q", out)
|
|
53
|
+
}
|
|
54
|
+
if !strings.Contains(out, "[savepoint debug]") {
|
|
55
|
+
t.Fatalf("expected debug prefix, got: %q", out)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestDebugfSilentWhenDisabled(t *testing.T) {
|
|
60
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
61
|
+
|
|
62
|
+
r, w, err := os.Pipe()
|
|
63
|
+
if err != nil {
|
|
64
|
+
t.Fatal(err)
|
|
65
|
+
}
|
|
66
|
+
orig := os.Stderr
|
|
67
|
+
os.Stderr = w
|
|
68
|
+
|
|
69
|
+
SetDebug(false)
|
|
70
|
+
debugf("should not appear")
|
|
71
|
+
|
|
72
|
+
os.Stderr = orig
|
|
73
|
+
w.Close()
|
|
74
|
+
|
|
75
|
+
var buf bytes.Buffer
|
|
76
|
+
io.Copy(&buf, r)
|
|
77
|
+
r.Close()
|
|
78
|
+
|
|
79
|
+
if buf.Len() != 0 {
|
|
80
|
+
t.Fatalf("expected no debug output when disabled, got: %q", buf.String())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func TestDebugfFormat(t *testing.T) {
|
|
85
|
+
t.Cleanup(func() { SetDebug(false) })
|
|
86
|
+
|
|
87
|
+
r, w, err := os.Pipe()
|
|
88
|
+
if err != nil {
|
|
89
|
+
t.Fatal(err)
|
|
90
|
+
}
|
|
91
|
+
orig := os.Stderr
|
|
92
|
+
os.Stderr = w
|
|
93
|
+
|
|
94
|
+
SetDebug(true)
|
|
95
|
+
debugf("key=%q value=%d", "hello", 7)
|
|
96
|
+
|
|
97
|
+
os.Stderr = orig
|
|
98
|
+
w.Close()
|
|
99
|
+
|
|
100
|
+
var buf bytes.Buffer
|
|
101
|
+
io.Copy(&buf, r)
|
|
102
|
+
r.Close()
|
|
103
|
+
|
|
104
|
+
want := fmt.Sprintf("[savepoint debug] key=%q value=%d\n", "hello", 7)
|
|
105
|
+
if buf.String() != want {
|
|
106
|
+
t.Fatalf("expected %q, got %q", want, buf.String())
|
|
107
|
+
}
|
|
108
|
+
}
|
package/internal/board/detail.go
CHANGED
|
@@ -85,6 +85,36 @@ func renderChecklistSentences(text, glyph string, width int, style lipgloss.Styl
|
|
|
85
85
|
return lines
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// knownAbbreviations is the set of dot-terminated tokens that must not trigger
|
|
89
|
+
// sentence splits. Add entries (lowercase, trailing dot) to extend the list.
|
|
90
|
+
var knownAbbreviations = map[string]bool{
|
|
91
|
+
"e.g.": true,
|
|
92
|
+
"i.e.": true,
|
|
93
|
+
"vs.": true,
|
|
94
|
+
"etc.": true,
|
|
95
|
+
"fig.": true,
|
|
96
|
+
"no.": true,
|
|
97
|
+
"mr.": true,
|
|
98
|
+
"mrs.": true,
|
|
99
|
+
"dr.": true,
|
|
100
|
+
"st.": true,
|
|
101
|
+
"jr.": true,
|
|
102
|
+
"sr.": true,
|
|
103
|
+
"prof.": true,
|
|
104
|
+
"approx.": true,
|
|
105
|
+
"est.": true,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// isKnownAbbreviation reports whether the period at dotPos in s is the trailing
|
|
109
|
+
// dot of a known abbreviation (e.g. "e.g.", "Dr.").
|
|
110
|
+
func isKnownAbbreviation(s string, dotPos int) bool {
|
|
111
|
+
start := dotPos
|
|
112
|
+
for start > 0 && s[start-1] != ' ' {
|
|
113
|
+
start--
|
|
114
|
+
}
|
|
115
|
+
return knownAbbreviations[strings.ToLower(s[start:dotPos+1])]
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
func splitChecklistSentences(text string) []string {
|
|
89
119
|
fields := strings.Fields(text)
|
|
90
120
|
if len(fields) == 0 {
|
|
@@ -98,6 +128,9 @@ func splitChecklistSentences(text string) []string {
|
|
|
98
128
|
if r != '.' && r != '!' && r != '?' {
|
|
99
129
|
continue
|
|
100
130
|
}
|
|
131
|
+
if r == '.' && isKnownAbbreviation(normalized, i) {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
101
134
|
end := i + len(string(r))
|
|
102
135
|
if end < len(normalized) && normalized[end] != ' ' {
|
|
103
136
|
continue
|
|
@@ -321,6 +321,54 @@ func TestUpdate_detailOverlayScrollsWithJK(t *testing.T) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
func TestSplitChecklistSentences_abbreviationEg(t *testing.T) {
|
|
325
|
+
got := splitChecklistSentences("Use e.g. a widget. Done.")
|
|
326
|
+
if len(got) != 2 {
|
|
327
|
+
t.Fatalf("splitChecklistSentences with e.g. = %d sentences, want 2: %v", len(got), got)
|
|
328
|
+
}
|
|
329
|
+
if got[0] != "Use e.g. a widget." {
|
|
330
|
+
t.Errorf("sentence[0] = %q, want %q", got[0], "Use e.g. a widget.")
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func TestSplitChecklistSentences_abbreviationIe(t *testing.T) {
|
|
335
|
+
got := splitChecklistSentences("Call i.e. the function. Done.")
|
|
336
|
+
if len(got) != 2 {
|
|
337
|
+
t.Fatalf("splitChecklistSentences with i.e. = %d sentences, want 2: %v", len(got), got)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestSplitChecklistSentences_abbreviationDr(t *testing.T) {
|
|
342
|
+
got := splitChecklistSentences("Dr. Smith approved it. Done.")
|
|
343
|
+
if len(got) != 2 {
|
|
344
|
+
t.Fatalf("splitChecklistSentences with Dr. = %d sentences, want 2: %v", len(got), got)
|
|
345
|
+
}
|
|
346
|
+
if got[0] != "Dr. Smith approved it." {
|
|
347
|
+
t.Errorf("sentence[0] = %q, want %q", got[0], "Dr. Smith approved it.")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func TestSplitChecklistSentences_abbreviationEtc(t *testing.T) {
|
|
352
|
+
got := splitChecklistSentences("Add widgets, buttons, etc. to the panel. Done.")
|
|
353
|
+
if len(got) != 2 {
|
|
354
|
+
t.Fatalf("splitChecklistSentences with etc. = %d sentences, want 2: %v", len(got), got)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func TestSplitChecklistSentences_abbreviationCaseInsensitive(t *testing.T) {
|
|
359
|
+
got := splitChecklistSentences("See Fig. 3 for details. Done.")
|
|
360
|
+
if len(got) != 2 {
|
|
361
|
+
t.Fatalf("splitChecklistSentences with Fig. = %d sentences, want 2: %v", len(got), got)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
func TestSplitChecklistSentences_normalSplitUnaffected(t *testing.T) {
|
|
366
|
+
got := splitChecklistSentences("First sentence. Second sentence.")
|
|
367
|
+
if len(got) != 2 {
|
|
368
|
+
t.Fatalf("splitChecklistSentences normal split = %d sentences, want 2: %v", len(got), got)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
324
372
|
func TestOverlayWidth_clampMax(t *testing.T) {
|
|
325
373
|
if got := overlayWidth(120); got != 80 {
|
|
326
374
|
t.Errorf("overlayWidth(120) = %d, want 80", got)
|
|
@@ -113,6 +113,8 @@ func RenderEpicAuditTab(epicSlug, content string, overlayW, maxHeight, offset in
|
|
|
113
113
|
return styles.EpicDetailOverlay.Width(overlayW).Render(strings.Join(lines, "\n"))
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// epicAuditHiddenSectionHeadings lists markdown section headings suppressed in the audit tab overlay.
|
|
117
|
+
// Sections that are implementation details or planning artifacts clutter the summary view.
|
|
116
118
|
var epicAuditHiddenSectionHeadings = map[string]struct{}{
|
|
117
119
|
"12. Distribution & build": {},
|
|
118
120
|
"Acceptance Criteria": {},
|
package/internal/board/update.go
CHANGED
|
@@ -18,10 +18,12 @@ var columnOrder = []data.ColumnType{
|
|
|
18
18
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
19
19
|
switch msg := msg.(type) {
|
|
20
20
|
case fileChangeMsg:
|
|
21
|
+
debugf("dispatch: fileChangeMsg")
|
|
21
22
|
if m.Root != "" {
|
|
22
23
|
return m, reloadTasks(m.Root, m.Dependencies)
|
|
23
24
|
}
|
|
24
25
|
case reloadMsg:
|
|
26
|
+
debugf("dispatch: reloadMsg tasks=%d releases=%d", len(msg.tasks), len(msg.releases))
|
|
25
27
|
m.AllTasks = msg.tasks
|
|
26
28
|
m.Releases = append([]string(nil), msg.releases...)
|
|
27
29
|
m.ReleaseEpics = copyReleaseEpics(msg.releaseEpics)
|
|
@@ -521,6 +523,23 @@ func (m Model) columnPageSize() int {
|
|
|
521
523
|
return visibleColumnTaskLimit(CalculateLayout(m.Width, h).ContentHeight)
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
// conservativeColumnPageSize reserves 2 lines for scroll indicators so that
|
|
527
|
+
// ensureFocusedTaskVisible never sets an offset where the focused card is hidden
|
|
528
|
+
// by a top or bottom indicator that wasn't accounted for in the page budget.
|
|
529
|
+
func (m Model) conservativeColumnPageSize() int {
|
|
530
|
+
h := m.Height
|
|
531
|
+
if h == 0 {
|
|
532
|
+
h = defaultTermH
|
|
533
|
+
}
|
|
534
|
+
contentHeight := CalculateLayout(m.Width, h).ContentHeight
|
|
535
|
+
// contentHeight - 2 = card budget; subtract 2 more for both indicators.
|
|
536
|
+
limit := (contentHeight - 4) / 3
|
|
537
|
+
if limit < 1 {
|
|
538
|
+
return 1
|
|
539
|
+
}
|
|
540
|
+
return limit
|
|
541
|
+
}
|
|
542
|
+
|
|
524
543
|
func (m Model) detailPageSize() int {
|
|
525
544
|
return max(detailMaxHeight(m.Height)-3, 1)
|
|
526
545
|
}
|
|
@@ -554,6 +554,33 @@ func TestReloadMsgUpdatesRouterState(t *testing.T) {
|
|
|
554
554
|
}
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
+
func TestEnsureFocusedTaskVisible_lastTaskAlwaysVisible(t *testing.T) {
|
|
558
|
+
// 5 tasks, pageSize=4 at standard height. Pressing down past the page
|
|
559
|
+
// boundary must keep the focused task in the rendered window.
|
|
560
|
+
tasks := make([]data.Task, 5)
|
|
561
|
+
for i := range tasks {
|
|
562
|
+
tasks[i] = data.Task{ID: "T00" + string(rune('1'+i)), Column: data.ColumnPlanned}
|
|
563
|
+
}
|
|
564
|
+
m := NewModel(tasks, "v1", "E01")
|
|
565
|
+
m.Width = 100
|
|
566
|
+
m.Height = 24
|
|
567
|
+
m.FocusedColumn = data.ColumnPlanned
|
|
568
|
+
m.FocusedTask = 4
|
|
569
|
+
|
|
570
|
+
m.ensureFocusedTaskVisible()
|
|
571
|
+
|
|
572
|
+
offset := m.ColumnOffsets[data.ColumnPlanned]
|
|
573
|
+
pageSize := m.columnPageSize() // 4
|
|
574
|
+
if offset+pageSize-1 < 4 {
|
|
575
|
+
t.Errorf("offset=%d pageSize=%d: focused task 4 not in [offset, offset+pageSize-1]", offset, pageSize)
|
|
576
|
+
}
|
|
577
|
+
// Render must actually show the focused task (not cut off by scroll indicator).
|
|
578
|
+
got := RenderColumn(tasks, data.ColumnPlanned, 30, CalculateLayout(100, 24).ContentHeight, offset, 4, true, nil)
|
|
579
|
+
if !strings.Contains(got, tasks[4].ID) {
|
|
580
|
+
t.Errorf("focused last task %q not visible in rendered column (offset=%d):\n%s", tasks[4].ID, offset, got)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
557
584
|
func writeRouterFixture(t *testing.T) string {
|
|
558
585
|
t.Helper()
|
|
559
586
|
root := t.TempDir()
|