@miphamai/cli 0.8.0 → 0.10.0
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/dist/mipham +0 -0
- package/package.json +1 -1
- package/src/agent/background-registry.ts +192 -0
- package/src/agent/message-bus.ts +113 -0
- package/src/agent/sub-agent.ts +101 -3
- package/src/agent/types.ts +4 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/agent-view/dashboard.tsx +20 -1
- package/src/core/engine.ts +52 -0
- package/src/core/hooks.ts +48 -0
- package/src/shared/types.ts +16 -1
- package/src/tools/agent/agent.ts +41 -2
- package/src/tools/agent/enter-plan.ts +92 -0
- package/src/tools/agent/exit-plan.ts +54 -0
- package/src/tools/agent/report-findings.ts +186 -0
- package/src/tools/agent/send-message.ts +67 -0
- package/src/tools/agent/workflow.ts +25 -5
- package/src/tools/exec/enter-worktree.ts +142 -0
- package/src/tools/exec/exit-worktree.ts +162 -0
- package/src/tools/exec/task.ts +215 -22
- package/src/tools/index.ts +12 -0
- package/src/ui/app.tsx +49 -19
- package/src/ui/commands.ts +10 -12
- package/src/workflow/primitives/agent.ts +77 -15
- package/src/workflow/runtime.ts +84 -7
- package/src/workflow/schema-validator.ts +162 -0
package/src/ui/app.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
handleSwitch,
|
|
16
16
|
type CommandContext,
|
|
17
17
|
} from './commands'
|
|
18
|
+
import type { PermissionMode } from '../shared/index.ts'
|
|
18
19
|
|
|
19
20
|
interface AppProps {
|
|
20
21
|
engine: QueryEngine
|
|
@@ -49,13 +50,29 @@ interface AgentProgress {
|
|
|
49
50
|
// Version is read fresh from package.json at startup via runApp prop
|
|
50
51
|
// (bypasses Bun module caching after npm update)
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const PERMISSION_MODES: PermissionMode[] = [
|
|
54
|
+
'default',
|
|
55
|
+
'acceptEdits',
|
|
56
|
+
'plan',
|
|
57
|
+
'auto',
|
|
58
|
+
'dontAsk',
|
|
59
|
+
'bypassPermissions',
|
|
60
|
+
]
|
|
55
61
|
const PERMISSION_LABELS: Record<PermissionMode, string> = {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
default: 'default · reads free',
|
|
63
|
+
acceptEdits: 'accept edits · reads + edits free',
|
|
64
|
+
plan: 'plan mode · read-only',
|
|
65
|
+
auto: 'auto mode · hooks gate',
|
|
66
|
+
dontAsk: "don't ask · pre-approved only",
|
|
67
|
+
bypassPermissions: 'bypass · skip all checks',
|
|
68
|
+
}
|
|
69
|
+
const PERMISSION_COLORS: Record<PermissionMode, string> = {
|
|
70
|
+
default: 'white',
|
|
71
|
+
acceptEdits: 'blue',
|
|
72
|
+
plan: 'yellow',
|
|
73
|
+
auto: 'green',
|
|
74
|
+
dontAsk: 'cyan',
|
|
75
|
+
bypassPermissions: 'red',
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
export function App({
|
|
@@ -78,7 +95,7 @@ export function App({
|
|
|
78
95
|
const [effort, setEffort] = useState('high')
|
|
79
96
|
const [focusMode, setFocusMode] = useState(false)
|
|
80
97
|
const [goalText, setGoalText] = useState('')
|
|
81
|
-
const [permissionMode, setPermissionMode] = useState<PermissionMode>('
|
|
98
|
+
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
|
|
82
99
|
const abortRef = useRef<AbortController | null>(null)
|
|
83
100
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
|
|
84
101
|
const [agentElapsed, setAgentElapsed] = useState(0)
|
|
@@ -304,6 +321,24 @@ export function App({
|
|
|
304
321
|
{ role: 'system', content: `❌ Error: ${chunk.error}` },
|
|
305
322
|
])
|
|
306
323
|
}
|
|
324
|
+
|
|
325
|
+
if (chunk.type === 'task_notification' && chunk.taskNotification) {
|
|
326
|
+
const tn = chunk.taskNotification
|
|
327
|
+
const emoji = tn.status === 'completed' ? '✅' : '❌'
|
|
328
|
+
const label = tn.status === 'completed' ? 'completed' : 'failed'
|
|
329
|
+
const preview = tn.content
|
|
330
|
+
? tn.content.slice(0, 120) + (tn.content.length > 120 ? '...' : '')
|
|
331
|
+
: tn.error
|
|
332
|
+
? `Error: ${tn.error.slice(0, 120)}`
|
|
333
|
+
: '(no output)'
|
|
334
|
+
setMessages((prev) => [
|
|
335
|
+
...prev,
|
|
336
|
+
{
|
|
337
|
+
role: 'system',
|
|
338
|
+
content: `${emoji} Background Agent ${label}: ${tn.description}\n ID: ${tn.taskId}\n ${preview}`,
|
|
339
|
+
},
|
|
340
|
+
])
|
|
341
|
+
}
|
|
307
342
|
}
|
|
308
343
|
} catch (err) {
|
|
309
344
|
setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
|
|
@@ -365,13 +400,13 @@ export function App({
|
|
|
365
400
|
})
|
|
366
401
|
return
|
|
367
402
|
}
|
|
368
|
-
// Shift+Tab → cycle
|
|
403
|
+
// Shift+Tab → cycle through all 6 permission modes
|
|
369
404
|
if (key.shift && key.tab) {
|
|
370
405
|
setPermissionMode((prev) => {
|
|
371
406
|
const idx = PERMISSION_MODES.indexOf(prev)
|
|
372
407
|
const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
|
|
373
|
-
// Sync to engine
|
|
374
|
-
engine.getPermission().
|
|
408
|
+
// Sync to engine — use setMode for proper mode semantics
|
|
409
|
+
engine.getPermission().setMode(next)
|
|
375
410
|
return next
|
|
376
411
|
})
|
|
377
412
|
return
|
|
@@ -441,17 +476,12 @@ export function App({
|
|
|
441
476
|
</Box>
|
|
442
477
|
)}
|
|
443
478
|
<Box flexDirection="row">
|
|
444
|
-
<Text
|
|
445
|
-
color={
|
|
446
|
-
permissionMode === 'auto' ? 'green' : permissionMode === 'ask' ? 'yellow' : 'red'
|
|
447
|
-
}
|
|
448
|
-
>
|
|
479
|
+
<Text color={PERMISSION_COLORS[permissionMode]}>
|
|
449
480
|
● {PERMISSION_LABELS[permissionMode]}
|
|
450
481
|
</Text>
|
|
451
|
-
<Text dimColor> ·
|
|
452
|
-
<Text dimColor>
|
|
453
|
-
<Text dimColor> ·
|
|
454
|
-
<Text dimColor> · ← agents</Text>
|
|
482
|
+
<Text dimColor> · Shift+Tab mode</Text>
|
|
483
|
+
<Text dimColor> · Ctrl+P model · Ctrl+O expand</Text>
|
|
484
|
+
<Text dimColor> · Esc cancel · /help · ← agents</Text>
|
|
455
485
|
</Box>
|
|
456
486
|
</Box>
|
|
457
487
|
</Box>
|
package/src/ui/commands.ts
CHANGED
|
@@ -392,18 +392,16 @@ const skillsCmd: CommandHandler = (ctx) => {
|
|
|
392
392
|
// Workflow
|
|
393
393
|
// ═══════════════════════════════════════════════════════════════
|
|
394
394
|
|
|
395
|
-
const planCmd: CommandHandler = (_ctx) =>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
`,
|
|
406
|
-
})
|
|
395
|
+
const planCmd: CommandHandler = (_ctx, args) => {
|
|
396
|
+
const description = args.join(' ') || undefined
|
|
397
|
+
return {
|
|
398
|
+
content:
|
|
399
|
+
'── Plan Mode ──\n\nEntering plan mode — read-only analysis and design.\nUse EnterPlanMode to start, ExitPlanMode to submit for approval.',
|
|
400
|
+
forwardToAI: description
|
|
401
|
+
? `Use EnterPlanMode with description: "${description}". Then explore, design, and use ExitPlanMode when ready for approval.`
|
|
402
|
+
: 'Use EnterPlanMode to enter plan mode. Explore the codebase, design an approach, then use ExitPlanMode to submit for approval.',
|
|
403
|
+
}
|
|
404
|
+
}
|
|
407
405
|
|
|
408
406
|
const tddCmd: CommandHandler = (_ctx, args) => {
|
|
409
407
|
const target = args.join(' ') || 'the current task'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SubAgent } from '../../agent/sub-agent'
|
|
2
2
|
import type { ProviderRegistry } from '../../providers/registry'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
|
|
4
5
|
|
|
5
6
|
export interface WorkflowAgentOpts {
|
|
6
7
|
label?: string
|
|
@@ -9,11 +10,19 @@ export interface WorkflowAgentOpts {
|
|
|
9
10
|
model?: string
|
|
10
11
|
provider?: string
|
|
11
12
|
effort?: 'low' | 'medium' | 'high' | 'max'
|
|
13
|
+
/** Maximum retries on schema validation failure (default: 2). */
|
|
14
|
+
maxRetries?: number
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Workflow agent() primitive — creates a SubAgent with optional
|
|
16
|
-
* provider/model override and structured output schema.
|
|
19
|
+
* provider/model override and structured output schema validation.
|
|
20
|
+
*
|
|
21
|
+
* When `schema` is provided:
|
|
22
|
+
* 1. The sub-agent is prompted to return valid JSON matching the schema.
|
|
23
|
+
* 2. The result is JSON.parsed and validated against the schema.
|
|
24
|
+
* 3. On validation failure, the sub-agent is retried with error feedback.
|
|
25
|
+
* 4. Returns the validated object, or { raw, validationErrors } on final failure.
|
|
17
26
|
*/
|
|
18
27
|
export async function workflowAgent(
|
|
19
28
|
prompt: string,
|
|
@@ -28,24 +37,77 @@ export async function workflowAgent(
|
|
|
28
37
|
registry.switchProvider(registry.getActive().config.id, opts.model)
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
const
|
|
32
|
-
const result = await sub.execute(prompt, opts.label || 'workflow-agent', {
|
|
33
|
-
type: 'general',
|
|
34
|
-
modelOverride: opts.model,
|
|
35
|
-
allowedTools: undefined, // use all tools by default
|
|
36
|
-
})
|
|
40
|
+
const maxRetries = opts.maxRetries ?? 2
|
|
37
41
|
|
|
38
|
-
//
|
|
42
|
+
// Build the prompt — if schema is requested, instruct the model to return JSON
|
|
43
|
+
let effectivePrompt = prompt
|
|
39
44
|
if (opts.schema) {
|
|
45
|
+
const schemaDesc = JSON.stringify(opts.schema, null, 2)
|
|
46
|
+
effectivePrompt =
|
|
47
|
+
`${prompt}\n\n` +
|
|
48
|
+
`IMPORTANT: Your response MUST be valid JSON matching this schema:\n` +
|
|
49
|
+
`\`\`\`json\n${schemaDesc}\n\`\`\`\n` +
|
|
50
|
+
`Return ONLY the JSON object, no other text. Do not wrap in markdown code fences.`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
54
|
+
|
|
55
|
+
// ── Attempt execution with optional schema validation + retry ──
|
|
56
|
+
let lastResult = ''
|
|
57
|
+
let lastErrors: string[] = []
|
|
58
|
+
|
|
59
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
60
|
+
const retryPrompt =
|
|
61
|
+
attempt === 0
|
|
62
|
+
? effectivePrompt
|
|
63
|
+
: `${effectivePrompt}\n\n` +
|
|
64
|
+
`[RETRY #${attempt}] Your previous response did NOT match the required schema.\n` +
|
|
65
|
+
`Validation errors:\n${lastErrors.map((e) => ` • ${e}`).join('\n')}\n\n` +
|
|
66
|
+
`Please fix the errors and return a valid JSON object matching the schema exactly.`
|
|
67
|
+
|
|
68
|
+
const result = await sub.execute(retryPrompt, opts.label || 'workflow-agent', {
|
|
69
|
+
type: 'general',
|
|
70
|
+
modelOverride: opts.model,
|
|
71
|
+
allowedTools: undefined, // use all tools by default
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
lastResult = result
|
|
75
|
+
|
|
76
|
+
// No schema — return raw result
|
|
77
|
+
if (!opts.schema) {
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Try to parse and validate
|
|
40
82
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
83
|
+
// Extract JSON from potential markdown fences
|
|
84
|
+
let jsonStr = result.trim()
|
|
85
|
+
const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
|
86
|
+
if (fenceMatch) {
|
|
87
|
+
jsonStr = fenceMatch[1]!.trim()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const parsed = JSON.parse(jsonStr)
|
|
91
|
+
const errors = validateJSONSchema(parsed, opts.schema as Record<string, unknown>)
|
|
92
|
+
|
|
93
|
+
if (errors.length === 0) {
|
|
94
|
+
// Valid! Return the parsed object
|
|
95
|
+
return parsed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Not valid — collect errors for retry
|
|
99
|
+
lastErrors = [formatValidationErrors(errors)]
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// JSON parse error
|
|
102
|
+
lastErrors = [`JSON parse error: ${String(err)}. Ensure your response is valid JSON only.`]
|
|
47
103
|
}
|
|
48
104
|
}
|
|
49
105
|
|
|
50
|
-
return result
|
|
106
|
+
// All retries exhausted — return raw result with validation errors
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(lastResult)
|
|
109
|
+
return { raw: lastResult, parsed, validationErrors: lastErrors }
|
|
110
|
+
} catch {
|
|
111
|
+
return { raw: lastResult, validationErrors: lastErrors }
|
|
112
|
+
}
|
|
51
113
|
}
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSandbox } from './sandbox'
|
|
2
|
-
import { createJournal, appendJournal } from './journal'
|
|
2
|
+
import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
|
|
3
3
|
import { createBudget } from './budget'
|
|
4
4
|
import { workflowAgent } from './primitives/agent'
|
|
5
5
|
import { parallel } from './primitives/parallel'
|
|
@@ -12,30 +12,103 @@ export interface WorkflowRunResult {
|
|
|
12
12
|
runId: string
|
|
13
13
|
result: unknown
|
|
14
14
|
journalEntries: number
|
|
15
|
+
/** Number of agent() calls served from cache (only set when resumeFromRunId is used). */
|
|
16
|
+
cacheHits: number
|
|
17
|
+
/** Number of agent() calls executed live (only set when resumeFromRunId is used). */
|
|
18
|
+
cacheMisses: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a cache key for an agent() call.
|
|
23
|
+
* Uses prompt + deterministic subset of opts.
|
|
24
|
+
*/
|
|
25
|
+
function agentCacheKey(prompt: string, opts: Record<string, unknown> = {}): string {
|
|
26
|
+
const relevant = {
|
|
27
|
+
prompt,
|
|
28
|
+
label: opts.label,
|
|
29
|
+
phase: opts.phase,
|
|
30
|
+
model: opts.model,
|
|
31
|
+
provider: opts.provider,
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(relevant)
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
/**
|
|
18
37
|
* Execute a workflow script string.
|
|
19
38
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
39
|
+
* When `resumeFromRunId` is provided:
|
|
40
|
+
* - Loads the journal from the prior run
|
|
41
|
+
* - Compares the current script against the saved script
|
|
42
|
+
* - Builds a cache from prior agent() results (keyed by prompt+opts)
|
|
43
|
+
* - New agent() calls that match the cache return the cached result
|
|
44
|
+
* - New/changed agent() calls execute live and append to the journal
|
|
45
|
+
* - Returns cacheHits and cacheMisses counts
|
|
46
|
+
*
|
|
47
|
+
* The sandbox blocks non-deterministic APIs (Date.now, Math.random) to make
|
|
48
|
+
* this deterministic replay possible.
|
|
22
49
|
*/
|
|
23
50
|
export async function runWorkflow(
|
|
24
51
|
script: string,
|
|
25
52
|
engine: QueryEngine,
|
|
26
53
|
args: unknown = {},
|
|
27
54
|
budgetTotal: number | null = null,
|
|
55
|
+
resumeFromRunId?: string,
|
|
28
56
|
): Promise<WorkflowRunResult> {
|
|
29
|
-
|
|
30
|
-
|
|
57
|
+
let runId: string
|
|
58
|
+
let cacheHits = 0
|
|
59
|
+
let cacheMisses = 0
|
|
60
|
+
|
|
61
|
+
// ── Resume mode: load prior journal, build cache ──
|
|
62
|
+
let resultCache: Map<string, unknown> | null = null
|
|
63
|
+
if (resumeFromRunId) {
|
|
64
|
+
const savedScript = loadScript(resumeFromRunId)
|
|
65
|
+
if (savedScript && savedScript !== script) {
|
|
66
|
+
// Script changed — warn but proceed (cache may have stale entries)
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`[workflow] ⚠ Script differs from saved run "${resumeFromRunId}". ` +
|
|
69
|
+
`Cache may contain stale entries for changed agent calls.\n`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const priorEntries = loadJournal(resumeFromRunId)
|
|
74
|
+
if (priorEntries.length === 0) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`[workflow] ⚠ No journal entries found for run "${resumeFromRunId}". Proceeding without cache.\n`,
|
|
77
|
+
)
|
|
78
|
+
} else {
|
|
79
|
+
resultCache = new Map()
|
|
80
|
+
for (const entry of priorEntries) {
|
|
81
|
+
if (entry.type === 'agent' && entry.prompt && entry.result !== undefined) {
|
|
82
|
+
const key = agentCacheKey(entry.prompt, entry.opts || {})
|
|
83
|
+
resultCache.set(key, entry.result)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Re-use the existing run directory for appending
|
|
89
|
+
runId = resumeFromRunId
|
|
90
|
+
} else {
|
|
91
|
+
runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
92
|
+
createJournal(runId, script)
|
|
93
|
+
}
|
|
31
94
|
|
|
32
95
|
const registry: ProviderRegistry = engine.getRegistry()
|
|
33
96
|
const toolRegistry = engine.getTools()
|
|
34
97
|
|
|
35
98
|
const budget = createBudget(budgetTotal)
|
|
36
99
|
|
|
37
|
-
// Wrap primitives with journal recording
|
|
100
|
+
// Wrap primitives with journal recording + cache support
|
|
38
101
|
const agent = async (prompt: string, opts?: Record<string, unknown>) => {
|
|
102
|
+
// Check cache first
|
|
103
|
+
if (resultCache) {
|
|
104
|
+
const key = agentCacheKey(prompt, opts || {})
|
|
105
|
+
if (resultCache.has(key)) {
|
|
106
|
+
cacheHits++
|
|
107
|
+
return resultCache.get(key)!
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
cacheMisses++
|
|
111
|
+
|
|
39
112
|
const result = await workflowAgent(
|
|
40
113
|
prompt,
|
|
41
114
|
registry,
|
|
@@ -83,5 +156,9 @@ export async function runWorkflow(
|
|
|
83
156
|
|
|
84
157
|
const result = await scriptFn(agent, parallel, pipeline, wrappedPhase, log, args, budget)
|
|
85
158
|
|
|
86
|
-
|
|
159
|
+
// Count journal entries from state
|
|
160
|
+
const priorEntries = loadJournal(runId)
|
|
161
|
+
const journalEntries = priorEntries.length
|
|
162
|
+
|
|
163
|
+
return { runId, result, journalEntries, cacheHits, cacheMisses }
|
|
87
164
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight JSON Schema validator for workflow agent() schema parameter.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - type: string | number | integer | boolean | object | array
|
|
6
|
+
* - properties (objects), items (arrays), required, enum
|
|
7
|
+
* - Nested objects and arrays (recursive validation)
|
|
8
|
+
*
|
|
9
|
+
* Zero external dependencies — pure TypeScript implementation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ValidationError {
|
|
13
|
+
path: string // JSON path to the error, e.g. "$.name" or "$.items[0].value"
|
|
14
|
+
message: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate `data` against a JSON Schema definition.
|
|
19
|
+
* Returns an array of ValidationError — empty means valid.
|
|
20
|
+
*/
|
|
21
|
+
export function validateJSONSchema(
|
|
22
|
+
data: unknown,
|
|
23
|
+
schema: Record<string, unknown>,
|
|
24
|
+
): ValidationError[] {
|
|
25
|
+
const errors: ValidationError[] = []
|
|
26
|
+
_validate(data, schema, '$', errors)
|
|
27
|
+
return errors
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format validation errors into a human-readable message for LLM feedback.
|
|
32
|
+
*/
|
|
33
|
+
export function formatValidationErrors(errors: ValidationError[]): string {
|
|
34
|
+
if (errors.length === 0) return ''
|
|
35
|
+
return errors.map((e) => ` • ${e.path}: ${e.message}`).join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Internal recursive validator ──
|
|
39
|
+
|
|
40
|
+
function _validate(
|
|
41
|
+
data: unknown,
|
|
42
|
+
schema: Record<string, unknown>,
|
|
43
|
+
path: string,
|
|
44
|
+
errors: ValidationError[],
|
|
45
|
+
): void {
|
|
46
|
+
const type = schema.type as string | undefined
|
|
47
|
+
|
|
48
|
+
// ── Null check ──
|
|
49
|
+
if (data === null || data === undefined) {
|
|
50
|
+
// If required, the parent object handles this. Here we just check type mismatch.
|
|
51
|
+
if (type && type !== 'null') {
|
|
52
|
+
errors.push({ path, message: `expected ${type}, got null` })
|
|
53
|
+
}
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Type checking ──
|
|
58
|
+
if (type) {
|
|
59
|
+
const actualType = Array.isArray(data) ? 'array' : typeof data
|
|
60
|
+
const expectedType = type === 'integer' ? 'number' : type
|
|
61
|
+
|
|
62
|
+
if (expectedType === 'number' && actualType === 'number') {
|
|
63
|
+
// OK — integer is a subset of number
|
|
64
|
+
} else if (expectedType !== actualType) {
|
|
65
|
+
errors.push({ path, message: `expected type "${type}", got "${actualType}"` })
|
|
66
|
+
return // Can't validate further if type is wrong
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Integer check
|
|
70
|
+
if (type === 'integer' && !Number.isInteger(data)) {
|
|
71
|
+
errors.push({ path, message: `expected integer, got ${data}` })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Enum validation ──
|
|
76
|
+
if (schema.enum && Array.isArray(schema.enum)) {
|
|
77
|
+
if (!schema.enum.includes(data)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
path,
|
|
80
|
+
message: `value must be one of: ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}`,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Object validation ──
|
|
86
|
+
if (type === 'object' || (!type && typeof data === 'object' && !Array.isArray(data))) {
|
|
87
|
+
const obj = data as Record<string, unknown>
|
|
88
|
+
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
89
|
+
const required = schema.required as string[] | undefined
|
|
90
|
+
|
|
91
|
+
// Required fields
|
|
92
|
+
if (required) {
|
|
93
|
+
for (const field of required) {
|
|
94
|
+
if (obj[field] === undefined || obj[field] === null) {
|
|
95
|
+
errors.push({ path: `${path}.${field}`, message: `required field is missing` })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Property validation
|
|
101
|
+
if (properties) {
|
|
102
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
103
|
+
const value = obj[key]
|
|
104
|
+
if (value !== undefined && value !== null) {
|
|
105
|
+
_validate(value, propSchema, `${path}.${key}`, errors)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Array validation ──
|
|
112
|
+
if (type === 'array' || (!type && Array.isArray(data))) {
|
|
113
|
+
const arr = data as unknown[]
|
|
114
|
+
const items = schema.items as Record<string, unknown> | undefined
|
|
115
|
+
|
|
116
|
+
if (items) {
|
|
117
|
+
for (let i = 0; i < arr.length; i++) {
|
|
118
|
+
_validate(arr[i], items, `${path}[${i}]`, errors)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// minItems / maxItems
|
|
123
|
+
if (schema.minItems !== undefined && arr.length < (schema.minItems as number)) {
|
|
124
|
+
errors.push({
|
|
125
|
+
path,
|
|
126
|
+
message: `array has ${arr.length} items, minimum is ${schema.minItems}`,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (schema.maxItems !== undefined && arr.length > (schema.maxItems as number)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
path,
|
|
132
|
+
message: `array has ${arr.length} items, maximum is ${schema.maxItems}`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── String validation ──
|
|
138
|
+
if ((type === 'string' || typeof data === 'string') && typeof data === 'string') {
|
|
139
|
+
if (schema.minLength !== undefined && data.length < (schema.minLength as number)) {
|
|
140
|
+
errors.push({
|
|
141
|
+
path,
|
|
142
|
+
message: `string length ${data.length} is less than minimum ${schema.minLength}`,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
if (schema.maxLength !== undefined && data.length > (schema.maxLength as number)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path,
|
|
148
|
+
message: `string length ${data.length} exceeds maximum ${schema.maxLength}`,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Number validation ──
|
|
154
|
+
if ((type === 'number' || type === 'integer') && typeof data === 'number') {
|
|
155
|
+
if (schema.minimum !== undefined && data < (schema.minimum as number)) {
|
|
156
|
+
errors.push({ path, message: `value ${data} is less than minimum ${schema.minimum}` })
|
|
157
|
+
}
|
|
158
|
+
if (schema.maximum !== undefined && data > (schema.maximum as number)) {
|
|
159
|
+
errors.push({ path, message: `value ${data} exceeds maximum ${schema.maximum}` })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|