diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index 498ff43d0ccc..c5e9b56b26be 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -192,8 +192,9 @@ every human `Thanks @...` attribution. 9. Check release-note side conditions: - inspect `src/plugins/compat/registry.ts` - inspect `src/commands/doctor/shared/deprecation-compat.ts` - - if any compatibility `removeAfter` is on/before release date, resolve it - or explicitly record the blocker before shipping + - if a deprecated compatibility record reaches `removeAfter`, remove it when + proven safe or move it to `removal-pending` and record the blocker; keep a + due `removal-pending` record only until its documented conditions are met 10. Validate and ship: - after the manifest-driven rewrite, regenerate and verify the complete diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 72cc7d7ac40c..e8478c95ec54 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -103,11 +103,13 @@ a workflow fix that the existing parent run cannot consume. returns to the Code SHA loop. - During release planning, inspect both `src/plugins/compat/registry.ts` and `src/commands/doctor/shared/deprecation-compat.ts` before branching and again - before final publish. For every deprecated or removal-pending compatibility - record whose `removeAfter` date is on or before the release date, either - remove the compatibility path where safe and validate the affected tests, or - write down why removal is blocked and get explicit maintainer approval before - shipping the expired compatibility path. + before final publish. For every `deprecated` compatibility record whose + `removeAfter` date is on or before the release date, either remove the + compatibility path where safe and validate the affected tests, or change it + to `removal-pending`, document the blocker, and get explicit maintainer + approval. Revalidate every due `removal-pending` record's blocker and upgrade + conditions before shipping; keep it only with explicit maintainer approval + until those conditions are met. - When removing deprecated runtime/config compatibility, preserve any doctor migration, repair, or hint that is still needed by supported upgrade paths. Doctor-side compatibility should stay tracked in diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ae9ca7cd0d7..2754f8ef01f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2227,6 +2227,14 @@ jobs: else echo "[skip] historical target predates the script declaration contract" fi + if [[ "$HISTORICAL_TARGET" == "true" ]]; then + echo "[skip] historical target skips the wall-clock doctor deprecation registry guard" + elif has_package_script "check:doctor-deprecation-registry"; then + pnpm check:doctor-deprecation-registry + else + echo "Current CI targets must provide the check:doctor-deprecation-registry package script." >&2 + exit 1 + fi pnpm tool-display:check pnpm check:host-env-policy:swift pnpm dup:check:coverage diff --git a/docs/plugins/compatibility.md b/docs/plugins/compatibility.md index 5d3d29ab556d..63bbaf681aae 100644 --- a/docs/plugins/compatibility.md +++ b/docs/plugins/compatibility.md @@ -36,6 +36,13 @@ Doctor repair and migration compatibility is tracked separately at config shapes, install-ledger layouts, and repair shims that may need to stay available after the runtime compatibility path is removed. +Every doctor compatibility record declares `introduced` and `removeAfter`. +The `pnpm check:doctor-deprecation-registry` guard fails when a record is still +`deprecated` on or after `removeAfter`; maintainers must either remove it after +supported-upgrade proof or move it to `removal-pending` with a documented +blocker. `removal-pending` records do not fail the date guard, but remain in the +explicit review queue until their upgrade conditions are met. + Release sweeps should check both registries. Do not delete a doctor migration just because the matching runtime or config compatibility record expired; first verify there is no supported upgrade path that still needs diff --git a/package.json b/package.json index 087e38e61adf..6dac0ae9d33a 100644 --- a/package.json +++ b/package.json @@ -1510,6 +1510,7 @@ "check:database-first-legacy-stores": "node scripts/check-database-first-legacy-stores.mjs", "check:deprecated-api-usage": "node scripts/check-deprecated-api-usage.mjs", "check:deprecated-jsdoc": "node scripts/check-deprecated-jsdoc.mjs", + "check:doctor-deprecation-registry": "node --import tsx scripts/check-doctor-deprecation-registry.ts", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:env-var-count": "node scripts/check-env-var-count.mjs", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 732ceeae31fc..2a433423572d 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -534,6 +534,7 @@ export function createChangedCheckPlan(result, options = {}) { ]); } add("changelog attributions", ["check:changelog-attributions"]); + add("doctor deprecation registry", ["check:doctor-deprecation-registry"]); add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]); add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); add("duplicate scan target coverage", ["dup:check:coverage"]); diff --git a/scripts/check-doctor-deprecation-registry.ts b/scripts/check-doctor-deprecation-registry.ts new file mode 100644 index 000000000000..aaf38a846c02 --- /dev/null +++ b/scripts/check-doctor-deprecation-registry.ts @@ -0,0 +1,80 @@ +import { pathToFileURL } from "node:url"; +import { + listDoctorDeprecationCompatRecords, + type DoctorDeprecationCompatRecord, +} from "../src/commands/doctor/shared/deprecation-compat.js"; + +type DeadlineRecord = Pick; +type ExpiredDeadlineRecord = DeadlineRecord & { removeAfter: string }; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +export function findExpiredDeprecatedDoctorRecords( + records: readonly DeadlineRecord[], + asOf: string, +): ExpiredDeadlineRecord[] { + return records + .filter( + (record): record is ExpiredDeadlineRecord => + record.status === "deprecated" && + record.removeAfter !== undefined && + record.removeAfter <= asOf, + ) + .toSorted( + (left, right) => + left.removeAfter.localeCompare(right.removeAfter) || left.code.localeCompare(right.code), + ); +} + +function isUtcDate(value: string): boolean { + if (!DATE_PATTERN.test(value)) { + return false; + } + const timestamp = Date.parse(`${value}T00:00:00Z`); + return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; +} + +function parseAsOf(argv: readonly string[]): string | undefined { + if (argv.length === 0) { + return undefined; + } + if (argv.length === 2 && argv[0] === "--as-of" && isUtcDate(argv[1] ?? "")) { + return argv[1]; + } + throw new Error( + "Usage: node --import tsx scripts/check-doctor-deprecation-registry.ts [--as-of YYYY-MM-DD]", + ); +} + +function main(argv = process.argv.slice(2)): void { + let requestedAsOf: string | undefined; + try { + requestedAsOf = parseAsOf(argv); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + return; + } + + const asOf = requestedAsOf ?? new Date().toISOString().slice(0, 10); + const expired = findExpiredDeprecatedDoctorRecords(listDoctorDeprecationCompatRecords(), asOf); + if (expired.length === 0) { + console.log(`[doctor-deprecation-registry] OK as of ${asOf}`); + return; + } + + console.error( + `[doctor-deprecation-registry] ${expired.length} deprecated record(s) reached removeAfter by ${asOf}:`, + ); + for (const record of expired) { + console.error(`- ${record.code}: removeAfter ${record.removeAfter}`); + } + console.error( + "Remove each migration after supported-upgrade proof, or move it to removal-pending with a documented blocker.", + ); + process.exitCode = 1; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check.mjs b/scripts/check.mjs index c239e526940a..ab1349ba62dd 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -89,6 +89,7 @@ export async function main(argv = process.argv.slice(2)) { { name: "max-lines suppression ratchet", args: ["check:max-lines-ratchet"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, { name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] }, + { name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] }, { name: "guarded extension wildcard re-exports", args: ["lint:extensions:no-guarded-wildcard-reexports"], diff --git a/src/commands/doctor/shared/deprecation-compat.test.ts b/src/commands/doctor/shared/deprecation-compat.test.ts index fe58b20584e4..1422b523ee8a 100644 --- a/src/commands/doctor/shared/deprecation-compat.test.ts +++ b/src/commands/doctor/shared/deprecation-compat.test.ts @@ -10,6 +10,12 @@ import { const datePattern = /^\d{4}-\d{2}-\d{2}$/u; +function addUtcMonths(date: string, months: number): string { + const next = new Date(`${date}T00:00:00Z`); + next.setUTCMonth(next.getUTCMonth() + months); + return next.toISOString().slice(0, 10); +} + const requiredDoctorCompatCodes = [ "doctor-agent-runtime-embedded-harness", "doctor-agent-embedded-pi-config", @@ -24,21 +30,12 @@ const requiredDoctorCompatCodes = [ "doctor-x-search-plugin-config", ] as const; -function parseDate(date: string): Date { - return new Date(`${date}T00:00:00Z`); -} - -function addUtcMonths(date: Date, months: number): Date { - const next = new Date(date); - next.setUTCMonth(next.getUTCMonth() + months); - return next; -} - describe("doctor deprecation compatibility inventory", () => { it("keeps compatibility codes unique and lookup-safe", () => { const records = listDoctorDeprecationCompatRecords(); const codes = records.map((record) => record.code); + expect(records).toHaveLength(43); expect(new Set(codes).size).toBe(codes.length); expect(isDoctorDeprecationCompatCode("doctor-web-search-plugin-config")).toBe(true); expect(isDoctorDeprecationCompatCode("missing-code")).toBe(false); @@ -53,26 +50,28 @@ describe("doctor deprecation compatibility inventory", () => { } }); - it("requires dated deprecation metadata with a three-month maximum window", () => { + it("keeps dated deprecation metadata in chronological order", () => { for (const record of listDeprecatedDoctorDeprecationCompatRecords()) { + expect(record.introduced, record.code).toMatch(datePattern); expect(record.deprecated, record.code).toMatch(datePattern); expect(record.warningStarts, record.code).toMatch(datePattern); expect(record.removeAfter, record.code).toMatch(datePattern); - if (!record.warningStarts || !record.removeAfter) { + if (!record.deprecated || !record.warningStarts || !record.removeAfter) { throw new Error(`${record.code} is missing deprecation window dates`); } - const maxRemoveAfter = addUtcMonths(parseDate(record.warningStarts), 3); - const removeAfter = parseDate(record.removeAfter); - expect(removeAfter <= maxRemoveAfter, record.code).toBe(true); + expect(record.introduced <= record.deprecated, record.code).toBe(true); + expect(record.deprecated <= record.warningStarts, record.code).toBe(true); + expect(record.warningStarts <= record.removeAfter, record.code).toBe(true); + expect(record.removeAfter <= addUtcMonths(record.warningStarts, 3), record.code).toBe(true); } }); it("keeps every record actionable", () => { for (const record of listDoctorDeprecationCompatRecords()) { expect(record.introduced, record.code).toMatch(datePattern); - expect(record.source, record.code).not.toBe(""); - expect(record.migration, record.code).not.toBe(""); - expect(record.replacement, record.code).not.toBe(""); + expect(record.source, record.code).toMatch(/\S/u); + expect(record.migration, record.code).toMatch(/\S/u); + expect(record.replacement, record.code).toMatch(/\S/u); expect(record.docsPath, record.code).toMatch(/^\//u); expect(fs.existsSync(record.migration), `${record.code}: ${record.migration}`).toBe(true); expect(record.tests.length, record.code).toBeGreaterThan(0); diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index ceb1fcf9038b..c14bd4115a86 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -33,8 +33,6 @@ export type DoctorDeprecationCompatRecord = { notes?: string; }; -const TODAY = "2026-04-26"; -const MAX_REMOVE_AFTER = "2026-07-26"; const DEFAULT_TESTS = ["src/commands/doctor/shared/legacy-config-migrate.test.ts"] as const; function deprecatedCompatRecord( @@ -43,22 +41,16 @@ function deprecatedCompatRecord( DoctorDeprecationCompatRecord, "code" | "introduced" | "deprecated" | "warningStarts" | "removeAfter" | "status" | "tests" > & + Required, "introduced" | "removeAfter">> & Partial< - Pick< - DoctorDeprecationCompatRecord, - "introduced" | "deprecated" | "removeAfter" | "status" | "warningStarts" | "tests" - > + Pick, "deprecated" | "status" | "warningStarts" | "tests"> >, ): DoctorDeprecationCompatRecord { - const introduced = record.introduced ?? TODAY; - const deprecated = record.deprecated ?? (record.removeAfter ? introduced : TODAY); return { code, status: "deprecated", - introduced, - deprecated, - warningStarts: deprecated, - removeAfter: MAX_REMOVE_AFTER, + deprecated: record.introduced, + warningStarts: record.introduced, tests: DEFAULT_TESTS, ...record, }; @@ -245,8 +237,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/config/dead-config-keys.test.ts"], }), deprecatedCompatRecord("doctor-agent-llm-timeout", { + status: "removal-pending", owner: "agent-runtime", introduced: "2026-04-27", + removeAfter: "2026-07-26", source: "agents.defaults.llm.idleTimeoutSeconds", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", replacement: "models.providers..timeoutSeconds", @@ -255,8 +249,12 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "The old agent-level idle timeout knob was collapsed into provider request timeout handling, bounded by the agent/run timeout ceiling.", }), deprecatedCompatRecord("doctor-agent-runtime-embedded-harness", { + status: "removal-pending", owner: "agent-runtime", introduced: "2026-04-25", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", source: "agents.defaults.embeddedHarness; agents.list[].embeddedHarness", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", replacement: "models.providers..agentRuntime or model-scoped agentRuntime", @@ -265,8 +263,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "Whole-agent runtime pins are retired; doctor preserves intent only when it can move the value to provider/model runtime policy.", }), deprecatedCompatRecord("doctor-agent-embedded-pi-config", { + status: "removal-pending", owner: "agent-runtime", introduced: "2026-05-21", + removeAfter: "2026-07-26", source: "agents.defaults.embeddedPi; agents.list[].embeddedPi", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", replacement: "agents.defaults.embeddedAgent; agents.list[].embeddedAgent", @@ -275,7 +275,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "Runtime code no longer reads the legacy key; doctor keeps this migration only to preserve shipped configs during upgrade.", }), deprecatedCompatRecord("doctor-agent-sandbox-persession", { + status: "removal-pending", owner: "agent-runtime", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "agents.defaults.sandbox.perSession; agents.list[].sandbox.perSession", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", replacement: "agents.*.sandbox.scope", @@ -300,15 +303,20 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ docsPath: "/concepts/typing-indicators", }), deprecatedCompatRecord("doctor-top-level-heartbeat", { + status: "removal-pending", owner: "config", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "heartbeat", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", replacement: "agents.defaults.heartbeat and channels.defaults.heartbeat", docsPath: "/automation", }), deprecatedCompatRecord("doctor-mcp-server-type-alias", { + status: "removal-pending", owner: "config", introduced: "2026-04-27", + removeAfter: "2026-07-26", source: "mcp.servers.*.type", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts", replacement: "mcp.servers.*.transport", @@ -317,36 +325,50 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "OpenClaw stores transport names; CLI backends receive their own type fields through runtime adapters.", }), deprecatedCompatRecord("doctor-gateway-bind-host-aliases", { + status: "removal-pending", owner: "gateway", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "gateway.bind host aliases such as 0.0.0.0 and localhost", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts", replacement: "gateway.bind.mode values such as lan, loopback, custom, tailnet, and auto", docsPath: "/gateway/configuration", }), deprecatedCompatRecord("doctor-audio-transcription-command", { + status: "removal-pending", owner: "audio", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "audio.transcription", migration: "src/commands/doctor/shared/legacy-config-migrations.audio.ts", replacement: "capability-tagged tools.media.models", docsPath: "/tools/media-overview", }), deprecatedCompatRecord("doctor-channel-thread-binding-ttl", { + status: "removal-pending", owner: "channel", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "threadBindings.ttlHours", migration: "src/commands/doctor/shared/legacy-config-migrations.channels.ts", replacement: "threadBindings.idleHours", docsPath: "/channels/channel-routing", }), deprecatedCompatRecord("doctor-message-queue-steering-modes", { + status: "removal-pending", owner: "config", introduced: "2026-05-04", + removeAfter: "2026-07-26", source: "messages.queue.mode and messages.queue.byChannel retired queue modes", migration: "src/commands/doctor/shared/legacy-config-migrations.queue.ts", replacement: "steer, followup, collect, or interrupt queue modes", docsPath: "/concepts/queue", }), deprecatedCompatRecord("doctor-channel-dm-aliases", { + status: "removal-pending", owner: "channel", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "channels..dm.policy and channels..dm.allowFrom", migration: "src/config/channel-compat-normalization.ts", replacement: "channels..dmPolicy and channels..allowFrom", @@ -354,7 +376,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/channel-legacy-config-migrate.test.ts"], }), deprecatedCompatRecord("doctor-channel-streaming-aliases", { + status: "removal-pending", owner: "channel", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "streamMode, scalar streaming, chunkMode, blockStreaming, draftChunk, nativeStreaming", migration: "src/config/channel-compat-normalization.ts", replacement: "channels..streaming.*", @@ -368,6 +393,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ owner: "channel", introduced: "2026-05-18", deprecated: "2026-05-31", + warningStarts: "2026-05-31", removeAfter: "2026-08-31", source: "channels.webchat", migration: "src/commands/doctor/shared/legacy-config-migrations.channels.ts", @@ -387,15 +413,20 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], }), deprecatedCompatRecord("doctor-tts-provider-aliases", { + status: "removal-pending", owner: "tts", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "messages.tts.openai/elevenlabs/edge and plugins.entries.voice-call.config.tts aliases", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", replacement: "tts.providers. and microsoft instead of edge", docsPath: "/tools/tts", }), deprecatedCompatRecord("doctor-tts-enabled-auto-mode", { + status: "removal-pending", owner: "tts", introduced: "2026-04-29", + removeAfter: "2026-07-26", source: "messages.tts.enabled, agents.list[].tts.enabled, supported channel TTS enabled fields, and voice-call plugin tts.enabled", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", @@ -405,8 +436,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], }), deprecatedCompatRecord("doctor-tts-speaker-selection-fields", { + status: "removal-pending", owner: "tts", introduced: "2026-05-28", + removeAfter: "2026-07-26", source: "TTS provider speaker selection fields named voice, voiceName, and voiceId", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", replacement: "speakerVoice and speakerVoiceId", @@ -414,8 +447,12 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], }), deprecatedCompatRecord("doctor-plugin-install-config-ledger", { + status: "removal-pending", owner: "plugin", introduced: "2026-04-25", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", source: "plugins.installs in authored config", migration: "src/config/plugin-install-config-migration.ts", replacement: "shared SQLite installed_plugin_index install ledger", @@ -426,8 +463,12 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ ], }), deprecatedCompatRecord("doctor-bundled-plugin-load-paths", { + status: "removal-pending", owner: "plugin", introduced: "2026-04-25", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", source: "plugins.load.paths entries that point at bundled plugin source/dist locations", migration: "src/commands/doctor/shared/bundled-plugin-load-paths.ts", replacement: "packaged bundled plugins and the persisted plugin registry", @@ -435,8 +476,12 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/bundled-plugin-load-paths.test.ts"], }), deprecatedCompatRecord("doctor-bundled-provider-discovery-allowlist", { + status: "removal-pending", owner: "plugin", introduced: "2026-04-25", + deprecated: "2026-04-26", + warningStarts: "2026-04-26", + removeAfter: "2026-07-26", source: "plugins.allow configs created before bundled provider discovery was explicit", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts", replacement: "plugins.bundledDiscovery allowlist mode plus explicit plugin/provider entries", @@ -448,6 +493,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ owner: "plugin", introduced: "2026-05-29", deprecated: "2026-07-09", + warningStarts: "2026-07-09", removeAfter: "2026-10-09", source: "plugins.entries.codex-supervisor and codex-supervisor plugin policy references", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts", @@ -457,7 +503,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "The core bootstrap migration must remain available when the external Codex plugin is not installed yet.", }), deprecatedCompatRecord("doctor-web-search-plugin-config", { + status: "removal-pending", owner: "provider", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "tools.web.search.apiKey and tools.web.search.", migration: "src/commands/doctor/shared/legacy-web-search-migrate.ts", replacement: "plugins.entries..config.webSearch", @@ -467,7 +516,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "Provider/plugin ownership can move as bundled providers externalize; verify the current manifest owner before deleting migration support.", }), deprecatedCompatRecord("doctor-web-fetch-plugin-config", { + status: "removal-pending", owner: "provider", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "tools.web.fetch.firecrawl", migration: "src/commands/doctor/shared/legacy-web-fetch-migrate.ts", replacement: "plugins.entries.firecrawl.config.webFetch", @@ -475,7 +527,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/legacy-web-fetch-migrate.test.ts"], }), deprecatedCompatRecord("doctor-x-search-plugin-config", { + status: "removal-pending", owner: "provider", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "tools.web.x_search.apiKey", migration: "src/commands/doctor/shared/legacy-x-search-migrate.ts", replacement: "plugins.entries.xai.config.webSearch.apiKey", @@ -486,14 +541,20 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ ], }), deprecatedCompatRecord("doctor-talk-provider-shape", { + status: "removal-pending", owner: "tts", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "legacy talk provider scalar fields and provider/provider ids", migration: "src/commands/doctor/shared/legacy-talk-config-normalizer.ts", replacement: "talk.providers.", docsPath: "/tools/tts", }), deprecatedCompatRecord("doctor-legacy-tools-by-sender", { + status: "removal-pending", owner: "tools", + introduced: "2026-04-26", + removeAfter: "2026-07-26", source: "untyped toolsBySender keys", migration: "src/commands/doctor/shared/legacy-tools-by-sender.ts", replacement: "typed id:, e164:, username:, or name: sender keys", diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 68143c746e10..c5ba55634eca 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1460,6 +1460,7 @@ describe("scripts/changed-lanes", () => { "environment variable count ratchet", "max-lines suppression ratchet", "changelog attributions", + "doctor deprecation registry", "guarded extension wildcard re-exports", "plugin-sdk wildcard re-exports", "duplicate scan target coverage", @@ -1640,6 +1641,7 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).toEqual([ "check:no-conflict-markers", "check:changelog-attributions", + "check:doctor-deprecation-registry", "lint:extensions:no-guarded-wildcard-reexports", "lint:extensions:no-plugin-sdk-wildcard-reexports", "dup:check:coverage", @@ -2314,6 +2316,7 @@ describe("scripts/changed-lanes", () => { expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, + { name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] }, { name: "guarded extension wildcard re-exports", args: ["lint:extensions:no-guarded-wildcard-reexports"], @@ -2336,6 +2339,7 @@ describe("scripts/changed-lanes", () => { expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, + { name: "doctor deprecation registry", args: ["check:doctor-deprecation-registry"] }, { name: "guarded extension wildcard re-exports", args: ["lint:extensions:no-guarded-wildcard-reexports"], diff --git a/test/scripts/check-doctor-deprecation-registry.test.ts b/test/scripts/check-doctor-deprecation-registry.test.ts new file mode 100644 index 000000000000..e460d3034e46 --- /dev/null +++ b/test/scripts/check-doctor-deprecation-registry.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { findExpiredDeprecatedDoctorRecords } from "../../scripts/check-doctor-deprecation-registry.js"; +import { listDoctorDeprecationCompatRecords } from "../../src/commands/doctor/shared/deprecation-compat.js"; + +const deadlineRecord = { + code: "doctor-test-deadline", + status: "deprecated", + removeAfter: "2026-07-26", +} as const; + +describe("doctor deprecation registry guard", () => { + it.each([ + ["before", "2026-07-25", 0], + ["on", "2026-07-26", 1], + ["after", "2026-07-27", 1], + ])("handles the %s-deadline date", (_label, asOf, expectedCount) => { + expect(findExpiredDeprecatedDoctorRecords([deadlineRecord], asOf)).toHaveLength(expectedCount); + }); + + it("leaves removal-pending records in the explicit review queue", () => { + expect( + findExpiredDeprecatedDoctorRecords( + [{ ...deadlineRecord, status: "removal-pending" }], + "2026-08-08", + ), + ).toEqual([]); + }); + + it("keeps the real registry current as of 2026-08-08", () => { + const records = listDoctorDeprecationCompatRecords(); + const removalPending = records.filter((record) => record.status === "removal-pending"); + + expect(records).toHaveLength(43); + expect(removalPending).toHaveLength(23); + expect(new Set(removalPending.map((record) => record.removeAfter))).toEqual( + new Set(["2026-07-26"]), + ); + expect(findExpiredDeprecatedDoctorRecords(records, "2026-08-08")).toEqual([]); + }); + + it("prints every offending code and date with actionable guidance", () => { + const asOf = "9999-12-31"; + const offenders = findExpiredDeprecatedDoctorRecords( + listDoctorDeprecationCompatRecords(), + asOf, + ); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/check-doctor-deprecation-registry.ts", "--as-of", asOf], + { encoding: "utf8" }, + ); + + expect(offenders.length).toBeGreaterThan(0); + expect(result.status).toBe(1); + for (const offender of offenders) { + expect(result.stderr).toContain(`${offender.code}: removeAfter ${offender.removeAfter}`); + } + expect(result.stderr).toContain("Remove each migration after supported-upgrade proof"); + expect(result.stderr).toContain("move it to removal-pending with a documented blocker"); + }); +}); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 7cc1f5aaa7b1..b83fd5d95c74 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4377,6 +4377,17 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(preflightGuards).toContain( "Current CI targets must provide the check:script-declarations package script.", ); + expect(preflightGuards).toContain('has_package_script "check:doctor-deprecation-registry"'); + expect(preflightGuards).toContain("pnpm check:doctor-deprecation-registry"); + expect(preflightGuards).toContain( + "[skip] historical target skips the wall-clock doctor deprecation registry guard", + ); + expect(preflightGuards).toContain( + "Current CI targets must provide the check:doctor-deprecation-registry package script.", + ); + expect(preflightGuards.indexOf('if [[ "$HISTORICAL_TARGET" == "true" ]]')).toBeLessThan( + preflightGuards.indexOf("pnpm check:doctor-deprecation-registry"), + ); expect(npmLockGuards).toContain("pnpm deps:npm-lock:check"); expect(preflightGuards).toContain("pnpm deps:patches:check"); expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe(