@beignet/cli 0.0.1 → 0.0.4
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 +231 -0
- package/README.md +497 -90
- package/dist/ansi.d.ts +10 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +20 -0
- package/dist/ansi.js.map +1 -0
- package/dist/choices.d.ts +72 -0
- package/dist/choices.d.ts.map +1 -0
- package/dist/choices.js +88 -0
- package/dist/choices.js.map +1 -0
- package/dist/completion.d.ts +47 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +123 -0
- package/dist/completion.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/create-prompts.d.ts +42 -0
- package/dist/create-prompts.d.ts.map +1 -0
- package/dist/create-prompts.js +136 -0
- package/dist/create-prompts.js.map +1 -0
- package/dist/create.d.ts +12 -0
- package/dist/create.d.ts.map +1 -1
- package/dist/create.js +19 -24
- package/dist/create.js.map +1 -1
- package/dist/db.d.ts +37 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +146 -0
- package/dist/db.js.map +1 -0
- package/dist/github-annotations.d.ts +18 -0
- package/dist/github-annotations.d.ts.map +1 -0
- package/dist/github-annotations.js +22 -0
- package/dist/github-annotations.js.map +1 -0
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +710 -400
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +45 -2
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +2191 -100
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +20 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/lib.js.map +1 -0
- package/dist/lint.d.ts +22 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +370 -38
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +109 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +2225 -333
- package/dist/make.js.map +1 -1
- package/dist/outbox.d.ts +24 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +138 -0
- package/dist/outbox.js.map +1 -0
- package/dist/schedule.d.ts +36 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +155 -0
- package/dist/schedule.js.map +1 -0
- package/dist/task.d.ts +26 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +106 -0
- package/dist/task.js.map +1 -0
- package/dist/templates.d.ts +12 -8
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +2144 -385
- package/dist/templates.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/package.json +9 -8
- package/src/ansi.ts +30 -0
- package/src/choices.ts +137 -0
- package/src/completion.ts +169 -0
- package/src/config.ts +47 -0
- package/src/create-prompts.ts +182 -0
- package/src/create.ts +32 -28
- package/src/db.ts +222 -0
- package/src/github-annotations.ts +37 -0
- package/src/index.ts +1119 -535
- package/src/inspect.ts +3372 -134
- package/src/lib.ts +45 -0
- package/src/lint.ts +533 -45
- package/src/make.ts +3010 -397
- package/src/outbox.ts +249 -0
- package/src/schedule.ts +272 -0
- package/src/task.ts +169 -0
- package/src/templates.ts +2282 -462
- package/src/version.ts +20 -0
- package/dist/create-bin.d.ts +0 -3
- package/dist/create-bin.d.ts.map +0 -1
- package/dist/create-bin.js +0 -9
- package/dist/create-bin.js.map +0 -1
- package/src/create-bin.ts +0 -11
package/src/inspect.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
type ProviderPackageMetadata,
|
|
6
|
+
parseProviderPackageMetadata,
|
|
7
|
+
} from "@beignet/core/providers";
|
|
8
|
+
import { createPainter } from "./ansi.js";
|
|
3
9
|
import {
|
|
4
10
|
type BeignetConfig,
|
|
5
11
|
directoryPath,
|
|
@@ -8,6 +14,11 @@ import {
|
|
|
8
14
|
type ResolvedBeignetConfig,
|
|
9
15
|
resolveConfig,
|
|
10
16
|
} from "./config.js";
|
|
17
|
+
import {
|
|
18
|
+
formatGithubAnnotation,
|
|
19
|
+
type GithubAnnotationSeverity,
|
|
20
|
+
} from "./github-annotations.js";
|
|
21
|
+
import { getCliVersion } from "./version.js";
|
|
11
22
|
|
|
12
23
|
type InspectAppOptions = {
|
|
13
24
|
cwd?: string;
|
|
@@ -24,33 +35,62 @@ type HttpMethod =
|
|
|
24
35
|
| "HEAD"
|
|
25
36
|
| "OPTIONS";
|
|
26
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Contract discovered by app inspection.
|
|
40
|
+
*/
|
|
27
41
|
export type InspectedContract = {
|
|
28
42
|
exportName: string;
|
|
29
43
|
file: string;
|
|
30
44
|
method: HttpMethod;
|
|
31
45
|
path: string;
|
|
46
|
+
metadata?: InspectedContractMetadata;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type InspectedContractMetadata = {
|
|
50
|
+
authRequired: boolean;
|
|
51
|
+
authorizationAbility?: string;
|
|
52
|
+
auditRequired: boolean;
|
|
53
|
+
catalogErrors: InspectedCatalogErrorRef[];
|
|
32
54
|
};
|
|
33
55
|
|
|
56
|
+
type InspectedCatalogErrorRef = {
|
|
57
|
+
key: string;
|
|
58
|
+
catalog: string;
|
|
59
|
+
catalogKey: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Route handler mapping discovered by app inspection.
|
|
64
|
+
*/
|
|
34
65
|
export type InspectedRoute = InspectedContract & {
|
|
35
66
|
handlerFile?: string;
|
|
36
67
|
handlerExport?: HttpMethod;
|
|
37
68
|
handlerSource: "next-route" | "route-local" | "missing";
|
|
38
69
|
};
|
|
39
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Doctor diagnostic produced by app inspection.
|
|
73
|
+
*/
|
|
40
74
|
export type InspectDiagnostic = {
|
|
41
|
-
severity: "error" | "warning";
|
|
75
|
+
severity: "error" | "warning" | "hint";
|
|
42
76
|
code: string;
|
|
43
77
|
message: string;
|
|
44
78
|
file?: string;
|
|
45
79
|
contract?: string;
|
|
46
80
|
};
|
|
47
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Automatic doctor fix that was applied.
|
|
84
|
+
*/
|
|
48
85
|
export type InspectFix = {
|
|
49
86
|
code: string;
|
|
50
87
|
message: string;
|
|
51
88
|
file: string;
|
|
52
89
|
};
|
|
53
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Detected app convention and missing convention files.
|
|
93
|
+
*/
|
|
54
94
|
export type InspectConvention = {
|
|
55
95
|
kind: "standard" | "next" | "custom";
|
|
56
96
|
nextLayout: boolean;
|
|
@@ -60,7 +100,11 @@ export type InspectConvention = {
|
|
|
60
100
|
missingResourceGenerator: string[];
|
|
61
101
|
};
|
|
62
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Full app inspection result.
|
|
105
|
+
*/
|
|
63
106
|
export type InspectAppResult = {
|
|
107
|
+
schemaVersion: 1;
|
|
64
108
|
targetDir: string;
|
|
65
109
|
config: ResolvedBeignetConfig;
|
|
66
110
|
strict: boolean;
|
|
@@ -138,6 +182,7 @@ export async function inspectApp(
|
|
|
138
182
|
);
|
|
139
183
|
|
|
140
184
|
return {
|
|
185
|
+
schemaVersion: 1,
|
|
141
186
|
targetDir,
|
|
142
187
|
config,
|
|
143
188
|
strict: Boolean(options.strict),
|
|
@@ -161,21 +206,41 @@ export async function applyDoctorFixes(
|
|
|
161
206
|
: await loadBeignetConfig(targetDir, files);
|
|
162
207
|
const convention = inspectConvention(files, config);
|
|
163
208
|
const contracts = await readContracts(targetDir, files, config);
|
|
209
|
+
const routeFiles = files.filter(
|
|
210
|
+
(file) =>
|
|
211
|
+
file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
|
|
212
|
+
file.endsWith("/route.ts"),
|
|
213
|
+
);
|
|
214
|
+
const routeExports = await readRouteExports(targetDir, routeFiles, config);
|
|
215
|
+
const matchedRoutes = matchRoutes(contracts, routeExports);
|
|
164
216
|
const fixes: InspectFix[] = [];
|
|
165
217
|
|
|
166
218
|
const packageFix = await fixMissingTestScript(targetDir, files, convention);
|
|
167
219
|
if (packageFix) fixes.push(packageFix);
|
|
168
220
|
|
|
221
|
+
const routeGroupFix = await fixUnregisteredRouteGroups(
|
|
222
|
+
targetDir,
|
|
223
|
+
files,
|
|
224
|
+
config,
|
|
225
|
+
);
|
|
226
|
+
if (routeGroupFix) fixes.push(routeGroupFix);
|
|
227
|
+
|
|
169
228
|
const openApiFix = await fixDirectOpenApiArrayDrift(
|
|
170
229
|
targetDir,
|
|
230
|
+
files,
|
|
171
231
|
config,
|
|
232
|
+
convention,
|
|
172
233
|
contracts,
|
|
234
|
+
matchedRoutes,
|
|
173
235
|
);
|
|
174
236
|
if (openApiFix) fixes.push(openApiFix);
|
|
175
237
|
|
|
176
238
|
return fixes;
|
|
177
239
|
}
|
|
178
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Format inspected routes as a CLI table.
|
|
243
|
+
*/
|
|
179
244
|
export function formatRoutes(result: InspectAppResult): string {
|
|
180
245
|
if (result.routes.length === 0) {
|
|
181
246
|
return `No Beignet routes found in ${result.targetDir}`;
|
|
@@ -197,7 +262,14 @@ export function formatRoutes(result: InspectAppResult): string {
|
|
|
197
262
|
]);
|
|
198
263
|
}
|
|
199
264
|
|
|
200
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Format doctor diagnostics and applied fixes for CLI output.
|
|
267
|
+
*/
|
|
268
|
+
export function formatDoctor(
|
|
269
|
+
result: InspectAppResult,
|
|
270
|
+
options: { color?: boolean } = {},
|
|
271
|
+
): string {
|
|
272
|
+
const paint = createPainter(options.color);
|
|
201
273
|
const fixLines =
|
|
202
274
|
result.fixes.length === 0
|
|
203
275
|
? []
|
|
@@ -207,7 +279,7 @@ export function formatDoctor(result: InspectAppResult): string {
|
|
|
207
279
|
}:`,
|
|
208
280
|
"",
|
|
209
281
|
...result.fixes.map(
|
|
210
|
-
(fix) =>
|
|
282
|
+
(fix) => `${paint("FIXED", "green")} ${fix.code} ${fix.file}
|
|
211
283
|
${fix.message}`,
|
|
212
284
|
),
|
|
213
285
|
"",
|
|
@@ -219,18 +291,80 @@ export function formatDoctor(result: InspectAppResult): string {
|
|
|
219
291
|
);
|
|
220
292
|
}
|
|
221
293
|
|
|
222
|
-
|
|
294
|
+
const groups = [
|
|
295
|
+
{
|
|
296
|
+
severity: "error",
|
|
297
|
+
header: "Errors",
|
|
298
|
+
label: paint("ERROR", "red"),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
severity: "warning",
|
|
302
|
+
header: "Warnings",
|
|
303
|
+
label: paint("WARNING", "yellow"),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
severity: "hint",
|
|
307
|
+
header: "Hints",
|
|
308
|
+
label: paint("HINT", "cyan"),
|
|
309
|
+
},
|
|
310
|
+
] as const;
|
|
311
|
+
|
|
312
|
+
const lines = [
|
|
223
313
|
...fixLines,
|
|
224
314
|
`Found ${result.diagnostics.length} Beignet issue${
|
|
225
315
|
result.diagnostics.length === 1 ? "" : "s"
|
|
226
316
|
} in ${result.targetDir}:`,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
for (const group of groups) {
|
|
320
|
+
const diagnostics = result.diagnostics.filter(
|
|
321
|
+
(diagnostic) => diagnostic.severity === group.severity,
|
|
322
|
+
);
|
|
323
|
+
if (diagnostics.length === 0) continue;
|
|
324
|
+
|
|
325
|
+
lines.push("", paint(`${group.header}:`, "bold"), "");
|
|
326
|
+
lines.push(
|
|
327
|
+
...diagnostics.map((diagnostic) => {
|
|
328
|
+
const location = diagnostic.file ? ` ${diagnostic.file}` : "";
|
|
329
|
+
return `${group.label} ${diagnostic.code}${location}
|
|
231
330
|
${diagnostic.message}`;
|
|
232
|
-
|
|
233
|
-
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
lines.push("", formatDoctorFooter(result));
|
|
336
|
+
return lines.join("\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatDoctorFooter(result: InspectAppResult): string {
|
|
340
|
+
const count = (severity: InspectDiagnostic["severity"]) =>
|
|
341
|
+
result.diagnostics.filter((diagnostic) => diagnostic.severity === severity)
|
|
342
|
+
.length;
|
|
343
|
+
const footer = `${count("error")} error(s), ${count("warning")} warning(s), ${count("hint")} hint(s)`;
|
|
344
|
+
|
|
345
|
+
return result.strict ? `${footer} (strict: warnings fail)` : footer;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Format doctor diagnostics as GitHub Actions annotations.
|
|
350
|
+
*/
|
|
351
|
+
export function formatDoctorGithub(result: InspectAppResult): string {
|
|
352
|
+
return result.diagnostics
|
|
353
|
+
.map((diagnostic) =>
|
|
354
|
+
formatGithubAnnotation({
|
|
355
|
+
severity: githubAnnotationSeverity(diagnostic.severity),
|
|
356
|
+
file: diagnostic.file,
|
|
357
|
+
message: `${diagnostic.code}: ${diagnostic.message}`,
|
|
358
|
+
}),
|
|
359
|
+
)
|
|
360
|
+
.join("\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function githubAnnotationSeverity(
|
|
364
|
+
severity: InspectDiagnostic["severity"],
|
|
365
|
+
): GithubAnnotationSeverity {
|
|
366
|
+
if (severity === "hint") return "notice";
|
|
367
|
+
return severity;
|
|
234
368
|
}
|
|
235
369
|
|
|
236
370
|
async function assertDirectory(targetDir: string): Promise<void> {
|
|
@@ -344,13 +478,14 @@ function hasRequirement(files: string[], requirement: string): boolean {
|
|
|
344
478
|
function parseContracts(source: string, file: string): InspectedContract[] {
|
|
345
479
|
const contracts: InspectedContract[] = [];
|
|
346
480
|
const groupPrefixes = parseContractGroupPrefixes(source);
|
|
481
|
+
const groupErrorRefs = parseContractGroupErrorRefs(source);
|
|
347
482
|
const exportRegex =
|
|
348
483
|
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
|
|
349
484
|
|
|
350
485
|
for (const exportMatch of source.matchAll(exportRegex)) {
|
|
351
486
|
const block = exportMatch[2];
|
|
352
487
|
const methodMatch =
|
|
353
|
-
/([A-Za-z_$][\w$]*)\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(
|
|
488
|
+
/([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(
|
|
354
489
|
block,
|
|
355
490
|
);
|
|
356
491
|
if (methodMatch) {
|
|
@@ -361,6 +496,10 @@ function parseContracts(source: string, file: string): InspectedContract[] {
|
|
|
361
496
|
contracts.push({
|
|
362
497
|
exportName: exportMatch[1],
|
|
363
498
|
file,
|
|
499
|
+
metadata: parseContractMetadata(
|
|
500
|
+
block,
|
|
501
|
+
groupErrorRefs.get(receiver) ?? [],
|
|
502
|
+
),
|
|
364
503
|
method: methodMatch[2].toUpperCase() as HttpMethod,
|
|
365
504
|
path: routePath,
|
|
366
505
|
});
|
|
@@ -372,6 +511,7 @@ function parseContracts(source: string, file: string): InspectedContract[] {
|
|
|
372
511
|
contracts.push({
|
|
373
512
|
exportName: exportMatch[1],
|
|
374
513
|
file,
|
|
514
|
+
metadata: parseContractMetadata(block),
|
|
375
515
|
method: directContract.method,
|
|
376
516
|
path: normalizeContractPath(directContract.path),
|
|
377
517
|
});
|
|
@@ -381,10 +521,44 @@ function parseContracts(source: string, file: string): InspectedContract[] {
|
|
|
381
521
|
return contracts;
|
|
382
522
|
}
|
|
383
523
|
|
|
524
|
+
function parseContractMetadata(
|
|
525
|
+
block: string,
|
|
526
|
+
inheritedCatalogErrors: InspectedCatalogErrorRef[] = [],
|
|
527
|
+
): InspectedContractMetadata | undefined {
|
|
528
|
+
const authRequired = /\bauth\s*:\s*["']required["']/.test(block);
|
|
529
|
+
const authorizationAbility =
|
|
530
|
+
/\bauthorization\s*:\s*\{[\s\S]*?\bability\s*:\s*["']([^"']+)["']/.exec(
|
|
531
|
+
block,
|
|
532
|
+
)?.[1];
|
|
533
|
+
const auditRequired =
|
|
534
|
+
/\baudit\s*:\s*["']required["']/.test(block) ||
|
|
535
|
+
/\bauditRequired\s*:\s*true\b/.test(block);
|
|
536
|
+
const catalogErrors = dedupeCatalogErrorRefs([
|
|
537
|
+
...inheritedCatalogErrors,
|
|
538
|
+
...parseCatalogErrorRefs(block),
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
if (
|
|
542
|
+
!authRequired &&
|
|
543
|
+
!authorizationAbility &&
|
|
544
|
+
!auditRequired &&
|
|
545
|
+
catalogErrors.length === 0
|
|
546
|
+
) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
authRequired,
|
|
552
|
+
authorizationAbility,
|
|
553
|
+
auditRequired,
|
|
554
|
+
catalogErrors,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
384
558
|
function parseDirectContract(
|
|
385
559
|
block: string,
|
|
386
560
|
): { method: HttpMethod; path: string } | undefined {
|
|
387
|
-
const configMatch = /
|
|
561
|
+
const configMatch = /defineContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
|
|
388
562
|
if (!configMatch) return undefined;
|
|
389
563
|
|
|
390
564
|
const configSource = configMatch[1];
|
|
@@ -436,11 +610,59 @@ function parseContractGroupPrefixes(source: string): Map<string, string> {
|
|
|
436
610
|
return prefixes;
|
|
437
611
|
}
|
|
438
612
|
|
|
613
|
+
function parseContractGroupErrorRefs(
|
|
614
|
+
source: string,
|
|
615
|
+
): Map<string, InspectedCatalogErrorRef[]> {
|
|
616
|
+
const assignments = [
|
|
617
|
+
...source.matchAll(
|
|
618
|
+
/(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g,
|
|
619
|
+
),
|
|
620
|
+
].map((match) => ({
|
|
621
|
+
name: match[1],
|
|
622
|
+
expression: match[2],
|
|
623
|
+
}));
|
|
624
|
+
const groupErrors = new Map<string, InspectedCatalogErrorRef[]>();
|
|
625
|
+
|
|
626
|
+
let changed = true;
|
|
627
|
+
while (changed) {
|
|
628
|
+
changed = false;
|
|
629
|
+
for (const assignment of assignments) {
|
|
630
|
+
if (groupErrors.has(assignment.name)) continue;
|
|
631
|
+
|
|
632
|
+
const baseErrors = baseGroupErrors(assignment.expression, groupErrors);
|
|
633
|
+
if (baseErrors === undefined) continue;
|
|
634
|
+
|
|
635
|
+
groupErrors.set(
|
|
636
|
+
assignment.name,
|
|
637
|
+
dedupeCatalogErrorRefs([
|
|
638
|
+
...baseErrors,
|
|
639
|
+
...parseCatalogErrorRefs(assignment.expression),
|
|
640
|
+
]),
|
|
641
|
+
);
|
|
642
|
+
changed = true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return groupErrors;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function baseGroupErrors(
|
|
650
|
+
expression: string,
|
|
651
|
+
groupErrors: Map<string, InspectedCatalogErrorRef[]>,
|
|
652
|
+
): InspectedCatalogErrorRef[] | undefined {
|
|
653
|
+
if (expression.includes("defineContractGroup(")) return [];
|
|
654
|
+
|
|
655
|
+
const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
|
|
656
|
+
if (!baseMatch) return undefined;
|
|
657
|
+
|
|
658
|
+
return groupErrors.get(baseMatch[1]);
|
|
659
|
+
}
|
|
660
|
+
|
|
439
661
|
function baseGroupPrefix(
|
|
440
662
|
expression: string,
|
|
441
663
|
prefixes: Map<string, string>,
|
|
442
664
|
): string | undefined {
|
|
443
|
-
if (expression.includes("
|
|
665
|
+
if (expression.includes("defineContractGroup(")) return "";
|
|
444
666
|
|
|
445
667
|
const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
|
|
446
668
|
if (!baseMatch) return undefined;
|
|
@@ -454,6 +676,184 @@ function prefixCalls(expression: string): string[] {
|
|
|
454
676
|
);
|
|
455
677
|
}
|
|
456
678
|
|
|
679
|
+
function parseCatalogErrorRefs(source: string): InspectedCatalogErrorRef[] {
|
|
680
|
+
return dedupeCatalogErrorRefs(
|
|
681
|
+
objectArgumentBodies(source, /\.errors\s*\(/g).flatMap((body) =>
|
|
682
|
+
[...body.matchAll(errorCatalogRefRegex)].map((match) => ({
|
|
683
|
+
key: match[1],
|
|
684
|
+
catalog: match[2],
|
|
685
|
+
catalogKey: match[3],
|
|
686
|
+
})),
|
|
687
|
+
),
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const errorCatalogRefRegex =
|
|
692
|
+
/(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)/g;
|
|
693
|
+
|
|
694
|
+
function parseErrorCatalogKeys(source: string): Set<string> {
|
|
695
|
+
const keys = new Set<string>();
|
|
696
|
+
const catalogBodies = objectArgumentBodies(source, /\bdefineErrors\s*\(/g);
|
|
697
|
+
|
|
698
|
+
for (const body of catalogBodies) {
|
|
699
|
+
if (/\.\.\.\s*httpErrors\b/.test(body)) {
|
|
700
|
+
for (const key of httpErrorCatalogKeys) keys.add(key);
|
|
701
|
+
}
|
|
702
|
+
for (const key of topLevelObjectKeys(body)) keys.add(key);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return keys;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const httpErrorCatalogKeys = [
|
|
709
|
+
"BadRequest",
|
|
710
|
+
"Unauthorized",
|
|
711
|
+
"Forbidden",
|
|
712
|
+
"NotFound",
|
|
713
|
+
"Conflict",
|
|
714
|
+
"IdempotencyConflict",
|
|
715
|
+
"IdempotencyInProgress",
|
|
716
|
+
"TooManyRequests",
|
|
717
|
+
"UnprocessableEntity",
|
|
718
|
+
"ValidationError",
|
|
719
|
+
"InternalServerError",
|
|
720
|
+
] as const;
|
|
721
|
+
|
|
722
|
+
function objectArgumentBodies(source: string, callPattern: RegExp): string[] {
|
|
723
|
+
const bodies: string[] = [];
|
|
724
|
+
const pattern = new RegExp(callPattern.source, callPattern.flags);
|
|
725
|
+
|
|
726
|
+
for (const match of source.matchAll(pattern)) {
|
|
727
|
+
const callIndex = match.index ?? 0;
|
|
728
|
+
const openParen = source.indexOf("(", callIndex);
|
|
729
|
+
if (openParen === -1) continue;
|
|
730
|
+
|
|
731
|
+
const objectStart = source.indexOf("{", openParen);
|
|
732
|
+
if (objectStart === -1) continue;
|
|
733
|
+
|
|
734
|
+
const objectSource = balancedObjectSource(source, objectStart);
|
|
735
|
+
if (!objectSource) continue;
|
|
736
|
+
|
|
737
|
+
bodies.push(objectSource.slice(1, -1));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return bodies;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function balancedObjectSource(
|
|
744
|
+
source: string,
|
|
745
|
+
startIndex: number,
|
|
746
|
+
): string | undefined {
|
|
747
|
+
if (source[startIndex] !== "{") return undefined;
|
|
748
|
+
|
|
749
|
+
let depth = 0;
|
|
750
|
+
let quote: '"' | "'" | "`" | undefined;
|
|
751
|
+
let escaped = false;
|
|
752
|
+
|
|
753
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
754
|
+
const char = source[index];
|
|
755
|
+
|
|
756
|
+
if (quote) {
|
|
757
|
+
if (escaped) {
|
|
758
|
+
escaped = false;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (char === "\\") {
|
|
762
|
+
escaped = true;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (char === quote) quote = undefined;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
770
|
+
quote = char;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (char === "{") {
|
|
775
|
+
depth += 1;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (char === "}") {
|
|
780
|
+
depth -= 1;
|
|
781
|
+
if (depth === 0) return source.slice(startIndex, index + 1);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function topLevelObjectKeys(body: string): string[] {
|
|
789
|
+
const keys: string[] = [];
|
|
790
|
+
let depth = 0;
|
|
791
|
+
let quote: '"' | "'" | "`" | undefined;
|
|
792
|
+
let escaped = false;
|
|
793
|
+
|
|
794
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
795
|
+
const char = body[index];
|
|
796
|
+
|
|
797
|
+
if (quote) {
|
|
798
|
+
if (escaped) {
|
|
799
|
+
escaped = false;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (char === "\\") {
|
|
803
|
+
escaped = true;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (char === quote) quote = undefined;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
811
|
+
quote = char;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (char === "{" || char === "[" || char === "(") {
|
|
816
|
+
depth += 1;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (char === "}" || char === "]" || char === ")") {
|
|
821
|
+
depth = Math.max(0, depth - 1);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (depth !== 0) continue;
|
|
826
|
+
|
|
827
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(body.slice(index));
|
|
828
|
+
if (!identifier) continue;
|
|
829
|
+
|
|
830
|
+
const name = identifier[0];
|
|
831
|
+
const next = body.slice(index + name.length).match(/^\s*:/);
|
|
832
|
+
if (next) keys.push(name);
|
|
833
|
+
|
|
834
|
+
index += name.length - 1;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return keys;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function dedupeCatalogErrorRefs(
|
|
841
|
+
refs: InspectedCatalogErrorRef[],
|
|
842
|
+
): InspectedCatalogErrorRef[] {
|
|
843
|
+
const seen = new Set<string>();
|
|
844
|
+
const deduped: InspectedCatalogErrorRef[] = [];
|
|
845
|
+
|
|
846
|
+
for (const ref of refs) {
|
|
847
|
+
const key = `${ref.key}:${ref.catalog}:${ref.catalogKey}`;
|
|
848
|
+
if (seen.has(key)) continue;
|
|
849
|
+
|
|
850
|
+
seen.add(key);
|
|
851
|
+
deduped.push(ref);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return deduped;
|
|
855
|
+
}
|
|
856
|
+
|
|
457
857
|
function joinContractPaths(prefix: string, routePath: string): string {
|
|
458
858
|
if (!prefix) return normalizeContractPath(routePath);
|
|
459
859
|
const segments = [
|
|
@@ -577,6 +977,30 @@ function parseNamedImports(
|
|
|
577
977
|
return imports;
|
|
578
978
|
}
|
|
579
979
|
|
|
980
|
+
function parseNamedImportSources(
|
|
981
|
+
source: string,
|
|
982
|
+
): Map<string, { importedName: string; sourcePath: string }> {
|
|
983
|
+
const imports = new Map<
|
|
984
|
+
string,
|
|
985
|
+
{ importedName: string; sourcePath: string }
|
|
986
|
+
>();
|
|
987
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
988
|
+
|
|
989
|
+
for (const match of source.matchAll(importRegex)) {
|
|
990
|
+
for (const member of match[1].split(",")) {
|
|
991
|
+
const parsed = parseImportMember(member);
|
|
992
|
+
if (!parsed) continue;
|
|
993
|
+
|
|
994
|
+
imports.set(parsed.localName, {
|
|
995
|
+
importedName: parsed.importedName,
|
|
996
|
+
sourcePath: match[2],
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return imports;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
580
1004
|
function parseImportMember(
|
|
581
1005
|
member: string,
|
|
582
1006
|
): { importedName: string; localName: string } | undefined {
|
|
@@ -603,6 +1027,33 @@ function parseImportMember(
|
|
|
603
1027
|
return undefined;
|
|
604
1028
|
}
|
|
605
1029
|
|
|
1030
|
+
function sourceFileFromImport(
|
|
1031
|
+
sourcePath: string,
|
|
1032
|
+
importerFile = "index.ts",
|
|
1033
|
+
files?: string[],
|
|
1034
|
+
): string | undefined {
|
|
1035
|
+
const resolveCandidate = (candidate: string) => {
|
|
1036
|
+
if (path.extname(candidate)) return candidate;
|
|
1037
|
+
|
|
1038
|
+
const candidates = [`${candidate}.ts`, `${candidate}/index.ts`];
|
|
1039
|
+
return files
|
|
1040
|
+
? candidates.find((file) => files.includes(file))
|
|
1041
|
+
: candidates[0];
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
if (sourcePath.startsWith("@/")) {
|
|
1045
|
+
return resolveCandidate(sourcePath.slice("@/".length));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (sourcePath.startsWith(".")) {
|
|
1049
|
+
return resolveCandidate(
|
|
1050
|
+
normalizePath(path.join(path.dirname(importerFile), sourcePath)),
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return undefined;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
606
1057
|
function contractFileFromImport(
|
|
607
1058
|
sourcePath: string,
|
|
608
1059
|
config: ResolvedBeignetConfig,
|
|
@@ -766,7 +1217,7 @@ async function inspectDiagnostics(
|
|
|
766
1217
|
if (!convention.nextLayout) {
|
|
767
1218
|
diagnostics.push({
|
|
768
1219
|
severity: "warning",
|
|
769
|
-
code: "
|
|
1220
|
+
code: "BEIGNET_APP_LAYOUT_NOT_FOUND",
|
|
770
1221
|
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
1222
|
});
|
|
772
1223
|
}
|
|
@@ -775,7 +1226,7 @@ async function inspectDiagnostics(
|
|
|
775
1226
|
if (route.handlerSource !== "missing") continue;
|
|
776
1227
|
diagnostics.push({
|
|
777
1228
|
severity: "error",
|
|
778
|
-
code: "
|
|
1229
|
+
code: "BEIGNET_ROUTE_MISSING",
|
|
779
1230
|
file: route.file,
|
|
780
1231
|
contract: route.exportName,
|
|
781
1232
|
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.`,
|
|
@@ -788,7 +1239,14 @@ async function inspectDiagnostics(
|
|
|
788
1239
|
|
|
789
1240
|
diagnostics.push(
|
|
790
1241
|
...(await inspectPackageScripts(targetDir, files, convention)),
|
|
791
|
-
...(await inspectOpenApiDrift(
|
|
1242
|
+
...(await inspectOpenApiDrift(
|
|
1243
|
+
targetDir,
|
|
1244
|
+
files,
|
|
1245
|
+
config,
|
|
1246
|
+
convention,
|
|
1247
|
+
contracts,
|
|
1248
|
+
matchedRoutes,
|
|
1249
|
+
)),
|
|
792
1250
|
...(await inspectFeatureRouteRegistration(
|
|
793
1251
|
targetDir,
|
|
794
1252
|
files,
|
|
@@ -796,127 +1254,2121 @@ async function inspectDiagnostics(
|
|
|
796
1254
|
convention,
|
|
797
1255
|
contracts,
|
|
798
1256
|
)),
|
|
1257
|
+
...(await inspectRouteErrorCatalogDrift(
|
|
1258
|
+
targetDir,
|
|
1259
|
+
files,
|
|
1260
|
+
config,
|
|
1261
|
+
convention,
|
|
1262
|
+
contracts,
|
|
1263
|
+
strict,
|
|
1264
|
+
)),
|
|
1265
|
+
...(await inspectCanonicalConformance(
|
|
1266
|
+
targetDir,
|
|
1267
|
+
files,
|
|
1268
|
+
config,
|
|
1269
|
+
convention,
|
|
1270
|
+
strict,
|
|
1271
|
+
)),
|
|
1272
|
+
...(await inspectVersionSkew(targetDir, files)),
|
|
1273
|
+
...(await inspectProviderMetadataDrift(targetDir, files, convention)),
|
|
799
1274
|
...(await inspectPortProviderDrift(targetDir, files, config, convention)),
|
|
800
|
-
...(await
|
|
1275
|
+
...(await inspectProviderRegistrationDrift(
|
|
1276
|
+
targetDir,
|
|
1277
|
+
files,
|
|
1278
|
+
config,
|
|
1279
|
+
convention,
|
|
1280
|
+
)),
|
|
1281
|
+
...(await inspectDatabaseLifecycleDrift(
|
|
801
1282
|
targetDir,
|
|
802
1283
|
files,
|
|
803
1284
|
config,
|
|
804
1285
|
convention,
|
|
805
1286
|
strict,
|
|
806
1287
|
)),
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1288
|
+
...(await inspectAuthzAuditDrift(
|
|
1289
|
+
targetDir,
|
|
1290
|
+
files,
|
|
1291
|
+
config,
|
|
1292
|
+
convention,
|
|
1293
|
+
contracts,
|
|
1294
|
+
)),
|
|
1295
|
+
...(await inspectServerlessFootguns(targetDir, files)),
|
|
1296
|
+
...(await inspectProductionReadiness(targetDir, files, config, convention)),
|
|
1297
|
+
...(await inspectFeatureArtifactDrift(
|
|
1298
|
+
targetDir,
|
|
1299
|
+
files,
|
|
1300
|
+
config,
|
|
1301
|
+
convention,
|
|
1302
|
+
)),
|
|
1303
|
+
...(await inspectResourceSlices(
|
|
1304
|
+
targetDir,
|
|
1305
|
+
files,
|
|
1306
|
+
config,
|
|
1307
|
+
convention,
|
|
1308
|
+
strict,
|
|
1309
|
+
)),
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
return dedupeDiagnostics(diagnostics);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function inspectCanonicalConformance(
|
|
1316
|
+
targetDir: string,
|
|
1317
|
+
files: string[],
|
|
1318
|
+
config: ResolvedBeignetConfig,
|
|
1319
|
+
convention: InspectConvention,
|
|
1320
|
+
strict: boolean,
|
|
1321
|
+
): Promise<InspectDiagnostic[]> {
|
|
1322
|
+
if (!strict || !convention.resourceGenerator) return [];
|
|
1323
|
+
|
|
1324
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1325
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
1326
|
+
const routeRegistryFile = `${serverDir}/routes.ts`;
|
|
1327
|
+
const providerRegistryFile = `${serverDir}/providers.ts`;
|
|
1328
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1329
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
1330
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1331
|
+
const hasClientSurface =
|
|
1332
|
+
files.some(
|
|
1333
|
+
(file) =>
|
|
1334
|
+
file.startsWith(`${featuresPath}/`) && file.includes("/components/"),
|
|
1335
|
+
) ||
|
|
1336
|
+
files.some((file) => file.startsWith("client/")) ||
|
|
1337
|
+
[
|
|
1338
|
+
"@beignet/react-query",
|
|
1339
|
+
"@beignet/react-hook-form",
|
|
1340
|
+
"@beignet/react-uploads",
|
|
1341
|
+
"@beignet/nuqs",
|
|
1342
|
+
].some((packageName) => installedPackages.has(packageName));
|
|
1343
|
+
|
|
1344
|
+
const requiredFiles = [
|
|
1345
|
+
{
|
|
1346
|
+
file: config.paths.appContext,
|
|
1347
|
+
reason:
|
|
1348
|
+
"Beignet apps should define the shared AppContext type once in app-context.ts.",
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
file: config.paths.server,
|
|
1352
|
+
reason:
|
|
1353
|
+
"server/index.ts should compose ports, providers, hooks, routes, and request context.",
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
file: routeRegistryFile,
|
|
1357
|
+
reason:
|
|
1358
|
+
"server/routes.ts should own the central route registry and exported contract list.",
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
file: providerRegistryFile,
|
|
1362
|
+
reason:
|
|
1363
|
+
"server/providers.ts should own provider registration, even when the provider list is small.",
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
file: config.paths.infrastructurePorts,
|
|
1367
|
+
reason:
|
|
1368
|
+
"infra/app-ports.ts should wire concrete app ports for the selected runtime.",
|
|
1369
|
+
},
|
|
1370
|
+
];
|
|
1371
|
+
|
|
1372
|
+
if (hasClientSurface) {
|
|
1373
|
+
requiredFiles.push({
|
|
1374
|
+
file: "client/index.ts",
|
|
1375
|
+
reason:
|
|
1376
|
+
"client/index.ts should own the typed client and frontend adapter setup.",
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (
|
|
1381
|
+
installedPackages.has("@beignet/react-hook-form") ||
|
|
1382
|
+
files.includes("client/forms.ts")
|
|
1383
|
+
) {
|
|
1384
|
+
requiredFiles.push({
|
|
1385
|
+
file: "client/forms.ts",
|
|
1386
|
+
reason:
|
|
1387
|
+
"client/forms.ts should own React Hook Form adapter setup when form helpers are used.",
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
for (const required of requiredFiles) {
|
|
1392
|
+
if (files.includes(required.file)) continue;
|
|
1393
|
+
diagnostics.push({
|
|
1394
|
+
severity: "warning",
|
|
1395
|
+
code: "BEIGNET_CANONICAL_FILE_MISSING",
|
|
1396
|
+
file: required.file,
|
|
1397
|
+
message: `${required.file} is missing. ${required.reason}`,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const sourceCache = new Map<string, string>();
|
|
1402
|
+
const appContextSource = files.includes(config.paths.appContext)
|
|
1403
|
+
? await readCachedSource(targetDir, config.paths.appContext, sourceCache)
|
|
1404
|
+
: "";
|
|
1405
|
+
const expectedContextFields = canonicalContextFields(appContextSource);
|
|
1406
|
+
|
|
1407
|
+
if (files.includes(routeRegistryFile)) {
|
|
1408
|
+
const routeRegistrySource = await readCachedSource(
|
|
1409
|
+
targetDir,
|
|
1410
|
+
routeRegistryFile,
|
|
1411
|
+
sourceCache,
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
if (
|
|
1415
|
+
!importsAppContext(routeRegistrySource, routeRegistryFile, config, files)
|
|
1416
|
+
) {
|
|
1417
|
+
diagnostics.push({
|
|
1418
|
+
severity: "warning",
|
|
1419
|
+
code: "BEIGNET_ROUTE_REGISTRY_APP_CONTEXT",
|
|
1420
|
+
file: routeRegistryFile,
|
|
1421
|
+
message: `${routeRegistryFile} should import type { AppContext } from ${config.paths.appContext} so the central route registry uses the app-wide context type.`,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (
|
|
1426
|
+
!/\bexport\s+const\s+routes\s*=\s*defineRoutes\s*<\s*AppContext\s*>/.test(
|
|
1427
|
+
routeRegistrySource,
|
|
1428
|
+
)
|
|
1429
|
+
) {
|
|
1430
|
+
diagnostics.push({
|
|
1431
|
+
severity: "warning",
|
|
1432
|
+
code: "BEIGNET_ROUTE_REGISTRY_ROUTES_EXPORT",
|
|
1433
|
+
file: routeRegistryFile,
|
|
1434
|
+
message: `${routeRegistryFile} should export routes with defineRoutes<AppContext>([...]) so route registration follows the canonical Beignet path.`,
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (
|
|
1439
|
+
!/\bexport\s+const\s+contracts\s*=\s*contractsFromRoutes\s*\(\s*routes\s*\)/.test(
|
|
1440
|
+
routeRegistrySource,
|
|
1441
|
+
)
|
|
1442
|
+
) {
|
|
1443
|
+
diagnostics.push({
|
|
1444
|
+
severity: "warning",
|
|
1445
|
+
code: "BEIGNET_ROUTE_REGISTRY_CONTRACTS_EXPORT",
|
|
1446
|
+
file: routeRegistryFile,
|
|
1447
|
+
message: `${routeRegistryFile} should export contracts = contractsFromRoutes(routes) so OpenAPI, typed clients, and devtools derive from the central route registry.`,
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (files.includes(config.paths.server)) {
|
|
1453
|
+
const serverSource = await readCachedSource(
|
|
1454
|
+
targetDir,
|
|
1455
|
+
config.paths.server,
|
|
1456
|
+
sourceCache,
|
|
1457
|
+
);
|
|
1458
|
+
const importedRouteRegistry = routeRegistryFileFromServerSource(
|
|
1459
|
+
serverSource,
|
|
1460
|
+
config.paths.server,
|
|
1461
|
+
files,
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1464
|
+
if (importedRouteRegistry !== routeRegistryFile) {
|
|
1465
|
+
diagnostics.push({
|
|
1466
|
+
severity: "warning",
|
|
1467
|
+
code: "BEIGNET_SERVER_ROUTE_REGISTRY",
|
|
1468
|
+
file: config.paths.server,
|
|
1469
|
+
message: `${config.paths.server} should import routes from ./${path.basename(routeRegistryFile, ".ts")} so server setup uses the central route registry.`,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (!importsAppContext(serverSource, config.paths.server, config, files)) {
|
|
1474
|
+
diagnostics.push({
|
|
1475
|
+
severity: "warning",
|
|
1476
|
+
code: "BEIGNET_SERVER_APP_CONTEXT",
|
|
1477
|
+
file: config.paths.server,
|
|
1478
|
+
message: `${config.paths.server} should import type { AppContext } from ${config.paths.appContext} and thread that context through createNextServer and hooks.`,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Apps may keep the context blueprint in a sibling server/context.ts
|
|
1483
|
+
// (defineServerContext) instead of inline in the server file.
|
|
1484
|
+
const serverContextFile = `${serverDir}/context.ts`;
|
|
1485
|
+
const serverContextSource = files.includes(serverContextFile)
|
|
1486
|
+
? await readCachedSource(targetDir, serverContextFile, sourceCache)
|
|
1487
|
+
: "";
|
|
1488
|
+
const contextBlueprintFile = files.includes(serverContextFile)
|
|
1489
|
+
? serverContextFile
|
|
1490
|
+
: config.paths.server;
|
|
1491
|
+
const contextBlueprintSource = `${serverSource}\n${serverContextSource}`;
|
|
1492
|
+
|
|
1493
|
+
for (const field of expectedContextFields) {
|
|
1494
|
+
if (
|
|
1495
|
+
new RegExp(`\\b${escapeRegExp(field)}\\s*[:,]`).test(
|
|
1496
|
+
contextBlueprintSource,
|
|
1497
|
+
)
|
|
1498
|
+
) {
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
diagnostics.push({
|
|
1502
|
+
severity: "warning",
|
|
1503
|
+
code: "BEIGNET_CONTEXT_FIELD_MISSING",
|
|
1504
|
+
file: contextBlueprintFile,
|
|
1505
|
+
message: `${contextBlueprintFile} context factories do not appear to return ${field}. Keep the canonical AppContext shape aligned with app-context.ts.`,
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const featureRoutesPath = `${featuresPath}/`;
|
|
1511
|
+
for (const file of files) {
|
|
1512
|
+
if (!file.startsWith(featureRoutesPath) || !file.endsWith("/routes.ts")) {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1517
|
+
if (!source.includes("defineRouteGroup")) continue;
|
|
1518
|
+
|
|
1519
|
+
if (!importsAppContext(source, file, config, files)) {
|
|
1520
|
+
diagnostics.push({
|
|
1521
|
+
severity: "warning",
|
|
1522
|
+
code: "BEIGNET_FEATURE_ROUTE_APP_CONTEXT",
|
|
1523
|
+
file,
|
|
1524
|
+
message: `${file} should import type { AppContext } from ${config.paths.appContext} so feature routes use the app-wide context type.`,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (!/defineRouteGroup\s*<\s*AppContext\s*>/.test(source)) {
|
|
1529
|
+
diagnostics.push({
|
|
1530
|
+
severity: "warning",
|
|
1531
|
+
code: "BEIGNET_FEATURE_ROUTE_GROUP_CONTEXT",
|
|
1532
|
+
file,
|
|
1533
|
+
message: `${file} should call defineRouteGroup<AppContext>(...) for ordinary feature route groups, or defineRouteGroup<AppContext>()({ ... }) when group-level hooks enrich ctx.`,
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
for (const file of files) {
|
|
1539
|
+
if (
|
|
1540
|
+
file === config.paths.appContext ||
|
|
1541
|
+
!file.endsWith(".ts") ||
|
|
1542
|
+
file.endsWith(".d.ts")
|
|
1543
|
+
) {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1548
|
+
if (!/\b(?:type|interface)\s+AppContext\b/.test(source)) continue;
|
|
1549
|
+
|
|
1550
|
+
diagnostics.push({
|
|
1551
|
+
severity: "warning",
|
|
1552
|
+
code: "BEIGNET_APP_CONTEXT_REDECLARED",
|
|
1553
|
+
file,
|
|
1554
|
+
message: `${file} declares AppContext locally. Import AppContext from "@/app-context" so routes, hooks, use cases, jobs, schedules, and tests share one context model.`,
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
return diagnostics;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function canonicalContextFields(appContextSource: string): string[] {
|
|
1562
|
+
const fields = ["requestId", "ports"];
|
|
1563
|
+
for (const field of ["actor", "auth", "gate"]) {
|
|
1564
|
+
if (appContextSource.includes(`${field}:`)) fields.push(field);
|
|
1565
|
+
}
|
|
1566
|
+
return fields;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function importsAppContext(
|
|
1570
|
+
source: string,
|
|
1571
|
+
importerFile: string,
|
|
1572
|
+
config: ResolvedBeignetConfig,
|
|
1573
|
+
files: string[],
|
|
1574
|
+
): boolean {
|
|
1575
|
+
const importRegex =
|
|
1576
|
+
/import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
1577
|
+
|
|
1578
|
+
for (const match of source.matchAll(importRegex)) {
|
|
1579
|
+
const sourceFile = sourceFileFromImport(match[2], importerFile, files);
|
|
1580
|
+
if (sourceFile !== config.paths.appContext) continue;
|
|
1581
|
+
|
|
1582
|
+
for (const member of match[1].split(",")) {
|
|
1583
|
+
const parsed = parseImportMember(member);
|
|
1584
|
+
if (
|
|
1585
|
+
parsed?.importedName === "AppContext" &&
|
|
1586
|
+
parsed.localName === "AppContext"
|
|
1587
|
+
) {
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async function inspectAuthzAuditDrift(
|
|
1597
|
+
targetDir: string,
|
|
1598
|
+
files: string[],
|
|
1599
|
+
config: ResolvedBeignetConfig,
|
|
1600
|
+
convention: InspectConvention,
|
|
1601
|
+
contracts: InspectedContract[],
|
|
1602
|
+
): Promise<InspectDiagnostic[]> {
|
|
1603
|
+
if (!convention.resourceGenerator) return [];
|
|
1604
|
+
|
|
1605
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1606
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1607
|
+
const sourceCache = new Map<string, string>();
|
|
1608
|
+
|
|
1609
|
+
for (const contract of contracts) {
|
|
1610
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
1611
|
+
if (!feature) continue;
|
|
1612
|
+
|
|
1613
|
+
const ability = contract.metadata?.authorizationAbility;
|
|
1614
|
+
if (ability) {
|
|
1615
|
+
const policyFile = `${featuresPath}/${feature}/policy.ts`;
|
|
1616
|
+
if (!files.includes(policyFile)) {
|
|
1617
|
+
diagnostics.push({
|
|
1618
|
+
severity: "warning",
|
|
1619
|
+
code: "BEIGNET_AUTHZ_POLICY_MISSING",
|
|
1620
|
+
file: contract.file,
|
|
1621
|
+
contract: contract.exportName,
|
|
1622
|
+
message: `${contract.exportName} declares authorization ability "${ability}", but ${policyFile} is missing. Add a feature policy for the ability or remove stale authorization metadata from the contract.`,
|
|
1623
|
+
});
|
|
1624
|
+
} else {
|
|
1625
|
+
const policySource = await readCachedSource(
|
|
1626
|
+
targetDir,
|
|
1627
|
+
policyFile,
|
|
1628
|
+
sourceCache,
|
|
1629
|
+
);
|
|
1630
|
+
if (!policySource.includes(ability)) {
|
|
1631
|
+
diagnostics.push({
|
|
1632
|
+
severity: "warning",
|
|
1633
|
+
code: "BEIGNET_AUTHZ_POLICY_DRIFT",
|
|
1634
|
+
file: policyFile,
|
|
1635
|
+
contract: contract.exportName,
|
|
1636
|
+
message: `${contract.exportName} declares authorization ability "${ability}", but ${policyFile} does not mention that ability. Keep contract metadata aligned with the feature policy.`,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const policyTestFiles = featureTestFiles(files, featuresPath, feature, {
|
|
1642
|
+
policyOnly: true,
|
|
1643
|
+
});
|
|
1644
|
+
if (
|
|
1645
|
+
policyTestFiles.length === 0 ||
|
|
1646
|
+
!(await anyFileContains(
|
|
1647
|
+
targetDir,
|
|
1648
|
+
policyTestFiles,
|
|
1649
|
+
ability,
|
|
1650
|
+
sourceCache,
|
|
1651
|
+
))
|
|
1652
|
+
) {
|
|
1653
|
+
diagnostics.push({
|
|
1654
|
+
severity: "warning",
|
|
1655
|
+
code: "BEIGNET_AUTHZ_POLICY_TEST_MISSING",
|
|
1656
|
+
file:
|
|
1657
|
+
policyTestFiles[0] ??
|
|
1658
|
+
`${featuresPath}/${feature}/tests/policy.test.ts`,
|
|
1659
|
+
contract: contract.exportName,
|
|
1660
|
+
message: `${contract.exportName} declares authorization ability "${ability}", but no feature policy test appears to cover it. Add policy matrix coverage so authorization metadata, policy behavior, and tests stay aligned.`,
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (contract.metadata?.auditRequired) {
|
|
1666
|
+
const featureSourceFiles = featureRuntimeFiles(
|
|
1667
|
+
files,
|
|
1668
|
+
featuresPath,
|
|
1669
|
+
feature,
|
|
1670
|
+
);
|
|
1671
|
+
if (
|
|
1672
|
+
featureSourceFiles.length === 0 ||
|
|
1673
|
+
!(await anyFileMatches(
|
|
1674
|
+
targetDir,
|
|
1675
|
+
featureSourceFiles,
|
|
1676
|
+
/\baudit\.record\s*\(/,
|
|
1677
|
+
sourceCache,
|
|
1678
|
+
))
|
|
1679
|
+
) {
|
|
1680
|
+
diagnostics.push({
|
|
1681
|
+
severity: "warning",
|
|
1682
|
+
code: "BEIGNET_AUDIT_WRITE_MISSING",
|
|
1683
|
+
file: contract.file,
|
|
1684
|
+
contract: contract.exportName,
|
|
1685
|
+
message: `${contract.exportName} declares audit-required metadata, but no feature runtime file appears to record through ctx.ports.audit or a transaction audit port. Record the audited business action in the use case, listener, job, schedule, or upload completion hook.`,
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const testFiles = featureTestFiles(files, featuresPath, feature);
|
|
1690
|
+
if (
|
|
1691
|
+
testFiles.length === 0 ||
|
|
1692
|
+
!(await anyFileMatches(
|
|
1693
|
+
targetDir,
|
|
1694
|
+
testFiles,
|
|
1695
|
+
/\b(assertAuditEntry|assertAuditEntries|audit\.entries)\b/,
|
|
1696
|
+
sourceCache,
|
|
1697
|
+
))
|
|
1698
|
+
) {
|
|
1699
|
+
diagnostics.push({
|
|
1700
|
+
severity: "warning",
|
|
1701
|
+
code: "BEIGNET_AUDIT_TEST_MISSING",
|
|
1702
|
+
file: testFiles[0] ?? `${featuresPath}/${feature}/tests/`,
|
|
1703
|
+
contract: contract.exportName,
|
|
1704
|
+
message: `${contract.exportName} declares audit-required metadata, but no feature test appears to assert audit behavior. Add an audit assertion for the sensitive workflow so contract metadata and durable activity logging stay aligned.`,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return diagnostics;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async function inspectRouteErrorCatalogDrift(
|
|
1714
|
+
targetDir: string,
|
|
1715
|
+
files: string[],
|
|
1716
|
+
config: ResolvedBeignetConfig,
|
|
1717
|
+
convention: InspectConvention,
|
|
1718
|
+
contracts: InspectedContract[],
|
|
1719
|
+
strict: boolean,
|
|
1720
|
+
): Promise<InspectDiagnostic[]> {
|
|
1721
|
+
if (!convention.resourceGenerator) return [];
|
|
1722
|
+
|
|
1723
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1724
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1725
|
+
const sharedErrorsFile = `${featuresPath}/shared/errors.ts`;
|
|
1726
|
+
const sourceCache = new Map<string, string>();
|
|
1727
|
+
const sharedErrorKeys = files.includes(sharedErrorsFile)
|
|
1728
|
+
? parseErrorCatalogKeys(
|
|
1729
|
+
await readCachedSource(targetDir, sharedErrorsFile, sourceCache),
|
|
1730
|
+
)
|
|
1731
|
+
: undefined;
|
|
1732
|
+
const declaredByFeature = new Map<string, Set<string>>();
|
|
1733
|
+
const contractFiles = new Set(contracts.map((contract) => contract.file));
|
|
1734
|
+
const sharedCatalogBindingsByFile = new Map<string, Set<string>>();
|
|
1735
|
+
|
|
1736
|
+
for (const file of contractFiles) {
|
|
1737
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1738
|
+
sharedCatalogBindingsByFile.set(
|
|
1739
|
+
file,
|
|
1740
|
+
sharedErrorCatalogBindings(source, file, files, sharedErrorsFile),
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
for (const contract of contracts) {
|
|
1745
|
+
const catalogErrors = contract.metadata?.catalogErrors ?? [];
|
|
1746
|
+
if (catalogErrors.length === 0) continue;
|
|
1747
|
+
|
|
1748
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
1749
|
+
if (feature) {
|
|
1750
|
+
const declared = declaredByFeature.get(feature) ?? new Set<string>();
|
|
1751
|
+
for (const error of catalogErrors) declared.add(error.key);
|
|
1752
|
+
declaredByFeature.set(feature, declared);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const sharedBindings =
|
|
1756
|
+
sharedCatalogBindingsByFile.get(contract.file) ?? new Set<string>();
|
|
1757
|
+
for (const error of catalogErrors) {
|
|
1758
|
+
if (!sharedBindings.has(error.catalog)) continue;
|
|
1759
|
+
|
|
1760
|
+
if (!sharedErrorKeys) {
|
|
1761
|
+
diagnostics.push({
|
|
1762
|
+
severity: "error",
|
|
1763
|
+
code: "BEIGNET_ROUTE_ERROR_CATALOG_MISSING",
|
|
1764
|
+
file: sharedErrorsFile,
|
|
1765
|
+
contract: contract.exportName,
|
|
1766
|
+
message: `${contract.exportName} declares route-owned error ${error.key} from ${sharedErrorsFile}, but the shared error catalog file is missing. Add ${sharedErrorsFile} or remove the stale contract .errors(...) entry.`,
|
|
1767
|
+
});
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (!sharedErrorKeys.has(error.catalogKey)) {
|
|
1772
|
+
diagnostics.push({
|
|
1773
|
+
severity: "error",
|
|
1774
|
+
code: "BEIGNET_ROUTE_ERROR_CATALOG_ENTRY_MISSING",
|
|
1775
|
+
file: sharedErrorsFile,
|
|
1776
|
+
contract: contract.exportName,
|
|
1777
|
+
message: `${contract.exportName} declares route-owned error ${error.key}, but ${sharedErrorsFile} does not define ${error.catalogKey}. Add it to defineErrors(...) or remove the stale contract .errors(...) entry.`,
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
for (const feature of featureNamesFromContracts(contracts, config)) {
|
|
1784
|
+
const featureRuntime = featureErrorRuntimeFiles(
|
|
1785
|
+
files,
|
|
1786
|
+
featuresPath,
|
|
1787
|
+
feature,
|
|
1788
|
+
);
|
|
1789
|
+
const declared = declaredByFeature.get(feature) ?? new Set<string>();
|
|
1790
|
+
const used = await featureAppErrorUses(
|
|
1791
|
+
targetDir,
|
|
1792
|
+
featureRuntime,
|
|
1793
|
+
sourceCache,
|
|
1794
|
+
);
|
|
1795
|
+
|
|
1796
|
+
for (const [errorName, file] of used) {
|
|
1797
|
+
if (sharedErrorKeys && !sharedErrorKeys.has(errorName)) {
|
|
1798
|
+
diagnostics.push({
|
|
1799
|
+
severity: "error",
|
|
1800
|
+
code: "BEIGNET_APP_ERROR_CATALOG_ENTRY_MISSING",
|
|
1801
|
+
file: sharedErrorsFile,
|
|
1802
|
+
message: `${file} creates appError("${errorName}"), but ${sharedErrorsFile} does not define ${errorName}. Add it to the shared error catalog or fix the stale appError(...) call.`,
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (!declared.has(errorName)) {
|
|
1807
|
+
diagnostics.push({
|
|
1808
|
+
severity: "warning",
|
|
1809
|
+
code: "BEIGNET_APP_ERROR_CONTRACT_MISSING",
|
|
1810
|
+
file,
|
|
1811
|
+
message: `${file} creates appError("${errorName}"), but no ${feature} contract declares ${errorName} with .errors(...). Declare the route-owned error on the contract that can surface it, or move the non-HTTP error out of route-backed workflows.`,
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (!strict) continue;
|
|
1817
|
+
|
|
1818
|
+
for (const errorName of declared) {
|
|
1819
|
+
if (!isFeatureOwnedErrorName(errorName, feature)) continue;
|
|
1820
|
+
if (used.has(errorName)) continue;
|
|
1821
|
+
|
|
1822
|
+
diagnostics.push({
|
|
1823
|
+
severity: "warning",
|
|
1824
|
+
code: "BEIGNET_ROUTE_ERROR_UNUSED",
|
|
1825
|
+
file: `${featuresPath}/${feature}/contracts.ts`,
|
|
1826
|
+
message: `${feature} contracts declare ${errorName}, but no ${feature} runtime file appears to create appError("${errorName}"). Remove the stale .errors(...) entry or add runtime coverage for the route-owned error.`,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
return diagnostics;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
type ProviderDoctorRule = {
|
|
1835
|
+
packageName: string;
|
|
1836
|
+
displayName: string;
|
|
1837
|
+
tokens: readonly string[];
|
|
1838
|
+
appPorts?: readonly { name: string; type: string }[];
|
|
1839
|
+
requiredEnv?: readonly string[];
|
|
1840
|
+
registrationSeverity?: "warning" | "hint";
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
type ProviderPackageMetadataSource = {
|
|
1844
|
+
packageName: string;
|
|
1845
|
+
file: string;
|
|
1846
|
+
metadata: unknown;
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
async function inspectProductionReadiness(
|
|
1850
|
+
targetDir: string,
|
|
1851
|
+
files: string[],
|
|
1852
|
+
config: ResolvedBeignetConfig,
|
|
1853
|
+
convention: InspectConvention,
|
|
1854
|
+
): Promise<InspectDiagnostic[]> {
|
|
1855
|
+
if (!convention.resourceGenerator) return [];
|
|
1856
|
+
|
|
1857
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1858
|
+
const sourceCache = new Map<string, string>();
|
|
1859
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1860
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
1861
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(
|
|
1862
|
+
targetDir,
|
|
1863
|
+
packageJson,
|
|
1864
|
+
);
|
|
1865
|
+
const configFiles = operationalConfigFiles(files, config);
|
|
1866
|
+
|
|
1867
|
+
for (const file of files) {
|
|
1868
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
|
|
1869
|
+
|
|
1870
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1871
|
+
|
|
1872
|
+
if (
|
|
1873
|
+
containsCallExpression(source, "createDevtoolsRoute") &&
|
|
1874
|
+
/\benabled\s*:\s*true\b/.test(source) &&
|
|
1875
|
+
!/\bauthorize\s*:/.test(source)
|
|
1876
|
+
) {
|
|
1877
|
+
diagnostics.push({
|
|
1878
|
+
severity: "warning",
|
|
1879
|
+
code: "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
|
|
1880
|
+
file,
|
|
1881
|
+
message: `${file} enables devtools with enabled: true and no authorize callback. Keep devtools disabled in production or add app-owned authorization before exposing diagnostic data.`,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (
|
|
1886
|
+
file.startsWith("app/api/cron/") &&
|
|
1887
|
+
file.endsWith("/route.ts") &&
|
|
1888
|
+
!/\bCRON_SECRET\b/.test(source) &&
|
|
1889
|
+
!/\bcreateOutboxDrainRoute\s*\(/.test(source)
|
|
1890
|
+
) {
|
|
1891
|
+
diagnostics.push({
|
|
1892
|
+
severity: "warning",
|
|
1893
|
+
code: "BEIGNET_CRON_SECRET_MISSING",
|
|
1894
|
+
file,
|
|
1895
|
+
message: `${file} looks like a cron route that does not reference CRON_SECRET. Protect cron entrypoints with a shared secret or a framework helper such as createOutboxDrainRoute(...).`,
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
if (
|
|
1900
|
+
isFeatureUploadDefinition(file, config) &&
|
|
1901
|
+
containsCallExpression(source, "defineUpload") &&
|
|
1902
|
+
!/\bmaxSizeBytes\s*:/.test(source)
|
|
1903
|
+
) {
|
|
1904
|
+
diagnostics.push({
|
|
1905
|
+
severity: "warning",
|
|
1906
|
+
code: "BEIGNET_UPLOAD_SIZE_LIMIT_MISSING",
|
|
1907
|
+
file,
|
|
1908
|
+
message: `${file} defines an upload without maxSizeBytes. Add an explicit size limit so upload behavior is predictable before deployment.`,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
if (
|
|
1913
|
+
isRouteServerOrInfraFile(file, config) &&
|
|
1914
|
+
containsCallExpression(source, "createCorsHooks") &&
|
|
1915
|
+
/\bcredentials\s*:\s*true\b/.test(source) &&
|
|
1916
|
+
/(?:\borigin\b|\borigins\b)\s*:\s*(?:["']\*["']|\[\s*["']\*["'])/.test(
|
|
1917
|
+
source,
|
|
1918
|
+
)
|
|
1919
|
+
) {
|
|
1920
|
+
diagnostics.push({
|
|
1921
|
+
severity: "warning",
|
|
1922
|
+
code: "BEIGNET_CORS_CREDENTIALS_WILDCARD",
|
|
1923
|
+
file,
|
|
1924
|
+
message: `${file} enables credentialed CORS with a wildcard origin. Use an explicit origin allow-list so cookies and authorization headers are not exposed to arbitrary origins.`,
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
for (const provider of providerDoctorRules) {
|
|
1930
|
+
if (!installedPackages.has(provider.packageName)) continue;
|
|
1931
|
+
for (const envVar of provider.requiredEnv ?? []) {
|
|
1932
|
+
if (await anyFileContains(targetDir, configFiles, envVar, sourceCache)) {
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
diagnostics.push({
|
|
1936
|
+
severity: "warning",
|
|
1937
|
+
code: "BEIGNET_PROVIDER_ENV_MISSING",
|
|
1938
|
+
file: "package.json",
|
|
1939
|
+
message: `${provider.packageName} is installed, but ${envVar} is not mentioned in app env/config files. Add the expected environment variable or remove the unused provider package.`,
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
if (installedPackages.has("@beignet/provider-auth-better-auth")) {
|
|
1945
|
+
if (!hasBetterAuthRoute(files, config)) {
|
|
1946
|
+
diagnostics.push({
|
|
1947
|
+
severity: "warning",
|
|
1948
|
+
code: "BEIGNET_AUTH_ROUTE_MISSING",
|
|
1949
|
+
file: directoryPath(config.paths.routes),
|
|
1950
|
+
message:
|
|
1951
|
+
"@beignet/provider-auth-better-auth is installed, but no Better Auth route was found. Expose Better Auth through app/api/auth/[...all]/route.ts or remove the unused auth provider.",
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
if (
|
|
1956
|
+
!(await anyFileContains(
|
|
1957
|
+
targetDir,
|
|
1958
|
+
configFiles,
|
|
1959
|
+
"BETTER_AUTH_TRUSTED_ORIGINS",
|
|
1960
|
+
sourceCache,
|
|
1961
|
+
))
|
|
1962
|
+
) {
|
|
1963
|
+
diagnostics.push({
|
|
1964
|
+
severity: "warning",
|
|
1965
|
+
code: "BEIGNET_AUTH_TRUSTED_ORIGINS_MISSING",
|
|
1966
|
+
file: "package.json",
|
|
1967
|
+
message:
|
|
1968
|
+
"@beignet/provider-auth-better-auth is installed, but BETTER_AUTH_TRUSTED_ORIGINS is not mentioned in app env/config files. Add it before deploying across multiple origins or document why the app is single-origin only.",
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
return diagnostics;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Local-dev ranges such as the generated-app conformance vendoring point each
|
|
1977
|
+
// @beignet/* package at a different path on purpose, so they are exempt from
|
|
1978
|
+
// the declared-range comparison. Their installed versions still participate in
|
|
1979
|
+
// the installed-version comparison.
|
|
1980
|
+
const localOnlyRangePrefixes = ["file:", "link:", "workspace:"];
|
|
1981
|
+
|
|
1982
|
+
async function inspectVersionSkew(
|
|
1983
|
+
targetDir: string,
|
|
1984
|
+
files: string[],
|
|
1985
|
+
): Promise<InspectDiagnostic[]> {
|
|
1986
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1987
|
+
const declaredRanges = new Map<string, string>();
|
|
1988
|
+
for (const section of [
|
|
1989
|
+
packageJson?.dependencies,
|
|
1990
|
+
packageJson?.devDependencies,
|
|
1991
|
+
]) {
|
|
1992
|
+
for (const [name, range] of Object.entries(section ?? {})) {
|
|
1993
|
+
if (name.startsWith("@beignet/")) declaredRanges.set(name, range);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (declaredRanges.size === 0) return [];
|
|
1997
|
+
|
|
1998
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
1999
|
+
const comparableRanges = [...declaredRanges].filter(
|
|
2000
|
+
([, range]) =>
|
|
2001
|
+
!localOnlyRangePrefixes.some((prefix) => range.startsWith(prefix)),
|
|
2002
|
+
);
|
|
2003
|
+
const distinctRanges = new Set(comparableRanges.map(([, range]) => range));
|
|
2004
|
+
if (distinctRanges.size > 1) {
|
|
2005
|
+
const pairs = comparableRanges
|
|
2006
|
+
.map(([name, range]) => `${name}@${range}`)
|
|
2007
|
+
.join(", ");
|
|
2008
|
+
diagnostics.push({
|
|
2009
|
+
severity: "warning",
|
|
2010
|
+
code: "BEIGNET_VERSION_SKEW",
|
|
2011
|
+
file: "package.json",
|
|
2012
|
+
message: `package.json declares mixed @beignet/* version ranges: ${pairs}. Align all @beignet/* packages to one version; they release together.`,
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
const installedVersions = await readInstalledBeignetVersions(targetDir);
|
|
2017
|
+
const distinctInstalled = new Set(installedVersions.values());
|
|
2018
|
+
if (distinctInstalled.size > 1) {
|
|
2019
|
+
const pairs = [...installedVersions]
|
|
2020
|
+
.map(([name, version]) => `${name}@${version}`)
|
|
2021
|
+
.join(", ");
|
|
2022
|
+
diagnostics.push({
|
|
2023
|
+
severity: "warning",
|
|
2024
|
+
code: "BEIGNET_VERSION_SKEW",
|
|
2025
|
+
file: "package.json",
|
|
2026
|
+
message: `node_modules contains mixed @beignet/* versions: ${pairs}. Align the declared @beignet/* ranges to one version, then reinstall so a single version is installed.`,
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const coreVersion = installedVersions.get("@beignet/core");
|
|
2031
|
+
const cliVersion = getCliVersion();
|
|
2032
|
+
if (
|
|
2033
|
+
coreVersion !== undefined &&
|
|
2034
|
+
cliVersion !== "0.0.0" &&
|
|
2035
|
+
coreVersion !== cliVersion
|
|
2036
|
+
) {
|
|
2037
|
+
diagnostics.push({
|
|
2038
|
+
severity: "hint",
|
|
2039
|
+
code: "BEIGNET_CLI_VERSION_SKEW",
|
|
2040
|
+
file: "package.json",
|
|
2041
|
+
message: `This CLI is ${cliVersion} but the app uses @beignet/core ${coreVersion}. Prefer the app-local CLI via \`bun beignet\` so the CLI and framework versions stay aligned.`,
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
return diagnostics;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function readInstalledBeignetVersions(
|
|
2049
|
+
targetDir: string,
|
|
2050
|
+
): Promise<Map<string, string>> {
|
|
2051
|
+
const scopeDir = path.join(targetDir, "node_modules", "@beignet");
|
|
2052
|
+
let entries: string[];
|
|
2053
|
+
try {
|
|
2054
|
+
entries = await readdir(scopeDir);
|
|
2055
|
+
} catch {
|
|
2056
|
+
return new Map();
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const versions = new Map<string, string>();
|
|
2060
|
+
for (const entry of entries.sort()) {
|
|
2061
|
+
if (entry.startsWith(".")) continue;
|
|
2062
|
+
const installedPackageJson = await readJsonIfExists<{ version?: string }>(
|
|
2063
|
+
path.join(scopeDir, entry, "package.json"),
|
|
2064
|
+
);
|
|
2065
|
+
if (installedPackageJson?.version) {
|
|
2066
|
+
versions.set(`@beignet/${entry}`, installedPackageJson.version);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
return versions;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async function inspectProviderMetadataDrift(
|
|
2074
|
+
targetDir: string,
|
|
2075
|
+
files: string[],
|
|
2076
|
+
convention: InspectConvention,
|
|
2077
|
+
): Promise<InspectDiagnostic[]> {
|
|
2078
|
+
if (!convention.resourceGenerator) return [];
|
|
2079
|
+
|
|
2080
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
2081
|
+
const packageNames = [...installedPackageNames(packageJson)];
|
|
2082
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
2083
|
+
|
|
2084
|
+
for (const packageName of packageNames) {
|
|
2085
|
+
const source = await readProviderPackageMetadataSource(
|
|
2086
|
+
targetDir,
|
|
2087
|
+
packageName,
|
|
2088
|
+
);
|
|
2089
|
+
if (!source) continue;
|
|
2090
|
+
|
|
2091
|
+
const result = parseProviderPackageMetadata(source.metadata);
|
|
2092
|
+
if (result.success) continue;
|
|
2093
|
+
|
|
2094
|
+
diagnostics.push({
|
|
2095
|
+
severity: "warning",
|
|
2096
|
+
code: "BEIGNET_PROVIDER_METADATA_INVALID",
|
|
2097
|
+
file: diagnosticPackageJsonFile(targetDir, source.file),
|
|
2098
|
+
message: `${packageName} declares invalid beignet.provider metadata: ${formatProviderMetadataIssues(result.issues)}. Fix the provider package metadata or remove the package until it publishes a valid manifest.`,
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
return diagnostics;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
async function inspectProviderRegistrationDrift(
|
|
2106
|
+
targetDir: string,
|
|
2107
|
+
files: string[],
|
|
2108
|
+
config: ResolvedBeignetConfig,
|
|
2109
|
+
convention: InspectConvention,
|
|
2110
|
+
): Promise<InspectDiagnostic[]> {
|
|
2111
|
+
if (!convention.resourceGenerator) return [];
|
|
2112
|
+
|
|
2113
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
2114
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
2115
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
2116
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(
|
|
2117
|
+
targetDir,
|
|
2118
|
+
packageJson,
|
|
2119
|
+
);
|
|
2120
|
+
const providerFiles = serverProviderFiles(files, config);
|
|
2121
|
+
const sourceCache = new Map<string, string>();
|
|
2122
|
+
const providerSource = (
|
|
2123
|
+
await Promise.all(
|
|
2124
|
+
providerFiles.map((file) =>
|
|
2125
|
+
readCachedSource(targetDir, file, sourceCache),
|
|
2126
|
+
),
|
|
2127
|
+
)
|
|
2128
|
+
).join("\n");
|
|
2129
|
+
const providerListSource = extractProviderListSource(providerSource);
|
|
2130
|
+
const providerEntries =
|
|
2131
|
+
providerListSource === undefined
|
|
2132
|
+
? []
|
|
2133
|
+
: providerListEntries(providerListSource);
|
|
2134
|
+
|
|
2135
|
+
for (const provider of providerDoctorRules) {
|
|
2136
|
+
const severity = provider.registrationSeverity;
|
|
2137
|
+
if (severity === undefined) continue;
|
|
2138
|
+
if (!installedPackages.has(provider.packageName)) continue;
|
|
2139
|
+
if (
|
|
2140
|
+
provider.tokens.some((token) =>
|
|
2141
|
+
providerEntries.some((entry) => entry.includes(token)),
|
|
2142
|
+
)
|
|
2143
|
+
) {
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
diagnostics.push({
|
|
2148
|
+
severity,
|
|
2149
|
+
code: "BEIGNET_PROVIDER_REGISTRATION_MISSING",
|
|
2150
|
+
file: providerFiles[0] ?? config.paths.server,
|
|
2151
|
+
message:
|
|
2152
|
+
severity === "hint"
|
|
2153
|
+
? `${provider.packageName} is installed, but ${provider.displayName} is not registered in server/providers.ts. Register ${registrationTokenHint(provider.tokens)} in the exported providers array or remove the dependency.`
|
|
2154
|
+
: `${provider.packageName} is installed, but ${provider.displayName} is not registered in server/providers.ts. Add it to the exported providers array or remove the unused package.`,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const drizzleIndex = findProviderEntryIndex(providerEntries, [
|
|
2159
|
+
"drizzleTursoProvider",
|
|
2160
|
+
"createDrizzleTursoProvider",
|
|
2161
|
+
]);
|
|
2162
|
+
const appDatabaseProviderIndex = findProviderEntryIndex(providerEntries, [
|
|
2163
|
+
"starterDatabaseProvider",
|
|
2164
|
+
"exampleDatabaseProvider",
|
|
2165
|
+
"twitterDatabaseProvider",
|
|
2166
|
+
"databaseProvider",
|
|
2167
|
+
]);
|
|
2168
|
+
if (
|
|
2169
|
+
appDatabaseProviderIndex >= 0 &&
|
|
2170
|
+
drizzleIndex >= 0 &&
|
|
2171
|
+
appDatabaseProviderIndex < drizzleIndex
|
|
2172
|
+
) {
|
|
2173
|
+
diagnostics.push({
|
|
2174
|
+
severity: "warning",
|
|
2175
|
+
code: "BEIGNET_PROVIDER_ORDER",
|
|
2176
|
+
file: providerFiles[0] ?? config.paths.server,
|
|
2177
|
+
message:
|
|
2178
|
+
"An app database provider is registered before createDrizzleTursoProvider(...). Register the Drizzle/Turso provider first so app-owned database wiring can read ctx.ports.db during setup.",
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
return diagnostics;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function isFeatureUploadDefinition(
|
|
2186
|
+
file: string,
|
|
2187
|
+
config: ResolvedBeignetConfig,
|
|
2188
|
+
): boolean {
|
|
2189
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2190
|
+
return new RegExp(
|
|
2191
|
+
`^${escapeRegExp(featuresPath)}/[^/]+/uploads/[^/]+\\.ts$`,
|
|
2192
|
+
).test(file);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function operationalConfigFiles(
|
|
2196
|
+
files: string[],
|
|
2197
|
+
config: ResolvedBeignetConfig,
|
|
2198
|
+
): string[] {
|
|
2199
|
+
return files.filter((file) => {
|
|
2200
|
+
if (file === ".env.example") return true;
|
|
2201
|
+
if (file === "drizzle.config.ts") return true;
|
|
2202
|
+
if (file === "lib/env.ts" || file === "lib/better-auth.ts") return true;
|
|
2203
|
+
if (isInfraOrServerFile(file, config) && file.endsWith(".ts")) return true;
|
|
2204
|
+
return false;
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
async function readPackageJson(
|
|
2209
|
+
targetDir: string,
|
|
2210
|
+
files: string[],
|
|
2211
|
+
): Promise<
|
|
2212
|
+
| {
|
|
2213
|
+
dependencies?: Record<string, string>;
|
|
2214
|
+
devDependencies?: Record<string, string>;
|
|
2215
|
+
peerDependencies?: Record<string, string>;
|
|
2216
|
+
}
|
|
2217
|
+
| undefined
|
|
2218
|
+
> {
|
|
2219
|
+
if (!files.includes("package.json")) return undefined;
|
|
2220
|
+
|
|
2221
|
+
return JSON.parse(
|
|
2222
|
+
await readFile(path.join(targetDir, "package.json"), "utf8"),
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
async function providerDoctorRulesForInstalledPackages(
|
|
2227
|
+
targetDir: string,
|
|
2228
|
+
packageJson:
|
|
2229
|
+
| {
|
|
2230
|
+
dependencies?: Record<string, string>;
|
|
2231
|
+
devDependencies?: Record<string, string>;
|
|
2232
|
+
peerDependencies?: Record<string, string>;
|
|
2233
|
+
}
|
|
2234
|
+
| undefined,
|
|
2235
|
+
): Promise<ProviderDoctorRule[]> {
|
|
2236
|
+
const packageNames = [...installedPackageNames(packageJson)];
|
|
2237
|
+
const rules = await Promise.all(
|
|
2238
|
+
packageNames.map(async (packageName) => {
|
|
2239
|
+
const source = await readProviderPackageMetadataSource(
|
|
2240
|
+
targetDir,
|
|
2241
|
+
packageName,
|
|
2242
|
+
);
|
|
2243
|
+
if (!source) return undefined;
|
|
2244
|
+
const result = parseProviderPackageMetadata(source.metadata);
|
|
2245
|
+
if (!result.success) return undefined;
|
|
2246
|
+
return providerDoctorRuleFromMetadata(packageName, result.metadata);
|
|
2247
|
+
}),
|
|
2248
|
+
);
|
|
2249
|
+
|
|
2250
|
+
return rules.filter((rule): rule is ProviderDoctorRule => Boolean(rule));
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
async function readProviderPackageMetadataSource(
|
|
2254
|
+
targetDir: string,
|
|
2255
|
+
packageName: string,
|
|
2256
|
+
): Promise<ProviderPackageMetadataSource | undefined> {
|
|
2257
|
+
for (const candidate of providerPackageJsonCandidates(
|
|
2258
|
+
targetDir,
|
|
2259
|
+
packageName,
|
|
2260
|
+
)) {
|
|
2261
|
+
const packageJson = await readJsonIfExists<{
|
|
2262
|
+
beignet?: { provider?: unknown };
|
|
2263
|
+
}>(candidate);
|
|
2264
|
+
const metadata = packageJson?.beignet?.provider;
|
|
2265
|
+
if (metadata !== undefined) {
|
|
2266
|
+
return { packageName, file: candidate, metadata };
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
return undefined;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function providerPackageJsonCandidates(
|
|
2274
|
+
targetDir: string,
|
|
2275
|
+
packageName: string,
|
|
2276
|
+
): string[] {
|
|
2277
|
+
const candidates = [
|
|
2278
|
+
path.join(
|
|
2279
|
+
targetDir,
|
|
2280
|
+
"node_modules",
|
|
2281
|
+
...packageName.split("/"),
|
|
2282
|
+
"package.json",
|
|
2283
|
+
),
|
|
2284
|
+
];
|
|
2285
|
+
|
|
2286
|
+
if (packageName.startsWith("@beignet/")) {
|
|
2287
|
+
candidates.push(
|
|
2288
|
+
path.resolve(
|
|
2289
|
+
// import.meta.dir is Bun-only; the published bin also runs under
|
|
2290
|
+
// plain Node, where only import.meta.url is available.
|
|
2291
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
2292
|
+
"../../..",
|
|
2293
|
+
"packages",
|
|
2294
|
+
packageName.slice("@beignet/".length),
|
|
2295
|
+
"package.json",
|
|
2296
|
+
),
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
return candidates;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
async function readJsonIfExists<T>(file: string): Promise<T | undefined> {
|
|
2304
|
+
try {
|
|
2305
|
+
return JSON.parse(await readFile(file, "utf8")) as T;
|
|
2306
|
+
} catch (error) {
|
|
2307
|
+
if (
|
|
2308
|
+
error instanceof Error &&
|
|
2309
|
+
"code" in error &&
|
|
2310
|
+
(error as { code?: string }).code === "ENOENT"
|
|
2311
|
+
) {
|
|
2312
|
+
return undefined;
|
|
2313
|
+
}
|
|
2314
|
+
throw error;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function providerDoctorRuleFromMetadata(
|
|
2319
|
+
packageName: string,
|
|
2320
|
+
metadata: ProviderPackageMetadata,
|
|
2321
|
+
): ProviderDoctorRule {
|
|
2322
|
+
const registration = metadata.registration;
|
|
2323
|
+
|
|
2324
|
+
return {
|
|
2325
|
+
packageName,
|
|
2326
|
+
displayName: metadata.displayName ?? packageName,
|
|
2327
|
+
tokens: registration?.tokens ?? [],
|
|
2328
|
+
appPorts: metadata.appPorts ?? [],
|
|
2329
|
+
requiredEnv: metadata.requiredEnv ?? [],
|
|
2330
|
+
registrationSeverity:
|
|
2331
|
+
registration?.severity ??
|
|
2332
|
+
(registration?.required === true ? "warning" : undefined),
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function registrationTokenHint(tokens: readonly string[]): string {
|
|
2337
|
+
const token = tokens[0];
|
|
2338
|
+
if (!token) return "the provider";
|
|
2339
|
+
return token.startsWith("create") ? `${token}()` : token;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function diagnosticPackageJsonFile(targetDir: string, file: string): string {
|
|
2343
|
+
const relative = normalizePath(path.relative(targetDir, file));
|
|
2344
|
+
if (relative.startsWith("../")) return "package.json";
|
|
2345
|
+
return relative;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function formatProviderMetadataIssues(
|
|
2349
|
+
issues: readonly { path: string; message: string }[],
|
|
2350
|
+
): string {
|
|
2351
|
+
return issues.map((issue) => `${issue.path} ${issue.message}`).join("; ");
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function installedPackageNames(
|
|
2355
|
+
packageJson:
|
|
2356
|
+
| {
|
|
2357
|
+
dependencies?: Record<string, string>;
|
|
2358
|
+
devDependencies?: Record<string, string>;
|
|
2359
|
+
peerDependencies?: Record<string, string>;
|
|
2360
|
+
}
|
|
2361
|
+
| undefined,
|
|
2362
|
+
): Set<string> {
|
|
2363
|
+
return new Set([
|
|
2364
|
+
...Object.keys(packageJson?.dependencies ?? {}),
|
|
2365
|
+
...Object.keys(packageJson?.devDependencies ?? {}),
|
|
2366
|
+
...Object.keys(packageJson?.peerDependencies ?? {}),
|
|
2367
|
+
]);
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function serverProviderFiles(
|
|
2371
|
+
files: string[],
|
|
2372
|
+
config: ResolvedBeignetConfig,
|
|
2373
|
+
): string[] {
|
|
2374
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
2375
|
+
const candidates = [`${serverDir}/providers.ts`, config.paths.server];
|
|
2376
|
+
return candidates.filter((file) => files.includes(file));
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
function extractProviderListSource(source: string): string | undefined {
|
|
2380
|
+
const listMatches = [
|
|
2381
|
+
...source.matchAll(/\bproviders\s*=\s*\[([\s\S]*?)\]\s*(?:;|as const)/g),
|
|
2382
|
+
...source.matchAll(/\bproviders\s*:\s*\[([\s\S]*?)\]/g),
|
|
2383
|
+
];
|
|
2384
|
+
if (listMatches.length === 0) return undefined;
|
|
2385
|
+
return listMatches.map((match) => match[1]).join("\n");
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function providerListEntries(source: string): string[] {
|
|
2389
|
+
const entries: string[] = [];
|
|
2390
|
+
let current = "";
|
|
2391
|
+
let depth = 0;
|
|
2392
|
+
let quote: '"' | "'" | "`" | undefined;
|
|
2393
|
+
let escaped = false;
|
|
2394
|
+
let lineComment = false;
|
|
2395
|
+
let blockComment = false;
|
|
2396
|
+
|
|
2397
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
2398
|
+
const char = source[index];
|
|
2399
|
+
const next = source[index + 1];
|
|
2400
|
+
|
|
2401
|
+
if (lineComment) {
|
|
2402
|
+
if (char === "\n") {
|
|
2403
|
+
lineComment = false;
|
|
2404
|
+
current += char;
|
|
2405
|
+
}
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (blockComment) {
|
|
2410
|
+
if (char === "*" && next === "/") {
|
|
2411
|
+
blockComment = false;
|
|
2412
|
+
index += 1;
|
|
2413
|
+
}
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
if (quote) {
|
|
2418
|
+
current += char;
|
|
2419
|
+
if (escaped) {
|
|
2420
|
+
escaped = false;
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
if (char === "\\") {
|
|
2424
|
+
escaped = true;
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
if (char === quote) {
|
|
2428
|
+
quote = undefined;
|
|
2429
|
+
}
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
if (char === "/" && next === "/") {
|
|
2434
|
+
lineComment = true;
|
|
2435
|
+
index += 1;
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
if (char === "/" && next === "*") {
|
|
2440
|
+
blockComment = true;
|
|
2441
|
+
index += 1;
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
2446
|
+
quote = char;
|
|
2447
|
+
current += char;
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
2452
|
+
depth += 1;
|
|
2453
|
+
} else if (char === ")" || char === "]" || char === "}") {
|
|
2454
|
+
depth = Math.max(0, depth - 1);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
if (char === "," && depth === 0) {
|
|
2458
|
+
const entry = current.trim();
|
|
2459
|
+
if (entry) entries.push(entry);
|
|
2460
|
+
current = "";
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
current += char;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const entry = current.trim();
|
|
2468
|
+
if (entry) entries.push(entry);
|
|
2469
|
+
return entries;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function findProviderEntryIndex(
|
|
2473
|
+
entries: string[],
|
|
2474
|
+
tokens: readonly string[],
|
|
2475
|
+
): number {
|
|
2476
|
+
return entries.findIndex((entry) =>
|
|
2477
|
+
tokens.some((token) => entry.includes(token)),
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
function hasBetterAuthRoute(
|
|
2482
|
+
files: string[],
|
|
2483
|
+
config: ResolvedBeignetConfig,
|
|
2484
|
+
): boolean {
|
|
2485
|
+
const routesPath = directoryPath(config.paths.routes);
|
|
2486
|
+
return files.some(
|
|
2487
|
+
(file) =>
|
|
2488
|
+
file.startsWith(`${routesPath}/auth/`) && file.endsWith("/route.ts"),
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function featureNameFromContractFile(
|
|
2493
|
+
file: string,
|
|
2494
|
+
config: ResolvedBeignetConfig,
|
|
2495
|
+
): string | undefined {
|
|
2496
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2497
|
+
const match = new RegExp(
|
|
2498
|
+
`^${escapeRegExp(featuresPath)}/([^/]+)/contracts\\.ts$`,
|
|
2499
|
+
).exec(file);
|
|
2500
|
+
return match?.[1];
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function featureRuntimeFiles(
|
|
2504
|
+
files: string[],
|
|
2505
|
+
featuresPath: string,
|
|
2506
|
+
feature: string,
|
|
2507
|
+
): string[] {
|
|
2508
|
+
const runtimeFolders = [
|
|
2509
|
+
"jobs",
|
|
2510
|
+
"listeners",
|
|
2511
|
+
"schedules",
|
|
2512
|
+
"uploads",
|
|
2513
|
+
"use-cases",
|
|
2514
|
+
];
|
|
2515
|
+
return files.filter((file) => {
|
|
2516
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) return false;
|
|
2517
|
+
return runtimeFolders.some((folder) =>
|
|
2518
|
+
file.startsWith(`${featuresPath}/${feature}/${folder}/`),
|
|
2519
|
+
);
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function featureTestFiles(
|
|
2524
|
+
files: string[],
|
|
2525
|
+
featuresPath: string,
|
|
2526
|
+
feature: string,
|
|
2527
|
+
options: { policyOnly?: boolean } = {},
|
|
2528
|
+
): string[] {
|
|
2529
|
+
return files.filter((file) => {
|
|
2530
|
+
if (
|
|
2531
|
+
!file.startsWith(`${featuresPath}/${feature}/`) ||
|
|
2532
|
+
!file.endsWith(".test.ts")
|
|
2533
|
+
) {
|
|
2534
|
+
return false;
|
|
2535
|
+
}
|
|
2536
|
+
return !options.policyOnly || file.endsWith("/policy.test.ts");
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function featureNamesFromContracts(
|
|
2541
|
+
contracts: InspectedContract[],
|
|
2542
|
+
config: ResolvedBeignetConfig,
|
|
2543
|
+
): string[] {
|
|
2544
|
+
return [
|
|
2545
|
+
...new Set(
|
|
2546
|
+
contracts
|
|
2547
|
+
.map((contract) => featureNameFromContractFile(contract.file, config))
|
|
2548
|
+
.filter((feature): feature is string => feature !== undefined),
|
|
2549
|
+
),
|
|
2550
|
+
].sort();
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function sharedErrorCatalogBindings(
|
|
2554
|
+
source: string,
|
|
2555
|
+
file: string,
|
|
2556
|
+
files: string[],
|
|
2557
|
+
sharedErrorsFile: string,
|
|
2558
|
+
): Set<string> {
|
|
2559
|
+
const bindings = new Set<string>();
|
|
2560
|
+
const imports = parseNamedImportSources(source);
|
|
2561
|
+
|
|
2562
|
+
for (const [localName, imported] of imports) {
|
|
2563
|
+
if (imported.importedName !== "errors") continue;
|
|
2564
|
+
if (
|
|
2565
|
+
importResolvesToFile(imported.sourcePath, file, files, sharedErrorsFile)
|
|
2566
|
+
) {
|
|
2567
|
+
bindings.add(localName);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
return bindings;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function importResolvesToFile(
|
|
2575
|
+
sourcePath: string,
|
|
2576
|
+
importerFile: string,
|
|
2577
|
+
files: string[],
|
|
2578
|
+
expectedFile: string,
|
|
2579
|
+
): boolean {
|
|
2580
|
+
return (
|
|
2581
|
+
sourceFileFromImport(sourcePath, importerFile, files) === expectedFile ||
|
|
2582
|
+
sourceFileFromImport(sourcePath, importerFile) === expectedFile
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function featureErrorRuntimeFiles(
|
|
2587
|
+
files: string[],
|
|
2588
|
+
featuresPath: string,
|
|
2589
|
+
feature: string,
|
|
2590
|
+
): string[] {
|
|
2591
|
+
const runtimeFiles = new Set(
|
|
2592
|
+
featureRuntimeFiles(files, featuresPath, feature),
|
|
2593
|
+
);
|
|
2594
|
+
const policyFile = `${featuresPath}/${feature}/policy.ts`;
|
|
2595
|
+
if (files.includes(policyFile)) runtimeFiles.add(policyFile);
|
|
2596
|
+
|
|
2597
|
+
return [...runtimeFiles].sort();
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
async function featureAppErrorUses(
|
|
2601
|
+
targetDir: string,
|
|
2602
|
+
files: string[],
|
|
2603
|
+
sourceCache: Map<string, string>,
|
|
2604
|
+
): Promise<Map<string, string>> {
|
|
2605
|
+
const uses = new Map<string, string>();
|
|
2606
|
+
|
|
2607
|
+
for (const file of files) {
|
|
2608
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
2609
|
+
for (const match of source.matchAll(
|
|
2610
|
+
/\bappError\s*\(\s*["']([^"']+)["']/g,
|
|
2611
|
+
)) {
|
|
2612
|
+
if (!uses.has(match[1])) uses.set(match[1], file);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
return uses;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function isFeatureOwnedErrorName(errorName: string, feature: string): boolean {
|
|
2620
|
+
const singularPascal = pascalCase(singularize(feature));
|
|
2621
|
+
const pluralPascal = pascalCase(feature);
|
|
2622
|
+
|
|
2623
|
+
return (
|
|
2624
|
+
errorName.startsWith(singularPascal) || errorName.startsWith(pluralPascal)
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
async function anyFileContains(
|
|
2629
|
+
targetDir: string,
|
|
2630
|
+
files: string[],
|
|
2631
|
+
needle: string,
|
|
2632
|
+
sourceCache: Map<string, string>,
|
|
2633
|
+
): Promise<boolean> {
|
|
2634
|
+
for (const file of files) {
|
|
2635
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
2636
|
+
if (source.includes(needle)) return true;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
return false;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
async function anyFileMatches(
|
|
2643
|
+
targetDir: string,
|
|
2644
|
+
files: string[],
|
|
2645
|
+
pattern: RegExp,
|
|
2646
|
+
sourceCache: Map<string, string>,
|
|
2647
|
+
): Promise<boolean> {
|
|
2648
|
+
for (const file of files) {
|
|
2649
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
2650
|
+
if (pattern.test(source)) return true;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
async function readCachedSource(
|
|
2657
|
+
targetDir: string,
|
|
2658
|
+
file: string,
|
|
2659
|
+
sourceCache: Map<string, string>,
|
|
2660
|
+
): Promise<string> {
|
|
2661
|
+
const cached = sourceCache.get(file);
|
|
2662
|
+
if (cached !== undefined) return cached;
|
|
2663
|
+
|
|
2664
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2665
|
+
sourceCache.set(file, source);
|
|
2666
|
+
return source;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
async function inspectFeatureArtifactDrift(
|
|
2670
|
+
targetDir: string,
|
|
2671
|
+
files: string[],
|
|
2672
|
+
config: ResolvedBeignetConfig,
|
|
2673
|
+
convention: InspectConvention,
|
|
2674
|
+
): Promise<InspectDiagnostic[]> {
|
|
2675
|
+
if (!convention.resourceGenerator) return [];
|
|
2676
|
+
|
|
2677
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
2678
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2679
|
+
const uploadDefinitions = files.filter((file) =>
|
|
2680
|
+
isFeatureArtifactFile(file, featuresPath, "uploads"),
|
|
2681
|
+
);
|
|
2682
|
+
|
|
2683
|
+
if (
|
|
2684
|
+
uploadDefinitions.length > 0 &&
|
|
2685
|
+
!(await appHasUploadRoute(targetDir, files, config))
|
|
2686
|
+
) {
|
|
2687
|
+
diagnostics.push({
|
|
2688
|
+
severity: "warning",
|
|
2689
|
+
code: "BEIGNET_UPLOAD_ROUTE_MISSING",
|
|
2690
|
+
file: uploadDefinitions[0],
|
|
2691
|
+
message:
|
|
2692
|
+
"This app defines feature uploads but no known upload route. Register the upload registry with createUploadRouter(...) and expose it through createUploadRoute(...) so browser uploads can reach the workflow.",
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
for (const file of files) {
|
|
2697
|
+
const match = new RegExp(
|
|
2698
|
+
`^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.test\\.ts$`,
|
|
2699
|
+
).exec(file);
|
|
2700
|
+
if (!match) continue;
|
|
2701
|
+
diagnostics.push({
|
|
2702
|
+
severity: "warning",
|
|
2703
|
+
code: "BEIGNET_FEATURE_TEST_PLACEMENT",
|
|
2704
|
+
file,
|
|
2705
|
+
message: `${file} places a test directly under ${featuresPath}/${match[1]}/. Move feature behavior tests into ${featuresPath}/${match[1]}/tests/ so feature suites stay in the canonical folder; adjacent *.test.ts files are for infra and server modules.`,
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
for (const file of files) {
|
|
2710
|
+
if (
|
|
2711
|
+
!file.endsWith(".ts") ||
|
|
2712
|
+
file.endsWith(".test.ts") ||
|
|
2713
|
+
file.endsWith(".d.ts")
|
|
2714
|
+
) {
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
const misplaced = misplacedFeatureArtifactFolder(file, featuresPath);
|
|
2719
|
+
if (misplaced) {
|
|
2720
|
+
diagnostics.push({
|
|
2721
|
+
severity: "warning",
|
|
2722
|
+
code: "BEIGNET_FEATURE_ARTIFACT_FOLDER",
|
|
2723
|
+
file,
|
|
2724
|
+
message: `${file} uses ${misplaced.actual}, but Beignet expects ${misplaced.expected}. Keep feature-owned workflow artifacts in the canonical feature folders so CLI generators, docs, and architecture lint stay aligned.`,
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
if (
|
|
2729
|
+
isNotificationDefinitionFile(file, featuresPath) ||
|
|
2730
|
+
isInfraOrServerFile(file, config)
|
|
2731
|
+
) {
|
|
2732
|
+
continue;
|
|
2733
|
+
}
|
|
2734
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2735
|
+
if (containsCallExpression(source, "createInlineNotificationDispatcher")) {
|
|
2736
|
+
diagnostics.push({
|
|
2737
|
+
severity: "warning",
|
|
2738
|
+
code: "BEIGNET_NOTIFICATION_PORT_BYPASS",
|
|
2739
|
+
file,
|
|
2740
|
+
message: `${file} creates a notification dispatcher directly. Application workflows should send notifications through ctx.ports.notifications so delivery can be swapped, tested, instrumented, and run through jobs/outbox consistently.`,
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
return diagnostics;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function isFeatureArtifactFile(
|
|
2749
|
+
file: string,
|
|
2750
|
+
featuresPath: string,
|
|
2751
|
+
folder: "uploads" | "notifications",
|
|
2752
|
+
): boolean {
|
|
2753
|
+
const match = new RegExp(
|
|
2754
|
+
`^${escapeRegExp(featuresPath)}/[^/]+/${folder}/([^/]+)\\.ts$`,
|
|
2755
|
+
).exec(file);
|
|
2756
|
+
return Boolean(match && match[1] !== "index");
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
async function appHasUploadRoute(
|
|
2760
|
+
targetDir: string,
|
|
2761
|
+
files: string[],
|
|
2762
|
+
config: ResolvedBeignetConfig,
|
|
2763
|
+
): Promise<boolean> {
|
|
2764
|
+
for (const file of files) {
|
|
2765
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
|
|
2766
|
+
if (!isRouteServerOrInfraFile(file, config)) continue;
|
|
2767
|
+
|
|
2768
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2769
|
+
if (
|
|
2770
|
+
containsCallExpression(source, "createUploadRoute") ||
|
|
2771
|
+
containsCallExpression(source, "createUploadRouter")
|
|
2772
|
+
) {
|
|
2773
|
+
return true;
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function misplacedFeatureArtifactFolder(
|
|
2781
|
+
file: string,
|
|
2782
|
+
featuresPath: string,
|
|
2783
|
+
): { actual: string; expected: string } | undefined {
|
|
2784
|
+
const match = new RegExp(
|
|
2785
|
+
`^${escapeRegExp(featuresPath)}/([^/]+)/([^/]+)/`,
|
|
2786
|
+
).exec(file);
|
|
2787
|
+
if (!match) return undefined;
|
|
2788
|
+
|
|
2789
|
+
const folder = match[2];
|
|
2790
|
+
const mappings: Record<string, string> = {
|
|
2791
|
+
command: "tasks/",
|
|
2792
|
+
commands: "tasks/",
|
|
2793
|
+
event: "domain/events/",
|
|
2794
|
+
events: "domain/events/",
|
|
2795
|
+
job: "jobs/",
|
|
2796
|
+
listener: "listeners/",
|
|
2797
|
+
notification: "notifications/",
|
|
2798
|
+
schedule: "schedules/",
|
|
2799
|
+
scheduled: "schedules/",
|
|
2800
|
+
task: "tasks/",
|
|
2801
|
+
upload: "uploads/",
|
|
2802
|
+
};
|
|
2803
|
+
|
|
2804
|
+
const expected = mappings[folder];
|
|
2805
|
+
if (!expected) return undefined;
|
|
2806
|
+
|
|
2807
|
+
return { actual: `${folder}/`, expected };
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function isNotificationDefinitionFile(
|
|
2811
|
+
file: string,
|
|
2812
|
+
featuresPath: string,
|
|
2813
|
+
): boolean {
|
|
2814
|
+
return isFeatureArtifactFile(file, featuresPath, "notifications");
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function isRouteServerOrInfraFile(
|
|
2818
|
+
file: string,
|
|
2819
|
+
config: ResolvedBeignetConfig,
|
|
2820
|
+
): boolean {
|
|
2821
|
+
return (
|
|
2822
|
+
file.startsWith(`${directoryPath(config.paths.routes)}/`) ||
|
|
2823
|
+
isInfraOrServerFile(file, config)
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function isInfraOrServerFile(
|
|
2828
|
+
file: string,
|
|
2829
|
+
config: ResolvedBeignetConfig,
|
|
2830
|
+
): boolean {
|
|
2831
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
2832
|
+
const infrastructureDir = directoryPath(
|
|
2833
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
2834
|
+
);
|
|
2835
|
+
|
|
2836
|
+
return (
|
|
2837
|
+
file === config.paths.server ||
|
|
2838
|
+
file.startsWith(`${serverDir}/`) ||
|
|
2839
|
+
file === config.paths.infrastructurePorts ||
|
|
2840
|
+
file.startsWith(`${infrastructureDir}/`)
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function containsCallExpression(source: string, name: string): boolean {
|
|
2845
|
+
return new RegExp(
|
|
2846
|
+
`\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`,
|
|
2847
|
+
).test(source);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
async function inspectServerlessFootguns(
|
|
2851
|
+
targetDir: string,
|
|
2852
|
+
files: string[],
|
|
2853
|
+
): Promise<InspectDiagnostic[]> {
|
|
2854
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
2855
|
+
|
|
2856
|
+
for (const file of files) {
|
|
2857
|
+
if (
|
|
2858
|
+
!file.endsWith(".ts") ||
|
|
2859
|
+
file.endsWith(".test.ts") ||
|
|
2860
|
+
file.endsWith(".d.ts")
|
|
2861
|
+
) {
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
const isInfraOrServerFile =
|
|
2866
|
+
file.startsWith("infra/") || file.startsWith("server/");
|
|
2867
|
+
const isProviderFile =
|
|
2868
|
+
/(^|\/)provider(s)?\.ts$/.test(file) ||
|
|
2869
|
+
/(^|\/)[^/]*provider[^/]*\.ts$/.test(file);
|
|
2870
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2871
|
+
|
|
2872
|
+
if (
|
|
2873
|
+
isInfraOrServerFile &&
|
|
2874
|
+
isProviderFile &&
|
|
2875
|
+
/\bset(?:Interval|Timeout)\s*\(/.test(source)
|
|
2876
|
+
) {
|
|
2877
|
+
diagnostics.push({
|
|
2878
|
+
severity: "warning",
|
|
2879
|
+
code: "BEIGNET_PROVIDER_BACKGROUND_WORK",
|
|
2880
|
+
file,
|
|
2881
|
+
message: `${file} starts timer-based work from provider/server setup. Serverless apps should expose bounded runtime entrypoints such as cron routes, scheduled handlers, job functions, or worker processes instead of provider polling loops.`,
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
if (
|
|
2886
|
+
isInfraOrServerFile &&
|
|
2887
|
+
isProviderFile &&
|
|
2888
|
+
/\bcreateProvider\s*\(/.test(source) &&
|
|
2889
|
+
/\bdrainOutbox\s*\(/.test(source)
|
|
2890
|
+
) {
|
|
2891
|
+
diagnostics.push({
|
|
2892
|
+
severity: "warning",
|
|
2893
|
+
code: "BEIGNET_PROVIDER_OUTBOX_DRAIN",
|
|
2894
|
+
file,
|
|
2895
|
+
message: `${file} drains the outbox from provider lifecycle code. Move outbox delivery to createOutboxDrainRoute(...) or another bounded runtime entrypoint so imports and serverless cold starts do not run background work.`,
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
if (
|
|
2900
|
+
file.startsWith("app/api/cron/") &&
|
|
2901
|
+
file.endsWith("/route.ts") &&
|
|
2902
|
+
!/\bauthorization\b/i.test(source) &&
|
|
2903
|
+
!/\bcreateOutboxDrainRoute\s*\(/.test(source) &&
|
|
2904
|
+
!/\bcreateScheduleRoute\s*\(/.test(source)
|
|
2905
|
+
) {
|
|
2906
|
+
diagnostics.push({
|
|
2907
|
+
severity: "warning",
|
|
2908
|
+
code: "BEIGNET_CRON_ROUTE_AUTH_MISSING",
|
|
2909
|
+
file,
|
|
2910
|
+
message: `${file} looks like a cron route without an authorization check. Require a shared secret or use a framework route helper such as createOutboxDrainRoute(...) or createScheduleRoute(...).`,
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
if (
|
|
2916
|
+
hasOutboxRegistry(files) &&
|
|
2917
|
+
!(await appHasOutboxDrainEntrypoint(targetDir, files))
|
|
2918
|
+
) {
|
|
2919
|
+
diagnostics.push({
|
|
2920
|
+
severity: "warning",
|
|
2921
|
+
code: "BEIGNET_OUTBOX_DRAIN_MISSING",
|
|
2922
|
+
message:
|
|
2923
|
+
"This app defines an outbox registry but no known drain entrypoint. Add a cron/schedule/job route with createOutboxDrainRoute(...) so durable events and jobs are delivered in serverless deployments.",
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
return diagnostics;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function hasOutboxRegistry(files: string[]): boolean {
|
|
2931
|
+
return files.some(isOutboxRegistryFile);
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function isOutboxRegistryFile(file: string): boolean {
|
|
2935
|
+
return file === "server/outbox.ts";
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
async function appHasOutboxDrainEntrypoint(
|
|
2939
|
+
targetDir: string,
|
|
2940
|
+
files: string[],
|
|
2941
|
+
): Promise<boolean> {
|
|
2942
|
+
for (const file of files) {
|
|
2943
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
|
|
2944
|
+
if (
|
|
2945
|
+
!file.startsWith("app/api/") &&
|
|
2946
|
+
!file.startsWith("server/") &&
|
|
2947
|
+
!file.startsWith("infra/")
|
|
2948
|
+
) {
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
2953
|
+
if (
|
|
2954
|
+
/\bcreateOutboxDrainRoute\s*\(/.test(source) ||
|
|
2955
|
+
/\bdrainOutbox\s*\(/.test(source)
|
|
2956
|
+
) {
|
|
2957
|
+
return true;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
return false;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
async function inspectPortProviderDrift(
|
|
2965
|
+
targetDir: string,
|
|
2966
|
+
files: string[],
|
|
2967
|
+
config: ResolvedBeignetConfig,
|
|
2968
|
+
convention: InspectConvention,
|
|
2969
|
+
): Promise<InspectDiagnostic[]> {
|
|
2970
|
+
if (!convention.resourceGenerator) return [];
|
|
2971
|
+
|
|
820
2972
|
const diagnostics: InspectDiagnostic[] = [];
|
|
821
2973
|
const portsSource = files.includes(config.paths.ports)
|
|
822
2974
|
? await readFile(path.join(targetDir, config.paths.ports), "utf8")
|
|
823
2975
|
: "";
|
|
824
2976
|
const packageJson = files.includes("package.json")
|
|
825
|
-
? (
|
|
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
|
-
})
|
|
2977
|
+
? await readPackageJson(targetDir, files)
|
|
832
2978
|
: undefined;
|
|
833
2979
|
const installedPackages = new Set([
|
|
834
2980
|
...Object.keys(packageJson?.dependencies ?? {}),
|
|
835
2981
|
...Object.keys(packageJson?.devDependencies ?? {}),
|
|
836
2982
|
...Object.keys(packageJson?.peerDependencies ?? {}),
|
|
837
2983
|
]);
|
|
2984
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(
|
|
2985
|
+
targetDir,
|
|
2986
|
+
packageJson,
|
|
2987
|
+
);
|
|
2988
|
+
|
|
2989
|
+
const providerPortRequirements = providerDoctorRules.flatMap((provider) =>
|
|
2990
|
+
(provider.appPorts ?? []).map((port) => ({
|
|
2991
|
+
packageName: provider.packageName,
|
|
2992
|
+
portName: port.name,
|
|
2993
|
+
portType: port.type,
|
|
2994
|
+
})),
|
|
2995
|
+
);
|
|
2996
|
+
|
|
2997
|
+
for (const expected of providerPortRequirements) {
|
|
2998
|
+
if (!installedPackages.has(expected.packageName)) continue;
|
|
2999
|
+
if (portsSource.includes(`${expected.portName}:`)) continue;
|
|
3000
|
+
diagnostics.push({
|
|
3001
|
+
severity: "warning",
|
|
3002
|
+
code: "BEIGNET_PROVIDER_PORT_MISSING",
|
|
3003
|
+
file: config.paths.ports,
|
|
3004
|
+
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.`,
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
const portDir = directoryPath(path.dirname(config.paths.ports));
|
|
3009
|
+
for (const file of files) {
|
|
3010
|
+
if (
|
|
3011
|
+
!file.startsWith(`${portDir}/`) ||
|
|
3012
|
+
!file.endsWith(".ts") ||
|
|
3013
|
+
file === config.paths.ports ||
|
|
3014
|
+
file.endsWith("-repository.ts")
|
|
3015
|
+
) {
|
|
3016
|
+
continue;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
3020
|
+
const generatedPort =
|
|
3021
|
+
/export interface ([A-Z]\w*Port)\s*\{[\s\S]*execute\(/.exec(source);
|
|
3022
|
+
if (!generatedPort) continue;
|
|
3023
|
+
const fakeFactory = `createFake${generatedPort[1].replace(/Port$/, "")}Port`;
|
|
3024
|
+
if (source.includes(fakeFactory)) continue;
|
|
3025
|
+
diagnostics.push({
|
|
3026
|
+
severity: "warning",
|
|
3027
|
+
code: "BEIGNET_PORT_TEST_FAKE_MISSING",
|
|
3028
|
+
file,
|
|
3029
|
+
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.`,
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
return diagnostics;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
async function inspectDatabaseLifecycleDrift(
|
|
3037
|
+
targetDir: string,
|
|
3038
|
+
files: string[],
|
|
3039
|
+
config: ResolvedBeignetConfig,
|
|
3040
|
+
convention: InspectConvention,
|
|
3041
|
+
strict: boolean,
|
|
3042
|
+
): Promise<InspectDiagnostic[]> {
|
|
3043
|
+
if (!convention.resourceGenerator) return [];
|
|
3044
|
+
|
|
3045
|
+
const diagnostics: InspectDiagnostic[] = [];
|
|
3046
|
+
const resources = await collectResourceNames(targetDir, files, config);
|
|
3047
|
+
const infrastructurePath = directoryPath(
|
|
3048
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
3049
|
+
);
|
|
3050
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
3051
|
+
const repositoriesPath = path.join(
|
|
3052
|
+
infrastructurePath,
|
|
3053
|
+
"db",
|
|
3054
|
+
"repositories.ts",
|
|
3055
|
+
);
|
|
3056
|
+
const repositoriesSource = files.includes(repositoriesPath)
|
|
3057
|
+
? await readFile(path.join(targetDir, repositoriesPath), "utf8")
|
|
3058
|
+
: "";
|
|
3059
|
+
const packageScripts = await readPackageScripts(targetDir, files);
|
|
3060
|
+
const schemaDir = `${infrastructurePath}/db/schema`;
|
|
3061
|
+
const schemaIndexFile = `${schemaDir}/index.ts`;
|
|
3062
|
+
const schemaFiles = files.filter(
|
|
3063
|
+
(file) =>
|
|
3064
|
+
file.startsWith(`${schemaDir}/`) &&
|
|
3065
|
+
file.endsWith(".ts") &&
|
|
3066
|
+
file !== schemaIndexFile,
|
|
3067
|
+
);
|
|
3068
|
+
const hasDrizzleArtifacts =
|
|
3069
|
+
hasDrizzleConfig(files) ||
|
|
3070
|
+
files.includes(repositoriesPath) ||
|
|
3071
|
+
files.includes(schemaIndexFile) ||
|
|
3072
|
+
schemaFiles.length > 0 ||
|
|
3073
|
+
files.some(
|
|
3074
|
+
(file) =>
|
|
3075
|
+
file.startsWith(`${infrastructurePath}/`) &&
|
|
3076
|
+
file.endsWith("-repository.ts") &&
|
|
3077
|
+
file.includes("/drizzle-"),
|
|
3078
|
+
);
|
|
3079
|
+
|
|
3080
|
+
if (
|
|
3081
|
+
hasDrizzleArtifacts &&
|
|
3082
|
+
!hasDrizzleConfig(files) &&
|
|
3083
|
+
hasDrizzleLifecycleScriptNeedingRootConfig(packageScripts)
|
|
3084
|
+
) {
|
|
3085
|
+
diagnostics.push({
|
|
3086
|
+
severity: "warning",
|
|
3087
|
+
code: "BEIGNET_DRIZZLE_CONFIG_MISSING",
|
|
3088
|
+
file: "drizzle.config.ts",
|
|
3089
|
+
message:
|
|
3090
|
+
"This app has Drizzle database artifacts, but no drizzle.config.ts, drizzle.config.mts, drizzle.config.js, or drizzle.config.mjs exists at the app root. Add a Drizzle config so beignet db generate and beignet db migrate can run.",
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
if (hasDrizzleArtifacts) {
|
|
3095
|
+
for (const script of ["db:generate", "db:migrate"] as const) {
|
|
3096
|
+
if (packageScripts[script]) continue;
|
|
3097
|
+
diagnostics.push({
|
|
3098
|
+
severity: "warning",
|
|
3099
|
+
code: "BEIGNET_DB_SCRIPT_MISSING",
|
|
3100
|
+
file: "package.json",
|
|
3101
|
+
message: `This app has Drizzle database artifacts, but package.json does not define "${script}". Add an app-owned ${script} script so beignet db ${script.replace("db:", "")} can run the database lifecycle command.`,
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
const schemaFilePath = `${infrastructurePath}/db/schema.ts`;
|
|
3107
|
+
if (files.includes(schemaFilePath)) {
|
|
3108
|
+
diagnostics.push({
|
|
3109
|
+
severity: "warning",
|
|
3110
|
+
code: "BEIGNET_DB_SCHEMA_FILE_FORM",
|
|
3111
|
+
file: schemaFilePath,
|
|
3112
|
+
message: `${schemaFilePath} keeps the Drizzle schema in a single file. Move it to ${schemaIndexFile} so feature tables can live in separate modules under ${schemaDir}/.`,
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
if (schemaFiles.length > 0 && !files.includes(schemaIndexFile)) {
|
|
3117
|
+
diagnostics.push({
|
|
3118
|
+
severity: "warning",
|
|
3119
|
+
code: "BEIGNET_DB_SCHEMA_INDEX_MISSING",
|
|
3120
|
+
file: schemaIndexFile,
|
|
3121
|
+
message: `${schemaDir} contains table schema files, but ${schemaIndexFile} is missing. Add the schema index so Drizzle config and generated repositories can import one schema module.`,
|
|
3122
|
+
});
|
|
3123
|
+
} else if (schemaFiles.length > 0) {
|
|
3124
|
+
const schemaIndexSource = await readFile(
|
|
3125
|
+
path.join(targetDir, schemaIndexFile),
|
|
3126
|
+
"utf8",
|
|
3127
|
+
);
|
|
3128
|
+
for (const schemaFile of schemaFiles) {
|
|
3129
|
+
const moduleName = path.basename(schemaFile, ".ts");
|
|
3130
|
+
if (schemaIndexSource.includes(`"./${moduleName}"`)) continue;
|
|
3131
|
+
diagnostics.push({
|
|
3132
|
+
severity: "warning",
|
|
3133
|
+
code: "BEIGNET_DB_SCHEMA_EXPORT_MISSING",
|
|
3134
|
+
file: schemaIndexFile,
|
|
3135
|
+
message: `${schemaFile} is not exported from ${schemaIndexFile}. Export it so Drizzle config, migrations, and repository factories see the same schema module.`,
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
838
3139
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
3140
|
+
const standardEntrypoints = [
|
|
3141
|
+
{
|
|
3142
|
+
script: "db:seed",
|
|
3143
|
+
command: "seed",
|
|
3144
|
+
file: `${infrastructurePath}/db/seed.ts`,
|
|
3145
|
+
code: "BEIGNET_DB_SEED_ENTRYPOINT_MISSING",
|
|
3146
|
+
},
|
|
3147
|
+
{
|
|
3148
|
+
script: "db:reset",
|
|
3149
|
+
command: "reset",
|
|
3150
|
+
file: `${infrastructurePath}/db/reset.ts`,
|
|
3151
|
+
code: "BEIGNET_DB_RESET_ENTRYPOINT_MISSING",
|
|
3152
|
+
},
|
|
3153
|
+
] as const;
|
|
3154
|
+
|
|
3155
|
+
for (const entrypoint of standardEntrypoints) {
|
|
3156
|
+
const script = packageScripts[entrypoint.script];
|
|
3157
|
+
if (!script?.includes(entrypoint.file) || files.includes(entrypoint.file)) {
|
|
3158
|
+
continue;
|
|
3159
|
+
}
|
|
842
3160
|
diagnostics.push({
|
|
843
3161
|
severity: "warning",
|
|
844
|
-
code:
|
|
845
|
-
file:
|
|
846
|
-
message:
|
|
3162
|
+
code: entrypoint.code,
|
|
3163
|
+
file: entrypoint.file,
|
|
3164
|
+
message: `package.json script "${entrypoint.script}" runs ${entrypoint.file}, but that file is missing. Restore ${entrypoint.file} or update the script before running beignet db ${entrypoint.command}.`,
|
|
847
3165
|
});
|
|
848
3166
|
}
|
|
849
3167
|
|
|
850
|
-
const
|
|
851
|
-
|
|
3168
|
+
const resetEntrypoint = `${infrastructurePath}/db/reset.ts`;
|
|
3169
|
+
if (
|
|
3170
|
+
packageScripts["db:reset"]?.includes(resetEntrypoint) &&
|
|
3171
|
+
files.includes(resetEntrypoint)
|
|
3172
|
+
) {
|
|
3173
|
+
const resetSource = await readFile(
|
|
3174
|
+
path.join(targetDir, resetEntrypoint),
|
|
3175
|
+
"utf8",
|
|
3176
|
+
);
|
|
3177
|
+
if (!resetSource.includes("BEIGNET_ALLOW_DATABASE_RESET")) {
|
|
3178
|
+
diagnostics.push({
|
|
3179
|
+
severity: "warning",
|
|
3180
|
+
code: "BEIGNET_DB_RESET_GUARD_MISSING",
|
|
3181
|
+
file: resetEntrypoint,
|
|
3182
|
+
message:
|
|
3183
|
+
"The standard database reset entrypoint does not mention BEIGNET_ALLOW_DATABASE_RESET. Keep destructive reset behavior guarded so non-local databases are not reset accidentally.",
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
for (const resource of resources) {
|
|
3189
|
+
const singular = singularize(resource);
|
|
3190
|
+
const pascal = pascalCase(singular);
|
|
3191
|
+
const pluralCamel = camelCase(resource);
|
|
3192
|
+
const infrastructureDir = `${infrastructurePath}/${resource}/`;
|
|
3193
|
+
const hasRepositoryAdapter = hasRepositoryAdapterFile(
|
|
3194
|
+
files,
|
|
3195
|
+
infrastructureDir,
|
|
3196
|
+
);
|
|
3197
|
+
const drizzleRepositoryAdapter = drizzleRepositoryAdapterFile(
|
|
3198
|
+
files,
|
|
3199
|
+
infrastructureDir,
|
|
3200
|
+
singular,
|
|
3201
|
+
);
|
|
3202
|
+
|
|
3203
|
+
const portFile = resourcePortFile(resource, singular, config);
|
|
3204
|
+
|
|
3205
|
+
if (files.includes(portFile) && !hasRepositoryAdapter) {
|
|
3206
|
+
diagnostics.push({
|
|
3207
|
+
severity: "warning",
|
|
3208
|
+
code: "BEIGNET_REPOSITORY_ADAPTER_MISSING",
|
|
3209
|
+
file: portFile,
|
|
3210
|
+
message: `${portFile} declares a repository port, but ${infrastructureDir} does not contain a repository adapter. Add an infra adapter such as drizzle-${singular}-repository.ts and wire it through app ports.`,
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
if (drizzleRepositoryAdapter) {
|
|
3215
|
+
const factoryName = `createDrizzle${pascal}Repository`;
|
|
3216
|
+
if (
|
|
3217
|
+
repositoriesSource &&
|
|
3218
|
+
(!repositoriesSource.includes(factoryName) ||
|
|
3219
|
+
!new RegExp(`\\b${pluralCamel}\\s*:`).test(repositoriesSource))
|
|
3220
|
+
) {
|
|
3221
|
+
diagnostics.push({
|
|
3222
|
+
severity: "warning",
|
|
3223
|
+
code: "BEIGNET_DRIZZLE_REPOSITORY_UNWIRED",
|
|
3224
|
+
file: repositoriesPath,
|
|
3225
|
+
message: `${drizzleRepositoryAdapter} exists, but ${repositoriesPath} does not appear to import and return ${factoryName}(db). Wire the repository through createRepositories(...) so normal and transaction-scoped ports stay aligned.`,
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
|
|
852
3230
|
if (
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
file.endsWith("-repository.ts")
|
|
3231
|
+
strict &&
|
|
3232
|
+
hasFeatureSeeds(files, featuresPath, resource) &&
|
|
3233
|
+
!hasFeatureFactories(files, featuresPath, resource)
|
|
857
3234
|
) {
|
|
858
|
-
|
|
3235
|
+
diagnostics.push({
|
|
3236
|
+
severity: "warning",
|
|
3237
|
+
code: "BEIGNET_FACTORY_MISSING",
|
|
3238
|
+
file: `${featuresPath}/${resource}/seeds/`,
|
|
3239
|
+
message: `${resource} defines feature seeds but no feature factory. Add a factory under ${featuresPath}/${resource}/tests/factories/ so seeds and tests share the same app-owned data setup path.`,
|
|
3240
|
+
});
|
|
859
3241
|
}
|
|
3242
|
+
}
|
|
860
3243
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const fakeFactory = `createFake${generatedPort[1].replace(/Port$/, "")}Port`;
|
|
866
|
-
if (source.includes(fakeFactory)) continue;
|
|
3244
|
+
if (
|
|
3245
|
+
hasAnyFeatureSeeds(files, featuresPath) &&
|
|
3246
|
+
!(await packageScriptExists(targetDir, files, "db:seed"))
|
|
3247
|
+
) {
|
|
867
3248
|
diagnostics.push({
|
|
868
3249
|
severity: "warning",
|
|
869
|
-
code: "
|
|
870
|
-
file,
|
|
871
|
-
message:
|
|
3250
|
+
code: "BEIGNET_DB_SEED_SCRIPT_MISSING",
|
|
3251
|
+
file: "package.json",
|
|
3252
|
+
message:
|
|
3253
|
+
'This app defines feature seeds, but package.json does not define "db:seed". Add an app-owned db:seed script so beignet db seed can run the seed entrypoint.',
|
|
872
3254
|
});
|
|
873
3255
|
}
|
|
874
3256
|
|
|
875
3257
|
return diagnostics;
|
|
876
3258
|
}
|
|
877
3259
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
3260
|
+
function hasRepositoryAdapterFile(
|
|
3261
|
+
files: string[],
|
|
3262
|
+
infrastructureDir: string,
|
|
3263
|
+
): boolean {
|
|
3264
|
+
return files.some(
|
|
3265
|
+
(file) =>
|
|
3266
|
+
file.startsWith(infrastructureDir) &&
|
|
3267
|
+
file.endsWith("-repository.ts") &&
|
|
3268
|
+
!file.endsWith(".test.ts"),
|
|
3269
|
+
);
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
function drizzleRepositoryAdapterFile(
|
|
3273
|
+
files: string[],
|
|
3274
|
+
infrastructureDir: string,
|
|
3275
|
+
singular: string,
|
|
3276
|
+
): string | undefined {
|
|
3277
|
+
return files.find(
|
|
3278
|
+
(file) =>
|
|
3279
|
+
file.startsWith(infrastructureDir) &&
|
|
3280
|
+
file.endsWith(`/drizzle-${singular}-repository.ts`),
|
|
3281
|
+
);
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
function hasFeatureSeeds(
|
|
3285
|
+
files: string[],
|
|
3286
|
+
featuresPath: string,
|
|
3287
|
+
resource: string,
|
|
3288
|
+
): boolean {
|
|
3289
|
+
return files.some(
|
|
3290
|
+
(file) =>
|
|
3291
|
+
file.startsWith(`${featuresPath}/${resource}/seeds/`) &&
|
|
3292
|
+
file.endsWith(".ts") &&
|
|
3293
|
+
!file.endsWith("/index.ts"),
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
function hasAnyFeatureSeeds(files: string[], featuresPath: string): boolean {
|
|
3298
|
+
return files.some(
|
|
3299
|
+
(file) =>
|
|
3300
|
+
new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/seeds/`).test(file) &&
|
|
3301
|
+
file.endsWith(".ts") &&
|
|
3302
|
+
!file.endsWith("/index.ts"),
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
function hasFeatureFactories(
|
|
3307
|
+
files: string[],
|
|
3308
|
+
featuresPath: string,
|
|
3309
|
+
resource: string,
|
|
3310
|
+
): boolean {
|
|
3311
|
+
return files.some(
|
|
3312
|
+
(file) =>
|
|
3313
|
+
file === `${featuresPath}/${resource}/tests/factories.ts` ||
|
|
3314
|
+
(file.startsWith(`${featuresPath}/${resource}/tests/factories/`) &&
|
|
3315
|
+
file.endsWith(".ts") &&
|
|
3316
|
+
!file.endsWith("/index.ts")),
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
async function packageScriptExists(
|
|
3321
|
+
targetDir: string,
|
|
3322
|
+
files: string[],
|
|
3323
|
+
script: string,
|
|
3324
|
+
): Promise<boolean> {
|
|
3325
|
+
const scripts = await readPackageScripts(targetDir, files);
|
|
3326
|
+
|
|
3327
|
+
return Boolean(scripts[script]);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
async function readPackageScripts(
|
|
3331
|
+
targetDir: string,
|
|
3332
|
+
files: string[],
|
|
3333
|
+
): Promise<Record<string, string>> {
|
|
3334
|
+
if (!files.includes("package.json")) return {};
|
|
3335
|
+
|
|
3336
|
+
const packageJson = JSON.parse(
|
|
3337
|
+
await readFile(path.join(targetDir, "package.json"), "utf8"),
|
|
3338
|
+
) as {
|
|
3339
|
+
scripts?: Record<string, string>;
|
|
3340
|
+
};
|
|
3341
|
+
|
|
3342
|
+
return packageJson.scripts ?? {};
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function hasDrizzleConfig(files: string[]): boolean {
|
|
3346
|
+
return [
|
|
3347
|
+
"drizzle.config.ts",
|
|
3348
|
+
"drizzle.config.mts",
|
|
3349
|
+
"drizzle.config.js",
|
|
3350
|
+
"drizzle.config.mjs",
|
|
3351
|
+
].some((file) => files.includes(file));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
function hasDrizzleLifecycleScriptNeedingRootConfig(
|
|
3355
|
+
scripts: Record<string, string>,
|
|
3356
|
+
): boolean {
|
|
3357
|
+
const lifecycleScripts = ["db:generate", "db:migrate"]
|
|
3358
|
+
.map((script) => scripts[script])
|
|
3359
|
+
.filter((script): script is string => Boolean(script));
|
|
3360
|
+
|
|
3361
|
+
if (lifecycleScripts.length === 0) return true;
|
|
3362
|
+
|
|
3363
|
+
return lifecycleScripts.some(
|
|
3364
|
+
(script) =>
|
|
3365
|
+
/\bdrizzle-kit\b/.test(script) && !hasExplicitDrizzleConfigFlag(script),
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function hasExplicitDrizzleConfigFlag(script: string): boolean {
|
|
3370
|
+
return /(?:^|\s)--config(?:=|\s)/.test(script);
|
|
3371
|
+
}
|
|
920
3372
|
|
|
921
3373
|
async function inspectPackageScripts(
|
|
922
3374
|
targetDir: string,
|
|
@@ -938,7 +3390,7 @@ async function inspectPackageScripts(
|
|
|
938
3390
|
return [
|
|
939
3391
|
{
|
|
940
3392
|
severity: "warning",
|
|
941
|
-
code: "
|
|
3393
|
+
code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
|
|
942
3394
|
file: "package.json",
|
|
943
3395
|
message:
|
|
944
3396
|
'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
|
|
@@ -969,7 +3421,7 @@ async function fixMissingTestScript(
|
|
|
969
3421
|
|
|
970
3422
|
await writeFile(filePath, next);
|
|
971
3423
|
return {
|
|
972
|
-
code: "
|
|
3424
|
+
code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
|
|
973
3425
|
file: "package.json",
|
|
974
3426
|
message: 'Added "test": "bun test".',
|
|
975
3427
|
};
|
|
@@ -981,7 +3433,7 @@ function unmatchedRouteDiagnostic(
|
|
|
981
3433
|
if (routeHandler.source === "next-route") {
|
|
982
3434
|
return {
|
|
983
3435
|
severity: "error",
|
|
984
|
-
code: "
|
|
3436
|
+
code: "BEIGNET_ROUTE_HANDLER_ORPHANED",
|
|
985
3437
|
file: routeHandler.handlerFile,
|
|
986
3438
|
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
3439
|
};
|
|
@@ -993,7 +3445,7 @@ function unmatchedRouteDiagnostic(
|
|
|
993
3445
|
|
|
994
3446
|
return {
|
|
995
3447
|
severity: "error",
|
|
996
|
-
code: "
|
|
3448
|
+
code: "BEIGNET_ROUTE_HANDLER_UNKNOWN_CONTRACT",
|
|
997
3449
|
file: routeHandler.handlerFile,
|
|
998
3450
|
contract: routeHandler.contractRef,
|
|
999
3451
|
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.`,
|
|
@@ -1025,7 +3477,9 @@ async function inspectOpenApiDrift(
|
|
|
1025
3477
|
targetDir: string,
|
|
1026
3478
|
files: string[],
|
|
1027
3479
|
config: ResolvedBeignetConfig,
|
|
3480
|
+
convention: InspectConvention,
|
|
1028
3481
|
contracts: InspectedContract[],
|
|
3482
|
+
matchedRoutes: MatchedRoutesResult,
|
|
1029
3483
|
): Promise<InspectDiagnostic[]> {
|
|
1030
3484
|
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
1031
3485
|
try {
|
|
@@ -1035,6 +3489,14 @@ async function inspectOpenApiDrift(
|
|
|
1035
3489
|
}
|
|
1036
3490
|
|
|
1037
3491
|
const source = await readFile(routePath, "utf8");
|
|
3492
|
+
const routeSurface = await routeSurfaceContracts(
|
|
3493
|
+
targetDir,
|
|
3494
|
+
files,
|
|
3495
|
+
config,
|
|
3496
|
+
convention,
|
|
3497
|
+
contracts,
|
|
3498
|
+
matchedRoutes,
|
|
3499
|
+
);
|
|
1038
3500
|
const listedContracts = await openApiContracts(
|
|
1039
3501
|
targetDir,
|
|
1040
3502
|
files,
|
|
@@ -1045,15 +3507,76 @@ async function inspectOpenApiDrift(
|
|
|
1045
3507
|
return [];
|
|
1046
3508
|
}
|
|
1047
3509
|
|
|
1048
|
-
|
|
1049
|
-
.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
3510
|
+
const contractByName = new Map(
|
|
3511
|
+
contracts.map((contract) => [contract.exportName, contract]),
|
|
3512
|
+
);
|
|
3513
|
+
const routeSurfaceNames = new Set(
|
|
3514
|
+
routeSurface.map((contract) => contract.exportName),
|
|
3515
|
+
);
|
|
3516
|
+
|
|
3517
|
+
return [
|
|
3518
|
+
...routeSurface
|
|
3519
|
+
.filter((contract) => !listedContracts.has(contract.exportName))
|
|
3520
|
+
.map((contract) => ({
|
|
3521
|
+
severity: "warning" as const,
|
|
3522
|
+
code: "BEIGNET_OPENAPI_MISSING",
|
|
3523
|
+
file: config.paths.openapiRoute,
|
|
3524
|
+
contract: contract.exportName,
|
|
3525
|
+
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.`,
|
|
3526
|
+
})),
|
|
3527
|
+
...[...listedContracts]
|
|
3528
|
+
.filter((contractName) => !routeSurfaceNames.has(contractName))
|
|
3529
|
+
.flatMap((contractName) => {
|
|
3530
|
+
const contract = contractByName.get(contractName);
|
|
3531
|
+
if (!contract) return [];
|
|
3532
|
+
|
|
3533
|
+
return [
|
|
3534
|
+
{
|
|
3535
|
+
severity: "warning" as const,
|
|
3536
|
+
code: "BEIGNET_OPENAPI_UNROUTED",
|
|
3537
|
+
file: config.paths.openapiRoute,
|
|
3538
|
+
contract: contract.exportName,
|
|
3539
|
+
message: `${contract.exportName} (${contract.method} ${contract.path}) is listed in createOpenAPIHandler, but it is not part of the registered route surface. Register the feature route group or remove the contract from OpenAPI.`,
|
|
3540
|
+
},
|
|
3541
|
+
];
|
|
3542
|
+
}),
|
|
3543
|
+
];
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
async function routeSurfaceContracts(
|
|
3547
|
+
targetDir: string,
|
|
3548
|
+
files: string[],
|
|
3549
|
+
config: ResolvedBeignetConfig,
|
|
3550
|
+
convention: InspectConvention,
|
|
3551
|
+
contracts: InspectedContract[],
|
|
3552
|
+
matchedRoutes: MatchedRoutesResult,
|
|
3553
|
+
): Promise<InspectedContract[]> {
|
|
3554
|
+
if (convention.resourceGenerator && files.includes(config.paths.server)) {
|
|
3555
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
3556
|
+
const serverSource = await readFile(
|
|
3557
|
+
path.join(targetDir, config.paths.server),
|
|
3558
|
+
"utf8",
|
|
3559
|
+
);
|
|
3560
|
+
const registeredGroups = await registeredRouteGroupsForServer(
|
|
3561
|
+
targetDir,
|
|
3562
|
+
files,
|
|
3563
|
+
config,
|
|
3564
|
+
serverSource,
|
|
3565
|
+
);
|
|
3566
|
+
const routeGroupContracts = contractsForRegisteredRouteGroups(
|
|
3567
|
+
contracts,
|
|
3568
|
+
routeGroups,
|
|
3569
|
+
registeredGroups,
|
|
3570
|
+
);
|
|
3571
|
+
|
|
3572
|
+
if (routeGroups.length > 0) {
|
|
3573
|
+
return routeGroupContracts;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
return matchedRoutes.routes.filter(
|
|
3578
|
+
(route) => route.handlerSource !== "missing",
|
|
3579
|
+
);
|
|
1057
3580
|
}
|
|
1058
3581
|
|
|
1059
3582
|
async function inspectFeatureRouteRegistration(
|
|
@@ -1080,6 +3603,44 @@ async function inspectFeatureRouteRegistration(
|
|
|
1080
3603
|
serverSource,
|
|
1081
3604
|
);
|
|
1082
3605
|
const diagnostics: InspectDiagnostic[] = [];
|
|
3606
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
3607
|
+
|
|
3608
|
+
for (const contract of contracts) {
|
|
3609
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
3610
|
+
if (!feature) continue;
|
|
3611
|
+
|
|
3612
|
+
const routeFile = `${featuresPath}/${feature}/routes.ts`;
|
|
3613
|
+
const featureRouteGroups = routeGroups.filter(
|
|
3614
|
+
(routeGroup) => routeGroup.file === routeFile,
|
|
3615
|
+
);
|
|
3616
|
+
|
|
3617
|
+
if (featureRouteGroups.length === 0) {
|
|
3618
|
+
diagnostics.push({
|
|
3619
|
+
severity: "error",
|
|
3620
|
+
code: "BEIGNET_FEATURE_ROUTE_GROUP_MISSING",
|
|
3621
|
+
file: contract.file,
|
|
3622
|
+
contract: contract.exportName,
|
|
3623
|
+
message: `${contract.exportName} (${contract.method} ${contract.path}) is declared in ${contract.file}, but ${routeFile} is missing. Add a feature route group that maps the contract to a use case, or remove the unused contract.`,
|
|
3624
|
+
});
|
|
3625
|
+
continue;
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
const routeGroupHasContract = featureRouteGroups.some((routeGroup) =>
|
|
3629
|
+
routeGroup.contracts.some(
|
|
3630
|
+
(routeGroupContract) =>
|
|
3631
|
+
findRouteGroupContract([contract], routeGroupContract) !== undefined,
|
|
3632
|
+
),
|
|
3633
|
+
);
|
|
3634
|
+
if (routeGroupHasContract) continue;
|
|
3635
|
+
|
|
3636
|
+
diagnostics.push({
|
|
3637
|
+
severity: "error",
|
|
3638
|
+
code: "BEIGNET_ROUTE_GROUP_CONTRACT_MISSING",
|
|
3639
|
+
file: routeFile,
|
|
3640
|
+
contract: contract.exportName,
|
|
3641
|
+
message: `${contract.exportName} (${contract.method} ${contract.path}) is declared in ${contract.file}, but no route in ${routeFile} references it. Add the contract to the feature route group or remove the unused contract.`,
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
1083
3644
|
|
|
1084
3645
|
for (const routeGroup of routeGroups) {
|
|
1085
3646
|
if (registeredGroups.has(routeGroup.name)) continue;
|
|
@@ -1088,7 +3649,7 @@ async function inspectFeatureRouteRegistration(
|
|
|
1088
3649
|
const contract = findRouteGroupContract(contracts, routeGroupContract);
|
|
1089
3650
|
diagnostics.push({
|
|
1090
3651
|
severity: "error",
|
|
1091
|
-
code: "
|
|
3652
|
+
code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
|
|
1092
3653
|
file: config.paths.server,
|
|
1093
3654
|
contract: routeGroupContract.exportName,
|
|
1094
3655
|
message: contract
|
|
@@ -1101,6 +3662,32 @@ async function inspectFeatureRouteRegistration(
|
|
|
1101
3662
|
return diagnostics;
|
|
1102
3663
|
}
|
|
1103
3664
|
|
|
3665
|
+
function contractsForRegisteredRouteGroups(
|
|
3666
|
+
contracts: InspectedContract[],
|
|
3667
|
+
routeGroups: FeatureRouteGroup[],
|
|
3668
|
+
registeredGroups: Set<string>,
|
|
3669
|
+
): InspectedContract[] {
|
|
3670
|
+
const seen = new Set<string>();
|
|
3671
|
+
const registeredContracts: InspectedContract[] = [];
|
|
3672
|
+
|
|
3673
|
+
for (const routeGroup of routeGroups) {
|
|
3674
|
+
if (!registeredGroups.has(routeGroup.name)) continue;
|
|
3675
|
+
|
|
3676
|
+
for (const routeGroupContract of routeGroup.contracts) {
|
|
3677
|
+
const contract = findRouteGroupContract(contracts, routeGroupContract);
|
|
3678
|
+
if (!contract) continue;
|
|
3679
|
+
|
|
3680
|
+
const key = contractKey(contract);
|
|
3681
|
+
if (seen.has(key)) continue;
|
|
3682
|
+
|
|
3683
|
+
seen.add(key);
|
|
3684
|
+
registeredContracts.push(contract);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
return registeredContracts.sort(compareContracts);
|
|
3689
|
+
}
|
|
3690
|
+
|
|
1104
3691
|
async function readFeatureRouteGroups(
|
|
1105
3692
|
targetDir: string,
|
|
1106
3693
|
files: string[],
|
|
@@ -1128,10 +3715,14 @@ function parseFeatureRouteGroups(
|
|
|
1128
3715
|
): FeatureRouteGroup[] {
|
|
1129
3716
|
const routeGroups: FeatureRouteGroup[] = [];
|
|
1130
3717
|
const exportRegex =
|
|
1131
|
-
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(
|
|
3718
|
+
/(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(/g;
|
|
1132
3719
|
|
|
1133
3720
|
for (const match of source.matchAll(exportRegex)) {
|
|
1134
|
-
const
|
|
3721
|
+
const callStart = (match.index ?? 0) + match[0].length;
|
|
3722
|
+
const routesBody = routeGroupRoutesArrayBody(source, callStart);
|
|
3723
|
+
if (routesBody === undefined) continue;
|
|
3724
|
+
|
|
3725
|
+
const contracts = [...routesBody.matchAll(/contract:\s*([^,\n}]+)/g)]
|
|
1135
3726
|
.map((contractMatch) =>
|
|
1136
3727
|
routeGroupContractRef(contractMatch[1].trim(), imports),
|
|
1137
3728
|
)
|
|
@@ -1139,14 +3730,87 @@ function parseFeatureRouteGroups(
|
|
|
1139
3730
|
Boolean(contract),
|
|
1140
3731
|
);
|
|
1141
3732
|
|
|
1142
|
-
routeGroups.push({
|
|
1143
|
-
name: match[1],
|
|
1144
|
-
file,
|
|
1145
|
-
contracts,
|
|
1146
|
-
});
|
|
3733
|
+
routeGroups.push({
|
|
3734
|
+
name: match[1],
|
|
3735
|
+
file,
|
|
3736
|
+
contracts,
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
return routeGroups;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
/**
|
|
3744
|
+
* Extract the body of the `routes: [...]` array for a route group call.
|
|
3745
|
+
*
|
|
3746
|
+
* Both binder routes (`{ contract, useCase }`) and full handler routes
|
|
3747
|
+
* (`{ contract, handle }`) can contain nested brackets, strings, and arrow
|
|
3748
|
+
* functions, so the array is scanned with balanced-bracket tracking instead
|
|
3749
|
+
* of a regular expression.
|
|
3750
|
+
*/
|
|
3751
|
+
function routeGroupRoutesArrayBody(
|
|
3752
|
+
source: string,
|
|
3753
|
+
callStart: number,
|
|
3754
|
+
): string | undefined {
|
|
3755
|
+
const objectStart = source.indexOf("{", callStart);
|
|
3756
|
+
if (objectStart === -1) return undefined;
|
|
3757
|
+
|
|
3758
|
+
const groupObject = balancedObjectSource(source, objectStart);
|
|
3759
|
+
if (!groupObject) return undefined;
|
|
3760
|
+
|
|
3761
|
+
const routesIndex = groupObject.indexOf("routes:");
|
|
3762
|
+
if (routesIndex === -1) return undefined;
|
|
3763
|
+
|
|
3764
|
+
const arrayStart = groupObject.indexOf("[", routesIndex);
|
|
3765
|
+
if (arrayStart === -1) return undefined;
|
|
3766
|
+
|
|
3767
|
+
const arraySource = balancedArraySource(groupObject, arrayStart);
|
|
3768
|
+
return arraySource?.slice(1, -1);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
function balancedArraySource(
|
|
3772
|
+
source: string,
|
|
3773
|
+
startIndex: number,
|
|
3774
|
+
): string | undefined {
|
|
3775
|
+
if (source[startIndex] !== "[") return undefined;
|
|
3776
|
+
|
|
3777
|
+
let depth = 0;
|
|
3778
|
+
let quote: '"' | "'" | "`" | undefined;
|
|
3779
|
+
let escaped = false;
|
|
3780
|
+
|
|
3781
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
3782
|
+
const char = source[index];
|
|
3783
|
+
|
|
3784
|
+
if (quote) {
|
|
3785
|
+
if (escaped) {
|
|
3786
|
+
escaped = false;
|
|
3787
|
+
continue;
|
|
3788
|
+
}
|
|
3789
|
+
if (char === "\\") {
|
|
3790
|
+
escaped = true;
|
|
3791
|
+
continue;
|
|
3792
|
+
}
|
|
3793
|
+
if (char === quote) quote = undefined;
|
|
3794
|
+
continue;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
3798
|
+
quote = char;
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
if (char === "[") {
|
|
3803
|
+
depth += 1;
|
|
3804
|
+
continue;
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
if (char === "]") {
|
|
3808
|
+
depth -= 1;
|
|
3809
|
+
if (depth === 0) return source.slice(startIndex, index + 1);
|
|
3810
|
+
}
|
|
1147
3811
|
}
|
|
1148
3812
|
|
|
1149
|
-
return
|
|
3813
|
+
return undefined;
|
|
1150
3814
|
}
|
|
1151
3815
|
|
|
1152
3816
|
function routeGroupContractRef(
|
|
@@ -1250,8 +3914,11 @@ function findRouteGroupContract(
|
|
|
1250
3914
|
|
|
1251
3915
|
async function fixDirectOpenApiArrayDrift(
|
|
1252
3916
|
targetDir: string,
|
|
3917
|
+
files: string[],
|
|
1253
3918
|
config: ResolvedBeignetConfig,
|
|
3919
|
+
convention: InspectConvention,
|
|
1254
3920
|
contracts: InspectedContract[],
|
|
3921
|
+
matchedRoutes: MatchedRoutesResult,
|
|
1255
3922
|
): Promise<InspectFix | undefined> {
|
|
1256
3923
|
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
1257
3924
|
let source: string;
|
|
@@ -1264,8 +3931,16 @@ async function fixDirectOpenApiArrayDrift(
|
|
|
1264
3931
|
const firstArg = firstCreateOpenAPIHandlerArgInfo(source);
|
|
1265
3932
|
if (!firstArg?.text.startsWith("[")) return undefined;
|
|
1266
3933
|
|
|
3934
|
+
const routeSurface = await routeSurfaceContracts(
|
|
3935
|
+
targetDir,
|
|
3936
|
+
files,
|
|
3937
|
+
config,
|
|
3938
|
+
convention,
|
|
3939
|
+
contracts,
|
|
3940
|
+
matchedRoutes,
|
|
3941
|
+
);
|
|
1267
3942
|
const listedContracts = contractsFromArrayExpression(firstArg.text);
|
|
1268
|
-
const missingContracts =
|
|
3943
|
+
const missingContracts = routeSurface.filter(
|
|
1269
3944
|
(contract) => !listedContracts.has(contract.exportName),
|
|
1270
3945
|
);
|
|
1271
3946
|
if (missingContracts.length === 0) return undefined;
|
|
@@ -1289,7 +3964,7 @@ async function fixDirectOpenApiArrayDrift(
|
|
|
1289
3964
|
|
|
1290
3965
|
await writeFile(routePath, next);
|
|
1291
3966
|
return {
|
|
1292
|
-
code: "
|
|
3967
|
+
code: "BEIGNET_OPENAPI_MISSING",
|
|
1293
3968
|
file: config.paths.openapiRoute,
|
|
1294
3969
|
message: `Added ${missingContracts
|
|
1295
3970
|
.map((contract) => contract.exportName)
|
|
@@ -1297,6 +3972,113 @@ async function fixDirectOpenApiArrayDrift(
|
|
|
1297
3972
|
};
|
|
1298
3973
|
}
|
|
1299
3974
|
|
|
3975
|
+
async function fixUnregisteredRouteGroups(
|
|
3976
|
+
targetDir: string,
|
|
3977
|
+
files: string[],
|
|
3978
|
+
config: ResolvedBeignetConfig,
|
|
3979
|
+
): Promise<InspectFix | undefined> {
|
|
3980
|
+
if (!files.includes(config.paths.server)) return undefined;
|
|
3981
|
+
|
|
3982
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
3983
|
+
if (routeGroups.length === 0) return undefined;
|
|
3984
|
+
|
|
3985
|
+
const serverSource = await readFile(
|
|
3986
|
+
path.join(targetDir, config.paths.server),
|
|
3987
|
+
"utf8",
|
|
3988
|
+
);
|
|
3989
|
+
const registeredGroups = await registeredRouteGroupsForServer(
|
|
3990
|
+
targetDir,
|
|
3991
|
+
files,
|
|
3992
|
+
config,
|
|
3993
|
+
serverSource,
|
|
3994
|
+
);
|
|
3995
|
+
const missingGroups = routeGroups
|
|
3996
|
+
.filter((routeGroup) => !registeredGroups.has(routeGroup.name))
|
|
3997
|
+
.sort((left, right) => left.file.localeCompare(right.file));
|
|
3998
|
+
if (missingGroups.length === 0) return undefined;
|
|
3999
|
+
|
|
4000
|
+
const targetFile =
|
|
4001
|
+
routeRegistryFileFromServerSource(
|
|
4002
|
+
serverSource,
|
|
4003
|
+
config.paths.server,
|
|
4004
|
+
files,
|
|
4005
|
+
) ?? config.paths.server;
|
|
4006
|
+
if (!files.includes(targetFile)) return undefined;
|
|
4007
|
+
|
|
4008
|
+
const targetPath = path.join(targetDir, targetFile);
|
|
4009
|
+
const original = await readFile(targetPath, "utf8");
|
|
4010
|
+
const firstArg = firstDefineRoutesArgInfo(original);
|
|
4011
|
+
if (!firstArg?.text.startsWith("[")) return undefined;
|
|
4012
|
+
|
|
4013
|
+
const registeredInTarget = contractsFromArrayExpression(firstArg.text);
|
|
4014
|
+
const groupsToAppend = missingGroups.filter(
|
|
4015
|
+
(routeGroup) => !registeredInTarget.has(routeGroup.name),
|
|
4016
|
+
);
|
|
4017
|
+
if (groupsToAppend.length === 0) return undefined;
|
|
4018
|
+
|
|
4019
|
+
let next = original;
|
|
4020
|
+
for (const routeGroup of groupsToAppend) {
|
|
4021
|
+
const imported = parseNamedImportSources(next).get(routeGroup.name);
|
|
4022
|
+
if (imported) {
|
|
4023
|
+
const importedFile = sourceFileFromImport(
|
|
4024
|
+
imported.sourcePath,
|
|
4025
|
+
targetFile,
|
|
4026
|
+
files,
|
|
4027
|
+
);
|
|
4028
|
+
if (importedFile !== routeGroup.file) return undefined;
|
|
4029
|
+
continue;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
next = insertAfterImports(
|
|
4033
|
+
next,
|
|
4034
|
+
`import { ${routeGroup.name} } from "${relativeModule(targetFile, routeGroup.file)}";`,
|
|
4035
|
+
);
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
const nextFirstArg = firstDefineRoutesArgInfo(next);
|
|
4039
|
+
if (!nextFirstArg?.text.startsWith("[")) return undefined;
|
|
4040
|
+
|
|
4041
|
+
const nextArray = appendToArrayExpression(
|
|
4042
|
+
nextFirstArg.text,
|
|
4043
|
+
groupsToAppend.map((routeGroup) => routeGroup.name),
|
|
4044
|
+
);
|
|
4045
|
+
next = `${next.slice(0, nextFirstArg.start)}${nextArray}${next.slice(
|
|
4046
|
+
nextFirstArg.end,
|
|
4047
|
+
)}`;
|
|
4048
|
+
if (next === original) return undefined;
|
|
4049
|
+
|
|
4050
|
+
await writeFile(targetPath, next);
|
|
4051
|
+
return {
|
|
4052
|
+
code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
|
|
4053
|
+
file: targetFile,
|
|
4054
|
+
message: `Registered ${groupsToAppend
|
|
4055
|
+
.map((routeGroup) => routeGroup.name)
|
|
4056
|
+
.join(", ")} in the central route list.`,
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
function routeRegistryFileFromServerSource(
|
|
4061
|
+
source: string,
|
|
4062
|
+
serverFile: string,
|
|
4063
|
+
files: string[],
|
|
4064
|
+
): string | undefined {
|
|
4065
|
+
const imports = parseNamedImportSources(source);
|
|
4066
|
+
|
|
4067
|
+
for (const identifier of routeOptionIdentifiers(source)) {
|
|
4068
|
+
const imported = imports.get(identifier);
|
|
4069
|
+
if (!imported) continue;
|
|
4070
|
+
|
|
4071
|
+
const importedFile = sourceFileFromImport(
|
|
4072
|
+
imported.sourcePath,
|
|
4073
|
+
serverFile,
|
|
4074
|
+
files,
|
|
4075
|
+
);
|
|
4076
|
+
if (importedFile && files.includes(importedFile)) return importedFile;
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
return undefined;
|
|
4080
|
+
}
|
|
4081
|
+
|
|
1300
4082
|
async function openApiContracts(
|
|
1301
4083
|
targetDir: string,
|
|
1302
4084
|
files: string[],
|
|
@@ -1313,6 +4095,16 @@ async function openApiContracts(
|
|
|
1313
4095
|
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(firstArg)?.[1];
|
|
1314
4096
|
if (!identifier) return undefined;
|
|
1315
4097
|
|
|
4098
|
+
const routeContracts = await contractsFromRoutesExport(
|
|
4099
|
+
targetDir,
|
|
4100
|
+
files,
|
|
4101
|
+
config,
|
|
4102
|
+
config.paths.openapiRoute,
|
|
4103
|
+
source,
|
|
4104
|
+
identifier,
|
|
4105
|
+
);
|
|
4106
|
+
if (routeContracts) return routeContracts;
|
|
4107
|
+
|
|
1316
4108
|
const imports = parseNamedImports(source, config);
|
|
1317
4109
|
const imported = imports.get(identifier);
|
|
1318
4110
|
const listFile = imported?.contractFile ?? config.paths.openapiRoute;
|
|
@@ -1324,20 +4116,143 @@ async function openApiContracts(
|
|
|
1324
4116
|
);
|
|
1325
4117
|
}
|
|
1326
4118
|
|
|
4119
|
+
async function contractsFromRoutesExport(
|
|
4120
|
+
targetDir: string,
|
|
4121
|
+
files: string[],
|
|
4122
|
+
config: ResolvedBeignetConfig,
|
|
4123
|
+
importerFile: string,
|
|
4124
|
+
source: string,
|
|
4125
|
+
identifier: string,
|
|
4126
|
+
): Promise<Set<string> | undefined> {
|
|
4127
|
+
const localRouteContracts = await contractsFromRoutesSource(
|
|
4128
|
+
targetDir,
|
|
4129
|
+
files,
|
|
4130
|
+
config,
|
|
4131
|
+
importerFile,
|
|
4132
|
+
source,
|
|
4133
|
+
identifier,
|
|
4134
|
+
);
|
|
4135
|
+
if (localRouteContracts) return localRouteContracts;
|
|
4136
|
+
|
|
4137
|
+
const imports = parseNamedImportSources(source);
|
|
4138
|
+
const imported = imports.get(identifier);
|
|
4139
|
+
if (!imported) return undefined;
|
|
4140
|
+
|
|
4141
|
+
const importedFile = sourceFileFromImport(
|
|
4142
|
+
imported.sourcePath,
|
|
4143
|
+
importerFile,
|
|
4144
|
+
files,
|
|
4145
|
+
);
|
|
4146
|
+
if (!importedFile || !files.includes(importedFile)) return undefined;
|
|
4147
|
+
|
|
4148
|
+
const importedSource = await readFile(path.join(targetDir, importedFile), {
|
|
4149
|
+
encoding: "utf8",
|
|
4150
|
+
});
|
|
4151
|
+
return contractsFromRoutesSource(
|
|
4152
|
+
targetDir,
|
|
4153
|
+
files,
|
|
4154
|
+
config,
|
|
4155
|
+
importedFile,
|
|
4156
|
+
importedSource,
|
|
4157
|
+
imported.importedName,
|
|
4158
|
+
);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
async function contractsFromRoutesSource(
|
|
4162
|
+
targetDir: string,
|
|
4163
|
+
files: string[],
|
|
4164
|
+
config: ResolvedBeignetConfig,
|
|
4165
|
+
file: string,
|
|
4166
|
+
source: string,
|
|
4167
|
+
exportName: string,
|
|
4168
|
+
): Promise<Set<string> | undefined> {
|
|
4169
|
+
const routeIdentifier = contractsFromRoutesIdentifier(source, exportName);
|
|
4170
|
+
if (!routeIdentifier) return undefined;
|
|
4171
|
+
|
|
4172
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
4173
|
+
const registeredGroups = await registeredRouteGroupsForSource(
|
|
4174
|
+
targetDir,
|
|
4175
|
+
files,
|
|
4176
|
+
file,
|
|
4177
|
+
source,
|
|
4178
|
+
routeIdentifier,
|
|
4179
|
+
);
|
|
4180
|
+
const registeredContracts = contractsForRegisteredRouteGroups(
|
|
4181
|
+
await readContracts(targetDir, files, config),
|
|
4182
|
+
routeGroups,
|
|
4183
|
+
registeredGroups,
|
|
4184
|
+
);
|
|
4185
|
+
|
|
4186
|
+
return new Set(registeredContracts.map((contract) => contract.exportName));
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
function contractsFromRoutesIdentifier(
|
|
4190
|
+
source: string,
|
|
4191
|
+
exportName: string,
|
|
4192
|
+
): string | undefined {
|
|
4193
|
+
return new RegExp(
|
|
4194
|
+
`(?:export\\s+)?const\\s+${escapeRegExp(exportName)}\\s*=\\s*contractsFromRoutes(?:<[^>]+>)?\\(\\s*([A-Za-z_$][\\w$]*)\\s*\\)`,
|
|
4195
|
+
).exec(source)?.[1];
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
async function registeredRouteGroupsForSource(
|
|
4199
|
+
targetDir: string,
|
|
4200
|
+
files: string[],
|
|
4201
|
+
file: string,
|
|
4202
|
+
source: string,
|
|
4203
|
+
routeIdentifier: string,
|
|
4204
|
+
): Promise<Set<string>> {
|
|
4205
|
+
if (
|
|
4206
|
+
new RegExp(
|
|
4207
|
+
`(?:export\\s+)?const\\s+${escapeRegExp(routeIdentifier)}\\s*=\\s*defineRoutes`,
|
|
4208
|
+
).test(source)
|
|
4209
|
+
) {
|
|
4210
|
+
return registeredRouteGroups(source);
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
const imports = parseNamedImportSources(source);
|
|
4214
|
+
const imported = imports.get(routeIdentifier);
|
|
4215
|
+
if (!imported) return registeredRouteGroups(source);
|
|
4216
|
+
|
|
4217
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, file, files);
|
|
4218
|
+
if (!importedFile || !files.includes(importedFile)) {
|
|
4219
|
+
return registeredRouteGroups(source);
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
return registeredRouteGroups(
|
|
4223
|
+
await readFile(path.join(targetDir, importedFile), "utf8"),
|
|
4224
|
+
);
|
|
4225
|
+
}
|
|
4226
|
+
|
|
1327
4227
|
function firstCreateOpenAPIHandlerArg(source: string): string | undefined {
|
|
1328
4228
|
return firstCreateOpenAPIHandlerArgInfo(source)?.text;
|
|
1329
4229
|
}
|
|
1330
4230
|
|
|
4231
|
+
function firstDefineRoutesArgInfo(
|
|
4232
|
+
source: string,
|
|
4233
|
+
): { text: string; start: number; end: number } | undefined {
|
|
4234
|
+
const start = /defineRoutes(?:<[^>]+>)?\(/.exec(source);
|
|
4235
|
+
if (!start) return undefined;
|
|
4236
|
+
|
|
4237
|
+
return firstCallArgInfo(source, start.index + start[0].length);
|
|
4238
|
+
}
|
|
4239
|
+
|
|
1331
4240
|
function firstCreateOpenAPIHandlerArgInfo(
|
|
1332
4241
|
source: string,
|
|
1333
4242
|
): { text: string; start: number; end: number } | undefined {
|
|
1334
4243
|
const start = source.indexOf("createOpenAPIHandler(");
|
|
1335
4244
|
if (start === -1) return undefined;
|
|
1336
4245
|
|
|
4246
|
+
return firstCallArgInfo(source, start + "createOpenAPIHandler(".length);
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
function firstCallArgInfo(
|
|
4250
|
+
source: string,
|
|
4251
|
+
argStart: number,
|
|
4252
|
+
): { text: string; start: number; end: number } | undefined {
|
|
1337
4253
|
let depth = 0;
|
|
1338
4254
|
let inString: string | undefined;
|
|
1339
4255
|
let escaped = false;
|
|
1340
|
-
const argStart = start + "createOpenAPIHandler(".length;
|
|
1341
4256
|
|
|
1342
4257
|
for (let index = argStart; index < source.length; index++) {
|
|
1343
4258
|
const char = source[index];
|
|
@@ -1441,6 +4356,24 @@ function appendToArrayExpression(expression: string, names: string[]): string {
|
|
|
1441
4356
|
return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
|
|
1442
4357
|
}
|
|
1443
4358
|
|
|
4359
|
+
function insertAfterImports(source: string, line: string): string {
|
|
4360
|
+
const lines = source.split("\n");
|
|
4361
|
+
let lastImportIndex = -1;
|
|
4362
|
+
|
|
4363
|
+
for (let index = 0; index < lines.length; index++) {
|
|
4364
|
+
if (lines[index].startsWith("import ")) {
|
|
4365
|
+
lastImportIndex = index;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
if (lastImportIndex === -1) {
|
|
4370
|
+
return `${line}\n${source}`;
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
lines.splice(lastImportIndex + 1, 0, line);
|
|
4374
|
+
return lines.join("\n");
|
|
4375
|
+
}
|
|
4376
|
+
|
|
1444
4377
|
function hasImportedIdentifier(source: string, identifier: string): boolean {
|
|
1445
4378
|
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
|
|
1446
4379
|
|
|
@@ -1454,6 +4387,22 @@ function hasImportedIdentifier(source: string, identifier: string): boolean {
|
|
|
1454
4387
|
return false;
|
|
1455
4388
|
}
|
|
1456
4389
|
|
|
4390
|
+
function relativeModule(fromFile: string, toFile: string): string {
|
|
4391
|
+
const fromDir = path.posix.dirname(normalizePath(fromFile));
|
|
4392
|
+
const relative = path.posix.relative(fromDir, normalizePath(toFile));
|
|
4393
|
+
const specifier = modulePath(relative);
|
|
4394
|
+
|
|
4395
|
+
if (specifier.startsWith("../")) return specifier;
|
|
4396
|
+
if (specifier === "..") return specifier;
|
|
4397
|
+
return specifier.startsWith(".") ? specifier : `./${specifier}`;
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
function modulePath(filePath: string): string {
|
|
4401
|
+
return directoryPath(filePath)
|
|
4402
|
+
.replace(/\.(?:[cm]?[jt]sx?)$/, "")
|
|
4403
|
+
.replace(/\/index$/, "");
|
|
4404
|
+
}
|
|
4405
|
+
|
|
1457
4406
|
async function readContractLists(
|
|
1458
4407
|
targetDir: string,
|
|
1459
4408
|
files: string[],
|
|
@@ -1613,12 +4562,40 @@ async function inspectResourceSlices(
|
|
|
1613
4562
|
|
|
1614
4563
|
for (const resource of resources) {
|
|
1615
4564
|
const singular = singularize(resource);
|
|
4565
|
+
const pluralCamel = camelCase(resource);
|
|
4566
|
+
const singularPascal = pascalCase(singular);
|
|
1616
4567
|
const contractFile = resourceContractFile(resource, config);
|
|
1617
4568
|
const useCaseDir = resourceUseCaseDir(resource, config);
|
|
1618
4569
|
const portFile = resourcePortFile(resource, singular, config);
|
|
4570
|
+
const featureRouteFile = path.join(
|
|
4571
|
+
config.paths.features,
|
|
4572
|
+
resource,
|
|
4573
|
+
"routes.ts",
|
|
4574
|
+
);
|
|
4575
|
+
const sharedErrorsFile = path.join(
|
|
4576
|
+
config.paths.features,
|
|
4577
|
+
"shared",
|
|
4578
|
+
"errors.ts",
|
|
4579
|
+
);
|
|
1619
4580
|
const infrastructureDir = `${directoryPath(
|
|
1620
4581
|
path.dirname(config.paths.infrastructurePorts),
|
|
1621
4582
|
)}/${resource}/`;
|
|
4583
|
+
const drizzleSchemaFile = path.join(
|
|
4584
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
4585
|
+
"db",
|
|
4586
|
+
"schema",
|
|
4587
|
+
`${resource}.ts`,
|
|
4588
|
+
);
|
|
4589
|
+
const drizzleRepositoryFile = path.join(
|
|
4590
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
4591
|
+
resource,
|
|
4592
|
+
`drizzle-${singular}-repository.ts`,
|
|
4593
|
+
);
|
|
4594
|
+
const memoryRepositoryFile = path.join(
|
|
4595
|
+
path.dirname(config.paths.infrastructurePorts),
|
|
4596
|
+
resource,
|
|
4597
|
+
`in-memory-${singular}-repository.ts`,
|
|
4598
|
+
);
|
|
1622
4599
|
const routeFile = path.join(config.paths.routes, resource, "route.ts");
|
|
1623
4600
|
const catchAllRouteFile = path.join(
|
|
1624
4601
|
config.paths.routes,
|
|
@@ -1630,7 +4607,7 @@ async function inspectResourceSlices(
|
|
|
1630
4607
|
if (!hasRequirement(files, contractFile)) {
|
|
1631
4608
|
diagnostics.push(
|
|
1632
4609
|
resourceDiagnostic(
|
|
1633
|
-
"
|
|
4610
|
+
"BEIGNET_RESOURCE_CONTRACT_MISSING",
|
|
1634
4611
|
resource,
|
|
1635
4612
|
contractFile,
|
|
1636
4613
|
),
|
|
@@ -1639,7 +4616,7 @@ async function inspectResourceSlices(
|
|
|
1639
4616
|
if (!hasRequirement(files, useCaseDir)) {
|
|
1640
4617
|
diagnostics.push(
|
|
1641
4618
|
resourceDiagnostic(
|
|
1642
|
-
"
|
|
4619
|
+
"BEIGNET_RESOURCE_USE_CASES_MISSING",
|
|
1643
4620
|
resource,
|
|
1644
4621
|
useCaseDir,
|
|
1645
4622
|
),
|
|
@@ -1647,13 +4624,13 @@ async function inspectResourceSlices(
|
|
|
1647
4624
|
}
|
|
1648
4625
|
if (!hasRequirement(files, portFile)) {
|
|
1649
4626
|
diagnostics.push(
|
|
1650
|
-
resourceDiagnostic("
|
|
4627
|
+
resourceDiagnostic("BEIGNET_RESOURCE_PORT_MISSING", resource, portFile),
|
|
1651
4628
|
);
|
|
1652
4629
|
}
|
|
1653
4630
|
if (!hasRequirement(files, infrastructureDir)) {
|
|
1654
4631
|
diagnostics.push(
|
|
1655
4632
|
resourceDiagnostic(
|
|
1656
|
-
"
|
|
4633
|
+
"BEIGNET_RESOURCE_INFRASTRUCTURE_MISSING",
|
|
1657
4634
|
resource,
|
|
1658
4635
|
infrastructureDir,
|
|
1659
4636
|
),
|
|
@@ -1664,20 +4641,271 @@ async function inspectResourceSlices(
|
|
|
1664
4641
|
!hasRequirement(files, catchAllRouteFile)
|
|
1665
4642
|
) {
|
|
1666
4643
|
diagnostics.push(
|
|
1667
|
-
resourceDiagnostic(
|
|
4644
|
+
resourceDiagnostic(
|
|
4645
|
+
"BEIGNET_RESOURCE_ROUTE_MISSING",
|
|
4646
|
+
resource,
|
|
4647
|
+
routeFile,
|
|
4648
|
+
),
|
|
1668
4649
|
);
|
|
1669
4650
|
}
|
|
1670
4651
|
if (strict && !hasRequirement(files, testFile)) {
|
|
1671
4652
|
diagnostics.push({
|
|
1672
|
-
...resourceDiagnostic(
|
|
4653
|
+
...resourceDiagnostic(
|
|
4654
|
+
"BEIGNET_RESOURCE_TEST_MISSING",
|
|
4655
|
+
resource,
|
|
4656
|
+
testFile,
|
|
4657
|
+
),
|
|
4658
|
+
severity: "warning",
|
|
4659
|
+
});
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
const contractSource = await readSourceIfPresent(
|
|
4663
|
+
targetDir,
|
|
4664
|
+
files,
|
|
4665
|
+
contractFile,
|
|
4666
|
+
);
|
|
4667
|
+
const portSource = await readSourceIfPresent(targetDir, files, portFile);
|
|
4668
|
+
const routeSource = await readSourceIfPresent(
|
|
4669
|
+
targetDir,
|
|
4670
|
+
files,
|
|
4671
|
+
featureRouteFile,
|
|
4672
|
+
);
|
|
4673
|
+
const testSource = await readSourceIfPresent(targetDir, files, testFile);
|
|
4674
|
+
const drizzleSchemaSource = await readSourceIfPresent(
|
|
4675
|
+
targetDir,
|
|
4676
|
+
files,
|
|
4677
|
+
drizzleSchemaFile,
|
|
4678
|
+
);
|
|
4679
|
+
const drizzleRepositorySource = await readSourceIfPresent(
|
|
4680
|
+
targetDir,
|
|
4681
|
+
files,
|
|
4682
|
+
drizzleRepositoryFile,
|
|
4683
|
+
);
|
|
4684
|
+
const memoryRepositorySource = await readSourceIfPresent(
|
|
4685
|
+
targetDir,
|
|
4686
|
+
files,
|
|
4687
|
+
memoryRepositoryFile,
|
|
4688
|
+
);
|
|
4689
|
+
const sharedErrorsSource = await readSourceIfPresent(
|
|
4690
|
+
targetDir,
|
|
4691
|
+
files,
|
|
4692
|
+
sharedErrorsFile,
|
|
4693
|
+
);
|
|
4694
|
+
const crudUseCaseFiles = [
|
|
4695
|
+
path.join(useCaseDir, `get-${singular}.ts`),
|
|
4696
|
+
path.join(useCaseDir, `update-${singular}.ts`),
|
|
4697
|
+
path.join(useCaseDir, `delete-${singular}.ts`),
|
|
4698
|
+
];
|
|
4699
|
+
const hasCrudSignals =
|
|
4700
|
+
crudUseCaseFiles.slice(1).some((file) => files.includes(file)) ||
|
|
4701
|
+
sourceIncludesAny(contractSource, [
|
|
4702
|
+
`export const update${singularPascal}`,
|
|
4703
|
+
`export const delete${singularPascal}`,
|
|
4704
|
+
]) ||
|
|
4705
|
+
sourceIncludesAny(portSource, [
|
|
4706
|
+
`update(input: Update${singularPascal}Input)`,
|
|
4707
|
+
`update(input: Update${singularPascal}RepositoryInput)`,
|
|
4708
|
+
"delete(id: string): Promise<boolean>",
|
|
4709
|
+
]);
|
|
4710
|
+
|
|
4711
|
+
if (!hasCrudSignals) continue;
|
|
4712
|
+
|
|
4713
|
+
for (const exportName of [
|
|
4714
|
+
`get${singularPascal}`,
|
|
4715
|
+
`update${singularPascal}`,
|
|
4716
|
+
`delete${singularPascal}`,
|
|
4717
|
+
]) {
|
|
4718
|
+
if (contractSource?.includes(`export const ${exportName}`)) continue;
|
|
4719
|
+
|
|
4720
|
+
diagnostics.push({
|
|
4721
|
+
severity: "error",
|
|
4722
|
+
code: "BEIGNET_RESOURCE_CONTRACT_EXPORT_MISSING",
|
|
4723
|
+
file: contractFile,
|
|
4724
|
+
contract: exportName,
|
|
4725
|
+
message: `${resource} appears to be a CRUD resource, but ${contractFile} does not export ${exportName}. Restore the generated ${exportName} contract or remove the partial CRUD wiring.`,
|
|
4726
|
+
});
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
for (const file of crudUseCaseFiles) {
|
|
4730
|
+
if (files.includes(file)) continue;
|
|
4731
|
+
|
|
4732
|
+
diagnostics.push({
|
|
4733
|
+
severity: "error",
|
|
4734
|
+
code: "BEIGNET_RESOURCE_CRUD_USE_CASE_MISSING",
|
|
4735
|
+
file,
|
|
4736
|
+
message: `${resource} appears to be a CRUD resource, but ${file} is missing. Restore the generated CRUD use case or remove the partial CRUD wiring.`,
|
|
4737
|
+
});
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
const expectedRepositoryMethods = [
|
|
4741
|
+
{
|
|
4742
|
+
display: `findById(id: string): Promise<${singularPascal} | null>`,
|
|
4743
|
+
alternatives: [
|
|
4744
|
+
`findById(id: string): Promise<${singularPascal} | null>`,
|
|
4745
|
+
`findById(id: string, filter: ${singularPascal}TenantFilter): Promise<${singularPascal} | null>`,
|
|
4746
|
+
],
|
|
4747
|
+
},
|
|
4748
|
+
{
|
|
4749
|
+
display: `update(input: Update${singularPascal}Input)`,
|
|
4750
|
+
alternatives: [
|
|
4751
|
+
`update(input: Update${singularPascal}Input)`,
|
|
4752
|
+
`update(input: Update${singularPascal}RepositoryInput)`,
|
|
4753
|
+
],
|
|
4754
|
+
},
|
|
4755
|
+
{
|
|
4756
|
+
display: "delete(id: string): Promise<boolean>",
|
|
4757
|
+
alternatives: [
|
|
4758
|
+
"delete(id: string): Promise<boolean>",
|
|
4759
|
+
`delete(id: string, filter: ${singularPascal}TenantFilter): Promise<boolean>`,
|
|
4760
|
+
],
|
|
4761
|
+
},
|
|
4762
|
+
];
|
|
4763
|
+
|
|
4764
|
+
for (const method of expectedRepositoryMethods) {
|
|
4765
|
+
if (sourceIncludesAny(portSource, method.alternatives)) continue;
|
|
4766
|
+
|
|
4767
|
+
diagnostics.push({
|
|
4768
|
+
severity: "error",
|
|
4769
|
+
code: "BEIGNET_RESOURCE_REPOSITORY_METHOD_MISSING",
|
|
4770
|
+
file: portFile,
|
|
4771
|
+
message: `${resource} appears to be a CRUD resource, but ${portFile} does not declare ${method.display}. Restore the generated repository method or remove the partial CRUD wiring.`,
|
|
4772
|
+
});
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
for (const exportName of [
|
|
4776
|
+
`get${singularPascal}`,
|
|
4777
|
+
`update${singularPascal}`,
|
|
4778
|
+
`delete${singularPascal}`,
|
|
4779
|
+
]) {
|
|
4780
|
+
if (routeSource?.includes(`contract: ${exportName}`)) continue;
|
|
4781
|
+
|
|
4782
|
+
diagnostics.push({
|
|
4783
|
+
severity: "error",
|
|
4784
|
+
code: "BEIGNET_RESOURCE_ROUTE_CONTRACT_MISSING",
|
|
4785
|
+
file: featureRouteFile,
|
|
4786
|
+
contract: exportName,
|
|
4787
|
+
message: `${resource} appears to be a CRUD resource, but ${featureRouteFile} does not route ${exportName}. Add the contract to the feature route group or remove the partial CRUD wiring.`,
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
const notFoundError = `${singularPascal}NotFound`;
|
|
4792
|
+
const conflictError = `${singularPascal}Conflict`;
|
|
4793
|
+
if (!sharedErrorsSource?.includes(notFoundError)) {
|
|
4794
|
+
diagnostics.push({
|
|
4795
|
+
severity: "error",
|
|
4796
|
+
code: "BEIGNET_RESOURCE_ERROR_CATALOG_MISSING",
|
|
4797
|
+
file: sharedErrorsFile,
|
|
4798
|
+
message: `${resource} appears to be a CRUD resource, but ${sharedErrorsFile} does not declare ${notFoundError}. Add the generated not-found catalog error or remove the partial CRUD wiring.`,
|
|
4799
|
+
});
|
|
4800
|
+
}
|
|
4801
|
+
if (!sharedErrorsSource?.includes(conflictError)) {
|
|
4802
|
+
diagnostics.push({
|
|
4803
|
+
severity: "error",
|
|
4804
|
+
code: "BEIGNET_RESOURCE_ERROR_CATALOG_MISSING",
|
|
4805
|
+
file: sharedErrorsFile,
|
|
4806
|
+
message: `${resource} appears to be a CRUD resource, but ${sharedErrorsFile} does not declare ${conflictError}. Add the generated conflict catalog error or remove the partial CRUD wiring.`,
|
|
4807
|
+
});
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4810
|
+
if (
|
|
4811
|
+
strict &&
|
|
4812
|
+
testSource &&
|
|
4813
|
+
!sourceIncludesAll(testSource, [
|
|
4814
|
+
`get${singularPascal}`,
|
|
4815
|
+
`update${singularPascal}`,
|
|
4816
|
+
`delete${singularPascal}`,
|
|
4817
|
+
`${notFoundError}`,
|
|
4818
|
+
`${conflictError}`,
|
|
4819
|
+
])
|
|
4820
|
+
) {
|
|
4821
|
+
diagnostics.push({
|
|
1673
4822
|
severity: "warning",
|
|
4823
|
+
code: "BEIGNET_RESOURCE_CRUD_TEST_COVERAGE_MISSING",
|
|
4824
|
+
file: testFile,
|
|
4825
|
+
message: `${resource} appears to be a CRUD resource, but ${testFile} does not exercise get, update, delete, ${notFoundError}, and ${conflictError}. Expand the generated resource test or remove the partial CRUD wiring.`,
|
|
1674
4826
|
});
|
|
1675
4827
|
}
|
|
4828
|
+
|
|
4829
|
+
const hasDrizzleSoftDeleteSignals =
|
|
4830
|
+
sourceIncludesAny(drizzleSchemaSource, ["deletedAt", "deleted_at"]) ||
|
|
4831
|
+
sourceIncludesAny(drizzleRepositorySource, ["deletedAt", "isNull"]);
|
|
4832
|
+
const hasSoftDeleteSignals =
|
|
4833
|
+
hasDrizzleSoftDeleteSignals ||
|
|
4834
|
+
sourceIncludesAny(memoryRepositorySource, ["deletedAt"]);
|
|
4835
|
+
|
|
4836
|
+
if (hasSoftDeleteSignals) {
|
|
4837
|
+
if (hasDrizzleSoftDeleteSignals) {
|
|
4838
|
+
if (
|
|
4839
|
+
!sourceIncludesAny(drizzleSchemaSource, ["deletedAt", "deleted_at"])
|
|
4840
|
+
) {
|
|
4841
|
+
diagnostics.push({
|
|
4842
|
+
severity: "error",
|
|
4843
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_SCHEMA_MISSING",
|
|
4844
|
+
file: drizzleSchemaFile,
|
|
4845
|
+
message: `${resource} appears to use soft delete, but ${drizzleSchemaFile} does not declare deletedAt. Restore the generated deletedAt column or remove the partial soft-delete wiring.`,
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
if (
|
|
4850
|
+
!sourceIncludesAll(drizzleRepositorySource ?? "", [
|
|
4851
|
+
"isNull",
|
|
4852
|
+
"deletedAt",
|
|
4853
|
+
`.update(schema.${pluralCamel})`,
|
|
4854
|
+
])
|
|
4855
|
+
) {
|
|
4856
|
+
diagnostics.push({
|
|
4857
|
+
severity: "error",
|
|
4858
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_REPOSITORY_DRIFT",
|
|
4859
|
+
file: drizzleRepositoryFile,
|
|
4860
|
+
message: `${resource} appears to use soft delete, but ${drizzleRepositoryFile} does not consistently filter active rows and archive deletes. Restore the generated soft-delete repository behavior or remove the partial soft-delete wiring.`,
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
|
|
4865
|
+
if (
|
|
4866
|
+
strict &&
|
|
4867
|
+
testSource &&
|
|
4868
|
+
!sourceIncludesAll(testSource, [
|
|
4869
|
+
"afterDelete",
|
|
4870
|
+
"listedAfterDeleteViaRoute",
|
|
4871
|
+
])
|
|
4872
|
+
) {
|
|
4873
|
+
diagnostics.push({
|
|
4874
|
+
severity: "warning",
|
|
4875
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_TEST_COVERAGE_MISSING",
|
|
4876
|
+
file: testFile,
|
|
4877
|
+
message: `${resource} appears to use soft delete, but ${testFile} does not assert that deleted records disappear from reads. Expand the generated resource test or remove the partial soft-delete wiring.`,
|
|
4878
|
+
});
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
1676
4881
|
}
|
|
1677
4882
|
|
|
1678
4883
|
return diagnostics;
|
|
1679
4884
|
}
|
|
1680
4885
|
|
|
4886
|
+
async function readSourceIfPresent(
|
|
4887
|
+
targetDir: string,
|
|
4888
|
+
files: string[],
|
|
4889
|
+
file: string,
|
|
4890
|
+
): Promise<string | undefined> {
|
|
4891
|
+
if (!files.includes(file)) return undefined;
|
|
4892
|
+
return readFile(path.join(targetDir, file), "utf8");
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4895
|
+
function sourceIncludesAny(
|
|
4896
|
+
source: string | undefined,
|
|
4897
|
+
snippets: readonly string[],
|
|
4898
|
+
): boolean {
|
|
4899
|
+
return source ? snippets.some((snippet) => source.includes(snippet)) : false;
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
function sourceIncludesAll(
|
|
4903
|
+
source: string,
|
|
4904
|
+
snippets: readonly string[],
|
|
4905
|
+
): boolean {
|
|
4906
|
+
return snippets.every((snippet) => source.includes(snippet));
|
|
4907
|
+
}
|
|
4908
|
+
|
|
1681
4909
|
async function collectResourceNames(
|
|
1682
4910
|
targetDir: string,
|
|
1683
4911
|
files: string[],
|
|
@@ -1742,11 +4970,16 @@ async function isGeneratedResourcePort(
|
|
|
1742
4970
|
const singular = singularize(resource);
|
|
1743
4971
|
const singularPascal = pascalCase(singular);
|
|
1744
4972
|
const pluralPascal = pascalCase(resource);
|
|
4973
|
+
const hasCursorList =
|
|
4974
|
+
source.includes("CursorPage") &&
|
|
4975
|
+
source.includes(`list(query: List${pluralPascal}Query)`);
|
|
4976
|
+
const hasOffsetList =
|
|
4977
|
+
source.includes("OffsetPage") && source.includes("list(page: OffsetPage)");
|
|
1745
4978
|
|
|
1746
4979
|
return (
|
|
1747
4980
|
source.includes(`Create${singularPascal}Input`) &&
|
|
1748
|
-
source.includes(`List${pluralPascal}Input`) &&
|
|
1749
4981
|
source.includes(`List${pluralPascal}Result`) &&
|
|
4982
|
+
(hasCursorList || hasOffsetList) &&
|
|
1750
4983
|
source.includes(`interface ${singularPascal}Repository`) &&
|
|
1751
4984
|
source.includes(`create(input: Create${singularPascal}Input)`)
|
|
1752
4985
|
);
|
|
@@ -1853,7 +5086,7 @@ function resourceDiagnostic(
|
|
|
1853
5086
|
severity: "error",
|
|
1854
5087
|
code,
|
|
1855
5088
|
file,
|
|
1856
|
-
message: `${resource} appears to be a generated
|
|
5089
|
+
message: `${resource} appears to be a generated feature slice, but ${file} is missing. Re-run make feature ${resource}, restore the missing file, or remove the partial feature wiring.`,
|
|
1857
5090
|
};
|
|
1858
5091
|
}
|
|
1859
5092
|
|
|
@@ -1893,6 +5126,11 @@ function pascalCase(value: string): string {
|
|
|
1893
5126
|
.join("");
|
|
1894
5127
|
}
|
|
1895
5128
|
|
|
5129
|
+
function camelCase(value: string): string {
|
|
5130
|
+
const pascal = pascalCase(value);
|
|
5131
|
+
return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
|
|
5132
|
+
}
|
|
5133
|
+
|
|
1896
5134
|
function singularize(value: string): string {
|
|
1897
5135
|
if (value.endsWith("ies") && value.length > 3) {
|
|
1898
5136
|
return `${value.slice(0, -3)}y`;
|