From ff3afe5f7e957860babc3c971e98e166053b3f85 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Wed, 2 Sep 2026 08:38:13 -0400 Subject: [PATCH] fix(catalog): extract catalog from bundles with $-prefixed vars command-code 1.40.1 minifies provider constants as $R="vercel-ai-gateway"; the \b boundary in extractStringBindings never fires before "$", so the provider alias was captured as "R" (or skipped) and evaluating the model catalog threw $R is not defined. Use lookbehind/lookahead boundaries that honor "$" names. Also normalize catalog-break titles when npm metadata returns a "v"-prefixed version, and treat chore(scope) commits that touch product files as patch releases so models.json syncs keep publishing. --- scripts/analyze-release-scope.ts | 5 ++++- src/catalog-break.ts | 3 ++- src/catalog.ts | 6 ++++-- tests/unit/analyze-release-scope.test.ts | 23 +++++++++++++++++++++++ tests/unit/catalog-break.test.ts | 20 ++++++++++++++++++++ tests/unit/catalog.test.ts | 14 ++++++++++++++ 6 files changed, 67 insertions(+), 4 deletions(-) diff --git a/scripts/analyze-release-scope.ts b/scripts/analyze-release-scope.ts index 9369dd8..f770707 100644 --- a/scripts/analyze-release-scope.ts +++ b/scripts/analyze-release-scope.ts @@ -31,15 +31,18 @@ export function latestTag(root = process.cwd()): string | null { type Level = "major" | "minor" | "patch"; const LEVEL_RANK: Record = { patch: 1, minor: 2, major: 3 }; +// "chore(scope)" covers routine product refreshes that still ship (e.g. +// chore(catalog): sync models.json); "chore" without a scope stays inert. const TYPE_LEVEL: Record = { fix: "patch", perf: "patch", feat: "minor" }; const subjectLevel = (commit: string): Level | null => { const firstLine = commit.split("\n")[0] ?? ""; - const m = /^(?:fix|perf|feat)(?:\([^)]*\))?!?:/.exec(firstLine); + const m = /^(?:(?:fix|perf|feat)|chore\([^)]*\))(?:\([^)]*\))?!?:/.exec(firstLine); if (!m) return null; if (m[0].includes("!")) return "major"; const body = commit.split("\n").slice(1).join("\n"); const type = m[0].replace(/\(.*$/, "").replace(/!$/, "").replace(/:$/, ""); + if (type === "chore") return "patch"; return /BREAKING[- ]CHANGE:/.test(body) ? "major" : (TYPE_LEVEL[type] ?? null); }; diff --git a/src/catalog-break.ts b/src/catalog-break.ts index 508216f..d73f2fe 100644 --- a/src/catalog-break.ts +++ b/src/catalog-break.ts @@ -1,5 +1,6 @@ export function catalogBreakTitle(commandCodeVersion: string): string { - return `[catalog-break] command-code@${commandCodeVersion} — model extraction failed`; + const version = commandCodeVersion.trim().replace(/^v/, ""); + return `[catalog-break] command-code@${version} — model extraction failed`; } export function renderCatalogBreakBody(input: { diff --git a/src/catalog.ts b/src/catalog.ts index c350299..1c8038e 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -279,7 +279,9 @@ export function extractStringBindings( ): Record { const before = source.slice(Math.max(0, endIdx - window), endIdx); const bindings: Record = {}; - const strRe = /\b([A-Za-z_$][\w$]*)="([^"]*)"/g; + // \b does not fire before "$" (not a word char); minified bundles use names like + // $R="vercel-ai-gateway". A lookbehind boundary handles both $ and letter names. + const strRe = /(? { r.cleanup(); } }); + + test("chore(scope) commits that only touch non-product files yield no release", () => { + const r = repo(); + r.tag("v0.6.0"); + try { + r.commit("chore(catalog): tweak CI", { ".github/workflows/catalog-sync.yml": "cron: 0 *\n" }); + expect(analyzeReleaseScope(r.root)).toEqual({ level: null }); + } finally { + r.cleanup(); + } + }); + + test("chore(scope) with product-file change still releases at patch", () => { + const r = repo(); + r.tag("v0.6.0"); + try { + r.commit("chore(catalog): refresh", { "models.json": "[]\n" }); + // prod file touched + chore(scope): treat as the scope's default patch release + expect(analyzeReleaseScope(r.root).level).toBe("patch"); + } finally { + r.cleanup(); + } + }); }); diff --git a/tests/unit/catalog-break.test.ts b/tests/unit/catalog-break.test.ts index 7093ad6..d9d3557 100644 --- a/tests/unit/catalog-break.test.ts +++ b/tests/unit/catalog-break.test.ts @@ -11,6 +11,12 @@ describe("catalogBreakTitle", () => { "[catalog-break] command-code@1.39.0 — model extraction failed", ); }); + + test("survives npm version tags with leading v", () => { + expect(catalogBreakTitle("v1.39.0")).toBe( + "[catalog-break] command-code@1.39.0 — model extraction failed", + ); + }); }); describe("renderCatalogBreakBody", () => { @@ -27,6 +33,20 @@ describe("renderCatalogBreakBody", () => { expect(body).toContain("1.38.1"); expect(body).toContain("src/catalog.ts"); }); + + test("normalizes whitespace inside the embedded error and bundled version", () => { + const body = renderCatalogBreakBody({ + commandCodeVersion: "1.40.1", + error: "SyntaxError: unexpected token\n at foo", + workflowUrl: "https://example.com/run", + bundledCommandCodeVersion: "0.7.4", + }); + // error code block preserved verbatim + expect(body).toContain("SyntaxError: unexpected token"); + // no double blank lines or stray leading spaces collapse the markdown + expect(body).not.toMatch(/\n{3,}/); + expect(body).toContain("command-code@1.40.1"); + }); }); describe("catalogBreakResolvedComment", () => { diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index c99ee56..ee250b5 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -259,6 +259,20 @@ describe("loadCatalogFromBundle", () => { expect(gpt!.modalities).toEqual({ input: ["text"], output: ["text"] }); }); + test("binds string vars prefixed with $ (minifier shape) for catalog eval", () => { + // command-code 1.40 bundles provider/spec constants into $R="..."-style vars; + // a \b boundary skips "$" and breaks evaluation of the model catalog object. + const source = [ + 'var $R="vercel-ai-gateway",KR="chatComplete",qR="responses";', + 'var Sn=($R=>({SONNET_4_6:{id:"claude-sonnet-4-6",provider:$R,spec:KR,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","high"],contextWindow:2e5},GPT_X:{id:"gpt-5.5",provider:"openai",spec:qR,label:"GPT",name:"GPT-5.5",description:"d",inputModalities:["text"]}}))($R);', + ].join(""); + + const entries = loadCatalogFromBundle(source); + const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6"); + expect(sonnet).toBeDefined(); + expect(sonnet!.reasoningEfforts).toEqual(["low", "high"]); + }); + test("returns models when cost extraction fails", () => { const source = [ '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});',