From 51174dbe1858e89325c9f370b078b9853b0bb5ad Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 9 Apr 2026 15:32:49 -0300 Subject: [PATCH 1/6] 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) --- .../src/sources/toolkit-data-source.ts | 15 +- toolkit-docs-generator/src/utils/index.ts | 4 + .../src/utils/version-coherence.ts | 65 ++++++ .../scenarios/stale-version-tools.test.ts | 205 ++++++++++++++++++ .../tests/sources/toolkit-data-source.test.ts | 91 ++++++++ .../tests/utils/version-coherence.test.ts | 141 ++++++++++++ 6 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 toolkit-docs-generator/src/utils/version-coherence.ts create mode 100644 toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts create mode 100644 toolkit-docs-generator/tests/utils/version-coherence.test.ts diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index a2e887e83..e4cacfd02 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -9,6 +9,7 @@ import { join } from "path"; import type { ToolDefinition, ToolkitMetadata } from "../types/index.js"; import { normalizeId } from "../utils/fp.js"; +import { filterToolsByMajorityVersion } from "../utils/version-coherence.js"; import { type ArcadeApiSourceConfig, createArcadeApiSource, @@ -130,13 +131,14 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { } } - // Filter tools by version if specified + // Filter tools by version if specified, otherwise apply majority-version + // coherence to drop stale tools from older releases that Engine still serves. const filteredTools = version ? tools.filter((tool) => { const toolVersion = tool.fullyQualifiedName.split("@")[1]; return toolVersion === version; }) - : tools; + : filterToolsByMajorityVersion(tools); return { tools: filteredTools, @@ -167,6 +169,15 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { metadataMap.set(metadata.id, metadata); } + // Filter each toolkit to its majority version to drop stale + // tools from older releases that Engine still serves. + for (const [toolkitId, tools] of toolkitGroups) { + const filtered = filterToolsByMajorityVersion(tools); + if (filtered !== tools) { + toolkitGroups.set(toolkitId, [...filtered]); + } + } + // Combine into ToolkitData map. // Use getToolkitMetadata for toolkits without a direct match so that // fallback logic (e.g. "WeaviateApi" → "Weaviate") is applied consistently, diff --git a/toolkit-docs-generator/src/utils/index.ts b/toolkit-docs-generator/src/utils/index.ts index 7ebc30f14..e419f363e 100644 --- a/toolkit-docs-generator/src/utils/index.ts +++ b/toolkit-docs-generator/src/utils/index.ts @@ -10,3 +10,7 @@ export { readIgnoreList } from "./ignore-list.js"; export * from "./logger.js"; export * from "./progress.js"; export * from "./retry.js"; +export { + filterToolsByMajorityVersion, + getMajorityVersion, +} from "./version-coherence.js"; diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts new file mode 100644 index 000000000..b2443be4f --- /dev/null +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -0,0 +1,65 @@ +import type { ToolDefinition } from "../types/index.js"; + +/** + * Extract version from a fully qualified tool name. + * "Github.CreateIssue@3.1.3" → "3.1.3" + */ +const extractVersion = (fullyQualifiedName: string): string => { + const parts = fullyQualifiedName.split("@"); + return parts[1] ?? "0.0.0"; +}; + +/** + * Compute the version shared by the most tools in a toolkit. + * Ties are broken by lexicographic comparison (highest version wins). + */ +export const getMajorityVersion = ( + tools: readonly ToolDefinition[] +): string | null => { + if (tools.length === 0) { + return null; + } + + const counts = new Map(); + for (const tool of tools) { + const version = extractVersion(tool.fullyQualifiedName); + counts.set(version, (counts.get(version) ?? 0) + 1); + } + + let bestVersion = ""; + let bestCount = 0; + for (const [version, count] of counts) { + if (count > bestCount || (count === bestCount && version > bestVersion)) { + bestVersion = version; + bestCount = count; + } + } + + return bestVersion || null; +}; + +/** + * Keep only tools whose @version matches the majority version for + * their toolkit. If all tools share the same version (the common + * case), returns the original array unchanged. + */ +export const filterToolsByMajorityVersion = ( + tools: readonly ToolDefinition[] +): readonly ToolDefinition[] => { + const majorityVersion = getMajorityVersion(tools); + if (majorityVersion === null) { + return tools; + } + + // Fast path: if every tool is already at the majority version, skip filtering + const allSame = tools.every( + (t) => extractVersion(t.fullyQualifiedName) === majorityVersion + ); + if (allSame) { + return tools; + } + + return tools.filter( + (t) => extractVersion(t.fullyQualifiedName) === majorityVersion + ); +}; diff --git a/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts b/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts new file mode 100644 index 000000000..f94b83a2c --- /dev/null +++ b/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts @@ -0,0 +1,205 @@ +/** + * Scenario Test: Stale version tools are filtered out + * + * Verifies that when the Engine API returns tools at mixed versions for the + * same toolkit, the majority-version coherence filter drops stale tools before + * they reach the diff or merger. + */ +import { describe, expect, it } from "vitest"; + +import { + detectChanges, + getChangedToolkitIds, + hasChanges, +} from "../../src/diff/index.js"; +import { + InMemoryMetadataSource, + InMemoryToolDataSource, +} from "../../src/sources/in-memory.js"; +import { createCombinedToolkitDataSource } from "../../src/sources/toolkit-data-source.js"; +import type { MergedToolkit, ToolDefinition } from "../../src/types/index.js"; + +const createTool = ( + overrides: Partial = {} +): ToolDefinition => ({ + name: "TestTool", + qualifiedName: "Github.TestTool", + fullyQualifiedName: "Github.TestTool@3.1.3", + description: "A test tool", + parameters: [], + auth: null, + secrets: [], + output: null, + ...overrides, +}); + +const createMergedToolkit = ( + id: string, + version: string, + toolNames: string[] +): MergedToolkit => ({ + id, + label: id, + version, + description: "A toolkit", + metadata: { + category: "development", + iconUrl: "https://example.com/icon.svg", + isBYOC: false, + isPro: false, + type: "arcade", + docsLink: "https://docs.example.com", + isComingSoon: false, + isHidden: false, + }, + auth: null, + tools: toolNames.map((name) => ({ + name, + qualifiedName: `${id}.${name}`, + fullyQualifiedName: `${id}.${name}@${version}`, + description: "A test tool", + parameters: [], + auth: null, + secrets: [], + secretsInfo: [], + output: null, + documentationChunks: [], + })), + documentationChunks: [], + customImports: [], + subPages: [], + generatedAt: new Date().toISOString(), +}); + +describe("Scenario: Stale version tools filtered by majority-version coherence", () => { + it("fetchAllToolkitsData drops stale tools at non-majority versions", async () => { + // Simulate Engine API returning mixed-version Github tools + const toolSource = new InMemoryToolDataSource([ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "ListPullRequests", + qualifiedName: "Github.ListPullRequests", + fullyQualifiedName: "Github.ListPullRequests@3.1.3", + }), + createTool({ + name: "SetStarred", + qualifiedName: "Github.SetStarred", + fullyQualifiedName: "Github.SetStarred@3.1.3", + }), + // Stale tools from older version + createTool({ + name: "GetNotificationSummary", + qualifiedName: "Github.GetNotificationSummary", + fullyQualifiedName: "Github.GetNotificationSummary@2.0.1", + }), + createTool({ + name: "ListNotifications", + qualifiedName: "Github.ListNotifications", + fullyQualifiedName: "Github.ListNotifications@2.0.1", + }), + ]); + const metadataSource = new InMemoryMetadataSource([]); + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + const result = await dataSource.fetchAllToolkitsData(); + const github = result.get("Github"); + + expect(github).toBeDefined(); + expect(github?.tools).toHaveLength(3); + expect( + github?.tools.every((t) => t.fullyQualifiedName.endsWith("@3.1.3")) + ).toBe(true); + }); + + it("diff correctly reflects removal when previous output had stale tools", async () => { + // After filtering, the data source returns only @3.1.3 tools + const filteredTools = new Map([ + [ + "Github", + [ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "ListPullRequests", + qualifiedName: "Github.ListPullRequests", + fullyQualifiedName: "Github.ListPullRequests@3.1.3", + }), + createTool({ + name: "SetStarred", + qualifiedName: "Github.SetStarred", + fullyQualifiedName: "Github.SetStarred@3.1.3", + }), + ], + ], + ]); + + // Previous output (before the filter existed) had stale tools + const previousToolkits = new Map([ + [ + "Github", + createMergedToolkit("Github", "3.1.3", [ + "CreateIssue", + "ListPullRequests", + "SetStarred", + "GetNotificationSummary", + "ListNotifications", + ]), + ], + ]); + + const result = detectChanges(filteredTools, previousToolkits); + + // The diff should detect the change (stale tools removed) + expect(hasChanges(result)).toBe(true); + const changedIds = getChangedToolkitIds(result); + expect(changedIds).toContain("Github"); + expect(result.summary.modifiedToolkits).toBe(1); + }); + + it("diff sees no changes when previous output already had clean data", async () => { + // After filtering, data source returns only @3.1.3 tools + const filteredTools = new Map([ + [ + "Github", + [ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "ListPullRequests", + qualifiedName: "Github.ListPullRequests", + fullyQualifiedName: "Github.ListPullRequests@3.1.3", + }), + ], + ], + ]); + + // Previous output already had only the majority-version tools + const previousToolkits = new Map([ + [ + "Github", + createMergedToolkit("Github", "3.1.3", [ + "CreateIssue", + "ListPullRequests", + ]), + ], + ]); + + const result = detectChanges(filteredTools, previousToolkits); + + expect(hasChanges(result)).toBe(false); + expect(getChangedToolkitIds(result)).toHaveLength(0); + }); +}); diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index 989464219..df399eaff 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -160,6 +160,97 @@ describe("CombinedToolkitDataSource", () => { expect(weaviate?.metadata?.label).toBe("Weaviate API"); }); + it("should filter out stale tools at non-majority versions in fetchAllToolkitsData", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "ListPullRequests", + qualifiedName: "Github.ListPullRequests", + fullyQualifiedName: "Github.ListPullRequests@3.1.3", + }), + createTool({ + name: "GetNotificationSummary", + qualifiedName: "Github.GetNotificationSummary", + fullyQualifiedName: "Github.GetNotificationSummary@2.0.1", + }), + ]); + const metadataSource = new InMemoryMetadataSource([createMetadata()]); + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + const result = await dataSource.fetchAllToolkitsData(); + const github = result.get("Github"); + + expect(github?.tools).toHaveLength(2); + expect( + github?.tools.every((t) => t.fullyQualifiedName.endsWith("@3.1.3")) + ).toBe(true); + }); + + it("should apply majority-version filter in fetchToolkitData when no version specified", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "ListPullRequests", + qualifiedName: "Github.ListPullRequests", + fullyQualifiedName: "Github.ListPullRequests@3.1.3", + }), + createTool({ + name: "GetNotificationSummary", + qualifiedName: "Github.GetNotificationSummary", + fullyQualifiedName: "Github.GetNotificationSummary@2.0.1", + }), + ]); + const metadataSource = new InMemoryMetadataSource([createMetadata()]); + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + const result = await dataSource.fetchToolkitData("Github"); + + expect(result.tools).toHaveLength(2); + expect( + result.tools.every((t) => t.fullyQualifiedName.endsWith("@3.1.3")) + ).toBe(true); + }); + + it("should still allow explicit version filter in fetchToolkitData", async () => { + const toolSource = new InMemoryToolDataSource([ + createTool({ + name: "CreateIssue", + qualifiedName: "Github.CreateIssue", + fullyQualifiedName: "Github.CreateIssue@3.1.3", + }), + createTool({ + name: "GetNotificationSummary", + qualifiedName: "Github.GetNotificationSummary", + fullyQualifiedName: "Github.GetNotificationSummary@2.0.1", + }), + ]); + const metadataSource = new InMemoryMetadataSource([createMetadata()]); + const dataSource = createCombinedToolkitDataSource({ + toolSource, + metadataSource, + }); + + // Explicit version bypasses majority filter + const result = await dataSource.fetchToolkitData("Github", "2.0.1"); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]?.fullyQualifiedName).toContain("@2.0.1"); + }); + it("should fall back to providerId metadata for *Api toolkits", async () => { const toolSource = new InMemoryToolDataSource([ createTool({ diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts new file mode 100644 index 000000000..6d82b7bfb --- /dev/null +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for majority-version coherence filter + */ +import { describe, expect, it } from "vitest"; +import type { ToolDefinition } from "../../src/types/index.js"; +import { + filterToolsByMajorityVersion, + getMajorityVersion, +} from "../../src/utils/version-coherence.js"; + +const createTool = ( + fullyQualifiedName: string, + overrides: Partial = {} +): ToolDefinition => ({ + name: fullyQualifiedName.split("@")[0]?.split(".")[1] ?? "Tool", + qualifiedName: fullyQualifiedName.split("@")[0] ?? "", + fullyQualifiedName, + description: "A test tool", + parameters: [], + auth: null, + secrets: [], + output: null, + ...overrides, +}); + +describe("getMajorityVersion", () => { + it("returns null for empty array", () => { + expect(getMajorityVersion([])).toBeNull(); + }); + + it("returns the version when all tools share the same version", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + ]; + expect(getMajorityVersion(tools)).toBe("3.1.3"); + }); + + it("returns the majority version when mixed", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + createTool("Github.GetStar@3.1.3"), + createTool("Github.ListStars@3.1.3"), + createTool("Github.GetNotificationSummary@2.0.1"), + createTool("Github.ListNotifications@2.0.1"), + ]; + expect(getMajorityVersion(tools)).toBe("3.1.3"); + }); + + it("returns the version for a single tool", () => { + const tools = [createTool("Github.CreateIssue@1.0.0")]; + expect(getMajorityVersion(tools)).toBe("1.0.0"); + }); + + it("breaks ties by picking the lexicographically higher version", () => { + const tools = [ + createTool("Github.CreateIssue@1.0.0"), + createTool("Github.ListPullRequests@1.0.0"), + createTool("Github.SetStarred@2.0.0"), + createTool("Github.GetStar@2.0.0"), + ]; + expect(getMajorityVersion(tools)).toBe("2.0.0"); + }); +}); + +describe("filterToolsByMajorityVersion", () => { + it("returns the same array reference when all tools share the same version", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + ]; + const result = filterToolsByMajorityVersion(tools); + expect(result).toBe(tools); // same reference + expect(result).toHaveLength(3); + }); + + it("filters out minority-version tools", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + createTool("Github.GetStar@3.1.3"), + createTool("Github.ListStars@3.1.3"), + createTool("Github.GetNotificationSummary@2.0.1"), + createTool("Github.ListNotifications@2.0.1"), + ]; + const result = filterToolsByMajorityVersion(tools); + expect(result).toHaveLength(5); + expect(result.every((t) => t.fullyQualifiedName.endsWith("@3.1.3"))).toBe( + true + ); + }); + + it("returns the original array for empty input", () => { + const tools: ToolDefinition[] = []; + const result = filterToolsByMajorityVersion(tools); + expect(result).toBe(tools); + expect(result).toHaveLength(0); + }); + + it("returns a single tool unchanged", () => { + const tools = [createTool("Github.CreateIssue@1.0.0")]; + const result = filterToolsByMajorityVersion(tools); + expect(result).toBe(tools); + expect(result).toHaveLength(1); + }); + + it("breaks ties by keeping tools at the higher version", () => { + const tools = [ + createTool("Github.CreateIssue@1.0.0"), + createTool("Github.ListPullRequests@1.0.0"), + createTool("Github.SetStarred@2.0.0"), + createTool("Github.GetStar@2.0.0"), + ]; + const result = filterToolsByMajorityVersion(tools); + expect(result).toHaveLength(2); + expect(result.every((t) => t.fullyQualifiedName.endsWith("@2.0.0"))).toBe( + true + ); + }); + + it("handles tools with no @ version gracefully", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + { + ...createTool("Github.Broken@0.0.0"), + fullyQualifiedName: "Github.Broken", + }, + ]; + const result = filterToolsByMajorityVersion(tools); + expect(result).toHaveLength(2); + expect(result.every((t) => t.fullyQualifiedName.endsWith("@3.1.3"))).toBe( + true + ); + }); +}); From ffd37c4515401871259a861c7b0e58d7a697c09e Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 9 Apr 2026 15:38:27 -0300 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20dedup?= =?UTF-8?q?licate=20extractVersion,=20fix=20semver=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/merger/data-merger.ts | 12 ++++------ .../src/sources/toolkit-data-source.ts | 2 +- toolkit-docs-generator/src/utils/fp.ts | 9 +++++++ .../src/utils/version-coherence.ts | 24 +++++++++++++------ .../tests/utils/version-coherence.test.ts | 14 +++++++++-- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/toolkit-docs-generator/src/merger/data-merger.ts b/toolkit-docs-generator/src/merger/data-merger.ts index 1be878d6e..4b12f1d0d 100644 --- a/toolkit-docs-generator/src/merger/data-merger.ts +++ b/toolkit-docs-generator/src/merger/data-merger.ts @@ -23,6 +23,7 @@ import type { ToolkitMetadata, } from "../types/index.js"; import { mapWithConcurrency } from "../utils/concurrency.js"; +import { extractVersion } from "../utils/fp.js"; import { detectMetadataChanges, formatFreshnessWarnings, @@ -340,13 +341,10 @@ export const getProviderId = ( return toolWithAuth?.auth?.providerId ?? null; }; -/** - * Extract version from fully qualified name - */ -export const extractVersion = (fullyQualifiedName: string): string => { - const parts = fullyQualifiedName.split("@"); - return parts[1] ?? "0.0.0"; -}; +// Re-export extractVersion so existing consumers (e.g. toolkit-diff.ts) +// that import it from this module continue to work. +export { extractVersion } from "../utils/fp.js"; + /** * Create default metadata for toolkits not found in Design System */ diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index e4cacfd02..1b3b156ad 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -174,7 +174,7 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { for (const [toolkitId, tools] of toolkitGroups) { const filtered = filterToolsByMajorityVersion(tools); if (filtered !== tools) { - toolkitGroups.set(toolkitId, [...filtered]); + toolkitGroups.set(toolkitId, filtered as ToolDefinition[]); } } diff --git a/toolkit-docs-generator/src/utils/fp.ts b/toolkit-docs-generator/src/utils/fp.ts index 19061323c..ae83c9f3f 100644 --- a/toolkit-docs-generator/src/utils/fp.ts +++ b/toolkit-docs-generator/src/utils/fp.ts @@ -159,6 +159,15 @@ export const deepMerge = ( return result; }; +/** + * Extract version from a fully qualified tool name. + * "Github.CreateIssue@3.1.3" → "3.1.3" + */ +export const extractVersion = (fullyQualifiedName: string): string => { + const parts = fullyQualifiedName.split("@"); + return parts[1] ?? "0.0.0"; +}; + /** * Normalize a string for comparison (lowercase, remove hyphens/underscores) */ diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index b2443be4f..c8686dac2 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -1,17 +1,24 @@ import type { ToolDefinition } from "../types/index.js"; +import { extractVersion } from "./fp.js"; /** - * Extract version from a fully qualified tool name. - * "Github.CreateIssue@3.1.3" → "3.1.3" + * Compare two semver-like version strings numerically. + * Returns a positive number if a > b, negative if a < b, 0 if equal. */ -const extractVersion = (fullyQualifiedName: string): string => { - const parts = fullyQualifiedName.split("@"); - return parts[1] ?? "0.0.0"; +const compareVersions = (a: string, b: string): number => { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + const len = Math.max(aParts.length, bParts.length); + for (let i = 0; i < len; i++) { + const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; }; /** * Compute the version shared by the most tools in a toolkit. - * Ties are broken by lexicographic comparison (highest version wins). + * Ties are broken by numeric semver comparison (highest version wins). */ export const getMajorityVersion = ( tools: readonly ToolDefinition[] @@ -29,7 +36,10 @@ export const getMajorityVersion = ( let bestVersion = ""; let bestCount = 0; for (const [version, count] of counts) { - if (count > bestCount || (count === bestCount && version > bestVersion)) { + if ( + count > bestCount || + (count === bestCount && compareVersions(version, bestVersion) > 0) + ) { bestVersion = version; bestCount = count; } diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index 6d82b7bfb..9a3dedcbe 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -55,7 +55,7 @@ describe("getMajorityVersion", () => { expect(getMajorityVersion(tools)).toBe("1.0.0"); }); - it("breaks ties by picking the lexicographically higher version", () => { + it("breaks ties by picking the numerically higher version", () => { const tools = [ createTool("Github.CreateIssue@1.0.0"), createTool("Github.ListPullRequests@1.0.0"), @@ -64,6 +64,16 @@ describe("getMajorityVersion", () => { ]; expect(getMajorityVersion(tools)).toBe("2.0.0"); }); + + it("handles multi-digit version components correctly in tie-break", () => { + const tools = [ + createTool("Github.CreateIssue@9.0.0"), + createTool("Github.ListPullRequests@9.0.0"), + createTool("Github.SetStarred@10.0.0"), + createTool("Github.GetStar@10.0.0"), + ]; + expect(getMajorityVersion(tools)).toBe("10.0.0"); + }); }); describe("filterToolsByMajorityVersion", () => { @@ -109,7 +119,7 @@ describe("filterToolsByMajorityVersion", () => { expect(result).toHaveLength(1); }); - it("breaks ties by keeping tools at the higher version", () => { + it("breaks ties by keeping tools at the numerically higher version", () => { const tools = [ createTool("Github.CreateIssue@1.0.0"), createTool("Github.ListPullRequests@1.0.0"), From 651b4be0f8e46ebdc5f8c2dca30dca578f9e4fb7 Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 9 Apr 2026 15:45:14 -0300 Subject: [PATCH 3/6] 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) --- .../src/utils/version-coherence.ts | 26 ++++++++++++++-- .../tests/utils/version-coherence.test.ts | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index c8686dac2..a61c9080c 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -2,12 +2,32 @@ import type { ToolDefinition } from "../types/index.js"; import { extractVersion } from "./fp.js"; /** - * Compare two semver-like version strings numerically. + * Parse the numeric MAJOR.MINOR.PATCH tuple from a semver string, + * stripping pre-release (`-alpha.1`) and build metadata (`+build.456`). + * + * Handles formats produced by arcade-mcp's normalize_version(): + * "3.1.3", "1.2.3-beta.1", "1.2.3+build.456", "1.2.3-rc.1+build.789" + */ +const parseNumericVersion = (version: string): number[] => { + // Strip build metadata (after +) then pre-release (after -) + const core = version.split("+")[0]?.split("-")[0] ?? version; + return core.split(".").map((s) => { + const n = Number(s); + return Number.isNaN(n) ? 0 : n; + }); +}; + +/** + * Compare two semver version strings numerically by MAJOR.MINOR.PATCH. + * Pre-release and build metadata are ignored for ordering purposes + * (they are unlikely to appear in Engine API responses, but we handle + * them defensively since arcade-mcp's semver allows them). + * * Returns a positive number if a > b, negative if a < b, 0 if equal. */ const compareVersions = (a: string, b: string): number => { - const aParts = a.split(".").map(Number); - const bParts = b.split(".").map(Number); + const aParts = parseNumericVersion(a); + const bParts = parseNumericVersion(b); const len = Math.max(aParts.length, bParts.length); for (let i = 0; i < len; i++) { const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0); diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index 9a3dedcbe..3b172db1e 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -74,6 +74,36 @@ describe("getMajorityVersion", () => { ]; expect(getMajorityVersion(tools)).toBe("10.0.0"); }); + + it("handles pre-release versions in tie-break comparison", () => { + const tools = [ + createTool("Github.CreateIssue@1.0.0-alpha.1"), + createTool("Github.ListPullRequests@1.0.0-alpha.1"), + createTool("Github.SetStarred@2.0.0-beta.1"), + createTool("Github.GetStar@2.0.0-beta.1"), + ]; + expect(getMajorityVersion(tools)).toBe("2.0.0-beta.1"); + }); + + it("handles build metadata versions in tie-break comparison", () => { + const tools = [ + createTool("Github.CreateIssue@1.0.0+build.456"), + createTool("Github.ListPullRequests@1.0.0+build.456"), + createTool("Github.SetStarred@3.0.0+build.789"), + createTool("Github.GetStar@3.0.0+build.789"), + ]; + expect(getMajorityVersion(tools)).toBe("3.0.0+build.789"); + }); + + it("handles pre-release + build metadata combined", () => { + const tools = [ + createTool("Github.CreateIssue@1.2.3-rc.1+build.789"), + createTool("Github.ListPullRequests@1.2.3-rc.1+build.789"), + createTool("Github.SetStarred@4.0.0-beta.1+build.123"), + createTool("Github.GetStar@4.0.0-beta.1+build.123"), + ]; + expect(getMajorityVersion(tools)).toBe("4.0.0-beta.1+build.123"); + }); }); describe("filterToolsByMajorityVersion", () => { From ea5ab6b9348e7b9ee24b6512d916cfc489e09cdf Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 9 Apr 2026 17:20:57 -0300 Subject: [PATCH 4/6] fix: use highest version, not majority count, to filter stale tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/sources/toolkit-data-source.ts | 12 +- toolkit-docs-generator/src/utils/index.ts | 4 +- .../src/utils/version-coherence.ts | 46 ++++---- .../scenarios/stale-version-tools.test.ts | 8 +- .../tests/sources/toolkit-data-source.test.ts | 6 +- .../tests/utils/version-coherence.test.ts | 105 ++++++++++-------- 6 files changed, 91 insertions(+), 90 deletions(-) diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 1b3b156ad..52e85c386 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -9,7 +9,7 @@ import { join } from "path"; import type { ToolDefinition, ToolkitMetadata } from "../types/index.js"; import { normalizeId } from "../utils/fp.js"; -import { filterToolsByMajorityVersion } from "../utils/version-coherence.js"; +import { filterToolsByHighestVersion } from "../utils/version-coherence.js"; import { type ArcadeApiSourceConfig, createArcadeApiSource, @@ -131,14 +131,14 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { } } - // Filter tools by version if specified, otherwise apply majority-version - // coherence to drop stale tools from older releases that Engine still serves. + // Filter tools by version if specified, otherwise keep only the highest + // version to drop stale tools from older releases that Engine still serves. const filteredTools = version ? tools.filter((tool) => { const toolVersion = tool.fullyQualifiedName.split("@")[1]; return toolVersion === version; }) - : filterToolsByMajorityVersion(tools); + : filterToolsByHighestVersion(tools); return { tools: filteredTools, @@ -169,10 +169,10 @@ export class CombinedToolkitDataSource implements IToolkitDataSource { metadataMap.set(metadata.id, metadata); } - // Filter each toolkit to its majority version to drop stale + // Filter each toolkit to its highest version to drop stale // tools from older releases that Engine still serves. for (const [toolkitId, tools] of toolkitGroups) { - const filtered = filterToolsByMajorityVersion(tools); + const filtered = filterToolsByHighestVersion(tools); if (filtered !== tools) { toolkitGroups.set(toolkitId, filtered as ToolDefinition[]); } diff --git a/toolkit-docs-generator/src/utils/index.ts b/toolkit-docs-generator/src/utils/index.ts index e419f363e..39d7aa24d 100644 --- a/toolkit-docs-generator/src/utils/index.ts +++ b/toolkit-docs-generator/src/utils/index.ts @@ -11,6 +11,6 @@ export * from "./logger.js"; export * from "./progress.js"; export * from "./retry.js"; export { - filterToolsByMajorityVersion, - getMajorityVersion, + filterToolsByHighestVersion, + getHighestVersion, } from "./version-coherence.js"; diff --git a/toolkit-docs-generator/src/utils/version-coherence.ts b/toolkit-docs-generator/src/utils/version-coherence.ts index a61c9080c..47ffedc82 100644 --- a/toolkit-docs-generator/src/utils/version-coherence.ts +++ b/toolkit-docs-generator/src/utils/version-coherence.ts @@ -37,59 +37,51 @@ const compareVersions = (a: string, b: string): number => { }; /** - * Compute the version shared by the most tools in a toolkit. - * Ties are broken by numeric semver comparison (highest version wins). + * Find the highest version among all tools in a toolkit. + * This is the version we keep — stale tools from older releases are dropped. */ -export const getMajorityVersion = ( +export const getHighestVersion = ( tools: readonly ToolDefinition[] ): string | null => { if (tools.length === 0) { return null; } - const counts = new Map(); + let best = ""; for (const tool of tools) { const version = extractVersion(tool.fullyQualifiedName); - counts.set(version, (counts.get(version) ?? 0) + 1); - } - - let bestVersion = ""; - let bestCount = 0; - for (const [version, count] of counts) { - if ( - count > bestCount || - (count === bestCount && compareVersions(version, bestVersion) > 0) - ) { - bestVersion = version; - bestCount = count; + if (best === "" || compareVersions(version, best) > 0) { + best = version; } } - return bestVersion || null; + return best || null; }; /** - * Keep only tools whose @version matches the majority version for - * their toolkit. If all tools share the same version (the common + * Keep only tools whose @version matches the highest version for + * their toolkit. If all tools share the same version (the common * case), returns the original array unchanged. + * + * This drops stale tools from older releases that Engine still serves, + * while always preserving the newest version — even if it has fewer tools + * (e.g. tools were removed/consolidated in the new release). */ -export const filterToolsByMajorityVersion = ( +export const filterToolsByHighestVersion = ( tools: readonly ToolDefinition[] ): readonly ToolDefinition[] => { - const majorityVersion = getMajorityVersion(tools); - if (majorityVersion === null) { + const highest = getHighestVersion(tools); + if (highest === null) { return tools; } - // Fast path: if every tool is already at the majority version, skip filtering + // Fast path: if every tool is already at the highest version, skip filtering const allSame = tools.every( - (t) => extractVersion(t.fullyQualifiedName) === majorityVersion + (t) => extractVersion(t.fullyQualifiedName) === highest ); if (allSame) { return tools; } - return tools.filter( - (t) => extractVersion(t.fullyQualifiedName) === majorityVersion - ); + return tools.filter((t) => extractVersion(t.fullyQualifiedName) === highest); }; diff --git a/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts b/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts index f94b83a2c..49d2dcc9f 100644 --- a/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts +++ b/toolkit-docs-generator/tests/scenarios/stale-version-tools.test.ts @@ -2,7 +2,7 @@ * Scenario Test: Stale version tools are filtered out * * Verifies that when the Engine API returns tools at mixed versions for the - * same toolkit, the majority-version coherence filter drops stale tools before + * same toolkit, the highest-version coherence filter drops stale tools before * they reach the diff or merger. */ import { describe, expect, it } from "vitest"; @@ -71,8 +71,8 @@ const createMergedToolkit = ( generatedAt: new Date().toISOString(), }); -describe("Scenario: Stale version tools filtered by majority-version coherence", () => { - it("fetchAllToolkitsData drops stale tools at non-majority versions", async () => { +describe("Scenario: Stale version tools filtered by highest-version coherence", () => { + it("fetchAllToolkitsData drops stale tools at older versions", async () => { // Simulate Engine API returning mixed-version Github tools const toolSource = new InMemoryToolDataSource([ createTool({ @@ -186,7 +186,7 @@ describe("Scenario: Stale version tools filtered by majority-version coherence", ], ]); - // Previous output already had only the majority-version tools + // Previous output already had only the highest-version tools const previousToolkits = new Map([ [ "Github", diff --git a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts index df399eaff..5b014cba6 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -160,7 +160,7 @@ describe("CombinedToolkitDataSource", () => { expect(weaviate?.metadata?.label).toBe("Weaviate API"); }); - it("should filter out stale tools at non-majority versions in fetchAllToolkitsData", async () => { + it("should filter out stale tools at older versions in fetchAllToolkitsData", async () => { const toolSource = new InMemoryToolDataSource([ createTool({ name: "CreateIssue", @@ -193,7 +193,7 @@ describe("CombinedToolkitDataSource", () => { ).toBe(true); }); - it("should apply majority-version filter in fetchToolkitData when no version specified", async () => { + it("should apply highest-version filter in fetchToolkitData when no version specified", async () => { const toolSource = new InMemoryToolDataSource([ createTool({ name: "CreateIssue", @@ -244,7 +244,7 @@ describe("CombinedToolkitDataSource", () => { metadataSource, }); - // Explicit version bypasses majority filter + // Explicit version bypasses highest-version filter const result = await dataSource.fetchToolkitData("Github", "2.0.1"); expect(result.tools).toHaveLength(1); diff --git a/toolkit-docs-generator/tests/utils/version-coherence.test.ts b/toolkit-docs-generator/tests/utils/version-coherence.test.ts index 3b172db1e..e399c859b 100644 --- a/toolkit-docs-generator/tests/utils/version-coherence.test.ts +++ b/toolkit-docs-generator/tests/utils/version-coherence.test.ts @@ -1,11 +1,11 @@ /** - * Tests for majority-version coherence filter + * Tests for highest-version coherence filter */ import { describe, expect, it } from "vitest"; import type { ToolDefinition } from "../../src/types/index.js"; import { - filterToolsByMajorityVersion, - getMajorityVersion, + filterToolsByHighestVersion, + getHighestVersion, } from "../../src/utils/version-coherence.js"; const createTool = ( @@ -23,9 +23,9 @@ const createTool = ( ...overrides, }); -describe("getMajorityVersion", () => { +describe("getHighestVersion", () => { it("returns null for empty array", () => { - expect(getMajorityVersion([])).toBeNull(); + expect(getHighestVersion([])).toBeNull(); }); it("returns the version when all tools share the same version", () => { @@ -34,10 +34,10 @@ describe("getMajorityVersion", () => { createTool("Github.ListPullRequests@3.1.3"), createTool("Github.SetStarred@3.1.3"), ]; - expect(getMajorityVersion(tools)).toBe("3.1.3"); + expect(getHighestVersion(tools)).toBe("3.1.3"); }); - it("returns the majority version when mixed", () => { + it("returns the highest version when mixed", () => { const tools = [ createTool("Github.CreateIssue@3.1.3"), createTool("Github.ListPullRequests@3.1.3"), @@ -47,78 +47,84 @@ describe("getMajorityVersion", () => { createTool("Github.GetNotificationSummary@2.0.1"), createTool("Github.ListNotifications@2.0.1"), ]; - expect(getMajorityVersion(tools)).toBe("3.1.3"); + expect(getHighestVersion(tools)).toBe("3.1.3"); }); it("returns the version for a single tool", () => { const tools = [createTool("Github.CreateIssue@1.0.0")]; - expect(getMajorityVersion(tools)).toBe("1.0.0"); + expect(getHighestVersion(tools)).toBe("1.0.0"); }); - it("breaks ties by picking the numerically higher version", () => { + it("picks the numerically higher version regardless of tool count", () => { const tools = [ createTool("Github.CreateIssue@1.0.0"), createTool("Github.ListPullRequests@1.0.0"), createTool("Github.SetStarred@2.0.0"), - createTool("Github.GetStar@2.0.0"), ]; - expect(getMajorityVersion(tools)).toBe("2.0.0"); + expect(getHighestVersion(tools)).toBe("2.0.0"); }); - it("handles multi-digit version components correctly in tie-break", () => { + it("picks newer version even when it has fewer tools", () => { + // New release @4.0.0 consolidated tools — only 2 remain. + // Old stale tools @3.1.3 still outnumber it. Highest version wins. + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + createTool("Github.GetStar@3.1.3"), + createTool("Github.ListStars@3.1.3"), + createTool("Github.Search@4.0.0"), + createTool("Github.Advanced@4.0.0"), + ]; + expect(getHighestVersion(tools)).toBe("4.0.0"); + }); + + it("handles multi-digit version components correctly", () => { const tools = [ createTool("Github.CreateIssue@9.0.0"), - createTool("Github.ListPullRequests@9.0.0"), createTool("Github.SetStarred@10.0.0"), - createTool("Github.GetStar@10.0.0"), ]; - expect(getMajorityVersion(tools)).toBe("10.0.0"); + expect(getHighestVersion(tools)).toBe("10.0.0"); }); - it("handles pre-release versions in tie-break comparison", () => { + it("handles pre-release versions", () => { const tools = [ createTool("Github.CreateIssue@1.0.0-alpha.1"), - createTool("Github.ListPullRequests@1.0.0-alpha.1"), createTool("Github.SetStarred@2.0.0-beta.1"), - createTool("Github.GetStar@2.0.0-beta.1"), ]; - expect(getMajorityVersion(tools)).toBe("2.0.0-beta.1"); + expect(getHighestVersion(tools)).toBe("2.0.0-beta.1"); }); - it("handles build metadata versions in tie-break comparison", () => { + it("handles build metadata versions", () => { const tools = [ createTool("Github.CreateIssue@1.0.0+build.456"), - createTool("Github.ListPullRequests@1.0.0+build.456"), createTool("Github.SetStarred@3.0.0+build.789"), - createTool("Github.GetStar@3.0.0+build.789"), ]; - expect(getMajorityVersion(tools)).toBe("3.0.0+build.789"); + expect(getHighestVersion(tools)).toBe("3.0.0+build.789"); }); it("handles pre-release + build metadata combined", () => { const tools = [ createTool("Github.CreateIssue@1.2.3-rc.1+build.789"), - createTool("Github.ListPullRequests@1.2.3-rc.1+build.789"), createTool("Github.SetStarred@4.0.0-beta.1+build.123"), - createTool("Github.GetStar@4.0.0-beta.1+build.123"), ]; - expect(getMajorityVersion(tools)).toBe("4.0.0-beta.1+build.123"); + expect(getHighestVersion(tools)).toBe("4.0.0-beta.1+build.123"); }); }); -describe("filterToolsByMajorityVersion", () => { +describe("filterToolsByHighestVersion", () => { it("returns the same array reference when all tools share the same version", () => { const tools = [ createTool("Github.CreateIssue@3.1.3"), createTool("Github.ListPullRequests@3.1.3"), createTool("Github.SetStarred@3.1.3"), ]; - const result = filterToolsByMajorityVersion(tools); + const result = filterToolsByHighestVersion(tools); expect(result).toBe(tools); // same reference expect(result).toHaveLength(3); }); - it("filters out minority-version tools", () => { + it("filters out older-version tools", () => { const tools = [ createTool("Github.CreateIssue@3.1.3"), createTool("Github.ListPullRequests@3.1.3"), @@ -128,41 +134,44 @@ describe("filterToolsByMajorityVersion", () => { createTool("Github.GetNotificationSummary@2.0.1"), createTool("Github.ListNotifications@2.0.1"), ]; - const result = filterToolsByMajorityVersion(tools); + const result = filterToolsByHighestVersion(tools); expect(result).toHaveLength(5); expect(result.every((t) => t.fullyQualifiedName.endsWith("@3.1.3"))).toBe( true ); }); + it("keeps newer version even when it has fewer tools", () => { + const tools = [ + createTool("Github.CreateIssue@3.1.3"), + createTool("Github.ListPullRequests@3.1.3"), + createTool("Github.SetStarred@3.1.3"), + createTool("Github.GetStar@3.1.3"), + createTool("Github.ListStars@3.1.3"), + createTool("Github.Search@4.0.0"), + createTool("Github.Advanced@4.0.0"), + ]; + const result = filterToolsByHighestVersion(tools); + expect(result).toHaveLength(2); + expect(result.every((t) => t.fullyQualifiedName.endsWith("@4.0.0"))).toBe( + true + ); + }); + it("returns the original array for empty input", () => { const tools: ToolDefinition[] = []; - const result = filterToolsByMajorityVersion(tools); + const result = filterToolsByHighestVersion(tools); expect(result).toBe(tools); expect(result).toHaveLength(0); }); it("returns a single tool unchanged", () => { const tools = [createTool("Github.CreateIssue@1.0.0")]; - const result = filterToolsByMajorityVersion(tools); + const result = filterToolsByHighestVersion(tools); expect(result).toBe(tools); expect(result).toHaveLength(1); }); - it("breaks ties by keeping tools at the numerically higher version", () => { - const tools = [ - createTool("Github.CreateIssue@1.0.0"), - createTool("Github.ListPullRequests@1.0.0"), - createTool("Github.SetStarred@2.0.0"), - createTool("Github.GetStar@2.0.0"), - ]; - const result = filterToolsByMajorityVersion(tools); - expect(result).toHaveLength(2); - expect(result.every((t) => t.fullyQualifiedName.endsWith("@2.0.0"))).toBe( - true - ); - }); - it("handles tools with no @ version gracefully", () => { const tools = [ createTool("Github.CreateIssue@3.1.3"), @@ -172,7 +181,7 @@ describe("filterToolsByMajorityVersion", () => { fullyQualifiedName: "Github.Broken", }, ]; - const result = filterToolsByMajorityVersion(tools); + const result = filterToolsByHighestVersion(tools); expect(result).toHaveLength(2); expect(result.every((t) => t.fullyQualifiedName.endsWith("@3.1.3"))).toBe( true From 498d9e8862931b56bbd3a1f6caaab5e4cb09c848 Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 10 Apr 2026 12:52:05 -0300 Subject: [PATCH 5/6] 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) --- toolkit-docs-generator/src/merger/data-merger.ts | 4 ---- toolkit-docs-generator/tests/merger/data-merger.test.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/toolkit-docs-generator/src/merger/data-merger.ts b/toolkit-docs-generator/src/merger/data-merger.ts index 4b12f1d0d..e373d51a7 100644 --- a/toolkit-docs-generator/src/merger/data-merger.ts +++ b/toolkit-docs-generator/src/merger/data-merger.ts @@ -341,10 +341,6 @@ export const getProviderId = ( return toolWithAuth?.auth?.providerId ?? null; }; -// Re-export extractVersion so existing consumers (e.g. toolkit-diff.ts) -// that import it from this module continue to work. -export { extractVersion } from "../utils/fp.js"; - /** * Create default metadata for toolkits not found in Design System */ diff --git a/toolkit-docs-generator/tests/merger/data-merger.test.ts b/toolkit-docs-generator/tests/merger/data-merger.test.ts index 124541450..bb4991b3f 100644 --- a/toolkit-docs-generator/tests/merger/data-merger.test.ts +++ b/toolkit-docs-generator/tests/merger/data-merger.test.ts @@ -9,7 +9,6 @@ import { computeAllScopes, DataMerger, determineAuthType, - extractVersion, getProviderId, groupToolsByToolkit, mergeToolkit, @@ -32,6 +31,7 @@ import type { ToolDefinition, ToolkitMetadata, } from "../../src/types/index.js"; +import { extractVersion } from "../../src/utils/index.js"; // ============================================================================ // Test Fixtures - Realistic data matching production schema From 5947ddeba90ead27015e9534cd101d034f1c5e91 Mon Sep 17 00:00:00 2001 From: jottakka Date: Fri, 10 Apr 2026 13:04:24 -0300 Subject: [PATCH 6/6] 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) --- toolkit-docs-generator/src/diff/toolkit-diff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkit-docs-generator/src/diff/toolkit-diff.ts b/toolkit-docs-generator/src/diff/toolkit-diff.ts index 1c19d6432..d8b04911d 100644 --- a/toolkit-docs-generator/src/diff/toolkit-diff.ts +++ b/toolkit-docs-generator/src/diff/toolkit-diff.ts @@ -7,7 +7,6 @@ import { buildComparableToolSignature, - extractVersion, stableStringify, } from "../merger/data-merger.js"; import type { @@ -16,6 +15,7 @@ import type { ToolDefinition, ToolkitMetadata, } from "../types/index.js"; +import { extractVersion } from "../utils/index.js"; // ============================================================================ // Types