Skip to content

Commit c65f69a

Browse files
committed
feat(spec): YAML loader and parser with structured errors (#3)
- Add LoadResourceFile and ParseResourceFromBytes (single-document yaml.v3 decode) - Enforce apiVersion, kind, metadata, metadata.name, and non-null spec (§6.1) - Map kind to typed *Resource envelopes; ErrUnknownKind / ErrMultipleDocuments - LoadError carries path and best-effort YAML line from parser messages - Tests: all MVP kinds load from disk; missing metadata.name; invalid YAML path; multi-doc; unknown kind Closes #3 Made-with: Cursor
1 parent bb970bb commit c65f69a

5 files changed

Lines changed: 430 additions & 2 deletions

File tree

internal/spec/doc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
// Package spec defines resource envelopes and MVP kind structs (design doc §6–§7).
2-
// Parsing, defaults, and validation live in future packages.
1+
// Package spec defines resource envelopes, MVP kind structs (§6–§7), and YAML loading.
2+
// Defaults, reference resolution, and semantic validation are out of scope for now.
33
package spec

internal/spec/errors.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package spec
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"regexp"
7+
"strconv"
8+
)
9+
10+
// Sentinel errors for resource loading.
11+
var (
12+
ErrMultipleDocuments = errors.New("expected exactly one YAML document")
13+
ErrUnknownKind = errors.New("unknown resource kind")
14+
)
15+
16+
// LoadError records a resource load or decode failure with file context (issue #3).
17+
type LoadError struct {
18+
Path string
19+
Line int // 1-based; 0 if unknown
20+
Column int // 1-based; 0 if unknown
21+
Msg string
22+
Err error
23+
}
24+
25+
func (e *LoadError) Error() string {
26+
if e == nil {
27+
return ""
28+
}
29+
prefix := ""
30+
switch {
31+
case e.Path != "" && e.Line > 0 && e.Column > 0:
32+
prefix = fmt.Sprintf("%s:%d:%d: ", e.Path, e.Line, e.Column)
33+
case e.Path != "" && e.Line > 0:
34+
prefix = fmt.Sprintf("%s:%d: ", e.Path, e.Line)
35+
case e.Path != "":
36+
prefix = e.Path + ": "
37+
}
38+
return prefix + e.Msg
39+
}
40+
41+
// Unwrap returns the underlying error for errors.Is / errors.As.
42+
func (e *LoadError) Unwrap() error { return e.Err }
43+
44+
var yamlLineHint = regexp.MustCompile(`line (\d+)`)
45+
46+
func yamlLocationHint(err error) (line, col int) {
47+
if err == nil {
48+
return 0, 0
49+
}
50+
m := yamlLineHint.FindStringSubmatch(err.Error())
51+
if len(m) < 2 {
52+
return 0, 0
53+
}
54+
line, _ = strconv.Atoi(m[1])
55+
return line, col
56+
}
57+
58+
// wrapLoadError attaches path and best-effort YAML line/column from parser errors.
59+
func wrapLoadError(path, msg string, err error) error {
60+
if err == nil {
61+
return &LoadError{Path: path, Msg: msg}
62+
}
63+
line, col := yamlLocationHint(err)
64+
return &LoadError{
65+
Path: path, Line: line, Column: col,
66+
Msg: msg, Err: err,
67+
}
68+
}

internal/spec/loader.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package spec
2+
3+
import (
4+
"os"
5+
)
6+
7+
// LoadResourceFile reads path and decodes exactly one YAML MVP resource.
8+
func LoadResourceFile(path string) (*Decoded, error) {
9+
data, err := os.ReadFile(path)
10+
if err != nil {
11+
return nil, &LoadError{Path: path, Msg: "read file", Err: err}
12+
}
13+
return ParseResourceFromBytes(data, path)
14+
}

