diff --git a/package.json b/package.json index 4a0e9a2b202..b6239a3abc2 100644 --- a/package.json +++ b/package.json @@ -1336,7 +1336,7 @@ "check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check", "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", "check:changed": "node scripts/check-changed.mjs", - "check:deprecated-internal-config-api": "pnpm test src/plugins/contracts/deprecated-internal-config-api.test.ts -- --reporter=verbose", + "check:deprecated-internal-config-api": "node scripts/check-deprecated-internal-config-api.mjs", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:import-cycles": "node --import tsx scripts/check-import-cycles.ts", diff --git a/scripts/check-deprecated-internal-config-api.mjs b/scripts/check-deprecated-internal-config-api.mjs new file mode 100644 index 00000000000..df541b9abd3 --- /dev/null +++ b/scripts/check-deprecated-internal-config-api.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { collectDeprecatedInternalConfigApiViolations } from "./lib/deprecated-config-api-guard.mjs"; + +export function main() { + const violations = collectDeprecatedInternalConfigApiViolations(); + if (violations.length === 0) { + console.log("deprecated internal config API guard passed"); + return 0; + } + + console.error("Deprecated internal config API guard failed:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + return 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(); +} diff --git a/scripts/lib/deprecated-config-api-guard.d.mts b/scripts/lib/deprecated-config-api-guard.d.mts new file mode 100644 index 00000000000..fe2df225986 --- /dev/null +++ b/scripts/lib/deprecated-config-api-guard.d.mts @@ -0,0 +1,3 @@ +export function collectDeprecatedInternalConfigApiViolations(options?: { + repoRoot?: string; +}): string[]; diff --git a/scripts/lib/deprecated-config-api-guard.mjs b/scripts/lib/deprecated-config-api-guard.mjs new file mode 100644 index 00000000000..a087c02dcd6 --- /dev/null +++ b/scripts/lib/deprecated-config-api-guard.mjs @@ -0,0 +1,325 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +const COMPAT_CONFIG_API_FILES = new Set([ + "src/config/config.ts", + "src/config/io.ts", + "src/config/mutate.ts", + "src/memory-host-sdk/runtime-core.ts", + "src/plugin-sdk/browser-config-runtime.ts", + "src/plugin-sdk/config-runtime.ts", + "src/plugin-sdk/memory-core.ts", + "src/plugin-sdk/memory-core-host-runtime-core.ts", + "src/plugins/contracts/deprecated-internal-config-api.test.ts", + "src/plugins/runtime/runtime-config.test.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/types-core.ts", +]); + +const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ + "src/plugins/runtime/load-context.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/runtime-plugin-boundary.ts", +]); + +const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ + "src/cli/banner-config-lite.ts", + "src/cli/daemon-cli/status.gather.ts", +]); + +function collectTypeScriptFiles(dir) { + if (!existsSync(dir)) { + return []; + } + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "dist" || entry.name === "node_modules") { + continue; + } + files.push(...collectTypeScriptFiles(fullPath)); + continue; + } + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +function repoRelative(repoRoot, filePath) { + return relative(repoRoot, filePath).split(sep).join("/"); +} + +function isProductionExtensionFile(relPath) { + if ( + relPath.includes("/test-support/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ) { + return false; + } + return true; +} + +function isTestOrHarnessFile(relPath) { + return ( + relPath.includes("test-support") || + relPath.includes("/test-support/") || + relPath.includes("/test-helpers/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith(".test-helpers.ts") || + relPath.endsWith(".test-support.ts") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ); +} + +function isCompatConfigApiFile(relPath) { + return COMPAT_CONFIG_API_FILES.has(relPath); +} + +function isAmbientRuntimeConfigCompatFile(relPath) { + return AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES.has(relPath); +} + +function findLineNumbers(source, pattern) { + const lines = source.split(/\r?\n/); + return lines.flatMap((line, index) => (pattern.test(line) ? [index + 1] : [])); +} + +function findMatchLineNumbers(source, pattern) { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + const regex = new RegExp(pattern.source, flags); + const lines = []; + for (let match = regex.exec(source); match; match = regex.exec(source)) { + lines.push(source.slice(0, match.index).split(/\r?\n/).length); + } + return lines; +} + +function findNonCommentLineNumbers(source, pattern) { + return source.split(/\r?\n/).flatMap((line, index) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) { + return []; + } + return pattern.test(line) ? [index + 1] : []; + }); +} + +function repoCodeRoots(repoRoot) { + return ["src", "extensions", "packages", "test", "scripts"].map((entry) => + resolve(repoRoot, entry), + ); +} + +function pushDeprecatedRuntimeApiViolations(violations, files) { + const guards = [ + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, + replacement: "use runtime.config.current() or pass the already loaded config", + }, + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, + replacement: + "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", + }, + ]; + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + for (const guard of guards) { + for (const line of findMatchLineNumbers(source, guard.pattern)) { + violations.push(`${relPath}:${line} ${guard.replacement}`); + } + } + } +} + +export function collectDeprecatedInternalConfigApiViolations({ + repoRoot = DEFAULT_REPO_ROOT, +} = {}) { + const srcRoot = resolve(repoRoot, "src"); + const extensionsRoot = resolve(repoRoot, "extensions"); + const gatewayServerMethodsRoot = resolve(srcRoot, "gateway/server-methods"); + const ambientRuntimeConfigRoots = [ + "src/gateway", + "src/auto-reply", + "src/agents", + "src/infra", + "src/mcp", + "src/plugins/runtime", + "src/config/sessions", + ].map((entry) => resolve(repoRoot, entry)); + + const violations = []; + + const productionExtensionFiles = collectTypeScriptFiles(extensionsRoot) + .map((filePath) => ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter(({ relPath }) => isProductionExtensionFile(relPath)); + pushDeprecatedRuntimeApiViolations(violations, productionExtensionFiles); + + for (const { filePath, relPath } of productionExtensionFiles) { + const source = readFileSync(filePath, "utf8"); + const guards = [ + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\bloadConfig\b[^}]*\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or pass the already loaded config", + }, + { + pattern: /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })); + + pushDeprecatedRuntimeApiViolations( + violations, + repoFiles.filter(({ relPath }) => !isCompatConfigApiFile(relPath)), + ); + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => !isCompatConfigApiFile(relPath), + )) { + const source = readFileSync(filePath, "utf8"); + const guards = [ + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[\s\S]*?\b(?:loadConfig|writeConfigFile)\b[\s\S]*?\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or mutation helpers with afterWrite", + }, + { + pattern: + /ReturnType/, + replacement: "use OpenClawConfig or the explicit mutation helper type", + }, + ]; + for (const guard of guards) { + for (const line of findMatchLineNumbers(source, guard.pattern)) { + violations.push(`${relPath}:${line} ${guard.replacement}`); + } + } + } + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !relPath.startsWith("test/"), + )) { + const source = readFileSync(filePath, "utf8"); + const importPattern = + /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; + const dynamicImportPattern = + /\bconst\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s*=\s*await\s+import\(["'][^"']*(?:config\/config|config\/io)\.js["']\)/; + const directMethodPattern = /\.\s*writeConfigFile\s*\(/; + for (const pattern of [importPattern, dynamicImportPattern]) { + for (const line of findMatchLineNumbers(source, pattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + for (const line of findNonCommentLineNumbers(source, directMethodPattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + + for (const { filePath, relPath } of repoFiles.filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && + !relPath.startsWith("test/"), + )) { + const source = readFileSync(filePath, "utf8"); + for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter(({ relPath }) => !isTestOrHarnessFile(relPath))) { + const source = readFileSync(filePath, "utf8"); + const importPattern = + /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; + for (const line of findMatchLineNumbers(source, importPattern)) { + violations.push( + `${relPath}:${line} use context.getRuntimeConfig() in gateway request handlers`, + ); + } + for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) + .filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !isAmbientRuntimeConfigCompatFile(relPath), + )) { + const source = readFileSync(filePath, "utf8"); + const loadConfigLines = findNonCommentLineNumbers(source, /(? - resolve(REPO_ROOT, entry), -); -const GATEWAY_SERVER_METHODS_ROOT = resolve(SRC_ROOT, "gateway/server-methods"); -const AMBIENT_RUNTIME_CONFIG_ROOTS = [ - "src/gateway", - "src/auto-reply", - "src/agents", - "src/infra", - "src/mcp", - "src/plugins/runtime", - "src/config/sessions", -].map((entry) => resolve(REPO_ROOT, entry)); - -const COMPAT_CONFIG_API_FILES = new Set([ - "src/config/config.ts", - "src/config/io.ts", - "src/config/mutate.ts", - "src/memory-host-sdk/runtime-core.ts", - "src/plugin-sdk/browser-config-runtime.ts", - "src/plugin-sdk/config-runtime.ts", - "src/plugin-sdk/memory-core.ts", - "src/plugin-sdk/memory-core-host-runtime-core.ts", - "src/plugins/contracts/deprecated-internal-config-api.test.ts", - "src/plugins/runtime/runtime-config.test.ts", - "src/plugins/runtime/runtime-config.ts", - "src/plugins/runtime/types-core.ts", -]); -const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ - "src/plugins/runtime/load-context.ts", - "src/plugins/runtime/runtime-config.ts", - "src/plugins/runtime/runtime-plugin-boundary.ts", -]); -const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ - "src/cli/banner-config-lite.ts", - "src/cli/daemon-cli/status.gather.ts", -]); - -function collectTypeScriptFiles(dir: string): string[] { - const entries = readdirSync(dir, { withFileTypes: true }); - const files: string[] = []; - for (const entry of entries) { - const fullPath = resolve(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "dist" || entry.name === "node_modules") { - continue; - } - files.push(...collectTypeScriptFiles(fullPath)); - continue; - } - if (entry.isFile() && entry.name.endsWith(".ts")) { - files.push(fullPath); - } - } - return files; -} - -function repoRelative(filePath: string): string { - return relative(REPO_ROOT, filePath).split(sep).join("/"); -} - -function isProductionExtensionFile(relPath: string): boolean { - if ( - relPath.includes("/test-support/") || - relPath.includes(".test.") || - relPath.includes(".live.test.") || - relPath.includes(".test-d.") || - relPath.includes(".test-harness.") || - relPath.includes(".test-shared.") || - relPath.endsWith("-test-helpers.ts") || - relPath.endsWith("-test-support.ts") - ) { - return false; - } - return true; -} - -function isTestOrHarnessFile(relPath: string): boolean { - return ( - relPath.includes("test-support") || - relPath.includes("/test-support/") || - relPath.includes("/test-helpers/") || - relPath.includes(".test.") || - relPath.includes(".live.test.") || - relPath.includes(".test-d.") || - relPath.includes(".test-harness.") || - relPath.includes(".test-shared.") || - relPath.endsWith(".test-helpers.ts") || - relPath.endsWith(".test-support.ts") || - relPath.endsWith("-test-helpers.ts") || - relPath.endsWith("-test-support.ts") - ); -} - -function isCompatConfigApiFile(relPath: string): boolean { - return COMPAT_CONFIG_API_FILES.has(relPath); -} - -function isAmbientRuntimeConfigCompatFile(relPath: string): boolean { - return AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES.has(relPath); -} - -function findLineNumbers(source: string, pattern: RegExp): number[] { - const lines = source.split(/\r?\n/); - return lines.flatMap((line, index) => (pattern.test(line) ? [index + 1] : [])); -} - -function findMatchLineNumbers(source: string, pattern: RegExp): number[] { - const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; - const regex = new RegExp(pattern.source, flags); - const lines: number[] = []; - for (let match = regex.exec(source); match; match = regex.exec(source)) { - lines.push(source.slice(0, match.index).split(/\r?\n/).length); - } - return lines; -} - -function findNonCommentLineNumbers(source: string, pattern: RegExp): number[] { - return source.split(/\r?\n/).flatMap((line, index) => { - const trimmed = line.trimStart(); - if (trimmed.startsWith("//") || trimmed.startsWith("*")) { - return []; - } - return pattern.test(line) ? [index + 1] : []; - }); -} +import { collectDeprecatedInternalConfigApiViolations } from "../../../scripts/lib/deprecated-config-api-guard.mjs"; describe("deprecated internal config API guardrails", () => { - it("keeps bundled plugin production code off direct runtime config load/write APIs", () => { - const violations: string[] = []; - const files = collectTypeScriptFiles(EXTENSIONS_ROOT) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter(({ relPath }) => isProductionExtensionFile(relPath)); - - for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); - const guards = [ - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, - replacement: "use runtime.config.current() or pass the already loaded config", - }, - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, - replacement: - "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", - }, - { - pattern: - /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\bloadConfig\b[^}]*\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core)["']/, - replacement: - "use getRuntimeConfig(), runtime.config.current(), or pass the already loaded config", - }, - { - pattern: /(? { - const violations: string[] = []; - const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter(({ relPath }) => !isCompatConfigApiFile(relPath)); - - const guards = [ - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, - replacement: "use runtime.config.current() or pass the already loaded config", - }, - { - pattern: - /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, - replacement: - "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", - }, - { - pattern: - /\b(?:import|export)\s+(?:type\s+)?\{[\s\S]*?\b(?:loadConfig|writeConfigFile)\b[\s\S]*?\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']/, - replacement: - "use getRuntimeConfig(), runtime.config.current(), or mutation helpers with afterWrite", - }, - { - pattern: - /ReturnType/, - replacement: "use OpenClawConfig or the explicit mutation helper type", - }, - ]; - - for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); - for (const guard of guards) { - for (const line of findMatchLineNumbers(source, guard.pattern)) { - violations.push(`${relPath}:${line} ${guard.replacement}`); - } - } - } - - expect(violations).toEqual([]); - }); - - it("keeps production config writes on mutation helpers", () => { - const violations: string[] = []; - const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !relPath.startsWith("test/"), - ); - - const importPattern = - /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; - const dynamicImportPattern = - /\bconst\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s*=\s*await\s+import\(["'][^"']*(?:config\/config|config\/io)\.js["']\)/; - const directMethodPattern = /\.\s*writeConfigFile\s*\(/; - - for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); - for (const pattern of [importPattern, dynamicImportPattern]) { - for (const line of findMatchLineNumbers(source, pattern)) { - violations.push( - `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, - ); - } - } - for (const line of findNonCommentLineNumbers(source, directMethodPattern)) { - violations.push( - `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, - ); - } - } - - expect(violations).toEqual([]); - }); - - it("keeps production code off direct config loads outside explicit process boundaries", () => { - const violations: string[] = []; - const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && - !relPath.startsWith("test/"), - ); - - const directCallPattern = /(? { - const violations: string[] = []; - const files = collectTypeScriptFiles(GATEWAY_SERVER_METHODS_ROOT) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter(({ relPath }) => !isTestOrHarnessFile(relPath)); - - const guards = [ - { - pattern: - /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/, - replacement: "use context.getRuntimeConfig() in gateway request handlers", - }, - { - pattern: /(? { - const violations: string[] = []; - const files = AMBIENT_RUNTIME_CONFIG_ROOTS.flatMap(collectTypeScriptFiles) - .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) - .filter( - ({ relPath }) => - !isTestOrHarnessFile(relPath) && - !isCompatConfigApiFile(relPath) && - !isAmbientRuntimeConfigCompatFile(relPath), - ); - - for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); - const loadConfigLines = findNonCommentLineNumbers(source, /(? { + expect(collectDeprecatedInternalConfigApiViolations()).toEqual([]); }); }); diff --git a/src/plugins/runtime/runtime-config.test.ts b/src/plugins/runtime/runtime-config.test.ts index afc7d9bd203..acbc3c6f602 100644 --- a/src/plugins/runtime/runtime-config.test.ts +++ b/src/plugins/runtime/runtime-config.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; const getRuntimeConfigMock = vi.fn(); const mutateConfigFileMock = vi.fn(); const replaceConfigFileMock = vi.fn(); +const logWarnMock = vi.fn(); vi.mock("../../config/config.js", () => ({ getRuntimeConfig: () => getRuntimeConfigMock(), @@ -14,6 +15,10 @@ vi.mock("../../config/mutate.js", () => ({ replaceConfigFile: (...args: unknown[]) => replaceConfigFileMock(...args), })); +vi.mock("../../logger.js", () => ({ + logWarn: (...args: unknown[]) => logWarnMock(...args), +})); + const { createRuntimeConfig } = await import("./runtime-config.js"); describe("createRuntimeConfig", () => { @@ -21,6 +26,7 @@ describe("createRuntimeConfig", () => { getRuntimeConfigMock.mockReset(); mutateConfigFileMock.mockReset(); replaceConfigFileMock.mockReset(); + logWarnMock.mockClear(); getRuntimeConfigMock.mockReturnValue({ plugins: {} }); mutateConfigFileMock.mockResolvedValue({ previousHash: null, nextHash: "next" }); replaceConfigFileMock.mockResolvedValue({ previousHash: null, nextHash: "next" }); @@ -34,6 +40,9 @@ describe("createRuntimeConfig", () => { expect(configApi.current()).toBe(runtimeConfig); expect(configApi.loadConfig()).toBe(runtimeConfig); expect(getRuntimeConfigMock).toHaveBeenCalledTimes(2); + expect(logWarnMock).toHaveBeenCalledWith( + "plugin runtime config.loadConfig() is deprecated; use config.current().", + ); }); it("routes deprecated writeConfigFile through replaceConfigFile with afterWrite", async () => { @@ -42,6 +51,9 @@ describe("createRuntimeConfig", () => { await configApi.writeConfigFile(nextConfig); + expect(logWarnMock).toHaveBeenCalledWith( + "plugin runtime config.writeConfigFile() is deprecated; use config.mutateConfigFile(...) or config.replaceConfigFile(...).", + ); expect(replaceConfigFileMock).toHaveBeenCalledWith({ nextConfig, afterWrite: { mode: "auto" }, diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts index 5fba1326297..9792c14d048 100644 --- a/src/plugins/runtime/runtime-config.ts +++ b/src/plugins/runtime/runtime-config.ts @@ -3,8 +3,22 @@ import { mutateConfigFile as mutateConfigFileInternal, replaceConfigFile as replaceConfigFileInternal, } from "../../config/mutate.js"; +import { logWarn } from "../../logger.js"; import type { PluginRuntime } from "./types.js"; +const warnedDeprecatedConfigApis = new Set(); + +function warnDeprecatedConfigApiOnce( + name: "loadConfig" | "writeConfigFile", + replacement: string, +): void { + if (warnedDeprecatedConfigApis.has(name)) { + return; + } + warnedDeprecatedConfigApis.add(name); + logWarn(`plugin runtime config.${name}() is deprecated; use ${replacement}.`); +} + export function createRuntimeConfig(): PluginRuntime["config"] { return { current: getRuntimeConfig, @@ -18,8 +32,15 @@ export function createRuntimeConfig(): PluginRuntime["config"] { ...params, writeOptions: params.writeOptions, }), - loadConfig: getRuntimeConfig, + loadConfig: () => { + warnDeprecatedConfigApiOnce("loadConfig", "config.current()"); + return getRuntimeConfig(); + }, writeConfigFile: async (cfg, options) => { + warnDeprecatedConfigApiOnce( + "writeConfigFile", + "config.mutateConfigFile(...) or config.replaceConfigFile(...)", + ); await replaceConfigFileInternal({ nextConfig: cfg, afterWrite: options?.afterWrite ?? { mode: "auto" }, diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index ba0607e8330..1846490c22c 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -63,6 +63,12 @@ function createTaskFlowSessionMock() { }; } +function createDeprecatedRuntimeConfigError(name: "loadConfig" | "writeConfigFile"): Error { + return new Error( + `Plugin runtime config.${name}() is deprecated in tests; pass cfg/current() or use mutateConfigFile()/replaceConfigFile().`, + ); +} + export function createPluginRuntimeMock(overrides: DeepPartial = {}): PluginRuntime { const taskFlow = { bindSession: vi.fn( @@ -93,8 +99,12 @@ export function createPluginRuntimeMock(overrides: DeepPartial = afterWrite: { mode: "auto" }, followUp: { mode: "auto", requiresRestart: false }, })) as unknown as PluginRuntime["config"]["replaceConfigFile"], - loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], - writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], + loadConfig: vi.fn(() => { + throw createDeprecatedRuntimeConfigError("loadConfig"); + }) as unknown as PluginRuntime["config"]["loadConfig"], + writeConfigFile: vi.fn(async () => { + throw createDeprecatedRuntimeConfigError("writeConfigFile"); + }) as unknown as PluginRuntime["config"]["writeConfigFile"], }, agent: { defaults: {