diff --git a/internal/concept/v02.go b/internal/concept/v02.go index 1c17ea7..b2b166b 100644 --- a/internal/concept/v02.go +++ b/internal/concept/v02.go @@ -157,18 +157,28 @@ func (f *Frontmatter) GeneratedAt() (at time.Time, ok bool) { return time.Time{}, false } -// IsStale reports whether the concept is stale as of today: stale when -// today >= stale_after (OKF §5.5). Absent or unparseable stale_after is never +// IsStale reports whether the concept is stale as of now: stale when +// now >= stale_after (OKF §5.5). Absent or unparseable stale_after is never // stale; validation flags the unparseable case separately. -func (f *Frontmatter) IsStale(today time.Time) bool { +func (f *Frontmatter) IsStale(now time.Time) bool { if strings.TrimSpace(f.StaleAfter) == "" { return false } - d, err := time.Parse("2006-01-02", f.StaleAfter) + t, err := ParseStaleAfter(f.StaleAfter) if err != nil { return false } - return !today.Before(d) + return !now.Before(t) +} + +// ParseStaleAfter parses a stale_after value (OKF §5.5): an RFC 3339 datetime +// with any offset, stale from that instant on, or a YYYY-MM-DD date, stale +// from the start of that day (00:00 UTC) on. +func ParseStaleAfter(s string) (time.Time, error) { + if t, err := ParseDatetime(s); err == nil { + return t, nil + } + return time.Parse("2006-01-02", s) } // ActorKind classifies an actor string per the convention of §7: diff --git a/internal/concept/v02_test.go b/internal/concept/v02_test.go index e19ced6..5f9b3ce 100644 --- a/internal/concept/v02_test.go +++ b/internal/concept/v02_test.go @@ -165,6 +165,46 @@ func TestIsStale_AbsentNeverStale(t *testing.T) { } } +func TestIsStale_DateAndDatetimeForms(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + cases := []struct { + name string + value string // raw YAML scalar + wantErr bool + wantStale bool + }{ + {"date past", "2026-09-01", false, true}, + {"date future", "2026-12-01", false, false}, + {"date today", "2026-10-06", false, true}, + {"datetime past", "2026-09-01T00:00:00Z", false, true}, + {"datetime future", "2027-10-06T00:00:00Z", false, false}, + {"datetime at now", "2026-10-06T12:00:00Z", false, true}, + {"datetime just after now", "2026-10-06T12:00:01Z", false, false}, + {"datetime quoted", `"2026-09-01T00:00:00Z"`, false, true}, + {"datetime positive offset past", "2026-10-06T18:30:00+07:00", false, true}, // 11:30Z + {"datetime negative offset future", "2026-10-06T05:30:00-07:00", false, false}, // 12:30Z + {"invalid word", "soon", true, false}, + {"invalid date", "2026-13-01", true, false}, + {"invalid datetime without offset", "2026-09-01T00:00:00", true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := []byte("---\ntype: T\nstale_after: " + tc.value + "\n---\nbody") + c, err := ParseBytes(raw, "a.md", "/tmp/a.md") + if err != nil { + t.Fatalf("ParseBytes: %v", err) + } + _, err = ParseStaleAfter(c.Frontmatter.StaleAfter) + if (err != nil) != tc.wantErr { + t.Errorf("ParseStaleAfter(%q) error = %v, wantErr %v", c.Frontmatter.StaleAfter, err, tc.wantErr) + } + if got := c.Frontmatter.IsStale(now); got != tc.wantStale { + t.Errorf("IsStale(%s) with stale_after %q = %v, want %v", now.Format(time.RFC3339), c.Frontmatter.StaleAfter, got, tc.wantStale) + } + }) + } +} + // --- §13.1 generated.at with legacy timestamp fallback --- func TestGeneratedAt_FallbackToLegacyTimestamp(t *testing.T) { diff --git a/internal/validate/rules.go b/internal/validate/rules.go index 7c81794..063e193 100644 --- a/internal/validate/rules.go +++ b/internal/validate/rules.go @@ -71,8 +71,8 @@ var ruleDescriptions = map[string]string{ RuleLinkBroken: "cross-link does not resolve to a concept in the bundle; consumers must tolerate this (OKF §6.1, §11)", RuleStatusInvalid: "'status' must be draft, stable, or deprecated (OKF §5.4)", - RuleStaleAfterInvalid: "'stale_after' must be an absolute YYYY-MM-DD date (OKF §5.5)", - RuleStale: "concept is past its stale_after date (OKF §5.5)", + RuleStaleAfterInvalid: "'stale_after' must be an RFC 3339 datetime or a YYYY-MM-DD date (OKF §5.5)", + RuleStale: "concept is past its stale_after instant (OKF §5.5)", RuleGeneratedByRequired: "'generated' requires 'by' (OKF §5.2)", RuleGeneratedAtInvalid: "'generated.at' is not an ISO 8601 datetime (OKF §5.2)", diff --git a/internal/validate/v02.go b/internal/validate/v02.go index 9f0d688..c1f7b77 100644 --- a/internal/validate/v02.go +++ b/internal/validate/v02.go @@ -36,14 +36,15 @@ func validateV02(r *Report, b *bundle.Bundle, c *concept.Concept) { "frontmatter: 'status' must be draft, stable, or deprecated, got %q (OKF §5.4)", fm.Status)) } - // §5.5 stale_after is an absolute YYYY-MM-DD date. + // §5.5 stale_after is an absolute instant: an RFC 3339 datetime or a + // YYYY-MM-DD date. if s := strings.TrimSpace(fm.StaleAfter); s != "" { - if _, err := time.Parse("2006-01-02", s); err != nil { + if _, err := concept.ParseStaleAfter(s); err != nil { r.add(c.ID, RuleStaleAfterInvalid, SeverityError, fmt.Sprintf( - "frontmatter: 'stale_after' must be an absolute YYYY-MM-DD date, got %q (OKF §5.5)", fm.StaleAfter)) + "frontmatter: 'stale_after' must be an RFC 3339 datetime or a YYYY-MM-DD date, got %q (OKF §5.5)", fm.StaleAfter)) } else if fm.IsStale(now()) { r.add(c.ID, RuleStale, SeverityWarning, fmt.Sprintf( - "concept is stale: today >= stale_after (%s) (OKF §5.5)", fm.StaleAfter)) + "concept is stale: now >= stale_after (%s) (OKF §5.5)", fm.StaleAfter)) } } diff --git a/internal/validate/v02_test.go b/internal/validate/v02_test.go index d4b90bf..d5af2fb 100644 --- a/internal/validate/v02_test.go +++ b/internal/validate/v02_test.go @@ -61,7 +61,9 @@ func TestValidate_MalformedStaleAfter(t *testing.T) { b := testBundle(t, map[string]string{ "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nstale_after: soon\n---\n\nbody", }) - mustFinding(t, Validate(b), SeverityError, "stale_after") + r := Validate(b) + mustFinding(t, r, SeverityError, "stale_after") + mustFinding(t, r, SeverityError, "RFC 3339 datetime or a YYYY-MM-DD date") } func TestValidate_StaleConceptWarns(t *testing.T) { @@ -82,6 +84,52 @@ func TestValidate_FreshConceptNoStaleWarning(t *testing.T) { mustNotFinding(t, Validate(b), "stale") } +// ruleFindings returns the findings carrying the given rule ID. +func ruleFindings(r *Report, ruleID string) []Finding { + var out []Finding + for _, f := range r.Findings { + if f.RuleID == ruleID { + out = append(out, f) + } + } + return out +} + +// The spec's own §5.5 example form is a datetime (okfcli/okf#34). +func TestValidate_DatetimeStaleAfter(t *testing.T) { + defer func(orig func() time.Time) { now = orig }(now) + now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) } + cases := []struct { + name string + value string + wantStale bool + }{ + {"past UTC", "2026-09-01T00:00:00Z", true}, + {"past with offset", "2026-10-06T18:30:00+07:00", true}, + {"quoted past", `"2026-09-01T00:00:00+00:00"`, true}, + {"future UTC", "2027-10-06T00:00:00Z", false}, + {"future with offset", "2026-10-06T05:30:00-07:00", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nstale_after: " + tc.value + "\n---\n\nbody", + }) + r := Validate(b) + if got := ruleFindings(r, RuleStaleAfterInvalid); len(got) != 0 { + t.Fatalf("unexpected %s findings: %+v", RuleStaleAfterInvalid, got) + } + stale := ruleFindings(r, RuleStale) + if tc.wantStale && (len(stale) != 1 || stale[0].Severity != SeverityWarning) { + t.Fatalf("want one %s warning, findings = %+v", RuleStale, r.Findings) + } + if !tc.wantStale && len(stale) != 0 { + t.Fatalf("unexpected %s findings: %+v", RuleStale, stale) + } + }) + } +} + // --- §5.2 generated / verified --- func TestValidate_GeneratedWithoutBy(t *testing.T) {