Skip to content

Commit 2b1e506

Browse files
authored
Merge pull request #37 from LAA-Software-Engineering/issue/5-project-import-loader
feat(project): import loader with merge and duplicate detection (#5)
2 parents 2e990d5 + 95a1140 commit 2b1e506

10 files changed

Lines changed: 364 additions & 1 deletion

File tree

internal/project/doc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
// Package project loads project directories, merges resources, and resolves imports.
1+
// Package project loads the root project.yaml, expands spec.imports, and merges
2+
// resources into a spec.ProjectGraph. Reference resolution and validation are separate.
23
package project

internal/project/errors.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package project
2+
3+
import "fmt"
4+
5+
// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
6+
type DuplicateResourceError struct {
7+
Kind string
8+
Name string
9+
Paths []string // typically [firstFile, secondFile]
10+
}
11+
12+
func (e *DuplicateResourceError) Error() string {
13+
if e == nil {
14+
return ""
15+
}
16+
if len(e.Paths) >= 2 {
17+
return fmt.Sprintf("duplicate resource %s/%s: first %q, second %q", e.Kind, e.Name, e.Paths[0], e.Paths[1])
18+
}
19+
return fmt.Sprintf("duplicate resource %s/%s", e.Kind, e.Name)
20+
}

internal/project/loader.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package project
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
12+
)
13+
14+
// YAML file suffixes loaded from directories (recursive) and explicit import paths.
15+
const yamlExt = ".yaml"
16+
const ymlExt = ".yml"
17+
18+
// LoadProject loads root/project.yaml (or project.yml), expands spec.imports, parses
19+
// every YAML document with [internal/spec], and merges resources into a ProjectGraph.
20+
// Duplicate kind/metadata.name pairs are rejected (§9.1). Only the root project file
21+
// may define kind Project.
22+
func LoadProject(root string) (*spec.ProjectGraph, error) {
23+
rootAbs, err := filepath.Abs(filepath.Clean(root))
24+
if err != nil {
25+
return nil, fmt.Errorf("project root: %w", err)
26+
}
27+
28+
projPath, err := findProjectFile(rootAbs)
29+
if err != nil {
30+
return nil, err
31+
}
32+
33+
dec, err := spec.LoadResourceFile(projPath)
34+
if err != nil {
35+
return nil, err
36+
}
37+
pr, ok := dec.Resource.(*spec.ProjectResource)
38+
if !ok || dec.Kind() != spec.KindProject {
39+
return nil, fmt.Errorf("%s: expected kind Project, got %q", projPath, dec.Kind())
40+
}
41+
42+
g := &spec.ProjectGraph{
43+
Meta: pr.Metadata,
44+
Spec: pr.Spec,
45+
Agents: make(map[string]*spec.AgentResource),
46+
Tools: make(map[string]*spec.ToolResource),
47+
Workflows: make(map[string]*spec.WorkflowResource),
48+
Policies: make(map[string]*spec.PolicyResource),
49+
Environments: make(map[string]*spec.EnvironmentResource),
50+
}
51+
52+
seen := map[resourceKey]string{
53+
{kind: spec.KindProject, name: strings.TrimSpace(pr.Metadata.Name)}: projPath,
54+
}
55+
56+
files, err := expandImports(rootAbs, projPath, g.Spec.Imports)
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
for _, path := range files {
62+
if path == projPath {
63+
continue
64+
}
65+
d, err := spec.LoadResourceFile(path)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if err := mergeDecoded(g, d, path, seen); err != nil {
70+
return nil, err
71+
}
72+
}
73+
74+
return g, nil
75+
}
76+
77+
type resourceKey struct {
78+
kind string
79+
name string
80+
}
81+
82+
func findProjectFile(dir string) (string, error) {
83+
for _, name := range []string{"project.yaml", "project.yml"} {
84+
p := filepath.Join(dir, name)
85+
if _, err := os.Stat(p); err == nil {
86+
return p, nil
87+
}
88+
}
89+
return "", fmt.Errorf("no project.yaml or project.yml in %q", dir)
90+
}
91+
92+
func expandImports(rootAbs, projPath string, imports []string) ([]string, error) {
93+
seen := map[string]struct{}{}
94+
var out []string
95+
96+
add := func(p string) {
97+
p = filepath.Clean(p)
98+
if _, ok := seen[p]; ok {
99+
return
100+
}
101+
seen[p] = struct{}{}
102+
out = append(out, p)
103+
}
104+
105+
add(projPath)
106+
107+
for _, imp := range imports {
108+
imp = strings.TrimSpace(imp)
109+
if imp == "" {
110+
continue
111+
}
112+
if filepath.IsAbs(imp) {
113+
return nil, fmt.Errorf("import %q: absolute paths are not allowed", imp)
114+
}
115+
full := filepath.Join(rootAbs, filepath.FromSlash(imp))
116+
full = filepath.Clean(full)
117+
if !isUnderRoot(rootAbs, full) {
118+
return nil, fmt.Errorf("import %q resolves outside project root", imp)
119+
}
120+
121+
fi, err := os.Stat(full)
122+
if err != nil {
123+
return nil, fmt.Errorf("import %q: %w", imp, err)
124+
}
125+
126+
if fi.IsDir() {
127+
list, err := walkYAMLFiles(full)
128+
if err != nil {
129+
return nil, fmt.Errorf("import %q: %w", imp, err)
130+
}
131+
for _, f := range list {
132+
add(f)
133+
}
134+
} else {
135+
add(full)
136+
}
137+
}
138+
139+
sort.Strings(out)
140+
return out, nil
141+
}
142+
143+
func walkYAMLFiles(dir string) ([]string, error) {
144+
var files []string
145+
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
146+
if err != nil {
147+
return err
148+
}
149+
if d.IsDir() {
150+
return nil
151+
}
152+
ext := strings.ToLower(filepath.Ext(path))
153+
if ext == yamlExt || ext == ymlExt {
154+
files = append(files, path)
155+
}
156+
return nil
157+
})
158+
if err != nil {
159+
return nil, err
160+
}
161+
sort.Strings(files)
162+
return files, nil
163+
}
164+
165+
func isUnderRoot(root, p string) bool {
166+
root = filepath.Clean(root)
167+
p = filepath.Clean(p)
168+
rel, err := filepath.Rel(root, p)
169+
if err != nil {
170+
return false
171+
}
172+
if rel == "." {
173+
return true
174+
}
175+
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
176+
}
177+
178+
func mergeDecoded(g *spec.ProjectGraph, d *spec.Decoded, path string, seen map[resourceKey]string) error {
179+
kind := d.Kind()
180+
if kind == spec.KindProject {
181+
return fmt.Errorf("%s: kind Project must only be defined in the root project.yaml", path)
182+
}
183+
184+
id := d.ResourceID()
185+
name := strings.TrimSpace(id.Name)
186+
if name == "" {
187+
return fmt.Errorf("%s: resource has empty metadata.name", path)
188+
}
189+
190+
key := resourceKey{kind: kind, name: name}
191+
if prev, ok := seen[key]; ok {
192+
return &DuplicateResourceError{Kind: kind, Name: name, Paths: []string{prev, path}}
193+
}
194+
seen[key] = path
195+
196+
switch kind {
197+
case spec.KindAgent:
198+
ar, ok := d.Resource.(*spec.AgentResource)
199+
if !ok {
200+
return fmt.Errorf("%s: internal error: wrong type for Agent", path)
201+
}
202+
g.Agents[name] = ar
203+
case spec.KindTool:
204+
tr, ok := d.Resource.(*spec.ToolResource)
205+
if !ok {
206+
return fmt.Errorf("%s: internal error: wrong type for Tool", path)
207+
}
208+
g.Tools[name] = tr
209+
case spec.KindWorkflow:
210+
wr, ok := d.Resource.(*spec.WorkflowResource)
211+
if !ok {
212+
return fmt.Errorf("%s: internal error: wrong type for Workflow", path)
213+
}
214+
g.Workflows[name] = wr
215+
case spec.KindPolicy:
216+
pr, ok := d.Resource.(*spec.PolicyResource)
217+
if !ok {
218+
return fmt.Errorf("%s: internal error: wrong type for Policy", path)
219+
}
220+
g.Policies[name] = pr
221+
case spec.KindEnvironment:
222+
er, ok := d.Resource.(*spec.EnvironmentResource)
223+
if !ok {
224+
return fmt.Errorf("%s: internal error: wrong type for Environment", path)
225+
}
226+
g.Environments[name] = er
227+
default:
228+
return fmt.Errorf("%s: unsupported kind %q", path, kind)
229+
}
230+
return nil
231+
}

