diff --git a/agentci/clotho.go b/agentci/clotho.go
index 41ce261..a9ac28c 100644
--- a/agentci/clotho.go
+++ b/agentci/clotho.go
@@ -1,27 +1,34 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package agentci
import (
"context"
- "strings"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"dappco.re/go/core/scm/jobrunner"
)
// RunMode determines the execution strategy for a dispatched task.
+//
type RunMode string
const (
+ //
ModeStandard RunMode = "standard"
- ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification
+ //
+ ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification
)
// Spinner is the Clotho orchestrator that determines the fate of each task.
+//
type Spinner struct {
Config ClothoConfig
Agents map[string]AgentConfig
}
// NewSpinner creates a new Clotho orchestrator.
+//
func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner {
return &Spinner{
Config: cfg,
diff --git a/agentci/config.go b/agentci/config.go
index a7386b2..0f2485f 100644
--- a/agentci/config.go
+++ b/agentci/config.go
@@ -1,14 +1,17 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package agentci provides configuration, security, and orchestration for AgentCI dispatch targets.
package agentci
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/config"
coreerr "dappco.re/go/core/log"
+ "forge.lthn.ai/core/config"
)
// AgentConfig represents a single agent machine in the config file.
+//
type AgentConfig struct {
Host string `yaml:"host" mapstructure:"host"`
QueueDir string `yaml:"queue_dir" mapstructure:"queue_dir"`
@@ -23,6 +26,7 @@ type AgentConfig struct {
}
// ClothoConfig controls the orchestration strategy.
+//
type ClothoConfig struct {
Strategy string `yaml:"strategy" mapstructure:"strategy"` // direct, clotho-verified
ValidationThreshold float64 `yaml:"validation_threshold" mapstructure:"validation_threshold"` // divergence limit (0.0-1.0)
@@ -31,6 +35,7 @@ type ClothoConfig struct {
// LoadAgents reads agent targets from config and returns a map of AgentConfig.
// Returns an empty map (not an error) if no agents are configured.
+//
func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
var agents map[string]AgentConfig
if err := cfg.Get("agentci.agents", &agents); err != nil {
@@ -61,6 +66,7 @@ func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
}
// LoadActiveAgents returns only active agents.
+//
func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
all, err := LoadAgents(cfg)
if err != nil {
@@ -77,6 +83,7 @@ func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
// LoadClothoConfig loads the Clotho orchestrator settings.
// Returns sensible defaults if no config is present.
+//
func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) {
var cc ClothoConfig
if err := cfg.Get("agentci.clotho", &cc); err != nil {
@@ -95,6 +102,7 @@ func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) {
}
// SaveAgent writes an agent config entry to the config file.
+//
func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
key := fmt.Sprintf("agentci.agents.%s", name)
data := map[string]any{
@@ -123,6 +131,7 @@ func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
}
// RemoveAgent removes an agent from the config file.
+//
func RemoveAgent(cfg *config.Config, name string) error {
var agents map[string]AgentConfig
if err := cfg.Get("agentci.agents", &agents); err != nil {
@@ -136,6 +145,7 @@ func RemoveAgent(cfg *config.Config, name string) error {
}
// ListAgents returns all configured agents (active and inactive).
+//
func ListAgents(cfg *config.Config) (map[string]AgentConfig, error) {
var agents map[string]AgentConfig
if err := cfg.Get("agentci.agents", &agents); err != nil {
diff --git a/agentci/config_test.go b/agentci/config_test.go
index 034ff09..18c5db6 100644
--- a/agentci/config_test.go
+++ b/agentci/config_test.go
@@ -3,8 +3,8 @@ package agentci
import (
"testing"
- "forge.lthn.ai/core/config"
"dappco.re/go/core/io"
+ "forge.lthn.ai/core/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -299,7 +299,7 @@ func TestListAgents_Good_Empty(t *testing.T) {
assert.Empty(t, agents)
}
-func TestRoundTrip_SaveThenLoad(t *testing.T) {
+func TestRoundTrip_Good_SaveThenLoad(t *testing.T) {
cfg := newTestConfig(t, "")
err := SaveAgent(cfg, "alpha", AgentConfig{
diff --git a/agentci/security.go b/agentci/security.go
index 52c106c..a77de6d 100644
--- a/agentci/security.go
+++ b/agentci/security.go
@@ -1,36 +1,150 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package agentci
import (
- "os/exec"
- "path/filepath"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
+ "path"
"regexp"
- "strings"
coreerr "dappco.re/go/core/log"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
)
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
// SanitizePath ensures a filename or directory name is safe and prevents path traversal.
-// Returns filepath.Base of the input after validation.
+// Returns the validated input unchanged.
+//
func SanitizePath(input string) (string, error) {
- base := filepath.Base(input)
- if !safeNameRegex.MatchString(base) {
+ if input == "" {
+ return "", coreerr.E("agentci.SanitizePath", "path element is required", nil)
+ }
+ if strings.ContainsAny(input, `/\`) {
+ return "", coreerr.E("agentci.SanitizePath", "path separators are not allowed: "+input, nil)
+ }
+ if input == "." || input == ".." {
+ return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+input, nil)
+ }
+ if !safeNameRegex.MatchString(input) {
return "", coreerr.E("agentci.SanitizePath", "invalid characters in path element: "+input, nil)
}
- if base == "." || base == ".." || base == "/" {
- return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+base, nil)
+ return input, nil
+}
+
+// ValidatePathElement validates a single local path element and returns its safe form.
+//
+func ValidatePathElement(input string) (string, error) {
+ return SanitizePath(input)
+}
+
+// ResolvePathWithinRoot resolves a validated path element beneath a root directory.
+//
+func ResolvePathWithinRoot(root string, input string) (string, string, error) {
+ safeName, err := ValidatePathElement(input)
+ if err != nil {
+ return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "invalid path element", err)
}
- return base, nil
+
+ absRoot, err := filepath.Abs(root)
+ if err != nil {
+ return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolve root", err)
+ }
+
+ resolved := filepath.Clean(filepath.Join(absRoot, safeName))
+ cleanRoot := filepath.Clean(absRoot)
+ rootPrefix := cleanRoot + string(filepath.Separator)
+ if resolved != cleanRoot && !strings.HasPrefix(resolved, rootPrefix) {
+ return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolved path escaped root", nil)
+ }
+
+ return safeName, resolved, nil
+}
+
+// ValidateRemoteDir validates a remote directory path used over SSH.
+//
+func ValidateRemoteDir(dir string) (string, error) {
+ if strings.TrimSpace(dir) == "" {
+ return "", coreerr.E("agentci.ValidateRemoteDir", "directory is required", nil)
+ }
+ if strings.ContainsAny(dir, `\`) {
+ return "", coreerr.E("agentci.ValidateRemoteDir", "backslashes are not allowed", nil)
+ }
+
+ switch dir {
+ case "/", "~":
+ return dir, nil
+ }
+
+ cleaned := path.Clean(dir)
+ prefix := ""
+ rest := cleaned
+
+ if strings.HasPrefix(dir, "~/") {
+ prefix = "~/"
+ rest = strings.TrimPrefix(cleaned, "~/")
+ }
+ if strings.HasPrefix(dir, "/") {
+ prefix = "/"
+ rest = strings.TrimPrefix(cleaned, "/")
+ }
+
+ if rest == "." || rest == ".." || strings.HasPrefix(rest, "../") {
+ return "", coreerr.E("agentci.ValidateRemoteDir", "directory escaped root", nil)
+ }
+
+ for _, part := range strings.Split(rest, "/") {
+ if part == "" {
+ continue
+ }
+ if _, err := ValidatePathElement(part); err != nil {
+ return "", coreerr.E("agentci.ValidateRemoteDir", "invalid directory segment", err)
+ }
+ }
+
+ if rest == "" || rest == "." {
+ return prefix, nil
+ }
+
+ return prefix + rest, nil
+}
+
+// JoinRemotePath joins validated remote path elements using forward slashes.
+//
+func JoinRemotePath(base string, parts ...string) (string, error) {
+ safeBase, err := ValidateRemoteDir(base)
+ if err != nil {
+ return "", coreerr.E("agentci.JoinRemotePath", "invalid base directory", err)
+ }
+
+ cleanParts := make([]string, 0, len(parts))
+ for _, part := range parts {
+ safePart, partErr := ValidatePathElement(part)
+ if partErr != nil {
+ return "", coreerr.E("agentci.JoinRemotePath", "invalid path element", partErr)
+ }
+ cleanParts = append(cleanParts, safePart)
+ }
+
+ if safeBase == "~" {
+ return path.Join("~", path.Join(cleanParts...)), nil
+ }
+ if strings.HasPrefix(safeBase, "~/") {
+ return "~/" + path.Join(strings.TrimPrefix(safeBase, "~/"), path.Join(cleanParts...)), nil
+ }
+ return path.Join(append([]string{safeBase}, cleanParts...)...), nil
}
// EscapeShellArg wraps a string in single quotes for safe remote shell insertion.
// Prefer exec.Command arguments over constructing shell strings where possible.
+//
func EscapeShellArg(arg string) string {
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode.
+//
func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
return exec.Command("ssh",
"-o", "StrictHostKeyChecking=yes",
@@ -42,6 +156,7 @@ func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
}
// MaskToken returns a masked version of a token for safe logging.
+//
func MaskToken(token string) string {
if len(token) < 8 {
return "*****"
diff --git a/agentci/security_test.go b/agentci/security_test.go
index 6d0b68e..1167e50 100644
--- a/agentci/security_test.go
+++ b/agentci/security_test.go
@@ -20,7 +20,6 @@ func TestSanitizePath_Good(t *testing.T) {
{"with.dot", "with.dot"},
{"CamelCase", "CamelCase"},
{"123", "123"},
- {"path/to/file.txt", "file.txt"},
}
for _, tt := range tests {
@@ -44,8 +43,11 @@ func TestSanitizePath_Bad(t *testing.T) {
{"pipe", "file|name"},
{"ampersand", "file&name"},
{"dollar", "file$name"},
+ {"slash", "path/to/file.txt"},
+ {"backslash", `path\to\file.txt`},
{"parent traversal base", ".."},
{"root", "/"},
+ {"empty", ""},
}
for _, tt := range tests {
diff --git a/cmd/collect/cmd.go b/cmd/collect/cmd.go
index 8f7e43c..0725107 100644
--- a/cmd/collect/cmd.go
+++ b/cmd/collect/cmd.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
"dappco.re/go/core/io"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
func init() {
@@ -28,6 +30,7 @@ var (
)
// AddCollectCommands registers the 'collect' command and all subcommands.
+//
func AddCollectCommands(root *cli.Command) {
collectCmd := &cli.Command{
Use: "collect",
diff --git a/cmd/collect/cmd_bitcointalk.go b/cmd/collect/cmd_bitcointalk.go
index 0bab644..3059f3d 100644
--- a/cmd/collect/cmd_bitcointalk.go
+++ b/cmd/collect/cmd_bitcointalk.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "strings"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// BitcoinTalk command flags
diff --git a/cmd/collect/cmd_dispatch.go b/cmd/collect/cmd_dispatch.go
index 9467e7f..6a790d4 100644
--- a/cmd/collect/cmd_dispatch.go
+++ b/cmd/collect/cmd_dispatch.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
- "forge.lthn.ai/core/cli/pkg/cli"
- collectpkg "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ collectpkg "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
diff --git a/cmd/collect/cmd_excavate.go b/cmd/collect/cmd_excavate.go
index a9bf038..870b558 100644
--- a/cmd/collect/cmd_excavate.go
+++ b/cmd/collect/cmd_excavate.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Excavate command flags
diff --git a/cmd/collect/cmd_github.go b/cmd/collect/cmd_github.go
index 22c1f7d..6ab8522 100644
--- a/cmd/collect/cmd_github.go
+++ b/cmd/collect/cmd_github.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "strings"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// GitHub command flags
diff --git a/cmd/collect/cmd_market.go b/cmd/collect/cmd_market.go
index 9a8fd47..1605eeb 100644
--- a/cmd/collect/cmd_market.go
+++ b/cmd/collect/cmd_market.go
@@ -1,11 +1,13 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Market command flags
diff --git a/cmd/collect/cmd_papers.go b/cmd/collect/cmd_papers.go
index ef270ba..fb1b76e 100644
--- a/cmd/collect/cmd_papers.go
+++ b/cmd/collect/cmd_papers.go
@@ -1,11 +1,13 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Papers command flags
diff --git a/cmd/collect/cmd_process.go b/cmd/collect/cmd_process.go
index 8ac116f..ab53dcb 100644
--- a/cmd/collect/cmd_process.go
+++ b/cmd/collect/cmd_process.go
@@ -1,11 +1,13 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "forge.lthn.ai/core/cli/pkg/cli"
- "dappco.re/go/core/scm/collect"
"dappco.re/go/core/i18n"
+ "dappco.re/go/core/scm/collect"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// addProcessCommand adds the 'process' subcommand to the collect parent.
diff --git a/cmd/forge/cmd_auth.go b/cmd/forge/cmd_auth.go
index a707bf5..ffb62c3 100644
--- a/cmd/forge/cmd_auth.go
+++ b/cmd/forge/cmd_auth.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Auth command flags.
diff --git a/cmd/forge/cmd_config.go b/cmd/forge/cmd_config.go
index 94c5679..1347888 100644
--- a/cmd/forge/cmd_config.go
+++ b/cmd/forge/cmd_config.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Config command flags.
diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go
index 65e0440..654915d 100644
--- a/cmd/forge/cmd_forge.go
+++ b/cmd/forge/cmd_forge.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package forge provides CLI commands for managing a Forgejo instance.
//
// Commands:
@@ -33,6 +35,7 @@ var (
)
// AddForgeCommands registers the 'forge' command and all subcommands.
+//
func AddForgeCommands(root *cli.Command) {
forgeCmd := &cli.Command{
Use: "forge",
diff --git a/cmd/forge/cmd_issues.go b/cmd/forge/cmd_issues.go
index 108d237..85ae4cd 100644
--- a/cmd/forge/cmd_issues.go
+++ b/cmd/forge/cmd_issues.go
@@ -1,13 +1,15 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Issues command flags.
diff --git a/cmd/forge/cmd_labels.go b/cmd/forge/cmd_labels.go
index 745ab61..ac2e90e 100644
--- a/cmd/forge/cmd_labels.go
+++ b/cmd/forge/cmd_labels.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Labels command flags.
diff --git a/cmd/forge/cmd_migrate.go b/cmd/forge/cmd_migrate.go
index 8f8a8bb..2d6cf64 100644
--- a/cmd/forge/cmd_migrate.go
+++ b/cmd/forge/cmd_migrate.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Migrate command flags.
diff --git a/cmd/forge/cmd_orgs.go b/cmd/forge/cmd_orgs.go
index 27389a3..c119dce 100644
--- a/cmd/forge/cmd_orgs.go
+++ b/cmd/forge/cmd_orgs.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// addOrgsCommand adds the 'orgs' subcommand for listing organisations.
diff --git a/cmd/forge/cmd_prs.go b/cmd/forge/cmd_prs.go
index e32db77..9b5811f 100644
--- a/cmd/forge/cmd_prs.go
+++ b/cmd/forge/cmd_prs.go
@@ -1,13 +1,15 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// PRs command flags.
diff --git a/cmd/forge/cmd_repos.go b/cmd/forge/cmd_repos.go
index 71cb2a9..d9f84c7 100644
--- a/cmd/forge/cmd_repos.go
+++ b/cmd/forge/cmd_repos.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Repos command flags.
diff --git a/cmd/forge/cmd_status.go b/cmd/forge/cmd_status.go
index 4777894..34eeb7d 100644
--- a/cmd/forge/cmd_status.go
+++ b/cmd/forge/cmd_status.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
fg "dappco.re/go/core/scm/forge"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// addStatusCommand adds the 'status' subcommand for instance info.
diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go
index 390d8c9..d00345c 100644
--- a/cmd/forge/cmd_sync.go
+++ b/cmd/forge/cmd_sync.go
@@ -1,17 +1,21 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
+ "net/url"
+
+ coreerr "dappco.re/go/core/log"
+ "dappco.re/go/core/scm/agentci"
+ fg "dappco.re/go/core/scm/forge"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
-
"forge.lthn.ai/core/cli/pkg/cli"
- coreerr "dappco.re/go/core/log"
- fg "dappco.re/go/core/scm/forge"
)
// Sync command flags.
@@ -95,11 +99,14 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn
if len(args) > 0 {
for _, arg := range args {
- name := arg
- if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
- name = parts[1]
+ name, err := syncRepoNameFromArg(arg)
+ if err != nil {
+ return nil, coreerr.E("forge.buildSyncRepoList", "invalid repo argument", err)
+ }
+ _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name)
+ if err != nil {
+ return nil, coreerr.E("forge.buildSyncRepoList", "resolve local path", err)
}
- localPath := filepath.Join(basePath, name)
branch := syncDetectDefaultBranch(localPath)
repos = append(repos, syncRepoEntry{
name: name,
@@ -113,10 +120,17 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn
return nil, err
}
for _, r := range orgRepos {
- localPath := filepath.Join(basePath, r.Name)
+ name, err := agentci.ValidatePathElement(r.Name)
+ if err != nil {
+ return nil, coreerr.E("forge.buildSyncRepoList", "invalid repo name from org list", err)
+ }
+ _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name)
+ if err != nil {
+ return nil, coreerr.E("forge.buildSyncRepoList", "resolve local path", err)
+ }
branch := syncDetectDefaultBranch(localPath)
repos = append(repos, syncRepoEntry{
- name: r.Name,
+ name: name,
localPath: localPath,
defaultBranch: branch,
})
@@ -333,3 +347,27 @@ func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error {
return nil
}
+
+func syncRepoNameFromArg(arg string) (string, error) {
+ decoded, err := url.PathUnescape(arg)
+ if err != nil {
+ return "", coreerr.E("forge.syncRepoNameFromArg", "decode repo argument", err)
+ }
+
+ parts := strings.Split(decoded, "/")
+ switch len(parts) {
+ case 1:
+ return agentci.ValidatePathElement(parts[0])
+ case 2:
+ if _, err := agentci.ValidatePathElement(parts[0]); err != nil {
+ return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo owner", err)
+ }
+ name, err := agentci.ValidatePathElement(parts[1])
+ if err != nil {
+ return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo name", err)
+ }
+ return name, nil
+ default:
+ return "", coreerr.E("forge.syncRepoNameFromArg", "repo argument must be repo or owner/repo", nil)
+ }
+}
diff --git a/cmd/forge/cmd_sync_test.go b/cmd/forge/cmd_sync_test.go
new file mode 100644
index 0000000..d079c7c
--- /dev/null
+++ b/cmd/forge/cmd_sync_test.go
@@ -0,0 +1,53 @@
+package forge
+
+import (
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildSyncRepoList_Good(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ repos, err := buildSyncRepoList(nil, []string{"host-uk/core"}, basePath)
+ require.NoError(t, err)
+ require.Len(t, repos, 1)
+ assert.Equal(t, "core", repos[0].name)
+ assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath)
+}
+
+func TestBuildSyncRepoList_Bad_PathTraversal(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildSyncRepoList(nil, []string{"../escape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
+
+func TestBuildSyncRepoList_Good_OwnerRepo(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ repos, err := buildSyncRepoList(nil, []string{"Host-UK/core"}, basePath)
+ require.NoError(t, err)
+ require.Len(t, repos, 1)
+ assert.Equal(t, "core", repos[0].name)
+ assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath)
+}
+
+func TestBuildSyncRepoList_Bad_PathTraversal_OwnerRepo(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildSyncRepoList(nil, []string{"host-uk/../escape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
+
+func TestBuildSyncRepoList_Bad_PathTraversal_OwnerRepoEncoded(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildSyncRepoList(nil, []string{"host-uk%2F..%2Fescape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
diff --git a/cmd/forge/helpers.go b/cmd/forge/helpers.go
index eec2d68..afc8a40 100644
--- a/cmd/forge/helpers.go
+++ b/cmd/forge/helpers.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"path"
- "strings"
"forge.lthn.ai/core/cli/pkg/cli"
)
diff --git a/cmd/gitea/cmd_config.go b/cmd/gitea/cmd_config.go
index 69f07f3..8d21ebf 100644
--- a/cmd/gitea/cmd_config.go
+++ b/cmd/gitea/cmd_config.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
gt "dappco.re/go/core/scm/gitea"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Config command flags.
diff --git a/cmd/gitea/cmd_gitea.go b/cmd/gitea/cmd_gitea.go
index 9268653..caa86d0 100644
--- a/cmd/gitea/cmd_gitea.go
+++ b/cmd/gitea/cmd_gitea.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package gitea provides CLI commands for managing a Gitea instance.
//
// Commands:
@@ -30,6 +32,7 @@ var (
)
// AddGiteaCommands registers the 'gitea' command and all subcommands.
+//
func AddGiteaCommands(root *cli.Command) {
giteaCmd := &cli.Command{
Use: "gitea",
diff --git a/cmd/gitea/cmd_issues.go b/cmd/gitea/cmd_issues.go
index a4fd5e2..984d8ea 100644
--- a/cmd/gitea/cmd_issues.go
+++ b/cmd/gitea/cmd_issues.go
@@ -1,13 +1,15 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"code.gitea.io/sdk/gitea"
- "forge.lthn.ai/core/cli/pkg/cli"
gt "dappco.re/go/core/scm/gitea"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Issues command flags.
diff --git a/cmd/gitea/cmd_mirror.go b/cmd/gitea/cmd_mirror.go
index e108686..40bce5a 100644
--- a/cmd/gitea/cmd_mirror.go
+++ b/cmd/gitea/cmd_mirror.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
- "os/exec"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
- "forge.lthn.ai/core/cli/pkg/cli"
gt "dappco.re/go/core/scm/gitea"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Mirror command flags.
diff --git a/cmd/gitea/cmd_prs.go b/cmd/gitea/cmd_prs.go
index 00e059e..226f3a2 100644
--- a/cmd/gitea/cmd_prs.go
+++ b/cmd/gitea/cmd_prs.go
@@ -1,13 +1,15 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
sdk "code.gitea.io/sdk/gitea"
- "forge.lthn.ai/core/cli/pkg/cli"
gt "dappco.re/go/core/scm/gitea"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// PRs command flags.
diff --git a/cmd/gitea/cmd_repos.go b/cmd/gitea/cmd_repos.go
index 892bdfe..ce0a324 100644
--- a/cmd/gitea/cmd_repos.go
+++ b/cmd/gitea/cmd_repos.go
@@ -1,10 +1,12 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
gt "dappco.re/go/core/scm/gitea"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
// Repos command flags.
diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go
index b9b4c8f..cdcfa4b 100644
--- a/cmd/gitea/cmd_sync.go
+++ b/cmd/gitea/cmd_sync.go
@@ -1,17 +1,21 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
+ "net/url"
+
+ coreerr "dappco.re/go/core/log"
+ "dappco.re/go/core/scm/agentci"
+ gt "dappco.re/go/core/scm/gitea"
"code.gitea.io/sdk/gitea"
-
"forge.lthn.ai/core/cli/pkg/cli"
- coreerr "dappco.re/go/core/log"
- gt "dappco.re/go/core/scm/gitea"
)
// Sync command flags.
@@ -96,12 +100,14 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt
if len(args) > 0 {
// Specific repos from args
for _, arg := range args {
- name := arg
- // Strip owner/ prefix if given
- if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
- name = parts[1]
+ name, err := repoNameFromArg(arg)
+ if err != nil {
+ return nil, coreerr.E("gitea.buildRepoList", "invalid repo argument", err)
+ }
+ _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name)
+ if err != nil {
+ return nil, coreerr.E("gitea.buildRepoList", "resolve local path", err)
}
- localPath := filepath.Join(basePath, name)
branch := detectDefaultBranch(localPath)
repos = append(repos, repoEntry{
name: name,
@@ -116,10 +122,17 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt
return nil, err
}
for _, r := range orgRepos {
- localPath := filepath.Join(basePath, r.Name)
+ name, err := agentci.ValidatePathElement(r.Name)
+ if err != nil {
+ return nil, coreerr.E("gitea.buildRepoList", "invalid repo name from org list", err)
+ }
+ _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name)
+ if err != nil {
+ return nil, coreerr.E("gitea.buildRepoList", "resolve local path", err)
+ }
branch := detectDefaultBranch(localPath)
repos = append(repos, repoEntry{
- name: r.Name,
+ name: name,
localPath: localPath,
defaultBranch: branch,
})
@@ -352,3 +365,27 @@ func createMainFromUpstream(client *gt.Client, org, repo string) error {
}
func strPtr(s string) *string { return &s }
+
+func repoNameFromArg(arg string) (string, error) {
+ decoded, err := url.PathUnescape(arg)
+ if err != nil {
+ return "", coreerr.E("gitea.repoNameFromArg", "decode repo argument", err)
+ }
+
+ parts := strings.Split(decoded, "/")
+ switch len(parts) {
+ case 1:
+ return agentci.ValidatePathElement(parts[0])
+ case 2:
+ if _, err := agentci.ValidatePathElement(parts[0]); err != nil {
+ return "", coreerr.E("gitea.repoNameFromArg", "invalid repo owner", err)
+ }
+ name, err := agentci.ValidatePathElement(parts[1])
+ if err != nil {
+ return "", coreerr.E("gitea.repoNameFromArg", "invalid repo name", err)
+ }
+ return name, nil
+ default:
+ return "", coreerr.E("gitea.repoNameFromArg", "repo argument must be repo or owner/repo", nil)
+ }
+}
diff --git a/cmd/gitea/cmd_sync_test.go b/cmd/gitea/cmd_sync_test.go
new file mode 100644
index 0000000..a8269bc
--- /dev/null
+++ b/cmd/gitea/cmd_sync_test.go
@@ -0,0 +1,53 @@
+package gitea
+
+import (
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildRepoList_Good(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ repos, err := buildRepoList(nil, []string{"host-uk/core"}, basePath)
+ require.NoError(t, err)
+ require.Len(t, repos, 1)
+ assert.Equal(t, "core", repos[0].name)
+ assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath)
+}
+
+func TestBuildRepoList_Bad_PathTraversal(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildRepoList(nil, []string{"../escape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
+
+func TestBuildRepoList_Good_OwnerRepo(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ repos, err := buildRepoList(nil, []string{"Host-UK/core"}, basePath)
+ require.NoError(t, err)
+ require.Len(t, repos, 1)
+ assert.Equal(t, "core", repos[0].name)
+ assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath)
+}
+
+func TestBuildRepoList_Bad_PathTraversal_OwnerRepo(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildRepoList(nil, []string{"host-uk/../escape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
+
+func TestBuildRepoList_Bad_PathTraversal_OwnerRepoEncoded(t *testing.T) {
+ basePath := filepath.Join(t.TempDir(), "repos")
+
+ _, err := buildRepoList(nil, []string{"host-uk%2F..%2Fescape"}, basePath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo argument")
+}
diff --git a/cmd/scm/cmd_compile.go b/cmd/scm/cmd_compile.go
index e5cce30..4f5c8eb 100644
--- a/cmd/scm/cmd_compile.go
+++ b/cmd/scm/cmd_compile.go
@@ -1,14 +1,16 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package scm
import (
"crypto/ed25519"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"encoding/hex"
- "os/exec"
- "strings"
+ exec "golang.org/x/sys/execabs"
- "forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/io"
"dappco.re/go/core/scm/manifest"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
func addCompileCommand(parent *cli.Command) {
diff --git a/cmd/scm/cmd_export.go b/cmd/scm/cmd_export.go
index 79da671..fcc8089 100644
--- a/cmd/scm/cmd_export.go
+++ b/cmd/scm/cmd_export.go
@@ -1,11 +1,13 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package scm
import (
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/io"
"dappco.re/go/core/scm/manifest"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
func addExportCommand(parent *cli.Command) {
diff --git a/cmd/scm/cmd_index.go b/cmd/scm/cmd_index.go
index dd8784b..d6ff3a4 100644
--- a/cmd/scm/cmd_index.go
+++ b/cmd/scm/cmd_index.go
@@ -1,11 +1,13 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package scm
import (
- "fmt"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- "forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core/scm/marketplace"
+ "forge.lthn.ai/core/cli/pkg/cli"
)
func addIndexCommand(parent *cli.Command) {
diff --git a/cmd/scm/cmd_scm.go b/cmd/scm/cmd_scm.go
index 4a7ad1b..9564d15 100644
--- a/cmd/scm/cmd_scm.go
+++ b/cmd/scm/cmd_scm.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package scm provides CLI commands for manifest compilation and marketplace
// index generation.
//
@@ -25,6 +27,7 @@ var (
)
// AddScmCommands registers the 'scm' command and all subcommands.
+//
func AddScmCommands(root *cli.Command) {
scmCmd := &cli.Command{
Use: "scm",
diff --git a/collect/bitcointalk.go b/collect/bitcointalk.go
index 8c189d6..7d76eca 100644
--- a/collect/bitcointalk.go
+++ b/collect/bitcointalk.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "fmt"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"iter"
"net/http"
- "path/filepath"
- "strings"
"time"
core "dappco.re/go/core/log"
@@ -20,6 +22,7 @@ var httpClient = &http.Client{
}
// BitcoinTalkCollector collects forum posts from BitcoinTalk.
+//
type BitcoinTalkCollector struct {
// TopicID is the numeric topic identifier.
TopicID string
@@ -281,6 +284,7 @@ func formatPostMarkdown(num int, post btPost) string {
// ParsePostsFromHTML parses BitcoinTalk posts from raw HTML content.
// This is exported for testing purposes.
+//
func ParsePostsFromHTML(htmlContent string) ([]btPost, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
@@ -290,14 +294,17 @@ func ParsePostsFromHTML(htmlContent string) ([]btPost, error) {
}
// FormatPostMarkdown is exported for testing purposes.
+//
func FormatPostMarkdown(num int, author, date, content string) string {
return formatPostMarkdown(num, btPost{Author: author, Date: date, Content: content})
}
// FetchPageFunc is an injectable function type for fetching pages, used in testing.
+//
type FetchPageFunc func(ctx context.Context, url string) ([]btPost, error)
// BitcoinTalkCollectorWithFetcher wraps BitcoinTalkCollector with a custom fetcher for testing.
+//
type BitcoinTalkCollectorWithFetcher struct {
BitcoinTalkCollector
Fetcher FetchPageFunc
@@ -305,6 +312,7 @@ type BitcoinTalkCollectorWithFetcher struct {
// SetHTTPClient replaces the package-level HTTP client.
// Use this in tests to inject a custom transport or timeout.
+//
func SetHTTPClient(c *http.Client) {
httpClient = c
}
diff --git a/collect/bitcointalk_http_test.go b/collect/bitcointalk_http_test.go
index 61a7a08..6b202cd 100644
--- a/collect/bitcointalk_http_test.go
+++ b/collect/bitcointalk_http_test.go
@@ -2,10 +2,10 @@ package collect
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"dappco.re/go/core/io"
diff --git a/collect/collect.go b/collect/collect.go
index d1acd04..8b37ac3 100644
--- a/collect/collect.go
+++ b/collect/collect.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package collect provides a data collection subsystem for gathering information
// from multiple sources including GitHub, BitcoinTalk, CoinGecko, and academic
// paper repositories. It supports rate limiting, incremental state tracking,
@@ -6,12 +8,13 @@ package collect
import (
"context"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
"dappco.re/go/core/io"
)
// Collector is the interface all collection sources implement.
+//
type Collector interface {
// Name returns a human-readable name for this collector.
Name() string
@@ -21,6 +24,7 @@ type Collector interface {
}
// Config holds shared configuration for all collectors.
+//
type Config struct {
// Output is the storage medium for writing collected data.
Output io.Medium
@@ -45,6 +49,7 @@ type Config struct {
}
// Result holds the output of a collection run.
+//
type Result struct {
// Source identifies which collector produced this result.
Source string
@@ -65,6 +70,7 @@ type Result struct {
// NewConfig creates a Config with sensible defaults.
// It initialises a MockMedium for output if none is provided,
// sets up a rate limiter, state tracker, and event dispatcher.
+//
func NewConfig(outputDir string) *Config {
m := io.NewMockMedium()
return &Config{
@@ -77,6 +83,7 @@ func NewConfig(outputDir string) *Config {
}
// NewConfigWithMedium creates a Config using the specified storage medium.
+//
func NewConfigWithMedium(m io.Medium, outputDir string) *Config {
return &Config{
Output: m,
@@ -88,6 +95,7 @@ func NewConfigWithMedium(m io.Medium, outputDir string) *Config {
}
// MergeResults combines multiple results into a single aggregated result.
+//
func MergeResults(source string, results ...*Result) *Result {
merged := &Result{Source: source}
for _, r := range results {
diff --git a/collect/coverage_boost_test.go b/collect/coverage_boost_test.go
index 70bae86..1e73324 100644
--- a/collect/coverage_boost_test.go
+++ b/collect/coverage_boost_test.go
@@ -2,7 +2,7 @@ package collect
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/collect/coverage_phase2_test.go b/collect/coverage_phase2_test.go
index 68b5469..b55db04 100644
--- a/collect/coverage_phase2_test.go
+++ b/collect/coverage_phase2_test.go
@@ -2,13 +2,14 @@ package collect
import (
"context"
- "encoding/json"
- "fmt"
+ core "dappco.re/go/core"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
goio "io"
"io/fs"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"time"
@@ -17,6 +18,14 @@ import (
"github.com/stretchr/testify/require"
)
+func testErr(msg string) error {
+ return core.E("collect.test", msg, nil)
+}
+
+func testErrf(format string, args ...any) error {
+ return core.E("collect.test", fmt.Sprintf(format, args...), nil)
+}
+
// errorMedium wraps MockMedium and injects errors on specific operations.
type errorMedium struct {
*io.MockMedium
@@ -50,16 +59,18 @@ func (e *errorMedium) Read(path string) (string, error) {
}
return e.MockMedium.Read(path)
}
-func (e *errorMedium) FileGet(path string) (string, error) { return e.MockMedium.FileGet(path) }
-func (e *errorMedium) FileSet(path, content string) error { return e.MockMedium.FileSet(path, content) }
-func (e *errorMedium) Delete(path string) error { return e.MockMedium.Delete(path) }
-func (e *errorMedium) DeleteAll(path string) error { return e.MockMedium.DeleteAll(path) }
-func (e *errorMedium) Rename(old, new string) error { return e.MockMedium.Rename(old, new) }
-func (e *errorMedium) Stat(path string) (fs.FileInfo, error) { return e.MockMedium.Stat(path) }
-func (e *errorMedium) Open(path string) (fs.File, error) { return e.MockMedium.Open(path) }
-func (e *errorMedium) Create(path string) (goio.WriteCloser, error) { return e.MockMedium.Create(path) }
-func (e *errorMedium) Append(path string) (goio.WriteCloser, error) { return e.MockMedium.Append(path) }
-func (e *errorMedium) ReadStream(path string) (goio.ReadCloser, error) { return e.MockMedium.ReadStream(path) }
+func (e *errorMedium) FileGet(path string) (string, error) { return e.MockMedium.FileGet(path) }
+func (e *errorMedium) FileSet(path, content string) error { return e.MockMedium.FileSet(path, content) }
+func (e *errorMedium) Delete(path string) error { return e.MockMedium.Delete(path) }
+func (e *errorMedium) DeleteAll(path string) error { return e.MockMedium.DeleteAll(path) }
+func (e *errorMedium) Rename(old, new string) error { return e.MockMedium.Rename(old, new) }
+func (e *errorMedium) Stat(path string) (fs.FileInfo, error) { return e.MockMedium.Stat(path) }
+func (e *errorMedium) Open(path string) (fs.File, error) { return e.MockMedium.Open(path) }
+func (e *errorMedium) Create(path string) (goio.WriteCloser, error) { return e.MockMedium.Create(path) }
+func (e *errorMedium) Append(path string) (goio.WriteCloser, error) { return e.MockMedium.Append(path) }
+func (e *errorMedium) ReadStream(path string) (goio.ReadCloser, error) {
+ return e.MockMedium.ReadStream(path)
+}
func (e *errorMedium) WriteStream(path string) (goio.WriteCloser, error) {
return e.MockMedium.WriteStream(path)
}
@@ -74,7 +85,7 @@ type errorLimiterWaiter struct{}
// --- Processor: list error ---
func TestProcessor_Process_Bad_ListError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), listErr: fmt.Errorf("list denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), listErr: testErr("list denied")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
p := &Processor{Source: "test", Dir: "/input"}
@@ -86,7 +97,7 @@ func TestProcessor_Process_Bad_ListError(t *testing.T) {
// --- Processor: ensureDir error ---
func TestProcessor_Process_Bad_EnsureDirError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")}
// Need to ensure List returns entries
em.MockMedium.Dirs["/input"] = true
em.MockMedium.Files["/input/test.html"] = "
Test
"
@@ -121,7 +132,7 @@ func TestProcessor_Process_Bad_ContextCancelledDuringLoop(t *testing.T) {
// --- Processor: read error during file processing ---
func TestProcessor_Process_Bad_ReadError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: fmt.Errorf("read denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: testErr("read denied")}
em.MockMedium.Dirs["/input"] = true
em.MockMedium.Files["/input/test.html"] = "Test
"
@@ -154,7 +165,7 @@ func TestProcessor_Process_Bad_InvalidJSONFile(t *testing.T) {
// --- Processor: write error during output ---
func TestProcessor_Process_Bad_WriteError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
em.MockMedium.Dirs["/input"] = true
em.MockMedium.Files["/input/page.html"] = "Title
"
@@ -255,13 +266,13 @@ func TestPapersCollector_CollectIACR_Bad_WriteError(t *testing.T) {
httpClient = &http.Client{Transport: transport}
defer func() { httpClient = old }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
p := &PapersCollector{Source: PaperSourceIACR, Query: "test"}
result, err := p.Collect(context.Background(), cfg)
- require.NoError(t, err) // Write errors increment Errors, not returned
+ require.NoError(t, err) // Write errors increment Errors, not returned
assert.Equal(t, 2, result.Errors) // 2 papers both fail to write
}
@@ -279,7 +290,7 @@ func TestPapersCollector_CollectIACR_Bad_EnsureDirError(t *testing.T) {
httpClient = &http.Client{Transport: transport}
defer func() { httpClient = old }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -303,7 +314,7 @@ func TestPapersCollector_CollectArXiv_Bad_WriteError(t *testing.T) {
httpClient = &http.Client{Transport: transport}
defer func() { httpClient = old }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -327,7 +338,7 @@ func TestPapersCollector_CollectArXiv_Bad_EnsureDirError(t *testing.T) {
httpClient = &http.Client{Transport: transport}
defer func() { httpClient = old }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -453,7 +464,7 @@ func TestMarketCollector_Collect_Bad_WriteError(t *testing.T) {
coinGeckoBaseURL = server.URL
defer func() { coinGeckoBaseURL = oldURL }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -477,7 +488,7 @@ func TestMarketCollector_Collect_Bad_EnsureDirError(t *testing.T) {
coinGeckoBaseURL = server.URL
defer func() { coinGeckoBaseURL = oldURL }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -552,7 +563,7 @@ func TestMarketCollector_Collect_Good_HistoricalCustomDate(t *testing.T) {
// --- BitcoinTalk: EnsureDir error ---
func TestBitcoinTalkCollector_Collect_Bad_EnsureDirError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
@@ -592,13 +603,13 @@ func TestBitcoinTalkCollector_Collect_Bad_WriteErrorOnPosts(t *testing.T) {
httpClient = &http.Client{Transport: transport}
defer func() { httpClient = old }()
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
cfg.Limiter = nil
b := &BitcoinTalkCollector{TopicID: "12345"}
result, err := b.Collect(context.Background(), cfg)
- require.NoError(t, err) // write errors are counted
+ require.NoError(t, err) // write errors are counted
assert.Equal(t, 3, result.Errors) // 3 posts all fail to write
assert.Equal(t, 0, result.Items)
}
@@ -968,34 +979,44 @@ func TestBitcoinTalkCollector_Collect_Bad_LimiterBlocks(t *testing.T) {
// writeCountMedium fails after N successful writes.
type writeCountMedium struct {
*io.MockMedium
- writeCount int
- failAfterN int
+ writeCount int
+ failAfterN int
}
func (w *writeCountMedium) Write(path, content string) error {
w.writeCount++
if w.writeCount > w.failAfterN {
- return fmt.Errorf("write %d: disk full", w.writeCount)
+ return testErrf("write %d: disk full", w.writeCount)
}
return w.MockMedium.Write(path, content)
}
-func (w *writeCountMedium) EnsureDir(path string) error { return w.MockMedium.EnsureDir(path) }
-func (w *writeCountMedium) Read(path string) (string, error) { return w.MockMedium.Read(path) }
-func (w *writeCountMedium) List(path string) ([]fs.DirEntry, error) { return w.MockMedium.List(path) }
-func (w *writeCountMedium) IsFile(path string) bool { return w.MockMedium.IsFile(path) }
-func (w *writeCountMedium) FileGet(path string) (string, error) { return w.MockMedium.FileGet(path) }
-func (w *writeCountMedium) FileSet(path, content string) error { return w.MockMedium.FileSet(path, content) }
-func (w *writeCountMedium) Delete(path string) error { return w.MockMedium.Delete(path) }
-func (w *writeCountMedium) DeleteAll(path string) error { return w.MockMedium.DeleteAll(path) }
-func (w *writeCountMedium) Rename(old, new string) error { return w.MockMedium.Rename(old, new) }
-func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) }
-func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) }
-func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) { return w.MockMedium.Create(path) }
-func (w *writeCountMedium) Append(path string) (goio.WriteCloser, error) { return w.MockMedium.Append(path) }
-func (w *writeCountMedium) ReadStream(path string) (goio.ReadCloser, error) { return w.MockMedium.ReadStream(path) }
-func (w *writeCountMedium) WriteStream(path string) (goio.WriteCloser, error) { return w.MockMedium.WriteStream(path) }
-func (w *writeCountMedium) Exists(path string) bool { return w.MockMedium.Exists(path) }
-func (w *writeCountMedium) IsDir(path string) bool { return w.MockMedium.IsDir(path) }
+func (w *writeCountMedium) EnsureDir(path string) error { return w.MockMedium.EnsureDir(path) }
+func (w *writeCountMedium) Read(path string) (string, error) { return w.MockMedium.Read(path) }
+func (w *writeCountMedium) List(path string) ([]fs.DirEntry, error) { return w.MockMedium.List(path) }
+func (w *writeCountMedium) IsFile(path string) bool { return w.MockMedium.IsFile(path) }
+func (w *writeCountMedium) FileGet(path string) (string, error) { return w.MockMedium.FileGet(path) }
+func (w *writeCountMedium) FileSet(path, content string) error {
+ return w.MockMedium.FileSet(path, content)
+}
+func (w *writeCountMedium) Delete(path string) error { return w.MockMedium.Delete(path) }
+func (w *writeCountMedium) DeleteAll(path string) error { return w.MockMedium.DeleteAll(path) }
+func (w *writeCountMedium) Rename(old, new string) error { return w.MockMedium.Rename(old, new) }
+func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) }
+func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) }
+func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) {
+ return w.MockMedium.Create(path)
+}
+func (w *writeCountMedium) Append(path string) (goio.WriteCloser, error) {
+ return w.MockMedium.Append(path)
+}
+func (w *writeCountMedium) ReadStream(path string) (goio.ReadCloser, error) {
+ return w.MockMedium.ReadStream(path)
+}
+func (w *writeCountMedium) WriteStream(path string) (goio.WriteCloser, error) {
+ return w.MockMedium.WriteStream(path)
+}
+func (w *writeCountMedium) Exists(path string) bool { return w.MockMedium.Exists(path) }
+func (w *writeCountMedium) IsDir(path string) bool { return w.MockMedium.IsDir(path) }
// Test that the summary.md write error in collectCurrent is handled.
func TestMarketCollector_Collect_Bad_SummaryWriteError(t *testing.T) {
@@ -1075,7 +1096,7 @@ func TestMarketCollector_Collect_Bad_HistoricalWriteError(t *testing.T) {
// --- State: Save write error ---
func TestState_Save_Bad_WriteError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")}
s := NewState(em, "/state.json")
s.Set("test", &StateEntry{Source: "test", Items: 1})
@@ -1134,7 +1155,7 @@ func TestBitcoinTalkCollector_Collect_Good_ZeroPostsPage(t *testing.T) {
// --- Excavator: state save error after collection ---
func TestExcavator_Run_Bad_StateSaveError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("state write failed")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("state write failed")}
cfg := &Config{
Output: io.NewMockMedium(), // Use regular medium for output
OutputDir: "/output",
@@ -1158,7 +1179,7 @@ func TestExcavator_Run_Bad_StateSaveError(t *testing.T) {
// --- State: Load with read error ---
func TestState_Load_Bad_ReadError(t *testing.T) {
- em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: fmt.Errorf("read denied")}
+ em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: testErr("read denied")}
em.MockMedium.Files["/state.json"] = "{}" // File exists but read will fail
s := NewState(em, "/state.json")
diff --git a/collect/events.go b/collect/events.go
index 7083986..d5b9f1c 100644
--- a/collect/events.go
+++ b/collect/events.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
@@ -8,22 +10,28 @@ import (
// Event types used by the collection subsystem.
const (
// EventStart is emitted when a collector begins its run.
+ //
EventStart = "start"
// EventProgress is emitted to report incremental progress.
+ //
EventProgress = "progress"
// EventItem is emitted when a single item is collected.
+ //
EventItem = "item"
// EventError is emitted when an error occurs during collection.
+ //
EventError = "error"
// EventComplete is emitted when a collector finishes its run.
+ //
EventComplete = "complete"
)
// Event represents a collection event.
+//
type Event struct {
// Type is one of the Event* constants.
Type string `json:"type"`
@@ -42,16 +50,19 @@ type Event struct {
}
// EventHandler handles collection events.
+//
type EventHandler func(Event)
// Dispatcher manages event dispatch. Handlers are registered per event type
// and are called synchronously when an event is emitted.
+//
type Dispatcher struct {
mu sync.RWMutex
handlers map[string][]EventHandler
}
// NewDispatcher creates a new event dispatcher.
+//
func NewDispatcher() *Dispatcher {
return &Dispatcher{
handlers: make(map[string][]EventHandler),
diff --git a/collect/excavate.go b/collect/excavate.go
index e491ba3..b2a8f34 100644
--- a/collect/excavate.go
+++ b/collect/excavate.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
core "dappco.re/go/core/log"
@@ -11,6 +13,7 @@ import (
// Excavator runs multiple collectors as a coordinated operation.
// It provides sequential execution with rate limit respect, state tracking
// for resume support, and aggregated results.
+//
type Excavator struct {
// Collectors is the list of collectors to run.
Collectors []Collector
diff --git a/collect/excavate_test.go b/collect/excavate_test.go
index 0bebb30..8c556ea 100644
--- a/collect/excavate_test.go
+++ b/collect/excavate_test.go
@@ -2,7 +2,8 @@ package collect
import (
"context"
- "fmt"
+ core "dappco.re/go/core"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"testing"
"dappco.re/go/core/io"
@@ -126,7 +127,7 @@ func TestExcavator_Run_Good_WithErrors(t *testing.T) {
cfg.Limiter = nil
c1 := &mockCollector{name: "good", items: 5}
- c2 := &mockCollector{name: "bad", err: fmt.Errorf("network error")}
+ c2 := &mockCollector{name: "bad", err: core.E("collect.mockCollector.Collect", "network error", nil)}
c3 := &mockCollector{name: "also-good", items: 3}
e := &Excavator{
diff --git a/collect/github.go b/collect/github.go
index cad8fa7..bd450c3 100644
--- a/collect/github.go
+++ b/collect/github.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "encoding/json"
- "fmt"
- "os/exec"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
"time"
core "dappco.re/go/core/log"
@@ -38,6 +40,7 @@ type ghRepo struct {
}
// GitHubCollector collects issues and PRs from GitHub repositories.
+//
type GitHubCollector struct {
// Org is the GitHub organisation.
Org string
diff --git a/collect/market.go b/collect/market.go
index e38e162..f6105a8 100644
--- a/collect/market.go
+++ b/collect/market.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "encoding/json"
- "fmt"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
- "path/filepath"
- "strings"
"time"
core "dappco.re/go/core/log"
@@ -17,6 +19,7 @@ import (
var coinGeckoBaseURL = "https://api.coingecko.com/api/v3"
// MarketCollector collects market data from CoinGecko.
+//
type MarketCollector struct {
// CoinID is the CoinGecko coin identifier (e.g. "bitcoin", "ethereum").
CoinID string
@@ -272,6 +275,7 @@ func formatMarketSummary(data *coinData) string {
}
// FormatMarketSummary is exported for testing.
+//
func FormatMarketSummary(data *coinData) string {
return formatMarketSummary(data)
}
diff --git a/collect/market_extra_test.go b/collect/market_extra_test.go
index bbbcac2..d25dc2d 100644
--- a/collect/market_extra_test.go
+++ b/collect/market_extra_test.go
@@ -2,7 +2,7 @@ package collect
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/collect/market_test.go b/collect/market_test.go
index c945a5f..3d9ca6e 100644
--- a/collect/market_test.go
+++ b/collect/market_test.go
@@ -2,7 +2,7 @@ package collect
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/collect/papers.go b/collect/papers.go
index bfbf663..2183d92 100644
--- a/collect/papers.go
+++ b/collect/papers.go
@@ -1,14 +1,16 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"encoding/xml"
- "fmt"
"iter"
"net/http"
"net/url"
- "path/filepath"
- "strings"
core "dappco.re/go/core/log"
"golang.org/x/net/html"
@@ -16,12 +18,16 @@ import (
// Paper source identifiers.
const (
- PaperSourceIACR = "iacr"
+ //
+ PaperSourceIACR = "iacr"
+ //
PaperSourceArXiv = "arxiv"
- PaperSourceAll = "all"
+ //
+ PaperSourceAll = "all"
)
// PapersCollector collects papers from IACR and arXiv.
+//
type PapersCollector struct {
// Source is one of PaperSourceIACR, PaperSourceArXiv, or PaperSourceAll.
Source string
@@ -403,6 +409,7 @@ func formatPaperMarkdown(ppr paper) string {
}
// FormatPaperMarkdown is exported for testing.
+//
func FormatPaperMarkdown(title string, authors []string, date, paperURL, source, abstract string) string {
return formatPaperMarkdown(paper{
Title: title,
diff --git a/collect/papers_http_test.go b/collect/papers_http_test.go
index b755413..0eb2a9b 100644
--- a/collect/papers_http_test.go
+++ b/collect/papers_http_test.go
@@ -2,9 +2,9 @@ package collect
import (
"context"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"dappco.re/go/core/io"
diff --git a/collect/process.go b/collect/process.go
index c0fb8d2..f41055b 100644
--- a/collect/process.go
+++ b/collect/process.go
@@ -1,19 +1,22 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "encoding/json"
- "fmt"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"maps"
- "path/filepath"
"slices"
- "strings"
core "dappco.re/go/core/log"
"golang.org/x/net/html"
)
// Processor converts collected data to clean markdown.
+//
type Processor struct {
// Source identifies the data source directory to process.
Source string
@@ -331,11 +334,13 @@ func jsonValueToMarkdown(b *strings.Builder, data any, depth int) {
}
// HTMLToMarkdown is exported for testing.
+//
func HTMLToMarkdown(content string) (string, error) {
return htmlToMarkdown(content)
}
// JSONToMarkdown is exported for testing.
+//
func JSONToMarkdown(content string) (string, error) {
return jsonToMarkdown(content)
}
diff --git a/collect/ratelimit.go b/collect/ratelimit.go
index 5fc4969..163974e 100644
--- a/collect/ratelimit.go
+++ b/collect/ratelimit.go
@@ -1,12 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
"maps"
- "os/exec"
"strconv"
- "strings"
"sync"
"time"
@@ -14,6 +16,7 @@ import (
)
// RateLimiter tracks per-source rate limiting to avoid overwhelming APIs.
+//
type RateLimiter struct {
mu sync.Mutex
delays map[string]time.Duration
@@ -30,6 +33,7 @@ var defaultDelays = map[string]time.Duration{
}
// NewRateLimiter creates a limiter with default delays.
+//
func NewRateLimiter() *RateLimiter {
delays := make(map[string]time.Duration, len(defaultDelays))
maps.Copy(delays, defaultDelays)
diff --git a/collect/state.go b/collect/state.go
index 08e2b95..d6db5ba 100644
--- a/collect/state.go
+++ b/collect/state.go
@@ -1,17 +1,20 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package collect
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"sync"
"time"
- core "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ core "dappco.re/go/core/log"
)
// State tracks collection progress for incremental runs.
// It persists entries to disk so that subsequent runs can resume
// where they left off.
+//
type State struct {
mu sync.Mutex
medium io.Medium
@@ -20,6 +23,7 @@ type State struct {
}
// StateEntry tracks state for one source.
+//
type StateEntry struct {
// Source identifies the collector.
Source string `json:"source"`
@@ -39,6 +43,7 @@ type StateEntry struct {
// NewState creates a state tracker that persists to the given path
// using the provided storage medium.
+//
func NewState(m io.Medium, path string) *State {
return &State{
medium: m,
diff --git a/docs/verification-pass-2026-03-27.md b/docs/verification-pass-2026-03-27.md
new file mode 100644
index 0000000..9a4628a
--- /dev/null
+++ b/docs/verification-pass-2026-03-27.md
@@ -0,0 +1,141 @@
+# Verification Pass 2026-03-27
+
+- Repository note: `CODEX.md` was not present under `/workspace`; conventions were taken from `CLAUDE.md`.
+- Commands run: `go build ./...`, `go vet ./...`, `go test ./...`
+- Command status: all passed
+
+## Banned imports
+
+ZERO FINDINGS for banned imports: `os`, `os/exec`, `encoding/json`, `fmt`, `errors`, `strings`, `path/filepath`.
+
+## Test names not matching `TestFile_Function_{Good,Bad,Ugly}`
+
+597 findings across 67 files:
+
+```text
+agentci/config_test.go:23,46,67,85,102,120,127,140,162,177,185,205,223,237,257,270,277,295,302
+agentci/security_test.go:12,34,61,79,92,109
+cmd/forge/cmd_sync_test.go:11,21,29,39,47
+cmd/gitea/cmd_sync_test.go:11,21,29,39,47
+collect/bitcointalk_http_test.go:36,76,104,128,152,183,192,210,225
+collect/bitcointalk_test.go:16,21,30,42,73,79,87
+collect/collect_test.go:10,23,35,57,63
+collect/coverage_boost_test.go:18,34,50,62,79,92,109,120,125,130,141,151,176,198,230,264,275,283,291,298,305,313,322,330,335,343,351,358,368,384,400,419,434,452,457,481,497,508,517,533,557,572,592,611,625,639
+collect/coverage_phase2_test.go:87,99,115,134,149,167,182,202,216,232,257,281,305,329,353,388,416,444,480,503,530,565,578,594,619,633,646,665,692,718,738,751,764,778,797,807,817,826,835,846,856,866,876,886,896,928,945,962,1022,1059,1098,1110,1132,1157,1181,1193,1230,1246,1261,1300
+collect/events_test.go:44,57,72,88,129
+collect/excavate_extra_test.go:13,42,68,86,104
+collect/excavate_test.go:67,78,99,124,147,164,185
+collect/github_test.go:17,22,41,53,65,91
+collect/market_extra_test.go:15,59,101,140,175,200,232
+collect/market_test.go:19,28,40,95,145,167
+collect/papers_http_test.go:112,168,188,208,229,249,281,298,306
+collect/papers_test.go:16,21,26,35,44,56,75,83
+collect/process_extra_test.go:12,21,29,36,43,51,60,68,76,84,92,101,108,118,134,158,175
+collect/process_test.go:16,25,37,58,78,97,114,173,182,191,198
+collect/ratelimit_test.go:30,54,63,69,78
+collect/state_extra_test.go:11,27,43,56
+collect/state_test.go:76,89,98,126,138
+forge/client_test.go:16,28,64,92,100,124,135,159,185,211,225,263,326,356,387,409,435,441
+forge/config_test.go:19,28,39,50,61,71,77,85,100
+forge/issues_test.go:22,44,55,73,94,116,135,154,176,194,211,230,247
+forge/labels_test.go:27,45,63,72,81,91,111,127,144
+forge/meta_test.go:26,48,66
+forge/orgs_test.go:22,40,62
+forge/prs_test.go:22,30,38,61,79,96,105,133,142
+forge/repos_test.go:22,42,60,81,100,122
+forge/webhooks_test.go:26,46
+gitea/client_test.go:10,21
+gitea/config_test.go:18,27,38,49,59,69,75,83,95
+gitea/coverage_boost_test.go:15,26,35,44,90,124,176,220,239,264,294,306
+gitea/issues_test.go:22,44,55,73,94,115,137,155,166
+gitea/meta_test.go:26,48,66,77,103,115
+gitea/repos_test.go:22,42,60,69,79,89,106,127
+jobrunner/forgejo/source_test.go:38,109,155,162,166
+jobrunner/handlers/dispatch_test.go:38,50,63,76,88,100,120,145,210,232,255,276,302,339
+jobrunner/handlers/enable_auto_merge_test.go:29,42,84
+jobrunner/handlers/publish_draft_test.go:26,36
+jobrunner/handlers/resolve_threads_test.go:26
+jobrunner/handlers/send_fix_command_test.go:16,25,37,49
+jobrunner/handlers/tick_parent_test.go:26
+jobrunner/journal_test.go:116,198,233,249
+jobrunner/poller_test.go:121,152,193
+jobrunner/types_test.go:28
+manifest/compile_test.go:14,38,61,67,74,81,106,123,128,152,163,169
+manifest/loader_test.go:12,28,34,51
+manifest/manifest_test.go:10,49,67,127,135,146,157,205,210,215,220
+manifest/sign_test.go:11,32,44
+marketplace/builder_test.go:39,56,71,87,102,121,138,147,154,166,178,189,198,221
+marketplace/discovery_test.go:25,59,84,105,122,130,136,147,195,234
+marketplace/installer_test.go:78,104,125,142,166,189,212,223,243,270,281,309
+marketplace/marketplace_test.go:10,26,38,50,61
+pkg/api/provider_handlers_test.go:20,47,66,79,90,105,118,131,142,155,169,183
+pkg/api/provider_security_test.go:12,18,23
+pkg/api/provider_test.go:92,116,167,181,200,230
+plugin/installer_test.go:14,26,36,47,60,81,95,105,120,132,140,148,156,162,168,174,180,186,192,198,204
+plugin/loader_test.go:46,71,92,123,132
+plugin/manifest_test.go:10,33,50,58,78,89,100
+plugin/plugin_test.go:10,25,37
+plugin/registry_test.go:28,69,78,129,138,149,160,173
+repos/gitstate_test.go:14,50,61,110,132,158,178,189,199,204,210
+repos/kbconfig_test.go:13,48,57,76,93
+repos/registry_test.go:13,40,72,93,120,127,181,201,209,243,266,286,309,334,350,363,373,382,390,398,403,411,421,427,474,481
+repos/workconfig_test.go:14,51,60,79,97,102
+```
+
+## Exported functions missing usage-example comments
+
+199 findings across 51 files:
+
+```text
+agentci/clotho.go:39,61,71,90
+collect/bitcointalk.go:37,46
+collect/events.go:72,81,96,105,115,125,135
+collect/excavate.go:27,33
+collect/github.go:57,65
+collect/market.go:33,67
+collect/papers.go:41,57
+collect/process.go:27,33
+collect/ratelimit.go:46,80,87,100,107
+collect/state.go:55,81,99,112
+forge/client.go:37,40,43,46,55,69
+forge/issues.go:21,56,66,76,86,97,130,164,174,185,209
+forge/labels.go:15,31,55,65,81,94,105
+forge/meta.go:43,100,139
+forge/orgs.go:10,34,44
+forge/prs.go:18,43,84,108,117
+forge/repos.go:12,36,61,85,110,120,130,141
+forge/webhooks.go:10,20
+git/git.go:32,37,42,283,293
+git/service.go:75,113,116,121,132,145,156
+gitea/client.go:36,39
+gitea/issues.go:20,52,62,72,105,139
+gitea/meta.go:43,101,141
+gitea/repos.go:12,36,61,85,110,122,145,155
+internal/ax/stdio/stdio.go:12,24
+jobrunner/forgejo/source.go:36,42,65
+jobrunner/handlers/completion.go:33,38,43
+jobrunner/handlers/dispatch.go:76,82,91
+jobrunner/handlers/enable_auto_merge.go:25,31,40
+jobrunner/handlers/publish_draft.go:25,30,37
+jobrunner/handlers/resolve_threads.go:30,35,40
+jobrunner/handlers/send_fix_command.go:26,32,46
+jobrunner/handlers/tick_parent.go:30,35,41
+jobrunner/journal.go:98
+jobrunner/poller.go:51,58,65,72,79,88,110
+jobrunner/types.go:37,42
+manifest/manifest.go:46,79,95
+marketplace/builder.go:35
+marketplace/discovery.go:132,140,145,151,160
+marketplace/installer.go:53,115,133,179
+marketplace/marketplace.go:39,53,64
+pkg/api/provider.go:61,64,67,75,86,108
+plugin/installer.go:35,99,133
+plugin/loader.go:28,53
+plugin/manifest.go:41
+plugin/plugin.go:44,47,50,53,56
+plugin/registry.go:35,48,54,63,78,105
+repos/gitstate.go:101,106,111,120,131,144,162
+repos/kbconfig.go:116,121
+repos/registry.go:255,265,271,283,325,330
+repos/workconfig.go:104
+```
diff --git a/forge/client.go b/forge/client.go
index 2ee2bb9..4f260fa 100644
--- a/forge/client.go
+++ b/forge/client.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package forge provides a thin wrapper around the Forgejo Go SDK
// for managing repositories, issues, and pull requests on a Forgejo instance.
//
@@ -15,6 +17,7 @@ import (
)
// Client wraps the Forgejo SDK client with config-based auth.
+//
type Client struct {
api *forgejo.Client
url string
@@ -22,6 +25,7 @@ type Client struct {
}
// New creates a new Forgejo API client for the given URL and token.
+//
func New(url, token string) (*Client, error) {
api, err := forgejo.NewClient(url, forgejo.SetToken(token))
if err != nil {
diff --git a/forge/client_test.go b/forge/client_test.go
index daf05c8..7302fc1 100644
--- a/forge/client_test.go
+++ b/forge/client_test.go
@@ -1,8 +1,8 @@
package forge
import (
- "encoding/json"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
@@ -132,7 +132,7 @@ func TestClient_SetPRDraft_Bad_ConnectionRefused(t *testing.T) {
assert.Error(t, err)
}
-func TestClient_SetPRDraft_URLConstruction(t *testing.T) {
+func TestClient_SetPRDraft_Good_URLConstruction(t *testing.T) {
// Verify the URL is constructed correctly by checking the request path.
var capturedPath string
mux := http.NewServeMux()
@@ -156,7 +156,7 @@ func TestClient_SetPRDraft_URLConstruction(t *testing.T) {
assert.Equal(t, "/api/v1/repos/my-org/my-repo/pulls/42", capturedPath)
}
-func TestClient_SetPRDraft_AuthHeader(t *testing.T) {
+func TestClient_SetPRDraft_Good_AuthHeader(t *testing.T) {
// Verify the authorisation header is set correctly.
var capturedAuth string
mux := http.NewServeMux()
@@ -182,7 +182,7 @@ func TestClient_SetPRDraft_AuthHeader(t *testing.T) {
// --- PRMeta and Comment struct tests ---
-func TestPRMeta_Fields(t *testing.T) {
+func TestPRMeta_Good_Fields(t *testing.T) {
meta := &PRMeta{
Number: 42,
Title: "Test PR",
@@ -208,7 +208,7 @@ func TestPRMeta_Fields(t *testing.T) {
assert.Equal(t, 5, meta.CommentCount)
}
-func TestComment_Fields(t *testing.T) {
+func TestComment_Good_Fields(t *testing.T) {
comment := Comment{
ID: 123,
Author: "reviewer",
@@ -222,7 +222,7 @@ func TestComment_Fields(t *testing.T) {
// --- MergePullRequest merge style mapping ---
-func TestMergePullRequest_StyleMapping(t *testing.T) {
+func TestMergePullRequest_Good_StyleMapping(t *testing.T) {
// We can't easily test the SDK call, but we can verify the method
// errors when the server returns failure. This exercises the style mapping code.
tests := []struct {
@@ -260,7 +260,7 @@ func TestMergePullRequest_StyleMapping(t *testing.T) {
// --- ListIssuesOpts defaulting ---
-func TestListIssuesOpts_Defaults(t *testing.T) {
+func TestListIssuesOpts_Good_Defaults(t *testing.T) {
tests := []struct {
name string
opts ListIssuesOpts
@@ -432,13 +432,13 @@ func TestClient_CreatePullRequest_Bad_ServerError(t *testing.T) {
// --- commentPageSize constant test ---
-func TestCommentPageSize(t *testing.T) {
+func TestCommentPageSize_Good(t *testing.T) {
assert.Equal(t, 50, commentPageSize, "comment page size should be 50")
}
// --- ListPullRequests state mapping ---
-func TestListPullRequests_StateMapping(t *testing.T) {
+func TestListPullRequests_Good_StateMapping(t *testing.T) {
// Verify state mapping via error path (server returns error).
tests := []struct {
name string
diff --git a/forge/config.go b/forge/config.go
index 1e97bda..9205432 100644
--- a/forge/config.go
+++ b/forge/config.go
@@ -1,19 +1,24 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "os"
+ os "dappco.re/go/core/scm/internal/ax/osx"
- "forge.lthn.ai/core/config"
"dappco.re/go/core/log"
+ "forge.lthn.ai/core/config"
)
const (
// ConfigKeyURL is the config key for the Forgejo instance URL.
+ //
ConfigKeyURL = "forge.url"
// ConfigKeyToken is the config key for the Forgejo API token.
+ //
ConfigKeyToken = "forge.token"
// DefaultURL is the default Forgejo instance URL.
+ //
DefaultURL = "http://localhost:4000"
)
@@ -22,6 +27,8 @@ const (
// 1. ~/.core/config.yaml keys: forge.token, forge.url
// 2. FORGE_TOKEN + FORGE_URL environment variables (override config file)
// 3. Provided flag overrides (highest priority; pass empty to skip)
+//
+//
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
url, token, err := ResolveConfig(flagURL, flagToken)
if err != nil {
@@ -37,6 +44,7 @@ func NewFromConfig(flagURL, flagToken string) (*Client, error) {
// ResolveConfig resolves the Forgejo URL and token from all config sources.
// Flag values take highest priority, then env vars, then config file.
+//
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
// Start with config file values
cfg, cfgErr := config.New()
@@ -70,6 +78,7 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
}
// SaveConfig persists the Forgejo URL and/or token to the config file.
+//
func SaveConfig(url, token string) error {
cfg, err := config.New()
if err != nil {
diff --git a/forge/config_test.go b/forge/config_test.go
index ace6e30..4f35c80 100644
--- a/forge/config_test.go
+++ b/forge/config_test.go
@@ -68,7 +68,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) {
assert.Equal(t, "some-token", token)
}
-func TestConstants(t *testing.T) {
+func TestConstants_Good(t *testing.T) {
assert.Equal(t, "forge.url", ConfigKeyURL)
assert.Equal(t, "forge.token", ConfigKeyToken)
assert.Equal(t, "http://localhost:4000", DefaultURL)
diff --git a/forge/issues.go b/forge/issues.go
index 664e140..9b07237 100644
--- a/forge/issues.go
+++ b/forge/issues.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
@@ -9,6 +11,7 @@ import (
)
// ListIssuesOpts configures issue listing.
+//
type ListIssuesOpts struct {
State string // "open", "closed", "all"
Labels []string // filter by label names
diff --git a/forge/labels.go b/forge/labels.go
index 063cb46..2f57fb0 100644
--- a/forge/labels.go
+++ b/forge/labels.go
@@ -1,7 +1,9 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
- "strings"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
diff --git a/forge/meta.go b/forge/meta.go
index 0cece76..6b0f068 100644
--- a/forge/meta.go
+++ b/forge/meta.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
@@ -10,6 +12,7 @@ import (
// PRMeta holds structural signals from a pull request,
// used by the pipeline MetaReader for AI-driven workflows.
+//
type PRMeta struct {
Number int64
Title string
@@ -26,6 +29,7 @@ type PRMeta struct {
}
// Comment represents a comment with metadata.
+//
type Comment struct {
ID int64
Author string
diff --git a/forge/orgs.go b/forge/orgs.go
index db20323..c1ce33b 100644
--- a/forge/orgs.go
+++ b/forge/orgs.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
diff --git a/forge/prs.go b/forge/prs.go
index 070662f..9a0b649 100644
--- a/forge/prs.go
+++ b/forge/prs.go
@@ -1,14 +1,19 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
"bytes"
- "encoding/json"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
-
- forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
+ "net/url"
+ "strconv"
"dappco.re/go/core/log"
+ "dappco.re/go/core/scm/agentci"
+
+ forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
)
// MergePullRequest merges a pull request with the given method ("squash", "rebase", "merge").
@@ -38,14 +43,27 @@ func (c *Client) MergePullRequest(owner, repo string, index int64, method string
// The Forgejo SDK v2.2.0 doesn't expose the draft field on EditPullRequestOption,
// so we use a raw HTTP PATCH request.
func (c *Client) SetPRDraft(owner, repo string, index int64, draft bool) error {
+ safeOwner, err := agentci.ValidatePathElement(owner)
+ if err != nil {
+ return log.E("forge.SetPRDraft", "invalid owner", err)
+ }
+ safeRepo, err := agentci.ValidatePathElement(repo)
+ if err != nil {
+ return log.E("forge.SetPRDraft", "invalid repo", err)
+ }
+
payload := map[string]bool{"draft": draft}
body, err := json.Marshal(payload)
if err != nil {
return log.E("forge.SetPRDraft", "marshal payload", err)
}
- url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.url, owner, repo, index)
- req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(body))
+ path, err := url.JoinPath(c.url, "api", "v1", "repos", safeOwner, safeRepo, "pulls", strconv.FormatInt(index, 10))
+ if err != nil {
+ return log.E("forge.SetPRDraft", "failed to build request path", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPatch, path, bytes.NewReader(body))
if err != nil {
return log.E("forge.SetPRDraft", "create request", err)
}
diff --git a/forge/prs_test.go b/forge/prs_test.go
index 14f30be..10d3aeb 100644
--- a/forge/prs_test.go
+++ b/forge/prs_test.go
@@ -1,7 +1,10 @@
package forge
import (
- "strings"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ "net/http"
+ "net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
@@ -98,3 +101,49 @@ func TestClient_DismissReview_Bad_ServerError(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to dismiss review")
}
+
+func TestClient_SetPRDraft_Good_Request(t *testing.T) {
+ var method, path string
+ var payload map[string]any
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, map[string]string{"version": "1.21.0"})
+ })
+ mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/3", func(w http.ResponseWriter, r *http.Request) {
+ method = r.Method
+ path = r.URL.Path
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
+ jsonResponse(w, map[string]any{"number": 3})
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ client, err := New(srv.URL, "test-token")
+ require.NoError(t, err)
+
+ err = client.SetPRDraft("test-org", "org-repo", 3, false)
+ assert.NoError(t, err)
+ assert.Equal(t, http.MethodPatch, method)
+ assert.Equal(t, "/api/v1/repos/test-org/org-repo/pulls/3", path)
+ assert.Equal(t, false, payload["draft"])
+}
+
+func TestClient_SetPRDraft_Bad_PathTraversalOwner(t *testing.T) {
+ client, srv := newTestClient(t)
+ defer srv.Close()
+
+ err := client.SetPRDraft("../owner", "org-repo", 3, true)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid owner")
+}
+
+func TestClient_SetPRDraft_Bad_PathTraversalRepo(t *testing.T) {
+ client, srv := newTestClient(t)
+ defer srv.Close()
+
+ err := client.SetPRDraft("test-org", "..", 3, true)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid repo")
+}
diff --git a/forge/repos.go b/forge/repos.go
index d894aee..8a92433 100644
--- a/forge/repos.go
+++ b/forge/repos.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
diff --git a/forge/testhelper_test.go b/forge/testhelper_test.go
index e38db64..6f31a7b 100644
--- a/forge/testhelper_test.go
+++ b/forge/testhelper_test.go
@@ -1,10 +1,10 @@
package forge
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
"net/http/httptest"
- "strings"
"testing"
)
diff --git a/forge/webhooks.go b/forge/webhooks.go
index 464c690..27a35fa 100644
--- a/forge/webhooks.go
+++ b/forge/webhooks.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forge
import (
diff --git a/git/git.go b/git/git.go
index 53ded5f..564914f 100644
--- a/git/git.go
+++ b/git/git.go
@@ -1,20 +1,23 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package git provides utilities for git operations across multiple repositories.
package git
import (
"bytes"
"context"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
"io"
"iter"
- "os"
- "os/exec"
"slices"
"strconv"
- "strings"
"sync"
)
// RepoStatus represents the git status of a single repository.
+//
type RepoStatus struct {
Name string
Path string
@@ -43,6 +46,7 @@ func (s *RepoStatus) HasUnpulled() bool {
}
// StatusOptions configures the status check.
+//
type StatusOptions struct {
// Paths is a list of repo paths to check
Paths []string
@@ -51,6 +55,7 @@ type StatusOptions struct {
}
// Status checks git status for multiple repositories in parallel.
+//
func Status(ctx context.Context, opts StatusOptions) []RepoStatus {
var wg sync.WaitGroup
results := make([]RepoStatus, len(opts.Paths))
@@ -72,6 +77,7 @@ func Status(ctx context.Context, opts StatusOptions) []RepoStatus {
}
// StatusIter returns an iterator over git status for multiple repositories.
+//
func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] {
return func(yield func(RepoStatus) bool) {
results := Status(ctx, opts)
@@ -156,17 +162,20 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int) {
// Push pushes commits for a single repository.
// Uses interactive mode to support SSH passphrase prompts.
+//
func Push(ctx context.Context, path string) error {
return gitInteractive(ctx, path, "push")
}
// Pull pulls changes for a single repository.
// Uses interactive mode to support SSH passphrase prompts.
+//
func Pull(ctx context.Context, path string) error {
return gitInteractive(ctx, path, "pull", "--rebase")
}
// IsNonFastForward checks if an error is a non-fast-forward rejection.
+//
func IsNonFastForward(err error) bool {
if err == nil {
return false
@@ -201,6 +210,7 @@ func gitInteractive(ctx context.Context, dir string, args ...string) error {
}
// PushResult represents the result of a push operation.
+//
type PushResult struct {
Name string
Path string
@@ -210,11 +220,13 @@ type PushResult struct {
// PushMultiple pushes multiple repositories sequentially.
// Sequential because SSH passphrase prompts need user interaction.
+//
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult {
return slices.Collect(PushMultipleIter(ctx, paths, names))
}
// PushMultipleIter returns an iterator that pushes repositories sequentially and yields results.
+//
func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] {
return func(yield func(PushResult) bool) {
for _, path := range paths {
@@ -263,6 +275,7 @@ func gitCommand(ctx context.Context, dir string, args ...string) (string, error)
}
// GitError wraps a git command error with stderr output.
+//
type GitError struct {
Err error
Stderr string
diff --git a/git/service.go b/git/service.go
index 13d66c6..a2fa160 100644
--- a/git/service.go
+++ b/git/service.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package git
import (
@@ -11,49 +13,58 @@ import (
// Queries for git service
// QueryStatus requests git status for paths.
+//
type QueryStatus struct {
Paths []string
Names map[string]string
}
// QueryDirtyRepos requests repos with uncommitted changes.
+//
type QueryDirtyRepos struct{}
// QueryAheadRepos requests repos with unpushed commits.
+//
type QueryAheadRepos struct{}
// Tasks for git service
// TaskPush requests git push for a path.
+//
type TaskPush struct {
Path string
Name string
}
// TaskPull requests git pull for a path.
+//
type TaskPull struct {
Path string
Name string
}
// TaskPushMultiple requests git push for multiple paths.
+//
type TaskPushMultiple struct {
Paths []string
Names map[string]string
}
// ServiceOptions for configuring the git service.
+//
type ServiceOptions struct {
WorkDir string
}
// Service provides git operations as a Core service.
+//
type Service struct {
*core.ServiceRuntime[ServiceOptions]
lastStatus []RepoStatus
}
// NewService creates a git service factory.
+//
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
diff --git a/gitea/client.go b/gitea/client.go
index 6d752ab..c93a017 100644
--- a/gitea/client.go
+++ b/gitea/client.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package gitea provides a thin wrapper around the Gitea Go SDK
// for managing repositories, issues, and pull requests on a Gitea instance.
//
@@ -15,12 +17,14 @@ import (
)
// Client wraps the Gitea SDK client with config-based auth.
+//
type Client struct {
api *gitea.Client
url string
}
// New creates a new Gitea API client for the given URL and token.
+//
func New(url, token string) (*Client, error) {
api, err := gitea.NewClient(url, gitea.SetToken(token))
if err != nil {
diff --git a/gitea/config.go b/gitea/config.go
index 80d4127..d2de11e 100644
--- a/gitea/config.go
+++ b/gitea/config.go
@@ -1,19 +1,24 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
- "os"
+ os "dappco.re/go/core/scm/internal/ax/osx"
- "forge.lthn.ai/core/config"
"dappco.re/go/core/log"
+ "forge.lthn.ai/core/config"
)
const (
// ConfigKeyURL is the config key for the Gitea instance URL.
+ //
ConfigKeyURL = "gitea.url"
// ConfigKeyToken is the config key for the Gitea API token.
+ //
ConfigKeyToken = "gitea.token"
// DefaultURL is the default Gitea instance URL.
+ //
DefaultURL = "https://gitea.snider.dev"
)
@@ -22,6 +27,8 @@ const (
// 1. ~/.core/config.yaml keys: gitea.token, gitea.url
// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file)
// 3. Provided flag overrides (highest priority; pass empty to skip)
+//
+//
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
url, token, err := ResolveConfig(flagURL, flagToken)
if err != nil {
@@ -37,6 +44,7 @@ func NewFromConfig(flagURL, flagToken string) (*Client, error) {
// ResolveConfig resolves the Gitea URL and token from all config sources.
// Flag values take highest priority, then env vars, then config file.
+//
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
// Start with config file values
cfg, cfgErr := config.New()
@@ -70,6 +78,7 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
}
// SaveConfig persists the Gitea URL and/or token to the config file.
+//
func SaveConfig(url, token string) error {
cfg, err := config.New()
if err != nil {
diff --git a/gitea/config_test.go b/gitea/config_test.go
index 9272ca2..bda2bc0 100644
--- a/gitea/config_test.go
+++ b/gitea/config_test.go
@@ -66,7 +66,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) {
assert.Equal(t, "some-token", token)
}
-func TestConstants(t *testing.T) {
+func TestConstants_Good(t *testing.T) {
assert.Equal(t, "gitea.url", ConfigKeyURL)
assert.Equal(t, "gitea.token", ConfigKeyToken)
assert.Equal(t, "https://gitea.snider.dev", DefaultURL)
diff --git a/gitea/coverage_boost_test.go b/gitea/coverage_boost_test.go
index 82a4763..4dcac22 100644
--- a/gitea/coverage_boost_test.go
+++ b/gitea/coverage_boost_test.go
@@ -1,7 +1,7 @@
package gitea
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
@@ -146,12 +146,12 @@ func newPRMetaWithManyCommentsServer(t *testing.T) *httptest.Server {
mux.HandleFunc("/api/v1/repos/test-org/test-repo/pulls/1", func(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, map[string]any{
"id": 1, "number": 1, "title": "Many Comments PR", "state": "open",
- "merged": false,
- "head": map[string]any{"ref": "feature", "label": "feature"},
- "base": map[string]any{"ref": "main", "label": "main"},
- "user": map[string]any{"login": "author"},
- "labels": []map[string]any{},
- "assignees": []map[string]any{},
+ "merged": false,
+ "head": map[string]any{"ref": "feature", "label": "feature"},
+ "base": map[string]any{"ref": "main", "label": "main"},
+ "user": map[string]any{"login": "author"},
+ "labels": []map[string]any{},
+ "assignees": []map[string]any{},
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-16T12:00:00Z",
})
diff --git a/gitea/issues.go b/gitea/issues.go
index 611b912..2cefa23 100644
--- a/gitea/issues.go
+++ b/gitea/issues.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
@@ -9,6 +11,7 @@ import (
)
// ListIssuesOpts configures issue listing.
+//
type ListIssuesOpts struct {
State string // "open", "closed", "all"
Page int
diff --git a/gitea/issues_test.go b/gitea/issues_test.go
index ef22b64..0675846 100644
--- a/gitea/issues_test.go
+++ b/gitea/issues_test.go
@@ -163,7 +163,7 @@ func TestClient_GetPullRequest_Bad_ServerError(t *testing.T) {
// --- ListIssuesOpts defaulting ---
-func TestListIssuesOpts_Defaults(t *testing.T) {
+func TestListIssuesOpts_Good_Defaults(t *testing.T) {
tests := []struct {
name string
opts ListIssuesOpts
diff --git a/gitea/meta.go b/gitea/meta.go
index a050ef8..80e1956 100644
--- a/gitea/meta.go
+++ b/gitea/meta.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
@@ -10,6 +12,7 @@ import (
// PRMeta holds structural signals from a pull request,
// used by the pipeline MetaReader for AI-driven workflows.
+//
type PRMeta struct {
Number int64
Title string
@@ -26,6 +29,7 @@ type PRMeta struct {
}
// Comment represents a comment with metadata.
+//
type Comment struct {
ID int64
Author string
diff --git a/gitea/meta_test.go b/gitea/meta_test.go
index bebb112..7a13945 100644
--- a/gitea/meta_test.go
+++ b/gitea/meta_test.go
@@ -74,7 +74,7 @@ func TestClient_GetIssueBody_Bad_ServerError(t *testing.T) {
// --- PRMeta struct tests ---
-func TestPRMeta_Fields(t *testing.T) {
+func TestPRMeta_Good_Fields(t *testing.T) {
meta := &PRMeta{
Number: 42,
Title: "Test PR",
@@ -100,7 +100,7 @@ func TestPRMeta_Fields(t *testing.T) {
assert.Equal(t, 5, meta.CommentCount)
}
-func TestComment_Fields(t *testing.T) {
+func TestComment_Good_Fields(t *testing.T) {
comment := Comment{
ID: 123,
Author: "reviewer",
@@ -112,6 +112,6 @@ func TestComment_Fields(t *testing.T) {
assert.Equal(t, "LGTM", comment.Body)
}
-func TestCommentPageSize(t *testing.T) {
+func TestCommentPageSize_Good(t *testing.T) {
assert.Equal(t, 50, commentPageSize, "comment page size should be 50")
}
diff --git a/gitea/repos.go b/gitea/repos.go
index ad3bfba..5606342 100644
--- a/gitea/repos.go
+++ b/gitea/repos.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package gitea
import (
diff --git a/gitea/testhelper_test.go b/gitea/testhelper_test.go
index daea37b..e119f72 100644
--- a/gitea/testhelper_test.go
+++ b/gitea/testhelper_test.go
@@ -1,10 +1,10 @@
package gitea
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
"net/http/httptest"
- "strings"
"testing"
)
@@ -130,7 +130,7 @@ func newGiteaMux() *http.ServeMux {
w.WriteHeader(http.StatusCreated)
jsonResponse(w, map[string]any{
"id": 40, "name": "mirrored-repo", "full_name": "test-org/mirrored-repo",
- "owner": map[string]any{"login": "test-org"},
+ "owner": map[string]any{"login": "test-org"},
"mirror": true,
})
})
diff --git a/go.mod b/go.mod
index 9e7770a..0b97fe0 100644
--- a/go.mod
+++ b/go.mod
@@ -5,25 +5,27 @@ go 1.26.0
require (
code.gitea.io/sdk/gitea v0.23.2
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
- dappco.re/go/core v0.4.7
- dappco.re/go/core/api v0.1.5
- dappco.re/go/core/i18n v0.1.7
- dappco.re/go/core/io v0.1.7
- dappco.re/go/core/log v0.0.4
- dappco.re/go/core/ws v0.2.5
+ dappco.re/go/core v0.5.0
+ dappco.re/go/core/api v0.2.0
+ dappco.re/go/core/i18n v0.2.0
+ dappco.re/go/core/io v0.2.0
+ dappco.re/go/core/log v0.1.0
+ dappco.re/go/core/ws v0.3.0
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/config v0.1.8
github.com/gin-gonic/gin v1.12.0
+ github.com/goccy/go-json v0.10.6
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.52.0
+ golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
)
require (
- forge.lthn.ai/core/go v0.3.2 // indirect
+ forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.7 // indirect
- forge.lthn.ai/core/go-io v0.1.5 // indirect
+ forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect
@@ -86,7 +88,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
- github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
@@ -145,7 +146,6 @@ require (
golang.org/x/mod v0.34.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
@@ -155,15 +155,3 @@ require (
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
-
-replace (
- dappco.re/go/core => ../go
- dappco.re/go/core/api => ../api
- dappco.re/go/core/i18n => ../go-i18n
- dappco.re/go/core/io => ../go-io
- dappco.re/go/core/log => ../go-log
- dappco.re/go/core/ws => ../go-ws
- forge.lthn.ai/core/cli => ../cli
- forge.lthn.ai/core/config => ../config
- forge.lthn.ai/core/go-inference => ../go-inference
-)
diff --git a/internal/ax/filepathx/filepathx.go b/internal/ax/filepathx/filepathx.go
new file mode 100644
index 0000000..784d948
--- /dev/null
+++ b/internal/ax/filepathx/filepathx.go
@@ -0,0 +1,55 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package filepathx
+
+import (
+ "path"
+ "syscall"
+)
+
+// Separator mirrors filepath.Separator for Unix-style Core paths.
+//
+const Separator = '/'
+
+// Abs mirrors filepath.Abs for the paths used in this repo.
+//
+func Abs(p string) (string, error) {
+ if path.IsAbs(p) {
+ return path.Clean(p), nil
+ }
+ cwd, err := syscall.Getwd()
+ if err != nil {
+ return "", err
+ }
+ return path.Clean(path.Join(cwd, p)), nil
+}
+
+// Base mirrors filepath.Base.
+//
+func Base(p string) string {
+ return path.Base(p)
+}
+
+// Clean mirrors filepath.Clean.
+//
+func Clean(p string) string {
+ return path.Clean(p)
+}
+
+// Dir mirrors filepath.Dir.
+//
+func Dir(p string) string {
+ return path.Dir(p)
+}
+
+// Ext mirrors filepath.Ext.
+//
+func Ext(p string) string {
+ return path.Ext(p)
+}
+
+// Join mirrors filepath.Join.
+//
+func Join(elem ...string) string {
+ return path.Join(elem...)
+}
diff --git a/internal/ax/fmtx/fmtx.go b/internal/ax/fmtx/fmtx.go
new file mode 100644
index 0000000..7476794
--- /dev/null
+++ b/internal/ax/fmtx/fmtx.go
@@ -0,0 +1,40 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package fmtx
+
+import (
+ "io"
+
+ core "dappco.re/go/core"
+ "dappco.re/go/core/scm/internal/ax/stdio"
+)
+
+// Sprint mirrors fmt.Sprint using Core primitives.
+//
+func Sprint(args ...any) string {
+ return core.Sprint(args...)
+}
+
+// Sprintf mirrors fmt.Sprintf using Core primitives.
+//
+func Sprintf(format string, args ...any) string {
+ return core.Sprintf(format, args...)
+}
+
+// Fprintf mirrors fmt.Fprintf using Core primitives.
+//
+func Fprintf(w io.Writer, format string, args ...any) (int, error) {
+ return io.WriteString(w, Sprintf(format, args...))
+}
+
+// Printf mirrors fmt.Printf.
+//
+func Printf(format string, args ...any) (int, error) {
+ return Fprintf(stdio.Stdout, format, args...)
+}
+
+// Println mirrors fmt.Println.
+//
+func Println(args ...any) (int, error) {
+ return io.WriteString(stdio.Stdout, Sprint(args...)+"\n")
+}
diff --git a/internal/ax/jsonx/jsonx.go b/internal/ax/jsonx/jsonx.go
new file mode 100644
index 0000000..8a90048
--- /dev/null
+++ b/internal/ax/jsonx/jsonx.go
@@ -0,0 +1,39 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package jsonx
+
+import (
+ "io"
+
+ json "github.com/goccy/go-json"
+)
+
+// Marshal mirrors encoding/json.Marshal.
+//
+func Marshal(v any) ([]byte, error) {
+ return json.Marshal(v)
+}
+
+// MarshalIndent mirrors encoding/json.MarshalIndent.
+//
+func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
+ return json.MarshalIndent(v, prefix, indent)
+}
+
+// NewDecoder mirrors encoding/json.NewDecoder.
+//
+func NewDecoder(r io.Reader) *json.Decoder {
+ return json.NewDecoder(r)
+}
+
+// NewEncoder mirrors encoding/json.NewEncoder.
+//
+func NewEncoder(w io.Writer) *json.Encoder {
+ return json.NewEncoder(w)
+}
+
+// Unmarshal mirrors encoding/json.Unmarshal.
+//
+func Unmarshal(data []byte, v any) error {
+ return json.Unmarshal(data, v)
+}
diff --git a/internal/ax/osx/osx.go b/internal/ax/osx/osx.go
new file mode 100644
index 0000000..bea0f7f
--- /dev/null
+++ b/internal/ax/osx/osx.go
@@ -0,0 +1,113 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package osx
+
+import (
+ "io"
+ "io/fs"
+ "os/user"
+ "syscall"
+
+ core "dappco.re/go/core"
+ coreio "dappco.re/go/core/io"
+ "dappco.re/go/core/scm/internal/ax/stdio"
+)
+
+const (
+ //
+ O_APPEND = syscall.O_APPEND
+ //
+ O_CREATE = syscall.O_CREAT
+ //
+ O_WRONLY = syscall.O_WRONLY
+)
+
+// Stdin exposes process stdin without importing os.
+//
+var Stdin = stdio.Stdin
+
+// Stdout exposes process stdout without importing os.
+//
+var Stdout = stdio.Stdout
+
+// Stderr exposes process stderr without importing os.
+//
+var Stderr = stdio.Stderr
+
+// Getenv mirrors os.Getenv.
+//
+func Getenv(key string) string {
+ value, _ := syscall.Getenv(key)
+ return value
+}
+
+// Getwd mirrors os.Getwd.
+//
+func Getwd() (string, error) {
+ return syscall.Getwd()
+}
+
+// IsNotExist mirrors os.IsNotExist.
+//
+func IsNotExist(err error) bool {
+ return core.Is(err, fs.ErrNotExist)
+}
+
+// MkdirAll mirrors os.MkdirAll.
+//
+func MkdirAll(path string, _ fs.FileMode) error {
+ return coreio.Local.EnsureDir(path)
+}
+
+// Open mirrors os.Open.
+//
+func Open(path string) (fs.File, error) {
+ return coreio.Local.Open(path)
+}
+
+// OpenFile mirrors the append/create/write mode used in this repo.
+//
+func OpenFile(path string, flag int, _ fs.FileMode) (io.WriteCloser, error) {
+ if flag&O_APPEND != 0 {
+ return coreio.Local.Append(path)
+ }
+ return coreio.Local.Create(path)
+}
+
+// ReadDir mirrors os.ReadDir.
+//
+func ReadDir(path string) ([]fs.DirEntry, error) {
+ return coreio.Local.List(path)
+}
+
+// ReadFile mirrors os.ReadFile.
+//
+func ReadFile(path string) ([]byte, error) {
+ content, err := coreio.Local.Read(path)
+ return []byte(content), err
+}
+
+// Stat mirrors os.Stat.
+//
+func Stat(path string) (fs.FileInfo, error) {
+ return coreio.Local.Stat(path)
+}
+
+// UserHomeDir mirrors os.UserHomeDir.
+//
+func UserHomeDir() (string, error) {
+ if home := Getenv("HOME"); home != "" {
+ return home, nil
+ }
+ current, err := user.Current()
+ if err != nil {
+ return "", err
+ }
+ return current.HomeDir, nil
+}
+
+// WriteFile mirrors os.WriteFile.
+//
+func WriteFile(path string, data []byte, perm fs.FileMode) error {
+ return coreio.Local.WriteMode(path, string(data), perm)
+}
diff --git a/internal/ax/stdio/stdio.go b/internal/ax/stdio/stdio.go
new file mode 100644
index 0000000..ecebd5a
--- /dev/null
+++ b/internal/ax/stdio/stdio.go
@@ -0,0 +1,40 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package stdio
+
+import (
+ "io"
+ "syscall"
+)
+
+type fdReader struct {
+ fd int
+}
+
+func (r fdReader) Read(p []byte) (int, error) {
+ n, err := syscall.Read(r.fd, p)
+ if n == 0 && err == nil {
+ return 0, io.EOF
+ }
+ return n, err
+}
+
+type fdWriter struct {
+ fd int
+}
+
+func (w fdWriter) Write(p []byte) (int, error) {
+ return syscall.Write(w.fd, p)
+}
+
+// Stdin exposes process stdin without importing os.
+//
+var Stdin io.Reader = fdReader{fd: 0}
+
+// Stdout exposes process stdout without importing os.
+//
+var Stdout io.Writer = fdWriter{fd: 1}
+
+// Stderr exposes process stderr without importing os.
+//
+var Stderr io.Writer = fdWriter{fd: 2}
diff --git a/internal/ax/stringsx/stringsx.go b/internal/ax/stringsx/stringsx.go
new file mode 100644
index 0000000..95300e0
--- /dev/null
+++ b/internal/ax/stringsx/stringsx.go
@@ -0,0 +1,151 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
+package stringsx
+
+import (
+ "bufio"
+ "bytes"
+ "iter"
+
+ core "dappco.re/go/core"
+)
+
+// Builder provides a strings.Builder-like type without importing strings.
+//
+type Builder = bytes.Buffer
+
+// Contains mirrors strings.Contains.
+//
+func Contains(s, substr string) bool {
+ return core.Contains(s, substr)
+}
+
+// ContainsAny mirrors strings.ContainsAny.
+//
+func ContainsAny(s, chars string) bool {
+ return bytes.IndexAny([]byte(s), chars) >= 0
+}
+
+// EqualFold mirrors strings.EqualFold.
+//
+func EqualFold(s, t string) bool {
+ return bytes.EqualFold([]byte(s), []byte(t))
+}
+
+// Fields mirrors strings.Fields.
+//
+func Fields(s string) []string {
+ scanner := bufio.NewScanner(NewReader(s))
+ scanner.Split(bufio.ScanWords)
+ fields := make([]string, 0)
+ for scanner.Scan() {
+ fields = append(fields, scanner.Text())
+ }
+ return fields
+}
+
+// HasPrefix mirrors strings.HasPrefix.
+//
+func HasPrefix(s, prefix string) bool {
+ return core.HasPrefix(s, prefix)
+}
+
+// HasSuffix mirrors strings.HasSuffix.
+//
+func HasSuffix(s, suffix string) bool {
+ return core.HasSuffix(s, suffix)
+}
+
+// Join mirrors strings.Join.
+//
+func Join(elems []string, sep string) string {
+ return core.Join(sep, elems...)
+}
+
+// LastIndex mirrors strings.LastIndex.
+//
+func LastIndex(s, substr string) int {
+ return bytes.LastIndex([]byte(s), []byte(substr))
+}
+
+// NewReader mirrors strings.NewReader.
+//
+func NewReader(s string) *bytes.Reader {
+ return bytes.NewReader([]byte(s))
+}
+
+// Repeat mirrors strings.Repeat.
+//
+func Repeat(s string, count int) string {
+ if count <= 0 {
+ return ""
+ }
+ return string(bytes.Repeat([]byte(s), count))
+}
+
+// ReplaceAll mirrors strings.ReplaceAll.
+//
+func ReplaceAll(s, old, new string) string {
+ return core.Replace(s, old, new)
+}
+
+// Replace mirrors strings.Replace for replace-all call sites.
+//
+func Replace(s, old, new string, _ int) string {
+ return ReplaceAll(s, old, new)
+}
+
+// Split mirrors strings.Split.
+//
+func Split(s, sep string) []string {
+ return core.Split(s, sep)
+}
+
+// SplitN mirrors strings.SplitN.
+//
+func SplitN(s, sep string, n int) []string {
+ return core.SplitN(s, sep, n)
+}
+
+// SplitSeq mirrors strings.SplitSeq.
+//
+func SplitSeq(s, sep string) iter.Seq[string] {
+ parts := Split(s, sep)
+ return func(yield func(string) bool) {
+ for _, part := range parts {
+ if !yield(part) {
+ return
+ }
+ }
+ }
+}
+
+// ToLower mirrors strings.ToLower.
+//
+func ToLower(s string) string {
+ return core.Lower(s)
+}
+
+// ToUpper mirrors strings.ToUpper.
+//
+func ToUpper(s string) string {
+ return core.Upper(s)
+}
+
+// TrimPrefix mirrors strings.TrimPrefix.
+//
+func TrimPrefix(s, prefix string) string {
+ return core.TrimPrefix(s, prefix)
+}
+
+// TrimSpace mirrors strings.TrimSpace.
+//
+func TrimSpace(s string) string {
+ return core.Trim(s)
+}
+
+// TrimSuffix mirrors strings.TrimSuffix.
+//
+func TrimSuffix(s, suffix string) string {
+ return core.TrimSuffix(s, suffix)
+}
diff --git a/jobrunner/forgejo/signals.go b/jobrunner/forgejo/signals.go
index 48faa79..875672b 100644
--- a/jobrunner/forgejo/signals.go
+++ b/jobrunner/forgejo/signals.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forgejo
import (
diff --git a/jobrunner/forgejo/source.go b/jobrunner/forgejo/source.go
index 61f8970..f8e65fe 100644
--- a/jobrunner/forgejo/source.go
+++ b/jobrunner/forgejo/source.go
@@ -1,27 +1,32 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package forgejo
import (
"context"
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ "dappco.re/go/core/log"
"dappco.re/go/core/scm/forge"
"dappco.re/go/core/scm/jobrunner"
- "dappco.re/go/core/log"
)
// Config configures a ForgejoSource.
+//
type Config struct {
Repos []string // "owner/repo" format
}
// ForgejoSource polls a Forgejo instance for pipeline signals from epic issues.
+//
type ForgejoSource struct {
repos []string
forge *forge.Client
}
// New creates a ForgejoSource using the given forge client.
+//
func New(cfg Config, client *forge.Client) *ForgejoSource {
return &ForgejoSource{
repos: cfg.Repos,
diff --git a/jobrunner/forgejo/source_test.go b/jobrunner/forgejo/source_test.go
index 965e765..3656891 100644
--- a/jobrunner/forgejo/source_test.go
+++ b/jobrunner/forgejo/source_test.go
@@ -2,10 +2,10 @@ package forgejo
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -35,7 +35,7 @@ func newTestClient(t *testing.T, url string) *forge.Client {
return client
}
-func TestForgejoSource_Name(t *testing.T) {
+func TestForgejoSource_Good_Name(t *testing.T) {
s := New(Config{}, nil)
assert.Equal(t, "forgejo", s.Name())
}
@@ -106,7 +106,7 @@ func TestForgejoSource_Poll_Good(t *testing.T) {
assert.Equal(t, "abc123", sig.LastCommitSHA)
}
-func TestForgejoSource_Poll_NoEpics(t *testing.T) {
+func TestForgejoSource_Poll_Good_NoEpics(t *testing.T) {
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]any{})
@@ -152,18 +152,18 @@ func TestForgejoSource_Report_Good(t *testing.T) {
assert.Contains(t, capturedBody, "succeeded")
}
-func TestParseEpicChildren(t *testing.T) {
+func TestParseEpicChildren_Good(t *testing.T) {
body := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n- [x] #3\n"
unchecked, checked := parseEpicChildren(body)
assert.Equal(t, []int{7, 8}, unchecked)
assert.Equal(t, []int{1, 3}, checked)
}
-func TestFindLinkedPR(t *testing.T) {
+func TestFindLinkedPR_Good(t *testing.T) {
assert.Nil(t, findLinkedPR(nil, 7))
}
-func TestSplitRepo(t *testing.T) {
+func TestSplitRepo_Good(t *testing.T) {
owner, repo, err := splitRepo("host-uk/core")
require.NoError(t, err)
assert.Equal(t, "host-uk", owner)
diff --git a/jobrunner/handlers/completion.go b/jobrunner/handlers/completion.go
index 0c9b40e..61e5ec3 100644
--- a/jobrunner/handlers/completion.go
+++ b/jobrunner/handlers/completion.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
coreerr "dappco.re/go/core/log"
@@ -11,15 +13,18 @@ import (
)
const (
+ //
ColorAgentComplete = "#0e8a16" // Green
)
// CompletionHandler manages issue state when an agent finishes work.
+//
type CompletionHandler struct {
forge *forge.Client
}
// NewCompletionHandler creates a handler for agent completion events.
+//
func NewCompletionHandler(client *forge.Client) *CompletionHandler {
return &CompletionHandler{
forge: client,
diff --git a/jobrunner/handlers/dispatch.go b/jobrunner/handlers/dispatch.go
index fbd83e2..e1196e7 100644
--- a/jobrunner/handlers/dispatch.go
+++ b/jobrunner/handlers/dispatch.go
@@ -1,11 +1,14 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"bytes"
"context"
- "encoding/json"
- "fmt"
- "path/filepath"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ "path"
"time"
coreerr "dappco.re/go/core/log"
@@ -15,17 +18,24 @@ import (
)
const (
- LabelAgentReady = "agent-ready"
- LabelInProgress = "in-progress"
- LabelAgentFailed = "agent-failed"
+ //
+ LabelAgentReady = "agent-ready"
+ //
+ LabelInProgress = "in-progress"
+ //
+ LabelAgentFailed = "agent-failed"
+ //
LabelAgentComplete = "agent-completed"
- ColorInProgress = "#1d76db" // Blue
+ //
+ ColorInProgress = "#1d76db" // Blue
+ //
ColorAgentFailed = "#c0392b" // Red
)
// DispatchTicket is the JSON payload written to the agent's queue.
// The ForgeToken is transferred separately via a .env file with 0600 permissions.
+//
type DispatchTicket struct {
ID string `json:"id"`
RepoOwner string `json:"repo_owner"`
@@ -45,6 +55,7 @@ type DispatchTicket struct {
}
// DispatchHandler dispatches coding work to remote agent machines via SSH.
+//
type DispatchHandler struct {
forge *forge.Client
forgeURL string
@@ -53,6 +64,7 @@ type DispatchHandler struct {
}
// NewDispatchHandler creates a handler that dispatches tickets to agent machines.
+//
func NewDispatchHandler(client *forge.Client, forgeURL, token string, spinner *agentci.Spinner) *DispatchHandler {
return &DispatchHandler{
forge: client,
@@ -85,6 +97,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
if !ok {
return nil, coreerr.E("dispatch.Execute", "unknown agent: "+signal.Assignee, nil)
}
+ queueDir, err := agentci.ValidateRemoteDir(agent.QueueDir)
+ if err != nil {
+ return nil, coreerr.E("dispatch.Execute", "invalid agent queue dir", err)
+ }
// Sanitize inputs to prevent path traversal.
safeOwner, err := agentci.SanitizePath(signal.RepoOwner)
@@ -184,7 +200,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
}
// Transfer ticket JSON.
- remoteTicketPath := filepath.Join(agent.QueueDir, ticketName)
+ remoteTicketPath, err := agentci.JoinRemotePath(queueDir, ticketName)
+ if err != nil {
+ return nil, coreerr.E("dispatch.Execute", "ticket path", err)
+ }
if err := h.secureTransfer(ctx, agent, remoteTicketPath, ticketJSON, 0644); err != nil {
h.failDispatch(signal, fmt.Sprintf("Ticket transfer failed: %v", err))
return &jobrunner.ActionResult{
@@ -202,10 +221,13 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
// Transfer token via separate .env file with 0600 permissions.
envContent := fmt.Sprintf("FORGE_TOKEN=%s\n", h.token)
- remoteEnvPath := filepath.Join(agent.QueueDir, fmt.Sprintf(".env.%s", ticketID))
+ remoteEnvPath, err := agentci.JoinRemotePath(queueDir, fmt.Sprintf(".env.%s", ticketID))
+ if err != nil {
+ return nil, coreerr.E("dispatch.Execute", "env path", err)
+ }
if err := h.secureTransfer(ctx, agent, remoteEnvPath, []byte(envContent), 0600); err != nil {
// Clean up the ticket if env transfer fails.
- _ = h.runRemote(ctx, agent, fmt.Sprintf("rm -f %s", agentci.EscapeShellArg(remoteTicketPath)))
+ _ = h.runRemote(ctx, agent, "rm", "-f", remoteTicketPath)
h.failDispatch(signal, fmt.Sprintf("Token transfer failed: %v", err))
return &jobrunner.ActionResult{
Action: "dispatch",
@@ -255,8 +277,8 @@ func (h *DispatchHandler) failDispatch(signal *jobrunner.PipelineSignal, reason
// secureTransfer writes data to a remote path via SSH stdin, preventing command injection.
func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.AgentConfig, remotePath string, data []byte, mode int) error {
- safeRemotePath := agentci.EscapeShellArg(remotePath)
- remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safeRemotePath, mode, safeRemotePath)
+ safePath := agentci.EscapeShellArg(remotePath)
+ remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safePath, mode, safePath)
cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd)
cmd.Stdin = bytes.NewReader(data)
@@ -269,21 +291,55 @@ func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.Agen
}
// runRemote executes a command on the agent via SSH.
-func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, cmdStr string) error {
- cmd := agentci.SecureSSHCommand(agent.Host, cmdStr)
+func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, command string, args ...string) error {
+ remoteCmd := command
+ if len(args) > 0 {
+ escaped := make([]string, 0, 1+len(args))
+ escaped = append(escaped, command)
+ for _, arg := range args {
+ escaped = append(escaped, agentci.EscapeShellArg(arg))
+ }
+ remoteCmd = strings.Join(escaped, " ")
+ }
+
+ cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd)
return cmd.Run()
}
// ticketExists checks if a ticket file already exists in queue, active, or done.
func (h *DispatchHandler) ticketExists(ctx context.Context, agent agentci.AgentConfig, ticketName string) bool {
- safeTicket, err := agentci.SanitizePath(ticketName)
+ queueDir, err := agentci.ValidateRemoteDir(agent.QueueDir)
if err != nil {
return false
}
- qDir := agent.QueueDir
+ safeTicket, err := agentci.ValidatePathElement(ticketName)
+ if err != nil {
+ return false
+ }
+
+ queuePath, err := agentci.JoinRemotePath(queueDir, safeTicket)
+ if err != nil {
+ return false
+ }
+ parentDir := queueDir
+ if queueDir != "/" && queueDir != "~" {
+ parentDir = path.Dir(queueDir)
+ }
+ activePath, err := agentci.JoinRemotePath(parentDir, "active", safeTicket)
+ if err != nil {
+ return false
+ }
+ donePath, err := agentci.JoinRemotePath(parentDir, "done", safeTicket)
+ if err != nil {
+ return false
+ }
+
+ queuePath = agentci.EscapeShellArg(queuePath)
+ activePath = agentci.EscapeShellArg(activePath)
+ donePath = agentci.EscapeShellArg(donePath)
checkCmd := fmt.Sprintf(
- "test -f %s/%s || test -f %s/../active/%s || test -f %s/../done/%s",
- qDir, safeTicket, qDir, safeTicket, qDir, safeTicket,
+ "test -f %s || test -f %s || test -f %s",
+ queuePath, activePath, donePath,
)
cmd := agentci.SecureSSHCommand(agent.Host, checkCmd)
return cmd.Run() == nil
diff --git a/jobrunner/handlers/dispatch_test.go b/jobrunner/handlers/dispatch_test.go
index f981207..251a838 100644
--- a/jobrunner/handlers/dispatch_test.go
+++ b/jobrunner/handlers/dispatch_test.go
@@ -2,9 +2,12 @@ package handlers
import (
"context"
- "encoding/json"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
"dappco.re/go/core/scm/agentci"
@@ -13,6 +16,18 @@ import (
"github.com/stretchr/testify/require"
)
+func writeFakeSSHCommand(t *testing.T, outputPath string) string {
+ t.Helper()
+ dir := t.TempDir()
+ script := filepath.Join(dir, "ssh")
+ scriptContent := "#!/bin/sh\n" +
+ "OUT=" + strconv.Quote(outputPath) + "\n" +
+ "printf '%s\n' \"$@\" >> \"$OUT\"\n" +
+ "cat >> \"${OUT}.stdin\"\n"
+ require.NoError(t, os.WriteFile(script, []byte(scriptContent), 0o755))
+ return dir
+}
+
// newTestSpinner creates a Spinner with the given agents for testing.
func newTestSpinner(agents map[string]agentci.AgentConfig) *agentci.Spinner {
return agentci.NewSpinner(agentci.ClothoConfig{Strategy: "direct"}, agents)
@@ -127,6 +142,29 @@ func TestDispatch_Execute_Bad_UnknownAgent(t *testing.T) {
assert.Contains(t, err.Error(), "unknown agent")
}
+func TestDispatch_Execute_Bad_InvalidQueueDir(t *testing.T) {
+ spinner := newTestSpinner(map[string]agentci.AgentConfig{
+ "darbs-claude": {
+ Host: "localhost",
+ QueueDir: "/tmp/queue; touch /tmp/pwned",
+ Active: true,
+ },
+ })
+ h := NewDispatchHandler(nil, "", "", spinner)
+
+ sig := &jobrunner.PipelineSignal{
+ NeedsCoding: true,
+ Assignee: "darbs-claude",
+ RepoOwner: "host-uk",
+ RepoName: "core",
+ ChildNumber: 1,
+ }
+
+ _, err := h.Execute(context.Background(), sig)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid agent queue dir")
+}
+
func TestDispatch_TicketJSON_Good(t *testing.T) {
ticket := DispatchTicket{
ID: "host-uk-core-5-1234567890",
@@ -214,6 +252,53 @@ func TestDispatch_TicketJSON_Good_OmitsEmptyModelRunner(t *testing.T) {
assert.False(t, hasRunner, "runner should be omitted when empty")
}
+func TestDispatch_runRemote_Good_EscapesPath(t *testing.T) {
+ outputPath := filepath.Join(t.TempDir(), "ssh-output.txt")
+ toolPath := writeFakeSSHCommand(t, outputPath)
+ t.Setenv("PATH", toolPath+":"+os.Getenv("PATH"))
+
+ h := NewDispatchHandler(nil, "", "", newTestSpinner(nil))
+ dangerousPath := "/tmp/queue with spaces; touch /tmp/pwned"
+ err := h.runRemote(
+ context.Background(),
+ agentci.AgentConfig{Host: "localhost"},
+ "rm",
+ "-f",
+ dangerousPath,
+ )
+ require.NoError(t, err)
+
+ output, err := os.ReadFile(outputPath)
+ require.NoError(t, err)
+ assert.Contains(t, string(output), "rm '-f' '"+dangerousPath+"'\n")
+}
+
+func TestDispatch_secureTransfer_Good_EscapesPath(t *testing.T) {
+ outputPath := filepath.Join(t.TempDir(), "ssh-output.txt")
+ toolPath := writeFakeSSHCommand(t, outputPath)
+ t.Setenv("PATH", toolPath+":"+os.Getenv("PATH"))
+
+ h := NewDispatchHandler(nil, "", "", newTestSpinner(nil))
+ dangerousPath := "/tmp/queue with spaces; touch /tmp/pwned"
+ err := h.secureTransfer(
+ context.Background(),
+ agentci.AgentConfig{Host: "localhost"},
+ dangerousPath,
+ []byte("hello"),
+ 0644,
+ )
+ require.NoError(t, err)
+
+ output, err := os.ReadFile(outputPath)
+ require.NoError(t, err)
+ assert.Contains(t, string(output), "cat > '"+dangerousPath+"' && chmod 644 '"+dangerousPath+"'")
+
+ inputPath := outputPath + ".stdin"
+ input, err := os.ReadFile(inputPath)
+ require.NoError(t, err)
+ assert.Equal(t, "hello", string(input))
+}
+
func TestDispatch_TicketJSON_Good_ModelRunnerVariants(t *testing.T) {
tests := []struct {
name string
diff --git a/jobrunner/handlers/enable_auto_merge.go b/jobrunner/handlers/enable_auto_merge.go
index 7ab4d30..fcae794 100644
--- a/jobrunner/handlers/enable_auto_merge.go
+++ b/jobrunner/handlers/enable_auto_merge.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
"dappco.re/go/core/scm/forge"
@@ -10,11 +12,13 @@ import (
)
// EnableAutoMergeHandler merges a PR that is ready using squash strategy.
+//
type EnableAutoMergeHandler struct {
forge *forge.Client
}
// NewEnableAutoMergeHandler creates a handler that merges ready PRs.
+//
func NewEnableAutoMergeHandler(f *forge.Client) *EnableAutoMergeHandler {
return &EnableAutoMergeHandler{forge: f}
}
diff --git a/jobrunner/handlers/enable_auto_merge_test.go b/jobrunner/handlers/enable_auto_merge_test.go
index 9a5feac..0242e60 100644
--- a/jobrunner/handlers/enable_auto_merge_test.go
+++ b/jobrunner/handlers/enable_auto_merge_test.go
@@ -2,7 +2,7 @@ package handlers
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/jobrunner/handlers/publish_draft.go b/jobrunner/handlers/publish_draft.go
index 202726b..419e726 100644
--- a/jobrunner/handlers/publish_draft.go
+++ b/jobrunner/handlers/publish_draft.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
"dappco.re/go/core/scm/forge"
@@ -10,11 +12,13 @@ import (
)
// PublishDraftHandler marks a draft PR as ready for review once its checks pass.
+//
type PublishDraftHandler struct {
forge *forge.Client
}
// NewPublishDraftHandler creates a handler that publishes draft PRs.
+//
func NewPublishDraftHandler(f *forge.Client) *PublishDraftHandler {
return &PublishDraftHandler{forge: f}
}
diff --git a/jobrunner/handlers/resolve_threads.go b/jobrunner/handlers/resolve_threads.go
index 19f8480..51dc23d 100644
--- a/jobrunner/handlers/resolve_threads.go
+++ b/jobrunner/handlers/resolve_threads.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
@@ -15,11 +17,13 @@ import (
// DismissReviewsHandler dismisses stale "request changes" reviews on a PR.
// This replaces the GitHub-only ResolveThreadsHandler because Forgejo does
// not have a thread resolution API.
+//
type DismissReviewsHandler struct {
forge *forge.Client
}
// NewDismissReviewsHandler creates a handler that dismisses stale reviews.
+//
func NewDismissReviewsHandler(f *forge.Client) *DismissReviewsHandler {
return &DismissReviewsHandler{forge: f}
}
diff --git a/jobrunner/handlers/resolve_threads_test.go b/jobrunner/handlers/resolve_threads_test.go
index ec9dfd6..6e85c6a 100644
--- a/jobrunner/handlers/resolve_threads_test.go
+++ b/jobrunner/handlers/resolve_threads_test.go
@@ -2,7 +2,7 @@ package handlers
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/jobrunner/handlers/send_fix_command.go b/jobrunner/handlers/send_fix_command.go
index 5b65eab..b10b222 100644
--- a/jobrunner/handlers/send_fix_command.go
+++ b/jobrunner/handlers/send_fix_command.go
@@ -1,8 +1,10 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
"time"
"dappco.re/go/core/scm/forge"
@@ -11,11 +13,13 @@ import (
// SendFixCommandHandler posts a comment on a PR asking for conflict or
// review fixes.
+//
type SendFixCommandHandler struct {
forge *forge.Client
}
// NewSendFixCommandHandler creates a handler that posts fix commands.
+//
func NewSendFixCommandHandler(f *forge.Client) *SendFixCommandHandler {
return &SendFixCommandHandler{forge: f}
}
diff --git a/jobrunner/handlers/testhelper_test.go b/jobrunner/handlers/testhelper_test.go
index 20d966b..fb78991 100644
--- a/jobrunner/handlers/testhelper_test.go
+++ b/jobrunner/handlers/testhelper_test.go
@@ -1,8 +1,8 @@
package handlers
import (
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"net/http"
- "strings"
"testing"
"github.com/stretchr/testify/require"
diff --git a/jobrunner/handlers/tick_parent.go b/jobrunner/handlers/tick_parent.go
index 2d4bb74..9b04599 100644
--- a/jobrunner/handlers/tick_parent.go
+++ b/jobrunner/handlers/tick_parent.go
@@ -1,9 +1,11 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package handlers
import (
"context"
- "fmt"
- "strings"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"time"
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
@@ -15,11 +17,13 @@ import (
// TickParentHandler ticks a child checkbox in the parent epic issue body
// after the child's PR has been merged.
+//
type TickParentHandler struct {
forge *forge.Client
}
// NewTickParentHandler creates a handler that ticks parent epic checkboxes.
+//
func NewTickParentHandler(f *forge.Client) *TickParentHandler {
return &TickParentHandler{forge: f}
}
diff --git a/jobrunner/handlers/tick_parent_test.go b/jobrunner/handlers/tick_parent_test.go
index 836ecdf..3155210 100644
--- a/jobrunner/handlers/tick_parent_test.go
+++ b/jobrunner/handlers/tick_parent_test.go
@@ -2,11 +2,11 @@ package handlers
import (
"context"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"io"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"github.com/stretchr/testify/assert"
diff --git a/jobrunner/journal.go b/jobrunner/journal.go
index 2e3976b..e3605a5 100644
--- a/jobrunner/journal.go
+++ b/jobrunner/journal.go
@@ -1,21 +1,24 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package jobrunner
import (
- "encoding/json"
- "os"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"regexp"
- "strings"
"sync"
- coreerr "dappco.re/go/core/log"
coreio "dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
)
// validPathComponent matches safe repo owner/name characters (alphanumeric, hyphen, underscore, dot).
var validPathComponent = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// JournalEntry is a single line in the JSONL audit log.
+//
type JournalEntry struct {
Timestamp string `json:"ts"`
Epic int `json:"epic"`
@@ -29,6 +32,7 @@ type JournalEntry struct {
}
// SignalSnapshot captures the structural state of a PR at the time of action.
+//
type SignalSnapshot struct {
PRState string `json:"pr_state"`
IsDraft bool `json:"is_draft"`
@@ -39,6 +43,7 @@ type SignalSnapshot struct {
}
// ResultSnapshot captures the outcome of an action.
+//
type ResultSnapshot struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
@@ -46,12 +51,14 @@ type ResultSnapshot struct {
}
// Journal writes ActionResult entries to date-partitioned JSONL files.
+//
type Journal struct {
baseDir string
mu sync.Mutex
}
// NewJournal creates a new Journal rooted at baseDir.
+//
func NewJournal(baseDir string) (*Journal, error) {
if baseDir == "" {
return nil, coreerr.E("jobrunner.NewJournal", "base directory is required", nil)
diff --git a/jobrunner/journal_test.go b/jobrunner/journal_test.go
index a17a88b..b127eb7 100644
--- a/jobrunner/journal_test.go
+++ b/jobrunner/journal_test.go
@@ -2,10 +2,10 @@ package jobrunner
import (
"bufio"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"testing"
"time"
diff --git a/jobrunner/poller.go b/jobrunner/poller.go
index 302c563..bb0700d 100644
--- a/jobrunner/poller.go
+++ b/jobrunner/poller.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package jobrunner
import (
@@ -9,6 +11,7 @@ import (
)
// PollerConfig configures a Poller.
+//
type PollerConfig struct {
Sources []JobSource
Handlers []JobHandler
@@ -18,6 +21,7 @@ type PollerConfig struct {
}
// Poller discovers signals from sources and dispatches them to handlers.
+//
type Poller struct {
mu sync.RWMutex
sources []JobSource
@@ -29,6 +33,7 @@ type Poller struct {
}
// NewPoller creates a Poller from the given config.
+//
func NewPoller(cfg PollerConfig) *Poller {
interval := cfg.PollInterval
if interval <= 0 {
diff --git a/jobrunner/types.go b/jobrunner/types.go
index ce51caf..023793b 100644
--- a/jobrunner/types.go
+++ b/jobrunner/types.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package jobrunner
import (
@@ -7,6 +9,7 @@ import (
// PipelineSignal is the structural snapshot of a child issue/PR.
// Carries structural state plus issue title/body for dispatch prompts.
+//
type PipelineSignal struct {
EpicNumber int
ChildNumber int
@@ -43,6 +46,7 @@ func (s *PipelineSignal) HasUnresolvedThreads() bool {
}
// ActionResult carries the outcome of a handler execution.
+//
type ActionResult struct {
Action string `json:"action"`
RepoOwner string `json:"repo_owner"`
@@ -58,6 +62,7 @@ type ActionResult struct {
}
// JobSource discovers actionable work from an external system.
+//
type JobSource interface {
Name() string
Poll(ctx context.Context) ([]*PipelineSignal, error)
@@ -65,6 +70,7 @@ type JobSource interface {
}
// JobHandler processes a single pipeline signal.
+//
type JobHandler interface {
Name() string
Match(signal *PipelineSignal) bool
diff --git a/jobrunner/types_test.go b/jobrunner/types_test.go
index c81a840..c16c4d4 100644
--- a/jobrunner/types_test.go
+++ b/jobrunner/types_test.go
@@ -1,7 +1,7 @@
package jobrunner
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"testing"
"time"
diff --git a/locales/embed.go b/locales/embed.go
index 410cb55..4829926 100644
--- a/locales/embed.go
+++ b/locales/embed.go
@@ -1,7 +1,11 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package locales embeds translation files for this module.
package locales
import "embed"
+//
+//
//go:embed *.json
var FS embed.FS
diff --git a/manifest/compile.go b/manifest/compile.go
index d9f00ea..fff7da6 100644
--- a/manifest/compile.go
+++ b/manifest/compile.go
@@ -1,18 +1,21 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package manifest
import (
"crypto/ed25519"
- "encoding/json"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"time"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
)
// CompiledManifest is the distribution-ready form of a manifest, written as
// core.json at the repository root (not inside .core/). It embeds the
// original Manifest and adds build metadata stapled at compile time.
+//
type CompiledManifest struct {
Manifest `json:",inline" yaml:",inline"`
@@ -24,15 +27,17 @@ type CompiledManifest struct {
}
// CompileOptions controls how Compile populates the build metadata.
+//
type CompileOptions struct {
- Commit string // Git commit hash
- Tag string // Git tag (e.g. v1.0.0)
- BuiltBy string // Builder identity (e.g. "core build")
+ Commit string // Git commit hash
+ Tag string // Git tag (e.g. v1.0.0)
+ BuiltBy string // Builder identity (e.g. "core build")
SignKey ed25519.PrivateKey // Optional — signs before compiling
}
// Compile produces a CompiledManifest from a source manifest and build
// options. If opts.SignKey is provided the manifest is signed first.
+//
func Compile(m *Manifest, opts CompileOptions) (*CompiledManifest, error) {
if m == nil {
return nil, coreerr.E("manifest.Compile", "nil manifest", nil)
@@ -61,11 +66,13 @@ func Compile(m *Manifest, opts CompileOptions) (*CompiledManifest, error) {
}
// MarshalJSON serialises a CompiledManifest to JSON bytes.
+//
func MarshalJSON(cm *CompiledManifest) ([]byte, error) {
return json.MarshalIndent(cm, "", " ")
}
// ParseCompiled decodes a core.json into a CompiledManifest.
+//
func ParseCompiled(data []byte) (*CompiledManifest, error) {
var cm CompiledManifest
if err := json.Unmarshal(data, &cm); err != nil {
@@ -78,6 +85,7 @@ const compiledPath = "core.json"
// WriteCompiled writes a CompiledManifest as core.json to the given root
// directory. The file lives at the distribution root, not inside .core/.
+//
func WriteCompiled(medium io.Medium, root string, cm *CompiledManifest) error {
data, err := MarshalJSON(cm)
if err != nil {
@@ -88,6 +96,7 @@ func WriteCompiled(medium io.Medium, root string, cm *CompiledManifest) error {
}
// LoadCompiled reads and parses a core.json from the given root directory.
+//
func LoadCompiled(medium io.Medium, root string) (*CompiledManifest, error) {
path := filepath.Join(root, compiledPath)
data, err := medium.Read(path)
diff --git a/manifest/compile_test.go b/manifest/compile_test.go
index 09cdaa0..8e8655f 100644
--- a/manifest/compile_test.go
+++ b/manifest/compile_test.go
@@ -3,7 +3,7 @@ package manifest
import (
"crypto/ed25519"
"crypto/rand"
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"testing"
"dappco.re/go/core/io"
diff --git a/manifest/loader.go b/manifest/loader.go
index b454690..cef4efa 100644
--- a/manifest/loader.go
+++ b/manifest/loader.go
@@ -1,22 +1,26 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package manifest
import (
"crypto/ed25519"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
const manifestPath = ".core/manifest.yaml"
// MarshalYAML serializes a manifest to YAML bytes.
+//
func MarshalYAML(m *Manifest) ([]byte, error) {
return yaml.Marshal(m)
}
// Load reads and parses a .core/manifest.yaml from the given root directory.
+//
func Load(medium io.Medium, root string) (*Manifest, error) {
path := filepath.Join(root, manifestPath)
data, err := medium.Read(path)
@@ -27,6 +31,7 @@ func Load(medium io.Medium, root string) (*Manifest, error) {
}
// LoadVerified reads, parses, and verifies the ed25519 signature.
+//
func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) {
m, err := Load(medium, root)
if err != nil {
diff --git a/manifest/manifest.go b/manifest/manifest.go
index f0eede9..7a3bf14 100644
--- a/manifest/manifest.go
+++ b/manifest/manifest.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package manifest
import (
@@ -6,6 +8,7 @@ import (
)
// Manifest represents a .core/manifest.yaml application manifest.
+//
type Manifest struct {
Code string `yaml:"code" json:"code"`
Name string `yaml:"name" json:"name"`
@@ -25,12 +28,13 @@ type Manifest struct {
Element *ElementSpec `yaml:"element,omitempty" json:"element,omitempty"` // Custom element for GUI rendering
Spec string `yaml:"spec,omitempty" json:"spec,omitempty"` // Path to OpenAPI spec file
- Permissions Permissions `yaml:"permissions,omitempty" json:"permissions,omitempty"`
- Modules []string `yaml:"modules,omitempty" json:"modules,omitempty"`
- Daemons map[string]DaemonSpec `yaml:"daemons,omitempty" json:"daemons,omitempty"`
+ Permissions Permissions `yaml:"permissions,omitempty" json:"permissions,omitempty"`
+ Modules []string `yaml:"modules,omitempty" json:"modules,omitempty"`
+ Daemons map[string]DaemonSpec `yaml:"daemons,omitempty" json:"daemons,omitempty"`
}
// ElementSpec describes a web component for GUI rendering.
+//
type ElementSpec struct {
// Tag is the custom element tag name, e.g. "core-cool-widget".
Tag string `yaml:"tag" json:"tag"`
@@ -46,6 +50,7 @@ func (m *Manifest) IsProvider() bool {
}
// Permissions declares the I/O capabilities a module requires.
+//
type Permissions struct {
Read []string `yaml:"read" json:"read"`
Write []string `yaml:"write" json:"write"`
@@ -54,6 +59,7 @@ type Permissions struct {
}
// DaemonSpec describes a long-running process managed by the runtime.
+//
type DaemonSpec struct {
Binary string `yaml:"binary,omitempty" json:"binary,omitempty"`
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
@@ -62,6 +68,8 @@ type DaemonSpec struct {
}
// Parse decodes YAML bytes into a Manifest.
+//
+// m, err := manifest.Parse(yamlBytes)
func Parse(data []byte) (*Manifest, error) {
var m Manifest
if err := yaml.Unmarshal(data, &m); err != nil {
diff --git a/manifest/sign.go b/manifest/sign.go
index 7eb922f..6934274 100644
--- a/manifest/sign.go
+++ b/manifest/sign.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package manifest
import (
@@ -16,6 +18,7 @@ func signable(m *Manifest) ([]byte, error) {
}
// Sign computes the ed25519 signature and stores it in m.Sign (base64).
+//
func Sign(m *Manifest, priv ed25519.PrivateKey) error {
msg, err := signable(m)
if err != nil {
@@ -27,6 +30,7 @@ func Sign(m *Manifest, priv ed25519.PrivateKey) error {
}
// Verify checks the ed25519 signature in m.Sign against the public key.
+//
func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) {
if m.Sign == "" {
return false, coreerr.E("manifest.Verify", "no signature present", nil)
diff --git a/marketplace/builder.go b/marketplace/builder.go
index c85f182..8757a14 100644
--- a/marketplace/builder.go
+++ b/marketplace/builder.go
@@ -1,22 +1,27 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package marketplace
import (
- "encoding/json"
- "log"
- "os"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
"sort"
- coreerr "dappco.re/go/core/log"
+ core "dappco.re/go/core"
+
coreio "dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"dappco.re/go/core/scm/manifest"
)
// IndexVersion is the current marketplace index format version.
+//
const IndexVersion = 1
// Builder constructs a marketplace Index by crawling directories for
// core.json (compiled manifests) or .core/manifest.yaml files.
+//
type Builder struct {
// BaseURL is the prefix for constructing repository URLs, e.g.
// "https://forge.lthn.ai". When set, module Repo is derived as
@@ -50,7 +55,7 @@ func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) {
m, err := b.loadFromDir(filepath.Join(dir, e.Name()))
if err != nil {
- log.Printf("marketplace: skipping %s: %v", e.Name(), err)
+ core.Warn(core.Sprintf("marketplace: skipping %s: %v", e.Name(), err))
continue
}
if m == nil {
@@ -83,6 +88,7 @@ func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) {
// BuildFromManifests constructs an Index from pre-loaded manifests.
// This is useful when manifests have already been collected (e.g. from
// a Forge API crawl).
+//
func BuildFromManifests(manifests []*manifest.Manifest) *Index {
var modules []Module
seen := make(map[string]bool)
@@ -113,6 +119,7 @@ func BuildFromManifests(manifests []*manifest.Manifest) *Index {
}
// WriteIndex serialises an Index to JSON and writes it to the given path.
+//
func WriteIndex(path string, idx *Index) error {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return coreerr.E("marketplace.WriteIndex", "mkdir failed", err)
diff --git a/marketplace/builder_test.go b/marketplace/builder_test.go
index baf3121..97920c8 100644
--- a/marketplace/builder_test.go
+++ b/marketplace/builder_test.go
@@ -1,9 +1,9 @@
package marketplace
import (
- "encoding/json"
- "os"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
"testing"
"dappco.re/go/core/scm/manifest"
diff --git a/marketplace/discovery.go b/marketplace/discovery.go
index e9e8d89..d315384 100644
--- a/marketplace/discovery.go
+++ b/marketplace/discovery.go
@@ -1,17 +1,20 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package marketplace
import (
- "log"
- "os"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
- coreerr "dappco.re/go/core/log"
+ core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"dappco.re/go/core/scm/manifest"
"gopkg.in/yaml.v3"
)
// DiscoveredProvider represents a runtime provider found on disk.
+//
type DiscoveredProvider struct {
// Dir is the absolute path to the provider directory.
Dir string
@@ -24,6 +27,7 @@ type DiscoveredProvider struct {
// Each subdirectory is checked for a .core/manifest.yaml file. Directories
// without a valid manifest are skipped with a log warning.
// Only manifests with provider fields (namespace + binary) are returned.
+//
func DiscoverProviders(dir string) ([]DiscoveredProvider, error) {
entries, err := os.ReadDir(dir)
if err != nil {
@@ -44,18 +48,18 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) {
raw, err := coreio.Local.Read(manifestPath)
if err != nil {
- log.Printf("marketplace: skipping %s: %v", e.Name(), err)
+ core.Warn(core.Sprintf("marketplace: skipping %s: %v", e.Name(), err))
continue
}
m, err := manifest.Parse([]byte(raw))
if err != nil {
- log.Printf("marketplace: skipping %s: invalid manifest: %v", e.Name(), err)
+ core.Warn(core.Sprintf("marketplace: skipping %s: invalid manifest: %v", e.Name(), err))
continue
}
if !m.IsProvider() {
- log.Printf("marketplace: skipping %s: not a provider (missing namespace or binary)", e.Name())
+ core.Warn(core.Sprintf("marketplace: skipping %s: not a provider (missing namespace or binary)", e.Name()))
continue
}
@@ -69,6 +73,7 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) {
}
// ProviderRegistryEntry records metadata about an installed provider.
+//
type ProviderRegistryEntry struct {
Installed string `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
@@ -77,6 +82,7 @@ type ProviderRegistryEntry struct {
}
// ProviderRegistryFile represents the registry.yaml file tracking installed providers.
+//
type ProviderRegistryFile struct {
Version int `yaml:"version" json:"version"`
Providers map[string]ProviderRegistryEntry `yaml:"providers" json:"providers"`
@@ -84,6 +90,7 @@ type ProviderRegistryFile struct {
// LoadProviderRegistry reads a registry.yaml file from the given path.
// Returns an empty registry if the file does not exist.
+//
func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) {
raw, err := coreio.Local.Read(path)
if err != nil {
@@ -109,6 +116,7 @@ func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) {
}
// SaveProviderRegistry writes the registry to the given path.
+//
func SaveProviderRegistry(path string, reg *ProviderRegistryFile) error {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return coreerr.E("marketplace.SaveProviderRegistry", "ensure directory", err)
diff --git a/marketplace/discovery_test.go b/marketplace/discovery_test.go
index c96c9c8..5edb8c8 100644
--- a/marketplace/discovery_test.go
+++ b/marketplace/discovery_test.go
@@ -1,8 +1,8 @@
package marketplace
import (
- "os"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
"testing"
"github.com/stretchr/testify/assert"
diff --git a/marketplace/installer.go b/marketplace/installer.go
index 50a9686..87ba272 100644
--- a/marketplace/installer.go
+++ b/marketplace/installer.go
@@ -1,23 +1,27 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package marketplace
import (
"context"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
"encoding/hex"
- "encoding/json"
- "os/exec"
- "path/filepath"
- "strings"
+ exec "golang.org/x/sys/execabs"
"time"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
- "dappco.re/go/core/scm/manifest"
"dappco.re/go/core/io/store"
+ coreerr "dappco.re/go/core/log"
+ "dappco.re/go/core/scm/agentci"
+ "dappco.re/go/core/scm/manifest"
)
const storeGroup = "_modules"
// Installer handles module installation from Git repos.
+//
type Installer struct {
medium io.Medium
modulesDir string
@@ -25,6 +29,7 @@ type Installer struct {
}
// NewInstaller creates a new module installer.
+//
func NewInstaller(m io.Medium, modulesDir string, st *store.Store) *Installer {
return &Installer{
medium: m,
@@ -34,6 +39,7 @@ func NewInstaller(m io.Medium, modulesDir string, st *store.Store) *Installer {
}
// InstalledModule holds stored metadata about an installed module.
+//
type InstalledModule struct {
Code string `json:"code"`
Name string `json:"name"`
@@ -47,12 +53,16 @@ type InstalledModule struct {
// Install clones a module repo, verifies its manifest signature, and registers it.
func (i *Installer) Install(ctx context.Context, mod Module) error {
- // Check if already installed
- if _, err := i.store.Get(storeGroup, mod.Code); err == nil {
- return coreerr.E("marketplace.Installer.Install", "module already installed: "+mod.Code, nil)
+ safeCode, dest, err := i.resolveModulePath(mod.Code)
+ if err != nil {
+ return coreerr.E("marketplace.Installer.Install", "invalid module code", err)
+ }
+
+ // Check if already installed
+ if _, err := i.store.Get(storeGroup, safeCode); err == nil {
+ return coreerr.E("marketplace.Installer.Install", "module already installed: "+safeCode, nil)
}
- dest := filepath.Join(i.modulesDir, mod.Code)
if err := i.medium.EnsureDir(i.modulesDir); err != nil {
return coreerr.E("marketplace.Installer.Install", "mkdir", err)
}
@@ -80,7 +90,7 @@ func (i *Installer) Install(ctx context.Context, mod Module) error {
entryPoint := filepath.Join(dest, "main.ts")
installed := InstalledModule{
- Code: mod.Code,
+ Code: safeCode,
Name: m.Name,
Version: m.Version,
Repo: mod.Repo,
@@ -95,7 +105,7 @@ func (i *Installer) Install(ctx context.Context, mod Module) error {
return coreerr.E("marketplace.Installer.Install", "marshal", err)
}
- if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil {
+ if err := i.store.Set(storeGroup, safeCode, string(data)); err != nil {
return coreerr.E("marketplace.Installer.Install", "store", err)
}
@@ -105,21 +115,32 @@ func (i *Installer) Install(ctx context.Context, mod Module) error {
// Remove uninstalls a module by deleting its files and store entry.
func (i *Installer) Remove(code string) error {
- if _, err := i.store.Get(storeGroup, code); err != nil {
- return coreerr.E("marketplace.Installer.Remove", "module not installed: "+code, nil)
+ safeCode, dest, err := i.resolveModulePath(code)
+ if err != nil {
+ return coreerr.E("marketplace.Installer.Remove", "invalid module code", err)
}
- dest := filepath.Join(i.modulesDir, code)
- _ = i.medium.DeleteAll(dest)
+ if _, err := i.store.Get(storeGroup, safeCode); err != nil {
+ return coreerr.E("marketplace.Installer.Remove", "module not installed: "+safeCode, nil)
+ }
- return i.store.Delete(storeGroup, code)
+ if err := i.medium.DeleteAll(dest); err != nil {
+ return coreerr.E("marketplace.Installer.Remove", "delete module files", err)
+ }
+
+ return i.store.Delete(storeGroup, safeCode)
}
// Update pulls latest changes and re-verifies the manifest.
func (i *Installer) Update(ctx context.Context, code string) error {
- raw, err := i.store.Get(storeGroup, code)
+ safeCode, dest, err := i.resolveModulePath(code)
if err != nil {
- return coreerr.E("marketplace.Installer.Update", "module not installed: "+code, nil)
+ return coreerr.E("marketplace.Installer.Update", "invalid module code", err)
+ }
+
+ raw, err := i.store.Get(storeGroup, safeCode)
+ if err != nil {
+ return coreerr.E("marketplace.Installer.Update", "module not installed: "+safeCode, nil)
}
var installed InstalledModule
@@ -127,8 +148,6 @@ func (i *Installer) Update(ctx context.Context, code string) error {
return coreerr.E("marketplace.Installer.Update", "unmarshal", err)
}
- dest := filepath.Join(i.modulesDir, code)
-
cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only")
if output, err := cmd.CombinedOutput(); err != nil {
return coreerr.E("marketplace.Installer.Update", "pull: "+strings.TrimSpace(string(output)), err)
@@ -145,6 +164,7 @@ func (i *Installer) Update(ctx context.Context, code string) error {
}
// Update stored metadata
+ installed.Code = safeCode
installed.Name = m.Name
installed.Version = m.Version
installed.Permissions = m.Permissions
@@ -154,7 +174,7 @@ func (i *Installer) Update(ctx context.Context, code string) error {
return coreerr.E("marketplace.Installer.Update", "marshal", err)
}
- return i.store.Set(storeGroup, code, string(data))
+ return i.store.Set(storeGroup, safeCode, string(data))
}
// Installed returns all installed module metadata.
@@ -195,3 +215,11 @@ func gitClone(ctx context.Context, repo, dest string) error {
}
return nil
}
+
+func (i *Installer) resolveModulePath(code string) (string, string, error) {
+ safeCode, dest, err := agentci.ResolvePathWithinRoot(i.modulesDir, code)
+ if err != nil {
+ return "", "", coreerr.E("marketplace.Installer.resolveModulePath", "resolve module path", err)
+ }
+ return safeCode, dest, nil
+}
diff --git a/marketplace/installer_test.go b/marketplace/installer_test.go
index 358e69a..803e2a7 100644
--- a/marketplace/installer_test.go
+++ b/marketplace/installer_test.go
@@ -3,15 +3,15 @@ package marketplace
import (
"context"
"crypto/ed25519"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
"encoding/hex"
- "os"
- "os/exec"
- "path/filepath"
+ exec "golang.org/x/sys/execabs"
"testing"
"dappco.re/go/core/io"
- "dappco.re/go/core/scm/manifest"
"dappco.re/go/core/io/store"
+ "dappco.re/go/core/scm/manifest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -163,6 +163,29 @@ func TestInstall_Bad_InvalidSignature(t *testing.T) {
assert.True(t, os.IsNotExist(statErr), "directory should be cleaned up on failure")
}
+func TestInstall_Bad_PathTraversalCode(t *testing.T) {
+ repo := createTestRepo(t, "safe-mod", "1.0")
+ modulesDir := filepath.Join(t.TempDir(), "modules")
+
+ st, err := store.New(":memory:")
+ require.NoError(t, err)
+ defer st.Close()
+
+ inst := NewInstaller(io.Local, modulesDir, st)
+ err = inst.Install(context.Background(), Module{
+ Code: "../escape",
+ Repo: repo,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid module code")
+
+ _, err = st.Get("_modules", "escape")
+ assert.Error(t, err)
+
+ _, err = os.Stat(filepath.Join(filepath.Dir(modulesDir), "escape"))
+ assert.True(t, os.IsNotExist(err))
+}
+
func TestRemove_Good(t *testing.T) {
repo := createTestRepo(t, "rm-mod", "1.0")
modulesDir := filepath.Join(t.TempDir(), "modules")
@@ -197,6 +220,26 @@ func TestRemove_Bad_NotInstalled(t *testing.T) {
assert.Contains(t, err.Error(), "not installed")
}
+func TestRemove_Bad_PathTraversalCode(t *testing.T) {
+ baseDir := t.TempDir()
+ modulesDir := filepath.Join(baseDir, "modules")
+ escapeDir := filepath.Join(baseDir, "escape")
+ require.NoError(t, os.MkdirAll(escapeDir, 0755))
+
+ st, err := store.New(":memory:")
+ require.NoError(t, err)
+ defer st.Close()
+
+ inst := NewInstaller(io.Local, modulesDir, st)
+ err = inst.Remove("../escape")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid module code")
+
+ info, statErr := os.Stat(escapeDir)
+ require.NoError(t, statErr)
+ assert.True(t, info.IsDir())
+}
+
func TestInstalled_Good(t *testing.T) {
modulesDir := filepath.Join(t.TempDir(), "modules")
@@ -262,3 +305,16 @@ func TestUpdate_Good(t *testing.T) {
assert.Equal(t, "2.0", installed[0].Version)
assert.Equal(t, "Updated Module", installed[0].Name)
}
+
+func TestUpdate_Bad_PathTraversalCode(t *testing.T) {
+ modulesDir := filepath.Join(t.TempDir(), "modules")
+
+ st, err := store.New(":memory:")
+ require.NoError(t, err)
+ defer st.Close()
+
+ inst := NewInstaller(io.Local, modulesDir, st)
+ err = inst.Update(context.Background(), "../escape")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid module code")
+}
diff --git a/marketplace/marketplace.go b/marketplace/marketplace.go
index c97fe39..4e3d3f1 100644
--- a/marketplace/marketplace.go
+++ b/marketplace/marketplace.go
@@ -1,13 +1,16 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package marketplace
import (
- "encoding/json"
- "strings"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
coreerr "dappco.re/go/core/log"
)
// Module is a marketplace entry pointing to a module's Git repo.
+//
type Module struct {
Code string `json:"code"`
Name string `json:"name"`
@@ -17,6 +20,7 @@ type Module struct {
}
// Index is the root marketplace catalog.
+//
type Index struct {
Version int `json:"version"`
Modules []Module `json:"modules"`
@@ -24,6 +28,7 @@ type Index struct {
}
// ParseIndex decodes a marketplace index.json.
+//
func ParseIndex(data []byte) (*Index, error) {
var idx Index
if err := json.Unmarshal(data, &idx); err != nil {
diff --git a/pkg/api/embed.go b/pkg/api/embed.go
index 981cfb7..c245535 100644
--- a/pkg/api/embed.go
+++ b/pkg/api/embed.go
@@ -7,5 +7,7 @@ import "embed"
// Assets holds the built UI bundle (core-scm.js and related files).
// The directory is populated by running `npm run build` in the ui/ directory.
//
+//
+//
//go:embed all:ui/dist
var Assets embed.FS
diff --git a/pkg/api/provider.go b/pkg/api/provider.go
index 77475c9..74927ec 100644
--- a/pkg/api/provider.go
+++ b/pkg/api/provider.go
@@ -10,10 +10,12 @@ import (
"crypto/ed25519"
"encoding/hex"
"net/http"
+ "net/url"
"dappco.re/go/core/api"
"dappco.re/go/core/api/pkg/provider"
"dappco.re/go/core/io"
+ "dappco.re/go/core/scm/agentci"
"dappco.re/go/core/scm/manifest"
"dappco.re/go/core/scm/marketplace"
"dappco.re/go/core/scm/repos"
@@ -24,6 +26,7 @@ import (
// ScmProvider wraps go-scm marketplace, manifest, and registry operations
// as a service provider. It implements Provider, Streamable, Describable,
// and Renderable.
+//
type ScmProvider struct {
index *marketplace.Index
installer *marketplace.Installer
@@ -43,6 +46,7 @@ var (
// NewProvider creates an SCM provider backed by the given marketplace index,
// installer, and registry. The WS hub is used to emit real-time events.
// Pass nil for any dependency that is not available.
+//
func NewProvider(idx *marketplace.Index, inst *marketplace.Installer, reg *repos.Registry, hub *ws.Hub) *ScmProvider {
return &ScmProvider{
index: idx,
@@ -228,7 +232,10 @@ func (p *ScmProvider) getMarketplaceItem(c *gin.Context) {
return
}
- code := c.Param("code")
+ code, ok := marketplaceCodeParam(c)
+ if !ok {
+ return
+ }
mod, ok := p.index.Find(code)
if !ok {
c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace"))
@@ -243,7 +250,10 @@ func (p *ScmProvider) installItem(c *gin.Context) {
return
}
- code := c.Param("code")
+ code, ok := marketplaceCodeParam(c)
+ if !ok {
+ return
+ }
mod, ok := p.index.Find(code)
if !ok {
c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace"))
@@ -269,7 +279,10 @@ func (p *ScmProvider) removeItem(c *gin.Context) {
return
}
- code := c.Param("code")
+ code, ok := marketplaceCodeParam(c)
+ if !ok {
+ return
+ }
if err := p.installer.Remove(code); err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("remove_failed", err.Error()))
return
@@ -393,7 +406,10 @@ func (p *ScmProvider) updateInstalled(c *gin.Context) {
return
}
- code := c.Param("code")
+ code, ok := marketplaceCodeParam(c)
+ if !ok {
+ return
+ }
if err := p.installer.Update(context.Background(), code); err != nil {
c.JSON(http.StatusInternalServerError, api.Fail("update_failed", err.Error()))
return
@@ -448,3 +464,21 @@ func (p *ScmProvider) emitEvent(channel string, data any) {
Data: data,
})
}
+
+func marketplaceCodeParam(c *gin.Context) (string, bool) {
+ code, err := normaliseMarketplaceCode(c.Param("code"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, api.Fail("invalid_code", "invalid marketplace code"))
+ return "", false
+ }
+ return code, true
+}
+
+func normaliseMarketplaceCode(raw string) (string, error) {
+ decoded, err := url.PathUnescape(raw)
+ if err != nil {
+ return "", err
+ }
+
+ return agentci.ValidatePathElement(decoded)
+}
diff --git a/pkg/api/provider_handlers_test.go b/pkg/api/provider_handlers_test.go
index 80fb970..8e7ef9d 100644
--- a/pkg/api/provider_handlers_test.go
+++ b/pkg/api/provider_handlers_test.go
@@ -3,7 +3,7 @@
package api_test
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go
index 7e72509..d3177dc 100644
--- a/pkg/api/provider_test.go
+++ b/pkg/api/provider_test.go
@@ -3,7 +3,7 @@
package api_test
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"net/http"
"net/http/httptest"
"testing"
@@ -164,6 +164,18 @@ func TestScmProvider_GetMarketplaceItem_Bad(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
+func TestScmProvider_GetMarketplaceItem_Bad_PathTraversal(t *testing.T) {
+ idx := &marketplace.Index{Version: 1}
+ p := scmapi.NewProvider(idx, nil, nil, nil)
+
+ r := setupRouter(p)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/%2e%2e", nil)
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
// -- Installed Endpoints ------------------------------------------------------
func TestScmProvider_ListInstalled_NilInstaller_Good(t *testing.T) {
diff --git a/plugin/config.go b/plugin/config.go
index 3155489..2b5e4c5 100644
--- a/plugin/config.go
+++ b/plugin/config.go
@@ -1,6 +1,9 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package plugin
// PluginConfig holds configuration for a single installed plugin.
+//
type PluginConfig struct {
Name string `json:"name" yaml:"name"`
Version string `json:"version" yaml:"version"`
diff --git a/plugin/installer.go b/plugin/installer.go
index d98c59c..723902e 100644
--- a/plugin/installer.go
+++ b/plugin/installer.go
@@ -1,24 +1,30 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package plugin
import (
"context"
- "fmt"
- "os/exec"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
+ exec "golang.org/x/sys/execabs"
+ "net/url"
"time"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
+ "dappco.re/go/core/scm/agentci"
)
// Installer handles plugin installation from GitHub.
+//
type Installer struct {
medium io.Medium
registry *Registry
}
// NewInstaller creates a new plugin installer.
+//
func NewInstaller(m io.Medium, registry *Registry) *Installer {
return &Installer{
medium: m,
@@ -40,7 +46,10 @@ func (i *Installer) Install(ctx context.Context, source string) error {
}
// Clone the repository
- pluginDir := filepath.Join(i.registry.basePath, repo)
+ _, pluginDir, err := i.resolvePluginPath(repo)
+ if err != nil {
+ return coreerr.E("plugin.Installer.Install", "invalid plugin path", err)
+ }
if err := i.medium.EnsureDir(pluginDir); err != nil {
return coreerr.E("plugin.Installer.Install", "failed to create plugin directory", err)
}
@@ -90,14 +99,15 @@ func (i *Installer) Install(ctx context.Context, source string) error {
// Update updates a plugin to the latest version.
func (i *Installer) Update(ctx context.Context, name string) error {
- cfg, ok := i.registry.Get(name)
- if !ok {
- return coreerr.E("plugin.Installer.Update", "plugin not found: "+name, nil)
+ safeName, pluginDir, err := i.resolvePluginPath(name)
+ if err != nil {
+ return coreerr.E("plugin.Installer.Update", "invalid plugin name", err)
}
- // Parse the source to get org/repo
- source := strings.TrimPrefix(cfg.Source, "github:")
- pluginDir := filepath.Join(i.registry.basePath, name)
+ cfg, ok := i.registry.Get(safeName)
+ if !ok {
+ return coreerr.E("plugin.Installer.Update", "plugin not found: "+safeName, nil)
+ }
// Pull latest changes
cmd := exec.CommandContext(ctx, "git", "-C", pluginDir, "pull", "--ff-only")
@@ -118,18 +128,21 @@ func (i *Installer) Update(ctx context.Context, name string) error {
return coreerr.E("plugin.Installer.Update", "failed to save registry", err)
}
- _ = source // used for context
return nil
}
// Remove uninstalls a plugin by removing its files and registry entry.
func (i *Installer) Remove(name string) error {
- if _, ok := i.registry.Get(name); !ok {
- return coreerr.E("plugin.Installer.Remove", "plugin not found: "+name, nil)
+ safeName, pluginDir, err := i.resolvePluginPath(name)
+ if err != nil {
+ return coreerr.E("plugin.Installer.Remove", "invalid plugin name", err)
+ }
+
+ if _, ok := i.registry.Get(safeName); !ok {
+ return coreerr.E("plugin.Installer.Remove", "plugin not found: "+safeName, nil)
}
// Delete plugin directory
- pluginDir := filepath.Join(i.registry.basePath, name)
if i.medium.Exists(pluginDir) {
if err := i.medium.DeleteAll(pluginDir); err != nil {
return coreerr.E("plugin.Installer.Remove", "failed to delete plugin files", err)
@@ -137,7 +150,7 @@ func (i *Installer) Remove(name string) error {
}
// Remove from registry
- if err := i.registry.Remove(name); err != nil {
+ if err := i.registry.Remove(safeName); err != nil {
return coreerr.E("plugin.Installer.Remove", "failed to unregister plugin", err)
}
@@ -169,7 +182,13 @@ func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest stri
// Accepted formats:
// - "org/repo" -> org="org", repo="repo", version=""
// - "org/repo@v1.0" -> org="org", repo="repo", version="v1.0"
+//
+//
func ParseSource(source string) (org, repo, version string, err error) {
+ source, err = url.PathUnescape(source)
+ if err != nil {
+ return "", "", "", coreerr.E("plugin.ParseSource", "invalid source path", err)
+ }
if source == "" {
return "", "", "", coreerr.E("plugin.ParseSource", "source is empty", nil)
}
@@ -191,5 +210,22 @@ func ParseSource(source string) (org, repo, version string, err error) {
return "", "", "", coreerr.E("plugin.ParseSource", "source must be in format org/repo[@version]", nil)
}
- return parts[0], parts[1], version, nil
+ org, err = agentci.ValidatePathElement(parts[0])
+ if err != nil {
+ return "", "", "", coreerr.E("plugin.ParseSource", "invalid org", err)
+ }
+ repo, err = agentci.ValidatePathElement(parts[1])
+ if err != nil {
+ return "", "", "", coreerr.E("plugin.ParseSource", "invalid repo", err)
+ }
+
+ return org, repo, version, nil
+}
+
+func (i *Installer) resolvePluginPath(name string) (string, string, error) {
+ safeName, path, err := agentci.ResolvePathWithinRoot(i.registry.basePath, name)
+ if err != nil {
+ return "", "", coreerr.E("plugin.Installer.resolvePluginPath", "resolve plugin path", err)
+ }
+ return safeName, path, nil
}
diff --git a/plugin/loader.go b/plugin/loader.go
index 3362886..f3bf677 100644
--- a/plugin/loader.go
+++ b/plugin/loader.go
@@ -1,19 +1,23 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package plugin
import (
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
)
// Loader loads plugins from the filesystem.
+//
type Loader struct {
medium io.Medium
baseDir string
}
// NewLoader creates a new plugin loader.
+//
func NewLoader(m io.Medium, baseDir string) *Loader {
return &Loader{
medium: m,
diff --git a/plugin/manifest.go b/plugin/manifest.go
index 4e87c6f..4da3b7a 100644
--- a/plugin/manifest.go
+++ b/plugin/manifest.go
@@ -1,14 +1,17 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package plugin
import (
- "encoding/json"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
)
// Manifest represents a plugin.json manifest file.
// Each plugin repository must contain a plugin.json at its root.
+//
type Manifest struct {
Name string `json:"name"`
Version string `json:"version"`
@@ -20,6 +23,7 @@ type Manifest struct {
}
// LoadManifest reads and parses a plugin.json file from the given path.
+//
func LoadManifest(m io.Medium, path string) (*Manifest, error) {
content, err := m.Read(path)
if err != nil {
diff --git a/plugin/plugin.go b/plugin/plugin.go
index 9f060ec..60f71eb 100644
--- a/plugin/plugin.go
+++ b/plugin/plugin.go
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package plugin provides a plugin system for the core CLI.
//
// Plugins extend the CLI with additional commands and functionality.
@@ -14,6 +16,7 @@ package plugin
import "context"
// Plugin is the interface that all plugins must implement.
+//
type Plugin interface {
// Name returns the plugin's unique identifier.
Name() string
@@ -33,6 +36,7 @@ type Plugin interface {
// BasePlugin provides a default implementation of Plugin.
// Embed this in concrete plugin types to inherit default behaviour.
+//
type BasePlugin struct {
PluginName string
PluginVersion string
diff --git a/plugin/registry.go b/plugin/registry.go
index f81a025..9cd4725 100644
--- a/plugin/registry.go
+++ b/plugin/registry.go
@@ -1,19 +1,22 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package plugin
import (
"cmp"
- "encoding/json"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ json "dappco.re/go/core/scm/internal/ax/jsonx"
"slices"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
)
const registryFilename = "registry.json"
// Registry manages installed plugins.
// Plugin metadata is stored in a registry.json file under the base path.
+//
type Registry struct {
medium io.Medium
basePath string // e.g., ~/.core/plugins/
@@ -21,6 +24,7 @@ type Registry struct {
}
// NewRegistry creates a new plugin registry.
+//
func NewRegistry(m io.Medium, basePath string) *Registry {
return &Registry{
medium: m,
diff --git a/repos/gitstate.go b/repos/gitstate.go
index 15d8436..2c3f0b7 100644
--- a/repos/gitstate.go
+++ b/repos/gitstate.go
@@ -1,23 +1,27 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package repos
import (
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
"time"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// GitState holds per-machine git sync state for a workspace.
// Stored at .core/git.yaml and .gitignored (not shared across machines).
+//
type GitState struct {
- Version int `yaml:"version"`
- Repos map[string]*RepoGitState `yaml:"repos,omitempty"`
- Agents map[string]*AgentState `yaml:"agents,omitempty"`
+ Version int `yaml:"version"`
+ Repos map[string]*RepoGitState `yaml:"repos,omitempty"`
+ Agents map[string]*AgentState `yaml:"agents,omitempty"`
}
// RepoGitState tracks the last known git state for a single repo.
+//
type RepoGitState struct {
LastPull time.Time `yaml:"last_pull,omitempty"`
LastPush time.Time `yaml:"last_push,omitempty"`
@@ -28,6 +32,7 @@ type RepoGitState struct {
}
// AgentState tracks which agent last touched which repos.
+//
type AgentState struct {
LastSeen time.Time `yaml:"last_seen"`
Active []string `yaml:"active,omitempty"`
@@ -35,6 +40,7 @@ type AgentState struct {
// LoadGitState reads .core/git.yaml from the given workspace root directory.
// Returns a new empty GitState if the file does not exist.
+//
func LoadGitState(m io.Medium, root string) (*GitState, error) {
path := filepath.Join(root, ".core", "git.yaml")
@@ -63,6 +69,7 @@ func LoadGitState(m io.Medium, root string) (*GitState, error) {
}
// SaveGitState writes .core/git.yaml to the given workspace root directory.
+//
func SaveGitState(m io.Medium, root string, gs *GitState) error {
coreDir := filepath.Join(root, ".core")
if err := m.EnsureDir(coreDir); err != nil {
@@ -83,6 +90,7 @@ func SaveGitState(m io.Medium, root string, gs *GitState) error {
}
// NewGitState returns a new empty GitState with version 1.
+//
func NewGitState() *GitState {
return &GitState{
Version: 1,
diff --git a/repos/kbconfig.go b/repos/kbconfig.go
index fd8ed3a..7442196 100644
--- a/repos/kbconfig.go
+++ b/repos/kbconfig.go
@@ -1,16 +1,19 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package repos
import (
- "fmt"
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ fmt "dappco.re/go/core/scm/internal/ax/fmtx"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// KBConfig holds knowledge base configuration for a workspace.
// Stored at .core/kb.yaml and checked into git.
+//
type KBConfig struct {
Version int `yaml:"version"`
Wiki WikiConfig `yaml:"wiki"`
@@ -18,6 +21,7 @@ type KBConfig struct {
}
// WikiConfig controls local wiki mirror behaviour.
+//
type WikiConfig struct {
// Enabled toggles wiki cloning on sync.
Enabled bool `yaml:"enabled"`
@@ -29,6 +33,7 @@ type WikiConfig struct {
}
// KBSearch configures vector search against the OpenBrain Qdrant collection.
+//
type KBSearch struct {
// QdrantHost is the Qdrant server (gRPC).
QdrantHost string `yaml:"qdrant_host"`
@@ -45,6 +50,7 @@ type KBSearch struct {
}
// DefaultKBConfig returns sensible defaults for knowledge base config.
+//
func DefaultKBConfig() *KBConfig {
return &KBConfig{
Version: 1,
@@ -66,6 +72,7 @@ func DefaultKBConfig() *KBConfig {
// LoadKBConfig reads .core/kb.yaml from the given workspace root directory.
// Returns defaults if the file does not exist.
+//
func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) {
path := filepath.Join(root, ".core", "kb.yaml")
@@ -87,6 +94,7 @@ func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) {
}
// SaveKBConfig writes .core/kb.yaml to the given workspace root directory.
+//
func SaveKBConfig(m io.Medium, root string, kb *KBConfig) error {
coreDir := filepath.Join(root, ".core")
if err := m.EnsureDir(coreDir); err != nil {
diff --git a/repos/registry.go b/repos/registry.go
index f6af2f7..e1d1d91 100644
--- a/repos/registry.go
+++ b/repos/registry.go
@@ -1,19 +1,22 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
// Package repos provides functionality for managing multi-repo workspaces.
// It reads a repos.yaml registry file that defines repositories, their types,
// dependencies, and metadata.
package repos
import (
- "os"
- "path/filepath"
- "strings"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
+ os "dappco.re/go/core/scm/internal/ax/osx"
+ strings "dappco.re/go/core/scm/internal/ax/stringsx"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// Registry represents a collection of repositories defined in repos.yaml.
+//
type Registry struct {
Version int `yaml:"version"`
Org string `yaml:"org"`
@@ -24,6 +27,7 @@ type Registry struct {
}
// RegistryDefaults contains default values applied to all repos.
+//
type RegistryDefaults struct {
CI string `yaml:"ci"`
License string `yaml:"license"`
@@ -31,21 +35,27 @@ type RegistryDefaults struct {
}
// RepoType indicates the role of a repository in the ecosystem.
+//
type RepoType string
// Repository type constants for ecosystem classification.
const (
// RepoTypeFoundation indicates core foundation packages.
+ //
RepoTypeFoundation RepoType = "foundation"
// RepoTypeModule indicates reusable module packages.
+ //
RepoTypeModule RepoType = "module"
// RepoTypeProduct indicates end-user product applications.
+ //
RepoTypeProduct RepoType = "product"
// RepoTypeTemplate indicates starter templates.
+ //
RepoTypeTemplate RepoType = "template"
)
// Repo represents a single repository in the registry.
+//
type Repo struct {
Name string `yaml:"-"` // Set from map key
Type string `yaml:"type"`
@@ -63,6 +73,8 @@ type Repo struct {
// LoadRegistry reads and parses a repos.yaml file from the given medium.
// The path should be a valid path for the provided medium.
+//
+// reg, err := repos.LoadRegistry(io.Local, ".core/repos.yaml")
func LoadRegistry(m io.Medium, path string) (*Registry, error) {
content, err := m.Read(path)
if err != nil {
@@ -102,6 +114,8 @@ func LoadRegistry(m io.Medium, path string) (*Registry, error) {
// FindRegistry searches for repos.yaml in common locations.
// It checks: current directory, parent directories, and home directory.
// This function is primarily intended for use with io.Local or other local-like filesystems.
+//
+// path, err := repos.FindRegistry(io.Local)
func FindRegistry(m io.Medium) (string, error) {
// Check current directory and parents
dir, err := os.Getwd()
@@ -152,6 +166,8 @@ func FindRegistry(m io.Medium) (string, error) {
// ScanDirectory creates a Registry by scanning a directory for git repos.
// This is used as a fallback when no repos.yaml is found.
// The dir should be a valid path for the provided medium.
+//
+// reg, err := repos.ScanDirectory(io.Local, "/home/user/Code/core")
func ScanDirectory(m io.Medium, dir string) (*Registry, error) {
entries, err := m.List(dir)
if err != nil {
@@ -241,6 +257,8 @@ func detectOrg(m io.Medium, repoPath string) string {
}
// List returns all repos in the registry.
+//
+// repos := reg.List()
func (r *Registry) List() []*Repo {
repos := make([]*Repo, 0, len(r.Repos))
for _, repo := range r.Repos {
@@ -251,12 +269,16 @@ func (r *Registry) List() []*Repo {
}
// Get returns a repo by name.
+//
+// repo, ok := reg.Get("go-io")
func (r *Registry) Get(name string) (*Repo, bool) {
repo, ok := r.Repos[name]
return repo, ok
}
// ByType returns repos filtered by type.
+//
+// goRepos := reg.ByType("go")
func (r *Registry) ByType(t string) []*Repo {
var repos []*Repo
for _, repo := range r.Repos {
@@ -269,6 +291,8 @@ func (r *Registry) ByType(t string) []*Repo {
// TopologicalOrder returns repos sorted by dependency order.
// Foundation repos come first, then modules, then products.
+//
+// ordered, err := reg.TopologicalOrder()
func (r *Registry) TopologicalOrder() ([]*Repo, error) {
// Build dependency graph
visited := make(map[string]bool)
diff --git a/repos/workconfig.go b/repos/workconfig.go
index 7452245..022e99e 100644
--- a/repos/workconfig.go
+++ b/repos/workconfig.go
@@ -1,24 +1,28 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
package repos
import (
- "path/filepath"
+ filepath "dappco.re/go/core/scm/internal/ax/filepathx"
"time"
- coreerr "dappco.re/go/core/log"
"dappco.re/go/core/io"
+ coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// WorkConfig holds sync policy for a workspace.
// Stored at .core/work.yaml and checked into git (shared across the team).
+//
type WorkConfig struct {
- Version int `yaml:"version"`
- Sync SyncConfig `yaml:"sync"`
- Agents AgentPolicy `yaml:"agents"`
- Triggers []string `yaml:"triggers,omitempty"`
+ Version int `yaml:"version"`
+ Sync SyncConfig `yaml:"sync"`
+ Agents AgentPolicy `yaml:"agents"`
+ Triggers []string `yaml:"triggers,omitempty"`
}
// SyncConfig controls how and when repos are synced.
+//
type SyncConfig struct {
Interval time.Duration `yaml:"interval"`
AutoPull bool `yaml:"auto_pull"`
@@ -27,13 +31,15 @@ type SyncConfig struct {
}
// AgentPolicy controls multi-agent clash prevention.
+//
type AgentPolicy struct {
- Heartbeat time.Duration `yaml:"heartbeat"`
- StaleAfter time.Duration `yaml:"stale_after"`
- WarnOnOverlap bool `yaml:"warn_on_overlap"`
+ Heartbeat time.Duration `yaml:"heartbeat"`
+ StaleAfter time.Duration `yaml:"stale_after"`
+ WarnOnOverlap bool `yaml:"warn_on_overlap"`
}
// DefaultWorkConfig returns sensible defaults for workspace sync.
+//
func DefaultWorkConfig() *WorkConfig {
return &WorkConfig{
Version: 1,
@@ -54,6 +60,7 @@ func DefaultWorkConfig() *WorkConfig {
// LoadWorkConfig reads .core/work.yaml from the given workspace root directory.
// Returns defaults if the file does not exist.
+//
func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) {
path := filepath.Join(root, ".core", "work.yaml")
@@ -75,6 +82,7 @@ func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) {
}
// SaveWorkConfig writes .core/work.yaml to the given workspace root directory.
+//
func SaveWorkConfig(m io.Medium, root string, wc *WorkConfig) error {
coreDir := filepath.Join(root, ".core")
if err := m.EnsureDir(coreDir); err != nil {
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 312f000..061ada4 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -1,3 +1,5 @@
+// SPDX-Licence-Identifier: EUPL-1.2
+
import { defineConfig } from 'vite';
import { resolve } from 'path';