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.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. 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
+ }