Skip to content
Merged
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
166 changes: 166 additions & 0 deletions internal/status/field_conformance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// ABOUTME: Schema-driven per-field conformance — drives type/pattern/enum checks
// ABOUTME: from the embedded entity.mdschema.yml at the field's declared severity.
package status

import (
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"

spacedock "github.com/spacedock-dev/spacedock"
"gopkg.in/yaml.v3"
)

// schemaField is one entry of the entity mdschema's frontmatter.fields map. Only
// the keys the field-conformance loop reads are surfaced; unknown keys (semantics,
// coerce_*, etc.) are ignored by yaml.v3.
type schemaField struct {
Type string `yaml:"type"`
Pattern string `yaml:"pattern"`
Conventional []string `yaml:"conventional"`
InvalidSeverity string `yaml:"invalid_severity"`
UnknownSeverity string `yaml:"unknown_severity"`
}

// entitySchema is the parsed entity mdschema's field declarations — the SSOT the
// validator drives per-field checks from.
type entitySchema struct {
fields map[string]schemaField
}

var (
loadedSchema *entitySchema
loadedSchemaOnce sync.Once
)

// loadEntitySchema parses the embedded entity.mdschema.yml once and returns its
// field declarations. The schema is JSON (a YAML subset), so yaml.v3 parses it
// directly. A parse failure yields an empty field set — field conformance is a
// warn-tier advisory and must never block the binary; a corrupt embedded schema
// is a build-time problem the schema-driven test (AC-4) catches.
func loadEntitySchema() *entitySchema {
loadedSchemaOnce.Do(func() {
var doc struct {
Frontmatter struct {
Fields map[string]schemaField `yaml:"fields"`
} `yaml:"frontmatter"`
}
s := &entitySchema{fields: map[string]schemaField{}}
if err := yaml.Unmarshal(spacedock.EntityMDSchema, &doc); err == nil {
s.fields = doc.Frontmatter.Fields
}
loadedSchema = s
})
return loadedSchema
}

// isoLayouts are the timestamp shapes the spacedock writer emits (second and
// microsecond precision, UTC Z). A value matching any layout is a valid
// iso8601; the date-only RFC3339 prefix is accepted for fields the FO may set
// by hand.
var isoLayouts = []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02T15:04:05.000000Z",
"2006-01-02",
}

// isISO8601 reports whether s parses as one of the accepted ISO-8601 layouts.
func isISO8601(s string) bool {
for _, layout := range isoLayouts {
if _, err := time.Parse(layout, s); err == nil {
return true
}
}
return false
}

// fieldConformanceWarnings returns warn-tier diagnostics for every active +
// archived entity whose frontmatter violates a schema field's declared
// pattern / conventional enum / type, at the field's declared severity. Only
// fields whose severity is `warn` are surfaced here (no schema field is `error`
// today; an `error`-severity field would route through the structural-error path
// instead). Each line names the field and the violated rule plus the entity
// evidence so the FO can locate the entity.
func fieldConformanceWarnings(entities []*entity, workflowDir string) []string {
schema := loadEntitySchema()
var warns []string
for _, e := range entities {
for name, spec := range schema.fields {
if !isWarnSeverity(spec) {
continue
}
value, present := e.fields[name]
if !present || value == "" {
continue
}
problem := fieldViolation(name, spec, value)
if problem == "" {
continue
}
warns = append(warns, entityEvidenceLine("Warning", e, workflowDir, problem, e.displayID))
}
}
return warns
}

// isWarnSeverity reports whether a schema field carries a `warn` severity for
// its conformance check (either invalid_severity or unknown_severity). Fields
// with a pattern/conventional/type check but no explicit severity default to
// warn — the schema marks every per-field check warn today, and a field-level
// conformance gap must never harden into an exit-1 gate without an explicit
// `error` severity in the schema.
func isWarnSeverity(spec schemaField) bool {
switch spec.InvalidSeverity {
case "warn":
return true
case "error":
return false
}
if spec.UnknownSeverity == "error" {
return false
}
// No explicit invalid_severity: a checkable field (pattern/enum/type) with no
// declared severity is advisory (warn). Fields with nothing to check are
// skipped by fieldViolation returning "".
return spec.Pattern != "" || len(spec.Conventional) > 0 || spec.Type == "iso8601" || spec.Type == "numeric_string"
}

