trickle-cli 0.1.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 (125) hide show
  1. package/dist/api-client.d.ts +208 -0
  2. package/dist/api-client.js +237 -0
  3. package/dist/commands/annotate.d.ts +6 -0
  4. package/dist/commands/annotate.js +433 -0
  5. package/dist/commands/audit.d.ts +7 -0
  6. package/dist/commands/audit.js +82 -0
  7. package/dist/commands/auto.d.ts +8 -0
  8. package/dist/commands/auto.js +268 -0
  9. package/dist/commands/capture.d.ts +14 -0
  10. package/dist/commands/capture.js +271 -0
  11. package/dist/commands/check.d.ts +6 -0
  12. package/dist/commands/check.js +408 -0
  13. package/dist/commands/codegen.d.ts +21 -0
  14. package/dist/commands/codegen.js +129 -0
  15. package/dist/commands/coverage.d.ts +13 -0
  16. package/dist/commands/coverage.js +126 -0
  17. package/dist/commands/dashboard.d.ts +1 -0
  18. package/dist/commands/dashboard.js +83 -0
  19. package/dist/commands/dev.d.ts +14 -0
  20. package/dist/commands/dev.js +319 -0
  21. package/dist/commands/diff.d.ts +7 -0
  22. package/dist/commands/diff.js +79 -0
  23. package/dist/commands/docs.d.ts +13 -0
  24. package/dist/commands/docs.js +383 -0
  25. package/dist/commands/errors.d.ts +7 -0
  26. package/dist/commands/errors.js +180 -0
  27. package/dist/commands/export.d.ts +18 -0
  28. package/dist/commands/export.js +238 -0
  29. package/dist/commands/functions.d.ts +6 -0
  30. package/dist/commands/functions.js +71 -0
  31. package/dist/commands/infer.d.ts +14 -0
  32. package/dist/commands/infer.js +275 -0
  33. package/dist/commands/init.d.ts +5 -0
  34. package/dist/commands/init.js +395 -0
  35. package/dist/commands/mock.d.ts +5 -0
  36. package/dist/commands/mock.js +232 -0
  37. package/dist/commands/openapi.d.ts +8 -0
  38. package/dist/commands/openapi.js +82 -0
  39. package/dist/commands/overview.d.ts +11 -0
  40. package/dist/commands/overview.js +266 -0
  41. package/dist/commands/pack.d.ts +11 -0
  42. package/dist/commands/pack.js +133 -0
  43. package/dist/commands/proxy.d.ts +13 -0
  44. package/dist/commands/proxy.js +312 -0
  45. package/dist/commands/replay.d.ts +14 -0
  46. package/dist/commands/replay.js +289 -0
  47. package/dist/commands/run.d.ts +17 -0
  48. package/dist/commands/run.js +997 -0
  49. package/dist/commands/sample.d.ts +13 -0
  50. package/dist/commands/sample.js +260 -0
  51. package/dist/commands/search.d.ts +5 -0
  52. package/dist/commands/search.js +80 -0
  53. package/dist/commands/stubs.d.ts +6 -0
  54. package/dist/commands/stubs.js +187 -0
  55. package/dist/commands/tail.d.ts +4 -0
  56. package/dist/commands/tail.js +76 -0
  57. package/dist/commands/test-gen.d.ts +13 -0
  58. package/dist/commands/test-gen.js +237 -0
  59. package/dist/commands/trace.d.ts +14 -0
  60. package/dist/commands/trace.js +417 -0
  61. package/dist/commands/types.d.ts +7 -0
  62. package/dist/commands/types.js +128 -0
  63. package/dist/commands/unpack.d.ts +11 -0
  64. package/dist/commands/unpack.js +166 -0
  65. package/dist/commands/validate.d.ts +13 -0
  66. package/dist/commands/validate.js +310 -0
  67. package/dist/commands/watch.d.ts +9 -0
  68. package/dist/commands/watch.js +267 -0
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.js +66 -0
  71. package/dist/formatters/diff-formatter.d.ts +5 -0
  72. package/dist/formatters/diff-formatter.js +43 -0
  73. package/dist/formatters/type-formatter.d.ts +22 -0
  74. package/dist/formatters/type-formatter.js +135 -0
  75. package/dist/index.d.ts +2 -0
  76. package/dist/index.js +419 -0
  77. package/dist/local-codegen.d.ts +22 -0
  78. package/dist/local-codegen.js +762 -0
  79. package/dist/ui/badges.d.ts +16 -0
  80. package/dist/ui/badges.js +71 -0
  81. package/dist/ui/helpers.d.ts +13 -0
  82. package/dist/ui/helpers.js +85 -0
  83. package/package.json +23 -0
  84. package/src/api-client.ts +407 -0
  85. package/src/commands/annotate.ts +450 -0
  86. package/src/commands/audit.ts +103 -0
  87. package/src/commands/auto.ts +268 -0
  88. package/src/commands/capture.ts +257 -0
  89. package/src/commands/check.ts +437 -0
  90. package/src/commands/codegen.ts +128 -0
  91. package/src/commands/coverage.ts +170 -0
  92. package/src/commands/dashboard.ts +46 -0
  93. package/src/commands/dev.ts +323 -0
  94. package/src/commands/diff.ts +99 -0
  95. package/src/commands/docs.ts +392 -0
  96. package/src/commands/errors.ts +205 -0
  97. package/src/commands/export.ts +287 -0
  98. package/src/commands/functions.ts +81 -0
  99. package/src/commands/infer.ts +260 -0
  100. package/src/commands/init.ts +419 -0
  101. package/src/commands/mock.ts +220 -0
  102. package/src/commands/openapi.ts +53 -0
  103. package/src/commands/overview.ts +310 -0
  104. package/src/commands/pack.ts +139 -0
  105. package/src/commands/proxy.ts +314 -0
  106. package/src/commands/replay.ts +356 -0
  107. package/src/commands/run.ts +1190 -0
  108. package/src/commands/sample.ts +259 -0
  109. package/src/commands/search.ts +107 -0
  110. package/src/commands/stubs.ts +211 -0
  111. package/src/commands/tail.ts +94 -0
  112. package/src/commands/test-gen.ts +236 -0
  113. package/src/commands/trace.ts +440 -0
  114. package/src/commands/types.ts +161 -0
  115. package/src/commands/unpack.ts +179 -0
  116. package/src/commands/validate.ts +368 -0
  117. package/src/commands/watch.ts +277 -0
  118. package/src/config.ts +38 -0
  119. package/src/formatters/diff-formatter.ts +51 -0
  120. package/src/formatters/type-formatter.ts +161 -0
  121. package/src/index.ts +454 -0
  122. package/src/local-codegen.ts +859 -0
  123. package/src/ui/badges.ts +66 -0
  124. package/src/ui/helpers.ts +80 -0
  125. package/tsconfig.json +8 -0
