counterfact 2.7.0 → 2.9.0

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.
Files changed (57) hide show
  1. package/README.md +5 -160
  2. package/bin/README.md +39 -14
  3. package/bin/counterfact.js +18 -539
  4. package/bin/ts-loader.mjs +1 -0
  5. package/dist/api-runner.js +202 -0
  6. package/dist/app.js +102 -114
  7. package/dist/cli/banner.js +81 -0
  8. package/dist/cli/check-for-updates.js +45 -0
  9. package/dist/cli/run.js +304 -0
  10. package/dist/cli/telemetry.js +50 -0
  11. package/dist/migrate/paths-to-routes.js +1 -0
  12. package/dist/migrate/update-route-types.js +3 -3
  13. package/dist/msw.js +78 -0
  14. package/dist/repl/raw-http-client.js +22 -1
  15. package/dist/repl/repl.js +250 -63
  16. package/dist/repl/route-builder.js +68 -0
  17. package/dist/server/constants.js +8 -0
  18. package/dist/server/context-registry.js +54 -1
  19. package/dist/server/determine-module-kind.js +14 -0
  20. package/dist/server/dispatcher.js +46 -0
  21. package/dist/server/file-discovery.js +21 -9
  22. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  23. package/dist/server/json-to-xml.js +10 -0
  24. package/dist/server/load-openapi-document.js +4 -11
  25. package/dist/server/module-dependency-graph.js +25 -0
  26. package/dist/server/module-loader.js +52 -21
  27. package/dist/server/module-tree.js +36 -0
  28. package/dist/server/openapi-document.js +69 -0
  29. package/dist/server/registry.js +89 -0
  30. package/dist/server/response-builder.js +15 -0
  31. package/dist/server/scenario-registry.js +26 -0
  32. package/dist/server/tools.js +27 -0
  33. package/dist/server/transpiler.js +24 -9
  34. package/dist/server/{admin-api-middleware.js → web-server/admin-api-middleware.js} +19 -9
  35. package/dist/server/web-server/create-koa-app.js +68 -0
  36. package/dist/server/web-server/openapi-middleware.js +34 -0
  37. package/dist/server/{koa-middleware.js → web-server/routes-middleware.js} +26 -6
  38. package/dist/typescript-generator/code-generator.js +118 -4
  39. package/dist/typescript-generator/coder.js +76 -0
  40. package/dist/typescript-generator/operation-coder.js +12 -4
  41. package/dist/typescript-generator/operation-type-coder.js +39 -4
  42. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  43. package/dist/typescript-generator/prune.js +3 -1
  44. package/dist/typescript-generator/repository.js +77 -20
  45. package/dist/typescript-generator/requirement.js +69 -0
  46. package/dist/typescript-generator/{generate.js → scenario-file-generator.js} +99 -81
  47. package/dist/typescript-generator/script.js +70 -7
  48. package/dist/typescript-generator/specification.js +27 -0
  49. package/dist/util/ensure-directory-exists.js +8 -0
  50. package/dist/util/forward-slash-path.js +63 -0
  51. package/dist/util/load-config-file.js +2 -2
  52. package/dist/util/read-file.js +27 -2
  53. package/dist/util/runtime-can-execute-erasable-ts.js +12 -0
  54. package/dist/util/windows-escape.js +18 -0
  55. package/package.json +5 -4
  56. package/dist/server/create-koa-app.js +0 -42
  57. package/dist/server/openapi-middleware.js +0 -19
package/dist/repl/repl.js CHANGED
@@ -15,61 +15,125 @@ const ROUTE_BUILDER_METHODS = [
15
15
  "ready(",
16
16
  "send(",
17
17
  ];
