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
129 changes: 129 additions & 0 deletions internal/harness/forge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package harness

import (
"fmt"
"strings"
)

// ForgeConfig holds platform-specific harness configuration.
// This is purely declarative YAML config — it selects which
// scripts, skills, and env vars to use per platform. It is
// distinct from the forge.Client interface (internal/forge/),
// which is the runtime abstraction for forge API operations.
type ForgeConfig struct {
PreScript string `yaml:"pre_script,omitempty"`
PostScript string `yaml:"post_script,omitempty"`
Skills []string `yaml:"skills,omitempty"`
ValidationLoop *ValidationLoop `yaml:"validation_loop,omitempty"`
RunnerEnv map[string]string `yaml:"runner_env,omitempty"`
}

var validForgeKeys = map[string]bool{
"github": true,
"gitlab": true,
}

// validateForge checks that the forge section contains only recognized keys
// and that each ForgeConfig uses valid field values.
func (h *Harness) validateForge() error {
for key, fc := range h.Forge {
if !validForgeKeys[key] {
return fmt.Errorf("forge: unrecognized key %q (valid keys are: github, gitlab)", key)
}
if fc == nil {
continue
}
if fc.PreScript != "" && IsURL(fc.PreScript) {
return fmt.Errorf("forge.%s.pre_script must be a local path, not a URL", key)
}
if fc.PostScript != "" && IsURL(fc.PostScript) {
return fmt.Errorf("forge.%s.post_script must be a local path, not a URL", key)
}
for i, s := range fc.Skills {
if IsURL(s) {
if _, _, hasHash := ParseIntegrityHash(s); !hasHash {
return fmt.Errorf("forge.%s.skills[%d] URL must include #sha256=... integrity hash", key, i)
}
}
}
if fc.ValidationLoop != nil {
if fc.ValidationLoop.Script == "" {
return fmt.Errorf("forge.%s.validation_loop.script is required when validation_loop is set", key)
}
if IsURL(fc.ValidationLoop.Script) {
return fmt.Errorf("forge.%s.validation_loop.script must be a local path, not a URL", key)
}
}
}
return nil
}

// ResolveForge merges forge-specific overrides into the harness in place.
// After merging, h.Forge is set to nil (consumed). If platform is empty or
// h.Forge is nil, this is a no-op. If platform is not present in h.Forge,
// an error is returned.
//
// Pipeline ordering: ResolveForge runs between Unmarshal and Validate. It
// consumes h.Forge (sets it to nil), so validateForge — which validates the
// pre-merge forge map structure — must run before ResolveForge if both are
// called. In the planned pipeline (PR 3), LoadWithOpts calls ResolveForge
// then Validate; validateForge sees nil and is a no-op, which is correct
// because the forge map was already validated before merging.
func (h *Harness) ResolveForge(platform string) error {
if platform == "" || h.Forge == nil {
return nil
}
if !validForgeKeys[platform] {
return fmt.Errorf("forge platform %q is not valid (valid keys are: github, gitlab)", platform)
}
fc, ok := h.Forge[platform]
if !ok {
return fmt.Errorf("forge platform %q not configured (available: %s)", platform, forgeKeyList(h.Forge))
}
if fc != nil {
mergeForgeConfig(h, fc)
}
h.Forge = nil
return nil
}

// mergeForgeConfig applies forge-specific overrides to the harness.
//
// Merge rules per ADR-0045:
// - Scalars: forge overrides if non-empty
// - Skills: top-level + forge (concatenated)
// - RunnerEnv: top-level merged with forge; forge keys win
// - ValidationLoop: forge replaces entirely if non-nil
func mergeForgeConfig(h *Harness, fc *ForgeConfig) {
if fc.PreScript != "" {
h.PreScript = fc.PreScript
}
if fc.PostScript != "" {
h.PostScript = fc.PostScript
}

if fc.Skills != nil {
h.Skills = append(h.Skills, fc.Skills...)
}

if fc.RunnerEnv != nil {
if h.RunnerEnv == nil {
h.RunnerEnv = make(map[string]string, len(fc.RunnerEnv))
}
for k, v := range fc.RunnerEnv {
h.RunnerEnv[k] = v
}
}

if fc.ValidationLoop != nil {
h.ValidationLoop = fc.ValidationLoop
}
}

func forgeKeyList(m map[string]*ForgeConfig) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return strings.Join(keys, ", ")
}
Loading
Loading