Skip to content

Commit 46f1bc3

Browse files
committed
fix(catalog): route sync writes through validated artifacts
1 parent 02a6d2e commit 46f1bc3

5 files changed

Lines changed: 96 additions & 75 deletions

File tree

‎scripts/sync-models.ts‎

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -265,15 +265,19 @@ async function main() {
265265
console.log("Fetching callable model availability...");
266266
const availableIds = await fetchAvailabilityIds();
267267
console.log(` Callable models: ${availableIds.length}`);
268-
const { retained, unavailable } = filterCatalogByAvailability(candidates, availableIds);
269-
console.log(` Retained ${retained.length}, excluded ${unavailable.length} unavailable`);
268+
// Enrichment mutates entries in place, so filter the extracted candidates
269+
// first and run the floor gate before any cost work or artifact write.
270+
const prefiltered = filterCatalogByAvailability(candidates, availableIds);
271+
console.log(
272+
` Retained ${prefiltered.retained.length}, excluded ${prefiltered.unavailable.length} unavailable`,
273+
);
270274
const last = lastSuccessfulModelCount(priorManifest);
271-
if (!meetsModelCountFloor(retained.length, last)) {
275+
if (!meetsModelCountFloor(prefiltered.retained.length, last)) {
272276
throw new Error(
273-
`filtered model count ${retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`,
277+
`filtered model count ${prefiltered.retained.length} below floor (lastSuccessful=${last}); leaving generated artifacts unchanged`,
274278
);
275279
}
276-
const entries = retained;
280+
const entries = prefiltered.retained;
277281

278282
const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set<string>();
279283
const docIds = new Set<string>();
@@ -307,48 +311,32 @@ async function main() {
307311
const modalityFilled = applyModelsDevModalities(entries, modelsDevRows);
308312
console.log(` Applied models.dev modalities to ${modalityFilled} models`);
309313

310-
console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`);
311-
writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8");
312-
writeFileSync(VERSION_PATH, `${version}\n`, "utf-8");
313-
314+
// Entries are already filtered above; buildSyncArtifacts re-validates the
315+
// floor and returns every generated payload before the first write.
314316
const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string })
315317
.version;
316-
const unmatchedIds = entries
317-
.filter(
318-
(e) =>
319-
!cliIds.has(e.id) && !docIds.has(e.id) && !freeIds.has(e.id) && !thirdPartyIds.has(e.id),
320-
)
321-
.map((e) => e.id);
322-
const costSources = countCostSources({
323-
modelIds: entries.map((e) => e.id),
318+
const artifacts = buildSyncArtifacts({
319+
candidates: entries,
320+
version,
321+
sourceLabel,
322+
pluginVersion,
323+
availableIds,
324+
priorManifest,
324325
cliIds,
325-
officialDocIds: docIds,
326+
docIds,
326327
thirdPartyIds,
327328
freeIds,
329+
generatedAt: new Date().toISOString(),
328330
});
329-
writeManifest(
330-
MANIFEST_PATH,
331-
buildManifest({
332-
pluginVersion,
333-
commandCodeVersion: version,
334-
commandCodeTarball: commandCodeTarballUrl(version),
335-
modelCount: entries.length,
336-
reasoningModelCount: entries.filter((e) => e.reasoning).length,
337-
modelCatalogOk: true,
338-
costSources,
339-
review: withUnavailableIds(
340-
{
341-
thirdParty: [...thirdPartyIds],
342-
free: [...freeIds],
343-
unmatched: unmatchedIds,
344-
},
345-
unavailable,
346-
),
347-
generatedAt: new Date().toISOString(),
348-
}),
331+
332+
console.log(
333+
`\nWriting ${MODELS_JSON} with ${artifacts.models.length} models from ${sourceLabel}...`,
349334
);
335+
writeFileSync(MODELS_JSON, JSON.stringify(artifacts.models, null, 2) + "\n", "utf-8");
336+
writeFileSync(VERSION_PATH, `${artifacts.version}\n`, "utf-8");
337+
writeManifest(MANIFEST_PATH, artifacts.manifest);
350338

351-
const modelsObj = generateOpencodeModels(entries);
339+
const modelsObj = generateOpencodeModels(artifacts.models);
352340

353341
if (shouldUpdateGlobal) {
354342
console.log("Updating global config...");
@@ -373,4 +361,9 @@ async function main() {
373361
console.log("\nDone.");
374362
}
375363

376-
main();
364+
if (import.meta.main) {
365+
main().catch((err) => {
366+
console.error(err instanceof Error ? err.message : err);
367+
process.exit(1);
368+
});
369+
}

‎src/catalog.ts‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -783,8 +783,6 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record<string, un
783783

784784
export const MODELS_API_URL = "https://api.commandcode.ai/provider/v1/models";
785785

786-
export const UNAVAILABLE_REASON = "not-listed-by-provider-api";
787-
788786
export interface FilteredCatalog<T extends { id: string }> {
789787
retained: T[];
790788
unavailable: string[];
@@ -822,17 +820,6 @@ export function parseAvailabilityIds(payload: unknown): string[] {
822820
return ids;
823821
}
824822

825-
/** Exact, case-sensitive intersection of candidate and API IDs. API-only IDs are not synthesized. */
826-
export function intersectAvailability(
827-
candidateIds: string[],
828-
availableIds: string[],
829-
): { retained: string[]; unavailable: string[] } {
830-
const available = new Set(availableIds);
831-
const retained = candidateIds.filter((id) => available.has(id));
832-
const unavailable = candidateIds.filter((id) => !available.has(id)).sort();
833-
return { retained, unavailable };
834-
}
835-
836823
/** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */
837824
export function filterCatalogByAvailability<T extends { id: string }>(
838825
candidates: T[],

‎tests/unit/catalog.test.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
disambiguateModelNames,
55
filterCatalogByAvailability,
66
generateOpencodeModels,
7-
intersectAvailability,
87
loadCatalogFromBundle,
98
parseAvailabilityIds,
109
resolveCommandCodePackage,
@@ -359,17 +358,6 @@ describe("parseAvailabilityIds", () => {
359358
});
360359
});
361360

362-
describe("intersectAvailability", () => {
363-
test("retains only exact, case-sensitive matches and sorts exclusions", () => {
364-
const result = intersectAvailability(
365-
["claude-sonnet-4-6", "GPT-5.5", "retired-promo"],
366-
["claude-sonnet-4-6", "gpt-5.5"],
367-
);
368-
expect(result.retained).toEqual(["claude-sonnet-4-6"]);
369-
expect(result.unavailable).toEqual(["GPT-5.5", "retired-promo"]);
370-
});
371-
});
372-
373361
describe("filterCatalogByAvailability", () => {
374362
const entry = (id: string): ModelEntry => ({
375363
id,
@@ -390,4 +378,21 @@ describe("filterCatalogByAvailability", () => {
390378
expect(unavailable).toEqual(["retired"]);
391379
expect(retained.some((e) => e.id === "api-only")).toBe(false);
392380
});
381+
382+
test("matching is exact and case-sensitive", () => {
383+
const { retained, unavailable } = filterCatalogByAvailability(
384+
[entry("claude-sonnet-4-6"), entry("GPT-5.5"), entry("retired-promo")],
385+
["claude-sonnet-4-6", "gpt-5.5"],
386+
);
387+
expect(retained.map((e) => e.id)).toEqual(["claude-sonnet-4-6"]);
388+
expect(unavailable).toEqual(["GPT-5.5", "retired-promo"]);
389+
});
390+
391+
test("CLI-derived and HARDCODED_EXTRAS candidates pass through the same filter", () => {
392+
const { retained } = filterCatalogByAvailability(
393+
[entry("Qwen/Qwen3.7-Max"), entry("cli-model")],
394+
["Qwen/Qwen3.7-Max", "cli-model"],
395+
);
396+
expect(retained.map((e) => e.id).sort()).toEqual(["Qwen/Qwen3.7-Max", "cli-model"]);
397+
});
393398
});

‎tests/unit/release-workflow.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { expect, test, describe } from "bun:test";
2-
import { readFileSync } from "fs";
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3+
import { spawnSync } from "node:child_process";
4+
import { tmpdir } from "os";
35
import { join } from "path";
6+
import { buildManifest, withPluginVersion } from "../../src/manifest.ts";
7+
8+
const buildManifestForVersion = (pluginVersion: string) =>
9+
buildManifest({
10+
pluginVersion,
11+
commandCodeVersion: "1.38.1",
12+
commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz",
13+
modelCount: 2,
14+
reasoningModelCount: 1,
15+
modelCatalogOk: true,
16+
costSources: { cli: 2, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 },
17+
generatedAt: "2026-08-28T17:00:00.000Z",
18+
});
419

520
const ROOT = join(import.meta.dir, "../..");
621
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf-8");
@@ -200,18 +215,33 @@ describe("release.config.cjs", () => {
200215
});
201216

202217
describe("prepare-release-manifest.ts", () => {
203-
test("updates only pluginVersion from the release version", () => {
204-
const src = read("scripts/prepare-release-manifest.ts");
205-
expect(src).toContain("withPluginVersion");
206-
expect(src).toContain("process.argv");
218+
test("sets only pluginVersion from the release version", () => {
219+
expect(read("scripts/prepare-release-manifest.ts")).toContain("withPluginVersion");
220+
const manifest = buildManifestForVersion("0.5.0");
221+
const updated = withPluginVersion(manifest, "0.6.0");
222+
expect(updated.pluginVersion).toBe("0.6.0");
223+
expect(updated.modelCount).toBe(manifest.modelCount);
207224
});
208225
});
209226

210227
describe("verify-manifest-version.ts", () => {
211-
test("guards the packed manifest against the package version", () => {
212-
const src = read("scripts/verify-manifest-version.ts");
213-
expect(src).toContain("pluginVersion");
214-
expect(src).toContain("process.exit");
228+
test("exits non-zero for mismatched versions and zero for equal versions", () => {
229+
const dir = mkdtempSync(join(tmpdir(), "cc-mmv-"));
230+
try {
231+
writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.0.0" }));
232+
writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "9.9.9" }));
233+
expect(spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir]).status).not.toBe(
234+
0,
235+
);
236+
writeFileSync(join(dir, "manifest.json"), JSON.stringify({ pluginVersion: "1.0.0" }));
237+
const ok = spawnSync("bun", ["run", "scripts/verify-manifest-version.ts", dir], {
238+
encoding: "utf-8",
239+
});
240+
expect(ok.status).toBe(0);
241+
expect(ok.stdout).toContain("1.0.0");
242+
} finally {
243+
rmSync(dir, { recursive: true, force: true });
244+
}
215245
expect(json<{ scripts?: Record<string, string> }>("package.json").scripts?.prepack).toContain(
216246
"verify-manifest-version",
217247
);
@@ -240,5 +270,9 @@ describe("verify-release-candidate.ts", () => {
240270
test("rejects a packed manifest whose pluginVersion differs from package.json", () => {
241271
const src = read("scripts/verify-release-candidate.ts");
242272
expect(src).toContain("pluginVersion");
273+
const packed = JSON.parse(read("manifest.json")) as { pluginVersion?: string };
274+
expect(packed.pluginVersion).toBe(
275+
(JSON.parse(read("package.json")) as { version: string }).version,
276+
);
243277
});
244278
});

‎tests/unit/sync-models.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("buildSyncArtifacts", () => {
9393
]);
9494
});
9595

96-
test("a validation failure builds nothing and leaves generated artifacts byte-for-byte unchanged", () => {
96+
test("buildSyncArtifacts never writes generated artifacts, even on success", () => {
9797
const dir = mkdtempSync(join(tmpdir(), "cc-sync-"));
9898
try {
9999
const modelsPath = join(dir, "models.json");
@@ -107,6 +107,8 @@ describe("buildSyncArtifacts", () => {
107107
manifest: readFileSync(manifestPath, "utf-8"),
108108
version: readFileSync(versionPath, "utf-8"),
109109
};
110+
// Throws on the floor (1 retained < 20) — the only path that must not write.
111+
// Success returns payloads for main() to write after every build succeeds.
110112
expect(() =>
111113
buildSyncArtifacts({
112114
...base,

0 commit comments

Comments
 (0)