diff --git a/toolkit-docs-generator/src/cli/generate-flow.ts b/toolkit-docs-generator/src/cli/generate-flow.ts index 96f2885f0..194634e19 100644 --- a/toolkit-docs-generator/src/cli/generate-flow.ts +++ b/toolkit-docs-generator/src/cli/generate-flow.ts @@ -15,6 +15,21 @@ export const collectRemovedToolkitIds = ( .map((c) => c.toolkitId.toLowerCase()) ); +/** + * Protect incremental runs from treating a transient empty API response as a + * request to delete every previously generated toolkit. + */ +export const assertSafeCurrentToolkitSnapshot = ( + currentToolkitCount: number, + previousToolkitCount: number +): void => { + if (currentToolkitCount === 0 && previousToolkitCount > 0) { + throw new Error( + `Current toolkit snapshot is empty; refusing to remove all ${previousToolkitCount} existing toolkits.` + ); + } +}; + export interface ProcessingStats { totalToolkits: number; effectiveSkipped: number; diff --git a/toolkit-docs-generator/src/cli/index.ts b/toolkit-docs-generator/src/cli/index.ts index bb05939f6..565bf37eb 100644 --- a/toolkit-docs-generator/src/cli/index.ts +++ b/toolkit-docs-generator/src/cli/index.ts @@ -46,6 +46,7 @@ import { createMockMetadataSource } from "../sources/mock-metadata.js"; import { createDesignSystemProviderIdResolver } from "../sources/oauth-provider-resolver.js"; import { createArcadeToolkitDataSource, + createCachedToolkitDataSource, createEngineToolkitDataSource, createMockToolkitDataSource, type IToolkitDataSource, @@ -65,6 +66,7 @@ import { } from "../utils/run-logs.js"; import { cleanupExcludedToolkitOutput } from "./exclusion-cleanup.js"; import { + assertSafeCurrentToolkitSnapshot, collectRemovedToolkitIds, computeProcessingStats, filterProvidersBySkipIds, @@ -1209,13 +1211,15 @@ program // Create toolkit data source based on API source const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - options.verbose, - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + options.verbose, + spinner + ) ); const needsExamples = !options.skipExamples; @@ -1386,6 +1390,10 @@ program } currentToolkitDataForDiff.set(id, data); } + assertSafeCurrentToolkitSnapshot( + currentToolkitDataForDiff.size, + previousToolkits?.size ?? 0 + ); // Detect changes const compareStartedAt = Date.now(); @@ -2144,13 +2152,15 @@ program // Create toolkit data source based on API source const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - options.verbose, - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + options.verbose, + spinner + ) ); const needsExamples = !options.skipExamples; @@ -2727,13 +2737,15 @@ program }); const apiSource = resolveApiSource(options); - const toolkitDataSource = createToolkitDataSourceForApi( - apiSource, - options, - metadataSource, - mockDataDir, - false, // not verbose during fetch - spinner + const toolkitDataSource = createCachedToolkitDataSource( + createToolkitDataSourceForApi( + apiSource, + options, + metadataSource, + mockDataDir, + false, // not verbose during fetch + spinner + ) ); // Fetch current data from API @@ -2759,6 +2771,11 @@ program const loadPreviousDurationMs = Date.now() - loadPreviousStartedAt; const previousToolkits = previousToolkitLoad.toolkits; + assertSafeCurrentToolkitSnapshot( + currentToolkitDataForDiff.size, + previousToolkits.size + ); + // Detect changes spinner.text = "Comparing tool definitions..."; const compareStartedAt = Date.now(); diff --git a/toolkit-docs-generator/src/sources/toolkit-data-source.ts b/toolkit-docs-generator/src/sources/toolkit-data-source.ts index 3ee4e1fcf..1b6b191a6 100644 --- a/toolkit-docs-generator/src/sources/toolkit-data-source.ts +++ b/toolkit-docs-generator/src/sources/toolkit-data-source.ts @@ -75,6 +75,30 @@ export interface IToolkitDataSource { readonly isAvailable: () => Promise; } +/** + * Reuse one all-toolkit snapshot for the lifetime of a generation run. + * + * Change detection, progress setup, and merging all consume the same data + * instead of issuing independent API reads that can disagree mid-run. + */ +export const createCachedToolkitDataSource = ( + source: IToolkitDataSource +): IToolkitDataSource => { + let allToolkitsSnapshot: + | Promise> + | undefined; + + return { + fetchToolkitData: (toolkitId, version) => + source.fetchToolkitData(toolkitId, version), + fetchAllToolkitsData: () => { + allToolkitsSnapshot ??= source.fetchAllToolkitsData(); + return allToolkitsSnapshot; + }, + isAvailable: () => source.isAvailable(), + }; +}; + // ============================================================================ // Combined Implementation (Current: Separate Sources) // ============================================================================ diff --git a/toolkit-docs-generator/tests/cli/generate-flow.test.ts b/toolkit-docs-generator/tests/cli/generate-flow.test.ts index bdb2044ea..12440b99a 100644 --- a/toolkit-docs-generator/tests/cli/generate-flow.test.ts +++ b/toolkit-docs-generator/tests/cli/generate-flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + assertSafeCurrentToolkitSnapshot, collectRemovedToolkitIds, computeProcessingStats, filterProvidersBySkipIds, @@ -72,6 +73,19 @@ describe("collectRemovedToolkitIds", () => { }); }); +describe("assertSafeCurrentToolkitSnapshot", () => { + it("rejects an empty current snapshot when previous output exists", () => { + expect(() => assertSafeCurrentToolkitSnapshot(0, 115)).toThrow( + "refusing to remove all 115 existing toolkits" + ); + }); + + it("allows an empty initial snapshot and non-empty updates", () => { + expect(() => assertSafeCurrentToolkitSnapshot(0, 0)).not.toThrow(); + expect(() => assertSafeCurrentToolkitSnapshot(114, 115)).not.toThrow(); + }); +}); + describe("computeProcessingStats", () => { it("ignores skip IDs that do not exist in the fetched toolkit list", () => { const toolkitList = new Map([ 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 6e75e049d..99d93ee31 100644 --- a/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts +++ b/toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts @@ -11,7 +11,11 @@ import { InMemoryToolDataSource, } from "../../src/sources/in-memory.js"; import type { IMetadataSource } from "../../src/sources/internal.js"; -import { createCombinedToolkitDataSource } from "../../src/sources/toolkit-data-source.js"; +import { + createCachedToolkitDataSource, + createCombinedToolkitDataSource, + type IToolkitDataSource, +} from "../../src/sources/toolkit-data-source.js"; import type { ToolDefinition, ToolkitMetadata } from "../../src/types/index.js"; const createTool = ( @@ -406,3 +410,42 @@ describe("CombinedToolkitDataSource", () => { expect(lookedUpIds).toEqual([]); }); }); + +describe("createCachedToolkitDataSource", () => { + it("reuses one immutable all-toolkit snapshot within a run", async () => { + const snapshot = new Map([ + [ + "Github", + { + tools: [createTool()], + metadata: createMetadata(), + }, + ], + ]); + const githubData = snapshot.get("Github"); + if (!githubData) { + throw new Error("Expected GitHub fixture"); + } + let fetchAllCalls = 0; + const source: IToolkitDataSource = { + fetchToolkitData: async () => githubData, + fetchAllToolkitsData: async () => { + fetchAllCalls += 1; + return snapshot; + }, + isAvailable: async () => true, + }; + const cached = createCachedToolkitDataSource(source); + + const [first, second, third] = await Promise.all([ + cached.fetchAllToolkitsData(), + cached.fetchAllToolkitsData(), + cached.fetchAllToolkitsData(), + ]); + + expect(fetchAllCalls).toBe(1); + expect(first).toBe(snapshot); + expect(second).toBe(first); + expect(third).toBe(first); + }); +});