opencastle 0.31.3 → 0.31.4

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.
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { describe, it, expect, vi } from 'vitest'
2
2
  import { parse as yamlParse } from 'yaml'
3
3
  import { parseYaml, validateSpec } from '../run/schema.js'
4
4
  import {
@@ -271,12 +271,14 @@ describe('applyPatches', () => {
271
271
  expect(patched.branch).toBe('feat/patched-branch')
272
272
  })
273
273
 
274
- it('skips silently when task id does not exist', () => {
274
+ it('leaves task unchanged when task id does not exist', () => {
275
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
275
276
  const plan = minimalPlan()
276
277
  const patched = applyPatches(plan, [
277
278
  { task_id: 'nonexistent', field: 'prompt', value: 'x' },
278
279
  ])
279
280
  expect(patched.tasks[0].prompt).toBe('Do something useful')
281
+ warnSpy.mockRestore()
280
282
  })
281
283
 
282
284
  it('returns a new object (original plan unchanged)', () => {
@@ -306,6 +308,54 @@ describe('applyPatches', () => {
306
308
  expect(patched.tasks[1].agent).toBe('ui-expert')
307
309
  expect(patched.on_failure).toBe('continue')
308
310
  })
311
+
312
+ it('warns when patch targets unknown task_id', () => {
313
+ const plan: TaskPlan = {
314
+ name: 'test',
315
+ tasks: [{ id: 'task-1', prompt: 'do something' }],
316
+ }
317
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
318
+ const result = applyPatches(plan, [
319
+ { task_id: 'nonexistent', field: 'prompt', value: 'new prompt' },
320
+ ])
321
+ expect(warnSpy).toHaveBeenCalledWith(
322
+ expect.stringContaining('nonexistent')
323
+ )
324
+ // Original task unchanged
325
+ expect(result.tasks[0].prompt).toBe('do something')
326
+ warnSpy.mockRestore()
327
+ })
328
+
329
+ it('applies valid patches and warns for invalid ones', () => {
330
+ const plan: TaskPlan = {
331
+ name: 'test',
332
+ tasks: [
333
+ { id: 'task-1', prompt: 'original' },
334
+ { id: 'task-2', prompt: 'original2' },
335
+ ],
336
+ }
337
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
338
+ const result = applyPatches(plan, [
339
+ { task_id: 'task-1', field: 'prompt', value: 'updated' },
340
+ { task_id: 'ghost', field: 'prompt', value: 'nope' },
341
+ ])
342
+ expect(result.tasks[0].prompt).toBe('updated')
343
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ghost'))
344
+ warnSpy.mockRestore()
345
+ })
346
+
347
+ it('does not warn when all patches target valid tasks', () => {
348
+ const plan: TaskPlan = {
349
+ name: 'test',
350
+ tasks: [{ id: 'task-1', prompt: 'original' }],
351
+ }
352
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
353
+ applyPatches(plan, [
354
+ { task_id: 'task-1', field: 'prompt', value: 'updated' },
355
+ ])
356
+ expect(warnSpy).not.toHaveBeenCalled()
357
+ warnSpy.mockRestore()
358
+ })
309
359
  })
310
360
 
311
361
  // ── parseTaskPlan ──────────────────────────────────────────────────────────────
@@ -392,6 +442,62 @@ describe('parseTaskPlan', () => {
392
442
  const withTrailing = json + '\n```\nSome extra text'
393
443
  expect(parseTaskPlan(withTrailing)).toBeNull()
394
444
  })
445
+
446
+ it('returns null for duplicate task IDs', () => {
447
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
448
+ const plan = JSON.stringify({
449
+ name: 'test',
450
+ tasks: [
451
+ { id: 'setup', prompt: 'Do setup' },
452
+ { id: 'setup', prompt: 'Do setup again' },
453
+ ],
454
+ })
455
+ expect(parseTaskPlan(plan)).toBeNull()
456
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('setup'))
457
+ warnSpy.mockRestore()
458
+ })
459
+
460
+ it('returns null for invalid depends_on reference', () => {
461
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
462
+ const plan = JSON.stringify({
463
+ name: 'test',
464
+ tasks: [
465
+ { id: 'a', prompt: 'Do A' },
466
+ { id: 'b', prompt: 'Do B', depends_on: ['nonexistent'] },
467
+ ],
468
+ })
469
+ expect(parseTaskPlan(plan)).toBeNull()
470
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent'))
471
+ warnSpy.mockRestore()
472
+ })
473
+
474
+ it('returns null for dependency cycle', () => {
475
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
476
+ const plan = JSON.stringify({
477
+ name: 'test',
478
+ tasks: [
479
+ { id: 'a', prompt: 'Do A', depends_on: ['b'] },
480
+ { id: 'b', prompt: 'Do B', depends_on: ['a'] },
481
+ ],
482
+ })
483
+ expect(parseTaskPlan(plan)).toBeNull()
484
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('cycle'))
485
+ warnSpy.mockRestore()
486
+ })
487
+
488
+ it('accepts valid plan with correct dependencies', () => {
489
+ const plan = JSON.stringify({
490
+ name: 'test',
491
+ tasks: [
492
+ { id: 'a', prompt: 'Do A' },
493
+ { id: 'b', prompt: 'Do B', depends_on: ['a'] },
494
+ { id: 'c', prompt: 'Do C', depends_on: ['a', 'b'] },
495
+ ],
496
+ })
497
+ const result = parseTaskPlan(plan)
498
+ expect(result).not.toBeNull()
499
+ expect(result!.tasks).toHaveLength(3)
500
+ })
395
501
  })
