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
3 changes: 2 additions & 1 deletion internal/project/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
// Package project loads project directories, merges resources, and resolves imports.
// Package project loads the root project.yaml, expands spec.imports, and merges
// resources into a spec.ProjectGraph. Reference resolution and validation are separate.
package project
20 changes: 20 additions & 0 deletions internal/project/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package project

import "fmt"

// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
type DuplicateResourceError struct {
Kind string
Name string
Paths []string // typically [firstFile, secondFile]
}

func (e *DuplicateResourceError) Error() string {
if e == nil {
return ""
}
if len(e.Paths) >= 2 {
return fmt.Sprintf("duplicate resource %s/%s: first %q, second %q", e.Kind, e.Name, e.Paths[0], e.Paths[1])
}
return fmt.Sprintf("duplicate resource %s/%s", e.Kind, e.Name)
}
231 changes: 231 additions & 0 deletions internal/project/loader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package project

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// YAML file suffixes loaded from directories (recursive) and explicit import paths.
const yamlExt = ".yaml"
const ymlExt = ".yml"

// LoadProject loads root/project.yaml (or project.yml), expands spec.imports, parses
// every YAML document with [internal/spec], and merges resources into a ProjectGraph.
// Duplicate kind/metadata.name pairs are rejected (§9.1). Only the root project file
// may define kind Project.
func LoadProject(root string) (*spec.ProjectGraph, error) {
rootAbs, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("project root: %w", err)
}

projPath, err := findProjectFile(rootAbs)
if err != nil {
return nil, err
}

dec, err := spec.LoadResourceFile(projPath)
if err != nil {
return nil, err
}
pr, ok := dec.Resource.(*spec.ProjectResource)
if !ok || dec.Kind() != spec.KindProject {
return nil, fmt.Errorf("%s: expected kind Project, got %q", projPath, dec.Kind())
}

g := &spec.ProjectGraph{
Meta: pr.Metadata,
Spec: pr.Spec,
Agents: make(map[string]*spec.AgentResource),
Tools: make(map[string]*spec.ToolResource),
Workflows: make(map[string]*spec.WorkflowResource),
Policies: make(map[string]*spec.PolicyResource),
Environments: make(map[string]*spec.EnvironmentResource),
}

seen := map[resourceKey]string{
{kind: spec.KindProject, name: strings.TrimSpace(pr.Metadata.Name)}: projPath,
}

files, err := expandImports(rootAbs, projPath, g.Spec.Imports)
if err != nil {
return nil, err
}

for _, path := range files {
if path == projPath {
continue
}
d, err := spec.LoadResourceFile(path)
if err != nil {
return nil, err
}
if err := mergeDecoded(g, d, path, seen); err != nil {
return nil, err
}
}

return g, nil
}

type resourceKey struct {
kind string
name string
}