18
+ function getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries) {
19
+ function getPathCompletions(partial, registry) {
20
+ if (registry === undefined) {
21
+ return [[], partial];
22
+ }
23
+ const slashIdx = partial.lastIndexOf("/");
24
+ if (slashIdx === -1) {
25
+ const indexFunctions = registry.getExportedFunctionNames("index");
26
+ const fileKeys = registry.getFileKeys().filter((k) => k !== "index");
27
+ const topLevelPrefixes = [
28
+ ...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
29
+ ];
30
+ const allOptions = [...indexFunctions, ...topLevelPrefixes];
31
+ const matches = allOptions.filter((c) => c.startsWith(partial));
32
+ return [matches, partial];
33
+ }
34
+ const fileKey = partial.slice(0, slashIdx);
35
+ const funcPartial = partial.slice(slashIdx + 1);
36
+ const functions = registry.getExportedFunctionNames(fileKey);
37
+ const matches = functions
38
+ .filter((e) => e.startsWith(funcPartial))
39
+ .map((e) => `${fileKey}/${e}`);
40
+ return [matches, partial];
41
+ }
42
+ if (groupedScenarioRegistries !== undefined) {
43
+ if (!/^\.scenario(?:\s|$)/u.test(line)) {
44
+ return undefined;
45
+ }
46
+ const hasTrailingWhitespace = /\s$/u.test(line);
47
+ const args = line.trim().split(/\s+/u).slice(1);
48
+ const groupKeys = Object.keys(groupedScenarioRegistries);
49
+ if (args.length === 0) {
50
+ return [groupKeys, ""];
51
+ }
52
+ if (args.length === 1 && !hasTrailingWhitespace) {
53
+ const groupPartial = args[0] ?? "";
54
+ const matches = groupKeys.filter((key) => key.startsWith(groupPartial));
55
+ return [matches, groupPartial];
56
+ }
57
+ const selectedGroup = args[0] ?? "";
58
+ const selectedRegistry = groupedScenarioRegistries[selectedGroup];
59
+ if (selectedRegistry === undefined) {
60
+ const scenarioPartial = hasTrailingWhitespace
61
+ ? ""
62
+ : (args[args.length - 1] ?? "");
63
+ return [[], scenarioPartial];
64
+ }
65
+ if (args.length === 1 && hasTrailingWhitespace) {
66
+ return getPathCompletions("", selectedRegistry);
67
+ }
68
+ if (args.length === 2 && !hasTrailingWhitespace) {
69
+ const scenarioPartial = args[1];
70
+ if (scenarioPartial === undefined) {
71
+ return [[], ""];
72
+ }
73
+ return getPathCompletions(scenarioPartial, selectedRegistry);
74
+ }
75
+ // More than two args (or trailing whitespace after the second arg) means
76
+ // no additional `.scenario` arguments are valid in multi-API mode.
77
+ return [[], args[args.length - 1] ?? ""];
78
+ }
79
+ const applyMatch = line.match(/^\.scenario\s+(?<partial>\S*)$/u);
80
+ if (!applyMatch) {
81
+ return undefined;
82
+ }
83
+ const partial = applyMatch.groups?.["partial"] ?? "";
84
+ return getPathCompletions(partial, scenarioRegistry);
85
+ }
86
+ function getRouteBuilderMethodCompletions(line) {
87
+ const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
88
+ if (!builderMatch) {
89
+ return undefined;
90
+ }
91
+ const partial = builderMatch.groups?.["partial"] ?? "";
92
+ const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
93
+ return [matches, partial];
94
+ }
95
+ function getRoutesForCompletion(registry, openApiDocument) {
96
+ const openApiPaths = openApiDocument
97
+ ? Object.keys(openApiDocument.paths)
98
+ : [];
99
+ if (openApiPaths.length > 0) {
100
+ return openApiPaths;
101
+ }
102
+ return registry.routes.map((route) => route.path);
103
+ }
104
+ function getRouteCompletions(line, routes) {
105
+ const routeMatch = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
106
+ if (!routeMatch) {
107
+ return undefined;
108
+ }
109
+ const partial = routeMatch.groups?.["partial"] ?? "";
110
+ const matches = routes.filter((route) => route.startsWith(partial));
111
+ return [matches, partial];
112
+ }
18
113
  /**
19
114
  * Creates a tab-completion function for the REPL.
20
115
  *
21
116
  * @param registry - The route registry used to complete path arguments for `route()` and `client.*()` calls.
22
117
  * @param fallback - Optional fallback completer (e.g. the Node.js built-in completer) invoked when no custom completion matches.
23
- * @param scenarioRegistry - When provided, enables tab completion for `.apply` commands by enumerating
118
+ * @param openApiDocument - Optional OpenAPI document used as the source of route completions when available.
119
+ * @param scenarioRegistry - When provided, enables tab completion for `.scenario` commands by enumerating
24
120
  * exported function names and file-key prefixes from the loaded scenario modules.
25
121
  */