396
502
 
397
503
  // ── parsePatches ──────────────────────────────────────────────────────────────
@@ -169,6 +169,7 @@ export function buildConvoyYaml(plan: TaskPlan, enrichment?: SpecEnrichment): st
169
169
  */
170
170
  export function applyPatches(plan: TaskPlan, patches: TaskPatch[]): TaskPlan {
171
171
  const clone = structuredClone(plan)
172
+ let skipped = 0
172
173
  for (const patch of patches) {
173
174
  if (patch.task_id === '_plan') {
174
175
  ;(clone as unknown as Record<string, unknown>)[patch.field] = patch.value
@@ -176,10 +177,15 @@ export function applyPatches(plan: TaskPlan, patches: TaskPatch[]): TaskPlan {
176
177
  const task = clone.tasks.find((t) => t.id === patch.task_id)
177
178
  if (task) {
178
179
  ;(task as unknown as Record<string, unknown>)[patch.field] = patch.value
180
+ } else {
181
+ console.warn(` ⚠ applyPatches: patch targets unknown task "${patch.task_id}" — skipping`)
182
+ skipped++
179
183
  }
180
- // task not found: skip silently
181
184
  }
182
185
  }
186
+ if (skipped > 0) {
187
+ console.warn(` ⚠ applyPatches: ${skipped} of ${patches.length} patches skipped (unknown task IDs)`)
188
+ }
183
189
  return clone
184
190
  }
185
191
 
@@ -194,7 +200,66 @@ export function parseTaskPlan(jsonText: string): TaskPlan | null {
194
200
  for (const task of parsed.tasks as unknown[]) {
195
201
  const t = task as Record<string, unknown>
196
202
  if (typeof t.id !== 'string' || typeof t.prompt !== 'string') return null
203
+ if ((t.prompt as string).length === 0) return null
204
+ }
205
+
206
+ // Validate unique task IDs
207
+ const tasks = parsed.tasks as Array<Record<string, unknown>>
208
+ const ids = new Set<string>()
209
+ const duplicates: string[] = []
210
+ for (const task of tasks) {
211
+ const id = task.id as string
212
+ if (ids.has(id)) duplicates.push(id)
213
+ ids.add(id)
214
+ }
215
+ if (duplicates.length > 0) {
216
+ console.warn(` ⚠ parseTaskPlan: duplicate task IDs: ${duplicates.join(', ')}`)
217
+ return null
197
218
  }
219
+
220
+ // Validate depends_on references
221
+ for (const task of tasks) {
222
+ if (Array.isArray(task.depends_on)) {
223
+ for (const dep of task.depends_on as string[]) {
224
+ if (!ids.has(dep)) {
225
+ console.warn(` ⚠ parseTaskPlan: task "${task.id as string}" depends on unknown task "${dep}"`)
226
+ return null
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ // Cycle detection (Kahn's algorithm)
233
+ const inDegree = new Map<string, number>()
234
+ const adj = new Map<string, string[]>()
235
+ for (const id of ids) {
236
+ inDegree.set(id, 0)
237
+ adj.set(id, [])
238
+ }
239
+ for (const task of tasks) {
240
+ if (Array.isArray(task.depends_on)) {
241
+ for (const dep of task.depends_on as string[]) {
242
+ adj.get(dep)!.push(task.id as string)
243
+ inDegree.set(task.id as string, (inDegree.get(task.id as string) ?? 0) + 1)
244
+ }
245
+ }
246
+ }
247
+ const queue = [...ids].filter(id => inDegree.get(id) === 0)
248
+ let visited = 0
249
+ while (queue.length > 0) {
250
+ const node = queue.shift()!
251
+ visited++
252
+ for (const next of adj.get(node) ?? []) {
253
+ const deg = (inDegree.get(next) ?? 1) - 1
254
+ inDegree.set(next, deg)
255
+ if (deg === 0) queue.push(next)
256
+ }
257
+ }
258
+ if (visited < ids.size) {
259
+ console.warn(` ⚠ parseTaskPlan: dependency cycle detected`)
260
+ return null
261
+ }
262
+
198
263
  return parsed as unknown as TaskPlan
199
264
  } catch {
200
265
  return null
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseComplexityAssessment, deriveComplexityPath } from './pipeline.js'
2
+ import { parseComplexityAssessment, deriveComplexityPath, validateComplexityGroups, topologicalSortGroups } from './pipeline.js'
3
3
 
4
4
  const SINGLE_JSON = JSON.stringify({
5
5
  original_prompt: 'Build a REST API with user auth',
@@ -151,3 +151,132 @@ describe('deriveComplexityPath', () => {
151
151
  )
152
152
  })
153
153
  })
154
+
155
+ describe('validateComplexityGroups', () => {
156
+ it('accepts valid groups', () => {
157
+ const assessment = {
158
+ original_prompt: 'test',
159
+ total_tasks: 6,
160
+ total_phases: 3,
161
+ domains: ['frontend', 'api'],
162
+ complexity: 'medium' as const,
163
+ recommended_strategy: 'chain' as const,
164
+ convoy_groups: [
165
+ { name: 'backend', description: 'API', phases: [1, 2], depends_on: [] },
166
+ { name: 'frontend', description: 'UI', phases: [3], depends_on: ['backend'] },
167
+ ],
168
+ }
169
+ expect(validateComplexityGroups(assessment)).toEqual({ valid: true, reason: '' })
170
+ })
171
+
172
+ it('rejects groups exceeding max count for small projects', () => {
173
+ const assessment = {
174
+ original_prompt: 'test',
175
+ total_tasks: 10,
176
+ total_phases: 4,
177
+ domains: ['a'],
178
+ complexity: 'medium' as const,
179
+ recommended_strategy: 'chain' as const,
180
+ convoy_groups: [
181
+ { name: 'a', description: 'A', phases: [1], depends_on: [] },
182
+ { name: 'b', description: 'B', phases: [2], depends_on: [] },
183
+ { name: 'c-group', description: 'C', phases: [3], depends_on: [] },
184
+ { name: 'd-group', description: 'D', phases: [4], depends_on: [] },
185
+ ],
186
+ }
187
+ const result = validateComplexityGroups(assessment)
188
+ expect(result.valid).toBe(false)
189
+ expect(result.reason).toContain('maximum')
190
+ })
191
+
192
+ it('rejects overlapping phases', () => {
193
+ const assessment = {
194
+ original_prompt: 'test',
195
+ total_tasks: 8,
196
+ total_phases: 3,
197
+ domains: ['a'],
198
+ complexity: 'medium' as const,
199
+ recommended_strategy: 'chain' as const,
200
+ convoy_groups: [
201
+ { name: 'group-a', description: 'A', phases: [1, 2], depends_on: [] },
202
+ { name: 'group-b', description: 'B', phases: [2, 3], depends_on: [] },
203
+ ],
204
+ }
205
+ const result = validateComplexityGroups(assessment)
206
+ expect(result.valid).toBe(false)
207
+ expect(result.reason).toContain('overlap')
208
+ })
209
+
210
+ it('rejects invalid depends_on references', () => {
211
+ const assessment = {
212
+ original_prompt: 'test',
213
+ total_tasks: 8,
214
+ total_phases: 2,
215
+ domains: ['a'],
216
+ complexity: 'medium' as const,
217
+ recommended_strategy: 'chain' as const,
218
+ convoy_groups: [
219
+ { name: 'group-a', description: 'A', phases: [1], depends_on: ['nonexistent'] },
220
+ ],
221
+ }
222
+ const result = validateComplexityGroups(assessment)
223
+ expect(result.valid).toBe(false)
224
+ expect(result.reason).toContain('nonexistent')
225
+ })
226
+
227
+ it('rejects dependency cycles', () => {
228
+ const assessment = {
229
+ original_prompt: 'test',
230
+ total_tasks: 8,
231
+ total_phases: 2,
232
+ domains: ['a'],
233
+ complexity: 'medium' as const,
234
+ recommended_strategy: 'chain' as const,
235
+ convoy_groups: [
236
+ { name: 'group-a', description: 'A', phases: [1], depends_on: ['group-b'] },
237
+ { name: 'group-b', description: 'B', phases: [2], depends_on: ['group-a'] },
238
+ ],
239
+ }
240
+ const result = validateComplexityGroups(assessment)
241
+ expect(result.valid).toBe(false)
242
+ expect(result.reason).toContain('cycle')
243
+ })
244
+
245
+ it('rejects non-kebab-case group names', () => {
246
+ const assessment = {
247
+ original_prompt: 'test',
248
+ total_tasks: 8,
249
+ total_phases: 2,
250
+ domains: ['a'],
251
+ complexity: 'medium' as const,
252
+ recommended_strategy: 'chain' as const,
253
+ convoy_groups: [
254
+ { name: 'My Group', description: 'Bad name', phases: [1], depends_on: [] },
255
+ ],
256
+ }
257
+ const result = validateComplexityGroups(assessment)
258
+ expect(result.valid).toBe(false)
259
+ expect(result.reason).toContain('kebab')
260
+ })
261
+ })
262
+
263
+ describe('topologicalSortGroups', () => {
264
+ it('sorts groups in dependency order', () => {
265
+ const groups = [
266
+ { name: 'c', description: 'C', phases: [3], depends_on: ['b'] },
267
+ { name: 'a', description: 'A', phases: [1], depends_on: [] },
268
+ { name: 'b', description: 'B', phases: [2], depends_on: ['a'] },
269
+ ]
270
+ const sorted = topologicalSortGroups(groups)
271
+ expect(sorted.map(g => g.name)).toEqual(['a', 'b', 'c'])
272
+ })
273
+
274
+ it('preserves order for independent groups', () => {
275
+ const groups = [
276
+ { name: 'x', description: 'X', phases: [1], depends_on: [] },
277
+ { name: 'y', description: 'Y', phases: [2], depends_on: [] },
278
+ ]
279
+ const sorted = topologicalSortGroups(groups)
280
+ expect(sorted.map(g => g.name)).toEqual(['x', 'y'])
281
+ })
282
+ })