internal/spec/loader_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package spec
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"reflect"
8+
"strings"
9+
"testing"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
func TestLoadResourceFile_validKinds(t *testing.T) {
15+
cases := []struct {
16+
name string
17+
doc any
18+
}{
19+
{"Project", sampleProject()},
20+
{"Agent", sampleAgent()},
21+
{"Tool_MCP", sampleToolMCP()},
22+
{"Tool_HTTP", sampleToolHTTP()},
23+
{"Workflow", sampleWorkflow()},
24+
{"Policy", samplePolicy()},
25+
{"Environment", sampleEnvironment()},
26+
}
27+
28+
for _, tc := range cases {
29+
t.Run(tc.name, func(t *testing.T) {
30+
data, err := yaml.Marshal(tc.doc)
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
dir := t.TempDir()
35+
p := filepath.Join(dir, "res.yaml")
36+
if err := os.WriteFile(p, data, 0o600); err != nil {
37+
t.Fatal(err)
38+
}
39+
40+
got, err := LoadResourceFile(p)
41+
if err != nil {
42+
t.Fatalf("LoadResourceFile: %v", err)
43+
}
44+
if got.Path != p {
45+
t.Fatalf("Path = %q, want %q", got.Path, p)
46+
}
47+
if !reflect.DeepEqual(got.Resource, tc.doc) {
48+
t.Fatalf("resource mismatch\n got %#v\nwant %#v", got.Resource, tc.doc)
49+
}
50+
})
51+
}
52+
}
53+
54+
func TestParseResourceFromBytes_missingMetadataName(t *testing.T) {
55+
const y = `
56+
apiVersion: agentic.dev/v0
57+
kind: Agent
58+
metadata: {}
59+
spec: {}
60+
`
61+
_, err := ParseResourceFromBytes([]byte(y), "/tmp/agent.yaml")
62+
if err == nil {
63+
t.Fatal("expected error")
64+
}
65+
var le *LoadError
66+
if !errors.As(err, &le) {
67+
t.Fatalf("want *LoadError, got %T: %v", err, err)
68+
}
69+
if !strings.Contains(le.Error(), "/tmp/agent.yaml") {
70+
t.Fatalf("error should mention path: %q", le.Error())
71+
}
72+
if !strings.Contains(le.Error(), "metadata.name") {
73+
t.Fatalf("error should mention metadata.name: %q", le.Error())
74+
}
75+
}
76+
77+
func TestLoadResourceFile_invalidYAML_wrapsPath(t *testing.T) {
78+
dir := t.TempDir()
79+
p := filepath.Join(dir, "bad.yaml")
80+
// Line 2: invalid indentation triggers a parser error with a line hint.
81+
content := "apiVersion: x\n kind: Agent\nmetadata: {name: a}\nspec: {}\n"
82+
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
83+
t.Fatal(err)
84+
}
85+
86+
_, err := LoadResourceFile(p)
87+
if err == nil {
88+
t.Fatal("expected error")
89+
}
90+
var le *LoadError
91+
if !errors.As(err, &le) {
92+
t.Fatalf("want *LoadError, got %T: %v", err, err)
93+
}
94+
if le.Path != p {
95+
t.Fatalf("LoadError.Path = %q, want %q", le.Path, p)
96+
}
97+
if !strings.Contains(le.Error(), p) {
98+
t.Fatalf("error string should contain path: %q", le.Error())
99+
}
100+
_ = le.Line // best-effort from yaml error text; message always includes path
101+
}
102+
103+
func TestParseResourceFromBytes_multipleDocuments(t *testing.T) {
104+
y := `
105+
apiVersion: agentic.dev/v0
106+
kind: Project
107+
metadata: {name: a}
108+
spec: {}
109+
---
110+
apiVersion: agentic.dev/v0
111+
kind: Project
112+
metadata: {name: b}
113+
spec: {}
114+
`
115+
_, err := ParseResourceFromBytes([]byte(y), "multi.yaml")
116+
if err == nil {
117+
t.Fatal("expected error")
118+
}
119+
if !errors.Is(err, ErrMultipleDocuments) {
120+
t.Fatalf("want ErrMultipleDocuments in chain, got %v", err)
121+
}
122+
}
123+
124+
func TestParseResourceFromBytes_unknownKind(t *testing.T) {
125+
y := `
126+
apiVersion: agentic.dev/v0
127+
kind: NotAKind
128+
metadata: {name: x}
129+
spec: {}
130+
`
131+
_, err := ParseResourceFromBytes([]byte(y), "x.yaml")
132+
if err == nil {
133+
t.Fatal("expected error")
134+
}
135+
if !errors.Is(err, ErrUnknownKind) {
136+
t.Fatalf("want ErrUnknownKind in chain, got %v", err)
137+
}
138+
}

0 commit comments

Comments
 (0)