counterfact 2.6.0 → 2.8.1

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 (98) hide show
  1. package/README.md +14 -207
  2. package/bin/README.md +24 -4
  3. package/bin/counterfact.js +54 -3
  4. package/dist/app.js +81 -28
  5. package/dist/counterfact-types/cookie-options.js +1 -0
  6. package/dist/counterfact-types/counterfact-response.js +7 -0
  7. package/dist/counterfact-types/example-names.js +1 -0
  8. package/dist/counterfact-types/example.js +1 -0
  9. package/dist/counterfact-types/generic-response-builder.js +1 -0
  10. package/dist/counterfact-types/http-status-code.js +1 -0
  11. package/dist/counterfact-types/if-has-key.js +1 -0
  12. package/dist/counterfact-types/index.js +0 -1
  13. package/dist/counterfact-types/maybe-promise.js +1 -0
  14. package/dist/counterfact-types/media-type.js +1 -0
  15. package/dist/counterfact-types/omit-all.js +1 -0
  16. package/dist/counterfact-types/omit-value-when-never.js +1 -0
  17. package/dist/counterfact-types/open-api-content.js +1 -0
  18. package/dist/counterfact-types/open-api-operation.js +1 -0
  19. package/dist/counterfact-types/open-api-parameters.js +1 -0
  20. package/dist/counterfact-types/open-api-response.js +1 -0
  21. package/dist/counterfact-types/random-function.js +1 -0
  22. package/dist/counterfact-types/response-builder-factory.js +1 -0
  23. package/dist/counterfact-types/response-builder.js +1 -0
  24. package/dist/counterfact-types/wide-operation-argument.js +1 -0
  25. package/dist/counterfact-types/wide-response-builder.js +1 -0
  26. package/dist/migrate/update-route-types.js +2 -3
  27. package/dist/repl/raw-http-client.js +19 -0
  28. package/dist/repl/repl.js +116 -4
  29. package/dist/repl/route-builder.js +68 -0
  30. package/dist/server/constants.js +8 -0
  31. package/dist/server/context-registry.js +70 -1
  32. package/dist/server/counterfact-types/cookie-options.ts +14 -0
  33. package/dist/server/counterfact-types/counterfact-response.ts +15 -0
  34. package/dist/server/counterfact-types/example-names.ts +13 -0
  35. package/dist/server/counterfact-types/example.ts +10 -0
  36. package/dist/server/counterfact-types/generic-response-builder.ts +164 -0
  37. package/dist/server/counterfact-types/http-status-code.ts +62 -0
  38. package/dist/server/counterfact-types/if-has-key.ts +19 -0
  39. package/dist/server/counterfact-types/index.ts +20 -338
  40. package/dist/server/counterfact-types/maybe-promise.ts +6 -0
  41. package/dist/server/counterfact-types/media-type.ts +6 -0
  42. package/dist/server/counterfact-types/omit-all.ts +11 -0
  43. package/dist/server/counterfact-types/omit-value-when-never.ts +11 -0
  44. package/dist/server/counterfact-types/open-api-content.ts +8 -0
  45. package/dist/server/counterfact-types/open-api-operation.ts +36 -0
  46. package/dist/server/counterfact-types/open-api-parameters.ts +16 -0
  47. package/dist/server/counterfact-types/open-api-response.ts +22 -0
  48. package/dist/server/counterfact-types/random-function.ts +9 -0
  49. package/dist/server/counterfact-types/response-builder-factory.ts +16 -0
  50. package/dist/server/counterfact-types/response-builder.ts +31 -0
  51. package/dist/server/counterfact-types/wide-operation-argument.ts +17 -0
  52. package/dist/server/counterfact-types/wide-response-builder.ts +26 -0
  53. package/dist/server/create-koa-app.js +28 -24
  54. package/dist/server/determine-module-kind.js +13 -0
  55. package/dist/server/dispatcher.js +64 -5
  56. package/dist/server/file-discovery.js +20 -9
  57. package/dist/server/is-proxy-enabled-for-path.js +12 -0
  58. package/dist/server/json-to-xml.js +11 -1
  59. package/dist/server/koa-middleware.js +25 -2
  60. package/dist/server/load-openapi-document.js +6 -0
  61. package/dist/server/module-dependency-graph.js +25 -0
  62. package/dist/server/module-loader.js +112 -17
  63. package/dist/server/module-tree.js +36 -0
  64. package/dist/server/openapi-document.js +69 -0
  65. package/dist/server/openapi-middleware.js +34 -5
  66. package/dist/server/openapi-watcher.js +35 -0
  67. package/dist/server/registry.js +89 -0
  68. package/dist/server/request-validator.js +3 -7
  69. package/dist/server/response-builder.js +18 -0
  70. package/dist/server/response-validator.js +58 -0
  71. package/dist/server/scenario-registry.js +55 -0
  72. package/dist/server/tools.js +29 -2
  73. package/dist/server/transpiler.js +23 -9
  74. package/dist/typescript-generator/code-generator.js +117 -4
  75. package/dist/typescript-generator/coder.js +80 -2
  76. package/dist/typescript-generator/operation-coder.js +13 -5
  77. package/dist/typescript-generator/operation-type-coder.js +40 -53
  78. package/dist/typescript-generator/parameters-type-coder.js +2 -4
  79. package/dist/typescript-generator/prune.js +2 -1
  80. package/dist/typescript-generator/read-only-comments.js +1 -1
  81. package/dist/typescript-generator/repository.js +76 -20
  82. package/dist/typescript-generator/requirement.js +77 -1
  83. package/dist/typescript-generator/reserved-words.js +50 -0
  84. package/dist/typescript-generator/scenario-file-generator.js +235 -0
  85. package/dist/typescript-generator/script.js +70 -7
  86. package/dist/typescript-generator/specification.js +27 -0
  87. package/dist/util/ensure-directory-exists.js +7 -0
  88. package/dist/util/forward-slash-path.js +63 -0
  89. package/dist/util/load-config-file.js +44 -0
  90. package/dist/util/read-file.js +11 -0
  91. package/dist/util/runtime-can-execute-erasable-ts.js +11 -0
  92. package/dist/util/windows-escape.js +18 -0
  93. package/package.json +9 -10
  94. package/dist/client/README.md +0 -14
  95. package/dist/client/index.html.hbs +0 -244
  96. package/dist/client/rapi-doc.html.hbs +0 -36
  97. package/dist/server/page-middleware.js +0 -23
  98. package/dist/typescript-generator/generate.js +0 -63
