Skip to content

Commit ff3afe5

Browse files
committed
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.
1 parent 6d7513b commit ff3afe5

6 files changed

Lines changed: 67 additions & 4 deletions

File tree

scripts/analyze-release-scope.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,18 @@ export function latestTag(root = process.cwd()): string | null {
3131

3232
type Level = "major" | "minor" | "patch";
3333
const LEVEL_RANK: Record<Level, number> = { patch: 1, minor: 2, major: 3 };
34+
// "chore(scope)" covers routine product refreshes that still ship (e.g.
35+
// chore(catalog): sync models.json); "chore" without a scope stays inert.
3436
const TYPE_LEVEL: Record<string, Level> = { fix: "patch", perf: "patch", feat: "minor" };
3537

3638
const subjectLevel = (commit: string): Level | null => {
3739
const firstLine = commit.split("\n")[0] ?? "";
38-
const m = /^(?:fix|perf|feat)(?:\([^)]*\))?!?:/.exec(firstLine);
40+
const m = /^(?:(?:fix|perf|feat)|chore\([^)]*\))(?:\([^)]*\))?!?:/.exec(firstLine);
3941
if (!m) return null;
4042
if (m[0].includes("!")) return "major";
4143
const body = commit.split("\n").slice(1).join("\n");
4244
const type = m[0].replace(/\(.*$/, "").replace(/!$/, "").replace(/:$/, "");
45+
if (type === "chore") return "patch";
4346
return /BREAKING[- ]CHANGE:/.test(body) ? "major" : (TYPE_LEVEL[type] ?? null);
4447
};
4548

src/catalog-break.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export function catalogBreakTitle(commandCodeVersion: string): string {
2-
return `[catalog-break] command-code@${commandCodeVersion} — model extraction failed`;
2+
const version = commandCodeVersion.trim().replace(/^v/, "");
3+
return `[catalog-break] command-code@${version} — model extraction failed`;
34
}
45

56
export function renderCatalogBreakBody(input: {

src/catalog.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,17 @@ export function extractStringBindings(
279279
): Record<string, string> {
280280
const before = source.slice(Math.max(0, endIdx - window), endIdx);
281281
const bindings: Record<string, string> = {};
282-
const strRe = /\b([A-Za-z_$][\w$]*)="([^"]*)"/g;
282+
// \b does not fire before "$" (not a word char); minified bundles use names like
283+
// $R="vercel-ai-gateway". A lookbehind boundary handles both $ and letter names.
284+
const strRe = /(?<![A-Za-z0-9_$])([A-Za-z_$][\w$]*)="([^"]*)"/g;
283285
let m: RegExpExecArray | null;
284286
while ((m = strRe.exec(before))) {
285287
const name = m[1];
286288
const value = m[2];
287289
if (name !== undefined && value !== undefined) bindings[name] = value;
288290
}
289291
for (let pass = 0; pass < 4; pass++) {
290-
const aliasRe = /\b([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\b/g;
292+
const aliasRe = /(?<![A-Za-z0-9_$])([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)(?![A-Za-z0-9_$])/g;
291293
while ((m = aliasRe.exec(before))) {
292294
const alias = m[1];
293295
const target = m[2];

tests/unit/analyze-release-scope.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,27 @@ describe("analyzeReleaseScope", () => {
108108
r.cleanup();
109109
}
110110
});
111+
112+
test("chore(scope) commits that only touch non-product files yield no release", () => {
113+
const r = repo();
114+
r.tag("v0.6.0");
115+
try {
116+
r.commit("chore(catalog): tweak CI", { ".github/workflows/catalog-sync.yml": "cron: 0 *\n" });
117+
expect(analyzeReleaseScope(r.root)).toEqual({ level: null });
118+
} finally {
119+
r.cleanup();
120+
}
121+
});
122+
123+
test("chore(scope) with product-file change still releases at patch", () => {
124+
const r = repo();
125+
r.tag("v0.6.0");
126+
try {
127+
r.commit("chore(catalog): refresh", { "models.json": "[]\n" });
128+
// prod file touched + chore(scope): treat as the scope's default patch release
129+
expect(analyzeReleaseScope(r.root).level).toBe("patch");
130+
} finally {
131+
r.cleanup();
132+
}
133+
});
111134
});

tests/unit/catalog-break.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ describe("catalogBreakTitle", () => {
1111
"[catalog-break] command-code@1.39.0 — model extraction failed",
1212
);
1313
});
14+
15+
test("survives npm version tags with leading v", () => {
16+
expect(catalogBreakTitle("v1.39.0")).toBe(
17+
"[catalog-break] command-code@1.39.0 — model extraction failed",
18+
);
19+
});
1420
});
1521

1622
describe("renderCatalogBreakBody", () => {
@@ -27,6 +33,20 @@ describe("renderCatalogBreakBody", () => {
2733
expect(body).toContain("1.38.1");
2834
expect(body).toContain("src/catalog.ts");
2935
});
36+
37+
test("normalizes whitespace inside the embedded error and bundled version", () => {
38+
const body = renderCatalogBreakBody({
39+
commandCodeVersion: "1.40.1",
40+
error: "SyntaxError: unexpected token\n at foo",
41+
workflowUrl: "https://example.com/run",
42+
bundledCommandCodeVersion: "0.7.4",
43+
});
44+
// error code block preserved verbatim
45+
expect(body).toContain("SyntaxError: unexpected token");
46+
// no double blank lines or stray leading spaces collapse the markdown
47+
expect(body).not.toMatch(/\n{3,}/);
48+
expect(body).toContain("command-code@1.40.1");
49+
});
3050
});
3151

3252
describe("catalogBreakResolvedComment", () => {

tests/unit/catalog.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,20 @@ describe("loadCatalogFromBundle", () => {
259259
expect(gpt!.modalities).toEqual({ input: ["text"], output: ["text"] });
260260
});
261261

262+
test("binds string vars prefixed with $ (minifier shape) for catalog eval", () => {
263+
// command-code 1.40 bundles provider/spec constants into $R="..."-style vars;
264+
// a \b boundary skips "$" and breaks evaluation of the model catalog object.
265+
const source = [
266+
'var $R="vercel-ai-gateway",KR="chatComplete",qR="responses";',
267+
'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);',
268+
].join("");
269+
270+
const entries = loadCatalogFromBundle(source);
271+
const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6");
272+
expect(sonnet).toBeDefined();
273+
expect(sonnet!.reasoningEfforts).toEqual(["low", "high"]);
274+
});
275+
262276
test("returns models when cost extraction fails", () => {
263277
const source = [
264278
'(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});',

0 commit comments

Comments
 (0)