diff --git a/cmd/tools/gen-error-codes/main.go b/cmd/tools/gen-error-codes/main.go index 4792ae4..4c8a601 100644 --- a/cmd/tools/gen-error-codes/main.go +++ b/cmd/tools/gen-error-codes/main.go @@ -26,6 +26,7 @@ import ( // Side-effect imports: load every package that calls errcode.Register // so the registry is populated before we read it. + _ "github.com/sqlrush/opendbx/internal/app/skills" // spec-2.1 D-6: SKILL.* codes _ "github.com/sqlrush/opendbx/internal/entrypoints" "github.com/sqlrush/opendbx/internal/platform/errcode" _ "github.com/sqlrush/opendbx/internal/platform/logger" diff --git a/git-hooks/spec-registry.txt b/git-hooks/spec-registry.txt index 5577eac..542ae88 100644 --- a/git-hooks/spec-registry.txt +++ b/git-hooks/spec-registry.txt @@ -55,3 +55,4 @@ 1.4a 1 48 sub skip 1.25 1 49 main tag 1.21.1 1 50 sub tag +2.1 2 51 main tag diff --git a/internal/app/skills/doc.go b/internal/app/skills/doc.go index 0f8375f..e956faa 100644 --- a/internal/app/skills/doc.go +++ b/internal/app/skills/doc.go @@ -1,7 +1,26 @@ // Copyright 2026 opendbx contributors. See LICENSE. // -// Package skills SKILL.md loader and Skill interface. -// -// Design: spec-2.1 ~ spec-2.4 // Author: sqlrush + +// Package skills defines the SKILL.md file format: frontmatter schema, +// parsing, validation, and namespace resolution. spec-2.1-skill-md-format. +// +// Scope (spec-2.1): PURE format + validation, NO filesystem I/O. +// - schema.go — Schema (CC frontmatter mirror) + AllowedToolsList +// - skill.go — Skill value + SkillSource provenance + Key identity +// - parse.go — Parse: frontmatter split (yaml.Node) + Extra + caps +// - validate.go — Validate → ValidationReport (warnings as data) + errors +// - namespace.go — Resolve → Resolution (precedence + conflict kinds) +// - errors.go — SKILL.* errcode sentinels +// +// Out of scope (follow-on specs): discovery / hot-reload (spec-2.2), +// invocation / SkillTool / allowed-tools enforcement (spec-2.3), the +// improvement loop (spec-2.4). This package is a leaf: it imports only +// errcode + go.yaml.in/yaml/v3 + stdlib. +// +// Design intent: one SKILL.md loads byte-faithfully in BOTH Claude Code and +// opendbx. The frontmatter schema mirrors CC (survey +// docs/surveys/cc-ecosystem/skills.md, CC SHA 3da94d5); opendbx extension +// fields (opendbx_*) are preserved forward-compatibly in Schema.Extra rather +// than frozen as typed fields (Q6=B; §3.7 append-only). package skills diff --git a/internal/app/skills/errors.go b/internal/app/skills/errors.go new file mode 100644 index 0000000..b50f97d --- /dev/null +++ b/internal/app/skills/errors.go @@ -0,0 +1,88 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File errors.go — SKILL.* errcode sentinels (spec-2.1 D-6; 规则 7 三件套). +// +// Two classes: +// - Parse-time (malformed file): NO_FRONTMATTER / UNTERMINATED_FRONTMATTER +// / PARSE_ERROR / TOO_LARGE / TOO_DEEP +// - Validate-time (well-formed YAML, bad content): MISSING_NAME / +// INVALID_NAME / MISSING_DESCRIPTION / VALIDATION_FAILED (aggregate) +// - Namespace-time: NAMESPACE_CONFLICT (same name + same precedence) +// +// Messages/hints are English per 规则 13. Registered at file scope so the +// static manifest scan (cmd/opendbx/errcode_manifest_test.go) and the +// gen-error-codes blank-import both see them. + +package skills + +import "github.com/sqlrush/opendbx/internal/platform/errcode" + +//nolint:gochecknoglobals // spec-0.6 contract: errcode sentinels are package-level. +var ( + // ErrNoFrontmatter — file does not begin with a `---` frontmatter fence. + ErrNoFrontmatter = errcode.Register( + "SKILL.NO_FRONTMATTER", + "SKILL.md does not start with a --- frontmatter fence", + "begin the file with a YAML frontmatter block delimited by --- lines", + ) + // ErrUnterminatedFrontmatter — opening `---` has no closing `---` fence. + ErrUnterminatedFrontmatter = errcode.Register( + "SKILL.UNTERMINATED_FRONTMATTER", + "SKILL.md frontmatter is missing its closing --- fence", + "add a --- line (at column 0) to close the frontmatter before the body", + ) + // ErrParseError — frontmatter is not valid YAML. + ErrParseError = errcode.Register( + "SKILL.PARSE_ERROR", + "SKILL.md frontmatter is not valid YAML", + "fix the YAML syntax in the frontmatter block", + ) + // ErrTooLarge — file exceeds the 1 MiB safety cap (opendbx divergence, + // not a CC limit). + ErrTooLarge = errcode.Register( + "SKILL.TOO_LARGE", + "SKILL.md exceeds the 1 MiB size limit", + "split or trim the skill; the 1 MiB cap is an opendbx safety bound", + ) + // ErrTooDeep — frontmatter YAML nesting depth ≥ 32 (anti-bomb). + ErrTooDeep = errcode.Register( + "SKILL.TOO_DEEP", + "SKILL.md frontmatter YAML nesting is too deep (>= 32)", + "flatten the frontmatter; deep nesting is rejected as an anti-bomb guard", + ) + // ErrMissingName — frontmatter has no (non-blank) name. + ErrMissingName = errcode.Register( + "SKILL.MISSING_NAME", + "SKILL.md frontmatter is missing the required name field", + "add a non-empty name: field; the name is the global skill identifier", + ) + // ErrInvalidName — name contains path/control characters (loose rule; Q5). + ErrInvalidName = errcode.Register( + "SKILL.INVALID_NAME", + "SKILL.md name contains illegal characters or is too long", + "use a name without path separators, whitespace, or control chars (<= 128 chars)", + ) + // ErrMissingDescription — frontmatter has no (non-blank) description. + ErrMissingDescription = errcode.Register( + "SKILL.MISSING_DESCRIPTION", + "SKILL.md frontmatter is missing the required description field", + "add a description: field; it drives when the model selects this skill", + ) + // ErrNamespaceConflict — two skills share a name at the same precedence + // (not resolvable by source ranking). + ErrNamespaceConflict = errcode.Register( + "SKILL.NAMESPACE_CONFLICT", + "two skills share the same name at the same precedence", + "rename one skill or move it to a different source so precedence can resolve it", + ) + // ErrValidationFailed — aggregate wrapper over one or more validation + // failures (Q7 multi-error). Wraps errors.Join(detail...) so the sidecar + // sees this primary code while details survive under Unwrap. + ErrValidationFailed = errcode.Register( + "SKILL.VALIDATION_FAILED", + "SKILL.md failed one or more validation rules", + "fix the reported field violations; see the wrapped details for each", + ) +) diff --git a/internal/app/skills/errors_test.go b/internal/app/skills/errors_test.go new file mode 100644 index 0000000..5953192 --- /dev/null +++ b/internal/app/skills/errors_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import ( + "strings" + "testing" + + "github.com/sqlrush/opendbx/internal/platform/errcode" +) + +// allSkillSentinels lists every SKILL.* sentinel for table-driven assertions. +func allSkillSentinels() []errcode.Sentinel { + return []errcode.Sentinel{ + ErrNoFrontmatter, ErrUnterminatedFrontmatter, ErrParseError, + ErrTooLarge, ErrTooDeep, ErrMissingName, ErrInvalidName, + ErrMissingDescription, ErrNamespaceConflict, ErrValidationFailed, + } +} + +// TestErrorsRegistered — every SKILL.* sentinel is registered with the +// three-piece contract (Code/Message/Hint all non-empty), code prefix SKILL. +func TestErrorsRegistered(t *testing.T) { + t.Parallel() + seen := map[string]bool{} + for _, s := range allSkillSentinels() { + if !strings.HasPrefix(s.Code(), "SKILL.") { + t.Errorf("code %q lacks SKILL. prefix", s.Code()) + } + if s.Message() == "" || s.Hint() == "" { + t.Errorf("%s missing message/hint (规则 7 三件套)", s.Code()) + } + if seen[s.Code()] { + t.Errorf("duplicate code %s", s.Code()) + } + seen[s.Code()] = true + } + if len(seen) != 10 { + t.Errorf("expected 10 SKILL.* codes, got %d", len(seen)) + } +} diff --git a/internal/app/skills/namespace.go b/internal/app/skills/namespace.go new file mode 100644 index 0000000..850eb88 --- /dev/null +++ b/internal/app/skills/namespace.go @@ -0,0 +1,191 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File namespace.go — namespace resolution (spec-2.1 D-5). Pure, no I/O. +// +// Precondition: inputs have passed Validate (non-empty, well-formed Name). +// Skills with an empty Name are segregated — they never group or reach Active +// (review HIGH-3: prevents empty names from collapsing into a false conflict). +// +// Resolution model: +// - Same Name across DIFFERENT precedence → ConflictShadowed: the highest +// precedence wins (mirrors CC "project overrides global"); losers are +// reported, not silently dropped. +// - Same Name AND SAME precedence → ConflictUnresolvable: no winner is +// chosen (规则 7 / Q8 — don't silently pick); 2.2/UI decides. +// +// Resolve never mutates the input slice or the Skills' Extra maps (规则 12). + +package skills + +import ( + "sort" + + "github.com/sqlrush/opendbx/internal/platform/errcode" +) + +// ConflictKind distinguishes a legal shadow from an unresolvable duplicate. +type ConflictKind int + +const ( + // ConflictShadowed — a higher-precedence skill won; lower ones shadowed. + ConflictShadowed ConflictKind = iota + // ConflictUnresolvable — two+ skills share a name at the same precedence. + ConflictUnresolvable +) + +// Conflict records one name collision. +type Conflict struct { + Name string + Kind ConflictKind + Winner *Skill // the active skill for ConflictShadowed; nil if Unresolvable + Shadowed []Skill // the losers (Shadowed) or the full ambiguous set (Unresolvable) +} + +// Err returns a SKILL.NAMESPACE_CONFLICT error for an unresolvable conflict, +// or nil for a legally resolved shadow. spec-2.2 calls this when it chooses to +// fail on ambiguous skills (spec-2.1 only reports them as data). +func (c Conflict) Err() error { + if c.Kind != ConflictUnresolvable { + return nil + } + return errcode.Newf(ErrNamespaceConflict.Code(), + "skill %q is defined %d times at the same precedence", c.Name, len(c.Shadowed)) +} + +// Resolution is the outcome of Resolve. +type Resolution struct { + Active []Skill // one winner per name (sorted by name) + Shadowed []Skill // every skill a winner shadowed + Conflicts []Conflict // every name with >1 skill (Shadowed or Unresolvable) +} + +// Resolve picks one active skill per name by precedence and reports conflicts. +// See the file comment for the resolution model. +func Resolve(skills []Skill) Resolution { + groups, order := groupByName(skills) + var res Resolution + for _, name := range order { + group := groups[name] + if len(group) == 1 { + res.Active = append(res.Active, cloneSkill(group[0])) + continue + } + top, tie := topByPrecedence(group) + if tie { + // Unresolvable: do not pick a winner. + res.Conflicts = append(res.Conflicts, Conflict{ + Name: name, + Kind: ConflictUnresolvable, + Winner: nil, + Shadowed: cloneSkillSlice(group), + }) + continue + } + winner := cloneSkill(group[top]) + losers := make([]Skill, 0, len(group)-1) + for i, s := range group { + if i != top { + losers = append(losers, cloneSkill(s)) + } + } + res.Active = append(res.Active, winner) + res.Shadowed = append(res.Shadowed, losers...) + w := winner + res.Conflicts = append(res.Conflicts, Conflict{ + Name: name, + Kind: ConflictShadowed, + Winner: &w, + Shadowed: losers, + }) + } + return res +} + +// DetectConflicts returns the conflicts Resolve would report, without building +// the Active/Shadowed sets. Useful for 2.2 pre-flight reporting. +func DetectConflicts(skills []Skill) []Conflict { + return Resolve(skills).Conflicts +} + +// groupByName buckets skills by Name (skipping empty names) and returns the +// buckets plus a name list sorted for deterministic output. Input order within +// a bucket is preserved. +func groupByName(skills []Skill) (map[string][]Skill, []string) { + groups := make(map[string][]Skill) + for _, s := range skills { + if s.Schema.Name == "" { + continue // segregated: invalid input, never grouped + } + groups[s.Schema.Name] = append(groups[s.Schema.Name], s) + } + order := make([]string, 0, len(groups)) + for name := range groups { + order = append(order, name) + } + sort.Strings(order) + return groups, order +} + +// topByPrecedence returns the index of the single highest-precedence skill in +// group and whether the top is tied (two+ at the max precedence). +func topByPrecedence(group []Skill) (idx int, tie bool) { + best := 0 + count := 1 + for i := 1; i < len(group); i++ { + switch { + case group[i].Source.Precedence > group[best].Source.Precedence: + best = i + count = 1 + case group[i].Source.Precedence == group[best].Source.Precedence: + count++ + } + } + return best, count > 1 +} + +// cloneSkillSlice deep-copies every Skill so the result shares no Extra map +// with the input (review HIGH: Resolve output must not alias caller input). +func cloneSkillSlice(in []Skill) []Skill { + out := make([]Skill, len(in)) + for i, s := range in { + out[i] = cloneSkill(s) + } + return out +} + +// cloneSkill returns a copy of s with a deep-copied Schema.Extra so a caller +// mutating the returned skill cannot reach back into the input (规则 12). +func cloneSkill(s Skill) Skill { + s.Schema.Extra = deepCopyExtra(s.Schema.Extra) + return s +} + +// deepCopyExtra recursively copies a frontmatter Extra map (values are the +// map[string]any / []any / scalar shapes yaml.Node.Decode produces). +func deepCopyExtra(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = deepCopyValue(v) + } + return out +} + +func deepCopyValue(v any) any { + switch t := v.(type) { + case map[string]any: + return deepCopyExtra(t) + case []any: + s := make([]any, len(t)) + for i, e := range t { + s[i] = deepCopyValue(e) + } + return s + default: + return v // scalars are immutable + } +} diff --git a/internal/app/skills/namespace_test.go b/internal/app/skills/namespace_test.go new file mode 100644 index 0000000..1b4639b --- /dev/null +++ b/internal/app/skills/namespace_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import ( + "reflect" + "testing" +) + +func sk(name string, prec int) Skill { + return Skill{ + Schema: Schema{Name: name, Description: "d"}, + Source: SkillSource{Precedence: prec, Path: name}, + } +} + +func TestResolve_NoConflict(t *testing.T) { + t.Parallel() + res := Resolve([]Skill{sk("a", 1), sk("b", 1)}) + if len(res.Active) != 2 || len(res.Conflicts) != 0 || len(res.Shadowed) != 0 { + t.Fatalf("unexpected resolution: %+v", res) + } + // Sorted by name. + if res.Active[0].Schema.Name != "a" || res.Active[1].Schema.Name != "b" { + t.Errorf("Active not sorted by name: %+v", res.Active) + } +} + +func TestResolve_ShadowByPrecedence(t *testing.T) { + t.Parallel() + low := sk("top-sql", 1) + high := sk("top-sql", 10) + res := Resolve([]Skill{low, high}) + if len(res.Active) != 1 || res.Active[0].Source.Precedence != 10 { + t.Fatalf("higher precedence should win: %+v", res.Active) + } + if len(res.Shadowed) != 1 || res.Shadowed[0].Source.Precedence != 1 { + t.Fatalf("lower precedence should be shadowed: %+v", res.Shadowed) + } + if len(res.Conflicts) != 1 || res.Conflicts[0].Kind != ConflictShadowed { + t.Fatalf("expected one ConflictShadowed: %+v", res.Conflicts) + } + c := res.Conflicts[0] + if c.Winner == nil || c.Winner.Source.Precedence != 10 { + t.Errorf("conflict winner wrong: %+v", c) + } +} + +func TestResolve_Unresolvable(t *testing.T) { + t.Parallel() + res := Resolve([]Skill{sk("dup", 5), sk("dup", 5)}) + if len(res.Active) != 0 { + t.Fatalf("unresolvable must not pick a winner: %+v", res.Active) + } + if len(res.Conflicts) != 1 || res.Conflicts[0].Kind != ConflictUnresolvable { + t.Fatalf("expected ConflictUnresolvable: %+v", res.Conflicts) + } + if res.Conflicts[0].Winner != nil { + t.Errorf("unresolvable winner must be nil: %+v", res.Conflicts[0]) + } + if len(res.Conflicts[0].Shadowed) != 2 { + t.Errorf("unresolvable should list all duplicates: %+v", res.Conflicts[0]) + } +} + +func TestResolve_EmptyNameSegregated(t *testing.T) { + t.Parallel() + // Two empty-name skills must NOT collapse into one false conflict. + res := Resolve([]Skill{{Schema: Schema{Name: ""}}, {Schema: Schema{Name: ""}}, sk("real", 1)}) + if len(res.Conflicts) != 0 { + t.Errorf("empty names must not create a conflict: %+v", res.Conflicts) + } + if len(res.Active) != 1 || res.Active[0].Schema.Name != "real" { + t.Errorf("only the named skill should be active: %+v", res.Active) + } +} + +func TestDetectConflicts(t *testing.T) { + t.Parallel() + got := DetectConflicts([]Skill{sk("x", 1), sk("x", 2)}) + if len(got) != 1 || got[0].Kind != ConflictShadowed { + t.Errorf("DetectConflicts = %+v", got) + } +} + +// TestResolve_DoesNotMutateInput — review LOW-2: Resolve must not mutate the +// input slice or the Skills' Extra maps. Deep-compare a snapshot. +func TestResolve_DoesNotMutateInput(t *testing.T) { + t.Parallel() + input := []Skill{ + {Schema: Schema{Name: "a", Description: "d", Extra: map[string]any{"k": 1}}, Source: SkillSource{Precedence: 2}}, + {Schema: Schema{Name: "a", Description: "d", Extra: map[string]any{"k": 2}}, Source: SkillSource{Precedence: 1}}, + {Schema: Schema{Name: "b", Description: "d"}, Source: SkillSource{Precedence: 3}}, + } + snapshot := deepCopySkills(input) + _ = Resolve(input) + if !reflect.DeepEqual(input, snapshot) { + t.Errorf("Resolve mutated its input:\n got %+v\n want %+v", input, snapshot) + } +} + +// TestResolve_OutputIsolatedFromInput — review HIGH: mutating a returned +// Skill's Extra map must NOT reach back into the input slice. +func TestResolve_OutputIsolatedFromInput(t *testing.T) { + t.Parallel() + input := []Skill{ + {Schema: Schema{Name: "a", Description: "d", Extra: map[string]any{"k": map[string]any{"deep": 1}}}, Source: SkillSource{Precedence: 2}}, + {Schema: Schema{Name: "a", Description: "d", Extra: map[string]any{"k": 9}}, Source: SkillSource{Precedence: 1}}, + } + res := Resolve(input) + // Mutate the winner's Extra (top-level + nested) through the output. + res.Active[0].Schema.Extra["injected"] = 999 + if nested, ok := res.Active[0].Schema.Extra["k"].(map[string]any); ok { + nested["deep"] = 42 + } + // Mutate a shadowed copy too. + if len(res.Shadowed) > 0 { + res.Shadowed[0].Schema.Extra["injected2"] = 7 + } + if _, leaked := input[0].Schema.Extra["injected"]; leaked { + t.Error("output mutation leaked into input[0].Extra (top-level)") + } + if nested, ok := input[0].Schema.Extra["k"].(map[string]any); ok { + if nested["deep"] != 1 { + t.Errorf("output mutation leaked into input nested Extra: %v", nested["deep"]) + } + } + if _, leaked := input[1].Schema.Extra["injected2"]; leaked { + t.Error("shadowed mutation leaked into input[1].Extra") + } +} + +func TestConflict_Err(t *testing.T) { + t.Parallel() + res := Resolve([]Skill{sk("dup", 5), sk("dup", 5)}) + if len(res.Conflicts) != 1 { + t.Fatalf("want 1 conflict, got %d", len(res.Conflicts)) + } + err := res.Conflicts[0].Err() + if err == nil { + t.Fatal("unresolvable conflict must yield an error") + } + var ec interface{ Code() string } + if !asCode(err, &ec) || ec.Code() != ErrNamespaceConflict.Code() { + t.Errorf("Conflict.Err code = %v; want %s", err, ErrNamespaceConflict.Code()) + } + // A resolved shadow yields no error. + shadow := Resolve([]Skill{sk("x", 1), sk("x", 2)}) + if shadow.Conflicts[0].Err() != nil { + t.Errorf("shadowed conflict must not error: %v", shadow.Conflicts[0].Err()) + } +} + +func asCode(err error, target *interface{ Code() string }) bool { + if c, ok := err.(interface{ Code() string }); ok { + *target = c + return true + } + return false +} + +func deepCopySkills(in []Skill) []Skill { + out := make([]Skill, len(in)) + for i, s := range in { + out[i] = s + if s.Schema.Extra != nil { + cp := make(map[string]any, len(s.Schema.Extra)) + for k, v := range s.Schema.Extra { + cp[k] = v + } + out[i].Schema.Extra = cp + } + } + return out +} diff --git a/internal/app/skills/parse.go b/internal/app/skills/parse.go new file mode 100644 index 0000000..1905780 --- /dev/null +++ b/internal/app/skills/parse.go @@ -0,0 +1,188 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File parse.go — frontmatter split + YAML decode + Extra collection +// (spec-2.1 D-3). Pure, no I/O. +// +// Boundary detection (review HIGH-3): the closing fence is the first line +// that is EXACTLY "---" at column 0. That mirrors YAML's own document-end +// rule — a "---" inside a block scalar is necessarily indented, so it can +// never be at column 0 and never false-matches. Frontmatter SEMANTICS +// (decode + Extra + depth) then go through yaml.Node, which handles the +// block-scalar content correctly. + +package skills + +import ( + "bytes" + + "github.com/sqlrush/opendbx/internal/platform/errcode" + yaml "go.yaml.in/yaml/v3" +) + +const ( + // maxSkillSize caps a SKILL.md at 1 MiB. This is an opendbx safety bound + // (not a CC limit) — DoS guard before any parsing happens. + maxSkillSize = 1 << 20 + // maxYAMLDepth rejects frontmatter nesting >= 32 (anti-bomb; mirrors the + // spec-0.4 config value, reimplemented locally so app/skills stays a leaf + // that does not import platform/config). + maxYAMLDepth = 32 +) + +var ( + utf8BOM = []byte{0xEF, 0xBB, 0xBF} + fenceLine = []byte("---") +) + +// Parse splits a SKILL.md into frontmatter + body and decodes the frontmatter. +// It does NOT validate content (call Validate separately) so callers (2.2) +// can parse a batch and report all failures together. Returns a fresh Skill; +// inputs are never mutated. +func Parse(content []byte, src SkillSource) (Skill, error) { + if len(content) > maxSkillSize { + return Skill{}, errcode.Newf(ErrTooLarge.Code(), + "SKILL.md is %d bytes, exceeds the %d byte limit", len(content), maxSkillSize) + } + + fm, body, err := splitFrontmatter(content) + if err != nil { + // errcode-lint:exempt -- spec-2.1 D-3: splitFrontmatter returns SKILL.* errcode sentinels. + return Skill{}, err + } + + var node yaml.Node + if uerr := yaml.Unmarshal(fm, &node); uerr != nil { + return Skill{}, errcode.Wrap(ErrParseError.Code(), uerr, "", "") + } + + // Depth check on the full document node (its DocumentNode branch recurses + // into Content); reject anti-bomb nesting before any further work. + if depth := yamlNodeDepth(&node); depth >= maxYAMLDepth { + return Skill{}, errcode.Newf(ErrTooDeep.Code(), + "frontmatter YAML nesting depth %d >= %d", depth, maxYAMLDepth) + } + + root := &node + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + root = node.Content[0] + } + + var schema Schema + if root.Kind != 0 { // empty frontmatter leaves a zero node; skip decode + if derr := root.Decode(&schema); derr != nil { + return Skill{}, errcode.Wrap(ErrParseError.Code(), derr, "", "") + } + } + extra, eerr := collectExtra(root) + if eerr != nil { + // errcode-lint:exempt -- spec-2.1 R-fix: collectExtra returns SKILL.PARSE_ERROR errcode. + return Skill{}, eerr + } + schema.Extra = extra + + return Skill{Schema: schema, Body: string(body), Source: src}, nil +} + +// splitFrontmatter returns the frontmatter bytes (between the fences) and the +// body bytes (after the closing fence's newline). Strips a leading UTF-8 BOM. +// The body is byte-faithful: the closing fence line's trailing newline is +// consumed, everything after it is the body verbatim. +func splitFrontmatter(content []byte) (fm, body []byte, err error) { + content = bytes.TrimPrefix(content, utf8BOM) + + firstLine, afterFirst := readLine(content, 0) + if !isFence(firstLine) { + return nil, nil, errcode.New(ErrNoFrontmatter.Code(), "", "") + } + + for off := afterFirst; off < len(content); { + line, next := readLine(content, off) + if isFence(line) { + return content[afterFirst:off], content[next:], nil + } + off = next + } + // Reached EOF without a closing fence. The opening "---" with nothing + // after it (or only a partial line) is unterminated. + return nil, nil, errcode.New(ErrUnterminatedFrontmatter.Code(), "", "") +} + +// readLine returns the line starting at off (without its newline) and the +// offset of the next line's start. When the line is the last (no trailing +// newline), next == len(b). +func readLine(b []byte, off int) (line []byte, next int) { + rel := bytes.IndexByte(b[off:], '\n') + if rel < 0 { + return b[off:], len(b) + } + return b[off : off+rel], off + rel + 1 +} + +// isFence reports whether line is the "---" fence at column 0. Trailing +// whitespace and CR are tolerated (review: `--- ` is a valid close), but +// LEADING whitespace is not — that keeps the column-0 guard that prevents a +// `---` inside an (always-indented) block scalar from false-matching. Note: +// opendbx recognises only the literal `---` fence (the SKILL.md frontmatter +// convention), not the YAML `...` document-end marker or `--- # comment`. +func isFence(line []byte) bool { + return bytes.Equal(bytes.TrimRight(line, " \t\r"), fenceLine) +} + +// collectExtra gathers every top-level frontmatter key that does NOT map to an +// explicit Schema field into a map (forward-compat; never dropped — Q6=B / +// 规则 7). A value that cannot decode is a malformed frontmatter and returns +// SKILL.PARSE_ERROR rather than being silently dropped (review HIGH: silent +// data loss). Returns (nil, nil) when there are no extra keys. +func collectExtra(root *yaml.Node) (map[string]any, error) { + var extra map[string]any // nil when there are no extra keys (not nilnil: paired with a typed value) + if root == nil || root.Kind != yaml.MappingNode { + return extra, nil + } + for i := 0; i+1 < len(root.Content); i += 2 { + key := root.Content[i].Value + if knownKeys[key] { + continue + } + var v any + if err := root.Content[i+1].Decode(&v); err != nil { + return nil, errcode.Wrap(ErrParseError.Code(), err, + "frontmatter field "+key+" has an undecodable value", "") + } + if extra == nil { + extra = make(map[string]any) + } + extra[key] = v + } + return extra, nil +} + +// yamlNodeDepth returns the maximum container-nesting depth of the node tree. +// Scalars/aliases contribute 0; each mapping/sequence adds 1. A flat +// frontmatter mapping is depth 1. +func yamlNodeDepth(n *yaml.Node) int { + if n == nil { + return 0 + } + switch n.Kind { + case yaml.DocumentNode: + max := 0 + for _, c := range n.Content { + if d := yamlNodeDepth(c); d > max { + max = d + } + } + return max + case yaml.MappingNode, yaml.SequenceNode: + max := 0 + for _, c := range n.Content { + if d := yamlNodeDepth(c); d > max { + max = d + } + } + return max + 1 + default: + return 0 + } +} diff --git a/internal/app/skills/parse_test.go b/internal/app/skills/parse_test.go new file mode 100644 index 0000000..5d87d0b --- /dev/null +++ b/internal/app/skills/parse_test.go @@ -0,0 +1,264 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/sqlrush/opendbx/internal/platform/errcode" +) + +// TestParse_CCGolden — the CC code-reviewer.md (survey § 3.1) parses +// byte-faithfully: 4 CC fields + block-scalar description trailing newline + +// body boundary right after the closing fence. +func TestParse_CCGolden(t *testing.T) { + t.Parallel() + content, err := os.ReadFile("testdata/code-reviewer.md") + if err != nil { + t.Fatalf("read golden: %v", err) + } + sk, err := Parse(content, SkillSource{Kind: SourceProject, Precedence: 100, Path: "testdata/code-reviewer.md"}) + if err != nil { + t.Fatalf("Parse golden: %v", err) + } + if sk.Schema.Name != "code-reviewer" { + t.Errorf("Name = %q; want code-reviewer", sk.Schema.Name) + } + wantDesc := "Expert code review specialist. Proactively reviews code for quality,\n" + + "security, and maintainability. MUST BE USED for all code changes.\n" + if sk.Schema.Description != wantDesc { + t.Errorf("Description = %q;\nwant %q (block-scalar trailing \\n preserved)", sk.Schema.Description, wantDesc) + } + if sk.Schema.Model != "sonnet" { + t.Errorf("Model = %q; want sonnet", sk.Schema.Model) + } + got := sk.Schema.AllowedToolsList() + want := []string{"Read", "Grep", "Glob", "Bash"} + if len(got) != len(want) { + t.Fatalf("AllowedToolsList = %v; want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("AllowedToolsList[%d] = %q; want %q", i, got[i], want[i]) + } + } + if len(sk.Schema.Extra) != 0 { + t.Errorf("Extra should be empty for pure-CC frontmatter, got %v", sk.Schema.Extra) + } + // Body boundary: the closing fence's newline is consumed; the blank line + // before "# Body" is part of the body (byte-faithful). + if !strings.HasPrefix(sk.Body, "\n# Body — markdown") { + t.Errorf("Body prefix wrong; got %q", firstN(sk.Body, 40)) + } + if !strings.Contains(sk.Body, "Provide actionable suggestions") { + t.Error("Body missing trailing content") + } + if strings.Contains(sk.Body, "name: code-reviewer") { + t.Error("Body leaked frontmatter") + } +} + +func TestParse_NoFrontmatter(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "plain markdown": "# Just a heading\n\nbody", + "not-a-fence": "---foo\nname: x\n---\n", + "empty file": "", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(in), SkillSource{}) + assertCode(t, err, ErrNoFrontmatter.Code()) + }) + } +} + +func TestParse_UnterminatedFrontmatter(t *testing.T) { + t.Parallel() + _, err := Parse([]byte("---\nname: x\ndescription: y\n"), SkillSource{}) + assertCode(t, err, ErrUnterminatedFrontmatter.Code()) +} + +// TestParse_FenceInBlockScalar — review HIGH-3: a "---" line INSIDE a block +// scalar (indented) must NOT be mistaken for the closing fence. +func TestParse_FenceInBlockScalar(t *testing.T) { + t.Parallel() + content := "---\n" + + "name: tricky\n" + + "description: |\n" + + " See the section below\n" + + " ---\n" + // indented: part of the block scalar, NOT a fence + " continued after a rule\n" + + "---\n" + // column-0: the real closing fence + "# Body\n" + sk, err := Parse([]byte(content), SkillSource{}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !strings.Contains(sk.Schema.Description, "---") { + t.Errorf("block-scalar --- lost; Description = %q", sk.Schema.Description) + } + if !strings.Contains(sk.Schema.Description, "continued after a rule") { + t.Errorf("block scalar truncated at inner ---; Description = %q", sk.Schema.Description) + } + if !strings.HasPrefix(sk.Body, "# Body") { + t.Errorf("Body wrong; got %q", firstN(sk.Body, 30)) + } +} + +func TestParse_BOMAndCRLF(t *testing.T) { + t.Parallel() + content := "\xEF\xBB\xBF---\r\nname: bom\r\ndescription: d\r\n---\r\nbody\r\n" + sk, err := Parse([]byte(content), SkillSource{}) + if err != nil { + t.Fatalf("Parse BOM+CRLF: %v", err) + } + if sk.Schema.Name != "bom" { + t.Errorf("Name = %q; want bom", sk.Schema.Name) + } +} + +func TestParse_EmptyFrontmatter(t *testing.T) { + t.Parallel() + sk, err := Parse([]byte("---\n---\nbody"), SkillSource{}) + if err != nil { + t.Fatalf("Parse empty frontmatter: %v", err) + } + if sk.Schema.Name != "" || len(sk.Schema.Extra) != 0 { + t.Errorf("empty frontmatter should yield zero schema, got %+v", sk.Schema) + } + if sk.Body != "body" { + t.Errorf("Body = %q; want body", sk.Body) + } +} + +func TestParse_ParseError(t *testing.T) { + t.Parallel() + cases := map[string]string{ + // Invalid YAML syntax (caught at yaml.Unmarshal). + "bad syntax": "---\nname: : :\n - broken\n---\n", + // Valid YAML but not a mapping root (caught at root.Decode). + "sequence not mapping": "---\n- a\n- b\n---\n", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := Parse([]byte(in), SkillSource{}) + assertCode(t, err, ErrParseError.Code()) + }) + } +} + +// TestParse_ExtraDecodeError — review HIGH: an undecodable extra-field value +// (e.g. invalid !!binary) must NOT be silently dropped; Parse returns +// SKILL.PARSE_ERROR (Q6 preservation / 规则 7 non-silent). +func TestParse_ExtraDecodeError(t *testing.T) { + t.Parallel() + content := "---\nname: x\ndescription: d\nbad: !!binary \"not-valid-base64\"\n---\nbody\n" + _, err := Parse([]byte(content), SkillSource{}) + assertCode(t, err, ErrParseError.Code()) +} + +// TestParse_FenceTrailingWhitespace — a closing fence with trailing spaces/ +// tabs still closes (review: `--- ` is valid); leading whitespace does not. +func TestParse_FenceTrailingWhitespace(t *testing.T) { + t.Parallel() + sk, err := Parse([]byte("---\nname: x\ndescription: d\n--- \t\nbody\n"), SkillSource{}) + if err != nil { + t.Fatalf("trailing-ws fence should close: %v", err) + } + if sk.Schema.Name != "x" || sk.Body != "body\n" { + t.Errorf("unexpected parse: name=%q body=%q", sk.Schema.Name, sk.Body) + } +} + +func TestParse_TooLarge(t *testing.T) { + t.Parallel() + big := append([]byte("---\nname: x\ndescription: y\n---\n"), make([]byte, maxSkillSize)...) + _, err := Parse(big, SkillSource{}) + assertCode(t, err, ErrTooLarge.Code()) +} + +func TestParse_TooDeep(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString("---\nname: deep\ndescription: d\nnested:\n") + indent := " " + for i := 0; i < maxYAMLDepth+2; i++ { + b.WriteString(strings.Repeat(indent, i+1)) + b.WriteString("k:\n") + } + b.WriteString("---\nbody\n") + _, err := Parse([]byte(b.String()), SkillSource{}) + assertCode(t, err, ErrTooDeep.Code()) +} + +// TestParse_ExtraCollectedNotDropped — unknown frontmatter keys (incl +// opendbx_*) land in Extra; known keys never leak into Extra. +func TestParse_ExtraCollectedNotDropped(t *testing.T) { + t.Parallel() + content := "---\n" + + "name: x\n" + + "description: d\n" + + "opendbx_databases:\n - postgres\n - mysql\n" + + "future_cc_field: hello\n" + + "---\nbody\n" + sk, err := Parse([]byte(content), SkillSource{}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if _, ok := sk.Schema.Extra["opendbx_databases"]; !ok { + t.Errorf("opendbx_databases not preserved in Extra: %v", sk.Schema.Extra) + } + if _, ok := sk.Schema.Extra["future_cc_field"]; !ok { + t.Errorf("unknown field not preserved in Extra: %v", sk.Schema.Extra) + } + for _, leaked := range []string{"name", "description", "allowed-tools", "model"} { + if _, ok := sk.Schema.Extra[leaked]; ok { + t.Errorf("known field %q leaked into Extra", leaked) + } + } +} + +// TestKnownSchemaKeys_DriftGuard — every yaml-tagged Schema field (except the +// yaml:"-" Extra) appears in the known-key set, so Extra collection cannot +// drift from the struct definition. +func TestKnownSchemaKeys_DriftGuard(t *testing.T) { + t.Parallel() + known := knownSchemaKeys() + for _, k := range []string{"name", "description", "allowed-tools", "model"} { + if !known[k] { + t.Errorf("known-key set missing %q (struct/known drift)", k) + } + } + if known["-"] { + t.Error("yaml:\"-\" Extra field must not be a known key") + } +} + +func firstN(s string, n int) string { + if len(s) < n { + return s + } + return s[:n] +} + +func assertCode(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("expected error with code %s, got nil", want) + } + var ec errcode.Error + if !errors.As(err, &ec) { + t.Fatalf("error is not errcode.Error: %v", err) + } + if ec.Code() != want { + t.Errorf("code = %s; want %s (err: %v)", ec.Code(), want, err) + } +} diff --git a/internal/app/skills/schema.go b/internal/app/skills/schema.go new file mode 100644 index 0000000..f6d9ef8 --- /dev/null +++ b/internal/app/skills/schema.go @@ -0,0 +1,73 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File schema.go — Schema mirrors the CC SKILL.md frontmatter byte-faithfully +// (spec-2.1 D-1; survey § 3.1/3.2 @ CC SHA 3da94d5). +// +// Q6=B: NO opendbx_ explicit fields. Extensions live in Extra until a real +// consumer (spec-2.3/2.4) promotes them to typed fields (§3.7 append-only). + +package skills + +import ( + "reflect" + "strings" +) + +// Schema is the parsed SKILL.md frontmatter. The four CC fields carry yaml +// tags and are decoded directly; Extra (yaml:"-") is populated separately by +// Parse from every non-CC-known top-level key (forward-compat, never dropped). +type Schema struct { + Name string `yaml:"name"` // required, frontmatter-authoritative + Description string `yaml:"description"` // required; block-scalar trailing \n preserved + AllowedTools string `yaml:"allowed-tools"` // optional CSV; "" = inherit parent scope (CC) + Model string `yaml:"model"` // optional; string superset, validated at invocation (2.3) + Extra map[string]any `yaml:"-"` // all non-CC-known keys (incl user opendbx_*) +} + +// AllowedToolsList splits the CSV, trims each element, and drops empties. +// Pure (no side effects). An absent / blank AllowedTools returns nil, which +// callers (spec-2.3) treat as "inherit parent scope" — distinct from a +// present-but-empty list. +func (s Schema) AllowedToolsList() []string { + if strings.TrimSpace(s.AllowedTools) == "" { + return nil + } + parts := strings.Split(s.AllowedTools, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// knownKeys is the set of frontmatter keys that map to an explicit Schema +// field, computed once at init (review MED: was recomputed per Parse). +var knownKeys = knownSchemaKeys() + +// knownSchemaKeys returns the set of yaml frontmatter keys that map to an +// explicit Schema field. Derived via reflection over the yaml struct tags so +// it cannot drift from the struct definition (review: drift-proof known-key +// set). The yaml:"-" Extra field is excluded. +func knownSchemaKeys() map[string]bool { + t := reflect.TypeOf(Schema{}) + keys := make(map[string]bool, t.NumField()) + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("yaml") + if tag == "" || tag == "-" { + continue + } + // Strip any tag options (e.g. ",omitempty"). + if comma := strings.IndexByte(tag, ','); comma >= 0 { + tag = tag[:comma] + } + keys[tag] = true + } + return keys +} diff --git a/internal/app/skills/schema_test.go b/internal/app/skills/schema_test.go new file mode 100644 index 0000000..dfcfdfb --- /dev/null +++ b/internal/app/skills/schema_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import ( + "reflect" + "testing" +) + +func TestAllowedToolsList(t *testing.T) { + t.Parallel() + cases := []struct { + name string + csv string + want []string + }{ + {"empty inherits", "", nil}, + {"blank inherits", " ", nil}, + {"spaced", "Read, Grep, Glob, Bash", []string{"Read", "Grep", "Glob", "Bash"}}, + {"no spaces", "Read,Grep", []string{"Read", "Grep"}}, + {"consecutive commas filtered", "Read,,Grep", []string{"Read", "Grep"}}, + {"trailing comma filtered", "Read,", []string{"Read"}}, + {"only commas", ",,,", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := Schema{AllowedTools: tc.csv}.AllowedToolsList() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("AllowedToolsList(%q) = %v; want %v", tc.csv, got, tc.want) + } + }) + } +} diff --git a/internal/app/skills/skill.go b/internal/app/skills/skill.go new file mode 100644 index 0000000..5079bc4 --- /dev/null +++ b/internal/app/skills/skill.go @@ -0,0 +1,67 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File skill.go — Skill value type + SkillSource provenance + stable identity +// (spec-2.1 D-2). Skill is immutable: Parse returns a fresh value and nothing +// here mutates inputs (规则 12). + +package skills + +// SourceKind describes where a skill was discovered. It is descriptive; +// SkillSource.Precedence is the authoritative resolution key (review HIGH-4: +// 2.2 interleaves dual .claude/.opendbx + plugin-cache by assigning +// Precedence per its own path-ranking policy, not by re-ranking this enum). +type SourceKind int + +const ( + // SourceUnknown is the zero value. Resolve treats it as the lowest + // precedence and is the default when 2.2 has not tagged a skill. + SourceUnknown SourceKind = iota + // SourceBuiltin — opendbx built-in skill. + SourceBuiltin + // SourcePluginCache — installed plugin pack (.claude/plugins/cache/...). + SourcePluginCache + // SourceUserGlobal — ~/.claude or ~/.opendbx. + SourceUserGlobal + // SourceProject — ./.claude or ./.opendbx (highest by convention). + SourceProject +) + +// String renders the SourceKind for diagnostics. +func (k SourceKind) String() string { + switch k { + case SourceBuiltin: + return "builtin" + case SourcePluginCache: + return "plugin-cache" + case SourceUserGlobal: + return "user-global" + case SourceProject: + return "project" + default: + return "unknown" + } +} + +// SkillSource carries discovery provenance. spec-2.2 fills the real values; +// spec-2.1 only defines the shape and the precedence contract. +type SkillSource struct { + Kind SourceKind + Precedence int // higher wins in Resolve; 2.2 assigns per path policy + Path string // source file path (error messages + reporting) + PluginID string // non-empty for SourcePluginCache +} + +// Skill is a fully parsed SKILL.md: frontmatter + body + provenance. +type Skill struct { + Schema Schema + Body string // markdown body, byte-faithful after the closing fence + Source SkillSource +} + +// Key returns the canonical downstream identity, which is the validated +// Schema.Name. spec-2.3 maps a tool name to Skill.Key(). The loose name rule +// (Q5) forbids path/control chars but does NOT force lowercase, so callers +// compare keys byte-exact (case-sensitive). +func (s Skill) Key() string { return s.Schema.Name } diff --git a/internal/app/skills/skill_test.go b/internal/app/skills/skill_test.go new file mode 100644 index 0000000..03c73be --- /dev/null +++ b/internal/app/skills/skill_test.go @@ -0,0 +1,31 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import "testing" + +func TestSkillKey(t *testing.T) { + t.Parallel() + s := Skill{Schema: Schema{Name: "top-sql"}} + if s.Key() != "top-sql" { + t.Errorf("Key() = %q; want top-sql", s.Key()) + } +} + +func TestSourceKindString(t *testing.T) { + t.Parallel() + cases := map[SourceKind]string{ + SourceUnknown: "unknown", + SourceBuiltin: "builtin", + SourcePluginCache: "plugin-cache", + SourceUserGlobal: "user-global", + SourceProject: "project", + } + for k, want := range cases { + if got := k.String(); got != want { + t.Errorf("SourceKind(%d).String() = %q; want %q", k, got, want) + } + } +} diff --git a/internal/app/skills/testdata/code-reviewer.md b/internal/app/skills/testdata/code-reviewer.md new file mode 100644 index 0000000..848e5b1 --- /dev/null +++ b/internal/app/skills/testdata/code-reviewer.md @@ -0,0 +1,23 @@ +--- +name: code-reviewer +description: | + Expert code review specialist. Proactively reviews code for quality, + security, and maintainability. MUST BE USED for all code changes. +allowed-tools: Read, Grep, Glob, Bash +model: sonnet +--- + +# Body — markdown content injected into LLM scope when skill invoked + +You are an expert code reviewer. + +## Process + +1. Read the diff +2. Check for: security issues / performance / style / tests +3. Output structured review + +## Constraints + +- Be honest, not flattering +- Provide actionable suggestions diff --git a/internal/app/skills/validate.go b/internal/app/skills/validate.go new file mode 100644 index 0000000..b61af14 --- /dev/null +++ b/internal/app/skills/validate.go @@ -0,0 +1,124 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +// File validate.go — Skill validation (spec-2.1 D-4). Pure, no I/O. +// +// Warnings are returned as DATA (ValidationReport), never logged here — that +// keeps app/skills a pure leaf (no logger import; review CRIT). 2.2/UI decide +// how to surface warnings. +// +// Hard failures aggregate (Q7 multi-error): each detail is an errcode +// sentinel; they are joined and wrapped under SKILL.VALIDATION_FAILED so the +// sidecar sees one primary code while errors.Is still matches each detail. + +package skills + +import ( + "errors" + "regexp" + "sort" + "strings" + "unicode" + + "github.com/sqlrush/opendbx/internal/platform/errcode" +) + +const maxNameLen = 128 + +// kebabNameRE is the CC-convention shape (lowercase-with-hyphens). Non-match +// is a WARNING, not a failure (Q5 loose: CC has not pinned a name regex, so +// rejecting non-kebab could refuse a valid CC skill). +var kebabNameRE = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// WarnKind classifies a non-fatal validation warning. +type WarnKind int + +const ( + // WarnNonKebabName — name is valid but not kebab-case. + WarnNonKebabName WarnKind = iota + // WarnUnknownField — a frontmatter key was preserved in Extra. + WarnUnknownField +) + +// Warning is a non-fatal validation note returned as data. +type Warning struct { + Kind WarnKind + Field string + Detail string +} + +// ValidationReport carries the warnings from a Validate call. err (returned +// separately) carries the hard failures. +type ValidationReport struct { + Warnings []Warning +} + +// Validate checks a parsed Skill. It returns warnings as data and, when one +// or more hard rules fail, a SKILL.VALIDATION_FAILED error wrapping the joined +// detail sentinels. A skill with no failures returns (report, nil). +func Validate(s Skill) (ValidationReport, error) { + var report ValidationReport + var failures []error + + name := s.Schema.Name + switch { + case strings.TrimSpace(name) == "": + failures = append(failures, errcode.New(ErrMissingName.Code(), "", "")) + case !validName(name): + failures = append(failures, errcode.Newf(ErrInvalidName.Code(), + "name %q has illegal characters or exceeds %d chars", name, maxNameLen)) + default: + if !kebabNameRE.MatchString(name) { + report.Warnings = append(report.Warnings, Warning{ + Kind: WarnNonKebabName, + Field: "name", + Detail: "name is not kebab-case (lowercase-with-hyphens); CC convention", + }) + } + } + + if strings.TrimSpace(s.Schema.Description) == "" { + failures = append(failures, errcode.New(ErrMissingDescription.Code(), "", "")) + } + + // Unknown-field warnings (sorted for determinism). Preserved, not + // rejected (Q4 forward-compat); not silently dropped (规则 7). + if len(s.Schema.Extra) > 0 { + keys := make([]string, 0, len(s.Schema.Extra)) + for k := range s.Schema.Extra { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + report.Warnings = append(report.Warnings, Warning{ + Kind: WarnUnknownField, + Field: k, + Detail: "unknown frontmatter field preserved in Extra (forward-compat)", + }) + } + } + + if len(failures) == 0 { + return report, nil + } + // Single or multiple: always wrap under the aggregate primary code so the + // sidecar reads a stable SKILL.VALIDATION_FAILED while details survive + // under Unwrap / errors.Is (Q7). + return report, errcode.Wrap(ErrValidationFailed.Code(), errors.Join(failures...), "", "") +} + +// validName applies the loose name rule (Q5): no path separators, whitespace, +// or control characters, and within the length cap. kebab-case is only a +// warning, checked separately. +func validName(name string) bool { + if len(name) > maxNameLen { + return false + } + for _, r := range name { + if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) { + return false + } + } + return true +} diff --git a/internal/app/skills/validate_test.go b/internal/app/skills/validate_test.go new file mode 100644 index 0000000..f508325 --- /dev/null +++ b/internal/app/skills/validate_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 opendbx contributors. See LICENSE. +// +// Author: sqlrush + +package skills + +import ( + "errors" + "strings" + "testing" +) + +func skillWith(name, desc string) Skill { + return Skill{Schema: Schema{Name: name, Description: desc}} +} + +func TestValidate_OK(t *testing.T) { + t.Parallel() + rep, err := Validate(skillWith("code-reviewer", "reviews code")) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if len(rep.Warnings) != 0 { + t.Errorf("unexpected warnings: %+v", rep.Warnings) + } +} + +func TestValidate_MissingName(t *testing.T) { + t.Parallel() + _, err := Validate(skillWith(" ", "desc")) + assertWraps(t, err, ErrMissingName.Code()) +} + +func TestValidate_MissingDescription(t *testing.T) { + t.Parallel() + // whitespace-only description is treated as missing (TrimSpace). + _, err := Validate(skillWith("ok-name", " \n ")) + assertWraps(t, err, ErrMissingDescription.Code()) +} + +// TestValidate_LooseNameMatrix — Q5 loose rule: path/control chars reject; +// non-kebab (but legal) names pass with a warning. +func TestValidate_LooseNameMatrix(t *testing.T) { + t.Parallel() + cases := []struct { + name string + skillID string + wantErr string // "" = no error + wantWarn bool // WarnNonKebabName expected + }{ + {"kebab ok", "code-reviewer", "", false}, + {"single word", "explain", "", false}, + {"uppercase warns", "CodeReviewer", "", true}, + {"underscore warns", "code_reviewer", "", true}, + {"pure numeric warns", "123", "", true}, + {"slash rejected", "a/b", ErrInvalidName.Code(), false}, + {"backslash rejected", "a\\b", ErrInvalidName.Code(), false}, + {"space rejected", "a b", ErrInvalidName.Code(), false}, + {"too long rejected", strings.Repeat("a", maxNameLen+1), ErrInvalidName.Code(), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rep, err := Validate(skillWith(tc.skillID, "desc")) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("want no error, got %v", err) + } + } else { + assertWraps(t, err, tc.wantErr) + } + hasWarn := false + for _, w := range rep.Warnings { + if w.Kind == WarnNonKebabName { + hasWarn = true + } + } + if hasWarn != tc.wantWarn { + t.Errorf("kebab warning = %v; want %v (warnings: %+v)", hasWarn, tc.wantWarn, rep.Warnings) + } + }) + } +} + +// TestValidate_MultiError — multiple failures aggregate under +// SKILL.VALIDATION_FAILED while errors.Is still matches each detail (Q7). +func TestValidate_MultiError(t *testing.T) { + t.Parallel() + _, err := Validate(skillWith("", "")) // missing name AND description + if err == nil { + t.Fatal("expected error") + } + assertWraps(t, err, ErrValidationFailed.Code()) + if !errors.Is(err, ErrMissingName) { + t.Error("errors.Is should match ErrMissingName under the aggregate") + } + if !errors.Is(err, ErrMissingDescription) { + t.Error("errors.Is should match ErrMissingDescription under the aggregate") + } +} + +// TestValidate_UnknownFieldWarns — preserved Extra keys surface as sorted +// warnings, not failures (Q4). +func TestValidate_UnknownFieldWarns(t *testing.T) { + t.Parallel() + s := Skill{Schema: Schema{ + Name: "ok", + Description: "d", + Extra: map[string]any{"zeta": 1, "alpha": 2}, + }} + rep, err := Validate(s) + if err != nil { + t.Fatalf("unknown fields must not fail validation: %v", err) + } + var unknown []string + for _, w := range rep.Warnings { + if w.Kind == WarnUnknownField { + unknown = append(unknown, w.Field) + } + } + if len(unknown) != 2 || unknown[0] != "alpha" || unknown[1] != "zeta" { + t.Errorf("unknown-field warnings = %v; want sorted [alpha zeta]", unknown) + } +} + +func assertWraps(t *testing.T, err error, code string) { + t.Helper() + if err == nil { + t.Fatalf("expected error wrapping %s, got nil", code) + } + // The detail code must be reachable via errors.Is on the registered sentinel. + found := false + for _, sent := range allSkillSentinels() { + if sent.Code() == code && errors.Is(err, sent) { + found = true + } + } + if !found { + t.Errorf("error does not wrap code %s: %v", code, err) + } +} diff --git a/internal/platform/errcode/testdata/error-codes-frozen.txt b/internal/platform/errcode/testdata/error-codes-frozen.txt index 101c0df..d5bd850 100644 --- a/internal/platform/errcode/testdata/error-codes-frozen.txt +++ b/internal/platform/errcode/testdata/error-codes-frozen.txt @@ -50,6 +50,16 @@ RENDER.STREAM_FILTERED RENDER.STREAM_TRUNCATED RENDER.UNSUPPORTED_NODE REPORT.WRITE_FAILED +SKILL.INVALID_NAME +SKILL.MISSING_DESCRIPTION +SKILL.MISSING_NAME +SKILL.NAMESPACE_CONFLICT +SKILL.NO_FRONTMATTER +SKILL.PARSE_ERROR +SKILL.TOO_DEEP +SKILL.TOO_LARGE +SKILL.UNTERMINATED_FRONTMATTER +SKILL.VALIDATION_FAILED TERMINAL.INIT_FAILED TERMINAL.NOT_A_TTY TERMINAL.PROBE_FAILED