diff --git a/internal/status/field_conformance.go b/internal/status/field_conformance.go new file mode 100644 index 00000000..9cda50bd --- /dev/null +++ b/internal/status/field_conformance.go @@ -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 "" +} diff --git a/internal/status/field_conformance_test.go b/internal/status/field_conformance_test.go new file mode 100644 index 00000000..7d7ad782 --- /dev/null +++ b/internal/status/field_conformance_test.go @@ -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) + } +} diff --git a/internal/status/handlers.go b/internal/status/handlers.go index a122391b..3db73377 100644 --- a/internal/status/handlers.go +++ b/internal/status/handlers.go @@ -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) diff --git a/internal/status/native_runner.go b/internal/status/native_runner.go index 84637999..6b7e74b1 100644 --- a/internal/status/native_runner.go +++ b/internal/status/native_runner.go @@ -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) diff --git a/internal/status/validate.go b/internal/status/validate.go index b8cfd945..0f1c412d 100644 --- a/internal/status/validate.go +++ b/internal/status/validate.go @@ -15,12 +15,20 @@ import ( // entityEvidence formats the Error: ... workflow= scope= slug= id= [display=] // path= evidence line. Matches entity_evidence. func entityEvidence(e *entity, workflowDir, problem, displayID string) string { + return entityEvidenceLine("Error", e, workflowDir, problem, displayID) +} + +// entityEvidenceLine formats an evidence line at the given severity prefix +// ("Error" for gating structural defects, "Warning" for warn-tier field +// conformance). The field shape (workflow=/scope=/slug=/id=/[display=]/path=) is +// identical so the FO locates the entity the same way for either severity. +func entityEvidenceLine(severity string, e *entity, workflowDir, problem, displayID string) string { display := displayID if display == "" { display = e.displayID } parts := []string{ - fmt.Sprintf("Error: %s:", problem), + fmt.Sprintf("%s: %s:", severity, problem), "workflow=" + workflowDir, "scope=" + scopeOf(e), "slug=" + e.slug, @@ -143,8 +151,7 @@ func validateWorkflowStageNames(definitionDir string) []string { // status/--next/--boot/--next-id) never fires the AC classifier. A read path // failing on a flagged AC would lock the FO out of the very listing they need // to see the broken entity. -func validateWorkflow(definitionDir, entityDir, idStyle string, includeExternalProof bool, stderr io.Writer) []string { - var errs []string +func validateWorkflow(definitionDir, entityDir, idStyle string, includeExternalProof bool, stderr io.Writer) (errs []string, warns []string) { errs = append(errs, findEntityFormConflicts(entityDir, entityDir, "active")...) errs = append(errs, findEntityFormConflicts(filepath.Join(entityDir, "_archive"), PyJoin(entityDir, "_archive"), "archived")...) errs = append(errs, validateWorkflowStageNames(definitionDir)...) @@ -165,9 +172,17 @@ func validateWorkflow(definitionDir, entityDir, idStyle string, includeExternalP } if !includeExternalProof { - return errs + return errs, nil } + // Warn-tier per-field schema conformance shares the same opt-in as the + // external-proof sub-check: only the explicit --validate command computes it, + // reusing the entities already enumerated above (with effective ids applied so + // the evidence line carries the display id). Warns are returned separately so + // the caller keeps them out of the exit-code decision. + applyEffectiveIDs(entities, idStyle, entities) + warns = fieldConformanceWarnings(entities, entityDir) + // require-external-proof sub-check: when the workflow opts in, every // active entity is classified and each flagged AC is emitted as a standard // entityEvidence line. A typo in the README key is surfaced as the same @@ -175,7 +190,7 @@ func validateWorkflow(definitionDir, entityDir, idStyle string, includeExternalP policy, perr := resolveExternalProofPolicy(definitionDir) if perr != nil { errs = append(errs, "Error: "+perr.Error()) - return errs + return errs, warns } if policy == externalProofOn { for _, e := range entities { @@ -193,7 +208,7 @@ func validateWorkflow(definitionDir, entityDir, idStyle string, includeExternalP } } - return errs + return errs, warns } func validateSequential(entities []*entity, workflowDir string) []string { diff --git a/schema_embed.go b/schema_embed.go new file mode 100644 index 00000000..b4e7127a --- /dev/null +++ b/schema_embed.go @@ -0,0 +1,14 @@ +// ABOUTME: Embeds the canonical entity mdschema (the frontmatter SSOT) into the +// ABOUTME: binary so field-conformance validation has no runtime docs/ dependency. +package spacedock + +import _ "embed" + +// EntityMDSchema is the bytes of docs/schema/entity.mdschema.yml, the single +// source of truth for the entity frontmatter contract. It is embedded here at +// the module root because go:embed cannot reach a file above the embedding +// package's directory; embedding the canonical file (rather than a copy) keeps +// one source of truth that the validator reads at run time. +// +//go:embed docs/schema/entity.mdschema.yml +var EntityMDSchema []byte