diff --git a/.changeset/add-prune-option.md b/.changeset/add-prune-option.md new file mode 100644 index 000000000..62e9fc059 --- /dev/null +++ b/.changeset/add-prune-option.md @@ -0,0 +1,13 @@ +--- +"counterfact": minor +--- + +Added `--prune` option to remove route files that no longer exist in the OpenAPI spec. + +When an OpenAPI spec renames a path parameter (e.g. `/pet/{id}/update/{Name}` → `/pet/{id}/update/{nickname}`), running without `--prune` leaves the old file in place alongside the newly generated one, causing wildcard ambiguity in route matching. The new flag cleans up defunct route files before generation runs. + +```sh +npx counterfact openapi.yaml ./out --generate --prune +``` + +Context files (`_.context.ts`) and empty directories are handled correctly — context files are never pruned, and any directories left empty after pruning are removed automatically. diff --git a/bin/counterfact.js b/bin/counterfact.js index 50fccdb9a..b0c4d25cc 100755 --- a/bin/counterfact.js +++ b/bin/counterfact.js @@ -155,6 +155,8 @@ async function main(source, destination) { options.watch || options.watchTypes || options.buildCache, + + prune: Boolean(options.prune), }, openApiPath: source, @@ -326,5 +328,9 @@ program "--always-fake-optionals", "random responses will include optional fields", ) + .option( + "--prune", + "remove route files that no longer exist in the OpenAPI spec", + ) .action(main) .parse(process.argv); diff --git a/src/server/config.ts b/src/server/config.ts index 6e39cf371..7f2aa316c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -4,6 +4,7 @@ export interface Config { basePath: string; buildCache: boolean; generate: { + prune?: boolean; routes: boolean; types: boolean; }; diff --git a/src/typescript-generator/code-generator.ts b/src/typescript-generator/code-generator.ts index 45487db56..445eb7ee0 100644 --- a/src/typescript-generator/code-generator.ts +++ b/src/typescript-generator/code-generator.ts @@ -9,14 +9,18 @@ export class CodeGenerator extends EventTarget { private readonly destination: string; - private readonly generateOptions: { routes: boolean; types: boolean }; + private readonly generateOptions: { + prune?: boolean; + routes: boolean; + types: boolean; + }; private watcher: FSWatcher | undefined; public constructor( openApiPath: string, destination: string, - generateOptions: { routes: boolean; types: boolean }, + generateOptions: { prune?: boolean; routes: boolean; types: boolean }, ) { super(); this.openapiPath = openApiPath; diff --git a/src/typescript-generator/generate.js b/src/typescript-generator/generate.js index 37be6b3f2..4ec0eef4c 100644 --- a/src/typescript-generator/generate.js +++ b/src/typescript-generator/generate.js @@ -7,6 +7,7 @@ import createDebug from "debug"; import { ensureDirectoryExists } from "../util/ensure-directory-exists.js"; import { OperationCoder } from "./operation-coder.js"; +import { pruneRoutes } from "./prune.js"; import { Repository } from "./repository.js"; import { Specification } from "./specification.js"; @@ -72,6 +73,12 @@ export async function generate( debug("got %i paths", paths.size); + if (generateOptions.prune && generateOptions.routes) { + debug("pruning defunct route files"); + await pruneRoutes(destination, paths.keys()); + debug("done pruning"); + } + const securityRequirement = specification.getRequirement( "#/components/securitySchemes", ); diff --git a/src/typescript-generator/prune.js b/src/typescript-generator/prune.js new file mode 100644 index 000000000..4905dc862 --- /dev/null +++ b/src/typescript-generator/prune.js @@ -0,0 +1,127 @@ +import fs from "node:fs/promises"; +import nodePath from "node:path"; + +import createDebug from "debug"; + +const debug = createDebug("counterfact:typescript-generator:prune"); + +/** + * Collects all .ts route files in a directory recursively. + * Context files (_.context.ts) are excluded. + * @param {string} routesDir - Path to routes directory + * @param {string} currentPath - Current subdirectory being processed (relative to routesDir) + * @returns {Promise} - Array of relative paths (using forward slashes) + */ +async function collectRouteFiles(routesDir, currentPath = "") { + const files = []; + + try { + const fullDir = currentPath + ? nodePath.join(routesDir, currentPath) + : routesDir; + const entries = await fs.readdir(fullDir, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = currentPath + ? `${currentPath}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + files.push(...(await collectRouteFiles(routesDir, relativePath))); + } else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") { + files.push(relativePath); + } + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + + return files; +} + +/** + * Recursively removes empty directories under rootDir, but not rootDir itself. + * @param {string} dir - Directory to check + * @param {string} rootDir - Root directory that should never be removed + */ +async function removeEmptyDirectories(dir, rootDir) { + let entries; + + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + await removeEmptyDirectories(nodePath.join(dir, entry.name), rootDir); + } + } + + if (nodePath.resolve(dir) === nodePath.resolve(rootDir)) { + return; + } + + const remaining = await fs.readdir(dir); + + if (remaining.length === 0) { + await fs.rmdir(dir); + debug("removed empty directory: %s", dir); + } +} + +/** + * Converts an OpenAPI path to the expected route file path (relative to routesDir). + * e.g. "/pet/{id}" -> "pet/{id}.ts", "/" -> "index.ts" + * @param {string} openApiPath + * @returns {string} + */ +function openApiPathToRouteFile(openApiPath) { + const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1); + + return `${filePath}.ts`; +} + +/** + * Prunes route files that no longer correspond to any path in the OpenAPI spec. + * Context files (_.context.ts) are never pruned. + * @param {string} destination - Base destination directory (contains the routes/ sub-directory) + * @param {Iterable} openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}") + * @returns {Promise} - Number of files removed + */ +export async function pruneRoutes(destination, openApiPaths) { + const routesDir = nodePath.join(destination, "routes"); + + const expectedFiles = new Set( + Array.from(openApiPaths).map(openApiPathToRouteFile), + ); + + debug("expected route files: %o", Array.from(expectedFiles)); + + const actualFiles = await collectRouteFiles(routesDir); + + debug("actual route files: %o", actualFiles); + + let prunedCount = 0; + + for (const file of actualFiles) { + const normalizedFile = file.replaceAll("\\", "/"); + + if (!expectedFiles.has(normalizedFile)) { + const fullPath = nodePath.join(routesDir, file); + + debug("pruning %s", fullPath); + await fs.rm(fullPath); + prunedCount++; + } + } + + await removeEmptyDirectories(routesDir, routesDir); + + debug("pruned %d files", prunedCount); + + return prunedCount; +} diff --git a/test/typescript-generator/prune.test.js b/test/typescript-generator/prune.test.js new file mode 100644 index 000000000..326b18a9e --- /dev/null +++ b/test/typescript-generator/prune.test.js @@ -0,0 +1,125 @@ +import { describe, expect, it } from "@jest/globals"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { pruneRoutes } from "../../src/typescript-generator/prune.js"; +import { withTemporaryFiles } from "../lib/with-temporary-files.ts"; + +describe("pruneRoutes", () => { + it("removes a route file that is not in the OpenAPI spec", async () => { + await withTemporaryFiles( + { + "routes/pet/{id}.ts": "export const GET = () => ({ status: 200 });", + "routes/pet/{name}.ts": "export const GET = () => ({ status: 200 });", + }, + async (basePath, { path: getPath }) => { + const expectedPaths = ["/pet/{id}"]; + const count = await pruneRoutes(basePath, expectedPaths); + + expect(count).toBe(1); + await expect( + fs.access(getPath("routes/pet/{id}.ts")), + ).resolves.toBeUndefined(); + await expect( + fs.access(getPath("routes/pet/{name}.ts")), + ).rejects.toThrow(); + }, + ); + }); + + it("keeps context files even when not in the spec", async () => { + await withTemporaryFiles( + { + "routes/_.context.ts": "export class Context {}", + "routes/pet/_.context.ts": "export class Context {}", + "routes/pet/{id}.ts": "export const GET = () => ({ status: 200 });", + }, + async (basePath, { path: getPath }) => { + const expectedPaths = ["/pet/{id}"]; + const count = await pruneRoutes(basePath, expectedPaths); + + expect(count).toBe(0); + await expect( + fs.access(getPath("routes/_.context.ts")), + ).resolves.toBeUndefined(); + await expect( + fs.access(getPath("routes/pet/_.context.ts")), + ).resolves.toBeUndefined(); + }, + ); + }); + + it("removes empty directories after pruning", async () => { + await withTemporaryFiles( + { + "routes/old/{id}.ts": "export const GET = () => ({ status: 200 });", + }, + async (basePath, { path: getPath }) => { + const expectedPaths = []; + await pruneRoutes(basePath, expectedPaths); + + await expect(fs.access(getPath("routes/old"))).rejects.toThrow(); + }, + ); + }); + + it("does not remove directories that still contain context files", async () => { + await withTemporaryFiles( + { + "routes/old/{id}.ts": "export const GET = () => ({ status: 200 });", + "routes/old/_.context.ts": "export class Context {}", + }, + async (basePath, { path: getPath }) => { + const expectedPaths = []; + await pruneRoutes(basePath, expectedPaths); + + await expect(fs.access(getPath("routes/old"))).resolves.toBeUndefined(); + await expect( + fs.access(getPath("routes/old/_.context.ts")), + ).resolves.toBeUndefined(); + }, + ); + }); + + it("handles the root path '/'", async () => { + await withTemporaryFiles( + { + "routes/index.ts": "export const GET = () => ({ status: 200 });", + "routes/old.ts": "export const GET = () => ({ status: 200 });", + }, + async (basePath, { path: getPath }) => { + const expectedPaths = ["/"]; + const count = await pruneRoutes(basePath, expectedPaths); + + expect(count).toBe(1); + await expect( + fs.access(getPath("routes/index.ts")), + ).resolves.toBeUndefined(); + await expect(fs.access(getPath("routes/old.ts"))).rejects.toThrow(); + }, + ); + }); + + it("returns 0 when all files match the spec", async () => { + await withTemporaryFiles( + { + "routes/pet/{id}.ts": "export const GET = () => ({ status: 200 });", + "routes/pet.ts": "export const GET = () => ({ status: 200 });", + }, + async (basePath) => { + const expectedPaths = ["/pet/{id}", "/pet"]; + const count = await pruneRoutes(basePath, expectedPaths); + + expect(count).toBe(0); + }, + ); + }); + + it("returns 0 when the routes directory does not exist", async () => { + await withTemporaryFiles({}, async (basePath) => { + const count = await pruneRoutes(basePath, ["/pet/{id}"]); + + expect(count).toBe(0); + }); + }); +});