// fieldViolation returns a human-readable problem string when value violates the
// field's schema-declared rule, or "" when it conforms / has nothing to check.
// The rule (pattern, enum, type) is read from the schema spec — never a Go
// literal — so editing the schema changes what is enforced.
func fieldViolation(name string, spec schemaField, value string) string {
if spec.Pattern != "" {
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return ""
}
if !re.MatchString(value) {
return fmt.Sprintf("field '%s' value %q does not match pattern %s", name, value, spec.Pattern)
}
return ""
}
if len(spec.Conventional) > 0 {
for _, allowed := range spec.Conventional {
if value == allowed {
return ""
}
}
return fmt.Sprintf("field '%s' value %q is not one of [%s]", name, value, strings.Join(spec.Conventional, " "))
}
switch spec.Type {
case "numeric_string":
if _, err := strconv.ParseFloat(value, 64); err != nil {
return fmt.Sprintf("field '%s' value %q is not numeric", name, value)
}
case "iso8601":
if !isISO8601(value) {
return fmt.Sprintf("field '%s' value %q is not a valid ISO-8601 timestamp", name, value)
}
}
return ""
}
164 changes: 164 additions & 0 deletions internal/status/field_conformance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// ABOUTME: Schema-driven field-conformance tests — warn-tier field violations
// ABOUTME: surface on stderr, exit 0, never gate reads; schema bytes are the source.
package status

import (
"strings"
"testing"

spacedock "github.com/spacedock-dev/spacedock"
"gopkg.in/yaml.v3"
)

// entField builds a sequential-style entity with an extra frontmatter line so a
// single bad field can be planted while id/status stay clean.
func entField(id, status, extraKey, extraVal string) string {
fm := "---\nid: " + id + "\ntitle: T\nstatus: " + status + "\nscore: \"0.5\"\nsource: x\n"
if extraKey != "" {
fm += extraKey + ": " + extraVal + "\n"
}
return fm + "---\n# T\n"
}

// TestFieldConformanceWarnsSurface locks AC-1: each per-field schema violation
// the entity schema declares produces a field-named diagnostic on stderr under
// `--validate`. The bad value lives in the fixture; the rule is the schema's.
func TestFieldConformanceWarnsSurface(t *testing.T) {
env := pinnedEnv(t)
cases := []struct {
name string
files map[string]string
wantField string // the field name the diagnostic must mention
}{
{
name: "mod-block-no-colon",
files: map[string]string{"a.md": entField(`"001"`, "backlog", "mod-block", "noColonHere")},
wantField: "mod-block",
},
{
name: "verdict-out-of-enum",
files: map[string]string{"a.md": entField(`"001"`, "backlog", "verdict", "MAYBE")},
wantField: "verdict",
},
{
name: "score-not-numeric",
files: map[string]string{"a.md": entField(`"001"`, "backlog", "score", "notanumber")},
wantField: "score",
},
{
name: "started-malformed-iso",
files: map[string]string{"a.md": entField(`"001"`, "backlog", "started", "not-a-date")},
wantField: "started",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
root := validationFixture(t, seqREADME, tc.files)
_, nErr, nCode := runNative(t, root, env, "--workflow-dir", root, "--validate")
if nCode != 0 {
t.Fatalf("warn-only fixture must exit 0, got %d (stderr=%q)", nCode, nErr)
}
// The diagnostic must carry the Warning: prefix bound to the field —
// a field-conformance finding is advisory, not a gating Error:. This
// pins the warn-vs-error signal the FO reads; flipping the line to
// Error: must red this assertion.
wantLine := "Warning: field '" + tc.wantField
if !strings.Contains(nErr, wantLine) {
t.Fatalf("stderr missing warn line %q, got %q", wantLine, nErr)
}
if strings.Contains(nErr, "Error: field '"+tc.wantField) {
t.Fatalf("field-conformance finding misreported as Error: (must be Warning:), got %q", nErr)
}
})
}
}

// TestFieldConformanceWarnsDoNotGateExit locks AC-2: a warn-only workflow exits
// 0 with non-empty warning stderr; a structural error (dup id) still exits 1.
func TestFieldConformanceWarnsDoNotGateExit(t *testing.T) {
env := pinnedEnv(t)

t.Run("warn-only-exits-0", func(t *testing.T) {
files := map[string]string{
"a.md": entField(`"001"`, "backlog", "verdict", "MAYBE"),
"b.md": entField(`"002"`, "done", "mod-block", "noColon"),
}
root := validationFixture(t, seqREADME, files)
nOut, nErr, nCode := runNative(t, root, env, "--workflow-dir", root, "--validate")
if nCode != 0 {
t.Fatalf("warn-only --validate must exit 0, got %d", nCode)
}
if strings.TrimSpace(nErr) == "" {
t.Fatalf("warn-only --validate must print warnings to stderr, got empty")
}
if strings.TrimSpace(nOut) != "VALID" {
t.Fatalf("warn-only --validate stdout must stay VALID, got %q", nOut)
}
})

t.Run("structural-error-exits-1", func(t *testing.T) {
files := map[string]string{
"a.md": entField(`"001"`, "backlog", "verdict", "MAYBE"),
"b.md": entField(`"001"`, "done", "", ""), // dup id == structural error
}
root := validationFixture(t, seqREADME, files)
_, _, nCode := runNative(t, root, env, "--workflow-dir", root, "--validate")
if nCode != 1 {
t.Fatalf("structural error (dup id) must exit 1, got %d", nCode)
}
})
}

