Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions toolkit-docs-generator/src/cli/generate-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 38 additions & 21 deletions toolkit-docs-generator/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,6 +66,7 @@ import {
} from "../utils/run-logs.js";
import { cleanupExcludedToolkitOutput } from "./exclusion-cleanup.js";
import {
assertSafeCurrentToolkitSnapshot,
collectRemovedToolkitIds,
computeProcessingStats,
filterProvidersBySkipIds,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1386,6 +1390,10 @@ program
}
currentToolkitDataForDiff.set(id, data);
}
assertSafeCurrentToolkitSnapshot(
currentToolkitDataForDiff.size,
previousToolkits?.size ?? 0
);

// Detect changes
const compareStartedAt = Date.now();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions toolkit-docs-generator/src/sources/toolkit-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,30 @@ export interface IToolkitDataSource {
readonly isAvailable: () => Promise<boolean>;
}

/**
* 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<ReadonlyMap<string, ToolkitData>>
| undefined;

return {
fetchToolkitData: (toolkitId, version) =>
source.fetchToolkitData(toolkitId, version),
fetchAllToolkitsData: () => {
allToolkitsSnapshot ??= source.fetchAllToolkitsData();
return allToolkitsSnapshot;
},
isAvailable: () => source.isAvailable(),
};
};

// ============================================================================
// Combined Implementation (Current: Separate Sources)
// ============================================================================
Expand Down
14 changes: 14 additions & 0 deletions toolkit-docs-generator/tests/cli/generate-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
assertSafeCurrentToolkitSnapshot,
collectRemovedToolkitIds,
computeProcessingStats,
filterProvidersBySkipIds,
Expand Down Expand Up @@ -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([
Expand Down
45 changes: 44 additions & 1 deletion toolkit-docs-generator/tests/sources/toolkit-data-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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);
});
});
Loading