26
- export function createCompleter(registry, fallback, scenarioRegistry) {
122
+ export function createCompleter(registry, fallback, openApiDocument, scenarioRegistry, groupedScenarioRegistries) {
123
+ const routes = getRoutesForCompletion(registry, openApiDocument);
27
124
  return (line, callback) => {
28
- // Check for .apply completion: .apply <partial>
29
- const applyMatch = line.match(/^\.apply\s+(?<partial>\S*)$/u);
30
- if (applyMatch) {
31
- const partial = applyMatch.groups?.["partial"] ?? "";
32
- if (scenarioRegistry !== undefined) {
33
- const slashIdx = partial.lastIndexOf("/");
34
- if (slashIdx === -1) {
35
- // No slash: complete exports from "index" key + top-level file prefixes
36
- const indexFunctions = scenarioRegistry.getExportedFunctionNames("index");
37
- const fileKeys = scenarioRegistry
38
- .getFileKeys()
39
- .filter((k) => k !== "index");
40
- const topLevelPrefixes = [
41
- ...new Set(fileKeys.map((k) => k.split("/")[0] + "/")),
42
- ];
43
- const allOptions = [...indexFunctions, ...topLevelPrefixes];
44
- const matches = allOptions.filter((c) => c.startsWith(partial));
45
- callback(null, [matches, partial]);
46
- }
47
- else {
48
- // Has slash: complete exports from the named file key
49
- const fileKey = partial.slice(0, slashIdx);
50
- const funcPartial = partial.slice(slashIdx + 1);
51
- const functions = scenarioRegistry.getExportedFunctionNames(fileKey);
52
- const matches = functions
53
- .filter((e) => e.startsWith(funcPartial))
54
- .map((e) => `${fileKey}/${e}`);
55
- callback(null, [matches, partial]);
56
- }
57
- }
58
- else {
59
- callback(null, [[], partial]);
60
- }
125
+ const scenarioCompletions = getScenarioCompletions(line, scenarioRegistry, groupedScenarioRegistries);
126
+ if (scenarioCompletions !== undefined) {
127
+ callback(null, scenarioCompletions);
61
128
  return;
62
129
  }
63
- // Check for RouteBuilder method completion: route("..."). or chained calls
64
- const builderMatch = line.match(/route\(.*\)\.(?<partial>[a-zA-Z]*)$/u);
65
- if (builderMatch) {
66
- const partial = builderMatch.groups?.["partial"] ?? "";
67
- const matches = ROUTE_BUILDER_METHODS.filter((m) => m.startsWith(partial));
68
- callback(null, [matches, partial]);
130
+ const routeBuilderCompletions = getRouteBuilderMethodCompletions(line);
131
+ if (routeBuilderCompletions !== undefined) {
132
+ callback(null, routeBuilderCompletions);
69
133
  return;
70
134
  }
71
- const match = line.match(/(?:client\.(?:get|post|put|patch|delete)|route)\("(?<partial>[^"]*)$/u);
72
- if (!match) {
135
+ const routeCompletions = getRouteCompletions(line, routes);
136
+ if (routeCompletions === undefined) {
73
137
  if (fallback) {
74
138
  fallback(line, callback);
75
139
  }
@@ -78,13 +142,74 @@ export function createCompleter(registry, fallback, scenarioRegistry) {
78
142
  }
79
143
  return;
80
144
  }
81
- const partial = match.groups?.["partial"] ?? "";
82
- const routes = registry.routes.map((route) => route.path);
83
- const matches = routes.filter((route) => route.startsWith(partial));
84
- callback(null, [matches, partial]);
145
+ callback(null, routeCompletions);
85
146
  };
86
147
  }
87
- export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry) {
148
+ /**
149
+ * Launches the interactive Counterfact REPL.
150
+ *
151
+ * The REPL is a standard Node.js REPL augmented with:
152
+ * - `context` / `loadContext(path)` globals wired to the {@link ContextRegistry}.
153
+ * - `client` — a {@link RawHttpClient} pre-configured for `localhost`.
154
+ * - `route(path)` — creates a {@link RouteBuilder} for the given path.
155
+ * - `.counterfact` — help command.
156
+ * - `.proxy` — proxy configuration command.
157
+ * - `.scenario` — runs a named scenario function from the scenarios directory.
158
+ *
159
+ * @param contextRegistry - The live context registry.
160
+ * @param registry - The route registry (used for tab completion).
161
+ * @param config - Server configuration.
162
+ * @param print - Output function; defaults to writing to `stdout`.
163
+ * @param openApiDocument - Optional OpenAPI document for tab completion.
164
+ * @param scenarioRegistry - Optional scenario registry for `.scenario` support.
165
+ * @returns The configured Node.js REPL server instance.
166
+ */
167
+ export function startRepl(contextRegistry, registry, config, print = printToStdout, openApiDocument, scenarioRegistry, apiBindings) {
168
+ const bindings = apiBindings === undefined || apiBindings.length === 0
169
+ ? [
170
+ {
171
+ contextRegistry,
172
+ group: "",
173
+ openApiDocument,
174
+ registry,
175
+ scenarioRegistry,
176
+ },
177
+ ]
178
+ : apiBindings;
179
+ const isMultiApi = bindings.length > 1;
180
+ const groupedBindings = bindings.map((binding) => ({
181
+ ...binding,
182
+ key: binding.group.trim(),
183
+ }));
184
+ if (isMultiApi) {
185
+ const invalidBindings = groupedBindings.filter((binding) => binding.key === "");
186
+ if (invalidBindings.length > 0) {
187
+ throw new Error("Each API binding must define a non-empty group when multiple APIs are configured.");
188
+ }
189
+ const seenGroups = new Set();
190
+ const duplicateGroups = new Set();
191
+ for (const binding of groupedBindings) {
192
+ if (seenGroups.has(binding.key)) {
193
+ duplicateGroups.add(binding.key);
194
+ }
195
+ seenGroups.add(binding.key);
196
+ }
197
+ if (duplicateGroups.size > 0) {
198
+ throw new Error(`Duplicate API groups are not allowed when multiple APIs are configured (duplicate groups: ${[...duplicateGroups].join(", ")}).`);
199
+ }
200
+ }
201
+ const rootBinding = groupedBindings[0];
202
+ if (rootBinding === undefined) {
203
+ throw new Error("startRepl requires at least one API binding");
204
+ }
205
+ const groupedLoadContext = Object.fromEntries(groupedBindings.map((binding) => [
206
+ binding.key,
207
+ (path) => binding.contextRegistry.find(path),
208
+ ]));
209
+ const groupedRoute = Object.fromEntries(groupedBindings.map((binding) => [
210
+ binding.key,
211
+ createRouteFunction(config.port, "localhost", binding.openApiDocument),
212
+ ]));
88
213
  function printProxyStatus() {
89
214
  if (config.proxyUrl === "") {
90
215
  print("The proxy URL is not set.");
@@ -123,12 +248,17 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
123
248
  }
124
249
  }
125
250
  const replServer = repl.start({
126
- prompt: "⬣> ",
251
+ prompt: "\x1b[38;2;0;113;181m⬣> \x1b[0m",
127
252
  });
128
253
  const builtinCompleter = replServer.completer;
129
254
  // completer is typed as readonly in @types/node but is writable at runtime
130
255
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
- replServer.completer = createCompleter(registry, builtinCompleter, scenarioRegistry);
256
+ replServer.completer = createCompleter(rootBinding.registry, builtinCompleter, rootBinding.openApiDocument, rootBinding.scenarioRegistry, isMultiApi
257
+ ? Object.fromEntries(groupedBindings.map((binding) => [
258
+ binding.key,
259
+ binding.scenarioRegistry,
260
+ ]))
261
+ : undefined);
132
262
  replServer.defineCommand("counterfact", {
133
263
  action() {
134
264
  print("This is a read-eval-print loop (REPL), the same as the one you get when you run node with no arguments.");
@@ -167,17 +297,63 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
167
297
  },
168
298
  help: 'proxy configuration (".proxy help" for details)',
169
299
  });
170
- replServer.context.loadContext = (path) => contextRegistry.find(path);
171
- replServer.context.context = replServer.context.loadContext("/");
300
+ replServer.context.loadContext = isMultiApi
301
+ ? groupedLoadContext
302
+ : groupedLoadContext[rootBinding.key];
303
+ replServer.context.context = isMultiApi
304
+ ? Object.fromEntries(groupedBindings.map((binding) => [
305
+ binding.key,
306
+ binding.contextRegistry.find("/"),
307
+ ]))
308
+ : rootBinding.contextRegistry.find("/");
172
309
  replServer.context.client = new RawHttpClient("localhost", config.port);
173
310
  replServer.context.RawHttpClient = RawHttpClient;
174
- replServer.context.route = createRouteFunction(config.port, "localhost", openApiDocument);
175
- replServer.context.routes = {};
176
- replServer.defineCommand("apply", {
311
+ replServer.context.route = isMultiApi
312
+ ? groupedRoute
313
+ : groupedRoute[rootBinding.key];
314
+ replServer.context.routes = isMultiApi
315
+ ? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
316
+ : {};
317
+ replServer.defineCommand("scenario", {
177
318
  async action(text) {
178
- const parts = text.trim().split("/").filter(Boolean);
319
+ const trimmedText = text.trim();
320
+ const parsedArgs = trimmedText.split(/\s+/u).filter(Boolean);
321
+ const usage = isMultiApi
322
+ ? "usage: .scenario <group> <path>"
323
+ : "usage: .scenario <path>";
324
+ const { selectedBinding, scenarioPath } = (() => {
325
+ if (!isMultiApi) {
326
+ if (trimmedText === "") {
327
+ return { scenarioPath: undefined, selectedBinding: undefined };
328
+ }
329
+ return { scenarioPath: trimmedText, selectedBinding: rootBinding };
330
+ }
331
+ if (parsedArgs.length !== 2) {
332
+ return { scenarioPath: undefined, selectedBinding: undefined };
333
+ }
334
+ return {
335
+ scenarioPath: parsedArgs[1],
336
+ selectedBinding: groupedBindings.find((binding) => binding.key === parsedArgs[0]),
337
+ };
338
+ })();
339
+ if (selectedBinding === undefined || scenarioPath === undefined) {
340
+ if (isMultiApi &&
341
+ scenarioPath !== undefined &&
342
+ selectedBinding === undefined) {
343
+ const groupName = parsedArgs[0] ?? "";
344
+ const availableGroups = groupedBindings.map((binding) => binding.key);
345
+ print(`Error: Unknown API group "${groupName}". Available groups: ${availableGroups.join(", ")}`);
346
+ }
347
+ else {
348
+ print(usage);
349
+ }
350
+ this.clearBufferedCommand();
351
+ this.displayPrompt();
352
+ return;
353
+ }
354
+ const parts = scenarioPath.split("/").filter(Boolean);
179
355
  if (parts.length === 0) {
180
- print("usage: .apply <path>");
356
+ print(usage);
181
357
  this.clearBufferedCommand();
182
358
  this.displayPrompt();
183
359
  return;
@@ -190,7 +366,7 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
190
366
  }
191
367
  const functionName = parts[parts.length - 1] ?? "";
192
368
  const fileKey = parts.length === 1 ? "index" : parts.slice(0, -1).join("/");
193
- const module = scenarioRegistry?.getModule(fileKey);
369
+ const module = selectedBinding.scenarioRegistry?.getModule(fileKey);
194
370
  if (module === undefined) {
195
371
  print(`Error: Could not find scenario file "${fileKey}"`);
196
372
  this.clearBufferedCommand();
@@ -205,11 +381,20 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
205
381
  return;
206
382
  }
207
383
  try {
384
+ const selectedRoutes = isMultiApi
385
+ ? replServer.context["routes"][selectedBinding.key]
386
+ : replServer.context["routes"];
387
+ if (isMultiApi && selectedRoutes === undefined) {
388
+ print(`Error: Could not resolve routes for API group "${selectedBinding.key}"`);
389
+ this.clearBufferedCommand();
390
+ this.displayPrompt();
391
+ return;
392
+ }
208
393
  const applyContext = {
209
- context: replServer.context["context"],
210
- loadContext: replServer.context["loadContext"],
211
- route: replServer.context["route"],
212
- routes: replServer.context["routes"],
394
+ context: selectedBinding.contextRegistry.find("/"),
395
+ loadContext: ((path) => selectedBinding.contextRegistry.find(path)),
396
+ route: groupedRoute[selectedBinding.key],
397
+ routes: selectedRoutes,
213
398
  };
214
399
  await fn(applyContext);
215
400
  print(`Applied ${text.trim()}`);
@@ -220,7 +405,9 @@ export function startRepl(contextRegistry, registry, config, print = printToStdo
220
405
  this.clearBufferedCommand();
221
406
  this.displayPrompt();
222
407
  },
223
- help: 'apply a scenario script (".apply <path>" calls the named export from scenarios/)',
408
+ help: isMultiApi
409
+ ? 'apply a scenario script (".scenario <group> <path>" calls the named export from that group\'s scenarios/)'
410
+ : 'apply a scenario script (".scenario <path>" calls the named export from scenarios/)',
224
411
  });
225
412
  return replServer;
226
413
  }
@@ -1,4 +1,17 @@
1
1
  import { RawHttpClient } from "./raw-http-client.js";
2
+ /**
3
+ * Immutable fluent builder for constructing and sending HTTP requests from the
4
+ * Counterfact REPL.
5
+ *
6
+ * Each builder method returns a **new** `RouteBuilder` instance with the
7
+ * updated field — the original is never mutated. When all required parameters
8
+ * are set, call {@link send} to execute the request.
9
+ *
10
+ * ```ts
11
+ * // Inside the REPL:
12
+ * route("/pets/{petId}").method("get").path({ petId: 1 }).send();
13
+ * ```
14
+ */
2
15
  export class RouteBuilder {
3
16
  routePath;
4
17
  _body;
@@ -47,29 +60,63 @@ export class RouteBuilder {
47
60
  queryParams: overrides.queryParams ?? this._queryParams,
48
61
  });
49
62
  }
63
+ /**
64
+ * Returns a new builder with the HTTP method set.
65
+ *
66
+ * @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
67
+ */
50
68
  method(method) {
51
69
  return this.clone({ method: method.toUpperCase() });
52
70
  }
71
+ /**
72
+ * Returns a new builder with additional path parameters merged in.
73
+ *
74
+ * @param params - Key/value map of path variable names to values.
75
+ */
53
76
  path(params) {
54
77
  return this.clone({ pathParams: { ...this._pathParams, ...params } });
55
78
  }
79
+ /**
80
+ * Returns a new builder with additional query parameters merged in.
81
+ *
82
+ * @param params - Key/value map of query parameter names to values.
83
+ */
56
84
  query(params) {
57
85
  return this.clone({ queryParams: { ...this._queryParams, ...params } });
58
86
  }
87
+ /**
88
+ * Returns a new builder with additional request headers merged in.
89
+ *
90
+ * @param params - Key/value map of header names to values.
91
+ */
59
92
  headers(params) {
60
93
  return this.clone({ headerParams: { ...this._headerParams, ...params } });
61
94
  }
95
+ /**
96
+ * Returns a new builder with the request body set.
97
+ *
98
+ * @param body - The request body (will be serialised to JSON or sent as-is).
99
+ */
62
100
  body(body) {
63
101
  return this.clone({ body });
64
102
  }
65
103
  getOperation() {
66
104
  return this._operation;
67
105
  }
106
+ /**
107
+ * Returns `true` when a method is set and no required parameters are
108
+ * missing.
109
+ */
68
110
  ready() {
69
111
  if (!this._method)
70
112
  return false;
71
113
  return this.missing() === undefined;
72
114
  }
115
+ /**
116
+ * Returns a {@link MissingParams} object describing all required parameters
117
+ * that have not yet been set, or `undefined` when nothing is missing (or
118
+ * when the operation has no parameters).
119
+ */
73
120
  missing() {
74
121
  const operation = this.getOperation();
75
122
  if (!operation?.parameters)
@@ -98,6 +145,10 @@ export class RouteBuilder {
98
145
  return undefined;
99
146
  return missingParams;
100
147
  }
148
+ /**
149
+ * Returns a human-readable help string describing the operation, its
150
+ * parameters, and the expected responses.
151
+ */
101
152
  help() {
102
153
  const method = this._method ?? "[no method set]";
103
154
  const operation = this.getOperation();
@@ -168,6 +219,13 @@ export class RouteBuilder {
168
219
  }
169
220
  return lines.join("\n");
170
221
  }
222
+ /**
223
+ * Executes the HTTP request and returns the parsed response body.
224
+ *
225
+ * @throws When no HTTP method has been set.
226
+ * @throws When required parameters are missing.
227
+ * @throws When an unsupported HTTP method is used.
228
+ */
171
229
  async send() {
172
230
  if (!this._method) {
173
231
  throw new Error('No HTTP method set. Use .method("get") to set the method.');
@@ -265,6 +323,16 @@ export class RouteBuilder {
265
323
  return lines.join("\n");
266
324
  }
267
325
  }
326
+ /**
327
+ * Creates a factory function that constructs a {@link RouteBuilder} for a
328
+ * given route path, pre-configured with the server's host, port, and OpenAPI
329
+ * document.
330
+ *
331
+ * @param port - The port the Counterfact server is listening on.
332
+ * @param host - The server hostname (default `"localhost"`).
333
+ * @param openApiDocument - Optional OpenAPI document for parameter introspection.
334
+ * @returns A function `(routePath: string) => RouteBuilder`.
335
+ */
268
336
  export function createRouteFunction(port, host, openApiDocument) {
269
337
  return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
270
338
  }
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Default options passed to every chokidar watcher in Counterfact.
3
+ *
4
+ * - `ignoreInitial: true` — suppresses the initial `"add"` events emitted for
5
+ * files already present when the watcher starts.
6
+ * - `usePolling: true` on Windows — chokidar's native FSEvents are unreliable
7
+ * on Windows; polling is more reliable there.
8
+ */
1
9
  export const CHOKIDAR_OPTIONS = {
2
10
  ignoreInitial: true,
3
11
  usePolling: process.platform === "win32",
@@ -1,6 +1,19 @@
1
+ /**
2
+ * A context object that lives at a specific route path and is shared across
3
+ * all requests to that path (and its descendants).
4
+ *
5
+ * Route handlers receive this object as `$.context` and may freely add or
6
+ * modify properties to maintain state between requests.
7
+ */
1
8
  export class Context {
2
9
  constructor() { }
3
10
  }
11
+ /**
12
+ * Returns the parent path of a route path by stripping the last segment.
13
+ *
14
+ * @param path - A route path such as `"/pets/1"`.
15
+ * @returns The parent path (e.g. `"/pets"`), or `"/"` for top-level paths.
16
+ */
4
17
  export function parentPath(path) {
5
18
  return String(path.split("/").slice(0, -1).join("/")) || "/";
6
19
  }
@@ -29,6 +42,19 @@ function cloneForCache(value) {
29
42
  }
30
43
  return clone;
31
44
  }
45
+ /**
46
+ * Registry of per-path {@link Context} objects that persist state across
47
+ * requests.
48
+ *
49
+ * The registry is case-insensitive for path lookups and uses a write-through
50
+ * cache to detect which context properties have changed between hot-reloads.
51
+ * It extends {@link EventTarget} so that listeners can react to structural
52
+ * changes (e.g. to regenerate type files):
53
+ *
54
+ * ```ts
55
+ * contextRegistry.addEventListener("context-changed", () => { ... });
56
+ * ```
57
+ */
32
58
  export class ContextRegistry extends EventTarget {
33
59
  entries = new Map();
34
60
  cache = new Map();
@@ -46,6 +72,13 @@ export class ContextRegistry extends EventTarget {
46
72
  }
47
73
  return undefined;
48
74
  }
75
+ /**
76
+ * Registers a new context for `path`, replacing any existing one, and
77
+ * dispatches a `"context-changed"` event so listeners can react.
78
+ *
79
+ * @param path - The route path (e.g. `"/pets"`).
80
+ * @param context - The context object to store.
81
+ */
49
82
  add(path, context) {
50
83
  this.entries.set(path, context);
51
84
  this.cache.set(path, cloneForCache(context));
@@ -53,7 +86,7 @@ export class ContextRegistry extends EventTarget {
53
86
  }
54
87
  /**
55
88
  * Removes the context entry for the given path and dispatches a
56
- * "context-changed" event so that listeners (e.g. the scenario-context type
89
+ * "context-changed" event so that listeners (e.g. the _.context type
57
90
  * generator) can regenerate type files in response to the removal.
58
91
  *
59
92
  * @param path - The route path whose context entry should be deleted
@@ -65,10 +98,28 @@ export class ContextRegistry extends EventTarget {
65
98
  this.seen.delete(path);
66
99
  this.dispatchEvent(new Event("context-changed"));
67
100
  }
101
+ /**
102
+ * Finds the context for `path`, walking up the path hierarchy until a
103
+ * context is found. Falls back to `"/"` which always has a context.
104
+ *
105
+ * @param path - The route path to look up.
106
+ * @returns The nearest ancestor context (or the root context).
107
+ */
68
108
  find(path) {
69
109
  return (this.getContextIgnoreCase(this.entries, path) ??
70
110
  this.find(parentPath(path)));
71
111
  }
112
+ /**
113
+ * Merges `updatedContext` into the existing context for `path`.
114
+ *
115
+ * On the first call for a path the context is added directly. On subsequent
116
+ * calls only properties whose values differ from the cached snapshot are
117
+ * applied, preserving live mutations made by route handlers between reloads.
118
+ *
119
+ * @param path - The route path (e.g. `"/pets"`).
120
+ * @param updatedContext - The new context instance (typically freshly
121
+ * constructed from the reloaded `_.context.ts` file).
122
+ */
72
123
  update(path, updatedContext) {
73
124
  if (updatedContext === undefined) {
74
125
  return;
@@ -87,9 +138,11 @@ export class ContextRegistry extends EventTarget {
87
138
  Object.setPrototypeOf(context, Object.getPrototypeOf(updatedContext));
88
139
  this.cache.set(path, cloneForCache(updatedContext));
89
140
  }
141
+ /** Returns all registered route paths as an array. */
90
142
  getAllPaths() {
91
143
  return Array.from(this.entries.keys());
92
144
  }
145
+ /** Returns a plain object mapping every registered path to its context. */
93
146
  getAllContexts() {
94
147
  const result = {};
95
148
  for (const [path, context] of this.entries.entries()) {
@@ -1,7 +1,21 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ /* eslint-disable security/detect-non-literal-fs-filename -- module kind detection only probes package.json while walking parent directories. */
4
5
  const DEFAULT_MODULE_KIND = "commonjs";
6
+ /**
7
+ * Determines whether a module file should be treated as CommonJS or ESM.
8
+ *
9
+ * Resolution order (matches Node.js conventions):
10
+ * 1. `.cjs` extension → `"commonjs"`.
11
+ * 2. `.mjs` or `.ts` extension → `"module"`.
12
+ * 3. Walk up the directory tree looking for a `package.json` with a `"type"`
13
+ * field.
14
+ * 4. Falls back to `"commonjs"` at the filesystem root.
15
+ *
16
+ * @param modulePath - Absolute or relative path to the module file.
17
+ * @returns `"commonjs"` or `"module"`.
18
+ */
5
19
  export async function determineModuleKind(modulePath) {
6
20
  if (modulePath.endsWith(".cjs")) {
7
21
  return "commonjs";