Skip to content

Commit dfd64d2

Browse files
authored
fix: follow up on #907 version edge cases (#921)
* fix: resolve rc precedence and metadata fallback drift Ensure version coherence prefers stable releases over matching pre-release tags, and make fetchAll toolkit metadata resolution use the same providerId fallback path as single-toolkit fetches. Made-with: Cursor * refactor: split semver comparison into helpers Reduce comparator complexity while preserving semver precedence behavior for stable and pre-release versions. Made-with: Cursor * refactor: simplify prerelease comparator branching Extract optional prerelease part comparison so the semver helper stays readable and below complexity warning thresholds. Made-with: Cursor * test: expand version and metadata fallback coverage Fix redundant metadata lookups in fetchToolkitData and add regression tests for semver prerelease ordering plus metadata lookup behavior across single and bulk toolkit paths. Made-with: Cursor * refactor: harden semver filter and simplify metadata resolution Address review findings from six parallel reviewers: - Use compareVersions for filter equality so semver-equal tools (e.g. @1.0.0 vs @1.0.0+build.1) no longer get silently dropped. - Replace localeCompare("en") with ASCII byte ordering per SemVer 2.0.0 §11.4.2 so mixed-case prerelease tags rank correctly. - Replace ambiguous best === "" sentinel with a null sentinel in getHighestVersion. - Drop the allowToolkitIdLookup flag and lift the toolkit-id retry into the fetchAllToolkitsData call site; shared helper now only owns the *Api provider-id fallback. - Inline single-use compareOptionalPrereleasePart and simplify comparePrereleaseIdentifier with typeof narrowing. - Add regression tests: direct-map-hit no-relookup, patch-only version diff, ASCII prerelease ordering, string-vs-string prerelease, all-unversioned tools, semver-equal tools with differing build metadata. Made-with: Cursor
1 parent 02c61b4 commit dfd64d2

4 files changed

Lines changed: 391 additions & 56 deletions

File tree

toolkit-docs-generator/src/sources/toolkit-data-source.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -108,28 +108,45 @@ export class CombinedToolkitDataSource implements IToolkitDataSource {
108108
this.metadataSource = config.metadataSource;
109109
}
110110

