counterfact 2.14.1 → 2.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,7 +82,7 @@ export class ApiRunner {
82
82
  return this.group ? `/${this.group}` : "";
83
83
  }
84
84
  config;
85
- constructor(config, nativeTs, openApiDocument, group, version = "", versions = []) {
85
+ constructor(config, nativeTs, openApiDocument, group, version = "", versions = [], groupState) {
86
86
  this.group = group;
87
87
  this.version = version;
88
88
  const modulesPath = this.group
@@ -96,8 +96,9 @@ export class ApiRunner {
96
96
  this.prefix = config.prefix;
97
97
  this.overlays = config.overlays ?? [];
98
98
  this.registry = new Registry();
99
- this.contextRegistry = new ContextRegistry();
100
- this.scenarioRegistry = new ScenarioRegistry();
99
+ this.contextRegistry = groupState?.contextRegistry ?? new ContextRegistry();
100
+ this.scenarioRegistry =
101
+ groupState?.scenarioRegistry ?? new ScenarioRegistry();
101
102
  this.scenarioFileGenerator = new ScenarioFileGenerator(modulesPath);
102
103
  this.codeGenerator = new CodeGenerator(this.openApiPath, config.basePath + this.subdirectory, config.generate, version, config.overlays ?? []);
103
104
  this.dispatcher = new Dispatcher(this.registry, this.contextRegistry, openApiDocument, config, version, versions);
@@ -115,8 +116,9 @@ export class ApiRunner {
115
116
  * @param group - Optional group name placing generated code in a subdirectory (default `""`).
116
117
  * @param version - Optional version label for this spec (e.g. `"v1"`, `"v2"`).
117
118
  * @param versions - Optional ordered list of all version labels in this group (oldest first).
119
+ * @param groupState - Optional context/scenario registries shared by the group's runners.
118
120
  */
119
- static async create(config, group = "", version = "", versions = []) {
121
+ static async create(config, group = "", version = "", versions = [], groupState) {
120
122
  const nativeTs = await runtimeCanExecuteErasableTs();
121
123
  const modulesPath = group
122
124
  ? pathJoin(config.basePath, group)
@@ -128,7 +130,7 @@ export class ApiRunner {
128
130
  const openApiDocument = config.openApiPath === "_"
129
131
  ? undefined
130
132
  : await loadOpenApiDocument(config.openApiPath, config.overlays ?? []);
131
- return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions);
133
+ return new ApiRunner(config, nativeTs, openApiDocument, group, version, versions, groupState);
132
134
  }
133
135
  /**
134
136
  * Generates TypeScript route stubs and type files from the OpenAPI spec.
package/dist/app.js CHANGED
@@ -4,7 +4,9 @@ import { createHttpTerminator } from "http-terminator";
4
4
  import { ApiRunner } from "./api-runner.js";
5
5
  import { startRepl as startReplServer } from "./repl/repl.js";
6
6
  import { createRouteFunction } from "./repl/route-builder.js";
7
+ import { ContextRegistry } from "./server/context-registry.js";
7
8
  import { createKoaApp } from "./server/web-server/create-koa-app.js";
9
+ import { ScenarioRegistry } from "./server/scenario-registry.js";
8
10
  import { Repository } from "./typescript-generator/repository.js";
9
11
  import { ensureDirectoryExists } from "./util/ensure-directory-exists.js";
10
12
  import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js";
@@ -24,37 +26,42 @@ export async function runStartupScenario(scenarioRegistry, contextRegistry, conf
24
26
  await indexModule["startup"](scenario$);
25
27
  }
26
28
  /**
27
- * Derives the URL prefix for a spec entry.
28
- *
29
- * Applies the following precedence rules:
30
- * 1. Explicit `prefix` (even `""`) → returned as-is.
31
- * 2. `group` + `version` both present → `/<group>/<version>`.
32
- * 3. `group` present (no `version`) → `/<group>`.
33
- * 4. Neither → `""` (root).
29
+ * Runs one startup scenario per API group, in specification declaration order.
30
+ * Versioned runners in one group share the same scenario and context registries,
31
+ * so only the group's first runner participates in startup.
34
32
  */
35
- function derivePrefix(spec) {
36
- if (spec.prefix !== undefined) {
37
- return spec.prefix;
38
- }
39
- if (spec.group && spec.version) {
40
- return `/${spec.group}/${spec.version}`;
41
- }
42
- if (spec.group) {
43
- return `/${spec.group}`;
33
+ async function runGroupStartupScenarios(runners, config) {
34
+ const startedGroups = new Set();
35
+ for (const runner of runners) {
36
+ if (startedGroups.has(runner.group)) {
37
+ continue;
38
+ }
39
+ startedGroups.add(runner.group);
40
+ try {
41
+ await runStartupScenario(runner.scenarioRegistry, runner.contextRegistry, config, runner.openApiDocument);
42
+ }
43
+ catch (error) {
44
+ const groupLabel = runner.group
45
+ ? `group "${runner.group}"${runner.version ? ` (version "${runner.version}")` : ""}`
46
+ : "the primary API";
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ throw new Error(`Startup scenario failed for ${groupLabel}: ${message}`, {
49
+ cause: error,
50
+ });
51
+ }
44
52
  }
45
- return "";
46
53
  }
47
54
  /**
48
55
  * Normalises the spec configuration to an array.
49
56
  *
50
- * When `specs` is provided, each entry's `prefix` is resolved via
51
- * {@link derivePrefix} so the rest of the code can assume `prefix` is always
52
- * a string. When `specs` is omitted, a single-entry array is constructed from
53
- * `config.openApiPath`, `config.prefix`, and `group = ""`.
57
+ * When `specs` is provided, each omitted `prefix` defaults to `""` so the rest
58
+ * of the code can assume it is always a string. When `specs` is omitted, a
59
+ * single-entry array is constructed from `config.openApiPath`, `config.prefix`,
60
+ * and `group = ""`.
54
61
  */
55
62
  function normalizeSpecs(config, specs) {
56
63
  if (specs !== undefined) {
57
- return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) }));
64
+ return specs.map((spec) => ({ ...spec, prefix: spec.prefix ?? "" }));
58
65
  }
59
66
  return [
60
67
  {
@@ -131,6 +138,18 @@ export async function counterfact(config, specs) {
131
138
  versionsByGroup.set(spec.group, [...existing, version]);
132
139
  }
133
140
  }
141
+ // A group is one state boundary even when it contains several versioned
142
+ // specification runners. Preserve separate registries between groups while
143
+ // sharing context and scenarios between every runner in the same group.
144
+ const stateByGroup = new Map();
145
+ for (const spec of normalizedSpecs) {
146
+ if (!stateByGroup.has(spec.group)) {
147
+ stateByGroup.set(spec.group, {
148
+ contextRegistry: new ContextRegistry(),
149
+ scenarioRegistry: new ScenarioRegistry(),
150
+ });
151
+ }
152
+ }
134
153
  const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({
135
154
  ...config,
136
155
  openApiPath: spec.source,
@@ -138,7 +157,7 @@ export async function counterfact(config, specs) {
138
157
  // so that the --overlay CLI flag works in single-spec mode.
139
158
  overlays: spec.overlays ?? config.overlays ?? [],
140
159
  prefix: spec.prefix,
141
- }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [])));
160
+ }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [], stateByGroup.get(spec.group))));
142
161
  const koaApp = createKoaApp({
143
162
  runners,
144
163
  config,
@@ -205,7 +224,15 @@ export async function counterfact(config, specs) {
205
224
  await Promise.all(runners.map((runner) => runner.start(options)));
206
225
  let httpTerminator;
207
226
  if (options.startServer) {
208
- await runStartupScenario(primaryRunner.scenarioRegistry, primaryRunner.contextRegistry, { port: config.port }, primaryRunner.openApiDocument);
227
+ try {
228
+ await runGroupStartupScenarios(runners, { port: config.port });
229
+ }
230
+ catch (error) {
231
+ // Startup happens after the runner watchers are active. If a scenario
232
+ // fails, release those resources and leave the HTTP port untouched.
233
+ await Promise.allSettled(runners.map((runner) => runner.stopWatching()));
234
+ throw error;
235
+ }
209
236
  const server = koaApp.listen({
210
237
  port: config.port,
211
238
  });
package/dist/cli/run.js CHANGED
@@ -29,8 +29,7 @@ const DEFAULT_PORT = 3100;
29
29
  * (single spec derived from config).
30
30
  *
31
31
  * Note: `prefix` is intentionally left `undefined` when not supplied so that
32
- * `normalizeSpecs` (in `app.ts`) can derive it automatically from
33
- * `group`/`version`.
32
+ * `normalizeSpecs` (in `app.ts`) can apply the root-prefix default.
34
33
  */
35
34
  export function normalizeSpecOption(specOption) {
36
35
  if (Array.isArray(specOption)) {
@@ -4,7 +4,7 @@ import Koa from "koa";
4
4
  import bodyParser from "koa-bodyparser";
5
5
  import { koaSwagger } from "koa2-swagger-ui";
6
6
  import { adminApiMiddleware } from "./admin-api-middleware.js";
7
- import { routesMiddleware } from "./routes-middleware.js";
7
+ import { routesMiddleware, routesMiddlewareForRunners, } from "./routes-middleware.js";
8
8
  import { openapiMiddleware } from "./openapi-middleware.js";
9
9
  const debug = createDebug("counterfact:server:create-koa-app");
10
10
  /**
@@ -61,8 +61,15 @@ export function createKoaApp({ runners, config, }) {
61
61
  ctx.type = "application/json";
62
62
  }
63
63
  });
64
- for (const runner of runners) {
65
- app.use(routesMiddleware(runner.prefix, runner.dispatcher, {
64
+ const [onlyRunner] = runners;
65
+ if (runners.length === 1 && onlyRunner !== undefined) {
66
+ app.use(routesMiddleware(onlyRunner.prefix, onlyRunner.dispatcher, {
67
+ proxyPaths: config.proxyPaths,
68
+ proxyUrl: config.proxyUrl,
69
+ }));
70
+ }
71
+ else {
72
+ app.use(routesMiddlewareForRunners(runners, {
66
73
  proxyPaths: config.proxyPaths,
67
74
  proxyUrl: config.proxyUrl,
68
75
  }));
@@ -91,7 +91,7 @@ function getAuthObject(ctx) {
91
91
  * @param proxy - Proxy factory; injectable for testing.
92
92
  * @returns A Koa middleware function.
93
93
  */
94
- export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
94
+ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy, allowedMethodsOverride) {
95
95
  return async function middleware(ctx, next) {
96
96
  const { proxyUrl } = config;
97
97
  debug("middleware running for path: %s", ctx.request.path);
@@ -106,7 +106,7 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
106
106
  if (isProxyEnabledForPath(path, config) && proxyUrl) {
107
107
  return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next);
108
108
  }
109
- addCors(ctx, dispatcher.registry.allowedMethods(path), headers);
109
+ addCors(ctx, allowedMethodsOverride ?? dispatcher.registry.allowedMethods(path), headers);
110
110
  if (method === "OPTIONS") {
111
111
  ctx.status = HTTP_STATUS_CODE_OK;
112
112
  return undefined;
@@ -156,7 +156,56 @@ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) {
156
156
  ctx.res.appendHeader(key, value);
157
157
  }
158
158
  }
159
+ if (response.status === 405 && allowedMethodsOverride !== undefined) {
160
+ ctx.set("Allow", allowedMethodsOverride);
161
+ }
159
162
  ctx.status = response.status ?? HTTP_STATUS_CODE_OK;
160
163
  return undefined;
161
164
  };
162
165
  }
166
+ /**
167
+ * Selects the first runner that can handle a request, allowing runners with
168
+ * overlapping prefixes to share one public URL space.
169
+ */
170
+ export function routesMiddlewareForRunners(runners, config, proxy = koaProxy) {
171
+ return async function multiRunnerRoutesMiddleware(ctx, next) {
172
+ const candidates = runners
173
+ .filter(({ prefix }) => ctx.request.path.startsWith(prefix))
174
+ .map((runner) => ({
175
+ path: ctx.request.path.slice(runner.prefix.length),
176
+ runner,
177
+ }));
178
+ if (candidates.length === 0) {
179
+ return await next();
180
+ }
181
+ const method = ctx.request.method;
182
+ for (const candidate of candidates) {
183
+ if ((config.proxyUrl && isProxyEnabledForPath(candidate.path, config)) ||
184
+ candidate.runner.dispatcher.registry.exists(method, candidate.path)) {
185
+ return routesMiddleware(candidate.runner.prefix, candidate.runner.dispatcher, config, proxy)(ctx, next);
186
+ }
187
+ }
188
+ const allowedMethods = new Set();
189
+ let pathOwner;
190
+ for (const candidate of candidates) {
191
+ const methods = candidate.runner.dispatcher.registry.allowedMethods(candidate.path);
192
+ if (methods === "")
193
+ continue;
194
+ pathOwner ??= candidate;
195
+ for (const allowedMethod of methods.split(", ")) {
196
+ allowedMethods.add(allowedMethod);
197
+ }
198
+ }
199
+ if (method === "OPTIONS") {
200
+ const selected = pathOwner ?? candidates[0];
201
+ if (selected === undefined) {
202
+ return await next();
203
+ }
204
+ return routesMiddleware(selected.runner.prefix, selected.runner.dispatcher, config, proxy, [...allowedMethods].join(", "))(ctx, next);
205
+ }
206
+ if (pathOwner === undefined) {
207
+ return await next();
208
+ }
209
+ return routesMiddleware(pathOwner.runner.prefix, pathOwner.runner.dispatcher, config, proxy, [...allowedMethods].join(", "))(ctx, next);
210
+ };
211
+ }
@@ -8,7 +8,8 @@ import { CHOKIDAR_OPTIONS } from "../server/constants.js";
8
8
  import { ensureDirectoryExists } from "../util/ensure-directory-exists.js";
9
9
  import { waitForEvent } from "../util/wait-for-event.js";
10
10
  import { OperationCoder } from "./operation-coder.js";
11
- import { pruneRoutes } from "./prune.js";
11
+ import { assertNoNormalizedPathCollisions, normalizeOpenApiPath, } from "./openapi-path.js";
12
+ import { pruneRoutes, pruneTypes } from "./prune.js";
12
13
  import { Repository } from "./repository.js";
13
14
  import { Specification } from "./specification.js";
14
15
  const debug = createDebug("counterfact:typescript-generator:generate");
@@ -94,9 +95,11 @@ export class CodeGenerator extends EventTarget {
94
95
  debug("reading the #/paths from the specification");
95
96
  const paths = await this.getPathsFromSpecification(specification);
96
97
  debug("got %i paths", paths?.map?.length ?? 0);
98
+ const openApiPaths = paths.map((_pathDefinition, key) => key);
99
+ assertNoNormalizedPathCollisions(openApiPaths);
97
100
  if (this.generateOptions.prune && this.generateOptions.routes) {
98
101
  debug("pruning defunct route files");
99
- await pruneRoutes(destination, paths.map((_v, key) => key));
102
+ await pruneRoutes(destination, openApiPaths);
100
103
  debug("done pruning");
101
104
  }
102
105
  const securityRequirement = specification.getRequirement("#/components/securitySchemes");
@@ -125,13 +128,24 @@ export class CodeGenerator extends EventTarget {
125
128
  });
126
129
  paths.forEach((pathDefinition, key) => {
127
130
  debug("processing path %s", key);
128
- const path = key === "/" ? "/index" : key;
131
+ const normalizedPath = normalizeOpenApiPath(key);
132
+ const path = normalizedPath === "/" ? "/index" : normalizedPath;
129
133
  operationMethodsForPath(pathDefinition).forEach(([operation, requestMethod]) => {
130
134
  repository
131
135
  .get(`routes${path}.ts`)
132
136
  .export(new OperationCoder(operation, this.version, requestMethod, securitySchemes));
133
137
  });
134
138
  });
139
+ if (this.generateOptions.prune && this.generateOptions.types) {
140
+ debug("resolving the expected generated type files");
141
+ await repository.finished();
142
+ debug("pruning defunct generated type files");
143
+ await pruneTypes(destination, [
144
+ ...repository.scripts.keys(),
145
+ ...(this.version === "" ? [] : ["types/versions.ts"]),
146
+ ]);
147
+ debug("done pruning generated type files");
148
+ }
135
149
  debug("telling the repository to write the files to %s", destination);
136
150
  await repository.writeFiles(destination, this.generateOptions);
137
151
  debug("finished writing the files");
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Normalizes an OpenAPI path for Counterfact's route and type module trees.
3
+ *
4
+ * Incoming requests already match route modules with or without a terminal
5
+ * slash, so terminal slashes must not create an extra empty file segment.
6
+ */
7
+ export function normalizeOpenApiPath(openApiPath) {
8
+ return openApiPath.replace(/\/+$/u, "") || "/";
9
+ }
10
+ /**
11
+ * Rejects path keys that would target the same generated route module.
12
+ */
13
+ export function assertNoNormalizedPathCollisions(openApiPaths) {
14
+ const originalPathByNormalizedPath = new Map();
15
+ for (const openApiPath of openApiPaths) {
16
+ const normalizedPath = normalizeOpenApiPath(openApiPath);
17
+ const existingPath = originalPathByNormalizedPath.get(normalizedPath);
18
+ if (existingPath !== undefined && existingPath !== openApiPath) {
19
+ throw new Error(`OpenAPI paths "${existingPath}" and "${openApiPath}" normalize to the same path "${normalizedPath}". Remove one of the conflicting paths.`);
20
+ }
21
+ originalPathByNormalizedPath.set(normalizedPath, openApiPath);
22
+ }
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { pathJoin } from "../util/forward-slash-path.js";
2
2
  import { Coder } from "./coder.js";
3
+ import { normalizeOpenApiPath } from "./openapi-path.js";
3
4
  import { OperationTypeCoder, VersionedArgTypeCoder, } from "./operation-type-coder.js";
4
5
  import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
5
6
  /**
@@ -66,10 +67,7 @@ export class OperationCoder extends Coder {
66
67
  return script.importType(operationTypeCoder);
67
68
  }
68
69
  modulePath() {
69
- const pathString = this.requirement.url
70
- .split("/")
71
- .at(-2)
72
- .replaceAll("~1", "/");
70
+ const pathString = normalizeOpenApiPath(this.requirement.url.split("/").at(-2).replaceAll("~1", "/"));
73
71
  return `${pathJoin("routes", pathString)}.types.ts`;
74
72
  }
75
73
  }
@@ -9,6 +9,7 @@ import { ResponsesTypeCoder } from "./responses-type-coder.js";
9
9
  import { SchemaTypeCoder } from "./schema-type-coder.js";
10
10
  import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
11
11
  import { TypeCoder } from "./type-coder.js";
12
+ import { normalizeOpenApiPath } from "./openapi-path.js";
12
13
  import { Requirement } from "./requirement.js";
13
14
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
14
15
  function sanitizeIdentifier(value) {
@@ -145,10 +146,7 @@ export class OperationTypeCoder extends TypeCoder {
145
146
  .join(" | ");
146
147
  }
147
148
  modulePath() {
148
- const pathString = this.requirement.url
149
- .split("/")
150
- .at(-2)
151
- .replaceAll("~1", "/");
149
+ const pathString = normalizeOpenApiPath(this.requirement.url.split("/").at(-2).replaceAll("~1", "/"));
152
150
  return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
153
151
  }
154
152
  /**
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
2
  import nodePath from "node:path";
3
- /* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under destination/routes. */
3
+ /* eslint-disable security/detect-non-literal-fs-filename -- pruning only traverses and removes files under generator-owned destination subdirectories. */
4
4
  import createDebug from "debug";
5
5
  import { toForwardSlashPath } from "../util/forward-slash-path.js";
6
+ import { normalizeOpenApiPath } from "./openapi-path.js";
6
7
  const debug = createDebug("counterfact:typescript-generator:prune");
7
8
  /**
8
9
  * Collects all .ts route files in a directory recursively.
@@ -11,21 +12,19 @@ const debug = createDebug("counterfact:typescript-generator:prune");
11
12
  * @param currentPath - Current subdirectory being processed (relative to routesDir)
12
13
  * @returns Array of relative paths (using forward slashes)
13
14
  */
14
- async function collectRouteFiles(routesDir, currentPath = "") {
15
+ async function collectTypeScriptFiles(rootDir, currentPath = "") {
15
16
  const files = [];
16
17
  try {
17
- const fullDir = currentPath
18
- ? nodePath.join(routesDir, currentPath)
19
- : routesDir;
18
+ const fullDir = currentPath ? nodePath.join(rootDir, currentPath) : rootDir;
20
19
  const entries = await fs.readdir(fullDir, { withFileTypes: true });
21
20
  for (const entry of entries) {
22
21
  const relativePath = currentPath
23
22
  ? `${currentPath}/${entry.name}`
24
23
  : entry.name;
25
24
  if (entry.isDirectory()) {
26
- files.push(...(await collectRouteFiles(routesDir, relativePath)));
25
+ files.push(...(await collectTypeScriptFiles(rootDir, relativePath)));
27
26
  }
28
- else if (entry.name.endsWith(".ts") && entry.name !== "_.context.ts") {
27
+ else if (entry.name.endsWith(".ts")) {
29
28
  files.push(relativePath);
30
29
  }
31
30
  }
@@ -71,7 +70,8 @@ async function removeEmptyDirectories(dir, rootDir) {
71
70
  * @param openApiPath - The OpenAPI path string
72
71
  */
73
72
  function openApiPathToRouteFile(openApiPath) {
74
- const filePath = openApiPath === "/" ? "index" : openApiPath.slice(1);
73
+ const normalizedPath = normalizeOpenApiPath(openApiPath);
74
+ const filePath = normalizedPath === "/" ? "index" : normalizedPath.slice(1);
75
75
  return `${filePath}.ts`;
76
76
  }
77
77
  /**
@@ -85,7 +85,7 @@ export async function pruneRoutes(destination, openApiPaths) {
85
85
  const routesDir = nodePath.join(destination, "routes");
86
86
  const expectedFiles = new Set(Array.from(openApiPaths).map(openApiPathToRouteFile));
87
87
  debug("expected route files: %o", Array.from(expectedFiles));
88
- const actualFiles = await collectRouteFiles(routesDir);
88
+ const actualFiles = (await collectTypeScriptFiles(routesDir)).filter((file) => nodePath.basename(file) !== "_.context.ts");
89
89
  debug("actual route files: %o", actualFiles);
90
90
  let prunedCount = 0;
91
91
  for (const file of actualFiles) {
@@ -101,3 +101,46 @@ export async function pruneRoutes(destination, openApiPaths) {
101
101
  debug("pruned %d files", prunedCount);
102
102
  return prunedCount;
103
103
  }
104
+ /**
105
+ * Returns whether a path below `types/` belongs to a namespace populated by
106
+ * the OpenAPI generator. Context support and arbitrary user-authored type
107
+ * modules are deliberately outside this ownership policy.
108
+ */
109
+ function isGeneratorOwnedTypePath(path) {
110
+ const [first, second] = toForwardSlashPath(path).split("/");
111
+ const generatedCategory = (segment) => segment === "paths" || segment === "components" || segment === "#";
112
+ return (path === "versions.ts" ||
113
+ generatedCategory(first) ||
114
+ generatedCategory(second));
115
+ }
116
+ /**
117
+ * Prunes obsolete files from the OpenAPI generator-owned namespaces below
118
+ * `types/`. Hidden files are included by the recursive directory walk.
119
+ *
120
+ * `types/_.context.ts` and paths outside the known generated namespaces are
121
+ * preserved because separate generators or users own them.
122
+ *
123
+ * @param destination - Base destination directory (contains `types/`).
124
+ * @param expectedPaths - Repository-relative generated paths to retain.
125
+ * @returns Number of files removed.
126
+ */
127
+ export async function pruneTypes(destination, expectedPaths) {
128
+ const typesDir = nodePath.join(destination, "types");
129
+ const expectedFiles = new Set(Array.from(expectedPaths, (path) => toForwardSlashPath(path).replace(/^types\//u, "")));
130
+ const actualFiles = (await collectTypeScriptFiles(typesDir)).filter(isGeneratorOwnedTypePath);
131
+ let prunedCount = 0;
132
+ debug("expected type files: %o", Array.from(expectedFiles));
133
+ debug("actual generated type files: %o", actualFiles);
134
+ for (const file of actualFiles) {
135
+ const normalizedFile = toForwardSlashPath(file);
136
+ if (!expectedFiles.has(normalizedFile)) {
137
+ const fullPath = nodePath.join(typesDir, file);
138
+ debug("pruning %s", fullPath);
139
+ await fs.rm(fullPath);
140
+ prunedCount++;
141
+ }
142
+ }
143
+ await removeEmptyDirectories(typesDir, typesDir);
144
+ debug("pruned %d type files", prunedCount);
145
+ return prunedCount;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counterfact",
3
- "version": "2.14.1",
3
+ "version": "2.14.2",
4
4
  "description": "Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.",
5
5
  "type": "module",
6
6
  "main": "./dist/app.js",