@@ -1,3 +1,11 @@
1
+ /**
2
+ * A node in the dereferenced OpenAPI spec tree.
3
+ *
4
+ * A `Requirement` wraps a raw JSON object (`data`) together with its location
5
+ * (`url`, a JSON Pointer) and a back-reference to the owning
6
+ * {@link Specification}. Navigation methods (`get`, `select`, `find`, …)
7
+ * transparently follow `$ref` pointers.
8
+ */
1
9
  export class Requirement {
2
10
  data;
3
11
  url;
@@ -7,18 +15,36 @@ export class Requirement {
7
15
  this.url = url;
8
16
  this.specification = specification;
9
17
  }
18
+ /** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
10
19
  get isReference() {
11
20
  return this.data["$ref"] !== undefined;
12
21
  }
22
+ /**
23
+ * Resolves the `$ref` and returns the target {@link Requirement}.
24
+ *
25
+ * @throws When `isReference` is `false` or the specification is not set.
26
+ */
13
27
  reference() {
14
28
  return this.specification.getRequirement(this.data["$ref"]);
15
29
  }
30
+ /**
31
+ * Returns `true` when this node has a child property named `item`.
32
+ *
33
+ * Transparently follows `$ref` references.
34
+ *
35
+ * @param item - The property key to check.
36
+ */
16
37
  has(item) {
17
38
  if (this.isReference) {
18
39
  return this.reference().has(item);
19
40
  }
20
41
  return item in this.data;
21
42
  }
43
+ /**
44
+ * Returns the child {@link Requirement} for `item`, or `undefined`.
45
+ *
46
+ * @param item - The property key (string) or array index (number).
47
+ */
22
48
  get(item) {
23
49
  if (this.isReference) {
24
50
  return this.reference().get(item);
@@ -29,6 +55,16 @@ export class Requirement {
29
55
  }
30
56
  return new Requirement(this.data[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
31
57
  }
58
+ /**
59
+ * Navigates to a descendant node using a slash-delimited JSON Pointer path.
60
+ *
61
+ * Tilde-escaped characters (`~0` → `~`, `~1` → `/`) and percent-encoded
62
+ * characters are unescaped during traversal.
63
+ *
64
+ * @param path - A slash-delimited path (e.g. `"responses/200/content"`).
65
+ * @returns The target {@link Requirement}, or `undefined` if the path does
66
+ * not exist.
67
+ */
32
68
  select(path) {
33
69
  const parts = path
34
70
  .split("/")
@@ -36,7 +72,14 @@ export class Requirement {
36
72
  // Unescape URL encoded characters (e.g. %20 -> " ")
37
73
  // Technically we should not be unescaping, but it came up in https://github.com/pmcelhaney/counterfact/issues/1083
38
74
  // and I can't think of a reason anyone would intentionally put a % in a key name.
39
- .map(unescape);
75
+ .map((part) => {
76
+ try {
77
+ return decodeURIComponent(part);
78
+ }
79
+ catch {
80
+ return part;
81
+ }
82
+ });
40
83
  // eslint-disable-next-line @typescript-eslint/no-this-alias
41
84
  let result = this;
42
85
  for (const part of parts) {
@@ -47,19 +90,42 @@ export class Requirement {
47
90
  }
48
91
  return result;
49
92
  }
93
+ /**
94
+ * Iterates over all child properties and calls `callback` with each child
95
+ * requirement and its key.
96
+ *
97
+ * @param callback - Called for each child with `(child, key)`.
98
+ */
50
99
  forEach(callback) {
51
100
  Object.keys(this.data).forEach((key) => {
52
101
  callback(this.select(this.escapeJsonPointer(key)), key);
53
102
  });
54
103
  }
104
+ /**
105
+ * Maps over all child properties and returns the collected results.
106
+ *
107
+ * @param callback - Transformation function called with `(child, key)`.
108
+ */
55
109
  map(callback) {
56
110
  const result = [];
57
111
  this.forEach((value, key) => result.push(callback(value, key)));
58
112
  return result;
59
113
  }
114
+ /**
115
+ * Maps and flattens over all child properties.
116
+ *
117
+ * @param callback - Transformation function that may return a value or an
118
+ * array of values.
119
+ */
60
120
  flatMap(callback) {
61
121
  return this.map(callback).flat();
62
122
  }
123
+ /**
124
+ * Returns the first child for which `callback` returns `true`, or
125
+ * `undefined` when nothing matches.
126
+ *
127
+ * @param callback - Predicate called with `(child, key)`.
128
+ */
63
129
  find(callback) {
64
130
  let result;
65
131
  this.forEach((value, key) => {
@@ -69,11 +135,21 @@ export class Requirement {
69
135
  });
70
136
  return result;
71
137
  }
138
+ /**
139
+ * Escapes a JSON Pointer token: `~` → `~0`, `/` → `~1`.
140
+ *
141
+ * @param value - The token to escape.
142
+ */
72
143
  escapeJsonPointer(value) {
73
144
  if (typeof value !== "string")
74
145
  return value;
75
146
  return value.replaceAll("~", "~0").replaceAll("/", "~1");
76
147
  }
148
+ /**
149
+ * Unescapes a JSON Pointer token: `~1` → `/`, `~0` → `~`.
150
+ *
151
+ * @param pointer - The token to unescape.
152
+ */
77
153
  unescapeJsonPointer(pointer) {
78
154
  if (typeof pointer !== "string")
79
155
  return pointer;
@@ -0,0 +1,50 @@
1
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
2
+ export const RESERVED_WORDS = new Set([
3
+ "break",
4
+ "case",
5
+ "catch",
6
+ "class",
7
+ "const",
8
+ "continue",
9
+ "debugger",
10
+ "default",
11
+ "delete",
12
+ "do",
13
+ "else",
14
+ "export",
15
+ "extends",
16
+ "false",
17
+ "finally",
18
+ "for",
19
+ "function",
20
+ "if",
21
+ "import",
22
+ "in",
23
+ "instanceof",
24
+ "new",
25
+ "null",
26
+ "return",
27
+ "static",
28
+ "super",
29
+ "switch",
30
+ "this",
31
+ "throw",
32
+ "true",
33
+ "try",
34
+ "typeof",
35
+ "var",
36
+ "void",
37
+ "while",
38
+ "with",
39
+ "yield",
40
+ "await",
41
+ "enum",
42
+ "implements",
43
+ "interface",
44
+ "let",
45
+ "package",
46
+ "private",
47
+ "protected",
48
+ "public",
49
+ "type",
50
+ ]);
@@ -0,0 +1,235 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import nodePath from "node:path";
4
+ import { watch } from "chokidar";
5
+ import { CHOKIDAR_OPTIONS } from "../server/constants.js";
6
+ import { pathRelative } from "../util/forward-slash-path.js";
7
+ import { waitForEvent } from "../util/wait-for-event.js";
8
+ async function collectContextFiles(destination) {
9
+ const routesDir = nodePath.join(destination, "routes");
10
+ const results = [];
11
+ if (!existsSync(routesDir)) {
12
+ return results;
13
+ }
14
+ await walkForContextFiles(routesDir, routesDir, results);
15
+ results.sort((a, b) => b.depth - a.depth);
16
+ return results;
17
+ }
18
+ async function walkForContextFiles(routesDir, currentDir, results) {
19
+ let entries;
20
+ try {
21
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return;
25
+ }
26
+ for (const entry of entries) {
27
+ if (entry.isDirectory()) {
28
+ await walkForContextFiles(routesDir, nodePath.join(currentDir, entry.name), results);
29
+ }
30
+ else if (entry.name === "_.context.ts") {
31
+ const relDir = pathRelative(routesDir, currentDir);
32
+ const routePath = relDir === "" ? "/" : `/${relDir}`;
33
+ const depth = relDir === "" ? 0 : relDir.split("/").length;
34
+ const importPath = relDir === "" ? "../routes/_.context" : `../routes/${relDir}/_.context`;
35
+ const alias = routePathToAlias(routePath);
36
+ results.push({ importPath, alias, routePath, depth });
37
+ }
38
+ }
39
+ }
40
+ function routePathToAlias(routePath) {
41
+ if (routePath === "/") {
42
+ return "Context";
43
+ }
44
+ return (routePath
45
+ .split("/")
46
+ .filter(Boolean)
47
+ .map((seg) => seg
48
+ .replace(/\{(.+?)\}/g, (_match, name) => name.replace(/[^a-z0-9]/gi, " "))
49
+ .replace(/[-_\s]([a-z])/g, (_match, c) => c.toUpperCase())
50
+ .replace(/^[a-z]/, (c) => c.toUpperCase())
51
+ .replace(/[^a-z0-9]/gi, ""))
52
+ .join("") + "Context");
53
+ }
54
+ const PARAM_SEGMENT_REGEX = /^\{.+\}$/u;
55
+ function buildLoadContextOverload(routePath, alias) {
56
+ if (routePath === "/") {
57
+ return ' loadContext(path: "/" | `/${string}`): ' + alias + ";";
58
+ }
59
+ const segments = routePath.split("/").filter(Boolean);
60
+ const hasParam = segments.some((seg) => PARAM_SEGMENT_REGEX.test(seg));
61
+ if (!hasParam) {
62
+ return ` loadContext(path: "${routePath}" | \`${routePath}/\${string}\`): ${alias};`;
63
+ }
64
+ const templatePath = `/${segments
65
+ .map((seg) => (PARAM_SEGMENT_REGEX.test(seg) ? "${string}" : seg))
66
+ .join("/")}`;
67
+ return ` loadContext(path: \`${templatePath}\`): ${alias};`;
68
+ }
69
+ function buildScenarioContextContent(contextFiles) {
70
+ const rootContext = contextFiles.find((f) => f.routePath === "/");
71
+ const contextType = rootContext
72
+ ? rootContext.alias
73
+ : "Record<string, unknown>";
74
+ const importLines = contextFiles.map(({ importPath, alias }) => alias === "Context"
75
+ ? `import type { Context } from "${importPath}";`
76
+ : `import type { Context as ${alias} } from "${importPath}";`);
77
+ const overloadLines = contextFiles.map(({ alias, routePath }) => buildLoadContextOverload(routePath, alias));
78
+ const parts = [
79
+ "// This file is generated by Counterfact. Do not edit manually.",
80
+ ...importLines,
81
+ "",
82
+ "interface LoadContextDefinitions {",
83
+ " /* code generator adds additional signatures here */",
84
+ ...overloadLines,
85
+ " loadContext(path: string): Record<string, unknown>;",
86
+ "}",
87
+ "",
88
+ "export interface Scenario$ {",
89
+ ' /** Root context, same as loadContext("/") */',
90
+ ` readonly context: ${contextType};`,
91
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
92
+ " /** Named route builders stored in the REPL execution context */",
93
+ " readonly routes: Record<string, unknown>;",
94
+ " /** Create a new route builder for a given path */",
95
+ " readonly route: (path: string) => unknown;",
96
+ "}",
97
+ "",
98
+ "/** A scenario function that receives the live REPL environment */",
99
+ "export type Scenario = ($: Scenario$) => Promise<void> | void;",
100
+ "",
101
+ "/** Interface for Context objects defined in _.context.ts files */",
102
+ "export interface Context$ {",
103
+ " /** Load a context object for a specific path */",
104
+ ' readonly loadContext: LoadContextDefinitions["loadContext"];',
105
+ " /** Load a JSON file relative to this file's path */",
106
+ " readonly readJson: (relativePath: string) => Promise<unknown>;",
107
+ "}",
108
+ "",
109
+ ];
110
+ return parts.join("\n");
111
+ }
112
+ /**
113
+ * Writes the `types/_.context.ts` file, which exports the
114
+ * `Scenario$` interface used to type scenario functions.
115
+ *
116
+ * The interface is generated from all `_.context.ts` files found under the
117
+ * `routes/` directory, providing strongly typed `loadContext()` overloads for
118
+ * every route path that has a context file.
119
+ *
120
+ * @param destination - Root output directory.
121
+ */
122
+ async function writeScenarioContextType(destination) {
123
+ const typesDir = nodePath.join(destination, "types");
124
+ const filePath = nodePath.join(typesDir, "_.context.ts");
125
+ const contextFiles = await collectContextFiles(destination);
126
+ const content = buildScenarioContextContent(contextFiles);
127
+ await fs.mkdir(typesDir, { recursive: true });
128
+ await fs.writeFile(filePath, content, "utf8");
129
+ }
130
+ const DEFAULT_SCENARIOS_INDEX = `import type { Scenario } from "../types/_.context.js";
131
+
132
+ /**
133
+ * Scenario scripts are plain TypeScript functions that receive the live REPL
134
+ * environment and can read or mutate server state. Run them from the REPL with:
135
+ * .scenario <functionName>
136
+ */
137
+
138
+ /**
139
+ * Read or mutate the root context (same object routes see as $.context):
140
+ * $.context.<property> = <value>;
141
+ *
142
+ * Load a context for a specific path:
143
+ * const petsCtx = $.loadContext("/pets");
144
+ *
145
+ * Store a pre-configured route builder for later use in the REPL:
146
+ * $.routes.myRequest = $.route("/pets").method("get");
147
+ */
148
+
149
+ /**
150
+ * startup() runs automatically when the server initializes, right before the
151
+ * REPL starts. Use it to seed dummy data so the server is ready to use
152
+ * immediately. It receives the same $ argument as all other scenario functions.
153
+ *
154
+ * Tip: delegate to other scenario functions and pass $ along so each function
155
+ * stays focused on a single concern. You can also pass additional arguments to
156
+ * configure them, e.g. addPets($, 20, "dog").
157
+ *
158
+ * If you don't need a startup scenario, delete this function or leave it empty.
159
+ */
160
+ export const startup: Scenario = ($) => {
161
+ void $;
162
+ };
163
+
164
+ /**
165
+ * An example scenario. To use it in the REPL, type:
166
+ * .scenario help
167
+ */
168
+ export const help: Scenario = ($) => {
169
+ void $;
170
+
171
+ console.log(
172
+ [
173
+ "Scenarios are functions that populate the context object",
174
+ "and / or the REPL environment. They are intended to",
175
+ "populate your environment with specific data and",
176
+ "configurations for testing purposes.",
177
+ ].join("\\n"),
178
+ );
179
+
180
+ console.log(
181
+ "\\nScenarios (including this one) are defined in the ./scenarios directory.",
182
+ );
183
+ };
184
+ `;
185
+ async function writeDefaultScenariosIndex(destination) {
186
+ const scenariosDir = nodePath.join(destination, "scenarios");
187
+ const filePath = nodePath.join(scenariosDir, "index.ts");
188
+ if (existsSync(filePath)) {
189
+ return;
190
+ }
191
+ await fs.mkdir(scenariosDir, { recursive: true });
192
+ await fs.writeFile(filePath, DEFAULT_SCENARIOS_INDEX, "utf8");
193
+ }
194
+ /**
195
+ * Encapsulates the generation of scenario-related files:
196
+ * - `types/_.context.ts` — the typed `Scenario$` interface derived from all
197
+ * `_.context.ts` files found under `routes/`.
198
+ * - `scenarios/index.ts` — the default scenarios entry-point (created only if
199
+ * it does not already exist).
200
+ *
201
+ * When {@link watch} is called, a file-system watcher monitors the `routes/`
202
+ * directory for changes to `_.context.ts` files and automatically regenerates
203
+ * `types/_.context.ts`.
204
+ */
205
+ export class ScenarioFileGenerator {
206
+ destination;
207
+ watcher;
208
+ constructor(destination) {
209
+ this.destination = destination;
210
+ }
211
+ /** Generates both scenario-related files once and resolves when complete. */
212
+ async generate() {
213
+ await writeScenarioContextType(this.destination);
214
+ await writeDefaultScenariosIndex(this.destination);
215
+ }
216
+ /**
217
+ * Starts watching the `routes/` directory for `_.context.ts` changes and
218
+ * regenerates `types/_.context.ts` on every change.
219
+ *
220
+ * Resolves once the watcher is ready.
221
+ */
222
+ async watch() {
223
+ const routesDir = nodePath.join(this.destination, "routes");
224
+ this.watcher = watch(routesDir, CHOKIDAR_OPTIONS).on("all", (_event, filePath) => {
225
+ if (filePath.endsWith("_.context.ts")) {
226
+ void writeScenarioContextType(this.destination);
227
+ }
228
+ });
229
+ await waitForEvent(this.watcher, "ready");
230
+ }
231
+ /** Closes the file-system watcher. */
232
+ async stopWatching() {
233
+ await this.watcher?.close();
234
+ }
235
+ }
@@ -1,8 +1,18 @@
1
- import nodePath from "node:path";
2
1
  import createDebugger from "debug";
3
2
  import { format } from "prettier";
4
3
  import { escapePathForWindows } from "../util/windows-escape.js";
4
+ import { pathJoin, pathRelative, pathDirname, } from "../util/forward-slash-path.js";
5
5
  const debug = createDebugger("counterfact:typescript-generator:script");
6
+ /**
7
+ * Represents a single TypeScript file being assembled by the code generator.
8
+ *
9
+ * A `Script` accumulates exports, imports, and external imports contributed by
10
+ * {@link Coder} instances. Once all coders have resolved, {@link contents}
11
+ * formats the result with Prettier and returns the final source text.
12
+ *
13
+ * Scripts are created and retrieved through a {@link Repository} so that the
14
+ * same module path always maps to the same `Script` instance.
15
+ */
6
16
  export class Script {
7
17
  repository;
8
18
  comments;
@@ -22,6 +32,10 @@ export class Script {
22
32
  this.typeCache = new Map();
23
33
  this.path = path;
24
34
  }
35
+ /**
36
+ * A `"../"` path fragment that points from this script's directory back to
37
+ * the repository root, used to resolve relative import paths.
38
+ */
25
39
  get relativePathToBase() {
26
40
  return this.path
27
41
  .split("/")
@@ -29,6 +43,13 @@ export class Script {
29
43
  .map(() => "..")
30
44
  .join("/");
31
45
  }
46
+ /**
47
+ * Picks the first name from `coder.names()` that is not already used as an
48
+ * import in this script, ensuring export/import name uniqueness.
49
+ *
50
+ * @param coder - The coder needing a name.
51
+ * @throws When all 100 candidate names are already taken.
52
+ */
32
53
  firstUniqueName(coder) {
33
54
  for (const name of coder.names()) {
34
55
  if (!this.imports.has(name)) {
@@ -37,6 +58,17 @@ export class Script {
37
58
  }
38
59
  throw new Error(`could not find a unique name for ${coder.id}`);
39
60
  }
61
+ /**
62
+ * Registers an export for `coder` in this script and returns the export name.
63
+ *
64
+ * If the same coder has already been exported (cache hit), the previously
65
+ * assigned name is returned without creating a duplicate.
66
+ *
67
+ * @param coder - The coder to export.
68
+ * @param isType - Emit `export type` instead of `export const`.
69
+ * @param isDefault - Emit `export default` instead of a named export.
70
+ * @returns The name under which the coder is exported.
71
+ */
40
72
  export(coder, isType = false, isDefault = false) {
41
73
  const cacheKey = isDefault ? "default" : `${coder.id}:${isType}`;
42
74
  if (this.cache.has(cacheKey)) {
@@ -75,6 +107,16 @@ export class Script {
75
107
  exportDefault(coder, isType = false) {
76
108
  this.export(coder, isType, true);
77
109
  }
110
+ /**
111
+ * Registers an import of `coder` from its owning module and returns the
112
+ * local alias used in this script.
113
+ *
114
+ * The coder is also exported from its home module as a side effect.
115
+ *
116
+ * @param coder - The coder to import.
117
+ * @param isType - Use a `import type` declaration.
118
+ * @param isDefault - Import the default export rather than a named export.
119
+ */
78
120
  import(coder, isType = false, isDefault = false) {
79
121
  debug("import coder: %s", coder.id);
80
122
  const modulePath = coder.modulePath();
@@ -103,24 +145,42 @@ export class Script {
103
145
  importDefault(coder, isType = false) {
104
146
  return this.import(coder, isType, true);
105
147
  }
148
+ /**
149
+ * Registers an import from an external npm package or absolute module path
150
+ * (not managed by the repository).
151
+ *
152
+ * @param name - The local binding name.
153
+ * @param modulePath - The module specifier (e.g. `"counterfact-types/index"`).
154
+ * @param isType - Use a `import type` declaration.
155
+ * @returns `name` (for convenience in method chaining).
156
+ */
106
157
  importExternal(name, modulePath, isType = false) {
107
158
  this.externalImport.set(name, { isType, modulePath });
108
159
  return name;
109
160
  }
161
+ /**
162
+ * Convenience wrapper that calls {@link importExternal} with `isType = true`.
163
+ */
110
164
  importExternalType(name, modulePath) {
111
165
  return this.importExternal(name, modulePath, true);
112
166
  }
167
+ /**
168
+ * Imports a type from the shared `counterfact-types/index.ts` module,
169
+ * resolving the path relative to this script's location in the repository.
170
+ *
171
+ * @param name - The type name to import (e.g. `"WideOperationArgument"`).
172
+ */
113
173
  importSharedType(name) {
114
- return this.importExternal(name, nodePath
115
- .join(this.relativePathToBase, "counterfact-types/index.ts")
116
- .replaceAll("\\", "/"), true);
174
+ return this.importExternal(name, pathJoin(this.relativePathToBase, "counterfact-types/index.ts"), true);
117
175
  }
118
176
  exportType(coder) {
119
177
  return this.export(coder, true);
120
178
  }
179
+ /** `true` while at least one export promise is still pending. */
121
180
  isInProgress() {
122
181
  return Array.from(this.exports.values()).some((exportStatement) => !exportStatement.done);
123
182
  }
183
+ /** Returns a promise that resolves when all pending export promises settle. */
124
184
  finished() {
125
185
  return Promise.all(Array.from(this.exports.values(), (value) => value.promise));
126
186
  }
@@ -129,9 +189,7 @@ export class Script {
129
189
  }
130
190
  importStatements() {
131
191
  return Array.from(this.imports, ([name, { isDefault, isType, script }]) => {
132
- const resolvedPath = escapePathForWindows(nodePath
133
- .relative(nodePath.dirname(this.path).replaceAll("\\", "/"), script.path.replace(/\.ts$/u, ".js"))
134
- .replaceAll("\\", "/"));
192
+ const resolvedPath = escapePathForWindows(pathRelative(pathDirname(this.path), script.path.replace(/\.ts$/u, ".js")));
135
193
  return `import${isType ? " type" : ""} ${isDefault ? name : `{ ${name} }`} from "${resolvedPath.includes("../") ? "" : "./"}${resolvedPath}";`;
136
194
  });
137
195
  }
@@ -150,6 +208,11 @@ export class Script {
150
208
  return `${jsdoc}${beforeExport}export ${keyword} ${name ?? ""}${typeAnnotation} = ${code};`;
151
209
  });
152
210
  }
211
+ /**
212
+ * Formats the fully assembled script source with Prettier and returns it.
213
+ *
214
+ * All pending export promises are awaited before formatting.
215
+ */
153
216
  contents() {
154
217
  return format([
155
218
  this.comments.map((comment) => `// ${comment}`).join("\n"),
@@ -2,6 +2,14 @@ import { bundle } from "@apidevtools/json-schema-ref-parser";
2
2
  import createDebug from "debug";
3
3
  import { Requirement } from "./requirement.js";
4
4
  const debug = createDebug("counterfact:typescript-generator:specification");
5
+ /**
6
+ * Represents a fully dereferenced OpenAPI specification as a navigable tree
7
+ * of {@link Requirement} nodes.
8
+ *
9
+ * Use {@link Specification.fromFile} to load a spec from disk or a URL; the
10
+ * static method bundles external `$ref` references into a single in-memory
11
+ * object before constructing the tree.
12
+ */
5
13
  export class Specification {
6
14
  cache;
7
15
  rootRequirement;
@@ -11,15 +19,34 @@ export class Specification {
11
19
  this.rootRequirement = rootRequirement;
12
20
  }
13
21
  }
22
+ /**
23
+ * Loads the OpenAPI document at `urlOrPath`, bundles all external `$ref`
24
+ * references, and returns a fully initialised {@link Specification}.
25
+ *
26
+ * @param urlOrPath - A local file path or HTTP(S) URL.
27
+ * @throws When the document cannot be found or parsed.
28
+ */
14
29
  static async fromFile(urlOrPath) {
15
30
  const specification = new Specification();
16
31
  await specification.load(urlOrPath);
17
32
  return specification;
18
33
  }
34
+ /**
35
+ * Returns the {@link Requirement} at `url` (a JSON Pointer such as
36
+ * `"#/paths"`).
37
+ *
38
+ * @param url - A JSON Pointer string (must start with `"#/"`).
39
+ */
19
40
  getRequirement(url) {
20
41
  debug("getting requirement at %s", url);
21
42
  return this.rootRequirement.select(url.slice(2));
22
43
  }
44
+ /**
45
+ * Loads (or reloads) the specification from `urlOrPath`.
46
+ *
47
+ * @param urlOrPath - A local file path or HTTP(S) URL.
48
+ * @throws When the document cannot be found or parsed.
49
+ */
23
50
  async load(urlOrPath) {
24
51
  try {
25
52
  this.rootRequirement = new Requirement((await bundle(urlOrPath)), urlOrPath, this);
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import nodePath from "node:path";
3
+ /**
4
+ * Synchronously ensures that the directory containing `filePath` exists,
5
+ * creating it (and any missing ancestors) if necessary.
6
+ *
7
+ * @param filePath - Path to a file; the *directory* part of this path is
8
+ * created, not the file itself.
9
+ */
3
10
  export function ensureDirectoryExists(filePath) {
4
11
  const directory = nodePath.dirname(filePath);
5
12
  try {