fix(doctor): enforce deprecation registry deadlines (#120868)

This commit is contained in:
Peter Steinberger
2026-08-08 21:19:04 -07:00
committed by GitHub
parent 642b486986
commit 26ee1b4935
13 changed files with 275 additions and 37 deletions
@@ -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
@@ -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
+8
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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"]);
@@ -0,0 +1,80 @@
import { pathToFileURL } from "node:url";
import {
listDoctorDeprecationCompatRecords,
type DoctorDeprecationCompatRecord,
} from "../src/commands/doctor/shared/deprecation-compat.js";
type DeadlineRecord = Pick<DoctorDeprecationCompatRecord, "code" | "status" | "removeAfter">;
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();
}
+1
View File
@@ -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"],
@@ -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);
@@ -33,8 +33,6 @@ export type DoctorDeprecationCompatRecord<Code extends string = string> = {
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<Code extends string>(
@@ -43,22 +41,16 @@ function deprecatedCompatRecord<Code extends string>(
DoctorDeprecationCompatRecord<Code>,
"code" | "introduced" | "deprecated" | "warningStarts" | "removeAfter" | "status" | "tests"
> &
Required<Pick<DoctorDeprecationCompatRecord<Code>, "introduced" | "removeAfter">> &
Partial<
Pick<
DoctorDeprecationCompatRecord<Code>,
"introduced" | "deprecated" | "removeAfter" | "status" | "warningStarts" | "tests"
>
Pick<DoctorDeprecationCompatRecord<Code>, "deprecated" | "status" | "warningStarts" | "tests">
>,
): DoctorDeprecationCompatRecord<Code> {
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.<id>.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.<provider>.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.<id>.dm.policy and channels.<id>.dm.allowFrom",
migration: "src/config/channel-compat-normalization.ts",
replacement: "channels.<id>.dmPolicy and channels.<id>.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.<id>.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.<provider> 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.<provider>",
migration: "src/commands/doctor/shared/legacy-web-search-migrate.ts",
replacement: "plugins.entries.<plugin>.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.<provider>",
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",
+4
View File
@@ -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"],
@@ -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");
});
});
+11
View File
@@ -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(