Files
krow_backend/go-api/internal/definition/conformance_test.go
Suriyakumarvijayanayagam dc785b917c
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 8s
Separate what a worker does from what a company needs filled
Owliver could offer neither create. The Create Position flow worked and no chip
anywhere suggested it, because the chip row is entirely the backend's static
catalogue and no intent in it wrote anything. The gap was never in the
frontend's trigger matching — every phrasing already routed.

`employee_roles` is the supply side of `job_postings`. A posting is what the
ORGANIZATION needs filled; this is what a WORKER says they do. They share a
vocabulary and almost nothing else: "3 years" on a posting is a minimum an
applicant must clear, and the same words here are what the person has. There is
deliberately no foreign key between them — supply and demand already meet
through `job_applications`, which carries the funnel, the interview and the
outcome, and a second weaker link would disagree with it the first time
somebody withdrew.

NO NEW COMPANY ENTITY, AND THAT IS THE LOAD-BEARING DECISION. "Create a company
position" reads like it needs a client record. `organizations` is the TENANT —
absent from the resource table, absent from the policy map, written only by the
seeder — so creating a row there from a chat flow would provision a new tenant,
and the position would carry an org_id the operator's session cannot see. The
operator could never view the record they just created. That breaks I5 and I1
to add a feature nobody asked for. The client stays free text on the posting,
per blueprint decision D2, and the flow simply offers the clients this
organization already staffs for as chips. No schema change, no endpoint change.

Create is operators-only, and that is an I1 decision rather than a deferral.
The worker is named explicitly on the row and is deliberately NOT derived from
the session, because an operator recording a role on somebody's behalf is the
whole point of the flow. Granting talent the same Create would let a talent
caller write a role under any worker_email in the tenant — the attribution hole
Phase 3D closed elsewhere. Talent reads its own via a ScopeEmail predicate,
which is in place now so the grant is one line when a talent console exists.

`created_by` is in gen_resources.py's SERVER_OWNED as well as the policy's
Derived list. Both are required and the pairing is easy to miss: Derived fills
the column from the session, SERVER_OWNED is what makes the descriptor ReadOnly
so a request body cannot set it in the first place. Without it,
TestDerivedColumnsAreReadOnlyOrTalentScoped fails — verified by mutation, not
by reading.

The two catalogue intents carry PHRASE terms only. A bare "position" or "role"
term scores 10, the same as every reading on that page, and wins the tie on
declaration order — so a create chip would have arrived by evicting
`positions-attention` from the exact ordered result TestPositionsSuggestions
asserts. An offer to create something must not displace the reading a person
actually asked for. Neither declares a Subject, on the precedent of
`position-spec-steps`: a Subject would let the bare query "summarize" match
through matchShape and survive filterOnTopic. Neither declares a Signal, so an
empty composer still reports what the organization needs rather than proposing
paperwork.

Chip text is the coupling with nothing else holding it together: no page
context declares `capabilities`, so every server suggestion dispatches as its
own TEXT and is answered by whichever skill's trigger that text matches. A
renamed chip would open nothing, silently. Asserted on the frontend side.

The down migration drops `employee_role_status` and keeps `english_level`,
which is shared with job_postings.english_required and
job_applications.english_level. Rolled back and re-applied against the
database to prove it, not asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:25 +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", 24}, {"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) != 38 {
t.Errorf("shipped definitions: got %d, want 38", 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)
}
}