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,104 @@
|
|
|
1
|
+
// Package patch contains patch validation implementation.
|
|
2
|
+
//
|
|
3
|
+
// - Sensitive files are protected.
|
|
4
|
+
package patch
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"fmt"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
var blockedFiles = []string{
|
|
15
|
+
".env",
|
|
16
|
+
".env.local",
|
|
17
|
+
".env.production",
|
|
18
|
+
"id_rsa",
|
|
19
|
+
"id_ed25519",
|
|
20
|
+
".pem",
|
|
21
|
+
".key",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var binaryExtensions = []string{
|
|
25
|
+
".exe", ".dll", ".so", ".dylib",
|
|
26
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
|
27
|
+
".zip", ".tar", ".gz", ".rar",
|
|
28
|
+
".pdf", ".doc", ".docx",
|
|
29
|
+
".wasm",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Validator struct {
|
|
33
|
+
maxFiles int
|
|
34
|
+
maxLines int
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func NewValidator(maxFiles, maxLines int) *Validator {
|
|
38
|
+
return &Validator{
|
|
39
|
+
maxFiles: maxFiles,
|
|
40
|
+
maxLines: maxLines,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (v *Validator) Validate(p *models.Patch) error {
|
|
45
|
+
if p == nil {
|
|
46
|
+
return fmt.Errorf("patch cannot be nil")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if len(p.Files) > v.maxFiles {
|
|
50
|
+
return fmt.Errorf("patch contains too many files: %d (limit: %d)", len(p.Files), v.maxFiles)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lineCount := countLines(p.RawDiff)
|
|
54
|
+
if lineCount > v.maxLines {
|
|
55
|
+
return fmt.Errorf("patch contains too many lines: %d (limit: %d)", lineCount, v.maxLines)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for _, file := range p.Files {
|
|
59
|
+
if isBinaryFile(file.Path) {
|
|
60
|
+
return fmt.Errorf("binary file cannot be modified: %s", file.Path)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if isBlockedFile(file.Path) {
|
|
64
|
+
return fmt.Errorf("sensitive file cannot be modified: %s", file.Path)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func countLines(diff string) int {
|
|
72
|
+
count := 0
|
|
73
|
+
for _, line := range strings.Split(diff, "\n") {
|
|
74
|
+
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
|
|
75
|
+
if !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") {
|
|
76
|
+
count++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return count
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func isBinaryFile(path string) bool {
|
|
84
|
+
ext := strings.ToLower(filepath.Ext(path))
|
|
85
|
+
for _, binExt := range binaryExtensions {
|
|
86
|
+
if ext == binExt {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func isBlockedFile(path string) bool {
|
|
94
|
+
base := filepath.Base(path)
|
|
95
|
+
for _, blocked := range blockedFiles {
|
|
96
|
+
if strings.EqualFold(base, blocked) {
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
if strings.HasPrefix(blocked, ".") && strings.HasSuffix(strings.ToLower(path), blocked) {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
package planning
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var stopWords = map[string]struct{}{
|
|
13
|
+
"add": {}, "the": {}, "and": {}, "for": {}, "with": {}, "into": {}, "from": {},
|
|
14
|
+
"this": {}, "that": {}, "fix": {}, "make": {}, "code": {}, "write": {},
|
|
15
|
+
"update": {}, "service": {}, "feature": {}, "bug": {}, "test": {}, "tests": {},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type candidate struct {
|
|
19
|
+
path string
|
|
20
|
+
score int
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func NormalizeTask(task *models.Task) *models.TaskBrief {
|
|
24
|
+
if task == nil {
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
desc := strings.TrimSpace(task.Description)
|
|
29
|
+
lower := strings.ToLower(desc)
|
|
30
|
+
taskType := classifyTaskType(lower)
|
|
31
|
+
risk := classifyRisk(lower, taskType)
|
|
32
|
+
|
|
33
|
+
brief := &models.TaskBrief{
|
|
34
|
+
TaskID: task.ID,
|
|
35
|
+
UserRequest: desc,
|
|
36
|
+
NormalizedGoal: normalizeGoal(desc, taskType),
|
|
37
|
+
TaskType: taskType,
|
|
38
|
+
RiskLevel: risk,
|
|
39
|
+
Constraints: []string{},
|
|
40
|
+
Assumptions: deriveAssumptions(taskType, risk),
|
|
41
|
+
SuccessDefinition: successDefinitionFor(taskType),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return brief
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func CompilePlan(task *models.Task, brief *models.TaskBrief, repoMap *models.RepoMap) *models.Plan {
|
|
48
|
+
if task == nil {
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
if brief == nil {
|
|
52
|
+
brief = NormalizeTask(task)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
inspect, modify := rankFiles(task.Description, brief.TaskType, repoMap)
|
|
56
|
+
criteria := acceptanceCriteriaFor(brief)
|
|
57
|
+
testRequirements := testRequirementsFor(brief, repoMap)
|
|
58
|
+
plan := &models.Plan{
|
|
59
|
+
TaskID: task.ID,
|
|
60
|
+
Summary: brief.NormalizedGoal,
|
|
61
|
+
TaskType: brief.TaskType,
|
|
62
|
+
RiskLevel: brief.RiskLevel,
|
|
63
|
+
Steps: buildSteps(brief, inspect, modify),
|
|
64
|
+
FilesToModify: modify,
|
|
65
|
+
FilesToInspect: inspect,
|
|
66
|
+
Risks: risksFor(brief),
|
|
67
|
+
TestStrategy: strings.Join(testRequirements, "; "),
|
|
68
|
+
TestRequirements: testRequirements,
|
|
69
|
+
AcceptanceCriteria: criteria,
|
|
70
|
+
Invariants: invariantsFor(brief),
|
|
71
|
+
ForbiddenChanges: forbiddenChangesFor(brief),
|
|
72
|
+
}
|
|
73
|
+
return plan
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func classifyTaskType(lower string) models.TaskType {
|
|
77
|
+
switch {
|
|
78
|
+
case containsAny(lower, "race", "bug", "fix", "issue", "error", "regression"):
|
|
79
|
+
return models.TaskTypeBugfix
|
|
80
|
+
case containsAny(lower, "unit test", "integration test", "test ", "tests ", "coverage"):
|
|
81
|
+
return models.TaskTypeTest
|
|
82
|
+
case containsAny(lower, "refactor", "cleanup", "readability", "simplify"):
|
|
83
|
+
return models.TaskTypeRefactor
|
|
84
|
+
case containsAny(lower, "docs", "readme", "documentation"):
|
|
85
|
+
return models.TaskTypeDocs
|
|
86
|
+
case containsAny(lower, "add", "implement", "create", "support", "enable"):
|
|
87
|
+
return models.TaskTypeFeature
|
|
88
|
+
case containsAny(lower, "bump", "upgrade", "rename", "move", "remove", "chore"):
|
|
89
|
+
return models.TaskTypeChore
|
|
90
|
+
default:
|
|
91
|
+
return models.TaskTypeUnknown
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func classifyRisk(lower string, taskType models.TaskType) models.RiskLevel {
|
|
96
|
+
if containsAny(lower, "race", "concurrency", "auth", "security", "payment", "migration", "schema", "database") {
|
|
97
|
+
return models.RiskHigh
|
|
98
|
+
}
|
|
99
|
+
switch taskType {
|
|
100
|
+
case models.TaskTypeDocs, models.TaskTypeTest:
|
|
101
|
+
return models.RiskLow
|
|
102
|
+
case models.TaskTypeRefactor, models.TaskTypeFeature, models.TaskTypeBugfix:
|
|
103
|
+
return models.RiskMedium
|
|
104
|
+
default:
|
|
105
|
+
return models.RiskMedium
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func normalizeGoal(desc string, taskType models.TaskType) string {
|
|
110
|
+
trimmed := strings.TrimSpace(desc)
|
|
111
|
+
switch taskType {
|
|
112
|
+
case models.TaskTypeBugfix:
|
|
113
|
+
return fmt.Sprintf("Fix %s while preserving existing behavior.", trimmed)
|
|
114
|
+
case models.TaskTypeTest:
|
|
115
|
+
return fmt.Sprintf("Add or update tests for %s.", trimmed)
|
|
116
|
+
case models.TaskTypeRefactor:
|
|
117
|
+
return fmt.Sprintf("Refactor %s with minimal behavior change.", trimmed)
|
|
118
|
+
case models.TaskTypeDocs:
|
|
119
|
+
return fmt.Sprintf("Update documentation for %s.", trimmed)
|
|
120
|
+
case models.TaskTypeFeature:
|
|
121
|
+
return fmt.Sprintf("Implement %s following existing repository patterns.", trimmed)
|
|
122
|
+
default:
|
|
123
|
+
return fmt.Sprintf("Address task: %s", trimmed)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func deriveAssumptions(taskType models.TaskType, risk models.RiskLevel) []string {
|
|
128
|
+
assumptions := []string{"Follow existing repository conventions and keep the diff minimal."}
|
|
129
|
+
if risk == models.RiskHigh {
|
|
130
|
+
assumptions = append(assumptions, "Prefer behavior-preserving changes and protect public interfaces.")
|
|
131
|
+
}
|
|
132
|
+
if taskType == models.TaskTypeTest {
|
|
133
|
+
assumptions = append(assumptions, "Prefer colocated or adjacent test files when possible.")
|
|
134
|
+
}
|
|
135
|
+
return assumptions
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func successDefinitionFor(taskType models.TaskType) []string {
|
|
139
|
+
switch taskType {
|
|
140
|
+
case models.TaskTypeBugfix:
|
|
141
|
+
return []string{"The reported failure path is addressed.", "Existing behavior outside the bug scope remains unchanged."}
|
|
142
|
+
case models.TaskTypeFeature:
|
|
143
|
+
return []string{"The requested behavior is implemented.", "Relevant tests or verification steps exist."}
|
|
144
|
+
case models.TaskTypeTest:
|
|
145
|
+
return []string{"Relevant tests are added or updated.", "Tests validate the intended behavior."}
|
|
146
|
+
case models.TaskTypeRefactor:
|
|
147
|
+
return []string{"Code structure improves without changing intended behavior.", "Existing tests still pass."}
|
|
148
|
+
case models.TaskTypeDocs:
|
|
149
|
+
return []string{"Documentation reflects the requested behavior accurately."}
|
|
150
|
+
default:
|
|
151
|
+
return []string{"The task is completed with minimal scope change."}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func acceptanceCriteriaFor(brief *models.TaskBrief) []models.AcceptanceCriterion {
|
|
156
|
+
if brief == nil {
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
descriptions := []string{}
|
|
161
|
+
switch brief.TaskType {
|
|
162
|
+
case models.TaskTypeBugfix:
|
|
163
|
+
descriptions = []string{
|
|
164
|
+
"The original failure condition is no longer reproducible in the intended path.",
|
|
165
|
+
"Existing behavior outside the bug scope remains unchanged.",
|
|
166
|
+
"Relevant regression verification is included or documented.",
|
|
167
|
+
}
|
|
168
|
+
case models.TaskTypeFeature:
|
|
169
|
+
descriptions = []string{
|
|
170
|
+
"The requested behavior is implemented and reachable through the intended code path.",
|
|
171
|
+
"Changes follow existing repository patterns and remain scoped to the task.",
|
|
172
|
+
"Relevant verification or tests are included.",
|
|
173
|
+
}
|
|
174
|
+
case models.TaskTypeTest:
|
|
175
|
+
descriptions = []string{
|
|
176
|
+
"Relevant tests are added or updated.",
|
|
177
|
+
"Tests cover the requested behavior or failure mode.",
|
|
178
|
+
"Production code changes remain minimal unless required for testability.",
|
|
179
|
+
}
|
|
180
|
+
case models.TaskTypeRefactor:
|
|
181
|
+
descriptions = []string{
|
|
182
|
+
"Behavior remains unchanged for supported paths.",
|
|
183
|
+
"The resulting code is easier to follow or maintain.",
|
|
184
|
+
"Existing validation and tests still pass.",
|
|
185
|
+
}
|
|
186
|
+
case models.TaskTypeDocs:
|
|
187
|
+
descriptions = []string{
|
|
188
|
+
"Documentation accurately reflects the requested behavior or workflow.",
|
|
189
|
+
"Examples or instructions remain consistent with the repository.",
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
descriptions = []string{
|
|
193
|
+
"The task goal is addressed with a minimal scoped change.",
|
|
194
|
+
"Relevant verification steps are documented or executed.",
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
criteria := make([]models.AcceptanceCriterion, 0, len(descriptions))
|
|
199
|
+
for i, description := range descriptions {
|
|
200
|
+
criteria = append(criteria, models.AcceptanceCriterion{
|
|
201
|
+
ID: fmt.Sprintf("ac-%d", i+1),
|
|
202
|
+
Description: description,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return criteria
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func testRequirementsFor(brief *models.TaskBrief, repoMap *models.RepoMap) []string {
|
|
209
|
+
requirements := []string{"Run the configured test command and confirm no new failures."}
|
|
210
|
+
framework := ""
|
|
211
|
+
if repoMap != nil {
|
|
212
|
+
framework = strings.TrimSpace(repoMap.TestFramework)
|
|
213
|
+
}
|
|
214
|
+
if framework != "" && framework != "unknown" {
|
|
215
|
+
requirements = append(requirements, fmt.Sprintf("Use repository test framework: %s.", framework))
|
|
216
|
+
}
|
|
217
|
+
if brief != nil && brief.TaskType == models.TaskTypeBugfix {
|
|
218
|
+
requirements = append(requirements, "Prefer regression coverage for the reported failure path.")
|
|
219
|
+
}
|
|
220
|
+
if brief != nil && brief.TaskType == models.TaskTypeRefactor {
|
|
221
|
+
requirements = append(requirements, "Verify behavior remains unchanged after refactoring.")
|
|
222
|
+
}
|
|
223
|
+
return requirements
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func risksFor(brief *models.TaskBrief) []string {
|
|
227
|
+
if brief == nil {
|
|
228
|
+
return nil
|
|
229
|
+
}
|
|
230
|
+
risks := []string{"Scope drift or unrelated edits must be avoided."}
|
|
231
|
+
if brief.RiskLevel == models.RiskHigh {
|
|
232
|
+
risks = append(risks, "High-risk paths require especially careful review and regression validation.")
|
|
233
|
+
}
|
|
234
|
+
switch brief.TaskType {
|
|
235
|
+
case models.TaskTypeBugfix:
|
|
236
|
+
risks = append(risks, "Bug fixes can unintentionally change behavior in adjacent code paths.")
|
|
237
|
+
case models.TaskTypeRefactor:
|
|
238
|
+
risks = append(risks, "Refactors can introduce subtle regressions without obvious API changes.")
|
|
239
|
+
case models.TaskTypeFeature:
|
|
240
|
+
risks = append(risks, "Feature work can expand into unrelated modules if scope is not enforced.")
|
|
241
|
+
}
|
|
242
|
+
return risks
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func invariantsFor(brief *models.TaskBrief) []string {
|
|
246
|
+
if brief == nil {
|
|
247
|
+
return nil
|
|
248
|
+
}
|
|
249
|
+
invariants := []string{"Do not modify sensitive files or unrelated code paths."}
|
|
250
|
+
switch brief.TaskType {
|
|
251
|
+
case models.TaskTypeBugfix, models.TaskTypeRefactor:
|
|
252
|
+
invariants = append(invariants, "Preserve existing public API behavior unless the task explicitly requires a contract change.")
|
|
253
|
+
case models.TaskTypeTest:
|
|
254
|
+
invariants = append(invariants, "Keep production behavior unchanged unless a minimal testability fix is required.")
|
|
255
|
+
}
|
|
256
|
+
return invariants
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func forbiddenChangesFor(brief *models.TaskBrief) []string {
|
|
260
|
+
if brief == nil {
|
|
261
|
+
return nil
|
|
262
|
+
}
|
|
263
|
+
forbidden := []string{
|
|
264
|
+
"Do not introduce unrelated refactors.",
|
|
265
|
+
"Do not modify sensitive configuration or secret material.",
|
|
266
|
+
"Do not reformat unrelated files.",
|
|
267
|
+
}
|
|
268
|
+
if brief.TaskType != models.TaskTypeChore {
|
|
269
|
+
forbidden = append(forbidden, "Do not upgrade dependencies unless the task explicitly requires it.")
|
|
270
|
+
}
|
|
271
|
+
return forbidden
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func buildSteps(brief *models.TaskBrief, inspect, modify []string) []models.PlanStep {
|
|
275
|
+
goal := "the requested task"
|
|
276
|
+
if brief != nil && strings.TrimSpace(brief.NormalizedGoal) != "" {
|
|
277
|
+
goal = brief.NormalizedGoal
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
steps := []models.PlanStep{{
|
|
281
|
+
Order: 1,
|
|
282
|
+
Description: fmt.Sprintf("Inspect the most relevant files and confirm scope for %s", goal),
|
|
283
|
+
}}
|
|
284
|
+
if len(modify) > 0 {
|
|
285
|
+
steps = append(steps, models.PlanStep{
|
|
286
|
+
Order: 2,
|
|
287
|
+
Description: "Implement the smallest possible code change that satisfies the acceptance criteria.",
|
|
288
|
+
TargetFile: modify[0],
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
steps = append(steps,
|
|
292
|
+
models.PlanStep{Order: 3, Description: "Validate the resulting patch against scope, safety, and repository conventions."},
|
|
293
|
+
models.PlanStep{Order: 4, Description: "Run the required verification or tests and review the outcome before apply."},
|
|
294
|
+
)
|
|
295
|
+
return steps
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func rankFiles(description string, taskType models.TaskType, repoMap *models.RepoMap) ([]string, []string) {
|
|
299
|
+
if repoMap == nil || len(repoMap.Files) == 0 {
|
|
300
|
+
return []string{}, []string{}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
tokens := tokenize(description)
|
|
304
|
+
candidates := make([]candidate, 0, len(repoMap.Files))
|
|
305
|
+
for _, file := range repoMap.Files {
|
|
306
|
+
path := strings.ToLower(file.Path)
|
|
307
|
+
score := scorePath(path, tokens, taskType)
|
|
308
|
+
if score <= 0 {
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
candidates = append(candidates, candidate{path: file.Path, score: score})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if len(candidates) == 0 {
|
|
315
|
+
for _, file := range repoMap.Files {
|
|
316
|
+
candidates = append(candidates, candidate{path: file.Path, score: 1})
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
sort.SliceStable(candidates, func(i, j int) bool {
|
|
321
|
+
if candidates[i].score == candidates[j].score {
|
|
322
|
+
return candidates[i].path < candidates[j].path
|
|
323
|
+
}
|
|
324
|
+
return candidates[i].score > candidates[j].score
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
inspect := topUnique(candidates, 6, func(path string) bool { return true })
|
|
328
|
+
modifyFilter := func(path string) bool {
|
|
329
|
+
lower := strings.ToLower(path)
|
|
330
|
+
if taskType == models.TaskTypeTest {
|
|
331
|
+
return isTestPath(lower)
|
|
332
|
+
}
|
|
333
|
+
return !isTestPath(lower) && !isConfigPath(lower)
|
|
334
|
+
}
|
|
335
|
+
modify := topUnique(candidates, 4, modifyFilter)
|
|
336
|
+
if len(modify) == 0 && len(inspect) > 0 {
|
|
337
|
+
modify = append(modify, inspect[0])
|
|
338
|
+
}
|
|
339
|
+
return inspect, modify
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
func topUnique(candidates []candidate, max int, include func(path string) bool) []string {
|
|
343
|
+
result := make([]string, 0, max)
|
|
344
|
+
seen := map[string]struct{}{}
|
|
345
|
+
for _, candidate := range candidates {
|
|
346
|
+
if !include(candidate.path) {
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if _, ok := seen[candidate.path]; ok {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
seen[candidate.path] = struct{}{}
|
|
353
|
+
result = append(result, candidate.path)
|
|
354
|
+
if len(result) >= max {
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func scorePath(path string, tokens []string, taskType models.TaskType) int {
|
|
362
|
+
score := 0
|
|
363
|
+
base := strings.ToLower(filepath.Base(path))
|
|
364
|
+
for _, token := range tokens {
|
|
365
|
+
if strings.Contains(path, token) {
|
|
366
|
+
score += 3
|
|
367
|
+
}
|
|
368
|
+
if strings.Contains(base, token) {
|
|
369
|
+
score += 2
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if taskType == models.TaskTypeTest && isTestPath(path) {
|
|
373
|
+
score += 4
|
|
374
|
+
}
|
|
375
|
+
if taskType != models.TaskTypeTest && !isTestPath(path) {
|
|
376
|
+
score += 1
|
|
377
|
+
}
|
|
378
|
+
if isConfigPath(path) {
|
|
379
|
+
score--
|
|
380
|
+
}
|
|
381
|
+
return score
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func tokenize(description string) []string {
|
|
385
|
+
parts := strings.Fields(strings.ToLower(description))
|
|
386
|
+
result := make([]string, 0, len(parts))
|
|
387
|
+
for _, part := range parts {
|
|
388
|
+
cleaned := strings.Trim(part, `"'.,:;()[]{}<>!?`)
|
|
389
|
+
if len(cleaned) < 3 {
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
if _, ok := stopWords[cleaned]; ok {
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
result = append(result, cleaned)
|
|
396
|
+
}
|
|
397
|
+
return result
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func isTestPath(path string) bool {
|
|
401
|
+
return strings.Contains(path, "_test.") || strings.Contains(path, ".test.") || strings.Contains(path, ".spec.") || strings.Contains(path, "/test") || strings.Contains(path, "\\test")
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
func isConfigPath(path string) bool {
|
|
405
|
+
base := strings.ToLower(filepath.Base(path))
|
|
406
|
+
return strings.Contains(base, "config") || strings.Contains(base, ".env") || base == "package.json" || base == "go.mod" || base == "dockerfile"
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func containsAny(s string, values ...string) bool {
|
|
410
|
+
for _, value := range values {
|
|
411
|
+
if strings.Contains(s, value) {
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return false
|
|
416
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package planning
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestNormalizeTaskBugfixClassification(t *testing.T) {
|
|
11
|
+
task := &models.Task{
|
|
12
|
+
ID: "task-1",
|
|
13
|
+
Description: "fix race condition in auth service",
|
|
14
|
+
CreatedAt: time.Now(),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
brief := NormalizeTask(task)
|
|
18
|
+
if brief == nil {
|
|
19
|
+
t.Fatalf("expected task brief")
|
|
20
|
+
}
|
|
21
|
+
if brief.TaskType != models.TaskTypeBugfix {
|
|
22
|
+
t.Fatalf("unexpected task type: %s", brief.TaskType)
|
|
23
|
+
}
|
|
24
|
+
if brief.RiskLevel != models.RiskHigh {
|
|
25
|
+
t.Fatalf("unexpected risk level: %s", brief.RiskLevel)
|
|
26
|
+
}
|
|
27
|
+
if brief.NormalizedGoal == "" {
|
|
28
|
+
t.Fatalf("expected normalized goal")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestCompilePlanIncludesAcceptanceCriteriaAndTests(t *testing.T) {
|
|
33
|
+
task := &models.Task{
|
|
34
|
+
ID: "task-2",
|
|
35
|
+
Description: "add redis caching to user service",
|
|
36
|
+
CreatedAt: time.Now(),
|
|
37
|
+
}
|
|
38
|
+
repoMap := &models.RepoMap{
|
|
39
|
+
TestFramework: "go test",
|
|
40
|
+
Files: []models.FileInfo{
|
|
41
|
+
{Path: "internal/user/service.go", Language: "go"},
|
|
42
|
+
{Path: "internal/user/cache.go", Language: "go"},
|
|
43
|
+
{Path: "internal/user/service_test.go", Language: "go"},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
brief := NormalizeTask(task)
|
|
48
|
+
plan := CompilePlan(task, brief, repoMap)
|
|
49
|
+
if plan == nil {
|
|
50
|
+
t.Fatalf("expected plan")
|
|
51
|
+
}
|
|
52
|
+
if len(plan.AcceptanceCriteria) == 0 {
|
|
53
|
+
t.Fatalf("expected acceptance criteria")
|
|
54
|
+
}
|
|
55
|
+
if len(plan.TestRequirements) == 0 {
|
|
56
|
+
t.Fatalf("expected test requirements")
|
|
57
|
+
}
|
|
58
|
+
if len(plan.FilesToInspect) == 0 {
|
|
59
|
+
t.Fatalf("expected files to inspect")
|
|
60
|
+
}
|
|
61
|
+
if len(plan.Steps) == 0 {
|
|
62
|
+
t.Fatalf("expected plan steps")
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package providers
|
|
2
|
+
|
|
3
|
+
import "fmt"
|
|
4
|
+
|
|
5
|
+
const (
|
|
6
|
+
ErrAuthError = "provider_auth_error"
|
|
7
|
+
ErrRateLimited = "provider_rate_limited"
|
|
8
|
+
ErrTimeout = "provider_timeout"
|
|
9
|
+
ErrModelUnavailable = "provider_model_unavailable"
|
|
10
|
+
ErrTransient = "provider_transient_error"
|
|
11
|
+
ErrInvalidResponse = "provider_invalid_response"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Error struct {
|
|
15
|
+
Code string
|
|
16
|
+
Message string
|
|
17
|
+
Cause error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (e *Error) Error() string {
|
|
21
|
+
if e == nil {
|
|
22
|
+
return "provider error"
|
|
23
|
+
}
|
|
24
|
+
if e.Cause != nil {
|
|
25
|
+
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Cause)
|
|
26
|
+
}
|
|
27
|
+
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (e *Error) Unwrap() error {
|
|
31
|
+
if e == nil {
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
return e.Cause
|
|
35
|
+
}
|