internal/project/loader_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package project
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestLoadProject_duplicateKindName(t *testing.T) {
11+
root := filepath.Join("testdata", "dup_agents")
12+
_, err := LoadProject(root)
13+
if err == nil {
14+
t.Fatal("expected duplicate Agent/foo error")
15+
}
16+
var dup *DuplicateResourceError
17+
if !errors.As(err, &dup) {
18+
t.Fatalf("expected *DuplicateResourceError, got %T: %v", err, err)
19+
}
20+
if dup.Kind != "Agent" || dup.Name != "foo" {
21+
t.Fatalf("duplicate = %s/%s, want Agent/foo", dup.Kind, dup.Name)
22+
}
23+
if len(dup.Paths) != 2 {
24+
t.Fatalf("Paths = %v, want two entries", dup.Paths)
25+
}
26+
has := func(suffix string) bool {
27+
for _, p := range dup.Paths {
28+
if strings.HasSuffix(filepath.ToSlash(p), suffix) {
29+
return true
30+
}
31+
}
32+
return false
33+
}
34+
if !has("agents/one.yaml") || !has("agents/two.yaml") {
35+
t.Fatalf("expected paths to include agents/one.yaml and agents/two.yaml, got %#v", dup.Paths)
36+
}
37+
}
38+
39+
func TestLoadProject_nestedImportDirectory(t *testing.T) {
40+
root := filepath.Join("testdata", "nested_import")
41+
g, err := LoadProject(root)
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
if g.Meta.Name != "nested-test" {
46+
t.Fatalf("project name = %q", g.Meta.Name)
47+
}
48+
a, ok := g.Agents["deep-agent"]
49+
if !ok || a == nil {
50+
t.Fatalf("expected Agent deep-agent from nested/deep/here.yaml, got agents=%v", keys(g.Agents))
51+
}
52+
if a.Metadata.Name != "deep-agent" {
53+
t.Fatalf("agent metadata.name = %q", a.Metadata.Name)
54+
}
55+
}
56+
57+
func TestLoadProject_minimalNoImports(t *testing.T) {
58+
root := filepath.Join("testdata", "minimal")
59+
g, err := LoadProject(root)
60+
if err != nil {
61+
t.Fatal(err)
62+
}
63+
if g.Meta.Name != "minimal" {
64+
t.Fatalf("Meta.Name = %q", g.Meta.Name)
65+
}
66+
if len(g.Agents) != 0 {
67+
t.Fatalf("expected no agents, got %d", len(g.Agents))
68+
}
69+
}
70+
71+
func keys[K comparable, V any](m map[K]V) []K {
72+
out := make([]K, 0, len(m))
73+
for k := range m {
74+
out = append(out, k)
75+
}
76+
return out
77+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Agent
3+
metadata:
4+
name: foo
5+
spec: {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Agent
3+
metadata:
4+
name: foo
5+
spec: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: dup-test
5+
spec:
6+
imports:
7+
- ./agents
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: minimal
5+
spec: {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Agent
3+
metadata:
4+
name: deep-agent
5+
spec: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: nested-test
5+
spec:
6+
imports:
7+
- ./nested

0 commit comments

Comments
 (0)