Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions internal/concept/v02.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions internal/concept/v02_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions internal/validate/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
9 changes: 5 additions & 4 deletions internal/validate/v02.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
50 changes: 49 additions & 1 deletion internal/validate/v02_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down