111+
/**
112+
* Apply the "*Api" provider-id fallback when the direct metadata lookup
113+
* missed. Used by both `fetchToolkitData` and `fetchAllToolkitsData` so
114+
* they resolve metadata the same way.
115+
*/
116+
private async resolveProviderMetadata(
117+
toolkitId: string,
118+
tools: readonly ToolDefinition[],
119+
directMetadata: ToolkitMetadata | null
120+
): Promise<ToolkitMetadata | null> {
121+
if (directMetadata || !normalizeId(toolkitId).endsWith("api")) {
122+
return directMetadata;
123+
}
124+
125+
const providerId = tools.find((t) => t.auth?.providerId)?.auth?.providerId;
126+
if (!providerId) {
127+
return null;
128+
}
129+
130+
return (
131+
(await this.metadataSource.getToolkitMetadata(`${providerId}Api`)) ??
132+
(await this.metadataSource.getToolkitMetadata(providerId))
133+
);
134+
}
135+
111136
async fetchToolkitData(
112137
toolkitId: string,
113138
version?: string
114139
): Promise<ToolkitData> {
115140
// Fetch tools and metadata in parallel
116-
const [tools, fetchedMetadata] = await Promise.all([
141+
const [tools, directMetadata] = await Promise.all([
117142
this.toolSource.fetchToolsByToolkit(toolkitId),
118143
this.metadataSource.getToolkitMetadata(toolkitId),
119144
]);
120-
121-
// If the toolkit isn't in the Design System under its exact ID, try to match
122-
// based on the auth provider for "*Api" toolkits (e.g. MailchimpMarketingApi -> MailchimpApi).
123-
let metadata = fetchedMetadata;
124-
if (!metadata && normalizeId(toolkitId).endsWith("api")) {
125-
const providerId = tools.find((t) => t.auth?.providerId)?.auth
126-
?.providerId;
127-
if (providerId) {
128-
metadata =
129-
(await this.metadataSource.getToolkitMetadata(`${providerId}Api`)) ??
130-
(await this.metadataSource.getToolkitMetadata(providerId));
131-
}
132-
}
145+
const metadata = await this.resolveProviderMetadata(
146+
toolkitId,
147+
tools,
148+
directMetadata
149+
);
133150

134151
// Filter tools by version if specified, otherwise keep only the highest
135152
// version to drop stale tools from older releases that Engine still serves.
@@ -184,10 +201,16 @@ export class CombinedToolkitDataSource implements IToolkitDataSource {
184201
// matching the behaviour of fetchToolkitData.
185202
const result = new Map<string, ToolkitData>();
186203
for (const [toolkitId, tools] of toolkitGroups) {
187-
const directMetadata = metadataMap.get(toolkitId) ?? null;
188-
const metadata =
189-
directMetadata ??
204+
// Prefer the batch lookup; only fall back to per-toolkit lookup
205+
// (and then the provider-id fallback) when the map misses.
206+
const directMetadata =
207+
metadataMap.get(toolkitId) ??
190208
(await this.metadataSource.getToolkitMetadata(toolkitId));
209+
const metadata = await this.resolveProviderMetadata(
210+
toolkitId,
211+
tools,
212+
directMetadata
213+
);
191214
result.set(toolkitId, { tools, metadata });
192215
}
193216

Lines changed: 111 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,142 @@
11
import type { ToolDefinition } from "../types/index.js";
22
import { extractVersion } from "./fp.js";
33

4+
interface ParsedSemver {
5+
readonly core: readonly number[];
6+
readonly prerelease: readonly (number | string)[] | null;
7+
}
8+
49
/**
5-
* Parse the numeric MAJOR.MINOR.PATCH tuple from a semver string,
6-
* stripping pre-release (`-alpha.1`) and build metadata (`+build.456`).
7-
*
8-
* Handles formats produced by arcade-mcp's normalize_version():
9-
* "3.1.3", "1.2.3-beta.1", "1.2.3+build.456", "1.2.3-rc.1+build.789"
10+
* Parse a semver-ish string into numeric core + optional pre-release parts.
11+
* Build metadata (+...) is ignored for ordering.
1012
*/
11-
const parseNumericVersion = (version: string): number[] => {
12-
// Strip build metadata (after +) then pre-release (after -)
13-
const core = version.split("+")[0]?.split("-")[0] ?? version;
14-
return core.split(".").map((s) => {
15-
const n = Number(s);
13+
const parseSemver = (version: string): ParsedSemver => {
14+
const withoutBuild = version.split("+")[0] ?? version;
15+
const prereleaseIndex = withoutBuild.indexOf("-");
16+
const coreText =
17+
prereleaseIndex >= 0
18+
? withoutBuild.slice(0, prereleaseIndex)
19+
: withoutBuild;
20+
const prereleaseText =
21+
prereleaseIndex >= 0 ? withoutBuild.slice(prereleaseIndex + 1) : "";
22+
23+
const core = coreText.split(".").map((segment) => {
24+
const n = Number(segment);
1625
return Number.isNaN(n) ? 0 : n;
1726
});
27+
28+
const prerelease =
29+
prereleaseText.length > 0
30+
? prereleaseText.split(".").map((identifier) => {
31+
const n = Number(identifier);
32+
return Number.isNaN(n) ? identifier : n;
33+
})
34+
: null;
35+
36+
return { core, prerelease };
37+
};
38+
39+
const compareCoreVersions = (
40+
aCore: readonly number[],
41+
bCore: readonly number[]
42+
): number => {
43+
const len = Math.max(aCore.length, bCore.length);
44+
for (let i = 0; i < len; i++) {
45+
const diff = (aCore[i] ?? 0) - (bCore[i] ?? 0);
46+
if (diff !== 0) return diff;
47+
}
48+
return 0;
1849
};
1950

2051
/**
21-
* Compare two semver version strings numerically by MAJOR.MINOR.PATCH.
22-
* Pre-release and build metadata are ignored for ordering purposes
23-
* (they are unlikely to appear in Engine API responses, but we handle
24-
* them defensively since arcade-mcp's semver allows them).
25-
*
26-
* Returns a positive number if a > b, negative if a < b, 0 if equal.
52+
* Compare two prerelease identifiers following SemVer 2.0.0 §11.4.2:
53+
* numeric identifiers always rank lower than alphanumeric identifiers,
54+
* numeric identifiers compare numerically, and alphanumeric identifiers
55+
* compare by ASCII byte order (not locale).
2756
*/
28-
const compareVersions = (a: string, b: string): number => {
29-
const aParts = parseNumericVersion(a);
30-
const bParts = parseNumericVersion(b);
31-
const len = Math.max(aParts.length, bParts.length);
57+
const comparePrereleaseIdentifier = (
58+
aPart: number | string,
59+
bPart: number | string
60+
): number => {
61+
if (typeof aPart === "number" && typeof bPart === "number") {
62+
return aPart - bPart;
63+
}
64+
if (typeof aPart === "number") return -1;
65+
if (typeof bPart === "number") return 1;
66+
if (aPart < bPart) return -1;
67+
if (aPart > bPart) return 1;
68+
return 0;
69+
};
70+
71+
/**
72+
* Compare prerelease identifier arrays following SemVer 2.0.0 §11.4:
73+
* a prerelease version has lower precedence than the associated normal
74+
* version, and longer identifier lists with matching prefix rank higher.
75+
*/
76+
const comparePrerelease = (
77+
aPrerelease: readonly (number | string)[] | null,
78+
bPrerelease: readonly (number | string)[] | null
79+
): number => {
80+
if (!(aPrerelease || bPrerelease)) return 0;
81+
if (!aPrerelease) return 1;
82+
if (!bPrerelease) return -1;
83+
84+
const len = Math.max(aPrerelease.length, bPrerelease.length);
3285
for (let i = 0; i < len; i++) {
33-
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
86+
const aPart = aPrerelease[i];
87+
const bPart = bPrerelease[i];
88+
if (aPart === undefined) return -1;
89+
if (bPart === undefined) return 1;
90+
const diff = comparePrereleaseIdentifier(aPart, bPart);
3491
if (diff !== 0) return diff;
3592
}
93+
3694
return 0;
3795
};
3896

97+
/**
98+
* Compare two semver version strings using SemVer 2.0.0 precedence:
99+
* - MAJOR.MINOR.PATCH compared numerically
100+
* - Stable release > prerelease when core is equal
101+
* - Numeric prerelease identifiers compare numerically
102+
* - Alphanumeric prerelease identifiers compare by ASCII byte order
103+
* - Build metadata is ignored for precedence
104+
*
105+
* Returns a positive number if a > b, negative if a < b, 0 if equal.
106+
*/
107+
export const compareVersions = (a: string, b: string): number => {
108+
const aSemver = parseSemver(a);
109+
const bSemver = parseSemver(b);
110+
const coreDiff = compareCoreVersions(aSemver.core, bSemver.core);
111+
if (coreDiff !== 0) return coreDiff;
112+
return comparePrerelease(aSemver.prerelease, bSemver.prerelease);
113+
};
114+
39115
/**
40116
* Find the highest version among all tools in a toolkit.
41-
* This is the version we keep — stale tools from older releases are dropped.
117+
* Returns null when no tool carries a usable version.
42118
*/
43119
export const getHighestVersion = (
44120
tools: readonly ToolDefinition[]
45121
): string | null => {
46-
if (tools.length === 0) {
47-
return null;
48-
}
49-
50-
let best = "";
122+
let best: string | null = null;
51123
for (const tool of tools) {
52124
const version = extractVersion(tool.fullyQualifiedName);
53-
if (best === "" || compareVersions(version, best) > 0) {
125+
if (best === null || compareVersions(version, best) > 0) {
54126
best = version;
55127
}
56128
}
57-
58-
return best || null;
129+
return best;
59130
};
60131

61132
/**
62-
* Keep only tools whose @version matches the highest version for
63-
* their toolkit. If all tools share the same version (the common
64-
* case), returns the original array unchanged.
133+
* Keep only tools whose @version is semver-equivalent to the highest
134+
* version for their toolkit. If all tools share the highest version
135+
* (the common case), returns the original array unchanged.
65136
*
66-
* This drops stale tools from older releases that Engine still serves,
67-
* while always preserving the newest version — even if it has fewer tools
68-
* (e.g. tools were removed/consolidated in the new release).
137+
* Tools are compared via `compareVersions`, so build metadata is
138+
* ignored when deciding equivalence — a tool at `@1.0.0+build.1`
139+
* survives alongside `@1.0.0` instead of being silently dropped.
69140
*/
70141
export const filterToolsByHighestVersion = (
71142
tools: readonly ToolDefinition[]
@@ -75,13 +146,14 @@ export const filterToolsByHighestVersion = (
75146
return tools;
76147
}
77148

78-
// Fast path: if every tool is already at the highest version, skip filtering
79149
const allSame = tools.every(
80-
(t) => extractVersion(t.fullyQualifiedName) === highest
150+
(t) => compareVersions(extractVersion(t.fullyQualifiedName), highest) === 0
81151
);
82152
if (allSame) {
83153
return tools;
84154
}
85155

86-
return tools.filter((t) => extractVersion(t.fullyQualifiedName) === highest);
156+
return tools.filter(
157+
(t) => compareVersions(extractVersion(t.fullyQualifiedName), highest) === 0
158+
);
87159
};

0 commit comments

Comments
 (0)