diff --git a/.agents/sow/current/SOW-0102-20260603-quality-complexity-duplication-coverage.md b/.agents/sow/current/SOW-0102-20260603-quality-complexity-duplication-coverage.md index 4a673c7..033240f 100644 --- a/.agents/sow/current/SOW-0102-20260603-quality-complexity-duplication-coverage.md +++ b/.agents/sow/current/SOW-0102-20260603-quality-complexity-duplication-coverage.md @@ -4,7 +4,7 @@ Status: in-progress -Sub-state: twenty-ninth implementation slice validated; cache legacy migration coverage PR pending +Sub-state: thirtieth implementation slice validated; markdown helper coverage PR pending ## Requirements @@ -2324,7 +2324,130 @@ Artifact maintenance gate: - Specs: no update needed; legacy migration behavior is unchanged. - End-user/operator docs: no update needed. - End-user/operator skills: no update needed. -- SOW lifecycle: remains in `.agents/sow/current/`; Slice 29 is validated and pending PR merge. +- SOW lifecycle: remains in `.agents/sow/current/`; Slice 29 merged through PR #33 as merge commit `e797b6ae7a84f0e0e406e612381f1bb39ac53af2`. + +## Pre-Implementation Gate - Slice 30 + +Status: ready. + +Problem / root-cause model: + +- Facts: after the Slice 29 merge, root coverage is `73.8%` and local `tools/archposture` still reports zero production large functions. +- Facts: `go test -count=1 -coverprofile=/tmp/update-ipsets-markdown-baseline.cover -covermode=atomic ./pkg/markdown` reports `pkg/markdown` coverage at `70.8%`. +- Facts: `go tool cover -func=/tmp/update-ipsets-markdown-baseline.cover` reports clear helper gaps in `pkg/markdown`: `barFill` at `0.0%`, `truncate` at `0.0%`, `statusLabel` at `0.0%`, `statusLead` at `0.0%`, `int64Val` at `28.6%`, `toFloat` at `33.3%`, `uintVal` at `50.0%`, and `relTime` at `45.5%`. +- Working theory: markdown helper coverage is a low-risk slice because these helpers are deterministic presentation contracts for generated Markdown pages and can be tested through the existing template execution surface where possible. + +Evidence reviewed: + +- `pkg/markdown/funcs.go` +- `pkg/markdown/generate.go` +- `pkg/markdown/context_feed_values.go` +- `pkg/markdown/funcs_test.go` +- `pkg/markdown/generate_test.go` +- `/tmp/update-ipsets-markdown-baseline.cover` +- Project coding, testing, hygiene, Go best-practices, Go behavioral-testing, and content-surface skills. + +Affected contracts and surfaces: + +- Generated Markdown template helper behavior for numbers, percentages, dates, relative times, status labels/leads, bars, truncation, and source value conversion. +- SOW and tests only; no production code, public UI, docs, specs, install behavior, downloader behavior, scheduler behavior, or public serving behavior is expected to change. + +Existing patterns to reuse: + +- Existing external `markdown_test` tests that drive `markdown.ExecuteInline` and `markdown.RenderTable`. +- Existing same-package tests only where the helper has no exported or template-bound surface. +- Existing table-driven assertions and small synthetic values. +- Existing file-mode and path-safety assertions for template output. + +Risk and blast radius: + +- This slice should be test-only. +- Template-bound helpers should be tested through `ExecuteInline` to keep behavior tied to the generated Markdown contract. +- Time-relative tests must avoid brittle wall-clock exactness. Use current time offsets and assert stable output buckets such as `just now`, `5m ago`, or `2h ago`. +- Unexported context value converters should be tested as deterministic conversion helpers only; tests must not create a new public API expectation. +- No template wording, generated Markdown content, public website copy, or operator docs should change. + +Sensitive data handling plan: + +- This slice uses only synthetic strings, numbers, timestamps, and status values. +- No secrets, tokens, cookies, private endpoints, customer data, or personal data are needed. +- Durable artifacts will record only file paths, metrics, validation outcomes, and sanitized command evidence. + +Implementation plan: + +1. Extend `pkg/markdown/funcs_test.go` behavior coverage through `ExecuteInline` for `comma`, `relTime`, `bar`, `truncate`, `statusLabel`, and `statusLead`. +2. Add same-package tests for `context_feed_values.go` conversion helpers that do not have a direct exported/template-bound surface. +3. Keep production code unchanged unless tests expose a real helper bug. + +Validation plan: + +- Run `go test ./pkg/markdown`. +- Run `go test -count=1 -coverprofile=/tmp/update-ipsets-markdown-slice30.cover -covermode=atomic ./pkg/markdown` and inspect `go tool cover -func`. +- Run `go run ./tools/archposture -root . > /tmp/update-ipsets-archposture-slice30.json`. +- Run `make lint`, `make staticcheck`, `make golangci-lint`, `CI=true make coverage`, and `make test-strict`. +- Run whitespace and durable-artifact forbidden-name scans over the changed files before commit. + +Artifact impact plan: + +- AGENTS.md: no update expected. +- Runtime project skills: no update needed unless a repeatable markdown-helper testing lesson is found. +- Specs: no update expected because markdown helper behavior is unchanged. +- End-user/operator docs: no update expected. +- End-user/operator skills: no update expected. +- SOW lifecycle: this SOW remains in `.agents/sow/current/`; Slice 30 results will be recorded after validation. + +Open-source reference evidence: + +- None checked. This slice covers existing deterministic local helper behavior rather than adding a new external renderer, parser, protocol, or library. + +Open decisions: + +- No new user design decision is required because the slice is behavior-preserving test coverage under the previously approved quality plan. + +## Slice 30 Results + +Changes made: + +- Added behavior tests for Markdown template helper functions in `pkg/markdown/funcs_test.go`. +- Added behavior tests for `TemplateStore.Dir` in `pkg/markdown/generate_test.go`. +- Added same-package deterministic conversion tests for `pkg/markdown/context_feed_values.go`. +- Covered numeric formatting variants, percentage/date/relative-time helpers, duration rendering, bar rendering, truncation, status labels/leads, inline template parse errors, and context value conversion helpers. +- Split new helper tests into focused top-level tests after `tools/archposture` caught an oversized `TestFuncs` regression. +- Production code was unchanged. + +Measured result: + +- Baseline: `pkg/markdown` coverage was `70.8%` by `go test` output. +- After tests: `pkg/markdown` coverage is `79.3%`. +- `statusLabel` and `statusLead` moved from `0.0%` to `100.0%`. +- `barFill` moved from `0.0%` to `90.0%`. +- `truncate` moved from `0.0%` to `100.0%`. +- `relTime` moved from `45.5%` to `100.0%`. +- Context feed value helpers `strVal`, `intVal`, `firstIntVal`, `int64Val`, `uintVal`, `uint32Val`, `float64Val`, `boolVal`, and `toFloat` are now `100.0%`. +- Root coverage by `go tool cover -func=coverage.out` moved from `73.8%` to `74.0%`. +- `tools/archposture` after this slice: source files `636`, source lines `128319`, large files `49`, large functions `25`, and production large functions `0`. + +Tests or equivalent validation: + +- `go test ./pkg/markdown`: passed. +- `go test -count=1 -coverprofile=/tmp/update-ipsets-markdown-slice30.cover -covermode=atomic ./pkg/markdown`: passed, `79.3%`. +- `go tool cover -func=/tmp/update-ipsets-markdown-slice30.cover`: passed; targeted helper coverage listed above. +- `go run ./tools/archposture -root . > /tmp/update-ipsets-archposture-slice30.json`: passed. +- `make lint`: passed. +- `make staticcheck`: passed. +- `make golangci-lint`: passed with `0 issues`. +- `CI=true make coverage`: passed, root total `74.0%`. +- `make test-strict`: passed. +- `git diff --check`: passed. + +Artifact maintenance gate: + +- AGENTS.md: no update needed. +- Runtime project skills: no update needed; the posture regression was caught by existing project hygiene rules. +- Specs: no update needed; markdown helper behavior is unchanged. +- End-user/operator docs: no update needed. +- End-user/operator skills: no update needed. +- SOW lifecycle: remains in `.agents/sow/current/`; Slice 30 is validated and pending PR merge. ## Slice 27 Results @@ -3775,7 +3898,7 @@ Open decisions: ## Outcome -First through twenty-eighth implementation slices are complete, validated locally, and merged. The twenty-ninth implementation slice is complete and validated locally. The SOW remains open for the next focused coverage, complexity, or duplication slice. +First through twenty-ninth implementation slices are complete, validated locally, and merged. The thirtieth implementation slice is complete and validated locally. The SOW remains open for the next focused coverage, complexity, or duplication slice. ## Lessons Extracted diff --git a/pkg/markdown/context_feed_values_test.go b/pkg/markdown/context_feed_values_test.go new file mode 100644 index 0000000..ee6aa30 --- /dev/null +++ b/pkg/markdown/context_feed_values_test.go @@ -0,0 +1,166 @@ +package markdown + +import "testing" + +func TestStrVal(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want string + }{ + {name: "nil", in: nil, want: ""}, + {name: "string", in: "sample", want: "sample"}, + {name: "fallback", in: 42, want: "42"}, + } + for _, tc := range cases { + if got := strVal(tc.in); got != tc.want { + t.Fatalf("%s: strVal(%v)=%q; want %q", tc.name, tc.in, got, tc.want) + } + } +} + +func TestIntVal(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want int + }{ + {name: "nil", in: nil, want: 0}, + {name: "float64", in: 12.9, want: 12}, + {name: "int", in: 7, want: 7}, + {name: "int64", in: int64(8), want: 8}, + {name: "unsupported", in: "9", want: 0}, + } + for _, tc := range cases { + if got := intVal(tc.in); got != tc.want { + t.Fatalf("%s: intVal(%v)=%d; want %d", tc.name, tc.in, got, tc.want) + } + } +} + +func TestFirstIntVal(t *testing.T) { + t.Parallel() + + values := map[string]any{ + "zero": 0, + "later": 42, + } + if got := firstIntVal(values, "missing", "zero", "later"); got != 42 { + t.Fatalf("firstIntVal returned %d; want 42", got) + } + if got := firstIntVal(values, "missing", "zero"); got != 0 { + t.Fatalf("firstIntVal without non-zero value returned %d; want 0", got) + } +} + +func TestInt64Val(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want int64 + }{ + {name: "nil", in: nil, want: 0}, + {name: "float64", in: 12.9, want: 12}, + {name: "int64", in: int64(8), want: 8}, + {name: "int", in: 7, want: 7}, + {name: "unsupported", in: "9", want: 0}, + } + for _, tc := range cases { + if got := int64Val(tc.in); got != tc.want { + t.Fatalf("%s: int64Val(%v)=%d; want %d", tc.name, tc.in, got, tc.want) + } + } +} + +func TestUintVal(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want uint64 + }{ + {name: "nil", in: nil, want: 0}, + {name: "float64", in: 12.9, want: 12}, + {name: "int", in: 7, want: 7}, + {name: "uint64", in: uint64(8), want: 8}, + {name: "int64", in: int64(9), want: 9}, + {name: "unsupported", in: "10", want: 0}, + } + for _, tc := range cases { + if got := uintVal(tc.in); got != tc.want { + t.Fatalf("%s: uintVal(%v)=%d; want %d", tc.name, tc.in, got, tc.want) + } + } +} + +func TestUint32Val(t *testing.T) { + t.Parallel() + + if got := uint32Val(uint64(11)); got != 11 { + t.Fatalf("uint32Val returned %d; want 11", got) + } +} + +func TestFloat64Val(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want float64 + }{ + {name: "nil", in: nil, want: 0}, + {name: "float64", in: 12.5, want: 12.5}, + {name: "int", in: 7, want: 7}, + {name: "unsupported", in: int64(8), want: 0}, + } + for _, tc := range cases { + if got := float64Val(tc.in); got != tc.want { + t.Fatalf("%s: float64Val(%v)=%v; want %v", tc.name, tc.in, got, tc.want) + } + } +} + +func TestBoolVal(t *testing.T) { + t.Parallel() + + if got := boolVal(true); !got { + t.Fatal("boolVal(true)=false; want true") + } + if got := boolVal("true"); got { + t.Fatal(`boolVal("true")=true; want false`) + } + if got := boolVal(nil); got { + t.Fatal("boolVal(nil)=true; want false") + } +} + +func TestToFloat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in any + want float64 + ok bool + }{ + {name: "float64", in: 12.5, want: 12.5, ok: true}, + {name: "int", in: 7, want: 7, ok: true}, + {name: "int64", in: int64(8), want: 8, ok: true}, + {name: "uint64", in: uint64(9), want: 9, ok: true}, + {name: "unsupported", in: "10", want: 0, ok: false}, + } + for _, tc := range cases { + got, ok := toFloat(tc.in) + if ok != tc.ok || got != tc.want { + t.Fatalf("%s: toFloat(%v)=(%v,%v); want (%v,%v)", tc.name, tc.in, got, ok, tc.want, tc.ok) + } + } +} diff --git a/pkg/markdown/funcs_test.go b/pkg/markdown/funcs_test.go index d7d5349..0f05444 100644 --- a/pkg/markdown/funcs_test.go +++ b/pkg/markdown/funcs_test.go @@ -3,6 +3,7 @@ package markdown_test import ( "strings" "testing" + "time" "github.com/firehol/update-ipsets/pkg/markdown" ) @@ -61,85 +62,277 @@ func TestRenderTable(t *testing.T) { }) } -func TestFuncs(t *testing.T) { +func TestTemplateCommaFunction(t *testing.T) { t.Parallel() - t.Run("commaUint", func(t *testing.T) { - t.Parallel() - cases := []struct { - in uint64 - want string - }{ - {0, "0"}, - {999, "999"}, - {1000, "1,000"}, - {1234567, "1,234,567"}, - {1000000000, "1,000,000,000"}, - } - for _, tc := range cases { + cases := []struct { + name string + in any + want string + }{ + {name: "zero uint64", in: uint64(0), want: "0"}, + {name: "small uint64", in: uint64(999), want: "999"}, + {name: "grouped uint64", in: uint64(1234567), want: "1,234,567"}, + {name: "large uint64", in: uint64(1000000000), want: "1,000,000,000"}, + {name: "int", in: 1234567, want: "1,234,567"}, + {name: "int64", in: int64(-1234567), want: "-1,234,567"}, + {name: "float64", in: 1234.9, want: "1,234"}, + {name: "string fallback", in: "sample", want: "sample"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() got, err := markdown.ExecuteInline("{{comma .}}", tc.in) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { - t.Errorf("comma(%d)=%q; want %q", tc.in, got, tc.want) + t.Fatalf("comma(%v)=%q; want %q", tc.in, got, tc.want) } - } - }) + }) + } +} - t.Run("pct", func(t *testing.T) { - t.Parallel() - got, err := markdown.ExecuteInline("{{pct .}}", 45.67) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "45.7%" { - t.Fatalf("pct(45.67)=%q; want '45.7%%'", got) - } - }) +func TestTemplatePctAndDateFunctions(t *testing.T) { + t.Parallel() - t.Run("date formats unix ms", func(t *testing.T) { - t.Parallel() - got, err := markdown.ExecuteInline("{{date .}}", int64(1714646400000)) + got, err := markdown.ExecuteInline("{{pct .}}", 45.67) + if err != nil { + t.Fatalf("pct: %v", err) + } + if got != "45.7%" { + t.Fatalf("pct(45.67)=%q; want '45.7%%'", got) + } + + got, err = markdown.ExecuteInline("{{date .}}", int64(1714646400000)) + if err != nil { + t.Fatalf("date: %v", err) + } + if !strings.Contains(got, "2024") { + t.Fatalf("date=%q; want contains 2024", got) + } + + got, err = markdown.ExecuteInline("{{date .}}", int64(0)) + if err != nil { + t.Fatalf("date zero: %v", err) + } + if got != "" { + t.Fatalf("date(0)=%q; want empty", got) + } +} + +func TestTemplateMinsFunction(t *testing.T) { + t.Parallel() + + cases := []struct { + mins int + want string + }{ + {30, "30m"}, + {60, "1h"}, + {90, "1.5h"}, + {1440, "1d"}, + {0, ""}, + } + for _, tc := range cases { + got, err := markdown.ExecuteInline("{{mins .}}", tc.mins) if err != nil { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(got, "2024") { - t.Fatalf("date=%q; want contains 2024", got) + if got != tc.want { + t.Fatalf("mins(%d)=%q; want %q", tc.mins, got, tc.want) } - }) + } +} - t.Run("date zero returns empty", func(t *testing.T) { - t.Parallel() - got, err := markdown.ExecuteInline("{{date .}}", int64(0)) +func TestTemplateRelTimeFunction(t *testing.T) { + now := time.Now() + cases := []struct { + name string + in int64 + want string + }{ + {name: "zero", in: 0, want: ""}, + {name: "current", in: now.UnixMilli(), want: "just now"}, + {name: "minutes", in: now.Add(-5 * time.Minute).UnixMilli(), want: "5m ago"}, + {name: "hours", in: now.Add(-2 * time.Hour).UnixMilli(), want: "2h ago"}, + {name: "days", in: now.Add(-3 * 24 * time.Hour).UnixMilli(), want: "3d ago"}, + {name: "months", in: now.Add(-60 * 24 * time.Hour).UnixMilli(), want: "2mo ago"}, + {name: "years", in: now.Add(-400 * 24 * time.Hour).UnixMilli(), want: "1y ago"}, + } + for _, tc := range cases { + got, err := markdown.ExecuteInline("{{relTime .}}", tc.in) if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != "" { - t.Fatalf("date(0)=%q; want empty", got) + if got != tc.want { + t.Fatalf("%s: relTime(%d)=%q; want %q", tc.name, tc.in, got, tc.want) } - }) + } +} - t.Run("minsToDuration", func(t *testing.T) { - t.Parallel() - cases := []struct { - mins int - want string - }{ - {30, "30m"}, - {60, "1h"}, - {90, "1.5h"}, - {1440, "1d"}, - {0, ""}, - } - for _, tc := range cases { - got, err := markdown.ExecuteInline("{{mins .}}", tc.mins) +func TestTemplateBarFunction(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + pct float64 + width int + want string + }{ + {name: "fills width", pct: 40, width: 5, want: "██░░░"}, + {name: "negative clamps empty", pct: -20, width: 5, want: "░░░░░"}, + {name: "over hundred clamps full", pct: 120, width: 5, want: "█████"}, + {name: "default width", pct: 20, width: 0, want: "██░░░░░░░░"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := markdown.ExecuteInline("{{bar .Pct .Width}}", map[string]any{ + "Pct": tc.pct, + "Width": tc.width, + }) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { - t.Errorf("mins(%d)=%q; want %q", tc.mins, got, tc.want) + t.Fatalf("bar(%v,%d)=%q; want %q", tc.pct, tc.width, got, tc.want) } - } - }) + }) + } +} + +func TestTemplateTruncateFunction(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + text string + max int + want string + }{ + {name: "short unchanged", text: "sample", max: 10, want: "sample"}, + {name: "exact unchanged", text: "sample", max: 6, want: "sample"}, + {name: "with ellipsis", text: "sample text", max: 9, want: "sample..."}, + {name: "tiny max", text: "sample", max: 3, want: "sam"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := markdown.ExecuteInline("{{truncate .Text .Max}}", map[string]any{ + "Text": tc.text, + "Max": tc.max, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("truncate(%q,%d)=%q; want %q", tc.text, tc.max, got, tc.want) + } + }) + } +} + +func TestTemplateStatusHelpers(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status string + wantLabel string + wantLead string + }{ + { + name: "archived", + status: "archived", + wantLabel: "Archived", + wantLead: "Our health automation has archived this feed:", + }, + { + name: "unmaintained", + status: "unmaintained", + wantLabel: "Unmaintained", + wantLead: "Our health automation has flagged this feed as unmaintained:", + }, + { + name: "empty", + status: "empty", + wantLabel: "Empty", + wantLead: "This feed currently contains no entries:", + }, + { + name: "discontinued", + status: "discontinued", + wantLabel: "Discontinued", + wantLead: "The official status of this feed is discontinued:", + }, + { + name: "merged", + status: "merged", + wantLabel: "Merged", + wantLead: "The official status of this feed is merged:", + }, + { + name: "forked", + status: "forked", + wantLabel: "Forked", + wantLead: "The official status of this feed has been forked:", + }, + { + name: "reformatted", + status: "reformatted", + wantLabel: "Reformatted", + wantLead: "The official status of this feed is reformatted:", + }, + { + name: "altered scope", + status: "altered_scope", + wantLabel: "Altered scope", + wantLead: "The official status of this feed has been altered:", + }, + { + name: "unknown", + status: "unknown", + wantLabel: "Unknown", + wantLead: "The official status of this feed is unknown:", + }, + { + name: "custom", + status: "custom_state", + wantLabel: "custom state", + wantLead: "The status of this feed is custom state:", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label, err := markdown.ExecuteInline("{{statusLabel .}}", tc.status) + if err != nil { + t.Fatalf("statusLabel: %v", err) + } + if label != tc.wantLabel { + t.Fatalf("statusLabel(%q)=%q; want %q", tc.status, label, tc.wantLabel) + } + + lead, err := markdown.ExecuteInline("{{statusLead .}}", tc.status) + if err != nil { + t.Fatalf("statusLead: %v", err) + } + if lead != tc.wantLead { + t.Fatalf("statusLead(%q)=%q; want %q", tc.status, lead, tc.wantLead) + } + }) + } +} + +func TestExecuteInlineReturnsParseErrors(t *testing.T) { + t.Parallel() + + if _, err := markdown.ExecuteInline("{{", nil); err == nil { + t.Fatal("ExecuteInline returned nil error for malformed template") + } } diff --git a/pkg/markdown/generate_test.go b/pkg/markdown/generate_test.go index 8c7d047..6c6e5b8 100644 --- a/pkg/markdown/generate_test.go +++ b/pkg/markdown/generate_test.go @@ -64,6 +64,15 @@ func TestTemplateStoreLoadMissingDir(t *testing.T) { } } +func TestTemplateStoreDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if got := markdown.NewTemplateStore(dir).Dir(); got != dir { + t.Fatalf("Dir()=%q; want %q", got, dir) + } +} + func TestTemplateStoreWithTemplates(t *testing.T) { dir := t.TempDir()