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
13 changes: 13 additions & 0 deletions .changeset/add-prune-option.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions bin/counterfact.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ async function main(source, destination) {
options.watch ||
options.watchTypes ||
options.buildCache,

prune: Boolean(options.prune),
},

openApiPath: source,
Expand Down Expand Up @@ -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);
1 change: 1 addition & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface Config {
basePath: string;
buildCache: boolean;
generate: {
prune?: boolean;
routes: boolean;
types: boolean;
};
Expand Down
8 changes: 6 additions & 2 deletions src/typescript-generator/code-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/typescript-generator/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
);
Expand Down
127 changes: 127 additions & 0 deletions src/typescript-generator/prune.js
Original file line number Diff line number Diff line change
@@ -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<string[]>} - 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<string>} openApiPaths - Iterable of OpenAPI path strings (e.g. "/pet/{id}")
* @returns {Promise<number>} - 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;
}
125 changes: 125 additions & 0 deletions test/typescript-generator/prune.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading