Skip to content

Commit 02c61b4

Browse files
jottakkaclaude
andauthored
feat(toolkit-docs-generator): latest-version coherence filter (#907)
* feat(toolkit-docs-generator): add majority-version coherence filter Engine API returns tools at mixed versions for the same toolkit (e.g. Github tools at @3.1.3 alongside stale notification tools at @2.0.1). These stale tools survive through the pipeline because the data source has no version filtering, and --skip-unchanged preserves them indefinitely since the API response stays consistent. Add filterToolsByMajorityVersion() that computes the version shared by the most tools in a toolkit and drops tools at other versions. Applied in both fetchAllToolkitsData() and fetchToolkitData() (when no explicit version is passed), so stale tools are removed before reaching the diff or merger layers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review — deduplicate extractVersion, fix semver comparison - Move extractVersion to utils/fp.ts as the single source of truth; data-merger.ts re-exports it for backward compatibility - Replace lexicographic version tie-break with numeric semver comparison so multi-digit components (e.g. 9.0.0 vs 10.0.0) sort correctly - Remove redundant array spread in fetchAllToolkitsData filter - Add test for multi-digit version tie-break edge case Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle pre-release and build metadata in version comparison arcade-mcp's normalize_version() allows semver with pre-release tags (1.2.3-alpha.1) and build metadata (1.2.3+build.456). The previous compareVersions used .split(".").map(Number) which produced NaN for these suffixes. Now strips pre-release and build metadata before parsing numeric MAJOR.MINOR.PATCH for comparison. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use highest version, not majority count, to filter stale tools The majority-version approach was wrong: if a new release has fewer tools (e.g. consolidation), the filter would keep the OLD version with more tools and drop the new one. The correct logic is to always keep tools at the highest (newest) version — stale tools are always at older versions. Renamed: getMajorityVersion → getHighestVersion, filterToolsByMajorityVersion → filterToolsByHighestVersion. Added test: "keeps newer version even when it has fewer tools". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove ambiguous extractVersion re-export from data-merger Remove the re-export of extractVersion from data-merger.ts since it's already exported via utils/index.ts. This eliminates the ambiguous binding that TypeScript treats as a package API regression. Update test imports to use the canonical utils/index.ts export. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update toolkit-diff to import extractVersion from utils Update toolkit-diff.ts to import extractVersion from the canonical utils/index.ts location instead of data-merger.ts (which no longer re-exports it). This fixes the SyntaxError when running the CLI: 'The requested module data-merger.js does not provide an export named extractVersion' Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f7e57d6 commit 02c61b4

10 files changed

Lines changed: 602 additions & 11 deletions

File tree

toolkit-docs-generator/src/diff/toolkit-diff.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import {
99
buildComparableToolSignature,
10-
extractVersion,
1110
stableStringify,
1211
} from "../merger/data-merger.js";
1312
import type {
@@ -16,6 +15,7 @@ import type {
1615
ToolDefinition,
1716
ToolkitMetadata,
1817
} from "../types/index.js";
18+
import { extractVersion } from "../utils/index.js";
1919

2020
// ============================================================================
2121
// Types

toolkit-docs-generator/src/merger/data-merger.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
ToolkitMetadata,
2424
} from "../types/index.js";
2525
import { mapWithConcurrency } from "../utils/concurrency.js";
26+
import { extractVersion } from "../utils/fp.js";
2627
import {
2728
detectMetadataChanges,
2829
formatFreshnessWarnings,
@@ -340,13 +341,6 @@ export const getProviderId = (
340341
return toolWithAuth?.auth?.providerId ?? null;
341342
};
342343

343-
/**
344-
* Extract version from fully qualified name
345-
*/
346-
export const extractVersion = (fullyQualifiedName: string): string => {
347-
const parts = fullyQualifiedName.split("@");
348-
return parts[1] ?? "0.0.0";
349-
};
350344
/**
351345
* Create default metadata for toolkits not found in Design System
352346
*/

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { join } from "path";
1010
import type { ToolDefinition, ToolkitMetadata } from "../types/index.js";
1111
import { normalizeId } from "../utils/fp.js";
12+
import { filterToolsByHighestVersion } from "../utils/version-coherence.js";
1213
import {
1314
type ArcadeApiSourceConfig,
1415
createArcadeApiSource,
@@ -130,13 +131,14 @@ export class CombinedToolkitDataSource implements IToolkitDataSource {
130131
}
131132
}
132133

133-
// Filter tools by version if specified
134+
// Filter tools by version if specified, otherwise keep only the highest
135+
// version to drop stale tools from older releases that Engine still serves.
134136
const filteredTools = version
135137
? tools.filter((tool) => {
136138
const toolVersion = tool.fullyQualifiedName.split("@")[1];
137139
return toolVersion === version;
138140
})
139-
: tools;
141+
: filterToolsByHighestVersion(tools);
140142

141143
return {
142144
tools: filteredTools,
@@ -167,6 +169,15 @@ export class CombinedToolkitDataSource implements IToolkitDataSource {
167169
metadataMap.set(metadata.id, metadata);
168170
}
169171

172+
// Filter each toolkit to its highest version to drop stale
173+
// tools from older releases that Engine still serves.
174+
for (const [toolkitId, tools] of toolkitGroups) {
175+
const filtered = filterToolsByHighestVersion(tools);
176+
if (filtered !== tools) {
177+
toolkitGroups.set(toolkitId, filtered as ToolDefinition[]);
178+
}
179+
}
180+
170181
// Combine into ToolkitData map.
171182
// Use getToolkitMetadata for toolkits without a direct match so that
172183
// fallback logic (e.g. "WeaviateApi" → "Weaviate") is applied consistently,

toolkit-docs-generator/src/utils/fp.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,15 @@ export const deepMerge = <T extends object>(
159159
return result;
160160
};
161161

162+
/**
163+
* Extract version from a fully qualified tool name.
164+
* "Github.CreateIssue@3.1.3" → "3.1.3"
165+
*/
166+
export const extractVersion = (fullyQualifiedName: string): string => {
167+
const parts = fullyQualifiedName.split("@");
168+
return parts[1] ?? "0.0.0";
169+
};
170+
162171
/**
163172
* Normalize a string for comparison (lowercase, remove hyphens/underscores)
164173
*/

toolkit-docs-generator/src/utils/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ export { readIgnoreList } from "./ignore-list.js";
1010
export * from "./logger.js";
1111
export * from "./progress.js";
1212
export * from "./retry.js";
13+
export {
14+
filterToolsByHighestVersion,
15+
getHighestVersion,
16+
} from "./version-coherence.js";
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { ToolDefinition } from "../types/index.js";
2+
import { extractVersion } from "./fp.js";
3+
4+
/**
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+
*/
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);
16+
return Number.isNaN(n) ? 0 : n;
17+
});
18+
};
19+
20+
/**
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.
27+
*/
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);
32+
for (let i = 0; i < len; i++) {
33+
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
34+
if (diff !== 0) return diff;
35+
}
36+
return 0;
37+
};
38+
39+
/**
40+
* Find the highest version among all tools in a toolkit.
41+
* This is the version we keep — stale tools from older releases are dropped.
42+
*/
43+
export const getHighestVersion = (
44+
tools: readonly ToolDefinition[]
45+
): string | null => {
46+
if (tools.length === 0) {
47+
return null;
48+
}
49+
50+
let best = "";
51+
for (const tool of tools) {
52+
const version = extractVersion(tool.fullyQualifiedName);
53+
if (best === "" || compareVersions(version, best) > 0) {
54+
best = version;
55+
}
56+
}
57+
58+
return best || null;
59+
};
60+
61+
/**
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.
65+
*
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).
69+
*/
70+
export const filterToolsByHighestVersion = (
71+
tools: readonly ToolDefinition[]
72+
): readonly ToolDefinition[] => {
73+
const highest = getHighestVersion(tools);
74+
if (highest === null) {
75+
return tools;
76+
}
77+
78+
// Fast path: if every tool is already at the highest version, skip filtering
79+
const allSame = tools.every(
80+
(t) => extractVersion(t.fullyQualifiedName) === highest
81+
);
82+
if (allSame) {
83+
return tools;
84+
}
85+
86+
return tools.filter((t) => extractVersion(t.fullyQualifiedName) === highest);
87+
};

toolkit-docs-generator/tests/merger/data-merger.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
computeAllScopes,
1010
DataMerger,
1111
determineAuthType,
12-
extractVersion,
1312
getProviderId,
1413
groupToolsByToolkit,
1514
mergeToolkit,
@@ -32,6 +31,7 @@ import type {
3231
ToolDefinition,
3332
ToolkitMetadata,
3433
} from "../../src/types/index.js";
34+
import { extractVersion } from "../../src/utils/index.js";
3535

3636
// ============================================================================
3737
// Test Fixtures - Realistic data matching production schema

0 commit comments

Comments
 (0)