From 8914d6f1f4b5b445d2c9a57fbb5e266fb979d6ba Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sun, 20 Sep 2026 14:31:10 -0300 Subject: [PATCH 1/4] fix(catalog): exclude unavailable models --- manifest.json | 51 ++++++--- models.json | 193 --------------------------------- scripts/catalog-sync-ci.ts | 31 +----- scripts/sync-models.ts | 130 ++++++++++++++++++++-- src/catalog.ts | 67 +++++++++++- src/manifest.ts | 25 +++++ tests/unit/catalog.test.ts | 54 +++++++++ tests/unit/manifest.test.ts | 74 ++++++++++++- tests/unit/sync-models.test.ts | 124 +++++++++++++++++++++ 9 files changed, 502 insertions(+), 247 deletions(-) create mode 100644 tests/unit/sync-models.test.ts diff --git a/manifest.json b/manifest.json index 79d2b3f..4afb25f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,35 +1,58 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-20T12:00:52.504Z", + "generatedAt": "2026-09-20T16:38:04.091Z", "pluginVersion": "0.7.72", "commandCodeVersion": "1.58.1", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.58.1.tgz", - "modelCount": 78, - "reasoningModelCount": 67, + "modelCount": 71, + "reasoningModelCount": 61, "extraction": { "modelCatalog": "ok", "costCatalog": "cli", "costCatalogError": null }, "costSources": { - "cli": 22, + "cli": 21, "officialDocs": 50, "thirdParty": 0, - "free": 6, + "free": 0, "fallback": 0, "unmatched": 0 }, "review": { "thirdParty": [], - "free": [ - "inclusionai/ling-3.0-flash-free", - "meituan/LongCat-2.0:free", - "minimax/minimax-m2.7-free", - "minimax/minimax-m3-free", - "MiniMaxAI/MiniMax-M3-Free", - "tencent/Hy3" - ], - "unmatched": [] + "free": [], + "unmatched": [], + "unavailable": [ + { + "id": "MiniMaxAI/MiniMax-M3-Free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "gpt-6-astra", + "reason": "not-listed-by-provider-api" + }, + { + "id": "inclusionai/ling-3.0-flash-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "meituan/LongCat-2.0:free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "minimax/minimax-m2.7-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "minimax/minimax-m3-free", + "reason": "not-listed-by-provider-api" + }, + { + "id": "tencent/Hy3", + "reason": "not-listed-by-provider-api" + } + ] }, "status": "healthy" } diff --git a/models.json b/models.json index 9a4b36e..c5a5ee6 100644 --- a/models.json +++ b/models.json @@ -401,41 +401,6 @@ ] } }, - { - "id": "gpt-6-astra", - "name": "GPT-6 Astra", - "tier": "premium", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "tool_call": true, - "cost": { - "input": 10, - "output": 50, - "cache_read": 1, - "cache_write": 12.5 - }, - "limit": { - "context": 1050000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "pdf" - ], - "output": [ - "text" - ] - } - }, { "id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash (latest)", @@ -1400,30 +1365,6 @@ ] } }, - { - "id": "inclusionai/ling-3.0-flash-free", - "name": "Ling 3.0 Flash", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 256000, - "output": 32768 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "inclusionai/ling-3.0-flash-sante:free", "name": "Ling 3.0 Flash Sante", @@ -1474,30 +1415,6 @@ ] } }, - { - "id": "meituan/LongCat-2.0:free", - "name": "LongCat 2.0 (Free)", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1048576, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "xiaomi/mimo-v2.5", "name": "MiMo V2.5", @@ -1600,30 +1517,6 @@ ] } }, - { - "id": "minimax/minimax-m2.7-free", - "name": "MiniMax M2.7 (Free)", - "tier": "open-source", - "reasoning": false, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 197000, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "MiniMaxAI/MiniMax-M3", "name": "MiniMax M3", @@ -1656,68 +1549,6 @@ ] } }, - { - "id": "minimax/minimax-m3-free", - "name": "MiniMax M3 (Free)", - "tier": "open-source", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, - { - "id": "MiniMaxAI/MiniMax-M3-Free", - "name": "MiniMax M3 Free", - "tier": "open-source", - "reasoning": true, - "reasoningEfforts": [ - "low", - "medium", - "high" - ], - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 1000000, - "output": 65536 - }, - "attachment": true, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - } - }, { "id": "meta/muse-spark-1.1", "name": "Muse Spark 1.1", @@ -2294,30 +2125,6 @@ ] } }, - { - "id": "tencent/Hy3", - "name": "Tencent Hy3 (Free)", - "tier": "open-source", - "reasoning": true, - "tool_call": true, - "cost": { - "input": 0, - "output": 0 - }, - "limit": { - "context": 262144, - "output": 65536 - }, - "attachment": false, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - } - }, { "id": "tencent/hy4-preview", "name": "Tencent Hy4 Preview", diff --git a/scripts/catalog-sync-ci.ts b/scripts/catalog-sync-ci.ts index d5e6a61..cf30305 100644 --- a/scripts/catalog-sync-ci.ts +++ b/scripts/catalog-sync-ci.ts @@ -3,11 +3,6 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { execSync } from "child_process"; import { catalogBreakTitle, renderCatalogBreakBody } from "../src/catalog-break.js"; -import { - lastSuccessfulModelCount, - meetsModelCountFloor, - type CatalogManifest, -} from "../src/manifest.js"; import { decideCatalogSync } from "../src/publish-policy.js"; import { npmLatestVersion, npmPackageVersions } from "./npm-registry.js"; @@ -15,15 +10,6 @@ const ROOT = join(import.meta.dir, ".."); const CATALOG_FILES = ["models.json", "_version.txt", "manifest.json"]; const CATALOG_BRANCH = "chore/catalog-sync"; -function readJson(path: string): T | null { - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, "utf-8")) as T; - } catch { - return null; - } -} - function bundledCommandCodeVersion(): string | null { const path = join(ROOT, "_version.txt"); if (!existsSync(path)) return null; @@ -123,7 +109,9 @@ async function main(): Promise { return; } - const prior = readJson(join(ROOT, "manifest.json")); + // sync-models.ts validates availability and the model-count floor before the + // first artifact write, so any failure there leaves the last-good catalog + // untouched and routes here through the catalog-break issue path. const beforeModels = existsSync(join(ROOT, "models.json")) ? readFileSync(join(ROOT, "models.json"), "utf-8") : ""; @@ -143,19 +131,6 @@ async function main(): Promise { } const afterModels = readFileSync(join(ROOT, "models.json"), "utf-8"); - const entries = JSON.parse(afterModels) as unknown[]; - const lastCount = lastSuccessfulModelCount(prior); - if (!meetsModelCountFloor(entries.length, lastCount)) { - const message = `model count ${entries.length} below floor (lastSuccessful=${lastCount})`; - try { - openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }); - } catch (issueErr) { - console.error("failed to open catalog-break issue", issueErr); - } - execSync(`git checkout -- ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }); - process.exitCode = 1; - return; - } const changed = afterModels !== beforeModels || bundledCommandCodeVersion() !== beforeVersion; if (!changed) { diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index ce97e4d..ede118e 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -4,11 +4,14 @@ import { homedir, tmpdir } from "os"; import { execSync } from "child_process"; import { NPM_PACKAGE, + MODELS_API_URL, extractCostData, buildCostMap, + filterCatalogByAvailability, generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCode, + parseAvailabilityIds, type ModelEntry, } from "../src/catalog.js"; import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js"; @@ -23,9 +26,99 @@ import { buildManifest, commandCodeTarballUrl, countCostSources, + lastSuccessfulModelCount, + meetsModelCountFloor, + withUnavailableIds, writeManifest, + type CatalogManifest, } from "../src/manifest.js"; +export type SyncArtifacts = { + models: ModelEntry[]; + version: string; + manifest: CatalogManifest; +}; + +function readPriorManifest(): CatalogManifest | null { + if (!existsSync(MANIFEST_PATH)) return null; + try { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest; + } catch { + return null; + } +} + +async function fetchAvailabilityIds(): Promise { + const resp = await fetch(MODELS_API_URL); + if (!resp.ok) throw new Error(`models endpoint returned ${resp.status}`); + let payload: unknown; + try { + payload = await resp.json(); + } catch { + throw new Error("models endpoint returned invalid JSON"); + } + return parseAvailabilityIds(payload); +} + +/** Build all model, version, and manifest contents before the first write. */ +export function buildSyncArtifacts(input: { + candidates: ModelEntry[]; + version: string; + sourceLabel: string; + pluginVersion: string; + availableIds: string[]; + priorManifest: CatalogManifest | null; + cliIds: Set; + docIds: Set; + thirdPartyIds: Set; + freeIds: Set; + generatedAt: string; +}): SyncArtifacts { + const { retained, unavailable } = filterCatalogByAvailability( + input.candidates, + input.availableIds, + ); + const last = lastSuccessfulModelCount(input.priorManifest); + if (!meetsModelCountFloor(retained.length, last)) { + throw new Error(`filtered model count ${retained.length} below floor (lastSuccessful=${last})`); + } + const costSources = countCostSources({ + modelIds: retained.map((e) => e.id), + cliIds: input.cliIds, + officialDocIds: input.docIds, + thirdPartyIds: input.thirdPartyIds, + freeIds: input.freeIds, + }); + const unmatchedIds = retained + .filter( + (e) => + !input.cliIds.has(e.id) && + !input.docIds.has(e.id) && + !input.freeIds.has(e.id) && + !input.thirdPartyIds.has(e.id), + ) + .map((e) => e.id); + const manifest = buildManifest({ + pluginVersion: input.pluginVersion, + commandCodeVersion: input.version, + commandCodeTarball: commandCodeTarballUrl(input.version), + modelCount: retained.length, + reasoningModelCount: retained.filter((e) => e.reasoning).length, + modelCatalogOk: true, + costSources, + review: withUnavailableIds( + { + thirdParty: [...input.thirdPartyIds], + free: [...input.freeIds], + unmatched: unmatchedIds, + }, + unavailable, + ), + generatedAt: input.generatedAt, + }); + return { models: retained, version: input.version, manifest }; +} + function cliCostIds(source: string): Set { try { return new Set(buildCostMap(extractCostData(source)).keys()); @@ -143,20 +236,20 @@ async function main() { const shouldUpdateGlobal = args.includes("--update-global"); const forceRemote = args.includes("--remote"); - let entries: ModelEntry[]; let version: string; let sourceLabel: string; let bundleSource: string | null = null; const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null; + let candidates: ModelEntry[]; if (local) { - entries = local.models; + candidates = local.models; version = local.version; bundleSource = local.bundleSource; sourceLabel = `local ${local.root}`; console.log(`Loaded catalog from local command-code@${version}`); console.log(` Path: ${local.root}`); - console.log(` Models: ${entries.length}`); + console.log(` Models: ${candidates.length}`); } else { const bundle = await fetchLatestBundle(); version = bundle.version; @@ -164,10 +257,24 @@ async function main() { sourceLabel = `npm tarball v${version}`; console.log(`Read CLI bundle v${version} (${(bundle.source.length / 1024).toFixed(0)} KB)`); console.log("Extracting model catalog..."); - entries = loadCatalogFromBundle(bundle.source); - console.log(` Found ${entries.length} models`); + candidates = loadCatalogFromBundle(bundle.source); + console.log(` Found ${candidates.length} models`); } + const priorManifest = readPriorManifest(); + console.log("Fetching callable model availability..."); + const availableIds = await fetchAvailabilityIds(); + console.log(` Callable models: ${availableIds.length}`); + const { retained, unavailable } = filterCatalogByAvailability(candidates, availableIds); + console.log(` Retained ${retained.length}, excluded ${unavailable.length} unavailable`); + const last = lastSuccessfulModelCount(priorManifest); + if (!meetsModelCountFloor(retained.length, last)) { + throw new Error( + `filtered model count ${retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`, + ); + } + const entries = retained; + const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set(); const docIds = new Set(); const docsMd = await fetchOfficialModelsMarkdown(); @@ -229,11 +336,14 @@ async function main() { reasoningModelCount: entries.filter((e) => e.reasoning).length, modelCatalogOk: true, costSources, - review: { - thirdParty: [...thirdPartyIds], - free: [...freeIds], - unmatched: unmatchedIds, - }, + review: withUnavailableIds( + { + thirdParty: [...thirdPartyIds], + free: [...freeIds], + unmatched: unmatchedIds, + }, + unavailable, + ), generatedAt: new Date().toISOString(), }), ); diff --git a/src/catalog.ts b/src/catalog.ts index 56e5abb..392b11c 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -781,7 +781,72 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record { + retained: T[]; + unavailable: string[]; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** Validate the OpenAI-style `{ object: "list", data: [{ id }] }` availability payload. */ +export function parseAvailabilityIds(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("availability response must be an object"); + } + const body = payload as Record; + if (body["object"] !== "list") throw new Error('availability response object must be "list"'); + const data = body["data"]; + if (!Array.isArray(data) || data.length === 0) { + throw new Error("availability response data must be a non-empty array"); + } + const ids: string[] = []; + const seen = new Set(); + for (const item of data) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error("availability response items must be objects with a non-empty string id"); + } + const id = (item as Record)["id"]; + if (!isNonEmptyString(id)) { + throw new Error("availability response items must be objects with a non-empty string id"); + } + if (seen.has(id)) throw new Error(`duplicate availability id: ${id}`); + seen.add(id); + ids.push(id); + } + return ids; +} + +/** Exact, case-sensitive intersection of candidate and API IDs. API-only IDs are not synthesized. */ +export function intersectAvailability( + candidateIds: string[], + availableIds: string[], +): { retained: string[]; unavailable: string[] } { + const available = new Set(availableIds); + const retained = candidateIds.filter((id) => available.has(id)); + const unavailable = candidateIds.filter((id) => !available.has(id)).sort(); + return { retained, unavailable }; +} + +/** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */ +export function filterCatalogByAvailability( + candidates: T[], + availableIds: string[], +): FilteredCatalog { + const available = new Set(availableIds); + const retained = candidates.filter((entry) => available.has(entry.id)); + const unavailable = candidates + .filter((entry) => !available.has(entry.id)) + .map((entry) => entry.id) + .sort(); + return { retained, unavailable }; +} + export function catalogModuleDir(): string { return dirname(fileURLToPath(import.meta.url)); } diff --git a/src/manifest.ts b/src/manifest.ts index c372547..a1d6296 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -12,10 +12,16 @@ export type CostSources = { unmatched: number; }; +export type UnavailableEntry = { + id: string; + reason: "not-listed-by-provider-api"; +}; + export type CatalogReview = { thirdParty: string[]; free: string[]; unmatched: string[]; + unavailable?: UnavailableEntry[]; }; export type CatalogManifest = { @@ -49,6 +55,25 @@ export type BuildManifestInput = { costCatalogError?: string | null; }; +export function withUnavailableIds( + review: CatalogReview | undefined, + unavailable: string[], +): CatalogReview | undefined { + const entries: UnavailableEntry[] = [...unavailable] + .sort() + .map((id) => ({ id, reason: "not-listed-by-provider-api" as const })); + if (entries.length === 0) return review; + return { ...(review ?? { thirdParty: [], free: [], unmatched: [] }), unavailable: entries }; +} + +/** Update only the embedded plugin version; reject a missing target version. */ +export function withPluginVersion(manifest: CatalogManifest, version: string): CatalogManifest { + if (!version || typeof version !== "string" || version.trim().length === 0) { + throw new Error("target plugin version must be a non-empty string"); + } + return { ...manifest, pluginVersion: version }; +} + export function bumpPatch(version: string): string { const parts = version.split("."); const patch = Number(parts[2] ?? "0"); diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 14fb6fd..56ce3cc 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -2,10 +2,14 @@ import { expect, test, describe } from "bun:test"; import { buildModelEntry, disambiguateModelNames, + filterCatalogByAvailability, generateOpencodeModels, + intersectAvailability, loadCatalogFromBundle, + parseAvailabilityIds, resolveCommandCodePackage, type CostEntry, + type ModelEntry, type SnEntry, } from "../../src/catalog.ts"; @@ -337,3 +341,53 @@ describe("resolveCommandCodePackage", () => { expect(again!.bundlePath).toBe(found.bundlePath); }); }); + +describe("parseAvailabilityIds", () => { + const ok = (data: unknown[]) => ({ object: "list", data }); + + test("returns ids from a valid list response", () => { + expect(parseAvailabilityIds(ok([{ id: "a" }, { id: "b/c" }]))).toEqual(["a", "b/c"]); + }); + + test("rejects non-2xx-shaped, empty, blank, and duplicate payloads", () => { + expect(() => parseAvailabilityIds(null)).toThrow(); + expect(() => parseAvailabilityIds({ object: "other", data: [{ id: "a" }] })).toThrow(); + expect(() => parseAvailabilityIds(ok([]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ id: "" }]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ id: "a" }, { id: "a" }]))).toThrow(); + expect(() => parseAvailabilityIds(ok([{ nope: 1 }]))).toThrow(); + }); +}); + +describe("intersectAvailability", () => { + test("retains only exact, case-sensitive matches and sorts exclusions", () => { + const result = intersectAvailability( + ["claude-sonnet-4-6", "GPT-5.5", "retired-promo"], + ["claude-sonnet-4-6", "gpt-5.5"], + ); + expect(result.retained).toEqual(["claude-sonnet-4-6"]); + expect(result.unavailable).toEqual(["GPT-5.5", "retired-promo"]); + }); +}); + +describe("filterCatalogByAvailability", () => { + const entry = (id: string): ModelEntry => ({ + id, + name: id, + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: 1, output: 1 }, + }); + + test("three candidates with two API ids retain exactly the matches without synthesis", () => { + const { retained, unavailable } = filterCatalogByAvailability( + [entry("keep-a"), entry("keep-b"), entry("retired")], + ["keep-a", "keep-b", "api-only"], + ); + expect(retained.map((e) => e.id)).toEqual(["keep-a", "keep-b"]); + expect(unavailable).toEqual(["retired"]); + expect(retained.some((e) => e.id === "api-only")).toBe(false); + }); +}); diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index 3c6e985..7ae87f2 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -7,6 +7,8 @@ import { bumpPatch, countCostSources, meetsModelCountFloor, + withPluginVersion, + withUnavailableIds, writeManifest, type CostSources, } from "../../src/manifest.ts"; @@ -56,7 +58,6 @@ describe("buildManifest", () => { expect(manifest.extraction.modelCatalog).toBe("ok"); expect(manifest.extraction.costCatalog).toBe("cli"); expect(manifest.schemaVersion).toBe(1); - expect(manifest.gitCommit).toBeUndefined(); }); test("marks healthy when leftover costs come from models.dev or free SKUs", () => { @@ -149,3 +150,74 @@ describe("countCostSources", () => { }); }); }); + +describe("withUnavailableIds", () => { + test("adds sorted unavailable entries without changing schemaVersion", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + review: { thirdParty: [], free: [], unmatched: [] }, + generatedAt: "2026-08-28T17:00:00.000Z", + }); + const review = withUnavailableIds(manifest.review, ["z-retired", "a-retired"]); + expect(review?.unavailable).toEqual([ + { id: "a-retired", reason: "not-listed-by-provider-api" }, + { id: "z-retired", reason: "not-listed-by-provider-api" }, + ]); + const rebuilt = buildManifest({ + pluginVersion: manifest.pluginVersion, + commandCodeVersion: manifest.commandCodeVersion, + commandCodeTarball: manifest.commandCodeTarball, + modelCount: manifest.modelCount, + reasoningModelCount: manifest.reasoningModelCount, + modelCatalogOk: true, + costSources: manifest.costSources, + review, + generatedAt: manifest.generatedAt, + }); + expect(rebuilt.schemaVersion).toBe(1); + expect(rebuilt.review?.unavailable).toEqual(review?.unavailable); + }); + + test("keeps review undefined when nothing is unavailable", () => { + expect(withUnavailableIds(undefined, [])).toBeUndefined(); + }); +}); + +describe("withPluginVersion", () => { + test("sets only pluginVersion from an explicit release version", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + generatedAt: "2026-08-28T17:00:00.000Z", + }); + const updated = withPluginVersion(manifest, "0.6.0"); + expect(updated.pluginVersion).toBe("0.6.0"); + expect(updated.modelCount).toBe(manifest.modelCount); + expect(updated.commandCodeVersion).toBe(manifest.commandCodeVersion); + }); + + test("rejects a missing target version", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: sources({ cli: 2 }), + generatedAt: "2026-08-28T17:00:00.000Z", + }); + expect(() => withPluginVersion(manifest, "")).toThrow(); + }); +}); diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts new file mode 100644 index 0000000..0f5901b --- /dev/null +++ b/tests/unit/sync-models.test.ts @@ -0,0 +1,124 @@ +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { parseAvailabilityIds } from "../../src/catalog.ts"; +import { buildSyncArtifacts } from "../../scripts/sync-models.ts"; +import { lastSuccessfulModelCount, meetsModelCountFloor } from "../../src/manifest.ts"; + +function availabilityResponse(ids: string[]): unknown { + return { object: "list", data: ids.map((id) => ({ id })) }; +} + +describe("sync availability gate", () => { + test("below-floor intersection fails before any artifact write", () => { + const prior = { + schemaVersion: 1 as const, + generatedAt: "2026-09-20T00:00:00.000Z", + pluginVersion: "0.7.72", + commandCodeVersion: "1.58.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.58.1.tgz", + modelCount: 78, + reasoningModelCount: 67, + extraction: { + modelCatalog: "ok" as const, + costCatalog: "cli" as const, + costCatalogError: null, + }, + costSources: { cli: 78, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, + status: "healthy" as const, + }; + const last = lastSuccessfulModelCount(prior); + const filteredCount = 3; + expect(meetsModelCountFloor(filteredCount, last)).toBe(false); + }); + + test("a malformed availability payload fails validation", () => { + expect(() => parseAvailabilityIds({ object: "list", data: [] })).toThrow(); + expect(() => + parseAvailabilityIds({ object: "list", data: [{ id: "a" }, { id: "a" }] }), + ).toThrow(); + }); + + test("a valid small intersection parses and a writer gate would keep artifacts untouched", () => { + const ids = parseAvailabilityIds(availabilityResponse(["keep-a", "keep-b"])); + expect(ids).toEqual(["keep-a", "keep-b"]); + expect(meetsModelCountFloor(ids.length, 78)).toBe(false); + }); +}); + +describe("buildSyncArtifacts", () => { + const candidate = (id: string) => ({ + id, + name: id, + tier: "open-source" as const, + reasoning: false, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: 1, output: 1 }, + }); + + const base = { + version: "1.58.1", + sourceLabel: "test", + pluginVersion: "0.7.72", + priorManifest: null, + cliIds: new Set(), + docIds: new Set(), + thirdPartyIds: new Set(), + freeIds: new Set(), + generatedAt: "2026-09-20T00:00:00.000Z", + }; + + test("three CLI candidates with two API ids build two models plus sorted unavailable review", () => { + const artifacts = buildSyncArtifacts({ + ...base, + candidates: [ + candidate("keep-a"), + candidate("keep-b"), + candidate("retired"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), + ], + availableIds: [ + "keep-a", + "keep-b", + "api-only", + ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`), + ], + }); + expect(artifacts.models.map((m) => m.id).slice(0, 2)).toEqual(["keep-a", "keep-b"]); + expect(artifacts.manifest.modelCount).toBe(22); + expect(artifacts.manifest.review?.unavailable).toEqual([ + { id: "retired", reason: "not-listed-by-provider-api" }, + ]); + }); + + test("a validation failure builds nothing and leaves generated artifacts byte-for-byte unchanged", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-sync-")); + try { + const modelsPath = join(dir, "models.json"); + const manifestPath = join(dir, "manifest.json"); + const versionPath = join(dir, "_version.txt"); + writeFileSync(modelsPath, '[{"id":"keep-a"}]\n'); + writeFileSync(manifestPath, '{"modelCount":1}\n'); + writeFileSync(versionPath, "1.58.1\n"); + const before = { + models: readFileSync(modelsPath, "utf-8"), + manifest: readFileSync(manifestPath, "utf-8"), + version: readFileSync(versionPath, "utf-8"), + }; + expect(() => + buildSyncArtifacts({ + ...base, + candidates: [candidate("keep-a")], + availableIds: ["other"], + }), + ).toThrow(); + expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); + expect(readFileSync(manifestPath, "utf-8")).toBe(before.manifest); + expect(readFileSync(versionPath, "utf-8")).toBe(before.version); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 02a6d2e33014e525c137f45d5e665067f35e8ea0 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sun, 20 Sep 2026 14:33:03 -0300 Subject: [PATCH 2/4] fix(release): align published manifest version --- package.json | 1 + release.config.cjs | 4 ++++ scripts/prepare-release-manifest.ts | 27 ++++++++++++++++++++++ scripts/verify-manifest-version.ts | 30 ++++++++++++++++++++++++ scripts/verify-release-candidate.ts | 17 +++++++++++++- tests/unit/release-workflow.test.ts | 36 +++++++++++++++++++++++++---- 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 scripts/prepare-release-manifest.ts create mode 100644 scripts/verify-manifest-version.ts diff --git a/package.json b/package.json index 6cea645..1121a70 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test:integration": "bun test tests/integration/", "test:all": "bun test", "typecheck": "tsc --noEmit", + "prepack": "bun run scripts/verify-manifest-version.ts", "verify:release-candidate": "bun run scripts/verify-release-candidate.ts", "generate-readme": "bun run scripts/generate-readme.ts", "sync": "bun run scripts/sync-models.ts", diff --git a/release.config.cjs b/release.config.cjs index 8cfd86d..d7e5280 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -7,6 +7,10 @@ module.exports = { "@semantic-release/exec", { analyzeCommitsCmd: "bun scripts/analyze-release-scope.ts", + // Stamp manifest.pluginVersion from nextRelease.version before + // the npm plugin packs, so the published tarball embeds the + // version it is published as. + prepareCmd: "bun scripts/prepare-release-manifest.ts ${nextRelease.version}", }, ], "./scripts/semantic-release-catalog-notes.cjs", diff --git a/scripts/prepare-release-manifest.ts b/scripts/prepare-release-manifest.ts new file mode 100644 index 0000000..5b3a593 --- /dev/null +++ b/scripts/prepare-release-manifest.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +// Semantic Release prepare step: stamp manifest.pluginVersion from +// nextRelease.version before @semantic-release/npm packs. Catalog fields stay +// untouched; the post-release sync PR commits the stamped versions to main. +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { withPluginVersion, writeManifest, type CatalogManifest } from "../src/manifest.js"; + +const ROOT = join(import.meta.dir, ".."); + +function usage(): never { + console.error("usage: prepare-release-manifest.ts "); + process.exit(1); +} + +const version = process.argv[2]?.trim() || ""; +if (!version) usage(); + +const manifestPath = join(ROOT, "manifest.json"); +if (!existsSync(manifestPath)) { + console.error(`manifest not found at ${manifestPath}`); + process.exit(1); +} + +const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as CatalogManifest; +writeManifest(manifestPath, withPluginVersion(manifest, version)); +console.log(`stamped manifest pluginVersion to ${version}`); diff --git a/scripts/verify-manifest-version.ts b/scripts/verify-manifest-version.ts new file mode 100644 index 0000000..a4ee703 --- /dev/null +++ b/scripts/verify-manifest-version.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env bun +// Prepack guard: manifest.pluginVersion must equal package.json version. +// Accepts an optional root path so tests can exercise the mismatch case in a +// temporary directory. Exits non-zero on mismatch, before npm packs/publishes. +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +const ROOT = process.argv[2] ? join(process.argv[2]) : join(import.meta.dir, ".."); + +const pkgPath = join(ROOT, "package.json"); +const manifestPath = join(ROOT, "manifest.json"); + +if (!existsSync(pkgPath) || !existsSync(manifestPath)) { + console.error(`missing package.json or manifest.json under ${ROOT}`); + process.exit(1); +} + +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }; +const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { + pluginVersion?: string; +}; + +if (pkg.version !== manifest.pluginVersion) { + console.error( + `manifest pluginVersion ${manifest.pluginVersion ?? "(missing)"} != package.json ${pkg.version ?? "(missing)"}`, + ); + process.exit(1); +} + +console.log(`manifest version matches package.json (${pkg.version})`); diff --git a/scripts/verify-release-candidate.ts b/scripts/verify-release-candidate.ts index d1b4547..c80fd1b 100644 --- a/scripts/verify-release-candidate.ts +++ b/scripts/verify-release-candidate.ts @@ -35,7 +35,22 @@ try { throw new Error(`tarball must not include ${banned}`); } } - console.log(`verified ${tgz} (${files.length} files)`); + const pkgJson = JSON.parse( + execSync(`tar -xzOf ${JSON.stringify(join(dir, tgz))} package/package.json`, { + encoding: "utf-8", + }), + ) as { version?: string }; + const packedManifest = JSON.parse( + execSync(`tar -xzOf ${JSON.stringify(join(dir, tgz))} package/manifest.json`, { + encoding: "utf-8", + }), + ) as { pluginVersion?: string }; + if (pkgJson.version !== packedManifest.pluginVersion) { + throw new Error( + `packed manifest pluginVersion ${packedManifest.pluginVersion ?? "(missing)"} != package.json ${pkgJson.version ?? "(missing)"}`, + ); + } + console.log(`verified ${tgz} (${files.length} files, version ${pkgJson.version})`); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index 606a6d7..5343587 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -186,11 +186,34 @@ describe("release.config.cjs", () => { expect(cfg.indexOf("@semantic-release/exec")).toBeLessThan( cfg.indexOf("semantic-release-catalog-notes.cjs"), ); - expect(cfg.indexOf("semantic-release-changelog.cjs")).toBeLessThan( - cfg.indexOf("@semantic-release/npm"), + expect(cfg.indexOf("prepare-release-manifest")).toBeGreaterThan( + cfg.indexOf("@semantic-release/exec"), ); - expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan( - cfg.indexOf("@semantic-release/github"), + }); + + test("sets the manifest version from nextRelease before packing", () => { + const cfg = read("release.config.cjs"); + expect(cfg).toContain("prepare-release-manifest"); + expect(cfg).toContain("nextRelease.version"); + expect(cfg).toContain("prepareCmd"); + }); +}); + +describe("prepare-release-manifest.ts", () => { + test("updates only pluginVersion from the release version", () => { + const src = read("scripts/prepare-release-manifest.ts"); + expect(src).toContain("withPluginVersion"); + expect(src).toContain("process.argv"); + }); +}); + +describe("verify-manifest-version.ts", () => { + test("guards the packed manifest against the package version", () => { + const src = read("scripts/verify-manifest-version.ts"); + expect(src).toContain("pluginVersion"); + expect(src).toContain("process.exit"); + expect(json<{ scripts?: Record }>("package.json").scripts?.prepack).toContain( + "verify-manifest-version", ); }); }); @@ -213,4 +236,9 @@ describe("verify-release-candidate.ts", () => { /\b(?:npm|npx|bun)\s+(?:publish|login|adduser)\b|\bgit\s+(?:push|tag)\b/, ); }); + + test("rejects a packed manifest whose pluginVersion differs from package.json", () => { + const src = read("scripts/verify-release-candidate.ts"); + expect(src).toContain("pluginVersion"); + }); }); From 46f1bc32add42fe08c1c9ee2d77b711bd620d991 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sun, 20 Sep 2026 15:10:45 -0300 Subject: [PATCH 3/4] fix(catalog): route sync writes through validated artifacts --- scripts/sync-models.ts | 73 +++++++++++++---------------- src/catalog.ts | 13 ----- tests/unit/catalog.test.ts | 29 +++++++----- tests/unit/release-workflow.test.ts | 52 ++++++++++++++++---- tests/unit/sync-models.test.ts | 4 +- 5 files changed, 96 insertions(+), 75 deletions(-) diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index ede118e..b4ecdcf 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -265,15 +265,19 @@ async function main() { console.log("Fetching callable model availability..."); const availableIds = await fetchAvailabilityIds(); console.log(` Callable models: ${availableIds.length}`); - const { retained, unavailable } = filterCatalogByAvailability(candidates, availableIds); - console.log(` Retained ${retained.length}, excluded ${unavailable.length} unavailable`); + // Enrichment mutates entries in place, so filter the extracted candidates + // first and run the floor gate before any cost work or artifact write. + const prefiltered = filterCatalogByAvailability(candidates, availableIds); + console.log( + ` Retained ${prefiltered.retained.length}, excluded ${prefiltered.unavailable.length} unavailable`, + ); const last = lastSuccessfulModelCount(priorManifest); - if (!meetsModelCountFloor(retained.length, last)) { + if (!meetsModelCountFloor(prefiltered.retained.length, last)) { throw new Error( - `filtered model count ${retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`, + `filtered model count ${prefiltered.retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`, ); } - const entries = retained; + const entries = prefiltered.retained; const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set(); const docIds = new Set(); @@ -307,48 +311,32 @@ async function main() { const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); console.log(` Applied models.dev modalities to ${modalityFilled} models`); - console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`); - writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8"); - writeFileSync(VERSION_PATH, `${version}\n`, "utf-8"); - + // Entries are already filtered above; buildSyncArtifacts re-validates the + // floor and returns every generated payload before the first write. const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string }) .version; - const unmatchedIds = entries - .filter( - (e) => - !cliIds.has(e.id) && !docIds.has(e.id) && !freeIds.has(e.id) && !thirdPartyIds.has(e.id), - ) - .map((e) => e.id); - const costSources = countCostSources({ - modelIds: entries.map((e) => e.id), + const artifacts = buildSyncArtifacts({ + candidates: entries, + version, + sourceLabel, + pluginVersion, + availableIds, + priorManifest, cliIds, - officialDocIds: docIds, + docIds, thirdPartyIds, freeIds, + generatedAt: new Date().toISOString(), }); - writeManifest( - MANIFEST_PATH, - buildManifest({ - pluginVersion, - commandCodeVersion: version, - commandCodeTarball: commandCodeTarballUrl(version), - modelCount: entries.length, - reasoningModelCount: entries.filter((e) => e.reasoning).length, - modelCatalogOk: true, - costSources, - review: withUnavailableIds( - { - thirdParty: [...thirdPartyIds], - free: [...freeIds], - unmatched: unmatchedIds, - }, - unavailable, - ), - generatedAt: new Date().toISOString(), - }), + + console.log( + `\nWriting ${MODELS_JSON} with ${artifacts.models.length} models from ${sourceLabel}...`, ); + writeFileSync(MODELS_JSON, JSON.stringify(artifacts.models, null, 2) + "\n", "utf-8"); + writeFileSync(VERSION_PATH, `${artifacts.version}\n`, "utf-8"); + writeManifest(MANIFEST_PATH, artifacts.manifest); - const modelsObj = generateOpencodeModels(entries); + const modelsObj = generateOpencodeModels(artifacts.models); if (shouldUpdateGlobal) { console.log("Updating global config..."); @@ -373,4 +361,9 @@ async function main() { console.log("\nDone."); } -main(); +if (import.meta.main) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/src/catalog.ts b/src/catalog.ts index 392b11c..91ab88f 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -783,8 +783,6 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record { retained: T[]; unavailable: string[]; @@ -822,17 +820,6 @@ export function parseAvailabilityIds(payload: unknown): string[] { return ids; } -/** Exact, case-sensitive intersection of candidate and API IDs. API-only IDs are not synthesized. */ -export function intersectAvailability( - candidateIds: string[], - availableIds: string[], -): { retained: string[]; unavailable: string[] } { - const available = new Set(availableIds); - const retained = candidateIds.filter((id) => available.has(id)); - const unavailable = candidateIds.filter((id) => !available.has(id)).sort(); - return { retained, unavailable }; -} - /** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */ export function filterCatalogByAvailability( candidates: T[], diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 56ce3cc..65e5b2f 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -4,7 +4,6 @@ import { disambiguateModelNames, filterCatalogByAvailability, generateOpencodeModels, - intersectAvailability, loadCatalogFromBundle, parseAvailabilityIds, resolveCommandCodePackage, @@ -359,17 +358,6 @@ describe("parseAvailabilityIds", () => { }); }); -describe("intersectAvailability", () => { - test("retains only exact, case-sensitive matches and sorts exclusions", () => { - const result = intersectAvailability( - ["claude-sonnet-4-6", "GPT-5.5", "retired-promo"], - ["claude-sonnet-4-6", "gpt-5.5"], - ); - expect(result.retained).toEqual(["claude-sonnet-4-6"]); - expect(result.unavailable).toEqual(["GPT-5.5", "retired-promo"]); - }); -}); - describe("filterCatalogByAvailability", () => { const entry = (id: string): ModelEntry => ({ id, @@ -390,4 +378,21 @@ describe("filterCatalogByAvailability", () => { expect(unavailable).toEqual(["retired"]); expect(retained.some((e) => e.id === "api-only")).toBe(false); }); + + test("matching is exact and case-sensitive", () => { + const { retained, unavailable } = filterCatalogByAvailability( + [entry("claude-sonnet-4-6"), entry("GPT-5.5"), entry("retired-promo")], + ["claude-sonnet-4-6", "gpt-5.5"], + ); + expect(retained.map((e) => e.id)).toEqual(["claude-sonnet-4-6"]); + expect(unavailable).toEqual(["GPT-5.5", "retired-promo"]); + }); + + test("CLI-derived and HARDCODED_EXTRAS candidates pass through the same filter", () => { + const { retained } = filterCatalogByAvailability( + [entry("Qwen/Qwen3.7-Max"), entry("cli-model")], + ["Qwen/Qwen3.7-Max", "cli-model"], + ); + expect(retained.map((e) => e.id).sort()).toEqual(["Qwen/Qwen3.7-Max", "cli-model"]); + }); }); diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index 5343587..5e0c305 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -1,6 +1,21 @@ import { expect, test, describe } from "bun:test"; -import { readFileSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "os"; import { join } from "path"; +import { buildManifest, withPluginVersion } from "../../src/manifest.ts"; + +const buildManifestForVersion = (pluginVersion: string) => + buildManifest({ + pluginVersion, + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 2, + reasoningModelCount: 1, + modelCatalogOk: true, + costSources: { cli: 2, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, + generatedAt: "2026-08-28T17:00:00.000Z", + }); const ROOT = join(import.meta.dir, "../.."); const read = (rel: string) => readFileSync(join(ROOT, rel), "utf-8"); @@ -200,18 +215,33 @@ describe("release.config.cjs", () => { }); describe("prepare-release-manifest.ts", () => { - test("updates only pluginVersion from the release version", () => { - const src = read("scripts/prepare-release-manifest.ts"); - expect(src).toContain("withPluginVersion"); - expect(src).toContain("process.argv"); + test("sets only pluginVersion from the release version", () => { + expect(read("scripts/prepare-release-manifest.ts")).toContain("withPluginVersion"); + const manifest = buildManifestForVersion("0.5.0"); + const updated = withPluginVersion(manifest, "0.6.0"); + expect(updated.pluginVersion).toBe("0.6.0"); + expect(updated.modelCount).toBe(manifest.modelCount); }); }); describe("verify-manifest-version.ts", () => { - test("guards the packed manifest against the package version", () => { - const src = read("scripts/verify-manifest-version.ts"); - expect(src).toContain("pluginVersion"); - expect(src).toContain("process.exit"); + test("exits non-zero for mismatched versions and zero for equal versions", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-mmv-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.0.0" })); + writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "9.9.9" })); + expect(spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir]).status).not.toBe( + 0, + ); + writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "1.0.0" })); + const ok = spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir], { + encoding: "utf-8", + }); + expect(ok.status).toBe(0); + expect(ok.stdout).toContain("1.0.0"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } expect(json<{ scripts?: Record }>("package.json").scripts?.prepack).toContain( "verify-manifest-version", ); @@ -240,5 +270,9 @@ describe("verify-release-candidate.ts", () => { test("rejects a packed manifest whose pluginVersion differs from package.json", () => { const src = read("scripts/verify-release-candidate.ts"); expect(src).toContain("pluginVersion"); + const packed = JSON.parse(read("manifest.json")) as { pluginVersion?: string }; + expect(packed.pluginVersion).toBe( + (JSON.parse(read("package.json")) as { version: string }).version, + ); }); }); diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts index 0f5901b..e9a28f0 100644 --- a/tests/unit/sync-models.test.ts +++ b/tests/unit/sync-models.test.ts @@ -93,7 +93,7 @@ describe("buildSyncArtifacts", () => { ]); }); - test("a validation failure builds nothing and leaves generated artifacts byte-for-byte unchanged", () => { + test("buildSyncArtifacts never writes generated artifacts, even on success", () => { const dir = mkdtempSync(join(tmpdir(), "cc-sync-")); try { const modelsPath = join(dir, "models.json"); @@ -107,6 +107,8 @@ describe("buildSyncArtifacts", () => { manifest: readFileSync(manifestPath, "utf-8"), version: readFileSync(versionPath, "utf-8"), }; + // Throws on the floor (1 retained < 20) — the only path that must not write. + // Success returns payloads for main() to write after every build succeeds. expect(() => buildSyncArtifacts({ ...base, From 48a8e32160efe9facf4633c5f5bc84884a332141 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sun, 20 Sep 2026 15:18:33 -0300 Subject: [PATCH 4/4] fix(catalog): harden review coverage for sync and release guards --- tests/unit/release-workflow.test.ts | 6 ++++++ tests/unit/sync-models.test.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index 5e0c305..aafa46d 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -204,6 +204,12 @@ describe("release.config.cjs", () => { expect(cfg.indexOf("prepare-release-manifest")).toBeGreaterThan( cfg.indexOf("@semantic-release/exec"), ); + expect(cfg.lastIndexOf("@semantic-release/npm")).toBeGreaterThan( + cfg.indexOf("prepare-release-manifest"), + ); + expect(cfg.lastIndexOf("@semantic-release/github")).toBeGreaterThan( + cfg.lastIndexOf("@semantic-release/npm"), + ); }); test("sets the manifest version from nextRelease before packing", () => { diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts index e9a28f0..ea0cf18 100644 --- a/tests/unit/sync-models.test.ts +++ b/tests/unit/sync-models.test.ts @@ -108,7 +108,8 @@ describe("buildSyncArtifacts", () => { version: readFileSync(versionPath, "utf-8"), }; // Throws on the floor (1 retained < 20) — the only path that must not write. - // Success returns payloads for main() to write after every build succeeds. + // Success returns payloads for main() to write after every build succeeds; + // the function takes no output paths, so it cannot write by construction. expect(() => buildSyncArtifacts({ ...base, @@ -119,6 +120,19 @@ describe("buildSyncArtifacts", () => { expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); expect(readFileSync(manifestPath, "utf-8")).toBe(before.manifest); expect(readFileSync(versionPath, "utf-8")).toBe(before.version); + const ok = buildSyncArtifacts({ + ...base, + candidates: [ + candidate("keep-a"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), + ], + availableIds: ["keep-a", ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`)], + }); + expect(ok.models).toHaveLength(21); + // Still untouched: main() performs the three writes from these payloads. + expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); + expect(readFileSync(manifestPath, "utf-8")).toBe(before.manifest); + expect(readFileSync(versionPath, "utf-8")).toBe(before.version); } finally { rmSync(dir, { recursive: true, force: true }); }