func findProjectFile(dir string) (string, error) {
for _, name := range []string{"project.yaml", "project.yml"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no project.yaml or project.yml in %q", dir)
}

func expandImports(rootAbs, projPath string, imports []string) ([]string, error) {
seen := map[string]struct{}{}
var out []string

add := func(p string) {
p = filepath.Clean(p)
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
out = append(out, p)
}

add(projPath)

for _, imp := range imports {
imp = strings.TrimSpace(imp)
if imp == "" {
continue
}
if filepath.IsAbs(imp) {
return nil, fmt.Errorf("import %q: absolute paths are not allowed", imp)
}
full := filepath.Join(rootAbs, filepath.FromSlash(imp))
full = filepath.Clean(full)
if !isUnderRoot(rootAbs, full) {
return nil, fmt.Errorf("import %q resolves outside project root", imp)
}

fi, err := os.Stat(full)
if err != nil {
return nil, fmt.Errorf("import %q: %w", imp, err)
}

if fi.IsDir() {
list, err := walkYAMLFiles(full)
if err != nil {
return nil, fmt.Errorf("import %q: %w", imp, err)
}
for _, f := range list {
add(f)
}
} else {
add(full)
}
}

sort.Strings(out)
return out, nil
}

func walkYAMLFiles(dir string) ([]string, error) {
var files []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == yamlExt || ext == ymlExt {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}

func isUnderRoot(root, p string) bool {
root = filepath.Clean(root)
p = filepath.Clean(p)
rel, err := filepath.Rel(root, p)
if err != nil {
return false
}
if rel == "." {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

func mergeDecoded(g *spec.ProjectGraph, d *spec.Decoded, path string, seen map[resourceKey]string) error {
kind := d.Kind()
if kind == spec.KindProject {
return fmt.Errorf("%s: kind Project must only be defined in the root project.yaml", path)
}

id := d.ResourceID()
name := strings.TrimSpace(id.Name)
if name == "" {
return fmt.Errorf("%s: resource has empty metadata.name", path)
}

key := resourceKey{kind: kind, name: name}
if prev, ok := seen[key]; ok {
return &DuplicateResourceError{Kind: kind, Name: name, Paths: []string{prev, path}}
}
seen[key] = path

switch kind {
case spec.KindAgent:
ar, ok := d.Resource.(*spec.AgentResource)
if !ok {
return fmt.Errorf("%s: internal error: wrong type for Agent", path)
}
g.Agents[name] = ar
case spec.KindTool:
tr, ok := d.Resource.(*spec.ToolResource)
if !ok {
return fmt.Errorf("%s: internal error: wrong type for Tool", path)
}
g.Tools[name] = tr
case spec.KindWorkflow:
wr, ok := d.Resource.(*spec.WorkflowResource)
if !ok {
return fmt.Errorf("%s: internal error: wrong type for Workflow", path)
}
g.Workflows[name] = wr
case spec.KindPolicy:
pr, ok := d.Resource.(*spec.PolicyResource)
if !ok {
return fmt.Errorf("%s: internal error: wrong type for Policy", path)
}
g.Policies[name] = pr
case spec.KindEnvironment:
er, ok := d.Resource.(*spec.EnvironmentResource)
if !ok {
return fmt.Errorf("%s: internal error: wrong type for Environment", path)
}
g.Environments[name] = er
default:
return fmt.Errorf("%s: unsupported kind %q", path, kind)
}
return nil
}
77 changes: 77 additions & 0 deletions internal/project/loader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package project

import (
"errors"
"path/filepath"
"strings"
"testing"
)

func TestLoadProject_duplicateKindName(t *testing.T) {
root := filepath.Join("testdata", "dup_agents")
_, err := LoadProject(root)
if err == nil {
t.Fatal("expected duplicate Agent/foo error")
}
var dup *DuplicateResourceError
if !errors.As(err, &dup) {
t.Fatalf("expected *DuplicateResourceError, got %T: %v", err, err)
}
if dup.Kind != "Agent" || dup.Name != "foo" {
t.Fatalf("duplicate = %s/%s, want Agent/foo", dup.Kind, dup.Name)
}
if len(dup.Paths) != 2 {
t.Fatalf("Paths = %v, want two entries", dup.Paths)
}
has := func(suffix string) bool {
for _, p := range dup.Paths {
if strings.HasSuffix(filepath.ToSlash(p), suffix) {
return true
}
}
return false
}
if !has("agents/one.yaml") || !has("agents/two.yaml") {
t.Fatalf("expected paths to include agents/one.yaml and agents/two.yaml, got %#v", dup.Paths)
}
}

func TestLoadProject_nestedImportDirectory(t *testing.T) {
root := filepath.Join("testdata", "nested_import")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
if g.Meta.Name != "nested-test" {
t.Fatalf("project name = %q", g.Meta.Name)
}
a, ok := g.Agents["deep-agent"]
if !ok || a == nil {
t.Fatalf("expected Agent deep-agent from nested/deep/here.yaml, got agents=%v", keys(g.Agents))
}
if a.Metadata.Name != "deep-agent" {
t.Fatalf("agent metadata.name = %q", a.Metadata.Name)
}
}

func TestLoadProject_minimalNoImports(t *testing.T) {
root := filepath.Join("testdata", "minimal")
g, err := LoadProject(root)
if err != nil {
t.Fatal(err)
}
if g.Meta.Name != "minimal" {
t.Fatalf("Meta.Name = %q", g.Meta.Name)
}
if len(g.Agents) != 0 {
t.Fatalf("expected no agents, got %d", len(g.Agents))
}
}

func keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
5 changes: 5 additions & 0 deletions internal/project/testdata/dup_agents/agents/one.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Agent
metadata:
name: foo
spec: {}
5 changes: 5 additions & 0 deletions internal/project/testdata/dup_agents/agents/two.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Agent
metadata:
name: foo
spec: {}
7 changes: 7 additions & 0 deletions internal/project/testdata/dup_agents/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: dup-test
spec:
imports:
- ./agents
5 changes: 5 additions & 0 deletions internal/project/testdata/minimal/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: minimal
spec: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: agentic.dev/v0
kind: Agent
metadata:
name: deep-agent
spec: {}
7 changes: 7 additions & 0 deletions internal/project/testdata/nested_import/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: agentic.dev/v0
kind: Project
metadata:
name: nested-test
spec:
imports:
- ./nested
Loading