opencastle 0.26.1 → 0.27.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/README.md +7 -1
- package/bin/cli.mjs +10 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +68 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2102 -26
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1572 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/events.d.ts +4 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +74 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +154 -27
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +47 -5
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +525 -19
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1345 -12
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +156 -2
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/destroy.d.ts +3 -0
- package/dist/cli/destroy.d.ts.map +1 -0
- package/dist/cli/destroy.js +69 -0
- package/dist/cli/destroy.js.map +1 -0
- package/dist/cli/destroy.test.d.ts +2 -0
- package/dist/cli/destroy.test.d.ts.map +1 -0
- package/dist/cli/destroy.test.js +116 -0
- package/dist/cli/destroy.test.js.map +1 -0
- package/dist/cli/gitignore.d.ts +9 -0
- package/dist/cli/gitignore.d.ts.map +1 -1
- package/dist/cli/gitignore.js +29 -0
- package/dist/cli/gitignore.js.map +1 -1
- package/dist/cli/plan.d.ts +3 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +288 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/engine.test.ts +1839 -70
- package/src/cli/convoy/engine.ts +2417 -38
- package/src/cli/convoy/events.test.ts +179 -38
- package/src/cli/convoy/events.ts +88 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +1512 -14
- package/src/cli/convoy/store.ts +676 -30
- package/src/cli/convoy/types.ts +170 -1
- package/src/cli/destroy.test.ts +141 -0
- package/src/cli/destroy.ts +88 -0
- package/src/cli/gitignore.ts +36 -0
- package/src/cli/plan.ts +316 -0
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { scanForSecrets } from './gates.js'
|
|
4
|
+
|
|
5
|
+
const EXPERTISE_PATH = '.opencastle/AGENT-EXPERTISE.md'
|
|
6
|
+
|
|
7
|
+
export function readExpertise(
|
|
8
|
+
agentName: string,
|
|
9
|
+
basePath?: string,
|
|
10
|
+
): { strong: string[]; weak: string[]; files: string[] } {
|
|
11
|
+
const base = basePath ?? process.cwd()
|
|
12
|
+
const filePath = join(base, EXPERTISE_PATH)
|
|
13
|
+
const empty = { strong: [] as string[], weak: [] as string[], files: [] as string[] }
|
|
14
|
+
if (!existsSync(filePath)) return empty
|
|
15
|
+
|
|
16
|
+
const content = readFileSync(filePath, 'utf8')
|
|
17
|
+
const lines = content.split('\n')
|
|
18
|
+
const agentHeaderRegex = new RegExp(
|
|
19
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
20
|
+
'i',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
let inAgentSection = false
|
|
24
|
+
let currentSubsection = ''
|
|
25
|
+
const result = { strong: [] as string[], weak: [] as string[], files: [] as string[] }
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (agentHeaderRegex.test(line)) {
|
|
29
|
+
inAgentSection = true
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (inAgentSection) {
|
|
33
|
+
if (line.startsWith('## ')) break
|
|
34
|
+
if (line.startsWith('### ')) {
|
|
35
|
+
currentSubsection = line.replace(/^###\s*/, '').trim()
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith('- ')) {
|
|
39
|
+
const item = line.replace(/^-\s*/, '').trim()
|
|
40
|
+
if (currentSubsection === 'Strong Areas') result.strong.push(item)
|
|
41
|
+
else if (currentSubsection === 'Weak Areas') result.weak.push(item)
|
|
42
|
+
else if (currentSubsection === 'File Familiarity') result.files.push(item)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendBulletToSubsection(
|
|
51
|
+
lines: string[],
|
|
52
|
+
agentName: string,
|
|
53
|
+
subsection: string,
|
|
54
|
+
item: string,
|
|
55
|
+
): void {
|
|
56
|
+
const agentHeaderRegex = new RegExp(
|
|
57
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
58
|
+
'i',
|
|
59
|
+
)
|
|
60
|
+
const subsectionHeader = '### ' + subsection
|
|
61
|
+
|
|
62
|
+
let inAgentSection = false
|
|
63
|
+
let inSubsection = false
|
|
64
|
+
let insertAfterLine = -1
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
if (agentHeaderRegex.test(lines[i])) {
|
|
68
|
+
inAgentSection = true
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (inAgentSection) {
|
|
72
|
+
if (lines[i].startsWith('## ')) break
|
|
73
|
+
if (lines[i] === subsectionHeader) {
|
|
74
|
+
inSubsection = true
|
|
75
|
+
insertAfterLine = i
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (inSubsection) {
|
|
79
|
+
if (lines[i].startsWith('### ') || lines[i].startsWith('## ')) break
|
|
80
|
+
if (lines[i].startsWith('- ')) {
|
|
81
|
+
insertAfterLine = i
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (insertAfterLine !== -1) {
|
|
88
|
+
lines.splice(insertAfterLine + 1, 0, '- ' + item)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureAgentSection(lines: string[], agentName: string): void {
|
|
93
|
+
const agentHeaderRegex = new RegExp(
|
|
94
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
95
|
+
'i',
|
|
96
|
+
)
|
|
97
|
+
const exists = lines.some(l => agentHeaderRegex.test(l))
|
|
98
|
+
if (!exists) {
|
|
99
|
+
lines.push(
|
|
100
|
+
'',
|
|
101
|
+
'## ' + agentName,
|
|
102
|
+
'',
|
|
103
|
+
'### Strong Areas',
|
|
104
|
+
'',
|
|
105
|
+
'### Weak Areas',
|
|
106
|
+
'',
|
|
107
|
+
'### File Familiarity',
|
|
108
|
+
'',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function updateExpertise(
|
|
114
|
+
agentName: string,
|
|
115
|
+
taskResult: { taskId: string; success: boolean; retries: number; files: string[] },
|
|
116
|
+
basePath?: string,
|
|
117
|
+
): { updated: boolean; reason?: string } {
|
|
118
|
+
const base = basePath ?? process.cwd()
|
|
119
|
+
const filePath = join(base, EXPERTISE_PATH)
|
|
120
|
+
|
|
121
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
122
|
+
const outcome = taskResult.success ? 'success' : 'failed'
|
|
123
|
+
const entryText =
|
|
124
|
+
'[' + date + '] ' + taskResult.taskId + ': ' + outcome +
|
|
125
|
+
'/retries=' + taskResult.retries + ', files: [' + taskResult.files.join(', ') + ']'
|
|
126
|
+
|
|
127
|
+
const scanResult = scanForSecrets(entryText, 'expertise')
|
|
128
|
+
if (!scanResult.clean) {
|
|
129
|
+
return { updated: false, reason: 'secrets_detected' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const initialContent = existsSync(filePath)
|
|
133
|
+
? readFileSync(filePath, 'utf8')
|
|
134
|
+
: '# Agent Expertise\n\nTracking agent performance across tasks.\n'
|
|
135
|
+
|
|
136
|
+
const lines = initialContent.split('\n')
|
|
137
|
+
ensureAgentSection(lines, agentName)
|
|
138
|
+
|
|
139
|
+
if (taskResult.success && taskResult.retries === 0) {
|
|
140
|
+
appendBulletToSubsection(lines, agentName, 'Strong Areas', entryText)
|
|
141
|
+
} else if (taskResult.success && taskResult.retries > 0) {
|
|
142
|
+
appendBulletToSubsection(lines, agentName, 'Strong Areas', entryText)
|
|
143
|
+
appendBulletToSubsection(lines, agentName, 'Weak Areas', entryText)
|
|
144
|
+
} else {
|
|
145
|
+
appendBulletToSubsection(lines, agentName, 'Weak Areas', entryText)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const existing = readExpertise(agentName, base)
|
|
149
|
+
const currentFiles = new Set(existing.files)
|
|
150
|
+
for (const f of taskResult.files) {
|
|
151
|
+
if (!currentFiles.has(f)) {
|
|
152
|
+
appendBulletToSubsection(lines, agentName, 'File Familiarity', f)
|
|
153
|
+
currentFiles.add(f)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
158
|
+
return { updated: true }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function feedCircuitBreaker(agentName: string, basePath?: string): string[] {
|
|
162
|
+
return readExpertise(agentName, basePath).weak
|
|
163
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtempSync,
|
|
3
|
+
rmSync,
|
|
4
|
+
realpathSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
parseFormula,
|
|
13
|
+
substituteVariables,
|
|
14
|
+
validateTemplate,
|
|
15
|
+
FormulaValidationError,
|
|
16
|
+
} from './formula.js'
|
|
17
|
+
import type { FormulaTemplate } from './formula.js'
|
|
18
|
+
|
|
19
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function makeBase(): string {
|
|
22
|
+
return realpathSync(mkdtempSync(join(tmpdir(), 'formula-test-')))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal valid TaskSpec content for use in formula `spec:` sections.
|
|
27
|
+
* After substitution, this must pass parseTaskSpecText validation.
|
|
28
|
+
*/
|
|
29
|
+
const MINIMAL_SPEC = {
|
|
30
|
+
name: 'Test convoy',
|
|
31
|
+
version: 1,
|
|
32
|
+
tasks: [{ id: 'task-1', prompt: 'Do the thing' }],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeFormula(dir: string, content: string): string {
|
|
36
|
+
const path = join(dir, 'formula.convoy.yml')
|
|
37
|
+
writeFileSync(path, content)
|
|
38
|
+
return path
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let tmpDir: string
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpDir = makeBase()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ── parseFormula ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('parseFormula', () => {
|
|
54
|
+
it('reads a formula file and returns a FormulaTemplate', () => {
|
|
55
|
+
const path = writeFormula(
|
|
56
|
+
tmpDir,
|
|
57
|
+
[
|
|
58
|
+
'name: My Formula',
|
|
59
|
+
'description: A test formula',
|
|
60
|
+
'variables:',
|
|
61
|
+
' app_name:',
|
|
62
|
+
' description: App name',
|
|
63
|
+
' required: true',
|
|
64
|
+
'spec:',
|
|
65
|
+
' name: Test convoy',
|
|
66
|
+
' version: 1',
|
|
67
|
+
' tasks:',
|
|
68
|
+
' - id: task-1',
|
|
69
|
+
' prompt: Do the thing',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const template = parseFormula(path)
|
|
74
|
+
expect(template.name).toBe('My Formula')
|
|
75
|
+
expect(template.description).toBe('A test formula')
|
|
76
|
+
expect(template.variables['app_name']).toMatchObject({ required: true })
|
|
77
|
+
expect(template.spec).toBeTruthy()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('throws when file does not exist', () => {
|
|
81
|
+
expect(() => parseFormula(join(tmpDir, 'missing.yml'))).toThrow(
|
|
82
|
+
/Cannot read formula file/,
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws on invalid YAML', () => {
|
|
87
|
+
const path = writeFormula(tmpDir, ': {{{')
|
|
88
|
+
expect(() => parseFormula(path)).toThrow(/Formula YAML parse error/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('throws when name field is missing', () => {
|
|
92
|
+
const path = writeFormula(
|
|
93
|
+
tmpDir,
|
|
94
|
+
'spec:\n name: Test convoy\n version: 1\n tasks:\n - id: t1\n prompt: p\n',
|
|
95
|
+
)
|
|
96
|
+
expect(() => parseFormula(path)).toThrow(/must have a "name" field/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('throws when spec field is missing', () => {
|
|
100
|
+
const path = writeFormula(tmpDir, 'name: My Formula\n')
|
|
101
|
+
expect(() => parseFormula(path)).toThrow(/must have a "spec" field/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns empty variables when no variables section', () => {
|
|
105
|
+
const path = writeFormula(
|
|
106
|
+
tmpDir,
|
|
107
|
+
'name: Simple\nspec:\n name: Test\n version: 1\n tasks:\n - id: t1\n prompt: p\n',
|
|
108
|
+
)
|
|
109
|
+
const template = parseFormula(path)
|
|
110
|
+
expect(template.variables).toEqual({})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('parses optional flag and default value', () => {
|
|
114
|
+
const path = writeFormula(
|
|
115
|
+
tmpDir,
|
|
116
|
+
[
|
|
117
|
+
'name: With defaults',
|
|
118
|
+
'variables:',
|
|
119
|
+
' env:',
|
|
120
|
+
' required: false',
|
|
121
|
+
' default: staging',
|
|
122
|
+
'spec:',
|
|
123
|
+
' name: Test',
|
|
124
|
+
' version: 1',
|
|
125
|
+
' tasks:',
|
|
126
|
+
' - id: t1',
|
|
127
|
+
' prompt: p',
|
|
128
|
+
].join('\n'),
|
|
129
|
+
)
|
|
130
|
+
const template = parseFormula(path)
|
|
131
|
+
expect(template.variables['env']).toMatchObject({ required: false, default: 'staging' })
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ── substituteVariables ───────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe('substituteVariables', () => {
|
|
138
|
+
function makeTemplate(overrides: Partial<FormulaTemplate> = {}): FormulaTemplate {
|
|
139
|
+
return {
|
|
140
|
+
name: 'Test Formula',
|
|
141
|
+
variables: {},
|
|
142
|
+
spec: MINIMAL_SPEC,
|
|
143
|
+
...overrides,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('substitutes a plain variable', () => {
|
|
148
|
+
const template = makeTemplate({
|
|
149
|
+
variables: { feature: { required: true } },
|
|
150
|
+
spec: {
|
|
151
|
+
name: '{{feature}}',
|
|
152
|
+
version: 1,
|
|
153
|
+
tasks: [{ id: 'task-1', prompt: 'Build {{feature}}' }],
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
const result = substituteVariables(template, { feature: 'auth' })
|
|
157
|
+
expect(result.name).toBe('auth')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('applies kebab filter', () => {
|
|
161
|
+
const template = makeTemplate({
|
|
162
|
+
variables: { feature: { required: true } },
|
|
163
|
+
spec: {
|
|
164
|
+
name: 'test',
|
|
165
|
+
version: 1,
|
|
166
|
+
tasks: [{ id: 'task-1', prompt: '{{feature | kebab}}' }],
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
const result = substituteVariables(template, { feature: 'MyFeature Name' })
|
|
170
|
+
expect(result.tasks![0].prompt).toContain('my-feature-name')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('applies snake filter', () => {
|
|
174
|
+
const template = makeTemplate({
|
|
175
|
+
variables: { feature: { required: true } },
|
|
176
|
+
spec: {
|
|
177
|
+
name: 'test',
|
|
178
|
+
version: 1,
|
|
179
|
+
tasks: [{ id: 'task-1', prompt: '{{feature | snake}}' }],
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
const result = substituteVariables(template, { feature: 'MyFeature Name' })
|
|
183
|
+
expect(result.tasks![0].prompt).toContain('my_feature_name')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('applies upper filter', () => {
|
|
187
|
+
const template = makeTemplate({
|
|
188
|
+
variables: { feature: { required: true } },
|
|
189
|
+
spec: {
|
|
190
|
+
name: 'test',
|
|
191
|
+
version: 1,
|
|
192
|
+
tasks: [{ id: 'task-1', prompt: '{{feature | upper}}' }],
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
const result = substituteVariables(template, { feature: 'my feature' })
|
|
196
|
+
expect(result.tasks![0].prompt).toContain('MY_FEATURE')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('throws FormulaValidationError for missing required variable', () => {
|
|
200
|
+
const template = makeTemplate({
|
|
201
|
+
variables: { required_var: { required: true } },
|
|
202
|
+
spec: {
|
|
203
|
+
name: 'test',
|
|
204
|
+
version: 1,
|
|
205
|
+
tasks: [{ id: 'task-1', prompt: '{{required_var}}' }],
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
expect(() => substituteVariables(template, {})).toThrow(FormulaValidationError)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('includes missing variable name in FormulaValidationError', () => {
|
|
212
|
+
const template = makeTemplate({
|
|
213
|
+
variables: { missing_var: { required: true } },
|
|
214
|
+
spec: {
|
|
215
|
+
name: 'test',
|
|
216
|
+
version: 1,
|
|
217
|
+
tasks: [{ id: 'task-1', prompt: '{{missing_var}}' }],
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
let err: FormulaValidationError | undefined
|
|
221
|
+
try {
|
|
222
|
+
substituteVariables(template, {})
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e instanceof FormulaValidationError) err = e
|
|
225
|
+
}
|
|
226
|
+
expect(err).toBeDefined()
|
|
227
|
+
expect(err!.missingVariables).toContain('missing_var')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('collects all missing required variables', () => {
|
|
231
|
+
const template = makeTemplate({
|
|
232
|
+
variables: {
|
|
233
|
+
var_a: { required: true },
|
|
234
|
+
var_b: { required: true },
|
|
235
|
+
},
|
|
236
|
+
spec: {
|
|
237
|
+
name: 'test',
|
|
238
|
+
version: 1,
|
|
239
|
+
tasks: [{ id: 'task-1', prompt: '{{var_a}} and {{var_b}}' }],
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
let err: FormulaValidationError | undefined
|
|
243
|
+
try {
|
|
244
|
+
substituteVariables(template, {})
|
|
245
|
+
} catch (e) {
|
|
246
|
+
if (e instanceof FormulaValidationError) err = e
|
|
247
|
+
}
|
|
248
|
+
expect(err!.missingVariables).toEqual(expect.arrayContaining(['var_a', 'var_b']))
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('uses default value for optional variable without provided value', () => {
|
|
252
|
+
const template = makeTemplate({
|
|
253
|
+
variables: { env: { required: false, default: 'production' } },
|
|
254
|
+
spec: {
|
|
255
|
+
name: 'test',
|
|
256
|
+
version: 1,
|
|
257
|
+
tasks: [{ id: 'task-1', prompt: 'Deploy to {{env}}' }],
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
const result = substituteVariables(template, {})
|
|
261
|
+
expect(result.tasks![0].prompt).toContain('production')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('uses empty string for optional variable with no default', () => {
|
|
265
|
+
const template = makeTemplate({
|
|
266
|
+
variables: { opt: { required: false } },
|
|
267
|
+
spec: {
|
|
268
|
+
name: 'test-task',
|
|
269
|
+
version: 1,
|
|
270
|
+
tasks: [{ id: 'task-1', prompt: 'prefix-{{opt}}-suffix' }],
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
const result = substituteVariables(template, {})
|
|
274
|
+
expect(result.tasks![0].prompt).toContain('prefix--suffix')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('returns a valid TaskSpec', () => {
|
|
278
|
+
const template = makeTemplate({
|
|
279
|
+
variables: { app: { required: true } },
|
|
280
|
+
spec: {
|
|
281
|
+
name: 'Deploy {{app}}',
|
|
282
|
+
version: 1,
|
|
283
|
+
tasks: [{ id: 'task-1', prompt: 'Deploy {{app}}' }],
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
const result = substituteVariables(template, { app: 'myapp' })
|
|
287
|
+
expect(result).toMatchObject({ name: expect.any(String) })
|
|
288
|
+
expect(Array.isArray(result.tasks)).toBe(true)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// ── validateTemplate ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe('validateTemplate', () => {
|
|
295
|
+
function makeTemplate(overrides: Partial<FormulaTemplate> = {}): FormulaTemplate {
|
|
296
|
+
return {
|
|
297
|
+
name: 'Valid Template',
|
|
298
|
+
variables: {},
|
|
299
|
+
spec: MINIMAL_SPEC,
|
|
300
|
+
...overrides,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it('returns valid for a correct template', () => {
|
|
305
|
+
const result = validateTemplate(makeTemplate())
|
|
306
|
+
expect(result.valid).toBe(true)
|
|
307
|
+
expect(result.errors).toHaveLength(0)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('returns error when name is missing', () => {
|
|
311
|
+
const template = makeTemplate({ name: '' })
|
|
312
|
+
const result = validateTemplate(template)
|
|
313
|
+
expect(result.valid).toBe(false)
|
|
314
|
+
expect(result.errors.some(e => e.includes('"name"'))).toBe(true)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('returns error when spec is null', () => {
|
|
318
|
+
const template = makeTemplate({ spec: null as unknown as unknown })
|
|
319
|
+
const result = validateTemplate(template)
|
|
320
|
+
expect(result.valid).toBe(false)
|
|
321
|
+
expect(result.errors.some(e => e.includes('"spec"'))).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('returns error for invalid variable identifier', () => {
|
|
325
|
+
const template = makeTemplate({
|
|
326
|
+
variables: { 'invalid-name': { required: false } },
|
|
327
|
+
})
|
|
328
|
+
const result = validateTemplate(template)
|
|
329
|
+
expect(result.valid).toBe(false)
|
|
330
|
+
expect(result.errors.some(e => e.includes('invalid-name'))).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('allows valid variable identifiers', () => {
|
|
334
|
+
const template = makeTemplate({
|
|
335
|
+
variables: {
|
|
336
|
+
app_name: { required: true },
|
|
337
|
+
env2: { required: false },
|
|
338
|
+
MY_VAR: { required: false },
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
const result = validateTemplate(template)
|
|
342
|
+
expect(result.valid).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('writes a warning for undeclared placeholder', () => {
|
|
346
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
347
|
+
const template = makeTemplate({
|
|
348
|
+
variables: {},
|
|
349
|
+
spec: {
|
|
350
|
+
name: 'test',
|
|
351
|
+
version: 1,
|
|
352
|
+
tasks: [{ id: 'task-1', prompt: 'Do {{undeclared_var}}' }],
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
validateTemplate(template)
|
|
356
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('undeclared_var'))
|
|
357
|
+
stderrSpy.mockRestore()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('does not warn for declared placeholders', () => {
|
|
361
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
362
|
+
const template = makeTemplate({
|
|
363
|
+
variables: { declared_var: { required: true } },
|
|
364
|
+
spec: {
|
|
365
|
+
name: 'test',
|
|
366
|
+
version: 1,
|
|
367
|
+
tasks: [{ id: 'task-1', prompt: 'Do {{declared_var}}' }],
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
validateTemplate(template)
|
|
371
|
+
expect(stderrSpy).not.toHaveBeenCalled()
|
|
372
|
+
stderrSpy.mockRestore()
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// ── Filter helpers ────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
describe('filter conversions via substituteVariables', () => {
|
|
379
|
+
function makeFilterTemplate(filter: string): FormulaTemplate {
|
|
380
|
+
return {
|
|
381
|
+
name: 'Filter Test',
|
|
382
|
+
variables: { val: { required: true } },
|
|
383
|
+
spec: {
|
|
384
|
+
name: `{{val | ${filter}}}`,
|
|
385
|
+
version: 1,
|
|
386
|
+
tasks: [{ id: 'task-1', prompt: 'x' }],
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
it('kebab: converts spaces and underscores to hyphens, lowercases', () => {
|
|
392
|
+
const result = substituteVariables(makeFilterTemplate('kebab'), { val: 'Hello World_Test' })
|
|
393
|
+
expect(result.name).toBe('hello-world-test')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('snake: converts spaces and hyphens to underscores, lowercases', () => {
|
|
397
|
+
const result = substituteVariables(makeFilterTemplate('snake'), { val: 'Hello World-Test' })
|
|
398
|
+
expect(result.name).toBe('hello_world_test')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('upper: converts to uppercase with underscores', () => {
|
|
402
|
+
const result = substituteVariables(makeFilterTemplate('upper'), { val: 'hello world' })
|
|
403
|
+
expect(result.name).toBe('HELLO_WORLD')
|
|
404
|
+
})
|
|
405
|
+
})
|