orch-code 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- package/scripts/release.js +52 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestLoadPreservesExplicitFalseSafetyValues(t *testing.T) {
|
|
10
|
+
repoRoot := t.TempDir()
|
|
11
|
+
if err := EnsureOrchDir(repoRoot); err != nil {
|
|
12
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
raw := `{
|
|
16
|
+
"version": 1,
|
|
17
|
+
"models": {"planner":"p","coder":"c","reviewer":"r"},
|
|
18
|
+
"commands": {"test":"go test ./...","lint":"go vet ./..."},
|
|
19
|
+
"patch": {"maxFiles":10,"maxLines":800},
|
|
20
|
+
"safety": {
|
|
21
|
+
"dryRun": true,
|
|
22
|
+
"requireDestructiveApproval": false,
|
|
23
|
+
"lockStaleAfterSeconds": 60,
|
|
24
|
+
"retry": {"validationMax": 1, "testMax": 1, "reviewMax": 1},
|
|
25
|
+
"confidence": {"completeMin": 0.8, "failBelow": 0.4},
|
|
26
|
+
"featureFlags": {
|
|
27
|
+
"permissionMode": false,
|
|
28
|
+
"repoLock": false,
|
|
29
|
+
"retryLimits": false,
|
|
30
|
+
"patchConflictReporting": false,
|
|
31
|
+
"confidenceEnforcement": false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}`
|
|
35
|
+
|
|
36
|
+
configPath := filepath.Join(repoRoot, OrchDir, ConfigFile)
|
|
37
|
+
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
38
|
+
t.Fatalf("write config: %v", err)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cfg, err := Load(repoRoot)
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("load config: %v", err)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if cfg.Safety.RequireDestructiveApproval {
|
|
47
|
+
t.Fatalf("expected explicit false requireDestructiveApproval to be preserved")
|
|
48
|
+
}
|
|
49
|
+
if cfg.Safety.FeatureFlags.PermissionMode || cfg.Safety.FeatureFlags.RepoLock || cfg.Safety.FeatureFlags.RetryLimits || cfg.Safety.FeatureFlags.PatchConflictReporting || cfg.Safety.FeatureFlags.ConfidenceEnforcement {
|
|
50
|
+
t.Fatalf("expected explicit false feature flags to be preserved")
|
|
51
|
+
}
|
|
52
|
+
if cfg.Safety.Confidence.CompleteMin != 0.8 || cfg.Safety.Confidence.FailBelow != 0.4 {
|
|
53
|
+
t.Fatalf("expected explicit confidence policy values to be preserved")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestLoadBackfillsMissingSafetyFields(t *testing.T) {
|
|
58
|
+
repoRoot := t.TempDir()
|
|
59
|
+
if err := EnsureOrchDir(repoRoot); err != nil {
|
|
60
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
raw := `{
|
|
64
|
+
"version": 1,
|
|
65
|
+
"models": {"planner":"p","coder":"c","reviewer":"r"},
|
|
66
|
+
"commands": {"test":"go test ./...","lint":"go vet ./..."},
|
|
67
|
+
"patch": {"maxFiles":10,"maxLines":800},
|
|
68
|
+
"safety": {"dryRun": true}
|
|
69
|
+
}`
|
|
70
|
+
|
|
71
|
+
configPath := filepath.Join(repoRoot, OrchDir, ConfigFile)
|
|
72
|
+
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
73
|
+
t.Fatalf("write config: %v", err)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
cfg, err := Load(repoRoot)
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatalf("load config: %v", err)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if !cfg.Safety.RequireDestructiveApproval {
|
|
82
|
+
t.Fatalf("expected missing requireDestructiveApproval to be defaulted true")
|
|
83
|
+
}
|
|
84
|
+
if !cfg.Safety.FeatureFlags.PermissionMode || !cfg.Safety.FeatureFlags.RepoLock || !cfg.Safety.FeatureFlags.RetryLimits || !cfg.Safety.FeatureFlags.PatchConflictReporting || !cfg.Safety.FeatureFlags.ConfidenceEnforcement {
|
|
85
|
+
t.Fatalf("expected missing featureFlags to be defaulted true")
|
|
86
|
+
}
|
|
87
|
+
if cfg.Safety.Confidence.CompleteMin <= 0 || cfg.Safety.Confidence.FailBelow <= 0 {
|
|
88
|
+
t.Fatalf("expected missing confidence policy to be backfilled")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if cfg.Provider.Default != "openai" {
|
|
92
|
+
t.Fatalf("expected default provider to be openai, got=%s", cfg.Provider.Default)
|
|
93
|
+
}
|
|
94
|
+
if cfg.Provider.OpenAI.Models.Coder == "" {
|
|
95
|
+
t.Fatalf("expected default openai coder model to be backfilled")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestLoadPreservesExplicitProviderFlags(t *testing.T) {
|
|
100
|
+
repoRoot := t.TempDir()
|
|
101
|
+
if err := EnsureOrchDir(repoRoot); err != nil {
|
|
102
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
raw := `{
|
|
106
|
+
"version": 1,
|
|
107
|
+
"models": {"planner":"p","coder":"c","reviewer":"r"},
|
|
108
|
+
"commands": {"test":"go test ./...","lint":"go vet ./..."},
|
|
109
|
+
"patch": {"maxFiles":10,"maxLines":800},
|
|
110
|
+
"safety": {"dryRun": true},
|
|
111
|
+
"provider": {
|
|
112
|
+
"default": "openai",
|
|
113
|
+
"openai": {
|
|
114
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
115
|
+
"baseURL": "https://api.openai.com/v1",
|
|
116
|
+
"reasoningEffort": "medium",
|
|
117
|
+
"timeoutSeconds": 30,
|
|
118
|
+
"maxRetries": 1,
|
|
119
|
+
"models": {"planner":"a","coder":"b","reviewer":"c"}
|
|
120
|
+
},
|
|
121
|
+
"flags": {"openaiEnabled": false}
|
|
122
|
+
}
|
|
123
|
+
}`
|
|
124
|
+
|
|
125
|
+
configPath := filepath.Join(repoRoot, OrchDir, ConfigFile)
|
|
126
|
+
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
|
|
127
|
+
t.Fatalf("write config: %v", err)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
cfg, err := Load(repoRoot)
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("load config: %v", err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if cfg.Provider.Flags.OpenAIEnabled {
|
|
136
|
+
t.Fatalf("expected explicit openaiEnabled=false to be preserved")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
package execution
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
|
|
6
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type ContractBuilder struct {
|
|
11
|
+
cfg *config.Config
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func NewContractBuilder(cfg *config.Config) *ContractBuilder {
|
|
15
|
+
return &ContractBuilder{cfg: cfg}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func (b *ContractBuilder) Build(task *models.Task, brief *models.TaskBrief, plan *models.Plan, ctx *models.ContextResult) *models.ExecutionContract {
|
|
19
|
+
if task == nil {
|
|
20
|
+
return nil
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
allowedFiles := uniqueNonEmpty(planFilesToModify(plan, ctx))
|
|
24
|
+
inspectFiles := uniqueNonEmpty(planFilesToInspect(plan, ctx))
|
|
25
|
+
requiredEdits := buildRequiredEdits(plan)
|
|
26
|
+
prohibitedActions := buildProhibitedActions(plan)
|
|
27
|
+
acceptanceCriteria := buildAcceptanceCriteria(plan)
|
|
28
|
+
invariants := buildInvariants(plan, brief)
|
|
29
|
+
|
|
30
|
+
return &models.ExecutionContract{
|
|
31
|
+
TaskID: task.ID,
|
|
32
|
+
AllowedFiles: allowedFiles,
|
|
33
|
+
InspectFiles: inspectFiles,
|
|
34
|
+
RequiredEdits: requiredEdits,
|
|
35
|
+
ProhibitedActions: prohibitedActions,
|
|
36
|
+
AcceptanceCriteria: acceptanceCriteria,
|
|
37
|
+
Invariants: invariants,
|
|
38
|
+
PatchBudget: models.PatchBudget{
|
|
39
|
+
MaxFiles: patchMaxFiles(b.cfg),
|
|
40
|
+
MaxChangedLines: patchMaxLines(b.cfg),
|
|
41
|
+
},
|
|
42
|
+
ScopeExpansionPolicy: models.ScopeExpansionPolicy{
|
|
43
|
+
Allowed: true,
|
|
44
|
+
RequiresReason: true,
|
|
45
|
+
MaxExtraFiles: 1,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func planFilesToModify(plan *models.Plan, ctx *models.ContextResult) []string {
|
|
51
|
+
files := make([]string, 0)
|
|
52
|
+
if plan != nil {
|
|
53
|
+
files = append(files, plan.FilesToModify...)
|
|
54
|
+
}
|
|
55
|
+
if len(files) == 0 && ctx != nil {
|
|
56
|
+
files = append(files, ctx.SelectedFiles...)
|
|
57
|
+
}
|
|
58
|
+
return files
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func planFilesToInspect(plan *models.Plan, ctx *models.ContextResult) []string {
|
|
62
|
+
files := make([]string, 0)
|
|
63
|
+
if plan != nil {
|
|
64
|
+
files = append(files, plan.FilesToInspect...)
|
|
65
|
+
files = append(files, plan.FilesToModify...)
|
|
66
|
+
}
|
|
67
|
+
if ctx != nil {
|
|
68
|
+
files = append(files, ctx.SelectedFiles...)
|
|
69
|
+
files = append(files, ctx.RelatedTests...)
|
|
70
|
+
files = append(files, ctx.RelevantConfigs...)
|
|
71
|
+
}
|
|
72
|
+
return files
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func buildRequiredEdits(plan *models.Plan) []string {
|
|
76
|
+
if plan == nil {
|
|
77
|
+
return []string{}
|
|
78
|
+
}
|
|
79
|
+
items := make([]string, 0, len(plan.AcceptanceCriteria)+len(plan.Steps))
|
|
80
|
+
for _, criterion := range plan.AcceptanceCriteria {
|
|
81
|
+
if strings.TrimSpace(criterion.Description) == "" {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
items = append(items, criterion.Description)
|
|
85
|
+
}
|
|
86
|
+
if len(items) == 0 {
|
|
87
|
+
for _, step := range plan.Steps {
|
|
88
|
+
if strings.TrimSpace(step.Description) == "" {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
items = append(items, step.Description)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return uniqueNonEmpty(items)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func buildProhibitedActions(plan *models.Plan) []string {
|
|
98
|
+
items := []string{
|
|
99
|
+
"Do not modify files outside the allowed file set unless scope expansion is explicitly justified.",
|
|
100
|
+
"Do not introduce unrelated refactors or formatting-only churn.",
|
|
101
|
+
"Do not change sensitive files, secrets, or unrelated configuration.",
|
|
102
|
+
}
|
|
103
|
+
if plan != nil {
|
|
104
|
+
items = append(items, plan.ForbiddenChanges...)
|
|
105
|
+
}
|
|
106
|
+
return uniqueNonEmpty(items)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func buildAcceptanceCriteria(plan *models.Plan) []string {
|
|
110
|
+
if plan == nil {
|
|
111
|
+
return []string{}
|
|
112
|
+
}
|
|
113
|
+
items := make([]string, 0, len(plan.AcceptanceCriteria))
|
|
114
|
+
for _, criterion := range plan.AcceptanceCriteria {
|
|
115
|
+
items = append(items, criterion.Description)
|
|
116
|
+
}
|
|
117
|
+
return uniqueNonEmpty(items)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func buildInvariants(plan *models.Plan, brief *models.TaskBrief) []string {
|
|
121
|
+
items := make([]string, 0)
|
|
122
|
+
if plan != nil {
|
|
123
|
+
items = append(items, plan.Invariants...)
|
|
124
|
+
}
|
|
125
|
+
if brief != nil && brief.RiskLevel == models.RiskHigh {
|
|
126
|
+
items = append(items, "Preserve existing behavior and public interfaces unless the task explicitly requires a contract change.")
|
|
127
|
+
}
|
|
128
|
+
return uniqueNonEmpty(items)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func patchMaxFiles(cfg *config.Config) int {
|
|
132
|
+
if cfg == nil || cfg.Patch.MaxFiles <= 0 {
|
|
133
|
+
return 10
|
|
134
|
+
}
|
|
135
|
+
return cfg.Patch.MaxFiles
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func patchMaxLines(cfg *config.Config) int {
|
|
139
|
+
if cfg == nil || cfg.Patch.MaxLines <= 0 {
|
|
140
|
+
return 800
|
|
141
|
+
}
|
|
142
|
+
return cfg.Patch.MaxLines
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func uniqueNonEmpty(values []string) []string {
|
|
146
|
+
result := make([]string, 0, len(values))
|
|
147
|
+
seen := map[string]struct{}{}
|
|
148
|
+
for _, value := range values {
|
|
149
|
+
trimmed := strings.TrimSpace(value)
|
|
150
|
+
if trimmed == "" {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if _, ok := seen[trimmed]; ok {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
seen[trimmed] = struct{}{}
|
|
157
|
+
result = append(result, trimmed)
|
|
158
|
+
}
|
|
159
|
+
return result
|
|
160
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package execution
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
8
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestContractBuilderBuildsStructuredContract(t *testing.T) {
|
|
12
|
+
builder := NewContractBuilder(config.DefaultConfig())
|
|
13
|
+
task := &models.Task{ID: "task-1", Description: "fix race condition in auth service", CreatedAt: time.Now()}
|
|
14
|
+
brief := &models.TaskBrief{TaskID: "task-1", TaskType: models.TaskTypeBugfix, RiskLevel: models.RiskHigh}
|
|
15
|
+
plan := &models.Plan{
|
|
16
|
+
TaskID: "task-1",
|
|
17
|
+
FilesToModify: []string{"internal/auth/service.go"},
|
|
18
|
+
FilesToInspect: []string{"internal/auth/service.go", "internal/auth/service_test.go"},
|
|
19
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{
|
|
20
|
+
ID: "ac-1",
|
|
21
|
+
Description: "Race condition is no longer reproducible.",
|
|
22
|
+
}},
|
|
23
|
+
Invariants: []string{"Public API remains unchanged."},
|
|
24
|
+
ForbiddenChanges: []string{"Do not change config files."},
|
|
25
|
+
Steps: []models.PlanStep{{
|
|
26
|
+
Order: 1,
|
|
27
|
+
Description: "Protect auth state mutation.",
|
|
28
|
+
}},
|
|
29
|
+
}
|
|
30
|
+
ctx := &models.ContextResult{
|
|
31
|
+
SelectedFiles: []string{"internal/auth/service.go"},
|
|
32
|
+
RelatedTests: []string{"internal/auth/service_test.go"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
contract := builder.Build(task, brief, plan, ctx)
|
|
36
|
+
if contract == nil {
|
|
37
|
+
t.Fatalf("expected contract")
|
|
38
|
+
}
|
|
39
|
+
if len(contract.AllowedFiles) != 1 || contract.AllowedFiles[0] != "internal/auth/service.go" {
|
|
40
|
+
t.Fatalf("unexpected allowed files: %#v", contract.AllowedFiles)
|
|
41
|
+
}
|
|
42
|
+
if len(contract.InspectFiles) < 2 {
|
|
43
|
+
t.Fatalf("expected inspect files to include plan and test context")
|
|
44
|
+
}
|
|
45
|
+
if len(contract.RequiredEdits) == 0 {
|
|
46
|
+
t.Fatalf("expected required edits")
|
|
47
|
+
}
|
|
48
|
+
if len(contract.ProhibitedActions) == 0 {
|
|
49
|
+
t.Fatalf("expected prohibited actions")
|
|
50
|
+
}
|
|
51
|
+
if contract.PatchBudget.MaxFiles <= 0 || contract.PatchBudget.MaxChangedLines <= 0 {
|
|
52
|
+
t.Fatalf("expected patch budget from config")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestScopeGuardRejectsOutOfScopePatch(t *testing.T) {
|
|
57
|
+
guard := NewScopeGuard()
|
|
58
|
+
contract := &models.ExecutionContract{AllowedFiles: []string{"internal/auth/service.go"}}
|
|
59
|
+
patch := &models.Patch{Files: []models.PatchFile{{Path: "internal/auth/service.go"}, {Path: "internal/auth/config.go"}}}
|
|
60
|
+
|
|
61
|
+
result := guard.Validate(contract, patch)
|
|
62
|
+
if result.Status != models.ValidationFail {
|
|
63
|
+
t.Fatalf("expected validation failure, got %s", result.Status)
|
|
64
|
+
}
|
|
65
|
+
if len(result.Details) != 1 || result.Details[0] != "internal/auth/config.go" {
|
|
66
|
+
t.Fatalf("unexpected scope violation details: %#v", result.Details)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package execution
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type PlanComplianceGuard struct{}
|
|
12
|
+
|
|
13
|
+
func NewPlanComplianceGuard() *PlanComplianceGuard {
|
|
14
|
+
return &PlanComplianceGuard{}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func (g *PlanComplianceGuard) Validate(plan *models.Plan, contract *models.ExecutionContract, patch *models.Patch) models.ValidationResult {
|
|
18
|
+
result := models.ValidationResult{
|
|
19
|
+
Name: "plan_compliance",
|
|
20
|
+
Stage: "validation",
|
|
21
|
+
Status: models.ValidationPass,
|
|
22
|
+
Severity: models.SeverityLow,
|
|
23
|
+
Summary: "patch remains compliant with the structured plan",
|
|
24
|
+
Metadata: map[string]string{},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if plan == nil {
|
|
28
|
+
result.Status = models.ValidationWarn
|
|
29
|
+
result.Severity = models.SeverityMedium
|
|
30
|
+
result.Summary = "structured plan missing; plan compliance could not be fully validated"
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
if patch == nil {
|
|
34
|
+
result.Status = models.ValidationFail
|
|
35
|
+
result.Severity = models.SeverityHigh
|
|
36
|
+
result.Summary = "patch missing for plan compliance validation"
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
if len(plan.AcceptanceCriteria) == 0 && (contract == nil || len(contract.AcceptanceCriteria) == 0) {
|
|
40
|
+
result.Status = models.ValidationFail
|
|
41
|
+
result.Severity = models.SeverityHigh
|
|
42
|
+
result.Summary = "structured plan is missing acceptance criteria"
|
|
43
|
+
result.ActionableItems = []string{"Regenerate the plan with explicit acceptance criteria before coding."}
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
if len(patch.Files) == 0 {
|
|
47
|
+
result.Status = models.ValidationFail
|
|
48
|
+
result.Severity = models.SeverityHigh
|
|
49
|
+
result.Summary = "patch contains no changed files despite a code task plan"
|
|
50
|
+
result.ActionableItems = []string{"Generate a non-empty patch that satisfies the required edits and acceptance criteria."}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
changedFiles := changedFileSet(patch)
|
|
55
|
+
requiredFiles := requiredModifyFiles(plan, contract)
|
|
56
|
+
missingFiles := make([]string, 0)
|
|
57
|
+
for _, file := range requiredFiles {
|
|
58
|
+
if _, ok := changedFiles[file]; ok {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
missingFiles = append(missingFiles, file)
|
|
62
|
+
}
|
|
63
|
+
if len(missingFiles) > 0 {
|
|
64
|
+
result.Status = models.ValidationFail
|
|
65
|
+
result.Severity = models.SeverityHigh
|
|
66
|
+
result.Summary = fmt.Sprintf("patch did not modify required planned files: %s", strings.Join(missingFiles, ", "))
|
|
67
|
+
result.Details = missingFiles
|
|
68
|
+
result.ActionableItems = []string{"Ensure all required planned files are updated or explicitly reduce scope before retrying."}
|
|
69
|
+
result.Metadata["required_files"] = strings.Join(requiredFiles, ",")
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
forbiddenViolations := detectForbiddenViolations(plan, patch)
|
|
74
|
+
if len(forbiddenViolations) > 0 {
|
|
75
|
+
result.Status = models.ValidationFail
|
|
76
|
+
result.Severity = models.SeverityHigh
|
|
77
|
+
result.Summary = fmt.Sprintf("patch appears to violate forbidden change rules: %s", strings.Join(forbiddenViolations, "; "))
|
|
78
|
+
result.Details = forbiddenViolations
|
|
79
|
+
result.ActionableItems = []string{"Remove forbidden changes and keep the patch aligned with the approved plan."}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
result.Metadata["changed_files"] = strings.Join(sortedKeys(changedFiles), ",")
|
|
84
|
+
result.Metadata["required_files"] = strings.Join(requiredFiles, ",")
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func changedFileSet(patch *models.Patch) map[string]struct{} {
|
|
89
|
+
result := map[string]struct{}{}
|
|
90
|
+
if patch == nil {
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
for _, file := range patch.Files {
|
|
94
|
+
path := strings.TrimSpace(file.Path)
|
|
95
|
+
if path == "" {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
result[path] = struct{}{}
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func requiredModifyFiles(plan *models.Plan, contract *models.ExecutionContract) []string {
|
|
104
|
+
files := make([]string, 0)
|
|
105
|
+
if plan != nil {
|
|
106
|
+
files = append(files, plan.FilesToModify...)
|
|
107
|
+
}
|
|
108
|
+
if len(files) == 0 && contract != nil {
|
|
109
|
+
files = append(files, contract.AllowedFiles...)
|
|
110
|
+
}
|
|
111
|
+
return uniqueNonEmpty(files)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func detectForbiddenViolations(plan *models.Plan, patch *models.Patch) []string {
|
|
115
|
+
if plan == nil || patch == nil {
|
|
116
|
+
return nil
|
|
117
|
+
}
|
|
118
|
+
violations := make([]string, 0)
|
|
119
|
+
for _, rule := range plan.ForbiddenChanges {
|
|
120
|
+
lowerRule := strings.ToLower(rule)
|
|
121
|
+
for _, file := range patch.Files {
|
|
122
|
+
path := strings.TrimSpace(file.Path)
|
|
123
|
+
if path == "" {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if strings.Contains(lowerRule, "config") && isConfigLike(path) {
|
|
127
|
+
violations = append(violations, fmt.Sprintf("forbidden config change touched %s", path))
|
|
128
|
+
}
|
|
129
|
+
if strings.Contains(lowerRule, "test") && isTestLike(path) {
|
|
130
|
+
violations = append(violations, fmt.Sprintf("forbidden test change touched %s", path))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return uniqueNonEmpty(violations)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func isConfigLike(path string) bool {
|
|
138
|
+
lower := strings.ToLower(path)
|
|
139
|
+
base := strings.ToLower(filepath.Base(path))
|
|
140
|
+
return strings.Contains(lower, "/config/") || strings.Contains(lower, "\\config\\") || strings.Contains(base, "config") || strings.Contains(base, ".env") || base == "package.json" || base == "go.mod" || base == "dockerfile" || strings.HasSuffix(base, ".yaml") || strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".toml") || strings.HasSuffix(base, ".ini")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func isTestLike(path string) bool {
|
|
144
|
+
lower := strings.ToLower(path)
|
|
145
|
+
return strings.Contains(lower, "_test.") || strings.Contains(lower, ".test.") || strings.Contains(lower, ".spec.") || strings.Contains(lower, "/test") || strings.Contains(lower, "\\test")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func sortedKeys(values map[string]struct{}) []string {
|
|
149
|
+
result := make([]string, 0, len(values))
|
|
150
|
+
for key := range values {
|
|
151
|
+
result = append(result, key)
|
|
152
|
+
}
|
|
153
|
+
for i := 0; i < len(result); i++ {
|
|
154
|
+
for j := i + 1; j < len(result); j++ {
|
|
155
|
+
if result[j] < result[i] {
|
|
156
|
+
result[i], result[j] = result[j], result[i]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package execution
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestPlanComplianceFailsWhenRequiredFilesAreMissing(t *testing.T) {
|
|
10
|
+
guard := NewPlanComplianceGuard()
|
|
11
|
+
plan := &models.Plan{
|
|
12
|
+
FilesToModify: []string{"internal/auth/service.go", "internal/auth/store.go"},
|
|
13
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{
|
|
14
|
+
ID: "ac-1",
|
|
15
|
+
Description: "Race condition no longer occurs.",
|
|
16
|
+
}},
|
|
17
|
+
}
|
|
18
|
+
contract := &models.ExecutionContract{AllowedFiles: []string{"internal/auth/service.go", "internal/auth/store.go"}}
|
|
19
|
+
patch := &models.Patch{Files: []models.PatchFile{{Path: "internal/auth/service.go"}}}
|
|
20
|
+
|
|
21
|
+
result := guard.Validate(plan, contract, patch)
|
|
22
|
+
if result.Status != models.ValidationFail {
|
|
23
|
+
t.Fatalf("expected failure, got %s", result.Status)
|
|
24
|
+
}
|
|
25
|
+
if len(result.Details) != 1 || result.Details[0] != "internal/auth/store.go" {
|
|
26
|
+
t.Fatalf("unexpected missing files: %#v", result.Details)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestPlanComplianceFailsOnForbiddenConfigChange(t *testing.T) {
|
|
31
|
+
guard := NewPlanComplianceGuard()
|
|
32
|
+
plan := &models.Plan{
|
|
33
|
+
FilesToModify: []string{"internal/auth/service.go"},
|
|
34
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Behavior remains stable."}},
|
|
35
|
+
ForbiddenChanges: []string{"Do not change config files."},
|
|
36
|
+
}
|
|
37
|
+
patch := &models.Patch{Files: []models.PatchFile{{Path: "config/app.yaml"}, {Path: "internal/auth/service.go"}}}
|
|
38
|
+
|
|
39
|
+
result := guard.Validate(plan, nil, patch)
|
|
40
|
+
if result.Status != models.ValidationFail {
|
|
41
|
+
t.Fatalf("expected failure, got %s", result.Status)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestRetryDirectiveBuilderUsesValidationFailures(t *testing.T) {
|
|
46
|
+
builder := NewRetryDirectiveBuilder()
|
|
47
|
+
state := &models.RunState{
|
|
48
|
+
ValidationResults: []models.ValidationResult{{
|
|
49
|
+
Name: "plan_compliance",
|
|
50
|
+
Stage: "validation",
|
|
51
|
+
Status: models.ValidationFail,
|
|
52
|
+
Severity: models.SeverityHigh,
|
|
53
|
+
Summary: "missing required file",
|
|
54
|
+
ActionableItems: []string{"Modify the required file."},
|
|
55
|
+
}},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
directive := builder.FromValidation(state, 2)
|
|
59
|
+
if directive == nil {
|
|
60
|
+
t.Fatalf("expected directive")
|
|
61
|
+
}
|
|
62
|
+
if directive.Attempt != 2 || directive.Stage != "validation" {
|
|
63
|
+
t.Fatalf("unexpected directive: %#v", directive)
|
|
64
|
+
}
|
|
65
|
+
if len(directive.FailedGates) != 1 || directive.FailedGates[0] != "plan_compliance" {
|
|
66
|
+
t.Fatalf("unexpected failed gates: %#v", directive.FailedGates)
|
|
67
|
+
}
|
|
68
|
+
if len(directive.Instructions) == 0 {
|
|
69
|
+
t.Fatalf("expected actionable retry instructions")
|
|
70
|
+
}
|
|
71
|
+
}
|