@beignet/cli 0.0.3 → 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.
Files changed (98) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +4 -1
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +16 -26
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +657 -588
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1756 -394
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates.d.ts +3 -32
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +1002 -527
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +16 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +24 -31
  82. package/src/db.ts +60 -4
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1067 -803
  85. package/src/inspect.ts +2859 -115
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +493 -39
  88. package/src/make.ts +2181 -405
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +1073 -567
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. 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,11 +41,18 @@ 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 openApiFix = await fixDirectOpenApiArrayDrift(targetDir, config, contracts);
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;
@@ -67,29 +80,77 @@ export function formatRoutes(result) {
67
80
  /**
68
81
  * Format doctor diagnostics and applied fixes for CLI output.
69
82
  */
70
- export function formatDoctor(result) {
83
+ export function formatDoctor(result, options = {}) {
84
+ const paint = createPainter(options.color);
71
85
  const fixLines = result.fixes.length === 0
72
86
  ? []
73
87
  : [
74
88
  `Applied ${result.fixes.length} Beignet fix${result.fixes.length === 1 ? "" : "es"}:`,
75
89
  "",
76
- ...result.fixes.map((fix) => `FIXED ${fix.code} ${fix.file}
90
+ ...result.fixes.map((fix) => `${paint("FIXED", "green")} ${fix.code} ${fix.file}
77
91
  ${fix.message}`),
78
92
  "",
79
93
  ];
80
94
  if (result.diagnostics.length === 0) {
81
95
  return [...fixLines, `No Beignet drift found in ${result.targetDir}.`].join("\n");
82
96
  }
83
- return [
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 = [
84
115
  ...fixLines,
85
116
  `Found ${result.diagnostics.length} Beignet issue${result.diagnostics.length === 1 ? "" : "s"} in ${result.targetDir}:`,
86
- "",
87
- ...result.diagnostics.map((diagnostic) => {
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) => {
88
124
  const location = diagnostic.file ? ` ${diagnostic.file}` : "";
89
- return `${diagnostic.severity.toUpperCase()} ${diagnostic.code}${location}
125
+ return `${group.label} ${diagnostic.code}${location}
90
126
  ${diagnostic.message}`;
91
- }),
92
- ].join("\n");
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;
93
154
  }
94
155
  async function assertDirectory(targetDir) {
95
156
  try {
@@ -176,10 +237,11 @@ function hasRequirement(files, requirement) {
176
237
  function parseContracts(source, file) {
177
238
  const contracts = [];
178
239
  const groupPrefixes = parseContractGroupPrefixes(source);
240
+ const groupErrorRefs = parseContractGroupErrorRefs(source);
179
241
  const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
180
242
  for (const exportMatch of source.matchAll(exportRegex)) {
181
243
  const block = exportMatch[2];
182
- 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);
183
245
  if (methodMatch) {
184
246
  const receiver = methodMatch[1];
185
247
  const pathPrefix = groupPrefixes.get(receiver) ?? "";
@@ -187,6 +249,7 @@ function parseContracts(source, file) {
187
249
  contracts.push({
188
250
  exportName: exportMatch[1],
189
251
  file,
252
+ metadata: parseContractMetadata(block, groupErrorRefs.get(receiver) ?? []),
190
253
  method: methodMatch[2].toUpperCase(),
191
254
  path: routePath,
192
255
  });
@@ -197,6 +260,7 @@ function parseContracts(source, file) {
197
260
  contracts.push({
198
261
  exportName: exportMatch[1],
199
262
  file,
263
+ metadata: parseContractMetadata(block),
200
264
  method: directContract.method,
201
265
  path: normalizeContractPath(directContract.path),
202
266
  });
@@ -204,8 +268,30 @@ function parseContracts(source, file) {
204
268
  }
205
269
  return contracts;
206
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
+ }
207
293
  function parseDirectContract(block) {
208
- const configMatch = /createContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
294
+ const configMatch = /defineContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
209
295
  if (!configMatch)
210
296
  return undefined;
211
297
  const configSource = configMatch[1];
@@ -245,8 +331,42 @@ function parseContractGroupPrefixes(source) {
245
331
  }
246
332
  return prefixes;
247
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
+ }
248
368
  function baseGroupPrefix(expression, prefixes) {
249
- if (expression.includes("createContractGroup("))
369
+ if (expression.includes("defineContractGroup("))
250
370
  return "";
251
371
  const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
252
372
  if (!baseMatch)
@@ -256,6 +376,152 @@ function baseGroupPrefix(expression, prefixes) {
256
376
  function prefixCalls(expression) {
257
377
  return [...expression.matchAll(/\.prefix\(\s*["']([^"']+)["']\s*\)/g)].map((match) => match[1]);
258
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
+ }
259
525
  function joinContractPaths(prefix, routePath) {
260
526
  if (!prefix)
261
527
  return normalizeContractPath(routePath);
@@ -336,6 +602,22 @@ function parseNamedImports(source, config, importerFile) {
336
602
  }
337
603
  return imports;
338
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
+ }
339
621
  function parseImportMember(member) {
340
622
  const trimmed = member.trim();
341
623
  if (!trimmed)
@@ -355,6 +637,23 @@ function parseImportMember(member) {
355
637
  }
356
638
  return undefined;
357
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
+ }
358
657
  function contractFileFromImport(sourcePath, config, importerFile) {
359
658
  const contractsPath = directoryPath(config.paths.contracts);
360
659
  const aliasPrefix = `@/${contractsPath}/`;
@@ -445,39 +744,955 @@ function findContracts(contracts, routeExport) {
445
744
  return contracts.filter((contract) => contract.method === routeExport.method &&
446
745
  contractPathMatchesCatchAll(contract.path, catchAllPrefix));
447
746
  }
448
- const contract = contracts.find((contract) => contract.path === routeExport.contractRef &&
449
- contract.method === routeExport.method);
450
- return contract ? [contract] : [];
747
+ const contract = contracts.find((contract) => contract.path === routeExport.contractRef &&
748
+ contract.method === routeExport.method);
749
+ return contract ? [contract] : [];
750
+ }
751
+ function contractPathMatchesCatchAll(contractPath, catchAllPrefix) {
752
+ return (contractPath === catchAllPrefix ||
753
+ contractPath.startsWith(`${catchAllPrefix}/`));
754
+ }
755
+ async function inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, strict) {
756
+ const diagnostics = [];
757
+ if (!convention.nextLayout) {
758
+ diagnostics.push({
759
+ severity: "warning",
760
+ code: "BEIGNET_APP_LAYOUT_NOT_FOUND",
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.`,
762
+ });
763
+ }
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;
451
1645
  }
452
- function contractPathMatchesCatchAll(contractPath, catchAllPrefix) {
453
- return (contractPath === catchAllPrefix ||
454
- contractPath.startsWith(`${catchAllPrefix}/`));
1646
+ function importResolvesToFile(sourcePath, importerFile, files, expectedFile) {
1647
+ return (sourceFileFromImport(sourcePath, importerFile, files) === expectedFile ||
1648
+ sourceFileFromImport(sourcePath, importerFile) === expectedFile);
455
1649
  }
456
- async function inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, strict) {
457
- const diagnostics = [];
458
- if (!convention.nextLayout) {
459
- diagnostics.push({
460
- severity: "warning",
461
- code: "CK_APP_LAYOUT_NOT_FOUND",
462
- 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.`,
463
- });
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
+ }
464
1665
  }
465
- for (const route of matchedRoutes.routes) {
466
- if (route.handlerSource !== "missing")
467
- continue;
468
- diagnostics.push({
469
- severity: "error",
470
- code: "CK_ROUTE_MISSING",
471
- file: route.file,
472
- contract: route.exportName,
473
- 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.`,
474
- });
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;
475
1678
  }
476
- for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
477
- diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
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;
478
1686
  }
479
- diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, contracts)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectServerlessFootguns(targetDir, files)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
480
- return dedupeDiagnostics(diagnostics);
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;
481
1696
  }
482
1697
  async function inspectFeatureArtifactDrift(targetDir, files, config, convention) {
483
1698
  if (!convention.resourceGenerator)
@@ -489,11 +1704,22 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
489
1704
  !(await appHasUploadRoute(targetDir, files, config))) {
490
1705
  diagnostics.push({
491
1706
  severity: "warning",
492
- code: "CK_UPLOAD_ROUTE_MISSING",
1707
+ code: "BEIGNET_UPLOAD_ROUTE_MISSING",
493
1708
  file: uploadDefinitions[0],
494
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.",
495
1710
  });
496
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
+ }
497
1723
  for (const file of files) {
498
1724
  if (!file.endsWith(".ts") ||
499
1725
  file.endsWith(".test.ts") ||
@@ -504,7 +1730,7 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
504
1730
  if (misplaced) {
505
1731
  diagnostics.push({
506
1732
  severity: "warning",
507
- code: "CK_FEATURE_ARTIFACT_FOLDER",
1733
+ code: "BEIGNET_FEATURE_ARTIFACT_FOLDER",
508
1734
  file,
509
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.`,
510
1736
  });
@@ -517,7 +1743,7 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
517
1743
  if (containsCallExpression(source, "createInlineNotificationDispatcher")) {
518
1744
  diagnostics.push({
519
1745
  severity: "warning",
520
- code: "CK_NOTIFICATION_PORT_BYPASS",
1746
+ code: "BEIGNET_NOTIFICATION_PORT_BYPASS",
521
1747
  file,
522
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.`,
523
1749
  });
@@ -549,6 +1775,8 @@ function misplacedFeatureArtifactFolder(file, featuresPath) {
549
1775
  return undefined;
550
1776
  const folder = match[2];
551
1777
  const mappings = {
1778
+ command: "tasks/",
1779
+ commands: "tasks/",
552
1780
  event: "domain/events/",
553
1781
  events: "domain/events/",
554
1782
  job: "jobs/",
@@ -556,6 +1784,7 @@ function misplacedFeatureArtifactFolder(file, featuresPath) {
556
1784
  notification: "notifications/",
557
1785
  schedule: "schedules/",
558
1786
  scheduled: "schedules/",
1787
+ task: "tasks/",
559
1788
  upload: "uploads/",
560
1789
  };
561
1790
  const expected = mappings[folder];
@@ -598,7 +1827,7 @@ async function inspectServerlessFootguns(targetDir, files) {
598
1827
  /\bset(?:Interval|Timeout)\s*\(/.test(source)) {
599
1828
  diagnostics.push({
600
1829
  severity: "warning",
601
- code: "CK_PROVIDER_BACKGROUND_WORK",
1830
+ code: "BEIGNET_PROVIDER_BACKGROUND_WORK",
602
1831
  file,
603
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.`,
604
1833
  });
@@ -609,7 +1838,7 @@ async function inspectServerlessFootguns(targetDir, files) {
609
1838
  /\bdrainOutbox\s*\(/.test(source)) {
610
1839
  diagnostics.push({
611
1840
  severity: "warning",
612
- code: "CK_PROVIDER_OUTBOX_DRAIN",
1841
+ code: "BEIGNET_PROVIDER_OUTBOX_DRAIN",
613
1842
  file,
614
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.`,
615
1844
  });
@@ -617,12 +1846,13 @@ async function inspectServerlessFootguns(targetDir, files) {
617
1846
  if (file.startsWith("app/api/cron/") &&
618
1847
  file.endsWith("/route.ts") &&
619
1848
  !/\bauthorization\b/i.test(source) &&
620
- !/\bcreateOutboxDrainRoute\s*\(/.test(source)) {
1849
+ !/\bcreateOutboxDrainRoute\s*\(/.test(source) &&
1850
+ !/\bcreateScheduleRoute\s*\(/.test(source)) {
621
1851
  diagnostics.push({
622
1852
  severity: "warning",
623
- code: "CK_CRON_ROUTE_AUTH_MISSING",
1853
+ code: "BEIGNET_CRON_ROUTE_AUTH_MISSING",
624
1854
  file,
625
- message: `${file} looks like a cron route without an authorization check. Require a shared secret or use a framework route helper such as createOutboxDrainRoute(...).`,
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(...).`,
626
1856
  });
627
1857
  }
628
1858
  }
@@ -630,7 +1860,7 @@ async function inspectServerlessFootguns(targetDir, files) {
630
1860
  !(await appHasOutboxDrainEntrypoint(targetDir, files))) {
631
1861
  diagnostics.push({
632
1862
  severity: "warning",
633
- code: "CK_OUTBOX_DRAIN_MISSING",
1863
+ code: "BEIGNET_OUTBOX_DRAIN_MISSING",
634
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.",
635
1865
  });
636
1866
  }
@@ -667,21 +1897,27 @@ async function inspectPortProviderDrift(targetDir, files, config, convention) {
667
1897
  ? await readFile(path.join(targetDir, config.paths.ports), "utf8")
668
1898
  : "";
669
1899
  const packageJson = files.includes("package.json")
670
- ? JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8"))
1900
+ ? await readPackageJson(targetDir, files)
671
1901
  : undefined;
672
1902
  const installedPackages = new Set([
673
1903
  ...Object.keys(packageJson?.dependencies ?? {}),
674
1904
  ...Object.keys(packageJson?.devDependencies ?? {}),
675
1905
  ...Object.keys(packageJson?.peerDependencies ?? {}),
676
1906
  ]);
677
- for (const expected of canonicalProviderPorts) {
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) {
678
1914
  if (!installedPackages.has(expected.packageName))
679
1915
  continue;
680
1916
  if (portsSource.includes(`${expected.portName}:`))
681
1917
  continue;
682
1918
  diagnostics.push({
683
1919
  severity: "warning",
684
- code: "CK_PROVIDER_PORT_MISSING",
1920
+ code: "BEIGNET_PROVIDER_PORT_MISSING",
685
1921
  file: config.paths.ports,
686
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.`,
687
1923
  });
@@ -703,7 +1939,7 @@ async function inspectPortProviderDrift(targetDir, files, config, convention) {
703
1939
  continue;
704
1940
  diagnostics.push({
705
1941
  severity: "warning",
706
- code: "CK_PORT_TEST_FAKE_MISSING",
1942
+ code: "BEIGNET_PORT_TEST_FAKE_MISSING",
707
1943
  file,
708
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.`,
709
1945
  });
@@ -721,6 +1957,111 @@ async function inspectDatabaseLifecycleDrift(targetDir, files, config, conventio
721
1957
  const repositoriesSource = files.includes(repositoriesPath)
722
1958
  ? await readFile(path.join(targetDir, repositoriesPath), "utf8")
723
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
+ }
724
2065
  for (const resource of resources) {
725
2066
  const singular = singularize(resource);
726
2067
  const pascal = pascalCase(singular);
@@ -732,7 +2073,7 @@ async function inspectDatabaseLifecycleDrift(targetDir, files, config, conventio
732
2073
  if (files.includes(portFile) && !hasRepositoryAdapter) {
733
2074
  diagnostics.push({
734
2075
  severity: "warning",
735
- code: "CK_REPOSITORY_ADAPTER_MISSING",
2076
+ code: "BEIGNET_REPOSITORY_ADAPTER_MISSING",
736
2077
  file: portFile,
737
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.`,
738
2079
  });
@@ -744,7 +2085,7 @@ async function inspectDatabaseLifecycleDrift(targetDir, files, config, conventio
744
2085
  !new RegExp(`\\b${pluralCamel}\\s*:`).test(repositoriesSource))) {
745
2086
  diagnostics.push({
746
2087
  severity: "warning",
747
- code: "CK_DRIZZLE_REPOSITORY_UNWIRED",
2088
+ code: "BEIGNET_DRIZZLE_REPOSITORY_UNWIRED",
748
2089
  file: repositoriesPath,
749
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.`,
750
2091
  });
@@ -755,7 +2096,7 @@ async function inspectDatabaseLifecycleDrift(targetDir, files, config, conventio
755
2096
  !hasFeatureFactories(files, featuresPath, resource)) {
756
2097
  diagnostics.push({
757
2098
  severity: "warning",
758
- code: "CK_FACTORY_MISSING",
2099
+ code: "BEIGNET_FACTORY_MISSING",
759
2100
  file: `${featuresPath}/${resource}/seeds/`,
760
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.`,
761
2102
  });
@@ -765,7 +2106,7 @@ async function inspectDatabaseLifecycleDrift(targetDir, files, config, conventio
765
2106
  !(await packageScriptExists(targetDir, files, "db:seed"))) {
766
2107
  diagnostics.push({
767
2108
  severity: "warning",
768
- code: "CK_DB_SEED_SCRIPT_MISSING",
2109
+ code: "BEIGNET_DB_SEED_SCRIPT_MISSING",
769
2110
  file: "package.json",
770
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.',
771
2112
  });
@@ -798,53 +2139,34 @@ function hasFeatureFactories(files, featuresPath, resource) {
798
2139
  !file.endsWith("/index.ts")));
799
2140
  }
800
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) {
801
2146
  if (!files.includes("package.json"))
802
- return false;
2147
+ return {};
803
2148
  const packageJson = JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8"));
804
- return Boolean(packageJson.scripts?.[script]);
805
- }
806
- const canonicalProviderPorts = [
807
- {
808
- packageName: "@beignet/provider-auth-better-auth",
809
- portName: "auth",
810
- portType: "AuthPort",
811
- },
812
- {
813
- packageName: "@beignet/provider-event-bus-memory",
814
- portName: "eventBus",
815
- portType: "EventBusPort",
816
- },
817
- {
818
- packageName: "@beignet/provider-inngest",
819
- portName: "jobs",
820
- portType: "JobDispatcherPort",
821
- },
822
- {
823
- packageName: "@beignet/provider-logger-pino",
824
- portName: "logger",
825
- portType: "LoggerPort",
826
- },
827
- {
828
- packageName: "@beignet/provider-mail-resend",
829
- portName: "mailer",
830
- portType: "MailerPort",
831
- },
832
- {
833
- packageName: "@beignet/provider-mail-smtp",
834
- portName: "mailer",
835
- portType: "MailerPort",
836
- },
837
- {
838
- packageName: "@beignet/provider-rate-limit-upstash",
839
- portName: "rateLimit",
840
- portType: "RateLimitPort",
841
- },
842
- {
843
- packageName: "@beignet/provider-redis",
844
- portName: "cache",
845
- portType: "CachePort",
846
- },
847
- ];
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
+ }
848
2170
  async function inspectPackageScripts(targetDir, files, convention) {
849
2171
  if (!convention.resourceGenerator || !files.includes("package.json")) {
850
2172
  return [];
@@ -855,7 +2177,7 @@ async function inspectPackageScripts(targetDir, files, convention) {
855
2177
  return [
856
2178
  {
857
2179
  severity: "warning",
858
- code: "CK_PACKAGE_TEST_SCRIPT_MISSING",
2180
+ code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
859
2181
  file: "package.json",
860
2182
  message: 'package.json does not define a test script. Add "test": "bun test" or run beignet doctor --fix.',
861
2183
  },
@@ -877,7 +2199,7 @@ async function fixMissingTestScript(targetDir, files, convention) {
877
2199
  return undefined;
878
2200
  await writeFile(filePath, next);
879
2201
  return {
880
- code: "CK_PACKAGE_TEST_SCRIPT_MISSING",
2202
+ code: "BEIGNET_PACKAGE_TEST_SCRIPT_MISSING",
881
2203
  file: "package.json",
882
2204
  message: 'Added "test": "bun test".',
883
2205
  };
@@ -886,7 +2208,7 @@ function unmatchedRouteDiagnostic(routeHandler) {
886
2208
  if (routeHandler.source === "next-route") {
887
2209
  return {
888
2210
  severity: "error",
889
- code: "CK_ROUTE_HANDLER_ORPHANED",
2211
+ code: "BEIGNET_ROUTE_HANDLER_ORPHANED",
890
2212
  file: routeHandler.handlerFile,
891
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.`,
892
2214
  };
@@ -896,7 +2218,7 @@ function unmatchedRouteDiagnostic(routeHandler) {
896
2218
  : routeHandler.contractRef;
897
2219
  return {
898
2220
  severity: "error",
899
- code: "CK_ROUTE_HANDLER_UNKNOWN_CONTRACT",
2221
+ code: "BEIGNET_ROUTE_HANDLER_UNKNOWN_CONTRACT",
900
2222
  file: routeHandler.handlerFile,
901
2223
  contract: routeHandler.contractRef,
902
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.`,
@@ -919,7 +2241,7 @@ function dedupeDiagnostics(diagnostics) {
919
2241
  }
920
2242
  return deduped;
921
2243
  }
922
- async function inspectOpenApiDrift(targetDir, files, config, contracts) {
2244
+ async function inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
923
2245
  const routePath = path.join(targetDir, config.paths.openapiRoute);
924
2246
  try {
925
2247
  await stat(routePath);
@@ -928,19 +2250,52 @@ async function inspectOpenApiDrift(targetDir, files, config, contracts) {
928
2250
  return [];
929
2251
  }
930
2252
  const source = await readFile(routePath, "utf8");
2253
+ const routeSurface = await routeSurfaceContracts(targetDir, files, config, convention, contracts, matchedRoutes);
931
2254
  const listedContracts = await openApiContracts(targetDir, files, config, source);
932
2255
  if (!listedContracts) {
933
2256
  return [];
934
2257
  }
935
- return contracts
936
- .filter((contract) => !listedContracts.has(contract.exportName))
937
- .map((contract) => ({
938
- severity: "warning",
939
- code: "CK_OPENAPI_MISSING",
940
- file: config.paths.openapiRoute,
941
- contract: contract.exportName,
942
- 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.`,
943
- }));
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");
944
2299
  }
945
2300
  async function inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts) {
946
2301
  if (!convention.resourceGenerator)
@@ -953,6 +2308,34 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
953
2308
  const serverSource = await readFile(path.join(targetDir, config.paths.server), "utf8");
954
2309
  const registeredGroups = await registeredRouteGroupsForServer(targetDir, files, config, serverSource);
955
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
+ }
956
2339
  for (const routeGroup of routeGroups) {
957
2340
  if (registeredGroups.has(routeGroup.name))
958
2341
  continue;
@@ -960,7 +2343,7 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
960
2343
  const contract = findRouteGroupContract(contracts, routeGroupContract);
961
2344
  diagnostics.push({
962
2345
  severity: "error",
963
- code: "CK_ROUTE_GROUP_UNREGISTERED",
2346
+ code: "BEIGNET_ROUTE_GROUP_UNREGISTERED",
964
2347
  file: config.paths.server,
965
2348
  contract: routeGroupContract.exportName,
966
2349
  message: contract
@@ -971,6 +2354,25 @@ async function inspectFeatureRouteRegistration(targetDir, files, config, convent
971
2354
  }
972
2355
  return diagnostics;
973
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
+ }
974
2376
  async function readFeatureRouteGroups(targetDir, files, config) {
975
2377
  const featuresPath = `${directoryPath(config.paths.features)}/`;
976
2378
  const routeFiles = files.filter((file) => file.startsWith(featuresPath) && file.endsWith("/routes.ts"));
@@ -984,9 +2386,13 @@ async function readFeatureRouteGroups(targetDir, files, config) {
984
2386
  }
985
2387
  function parseFeatureRouteGroups(source, file, imports) {
986
2388
  const routeGroups = [];
987
- const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(\s*(?:\)\s*\(\s*)?(?:\{[\s\S]*?routes:\s*)?\[([\s\S]*?)\]\s*(?:,?\s*\})?\s*\)\s*;?/g;
2389
+ const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=\s*defineRouteGroup(?:<[^>]+>)?\(/g;
988
2390
  for (const match of source.matchAll(exportRegex)) {
989
- const contracts = [...match[2].matchAll(/contract:\s*([^,\n}]+)/g)]
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)]
990
2396
  .map((contractMatch) => routeGroupContractRef(contractMatch[1].trim(), imports))
991
2397
  .filter((contract) => Boolean(contract));
992
2398
  routeGroups.push({
@@ -997,6 +2403,67 @@ function parseFeatureRouteGroups(source, file, imports) {
997
2403
  }
998
2404
  return routeGroups;
999
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
+ }
1000
2467
  function routeGroupContractRef(expression, imports) {
1001
2468
  const namedMatch = /^([A-Za-z_$][\w$]*)$/.exec(expression);
1002
2469
  if (namedMatch) {
@@ -1060,7 +2527,7 @@ function findRouteGroupContract(contracts, routeGroupContract) {
1060
2527
  contract.file ===
1061
2528
  routeGroupContract.file.replace(/\.ts$/, "/index.ts")));
1062
2529
  }
1063
- async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
2530
+ async function fixDirectOpenApiArrayDrift(targetDir, files, config, convention, contracts, matchedRoutes) {
1064
2531
  const routePath = path.join(targetDir, config.paths.openapiRoute);
1065
2532
  let source;
1066
2533
  try {
@@ -1072,8 +2539,9 @@ async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
1072
2539
  const firstArg = firstCreateOpenAPIHandlerArgInfo(source);
1073
2540
  if (!firstArg?.text.startsWith("["))
1074
2541
  return undefined;
2542
+ const routeSurface = await routeSurfaceContracts(targetDir, files, config, convention, contracts, matchedRoutes);
1075
2543
  const listedContracts = contractsFromArrayExpression(firstArg.text);
1076
- const missingContracts = contracts.filter((contract) => !listedContracts.has(contract.exportName));
2544
+ const missingContracts = routeSurface.filter((contract) => !listedContracts.has(contract.exportName));
1077
2545
  if (missingContracts.length === 0)
1078
2546
  return undefined;
1079
2547
  if (missingContracts.some((contract) => !hasImportedIdentifier(source, contract.exportName))) {
@@ -1085,13 +2553,77 @@ async function fixDirectOpenApiArrayDrift(targetDir, config, contracts) {
1085
2553
  return undefined;
1086
2554
  await writeFile(routePath, next);
1087
2555
  return {
1088
- code: "CK_OPENAPI_MISSING",
2556
+ code: "BEIGNET_OPENAPI_MISSING",
1089
2557
  file: config.paths.openapiRoute,
1090
2558
  message: `Added ${missingContracts
1091
2559
  .map((contract) => contract.exportName)
1092
2560
  .join(", ")} to the direct createOpenAPIHandler contract array.`,
1093
2561
  };
1094
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
+ }
1095
2627
  async function openApiContracts(targetDir, files, config, source) {
1096
2628
  const firstArg = firstCreateOpenAPIHandlerArg(source);
1097
2629
  if (!firstArg)
@@ -1102,22 +2634,75 @@ async function openApiContracts(targetDir, files, config, source) {
1102
2634
  const identifier = /^([A-Za-z_$][\w$]*)$/.exec(firstArg)?.[1];
1103
2635
  if (!identifier)
1104
2636
  return undefined;
2637
+ const routeContracts = await contractsFromRoutesExport(targetDir, files, config, config.paths.openapiRoute, source, identifier);
2638
+ if (routeContracts)
2639
+ return routeContracts;
1105
2640
  const imports = parseNamedImports(source, config);
1106
2641
  const imported = imports.get(identifier);
1107
2642
  const listFile = imported?.contractFile ?? config.paths.openapiRoute;
1108
2643
  return resolveContractList(await readContractLists(targetDir, files, config), listFile, imported?.importedName ?? identifier);
1109
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
+ }
1110
2687
  function firstCreateOpenAPIHandlerArg(source) {
1111
2688
  return firstCreateOpenAPIHandlerArgInfo(source)?.text;
1112
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
+ }
1113
2696
  function firstCreateOpenAPIHandlerArgInfo(source) {
1114
2697
  const start = source.indexOf("createOpenAPIHandler(");
1115
2698
  if (start === -1)
1116
2699
  return undefined;
2700
+ return firstCallArgInfo(source, start + "createOpenAPIHandler(".length);
2701
+ }
2702
+ function firstCallArgInfo(source, argStart) {
1117
2703
  let depth = 0;
1118
2704
  let inString;
1119
2705
  let escaped = false;
1120
- const argStart = start + "createOpenAPIHandler(".length;
1121
2706
  for (let index = argStart; index < source.length; index++) {
1122
2707
  const char = source[index];
1123
2708
  if (inString) {
@@ -1197,6 +2782,20 @@ function appendToArrayExpression(expression, names) {
1197
2782
  }
1198
2783
  return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
1199
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
+ }
1200
2799
  function hasImportedIdentifier(source, identifier) {
1201
2800
  const importRegex = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
1202
2801
  for (const match of source.matchAll(importRegex)) {
@@ -1208,6 +2807,21 @@ function hasImportedIdentifier(source, identifier) {
1208
2807
  }
1209
2808
  return false;
1210
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
+ }
1211
2825
  async function readContractLists(targetDir, files, config) {
1212
2826
  const lists = new Map();
1213
2827
  const contractFiles = contractListSourceFiles(files, config);
@@ -1312,38 +2926,229 @@ async function inspectResourceSlices(targetDir, files, config, convention, stric
1312
2926
  const diagnostics = [];
1313
2927
  for (const resource of resources) {
1314
2928
  const singular = singularize(resource);
2929
+ const pluralCamel = camelCase(resource);
2930
+ const singularPascal = pascalCase(singular);
1315
2931
  const contractFile = resourceContractFile(resource, config);
1316
2932
  const useCaseDir = resourceUseCaseDir(resource, config);
1317
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");
1318
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`);
1319
2940
  const routeFile = path.join(config.paths.routes, resource, "route.ts");
1320
2941
  const catchAllRouteFile = path.join(config.paths.routes, "[[...path]]", "route.ts");
1321
2942
  const testFile = resourceTestFile(resource, config);
1322
2943
  if (!hasRequirement(files, contractFile)) {
1323
- diagnostics.push(resourceDiagnostic("CK_RESOURCE_CONTRACT_MISSING", resource, contractFile));
2944
+ diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_CONTRACT_MISSING", resource, contractFile));
1324
2945
  }
1325
2946
  if (!hasRequirement(files, useCaseDir)) {
1326
- diagnostics.push(resourceDiagnostic("CK_RESOURCE_USE_CASES_MISSING", resource, useCaseDir));
2947
+ diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_USE_CASES_MISSING", resource, useCaseDir));
1327
2948
  }
1328
2949
  if (!hasRequirement(files, portFile)) {
1329
- diagnostics.push(resourceDiagnostic("CK_RESOURCE_PORT_MISSING", resource, portFile));
2950
+ diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_PORT_MISSING", resource, portFile));
1330
2951
  }
1331
2952
  if (!hasRequirement(files, infrastructureDir)) {
1332
- diagnostics.push(resourceDiagnostic("CK_RESOURCE_INFRASTRUCTURE_MISSING", resource, infrastructureDir));
2953
+ diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_INFRASTRUCTURE_MISSING", resource, infrastructureDir));
1333
2954
  }
1334
2955
  if (!hasRequirement(files, routeFile) &&
1335
2956
  !hasRequirement(files, catchAllRouteFile)) {
1336
- diagnostics.push(resourceDiagnostic("CK_RESOURCE_ROUTE_MISSING", resource, routeFile));
2957
+ diagnostics.push(resourceDiagnostic("BEIGNET_RESOURCE_ROUTE_MISSING", resource, routeFile));
1337
2958
  }
1338
2959
  if (strict && !hasRequirement(files, testFile)) {
1339
2960
  diagnostics.push({
1340
- ...resourceDiagnostic("CK_RESOURCE_TEST_MISSING", resource, testFile),
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({
1341
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.`,
1342
3095
  });
1343
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
+ }
1344
3138
  }
1345
3139
  return diagnostics;
1346
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
+ }
1347
3152
  async function collectResourceNames(targetDir, files, config) {
1348
3153
  const resources = new Set();
1349
3154
  const portsPath = directoryPath(path.dirname(config.paths.ports));
@@ -1380,11 +3185,13 @@ async function isGeneratedResourcePort(targetDir, file, resource) {
1380
3185
  const singular = singularize(resource);
1381
3186
  const singularPascal = pascalCase(singular);
1382
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)");
1383
3191
  return (source.includes(`Create${singularPascal}Input`) &&
1384
3192
  source.includes(`List${pluralPascal}Result`) &&
1385
- source.includes("OffsetPage") &&
3193
+ (hasCursorList || hasOffsetList) &&
1386
3194
  source.includes(`interface ${singularPascal}Repository`) &&
1387
- source.includes("list(page: OffsetPage)") &&
1388
3195
  source.includes(`create(input: Create${singularPascal}Input)`));
1389
3196
  }
1390
3197
  async function isGeneratedInfrastructureRepository(targetDir, file, resource) {