Files
krow_backend/go-api/internal/definition/conformance_test.go
2026-08-24 13:06:29 +05:30

929 lines
32 KiB
Go

package definition_test
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/definition"
)
// Phase 4D — JS/Go parser conformance.
//
// The fixture these tests read (testdata/oracle.json) is not written by hand.
// It is captured by scripts/oracle.mjs, which loads the REAL frontend module
// graph through Vite — import.meta.glob, the `@/` alias and raw Markdown
// loading all behave exactly as they do in the app — and records what the
// JavaScript parser did with every shipped definition and every adversarial
// case. So the assertion below is not "Go agrees with a description of the
// frontend"; it is "Go agrees with the frontend", replayed.
//
// Regenerate after any change to src/lib/skills or src/lib/agents:
//
// node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json
//
// A frontend change that alters parsing therefore fails these tests, which is
// the point: the contract cannot drift silently in either direction.
type oracle struct {
Vocabulary struct {
Pages []struct {
ID string `json:"id"`
Aliases []string `json:"aliases"`
} `json:"pages"`
AgentStatuses []string `json:"agentStatuses"`
Reasoning []string `json:"reasoning"`
Icons []string `json:"icons"`
KnowledgeKinds []string `json:"knowledgeKinds"`
Access []string `json:"access"`
Roles []string `json:"roles"`
} `json:"vocabulary"`
Corpus []observation `json:"corpus"`
Cases []observation `json:"cases"`
}
// observation is one definition as the JavaScript saw it, end to end.
type observation struct {
// Exactly one of these identifies the row.
Path string `json:"path"`
Name string `json:"name"`
Type string `json:"type"`
Kind string `json:"kind"` // agent | skill
RawBase64 string `json:"rawBase64"`
HasFrontmatter bool `json:"hasFrontmatter"`
Frontmatter struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Body string `json:"body"`
Error string `json:"error"`
} `json:"frontmatter"`
Parse struct {
OK bool `json:"ok"`
Error string `json:"error"`
} `json:"parse"`
Normalized map[string]any `json:"normalized"`
Accepted bool `json:"accepted"`
Rejection *string `json:"rejection"`
}
func (o observation) id() string {
if o.Path != "" {
return o.Path
}
return o.Name
}
func (o observation) raw(t *testing.T) string {
t.Helper()
b, err := base64.StdEncoding.DecodeString(o.RawBase64)
if err != nil {
t.Fatalf("%s: undecodable fixture: %v", o.id(), err)
}
return string(b)
}
func load(t *testing.T) *oracle {
t.Helper()
b, err := os.ReadFile("testdata/oracle.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var o oracle
if err := json.Unmarshal(b, &o); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if len(o.Corpus) == 0 || len(o.Cases) == 0 {
t.Fatal("fixture is empty; regenerate with scripts/oracle.mjs")
}
return &o
}
func all(o *oracle) []observation { return append(append([]observation{}, o.Corpus...), o.Cases...) }
/* ── 1. The corpus is the corpus ──────────────────────────────────────────── */
// The shipped definition count, asserted rather than assumed. A definition
// added to or removed from the product without regenerating the fixture leaves
// these tests passing against a corpus that no longer exists, which is the one
// way this suite could quietly stop meaning anything.
func TestCorpusShape(t *testing.T) {
o := load(t)
counts := map[string]int{}
for _, c := range o.Corpus {
counts[c.Type]++
}
for _, want := range []struct {
kind string
n int
}{{"agent", 9}, {"skill", 23}, {"example", 5}} {
if counts[want.kind] != want.n {
t.Errorf("%s definitions: got %d, want %d", want.kind, counts[want.kind], want.n)
}
}
if len(o.Corpus) != 37 {
t.Errorf("shipped definitions: got %d, want 37", len(o.Corpus))
}
}
/* ── 2. The vocabulary has not drifted ────────────────────────────────────── */
// Every closed table in vocabulary.go, checked against the table the frontend
// actually exports. A page added to surfaces.js fails here rather than becoming
// a definition the editor accepts and the API rejects.
func TestVocabularyMatchesFrontend(t *testing.T) {
o := load(t)
wantPages := make([]string, len(o.Vocabulary.Pages))
for i, p := range o.Vocabulary.Pages {
wantPages[i] = p.ID
}
if !reflect.DeepEqual(definition.SupportedPages, wantPages) {
t.Errorf("supported pages differ\n go %v\n js %v", definition.SupportedPages, wantPages)
}
// Aliases resolve, and resolve to the same canonical id.
for _, p := range o.Vocabulary.Pages {
for _, alias := range append([]string{p.ID}, p.Aliases...) {
if got := definition.CanonicalPage(alias); got != p.ID {
t.Errorf("CanonicalPage(%q) = %q, want %q", alias, got, p.ID)
}
}
}
for _, table := range []struct {
name string
got []string
wanted []string
}{
{"agent statuses", definition.AgentStatuses, o.Vocabulary.AgentStatuses},
{"reasoning modes", definition.ReasoningModes, o.Vocabulary.Reasoning},
{"icons", definition.AgentIcons, o.Vocabulary.Icons},
{"knowledge kinds", definition.KnowledgeKinds, o.Vocabulary.KnowledgeKinds},
{"access modes", definition.AgentAccess, o.Vocabulary.Access},
{"permission roles", definition.PermissionRole, o.Vocabulary.Roles},
} {
if !reflect.DeepEqual(table.got, table.wanted) {
t.Errorf("%s differ\n go %v\n js %v", table.name, table.got, table.wanted)
}
}
}
/* ── 3. The frontmatter tree ──────────────────────────────────────────────── */
// The deepest parity check available: the YAML subset must produce the same
// data structure the JavaScript produced, for every definition and every
// adversarial case. Not a projection of it — the whole tree.
func TestFrontmatterTreeParity(t *testing.T) {
for _, c := range all(load(t)) {
t.Run(c.id(), func(t *testing.T) {
raw := c.raw(t)
doc, err := definition.ParseFrontmatter(raw)
if !c.Frontmatter.OK {
if err == nil {
t.Fatalf("JS refused this frontmatter (%s); Go accepted it", c.Frontmatter.Error)
}
if err.Error() != c.Frontmatter.Error {
t.Errorf("error text differs\n go %q\n js %q", err.Error(), c.Frontmatter.Error)
}
return
}
if err != nil {
t.Fatalf("JS read this frontmatter; Go refused it: %v", err)
}
if got, want := normalizeTree(doc.Data), normalizeTree(c.Frontmatter.Data); !reflect.DeepEqual(got, want) {
t.Errorf("frontmatter differs\n go %s\n js %s", show(got), show(want))
}
if doc.Body != c.Frontmatter.Body {
t.Errorf("body differs\n go %q\n js %q", doc.Body, c.Frontmatter.Body)
}
if got := definition.HasFrontmatter(raw); got != c.HasFrontmatter {
t.Errorf("HasFrontmatter = %v, JS said %v", got, c.HasFrontmatter)
}
})
}
}
// normalizeTree puts a parsed tree into the shape `encoding/json` would have
// produced, so the Go value and the value round-tripped through the fixture's
// JSON are comparable. Numbers become float64 on both sides, which is what
// JavaScript had in the first place.
func normalizeTree(v any) any {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("unmarshalable: %v", err)
}
var out any
if err := json.Unmarshal(b, &out); err != nil {
return fmt.Sprintf("unmarshalable: %v", err)
}
return out
}
func show(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
/* ── 4. Accept / reject parity ────────────────────────────────────────────── */
// knownDivergence is the complete list of definitions where the two parsers
// disagree, each with the reason. It is a CLOSED list: anything not on it that
// disagrees fails, and anything on it that stops disagreeing fails too, so the
// list cannot quietly grow and cannot quietly go stale.
//
// Every entry is a case where Go is stricter, except the first — and the first
// is the one asymmetry this package documents as deferred.
var knownDivergence = map[string]string{
"skill-examples/board-invalid-context.md": "" +
"JS rejects on `ui:` placement/source semantics, which this package defers " +
"to the frontend. Go accepts and reports Deferred: [ui].",
"oversized-markdown": "" +
"JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " +
"first, so an author gets a message instead of a constraint violation.",
"oversized-agent": "" +
"JS accepts; the database refuses it (markdown_size CHECK). Go refuses it " +
"first, so an author gets a message instead of a constraint violation.",
"version-above-int32-agent": "" +
"JS accepts any whole number of 1 or more; agent_definitions.version is a " +
"PostgreSQL `integer`, so the database refuses this one. Go refuses it " +
"first, for the same reason as the size bound.",
}
func TestAcceptanceParity(t *testing.T) {
seen := map[string]bool{}
for _, c := range all(load(t)) {
t.Run(c.id(), func(t *testing.T) {
raw := c.raw(t)
var err error
if c.Kind == "agent" {
err = definition.ValidateAgent(raw)
} else {
err = definition.ValidateSkill(raw)
}
accepted := err == nil
if reason, expected := knownDivergence[c.id()]; expected {
seen[c.id()] = true
if accepted == c.Accepted {
t.Errorf("listed as a known divergence but the two now agree (%v).\n"+
"Remove it from knownDivergence.\n reason on file: %s", accepted, reason)
}
return
}
if accepted != c.Accepted {
t.Fatalf("acceptance differs: go=%v js=%v\n go said: %v\n js said: %v",
accepted, c.Accepted, err, deref(c.Rejection))
}
})
}
for id := range knownDivergence {
if !seen[id] {
t.Errorf("knownDivergence names %q, which is not in the fixture", id)
}
}
}
// Where both refuse a definition, they must refuse it for the same stated
// reason. A parser that rejects the right definitions with the wrong messages
// sends an author to the wrong line.
func TestRejectionMessageParity(t *testing.T) {
for _, c := range all(load(t)) {
if c.Accepted || c.Rejection == nil {
continue
}
if _, skip := knownDivergence[c.id()]; skip {
continue
}
t.Run(c.id(), func(t *testing.T) {
raw := c.raw(t)
var err error
if c.Kind == "agent" {
err = definition.ValidateAgent(raw)
} else {
err = definition.ValidateSkill(raw)
}
if err == nil {
t.Fatalf("JS rejected this; Go accepted it")
}
if r, ok := err.(*definition.Rejection); ok && r.BackendOnly {
t.Fatalf("refused by a backend-only rule where JS refused it too: %q", r.Message)
}
if err.Error() != *c.Rejection {
t.Errorf("rejection differs\n go %q\n js %q", err.Error(), *c.Rejection)
}
})
}
}
func deref(s *string) string {
if s == nil {
return "<accepted>"
}
return *s
}
/* ── 5. Normalized projection parity ──────────────────────────────────────── */
// textCoercion is the complete list of fixture cases where the JavaScript
// record holds a value that is NOT a string in a field migration 000005
// projects into a `text` or `text[]` column, named field by field.
//
// The frontend can afford this and the backend cannot. `name: [a, b]` leaves a
// JavaScript ARRAY on skill.name, and every consumer stringifies it at the
// point of use — the trigger list on that very record reads "a,b", which is
// String(["a","b"]). A text column has no such option: something must be
// written, once, at the boundary. This package writes String(x), which is the
// string the frontend's own consumers produce.
//
// Listing them rather than coercing everywhere is the point. Coercion is
// applied ONLY to the fields named here, so a genuine difference between two
// strings still fails; each entry is checked to be still necessary, so the list
// cannot go stale; and an unlisted case that needs coercion fails outright, so
// the list cannot quietly grow. It is the same closed-list discipline
// knownDivergence has, for the same reason.
var textCoercion = map[string][]string{
"invalid-field-type-name-list": {"name"},
"name-list-agent": {"name"},
"name-numeric": {"name"},
"name-boolean": {"name"},
"description-list": {"description"},
"description-numeric": {"description"},
"pages-numeric-entry": {"pages"},
"pages-mapping-entry": {"pages"},
}
// The two shapes a text projection can have, named rather than inferred.
//
// Which one applies is a property of the COLUMN, not of what the author
// happened to write. `name` is `text`, so a sequence written there becomes one
// string — String(["a","b"]) is "a,b". `pages` is `text[]`, so a sequence stays
// a sequence and each entry becomes a string of its own. Inferring the shape
// from whatever Go produced would make the test agree with the parser by
// construction, which is the one thing it must not do.
var textScalarFields = map[string]bool{
"name": true, "description": true, "category": true, "trigger": true, "prompt": true,
}
var textListFields = map[string]bool{
"pages": true, "actions": true, "triggers": true, "skills": true, "subagents": true,
}
// jsText is String(x) for a value decoded from the fixture's JSON — the same
// conversion jsvalue.go performs inside the parser, restated here so the test
// does not have to reach into the package it is testing to check it.
func jsText(t *testing.T, field string, v any) any {
t.Helper()
switch {
case textScalarFields[field]:
return jsScalarText(v)
case textListFields[field]:
list, ok := v.([]any)
if !ok {
return jsScalarText(v)
}
out := make([]any, len(list))
for i, item := range list {
out[i] = jsScalarText(item)
}
return out
}
t.Fatalf("textCoercion names %q, which is not a text-projected field", field)
return nil
}
func jsScalarText(v any) string {
switch x := v.(type) {
case nil:
return "null"
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'g', -1, 64)
case string:
return x
case []any:
// Array.prototype.toString: nil renders as the empty string, not
// "null", which is the one place the two differ.
parts := make([]string, len(x))
for i, item := range x {
if item == nil {
continue
}
parts[i] = jsScalarText(item)
}
return strings.Join(parts, ",")
case map[string]any:
return "[object Object]"
}
return ""
}
// The fields migration 000005 projects into columns, compared for every
// definition both parsers accept. These are the values that reach the
// database, so a difference here is a row the frontend would render wrongly.
func TestProjectionParity(t *testing.T) {
fixture := map[string]bool{}
for _, c := range all(load(t)) {
fixture[c.id()] = true
if c.Normalized == nil {
continue // JS could not parse it; covered by the tree test
}
coerce := map[string]bool{}
for _, field := range textCoercion[c.id()] {
coerce[field] = true
}
t.Run(c.id(), func(t *testing.T) {
raw := c.raw(t)
if c.Kind == "agent" {
agent, err := definition.ParseAgent(raw, definition.Options{})
if err != nil {
t.Fatalf("JS parsed this; Go refused it: %v", err)
}
compare(t, map[string]any{
"id": agent.ID,
"name": agent.Name,
"description": agent.Description,
"status": agent.Status,
"version": agent.Version,
"pages": agent.Pages,
"icon": agent.Icon,
"reasoning": agent.Reasoning,
"trigger": agent.Trigger,
"webSearch": agent.WebSearch,
"skills": agent.Skills,
"subagents": agent.Subagents,
"starters": agent.Starters,
"permissions": agent.Permissions,
"errors": agent.Errors,
}, c.Normalized, coerce)
return
}
skill, err := definition.ParseSkill(raw, definition.Options{})
if err != nil {
t.Fatalf("JS parsed this; Go refused it: %v", err)
}
compare(t, map[string]any{
"id": skill.ID,
"name": skill.Name,
"description": skill.Description,
"status": skill.Status,
"pages": skill.Pages,
"kind": skill.Kind,
"category": skill.Category,
"actions": skill.Actions,
"triggers": skill.Triggers,
"declaredTriggers": skill.DeclaredTriggers,
"prompt": skill.Prompt,
"skillId": skill.SkillID,
}, c.Normalized, coerce)
})
}
for id := range textCoercion {
if !fixture[id] {
t.Errorf("textCoercion names %q, which is not in the fixture", id)
}
}
}
// compare checks every field Go produced against the JS record, field by field
// so a failure names the field rather than dumping two objects.
func compare(t *testing.T, got map[string]any, want map[string]any, coerce map[string]bool) {
t.Helper()
keys := make([]string, 0, len(got))
for k := range got {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
wantValue, present := want[k]
if !present {
t.Errorf("%s: absent from the JS record", k)
continue
}
if coerce[k] {
// Listed in textCoercion. Check the entry is still earning its
// place before honouring it: if the JS value is already the string
// Go produced, the coercion is doing nothing and the list has gone
// stale.
coerced := jsText(t, k, normalizeTree(wantValue))
if reflect.DeepEqual(normalizeTree(wantValue), normalizeTree(coerced)) {
t.Errorf("%s: listed in textCoercion, but the JS value is already "+
"a string. Remove the entry.", k)
}
wantValue = coerced
}
g, w := normalizeTree(got[k]), normalizeTree(wantValue)
// An empty list and a missing one are the same thing to both parsers.
if isEmptyList(g) && isEmptyList(w) {
continue
}
if !reflect.DeepEqual(g, w) {
t.Errorf("%s differs\n go %s\n js %s", k, show(g), show(w))
}
}
}
func isEmptyList(v any) bool {
if v == nil {
return true
}
l, ok := v.([]any)
return ok && len(l) == 0
}
/* ── 6. The parser never rewrites what is stored ──────────────────────────── */
// Migration 000005 keeps `markdown` verbatim and derives every other column
// from it. Parsing must therefore be a read: normalization exists to
// INTERPRET a definition, never to rewrite it.
func TestParsingDoesNotMutateSource(t *testing.T) {
for _, c := range all(load(t)) {
raw := c.raw(t)
before := string(append([]byte{}, raw...))
_, _ = definition.ParseSkill(raw, definition.Options{})
_, _ = definition.ParseAgent(raw, definition.Options{})
_ = definition.ValidateSkill(raw)
_ = definition.ValidateAgent(raw)
if raw != before {
t.Fatalf("%s: the source changed under the parser", c.id())
}
}
}
// Normalize is what the parser reads THROUGH; what it returns must never be
// what gets stored. Asserted directly, because the whole separation rests on
// it: the corpus contains definitions whose normalized form differs from their
// stored form, and storing the normalized one would silently rewrite an
// author's file.
func TestNormalizationIsNotStorage(t *testing.T) {
rewritten := 0
for _, c := range all(load(t)) {
raw := c.raw(t)
if definition.Normalize(raw) != raw {
rewritten++
}
}
if rewritten == 0 {
t.Fatal("no case in the corpus is changed by Normalize; " +
"this test can no longer tell storage and interpretation apart")
}
t.Logf("%d of %d definitions normalize to something other than their stored bytes", rewritten, len(all(load(t))))
}
/* ── 7. Adversarial coverage is real ──────────────────────────────────────── */
// The adversarial cases Phase 4D requires, each mapped to the fixture rows that
// exercise it. A case list that drifts away from the requirement is a suite
// that looks thorough and tests something else.
func TestAdversarialCoverage(t *testing.T) {
required := map[string][]string{
"UTF-8 BOM": {"utf8-bom", "utf8-bom-agent", "bom-crlf-blankline"},
"CRLF": {"crlf", "crlf-agent", "crlf-inside-frontmatter-only"},
"CR": {"cr-only"},
"leading blank line": {"leading-blank-line"},
"multiple leading blank lines": {"multiple-leading-blank-lines", "leading-spaces-then-blank-lines"},
"trailing spaces": {"trailing-spaces-on-values"},
"trailing newline": {"many-trailing-newlines", "no-trailing-newline"},
"trailing ws after fence": {"trailing-ws-after-open-fence", "trailing-tab-after-close-fence"},
"quoted scalar": {"double-quoted-scalar", "doubled-quote-escape"},
"single-quoted scalar": {"single-quoted-scalar"},
"colon inside quoted string": {"colon-in-quoted-string", "colon-in-unquoted-string"},
"hash inside quoted string": {"hash-in-quoted-string", "hash-unquoted-trailing-comment", "hash-unquoted-midword"},
"empty scalar": {"empty-scalar", "tilde-scalar", "null-scalar"},
"empty array": {"empty-array"},
"inline array": {"inline-flow-array", "inline-flow-map"},
"multiline scalar": {"block-scalar-literal", "block-scalar-folded"},
"duplicate key": {"duplicate-key", "duplicate-key-array"},
"malformed YAML": {"malformed-yaml-bare-line", "key-with-space", "ragged-indent"},
"malformed opening fence": {"malformed-open-fence-two-dashes", "malformed-open-fence-four-dashes",
"malformed-open-fence-indented", "malformed-open-fence-text-after"},
"malformed closing fence": {"malformed-close-fence-two-dashes", "malformed-close-fence-missing",
"malformed-close-fence-four-dashes"},
"missing frontmatter": {"missing-frontmatter", "empty-fence-pair", "frontmatter-is-a-sequence"},
"unsupported frontmatter field": {"unsupported-frontmatter-field", "unsupported-field-agent", "uppercase-key"},
"invalid field type": {"invalid-field-type-pages-scalar", "invalid-field-type-pages-scalar-agent",
"invalid-field-type-name-list"},
"invalid definition id": {"invalid-definition-id-uppercase", "invalid-definition-id-leading-dash",
"invalid-definition-id-underscore"},
"invalid status": {"invalid-status-skill", "invalid-status-agent", "inactive-status-skill"},
"invalid visibility": {"visibility-field-personal", "visibility-field-invalid"},
"oversized markdown": {"oversized-markdown", "oversized-agent", "at-size-bound"},
"empty markdown": {"empty-markdown", "whitespace-only-markdown"},
}
present := map[string]bool{}
for _, c := range load(t).Cases {
present[c.Name] = true
}
for requirement, names := range required {
for _, n := range names {
if !present[n] {
t.Errorf("%q: the fixture has no case named %q", requirement, n)
}
}
}
}
/* ── 8. Deferred blocks are reported, not assumed ─────────────────────────── */
// Every skill carrying a `ui:` or `owliver:` block must say so, because that is
// the one part of validation this package does not do. A block that stopped
// being reported would be a gap nobody could see.
func TestDeferredBlocksAreReported(t *testing.T) {
o := load(t)
found := 0
for _, c := range append(append([]observation{}, o.Corpus...), o.Cases...) {
if c.Kind != "skill" || !c.Frontmatter.OK {
continue
}
want := []string{}
for _, key := range []string{"ui", "owliver"} {
if _, present := c.Frontmatter.Data[key]; present {
want = append(want, key)
}
}
skill, err := definition.ParseSkill(c.raw(t), definition.Options{})
if err != nil {
continue
}
if len(want) == 0 {
if len(skill.Deferred) != 0 {
t.Errorf("%s: reported Deferred %v with no such block", c.id(), skill.Deferred)
}
continue
}
found++
if !reflect.DeepEqual(skill.Deferred, want) {
t.Errorf("%s: Deferred = %v, want %v", c.id(), skill.Deferred, want)
}
}
if found != 19 {
t.Errorf("definitions carrying a deferred block: got %d, want 19", found)
}
}
/* ── 9. Mutation checks ───────────────────────────────────────────────────── */
// Tests that pass against a broken parser are not tests. Each mutation below
// is a plausible mistake in this package; every one must be caught by a real
// definition changing its meaning, not by an assertion written to notice it.
func TestMutationsWouldBeCaught(t *testing.T) {
base := strings.Join([]string{
"---",
"id: sample-skill",
"name: Sample Skill",
"description: A sample.",
"pages:",
" - candidates",
"---",
"",
"# Sample Skill",
}, "\n")
mutations := []struct {
name string
raw string
check func(t *testing.T, s *definition.Skill, err error)
}{
{
// Dropping the BOM strip: the fence stops matching and every field
// empties out.
name: "BOM before the fence still fences",
raw: "\uFEFF" + base,
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.ID != "sample-skill" || len(s.Pages) != 1 {
t.Errorf("got id=%q pages=%v err=%v", s.ID, s.Pages, err)
}
},
},
{
// Dropping CR normalization: `candidates\r` is not a page.
name: "CRLF endings do not leak into values",
raw: strings.ReplaceAll(base, "\n", "\r\n"),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || len(s.Pages) != 1 || s.Pages[0] != "candidates" {
t.Errorf("got pages=%v err=%v", s.Pages, err)
}
},
},
{
// Trimming the closing fence too eagerly, or not at all.
name: "trailing tab after the closing fence still closes it",
raw: strings.Replace(base, "\n---\n", "\n---\t\n", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.Name != "Sample Skill" {
t.Errorf("got name=%q err=%v", s.Name, err)
}
},
},
{
// A greedy fence would swallow the second document and lose the id.
name: "a second --- document is body, not frontmatter",
raw: base + "\n\n---\nid: second\n---\n",
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.ID != "sample-skill" {
t.Errorf("got id=%q err=%v", s.ID, err)
}
},
},
{
// Treating `#` as always starting a comment.
name: "a hash inside a word is part of the word",
raw: strings.Replace(base, "description: A sample.", "category: ops#1", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.Category != "ops#1" {
t.Errorf("got category=%q err=%v", s.Category, err)
}
},
},
{
// Treating a spaced `#` as part of the value.
name: "a spaced hash starts a comment",
raw: strings.Replace(base, "description: A sample.", "category: ops # note", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.Category != "ops" {
t.Errorf("got category=%q err=%v", s.Category, err)
}
},
},
{
// Splitting a quoted value on its colon.
name: "a colon inside quotes stays in the value",
raw: strings.Replace(base, "name: Sample Skill", `name: "Sample: Skill"`, 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.Name != "Sample: Skill" {
t.Errorf("got name=%q err=%v", s.Name, err)
}
},
},
{
// Keeping the first duplicate rather than the last.
name: "a duplicate key takes the last value",
raw: strings.Replace(base, "name: Sample Skill", "name: First\nname: Second", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || s.Name != "Second" {
t.Errorf("got name=%q err=%v", s.Name, err)
}
},
},
{
// Accepting ragged indentation instead of refusing it.
name: "ragged indentation is refused with its line",
raw: strings.Replace(base, " - candidates", " - candidates\n - positions", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
var pe *definition.Error
if err == nil {
t.Fatalf("accepted ragged indentation: %+v", s)
}
if !asError(err, &pe) || pe.Line != 6 {
t.Errorf("got %v, want an *Error on line 6", err)
}
},
},
{
// Canonicalising a skill's pages, which the frontend does not do.
name: "a skill keeps the page name as written",
raw: strings.Replace(base, " - candidates", " - Talent Pool", 1),
check: func(t *testing.T, s *definition.Skill, err error) {
if err != nil || len(s.Pages) != 1 || s.Pages[0] != "Talent Pool" {
t.Errorf("got pages=%v err=%v", s.Pages, err)
}
if err := definition.ValidateSkill(strings.Replace(base, " - candidates", " - Talent Pool", 1)); err != nil {
t.Errorf("an aliased page should still validate: %v", err)
}
},
},
}
for _, m := range mutations {
t.Run(m.name, func(t *testing.T) {
skill, err := definition.ParseSkill(m.raw, definition.Options{})
if skill == nil {
skill = &definition.Skill{}
}
m.check(t, skill, err)
})
}
}
// asError is errors.As, spelled out for the one concrete type this package
// returns.
func asError(err error, target **definition.Error) bool {
e, ok := err.(*definition.Error)
if ok {
*target = e
}
return ok
}
/* ── 10. Bounds ───────────────────────────────────────────────────────────── */
// The size bound is the backend's, and both directions of it matter: a
// definition at the limit must be storable and one character more must not.
// The companion to TestSizeBound, for the other backend-only bound. The
// fixture pins a version well above the bound and one exactly at it, which
// leaves the step between them untested — an off-by-one there would refuse a
// version PostgreSQL can store, or accept one it cannot. Both sides of the
// step are named here so that cannot happen.
func TestVersionBound(t *testing.T) {
agent := func(version string) string {
return "---\nid: sample-agent\nname: Sample Agent\npages:\n - candidates\n" +
"version: " + version + "\n---\n\n# Sample Agent\n"
}
at := strconv.Itoa(definition.MaxVersion)
if err := definition.ValidateAgent(agent(at)); err != nil {
t.Errorf("version %s, exactly at the bound, was refused: %v", at, err)
}
over := strconv.FormatInt(int64(definition.MaxVersion)+1, 10)
err := definition.ValidateAgent(agent(over))
if err == nil {
t.Fatalf("version %s, one past the bound, was accepted", over)
}
r, ok := err.(*definition.Rejection)
if !ok || !r.BackendOnly {
t.Errorf("the version bound should be reported as a backend-only rule, got %v", err)
}
// The bound belongs to validation, not to parsing: a version the database
// cannot store must still normalize to the number the author wrote, or the
// record the editor shows and the record Go builds would disagree.
parsed, err := definition.ParseAgent(agent(over), definition.Options{})
if err != nil {
t.Fatalf("parsing a too-large version failed: %v", err)
}
if got := strconv.Itoa(parsed.Version); got != over {
t.Errorf("parse clamped the version to %s; it should carry %s", got, over)
}
if len(parsed.Errors) != 0 {
t.Errorf("parse reported a backend-only bound as an authoring error: %v", parsed.Errors)
}
}
func TestSizeBound(t *testing.T) {
head := "---\nid: sample-skill\nname: Sample Skill\npages:\n - candidates\n---\n\n"
at := head + strings.Repeat("y", definition.MaxMarkdownLength-len(head))
if n := len([]rune(at)); n != definition.MaxMarkdownLength {
t.Fatalf("fixture is %d characters, wanted exactly %d", n, definition.MaxMarkdownLength)
}
if err := definition.ValidateSkill(at); err != nil {
t.Errorf("a definition exactly at the bound was refused: %v", err)
}
over := at + "y"
err := definition.ValidateSkill(over)
if err == nil {
t.Fatal("a definition one character over the bound was accepted")
}
r, ok := err.(*definition.Rejection)
if !ok || !r.BackendOnly {
t.Errorf("the size bound should be reported as a backend-only rule, got %v", err)
}
}