@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/dist/inspect.js
CHANGED
|
@@ -1,6 +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 { parseProviderPackageMetadata, } from "@beignet/core/providers";
|
|
5
|
+
import { createPainter } from "./ansi.js";
|
|
3
6
|
import { directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
|
|
7
|
+
import { formatGithubAnnotation, } from "./github-annotations.js";
|
|
8
|
+
import { getCliVersion } from "./version.js";
|
|
4
9
|
export async function inspectApp(options = {}) {
|
|
5
10
|
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
6
11
|
await assertDirectory(targetDir);
|
|
@@ -16,6 +21,7 @@ export async function inspectApp(options = {}) {
|
|
|
16
21
|
const matchedRoutes = matchRoutes(contracts, routeExports);
|
|
17
22
|
const diagnostics = await inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, Boolean(options.strict));
|
|
18
23
|
return {
|
|
24
|
+
schemaVersion: 1,
|
|
19
25
|
targetDir,
|
|
20
26
|
config,
|
|
21
27
|
strict: Boolean(options.strict),
|
|
@@ -35,15 +41,25 @@ export async function applyDoctorFixes(options = {}) {
|
|
|
35
41
|
: await loadBeignetConfig(targetDir, files);
|
|
36
42
|
const convention = inspectConvention(files, config);
|
|
37
43
|
const contracts = await readContracts(targetDir, files, config);
|
|
44
|
+
const routeFiles = files.filter((file) => file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
|
|
45
|
+
file.endsWith("/route.ts"));
|
|
46
|
+
const routeExports = await readRouteExports(targetDir, routeFiles, config);
|
|
47
|
+
const matchedRoutes = matchRoutes(contracts, routeExports);
|
|
38
48
|
const fixes = [];
|
|
39
49
|
const packageFix = await fixMissingTestScript(targetDir, files, convention);
|
|
40
50
|
if (packageFix)
|
|
41
51
|
fixes.push(packageFix);
|
|
42
|
-
const
|
|
52
|
+
const routeGroupFix = await fixUnregisteredRouteGroups(targetDir, files, config);
|
|
53
|
+
if (routeGroupFix)
|
|
54
|
+
fixes.push(routeGroupFix);
|
|
55
|
+
const openApiFix = await fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes);
|
|
43
56
|
if (openApiFix)
|
|
44
57
|
fixes.push(openApiFix);
|
|
45
58
|
return fixes;
|
|
46
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Format inspected routes as a CLI table.
|
|
62
|
+
*/
|
|
47
63
|
export function formatRoutes(result) {
|
|
48
64
|
if (result.routes.length === 0) {
|
|
49
65
|
return `No Beignet routes found in ${result.targetDir}`;
|
|
@@ -61,29 +77,80 @@ export function formatRoutes(result) {
|
|
|
61
77
|
...rows.map((row) => [row.method, row.path, row.contract, row.handler]),
|
|
62
78
|
]);
|
|
63
79
|
}
|
|
64
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Format doctor diagnostics and applied fixes for CLI output.
|
|
82
|
+
*/
|
|
83
|
+
export function formatDoctor(result, options = {}) {
|
|
84
|
+
const paint = createPainter(options.color);
|
|
65
85
|
const fixLines = result.fixes.length === 0
|
|
66
86
|
? []
|
|
67
87
|
: [
|
|
68
88
|
`Applied ${result.fixes.length} Beignet fix${result.fixes.length === 1 ? "" : "es"}:`,
|
|
69
89
|
"",
|
|
70
|
-
...result.fixes.map((fix) =>
|
|
90
|
+
...result.fixes.map((fix) => `${paint("FIXED", "green")} ${fix.code} ${fix.file}
|
|
71
91
|
${fix.message}`),
|
|
72
92
|
"",
|
|
73
93
|
];
|
|
74
94
|
if (result.diagnostics.length === 0) {
|
|
75
95
|
return [...fixLines, `No Beignet drift found in ${result.targetDir}.`].join("\n");
|
|
76
96
|
}
|
|
77
|
-
|
|
97
|
+
const groups = [
|
|
98
|
+
{
|
|
99
|
+
severity: "error",
|
|
100
|
+
header: "Errors",
|
|
101
|
+
label: paint("ERROR", "red"),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
severity: "warning",
|
|
105
|
+
header: "Warnings",
|
|
106
|
+
label: paint("WARNING", "yellow"),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
severity: "hint",
|
|
110
|
+
header: "Hints",
|
|
111
|
+
label: paint("HINT", "cyan"),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
const lines = [
|
|
78
115
|
...fixLines,
|
|
79
116
|
`Found ${result.diagnostics.length} Beignet issue${result.diagnostics.length === 1 ? "" : "s"} in ${result.targetDir}:`,
|
|
80
|
-
|
|
81
|
-
|
|
117
|
+
];
|
|
118
|
+
for (const group of groups) {
|
|
119
|
+
const diagnostics = result.diagnostics.filter((diagnostic) => diagnostic.severity === group.severity);
|
|
120
|
+
if (diagnostics.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
lines.push("", paint(`${group.header}:`, "bold"), "");
|
|
123
|
+
lines.push(...diagnostics.map((diagnostic) => {
|
|
82
124
|
const location = diagnostic.file ? ` ${diagnostic.file}` : "";
|
|
83
|
-
return `${
|
|
125
|
+
return `${group.label} ${diagnostic.code}${location}
|
|
84
126
|
${diagnostic.message}`;
|
|
85
|
-
})
|
|
86
|
-
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
lines.push("", formatDoctorFooter(result));
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
function formatDoctorFooter(result) {
|
|
133
|
+
const count = (severity) => result.diagnostics.filter((diagnostic) => diagnostic.severity === severity)
|
|
134
|
+
.length;
|
|
135
|
+
const footer = `${count("error")} error(s), ${count("warning")} warning(s), ${count("hint")} hint(s)`;
|
|
136
|
+
return result.strict ? `${footer} (strict: warnings fail)` : footer;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Format doctor diagnostics as GitHub Actions annotations.
|
|
140
|
+
*/
|
|
141
|
+
export function formatDoctorGithub(result) {
|
|
142
|
+
return result.diagnostics
|
|
143
|
+
.map((diagnostic) => formatGithubAnnotation({
|
|
144
|
+
severity: githubAnnotationSeverity(diagnostic.severity),
|
|
145
|
+
file: diagnostic.file,
|
|
146
|
+
message: `${diagnostic.code}: ${diagnostic.message}`,
|
|
147
|
+
}))
|
|
148
|
+
.join("\n");
|
|
149
|
+
}
|
|
150
|
+
function githubAnnotationSeverity(severity) {
|
|
151
|
+
if (severity === "hint")
|
|
152
|
+
return "notice";
|
|
153
|
+
return severity;
|
|
87
154
|
}
|
|
88
155
|
async function assertDirectory(targetDir) {
|
|
89
156
|
try {
|
|
@@ -170,10 +237,11 @@ function hasRequirement(files, requirement) {
|
|
|
170
237
|
function parseContracts(source, file) {
|
|
171
238
|
const contracts = [];
|
|
172
239
|
const groupPrefixes = parseContractGroupPrefixes(source);
|
|
240
|
+
const groupErrorRefs = parseContractGroupErrorRefs(source);
|
|
173
241
|
const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
|
|
174
242
|
for (const exportMatch of source.matchAll(exportRegex)) {
|
|
175
243
|
const block = exportMatch[2];
|
|
176
|
-
const methodMatch = /([A-Za-z_$][\w$]*)\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(block);
|
|
244
|
+
const methodMatch = /([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["']\s*\)/.exec(block);
|
|
177
245
|
if (methodMatch) {
|
|
178
246
|
const receiver = methodMatch[1];
|
|
179
247
|
const pathPrefix = groupPrefixes.get(receiver) ?? "";
|
|
@@ -181,6 +249,7 @@ function parseContracts(source, file) {
|
|
|
181
249
|
contracts.push({
|
|
182
250
|
exportName: exportMatch[1],
|
|
183
251
|
file,
|
|
252
|
+
metadata: parseContractMetadata(block, groupErrorRefs.get(receiver) ?? []),
|
|
184
253
|
method: methodMatch[2].toUpperCase(),
|
|
185
254
|
path: routePath,
|
|
186
255
|
});
|
|
@@ -191,6 +260,7 @@ function parseContracts(source, file) {
|
|
|
191
260
|
contracts.push({
|
|
192
261
|
exportName: exportMatch[1],
|
|
193
262
|
file,
|
|
263
|
+
metadata: parseContractMetadata(block),
|
|
194
264
|
method: directContract.method,
|
|
195
265
|
path: normalizeContractPath(directContract.path),
|
|
196
266
|
});
|
|
@@ -198,8 +268,30 @@ function parseContracts(source, file) {
|
|
|
198
268
|
}
|
|
199
269
|
return contracts;
|
|
200
270
|
}
|
|
271
|
+
function parseContractMetadata(block, inheritedCatalogErrors = []) {
|
|
272
|
+
const authRequired = /\bauth\s*:\s*["']required["']/.test(block);
|
|
273
|
+
const authorizationAbility = /\bauthorization\s*:\s*\{[\s\S]*?\bability\s*:\s*["']([^"']+)["']/.exec(block)?.[1];
|
|
274
|
+
const auditRequired = /\baudit\s*:\s*["']required["']/.test(block) ||
|
|
275
|
+
/\bauditRequired\s*:\s*true\b/.test(block);
|
|
276
|
+
const catalogErrors = dedupeCatalogErrorRefs([
|
|
277
|
+
...inheritedCatalogErrors,
|
|
278
|
+
...parseCatalogErrorRefs(block),
|
|
279
|
+
]);
|
|
280
|
+
if (!authRequired &&
|
|
281
|
+
!authorizationAbility &&
|
|
282
|
+
!auditRequired &&
|
|
283
|
+
catalogErrors.length === 0) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
authRequired,
|
|
288
|
+
authorizationAbility,
|
|
289
|
+
auditRequired,
|
|
290
|
+
catalogErrors,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
201
293
|
function parseDirectContract(block) {
|
|
202
|
-
const configMatch = /
|
|
294
|
+
const configMatch = /defineContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
|
|
203
295
|
if (!configMatch)
|
|
204
296
|
return undefined;
|
|
205
297
|
const configSource = configMatch[1];
|
|
@@ -239,8 +331,42 @@ function parseContractGroupPrefixes(source) {
|
|
|
239
331
|
}
|
|
240
332
|
return prefixes;
|
|
241
333
|
}
|
|
334
|
+
function parseContractGroupErrorRefs(source) {
|
|
335
|
+
const assignments = [
|
|
336
|
+
...source.matchAll(/(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g),
|
|
337
|
+
].map((match) => ({
|
|
338
|
+
name: match[1],
|
|
339
|
+
expression: match[2],
|
|
340
|
+
}));
|
|
341
|
+
const groupErrors = new Map();
|
|
342
|
+
let changed = true;
|
|
343
|
+
while (changed) {
|
|
344
|
+
changed = false;
|
|
345
|
+
for (const assignment of assignments) {
|
|
346
|
+
if (groupErrors.has(assignment.name))
|
|
347
|
+
continue;
|
|
348
|
+
const baseErrors = baseGroupErrors(assignment.expression, groupErrors);
|
|
349
|
+
if (baseErrors === undefined)
|
|
350
|
+
continue;
|
|
351
|
+
groupErrors.set(assignment.name, dedupeCatalogErrorRefs([
|
|
352
|
+
...baseErrors,
|
|
353
|
+
...parseCatalogErrorRefs(assignment.expression),
|
|
354
|
+
]));
|
|
355
|
+
changed = true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return groupErrors;
|
|
359
|
+
}
|
|
360
|
+
function baseGroupErrors(expression, groupErrors) {
|
|
361
|
+
if (expression.includes("defineContractGroup("))
|
|
362
|
+
return [];
|
|
363
|
+
const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
|
|
364
|
+
if (!baseMatch)
|
|
365
|
+
return undefined;
|
|
366
|
+
return groupErrors.get(baseMatch[1]);
|
|
367
|
+
}
|
|
242
368
|
function baseGroupPrefix(expression, prefixes) {
|
|
243
|
-
if (expression.includes("
|
|
369
|
+
if (expression.includes("defineContractGroup("))
|
|
244
370
|
return "";
|
|
245
371
|
const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
|
|
246
372
|
if (!baseMatch)
|
|
@@ -250,6 +376,152 @@ function baseGroupPrefix(expression, prefixes) {
|
|
|
250
376
|
function prefixCalls(expression) {
|
|
251
377
|
return [...expression.matchAll(/\.prefix\(\s*["']([^"']+)["']\s*\)/g)].map((match) => match[1]);
|
|
252
378
|
}
|
|
379
|
+
function parseCatalogErrorRefs(source) {
|
|
380
|
+
return dedupeCatalogErrorRefs(objectArgumentBodies(source, /\.errors\s*\(/g).flatMap((body) => [...body.matchAll(errorCatalogRefRegex)].map((match) => ({
|
|
381
|
+
key: match[1],
|
|
382
|
+
catalog: match[2],
|
|
383
|
+
catalogKey: match[3],
|
|
384
|
+
}))));
|
|
385
|
+
}
|
|
386
|
+
const errorCatalogRefRegex = /(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)/g;
|
|
387
|
+
function parseErrorCatalogKeys(source) {
|
|
388
|
+
const keys = new Set();
|
|
389
|
+
const catalogBodies = objectArgumentBodies(source, /\bdefineErrors\s*\(/g);
|
|
390
|
+
for (const body of catalogBodies) {
|
|
391
|
+
if (/\.\.\.\s*httpErrors\b/.test(body)) {
|
|
392
|
+
for (const key of httpErrorCatalogKeys)
|
|
393
|
+
keys.add(key);
|
|
394
|
+
}
|
|
395
|
+
for (const key of topLevelObjectKeys(body))
|
|
396
|
+
keys.add(key);
|
|
397
|
+
}
|
|
398
|
+
return keys;
|
|
399
|
+
}
|
|
400
|
+
const httpErrorCatalogKeys = [
|
|
401
|
+
"BadRequest",
|
|
402
|
+
"Unauthorized",
|
|
403
|
+
"Forbidden",
|
|
404
|
+
"NotFound",
|
|
405
|
+
"Conflict",
|
|
406
|
+
"IdempotencyConflict",
|
|
407
|
+
"IdempotencyInProgress",
|
|
408
|
+
"TooManyRequests",
|
|
409
|
+
"UnprocessableEntity",
|
|
410
|
+
"ValidationError",
|
|
411
|
+
"InternalServerError",
|
|
412
|
+
];
|
|
413
|
+
function objectArgumentBodies(source, callPattern) {
|
|
414
|
+
const bodies = [];
|
|
415
|
+
const pattern = new RegExp(callPattern.source, callPattern.flags);
|
|
416
|
+
for (const match of source.matchAll(pattern)) {
|
|
417
|
+
const callIndex = match.index ?? 0;
|
|
418
|
+
const openParen = source.indexOf("(", callIndex);
|
|
419
|
+
if (openParen === -1)
|
|
420
|
+
continue;
|
|
421
|
+
const objectStart = source.indexOf("{", openParen);
|
|
422
|
+
if (objectStart === -1)
|
|
423
|
+
continue;
|
|
424
|
+
const objectSource = balancedObjectSource(source, objectStart);
|
|
425
|
+
if (!objectSource)
|
|
426
|
+
continue;
|
|
427
|
+
bodies.push(objectSource.slice(1, -1));
|
|
428
|
+
}
|
|
429
|
+
return bodies;
|
|
430
|
+
}
|
|
431
|
+
function balancedObjectSource(source, startIndex) {
|
|
432
|
+
if (source[startIndex] !== "{")
|
|
433
|
+
return undefined;
|
|
434
|
+
let depth = 0;
|
|
435
|
+
let quote;
|
|
436
|
+
let escaped = false;
|
|
437
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
438
|
+
const char = source[index];
|
|
439
|
+
if (quote) {
|
|
440
|
+
if (escaped) {
|
|
441
|
+
escaped = false;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (char === "\\") {
|
|
445
|
+
escaped = true;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (char === quote)
|
|
449
|
+
quote = undefined;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
453
|
+
quote = char;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (char === "{") {
|
|
457
|
+
depth += 1;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (char === "}") {
|
|
461
|
+
depth -= 1;
|
|
462
|
+
if (depth === 0)
|
|
463
|
+
return source.slice(startIndex, index + 1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
function topLevelObjectKeys(body) {
|
|
469
|
+
const keys = [];
|
|
470
|
+
let depth = 0;
|
|
471
|
+
let quote;
|
|
472
|
+
let escaped = false;
|
|
473
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
474
|
+
const char = body[index];
|
|
475
|
+
if (quote) {
|
|
476
|
+
if (escaped) {
|
|
477
|
+
escaped = false;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (char === "\\") {
|
|
481
|
+
escaped = true;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (char === quote)
|
|
485
|
+
quote = undefined;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
489
|
+
quote = char;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (char === "{" || char === "[" || char === "(") {
|
|
493
|
+
depth += 1;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (char === "}" || char === "]" || char === ")") {
|
|
497
|
+
depth = Math.max(0, depth - 1);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (depth !== 0)
|
|
501
|
+
continue;
|
|
502
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(body.slice(index));
|
|
503
|
+
if (!identifier)
|
|
504
|
+
continue;
|
|
505
|
+
const name = identifier[0];
|
|
506
|
+
const next = body.slice(index + name.length).match(/^\s*:/);
|
|
507
|
+
if (next)
|
|
508
|
+
keys.push(name);
|
|
509
|
+
index += name.length - 1;
|
|
510
|
+
}
|
|
511
|
+
return keys;
|
|
512
|
+
}
|
|
513
|
+
function dedupeCatalogErrorRefs(refs) {
|
|
514
|
+
const seen = new Set();
|
|
515
|
+
const deduped = [];
|
|
516
|
+
for (const ref of refs) {
|
|
517
|
+
const key = `${ref.key}:${ref.catalog}:${ref.catalogKey}`;
|
|
518
|
+
if (seen.has(key))
|
|
519
|
+
continue;
|
|
520
|
+
seen.add(key);
|
|
521
|
+
deduped.push(ref);
|
|
522
|
+
}
|
|
523
|
+
return deduped;
|
|
524
|
+
}
|
|
253
525
|
function joinContractPaths(prefix, routePath) {
|
|
254
526
|
if (!prefix)
|
|
255
527
|
return normalizeContractPath(routePath);
|
|
@@ -330,6 +602,22 @@ function parseNamedImports(source, config, importerFile) {
|
|
|
330
602
|
}
|
|
331
603
|
return imports;
|
|
332
604
|
}
|
|
605
|
+
function parseNamedImportSources(source) {
|
|
606
|
+
const imports = new Map();
|
|
607
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
608
|
+
for (const match of source.matchAll(importRegex)) {
|
|
609
|
+
for (const member of match[1].split(",")) {
|
|
610
|
+
const parsed = parseImportMember(member);
|
|
611
|
+
if (!parsed)
|
|
612
|
+
continue;
|
|
613
|
+
imports.set(parsed.localName, {
|
|
614
|
+
importedName: parsed.importedName,
|
|
615
|
+
sourcePath: match[2],
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return imports;
|
|
620
|
+
}
|
|
333
621
|
function parseImportMember(member) {
|
|
334
622
|
const trimmed = member.trim();
|
|
335
623
|
if (!trimmed)
|
|
@@ -349,6 +637,23 @@ function parseImportMember(member) {
|
|
|
349
637
|
}
|
|
350
638
|
return undefined;
|
|
351
639
|
}
|
|
640
|
+
function sourceFileFromImport(sourcePath, importerFile = "index.ts", files) {
|
|
641
|
+
const resolveCandidate = (candidate) => {
|
|
642
|
+
if (path.extname(candidate))
|
|
643
|
+
return candidate;
|
|
644
|
+
const candidates = [`${candidate}.ts`, `${candidate}/index.ts`];
|
|
645
|
+
return files
|
|
646
|
+
? candidates.find((file) => files.includes(file))
|
|
647
|
+
: candidates[0];
|
|
648
|
+
};
|
|
649
|
+
if (sourcePath.startsWith("@/")) {
|
|
650
|
+
return resolveCandidate(sourcePath.slice("@/".length));
|
|
651
|
+
}
|
|
652
|
+
if (sourcePath.startsWith(".")) {
|
|
653
|
+
return resolveCandidate(normalizePath(path.join(path.dirname(importerFile), sourcePath)));
|
|
654
|
+
}
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
352
657
|
function contractFileFromImport(sourcePath, config, importerFile) {
|
|
353
658
|
const contractsPath = directoryPath(config.paths.contracts);
|
|
354
659
|
const aliasPrefix = `@/${contractsPath}/`;
|
|
@@ -452,26 +757,1137 @@ async function inspectDiagnostics(targetDir, files, config, convention, contract
|
|
|
452
757
|
if (!convention.nextLayout) {
|
|
453
758
|
diagnostics.push({
|
|
454
759
|
severity: "warning",
|
|
455
|
-
code: "
|
|
760
|
+
code: "BEIGNET_APP_LAYOUT_NOT_FOUND",
|
|
456
761
|
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.`,
|
|
457
762
|
});
|
|
458
763
|
}
|
|
459
|
-
for (const route of matchedRoutes.routes) {
|
|
460
|
-
if (route.handlerSource !== "missing")
|
|
461
|
-
continue;
|
|
764
|
+
for (const route of matchedRoutes.routes) {
|
|
765
|
+
if (route.handlerSource !== "missing")
|
|
766
|
+
continue;
|
|
767
|
+
diagnostics.push({
|
|
768
|
+
severity: "error",
|
|
769
|
+
code: "BEIGNET_ROUTE_MISSING",
|
|
770
|
+
file: route.file,
|
|
771
|
+
contract: route.exportName,
|
|
772
|
+
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.`,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
|
|
776
|
+
diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
|
|
777
|
+
}
|
|
778
|
+
diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
|
|
779
|
+
return dedupeDiagnostics(diagnostics);
|
|
780
|
+
}
|
|
781
|
+
async function inspectCanonicalConformance(targetDir, files, config, convention, strict) {
|
|
782
|
+
if (!strict || !convention.resourceGenerator)
|
|
783
|
+
return [];
|
|
784
|
+
const diagnostics = [];
|
|
785
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
786
|
+
const routeRegistryFile = `${serverDir}/routes.ts`;
|
|
787
|
+
const providerRegistryFile = `${serverDir}/providers.ts`;
|
|
788
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
789
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
790
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
791
|
+
const hasClientSurface = files.some((file) => file.startsWith(`${featuresPath}/`) && file.includes("/components/")) ||
|
|
792
|
+
files.some((file) => file.startsWith("client/")) ||
|
|
793
|
+
[
|
|
794
|
+
"@beignet/react-query",
|
|
795
|
+
"@beignet/react-hook-form",
|
|
796
|
+
"@beignet/react-uploads",
|
|
797
|
+
"@beignet/nuqs",
|
|
798
|
+
].some((packageName) => installedPackages.has(packageName));
|
|
799
|
+
const requiredFiles = [
|
|
800
|
+
{
|
|
801
|
+
file: config.paths.appContext,
|
|
802
|
+
reason: "Beignet apps should define the shared AppContext type once in app-context.ts.",
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
file: config.paths.server,
|
|
806
|
+
reason: "server/index.ts should compose ports, providers, hooks, routes, and request context.",
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
file: routeRegistryFile,
|
|
810
|
+
reason: "server/routes.ts should own the central route registry and exported contract list.",
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
file: providerRegistryFile,
|
|
814
|
+
reason: "server/providers.ts should own provider registration, even when the provider list is small.",
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
file: config.paths.infrastructurePorts,
|
|
818
|
+
reason: "infra/app-ports.ts should wire concrete app ports for the selected runtime.",
|
|
819
|
+
},
|
|
820
|
+
];
|
|
821
|
+
if (hasClientSurface) {
|
|
822
|
+
requiredFiles.push({
|
|
823
|
+
file: "client/index.ts",
|
|
824
|
+
reason: "client/index.ts should own the typed client and frontend adapter setup.",
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
if (installedPackages.has("@beignet/react-hook-form") ||
|
|
828
|
+
files.includes("client/forms.ts")) {
|
|
829
|
+
requiredFiles.push({
|
|
830
|
+
file: "client/forms.ts",
|
|
831
|
+
reason: "client/forms.ts should own React Hook Form adapter setup when form helpers are used.",
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
for (const required of requiredFiles) {
|
|
835
|
+
if (files.includes(required.file))
|
|
836
|
+
continue;
|
|
837
|
+
diagnostics.push({
|
|
838
|
+
severity: "warning",
|
|
839
|
+
code: "BEIGNET_CANONICAL_FILE_MISSING",
|
|
840
|
+
file: required.file,
|
|
841
|
+
message: `${required.file} is missing. ${required.reason}`,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const sourceCache = new Map();
|
|
845
|
+
const appContextSource = files.includes(config.paths.appContext)
|
|
846
|
+
? await readCachedSource(targetDir, config.paths.appContext, sourceCache)
|
|
847
|
+
: "";
|
|
848
|
+
const expectedContextFields = canonicalContextFields(appContextSource);
|
|
849
|
+
if (files.includes(routeRegistryFile)) {
|
|
850
|
+
const routeRegistrySource = await readCachedSource(targetDir, routeRegistryFile, sourceCache);
|
|
851
|
+
if (!importsAppContext(routeRegistrySource, routeRegistryFile, config, files)) {
|
|
852
|
+
diagnostics.push({
|
|
853
|
+
severity: "warning",
|
|
854
|
+
code: "BEIGNET_ROUTE_REGISTRY_APP_CONTEXT",
|
|
855
|
+
file: routeRegistryFile,
|
|
856
|
+
message: `${routeRegistryFile} should import type { AppContext } from ${config.paths.appContext} so the central route registry uses the app-wide context type.`,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
if (!/\bexport\s+const\s+routes\s*=\s*defineRoutes\s*<\s*AppContext\s*>/.test(routeRegistrySource)) {
|
|
860
|
+
diagnostics.push({
|
|
861
|
+
severity: "warning",
|
|
862
|
+
code: "BEIGNET_ROUTE_REGISTRY_ROUTES_EXPORT",
|
|
863
|
+
file: routeRegistryFile,
|
|
864
|
+
message: `${routeRegistryFile} should export routes with defineRoutes<AppContext>([...]) so route registration follows the canonical Beignet path.`,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (!/\bexport\s+const\s+contracts\s*=\s*contractsFromRoutes\s*\(\s*routes\s*\)/.test(routeRegistrySource)) {
|
|
868
|
+
diagnostics.push({
|
|
869
|
+
severity: "warning",
|
|
870
|
+
code: "BEIGNET_ROUTE_REGISTRY_CONTRACTS_EXPORT",
|
|
871
|
+
file: routeRegistryFile,
|
|
872
|
+
message: `${routeRegistryFile} should export contracts = contractsFromRoutes(routes) so OpenAPI, typed clients, and devtools derive from the central route registry.`,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (files.includes(config.paths.server)) {
|
|
877
|
+
const serverSource = await readCachedSource(targetDir, config.paths.server, sourceCache);
|
|
878
|
+
const importedRouteRegistry = routeRegistryFileFromServerSource(serverSource, config.paths.server, files);
|
|
879
|
+
if (importedRouteRegistry !== routeRegistryFile) {
|
|
880
|
+
diagnostics.push({
|
|
881
|
+
severity: "warning",
|
|
882
|
+
code: "BEIGNET_SERVER_ROUTE_REGISTRY",
|
|
883
|
+
file: config.paths.server,
|
|
884
|
+
message: `${config.paths.server} should import routes from ./${path.basename(routeRegistryFile, ".ts")} so server setup uses the central route registry.`,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
if (!importsAppContext(serverSource, config.paths.server, config, files)) {
|
|
888
|
+
diagnostics.push({
|
|
889
|
+
severity: "warning",
|
|
890
|
+
code: "BEIGNET_SERVER_APP_CONTEXT",
|
|
891
|
+
file: config.paths.server,
|
|
892
|
+
message: `${config.paths.server} should import type { AppContext } from ${config.paths.appContext} and thread that context through createNextServer and hooks.`,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
// Apps may keep the context blueprint in a sibling server/context.ts
|
|
896
|
+
// (defineServerContext) instead of inline in the server file.
|
|
897
|
+
const serverContextFile = `${serverDir}/context.ts`;
|
|
898
|
+
const serverContextSource = files.includes(serverContextFile)
|
|
899
|
+
? await readCachedSource(targetDir, serverContextFile, sourceCache)
|
|
900
|
+
: "";
|
|
901
|
+
const contextBlueprintFile = files.includes(serverContextFile)
|
|
902
|
+
? serverContextFile
|
|
903
|
+
: config.paths.server;
|
|
904
|
+
const contextBlueprintSource = `${serverSource}\n${serverContextSource}`;
|
|
905
|
+
for (const field of expectedContextFields) {
|
|
906
|
+
if (new RegExp(`\\b${escapeRegExp(field)}\\s*[:,]`).test(contextBlueprintSource)) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
diagnostics.push({
|
|
910
|
+
severity: "warning",
|
|
911
|
+
code: "BEIGNET_CONTEXT_FIELD_MISSING",
|
|
912
|
+
file: contextBlueprintFile,
|
|
913
|
+
message: `${contextBlueprintFile} context factories do not appear to return ${field}. Keep the canonical AppContext shape aligned with app-context.ts.`,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const featureRoutesPath = `${featuresPath}/`;
|
|
918
|
+
for (const file of files) {
|
|
919
|
+
if (!file.startsWith(featureRoutesPath) || !file.endsWith("/routes.ts")) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
923
|
+
if (!source.includes("defineRouteGroup"))
|
|
924
|
+
continue;
|
|
925
|
+
if (!importsAppContext(source, file, config, files)) {
|
|
926
|
+
diagnostics.push({
|
|
927
|
+
severity: "warning",
|
|
928
|
+
code: "BEIGNET_FEATURE_ROUTE_APP_CONTEXT",
|
|
929
|
+
file,
|
|
930
|
+
message: `${file} should import type { AppContext } from ${config.paths.appContext} so feature routes use the app-wide context type.`,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
if (!/defineRouteGroup\s*<\s*AppContext\s*>/.test(source)) {
|
|
934
|
+
diagnostics.push({
|
|
935
|
+
severity: "warning",
|
|
936
|
+
code: "BEIGNET_FEATURE_ROUTE_GROUP_CONTEXT",
|
|
937
|
+
file,
|
|
938
|
+
message: `${file} should call defineRouteGroup<AppContext>(...) for ordinary feature route groups, or defineRouteGroup<AppContext>()({ ... }) when group-level hooks enrich ctx.`,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
for (const file of files) {
|
|
943
|
+
if (file === config.paths.appContext ||
|
|
944
|
+
!file.endsWith(".ts") ||
|
|
945
|
+
file.endsWith(".d.ts")) {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
949
|
+
if (!/\b(?:type|interface)\s+AppContext\b/.test(source))
|
|
950
|
+
continue;
|
|
951
|
+
diagnostics.push({
|
|
952
|
+
severity: "warning",
|
|
953
|
+
code: "BEIGNET_APP_CONTEXT_REDECLARED",
|
|
954
|
+
file,
|
|
955
|
+
message: `${file} declares AppContext locally. Import AppContext from "@/app-context" so routes, hooks, use cases, jobs, schedules, and tests share one context model.`,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return diagnostics;
|
|
959
|
+
}
|
|
960
|
+
function canonicalContextFields(appContextSource) {
|
|
961
|
+
const fields = ["requestId", "ports"];
|
|
962
|
+
for (const field of ["actor", "auth", "gate"]) {
|
|
963
|
+
if (appContextSource.includes(`${field}:`))
|
|
964
|
+
fields.push(field);
|
|
965
|
+
}
|
|
966
|
+
return fields;
|
|
967
|
+
}
|
|
968
|
+
function importsAppContext(source, importerFile, config, files) {
|
|
969
|
+
const importRegex = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+["']([^"']+)["']/g;
|
|
970
|
+
for (const match of source.matchAll(importRegex)) {
|
|
971
|
+
const sourceFile = sourceFileFromImport(match[2], importerFile, files);
|
|
972
|
+
if (sourceFile !== config.paths.appContext)
|
|
973
|
+
continue;
|
|
974
|
+
for (const member of match[1].split(",")) {
|
|
975
|
+
const parsed = parseImportMember(member);
|
|
976
|
+
if (parsed?.importedName === "AppContext" &&
|
|
977
|
+
parsed.localName === "AppContext") {
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
async function inspectAuthzAuditDrift(targetDir, files, config, convention, contracts) {
|
|
985
|
+
if (!convention.resourceGenerator)
|
|
986
|
+
return [];
|
|
987
|
+
const diagnostics = [];
|
|
988
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
989
|
+
const sourceCache = new Map();
|
|
990
|
+
for (const contract of contracts) {
|
|
991
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
992
|
+
if (!feature)
|
|
993
|
+
continue;
|
|
994
|
+
const ability = contract.metadata?.authorizationAbility;
|
|
995
|
+
if (ability) {
|
|
996
|
+
const policyFile = `${featuresPath}/${feature}/policy.ts`;
|
|
997
|
+
if (!files.includes(policyFile)) {
|
|
998
|
+
diagnostics.push({
|
|
999
|
+
severity: "warning",
|
|
1000
|
+
code: "BEIGNET_AUTHZ_POLICY_MISSING",
|
|
1001
|
+
file: contract.file,
|
|
1002
|
+
contract: contract.exportName,
|
|
1003
|
+
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.`,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
const policySource = await readCachedSource(targetDir, policyFile, sourceCache);
|
|
1008
|
+
if (!policySource.includes(ability)) {
|
|
1009
|
+
diagnostics.push({
|
|
1010
|
+
severity: "warning",
|
|
1011
|
+
code: "BEIGNET_AUTHZ_POLICY_DRIFT",
|
|
1012
|
+
file: policyFile,
|
|
1013
|
+
contract: contract.exportName,
|
|
1014
|
+
message: `${contract.exportName} declares authorization ability "${ability}", but ${policyFile} does not mention that ability. Keep contract metadata aligned with the feature policy.`,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const policyTestFiles = featureTestFiles(files, featuresPath, feature, {
|
|
1019
|
+
policyOnly: true,
|
|
1020
|
+
});
|
|
1021
|
+
if (policyTestFiles.length === 0 ||
|
|
1022
|
+
!(await anyFileContains(targetDir, policyTestFiles, ability, sourceCache))) {
|
|
1023
|
+
diagnostics.push({
|
|
1024
|
+
severity: "warning",
|
|
1025
|
+
code: "BEIGNET_AUTHZ_POLICY_TEST_MISSING",
|
|
1026
|
+
file: policyTestFiles[0] ??
|
|
1027
|
+
`${featuresPath}/${feature}/tests/policy.test.ts`,
|
|
1028
|
+
contract: contract.exportName,
|
|
1029
|
+
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.`,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (contract.metadata?.auditRequired) {
|
|
1034
|
+
const featureSourceFiles = featureRuntimeFiles(files, featuresPath, feature);
|
|
1035
|
+
if (featureSourceFiles.length === 0 ||
|
|
1036
|
+
!(await anyFileMatches(targetDir, featureSourceFiles, /\baudit\.record\s*\(/, sourceCache))) {
|
|
1037
|
+
diagnostics.push({
|
|
1038
|
+
severity: "warning",
|
|
1039
|
+
code: "BEIGNET_AUDIT_WRITE_MISSING",
|
|
1040
|
+
file: contract.file,
|
|
1041
|
+
contract: contract.exportName,
|
|
1042
|
+
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.`,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
const testFiles = featureTestFiles(files, featuresPath, feature);
|
|
1046
|
+
if (testFiles.length === 0 ||
|
|
1047
|
+
!(await anyFileMatches(targetDir, testFiles, /\b(assertAuditEntry|assertAuditEntries|audit\.entries)\b/, sourceCache))) {
|
|
1048
|
+
diagnostics.push({
|
|
1049
|
+
severity: "warning",
|
|
1050
|
+
code: "BEIGNET_AUDIT_TEST_MISSING",
|
|
1051
|
+
file: testFiles[0] ?? `${featuresPath}/${feature}/tests/`,
|
|
1052
|
+
contract: contract.exportName,
|
|
1053
|
+
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.`,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return diagnostics;
|
|
1059
|
+
}
|
|
1060
|
+
async function inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict) {
|
|
1061
|
+
if (!convention.resourceGenerator)
|
|
1062
|
+
return [];
|
|
1063
|
+
const diagnostics = [];
|
|
1064
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1065
|
+
const sharedErrorsFile = `${featuresPath}/shared/errors.ts`;
|
|
1066
|
+
const sourceCache = new Map();
|
|
1067
|
+
const sharedErrorKeys = files.includes(sharedErrorsFile)
|
|
1068
|
+
? parseErrorCatalogKeys(await readCachedSource(targetDir, sharedErrorsFile, sourceCache))
|
|
1069
|
+
: undefined;
|
|
1070
|
+
const declaredByFeature = new Map();
|
|
1071
|
+
const contractFiles = new Set(contracts.map((contract) => contract.file));
|
|
1072
|
+
const sharedCatalogBindingsByFile = new Map();
|
|
1073
|
+
for (const file of contractFiles) {
|
|
1074
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1075
|
+
sharedCatalogBindingsByFile.set(file, sharedErrorCatalogBindings(source, file, files, sharedErrorsFile));
|
|
1076
|
+
}
|
|
1077
|
+
for (const contract of contracts) {
|
|
1078
|
+
const catalogErrors = contract.metadata?.catalogErrors ?? [];
|
|
1079
|
+
if (catalogErrors.length === 0)
|
|
1080
|
+
continue;
|
|
1081
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
1082
|
+
if (feature) {
|
|
1083
|
+
const declared = declaredByFeature.get(feature) ?? new Set();
|
|
1084
|
+
for (const error of catalogErrors)
|
|
1085
|
+
declared.add(error.key);
|
|
1086
|
+
declaredByFeature.set(feature, declared);
|
|
1087
|
+
}
|
|
1088
|
+
const sharedBindings = sharedCatalogBindingsByFile.get(contract.file) ?? new Set();
|
|
1089
|
+
for (const error of catalogErrors) {
|
|
1090
|
+
if (!sharedBindings.has(error.catalog))
|
|
1091
|
+
continue;
|
|
1092
|
+
if (!sharedErrorKeys) {
|
|
1093
|
+
diagnostics.push({
|
|
1094
|
+
severity: "error",
|
|
1095
|
+
code: "BEIGNET_ROUTE_ERROR_CATALOG_MISSING",
|
|
1096
|
+
file: sharedErrorsFile,
|
|
1097
|
+
contract: contract.exportName,
|
|
1098
|
+
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.`,
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (!sharedErrorKeys.has(error.catalogKey)) {
|
|
1103
|
+
diagnostics.push({
|
|
1104
|
+
severity: "error",
|
|
1105
|
+
code: "BEIGNET_ROUTE_ERROR_CATALOG_ENTRY_MISSING",
|
|
1106
|
+
file: sharedErrorsFile,
|
|
1107
|
+
contract: contract.exportName,
|
|
1108
|
+
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.`,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
for (const feature of featureNamesFromContracts(contracts, config)) {
|
|
1114
|
+
const featureRuntime = featureErrorRuntimeFiles(files, featuresPath, feature);
|
|
1115
|
+
const declared = declaredByFeature.get(feature) ?? new Set();
|
|
1116
|
+
const used = await featureAppErrorUses(targetDir, featureRuntime, sourceCache);
|
|
1117
|
+
for (const [errorName, file] of used) {
|
|
1118
|
+
if (sharedErrorKeys && !sharedErrorKeys.has(errorName)) {
|
|
1119
|
+
diagnostics.push({
|
|
1120
|
+
severity: "error",
|
|
1121
|
+
code: "BEIGNET_APP_ERROR_CATALOG_ENTRY_MISSING",
|
|
1122
|
+
file: sharedErrorsFile,
|
|
1123
|
+
message: `${file} creates appError("${errorName}"), but ${sharedErrorsFile} does not define ${errorName}. Add it to the shared error catalog or fix the stale appError(...) call.`,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
if (!declared.has(errorName)) {
|
|
1127
|
+
diagnostics.push({
|
|
1128
|
+
severity: "warning",
|
|
1129
|
+
code: "BEIGNET_APP_ERROR_CONTRACT_MISSING",
|
|
1130
|
+
file,
|
|
1131
|
+
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.`,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (!strict)
|
|
1136
|
+
continue;
|
|
1137
|
+
for (const errorName of declared) {
|
|
1138
|
+
if (!isFeatureOwnedErrorName(errorName, feature))
|
|
1139
|
+
continue;
|
|
1140
|
+
if (used.has(errorName))
|
|
1141
|
+
continue;
|
|
1142
|
+
diagnostics.push({
|
|
1143
|
+
severity: "warning",
|
|
1144
|
+
code: "BEIGNET_ROUTE_ERROR_UNUSED",
|
|
1145
|
+
file: `${featuresPath}/${feature}/contracts.ts`,
|
|
1146
|
+
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.`,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return diagnostics;
|
|
1151
|
+
}
|
|
1152
|
+
async function inspectProductionReadiness(targetDir, files, config, convention) {
|
|
1153
|
+
if (!convention.resourceGenerator)
|
|
1154
|
+
return [];
|
|
1155
|
+
const diagnostics = [];
|
|
1156
|
+
const sourceCache = new Map();
|
|
1157
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1158
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
1159
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(targetDir, packageJson);
|
|
1160
|
+
const configFiles = operationalConfigFiles(files, config);
|
|
1161
|
+
for (const file of files) {
|
|
1162
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts"))
|
|
1163
|
+
continue;
|
|
1164
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1165
|
+
if (containsCallExpression(source, "createDevtoolsRoute") &&
|
|
1166
|
+
/\benabled\s*:\s*true\b/.test(source) &&
|
|
1167
|
+
!/\bauthorize\s*:/.test(source)) {
|
|
1168
|
+
diagnostics.push({
|
|
1169
|
+
severity: "warning",
|
|
1170
|
+
code: "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
|
|
1171
|
+
file,
|
|
1172
|
+
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.`,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
if (file.startsWith("app/api/cron/") &&
|
|
1176
|
+
file.endsWith("/route.ts") &&
|
|
1177
|
+
!/\bCRON_SECRET\b/.test(source) &&
|
|
1178
|
+
!/\bcreateOutboxDrainRoute\s*\(/.test(source)) {
|
|
1179
|
+
diagnostics.push({
|
|
1180
|
+
severity: "warning",
|
|
1181
|
+
code: "BEIGNET_CRON_SECRET_MISSING",
|
|
1182
|
+
file,
|
|
1183
|
+
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(...).`,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (isFeatureUploadDefinition(file, config) &&
|
|
1187
|
+
containsCallExpression(source, "defineUpload") &&
|
|
1188
|
+
!/\bmaxSizeBytes\s*:/.test(source)) {
|
|
1189
|
+
diagnostics.push({
|
|
1190
|
+
severity: "warning",
|
|
1191
|
+
code: "BEIGNET_UPLOAD_SIZE_LIMIT_MISSING",
|
|
1192
|
+
file,
|
|
1193
|
+
message: `${file} defines an upload without maxSizeBytes. Add an explicit size limit so upload behavior is predictable before deployment.`,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (isRouteServerOrInfraFile(file, config) &&
|
|
1197
|
+
containsCallExpression(source, "createCorsHooks") &&
|
|
1198
|
+
/\bcredentials\s*:\s*true\b/.test(source) &&
|
|
1199
|
+
/(?:\borigin\b|\borigins\b)\s*:\s*(?:["']\*["']|\[\s*["']\*["'])/.test(source)) {
|
|
1200
|
+
diagnostics.push({
|
|
1201
|
+
severity: "warning",
|
|
1202
|
+
code: "BEIGNET_CORS_CREDENTIALS_WILDCARD",
|
|
1203
|
+
file,
|
|
1204
|
+
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.`,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
for (const provider of providerDoctorRules) {
|
|
1209
|
+
if (!installedPackages.has(provider.packageName))
|
|
1210
|
+
continue;
|
|
1211
|
+
for (const envVar of provider.requiredEnv ?? []) {
|
|
1212
|
+
if (await anyFileContains(targetDir, configFiles, envVar, sourceCache)) {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
diagnostics.push({
|
|
1216
|
+
severity: "warning",
|
|
1217
|
+
code: "BEIGNET_PROVIDER_ENV_MISSING",
|
|
1218
|
+
file: "package.json",
|
|
1219
|
+
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.`,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (installedPackages.has("@beignet/provider-auth-better-auth")) {
|
|
1224
|
+
if (!hasBetterAuthRoute(files, config)) {
|
|
1225
|
+
diagnostics.push({
|
|
1226
|
+
severity: "warning",
|
|
1227
|
+
code: "BEIGNET_AUTH_ROUTE_MISSING",
|
|
1228
|
+
file: directoryPath(config.paths.routes),
|
|
1229
|
+
message: "@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.",
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
if (!(await anyFileContains(targetDir, configFiles, "BETTER_AUTH_TRUSTED_ORIGINS", sourceCache))) {
|
|
1233
|
+
diagnostics.push({
|
|
1234
|
+
severity: "warning",
|
|
1235
|
+
code: "BEIGNET_AUTH_TRUSTED_ORIGINS_MISSING",
|
|
1236
|
+
file: "package.json",
|
|
1237
|
+
message: "@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.",
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return diagnostics;
|
|
1242
|
+
}
|
|
1243
|
+
// Local-dev ranges such as the generated-app conformance vendoring point each
|
|
1244
|
+
// @beignet/* package at a different path on purpose, so they are exempt from
|
|
1245
|
+
// the declared-range comparison. Their installed versions still participate in
|
|
1246
|
+
// the installed-version comparison.
|
|
1247
|
+
const localOnlyRangePrefixes = ["file:", "link:", "workspace:"];
|
|
1248
|
+
async function inspectVersionSkew(targetDir, files) {
|
|
1249
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1250
|
+
const declaredRanges = new Map();
|
|
1251
|
+
for (const section of [
|
|
1252
|
+
packageJson?.dependencies,
|
|
1253
|
+
packageJson?.devDependencies,
|
|
1254
|
+
]) {
|
|
1255
|
+
for (const [name, range] of Object.entries(section ?? {})) {
|
|
1256
|
+
if (name.startsWith("@beignet/"))
|
|
1257
|
+
declaredRanges.set(name, range);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (declaredRanges.size === 0)
|
|
1261
|
+
return [];
|
|
1262
|
+
const diagnostics = [];
|
|
1263
|
+
const comparableRanges = [...declaredRanges].filter(([, range]) => !localOnlyRangePrefixes.some((prefix) => range.startsWith(prefix)));
|
|
1264
|
+
const distinctRanges = new Set(comparableRanges.map(([, range]) => range));
|
|
1265
|
+
if (distinctRanges.size > 1) {
|
|
1266
|
+
const pairs = comparableRanges
|
|
1267
|
+
.map(([name, range]) => `${name}@${range}`)
|
|
1268
|
+
.join(", ");
|
|
1269
|
+
diagnostics.push({
|
|
1270
|
+
severity: "warning",
|
|
1271
|
+
code: "BEIGNET_VERSION_SKEW",
|
|
1272
|
+
file: "package.json",
|
|
1273
|
+
message: `package.json declares mixed @beignet/* version ranges: ${pairs}. Align all @beignet/* packages to one version; they release together.`,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const installedVersions = await readInstalledBeignetVersions(targetDir);
|
|
1277
|
+
const distinctInstalled = new Set(installedVersions.values());
|
|
1278
|
+
if (distinctInstalled.size > 1) {
|
|
1279
|
+
const pairs = [...installedVersions]
|
|
1280
|
+
.map(([name, version]) => `${name}@${version}`)
|
|
1281
|
+
.join(", ");
|
|
1282
|
+
diagnostics.push({
|
|
1283
|
+
severity: "warning",
|
|
1284
|
+
code: "BEIGNET_VERSION_SKEW",
|
|
1285
|
+
file: "package.json",
|
|
1286
|
+
message: `node_modules contains mixed @beignet/* versions: ${pairs}. Align the declared @beignet/* ranges to one version, then reinstall so a single version is installed.`,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
const coreVersion = installedVersions.get("@beignet/core");
|
|
1290
|
+
const cliVersion = getCliVersion();
|
|
1291
|
+
if (coreVersion !== undefined &&
|
|
1292
|
+
cliVersion !== "0.0.0" &&
|
|
1293
|
+
coreVersion !== cliVersion) {
|
|
1294
|
+
diagnostics.push({
|
|
1295
|
+
severity: "hint",
|
|
1296
|
+
code: "BEIGNET_CLI_VERSION_SKEW",
|
|
1297
|
+
file: "package.json",
|
|
1298
|
+
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.`,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
return diagnostics;
|
|
1302
|
+
}
|
|
1303
|
+
async function readInstalledBeignetVersions(targetDir) {
|
|
1304
|
+
const scopeDir = path.join(targetDir, "node_modules", "@beignet");
|
|
1305
|
+
let entries;
|
|
1306
|
+
try {
|
|
1307
|
+
entries = await readdir(scopeDir);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return new Map();
|
|
1311
|
+
}
|
|
1312
|
+
const versions = new Map();
|
|
1313
|
+
for (const entry of entries.sort()) {
|
|
1314
|
+
if (entry.startsWith("."))
|
|
1315
|
+
continue;
|
|
1316
|
+
const installedPackageJson = await readJsonIfExists(path.join(scopeDir, entry, "package.json"));
|
|
1317
|
+
if (installedPackageJson?.version) {
|
|
1318
|
+
versions.set(`@beignet/${entry}`, installedPackageJson.version);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return versions;
|
|
1322
|
+
}
|
|
1323
|
+
async function inspectProviderMetadataDrift(targetDir, files, convention) {
|
|
1324
|
+
if (!convention.resourceGenerator)
|
|
1325
|
+
return [];
|
|
1326
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1327
|
+
const packageNames = [...installedPackageNames(packageJson)];
|
|
1328
|
+
const diagnostics = [];
|
|
1329
|
+
for (const packageName of packageNames) {
|
|
1330
|
+
const source = await readProviderPackageMetadataSource(targetDir, packageName);
|
|
1331
|
+
if (!source)
|
|
1332
|
+
continue;
|
|
1333
|
+
const result = parseProviderPackageMetadata(source.metadata);
|
|
1334
|
+
if (result.success)
|
|
1335
|
+
continue;
|
|
1336
|
+
diagnostics.push({
|
|
1337
|
+
severity: "warning",
|
|
1338
|
+
code: "BEIGNET_PROVIDER_METADATA_INVALID",
|
|
1339
|
+
file: diagnosticPackageJsonFile(targetDir, source.file),
|
|
1340
|
+
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.`,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
return diagnostics;
|
|
1344
|
+
}
|
|
1345
|
+
async function inspectProviderRegistrationDrift(targetDir, files, config, convention) {
|
|
1346
|
+
if (!convention.resourceGenerator)
|
|
1347
|
+
return [];
|
|
1348
|
+
const diagnostics = [];
|
|
1349
|
+
const packageJson = await readPackageJson(targetDir, files);
|
|
1350
|
+
const installedPackages = installedPackageNames(packageJson);
|
|
1351
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(targetDir, packageJson);
|
|
1352
|
+
const providerFiles = serverProviderFiles(files, config);
|
|
1353
|
+
const sourceCache = new Map();
|
|
1354
|
+
const providerSource = (await Promise.all(providerFiles.map((file) => readCachedSource(targetDir, file, sourceCache)))).join("\n");
|
|
1355
|
+
const providerListSource = extractProviderListSource(providerSource);
|
|
1356
|
+
const providerEntries = providerListSource === undefined
|
|
1357
|
+
? []
|
|
1358
|
+
: providerListEntries(providerListSource);
|
|
1359
|
+
for (const provider of providerDoctorRules) {
|
|
1360
|
+
const severity = provider.registrationSeverity;
|
|
1361
|
+
if (severity === undefined)
|
|
1362
|
+
continue;
|
|
1363
|
+
if (!installedPackages.has(provider.packageName))
|
|
1364
|
+
continue;
|
|
1365
|
+
if (provider.tokens.some((token) => providerEntries.some((entry) => entry.includes(token)))) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
diagnostics.push({
|
|
1369
|
+
severity,
|
|
1370
|
+
code: "BEIGNET_PROVIDER_REGISTRATION_MISSING",
|
|
1371
|
+
file: providerFiles[0] ?? config.paths.server,
|
|
1372
|
+
message: severity === "hint"
|
|
1373
|
+
? `${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.`
|
|
1374
|
+
: `${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.`,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
const drizzleIndex = findProviderEntryIndex(providerEntries, [
|
|
1378
|
+
"drizzleTursoProvider",
|
|
1379
|
+
"createDrizzleTursoProvider",
|
|
1380
|
+
]);
|
|
1381
|
+
const appDatabaseProviderIndex = findProviderEntryIndex(providerEntries, [
|
|
1382
|
+
"starterDatabaseProvider",
|
|
1383
|
+
"exampleDatabaseProvider",
|
|
1384
|
+
"twitterDatabaseProvider",
|
|
1385
|
+
"databaseProvider",
|
|
1386
|
+
]);
|
|
1387
|
+
if (appDatabaseProviderIndex >= 0 &&
|
|
1388
|
+
drizzleIndex >= 0 &&
|
|
1389
|
+
appDatabaseProviderIndex < drizzleIndex) {
|
|
1390
|
+
diagnostics.push({
|
|
1391
|
+
severity: "warning",
|
|
1392
|
+
code: "BEIGNET_PROVIDER_ORDER",
|
|
1393
|
+
file: providerFiles[0] ?? config.paths.server,
|
|
1394
|
+
message: "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.",
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
return diagnostics;
|
|
1398
|
+
}
|
|
1399
|
+
function isFeatureUploadDefinition(file, config) {
|
|
1400
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1401
|
+
return new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/uploads/[^/]+\\.ts$`).test(file);
|
|
1402
|
+
}
|
|
1403
|
+
function operationalConfigFiles(files, config) {
|
|
1404
|
+
return files.filter((file) => {
|
|
1405
|
+
if (file === ".env.example")
|
|
1406
|
+
return true;
|
|
1407
|
+
if (file === "drizzle.config.ts")
|
|
1408
|
+
return true;
|
|
1409
|
+
if (file === "lib/env.ts" || file === "lib/better-auth.ts")
|
|
1410
|
+
return true;
|
|
1411
|
+
if (isInfraOrServerFile(file, config) && file.endsWith(".ts"))
|
|
1412
|
+
return true;
|
|
1413
|
+
return false;
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
async function readPackageJson(targetDir, files) {
|
|
1417
|
+
if (!files.includes("package.json"))
|
|
1418
|
+
return undefined;
|
|
1419
|
+
return JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8"));
|
|
1420
|
+
}
|
|
1421
|
+
async function providerDoctorRulesForInstalledPackages(targetDir, packageJson) {
|
|
1422
|
+
const packageNames = [...installedPackageNames(packageJson)];
|
|
1423
|
+
const rules = await Promise.all(packageNames.map(async (packageName) => {
|
|
1424
|
+
const source = await readProviderPackageMetadataSource(targetDir, packageName);
|
|
1425
|
+
if (!source)
|
|
1426
|
+
return undefined;
|
|
1427
|
+
const result = parseProviderPackageMetadata(source.metadata);
|
|
1428
|
+
if (!result.success)
|
|
1429
|
+
return undefined;
|
|
1430
|
+
return providerDoctorRuleFromMetadata(packageName, result.metadata);
|
|
1431
|
+
}));
|
|
1432
|
+
return rules.filter((rule) => Boolean(rule));
|
|
1433
|
+
}
|
|
1434
|
+
async function readProviderPackageMetadataSource(targetDir, packageName) {
|
|
1435
|
+
for (const candidate of providerPackageJsonCandidates(targetDir, packageName)) {
|
|
1436
|
+
const packageJson = await readJsonIfExists(candidate);
|
|
1437
|
+
const metadata = packageJson?.beignet?.provider;
|
|
1438
|
+
if (metadata !== undefined) {
|
|
1439
|
+
return { packageName, file: candidate, metadata };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return undefined;
|
|
1443
|
+
}
|
|
1444
|
+
function providerPackageJsonCandidates(targetDir, packageName) {
|
|
1445
|
+
const candidates = [
|
|
1446
|
+
path.join(targetDir, "node_modules", ...packageName.split("/"), "package.json"),
|
|
1447
|
+
];
|
|
1448
|
+
if (packageName.startsWith("@beignet/")) {
|
|
1449
|
+
candidates.push(path.resolve(
|
|
1450
|
+
// import.meta.dir is Bun-only; the published bin also runs under
|
|
1451
|
+
// plain Node, where only import.meta.url is available.
|
|
1452
|
+
path.dirname(fileURLToPath(import.meta.url)), "../../..", "packages", packageName.slice("@beignet/".length), "package.json"));
|
|
1453
|
+
}
|
|
1454
|
+
return candidates;
|
|
1455
|
+
}
|
|
1456
|
+
async function readJsonIfExists(file) {
|
|
1457
|
+
try {
|
|
1458
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
1459
|
+
}
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
if (error instanceof Error &&
|
|
1462
|
+
"code" in error &&
|
|
1463
|
+
error.code === "ENOENT") {
|
|
1464
|
+
return undefined;
|
|
1465
|
+
}
|
|
1466
|
+
throw error;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
function providerDoctorRuleFromMetadata(packageName, metadata) {
|
|
1470
|
+
const registration = metadata.registration;
|
|
1471
|
+
return {
|
|
1472
|
+
packageName,
|
|
1473
|
+
displayName: metadata.displayName ?? packageName,
|
|
1474
|
+
tokens: registration?.tokens ?? [],
|
|
1475
|
+
appPorts: metadata.appPorts ?? [],
|
|
1476
|
+
requiredEnv: metadata.requiredEnv ?? [],
|
|
1477
|
+
registrationSeverity: registration?.severity ??
|
|
1478
|
+
(registration?.required === true ? "warning" : undefined),
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
function registrationTokenHint(tokens) {
|
|
1482
|
+
const token = tokens[0];
|
|
1483
|
+
if (!token)
|
|
1484
|
+
return "the provider";
|
|
1485
|
+
return token.startsWith("create") ? `${token}()` : token;
|
|
1486
|
+
}
|
|
1487
|
+
function diagnosticPackageJsonFile(targetDir, file) {
|
|
1488
|
+
const relative = normalizePath(path.relative(targetDir, file));
|
|
1489
|
+
if (relative.startsWith("../"))
|
|
1490
|
+
return "package.json";
|
|
1491
|
+
return relative;
|
|
1492
|
+
}
|
|
1493
|
+
function formatProviderMetadataIssues(issues) {
|
|
1494
|
+
return issues.map((issue) => `${issue.path} ${issue.message}`).join("; ");
|
|
1495
|
+
}
|
|
1496
|
+
function installedPackageNames(packageJson) {
|
|
1497
|
+
return new Set([
|
|
1498
|
+
...Object.keys(packageJson?.dependencies ?? {}),
|
|
1499
|
+
...Object.keys(packageJson?.devDependencies ?? {}),
|
|
1500
|
+
...Object.keys(packageJson?.peerDependencies ?? {}),
|
|
1501
|
+
]);
|
|
1502
|
+
}
|
|
1503
|
+
function serverProviderFiles(files, config) {
|
|
1504
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
1505
|
+
const candidates = [`${serverDir}/providers.ts`, config.paths.server];
|
|
1506
|
+
return candidates.filter((file) => files.includes(file));
|
|
1507
|
+
}
|
|
1508
|
+
function extractProviderListSource(source) {
|
|
1509
|
+
const listMatches = [
|
|
1510
|
+
...source.matchAll(/\bproviders\s*=\s*\[([\s\S]*?)\]\s*(?:;|as const)/g),
|
|
1511
|
+
...source.matchAll(/\bproviders\s*:\s*\[([\s\S]*?)\]/g),
|
|
1512
|
+
];
|
|
1513
|
+
if (listMatches.length === 0)
|
|
1514
|
+
return undefined;
|
|
1515
|
+
return listMatches.map((match) => match[1]).join("\n");
|
|
1516
|
+
}
|
|
1517
|
+
function providerListEntries(source) {
|
|
1518
|
+
const entries = [];
|
|
1519
|
+
let current = "";
|
|
1520
|
+
let depth = 0;
|
|
1521
|
+
let quote;
|
|
1522
|
+
let escaped = false;
|
|
1523
|
+
let lineComment = false;
|
|
1524
|
+
let blockComment = false;
|
|
1525
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1526
|
+
const char = source[index];
|
|
1527
|
+
const next = source[index + 1];
|
|
1528
|
+
if (lineComment) {
|
|
1529
|
+
if (char === "\n") {
|
|
1530
|
+
lineComment = false;
|
|
1531
|
+
current += char;
|
|
1532
|
+
}
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (blockComment) {
|
|
1536
|
+
if (char === "*" && next === "/") {
|
|
1537
|
+
blockComment = false;
|
|
1538
|
+
index += 1;
|
|
1539
|
+
}
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
if (quote) {
|
|
1543
|
+
current += char;
|
|
1544
|
+
if (escaped) {
|
|
1545
|
+
escaped = false;
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
if (char === "\\") {
|
|
1549
|
+
escaped = true;
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
if (char === quote) {
|
|
1553
|
+
quote = undefined;
|
|
1554
|
+
}
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
if (char === "/" && next === "/") {
|
|
1558
|
+
lineComment = true;
|
|
1559
|
+
index += 1;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
if (char === "/" && next === "*") {
|
|
1563
|
+
blockComment = true;
|
|
1564
|
+
index += 1;
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
1568
|
+
quote = char;
|
|
1569
|
+
current += char;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
1573
|
+
depth += 1;
|
|
1574
|
+
}
|
|
1575
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
1576
|
+
depth = Math.max(0, depth - 1);
|
|
1577
|
+
}
|
|
1578
|
+
if (char === "," && depth === 0) {
|
|
1579
|
+
const entry = current.trim();
|
|
1580
|
+
if (entry)
|
|
1581
|
+
entries.push(entry);
|
|
1582
|
+
current = "";
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
current += char;
|
|
1586
|
+
}
|
|
1587
|
+
const entry = current.trim();
|
|
1588
|
+
if (entry)
|
|
1589
|
+
entries.push(entry);
|
|
1590
|
+
return entries;
|
|
1591
|
+
}
|
|
1592
|
+
function findProviderEntryIndex(entries, tokens) {
|
|
1593
|
+
return entries.findIndex((entry) => tokens.some((token) => entry.includes(token)));
|
|
1594
|
+
}
|
|
1595
|
+
function hasBetterAuthRoute(files, config) {
|
|
1596
|
+
const routesPath = directoryPath(config.paths.routes);
|
|
1597
|
+
return files.some((file) => file.startsWith(`${routesPath}/auth/`) && file.endsWith("/route.ts"));
|
|
1598
|
+
}
|
|
1599
|
+
function featureNameFromContractFile(file, config) {
|
|
1600
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1601
|
+
const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/contracts\\.ts$`).exec(file);
|
|
1602
|
+
return match?.[1];
|
|
1603
|
+
}
|
|
1604
|
+
function featureRuntimeFiles(files, featuresPath, feature) {
|
|
1605
|
+
const runtimeFolders = [
|
|
1606
|
+
"jobs",
|
|
1607
|
+
"listeners",
|
|
1608
|
+
"schedules",
|
|
1609
|
+
"uploads",
|
|
1610
|
+
"use-cases",
|
|
1611
|
+
];
|
|
1612
|
+
return files.filter((file) => {
|
|
1613
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts"))
|
|
1614
|
+
return false;
|
|
1615
|
+
return runtimeFolders.some((folder) => file.startsWith(`${featuresPath}/${feature}/${folder}/`));
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
function featureTestFiles(files, featuresPath, feature, options = {}) {
|
|
1619
|
+
return files.filter((file) => {
|
|
1620
|
+
if (!file.startsWith(`${featuresPath}/${feature}/`) ||
|
|
1621
|
+
!file.endsWith(".test.ts")) {
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
return !options.policyOnly || file.endsWith("/policy.test.ts");
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
function featureNamesFromContracts(contracts, config) {
|
|
1628
|
+
return [
|
|
1629
|
+
...new Set(contracts
|
|
1630
|
+
.map((contract) => featureNameFromContractFile(contract.file, config))
|
|
1631
|
+
.filter((feature) => feature !== undefined)),
|
|
1632
|
+
].sort();
|
|
1633
|
+
}
|
|
1634
|
+
function sharedErrorCatalogBindings(source, file, files, sharedErrorsFile) {
|
|
1635
|
+
const bindings = new Set();
|
|
1636
|
+
const imports = parseNamedImportSources(source);
|
|
1637
|
+
for (const [localName, imported] of imports) {
|
|
1638
|
+
if (imported.importedName !== "errors")
|
|
1639
|
+
continue;
|
|
1640
|
+
if (importResolvesToFile(imported.sourcePath, file, files, sharedErrorsFile)) {
|
|
1641
|
+
bindings.add(localName);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return bindings;
|
|
1645
|
+
}
|
|
1646
|
+
function importResolvesToFile(sourcePath, importerFile, files, expectedFile) {
|
|
1647
|
+
return (sourceFileFromImport(sourcePath, importerFile, files) === expectedFile ||
|
|
1648
|
+
sourceFileFromImport(sourcePath, importerFile) === expectedFile);
|
|
1649
|
+
}
|
|
1650
|
+
function featureErrorRuntimeFiles(files, featuresPath, feature) {
|
|
1651
|
+
const runtimeFiles = new Set(featureRuntimeFiles(files, featuresPath, feature));
|
|
1652
|
+
const policyFile = `${featuresPath}/${feature}/policy.ts`;
|
|
1653
|
+
if (files.includes(policyFile))
|
|
1654
|
+
runtimeFiles.add(policyFile);
|
|
1655
|
+
return [...runtimeFiles].sort();
|
|
1656
|
+
}
|
|
1657
|
+
async function featureAppErrorUses(targetDir, files, sourceCache) {
|
|
1658
|
+
const uses = new Map();
|
|
1659
|
+
for (const file of files) {
|
|
1660
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1661
|
+
for (const match of source.matchAll(/\bappError\s*\(\s*["']([^"']+)["']/g)) {
|
|
1662
|
+
if (!uses.has(match[1]))
|
|
1663
|
+
uses.set(match[1], file);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
return uses;
|
|
1667
|
+
}
|
|
1668
|
+
function isFeatureOwnedErrorName(errorName, feature) {
|
|
1669
|
+
const singularPascal = pascalCase(singularize(feature));
|
|
1670
|
+
const pluralPascal = pascalCase(feature);
|
|
1671
|
+
return (errorName.startsWith(singularPascal) || errorName.startsWith(pluralPascal));
|
|
1672
|
+
}
|
|
1673
|
+
async function anyFileContains(targetDir, files, needle, sourceCache) {
|
|
1674
|
+
for (const file of files) {
|
|
1675
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1676
|
+
if (source.includes(needle))
|
|
1677
|
+
return true;
|
|
1678
|
+
}
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
async function anyFileMatches(targetDir, files, pattern, sourceCache) {
|
|
1682
|
+
for (const file of files) {
|
|
1683
|
+
const source = await readCachedSource(targetDir, file, sourceCache);
|
|
1684
|
+
if (pattern.test(source))
|
|
1685
|
+
return true;
|
|
1686
|
+
}
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
async function readCachedSource(targetDir, file, sourceCache) {
|
|
1690
|
+
const cached = sourceCache.get(file);
|
|
1691
|
+
if (cached !== undefined)
|
|
1692
|
+
return cached;
|
|
1693
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1694
|
+
sourceCache.set(file, source);
|
|
1695
|
+
return source;
|
|
1696
|
+
}
|
|
1697
|
+
async function inspectFeatureArtifactDrift(targetDir, files, config, convention) {
|
|
1698
|
+
if (!convention.resourceGenerator)
|
|
1699
|
+
return [];
|
|
1700
|
+
const diagnostics = [];
|
|
1701
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1702
|
+
const uploadDefinitions = files.filter((file) => isFeatureArtifactFile(file, featuresPath, "uploads"));
|
|
1703
|
+
if (uploadDefinitions.length > 0 &&
|
|
1704
|
+
!(await appHasUploadRoute(targetDir, files, config))) {
|
|
1705
|
+
diagnostics.push({
|
|
1706
|
+
severity: "warning",
|
|
1707
|
+
code: "BEIGNET_UPLOAD_ROUTE_MISSING",
|
|
1708
|
+
file: uploadDefinitions[0],
|
|
1709
|
+
message: "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.",
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
for (const file of files) {
|
|
1713
|
+
const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.test\\.ts$`).exec(file);
|
|
1714
|
+
if (!match)
|
|
1715
|
+
continue;
|
|
1716
|
+
diagnostics.push({
|
|
1717
|
+
severity: "warning",
|
|
1718
|
+
code: "BEIGNET_FEATURE_TEST_PLACEMENT",
|
|
1719
|
+
file,
|
|
1720
|
+
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.`,
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
for (const file of files) {
|
|
1724
|
+
if (!file.endsWith(".ts") ||
|
|
1725
|
+
file.endsWith(".test.ts") ||
|
|
1726
|
+
file.endsWith(".d.ts")) {
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
const misplaced = misplacedFeatureArtifactFolder(file, featuresPath);
|
|
1730
|
+
if (misplaced) {
|
|
1731
|
+
diagnostics.push({
|
|
1732
|
+
severity: "warning",
|
|
1733
|
+
code: "BEIGNET_FEATURE_ARTIFACT_FOLDER",
|
|
1734
|
+
file,
|
|
1735
|
+
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.`,
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
if (isNotificationDefinitionFile(file, featuresPath) ||
|
|
1739
|
+
isInfraOrServerFile(file, config)) {
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1743
|
+
if (containsCallExpression(source, "createInlineNotificationDispatcher")) {
|
|
1744
|
+
diagnostics.push({
|
|
1745
|
+
severity: "warning",
|
|
1746
|
+
code: "BEIGNET_NOTIFICATION_PORT_BYPASS",
|
|
1747
|
+
file,
|
|
1748
|
+
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.`,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return diagnostics;
|
|
1753
|
+
}
|
|
1754
|
+
function isFeatureArtifactFile(file, featuresPath, folder) {
|
|
1755
|
+
const match = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/${folder}/([^/]+)\\.ts$`).exec(file);
|
|
1756
|
+
return Boolean(match && match[1] !== "index");
|
|
1757
|
+
}
|
|
1758
|
+
async function appHasUploadRoute(targetDir, files, config) {
|
|
1759
|
+
for (const file of files) {
|
|
1760
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts"))
|
|
1761
|
+
continue;
|
|
1762
|
+
if (!isRouteServerOrInfraFile(file, config))
|
|
1763
|
+
continue;
|
|
1764
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1765
|
+
if (containsCallExpression(source, "createUploadRoute") ||
|
|
1766
|
+
containsCallExpression(source, "createUploadRouter")) {
|
|
1767
|
+
return true;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return false;
|
|
1771
|
+
}
|
|
1772
|
+
function misplacedFeatureArtifactFolder(file, featuresPath) {
|
|
1773
|
+
const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/([^/]+)/`).exec(file);
|
|
1774
|
+
if (!match)
|
|
1775
|
+
return undefined;
|
|
1776
|
+
const folder = match[2];
|
|
1777
|
+
const mappings = {
|
|
1778
|
+
command: "tasks/",
|
|
1779
|
+
commands: "tasks/",
|
|
1780
|
+
event: "domain/events/",
|
|
1781
|
+
events: "domain/events/",
|
|
1782
|
+
job: "jobs/",
|
|
1783
|
+
listener: "listeners/",
|
|
1784
|
+
notification: "notifications/",
|
|
1785
|
+
schedule: "schedules/",
|
|
1786
|
+
scheduled: "schedules/",
|
|
1787
|
+
task: "tasks/",
|
|
1788
|
+
upload: "uploads/",
|
|
1789
|
+
};
|
|
1790
|
+
const expected = mappings[folder];
|
|
1791
|
+
if (!expected)
|
|
1792
|
+
return undefined;
|
|
1793
|
+
return { actual: `${folder}/`, expected };
|
|
1794
|
+
}
|
|
1795
|
+
function isNotificationDefinitionFile(file, featuresPath) {
|
|
1796
|
+
return isFeatureArtifactFile(file, featuresPath, "notifications");
|
|
1797
|
+
}
|
|
1798
|
+
function isRouteServerOrInfraFile(file, config) {
|
|
1799
|
+
return (file.startsWith(`${directoryPath(config.paths.routes)}/`) ||
|
|
1800
|
+
isInfraOrServerFile(file, config));
|
|
1801
|
+
}
|
|
1802
|
+
function isInfraOrServerFile(file, config) {
|
|
1803
|
+
const serverDir = directoryPath(path.dirname(config.paths.server));
|
|
1804
|
+
const infrastructureDir = directoryPath(path.dirname(config.paths.infrastructurePorts));
|
|
1805
|
+
return (file === config.paths.server ||
|
|
1806
|
+
file.startsWith(`${serverDir}/`) ||
|
|
1807
|
+
file === config.paths.infrastructurePorts ||
|
|
1808
|
+
file.startsWith(`${infrastructureDir}/`));
|
|
1809
|
+
}
|
|
1810
|
+
function containsCallExpression(source, name) {
|
|
1811
|
+
return new RegExp(`\\b${escapeRegExp(name)}\\s*(?:<[^;=(){}]*>\\s*)?\\(`).test(source);
|
|
1812
|
+
}
|
|
1813
|
+
async function inspectServerlessFootguns(targetDir, files) {
|
|
1814
|
+
const diagnostics = [];
|
|
1815
|
+
for (const file of files) {
|
|
1816
|
+
if (!file.endsWith(".ts") ||
|
|
1817
|
+
file.endsWith(".test.ts") ||
|
|
1818
|
+
file.endsWith(".d.ts")) {
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
const isInfraOrServerFile = file.startsWith("infra/") || file.startsWith("server/");
|
|
1822
|
+
const isProviderFile = /(^|\/)provider(s)?\.ts$/.test(file) ||
|
|
1823
|
+
/(^|\/)[^/]*provider[^/]*\.ts$/.test(file);
|
|
1824
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1825
|
+
if (isInfraOrServerFile &&
|
|
1826
|
+
isProviderFile &&
|
|
1827
|
+
/\bset(?:Interval|Timeout)\s*\(/.test(source)) {
|
|
1828
|
+
diagnostics.push({
|
|
1829
|
+
severity: "warning",
|
|
1830
|
+
code: "BEIGNET_PROVIDER_BACKGROUND_WORK",
|
|
1831
|
+
file,
|
|
1832
|
+
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.`,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
if (isInfraOrServerFile &&
|
|
1836
|
+
isProviderFile &&
|
|
1837
|
+
/\bcreateProvider\s*\(/.test(source) &&
|
|
1838
|
+
/\bdrainOutbox\s*\(/.test(source)) {
|
|
1839
|
+
diagnostics.push({
|
|
1840
|
+
severity: "warning",
|
|
1841
|
+
code: "BEIGNET_PROVIDER_OUTBOX_DRAIN",
|
|
1842
|
+
file,
|
|
1843
|
+
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.`,
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
if (file.startsWith("app/api/cron/") &&
|
|
1847
|
+
file.endsWith("/route.ts") &&
|
|
1848
|
+
!/\bauthorization\b/i.test(source) &&
|
|
1849
|
+
!/\bcreateOutboxDrainRoute\s*\(/.test(source) &&
|
|
1850
|
+
!/\bcreateScheduleRoute\s*\(/.test(source)) {
|
|
1851
|
+
diagnostics.push({
|
|
1852
|
+
severity: "warning",
|
|
1853
|
+
code: "BEIGNET_CRON_ROUTE_AUTH_MISSING",
|
|
1854
|
+
file,
|
|
1855
|
+
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(...).`,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if (hasOutboxRegistry(files) &&
|
|
1860
|
+
!(await appHasOutboxDrainEntrypoint(targetDir, files))) {
|
|
462
1861
|
diagnostics.push({
|
|
463
|
-
severity: "
|
|
464
|
-
code: "
|
|
465
|
-
|
|
466
|
-
contract: route.exportName,
|
|
467
|
-
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.`,
|
|
1862
|
+
severity: "warning",
|
|
1863
|
+
code: "BEIGNET_OUTBOX_DRAIN_MISSING",
|
|
1864
|
+
message: "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.",
|
|
468
1865
|
});
|
|
469
1866
|
}
|
|
470
|
-
|
|
471
|
-
|
|
1867
|
+
return diagnostics;
|
|
1868
|
+
}
|
|
1869
|
+
function hasOutboxRegistry(files) {
|
|
1870
|
+
return files.some(isOutboxRegistryFile);
|
|
1871
|
+
}
|
|
1872
|
+
function isOutboxRegistryFile(file) {
|
|
1873
|
+
return file === "server/outbox.ts";
|
|
1874
|
+
}
|
|
1875
|
+
async function appHasOutboxDrainEntrypoint(targetDir, files) {
|
|
1876
|
+
for (const file of files) {
|
|
1877
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts"))
|
|
1878
|
+
continue;
|
|
1879
|
+
if (!file.startsWith("app/api/") &&
|
|
1880
|
+
!file.startsWith("server/") &&
|
|
1881
|
+
!file.startsWith("infra/")) {
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
const source = await readFile(path.join(targetDir, file), "utf8");
|
|
1885
|
+
if (/\bcreateOutboxDrainRoute\s*\(/.test(source) ||
|
|
1886
|
+
/\bdrainOutbox\s*\(/.test(source)) {
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
472
1889
|
}
|
|
473
|
-
|
|
474
|
-
return dedupeDiagnostics(diagnostics);
|
|
1890
|
+
return false;
|
|
475
1891
|
}
|
|
476
1892
|
async function inspectPortProviderDrift(targetDir, files, config, convention) {
|
|
477
1893
|
if (!convention.resourceGenerator)
|
|
@@ -481,21 +1897,27 @@ async function inspectPortProviderDrift(targetDir, files, config, convention) {
|
|
|
481
1897
|
? await readFile(path.join(targetDir, config.paths.ports), "utf8")
|
|
482
1898
|
: "";
|
|
483
1899
|
const packageJson = files.includes("package.json")
|
|
484
|
-
?
|
|
1900
|
+
? await readPackageJson(targetDir, files)
|
|
485
1901
|
: undefined;
|
|
486
1902
|
const installedPackages = new Set([
|
|
487
1903
|
...Object.keys(packageJson?.dependencies ?? {}),
|
|
488
1904
|
...Object.keys(packageJson?.devDependencies ?? {}),
|
|
489
1905
|
...Object.keys(packageJson?.peerDependencies ?? {}),
|
|
490
1906
|
]);
|
|
491
|
-
|
|
1907
|
+
const providerDoctorRules = await providerDoctorRulesForInstalledPackages(targetDir, packageJson);
|
|
1908
|
+
const providerPortRequirements = providerDoctorRules.flatMap((provider) => (provider.appPorts ?? []).map((port) => ({
|
|
1909
|
+
packageName: provider.packageName,
|
|
1910
|
+
portName: port.name,
|
|
1911
|
+
portType: port.type,
|
|
1912
|
+
})));
|
|
1913
|
+
for (const expected of providerPortRequirements) {
|
|
492
1914
|
if (!installedPackages.has(expected.packageName))
|
|
493
1915
|
continue;
|
|
494
1916
|
if (portsSource.includes(`${expected.portName}:`))
|
|
495
1917
|
continue;
|
|
496
1918
|
diagnostics.push({
|
|
497
1919
|
severity: "warning",
|
|
498
|
-
code: "
|
|
1920
|
+
code: "BEIGNET_PROVIDER_PORT_MISSING",
|
|
499
1921
|
file: config.paths.ports,
|
|
500
1922
|
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.`,
|
|
501
1923
|
});
|
|
@@ -517,55 +1939,234 @@ async function inspectPortProviderDrift(targetDir, files, config, convention) {
|
|
|
517
1939
|
continue;
|
|
518
1940
|
diagnostics.push({
|
|
519
1941
|
severity: "warning",
|
|
520
|
-
code: "
|
|
1942
|
+
code: "BEIGNET_PORT_TEST_FAKE_MISSING",
|
|
521
1943
|
file,
|
|
522
1944
|
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.`,
|
|
523
1945
|
});
|
|
524
1946
|
}
|
|
525
1947
|
return diagnostics;
|
|
526
1948
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1949
|
+
async function inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict) {
|
|
1950
|
+
if (!convention.resourceGenerator)
|
|
1951
|
+
return [];
|
|
1952
|
+
const diagnostics = [];
|
|
1953
|
+
const resources = await collectResourceNames(targetDir, files, config);
|
|
1954
|
+
const infrastructurePath = directoryPath(path.dirname(config.paths.infrastructurePorts));
|
|
1955
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
1956
|
+
const repositoriesPath = path.join(infrastructurePath, "db", "repositories.ts");
|
|
1957
|
+
const repositoriesSource = files.includes(repositoriesPath)
|
|
1958
|
+
? await readFile(path.join(targetDir, repositoriesPath), "utf8")
|
|
1959
|
+
: "";
|
|
1960
|
+
const packageScripts = await readPackageScripts(targetDir, files);
|
|
1961
|
+
const schemaDir = `${infrastructurePath}/db/schema`;
|
|
1962
|
+
const schemaIndexFile = `${schemaDir}/index.ts`;
|
|
1963
|
+
const schemaFiles = files.filter((file) => file.startsWith(`${schemaDir}/`) &&
|
|
1964
|
+
file.endsWith(".ts") &&
|
|
1965
|
+
file !== schemaIndexFile);
|
|
1966
|
+
const hasDrizzleArtifacts = hasDrizzleConfig(files) ||
|
|
1967
|
+
files.includes(repositoriesPath) ||
|
|
1968
|
+
files.includes(schemaIndexFile) ||
|
|
1969
|
+
schemaFiles.length > 0 ||
|
|
1970
|
+
files.some((file) => file.startsWith(`${infrastructurePath}/`) &&
|
|
1971
|
+
file.endsWith("-repository.ts") &&
|
|
1972
|
+
file.includes("/drizzle-"));
|
|
1973
|
+
if (hasDrizzleArtifacts &&
|
|
1974
|
+
!hasDrizzleConfig(files) &&
|
|
1975
|
+
hasDrizzleLifecycleScriptNeedingRootConfig(packageScripts)) {
|
|
1976
|
+
diagnostics.push({
|
|
1977
|
+
severity: "warning",
|
|
1978
|
+
code: "BEIGNET_DRIZZLE_CONFIG_MISSING",
|
|
1979
|
+
file: "drizzle.config.ts",
|
|
1980
|
+
message: "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.",
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
if (hasDrizzleArtifacts) {
|
|
1984
|
+
for (const script of ["db:generate", "db:migrate"]) {
|
|
1985
|
+
if (packageScripts[script])
|
|
1986
|
+
continue;
|
|
1987
|
+
diagnostics.push({
|
|
1988
|
+
severity: "warning",
|
|
1989
|
+
code: "BEIGNET_DB_SCRIPT_MISSING",
|
|
1990
|
+
file: "package.json",
|
|
1991
|
+
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.`,
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const schemaFilePath = `${infrastructurePath}/db/schema.ts`;
|
|
1996
|
+
if (files.includes(schemaFilePath)) {
|
|
1997
|
+
diagnostics.push({
|
|
1998
|
+
severity: "warning",
|
|
1999
|
+
code: "BEIGNET_DB_SCHEMA_FILE_FORM",
|
|
2000
|
+
file: schemaFilePath,
|
|
2001
|
+
message: `${schemaFilePath} keeps the Drizzle schema in a single file. Move it to ${schemaIndexFile} so feature tables can live in separate modules under ${schemaDir}/.`,
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
if (schemaFiles.length > 0 && !files.includes(schemaIndexFile)) {
|
|
2005
|
+
diagnostics.push({
|
|
2006
|
+
severity: "warning",
|
|
2007
|
+
code: "BEIGNET_DB_SCHEMA_INDEX_MISSING",
|
|
2008
|
+
file: schemaIndexFile,
|
|
2009
|
+
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.`,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
else if (schemaFiles.length > 0) {
|
|
2013
|
+
const schemaIndexSource = await readFile(path.join(targetDir, schemaIndexFile), "utf8");
|
|
2014
|
+
for (const schemaFile of schemaFiles) {
|
|
2015
|
+
const moduleName = path.basename(schemaFile, ".ts");
|
|
2016
|
+
if (schemaIndexSource.includes(`"./${moduleName}"`))
|
|
2017
|
+
continue;
|
|
2018
|
+
diagnostics.push({
|
|
2019
|
+
severity: "warning",
|
|
2020
|
+
code: "BEIGNET_DB_SCHEMA_EXPORT_MISSING",
|
|
2021
|
+
file: schemaIndexFile,
|
|
2022
|
+
message: `${schemaFile} is not exported from ${schemaIndexFile}. Export it so Drizzle config, migrations, and repository factories see the same schema module.`,
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
const standardEntrypoints = [
|
|
2027
|
+
{
|
|
2028
|
+
script: "db:seed",
|
|
2029
|
+
command: "seed",
|
|
2030
|
+
file: `${infrastructurePath}/db/seed.ts`,
|
|
2031
|
+
code: "BEIGNET_DB_SEED_ENTRYPOINT_MISSING",
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
script: "db:reset",
|
|
2035
|
+
command: "reset",
|
|
2036
|
+
file: `${infrastructurePath}/db/reset.ts`,
|
|
2037
|
+
code: "BEIGNET_DB_RESET_ENTRYPOINT_MISSING",
|
|
2038
|
+
},
|
|
2039
|
+
];
|
|
2040
|
+
for (const entrypoint of standardEntrypoints) {
|
|
2041
|
+
const script = packageScripts[entrypoint.script];
|
|
2042
|
+
if (!script?.includes(entrypoint.file) || files.includes(entrypoint.file)) {
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
diagnostics.push({
|
|
2046
|
+
severity: "warning",
|
|
2047
|
+
code: entrypoint.code,
|
|
2048
|
+
file: entrypoint.file,
|
|
2049
|
+
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}.`,
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
const resetEntrypoint = `${infrastructurePath}/db/reset.ts`;
|
|
2053
|
+
if (packageScripts["db:reset"]?.includes(resetEntrypoint) &&
|
|
2054
|
+
files.includes(resetEntrypoint)) {
|
|
2055
|
+
const resetSource = await readFile(path.join(targetDir, resetEntrypoint), "utf8");
|
|
2056
|
+
if (!resetSource.includes("BEIGNET_ALLOW_DATABASE_RESET")) {
|
|
2057
|
+
diagnostics.push({
|
|
2058
|
+
severity: "warning",
|
|
2059
|
+
code: "BEIGNET_DB_RESET_GUARD_MISSING",
|
|
2060
|
+
file: resetEntrypoint,
|
|
2061
|
+
message: "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.",
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
for (const resource of resources) {
|
|
2066
|
+
const singular = singularize(resource);
|
|
2067
|
+
const pascal = pascalCase(singular);
|
|
2068
|
+
const pluralCamel = camelCase(resource);
|
|
2069
|
+
const infrastructureDir = `${infrastructurePath}/${resource}/`;
|
|
2070
|
+
const hasRepositoryAdapter = hasRepositoryAdapterFile(files, infrastructureDir);
|
|
2071
|
+
const drizzleRepositoryAdapter = drizzleRepositoryAdapterFile(files, infrastructureDir, singular);
|
|
2072
|
+
const portFile = resourcePortFile(resource, singular, config);
|
|
2073
|
+
if (files.includes(portFile) && !hasRepositoryAdapter) {
|
|
2074
|
+
diagnostics.push({
|
|
2075
|
+
severity: "warning",
|
|
2076
|
+
code: "BEIGNET_REPOSITORY_ADAPTER_MISSING",
|
|
2077
|
+
file: portFile,
|
|
2078
|
+
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.`,
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
if (drizzleRepositoryAdapter) {
|
|
2082
|
+
const factoryName = `createDrizzle${pascal}Repository`;
|
|
2083
|
+
if (repositoriesSource &&
|
|
2084
|
+
(!repositoriesSource.includes(factoryName) ||
|
|
2085
|
+
!new RegExp(`\\b${pluralCamel}\\s*:`).test(repositoriesSource))) {
|
|
2086
|
+
diagnostics.push({
|
|
2087
|
+
severity: "warning",
|
|
2088
|
+
code: "BEIGNET_DRIZZLE_REPOSITORY_UNWIRED",
|
|
2089
|
+
file: repositoriesPath,
|
|
2090
|
+
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.`,
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (strict &&
|
|
2095
|
+
hasFeatureSeeds(files, featuresPath, resource) &&
|
|
2096
|
+
!hasFeatureFactories(files, featuresPath, resource)) {
|
|
2097
|
+
diagnostics.push({
|
|
2098
|
+
severity: "warning",
|
|
2099
|
+
code: "BEIGNET_FACTORY_MISSING",
|
|
2100
|
+
file: `${featuresPath}/${resource}/seeds/`,
|
|
2101
|
+
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.`,
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (hasAnyFeatureSeeds(files, featuresPath) &&
|
|
2106
|
+
!(await packageScriptExists(targetDir, files, "db:seed"))) {
|
|
2107
|
+
diagnostics.push({
|
|
2108
|
+
severity: "warning",
|
|
2109
|
+
code: "BEIGNET_DB_SEED_SCRIPT_MISSING",
|
|
2110
|
+
file: "package.json",
|
|
2111
|
+
message: '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.',
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
return diagnostics;
|
|
2115
|
+
}
|
|
2116
|
+
function hasRepositoryAdapterFile(files, infrastructureDir) {
|
|
2117
|
+
return files.some((file) => file.startsWith(infrastructureDir) &&
|
|
2118
|
+
file.endsWith("-repository.ts") &&
|
|
2119
|
+
!file.endsWith(".test.ts"));
|
|
2120
|
+
}
|
|
2121
|
+
function drizzleRepositoryAdapterFile(files, infrastructureDir, singular) {
|
|
2122
|
+
return files.find((file) => file.startsWith(infrastructureDir) &&
|
|
2123
|
+
file.endsWith(`/drizzle-${singular}-repository.ts`));
|
|
2124
|
+
}
|
|
2125
|
+
function hasFeatureSeeds(files, featuresPath, resource) {
|
|
2126
|
+
return files.some((file) => file.startsWith(`${featuresPath}/${resource}/seeds/`) &&
|
|
2127
|
+
file.endsWith(".ts") &&
|
|
2128
|
+
!file.endsWith("/index.ts"));
|
|
2129
|
+
}
|
|
2130
|
+
function hasAnyFeatureSeeds(files, featuresPath) {
|
|
2131
|
+
return files.some((file) => new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/seeds/`).test(file) &&
|
|
2132
|
+
file.endsWith(".ts") &&
|
|
2133
|
+
!file.endsWith("/index.ts"));
|
|
2134
|
+
}
|
|
2135
|
+
function hasFeatureFactories(files, featuresPath, resource) {
|
|
2136
|
+
return files.some((file) => file === `${featuresPath}/${resource}/tests/factories.ts` ||
|
|
2137
|
+
(file.startsWith(`${featuresPath}/${resource}/tests/factories/`) &&
|
|
2138
|
+
file.endsWith(".ts") &&
|
|
2139
|
+
!file.endsWith("/index.ts")));
|
|
2140
|
+
}
|
|
2141
|
+
async function packageScriptExists(targetDir, files, script) {
|
|
2142
|
+
const scripts = await readPackageScripts(targetDir, files);
|
|
2143
|
+
return Boolean(scripts[script]);
|
|
2144
|
+
}
|
|
2145
|
+
async function readPackageScripts(targetDir, files) {
|
|
2146
|
+
if (!files.includes("package.json"))
|
|
2147
|
+
return {};
|
|
2148
|
+
const packageJson = JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8"));
|
|
2149
|
+
return packageJson.scripts ?? {};
|
|
2150
|
+
}
|
|
2151
|
+
function hasDrizzleConfig(files) {
|
|
2152
|
+
return [
|
|
2153
|
+
"drizzle.config.ts",
|
|
2154
|
+
"drizzle.config.mts",
|
|
2155
|
+
"drizzle.config.js",
|
|
2156
|
+
"drizzle.config.mjs",
|
|
2157
|
+
].some((file) => files.includes(file));
|
|
2158
|
+
}
|
|
2159
|
+
function hasDrizzleLifecycleScriptNeedingRootConfig(scripts) {
|
|
2160
|
+
const lifecycleScripts = ["db:generate", "db:migrate"]
|
|
2161
|
+
.map((script) => scripts[script])
|
|
2162
|
+
.filter((script) => Boolean(script));
|
|
2163
|
+
if (lifecycleScripts.length === 0)
|
|
2164
|
+
return true;
|
|
2165
|
+
return lifecycleScripts.some((script) => /\bdrizzle-kit\b/.test(script) && !hasExplicitDrizzleConfigFlag(script));
|
|
2166
|
+
}
|
|
2167
|
+
function hasExplicitDrizzleConfigFlag(script) {
|
|
2168
|
+
return /(?:^|\s)--config(?:=|\s)/.test(script);
|
|
2169
|
+
}
|
|
569
2170
|
async function inspectPackageScripts(targetDir, files, convention) {
|
|
570
2171
|
if (!convention.resourceGenerator || !files.includes("package.json")) {
|
|
571
2172
|
return [];
|
|
@@ -576,7 +2177,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
|
|
|
576
2177
|
return [
|
|
577
2178
|
{
|
|
578
2179
|
severity: "warning",
|
|
579
|
-
code: "
|
|
2180
|
+
code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
|
|
580
2181
|
file: "package.json",
|
|
581
2182
|
message: 'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
|
|
582
2183
|
},
|
|
@@ -598,7 +2199,7 @@ async function fixMissingTestScript(targetDir, files, convention) {
|
|
|
598
2199
|
return undefined;
|
|
599
2200
|
await writeFile(filePath, next);
|
|
600
2201
|
return {
|
|
601
|
-
code: "
|
|
2202
|
+
code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
|
|
602
2203
|
file: "package.json",
|
|
603
2204
|
message: 'Added "test": "bun test".',
|
|
604
2205
|
};
|
|
@@ -607,7 +2208,7 @@ function unmatchedRouteDiagnostic(routeHandler) {
|
|
|
607
2208
|
if (routeHandler.source === "next-route") {
|
|
608
2209
|
return {
|
|
609
2210
|
severity: "error",
|
|
610
|
-
code: "
|
|
2211
|
+
code: "BEIGNET_ROUTE_HANDLER_ORPHANED",
|
|
611
2212
|
file: routeHandler.handlerFile,
|
|
612
2213
|
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.`,
|
|
613
2214
|
};
|
|
@@ -617,7 +2218,7 @@ function unmatchedRouteDiagnostic(routeHandler) {
|
|
|
617
2218
|
: routeHandler.contractRef;
|
|
618
2219
|
return {
|
|
619
2220
|
severity: "error",
|
|
620
|
-
code: "
|
|
2221
|
+
code: "BEIGNET_ROUTE_HANDLER_UNKNOWN_CONTRACT",
|
|
621
2222
|
file: routeHandler.handlerFile,
|
|
622
2223
|
contract: routeHandler.contractRef,
|
|
623
2224
|
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.`,
|
|
@@ -640,7 +2241,7 @@ function dedupeDiagnostics(diagnostics) {
|
|
|
640
2241
|
}
|
|
641
2242
|
return deduped;
|
|
642
2243
|
}
|
|
643
|
-
async function inspectOpenApiDrift(targetDir, files, config, contracts) {
|
|
2244
|
+
async function inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
|
|
644
2245
|
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
645
2246
|
try {
|
|
646
2247
|
await stat(routePath);
|
|
@@ -649,19 +2250,52 @@ async function inspectOpenApiDrift(targetDir, files, config, contracts) {
|
|
|
649
2250
|
return [];
|
|
650
2251
|
}
|
|
651
2252
|
const source = await readFile(routePath, "utf8");
|
|
2253
|
+
const routeSurface = await routeSurfaceContracts(targetDir, files, config, convention, contracts, matchedRoutes);
|
|
652
2254
|
const listedContracts = await openApiContracts(targetDir, files, config, source);
|
|
653
2255
|
if (!listedContracts) {
|
|
654
2256
|
return [];
|
|
655
2257
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
2258
|
+
const contractByName = new Map(contracts.map((contract) => [contract.exportName, contract]));
|
|
2259
|
+
const routeSurfaceNames = new Set(routeSurface.map((contract) => contract.exportName));
|
|
2260
|
+
return [
|
|
2261
|
+
...routeSurface
|
|
2262
|
+
.filter((contract) => !listedContracts.has(contract.exportName))
|
|
2263
|
+
.map((contract) => ({
|
|
2264
|
+
severity: "warning",
|
|
2265
|
+
code: "BEIGNET_OPENAPI_MISSING",
|
|
2266
|
+
file: config.paths.openapiRoute,
|
|
2267
|
+
contract: contract.exportName,
|
|
2268
|
+
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.`,
|
|
2269
|
+
})),
|
|
2270
|
+
...[...listedContracts]
|
|
2271
|
+
.filter((contractName) => !routeSurfaceNames.has(contractName))
|
|
2272
|
+
.flatMap((contractName) => {
|
|
2273
|
+
const contract = contractByName.get(contractName);
|
|
2274
|
+
if (!contract)
|
|
2275
|
+
return [];
|
|
2276
|
+
return [
|
|
2277
|
+
{
|
|
2278
|
+
severity: "warning",
|
|
2279
|
+
code: "BEIGNET_OPENAPI_UNROUTED",
|
|
2280
|
+
file: config.paths.openapiRoute,
|
|
2281
|
+
contract: contract.exportName,
|
|
2282
|
+
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.`,
|
|
2283
|
+
},
|
|
2284
|
+
];
|
|
2285
|
+
}),
|
|
2286
|
+
];
|
|
2287
|
+
}
|
|
2288
|
+
async function routeSurfaceContracts(targetDir, files, config, convention, contracts, matchedRoutes) {
|
|
2289
|
+
if (convention.resourceGenerator && files.includes(config.paths.server)) {
|
|
2290
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
2291
|
+
const serverSource = await readFile(path.join(targetDir, config.paths.server), "utf8");
|
|
2292
|
+
const registeredGroups = await registeredRouteGroupsForServer(targetDir, files, config, serverSource);
|
|
2293
|
+
const routeGroupContracts = contractsForRegisteredRouteGroups(contracts, routeGroups, registeredGroups);
|
|
2294
|
+
if (routeGroups.length > 0) {
|
|
2295
|
+
return routeGroupContracts;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
return matchedRoutes.routes.filter((route) => route.handlerSource !== "missing");
|
|
665
2299
|
}
|
|
666
2300
|
async function inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts) {
|
|
667
2301
|
if (!convention.resourceGenerator)
|
|
@@ -674,6 +2308,34 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
|
|
|
674
2308
|
const serverSource = await readFile(path.join(targetDir, config.paths.server), "utf8");
|
|
675
2309
|
const registeredGroups = await registeredRouteGroupsForServer(targetDir, files, config, serverSource);
|
|
676
2310
|
const diagnostics = [];
|
|
2311
|
+
const featuresPath = directoryPath(config.paths.features);
|
|
2312
|
+
for (const contract of contracts) {
|
|
2313
|
+
const feature = featureNameFromContractFile(contract.file, config);
|
|
2314
|
+
if (!feature)
|
|
2315
|
+
continue;
|
|
2316
|
+
const routeFile = `${featuresPath}/${feature}/routes.ts`;
|
|
2317
|
+
const featureRouteGroups = routeGroups.filter((routeGroup) => routeGroup.file === routeFile);
|
|
2318
|
+
if (featureRouteGroups.length === 0) {
|
|
2319
|
+
diagnostics.push({
|
|
2320
|
+
severity: "error",
|
|
2321
|
+
code: "BEIGNET_FEATURE_ROUTE_GROUP_MISSING",
|
|
2322
|
+
file: contract.file,
|
|
2323
|
+
contract: contract.exportName,
|
|
2324
|
+
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.`,
|
|
2325
|
+
});
|
|
2326
|
+
continue;
|
|
2327
|
+
}
|
|
2328
|
+
const routeGroupHasContract = featureRouteGroups.some((routeGroup) => routeGroup.contracts.some((routeGroupContract) => findRouteGroupContract([contract], routeGroupContract) !== undefined));
|
|
2329
|
+
if (routeGroupHasContract)
|
|
2330
|
+
continue;
|
|
2331
|
+
diagnostics.push({
|
|
2332
|
+
severity: "error",
|
|
2333
|
+
code: "BEIGNET_ROUTE_GROUP_CONTRACT_MISSING",
|
|
2334
|
+
file: routeFile,
|
|
2335
|
+
contract: contract.exportName,
|
|
2336
|
+
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.`,
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
677
2339
|
for (const routeGroup of routeGroups) {
|
|
678
2340
|
if (registeredGroups.has(routeGroup.name))
|
|
679
2341
|
continue;
|
|
@@ -681,7 +2343,7 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
|
|
|
681
2343
|
const contract = findRouteGroupContract(contracts, routeGroupContract);
|
|
682
2344
|
diagnostics.push({
|
|
683
2345
|
severity: "error",
|
|
684
|
-
code: "
|
|
2346
|
+
code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
|
|
685
2347
|
file: config.paths.server,
|
|
686
2348
|
contract: routeGroupContract.exportName,
|
|
687
2349
|
message: contract
|
|
@@ -692,6 +2354,25 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
|
|
|
692
2354
|
}
|
|
693
2355
|
return diagnostics;
|
|
694
2356
|
}
|
|
2357
|
+
function contractsForRegisteredRouteGroups(contracts, routeGroups, registeredGroups) {
|
|
2358
|
+
const seen = new Set();
|
|
2359
|
+
const registeredContracts = [];
|
|
2360
|
+
for (const routeGroup of routeGroups) {
|
|
2361
|
+
if (!registeredGroups.has(routeGroup.name))
|
|
2362
|
+
continue;
|
|
2363
|
+
for (const routeGroupContract of routeGroup.contracts) {
|
|
2364
|
+
const contract = findRouteGroupContract(contracts, routeGroupContract);
|
|
2365
|
+
if (!contract)
|
|
2366
|
+
continue;
|
|
2367
|
+
const key = contractKey(contract);
|
|
2368
|
+
if (seen.has(key))
|
|
2369
|
+
continue;
|
|
2370
|
+
seen.add(key);
|
|
2371
|
+
registeredContracts.push(contract);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return registeredContracts.sort(compareContracts);
|
|
2375
|
+
}
|
|
695
2376
|
async function readFeatureRouteGroups(targetDir, files, config) {
|
|
696
2377
|
const featuresPath = `${directoryPath(config.paths.features)}/`;
|
|
697
2378
|
const routeFiles = files.filter((file) => file.startsWith(featuresPath) && file.endsWith("/routes.ts"));
|
|
@@ -705,9 +2386,13 @@ async function readFeatureRouteGroups(targetDir, files, config) {
|
|
|
705
2386
|
}
|
|
706
2387
|
function parseFeatureRouteGroups(source, file, imports) {
|
|
707
2388
|
const routeGroups = [];
|
|
708
|
-
const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(
|
|
2389
|
+
const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(/g;
|
|
709
2390
|
for (const match of source.matchAll(exportRegex)) {
|
|
710
|
-
const
|
|
2391
|
+
const callStart = (match.index ?? 0) + match[0].length;
|
|
2392
|
+
const routesBody = routeGroupRoutesArrayBody(source, callStart);
|
|
2393
|
+
if (routesBody === undefined)
|
|
2394
|
+
continue;
|
|
2395
|
+
const contracts = [...routesBody.matchAll(/contract:\s*([^,\n}]+)/g)]
|
|
711
2396
|
.map((contractMatch) => routeGroupContractRef(contractMatch[1].trim(), imports))
|
|
712
2397
|
.filter((contract) => Boolean(contract));
|
|
713
2398
|
routeGroups.push({
|
|
@@ -718,6 +2403,67 @@ function parseFeatureRouteGroups(source, file, imports) {
|
|
|
718
2403
|
}
|
|
719
2404
|
return routeGroups;
|
|
720
2405
|
}
|
|
2406
|
+
/**
|
|
2407
|
+
* Extract the body of the `routes: [...]` array for a route group call.
|
|
2408
|
+
*
|
|
2409
|
+
* Both binder routes (`{ contract, useCase }`) and full handler routes
|
|
2410
|
+
* (`{ contract, handle }`) can contain nested brackets, strings, and arrow
|
|
2411
|
+
* functions, so the array is scanned with balanced-bracket tracking instead
|
|
2412
|
+
* of a regular expression.
|
|
2413
|
+
*/
|
|
2414
|
+
function routeGroupRoutesArrayBody(source, callStart) {
|
|
2415
|
+
const objectStart = source.indexOf("{", callStart);
|
|
2416
|
+
if (objectStart === -1)
|
|
2417
|
+
return undefined;
|
|
2418
|
+
const groupObject = balancedObjectSource(source, objectStart);
|
|
2419
|
+
if (!groupObject)
|
|
2420
|
+
return undefined;
|
|
2421
|
+
const routesIndex = groupObject.indexOf("routes:");
|
|
2422
|
+
if (routesIndex === -1)
|
|
2423
|
+
return undefined;
|
|
2424
|
+
const arrayStart = groupObject.indexOf("[", routesIndex);
|
|
2425
|
+
if (arrayStart === -1)
|
|
2426
|
+
return undefined;
|
|
2427
|
+
const arraySource = balancedArraySource(groupObject, arrayStart);
|
|
2428
|
+
return arraySource?.slice(1, -1);
|
|
2429
|
+
}
|
|
2430
|
+
function balancedArraySource(source, startIndex) {
|
|
2431
|
+
if (source[startIndex] !== "[")
|
|
2432
|
+
return undefined;
|
|
2433
|
+
let depth = 0;
|
|
2434
|
+
let quote;
|
|
2435
|
+
let escaped = false;
|
|
2436
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
2437
|
+
const char = source[index];
|
|
2438
|
+
if (quote) {
|
|
2439
|
+
if (escaped) {
|
|
2440
|
+
escaped = false;
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
if (char === "\\") {
|
|
2444
|
+
escaped = true;
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
if (char === quote)
|
|
2448
|
+
quote = undefined;
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
2452
|
+
quote = char;
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
if (char === "[") {
|
|
2456
|
+
depth += 1;
|
|
2457
|
+
continue;
|
|
2458
|
+
}
|
|
2459
|
+
if (char === "]") {
|
|
2460
|
+
depth -= 1;
|
|
2461
|
+
if (depth === 0)
|
|
2462
|
+
return source.slice(startIndex, index + 1);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return undefined;
|
|
2466
|
+
}
|
|
721
2467
|
function routeGroupContractRef(expression, imports) {
|
|
722
2468
|
const namedMatch = /^([A-Za-z_$][\w$]*)$/.exec(expression);
|
|
723
2469
|
if (namedMatch) {
|
|
@@ -781,7 +2527,7 @@ function findRouteGroupContract(contracts, routeGroupContract) {
|
|
|
781
2527
|
contract.file ===
|
|
782
2528
|
routeGroupContract.file.replace(/\.ts$/, "/index.ts")));
|
|
783
2529
|
}
|
|
784
|
-
async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
|
|
2530
|
+
async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
|
|
785
2531
|
const routePath = path.join(targetDir, config.paths.openapiRoute);
|
|
786
2532
|
let source;
|
|
787
2533
|
try {
|
|
@@ -793,8 +2539,9 @@ async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
|
|
|
793
2539
|
const firstArg = firstCreateOpenAPIHandlerArgInfo(source);
|
|
794
2540
|
if (!firstArg?.text.startsWith("["))
|
|
795
2541
|
return undefined;
|
|
2542
|
+
const routeSurface = await routeSurfaceContracts(targetDir, files, config, convention, contracts, matchedRoutes);
|
|
796
2543
|
const listedContracts = contractsFromArrayExpression(firstArg.text);
|
|
797
|
-
const missingContracts =
|
|
2544
|
+
const missingContracts = routeSurface.filter((contract) => !listedContracts.has(contract.exportName));
|
|
798
2545
|
if (missingContracts.length === 0)
|
|
799
2546
|
return undefined;
|
|
800
2547
|
if (missingContracts.some((contract) => !hasImportedIdentifier(source, contract.exportName))) {
|
|
@@ -806,13 +2553,77 @@ async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
|
|
|
806
2553
|
return undefined;
|
|
807
2554
|
await writeFile(routePath, next);
|
|
808
2555
|
return {
|
|
809
|
-
code: "
|
|
2556
|
+
code: "BEIGNET_OPENAPI_MISSING",
|
|
810
2557
|
file: config.paths.openapiRoute,
|
|
811
2558
|
message: `Added ${missingContracts
|
|
812
2559
|
.map((contract) => contract.exportName)
|
|
813
2560
|
.join(", ")} to the direct createOpenAPIHandler contract array.`,
|
|
814
2561
|
};
|
|
815
2562
|
}
|
|
2563
|
+
async function fixUnregisteredRouteGroups(targetDir, files, config) {
|
|
2564
|
+
if (!files.includes(config.paths.server))
|
|
2565
|
+
return undefined;
|
|
2566
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
2567
|
+
if (routeGroups.length === 0)
|
|
2568
|
+
return undefined;
|
|
2569
|
+
const serverSource = await readFile(path.join(targetDir, config.paths.server), "utf8");
|
|
2570
|
+
const registeredGroups = await registeredRouteGroupsForServer(targetDir, files, config, serverSource);
|
|
2571
|
+
const missingGroups = routeGroups
|
|
2572
|
+
.filter((routeGroup) => !registeredGroups.has(routeGroup.name))
|
|
2573
|
+
.sort((left, right) => left.file.localeCompare(right.file));
|
|
2574
|
+
if (missingGroups.length === 0)
|
|
2575
|
+
return undefined;
|
|
2576
|
+
const targetFile = routeRegistryFileFromServerSource(serverSource, config.paths.server, files) ?? config.paths.server;
|
|
2577
|
+
if (!files.includes(targetFile))
|
|
2578
|
+
return undefined;
|
|
2579
|
+
const targetPath = path.join(targetDir, targetFile);
|
|
2580
|
+
const original = await readFile(targetPath, "utf8");
|
|
2581
|
+
const firstArg = firstDefineRoutesArgInfo(original);
|
|
2582
|
+
if (!firstArg?.text.startsWith("["))
|
|
2583
|
+
return undefined;
|
|
2584
|
+
const registeredInTarget = contractsFromArrayExpression(firstArg.text);
|
|
2585
|
+
const groupsToAppend = missingGroups.filter((routeGroup) => !registeredInTarget.has(routeGroup.name));
|
|
2586
|
+
if (groupsToAppend.length === 0)
|
|
2587
|
+
return undefined;
|
|
2588
|
+
let next = original;
|
|
2589
|
+
for (const routeGroup of groupsToAppend) {
|
|
2590
|
+
const imported = parseNamedImportSources(next).get(routeGroup.name);
|
|
2591
|
+
if (imported) {
|
|
2592
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, targetFile, files);
|
|
2593
|
+
if (importedFile !== routeGroup.file)
|
|
2594
|
+
return undefined;
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
next = insertAfterImports(next, `import { ${routeGroup.name} } from "${relativeModule(targetFile, routeGroup.file)}";`);
|
|
2598
|
+
}
|
|
2599
|
+
const nextFirstArg = firstDefineRoutesArgInfo(next);
|
|
2600
|
+
if (!nextFirstArg?.text.startsWith("["))
|
|
2601
|
+
return undefined;
|
|
2602
|
+
const nextArray = appendToArrayExpression(nextFirstArg.text, groupsToAppend.map((routeGroup) => routeGroup.name));
|
|
2603
|
+
next = `${next.slice(0, nextFirstArg.start)}${nextArray}${next.slice(nextFirstArg.end)}`;
|
|
2604
|
+
if (next === original)
|
|
2605
|
+
return undefined;
|
|
2606
|
+
await writeFile(targetPath, next);
|
|
2607
|
+
return {
|
|
2608
|
+
code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
|
|
2609
|
+
file: targetFile,
|
|
2610
|
+
message: `Registered ${groupsToAppend
|
|
2611
|
+
.map((routeGroup) => routeGroup.name)
|
|
2612
|
+
.join(", ")} in the central route list.`,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
function routeRegistryFileFromServerSource(source, serverFile, files) {
|
|
2616
|
+
const imports = parseNamedImportSources(source);
|
|
2617
|
+
for (const identifier of routeOptionIdentifiers(source)) {
|
|
2618
|
+
const imported = imports.get(identifier);
|
|
2619
|
+
if (!imported)
|
|
2620
|
+
continue;
|
|
2621
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, serverFile, files);
|
|
2622
|
+
if (importedFile && files.includes(importedFile))
|
|
2623
|
+
return importedFile;
|
|
2624
|
+
}
|
|
2625
|
+
return undefined;
|
|
2626
|
+
}
|
|
816
2627
|
async function openApiContracts(targetDir, files, config, source) {
|
|
817
2628
|
const firstArg = firstCreateOpenAPIHandlerArg(source);
|
|
818
2629
|
if (!firstArg)
|
|
@@ -823,22 +2634,75 @@ async function openApiContracts(targetDir, files, config, source) {
|
|
|
823
2634
|
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(firstArg)?.[1];
|
|
824
2635
|
if (!identifier)
|
|
825
2636
|
return undefined;
|
|
2637
|
+
const routeContracts = await contractsFromRoutesExport(targetDir, files, config, config.paths.openapiRoute, source, identifier);
|
|
2638
|
+
if (routeContracts)
|
|
2639
|
+
return routeContracts;
|
|
826
2640
|
const imports = parseNamedImports(source, config);
|
|
827
2641
|
const imported = imports.get(identifier);
|
|
828
2642
|
const listFile = imported?.contractFile ?? config.paths.openapiRoute;
|
|
829
2643
|
return resolveContractList(await readContractLists(targetDir, files, config), listFile, imported?.importedName ?? identifier);
|
|
830
2644
|
}
|
|
2645
|
+
async function contractsFromRoutesExport(targetDir, files, config, importerFile, source, identifier) {
|
|
2646
|
+
const localRouteContracts = await contractsFromRoutesSource(targetDir, files, config, importerFile, source, identifier);
|
|
2647
|
+
if (localRouteContracts)
|
|
2648
|
+
return localRouteContracts;
|
|
2649
|
+
const imports = parseNamedImportSources(source);
|
|
2650
|
+
const imported = imports.get(identifier);
|
|
2651
|
+
if (!imported)
|
|
2652
|
+
return undefined;
|
|
2653
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, importerFile, files);
|
|
2654
|
+
if (!importedFile || !files.includes(importedFile))
|
|
2655
|
+
return undefined;
|
|
2656
|
+
const importedSource = await readFile(path.join(targetDir, importedFile), {
|
|
2657
|
+
encoding: "utf8",
|
|
2658
|
+
});
|
|
2659
|
+
return contractsFromRoutesSource(targetDir, files, config, importedFile, importedSource, imported.importedName);
|
|
2660
|
+
}
|
|
2661
|
+
async function contractsFromRoutesSource(targetDir, files, config, file, source, exportName) {
|
|
2662
|
+
const routeIdentifier = contractsFromRoutesIdentifier(source, exportName);
|
|
2663
|
+
if (!routeIdentifier)
|
|
2664
|
+
return undefined;
|
|
2665
|
+
const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
|
|
2666
|
+
const registeredGroups = await registeredRouteGroupsForSource(targetDir, files, file, source, routeIdentifier);
|
|
2667
|
+
const registeredContracts = contractsForRegisteredRouteGroups(await readContracts(targetDir, files, config), routeGroups, registeredGroups);
|
|
2668
|
+
return new Set(registeredContracts.map((contract) => contract.exportName));
|
|
2669
|
+
}
|
|
2670
|
+
function contractsFromRoutesIdentifier(source, exportName) {
|
|
2671
|
+
return new RegExp(`(?:export\\s+)?const\\s+${escapeRegExp(exportName)}\\s*=\\s*contractsFromRoutes(?:<[^>]+>)?\\(\\s*([A-Za-z_$][\\w$]*)\\s*\\)`).exec(source)?.[1];
|
|
2672
|
+
}
|
|
2673
|
+
async function registeredRouteGroupsForSource(targetDir, files, file, source, routeIdentifier) {
|
|
2674
|
+
if (new RegExp(`(?:export\\s+)?const\\s+${escapeRegExp(routeIdentifier)}\\s*=\\s*defineRoutes`).test(source)) {
|
|
2675
|
+
return registeredRouteGroups(source);
|
|
2676
|
+
}
|
|
2677
|
+
const imports = parseNamedImportSources(source);
|
|
2678
|
+
const imported = imports.get(routeIdentifier);
|
|
2679
|
+
if (!imported)
|
|
2680
|
+
return registeredRouteGroups(source);
|
|
2681
|
+
const importedFile = sourceFileFromImport(imported.sourcePath, file, files);
|
|
2682
|
+
if (!importedFile || !files.includes(importedFile)) {
|
|
2683
|
+
return registeredRouteGroups(source);
|
|
2684
|
+
}
|
|
2685
|
+
return registeredRouteGroups(await readFile(path.join(targetDir, importedFile), "utf8"));
|
|
2686
|
+
}
|
|
831
2687
|
function firstCreateOpenAPIHandlerArg(source) {
|
|
832
2688
|
return firstCreateOpenAPIHandlerArgInfo(source)?.text;
|
|
833
2689
|
}
|
|
2690
|
+
function firstDefineRoutesArgInfo(source) {
|
|
2691
|
+
const start = /defineRoutes(?:<[^>]+>)?\(/.exec(source);
|
|
2692
|
+
if (!start)
|
|
2693
|
+
return undefined;
|
|
2694
|
+
return firstCallArgInfo(source, start.index + start[0].length);
|
|
2695
|
+
}
|
|
834
2696
|
function firstCreateOpenAPIHandlerArgInfo(source) {
|
|
835
2697
|
const start = source.indexOf("createOpenAPIHandler(");
|
|
836
2698
|
if (start === -1)
|
|
837
2699
|
return undefined;
|
|
2700
|
+
return firstCallArgInfo(source, start + "createOpenAPIHandler(".length);
|
|
2701
|
+
}
|
|
2702
|
+
function firstCallArgInfo(source, argStart) {
|
|
838
2703
|
let depth = 0;
|
|
839
2704
|
let inString;
|
|
840
2705
|
let escaped = false;
|
|
841
|
-
const argStart = start + "createOpenAPIHandler(".length;
|
|
842
2706
|
for (let index = argStart; index < source.length; index++) {
|
|
843
2707
|
const char = source[index];
|
|
844
2708
|
if (inString) {
|
|
@@ -918,6 +2782,20 @@ function appendToArrayExpression(expression, names) {
|
|
|
918
2782
|
}
|
|
919
2783
|
return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
|
|
920
2784
|
}
|
|
2785
|
+
function insertAfterImports(source, line) {
|
|
2786
|
+
const lines = source.split("\n");
|
|
2787
|
+
let lastImportIndex = -1;
|
|
2788
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2789
|
+
if (lines[index].startsWith("import ")) {
|
|
2790
|
+
lastImportIndex = index;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
if (lastImportIndex === -1) {
|
|
2794
|
+
return `${line}\n${source}`;
|
|
2795
|
+
}
|
|
2796
|
+
lines.splice(lastImportIndex + 1, 0, line);
|
|
2797
|
+
return lines.join("\n");
|
|
2798
|
+
}
|
|
921
2799
|
function hasImportedIdentifier(source, identifier) {
|
|
922
2800
|
const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
|
|
923
2801
|
for (const match of source.matchAll(importRegex)) {
|
|
@@ -929,6 +2807,21 @@ function hasImportedIdentifier(source, identifier) {
|
|
|
929
2807
|
}
|
|
930
2808
|
return false;
|
|
931
2809
|
}
|
|
2810
|
+
function relativeModule(fromFile, toFile) {
|
|
2811
|
+
const fromDir = path.posix.dirname(normalizePath(fromFile));
|
|
2812
|
+
const relative = path.posix.relative(fromDir, normalizePath(toFile));
|
|
2813
|
+
const specifier = modulePath(relative);
|
|
2814
|
+
if (specifier.startsWith("../"))
|
|
2815
|
+
return specifier;
|
|
2816
|
+
if (specifier === "..")
|
|
2817
|
+
return specifier;
|
|
2818
|
+
return specifier.startsWith(".") ? specifier : `./${specifier}`;
|
|
2819
|
+
}
|
|
2820
|
+
function modulePath(filePath) {
|
|
2821
|
+
return directoryPath(filePath)
|
|
2822
|
+
.replace(/\.(?:[cm]?[jt]sx?)$/, "")
|
|
2823
|
+
.replace(/\/index$/, "");
|
|
2824
|
+
}
|
|
932
2825
|
async function readContractLists(targetDir, files, config) {
|
|
933
2826
|
const lists = new Map();
|
|
934
2827
|
const contractFiles = contractListSourceFiles(files, config);
|
|
@@ -1033,38 +2926,229 @@ async function inspectResourceSlices(targetDir, files, config, convention, stric
|
|
|
1033
2926
|
const diagnostics = [];
|
|
1034
2927
|
for (const resource of resources) {
|
|
1035
2928
|
const singular = singularize(resource);
|
|
2929
|
+
const pluralCamel = camelCase(resource);
|
|
2930
|
+
const singularPascal = pascalCase(singular);
|
|
1036
2931
|
const contractFile = resourceContractFile(resource, config);
|
|
1037
2932
|
const useCaseDir = resourceUseCaseDir(resource, config);
|
|
1038
2933
|
const portFile = resourcePortFile(resource, singular, config);
|
|
2934
|
+
const featureRouteFile = path.join(config.paths.features, resource, "routes.ts");
|
|
2935
|
+
const sharedErrorsFile = path.join(config.paths.features, "shared", "errors.ts");
|
|
1039
2936
|
const infrastructureDir = `${directoryPath(path.dirname(config.paths.infrastructurePorts))}/${resource}/`;
|
|
2937
|
+
const drizzleSchemaFile = path.join(path.dirname(config.paths.infrastructurePorts), "db", "schema", `${resource}.ts`);
|
|
2938
|
+
const drizzleRepositoryFile = path.join(path.dirname(config.paths.infrastructurePorts), resource, `drizzle-${singular}-repository.ts`);
|
|
2939
|
+
const memoryRepositoryFile = path.join(path.dirname(config.paths.infrastructurePorts), resource, `in-memory-${singular}-repository.ts`);
|
|
1040
2940
|
const routeFile = path.join(config.paths.routes, resource, "route.ts");
|
|
1041
2941
|
const catchAllRouteFile = path.join(config.paths.routes, "[[...path]]", "route.ts");
|
|
1042
2942
|
const testFile = resourceTestFile(resource, config);
|
|
1043
2943
|
if (!hasRequirement(files, contractFile)) {
|
|
1044
|
-
diagnostics.push(resourceDiagnostic("
|
|
2944
|
+
diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_CONTRACT_MISSING", resource, contractFile));
|
|
1045
2945
|
}
|
|
1046
2946
|
if (!hasRequirement(files, useCaseDir)) {
|
|
1047
|
-
diagnostics.push(resourceDiagnostic("
|
|
2947
|
+
diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_USE_CASES_MISSING", resource, useCaseDir));
|
|
1048
2948
|
}
|
|
1049
2949
|
if (!hasRequirement(files, portFile)) {
|
|
1050
|
-
diagnostics.push(resourceDiagnostic("
|
|
2950
|
+
diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_PORT_MISSING", resource, portFile));
|
|
1051
2951
|
}
|
|
1052
2952
|
if (!hasRequirement(files, infrastructureDir)) {
|
|
1053
|
-
diagnostics.push(resourceDiagnostic("
|
|
2953
|
+
diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_INFRASTRUCTURE_MISSING", resource, infrastructureDir));
|
|
1054
2954
|
}
|
|
1055
2955
|
if (!hasRequirement(files, routeFile) &&
|
|
1056
2956
|
!hasRequirement(files, catchAllRouteFile)) {
|
|
1057
|
-
diagnostics.push(resourceDiagnostic("
|
|
2957
|
+
diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_ROUTE_MISSING", resource, routeFile));
|
|
1058
2958
|
}
|
|
1059
2959
|
if (strict && !hasRequirement(files, testFile)) {
|
|
1060
2960
|
diagnostics.push({
|
|
1061
|
-
...resourceDiagnostic("
|
|
2961
|
+
...resourceDiagnostic("BEIGNET_RESOURCE_TEST_MISSING", resource, testFile),
|
|
2962
|
+
severity: "warning",
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
const contractSource = await readSourceIfPresent(targetDir, files, contractFile);
|
|
2966
|
+
const portSource = await readSourceIfPresent(targetDir, files, portFile);
|
|
2967
|
+
const routeSource = await readSourceIfPresent(targetDir, files, featureRouteFile);
|
|
2968
|
+
const testSource = await readSourceIfPresent(targetDir, files, testFile);
|
|
2969
|
+
const drizzleSchemaSource = await readSourceIfPresent(targetDir, files, drizzleSchemaFile);
|
|
2970
|
+
const drizzleRepositorySource = await readSourceIfPresent(targetDir, files, drizzleRepositoryFile);
|
|
2971
|
+
const memoryRepositorySource = await readSourceIfPresent(targetDir, files, memoryRepositoryFile);
|
|
2972
|
+
const sharedErrorsSource = await readSourceIfPresent(targetDir, files, sharedErrorsFile);
|
|
2973
|
+
const crudUseCaseFiles = [
|
|
2974
|
+
path.join(useCaseDir, `get-${singular}.ts`),
|
|
2975
|
+
path.join(useCaseDir, `update-${singular}.ts`),
|
|
2976
|
+
path.join(useCaseDir, `delete-${singular}.ts`),
|
|
2977
|
+
];
|
|
2978
|
+
const hasCrudSignals = crudUseCaseFiles.slice(1).some((file) => files.includes(file)) ||
|
|
2979
|
+
sourceIncludesAny(contractSource, [
|
|
2980
|
+
`export const update${singularPascal}`,
|
|
2981
|
+
`export const delete${singularPascal}`,
|
|
2982
|
+
]) ||
|
|
2983
|
+
sourceIncludesAny(portSource, [
|
|
2984
|
+
`update(input: Update${singularPascal}Input)`,
|
|
2985
|
+
`update(input: Update${singularPascal}RepositoryInput)`,
|
|
2986
|
+
"delete(id: string): Promise<boolean>",
|
|
2987
|
+
]);
|
|
2988
|
+
if (!hasCrudSignals)
|
|
2989
|
+
continue;
|
|
2990
|
+
for (const exportName of [
|
|
2991
|
+
`get${singularPascal}`,
|
|
2992
|
+
`update${singularPascal}`,
|
|
2993
|
+
`delete${singularPascal}`,
|
|
2994
|
+
]) {
|
|
2995
|
+
if (contractSource?.includes(`export const ${exportName}`))
|
|
2996
|
+
continue;
|
|
2997
|
+
diagnostics.push({
|
|
2998
|
+
severity: "error",
|
|
2999
|
+
code: "BEIGNET_RESOURCE_CONTRACT_EXPORT_MISSING",
|
|
3000
|
+
file: contractFile,
|
|
3001
|
+
contract: exportName,
|
|
3002
|
+
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.`,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
for (const file of crudUseCaseFiles) {
|
|
3006
|
+
if (files.includes(file))
|
|
3007
|
+
continue;
|
|
3008
|
+
diagnostics.push({
|
|
3009
|
+
severity: "error",
|
|
3010
|
+
code: "BEIGNET_RESOURCE_CRUD_USE_CASE_MISSING",
|
|
3011
|
+
file,
|
|
3012
|
+
message: `${resource} appears to be a CRUD resource, but ${file} is missing. Restore the generated CRUD use case or remove the partial CRUD wiring.`,
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
const expectedRepositoryMethods = [
|
|
3016
|
+
{
|
|
3017
|
+
display: `findById(id: string): Promise<${singularPascal} | null>`,
|
|
3018
|
+
alternatives: [
|
|
3019
|
+
`findById(id: string): Promise<${singularPascal} | null>`,
|
|
3020
|
+
`findById(id: string, filter: ${singularPascal}TenantFilter): Promise<${singularPascal} | null>`,
|
|
3021
|
+
],
|
|
3022
|
+
},
|
|
3023
|
+
{
|
|
3024
|
+
display: `update(input: Update${singularPascal}Input)`,
|
|
3025
|
+
alternatives: [
|
|
3026
|
+
`update(input: Update${singularPascal}Input)`,
|
|
3027
|
+
`update(input: Update${singularPascal}RepositoryInput)`,
|
|
3028
|
+
],
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
display: "delete(id: string): Promise<boolean>",
|
|
3032
|
+
alternatives: [
|
|
3033
|
+
"delete(id: string): Promise<boolean>",
|
|
3034
|
+
`delete(id: string, filter: ${singularPascal}TenantFilter): Promise<boolean>`,
|
|
3035
|
+
],
|
|
3036
|
+
},
|
|
3037
|
+
];
|
|
3038
|
+
for (const method of expectedRepositoryMethods) {
|
|
3039
|
+
if (sourceIncludesAny(portSource, method.alternatives))
|
|
3040
|
+
continue;
|
|
3041
|
+
diagnostics.push({
|
|
3042
|
+
severity: "error",
|
|
3043
|
+
code: "BEIGNET_RESOURCE_REPOSITORY_METHOD_MISSING",
|
|
3044
|
+
file: portFile,
|
|
3045
|
+
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.`,
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
for (const exportName of [
|
|
3049
|
+
`get${singularPascal}`,
|
|
3050
|
+
`update${singularPascal}`,
|
|
3051
|
+
`delete${singularPascal}`,
|
|
3052
|
+
]) {
|
|
3053
|
+
if (routeSource?.includes(`contract: ${exportName}`))
|
|
3054
|
+
continue;
|
|
3055
|
+
diagnostics.push({
|
|
3056
|
+
severity: "error",
|
|
3057
|
+
code: "BEIGNET_RESOURCE_ROUTE_CONTRACT_MISSING",
|
|
3058
|
+
file: featureRouteFile,
|
|
3059
|
+
contract: exportName,
|
|
3060
|
+
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.`,
|
|
3061
|
+
});
|
|
3062
|
+
}
|
|
3063
|
+
const notFoundError = `${singularPascal}NotFound`;
|
|
3064
|
+
const conflictError = `${singularPascal}Conflict`;
|
|
3065
|
+
if (!sharedErrorsSource?.includes(notFoundError)) {
|
|
3066
|
+
diagnostics.push({
|
|
3067
|
+
severity: "error",
|
|
3068
|
+
code: "BEIGNET_RESOURCE_ERROR_CATALOG_MISSING",
|
|
3069
|
+
file: sharedErrorsFile,
|
|
3070
|
+
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.`,
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
if (!sharedErrorsSource?.includes(conflictError)) {
|
|
3074
|
+
diagnostics.push({
|
|
3075
|
+
severity: "error",
|
|
3076
|
+
code: "BEIGNET_RESOURCE_ERROR_CATALOG_MISSING",
|
|
3077
|
+
file: sharedErrorsFile,
|
|
3078
|
+
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.`,
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
if (strict &&
|
|
3082
|
+
testSource &&
|
|
3083
|
+
!sourceIncludesAll(testSource, [
|
|
3084
|
+
`get${singularPascal}`,
|
|
3085
|
+
`update${singularPascal}`,
|
|
3086
|
+
`delete${singularPascal}`,
|
|
3087
|
+
`${notFoundError}`,
|
|
3088
|
+
`${conflictError}`,
|
|
3089
|
+
])) {
|
|
3090
|
+
diagnostics.push({
|
|
1062
3091
|
severity: "warning",
|
|
3092
|
+
code: "BEIGNET_RESOURCE_CRUD_TEST_COVERAGE_MISSING",
|
|
3093
|
+
file: testFile,
|
|
3094
|
+
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.`,
|
|
1063
3095
|
});
|
|
1064
3096
|
}
|
|
3097
|
+
const hasDrizzleSoftDeleteSignals = sourceIncludesAny(drizzleSchemaSource, ["deletedAt", "deleted_at"]) ||
|
|
3098
|
+
sourceIncludesAny(drizzleRepositorySource, ["deletedAt", "isNull"]);
|
|
3099
|
+
const hasSoftDeleteSignals = hasDrizzleSoftDeleteSignals ||
|
|
3100
|
+
sourceIncludesAny(memoryRepositorySource, ["deletedAt"]);
|
|
3101
|
+
if (hasSoftDeleteSignals) {
|
|
3102
|
+
if (hasDrizzleSoftDeleteSignals) {
|
|
3103
|
+
if (!sourceIncludesAny(drizzleSchemaSource, ["deletedAt", "deleted_at"])) {
|
|
3104
|
+
diagnostics.push({
|
|
3105
|
+
severity: "error",
|
|
3106
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_SCHEMA_MISSING",
|
|
3107
|
+
file: drizzleSchemaFile,
|
|
3108
|
+
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.`,
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
if (!sourceIncludesAll(drizzleRepositorySource ?? "", [
|
|
3112
|
+
"isNull",
|
|
3113
|
+
"deletedAt",
|
|
3114
|
+
`.update(schema.${pluralCamel})`,
|
|
3115
|
+
])) {
|
|
3116
|
+
diagnostics.push({
|
|
3117
|
+
severity: "error",
|
|
3118
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_REPOSITORY_DRIFT",
|
|
3119
|
+
file: drizzleRepositoryFile,
|
|
3120
|
+
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.`,
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
if (strict &&
|
|
3125
|
+
testSource &&
|
|
3126
|
+
!sourceIncludesAll(testSource, [
|
|
3127
|
+
"afterDelete",
|
|
3128
|
+
"listedAfterDeleteViaRoute",
|
|
3129
|
+
])) {
|
|
3130
|
+
diagnostics.push({
|
|
3131
|
+
severity: "warning",
|
|
3132
|
+
code: "BEIGNET_RESOURCE_SOFT_DELETE_TEST_COVERAGE_MISSING",
|
|
3133
|
+
file: testFile,
|
|
3134
|
+
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.`,
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
1065
3138
|
}
|
|
1066
3139
|
return diagnostics;
|
|
1067
3140
|
}
|
|
3141
|
+
async function readSourceIfPresent(targetDir, files, file) {
|
|
3142
|
+
if (!files.includes(file))
|
|
3143
|
+
return undefined;
|
|
3144
|
+
return readFile(path.join(targetDir, file), "utf8");
|
|
3145
|
+
}
|
|
3146
|
+
function sourceIncludesAny(source, snippets) {
|
|
3147
|
+
return source ? snippets.some((snippet) => source.includes(snippet)) : false;
|
|
3148
|
+
}
|
|
3149
|
+
function sourceIncludesAll(source, snippets) {
|
|
3150
|
+
return snippets.every((snippet) => source.includes(snippet));
|
|
3151
|
+
}
|
|
1068
3152
|
async function collectResourceNames(targetDir, files, config) {
|
|
1069
3153
|
const resources = new Set();
|
|
1070
3154
|
const portsPath = directoryPath(path.dirname(config.paths.ports));
|
|
@@ -1101,9 +3185,12 @@ async function isGeneratedResourcePort(targetDir, file, resource) {
|
|
|
1101
3185
|
const singular = singularize(resource);
|
|
1102
3186
|
const singularPascal = pascalCase(singular);
|
|
1103
3187
|
const pluralPascal = pascalCase(resource);
|
|
3188
|
+
const hasCursorList = source.includes("CursorPage") &&
|
|
3189
|
+
source.includes(`list(query: List${pluralPascal}Query)`);
|
|
3190
|
+
const hasOffsetList = source.includes("OffsetPage") && source.includes("list(page: OffsetPage)");
|
|
1104
3191
|
return (source.includes(`Create${singularPascal}Input`) &&
|
|
1105
|
-
source.includes(`List${pluralPascal}Input`) &&
|
|
1106
3192
|
source.includes(`List${pluralPascal}Result`) &&
|
|
3193
|
+
(hasCursorList || hasOffsetList) &&
|
|
1107
3194
|
source.includes(`interface ${singularPascal}Repository`) &&
|
|
1108
3195
|
source.includes(`create(input: Create${singularPascal}Input)`));
|
|
1109
3196
|
}
|
|
@@ -1161,7 +3248,7 @@ function resourceDiagnostic(code, resource, file) {
|
|
|
1161
3248
|
severity: "error",
|
|
1162
3249
|
code,
|
|
1163
3250
|
file,
|
|
1164
|
-
message: `${resource} appears to be a generated
|
|
3251
|
+
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.`,
|
|
1165
3252
|
};
|
|
1166
3253
|
}
|
|
1167
3254
|
function nextRoutePath(file, routeRoot) {
|
|
@@ -1196,6 +3283,10 @@ function pascalCase(value) {
|
|
|
1196
3283
|
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
1197
3284
|
.join("");
|
|
1198
3285
|
}
|
|
3286
|
+
function camelCase(value) {
|
|
3287
|
+
const pascal = pascalCase(value);
|
|
3288
|
+
return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
|
|
3289
|
+
}
|
|
1199
3290
|
function singularize(value) {
|
|
1200
3291
|
if (value.endsWith("ies") && value.length > 3) {
|
|
1201
3292
|
return `${value.slice(0, -3)}y`;
|