// TestFieldConformanceWarnsDoNotGateReads locks AC-3: a warn-tier field
// violation does not gate the default-table read path — the FO is never locked
// out by a field warning. Mirrors TestNativeValidationGatesReads inverted.
func TestFieldConformanceWarnsDoNotGateReads(t *testing.T) {
env := pinnedEnv(t)
files := map[string]string{"a.md": entField(`"001"`, "backlog", "verdict", "MAYBE")}
root := validationFixture(t, seqREADME, files)

nOut, _, nCode := runNative(t, root, env, "--workflow-dir", root)
if nCode != 0 {
t.Fatalf("default table over a warn-violation fixture must exit 0, got %d", nCode)
}
if strings.TrimSpace(nOut) == "" {
t.Fatalf("default table must still print the table on stdout, got empty")
}
}

// TestFieldConformanceSchemaDriven locks AC-4: the validator's rule and the
// schema file agree. The test loads the embedded schema bytes independently,
// parses out the mod-block pattern, and asserts the validator's loaded pattern
// equals it — two independently-readable values that could diverge.
func TestFieldConformanceSchemaDriven(t *testing.T) {
// Independently parse the embedded schema for the mod-block pattern.
var doc struct {
Frontmatter struct {
Fields map[string]struct {
Pattern string `yaml:"pattern"`
Conventional []string `yaml:"conventional"`
} `yaml:"fields"`
} `yaml:"frontmatter"`
}
if err := yaml.Unmarshal(spacedock.EntityMDSchema, &doc); err != nil {
t.Fatalf("parse embedded schema: %v", err)
}
wantPattern := doc.Frontmatter.Fields["mod-block"].Pattern
if wantPattern == "" {
t.Fatalf("embedded schema has no mod-block pattern")
}

schema := loadEntitySchema()
gotPattern := schema.fields["mod-block"].Pattern
if gotPattern != wantPattern {
t.Fatalf("validator mod-block pattern %q != schema file pattern %q", gotPattern, wantPattern)
}

// Same for the verdict conventional enum.
wantEnum := doc.Frontmatter.Fields["verdict"].Conventional
gotEnum := schema.fields["verdict"].Conventional
if strings.Join(gotEnum, ",") != strings.Join(wantEnum, ",") {
t.Fatalf("validator verdict enum %v != schema file enum %v", gotEnum, wantEnum)
}
}
12 changes: 9 additions & 3 deletions internal/status/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,15 @@ func runRead(probe claudeteam.TeamStateProbe, roots roots, args []string, e env,
if len(incompatible) > 0 {
return errExit(stderr, "--validate cannot be combined with "+strings.Join(incompatible, ", "))
}
// Explicit --validate command opts INTO the external-proof sub-check.
// The read-path pre-check (failOnValidationErrors) passes false.
errs := validateWorkflow(roots.definitionDir, roots.entityDir, idStyle, true, stderr)
// Explicit --validate command opts INTO the external-proof sub-check and
// the warn-tier per-field schema-conformance sub-check. The read-path
// pre-check (failOnValidationErrors) passes false, opting out of both.
errs, warns := validateWorkflow(roots.definitionDir, roots.entityDir, idStyle, true, stderr)
// Warn-tier per-field schema conformance prints to stderr but does NOT
// flip the exit code — exit 1 stays reserved for structural errors.
for _, w := range warns {
fmt.Fprintln(stderr, w)
}
if len(errs) > 0 {
for _, er := range errs {
fmt.Fprintln(stderr, er)
Expand Down
5 changes: 4 additions & 1 deletion internal/status/native_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,10 @@ func failOnValidationErrors(roots roots, idStyle string, stderr io.Writer) int {
// / `--next` / `--boot` / `--next-id` — those are the surfaces they need to
// SEE the broken entity. The terminal-set guard in runSet still classifies
// directly, and the explicit `--validate` command opts the check in.
errs := validateWorkflow(roots.definitionDir, roots.entityDir, idStyle, false, stderr)
// Read-path gate passes false: it opts OUT of the warn-tier field-conformance
// (and external-proof) sub-check, so a warn-tier field finding never locks the
// FO out of the listing they need to SEE the broken entity (warns is dropped).
errs, _ := validateWorkflow(roots.definitionDir, roots.entityDir, idStyle, false, stderr)
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintln(stderr, err)
Expand Down
Loading
Loading