Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/tools/gen-error-codes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions git-hooks/spec-registry.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 22 additions & 3 deletions internal/app/skills/doc.go
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions internal/app/skills/errors.go
Original file line number Diff line number Diff line change
@@ -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",
)
)
43 changes: 43 additions & 0 deletions internal/app/skills/errors_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
191 changes: 191 additions & 0 deletions internal/app/skills/namespace.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading