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