import type { LegacyConfigIssue } from "./types.js"; type LegacyConfigRule = { path: string[]; message: string; match?: (value: unknown, root: Record) => boolean; }; type LegacyConfigMigration = { id: string; describe: string; apply: (raw: Record, changes: string[]) => void; }; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); const getRecord = (value: unknown): Record | null => isRecord(value) ? value : null; const ensureRecord = ( root: Record, key: string, ): Record => { const existing = root[key]; if (isRecord(existing)) return existing; const next: Record = {}; root[key] = next; return next; }; const mergeMissing = ( target: Record, source: Record, ) => { for (const [key, value] of Object.entries(source)) { if (value === undefined) continue; const existing = target[key]; if (existing === undefined) { target[key] = value; continue; } if (isRecord(existing) && isRecord(value)) { mergeMissing(existing, value); } } }; const AUDIO_TRANSCRIPTION_CLI_ALLOWLIST = new Set(["whisper"]); const mapLegacyAudioTranscription = ( value: unknown, ): Record | null => { const transcriber = getRecord(value); const command = Array.isArray(transcriber?.command) ? transcriber?.command : null; if (!command || command.length === 0) return null; const rawExecutable = String(command[0] ?? "").trim(); if (!rawExecutable) return null; const executableName = rawExecutable.split(/[\\/]/).pop() ?? rawExecutable; if (!AUDIO_TRANSCRIPTION_CLI_ALLOWLIST.has(executableName)) return null; const args = command.slice(1).map((part) => String(part)); const timeoutSeconds = typeof transcriber?.timeoutSeconds === "number" ? transcriber?.timeoutSeconds : undefined; const result: Record = {}; if (args.length > 0) result.args = args; if (timeoutSeconds !== undefined) result.timeoutSeconds = timeoutSeconds; return result; }; const getAgentsList = (agents: Record | null) => { const list = agents?.list; return Array.isArray(list) ? list : []; }; const resolveDefaultAgentIdFromRaw = (raw: Record) => { const agents = getRecord(raw.agents); const list = getAgentsList(agents); const defaultEntry = list.find( (entry): entry is { id: string } => isRecord(entry) && entry.default === true && typeof entry.id === "string" && entry.id.trim() !== "", ); if (defaultEntry) return defaultEntry.id.trim(); const routing = getRecord(raw.routing); const routingDefault = typeof routing?.defaultAgentId === "string" ? routing.defaultAgentId.trim() : ""; if (routingDefault) return routingDefault; const firstEntry = list.find( (entry): entry is { id: string } => isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "", ); if (firstEntry) return firstEntry.id.trim(); return "main"; }; const ensureAgentEntry = ( list: unknown[], id: string, ): Record => { const normalized = id.trim(); const existing = list.find( (entry): entry is Record => isRecord(entry) && typeof entry.id === "string" && entry.id.trim() === normalized, ); if (existing) return existing; const created: Record = { id: normalized }; list.push(created); return created; }; const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["routing", "allowFrom"], message: "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "bindings"], message: "routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "agents"], message: "routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "defaultAgentId"], message: "routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "agentToAgent"], message: "routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "groupChat", "requireMention"], message: 'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).', }, { path: ["routing", "groupChat", "mentionPatterns"], message: "routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "queue"], message: "routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).", }, { path: ["routing", "transcribeAudio"], message: "routing.transcribeAudio was moved; use tools.audio.transcription instead (run `clawdbot doctor` to migrate).", }, { path: ["telegram", "requireMention"], message: 'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).', }, { path: ["identity"], message: "identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).", }, { path: ["agent"], message: "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/bash settings) instead (run `clawdbot doctor` to migrate).", }, { path: ["agent", "model"], message: "agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).", match: (value) => typeof value === "string", }, { path: ["agent", "imageModel"], message: "agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).", match: (value) => typeof value === "string", }, { path: ["agent", "allowedModels"], message: "agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).", }, { path: ["agent", "modelAliases"], message: "agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).", }, { path: ["agent", "modelFallbacks"], message: "agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).", }, { path: ["agent", "imageModelFallbacks"], message: "agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).", }, { path: ["gateway", "token"], message: "gateway.token is ignored; use gateway.auth.token instead (run `clawdbot doctor` to migrate).", }, ]; const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", apply: (raw, changes) => { const routing = raw.routing; if (!routing || typeof routing !== "object") return; const allowFrom = (routing as Record).allowFrom; if (allowFrom === undefined) return; const whatsapp = raw.whatsapp && typeof raw.whatsapp === "object" ? (raw.whatsapp as Record) : {}; if (whatsapp.allowFrom === undefined) { whatsapp.allowFrom = allowFrom; changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); } else { changes.push( "Removed routing.allowFrom (whatsapp.allowFrom already set).", ); } delete (routing as Record).allowFrom; if (Object.keys(routing as Record).length === 0) { delete raw.routing; } raw.whatsapp = whatsapp; }, }, { id: "routing.groupChat.requireMention->groups.*.requireMention", describe: "Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups", apply: (raw, changes) => { const routing = raw.routing; if (!routing || typeof routing !== "object") return; const groupChat = (routing as Record).groupChat && typeof (routing as Record).groupChat === "object" ? ((routing as Record).groupChat as Record< string, unknown >) : null; if (!groupChat) return; const requireMention = groupChat.requireMention; if (requireMention === undefined) return; const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { const section = raw[key] && typeof raw[key] === "object" ? (raw[key] as Record) : {}; const groups = section.groups && typeof section.groups === "object" ? (section.groups as Record) : {}; const defaultKey = "*"; const entry = groups[defaultKey] && typeof groups[defaultKey] === "object" ? (groups[defaultKey] as Record) : {}; if (entry.requireMention === undefined) { entry.requireMention = requireMention; groups[defaultKey] = entry; section.groups = groups; raw[key] = section; changes.push( `Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`, ); } else { changes.push( `Removed routing.groupChat.requireMention (${key}.groups."*" already set).`, ); } }; applyTo("whatsapp"); applyTo("telegram"); applyTo("imessage"); delete groupChat.requireMention; if (Object.keys(groupChat).length === 0) { delete (routing as Record).groupChat; } if (Object.keys(routing as Record).length === 0) { delete raw.routing; } }, }, { id: "gateway.token->gateway.auth.token", describe: "Move gateway.token to gateway.auth.token", apply: (raw, changes) => { const gateway = raw.gateway; if (!gateway || typeof gateway !== "object") return; const token = (gateway as Record).token; if (token === undefined) return; const gatewayObj = gateway as Record; const auth = gatewayObj.auth && typeof gatewayObj.auth === "object" ? (gatewayObj.auth as Record) : {}; if (auth.token === undefined) { auth.token = token; if (!auth.mode) auth.mode = "token"; changes.push("Moved gateway.token → gateway.auth.token."); } else { changes.push("Removed gateway.token (gateway.auth.token already set)."); } delete gatewayObj.token; if (Object.keys(auth).length > 0) { gatewayObj.auth = auth; } raw.gateway = gatewayObj; }, }, { id: "telegram.requireMention->telegram.groups.*.requireMention", describe: "Move telegram.requireMention to telegram.groups.*.requireMention", apply: (raw, changes) => { const telegram = raw.telegram; if (!telegram || typeof telegram !== "object") return; const requireMention = (telegram as Record) .requireMention; if (requireMention === undefined) return; const groups = (telegram as Record).groups && typeof (telegram as Record).groups === "object" ? ((telegram as Record).groups as Record< string, unknown >) : {}; const defaultKey = "*"; const entry = groups[defaultKey] && typeof groups[defaultKey] === "object" ? (groups[defaultKey] as Record) : {}; if (entry.requireMention === undefined) { entry.requireMention = requireMention; groups[defaultKey] = entry; (telegram as Record).groups = groups; changes.push( 'Moved telegram.requireMention → telegram.groups."*".requireMention.', ); } else { changes.push( 'Removed telegram.requireMention (telegram.groups."*" already set).', ); } delete (telegram as Record).requireMention; if (Object.keys(telegram as Record).length === 0) { delete raw.telegram; } }, }, { id: "agent.model-config-v2", describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists", apply: (raw, changes) => { const agentRoot = getRecord(raw.agent); const defaults = getRecord(getRecord(raw.agents)?.defaults); const agent = agentRoot ?? defaults; if (!agent) return; const label = agentRoot ? "agent" : "agents.defaults"; const legacyModel = typeof agent.model === "string" ? String(agent.model) : undefined; const legacyImageModel = typeof agent.imageModel === "string" ? String(agent.imageModel) : undefined; const legacyAllowed = Array.isArray(agent.allowedModels) ? (agent.allowedModels as unknown[]).map(String) : []; const legacyModelFallbacks = Array.isArray(agent.modelFallbacks) ? (agent.modelFallbacks as unknown[]).map(String) : []; const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks) ? (agent.imageModelFallbacks as unknown[]).map(String) : []; const legacyAliases = agent.modelAliases && typeof agent.modelAliases === "object" ? (agent.modelAliases as Record) : {}; const hasLegacy = legacyModel || legacyImageModel || legacyAllowed.length > 0 || legacyModelFallbacks.length > 0 || legacyImageModelFallbacks.length > 0 || Object.keys(legacyAliases).length > 0; if (!hasLegacy) return; const models = agent.models && typeof agent.models === "object" ? (agent.models as Record) : {}; const ensureModel = (rawKey?: string) => { if (typeof rawKey !== "string") return; const key = rawKey.trim(); if (!key) return; if (!models[key]) models[key] = {}; }; ensureModel(legacyModel); ensureModel(legacyImageModel); for (const key of legacyAllowed) ensureModel(key); for (const key of legacyModelFallbacks) ensureModel(key); for (const key of legacyImageModelFallbacks) ensureModel(key); for (const target of Object.values(legacyAliases)) { if (typeof target !== "string") continue; ensureModel(target); } for (const [alias, targetRaw] of Object.entries(legacyAliases)) { if (typeof targetRaw !== "string") continue; const target = targetRaw.trim(); if (!target) continue; const entry = models[target] && typeof models[target] === "object" ? (models[target] as Record) : {}; if (!("alias" in entry)) { entry.alias = alias; models[target] = entry; } } const currentModel = agent.model && typeof agent.model === "object" ? (agent.model as Record) : null; if (currentModel) { if (!currentModel.primary && legacyModel) { currentModel.primary = legacyModel; } if ( legacyModelFallbacks.length > 0 && (!Array.isArray(currentModel.fallbacks) || currentModel.fallbacks.length === 0) ) { currentModel.fallbacks = legacyModelFallbacks; } agent.model = currentModel; } else if (legacyModel || legacyModelFallbacks.length > 0) { agent.model = { primary: legacyModel, fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [], }; } const currentImageModel = agent.imageModel && typeof agent.imageModel === "object" ? (agent.imageModel as Record) : null; if (currentImageModel) { if (!currentImageModel.primary && legacyImageModel) { currentImageModel.primary = legacyImageModel; } if ( legacyImageModelFallbacks.length > 0 && (!Array.isArray(currentImageModel.fallbacks) || currentImageModel.fallbacks.length === 0) ) { currentImageModel.fallbacks = legacyImageModelFallbacks; } agent.imageModel = currentImageModel; } else if (legacyImageModel || legacyImageModelFallbacks.length > 0) { agent.imageModel = { primary: legacyImageModel, fallbacks: legacyImageModelFallbacks.length ? legacyImageModelFallbacks : [], }; } agent.models = models; if (legacyModel !== undefined) { changes.push( `Migrated ${label}.model string → ${label}.model.primary.`, ); } if (legacyModelFallbacks.length > 0) { changes.push( `Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`, ); } if (legacyImageModel !== undefined) { changes.push( `Migrated ${label}.imageModel string → ${label}.imageModel.primary.`, ); } if (legacyImageModelFallbacks.length > 0) { changes.push( `Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`, ); } if (legacyAllowed.length > 0) { changes.push(`Migrated ${label}.allowedModels → ${label}.models.`); } if (Object.keys(legacyAliases).length > 0) { changes.push( `Migrated ${label}.modelAliases → ${label}.models.*.alias.`, ); } delete agent.allowedModels; delete agent.modelAliases; delete agent.modelFallbacks; delete agent.imageModelFallbacks; }, }, { id: "routing.agents-v2", describe: "Move routing.agents/defaultAgentId to agents.list", apply: (raw, changes) => { const routing = getRecord(raw.routing); if (!routing) return; const routingAgents = getRecord(routing.agents); const agents = ensureRecord(raw, "agents"); const list = getAgentsList(agents); if (routingAgents) { for (const [rawId, entryRaw] of Object.entries(routingAgents)) { const agentId = String(rawId ?? "").trim(); const entry = getRecord(entryRaw); if (!agentId || !entry) continue; const target = ensureAgentEntry(list, agentId); const entryCopy: Record = { ...entry }; if ("mentionPatterns" in entryCopy) { const mentionPatterns = entryCopy.mentionPatterns; const groupChat = ensureRecord(target, "groupChat"); if (groupChat.mentionPatterns === undefined) { groupChat.mentionPatterns = mentionPatterns; changes.push( `Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`, ); } else { changes.push( `Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`, ); } delete entryCopy.mentionPatterns; } const legacyGroupChat = getRecord(entryCopy.groupChat); if (legacyGroupChat) { const groupChat = ensureRecord(target, "groupChat"); mergeMissing(groupChat, legacyGroupChat); delete entryCopy.groupChat; } const legacySandbox = getRecord(entryCopy.sandbox); if (legacySandbox) { const sandboxTools = getRecord(legacySandbox.tools); if (sandboxTools) { const tools = ensureRecord(target, "tools"); const sandbox = ensureRecord(tools, "sandbox"); const toolPolicy = ensureRecord(sandbox, "tools"); mergeMissing(toolPolicy, sandboxTools); delete legacySandbox.tools; changes.push( `Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`, ); } entryCopy.sandbox = legacySandbox; } mergeMissing(target, entryCopy); } delete routing.agents; changes.push("Moved routing.agents → agents.list."); } const defaultAgentId = typeof routing.defaultAgentId === "string" ? routing.defaultAgentId.trim() : ""; if (defaultAgentId) { const hasDefault = list.some( (entry): entry is Record => isRecord(entry) && entry.default === true, ); if (!hasDefault) { const entry = ensureAgentEntry(list, defaultAgentId); entry.default = true; changes.push( `Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`, ); } else { changes.push( "Removed routing.defaultAgentId (agents.list default already set).", ); } delete routing.defaultAgentId; } if (list.length > 0) { agents.list = list; } if (Object.keys(routing).length === 0) { delete raw.routing; } }, }, { id: "routing.config-v2", describe: "Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio", apply: (raw, changes) => { const routing = getRecord(raw.routing); if (!routing) return; if (routing.bindings !== undefined) { if (raw.bindings === undefined) { raw.bindings = routing.bindings; changes.push("Moved routing.bindings → bindings."); } else { changes.push("Removed routing.bindings (bindings already set)."); } delete routing.bindings; } if (routing.agentToAgent !== undefined) { const tools = ensureRecord(raw, "tools"); if (tools.agentToAgent === undefined) { tools.agentToAgent = routing.agentToAgent; changes.push("Moved routing.agentToAgent → tools.agentToAgent."); } else { changes.push( "Removed routing.agentToAgent (tools.agentToAgent already set).", ); } delete routing.agentToAgent; } if (routing.queue !== undefined) { const messages = ensureRecord(raw, "messages"); if (messages.queue === undefined) { messages.queue = routing.queue; changes.push("Moved routing.queue → messages.queue."); } else { changes.push("Removed routing.queue (messages.queue already set)."); } delete routing.queue; } const groupChat = getRecord(routing.groupChat); if (groupChat) { const historyLimit = groupChat.historyLimit; if (historyLimit !== undefined) { const messages = ensureRecord(raw, "messages"); const messagesGroup = ensureRecord(messages, "groupChat"); if (messagesGroup.historyLimit === undefined) { messagesGroup.historyLimit = historyLimit; changes.push( "Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.", ); } else { changes.push( "Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).", ); } delete groupChat.historyLimit; } const mentionPatterns = groupChat.mentionPatterns; if (mentionPatterns !== undefined) { const messages = ensureRecord(raw, "messages"); const messagesGroup = ensureRecord(messages, "groupChat"); if (messagesGroup.mentionPatterns === undefined) { messagesGroup.mentionPatterns = mentionPatterns; changes.push( "Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.", ); } else { changes.push( "Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).", ); } delete groupChat.mentionPatterns; } if (Object.keys(groupChat).length === 0) { delete routing.groupChat; } else { routing.groupChat = groupChat; } } if (routing.transcribeAudio !== undefined) { const mapped = mapLegacyAudioTranscription(routing.transcribeAudio); if (mapped) { const tools = ensureRecord(raw, "tools"); const toolsAudio = ensureRecord(tools, "audio"); if (toolsAudio.transcription === undefined) { toolsAudio.transcription = mapped; changes.push( "Moved routing.transcribeAudio → tools.audio.transcription.", ); } else { changes.push( "Removed routing.transcribeAudio (tools.audio.transcription already set).", ); } } else { changes.push( "Removed routing.transcribeAudio (unsupported transcription CLI).", ); } delete routing.transcribeAudio; } const audio = getRecord(raw.audio); if (audio?.transcription !== undefined) { const mapped = mapLegacyAudioTranscription(audio.transcription); if (mapped) { const tools = ensureRecord(raw, "tools"); const toolsAudio = ensureRecord(tools, "audio"); if (toolsAudio.transcription === undefined) { toolsAudio.transcription = mapped; changes.push( "Moved audio.transcription → tools.audio.transcription.", ); } else { changes.push( "Removed audio.transcription (tools.audio.transcription already set).", ); } delete audio.transcription; if (Object.keys(audio).length === 0) delete raw.audio; else raw.audio = audio; } else { delete audio.transcription; changes.push( "Removed audio.transcription (unsupported transcription CLI).", ); if (Object.keys(audio).length === 0) delete raw.audio; else raw.audio = audio; } } if (Object.keys(routing).length === 0) { delete raw.routing; } }, }, { id: "agent.defaults-v2", describe: "Move agent config to agents.defaults and tools", apply: (raw, changes) => { const agent = getRecord(raw.agent); if (!agent) return; const agents = ensureRecord(raw, "agents"); const defaults = getRecord(agents.defaults) ?? {}; const tools = ensureRecord(raw, "tools"); const agentTools = getRecord(agent.tools); if (agentTools) { if (tools.allow === undefined && agentTools.allow !== undefined) { tools.allow = agentTools.allow; changes.push("Moved agent.tools.allow → tools.allow."); } if (tools.deny === undefined && agentTools.deny !== undefined) { tools.deny = agentTools.deny; changes.push("Moved agent.tools.deny → tools.deny."); } } const elevated = getRecord(agent.elevated); if (elevated) { if (tools.elevated === undefined) { tools.elevated = elevated; changes.push("Moved agent.elevated → tools.elevated."); } else { changes.push("Removed agent.elevated (tools.elevated already set)."); } } const bash = getRecord(agent.bash); if (bash) { if (tools.bash === undefined) { tools.bash = bash; changes.push("Moved agent.bash → tools.bash."); } else { changes.push("Removed agent.bash (tools.bash already set)."); } } const sandbox = getRecord(agent.sandbox); if (sandbox) { const sandboxTools = getRecord(sandbox.tools); if (sandboxTools) { const toolsSandbox = ensureRecord(tools, "sandbox"); const toolPolicy = ensureRecord(toolsSandbox, "tools"); mergeMissing(toolPolicy, sandboxTools); delete sandbox.tools; changes.push("Moved agent.sandbox.tools → tools.sandbox.tools."); } } const subagents = getRecord(agent.subagents); if (subagents) { const subagentTools = getRecord(subagents.tools); if (subagentTools) { const toolsSubagents = ensureRecord(tools, "subagents"); const toolPolicy = ensureRecord(toolsSubagents, "tools"); mergeMissing(toolPolicy, subagentTools); delete subagents.tools; changes.push("Moved agent.subagents.tools → tools.subagents.tools."); } } const agentCopy: Record = structuredClone(agent); delete agentCopy.tools; delete agentCopy.elevated; delete agentCopy.bash; if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools; if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools; mergeMissing(defaults, agentCopy); agents.defaults = defaults; raw.agents = agents; delete raw.agent; changes.push("Moved agent → agents.defaults."); }, }, { id: "identity->agents.list", describe: "Move identity to agents.list[].identity", apply: (raw, changes) => { const identity = getRecord(raw.identity); if (!identity) return; const agents = ensureRecord(raw, "agents"); const list = getAgentsList(agents); const defaultId = resolveDefaultAgentIdFromRaw(raw); const entry = ensureAgentEntry(list, defaultId); if (entry.identity === undefined) { entry.identity = identity; changes.push( `Moved identity → agents.list (id "${defaultId}").identity.`, ); } else { changes.push("Removed identity (agents.list identity already set)."); } agents.list = list; raw.agents = agents; delete raw.identity; }, }, ]; export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") return []; const root = raw as Record; const issues: LegacyConfigIssue[] = []; for (const rule of LEGACY_CONFIG_RULES) { let cursor: unknown = root; for (const key of rule.path) { if (!cursor || typeof cursor !== "object") { cursor = undefined; break; } cursor = (cursor as Record)[key]; } if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) { issues.push({ path: rule.path.join("."), message: rule.message }); } } return issues; } export function applyLegacyMigrations(raw: unknown): { next: Record | null; changes: string[]; } { if (!raw || typeof raw !== "object") return { next: null, changes: [] }; const next = structuredClone(raw) as Record; const changes: string[] = []; for (const migration of LEGACY_CONFIG_MIGRATIONS) { migration.apply(next, changes); } if (changes.length === 0) return { next: null, changes: [] }; return { next, changes }; }