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,161 @@
1
+ import chalk from "chalk";
2
+ import { listFunctions, listTypes, getTypeDiff } from "../api-client";
3
+ import { formatType } from "../formatters/type-formatter";
4
+ import { formatDiffs } from "../formatters/diff-formatter";
5
+ import { envBadge, timeBadge } from "../ui/badges";
6
+
7
+ export interface TypesOptions {
8
+ env?: string;
9
+ diff?: boolean;
10
+ env1?: string;
11
+ env2?: string;
12
+ }
13
+
14
+ export async function typesCommand(functionName: string, opts: TypesOptions): Promise<void> {
15
+ // Look up function by name (partial match)
16
+ const result = await listFunctions({ search: functionName });
17
+ const { functions } = result;
18
+
19
+ if (functions.length === 0) {
20
+ console.log(chalk.yellow(`\n No function found matching "${functionName}".\n`));
21
+ return;
22
+ }
23
+
24
+ // Prefer exact match, then first partial match
25
+ const exactMatch = functions.find(
26
+ (f) => f.function_name === functionName || f.function_name.toLowerCase() === functionName.toLowerCase()
27
+ );
28
+ const fn = exactMatch || functions[0];
29
+
30
+ if (!exactMatch && functions.length > 1) {
31
+ console.log(
32
+ chalk.gray(`\n Multiple matches found, showing results for "${chalk.white(fn.function_name)}"`)
33
+ );
34
+ console.log(
35
+ chalk.gray(
36
+ ` Other matches: ${functions
37
+ .slice(1, 5)
38
+ .map((f) => f.function_name)
39
+ .join(", ")}${functions.length > 5 ? "..." : ""}\n`
40
+ )
41
+ );
42
+ }
43
+
44
+ // Diff mode
45
+ if (opts.diff) {
46
+ await showDiff(fn.id, fn.function_name, opts);
47
+ return;
48
+ }
49
+
50
+ // Normal mode: show type snapshots
51
+ const typesResult = await listTypes(fn.id, { env: opts.env });
52
+ const { snapshots } = typesResult;
53
+
54
+ if (snapshots.length === 0) {
55
+ console.log(chalk.yellow(`\n No type snapshots found for "${fn.function_name}".\n`));
56
+ return;
57
+ }
58
+
59
+ console.log("");
60
+ console.log(chalk.white.bold(` ${fn.function_name}`) + chalk.gray(` (${fn.module})`));
61
+ console.log(chalk.gray(" " + "─".repeat(50)));
62
+
63
+ for (const snapshot of snapshots) {
64
+ console.log("");
65
+ console.log(
66
+ ` ${envBadge(snapshot.env)} ${timeBadge(snapshot.observed_at)}`
67
+ );
68
+ console.log("");
69
+
70
+ // Display function signature
71
+ console.log(chalk.gray(" args: ") + formatType(snapshot.args_type, 4));
72
+ console.log(chalk.gray(" returns: ") + formatType(snapshot.return_type, 4));
73
+
74
+ if (snapshot.sample_input !== undefined && snapshot.sample_input !== null) {
75
+ console.log("");
76
+ console.log(chalk.gray(" sample input:"));
77
+ console.log(chalk.gray(" ") + colorizeJson(snapshot.sample_input, 4));
78
+ }
79
+
80
+ if (snapshot.sample_output !== undefined && snapshot.sample_output !== null) {
81
+ console.log(chalk.gray(" sample output:"));
82
+ console.log(chalk.gray(" ") + colorizeJson(snapshot.sample_output, 4));
83
+ }
84
+ }
85
+
86
+ console.log("");
87
+ }
88
+
89
+ async function showDiff(functionId: number, functionName: string, opts: TypesOptions): Promise<void> {
90
+ try {
91
+ let diffResult;
92
+
93
+ if (opts.env1 && opts.env2) {
94
+ diffResult = await getTypeDiff(functionId, {
95
+ fromEnv: opts.env1,
96
+ toEnv: opts.env2,
97
+ });
98
+ } else {
99
+ // Get latest two snapshots and diff by their IDs
100
+ const typesResult = await listTypes(functionId, { limit: 2 });
101
+ const { snapshots } = typesResult;
102
+
103
+ if (snapshots.length < 2) {
104
+ console.log(chalk.yellow(`\n Not enough snapshots to diff for "${functionName}".`));
105
+ console.log(chalk.gray(" Need at least 2 snapshots. Try specifying --env1 and --env2.\n"));
106
+ return;
107
+ }
108
+
109
+ diffResult = await getTypeDiff(functionId, {
110
+ from: snapshots[1].id,
111
+ to: snapshots[0].id,
112
+ });
113
+ }
114
+
115
+ console.log("");
116
+ console.log(chalk.white.bold(` Type diff for ${functionName}`));
117
+ console.log(chalk.gray(" " + "─".repeat(50)));
118
+ console.log("");
119
+
120
+ console.log(
121
+ chalk.gray(" from: ") +
122
+ envBadge(diffResult.from.env) +
123
+ chalk.gray(" " + diffResult.from.observed_at)
124
+ );
125
+ console.log(
126
+ chalk.gray(" to: ") +
127
+ envBadge(diffResult.to.env) +
128
+ chalk.gray(" " + diffResult.to.observed_at)
129
+ );
130
+ console.log("");
131
+
132
+ if (diffResult.diffs.length === 0) {
133
+ console.log(chalk.green(" No type differences found.\n"));
134
+ } else {
135
+ console.log(formatDiffs(diffResult.diffs));
136
+ console.log("");
137
+ }
138
+ } catch (err: unknown) {
139
+ if (err instanceof Error) {
140
+ console.error(chalk.red(`\n Error: ${err.message}\n`));
141
+ }
142
+ }
143
+ }
144
+
145
+ function colorizeJson(value: unknown, indent: number = 0): string {
146
+ const json = JSON.stringify(value, null, 2);
147
+ if (!json) return chalk.gray("null");
148
+
149
+ return json
150
+ .split("\n")
151
+ .map((line, i) => {
152
+ const padded = i === 0 ? line : " ".repeat(indent) + line;
153
+ return padded
154
+ .replace(/"([^"]+)":/g, (_, key) => `${chalk.white(`"${key}"`)}:`)
155
+ .replace(/: "([^"]*)"/g, (_, val) => `: ${chalk.green(`"${val}"`)}`)
156
+ .replace(/: (\d+\.?\d*)/g, (_, val) => `: ${chalk.yellow(val)}`)
157
+ .replace(/: (true|false)/g, (_, val) => `: ${chalk.blue(val)}`)
158
+ .replace(/: (null)/g, (_, val) => `: ${chalk.gray(val)}`);
159
+ })
160
+ .join("\n");
161
+ }
@@ -0,0 +1,179 @@
1
+ import * as fs from "fs";
2
+ import * as crypto from "crypto";
3
+ import chalk from "chalk";
4
+ import { getBackendUrl } from "../config";
5
+
6
+ export interface UnpackOptions {
7
+ env?: string;
8
+ dryRun?: boolean;
9
+ }
10
+
11
+ interface PackedSnapshot {
12
+ typeHash: string;
13
+ env: string;
14
+ argsType: unknown;
15
+ returnType: unknown;
16
+ sampleInput?: unknown;
17
+ sampleOutput?: unknown;
18
+ observedAt: string;
19
+ }
20
+
21
+ interface PackedFunction {
22
+ functionName: string;
23
+ module: string;
24
+ language: string;
25
+ environment: string;
26
+ snapshots: PackedSnapshot[];
27
+ }
28
+
29
+ interface PackBundle {
30
+ version: number;
31
+ createdAt: string;
32
+ source: string;
33
+ functions: PackedFunction[];
34
+ stats: {
35
+ totalFunctions: number;
36
+ totalSnapshots: number;
37
+ };
38
+ }
39
+
40
+ /**
41
+ * `trickle unpack <file>` — Import types from a packed bundle into the backend.
42
+ *
43
+ * Reads a .trickle.json bundle (created by `trickle pack`) and ingests
44
+ * all functions and their type snapshots into the backend.
45
+ */
46
+ export async function unpackCommand(
47
+ file: string,
48
+ opts: UnpackOptions,
49
+ ): Promise<void> {
50
+ const backendUrl = getBackendUrl();
51
+
52
+ // Read and parse the bundle
53
+ if (!fs.existsSync(file)) {
54
+ console.error(chalk.red(`\n File not found: ${file}\n`));
55
+ process.exit(1);
56
+ }
57
+
58
+ let bundle: PackBundle;
59
+ try {
60
+ const content = fs.readFileSync(file, "utf-8");
61
+ bundle = JSON.parse(content) as PackBundle;
62
+ } catch {
63
+ console.error(chalk.red("\n Invalid bundle file — expected JSON.\n"));
64
+ process.exit(1);
65
+ }
66
+
67
+ // Validate bundle structure
68
+ if (!bundle.version || !bundle.functions || !Array.isArray(bundle.functions)) {
69
+ console.error(chalk.red("\n Invalid bundle format — missing required fields.\n"));
70
+ process.exit(1);
71
+ }
72
+
73
+ if (bundle.version !== 1) {
74
+ console.error(chalk.red(`\n Unsupported bundle version: ${bundle.version}\n`));
75
+ process.exit(1);
76
+ }
77
+
78
+ console.log("");
79
+ console.log(chalk.bold(" trickle unpack"));
80
+ console.log(chalk.gray(" " + "─".repeat(50)));
81
+ console.log(chalk.gray(` File: ${file}`));
82
+ console.log(chalk.gray(` Created: ${bundle.createdAt}`));
83
+ console.log(chalk.gray(` Source: ${bundle.source}`));
84
+ console.log(chalk.gray(` Contains: ${bundle.functions.length} functions, ${bundle.stats.totalSnapshots} snapshots`));
85
+ console.log(chalk.gray(" " + "─".repeat(50)));
86
+
87
+ if (opts.dryRun) {
88
+ console.log("");
89
+ console.log(chalk.yellow(" Dry run — listing contents without importing:"));
90
+ console.log("");
91
+ for (const fn of bundle.functions) {
92
+ console.log(
93
+ chalk.white(` ${fn.functionName}`) +
94
+ chalk.gray(` (${fn.module}, ${fn.language}, ${fn.snapshots.length} snapshot${fn.snapshots.length === 1 ? "" : "s"})`),
95
+ );
96
+ }
97
+ console.log("");
98
+ console.log(chalk.gray(` ${bundle.functions.length} functions would be imported.`));
99
+ console.log(chalk.gray(" Remove --dry-run to import.\n"));
100
+ return;
101
+ }
102
+
103
+ // Check backend connectivity
104
+ try {
105
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
106
+ if (!res.ok) throw new Error("not ok");
107
+ } catch {
108
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
109
+ process.exit(1);
110
+ }
111
+
112
+ console.log("");
113
+
114
+ // Ingest each function's snapshots
115
+ let imported = 0;
116
+ let skipped = 0;
117
+ let errors = 0;
118
+
119
+ for (const fn of bundle.functions) {
120
+ // Use the latest snapshot for ingest
121
+ const snapshot = fn.snapshots[0];
122
+ if (!snapshot) {
123
+ skipped++;
124
+ continue;
125
+ }
126
+
127
+ const env = opts.env || snapshot.env || fn.environment;
128
+ const typeHash = snapshot.typeHash || computeTypeHash(snapshot.argsType, snapshot.returnType);
129
+
130
+ const payload = {
131
+ functionName: fn.functionName,
132
+ module: fn.module,
133
+ language: fn.language,
134
+ environment: env,
135
+ typeHash,
136
+ argsType: snapshot.argsType,
137
+ returnType: snapshot.returnType,
138
+ sampleInput: snapshot.sampleInput,
139
+ sampleOutput: snapshot.sampleOutput,
140
+ };
141
+
142
+ try {
143
+ const res = await fetch(`${backendUrl}/api/ingest`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify(payload),
147
+ signal: AbortSignal.timeout(5000),
148
+ });
149
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
150
+ imported++;
151
+ console.log(chalk.green(" ✓ ") + chalk.white(fn.functionName));
152
+ } catch {
153
+ errors++;
154
+ console.log(chalk.red(" ✗ ") + chalk.white(fn.functionName) + chalk.gray(" (failed)"));
155
+ }
156
+ }
157
+
158
+ console.log("");
159
+ console.log(chalk.gray(" " + "─".repeat(50)));
160
+
161
+ if (imported > 0) {
162
+ console.log(chalk.green(` ${imported} functions imported successfully`));
163
+ }
164
+ if (skipped > 0) {
165
+ console.log(chalk.yellow(` ${skipped} skipped (no snapshots)`));
166
+ }
167
+ if (errors > 0) {
168
+ console.log(chalk.red(` ${errors} failed`));
169
+ }
170
+
171
+ console.log("");
172
+ console.log(chalk.gray(" Run ") + chalk.white("trickle overview") + chalk.gray(" to see imported types."));
173
+ console.log("");
174
+ }
175
+
176
+ function computeTypeHash(argsType: unknown, returnType: unknown): string {
177
+ const data = JSON.stringify({ a: argsType, r: returnType });
178
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
179
+ }
@@ -0,0 +1,368 @@
1
+ import chalk from "chalk";
2
+ import { getBackendUrl } from "../config";
3
+
4
+ export interface ValidateOptions {
5
+ header?: string[];
6
+ body?: string;
7
+ env?: string;
8
+ strict?: boolean;
9
+ }
10
+
11
+ interface TypeNode {
12
+ kind: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface Mismatch {
17
+ path: string;
18
+ issue: "missing" | "extra" | "type_mismatch" | "kind_mismatch";
19
+ expected?: string;
20
+ actual?: string;
21
+ }
22
+
23
+ /**
24
+ * `trickle validate <method> <url>` — Validate a live API response against observed types.
25
+ *
26
+ * Makes an HTTP request, infers types from the response, fetches the stored
27
+ * type for that route from the backend, and reports any mismatches.
28
+ */
29
+ export async function validateCommand(
30
+ method: string,
31
+ url: string,
32
+ opts: ValidateOptions,
33
+ ): Promise<void> {
34
+ const backendUrl = getBackendUrl();
35
+
36
+ const httpMethod = method.toUpperCase();
37
+ const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
38
+ if (!validMethods.includes(httpMethod)) {
39
+ console.error(chalk.red(`\n Invalid HTTP method: ${method}\n`));
40
+ process.exit(1);
41
+ }
42
+
43
+ let parsedUrl: URL;
44
+ try {
45
+ parsedUrl = new URL(url);
46
+ } catch {
47
+ console.error(chalk.red(`\n Invalid URL: ${url}\n`));
48
+ process.exit(1);
49
+ }
50
+
51
+ // Check backend
52
+ try {
53
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
54
+ if (!res.ok) throw new Error("not ok");
55
+ } catch {
56
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
57
+ process.exit(1);
58
+ }
59
+
60
+ // Build request headers
61
+ const headers: Record<string, string> = { Accept: "application/json" };
62
+ if (opts.header) {
63
+ for (const h of opts.header) {
64
+ const colonIdx = h.indexOf(":");
65
+ if (colonIdx === -1) {
66
+ console.error(chalk.red(`\n Invalid header: ${h}\n`));
67
+ process.exit(1);
68
+ }
69
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
70
+ }
71
+ }
72
+
73
+ let reqBody: string | undefined;
74
+ if (opts.body) {
75
+ reqBody = opts.body;
76
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
77
+ }
78
+
79
+ console.log("");
80
+ console.log(chalk.bold(" trickle validate"));
81
+ console.log(chalk.gray(" " + "─".repeat(50)));
82
+ console.log(chalk.gray(` ${chalk.bold(httpMethod)} ${url}`));
83
+
84
+ // Make the request
85
+ let response: Response;
86
+ try {
87
+ response = await fetch(url, {
88
+ method: httpMethod,
89
+ headers,
90
+ body: reqBody,
91
+ signal: AbortSignal.timeout(30000),
92
+ });
93
+ } catch (err: unknown) {
94
+ const msg = err instanceof Error ? err.message : "Unknown error";
95
+ console.error(chalk.red(`\n Request failed: ${msg}\n`));
96
+ process.exit(1);
97
+ }
98
+
99
+ const status = response.status;
100
+ const statusColor = status < 400 ? chalk.green : chalk.red;
101
+ console.log(chalk.gray(` Status: `) + statusColor(`${status} ${response.statusText}`));
102
+
103
+ // Parse response
104
+ const resText = await response.text();
105
+ let resJson: unknown;
106
+ const contentType = response.headers.get("content-type") || "";
107
+ if (contentType.includes("json") && resText.length > 0) {
108
+ try {
109
+ resJson = JSON.parse(resText);
110
+ } catch {
111
+ console.error(chalk.red("\n Response is not valid JSON.\n"));
112
+ process.exit(1);
113
+ }
114
+ } else {
115
+ console.error(chalk.red("\n Response is not JSON.\n"));
116
+ process.exit(1);
117
+ }
118
+
119
+ // Normalize path and find stored types
120
+ const routePath = normalizePath(parsedUrl.pathname);
121
+ const functionName = `${httpMethod} ${routePath}`;
122
+ console.log(chalk.gray(` Route: `) + chalk.white(functionName));
123
+
124
+ // Fetch stored function from backend
125
+ const funcsRes = await fetch(
126
+ `${backendUrl}/api/functions?q=${encodeURIComponent(functionName)}&limit=100`,
127
+ { signal: AbortSignal.timeout(5000) },
128
+ );
129
+ const funcsData = await funcsRes.json() as { functions: Array<{ id: number; function_name: string }> };
130
+
131
+ const matchedFunc = funcsData.functions.find(
132
+ (f) => f.function_name === functionName,
133
+ );
134
+
135
+ if (!matchedFunc) {
136
+ console.log(chalk.gray(" " + "─".repeat(50)));
137
+ console.log(chalk.yellow(" No previously observed types for this route."));
138
+ console.log(chalk.gray(" Run ") + chalk.white("trickle capture") + chalk.gray(" first to establish a baseline.\n"));
139
+ process.exit(1);
140
+ }
141
+
142
+ // Fetch latest type snapshot
143
+ const envQuery = opts.env ? `&env=${encodeURIComponent(opts.env)}` : "";
144
+ const typesRes = await fetch(
145
+ `${backendUrl}/api/types/${matchedFunc.id}?limit=1${envQuery}`,
146
+ { signal: AbortSignal.timeout(5000) },
147
+ );
148
+ const typesData = await typesRes.json() as {
149
+ snapshots: Array<{ return_type: string; observed_at: string }>;
150
+ };
151
+
152
+ if (!typesData.snapshots || typesData.snapshots.length === 0) {
153
+ console.log(chalk.yellow(" No type snapshots found for this route.\n"));
154
+ process.exit(1);
155
+ }
156
+
157
+ const snapshot = typesData.snapshots[0];
158
+ let expectedType: TypeNode;
159
+ try {
160
+ expectedType = (typeof snapshot.return_type === 'string'
161
+ ? JSON.parse(snapshot.return_type)
162
+ : snapshot.return_type) as TypeNode;
163
+ } catch {
164
+ console.error(chalk.red(" Cannot parse stored type snapshot.\n"));
165
+ process.exit(1);
166
+ }
167
+
168
+ console.log(chalk.gray(` Baseline: `) + chalk.gray(`observed ${snapshot.observed_at}`));
169
+ console.log(chalk.gray(" " + "─".repeat(50)));
170
+
171
+ // Infer type from actual response
172
+ const actualType = jsonToTypeNode(resJson);
173
+
174
+ // Compare
175
+ const mismatches: Mismatch[] = [];
176
+ compareTypes(expectedType, actualType, "response", mismatches, opts.strict || false);
177
+
178
+ if (mismatches.length === 0) {
179
+ console.log(chalk.green("\n ✓ Response matches observed type shape\n"));
180
+ console.log(chalk.gray(" All fields present with expected types.\n"));
181
+ } else {
182
+ console.log(chalk.red(`\n ✗ ${mismatches.length} mismatch${mismatches.length === 1 ? "" : "es"} found\n`));
183
+
184
+ for (const m of mismatches) {
185
+ switch (m.issue) {
186
+ case "missing":
187
+ console.log(
188
+ chalk.red(" MISSING ") +
189
+ chalk.white(m.path) +
190
+ chalk.gray(` (expected ${m.expected})`),
191
+ );
192
+ break;
193
+ case "extra":
194
+ console.log(
195
+ chalk.yellow(" EXTRA ") +
196
+ chalk.white(m.path) +
197
+ chalk.gray(` (${m.actual}, not in observed types)`),
198
+ );
199
+ break;
200
+ case "type_mismatch":
201
+ console.log(
202
+ chalk.red(" TYPE ") +
203
+ chalk.white(m.path) +
204
+ chalk.gray(` (expected ${m.expected}, got ${m.actual})`),
205
+ );
206
+ break;
207
+ case "kind_mismatch":
208
+ console.log(
209
+ chalk.red(" SHAPE ") +
210
+ chalk.white(m.path) +
211
+ chalk.gray(` (expected ${m.expected}, got ${m.actual})`),
212
+ );
213
+ break;
214
+ }
215
+ }
216
+
217
+ const errors = mismatches.filter((m) => m.issue !== "extra");
218
+ const warnings = mismatches.filter((m) => m.issue === "extra");
219
+
220
+ console.log("");
221
+ if (errors.length > 0) {
222
+ console.log(chalk.red(` ${errors.length} error${errors.length === 1 ? "" : "s"}`) +
223
+ (warnings.length > 0 ? chalk.yellow(`, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`) : ""));
224
+ } else {
225
+ console.log(chalk.yellow(` ${warnings.length} warning${warnings.length === 1 ? "" : "s"} (extra fields only)`));
226
+ }
227
+ console.log("");
228
+
229
+ if (errors.length > 0) {
230
+ process.exitCode = 1;
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Recursively compare expected vs actual TypeNode.
237
+ */
238
+ function compareTypes(
239
+ expected: TypeNode,
240
+ actual: TypeNode,
241
+ path: string,
242
+ mismatches: Mismatch[],
243
+ strict: boolean,
244
+ ): void {
245
+ // Kind mismatch
246
+ if (expected.kind !== actual.kind) {
247
+ // Special case: unknown expected matches anything
248
+ if (expected.kind === "unknown") return;
249
+ // Special case: expected primitive null matches any actual (nullable)
250
+ if (expected.kind === "primitive" && (expected as unknown as { name: string }).name === "null") return;
251
+
252
+ mismatches.push({
253
+ path,
254
+ issue: "kind_mismatch",
255
+ expected: describeType(expected),
256
+ actual: describeType(actual),
257
+ });
258
+ return;
259
+ }
260
+
261
+ switch (expected.kind) {
262
+ case "primitive": {
263
+ const expectedName = (expected as unknown as { name: string }).name;
264
+ const actualName = (actual as unknown as { name: string }).name;
265
+ if (expectedName !== actualName) {
266
+ mismatches.push({
267
+ path,
268
+ issue: "type_mismatch",
269
+ expected: expectedName,
270
+ actual: actualName,
271
+ });
272
+ }
273
+ break;
274
+ }
275
+
276
+ case "object": {
277
+ const expectedProps = expected.properties as Record<string, TypeNode>;
278
+ const actualProps = actual.properties as Record<string, TypeNode>;
279
+
280
+ // Check for missing fields
281
+ for (const key of Object.keys(expectedProps)) {
282
+ if (!(key in actualProps)) {
283
+ mismatches.push({
284
+ path: `${path}.${key}`,
285
+ issue: "missing",
286
+ expected: describeType(expectedProps[key]),
287
+ });
288
+ } else {
289
+ compareTypes(expectedProps[key], actualProps[key], `${path}.${key}`, mismatches, strict);
290
+ }
291
+ }
292
+
293
+ // Check for extra fields (warnings in non-strict, errors in strict)
294
+ if (strict) {
295
+ for (const key of Object.keys(actualProps)) {
296
+ if (!(key in expectedProps)) {
297
+ mismatches.push({
298
+ path: `${path}.${key}`,
299
+ issue: "extra",
300
+ actual: describeType(actualProps[key]),
301
+ });
302
+ }
303
+ }
304
+ }
305
+ break;
306
+ }
307
+
308
+ case "array": {
309
+ const expectedElement = expected.element as TypeNode;
310
+ const actualElement = actual.element as TypeNode;
311
+ if (expectedElement.kind !== "unknown" && actualElement.kind !== "unknown") {
312
+ compareTypes(expectedElement, actualElement, `${path}[]`, mismatches, strict);
313
+ }
314
+ break;
315
+ }
316
+
317
+ case "union": {
318
+ // For unions, check that actual matches at least one member
319
+ // Simplified: just skip deep comparison for unions
320
+ break;
321
+ }
322
+ }
323
+ }
324
+
325
+ function describeType(node: TypeNode): string {
326
+ switch (node.kind) {
327
+ case "primitive":
328
+ return (node as unknown as { name: string }).name;
329
+ case "object":
330
+ return "object";
331
+ case "array":
332
+ return `${describeType(node.element as TypeNode)}[]`;
333
+ case "union":
334
+ return (node.members as TypeNode[]).map(describeType).join(" | ");
335
+ default:
336
+ return node.kind;
337
+ }
338
+ }
339
+
340
+ function jsonToTypeNode(value: unknown): TypeNode {
341
+ if (value === null) return { kind: "primitive", name: "null" };
342
+ if (value === undefined) return { kind: "primitive", name: "undefined" };
343
+ switch (typeof value) {
344
+ case "string": return { kind: "primitive", name: "string" };
345
+ case "number": return { kind: "primitive", name: "number" };
346
+ case "boolean": return { kind: "primitive", name: "boolean" };
347
+ }
348
+ if (Array.isArray(value)) {
349
+ if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
350
+ return { kind: "array", element: jsonToTypeNode(value[0]) };
351
+ }
352
+ const obj = value as Record<string, unknown>;
353
+ const properties: Record<string, TypeNode> = {};
354
+ for (const [key, val] of Object.entries(obj)) {
355
+ properties[key] = jsonToTypeNode(val);
356
+ }
357
+ return { kind: "object", properties };
358
+ }
359
+
360
+ function normalizePath(urlPath: string): string {
361
+ return urlPath.split("/").map((part, i) => {
362
+ if (!part) return part;
363
+ if (/^\d+$/.test(part)) return ":id";
364
+ 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";
365
+ if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
366
+ return part;
367
+ }).join("/");
368
+ }