@miphamai/cli 0.9.0 → 0.11.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.
@@ -392,18 +392,16 @@ const skillsCmd: CommandHandler = (ctx) => {
392
392
  // Workflow
393
393
  // ═══════════════════════════════════════════════════════════════
394
394
 
395
- const planCmd: CommandHandler = (_ctx) => ({
396
- content: stripIndent`
397
- ── Plan Mode ──
398
- Plan mode activated — read-only analysis and design.
399
- No code will be modified. Use /no-plan to exit.
400
-
401
- In plan mode you can:
402
- • Explore codebase and analyze architecture
403
- • Design implementation approaches
404
- • Create structured plans before coding
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'
@@ -3184,64 +3182,183 @@ Full changelog: https://mipham.ai/code/releases`,
3184
3182
  // IDE — IDE integration guide
3185
3183
  // ═══════════════════════════════════════════════════════════════
3186
3184
 
3187
- const ideCmd: CommandHandler = () => ({
3188
- content: `── IDE Integration ──
3189
-
3190
- VS Code:
3191
- Install the Mipham Code extension from the VS Code marketplace.
3192
- • Open Command Palette (Cmd+Shift+P)
3193
- • Search "Mipham Code: Start"
3194
- • The terminal panel opens with Mipham Code loaded
3185
+ const ideCmd: CommandHandler = async (_ctx) => {
3186
+ const { mkdirSync, writeFileSync, existsSync } = await import('node:fs')
3187
+ const { join } = await import('node:path')
3195
3188
 
3196
- Or manually: add to .vscode/settings.json
3197
- {
3198
- "terminal.integrated.profiles.osx": {
3199
- "mipham": { "path": "bun", "args": ["run", "mipham"] }
3200
- }
3201
- }
3189
+ const cwd = process.cwd()
3190
+ const vscodeDir = join(cwd, '.vscode')
3191
+ mkdirSync(vscodeDir, { recursive: true })
3202
3192
 
3203
- JetBrains (IntelliJ / WebStorm / PyCharm):
3204
- • Settings → Tools → Terminal → Shell path
3205
- • Set to: bun run ~/path/to/mipham-code/apps/cli/bin/mipham
3193
+ const files: string[] = []
3206
3194
 
3207
- Terminal (any):
3208
- alias mipham='cd your-project && bun run path/to/mipham'
3195
+ // ── Detect bun path ──
3196
+ let bunPath = '/opt/homebrew/bin/bun'
3197
+ try {
3198
+ const { execSync } = await import('node:child_process')
3199
+ const detected = execSync('which bun 2>/dev/null || echo /opt/homebrew/bin/bun', {
3200
+ encoding: 'utf-8',
3201
+ }).trim()
3202
+ if (detected) bunPath = detected
3203
+ } catch {
3204
+ // Use default
3205
+ }
3206
+
3207
+ // ── settings.json: terminal profile ──
3208
+ const settingsPath = join(vscodeDir, 'settings.json')
3209
+ const settings = {
3210
+ 'terminal.integrated.profiles.osx': {
3211
+ mipham: {
3212
+ path: bunPath,
3213
+ args: ['run', 'mipham'],
3214
+ cwd: '${workspaceFolder}',
3215
+ },
3216
+ },
3217
+ }
3218
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
3219
+ files.push('.vscode/settings.json')
3220
+
3221
+ // ── keybindings.json: Cmd+Esc launch ──
3222
+ const keybindingsPath = join(vscodeDir, 'keybindings.json')
3223
+ const keybindings = [
3224
+ {
3225
+ key: 'cmd+escape',
3226
+ command: 'workbench.action.terminal.focus',
3227
+ when: 'terminalProcessSupported',
3228
+ },
3229
+ {
3230
+ key: 'cmd+shift+m',
3231
+ command: 'workbench.action.terminal.new',
3232
+ },
3233
+ ]
3234
+ writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', 'utf-8')
3235
+ files.push('.vscode/keybindings.json')
3209
3236
 
3210
- Or install globally: ${NPM_INSTALL_COMMAND}
3237
+ // ── extensions.json: recommended ──
3238
+ const extensionsPath = join(vscodeDir, 'extensions.json')
3239
+ const extensions = {
3240
+ recommendations: ['miphamai.mipham-code'],
3241
+ }
3242
+ writeFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', 'utf-8')
3243
+ files.push('.vscode/extensions.json')
3211
3244
 
3212
- Coming soon: dedicated VS Code & JetBrains plugin extensions.`,
3213
- })
3245
+ return {
3246
+ content: [
3247
+ '── VS Code Integration ──',
3248
+ '',
3249
+ `Generated in ${vscodeDir}:`,
3250
+ ...files.map((f) => ` ✅ ${f}`),
3251
+ '',
3252
+ 'What was configured:',
3253
+ ' • Terminal profile "mipham" — opens Mipham Code in integrated terminal',
3254
+ ' • Keyboard shortcut Cmd+Esc — focus terminal',
3255
+ ' • Cmd+Shift+M — new terminal',
3256
+ '',
3257
+ 'To use:',
3258
+ ' 1. Restart VS Code (or reload window: Cmd+Shift+P → Reload Window)',
3259
+ ' 2. Open terminal: Ctrl+` or Cmd+Esc',
3260
+ ' 3. Select "mipham" profile from the terminal dropdown',
3261
+ '',
3262
+ 'Install the VS Code extension for full integration:',
3263
+ ` code --install-extension miphamai.mipham-code`,
3264
+ '',
3265
+ 'JetBrains: Settings → Tools → Terminal → Shell path → bun run mipham',
3266
+ ].join('\n'),
3267
+ }
3268
+ }
3214
3269
 
3215
3270
  // ═══════════════════════════════════════════════════════════════
3216
3271
  // Terminal Setup — shell integration guide
3217
3272
  // ═══════════════════════════════════════════════════════════════
3218
3273
 
3219
- const terminalSetupCmd: CommandHandler = () => ({
3220
- content: `── Terminal Setup ──
3221
-
3222
- Install globally:
3223
- ${NPM_INSTALL_COMMAND}
3224
- mipham
3225
-
3226
- One-liner install:
3227
- curl -fsSL https://mipham.ai/install.sh | bash
3274
+ const terminalSetupCmd: CommandHandler = async () => {
3275
+ const { writeFileSync, appendFileSync, existsSync, mkdirSync } = await import('node:fs')
3276
+ const { join } = await import('node:path')
3277
+ const { homedir } = await import('node:os')
3228
3278
 
3229
- Add to shell profile (~/.zshrc or ~/.bashrc):
3230
- alias mipham='bun run ~/path/to/mipham-code/apps/cli/bin/mipham'
3279
+ const home = homedir()
3280
+ const lines: string[] = ['── Terminal Setup ──', '']
3281
+
3282
+ // ── 1. Generate standalone shell setup script ──
3283
+ const miphamDir = join(home, '.mipham')
3284
+ mkdirSync(miphamDir, { recursive: true })
3285
+
3286
+ const shellScript = join(miphamDir, 'shell-setup.sh')
3287
+ const shellContent = [
3288
+ '#!/bin/bash',
3289
+ '# Mipham Code — Shell Integration',
3290
+ '# Source this file in your shell profile:',
3291
+ '# source ~/.mipham/shell-setup.sh',
3292
+ '',
3293
+ `export MIPHAM_HOME="${home}/.mipham"`,
3294
+ '',
3295
+ '# Alias: launch Mipham Code in current directory',
3296
+ `alias mipham='cd $(pwd) && ${NPM_INSTALL_COMMAND} > /dev/null 2>&1; mipham'`,
3297
+ '',
3298
+ '# Or use the local development version:',
3299
+ '# alias mipham="bun run /path/to/mipham-code/apps/cli/bin/mipham"',
3300
+ '',
3301
+ '# Auto-detect provider from config',
3302
+ 'if [ -f ~/.mipham/config.yml ]; then',
3303
+ ' export MIPHAM_PROVIDER=$(grep "defaultProvider:" ~/.mipham/config.yml | awk "{print \$2}")',
3304
+ 'fi',
3305
+ ].join('\n')
3306
+ writeFileSync(shellScript, shellContent + '\n', 'utf-8')
3307
+ lines.push(` ✅ Generated: ${shellScript}`)
3308
+
3309
+ // ── 2. Append to shell profile ──
3310
+ const shell = process.env.SHELL || '/bin/zsh'
3311
+ const profileName = shell.includes('zsh') ? '.zshrc' : '.bashrc'
3312
+ const profilePath = join(home, profileName)
3313
+ const sourceLine = `\n# Mipham Code shell integration\n[ -f ~/.mipham/shell-setup.sh ] && source ~/.mipham/shell-setup.sh\n`
3231
3314
 
3232
- # Or with a specific provider/model:
3233
- alias mipham='mipham --provider anthropic --model claude-opus-4-8'
3315
+ try {
3316
+ const existing = existsSync(profilePath)
3317
+ ? require('node:fs').readFileSync(profilePath, 'utf-8')
3318
+ : ''
3319
+ if (existing.includes('shell-setup.sh')) {
3320
+ lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
3321
+ } else {
3322
+ appendFileSync(profilePath, sourceLine, 'utf-8')
3323
+ lines.push(` ✅ Added to ~/${profileName}`)
3324
+ }
3325
+ } catch {
3326
+ lines.push(` ⚠️ Could not update ~/${profileName}. Add manually:`)
3327
+ lines.push(` echo '${sourceLine.trim()}' >> ~/${profileName}`)
3328
+ }
3234
3329
 
3235
- Upgrade:
3236
- curl -fsSL https://mipham.ai/install.sh | bash
3237
- # or: ${NPM_UPDATE_COMMAND}
3330
+ // ── 3. Global install check ──
3331
+ lines.push('')
3332
+ lines.push('── Installation ──')
3333
+ try {
3334
+ const { execSync } = await import('node:child_process')
3335
+ const miphamPath = execSync('which mipham 2>/dev/null || echo ""', { encoding: 'utf-8' }).trim()
3336
+ if (miphamPath) {
3337
+ lines.push(` ✅ mipham found at: ${miphamPath}`)
3338
+ const version = execSync('mipham --version 2>/dev/null || echo "unknown"', {
3339
+ encoding: 'utf-8',
3340
+ }).trim()
3341
+ lines.push(` 📦 Version: ${version}`)
3342
+ } else {
3343
+ lines.push(` ⚠️ mipham not in PATH. Install globally:`)
3344
+ lines.push(` ${NPM_INSTALL_COMMAND}`)
3345
+ lines.push(` or: curl -fsSL https://mipham.ai/install.sh | bash`)
3346
+ }
3347
+ } catch {
3348
+ lines.push(` 💡 Install: ${NPM_INSTALL_COMMAND}`)
3349
+ }
3238
3350
 
3239
- Verify installation:
3240
- mipham --version
3241
- mipham --help
3351
+ // ── 4. Verify ──
3352
+ lines.push('')
3353
+ lines.push('── Next Steps ──')
3354
+ lines.push(' 1. Restart your terminal or run: source ~/.mipham/shell-setup.sh')
3355
+ lines.push(' 2. Run: mipham --version')
3356
+ lines.push(' 3. Start coding: cd your-project && mipham')
3357
+ lines.push('')
3358
+ lines.push(`Works with: Zsh, Bash. Shell: ${shell}`)
3242
3359
 
3243
- Works with: Bash, Zsh, Fish, PowerShell, Windows Terminal`,
3244
- })
3360
+ return { content: lines.join('\n') }
3361
+ }
3245
3362
 
3246
3363
  // ═══════════════════════════════════════════════════════════════
3247
3364
  // Phase 4 — MCP Server Management
@@ -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 sub = new SubAgent(registry, toolRegistry)
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
- // If schema is provided, attempt to parse the result as JSON
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
- const parsed = JSON.parse(result)
42
- // Basic validation: return parsed object
43
- return parsed
44
- } catch {
45
- // Return raw text if JSON parse fails
46
- return { raw: result }
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
  }
@@ -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
- * The script is evaluated in a sandboxed context with the workflow
21
- * primitives (agent, parallel, pipeline, phase, args, budget) injected.
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
- const runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
30
- createJournal(runId, script)
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
- return { runId, result, journalEntries: 0 }
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
+ }