Skip to content

Commit a4679ae

Browse files
authored
Merge pull request #40 from LAA-Software-Engineering/issue/8-spec-validator
feat(spec): graph validator and ref resolution in spec package (#8)
2 parents ed4c542 + 1291e20 commit a4679ae

9 files changed

Lines changed: 691 additions & 226 deletions

File tree

internal/project/doc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Package project loads the root project.yaml, expands spec.imports, merges resources
2-
// into a spec.ProjectGraph, and can resolve symbolic references via ResolveReferences.
2+
// into a spec.ProjectGraph. Reference checks use [ResolveReferences] (delegates to spec);
3+
// full MVP validation is [spec.ValidateProjectGraph].
34
package project

internal/project/errors.go

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,8 @@ import (
66
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
77
)
88

9-
// MissingRefError reports a reference from Referrer to a target kind/name that is not in the graph.
10-
type MissingRefError struct {
11-
Referrer spec.ResourceID
12-
Missing spec.ResourceID
13-
}
14-
15-
func (e *MissingRefError) Error() string {
16-
if e == nil {
17-
return ""
18-
}
19-
return fmt.Sprintf("%s references missing %s", e.Referrer.String(), e.Missing.String())
20-
}
9+
// MissingRefError is reported when a graph reference does not resolve (see [spec.MissingRefError]).
10+
type MissingRefError = spec.MissingRefError
2111

2212
// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
2313
type DuplicateResourceError struct {

internal/project/graph.go

Lines changed: 5 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,11 @@
11
package project
22

3-
import (
4-
"strings"
3+
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
54

6-
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
7-
)
8-
9-
// RefIndex summarizes symbolic references between resources before resolution (issue #6).
10-
// Names are metadata.name values; edges point to target kinds as implied by the field.
11-
type RefIndex struct {
12-
// AgentName -> tool names from Agent.spec.tools
13-
AgentTools map[string][]string
14-
// AgentName -> policy name when Agent.spec.policy is non-empty
15-
AgentPolicies map[string]string
16-
// WorkflowName -> agent names from steps with agent: set
17-
WorkflowAgents map[string][]string
18-
// WorkflowName -> tool names from steps with uses: tool.<name>...
19-
WorkflowTools map[string][]string
20-
// WorkflowName -> policy name when Workflow.spec.policy is non-empty
21-
WorkflowPolicies map[string]string
22-
}
5+
// RefIndex summarizes symbolic references between resources (see [spec.RefIndex]).
6+
type RefIndex = spec.RefIndex
237

248
// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
25-
func BuildRefIndex(g *spec.ProjectGraph) *RefIndex {
26-
if g == nil {
27-
return &RefIndex{
28-
AgentTools: map[string][]string{},
29-
AgentPolicies: map[string]string{},
30-
WorkflowAgents: map[string][]string{},
31-
WorkflowTools: map[string][]string{},
32-
WorkflowPolicies: map[string]string{},
33-
}
34-
}
35-
ix := &RefIndex{
36-
AgentTools: make(map[string][]string),
37-
AgentPolicies: make(map[string]string),
38-
WorkflowAgents: make(map[string][]string),
39-
WorkflowTools: make(map[string][]string),
40-
WorkflowPolicies: make(map[string]string),
41-
}
42-
for name, ar := range g.Agents {
43-
if ar == nil {
44-
continue
45-
}
46-
var tools []string
47-
for _, t := range ar.Spec.Tools {
48-
if s := strings.TrimSpace(t); s != "" {
49-
tools = append(tools, s)
50-
}
51-
}
52-
ix.AgentTools[name] = dedupeStrings(tools)
53-
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
54-
ix.AgentPolicies[name] = p
55-
}
56-
}
57-
for name, wr := range g.Workflows {
58-
if wr == nil {
59-
continue
60-
}
61-
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
62-
ix.WorkflowPolicies[name] = p
63-
}
64-
var agents, tools []string
65-
for _, st := range wr.Spec.Steps {
66-
if a := strings.TrimSpace(st.Agent); a != "" {
67-
agents = append(agents, a)
68-
}
69-
if u := strings.TrimSpace(st.Uses); u != "" {
70-
if tn, ok := spec.ParseToolUses(u); ok {
71-
tools = append(tools, tn)
72-
}
73-
}
74-
}
75-
ix.WorkflowAgents[name] = dedupeStrings(agents)
76-
ix.WorkflowTools[name] = dedupeStrings(tools)
77-
}
78-
return ix
79-
}
80-
81-
func dedupeStrings(in []string) []string {
82-
if len(in) == 0 {
83-
return nil
84-
}
85-
seen := make(map[string]struct{}, len(in))
86-
var out []string
87-
for _, s := range in {
88-
if _, ok := seen[s]; ok {
89-
continue
90-
}
91-
seen[s] = struct{}{}
92-
out = append(out, s)
93-
}
94-
return out
9+
func BuildRefIndex(g *spec.ProjectGraph) *spec.RefIndex {
10+
return spec.BuildRefIndex(g)
9511
}

internal/project/resolver.go

Lines changed: 3 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,8 @@
11
package project
22

3-
import (
4-
"fmt"
5-
"strings"
3+
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
64

7-
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
8-
)
9-
10-
// ResolveReferences checks that symbolic references in the merged graph resolve to
11-
// existing resources (§9.1) and applies simple sequential workflow rules (§9.4).
5+
// ResolveReferences checks symbolic references and workflow step rules (§9.1, §9.4).
126
func ResolveReferences(g *spec.ProjectGraph) error {
13-
if g == nil {
14-
return nil
15-
}
16-
ix := BuildRefIndex(g)
17-
18-
for agentName, tools := range ix.AgentTools {
19-
for _, tn := range tools {
20-
if _, ok := g.Tools[tn]; !ok {
21-
return &MissingRefError{
22-
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
23-
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
24-
}
25-
}
26-
}
27-
}
28-
for agentName, pol := range ix.AgentPolicies {
29-
if _, ok := g.Policies[pol]; !ok {
30-
return &MissingRefError{
31-
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
32-
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
33-
}
34-
}
35-
}
36-
37-
for wfName, wr := range g.Workflows {
38-
if wr == nil {
39-
continue
40-
}
41-
if err := validateWorkflowSteps(wfName, &wr.Spec); err != nil {
42-
return err
43-
}
44-
for _, an := range ix.WorkflowAgents[wfName] {
45-
if _, ok := g.Agents[an]; !ok {
46-
return &MissingRefError{
47-
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
48-
Missing: spec.ResourceID{Kind: spec.KindAgent, Name: an},
49-
}
50-
}
51-
}
52-
for _, tn := range ix.WorkflowTools[wfName] {
53-
if _, ok := g.Tools[tn]; !ok {
54-
return &MissingRefError{
55-
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
56-
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
57-
}
58-
}
59-
}
60-
if pol := ix.WorkflowPolicies[wfName]; pol != "" {
61-
if _, ok := g.Policies[pol]; !ok {
62-
return &MissingRefError{
63-
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
64-
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
65-
}
66-
}
67-
}
68-
if err := validateWorkflowStepOrder(wfName, &wr.Spec); err != nil {
69-
return err
70-
}
71-
}
72-
return nil
73-
}
74-
75-
func validateWorkflowSteps(wfName string, w *spec.WorkflowSpec) error {
76-
seenID := make(map[string]struct{})
77-
for _, st := range w.Steps {
78-
sid := strings.TrimSpace(st.ID)
79-
if sid != "" {
80-
if _, dup := seenID[sid]; dup {
81-
return fmt.Errorf("workflow %s: duplicate step id %q", wfName, sid)
82-
}
83-
seenID[sid] = struct{}{}
84-
}
85-
hasA := strings.TrimSpace(st.Agent) != ""
86-
hasU := strings.TrimSpace(st.Uses) != ""
87-
if hasA && hasU {
88-
return fmt.Errorf("workflow %s step %q: cannot set both agent and uses", wfName, sid)
89-
}
90-
if !hasA && !hasU {
91-
return fmt.Errorf("workflow %s step %q: must set exactly one of agent or uses", wfName, sid)
92-
}
93-
if hasU {
94-
u := strings.TrimSpace(st.Uses)
95-
if _, ok := spec.ParseToolUses(u); !ok {
96-
return fmt.Errorf("workflow %s step %q: unsupported uses %q (expected tool.<name>...)", wfName, sid, u)
97-
}
98-
}
99-
}
100-
return nil
101-
}
102-
103-
func validateWorkflowStepOrder(wfName string, w *spec.WorkflowSpec) error {
104-
idToIdx := make(map[string]int)
105-
for i, st := range w.Steps {
106-
id := strings.TrimSpace(st.ID)
107-
if id == "" {
108-
continue
109-
}
110-
idToIdx[id] = i
111-
}
112-
for i, st := range w.Steps {
113-
sid := strings.TrimSpace(st.ID)
114-
for _, sval := range spec.CollectWithStringValues(st.With) {
115-
for _, dep := range spec.InterpolationStepRefs(sval) {
116-
j, ok := idToIdx[dep]
117-
if !ok {
118-
return fmt.Errorf("workflow %s step %q: interpolation references unknown step %q", wfName, sid, dep)
119-
}
120-
if j >= i {
121-
return fmt.Errorf("workflow %s step %q: forward reference to steps.%s (§9.4)", wfName, sid, dep)
122-
}
123-
}
124-
}
125-
}
126-
return nil
7+
return spec.ResolveReferences(g)
1278
}

internal/spec/doc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
// Package spec defines resource envelopes, MVP kind structs (§6–§7), YAML loading,
2-
// and project-level default application (§7.1) via NormalizeProjectGraph.
3-
// Reference resolution, environment overrides, and semantic validation are future work.
2+
// project-level defaults ([NormalizeProjectGraph]), reference resolution ([ResolveReferences]),
3+
// and graph validation ([ValidateProjectGraph], §9.1–§9.5 MVP subset).
44
package spec

internal/spec/refindex.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package spec
2+
3+
import "strings"
4+
5+
// RefIndex summarizes symbolic references between resources (issue #6, §9.1).
6+
type RefIndex struct {
7+
AgentTools map[string][]string
8+
AgentPolicies map[string]string
9+
WorkflowAgents map[string][]string
10+
WorkflowTools map[string][]string
11+
WorkflowPolicies map[string]string
12+
}
13+
14+
// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
15+
func BuildRefIndex(g *ProjectGraph) *RefIndex {
16+
if g == nil {
17+
return &RefIndex{
18+
AgentTools: map[string][]string{},
19+
AgentPolicies: map[string]string{},
20+
WorkflowAgents: map[string][]string{},
21+
WorkflowTools: map[string][]string{},
22+
WorkflowPolicies: map[string]string{},
23+
}
24+
}
25+
ix := &RefIndex{
26+
AgentTools: make(map[string][]string),
27+
AgentPolicies: make(map[string]string),
28+
WorkflowAgents: make(map[string][]string),
29+
WorkflowTools: make(map[string][]string),
30+
WorkflowPolicies: make(map[string]string),
31+
}
32+
for name, ar := range g.Agents {
33+
if ar == nil {
34+
continue
35+
}
36+
var tools []string
37+
for _, t := range ar.Spec.Tools {
38+
if s := strings.TrimSpace(t); s != "" {
39+
tools = append(tools, s)
40+
}
41+
}
42+
ix.AgentTools[name] = dedupeRefStrings(tools)
43+
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
44+
ix.AgentPolicies[name] = p
45+
}
46+
}
47+
for name, wr := range g.Workflows {
48+
if wr == nil {
49+
continue
50+
}
51+
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
52+
ix.WorkflowPolicies[name] = p
53+
}
54+
var agents, tools []string
55+
for _, st := range wr.Spec.Steps {
56+
if a := strings.TrimSpace(st.Agent); a != "" {
57+
agents = append(agents, a)
58+
}
59+
if u := strings.TrimSpace(st.Uses); u != "" {
60+
if tn, ok := ParseToolUses(u); ok {
61+
tools = append(tools, tn)
62+
}
63+
}
64+
}
65+
ix.WorkflowAgents[name] = dedupeRefStrings(agents)
66+
ix.WorkflowTools[name] = dedupeRefStrings(tools)
67+
}
68+
return ix
69+
}
70+
71+
func dedupeRefStrings(in []string) []string {
72+
if len(in) == 0 {
73+
return nil
74+
}
75+
seen := make(map[string]struct{}, len(in))
76+
var out []string
77+
for _, s := range in {
78+
if _, ok := seen[s]; ok {
79+
continue
80+
}
81+
seen[s] = struct{}{}
82+
out = append(out, s)
83+
}
84+
return out
85+
}

0 commit comments

Comments
 (0)