@beignet/cli 0.0.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.
- package/CHANGELOG.md +5 -0
- package/README.md +409 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +131 -0
- package/dist/config.js.map +1 -0
- package/dist/create-bin.d.ts +3 -0
- package/dist/create-bin.d.ts.map +1 -0
- package/dist/create-bin.js +9 -0
- package/dist/create-bin.js.map +1 -0
- package/dist/create.d.ts +20 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +99 -0
- package/dist/create.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +735 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +54 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +1240 -0
- package/dist/inspect.js.map +1 -0
- package/dist/lint.d.ts +21 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +576 -0
- package/dist/lint.js.map +1 -0
- package/dist/make.d.ts +115 -0
- package/dist/make.d.ts.map +1 -0
- package/dist/make.js +2719 -0
- package/dist/make.js.map +1 -0
- package/dist/templates.d.ts +22 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +2236 -0
- package/dist/templates.js.map +1 -0
- package/package.json +73 -0
- package/src/config.ts +214 -0
- package/src/create-bin.ts +11 -0
- package/src/create.ts +164 -0
- package/src/index.ts +992 -0
- package/src/inspect.ts +1951 -0
- package/src/lint.ts +785 -0
- package/src/make.ts +3931 -0
- package/src/templates.ts +2460 -0
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,1951 @@
|
|
|
1
|
+
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type BeignetConfig,
|
|
5
|
+
directoryPath,
|
|
6
|
+
loadBeignetConfig,
|
|
7
|
+
normalizePath,
|
|
8
|
+
type ResolvedBeignetConfig,
|
|
9
|
+
resolveConfig,
|
|
10
|
+
} from "./config.js";
|
|
11
|
+
|
|
12
|
+
type InspectAppOptions = {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
config?: BeignetConfig;
|
|
15
|
+
strict?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type HttpMethod =
|
|
19
|
+
| "GET"
|
|
20
|
+
| "POST"
|
|
21
|
+
| "PUT"
|
|
22
|
+
| "PATCH"
|
|
23
|
+
| "DELETE"
|
|
24
|
+
| "HEAD"
|
|
25
|
+
| "OPTIONS";
|
|
26
|
+
|
|
27
|
+
export type InspectedContract = {
|
|
28
|
+
exportName: string;
|
|
29
|
+
file: string;
|
|
30
|
+
method: HttpMethod;
|
|
31
|
+
path: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type InspectedRoute = InspectedContract & {
|
|
35
|
+
handlerFile?: string;
|
|
36
|
+
handlerExport?: HttpMethod;
|
|
37
|
+
handlerSource: "next-route" | "route-local" | "missing";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type InspectDiagnostic = {
|
|
41
|
+
severity: "error" | "warning";
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
file?: string;
|
|
45
|
+
contract?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type InspectFix = {
|
|
49
|
+
code: string;
|
|
50
|
+
message: string;
|
|
51
|
+
file: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type InspectConvention = {
|
|
55
|
+
kind: "standard" | "next" | "custom";
|
|
56
|
+
nextLayout: boolean;
|
|
57
|
+
resourceGenerator: boolean;
|
|
58
|
+
configFile?: string;
|
|
59
|
+
missingNextLayout: string[];
|
|
60
|
+
missingResourceGenerator: string[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type InspectAppResult = {
|
|
64
|
+
targetDir: string;
|
|
65
|
+
config: ResolvedBeignetConfig;
|
|
66
|
+
strict: boolean;
|
|
67
|
+
convention: InspectConvention;
|
|
68
|
+
contracts: InspectedContract[];
|
|
69
|
+
routes: InspectedRoute[];
|
|
70
|
+
diagnostics: InspectDiagnostic[];
|
|
71
|
+
fixes: InspectFix[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type RouteExport = {
|
|
75
|
+
method: HttpMethod;
|
|
76
|
+
handlerFile: string;
|
|
77
|
+
contractRef?: string;
|
|
78
|
+
contractFile?: string;
|
|
79
|
+
catchAllPrefix?: string;
|
|
80
|
+
source: "next-route" | "route-local";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type MatchedRoutesResult = {
|
|
84
|
+
routes: InspectedRoute[];
|
|
85
|
+
unmatchedRouteHandlers: RouteExport[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type ContractListItem =
|
|
89
|
+
| { kind: "contract"; name: string }
|
|
90
|
+
| {
|
|
91
|
+
kind: "list";
|
|
92
|
+
name: string;
|
|
93
|
+
file?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type ContractList = {
|
|
97
|
+
name: string;
|
|
98
|
+
file: string;
|
|
99
|
+
items: ContractListItem[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type FeatureRouteGroup = {
|
|
103
|
+
name: string;
|
|
104
|
+
file: string;
|
|
105
|
+
contracts: Array<{
|
|
106
|
+
exportName: string;
|
|
107
|
+
file?: string;
|
|
108
|
+
}>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export async function inspectApp(
|
|
112
|
+
options: InspectAppOptions = {},
|
|
113
|
+
): Promise<InspectAppResult> {
|
|
114
|
+
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
115
|
+
await assertDirectory(targetDir);
|
|
116
|
+
|
|
117
|
+
const files = await listFiles(targetDir);
|
|
118
|
+
const config = options.config
|
|
119
|
+
? resolveConfig(options.config)
|
|
120
|
+
: await loadBeignetConfig(targetDir, files);
|
|
121
|
+
const convention = inspectConvention(files, config);
|
|
122
|
+
const contracts = await readContracts(targetDir, files, config);
|
|
123
|
+
const routeFiles = files.filter(
|
|
124
|
+
(file) =>
|
|
125
|
+
file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
|
|
126
|
+
file.endsWith("/route.ts"),
|
|
127
|
+
);
|
|
128
|
+
const routeExports = await readRouteExports(targetDir, routeFiles, config);
|
|
129
|
+
const matchedRoutes = matchRoutes(contracts, routeExports);
|
|
130
|
+
const diagnostics = await inspectDiagnostics(
|
|
131
|
+
targetDir,
|
|
132
|
+
files,
|
|
133
|
+
config,
|
|
134
|
+
convention,
|
|
135
|
+
contracts,
|
|
136
|
+
matchedRoutes,
|
|
137
|
+
Boolean(options.strict),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
targetDir,
|
|
142
|
+
config,
|
|
143
|
+
strict: Boolean(options.strict),
|
|
144
|
+
convention,
|
|
145
|
+
contracts,
|
|
146
|
+
routes: matchedRoutes.routes,
|
|
147
|
+
diagnostics,
|
|
148
|
+
fixes: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function applyDoctorFixes(
|
|
153
|
+
options: InspectAppOptions = {},
|
|
154
|
+
): Promise<InspectFix[]> {
|
|
155
|
+
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
156
|
+
await assertDirectory(targetDir);
|
|
157
|
+
|
|
158
|
+
const files = await listFiles(targetDir);
|
|
159
|
+
const config = options.config
|
|
160
|
+
? resolveConfig(options.config)
|
|
161
|
+
: await loadBeignetConfig(targetDir, files);
|
|
162
|
+
const convention = inspectConvention(files, config);
|
|
163
|
+
const contracts = await readContracts(targetDir, files, config);
|
|
164
|
+
const fixes: InspectFix[] = [];
|
|
165
|
+
|
|
166
|
+
const packageFix = await fixMissingTestScript(targetDir, files, convention);
|
|
167
|
+
if (packageFix) fixes.push(packageFix);
|
|
168
|
+
|
|
169
|
+
const openApiFix = await fixDirectOpenApiArrayDrift(
|
|
170
|
+
targetDir,
|
|
171
|
+
config,
|
|
172
|
+
contracts,
|
|
173
|
+
);
|
|
174
|
+
if (openApiFix) fixes.push(openApiFix);
|
|
175
|
+
|
|
176
|
+
return fixes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatRoutes(result: InspectAppResult): string {
|
|
180
|
+
if (result.routes.length === 0) {
|
|
181
|
+
return `No Beignet routes found in ${result.targetDir}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const rows = result.routes.map((route) => ({
|
|
185
|
+
method: route.method,
|
|
186
|
+
path: route.path,
|
|
187
|
+
contract: route.exportName,
|
|
188
|
+
handler:
|
|
189
|
+
route.handlerSource === "missing"
|
|
190
|
+
? "missing"
|
|
191
|
+
: `${route.handlerFile ?? "unknown"}:${route.handlerExport ?? route.method}`,
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
return table([
|
|
195
|
+
["METHOD", "PATH", "CONTRACT", "HANDLER"],
|
|
196
|
+
...rows.map((row) => [row.method, row.path, row.contract, row.handler]),
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function formatDoctor(result: InspectAppResult): string {
|
|
201
|
+
const fixLines =
|
|
202
|
+
result.fixes.length === 0
|
|
203
|
+
? []
|
|
204
|
+
: [
|
|
205
|
+
`Applied ${result.fixes.length} Beignet fix${
|
|
206
|
+
result.fixes.length === 1 ? "" : "es"
|
|
207
|
+
}:`,
|
|
208
|
+
"",
|
|
209
|
+
...result.fixes.map(
|
|
210
|
+
(fix) => `FIXED ${fix.code} ${fix.file}
|
|
211
|
+
${fix.message}`,
|
|
212
|
+
),
|
|
213
|
+
"",
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
if (result.diagnostics.length === 0) {
|
|
217
|
+
return [...fixLines, `No Beignet drift found in ${result.targetDir}.`].join(
|
|
218
|
+
"\n",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
...fixLines,
|
|
224
|
+
`Found ${result.diagnostics.length} Beignet issue${
|
|
225
|
+
result.diagnostics.length === 1 ? "" : "s"
|
|
226
|
+
} in ${result.targetDir}:`,
|
|
227
|
+
"",
|
|
228
|
+
...result.diagnostics.map((diagnostic) => {
|
|
229
|
+
const location = diagnostic.file ? ` ${diagnostic.file}` : "";
|
|
230
|
+
return `${diagnostic.severity.toUpperCase()} ${diagnostic.code}${location}
|
|
231
|
+
${diagnostic.message}`;
|
|
232
|
+
}),
|
|
233
|
+
].join("\n");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function assertDirectory(targetDir: string): Promise<void> {
|
|
237
|
+
try {
|
|
238
|
+
const stats = await stat(targetDir);
|
|
239
|
+
if (!stats.isDirectory()) {
|
|
240
|
+
throw new Error(`${targetDir} is not a directory.`);
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof Error && error.message.includes("not a directory")) {
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`Directory ${targetDir} does not exist.`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function listFiles(targetDir: string): Promise<string[]> {
|
|
251
|
+
const files: string[] = [];
|
|
252
|
+
|
|
253
|
+
async function visit(relativeDir: string): Promise<void> {
|
|
254
|
+
const absoluteDir = path.join(targetDir, relativeDir);
|
|
255
|
+
const entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
256
|
+
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (
|
|
259
|
+
entry.name === "node_modules" ||
|
|
260
|
+
entry.name === ".next" ||
|
|
261
|
+
entry.name === ".git" ||
|
|
262
|
+
entry.name === ".turbo" ||
|
|
263
|
+
entry.name === "coverage" ||
|
|
264
|
+
entry.name === "dist"
|
|
265
|
+
) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const relativePath = normalizePath(path.join(relativeDir, entry.name));
|
|
270
|
+
if (entry.isDirectory()) {
|
|
271
|
+
await visit(relativePath);
|
|
272
|
+
} else if (entry.isFile()) {
|
|
273
|
+
files.push(relativePath);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await visit("");
|
|
279
|
+
return files.sort();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readContracts(
|
|
283
|
+
targetDir: string,
|
|
284
|
+
files: string[],
|
|
285
|
+
config: ResolvedBeignetConfig,
|
|
286
|
+
): Promise<InspectedContract[]> {
|
|
287
|
+
const contracts: InspectedContract[] = [];
|
|
288
|
+
const contractFiles = contractSourceFiles(files, config);
|
|
289
|
+
|
|
290
|
+
for (const file of contractFiles) {
|
|
291
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
292
|
+
contracts.push(...parseContracts(source, file));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return contracts.sort(compareContracts);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function inspectConvention(
|
|
299
|
+
files: string[],
|
|
300
|
+
config: ResolvedBeignetConfig,
|
|
301
|
+
): InspectConvention {
|
|
302
|
+
const missingNextLayout = missingRequirements(files, [
|
|
303
|
+
`${directoryPath(config.paths.contracts)}/`,
|
|
304
|
+
`${directoryPath(config.paths.routes)}/`,
|
|
305
|
+
config.paths.server,
|
|
306
|
+
]);
|
|
307
|
+
const missingResourceGenerator = missingRequirements(files, [
|
|
308
|
+
config.paths.appContext,
|
|
309
|
+
config.paths.infrastructurePorts,
|
|
310
|
+
config.paths.ports,
|
|
311
|
+
config.paths.server,
|
|
312
|
+
config.paths.useCaseBuilder,
|
|
313
|
+
]);
|
|
314
|
+
const nextLayout = missingNextLayout.length === 0;
|
|
315
|
+
const resourceGenerator = missingResourceGenerator.length === 0;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
kind: resourceGenerator ? "standard" : nextLayout ? "next" : "custom",
|
|
319
|
+
nextLayout,
|
|
320
|
+
resourceGenerator,
|
|
321
|
+
configFile: config.configFile,
|
|
322
|
+
missingNextLayout,
|
|
323
|
+
missingResourceGenerator,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function missingRequirements(
|
|
328
|
+
files: string[],
|
|
329
|
+
requirements: string[],
|
|
330
|
+
): string[] {
|
|
331
|
+
return requirements.filter(
|
|
332
|
+
(requirement) => !hasRequirement(files, requirement),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasRequirement(files: string[], requirement: string): boolean {
|
|
337
|
+
if (requirement.endsWith("/")) {
|
|
338
|
+
return files.some((file) => file.startsWith(requirement));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return files.includes(requirement);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function parseContracts(source: string, file: string): InspectedContract[] {
|
|
345
|
+
const contracts: InspectedContract[] = [];
|
|
346
|
+
const groupPrefixes = parseContractGroupPrefixes(source);
|
|
347
|
+
const exportRegex =
|
|
348
|
+
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
|
|
349
|
+
|
|
350
|
+
for (const exportMatch of source.matchAll(exportRegex)) {
|
|
351
|
+
const block = exportMatch[2];
|
|
352
|
+
const methodMatch =
|
|
353
|
+
/([A-Za-z_$][\w$]*)\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(
|
|
354
|
+
block,
|
|
355
|
+
);
|
|
356
|
+
if (methodMatch) {
|
|
357
|
+
const receiver = methodMatch[1];
|
|
358
|
+
const pathPrefix = groupPrefixes.get(receiver) ?? "";
|
|
359
|
+
const routePath = joinContractPaths(pathPrefix, methodMatch[3]);
|
|
360
|
+
|
|
361
|
+
contracts.push({
|
|
362
|
+
exportName: exportMatch[1],
|
|
363
|
+
file,
|
|
364
|
+
method: methodMatch[2].toUpperCase() as HttpMethod,
|
|
365
|
+
path: routePath,
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const directContract = parseDirectContract(block);
|
|
371
|
+
if (directContract) {
|
|
372
|
+
contracts.push({
|
|
373
|
+
exportName: exportMatch[1],
|
|
374
|
+
file,
|
|
375
|
+
method: directContract.method,
|
|
376
|
+
path: normalizeContractPath(directContract.path),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return contracts;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function parseDirectContract(
|
|
385
|
+
block: string,
|
|
386
|
+
): { method: HttpMethod; path: string } | undefined {
|
|
387
|
+
const configMatch = /createContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
|
|
388
|
+
if (!configMatch) return undefined;
|
|
389
|
+
|
|
390
|
+
const configSource = configMatch[1];
|
|
391
|
+
const methodMatch = /method\s*:\s*["']([^"']+)["']/.exec(configSource);
|
|
392
|
+
const pathMatch = /path\s*:\s*["']([^"']+)["']/.exec(configSource);
|
|
393
|
+
if (!methodMatch || !pathMatch) return undefined;
|
|
394
|
+
|
|
395
|
+
const method = methodMatch[1].toUpperCase();
|
|
396
|
+
if (!isHttpMethod(method)) return undefined;
|
|
397
|
+
|
|
398
|
+
return { method, path: pathMatch[1] };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isHttpMethod(method: string): method is HttpMethod {
|
|
402
|
+
return ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(
|
|
403
|
+
method,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseContractGroupPrefixes(source: string): Map<string, string> {
|
|
408
|
+
const assignments = [
|
|
409
|
+
...source.matchAll(
|
|
410
|
+
/(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g,
|
|
411
|
+
),
|
|
412
|
+
].map((match) => ({
|
|
413
|
+
name: match[1],
|
|
414
|
+
expression: match[2],
|
|
415
|
+
}));
|
|
416
|
+
const prefixes = new Map<string, string>();
|
|
417
|
+
|
|
418
|
+
let changed = true;
|
|
419
|
+
while (changed) {
|
|
420
|
+
changed = false;
|
|
421
|
+
for (const assignment of assignments) {
|
|
422
|
+
if (prefixes.has(assignment.name)) continue;
|
|
423
|
+
|
|
424
|
+
const basePrefix = baseGroupPrefix(assignment.expression, prefixes);
|
|
425
|
+
if (basePrefix === undefined) continue;
|
|
426
|
+
|
|
427
|
+
const prefix = prefixCalls(assignment.expression).reduce(
|
|
428
|
+
(current, next) => joinContractPaths(current, next),
|
|
429
|
+
basePrefix,
|
|
430
|
+
);
|
|
431
|
+
prefixes.set(assignment.name, prefix);
|
|
432
|
+
changed = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return prefixes;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function baseGroupPrefix(
|
|
440
|
+
expression: string,
|
|
441
|
+
prefixes: Map<string, string>,
|
|
442
|
+
): string | undefined {
|
|
443
|
+
if (expression.includes("createContractGroup(")) return "";
|
|
444
|
+
|
|
445
|
+
const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
|
|
446
|
+
if (!baseMatch) return undefined;
|
|
447
|
+
|
|
448
|
+
return prefixes.get(baseMatch[1]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function prefixCalls(expression: string): string[] {
|
|
452
|
+
return [...expression.matchAll(/\.prefix\(\s*["']([^"']+)["']\s*\)/g)].map(
|
|
453
|
+
(match) => match[1],
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function joinContractPaths(prefix: string, routePath: string): string {
|
|
458
|
+
if (!prefix) return normalizeContractPath(routePath);
|
|
459
|
+
const segments = [
|
|
460
|
+
...prefix.split("/").filter(Boolean),
|
|
461
|
+
...routePath.split("/").filter(Boolean),
|
|
462
|
+
];
|
|
463
|
+
return `/${segments.join("/")}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function normalizeContractPath(routePath: string): string {
|
|
467
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
468
|
+
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function readRouteExports(
|
|
472
|
+
targetDir: string,
|
|
473
|
+
routeFiles: string[],
|
|
474
|
+
config: ResolvedBeignetConfig,
|
|
475
|
+
): Promise<Map<string, RouteExport[]>> {
|
|
476
|
+
const routeExports = new Map<string, RouteExport[]>();
|
|
477
|
+
|
|
478
|
+
for (const file of routeFiles) {
|
|
479
|
+
const routePath = nextRoutePath(file, config.paths.routes);
|
|
480
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
481
|
+
routeExports.set(file, parseRouteExports(source, file, routePath, config));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return routeExports;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseRouteExports(
|
|
488
|
+
source: string,
|
|
489
|
+
handlerFile: string,
|
|
490
|
+
routePath: string,
|
|
491
|
+
config: ResolvedBeignetConfig,
|
|
492
|
+
): RouteExport[] {
|
|
493
|
+
const exports: RouteExport[] = [];
|
|
494
|
+
const imports = parseNamedImports(source, config);
|
|
495
|
+
const exportRegex =
|
|
496
|
+
/export const\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*=\s*([^;\n]+)/g;
|
|
497
|
+
|
|
498
|
+
for (const match of source.matchAll(exportRegex)) {
|
|
499
|
+
const method = match[1] as HttpMethod;
|
|
500
|
+
const expression = match[2];
|
|
501
|
+
const contractMatch =
|
|
502
|
+
/server\.route\(\s*([A-Za-z_$][\w$]*)\s*\)\.handle/.exec(expression);
|
|
503
|
+
|
|
504
|
+
if (contractMatch) {
|
|
505
|
+
const localName = contractMatch[1];
|
|
506
|
+
const imported = imports.get(localName);
|
|
507
|
+
exports.push({
|
|
508
|
+
method,
|
|
509
|
+
handlerFile,
|
|
510
|
+
contractRef: imported?.importedName ?? localName,
|
|
511
|
+
contractFile: imported?.contractFile,
|
|
512
|
+
source: "route-local",
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (/server\.api\b/.test(expression)) {
|
|
518
|
+
exports.push({
|
|
519
|
+
method,
|
|
520
|
+
handlerFile,
|
|
521
|
+
contractRef: routePath,
|
|
522
|
+
catchAllPrefix: catchAllRoutePrefix(routePath),
|
|
523
|
+
source: "next-route",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return exports;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function catchAllRoutePrefix(routePath: string): string | undefined {
|
|
532
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
533
|
+
const catchAllIndex = segments.findIndex((segment) => segment.endsWith("*"));
|
|
534
|
+
if (catchAllIndex === -1) return undefined;
|
|
535
|
+
|
|
536
|
+
const prefixSegments = segments.slice(0, catchAllIndex);
|
|
537
|
+
return prefixSegments.length === 0 ? "/" : `/${prefixSegments.join("/")}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function parseNamedImports(
|
|
541
|
+
source: string,
|
|
542
|
+
config: ResolvedBeignetConfig,
|
|
543
|
+
importerFile?: string,
|
|
544
|
+
): Map<
|
|
545
|
+
string,
|
|
546
|
+
{
|
|
547
|
+
importedName: string;
|
|
548
|
+
contractFile?: string;
|
|
549
|
+
}
|
|
550
|
+
> {
|
|
551
|
+
const imports = new Map<
|
|
552
|
+
string,
|
|
553
|
+
{
|
|
554
|
+
importedName: string;
|
|
555
|
+
contractFile?: string;
|
|
556
|
+
}
|
|
557
|
+
>();
|
|
558
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
559
|
+
|
|
560
|
+
for (const match of source.matchAll(importRegex)) {
|
|
561
|
+
const sourcePath = match[2];
|
|
562
|
+
const contractFile = contractFileFromImport(
|
|
563
|
+
sourcePath,
|
|
564
|
+
config,
|
|
565
|
+
importerFile,
|
|
566
|
+
);
|
|
567
|
+
for (const member of match[1].split(",")) {
|
|
568
|
+
const parsed = parseImportMember(member);
|
|
569
|
+
if (!parsed) continue;
|
|
570
|
+
imports.set(parsed.localName, {
|
|
571
|
+
importedName: parsed.importedName,
|
|
572
|
+
contractFile,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return imports;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseImportMember(
|
|
581
|
+
member: string,
|
|
582
|
+
): { importedName: string; localName: string } | undefined {
|
|
583
|
+
const trimmed = member.trim();
|
|
584
|
+
if (!trimmed) return undefined;
|
|
585
|
+
|
|
586
|
+
const aliasMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(
|
|
587
|
+
trimmed,
|
|
588
|
+
);
|
|
589
|
+
if (aliasMatch) {
|
|
590
|
+
return {
|
|
591
|
+
importedName: aliasMatch[1],
|
|
592
|
+
localName: aliasMatch[2],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
|
|
597
|
+
return {
|
|
598
|
+
importedName: trimmed,
|
|
599
|
+
localName: trimmed,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function contractFileFromImport(
|
|
607
|
+
sourcePath: string,
|
|
608
|
+
config: ResolvedBeignetConfig,
|
|
609
|
+
importerFile?: string,
|
|
610
|
+
): string | undefined {
|
|
611
|
+
const contractsPath = directoryPath(config.paths.contracts);
|
|
612
|
+
const aliasPrefix = `@/${contractsPath}/`;
|
|
613
|
+
const aliasExact = `@/${contractsPath}`;
|
|
614
|
+
const relativePrefix = `${contractsPath}/`;
|
|
615
|
+
const relativeExact = contractsPath;
|
|
616
|
+
|
|
617
|
+
if (sourcePath === aliasExact || sourcePath === relativeExact) {
|
|
618
|
+
return `${contractsPath}/index.ts`;
|
|
619
|
+
}
|
|
620
|
+
if (sourcePath.startsWith(aliasPrefix)) {
|
|
621
|
+
return `${sourcePath.slice("@/".length)}.ts`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (sourcePath.startsWith(relativePrefix)) {
|
|
625
|
+
return `${sourcePath}.ts`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (importerFile && sourcePath.startsWith(".")) {
|
|
629
|
+
const resolved = normalizePath(
|
|
630
|
+
path.join(path.dirname(importerFile), sourcePath),
|
|
631
|
+
);
|
|
632
|
+
return `${resolved}.ts`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function matchRoutes(
|
|
639
|
+
contracts: InspectedContract[],
|
|
640
|
+
routeExports: Map<string, RouteExport[]>,
|
|
641
|
+
): MatchedRoutesResult {
|
|
642
|
+
const matched = new Set<string>();
|
|
643
|
+
const routes: InspectedRoute[] = [];
|
|
644
|
+
const unmatchedRouteHandlers: RouteExport[] = [];
|
|
645
|
+
const exports = [...routeExports.entries()].flatMap(
|
|
646
|
+
([handlerFile, entries]) =>
|
|
647
|
+
entries.map((routeExport) => ({ handlerFile, routeExport })),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
for (const { handlerFile, routeExport } of exports.filter(
|
|
651
|
+
({ routeExport }) => !routeExport.catchAllPrefix,
|
|
652
|
+
)) {
|
|
653
|
+
const contract = findContract(contracts, routeExport);
|
|
654
|
+
if (!contract) {
|
|
655
|
+
unmatchedRouteHandlers.push(routeExport);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const key = contractKey(contract);
|
|
660
|
+
if (matched.has(key)) continue;
|
|
661
|
+
matched.add(key);
|
|
662
|
+
routes.push({
|
|
663
|
+
...contract,
|
|
664
|
+
handlerFile,
|
|
665
|
+
handlerExport: routeExport.method,
|
|
666
|
+
handlerSource: routeExport.source,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
for (const { handlerFile, routeExport } of exports.filter(
|
|
671
|
+
({ routeExport }) => routeExport.catchAllPrefix,
|
|
672
|
+
)) {
|
|
673
|
+
const contractsForExport = findContracts(contracts, routeExport);
|
|
674
|
+
if (contractsForExport.length === 0) continue;
|
|
675
|
+
|
|
676
|
+
for (const contract of contractsForExport) {
|
|
677
|
+
const key = contractKey(contract);
|
|
678
|
+
if (matched.has(key)) continue;
|
|
679
|
+
|
|
680
|
+
matched.add(key);
|
|
681
|
+
routes.push({
|
|
682
|
+
...contract,
|
|
683
|
+
handlerFile,
|
|
684
|
+
handlerExport: routeExport.method,
|
|
685
|
+
handlerSource: routeExport.source,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
for (const contract of contracts) {
|
|
691
|
+
if (!matched.has(contractKey(contract))) {
|
|
692
|
+
routes.push({
|
|
693
|
+
...contract,
|
|
694
|
+
handlerSource: "missing",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
routes: routes.sort(compareContracts),
|
|
701
|
+
unmatchedRouteHandlers,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function findContract(
|
|
706
|
+
contracts: InspectedContract[],
|
|
707
|
+
routeExport: RouteExport,
|
|
708
|
+
): InspectedContract | undefined {
|
|
709
|
+
return findContracts(contracts, routeExport)[0];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function findContracts(
|
|
713
|
+
contracts: InspectedContract[],
|
|
714
|
+
routeExport: RouteExport,
|
|
715
|
+
): InspectedContract[] {
|
|
716
|
+
if (routeExport.source === "route-local") {
|
|
717
|
+
const contract = contracts.find(
|
|
718
|
+
(contract) =>
|
|
719
|
+
contract.exportName === routeExport.contractRef &&
|
|
720
|
+
(!routeExport.contractFile ||
|
|
721
|
+
contract.file === routeExport.contractFile ||
|
|
722
|
+
contract.file ===
|
|
723
|
+
routeExport.contractFile.replace(/\.ts$/, "/index.ts")),
|
|
724
|
+
);
|
|
725
|
+
return contract ? [contract] : [];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (routeExport.catchAllPrefix) {
|
|
729
|
+
const catchAllPrefix = routeExport.catchAllPrefix;
|
|
730
|
+
return contracts.filter(
|
|
731
|
+
(contract) =>
|
|
732
|
+
contract.method === routeExport.method &&
|
|
733
|
+
contractPathMatchesCatchAll(contract.path, catchAllPrefix),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const contract = contracts.find(
|
|
738
|
+
(contract) =>
|
|
739
|
+
contract.path === routeExport.contractRef &&
|
|
740
|
+
contract.method === routeExport.method,
|
|
741
|
+
);
|
|
742
|
+
return contract ? [contract] : [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function contractPathMatchesCatchAll(
|
|
746
|
+
contractPath: string,
|
|
747
|
+
catchAllPrefix: string,
|
|
748
|
+
): boolean {
|
|
749
|
+
return (
|
|
750
|
+
contractPath === catchAllPrefix ||
|
|
751
|
+
contractPath.startsWith(`${catchAllPrefix}/`)
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function inspectDiagnostics(
|
|
756
|
+
targetDir: string,
|
|
757
|
+
files: string[],
|
|
758
|
+
config: ResolvedBeignetConfig,
|
|
759
|
+
convention: InspectConvention,
|
|
760
|
+
contracts: InspectedContract[],
|
|
761
|
+
matchedRoutes: MatchedRoutesResult,
|
|
762
|
+
strict: boolean,
|
|
763
|
+
): Promise<InspectDiagnostic[]> {
|
|
764
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
765
|
+
|
|
766
|
+
if (!convention.nextLayout) {
|
|
767
|
+
diagnostics.push({
|
|
768
|
+
severity: "warning",
|
|
769
|
+
code: "CK_APP_LAYOUT_NOT_FOUND",
|
|
770
|
+
message: `This directory does not match the standard Beignet app layout. CLI inspection expects ${directoryPath(config.paths.contracts)}/, ${directoryPath(config.paths.routes)}/, and ${config.paths.server}. Missing: ${convention.missingNextLayout.join(", ")}. Add the missing app files or configure their paths in beignet.config.ts.`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (const route of matchedRoutes.routes) {
|
|
775
|
+
if (route.handlerSource !== "missing") continue;
|
|
776
|
+
diagnostics.push({
|
|
777
|
+
severity: "error",
|
|
778
|
+
code: "CK_ROUTE_MISSING",
|
|
779
|
+
file: route.file,
|
|
780
|
+
contract: route.exportName,
|
|
781
|
+
message: `${route.exportName} (${route.method} ${route.path}) has no matching Next route handler. Add ${methodArticle(route.method)} ${route.method} export to ${directoryPath(config.paths.routes)}/[[...path]]/route.ts or a matching route file, or remove the unused contract.`,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
|
|
786
|
+
diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
diagnostics.push(
|
|
790
|
+
...(await inspectPackageScripts(targetDir, files, convention)),
|
|
791
|
+
...(await inspectOpenApiDrift(targetDir, files, config, contracts)),
|
|
792
|
+
...(await inspectFeatureRouteRegistration(
|
|
793
|
+
targetDir,
|
|
794
|
+
files,
|
|
795
|
+
config,
|
|
796
|
+
convention,
|
|
797
|
+
contracts,
|
|
798
|
+
)),
|
|
799
|
+
...(await inspectPortProviderDrift(targetDir, files, config, convention)),
|
|
800
|
+
...(await inspectResourceSlices(
|
|
801
|
+
targetDir,
|
|
802
|
+
files,
|
|
803
|
+
config,
|
|
804
|
+
convention,
|
|
805
|
+
strict,
|
|
806
|
+
)),
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
return dedupeDiagnostics(diagnostics);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function inspectPortProviderDrift(
|
|
813
|
+
targetDir: string,
|
|
814
|
+
files: string[],
|
|
815
|
+
config: ResolvedBeignetConfig,
|
|
816
|
+
convention: InspectConvention,
|
|
817
|
+
): Promise<InspectDiagnostic[]> {
|
|
818
|
+
if (!convention.resourceGenerator) return [];
|
|
819
|
+
|
|
820
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
821
|
+
const portsSource = files.includes(config.paths.ports)
|
|
822
|
+
? await readFile(path.join(targetDir, config.paths.ports), "utf8")
|
|
823
|
+
: "";
|
|
824
|
+
const packageJson = files.includes("package.json")
|
|
825
|
+
? (JSON.parse(
|
|
826
|
+
await readFile(path.join(targetDir, "package.json"), "utf8"),
|
|
827
|
+
) as {
|
|
828
|
+
dependencies?: Record<string, string>;
|
|
829
|
+
devDependencies?: Record<string, string>;
|
|
830
|
+
peerDependencies?: Record<string, string>;
|
|
831
|
+
})
|
|
832
|
+
: undefined;
|
|
833
|
+
const installedPackages = new Set([
|
|
834
|
+
...Object.keys(packageJson?.dependencies ?? {}),
|
|
835
|
+
...Object.keys(packageJson?.devDependencies ?? {}),
|
|
836
|
+
...Object.keys(packageJson?.peerDependencies ?? {}),
|
|
837
|
+
]);
|
|
838
|
+
|
|
839
|
+
for (const expected of canonicalProviderPorts) {
|
|
840
|
+
if (!installedPackages.has(expected.packageName)) continue;
|
|
841
|
+
if (portsSource.includes(`${expected.portName}:`)) continue;
|
|
842
|
+
diagnostics.push({
|
|
843
|
+
severity: "warning",
|
|
844
|
+
code: "CK_PROVIDER_PORT_MISSING",
|
|
845
|
+
file: config.paths.ports,
|
|
846
|
+
message: `${expected.packageName} is installed, but AppPorts does not declare ${expected.portName}: ${expected.portType}. Add the canonical port to AppPorts or remove the unused provider package.`,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const portDir = directoryPath(path.dirname(config.paths.ports));
|
|
851
|
+
for (const file of files) {
|
|
852
|
+
if (
|
|
853
|
+
!file.startsWith(`${portDir}/`) ||
|
|
854
|
+
!file.endsWith(".ts") ||
|
|
855
|
+
file === config.paths.ports ||
|
|
856
|
+
file.endsWith("-repository.ts")
|
|
857
|
+
) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
862
|
+
const generatedPort =
|
|
863
|
+
/export interface ([A-Z]\w*Port)\s*\{[\s\S]*execute\(/.exec(source);
|
|
864
|
+
if (!generatedPort) continue;
|
|
865
|
+
const fakeFactory = `createFake${generatedPort[1].replace(/Port$/, "")}Port`;
|
|
866
|
+
if (source.includes(fakeFactory)) continue;
|
|
867
|
+
diagnostics.push({
|
|
868
|
+
severity: "warning",
|
|
869
|
+
code: "CK_PORT_TEST_FAKE_MISSING",
|
|
870
|
+
file,
|
|
871
|
+
message: `${file} looks like a generated port, but it does not export ${fakeFactory}(). Re-run make port with --force or add a small fake adapter so use-case tests can stay vendor-free.`,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return diagnostics;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const canonicalProviderPorts = [
|
|
879
|
+
{
|
|
880
|
+
packageName: "@beignet/provider-auth-better-auth",
|
|
881
|
+
portName: "auth",
|
|
882
|
+
portType: "AuthPort",
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
packageName: "@beignet/provider-event-bus-memory",
|
|
886
|
+
portName: "eventBus",
|
|
887
|
+
portType: "EventBusPort",
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
packageName: "@beignet/provider-inngest",
|
|
891
|
+
portName: "jobs",
|
|
892
|
+
portType: "JobDispatcherPort",
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
packageName: "@beignet/provider-logger-pino",
|
|
896
|
+
portName: "logger",
|
|
897
|
+
portType: "LoggerPort",
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
packageName: "@beignet/provider-mail-resend",
|
|
901
|
+
portName: "mailer",
|
|
902
|
+
portType: "MailerPort",
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
packageName: "@beignet/provider-mail-smtp",
|
|
906
|
+
portName: "mailer",
|
|
907
|
+
portType: "MailerPort",
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
packageName: "@beignet/provider-rate-limit-upstash",
|
|
911
|
+
portName: "rateLimit",
|
|
912
|
+
portType: "RateLimitPort",
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
packageName: "@beignet/provider-redis",
|
|
916
|
+
portName: "cache",
|
|
917
|
+
portType: "CachePort",
|
|
918
|
+
},
|
|
919
|
+
] as const;
|
|
920
|
+
|
|
921
|
+
async function inspectPackageScripts(
|
|
922
|
+
targetDir: string,
|
|
923
|
+
files: string[],
|
|
924
|
+
convention: InspectConvention,
|
|
925
|
+
): Promise<InspectDiagnostic[]> {
|
|
926
|
+
if (!convention.resourceGenerator || !files.includes("package.json")) {
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const packageJson = JSON.parse(
|
|
931
|
+
await readFile(path.join(targetDir, "package.json"), "utf8"),
|
|
932
|
+
) as {
|
|
933
|
+
scripts?: Record<string, string>;
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
if (packageJson.scripts?.test) return [];
|
|
937
|
+
|
|
938
|
+
return [
|
|
939
|
+
{
|
|
940
|
+
severity: "warning",
|
|
941
|
+
code: "CK_PACKAGE_TEST_SCRIPT_MISSING",
|
|
942
|
+
file: "package.json",
|
|
943
|
+
message:
|
|
944
|
+
'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
|
|
945
|
+
},
|
|
946
|
+
];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function fixMissingTestScript(
|
|
950
|
+
targetDir: string,
|
|
951
|
+
files: string[],
|
|
952
|
+
convention: InspectConvention,
|
|
953
|
+
): Promise<InspectFix | undefined> {
|
|
954
|
+
if (!convention.resourceGenerator || !files.includes("package.json")) {
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const filePath = path.join(targetDir, "package.json");
|
|
959
|
+
const original = await readFile(filePath, "utf8");
|
|
960
|
+
const packageJson = JSON.parse(original) as {
|
|
961
|
+
scripts?: Record<string, string>;
|
|
962
|
+
};
|
|
963
|
+
packageJson.scripts = packageJson.scripts ?? {};
|
|
964
|
+
if (packageJson.scripts.test) return undefined;
|
|
965
|
+
|
|
966
|
+
packageJson.scripts.test = "bun test";
|
|
967
|
+
const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
|
|
968
|
+
if (next === original) return undefined;
|
|
969
|
+
|
|
970
|
+
await writeFile(filePath, next);
|
|
971
|
+
return {
|
|
972
|
+
code: "CK_PACKAGE_TEST_SCRIPT_MISSING",
|
|
973
|
+
file: "package.json",
|
|
974
|
+
message: 'Added "test": "bun test".',
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function unmatchedRouteDiagnostic(
|
|
979
|
+
routeHandler: RouteExport,
|
|
980
|
+
): InspectDiagnostic {
|
|
981
|
+
if (routeHandler.source === "next-route") {
|
|
982
|
+
return {
|
|
983
|
+
severity: "error",
|
|
984
|
+
code: "CK_ROUTE_HANDLER_ORPHANED",
|
|
985
|
+
file: routeHandler.handlerFile,
|
|
986
|
+
message: `${routeHandler.handlerFile} exports ${routeHandler.method} = server.api, but no known contract matches ${routeHandler.method} ${routeHandler.contractRef}. Add the matching contract, update the route file path, or remove the handler export.`,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const contractLabel = routeHandler.contractFile
|
|
991
|
+
? `${routeHandler.contractFile}#${routeHandler.contractRef}`
|
|
992
|
+
: routeHandler.contractRef;
|
|
993
|
+
|
|
994
|
+
return {
|
|
995
|
+
severity: "error",
|
|
996
|
+
code: "CK_ROUTE_HANDLER_UNKNOWN_CONTRACT",
|
|
997
|
+
file: routeHandler.handlerFile,
|
|
998
|
+
contract: routeHandler.contractRef,
|
|
999
|
+
message: `${routeHandler.handlerFile} references ${contractLabel}, but that contract was not found under the configured contracts path. Fix the import/export or remove the route handler.`,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function dedupeDiagnostics(
|
|
1004
|
+
diagnostics: InspectDiagnostic[],
|
|
1005
|
+
): InspectDiagnostic[] {
|
|
1006
|
+
const seen = new Set<string>();
|
|
1007
|
+
const deduped: InspectDiagnostic[] = [];
|
|
1008
|
+
|
|
1009
|
+
for (const diagnostic of diagnostics) {
|
|
1010
|
+
const key = [
|
|
1011
|
+
diagnostic.code,
|
|
1012
|
+
diagnostic.file ?? "",
|
|
1013
|
+
diagnostic.contract ?? "",
|
|
1014
|
+
diagnostic.message,
|
|
1015
|
+
].join(":");
|
|
1016
|
+
if (seen.has(key)) continue;
|
|
1017
|
+
seen.add(key);
|
|
1018
|
+
deduped.push(diagnostic);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return deduped;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async function inspectOpenApiDrift(
|
|
1025
|
+
targetDir: string,
|
|
1026
|
+
files: string[],
|
|
1027
|
+
config: ResolvedBeignetConfig,
|
|
1028
|
+
contracts: InspectedContract[],
|
|
1029
|
+
): Promise<InspectDiagnostic[]> {
|
|
1030
|
+
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
1031
|
+
try {
|
|
1032
|
+
await stat(routePath);
|
|
1033
|
+
} catch {
|
|
1034
|
+
return [];
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const source = await readFile(routePath, "utf8");
|
|
1038
|
+
const listedContracts = await openApiContracts(
|
|
1039
|
+
targetDir,
|
|
1040
|
+
files,
|
|
1041
|
+
config,
|
|
1042
|
+
source,
|
|
1043
|
+
);
|
|
1044
|
+
if (!listedContracts) {
|
|
1045
|
+
return [];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return contracts
|
|
1049
|
+
.filter((contract) => !listedContracts.has(contract.exportName))
|
|
1050
|
+
.map((contract) => ({
|
|
1051
|
+
severity: "warning" as const,
|
|
1052
|
+
code: "CK_OPENAPI_MISSING",
|
|
1053
|
+
file: config.paths.openapiRoute,
|
|
1054
|
+
contract: contract.exportName,
|
|
1055
|
+
message: `${contract.exportName} (${contract.method} ${contract.path}) is not listed in createOpenAPIHandler. Add it to the OpenAPI contract array/list or remove the contract from the documented API surface.`,
|
|
1056
|
+
}));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function inspectFeatureRouteRegistration(
|
|
1060
|
+
targetDir: string,
|
|
1061
|
+
files: string[],
|
|
1062
|
+
config: ResolvedBeignetConfig,
|
|
1063
|
+
convention: InspectConvention,
|
|
1064
|
+
contracts: InspectedContract[],
|
|
1065
|
+
): Promise<InspectDiagnostic[]> {
|
|
1066
|
+
if (!convention.resourceGenerator) return [];
|
|
1067
|
+
if (!files.includes(config.paths.server)) return [];
|
|
1068
|
+
|
|
1069
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
1070
|
+
if (routeGroups.length === 0) return [];
|
|
1071
|
+
|
|
1072
|
+
const serverSource = await readFile(
|
|
1073
|
+
path.join(targetDir, config.paths.server),
|
|
1074
|
+
"utf8",
|
|
1075
|
+
);
|
|
1076
|
+
const registeredGroups = await registeredRouteGroupsForServer(
|
|
1077
|
+
targetDir,
|
|
1078
|
+
files,
|
|
1079
|
+
config,
|
|
1080
|
+
serverSource,
|
|
1081
|
+
);
|
|
1082
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1083
|
+
|
|
1084
|
+
for (const routeGroup of routeGroups) {
|
|
1085
|
+
if (registeredGroups.has(routeGroup.name)) continue;
|
|
1086
|
+
|
|
1087
|
+
for (const routeGroupContract of routeGroup.contracts) {
|
|
1088
|
+
const contract = findRouteGroupContract(contracts, routeGroupContract);
|
|
1089
|
+
diagnostics.push({
|
|
1090
|
+
severity: "error",
|
|
1091
|
+
code: "CK_ROUTE_GROUP_UNREGISTERED",
|
|
1092
|
+
file: config.paths.server,
|
|
1093
|
+
contract: routeGroupContract.exportName,
|
|
1094
|
+
message: contract
|
|
1095
|
+
? `${routeGroup.file} declares ${routeGroup.name} for ${contract.exportName} (${contract.method} ${contract.path}), but ${routeGroup.name} is not registered in defineRoutes([...]) in ${config.paths.server}. Import ${routeGroup.name} and add it to the central route list.`
|
|
1096
|
+
: `${routeGroup.file} declares ${routeGroup.name} for ${routeGroupContract.exportName}, but ${routeGroup.name} is not registered in defineRoutes([...]) in ${config.paths.server}. Import ${routeGroup.name} and add it to the central route list.`,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return diagnostics;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function readFeatureRouteGroups(
|
|
1105
|
+
targetDir: string,
|
|
1106
|
+
files: string[],
|
|
1107
|
+
config: ResolvedBeignetConfig,
|
|
1108
|
+
): Promise<FeatureRouteGroup[]> {
|
|
1109
|
+
const featuresPath = `${directoryPath(config.paths.features)}/`;
|
|
1110
|
+
const routeFiles = files.filter(
|
|
1111
|
+
(file) => file.startsWith(featuresPath) && file.endsWith("/routes.ts"),
|
|
1112
|
+
);
|
|
1113
|
+
const routeGroups: FeatureRouteGroup[] = [];
|
|
1114
|
+
|
|
1115
|
+
for (const file of routeFiles) {
|
|
1116
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1117
|
+
const imports = parseNamedImports(source, config, file);
|
|
1118
|
+
routeGroups.push(...parseFeatureRouteGroups(source, file, imports));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return routeGroups;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function parseFeatureRouteGroups(
|
|
1125
|
+
source: string,
|
|
1126
|
+
file: string,
|
|
1127
|
+
imports: ReturnType<typeof parseNamedImports>,
|
|
1128
|
+
): FeatureRouteGroup[] {
|
|
1129
|
+
const routeGroups: FeatureRouteGroup[] = [];
|
|
1130
|
+
const exportRegex =
|
|
1131
|
+
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(\s*(?:\{[\s\S]*?routes:\s*)?\[([\s\S]*?)\]\s*(?:,?\s*\})?\s*\)\s*;?/g;
|
|
1132
|
+
|
|
1133
|
+
for (const match of source.matchAll(exportRegex)) {
|
|
1134
|
+
const contracts = [...match[2].matchAll(/contract:\s*([^,\n}]+)/g)]
|
|
1135
|
+
.map((contractMatch) =>
|
|
1136
|
+
routeGroupContractRef(contractMatch[1].trim(), imports),
|
|
1137
|
+
)
|
|
1138
|
+
.filter((contract): contract is FeatureRouteGroup["contracts"][number] =>
|
|
1139
|
+
Boolean(contract),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
routeGroups.push({
|
|
1143
|
+
name: match[1],
|
|
1144
|
+
file,
|
|
1145
|
+
contracts,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
return routeGroups;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function routeGroupContractRef(
|
|
1153
|
+
expression: string,
|
|
1154
|
+
imports: ReturnType<typeof parseNamedImports>,
|
|
1155
|
+
): FeatureRouteGroup["contracts"][number] | undefined {
|
|
1156
|
+
const namedMatch = /^([A-Za-z_$][\w$]*)$/.exec(expression);
|
|
1157
|
+
if (namedMatch) {
|
|
1158
|
+
const localName = namedMatch[1];
|
|
1159
|
+
const imported = imports.get(localName);
|
|
1160
|
+
return {
|
|
1161
|
+
exportName: imported?.importedName ?? localName,
|
|
1162
|
+
file: imported?.contractFile,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const namespaceMatch = /^[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)$/.exec(
|
|
1167
|
+
expression,
|
|
1168
|
+
);
|
|
1169
|
+
if (namespaceMatch) {
|
|
1170
|
+
return {
|
|
1171
|
+
exportName: namespaceMatch[1],
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return undefined;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function registeredRouteGroups(source: string): Set<string> {
|
|
1179
|
+
const defineRoutesMatch = /defineRoutes(?:<[^>]+>)?\(\[/.exec(source);
|
|
1180
|
+
if (!defineRoutesMatch) return new Set();
|
|
1181
|
+
|
|
1182
|
+
const defineRoutesSource = source.slice(defineRoutesMatch.index);
|
|
1183
|
+
const defineRoutesEnd = defineRoutesSource.indexOf("]),");
|
|
1184
|
+
const searchSource =
|
|
1185
|
+
defineRoutesEnd === -1
|
|
1186
|
+
? defineRoutesSource
|
|
1187
|
+
: defineRoutesSource.slice(0, defineRoutesEnd);
|
|
1188
|
+
|
|
1189
|
+
return new Set(
|
|
1190
|
+
[...searchSource.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map(
|
|
1191
|
+
(match) => match[1],
|
|
1192
|
+
),
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function registeredRouteGroupsForServer(
|
|
1197
|
+
targetDir: string,
|
|
1198
|
+
files: string[],
|
|
1199
|
+
config: ResolvedBeignetConfig,
|
|
1200
|
+
serverSource: string,
|
|
1201
|
+
): Promise<Set<string>> {
|
|
1202
|
+
const registeredGroups = registeredRouteGroups(serverSource);
|
|
1203
|
+
const imports = parseNamedImports(serverSource, config, config.paths.server);
|
|
1204
|
+
|
|
1205
|
+
for (const identifier of routeOptionIdentifiers(serverSource)) {
|
|
1206
|
+
const imported = imports.get(identifier);
|
|
1207
|
+
if (!imported?.contractFile || !files.includes(imported.contractFile)) {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const source = await readFile(path.join(targetDir, imported.contractFile), {
|
|
1212
|
+
encoding: "utf8",
|
|
1213
|
+
});
|
|
1214
|
+
for (const routeGroup of registeredRouteGroups(source)) {
|
|
1215
|
+
registeredGroups.add(routeGroup);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return registeredGroups;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function routeOptionIdentifiers(source: string): Set<string> {
|
|
1223
|
+
const identifiers = new Set<string>();
|
|
1224
|
+
for (const match of source.matchAll(
|
|
1225
|
+
/(?:^|[,{])\s*routes\s*:\s*([A-Za-z_$][\w$]*)/g,
|
|
1226
|
+
)) {
|
|
1227
|
+
identifiers.add(match[1]);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (/(?:^|[,{])\s*routes\s*(?:[,}])/m.test(source)) {
|
|
1231
|
+
identifiers.add("routes");
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return identifiers;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function findRouteGroupContract(
|
|
1238
|
+
contracts: InspectedContract[],
|
|
1239
|
+
routeGroupContract: FeatureRouteGroup["contracts"][number],
|
|
1240
|
+
): InspectedContract | undefined {
|
|
1241
|
+
return contracts.find(
|
|
1242
|
+
(contract) =>
|
|
1243
|
+
contract.exportName === routeGroupContract.exportName &&
|
|
1244
|
+
(!routeGroupContract.file ||
|
|
1245
|
+
contract.file === routeGroupContract.file ||
|
|
1246
|
+
contract.file ===
|
|
1247
|
+
routeGroupContract.file.replace(/\.ts$/, "/index.ts")),
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function fixDirectOpenApiArrayDrift(
|
|
1252
|
+
targetDir: string,
|
|
1253
|
+
config: ResolvedBeignetConfig,
|
|
1254
|
+
contracts: InspectedContract[],
|
|
1255
|
+
): Promise<InspectFix | undefined> {
|
|
1256
|
+
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
1257
|
+
let source: string;
|
|
1258
|
+
try {
|
|
1259
|
+
source = await readFile(routePath, "utf8");
|
|
1260
|
+
} catch {
|
|
1261
|
+
return undefined;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const firstArg = firstCreateOpenAPIHandlerArgInfo(source);
|
|
1265
|
+
if (!firstArg?.text.startsWith("[")) return undefined;
|
|
1266
|
+
|
|
1267
|
+
const listedContracts = contractsFromArrayExpression(firstArg.text);
|
|
1268
|
+
const missingContracts = contracts.filter(
|
|
1269
|
+
(contract) => !listedContracts.has(contract.exportName),
|
|
1270
|
+
);
|
|
1271
|
+
if (missingContracts.length === 0) return undefined;
|
|
1272
|
+
|
|
1273
|
+
if (
|
|
1274
|
+
missingContracts.some(
|
|
1275
|
+
(contract) => !hasImportedIdentifier(source, contract.exportName),
|
|
1276
|
+
)
|
|
1277
|
+
) {
|
|
1278
|
+
return undefined;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const nextArray = appendToArrayExpression(
|
|
1282
|
+
firstArg.text,
|
|
1283
|
+
missingContracts.map((contract) => contract.exportName),
|
|
1284
|
+
);
|
|
1285
|
+
const next = `${source.slice(0, firstArg.start)}${nextArray}${source.slice(
|
|
1286
|
+
firstArg.end,
|
|
1287
|
+
)}`;
|
|
1288
|
+
if (next === source) return undefined;
|
|
1289
|
+
|
|
1290
|
+
await writeFile(routePath, next);
|
|
1291
|
+
return {
|
|
1292
|
+
code: "CK_OPENAPI_MISSING",
|
|
1293
|
+
file: config.paths.openapiRoute,
|
|
1294
|
+
message: `Added ${missingContracts
|
|
1295
|
+
.map((contract) => contract.exportName)
|
|
1296
|
+
.join(", ")} to the direct createOpenAPIHandler contract array.`,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function openApiContracts(
|
|
1301
|
+
targetDir: string,
|
|
1302
|
+
files: string[],
|
|
1303
|
+
config: ResolvedBeignetConfig,
|
|
1304
|
+
source: string,
|
|
1305
|
+
): Promise<Set<string> | undefined> {
|
|
1306
|
+
const firstArg = firstCreateOpenAPIHandlerArg(source);
|
|
1307
|
+
if (!firstArg) return undefined;
|
|
1308
|
+
|
|
1309
|
+
if (firstArg.startsWith("[")) {
|
|
1310
|
+
return contractsFromArrayExpression(firstArg);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(firstArg)?.[1];
|
|
1314
|
+
if (!identifier) return undefined;
|
|
1315
|
+
|
|
1316
|
+
const imports = parseNamedImports(source, config);
|
|
1317
|
+
const imported = imports.get(identifier);
|
|
1318
|
+
const listFile = imported?.contractFile ?? config.paths.openapiRoute;
|
|
1319
|
+
|
|
1320
|
+
return resolveContractList(
|
|
1321
|
+
await readContractLists(targetDir, files, config),
|
|
1322
|
+
listFile,
|
|
1323
|
+
imported?.importedName ?? identifier,
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function firstCreateOpenAPIHandlerArg(source: string): string | undefined {
|
|
1328
|
+
return firstCreateOpenAPIHandlerArgInfo(source)?.text;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function firstCreateOpenAPIHandlerArgInfo(
|
|
1332
|
+
source: string,
|
|
1333
|
+
): { text: string; start: number; end: number } | undefined {
|
|
1334
|
+
const start = source.indexOf("createOpenAPIHandler(");
|
|
1335
|
+
if (start === -1) return undefined;
|
|
1336
|
+
|
|
1337
|
+
let depth = 0;
|
|
1338
|
+
let inString: string | undefined;
|
|
1339
|
+
let escaped = false;
|
|
1340
|
+
const argStart = start + "createOpenAPIHandler(".length;
|
|
1341
|
+
|
|
1342
|
+
for (let index = argStart; index < source.length; index++) {
|
|
1343
|
+
const char = source[index];
|
|
1344
|
+
if (inString) {
|
|
1345
|
+
if (escaped) {
|
|
1346
|
+
escaped = false;
|
|
1347
|
+
} else if (char === "\\") {
|
|
1348
|
+
escaped = true;
|
|
1349
|
+
} else if (char === inString) {
|
|
1350
|
+
inString = undefined;
|
|
1351
|
+
}
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1356
|
+
inString = char;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (char === "[" || char === "(" || char === "{") {
|
|
1361
|
+
depth++;
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (char === "]" || char === ")" || char === "}") {
|
|
1366
|
+
if (depth === 0) {
|
|
1367
|
+
return trimmedSlice(source, argStart, index);
|
|
1368
|
+
}
|
|
1369
|
+
depth--;
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (char === "," && depth === 0) {
|
|
1374
|
+
return trimmedSlice(source, argStart, index);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
return undefined;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function trimmedSlice(
|
|
1382
|
+
source: string,
|
|
1383
|
+
start: number,
|
|
1384
|
+
end: number,
|
|
1385
|
+
): { text: string; start: number; end: number } {
|
|
1386
|
+
let trimmedStart = start;
|
|
1387
|
+
let trimmedEnd = end;
|
|
1388
|
+
|
|
1389
|
+
while (trimmedStart < trimmedEnd && /\s/.test(source[trimmedStart])) {
|
|
1390
|
+
trimmedStart++;
|
|
1391
|
+
}
|
|
1392
|
+
while (trimmedEnd > trimmedStart && /\s/.test(source[trimmedEnd - 1])) {
|
|
1393
|
+
trimmedEnd--;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
return {
|
|
1397
|
+
text: source.slice(trimmedStart, trimmedEnd),
|
|
1398
|
+
start: trimmedStart,
|
|
1399
|
+
end: trimmedEnd,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function contractsFromArrayExpression(expression: string): Set<string> {
|
|
1404
|
+
const withoutBrackets = expression.replace(/^\[/, "").replace(/\]$/, "");
|
|
1405
|
+
|
|
1406
|
+
return new Set(
|
|
1407
|
+
Array.from(
|
|
1408
|
+
withoutBrackets.matchAll(/\b[A-Za-z_$][\w$]*\b/g),
|
|
1409
|
+
([name]) => name,
|
|
1410
|
+
),
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function appendToArrayExpression(expression: string, names: string[]): string {
|
|
1415
|
+
const closingBracket = /\]\s*$/.exec(expression);
|
|
1416
|
+
if (!closingBracket) return expression;
|
|
1417
|
+
|
|
1418
|
+
const beforeClosingBracket = expression.slice(0, closingBracket.index);
|
|
1419
|
+
const closingBracketText = expression.slice(closingBracket.index);
|
|
1420
|
+
const inner = beforeClosingBracket.replace(/^\[/, "");
|
|
1421
|
+
if (!inner.trim()) return `[${names.join(", ")}]`;
|
|
1422
|
+
|
|
1423
|
+
if (!expression.includes("\n")) {
|
|
1424
|
+
const separator = /,\s*$/.test(inner) ? " " : ", ";
|
|
1425
|
+
return `${beforeClosingBracket}${separator}${names.join(", ")}${closingBracketText}`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const itemIndent =
|
|
1429
|
+
inner
|
|
1430
|
+
.split("\n")
|
|
1431
|
+
.find((line) => line.trim())
|
|
1432
|
+
?.match(/^[\t ]*/)?.[0] ?? "\t";
|
|
1433
|
+
const closingIndent = inner.match(/\n([\t ]*)$/)?.[1] ?? "";
|
|
1434
|
+
const trimmedBeforeClosingBracket = beforeClosingBracket.replace(/\s*$/, "");
|
|
1435
|
+
const appendedNames = names.join(`,\n${itemIndent}`);
|
|
1436
|
+
|
|
1437
|
+
if (/,\s*$/.test(inner)) {
|
|
1438
|
+
return `${trimmedBeforeClosingBracket}\n${itemIndent}${appendedNames},\n${closingIndent}${closingBracketText}`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function hasImportedIdentifier(source: string, identifier: string): boolean {
|
|
1445
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
|
|
1446
|
+
|
|
1447
|
+
for (const match of source.matchAll(importRegex)) {
|
|
1448
|
+
for (const member of match[1].split(",")) {
|
|
1449
|
+
const parsed = parseImportMember(member);
|
|
1450
|
+
if (parsed?.localName === identifier) return true;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function readContractLists(
|
|
1458
|
+
targetDir: string,
|
|
1459
|
+
files: string[],
|
|
1460
|
+
config: ResolvedBeignetConfig,
|
|
1461
|
+
): Promise<Map<string, ContractList>> {
|
|
1462
|
+
const lists = new Map<string, ContractList>();
|
|
1463
|
+
const contractFiles = contractListSourceFiles(files, config);
|
|
1464
|
+
|
|
1465
|
+
for (const file of contractFiles) {
|
|
1466
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1467
|
+
const imports = parseNamedImports(source, config, file);
|
|
1468
|
+
for (const list of parseContractLists(source, file, imports)) {
|
|
1469
|
+
lists.set(contractListKey(list.file, list.name), list);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
return lists;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function contractSourceFiles(
|
|
1477
|
+
files: string[],
|
|
1478
|
+
config: ResolvedBeignetConfig,
|
|
1479
|
+
): string[] {
|
|
1480
|
+
const contractsPath = directoryPath(config.paths.contracts);
|
|
1481
|
+
if (usesFeatureOwnedContracts(config)) {
|
|
1482
|
+
return files.filter(
|
|
1483
|
+
(file) =>
|
|
1484
|
+
new RegExp(
|
|
1485
|
+
`^${escapeRegExp(contractsPath)}/[^/]+/contracts\\.ts$`,
|
|
1486
|
+
).test(file) && !file.endsWith(".test.ts"),
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return files.filter(
|
|
1491
|
+
(file) =>
|
|
1492
|
+
file.startsWith(`${contractsPath}/`) &&
|
|
1493
|
+
file.endsWith(".ts") &&
|
|
1494
|
+
!file.endsWith(".test.ts"),
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function contractListSourceFiles(
|
|
1499
|
+
files: string[],
|
|
1500
|
+
config: ResolvedBeignetConfig,
|
|
1501
|
+
): string[] {
|
|
1502
|
+
const contractsPath = directoryPath(config.paths.contracts);
|
|
1503
|
+
if (usesFeatureOwnedContracts(config)) {
|
|
1504
|
+
return files.filter((file) => {
|
|
1505
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) return false;
|
|
1506
|
+
if (file === `${contractsPath}/index.ts`) return true;
|
|
1507
|
+
if (file === `${contractsPath}/contracts.ts`) return true;
|
|
1508
|
+
return new RegExp(
|
|
1509
|
+
`^${escapeRegExp(contractsPath)}/[^/]+/contracts\\.ts$`,
|
|
1510
|
+
).test(file);
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
return files.filter(
|
|
1515
|
+
(file) =>
|
|
1516
|
+
(file === `${contractsPath}.ts` ||
|
|
1517
|
+
file.startsWith(`${contractsPath}/`)) &&
|
|
1518
|
+
file.endsWith(".ts") &&
|
|
1519
|
+
!file.endsWith(".test.ts"),
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function parseContractLists(
|
|
1524
|
+
source: string,
|
|
1525
|
+
file: string,
|
|
1526
|
+
imports: ReturnType<typeof parseNamedImports>,
|
|
1527
|
+
): ContractList[] {
|
|
1528
|
+
const lists: ContractList[] = [];
|
|
1529
|
+
const exportRegex =
|
|
1530
|
+
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*\[([\s\S]*?)\]\s*;?/g;
|
|
1531
|
+
|
|
1532
|
+
for (const match of source.matchAll(exportRegex)) {
|
|
1533
|
+
const items: ContractListItem[] = [];
|
|
1534
|
+
for (const item of match[2].split(",")) {
|
|
1535
|
+
const trimmed = item.trim();
|
|
1536
|
+
if (!trimmed) continue;
|
|
1537
|
+
const spreadMatch = /^\.\.\.([A-Za-z_$][\w$]*)$/.exec(trimmed);
|
|
1538
|
+
if (spreadMatch) {
|
|
1539
|
+
const imported = imports.get(spreadMatch[1]);
|
|
1540
|
+
items.push({
|
|
1541
|
+
kind: "list",
|
|
1542
|
+
name: imported?.importedName ?? spreadMatch[1],
|
|
1543
|
+
file: imported?.contractFile ?? file,
|
|
1544
|
+
});
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const contractMatch = /^([A-Za-z_$][\w$]*)$/.exec(trimmed);
|
|
1549
|
+
if (contractMatch) {
|
|
1550
|
+
const imported = imports.get(contractMatch[1]);
|
|
1551
|
+
items.push({
|
|
1552
|
+
kind: "contract",
|
|
1553
|
+
name: imported?.importedName ?? contractMatch[1],
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
lists.push({ name: match[1], file, items });
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
return lists;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function resolveContractList(
|
|
1565
|
+
lists: Map<string, ContractList>,
|
|
1566
|
+
file: string,
|
|
1567
|
+
name: string,
|
|
1568
|
+
seen = new Set<string>(),
|
|
1569
|
+
): Set<string> | undefined {
|
|
1570
|
+
const key = contractListKey(file, name);
|
|
1571
|
+
if (seen.has(key)) return new Set();
|
|
1572
|
+
const list =
|
|
1573
|
+
lists.get(key) ??
|
|
1574
|
+
lists.get(contractListKey(file.replace(/\.ts$/, "/index.ts"), name));
|
|
1575
|
+
if (!list) return undefined;
|
|
1576
|
+
|
|
1577
|
+
seen.add(key);
|
|
1578
|
+
const contracts = new Set<string>();
|
|
1579
|
+
for (const item of list.items) {
|
|
1580
|
+
if (item.kind === "contract") {
|
|
1581
|
+
contracts.add(item.name);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const nested = resolveContractList(
|
|
1586
|
+
lists,
|
|
1587
|
+
item.file ?? file,
|
|
1588
|
+
item.name,
|
|
1589
|
+
seen,
|
|
1590
|
+
);
|
|
1591
|
+
if (!nested) continue;
|
|
1592
|
+
for (const contract of nested) contracts.add(contract);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return contracts;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function contractListKey(file: string, name: string): string {
|
|
1599
|
+
return `${file}:${name}`;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function inspectResourceSlices(
|
|
1603
|
+
targetDir: string,
|
|
1604
|
+
files: string[],
|
|
1605
|
+
config: ResolvedBeignetConfig,
|
|
1606
|
+
convention: InspectConvention,
|
|
1607
|
+
strict: boolean,
|
|
1608
|
+
): Promise<InspectDiagnostic[]> {
|
|
1609
|
+
if (!convention.resourceGenerator) return [];
|
|
1610
|
+
|
|
1611
|
+
const resources = await collectResourceNames(targetDir, files, config);
|
|
1612
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1613
|
+
|
|
1614
|
+
for (const resource of resources) {
|
|
1615
|
+
const singular = singularize(resource);
|
|
1616
|
+
const contractFile = resourceContractFile(resource, config);
|
|
1617
|
+
const useCaseDir = resourceUseCaseDir(resource, config);
|
|
1618
|
+
const portFile = resourcePortFile(resource, singular, config);
|
|
1619
|
+
const infrastructureDir = `${directoryPath(
|
|
1620
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
1621
|
+
)}/${resource}/`;
|
|
1622
|
+
const routeFile = path.join(config.paths.routes, resource, "route.ts");
|
|
1623
|
+
const catchAllRouteFile = path.join(
|
|
1624
|
+
config.paths.routes,
|
|
1625
|
+
"[[...path]]",
|
|
1626
|
+
"route.ts",
|
|
1627
|
+
);
|
|
1628
|
+
const testFile = resourceTestFile(resource, config);
|
|
1629
|
+
|
|
1630
|
+
if (!hasRequirement(files, contractFile)) {
|
|
1631
|
+
diagnostics.push(
|
|
1632
|
+
resourceDiagnostic(
|
|
1633
|
+
"CK_RESOURCE_CONTRACT_MISSING",
|
|
1634
|
+
resource,
|
|
1635
|
+
contractFile,
|
|
1636
|
+
),
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
if (!hasRequirement(files, useCaseDir)) {
|
|
1640
|
+
diagnostics.push(
|
|
1641
|
+
resourceDiagnostic(
|
|
1642
|
+
"CK_RESOURCE_USE_CASES_MISSING",
|
|
1643
|
+
resource,
|
|
1644
|
+
useCaseDir,
|
|
1645
|
+
),
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
if (!hasRequirement(files, portFile)) {
|
|
1649
|
+
diagnostics.push(
|
|
1650
|
+
resourceDiagnostic("CK_RESOURCE_PORT_MISSING", resource, portFile),
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
if (!hasRequirement(files, infrastructureDir)) {
|
|
1654
|
+
diagnostics.push(
|
|
1655
|
+
resourceDiagnostic(
|
|
1656
|
+
"CK_RESOURCE_INFRASTRUCTURE_MISSING",
|
|
1657
|
+
resource,
|
|
1658
|
+
infrastructureDir,
|
|
1659
|
+
),
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
if (
|
|
1663
|
+
!hasRequirement(files, routeFile) &&
|
|
1664
|
+
!hasRequirement(files, catchAllRouteFile)
|
|
1665
|
+
) {
|
|
1666
|
+
diagnostics.push(
|
|
1667
|
+
resourceDiagnostic("CK_RESOURCE_ROUTE_MISSING", resource, routeFile),
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
if (strict && !hasRequirement(files, testFile)) {
|
|
1671
|
+
diagnostics.push({
|
|
1672
|
+
...resourceDiagnostic("CK_RESOURCE_TEST_MISSING", resource, testFile),
|
|
1673
|
+
severity: "warning",
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return diagnostics;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function collectResourceNames(
|
|
1682
|
+
targetDir: string,
|
|
1683
|
+
files: string[],
|
|
1684
|
+
config: ResolvedBeignetConfig,
|
|
1685
|
+
): Promise<string[]> {
|
|
1686
|
+
const resources = new Set<string>();
|
|
1687
|
+
const portsPath = directoryPath(path.dirname(config.paths.ports));
|
|
1688
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1689
|
+
const infrastructurePath = directoryPath(
|
|
1690
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
1691
|
+
);
|
|
1692
|
+
|
|
1693
|
+
for (const file of files) {
|
|
1694
|
+
const portMatch = new RegExp(
|
|
1695
|
+
`^${escapeRegExp(portsPath)}/([^/]+)-repository\\.ts$`,
|
|
1696
|
+
).exec(file);
|
|
1697
|
+
if (portMatch) {
|
|
1698
|
+
resources.add(pluralize(portMatch[1]));
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const featurePortMatch = new RegExp(
|
|
1702
|
+
`^${escapeRegExp(featuresPath)}/([^/]+)/ports\\.ts$`,
|
|
1703
|
+
).exec(file);
|
|
1704
|
+
if (
|
|
1705
|
+
featurePortMatch &&
|
|
1706
|
+
(await isGeneratedResourcePort(targetDir, file, featurePortMatch[1]))
|
|
1707
|
+
) {
|
|
1708
|
+
resources.add(featurePortMatch[1]);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const infrastructureMatch = new RegExp(
|
|
1712
|
+
`^${escapeRegExp(infrastructurePath)}/([^/]+)/.*-repository\\.ts$`,
|
|
1713
|
+
).exec(file);
|
|
1714
|
+
if (
|
|
1715
|
+
infrastructureMatch &&
|
|
1716
|
+
(await isGeneratedInfrastructureRepository(
|
|
1717
|
+
targetDir,
|
|
1718
|
+
file,
|
|
1719
|
+
infrastructureMatch[1],
|
|
1720
|
+
))
|
|
1721
|
+
) {
|
|
1722
|
+
resources.add(infrastructureMatch[1]);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
return [...resources].sort();
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async function isGeneratedResourcePort(
|
|
1730
|
+
targetDir: string,
|
|
1731
|
+
file: string,
|
|
1732
|
+
resource: string,
|
|
1733
|
+
): Promise<boolean> {
|
|
1734
|
+
let source: string;
|
|
1735
|
+
try {
|
|
1736
|
+
source = await readFile(path.join(targetDir, file), "utf8");
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const singular = singularize(resource);
|
|
1743
|
+
const singularPascal = pascalCase(singular);
|
|
1744
|
+
const pluralPascal = pascalCase(resource);
|
|
1745
|
+
|
|
1746
|
+
return (
|
|
1747
|
+
source.includes(`Create${singularPascal}Input`) &&
|
|
1748
|
+
source.includes(`List${pluralPascal}Input`) &&
|
|
1749
|
+
source.includes(`List${pluralPascal}Result`) &&
|
|
1750
|
+
source.includes(`interface ${singularPascal}Repository`) &&
|
|
1751
|
+
source.includes(`create(input: Create${singularPascal}Input)`)
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function isGeneratedInfrastructureRepository(
|
|
1756
|
+
targetDir: string,
|
|
1757
|
+
file: string,
|
|
1758
|
+
resource: string,
|
|
1759
|
+
): Promise<boolean> {
|
|
1760
|
+
let source: string;
|
|
1761
|
+
try {
|
|
1762
|
+
source = await readFile(path.join(targetDir, file), "utf8");
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const singularPascal = pascalCase(singularize(resource));
|
|
1769
|
+
|
|
1770
|
+
return source.includes(`createInMemory${singularPascal}Repository`);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function usesFeatureOwnedContracts(config: ResolvedBeignetConfig): boolean {
|
|
1774
|
+
return (
|
|
1775
|
+
directoryPath(config.paths.contracts) ===
|
|
1776
|
+
directoryPath(config.paths.features)
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function usesFeatureOwnedUseCases(config: ResolvedBeignetConfig): boolean {
|
|
1781
|
+
return (
|
|
1782
|
+
directoryPath(config.paths.useCases) ===
|
|
1783
|
+
directoryPath(config.paths.features)
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function usesFeatureOwnedTests(config: ResolvedBeignetConfig): boolean {
|
|
1788
|
+
return (
|
|
1789
|
+
usesFeatureOwnedUseCases(config) &&
|
|
1790
|
+
directoryPath(config.paths.tests) === "tests"
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function resourceContractFile(
|
|
1795
|
+
resource: string,
|
|
1796
|
+
config: ResolvedBeignetConfig,
|
|
1797
|
+
): string {
|
|
1798
|
+
if (usesFeatureOwnedContracts(config)) {
|
|
1799
|
+
return path.join(config.paths.features, resource, "contracts.ts");
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
return path.join(config.paths.contracts, `${resource}.ts`);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function resourceUseCaseDir(
|
|
1806
|
+
resource: string,
|
|
1807
|
+
config: ResolvedBeignetConfig,
|
|
1808
|
+
): string {
|
|
1809
|
+
if (usesFeatureOwnedUseCases(config)) {
|
|
1810
|
+
return `${directoryPath(config.paths.features)}/${resource}/use-cases/`;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
return `${directoryPath(config.paths.useCases)}/${resource}/`;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function resourcePortFile(
|
|
1817
|
+
resource: string,
|
|
1818
|
+
singular: string,
|
|
1819
|
+
config: ResolvedBeignetConfig,
|
|
1820
|
+
): string {
|
|
1821
|
+
if (usesFeatureOwnedContracts(config)) {
|
|
1822
|
+
return path.join(config.paths.features, resource, "ports.ts");
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return path.join(
|
|
1826
|
+
path.dirname(config.paths.ports),
|
|
1827
|
+
`${singular}-repository.ts`,
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function resourceTestFile(
|
|
1832
|
+
resource: string,
|
|
1833
|
+
config: ResolvedBeignetConfig,
|
|
1834
|
+
): string {
|
|
1835
|
+
if (usesFeatureOwnedTests(config)) {
|
|
1836
|
+
return path.join(
|
|
1837
|
+
config.paths.features,
|
|
1838
|
+
resource,
|
|
1839
|
+
"tests",
|
|
1840
|
+
`${resource}.test.ts`,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
return path.join(config.paths.tests, `${resource}.test.ts`);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function resourceDiagnostic(
|
|
1848
|
+
code: string,
|
|
1849
|
+
resource: string,
|
|
1850
|
+
file: string,
|
|
1851
|
+
): InspectDiagnostic {
|
|
1852
|
+
return {
|
|
1853
|
+
severity: "error",
|
|
1854
|
+
code,
|
|
1855
|
+
file,
|
|
1856
|
+
message: `${resource} appears to be a generated resource slice, but ${file} is missing. Re-run make resource ${resource}, restore the missing file, or remove the partial resource wiring.`,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function nextRoutePath(file: string, routeRoot: string): string {
|
|
1861
|
+
const normalizedRouteRoot = directoryPath(routeRoot);
|
|
1862
|
+
const routeSegments = file
|
|
1863
|
+
.slice(normalizedRouteRoot.length + 1)
|
|
1864
|
+
.replace(/\/route\.ts$/, "")
|
|
1865
|
+
.split("/")
|
|
1866
|
+
.filter(Boolean);
|
|
1867
|
+
const apiPath = routeSegments.map(nextSegmentToContractSegment).join("/");
|
|
1868
|
+
return apiPath ? `/api/${apiPath}` : "/api";
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function nextSegmentToContractSegment(segment: string): string {
|
|
1872
|
+
const catchAll = /^\[\.\.\.(.+)\]$/.exec(segment);
|
|
1873
|
+
if (catchAll) return `:${catchAll[1]}*`;
|
|
1874
|
+
|
|
1875
|
+
const optionalCatchAll = /^\[\[\.\.\.(.+)\]\]$/.exec(segment);
|
|
1876
|
+
if (optionalCatchAll) return `:${optionalCatchAll[1]}*`;
|
|
1877
|
+
|
|
1878
|
+
const dynamic = /^\[(.+)\]$/.exec(segment);
|
|
1879
|
+
if (dynamic) return `:${dynamic[1]}`;
|
|
1880
|
+
|
|
1881
|
+
return segment;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function methodArticle(_method: HttpMethod): "a" {
|
|
1885
|
+
return "a";
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function pascalCase(value: string): string {
|
|
1889
|
+
return value
|
|
1890
|
+
.split(/[-_\s]+/)
|
|
1891
|
+
.filter(Boolean)
|
|
1892
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
1893
|
+
.join("");
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function singularize(value: string): string {
|
|
1897
|
+
if (value.endsWith("ies") && value.length > 3) {
|
|
1898
|
+
return `${value.slice(0, -3)}y`;
|
|
1899
|
+
}
|
|
1900
|
+
if (value.endsWith("ses") && value.length > 3) {
|
|
1901
|
+
return value.slice(0, -2);
|
|
1902
|
+
}
|
|
1903
|
+
if (value.endsWith("s") && value.length > 1) {
|
|
1904
|
+
return value.slice(0, -1);
|
|
1905
|
+
}
|
|
1906
|
+
return value;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function pluralize(value: string): string {
|
|
1910
|
+
if (value.endsWith("y") && value.length > 1) {
|
|
1911
|
+
return `${value.slice(0, -1)}ies`;
|
|
1912
|
+
}
|
|
1913
|
+
if (value.endsWith("s")) {
|
|
1914
|
+
return `${value}es`;
|
|
1915
|
+
}
|
|
1916
|
+
return `${value}s`;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function escapeRegExp(value: string): string {
|
|
1920
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function compareContracts(
|
|
1924
|
+
left: Pick<InspectedContract, "path" | "method" | "exportName">,
|
|
1925
|
+
right: Pick<InspectedContract, "path" | "method" | "exportName">,
|
|
1926
|
+
): number {
|
|
1927
|
+
return (
|
|
1928
|
+
left.path.localeCompare(right.path) ||
|
|
1929
|
+
left.method.localeCompare(right.method) ||
|
|
1930
|
+
left.exportName.localeCompare(right.exportName)
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function contractKey(contract: InspectedContract): string {
|
|
1935
|
+
return `${contract.file}:${contract.exportName}`;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function table(rows: string[][]): string {
|
|
1939
|
+
const widths = rows[0].map((_, columnIndex) =>
|
|
1940
|
+
Math.max(...rows.map((row) => row[columnIndex].length)),
|
|
1941
|
+
);
|
|
1942
|
+
|
|
1943
|
+
return rows
|
|
1944
|
+
.map((row) =>
|
|
1945
|
+
row
|
|
1946
|
+
.map((cell, columnIndex) => cell.padEnd(widths[columnIndex]))
|
|
1947
|
+
.join(" ")
|
|
1948
|
+
.trimEnd(),
|
|
1949
|
+
)
|
|
1950
|
+
.join("\n");
|
|
1951
|
+
}
|