@@ -0,0 +1,268 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { fetchCodegen } from "../api-client";
5
+
6
+ export interface AutoOptions {
7
+ dir?: string;
8
+ env?: string;
9
+ }
10
+
11
+ interface DetectedFormat {
12
+ format: string;
13
+ fileName: string;
14
+ label: string;
15
+ reason: string;
16
+ }
17
+
18
+ /**
19
+ * Detect which codegen formats are relevant based on package.json dependencies.
20
+ */
21
+ function detectFormats(projectDir: string): DetectedFormat[] {
22
+ const pkgPath = path.join(projectDir, "package.json");
23
+ if (!fs.existsSync(pkgPath)) {
24
+ return [];
25
+ }
26
+
27
+ let pkg: Record<string, unknown>;
28
+ try {
29
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
30
+ } catch {
31
+ return [];
32
+ }
33
+
34
+ const deps: Record<string, string> = {
35
+ ...(pkg.dependencies as Record<string, string> || {}),
36
+ ...(pkg.devDependencies as Record<string, string> || {}),
37
+ };
38
+
39
+ const formats: DetectedFormat[] = [];
40
+
41
+ // Always generate base TypeScript types
42
+ formats.push({
43
+ format: "",
44
+ fileName: "types.d.ts",
45
+ label: "TypeScript types",
46
+ reason: "always generated",
47
+ });
48
+
49
+ // Axios client
50
+ if (deps["axios"]) {
51
+ formats.push({
52
+ format: "axios",
53
+ fileName: "axios-client.ts",
54
+ label: "Axios client",
55
+ reason: "axios detected",
56
+ });
57
+ }
58
+
59
+ // Fetch-based client (always useful as a fallback if no axios)
60
+ if (!deps["axios"]) {
61
+ formats.push({
62
+ format: "client",
63
+ fileName: "api-client.ts",
64
+ label: "Fetch API client",
65
+ reason: "default HTTP client",
66
+ });
67
+ }
68
+
69
+ // React Query / TanStack Query
70
+ if (deps["@tanstack/react-query"] || deps["react-query"]) {
71
+ formats.push({
72
+ format: "react-query",
73
+ fileName: "hooks.ts",
74
+ label: "React Query hooks",
75
+ reason: deps["@tanstack/react-query"] ? "@tanstack/react-query" : "react-query",
76
+ });
77
+ }
78
+
79
+ // SWR
80
+ if (deps["swr"]) {
81
+ formats.push({
82
+ format: "swr",
83
+ fileName: "swr-hooks.ts",
84
+ label: "SWR hooks",
85
+ reason: "swr detected",
86
+ });
87
+ }
88
+
89
+ // Zod
90
+ if (deps["zod"]) {
91
+ formats.push({
92
+ format: "zod",
93
+ fileName: "schemas.ts",
94
+ label: "Zod schemas",
95
+ reason: "zod detected",
96
+ });
97
+ }
98
+
99
+ // tRPC
100
+ if (deps["@trpc/server"] || deps["@trpc/client"]) {
101
+ formats.push({
102
+ format: "trpc",
103
+ fileName: "trpc-router.ts",
104
+ label: "tRPC router",
105
+ reason: deps["@trpc/server"] ? "@trpc/server" : "@trpc/client",
106
+ });
107
+ }
108
+
109
+ // class-validator / NestJS
110
+ if (deps["class-validator"] || deps["@nestjs/common"]) {
111
+ formats.push({
112
+ format: "class-validator",
113
+ fileName: "dtos.ts",
114
+ label: "class-validator DTOs",
115
+ reason: deps["class-validator"] ? "class-validator" : "@nestjs/common",
116
+ });
117
+ }
118
+
119
+ // Express handler types
120
+ if (deps["express"] || deps["@types/express"]) {
121
+ formats.push({
122
+ format: "handlers",
123
+ fileName: "handlers.d.ts",
124
+ label: "Express handler types",
125
+ reason: deps["express"] ? "express" : "@types/express",
126
+ });
127
+ formats.push({
128
+ format: "middleware",
129
+ fileName: "middleware.ts",
130
+ label: "Express middleware",
131
+ reason: deps["express"] ? "express" : "@types/express",
132
+ });
133
+ }
134
+
135
+ // MSW
136
+ if (deps["msw"]) {
137
+ formats.push({
138
+ format: "msw",
139
+ fileName: "msw-handlers.ts",
140
+ label: "MSW mock handlers",
141
+ reason: "msw detected",
142
+ });
143
+ }
144
+
145
+ // Pydantic (Python projects)
146
+ if (fs.existsSync(path.join(projectDir, "requirements.txt")) ||
147
+ fs.existsSync(path.join(projectDir, "pyproject.toml"))) {
148
+ // Check if pydantic is in requirements
149
+ let hasPydantic = false;
150
+ try {
151
+ const reqPath = path.join(projectDir, "requirements.txt");
152
+ if (fs.existsSync(reqPath)) {
153
+ const reqs = fs.readFileSync(reqPath, "utf-8");
154
+ if (reqs.toLowerCase().includes("pydantic")) hasPydantic = true;
155
+ }
156
+ const pyprojectPath = path.join(projectDir, "pyproject.toml");
157
+ if (fs.existsSync(pyprojectPath)) {
158
+ const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
159
+ if (pyproject.toLowerCase().includes("pydantic")) hasPydantic = true;
160
+ }
161
+ } catch {}
162
+ if (hasPydantic) {
163
+ formats.push({
164
+ format: "pydantic",
165
+ fileName: "models.py",
166
+ label: "Pydantic models",
167
+ reason: "pydantic detected",
168
+ });
169
+ }
170
+ }
171
+
172
+ // Type guards (always useful)
173
+ formats.push({
174
+ format: "guards",
175
+ fileName: "guards.ts",
176
+ label: "Type guards",
177
+ reason: "runtime type checking",
178
+ });
179
+
180
+ return formats;
181
+ }
182
+
183
+ /**
184
+ * `trickle auto` — Auto-detect project deps and generate only relevant type files.
185
+ */
186
+ export async function autoCommand(opts: AutoOptions): Promise<void> {
187
+ const projectDir = process.cwd();
188
+ const outDir = path.resolve(opts.dir || ".trickle");
189
+
190
+ // Detect formats
191
+ const formats = detectFormats(projectDir);
192
+
193
+ if (formats.length === 0) {
194
+ console.error(chalk.red("\n No package.json found in current directory."));
195
+ console.error(chalk.gray(" Run this command from your project root.\n"));
196
+ process.exit(1);
197
+ }
198
+
199
+ // Ensure output directory
200
+ if (!fs.existsSync(outDir)) {
201
+ fs.mkdirSync(outDir, { recursive: true });
202
+ }
203
+
204
+ console.log("");
205
+ console.log(chalk.bold(" trickle auto"));
206
+ console.log(chalk.gray(" " + "─".repeat(50)));
207
+ console.log(chalk.gray(` Project: ${projectDir}`));
208
+ console.log(chalk.gray(` Output: ${outDir}`));
209
+ if (opts.env) {
210
+ console.log(chalk.gray(` Env: ${opts.env}`));
211
+ }
212
+ console.log(chalk.gray(" " + "─".repeat(50)));
213
+ console.log("");
214
+
215
+ // Show detected formats
216
+ console.log(chalk.gray(" Detected dependencies:"));
217
+ for (const f of formats) {
218
+ console.log(chalk.gray(` ${chalk.white(f.label)} ← ${f.reason}`));
219
+ }
220
+ console.log("");
221
+
222
+ // Generate each format
223
+ const queryOpts = { env: opts.env };
224
+ let generated = 0;
225
+ let skipped = 0;
226
+
227
+ for (const f of formats) {
228
+ const filePath = path.join(outDir, f.fileName);
229
+ try {
230
+ const result = await fetchCodegen({
231
+ ...queryOpts,
232
+ format: f.format || undefined,
233
+ });
234
+
235
+ const content = result.types;
236
+ if (!content || content.includes("No functions found") || content.includes("No API routes found")) {
237
+ console.log(chalk.yellow(" ─ ") + chalk.gray(`${f.fileName} (no data)`));
238
+ skipped++;
239
+ continue;
240
+ }
241
+
242
+ fs.writeFileSync(filePath, content, "utf-8");
243
+ generated++;
244
+
245
+ const size = content.split("\n").length;
246
+ console.log(
247
+ chalk.green(" ✓ ") +
248
+ chalk.bold(f.fileName) +
249
+ chalk.gray(` (${size} lines)`),
250
+ );
251
+ } catch {
252
+ console.log(chalk.yellow(" ─ ") + chalk.gray(`${f.fileName} (error)`));
253
+ skipped++;
254
+ }
255
+ }
256
+
257
+ console.log("");
258
+ if (generated > 0) {
259
+ console.log(
260
+ chalk.green(` ${generated} files generated`) +
261
+ (skipped > 0 ? chalk.gray(`, ${skipped} skipped`) : ""),
262
+ );
263
+ console.log(chalk.gray(` Output directory: ${outDir}`));
264
+ } else {
265
+ console.log(chalk.yellow(" No files generated — instrument your app and make some requests first."));
266
+ }
267
+ console.log("");
268
+ }
@@ -0,0 +1,257 @@
1
+ import * as crypto from "crypto";
2
+ import chalk from "chalk";
3
+ import { getBackendUrl } from "../config";
4
+
5
+ export interface CaptureOptions {
6
+ header?: string[];
7
+ body?: string;
8
+ env?: string;
9
+ module?: string;
10
+ }
11
+
12
+ interface TypeNode {
13
+ kind: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ /**
18
+ * `trickle capture <method> <url>` — Capture types from a live API endpoint.
19
+ *
20
+ * Makes an HTTP request to the given URL, infers TypeNode from the response,
21
+ * and sends the observation to the trickle backend. Zero instrumentation needed —
22
+ * just point at any API and start collecting types.
23
+ */
24
+ export async function captureCommand(
25
+ method: string,
26
+ url: string,
27
+ opts: CaptureOptions,
28
+ ): Promise<void> {
29
+ const backendUrl = getBackendUrl();
30
+
31
+ // Validate method
32
+ const httpMethod = method.toUpperCase();
33
+ const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
34
+ if (!validMethods.includes(httpMethod)) {
35
+ console.error(chalk.red(`\n Invalid HTTP method: ${method}`));
36
+ console.error(chalk.gray(` Valid methods: ${validMethods.join(", ")}\n`));
37
+ process.exit(1);
38
+ }
39
+
40
+ // Parse URL
41
+ let parsedUrl: URL;
42
+ try {
43
+ parsedUrl = new URL(url);
44
+ } catch {
45
+ console.error(chalk.red(`\n Invalid URL: ${url}\n`));
46
+ process.exit(1);
47
+ }
48
+
49
+ // Build request headers
50
+ const headers: Record<string, string> = {
51
+ "Accept": "application/json",
52
+ };
53
+ if (opts.header) {
54
+ for (const h of opts.header) {
55
+ const colonIdx = h.indexOf(":");
56
+ if (colonIdx === -1) {
57
+ console.error(chalk.red(`\n Invalid header format: ${h}`));
58
+ console.error(chalk.gray(' Use "Header-Name: value" format\n'));
59
+ process.exit(1);
60
+ }
61
+ const key = h.slice(0, colonIdx).trim();
62
+ const value = h.slice(colonIdx + 1).trim();
63
+ headers[key] = value;
64
+ }
65
+ }
66
+
67
+ // Parse request body
68
+ let reqBody: string | undefined;
69
+ let reqJson: unknown = undefined;
70
+ if (opts.body) {
71
+ reqBody = opts.body;
72
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
73
+ try {
74
+ reqJson = JSON.parse(opts.body);
75
+ } catch {
76
+ // Not JSON body — that's fine
77
+ }
78
+ }
79
+
80
+ // Check backend connectivity
81
+ try {
82
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
83
+ if (!res.ok) throw new Error("not ok");
84
+ } catch {
85
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}`));
86
+ console.error(chalk.gray(" Start the backend first: npx trickle-backend\n"));
87
+ process.exit(1);
88
+ }
89
+
90
+ console.log("");
91
+ console.log(chalk.bold(" trickle capture"));
92
+ console.log(chalk.gray(" " + "─".repeat(50)));
93
+ console.log(chalk.gray(` ${chalk.bold(httpMethod)} ${url}`));
94
+
95
+ // Make the request
96
+ let response: Response;
97
+ try {
98
+ response = await fetch(url, {
99
+ method: httpMethod,
100
+ headers,
101
+ body: reqBody,
102
+ signal: AbortSignal.timeout(30000),
103
+ });
104
+ } catch (err: unknown) {
105
+ const msg = err instanceof Error ? err.message : "Unknown error";
106
+ console.error(chalk.red(`\n Request failed: ${msg}\n`));
107
+ process.exit(1);
108
+ }
109
+
110
+ const status = response.status;
111
+ const statusColor = status < 400 ? chalk.green : chalk.red;
112
+ console.log(chalk.gray(` Status: `) + statusColor(`${status} ${response.statusText}`));
113
+
114
+ // Read response body
115
+ const resText = await response.text();
116
+ let resJson: unknown = undefined;
117
+ const contentType = response.headers.get("content-type") || "";
118
+ if (contentType.includes("json") && resText.length > 0) {
119
+ try {
120
+ resJson = JSON.parse(resText);
121
+ } catch {
122
+ console.error(chalk.yellow("\n Response is not valid JSON — cannot capture types.\n"));
123
+ process.exit(1);
124
+ }
125
+ } else {
126
+ console.error(chalk.yellow("\n Response is not JSON — cannot capture types."));
127
+ console.error(chalk.gray(` Content-Type: ${contentType}\n`));
128
+ process.exit(1);
129
+ }
130
+
131
+ // Build type observations
132
+ const routePath = normalizePath(parsedUrl.pathname);
133
+ const functionName = `${httpMethod} ${routePath}`;
134
+
135
+ const argsProperties: Record<string, TypeNode> = {};
136
+ if (reqJson !== undefined && reqJson !== null) {
137
+ argsProperties.body = jsonToTypeNode(reqJson);
138
+ }
139
+
140
+ // Extract query params
141
+ if (parsedUrl.search) {
142
+ const queryProps: Record<string, TypeNode> = {};
143
+ for (const [key] of parsedUrl.searchParams) {
144
+ queryProps[key] = { kind: "primitive", name: "string" };
145
+ }
146
+ if (Object.keys(queryProps).length > 0) {
147
+ argsProperties.query = { kind: "object", properties: queryProps };
148
+ }
149
+ }
150
+
151
+ const argsType: TypeNode = Object.keys(argsProperties).length > 0
152
+ ? { kind: "object", properties: argsProperties }
153
+ : { kind: "object", properties: {} };
154
+
155
+ const returnType = jsonToTypeNode(resJson);
156
+ const typeHash = computeTypeHash(argsType, returnType);
157
+
158
+ const payload = {
159
+ functionName,
160
+ module: opts.module || "capture",
161
+ language: "js",
162
+ environment: opts.env || "development",
163
+ typeHash,
164
+ argsType,
165
+ returnType,
166
+ sampleInput: Object.keys(argsProperties).length > 0 ? argsProperties : undefined,
167
+ sampleOutput: resJson,
168
+ };
169
+
170
+ // Send to backend
171
+ try {
172
+ const res = await fetch(`${backendUrl}/api/ingest`, {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify(payload),
176
+ signal: AbortSignal.timeout(5000),
177
+ });
178
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
179
+ } catch (err: unknown) {
180
+ const msg = err instanceof Error ? err.message : "Unknown error";
181
+ console.error(chalk.red(`\n Failed to send types to backend: ${msg}\n`));
182
+ process.exit(1);
183
+ }
184
+
185
+ console.log(chalk.gray(` Route: `) + chalk.white(functionName));
186
+ console.log(chalk.gray(` Backend: `) + chalk.white(backendUrl));
187
+ console.log(chalk.gray(" " + "─".repeat(50)));
188
+ console.log(chalk.green(" Types captured successfully!"));
189
+
190
+ // Show a preview of what was captured
191
+ const fieldCount = countFields(returnType);
192
+ console.log(chalk.gray(` Response shape: ${fieldCount} fields observed`));
193
+ if (reqJson) {
194
+ const reqFieldCount = countFields(argsProperties.body || { kind: "object", properties: {} });
195
+ console.log(chalk.gray(` Request body: ${reqFieldCount} fields observed`));
196
+ }
197
+ console.log("");
198
+ console.log(chalk.gray(" Run ") + chalk.white(`trickle codegen`) + chalk.gray(" to generate type definitions."));
199
+ console.log("");
200
+ }
201
+
202
+ /**
203
+ * Infer a TypeNode from a JSON value.
204
+ */
205
+ function jsonToTypeNode(value: unknown): TypeNode {
206
+ if (value === null) return { kind: "primitive", name: "null" };
207
+ if (value === undefined) return { kind: "primitive", name: "undefined" };
208
+
209
+ switch (typeof value) {
210
+ case "string": return { kind: "primitive", name: "string" };
211
+ case "number": return { kind: "primitive", name: "number" };
212
+ case "boolean": return { kind: "primitive", name: "boolean" };
213
+ }
214
+
215
+ if (Array.isArray(value)) {
216
+ if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
217
+ const elementType = jsonToTypeNode(value[0]);
218
+ return { kind: "array", element: elementType };
219
+ }
220
+
221
+ // Object
222
+ const obj = value as Record<string, unknown>;
223
+ const properties: Record<string, TypeNode> = {};
224
+ for (const [key, val] of Object.entries(obj)) {
225
+ properties[key] = jsonToTypeNode(val);
226
+ }
227
+ return { kind: "object", properties };
228
+ }
229
+
230
+ /**
231
+ * Normalize URL path: replace dynamic segments with :param patterns.
232
+ */
233
+ function normalizePath(urlPath: string): string {
234
+ const parts = urlPath.split("/");
235
+ return parts
236
+ .map((part, i) => {
237
+ if (!part) return part;
238
+ if (/^\d+$/.test(part)) return ":id";
239
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return ":id";
240
+ if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
241
+ return part;
242
+ })
243
+ .join("/");
244
+ }
245
+
246
+ function computeTypeHash(argsType: TypeNode, returnType: TypeNode): string {
247
+ const data = JSON.stringify({ a: argsType, r: returnType });
248
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
249
+ }
250
+
251
+ function countFields(node: TypeNode): number {
252
+ if (node.kind === "object" && node.properties) {
253
+ return Object.keys(node.properties as Record<string, unknown>).length;
254
+ }
255
+ if (node.kind === "array") return 1;
256
+ return 1;
257
+ }