@beignet/cli 0.0.47 → 0.0.49

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 (144) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +126 -42
  3. package/dist/analysis/contract-factories.d.ts +17 -0
  4. package/dist/analysis/contract-factories.d.ts.map +1 -0
  5. package/dist/analysis/contract-factories.js +176 -0
  6. package/dist/analysis/contract-factories.js.map +1 -0
  7. package/dist/analysis/layers.d.ts +5 -0
  8. package/dist/analysis/layers.d.ts.map +1 -0
  9. package/dist/analysis/layers.js +172 -0
  10. package/dist/analysis/layers.js.map +1 -0
  11. package/dist/analysis/port-wiring.d.ts +23 -0
  12. package/dist/analysis/port-wiring.d.ts.map +1 -0
  13. package/dist/analysis/port-wiring.js +379 -0
  14. package/dist/analysis/port-wiring.js.map +1 -0
  15. package/dist/analysis/source-index.d.ts +5 -0
  16. package/dist/analysis/source-index.d.ts.map +1 -1
  17. package/dist/analysis/source-index.js +171 -42
  18. package/dist/analysis/source-index.js.map +1 -1
  19. package/dist/analysis/workspace.d.ts +10 -4
  20. package/dist/analysis/workspace.d.ts.map +1 -1
  21. package/dist/analysis/workspace.js +66 -6
  22. package/dist/analysis/workspace.js.map +1 -1
  23. package/dist/app-map.d.ts.map +1 -1
  24. package/dist/app-map.js +311 -92
  25. package/dist/app-map.js.map +1 -1
  26. package/dist/check.d.ts +8 -1
  27. package/dist/check.d.ts.map +1 -1
  28. package/dist/check.js +55 -9
  29. package/dist/check.js.map +1 -1
  30. package/dist/db.d.ts +38 -0
  31. package/dist/db.d.ts.map +1 -1
  32. package/dist/db.js +52 -0
  33. package/dist/db.js.map +1 -1
  34. package/dist/explain.js +4 -4
  35. package/dist/explain.js.map +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +73 -6
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts.map +1 -1
  40. package/dist/inspect.js +454 -170
  41. package/dist/inspect.js.map +1 -1
  42. package/dist/lib.d.ts +2 -2
  43. package/dist/lib.d.ts.map +1 -1
  44. package/dist/lib.js +1 -1
  45. package/dist/lib.js.map +1 -1
  46. package/dist/lint.d.ts.map +1 -1
  47. package/dist/lint.js +309 -487
  48. package/dist/lint.js.map +1 -1
  49. package/dist/make/inbox.d.ts.map +1 -1
  50. package/dist/make/inbox.js +12 -5
  51. package/dist/make/inbox.js.map +1 -1
  52. package/dist/make/payments.js +3 -3
  53. package/dist/make/payments.js.map +1 -1
  54. package/dist/make/tenancy.js +1 -1
  55. package/dist/make/tenancy.js.map +1 -1
  56. package/dist/make.d.ts.map +1 -1
  57. package/dist/make.js +144 -8
  58. package/dist/make.js.map +1 -1
  59. package/dist/mcp.d.ts +1 -1
  60. package/dist/mcp.d.ts.map +1 -1
  61. package/dist/mcp.js +56 -19
  62. package/dist/mcp.js.map +1 -1
  63. package/dist/preflight.d.ts +11 -3
  64. package/dist/preflight.d.ts.map +1 -1
  65. package/dist/preflight.js +139 -16
  66. package/dist/preflight.js.map +1 -1
  67. package/dist/provider-add.d.ts.map +1 -1
  68. package/dist/provider-add.js +149 -9
  69. package/dist/provider-add.js.map +1 -1
  70. package/dist/provider-audit.d.ts +13 -0
  71. package/dist/provider-audit.d.ts.map +1 -1
  72. package/dist/provider-audit.js +122 -8
  73. package/dist/provider-audit.js.map +1 -1
  74. package/dist/registry-edits.d.ts +7 -0
  75. package/dist/registry-edits.d.ts.map +1 -1
  76. package/dist/registry-edits.js +237 -53
  77. package/dist/registry-edits.js.map +1 -1
  78. package/dist/templates/agents.d.ts.map +1 -1
  79. package/dist/templates/agents.js +29 -7
  80. package/dist/templates/agents.js.map +1 -1
  81. package/dist/templates/base.d.ts +1 -0
  82. package/dist/templates/base.d.ts.map +1 -1
  83. package/dist/templates/base.js +20 -6
  84. package/dist/templates/base.js.map +1 -1
  85. package/dist/templates/db/index.d.ts +1 -0
  86. package/dist/templates/db/index.d.ts.map +1 -1
  87. package/dist/templates/db/index.js.map +1 -1
  88. package/dist/templates/db/migration-status.d.ts +9 -0
  89. package/dist/templates/db/migration-status.d.ts.map +1 -0
  90. package/dist/templates/db/migration-status.js +117 -0
  91. package/dist/templates/db/migration-status.js.map +1 -0
  92. package/dist/templates/db/mysql.d.ts.map +1 -1
  93. package/dist/templates/db/mysql.js +23 -0
  94. package/dist/templates/db/mysql.js.map +1 -1
  95. package/dist/templates/db/postgres.d.ts.map +1 -1
  96. package/dist/templates/db/postgres.js +21 -0
  97. package/dist/templates/db/postgres.js.map +1 -1
  98. package/dist/templates/db/sqlite.d.ts.map +1 -1
  99. package/dist/templates/db/sqlite.js +53 -0
  100. package/dist/templates/db/sqlite.js.map +1 -1
  101. package/dist/templates/index.d.ts.map +1 -1
  102. package/dist/templates/index.js +5 -0
  103. package/dist/templates/index.js.map +1 -1
  104. package/dist/templates/server.d.ts.map +1 -1
  105. package/dist/templates/server.js +2 -0
  106. package/dist/templates/server.js.map +1 -1
  107. package/dist/templates/shared.d.ts +1 -0
  108. package/dist/templates/shared.d.ts.map +1 -1
  109. package/dist/templates/shared.js +4 -2
  110. package/dist/templates/shared.js.map +1 -1
  111. package/package.json +7 -6
  112. package/skills/app-structure/SKILL.md +56 -11
  113. package/src/analysis/contract-factories.ts +256 -0
  114. package/src/analysis/layers.ts +266 -0
  115. package/src/analysis/port-wiring.ts +506 -0
  116. package/src/analysis/source-index.ts +225 -57
  117. package/src/analysis/workspace.ts +115 -6
  118. package/src/app-map.ts +418 -109
  119. package/src/check.ts +86 -10
  120. package/src/db.ts +97 -0
  121. package/src/explain.ts +4 -4
  122. package/src/index.ts +108 -7
  123. package/src/inspect.ts +659 -216
  124. package/src/lib.ts +9 -1
  125. package/src/lint.ts +393 -643
  126. package/src/make/inbox.ts +12 -5
  127. package/src/make/payments.ts +3 -3
  128. package/src/make/tenancy.ts +1 -1
  129. package/src/make.ts +225 -8
  130. package/src/mcp.ts +73 -21
  131. package/src/preflight.ts +195 -16
  132. package/src/provider-add.ts +193 -9
  133. package/src/provider-audit.ts +210 -18
  134. package/src/registry-edits.ts +313 -63
  135. package/src/templates/agents.ts +29 -7
  136. package/src/templates/base.ts +21 -4
  137. package/src/templates/db/index.ts +1 -0
  138. package/src/templates/db/migration-status.ts +126 -0
  139. package/src/templates/db/mysql.ts +23 -0
  140. package/src/templates/db/postgres.ts +21 -0
  141. package/src/templates/db/sqlite.ts +53 -0
  142. package/src/templates/index.ts +5 -0
  143. package/src/templates/server.ts +2 -0
  144. package/src/templates/shared.ts +4 -2
package/src/inspect.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProviderPackageMetadata } from "@beignet/core/providers";
4
+ import ts from "typescript";
5
+ import {
6
+ type ContractFactoryBindings,
7
+ type ContractFactoryName,
8
+ contractFactoryBindings,
9
+ isImportedContractFactoryCall,
10
+ } from "./analysis/contract-factories.js";
11
+ import { canonicalFeatureLayerPath } from "./analysis/layers.js";
12
+ import { analyzePortWiringFiles } from "./analysis/port-wiring.js";
13
+ import { exportedVariableDeclarations } from "./analysis/source-index.js";
4
14
  import { createPainter } from "./ansi.js";
5
15
  import type { DatabaseSchemaDialect, DatabaseSchemaTable } from "./choices.js";
6
16
  import {
@@ -43,6 +53,7 @@ import {
43
53
  import {
44
54
  detectedProviderVariants,
45
55
  diagnosticPackageJsonFile,
56
+ formatProviderEnvAlternatives,
46
57
  formatProviderMetadataIssues,
47
58
  installedPackageNames,
48
59
  providerDoctorRulesForInstalledPackages,
@@ -65,7 +76,8 @@ import {
65
76
  identifiersFromArrayExpression,
66
77
  insertAfterImports,
67
78
  matchingDelimiterIndex,
68
- outboxRegistryValueInfo,
79
+ outboxRegistryEntryState,
80
+ outboxRegistryIdentifiers,
69
81
  } from "./registry-edits.js";
70
82
  import {
71
83
  currentGeneratedPackageScripts,
@@ -220,6 +232,16 @@ type FeatureRouteGroup = {
220
232
  }>;
221
233
  };
222
234
 
235
+ type ReadContractsResult = {
236
+ contracts: InspectedContract[];
237
+ queryTransportDiagnostics: InspectDiagnostic[];
238
+ };
239
+
240
+ type ParsedContract = {
241
+ contract: InspectedContract;
242
+ queryTransportMissing: boolean;
243
+ };
244
+
223
245
  export async function inspectApp(
224
246
  options: InspectAppOptions = {},
225
247
  ): Promise<InspectAppResult> {
@@ -231,7 +253,8 @@ export async function inspectApp(
231
253
  ? resolveConfig(options.config)
232
254
  : await loadBeignetConfig(targetDir, files);
233
255
  const convention = inspectConvention(files, config);
234
- const contracts = await readContracts(targetDir, files, config);
256
+ const inspectedContracts = await readContracts(targetDir, files, config);
257
+ const { contracts } = inspectedContracts;
235
258
  const matchedRoutes = await inspectRouteSurface(
236
259
  targetDir,
237
260
  files,
@@ -246,6 +269,7 @@ export async function inspectApp(
246
269
  contracts,
247
270
  matchedRoutes,
248
271
  Boolean(options.strict),
272
+ inspectedContracts.queryTransportDiagnostics,
249
273
  );
250
274
 
251
275
  return {
@@ -329,7 +353,7 @@ async function buildDoctorFixPlan(
329
353
  ? resolveConfig(options.config)
330
354
  : await loadBeignetConfig(targetDir, files);
331
355
  const convention = inspectConvention(files, config);
332
- const contracts = await readContracts(targetDir, files, config);
356
+ const { contracts } = await readContracts(targetDir, files, config);
333
357
  const matchedRoutes = await inspectRouteSurface(
334
358
  targetDir,
335
359
  files,
@@ -710,7 +734,6 @@ export const productionHardeningDiagnosticCodes = new Set([
710
734
  "BEIGNET_BILLING_IDEMPOTENCY_PORT_MISSING",
711
735
  "BEIGNET_CORS_CREDENTIALS_WILDCARD",
712
736
  "BEIGNET_CRON_SECRET_MISSING",
713
- "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
714
737
  "BEIGNET_ENTITLEMENTS_PORT_MISSING",
715
738
  "BEIGNET_PAYMENTS_STRIPE_MEMORY_PROVIDER",
716
739
  "BEIGNET_PAYMENTS_WEBHOOK_ROUTE_MISSING",
@@ -829,16 +852,32 @@ async function readContracts(
829
852
  targetDir: string,
830
853
  files: string[],
831
854
  config: ResolvedBeignetConfig,
832
- ): Promise<InspectedContract[]> {
855
+ ): Promise<ReadContractsResult> {
833
856
  const contracts: InspectedContract[] = [];
857
+ const queryTransportDiagnostics: InspectDiagnostic[] = [];
834
858
  const contractFiles = contractSourceFiles(files, config);
835
859
 
836
860
  for (const file of contractFiles) {
837
861
  const source = await readFile(path.join(targetDir, file), "utf8");
838
- contracts.push(...parseContracts(source, file));
862
+ for (const parsed of parseContracts(source, file)) {
863
+ contracts.push(parsed.contract);
864
+ if (!parsed.queryTransportMissing) continue;
865
+
866
+ queryTransportDiagnostics.push({
867
+ severity: "error",
868
+ code: "BEIGNET_QUERY_TRANSPORT_MISSING",
869
+ file,
870
+ contract: parsed.contract.exportName,
871
+ subject: { file, exportName: parsed.contract.exportName },
872
+ message: `${parsed.contract.exportName} declares a query schema without an explicit query transport. Pass defineQueryTransport(...) as the second .query(...) argument so the client, server, and OpenAPI document share one wire format.`,
873
+ });
874
+ }
839
875
  }
840
876
 
841
- return contracts.sort(compareContracts);
877
+ return {
878
+ contracts: contracts.sort(compareContracts),
879
+ queryTransportDiagnostics,
880
+ };
842
881
  }
843
882
 
844
883
  function inspectConvention(
@@ -900,68 +939,126 @@ function hasRequirement(files: string[], requirement: string): boolean {
900
939
  return files.includes(requirement);
901
940
  }
902
941
 
903
- function parseContracts(source: string, file: string): InspectedContract[] {
904
- const contracts: InspectedContract[] = [];
905
- const groupPrefixes = parseContractGroupPrefixes(source);
906
- const groupErrorRefs = parseContractGroupErrorRefs(source);
907
- const exportRegex =
908
- /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
942
+ function parseContracts(source: string, file: string): ParsedContract[] {
943
+ const contracts: ParsedContract[] = [];
944
+ const sourceFile = ts.createSourceFile(
945
+ file,
946
+ source,
947
+ ts.ScriptTarget.Latest,
948
+ true,
949
+ ts.ScriptKind.TS,
950
+ );
951
+ const assignments = variableAssignments(sourceFile);
952
+ const assignmentsByName = new Map(
953
+ assignments.map((assignment) => [assignment.name, assignment.expression]),
954
+ );
955
+ const factoryBindings = contractFactoryBindings(sourceFile);
909
956
 
910
- for (const exportMatch of source.matchAll(exportRegex)) {
911
- const block = exportMatch[2];
912
- const methodMatch =
913
- /([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["'](?:\s*,\s*["'][^"']+["'])?\s*\)/.exec(
914
- block,
915
- );
916
- if (methodMatch) {
917
- const receiver = methodMatch[1];
918
- const pathPrefix = groupPrefixes.get(receiver) ?? "";
919
- const routePath = joinContractPaths(pathPrefix, methodMatch[3]);
957
+ for (const { exportName, declaration } of exportedVariableDeclarations(
958
+ sourceFile,
959
+ )) {
960
+ if (!declaration.initializer) continue;
961
+ const chain = resolveContractCallChain(
962
+ declaration.initializer,
963
+ assignmentsByName,
964
+ );
965
+ if (!chain) continue;
920
966
 
967
+ const directContract = parseDirectContract(chain, factoryBindings);
968
+ if (directContract) {
921
969
  contracts.push({
922
- exportName: exportMatch[1],
923
- file,
924
- metadata: parseContractMetadata(
925
- block,
926
- groupErrorRefs.get(receiver) ?? [],
927
- ),
928
- method: methodMatch[2].toUpperCase() as HttpMethod,
929
- path: routePath,
970
+ contract: {
971
+ exportName,
972
+ file,
973
+ metadata: parseContractMetadata(
974
+ applyContractMetadataCalls(
975
+ emptyContractMetadataState(),
976
+ chain.calls,
977
+ ),
978
+ ),
979
+ method: directContract.method,
980
+ path: normalizeContractPath(directContract.path),
981
+ },
982
+ queryTransportMissing: contractQueryHasTransport(chain) === false,
930
983
  });
931
984
  continue;
932
985
  }
933
986
 
934
- const directContract = parseDirectContract(block);
935
- if (directContract) {
936
- contracts.push({
937
- exportName: exportMatch[1],
987
+ const methodIndex = chain.calls.findIndex((call) =>
988
+ isHttpMethod(call.name.toUpperCase()),
989
+ );
990
+ if (methodIndex === -1) continue;
991
+ const methodCall = chain.calls[methodIndex];
992
+ const routePath = staticString(methodCall.arguments[0]);
993
+ if (!routePath) continue;
994
+
995
+ const hasGroupFactory = contractFactoryCall(
996
+ chain,
997
+ "defineContractGroup",
998
+ factoryBindings,
999
+ );
1000
+ if (!hasGroupFactory) continue;
1001
+
1002
+ const pathPrefix = applyStaticPrefixes(
1003
+ "",
1004
+ chain.calls.slice(0, methodIndex),
1005
+ );
1006
+ if (pathPrefix === undefined) continue;
1007
+
1008
+ contracts.push({
1009
+ contract: {
1010
+ exportName,
938
1011
  file,
939
- metadata: parseContractMetadata(block),
940
- method: directContract.method,
941
- path: normalizeContractPath(directContract.path),
942
- });
943
- }
1012
+ metadata: parseContractMetadata(
1013
+ applyContractMetadataCalls(emptyContractMetadataState(), chain.calls),
1014
+ ),
1015
+ method: methodCall.name.toUpperCase() as HttpMethod,
1016
+ path: joinContractPaths(pathPrefix, routePath),
1017
+ },
1018
+ queryTransportMissing: contractQueryHasTransport(chain) === false,
1019
+ });
944
1020
  }
945
1021
 
946
1022
  return contracts;
947
1023
  }
948
1024
 
1025
+ function unwrapContractExpression(expression: ts.Expression): ts.Expression {
1026
+ let current = expression;
1027
+ while (
1028
+ ts.isParenthesizedExpression(current) ||
1029
+ ts.isAsExpression(current) ||
1030
+ ts.isTypeAssertionExpression(current) ||
1031
+ ts.isNonNullExpression(current) ||
1032
+ ts.isSatisfiesExpression(current)
1033
+ ) {
1034
+ current = current.expression;
1035
+ }
1036
+ return current;
1037
+ }
1038
+
1039
+ type ContractMetadataState = {
1040
+ authRequired?: boolean;
1041
+ authorizationAbility?: string;
1042
+ authorizationAuditRequired?: boolean;
1043
+ audit?: boolean;
1044
+ auditRequired?: boolean;
1045
+ catalogErrors: InspectedCatalogErrorRef[];
1046
+ };
1047
+
1048
+ function emptyContractMetadataState(): ContractMetadataState {
1049
+ return { catalogErrors: [] };
1050
+ }
1051
+
949
1052
  function parseContractMetadata(
950
- block: string,
951
- inheritedCatalogErrors: InspectedCatalogErrorRef[] = [],
1053
+ state: ContractMetadataState,
952
1054
  ): InspectedContractMetadata | undefined {
953
- const authRequired = /\bauth\s*:\s*["']required["']/.test(block);
954
- const authorizationAbility =
955
- /\bauthorization\s*:\s*\{[\s\S]*?\bability\s*:\s*["']([^"']+)["']/.exec(
956
- block,
957
- )?.[1];
1055
+ const authRequired = state.authRequired === true;
1056
+ const authorizationAbility = state.authorizationAbility;
958
1057
  const auditRequired =
959
- /\baudit\s*:\s*["']required["']/.test(block) ||
960
- /\bauditRequired\s*:\s*true\b/.test(block);
961
- const catalogErrors = dedupeCatalogErrorRefs([
962
- ...inheritedCatalogErrors,
963
- ...parseCatalogErrorRefs(block),
964
- ]);
1058
+ state.audit === true ||
1059
+ state.auditRequired === true ||
1060
+ state.authorizationAuditRequired === true;
1061
+ const catalogErrors = state.catalogErrors;
965
1062
 
966
1063
  if (
967
1064
  !authRequired &&
@@ -980,21 +1077,129 @@ function parseContractMetadata(
980
1077
  };
981
1078
  }
982
1079
 
983
- function parseDirectContract(
984
- block: string,
985
- ): { method: HttpMethod; path: string } | undefined {
986
- const configMatch = /defineContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
987
- if (!configMatch) return undefined;
1080
+ function applyContractMetadataCalls(
1081
+ initialState: ContractMetadataState,
1082
+ calls: ContractCall[],
1083
+ ): ContractMetadataState {
1084
+ const state: ContractMetadataState = {
1085
+ ...initialState,
1086
+ catalogErrors: [...initialState.catalogErrors],
1087
+ };
988
1088
 
989
- const configSource = configMatch[1];
990
- const methodMatch = /method\s*:\s*["']([^"']+)["']/.exec(configSource);
991
- const pathMatch = /path\s*:\s*["']([^"']+)["']/.exec(configSource);
992
- if (!methodMatch || !pathMatch) return undefined;
1089
+ for (const call of calls) {
1090
+ if (call.name === "meta") {
1091
+ applyContractMetaObject(state, objectLiteral(call.arguments[0]));
1092
+ continue;
1093
+ }
1094
+ if (call.name === "errors") {
1095
+ state.catalogErrors = applyCatalogErrorCall(
1096
+ state.catalogErrors,
1097
+ call.arguments[0],
1098
+ );
1099
+ }
1100
+ }
993
1101
 
994
- const method = methodMatch[1].toUpperCase();
995
- if (!isHttpMethod(method)) return undefined;
1102
+ return state;
1103
+ }
996
1104
 
997
- return { method, path: pathMatch[1] };
1105
+ function applyContractMetaObject(
1106
+ state: ContractMetadataState,
1107
+ metadata: ts.ObjectLiteralExpression | undefined,
1108
+ ): void {
1109
+ if (!metadata) {
1110
+ state.authRequired = undefined;
1111
+ state.authorizationAbility = undefined;
1112
+ state.authorizationAuditRequired = undefined;
1113
+ state.audit = undefined;
1114
+ state.auditRequired = undefined;
1115
+ return;
1116
+ }
1117
+
1118
+ const auth = objectPropertyResult(metadata, "auth");
1119
+ if (auth.kind !== "absent") {
1120
+ state.authRequired =
1121
+ auth.kind === "value" ? staticRequiredValue(auth.value) : undefined;
1122
+ }
1123
+
1124
+ const authorization = objectPropertyResult(metadata, "authorization");
1125
+ if (authorization.kind !== "absent") {
1126
+ const value =
1127
+ authorization.kind === "value"
1128
+ ? objectLiteral(authorization.value)
1129
+ : undefined;
1130
+ if (!value) {
1131
+ state.authorizationAbility = undefined;
1132
+ state.authorizationAuditRequired = undefined;
1133
+ } else {
1134
+ const ability = objectPropertyResult(value, "ability");
1135
+ state.authorizationAbility =
1136
+ ability.kind === "value" ? staticString(ability.value) : undefined;
1137
+ state.authorizationAuditRequired = objectRequiresAudit(value);
1138
+ }
1139
+ }
1140
+
1141
+ const audit = objectPropertyResult(metadata, "audit");
1142
+ const auditRequired = objectPropertyResult(metadata, "auditRequired");
1143
+ if (audit.kind !== "absent") {
1144
+ state.audit =
1145
+ audit.kind === "value" ? staticRequiredValue(audit.value) : undefined;
1146
+ }
1147
+ if (auditRequired.kind !== "absent") {
1148
+ state.auditRequired =
1149
+ auditRequired.kind === "value"
1150
+ ? staticBoolean(auditRequired.value)
1151
+ : undefined;
1152
+ }
1153
+ }
1154
+
1155
+ function objectRequiresAudit(
1156
+ metadata: ts.ObjectLiteralExpression,
1157
+ ): boolean | undefined {
1158
+ const audit = objectPropertyResult(metadata, "audit");
1159
+ const auditRequired = objectPropertyResult(metadata, "auditRequired");
1160
+ if (audit.kind === "unknown" || auditRequired.kind === "unknown") {
1161
+ return undefined;
1162
+ }
1163
+ const auditValue =
1164
+ audit.kind === "value" ? staticRequiredValue(audit.value) : false;
1165
+ const auditRequiredValue =
1166
+ auditRequired.kind === "value" ? staticBoolean(auditRequired.value) : false;
1167
+ if (auditValue === undefined || auditRequiredValue === undefined) {
1168
+ return undefined;
1169
+ }
1170
+ return auditValue || auditRequiredValue;
1171
+ }
1172
+
1173
+ function staticRequiredValue(expression: ts.Expression): boolean | undefined {
1174
+ const value = staticString(expression);
1175
+ return value === undefined ? undefined : value === "required";
1176
+ }
1177
+
1178
+ function staticBoolean(expression: ts.Expression): boolean | undefined {
1179
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) return true;
1180
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) return false;
1181
+ return undefined;
1182
+ }
1183
+
1184
+ function parseDirectContract(
1185
+ chain: ContractCallChain,
1186
+ factoryBindings: ContractFactoryBindings,
1187
+ ): { method: HttpMethod; path: string } | undefined {
1188
+ const factoryCall = contractFactoryCall(
1189
+ chain,
1190
+ "defineContract",
1191
+ factoryBindings,
1192
+ );
1193
+ if (!factoryCall) return undefined;
1194
+ const options = objectLiteral(factoryCall.arguments[0]);
1195
+ if (!options) return undefined;
1196
+ const methodValue = staticString(objectProperty(options, "method"));
1197
+ const contractPath = staticString(objectProperty(options, "path"));
1198
+ if (!methodValue || !contractPath) return undefined;
1199
+
1200
+ const method = methodValue.toUpperCase();
1201
+ if (!isHttpMethod(method)) return undefined;
1202
+ return { method, path: contractPath };
998
1203
  }
999
1204
 
1000
1205
  function isHttpMethod(method: string): method is HttpMethod {
@@ -1003,118 +1208,245 @@ function isHttpMethod(method: string): method is HttpMethod {
1003
1208
  );
1004
1209
  }
1005
1210
 
1006
- function parseContractGroupPrefixes(source: string): Map<string, string> {
1007
- const assignments = [
1008
- ...source.matchAll(
1009
- /(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g,
1010
- ),
1011
- ].map((match) => ({
1012
- name: match[1],
1013
- expression: match[2],
1014
- }));
1015
- const prefixes = new Map<string, string>();
1211
+ type VariableAssignment = {
1212
+ name: string;
1213
+ expression: ts.Expression;
1214
+ };
1016
1215
 
1017
- let changed = true;
1018
- while (changed) {
1019
- changed = false;
1020
- for (const assignment of assignments) {
1021
- if (prefixes.has(assignment.name)) continue;
1216
+ type ContractCall = {
1217
+ name: string;
1218
+ arguments: readonly ts.Expression[];
1219
+ node: ts.CallExpression;
1220
+ };
1022
1221
 
1023
- const basePrefix = baseGroupPrefix(assignment.expression, prefixes);
1024
- if (basePrefix === undefined) continue;
1222
+ type ContractCallChain = {
1223
+ baseIdentifier?: string;
1224
+ rootCall?: ContractCall;
1225
+ calls: ContractCall[];
1226
+ };
1025
1227
 
1026
- const prefix = prefixCalls(assignment.expression).reduce(
1027
- (current, next) => joinContractPaths(current, next),
1028
- basePrefix,
1029
- );
1030
- prefixes.set(assignment.name, prefix);
1031
- changed = true;
1228
+ function variableAssignments(sourceFile: ts.SourceFile): VariableAssignment[] {
1229
+ return sourceFile.statements
1230
+ .filter(ts.isVariableStatement)
1231
+ .flatMap((statement) =>
1232
+ [...statement.declarationList.declarations]
1233
+ .filter(
1234
+ (
1235
+ declaration,
1236
+ ): declaration is ts.VariableDeclaration & {
1237
+ name: ts.Identifier;
1238
+ initializer: ts.Expression;
1239
+ } =>
1240
+ ts.isIdentifier(declaration.name) &&
1241
+ declaration.initializer !== undefined,
1242
+ )
1243
+ .map((declaration) => ({
1244
+ name: declaration.name.text,
1245
+ expression: declaration.initializer,
1246
+ })),
1247
+ );
1248
+ }
1249
+
1250
+ function resolveContractCallChain(
1251
+ expression: ts.Expression,
1252
+ assignments: ReadonlyMap<string, ts.Expression>,
1253
+ resolving = new Set<string>(),
1254
+ ): ContractCallChain | undefined {
1255
+ const chain = contractCallChain(expression);
1256
+ if (!chain || chain.rootCall || !chain.baseIdentifier) return chain;
1257
+
1258
+ const baseExpression = assignments.get(chain.baseIdentifier);
1259
+ if (!baseExpression || resolving.has(chain.baseIdentifier)) return chain;
1260
+
1261
+ const nextResolving = new Set(resolving);
1262
+ nextResolving.add(chain.baseIdentifier);
1263
+ const baseChain = resolveContractCallChain(
1264
+ baseExpression,
1265
+ assignments,
1266
+ nextResolving,
1267
+ );
1268
+ if (!baseChain) return chain;
1269
+
1270
+ return {
1271
+ ...baseChain,
1272
+ calls: [...baseChain.calls, ...chain.calls],
1273
+ };
1274
+ }
1275
+
1276
+ function contractCallChain(
1277
+ rawExpression: ts.Expression,
1278
+ ): ContractCallChain | undefined {
1279
+ const calls: ContractCall[] = [];
1280
+ let expression = rawExpression;
1281
+
1282
+ while (true) {
1283
+ expression = unwrapContractExpression(expression);
1284
+ if (ts.isPropertyAccessExpression(expression)) {
1285
+ expression = expression.expression;
1286
+ continue;
1287
+ }
1288
+ if (ts.isCallExpression(expression)) {
1289
+ const callee = unwrapContractExpression(expression.expression);
1290
+ if (ts.isPropertyAccessExpression(callee)) {
1291
+ calls.unshift({
1292
+ name: callee.name.text,
1293
+ arguments: [...expression.arguments],
1294
+ node: expression,
1295
+ });
1296
+ expression = callee.expression;
1297
+ continue;
1298
+ }
1299
+ if (ts.isIdentifier(callee)) {
1300
+ return {
1301
+ baseIdentifier: callee.text,
1302
+ rootCall: {
1303
+ name: callee.text,
1304
+ arguments: [...expression.arguments],
1305
+ node: expression,
1306
+ },
1307
+ calls,
1308
+ };
1309
+ }
1310
+ return undefined;
1032
1311
  }
1312
+ if (ts.isIdentifier(expression)) {
1313
+ return { baseIdentifier: expression.text, calls };
1314
+ }
1315
+ return undefined;
1033
1316
  }
1317
+ }
1034
1318
 
1035
- return prefixes;
1319
+ function contractFactoryCall(
1320
+ chain: ContractCallChain,
1321
+ name: ContractFactoryName,
1322
+ factoryBindings: ContractFactoryBindings,
1323
+ ): ContractCall | undefined {
1324
+ const calls = chain.rootCall ? [chain.rootCall] : chain.calls;
1325
+ return calls.find((call) =>
1326
+ isImportedContractFactoryCall(call.node, name, factoryBindings),
1327
+ );
1036
1328
  }
1037
1329
 
1038
- function parseContractGroupErrorRefs(
1039
- source: string,
1040
- ): Map<string, InspectedCatalogErrorRef[]> {
1041
- const assignments = [
1042
- ...source.matchAll(
1043
- /(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g,
1044
- ),
1045
- ].map((match) => ({
1046
- name: match[1],
1047
- expression: match[2],
1048
- }));
1049
- const groupErrors = new Map<string, InspectedCatalogErrorRef[]>();
1050
-
1051
- let changed = true;
1052
- while (changed) {
1053
- changed = false;
1054
- for (const assignment of assignments) {
1055
- if (groupErrors.has(assignment.name)) continue;
1056
-
1057
- const baseErrors = baseGroupErrors(assignment.expression, groupErrors);
1058
- if (baseErrors === undefined) continue;
1059
-
1060
- groupErrors.set(
1061
- assignment.name,
1062
- dedupeCatalogErrorRefs([
1063
- ...baseErrors,
1064
- ...parseCatalogErrorRefs(assignment.expression),
1065
- ]),
1066
- );
1067
- changed = true;
1068
- }
1330
+ function applyStaticPrefixes(
1331
+ initialPrefix: string,
1332
+ calls: ContractCall[],
1333
+ ): string | undefined {
1334
+ let prefix = initialPrefix;
1335
+ for (const call of calls) {
1336
+ if (call.name !== "prefix") continue;
1337
+ const next = staticString(call.arguments[0]);
1338
+ if (next === undefined) return undefined;
1339
+ prefix = joinContractPaths(prefix, next);
1069
1340
  }
1341
+ return prefix;
1342
+ }
1070
1343
 
1071
- return groupErrors;
1344
+ function contractQueryHasTransport(
1345
+ chain: ContractCallChain,
1346
+ ): boolean | undefined {
1347
+ const queryCall = chain.calls.find((call) => call.name === "query");
1348
+ return queryCall ? queryCall.arguments.length >= 2 : undefined;
1072
1349
  }
1073
1350
 
1074
- function baseGroupErrors(
1075
- expression: string,
1076
- groupErrors: Map<string, InspectedCatalogErrorRef[]>,
1077
- ): InspectedCatalogErrorRef[] | undefined {
1078
- if (expression.includes("defineContractGroup(")) return [];
1351
+ function applyCatalogErrorCall(
1352
+ current: InspectedCatalogErrorRef[],
1353
+ expression: ts.Expression | undefined,
1354
+ ): InspectedCatalogErrorRef[] {
1355
+ const errors = objectLiteral(expression);
1356
+ if (!errors) return [];
1357
+ let merged = [...current];
1079
1358
 
1080
- const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
1081
- if (!baseMatch) return undefined;
1359
+ for (const property of errors.properties) {
1360
+ if (ts.isSpreadAssignment(property)) {
1361
+ merged = [];
1362
+ continue;
1363
+ }
1364
+ const key = staticPropertyName(property.name);
1365
+ if (!key) {
1366
+ merged = [];
1367
+ continue;
1368
+ }
1082
1369
 
1083
- return groupErrors.get(baseMatch[1]);
1084
- }
1370
+ merged = merged.filter((ref) => ref.key !== key);
1371
+ if (!ts.isPropertyAssignment(property)) continue;
1372
+ const value = unwrapContractExpression(property.initializer);
1373
+ if (
1374
+ !ts.isPropertyAccessExpression(value) ||
1375
+ !ts.isIdentifier(value.expression)
1376
+ ) {
1377
+ continue;
1378
+ }
1379
+ merged.push({
1380
+ key,
1381
+ catalog: value.expression.text,
1382
+ catalogKey: value.name.text,
1383
+ });
1384
+ }
1085
1385
 
1086
- function baseGroupPrefix(
1087
- expression: string,
1088
- prefixes: Map<string, string>,
1089
- ): string | undefined {
1090
- if (expression.includes("defineContractGroup(")) return "";
1386
+ return merged;
1387
+ }
1091
1388
 
1092
- const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
1093
- if (!baseMatch) return undefined;
1389
+ function objectLiteral(
1390
+ expression: ts.Expression | undefined,
1391
+ ): ts.ObjectLiteralExpression | undefined {
1392
+ if (!expression) return undefined;
1393
+ const unwrapped = unwrapContractExpression(expression);
1394
+ return ts.isObjectLiteralExpression(unwrapped) ? unwrapped : undefined;
1395
+ }
1094
1396
 
1095
- return prefixes.get(baseMatch[1]);
1397
+ function objectProperty(
1398
+ object: ts.ObjectLiteralExpression,
1399
+ name: string,
1400
+ ): ts.Expression | undefined {
1401
+ const result = objectPropertyResult(object, name);
1402
+ return result.kind === "value" ? result.value : undefined;
1096
1403
  }
1097
1404
 
1098
- function prefixCalls(expression: string): string[] {
1099
- return [...expression.matchAll(/\.prefix\(\s*["']([^"']+)["']\s*\)/g)].map(
1100
- (match) => match[1],
1101
- );
1405
+ type ObjectPropertyResult =
1406
+ | { kind: "absent" }
1407
+ | { kind: "unknown" }
1408
+ | { kind: "value"; value: ts.Expression };
1409
+
1410
+ function objectPropertyResult(
1411
+ object: ts.ObjectLiteralExpression,
1412
+ name: string,
1413
+ ): ObjectPropertyResult {
1414
+ for (const property of [...object.properties].reverse()) {
1415
+ if (ts.isSpreadAssignment(property)) return { kind: "unknown" };
1416
+ const propertyName = staticPropertyName(property.name);
1417
+ if (propertyName === undefined) return { kind: "unknown" };
1418
+ if (propertyName !== name) continue;
1419
+ if (ts.isPropertyAssignment(property)) {
1420
+ return {
1421
+ kind: "value",
1422
+ value: unwrapContractExpression(property.initializer),
1423
+ };
1424
+ }
1425
+ return { kind: "unknown" };
1426
+ }
1427
+ return { kind: "absent" };
1102
1428
  }
1103
1429
 
1104
- function parseCatalogErrorRefs(source: string): InspectedCatalogErrorRef[] {
1105
- return dedupeCatalogErrorRefs(
1106
- objectArgumentBodies(source, /\.errors\s*\(/g).flatMap((body) =>
1107
- [...body.matchAll(errorCatalogRefRegex)].map((match) => ({
1108
- key: match[1],
1109
- catalog: match[2],
1110
- catalogKey: match[3],
1111
- })),
1112
- ),
1113
- );
1430
+ function staticPropertyName(name: ts.PropertyName): string | undefined {
1431
+ if (
1432
+ ts.isIdentifier(name) ||
1433
+ ts.isStringLiteral(name) ||
1434
+ ts.isNumericLiteral(name)
1435
+ ) {
1436
+ return name.text;
1437
+ }
1438
+ return undefined;
1114
1439
  }
1115
1440
 
1116
- const errorCatalogRefRegex =
1117
- /(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)/g;
1441
+ function staticString(
1442
+ expression: ts.Expression | undefined,
1443
+ ): string | undefined {
1444
+ if (!expression) return undefined;
1445
+ const value = unwrapContractExpression(expression);
1446
+ return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
1447
+ ? value.text
1448
+ : undefined;
1449
+ }
1118
1450
 
1119
1451
  function parseErrorCatalogKeys(source: string): Set<string> {
1120
1452
  const keys = new Set<string>();
@@ -1207,23 +1539,6 @@ function topLevelObjectKeys(body: string): string[] {
1207
1539
  return keys;
1208
1540
  }
1209
1541
 
1210
- function dedupeCatalogErrorRefs(
1211
- refs: InspectedCatalogErrorRef[],
1212
- ): InspectedCatalogErrorRef[] {
1213
- const seen = new Set<string>();
1214
- const deduped: InspectedCatalogErrorRef[] = [];
1215
-
1216
- for (const ref of refs) {
1217
- const key = `${ref.key}:${ref.catalog}:${ref.catalogKey}`;
1218
- if (seen.has(key)) continue;
1219
-
1220
- seen.add(key);
1221
- deduped.push(ref);
1222
- }
1223
-
1224
- return deduped;
1225
- }
1226
-
1227
1542
  function joinContractPaths(prefix: string, routePath: string): string {
1228
1543
  if (!prefix) return normalizeContractPath(routePath);
1229
1544
  const segments = [
@@ -1666,8 +1981,9 @@ async function inspectDiagnostics(
1666
1981
  contracts: InspectedContract[],
1667
1982
  matchedRoutes: MatchedRoutesResult,
1668
1983
  strict: boolean,
1984
+ queryTransportDiagnostics: InspectDiagnostic[],
1669
1985
  ): Promise<InspectDiagnostic[]> {
1670
- const diagnostics: InspectDiagnostic[] = [];
1986
+ const diagnostics: InspectDiagnostic[] = [...queryTransportDiagnostics];
1671
1987
  const frameworkLayout =
1672
1988
  config.framework === "next" ? convention.nextLayout : convention.webLayout;
1673
1989
  const missingFrameworkLayout =
@@ -1774,6 +2090,7 @@ async function inspectDiagnostics(
1774
2090
  contracts,
1775
2091
  )),
1776
2092
  ...(await inspectServerlessFootguns(targetDir, files, config, convention)),
2093
+ ...(await inspectOutboxJobDispatcherDrift(targetDir, files, config)),
1777
2094
  ...(await inspectProductionReadiness(targetDir, files, config, convention)),
1778
2095
  ...(await inspectFeatureArtifactDrift(
1779
2096
  targetDir,
@@ -2471,14 +2788,14 @@ async function inspectProductionReadiness(
2471
2788
 
2472
2789
  if (
2473
2790
  containsCallExpression(source, "createDevtoolsRoute") &&
2474
- /\benabled\s*:\s*true\b/.test(source) &&
2475
- !/\bauthorize\s*:/.test(source)
2791
+ !/\bauthorize\s*(?::|\()/.test(source) &&
2792
+ !/\ballowUnauthenticatedDevelopmentAccess\s*:\s*true\b/.test(source)
2476
2793
  ) {
2477
2794
  diagnostics.push({
2478
2795
  severity: "warning",
2479
- code: "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
2796
+ code: "BEIGNET_DEVTOOLS_ROUTE_POLICY_MISSING",
2480
2797
  file,
2481
- 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.`,
2798
+ message: `${file} creates a devtools route without an access policy, so the route remains hidden. Add app-owned authorize(...), or explicitly set allowUnauthenticatedDevelopmentAccess: true only for a development server bound exclusively to loopback.`,
2482
2799
  });
2483
2800
  }
2484
2801
 
@@ -2619,6 +2936,27 @@ async function inspectProductionReadiness(
2619
2936
  provider.variants,
2620
2937
  providerEntries,
2621
2938
  )) {
2939
+ const envAlternatives = configuredProviderRequiredEnvAlternatives(
2940
+ variant.requiredEnvAlternatives,
2941
+ config,
2942
+ );
2943
+ if (
2944
+ envAlternatives.length > 0 &&
2945
+ !(await providerRequiredEnvAlternativeExists(
2946
+ targetDir,
2947
+ configFiles,
2948
+ envAlternatives,
2949
+ sourceCache,
2950
+ ))
2951
+ ) {
2952
+ diagnostics.push({
2953
+ severity: "warning",
2954
+ code: "BEIGNET_PROVIDER_ENV_MISSING",
2955
+ file: "package.json",
2956
+ message: `${provider.packageName} is installed and ${variant.displayName} is registered, but none of its required environment configurations are fully mentioned in app env/config files. Configure ${formatProviderEnvAlternatives(envAlternatives)}, or remove the provider registration.`,
2957
+ });
2958
+ }
2959
+
2622
2960
  for (const envVar of configuredProviderRequiredEnv(
2623
2961
  variant.requiredEnv,
2624
2962
  config,
@@ -2669,6 +3007,27 @@ async function inspectProductionReadiness(
2669
3007
  continue;
2670
3008
  }
2671
3009
 
3010
+ const envAlternatives = configuredProviderRequiredEnvAlternatives(
3011
+ provider.requiredEnvAlternatives ?? [],
3012
+ config,
3013
+ );
3014
+ if (
3015
+ envAlternatives.length > 0 &&
3016
+ !(await providerRequiredEnvAlternativeExists(
3017
+ targetDir,
3018
+ configFiles,
3019
+ envAlternatives,
3020
+ sourceCache,
3021
+ ))
3022
+ ) {
3023
+ diagnostics.push({
3024
+ severity: "warning",
3025
+ code: "BEIGNET_PROVIDER_ENV_MISSING",
3026
+ file: "package.json",
3027
+ message: `${provider.packageName} is installed, but none of its required environment configurations are fully mentioned in app env/config files. Configure ${formatProviderEnvAlternatives(envAlternatives)}, or remove the unused provider package.`,
3028
+ });
3029
+ }
3030
+
2672
3031
  for (const envVar of configuredProviderRequiredEnv(
2673
3032
  provider.requiredEnv ?? [],
2674
3033
  config,
@@ -3839,9 +4198,7 @@ async function inspectFeatureArtifactDrift(
3839
4198
  }
3840
4199
 
3841
4200
  for (const file of files) {
3842
- const match = new RegExp(
3843
- `^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.test\\.ts$`,
3844
- ).exec(file);
4201
+ const match = directFeatureTestMatch(file, featuresPath);
3845
4202
  if (!match) continue;
3846
4203
  diagnostics.push({
3847
4204
  severity: "warning",
@@ -3853,23 +4210,27 @@ async function inspectFeatureArtifactDrift(
3853
4210
 
3854
4211
  for (const file of files) {
3855
4212
  if (
3856
- !file.endsWith(".ts") ||
3857
- file.endsWith(".test.ts") ||
3858
- file.endsWith(".d.ts")
4213
+ !/\.(?:ts|tsx|mts|cts)$/.test(file) ||
4214
+ /\.d\.(?:ts|mts|cts)$/.test(file)
3859
4215
  ) {
3860
4216
  continue;
3861
4217
  }
3862
4218
 
3863
- const misplaced = misplacedFeatureArtifactFolder(file, featuresPath);
4219
+ const misplaced = directFeatureTestMatch(file, featuresPath)
4220
+ ? undefined
4221
+ : misplacedFeatureLayer(file, config);
3864
4222
  if (misplaced) {
3865
4223
  diagnostics.push({
3866
4224
  severity: "warning",
3867
4225
  code: "BEIGNET_FEATURE_ARTIFACT_FOLDER",
3868
4226
  file,
3869
- 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.`,
4227
+ message: `${file} uses ${misplaced.actual}, but Beignet expects ${misplaced.expected}. Keep feature-owned files in the canonical feature folders so CLI generators, docs, and architecture lint stay aligned.`,
3870
4228
  });
3871
4229
  }
3872
4230
 
4231
+ if (isTestSourceFile(file)) continue;
4232
+ if (!file.endsWith(".ts")) continue;
4233
+
3873
4234
  if (
3874
4235
  isNotificationDefinitionFile(file, featuresPath) ||
3875
4236
  isInfraOrServerFile(file, config)
@@ -3890,6 +4251,15 @@ async function inspectFeatureArtifactDrift(
3890
4251
  return diagnostics;
3891
4252
  }
3892
4253
 
4254
+ function directFeatureTestMatch(
4255
+ file: string,
4256
+ featuresPath: string,
4257
+ ): RegExpExecArray | null {
4258
+ return new RegExp(
4259
+ `^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.(?:test|spec)\\.(?:ts|tsx|mts|cts)$`,
4260
+ ).exec(file);
4261
+ }
4262
+
3893
4263
  function isFeatureArtifactFile(
3894
4264
  file: string,
3895
4265
  featuresPath: string,
@@ -3922,34 +4292,30 @@ async function appHasUploadRoute(
3922
4292
  return false;
3923
4293
  }
3924
4294
 
3925
- function misplacedFeatureArtifactFolder(
4295
+ function misplacedFeatureLayer(
3926
4296
  file: string,
3927
- featuresPath: string,
4297
+ config: ResolvedBeignetConfig,
3928
4298
  ): { actual: string; expected: string } | undefined {
3929
- const match = new RegExp(
3930
- `^${escapeRegExp(featuresPath)}/([^/]+)/([^/]+)/`,
3931
- ).exec(file);
4299
+ const featuresPath = directoryPath(config.paths.features);
4300
+ const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/(.+)$`).exec(
4301
+ file,
4302
+ );
3932
4303
  if (!match) return undefined;
3933
4304
 
3934
- const folder = match[2];
3935
- const mappings: Record<string, string> = {
3936
- command: "tasks/",
3937
- commands: "tasks/",
3938
- event: "domain/events/",
3939
- events: "domain/events/",
3940
- job: "jobs/",
3941
- listener: "listeners/",
3942
- notification: "notifications/",
3943
- schedule: "schedules/",
3944
- scheduled: "schedules/",
3945
- task: "tasks/",
3946
- upload: "uploads/",
3947
- };
4305
+ const canonicalPath = canonicalFeatureLayerPath(file, config);
4306
+ if (!canonicalPath) return undefined;
3948
4307
 
3949
- const expected = mappings[folder];
3950
- if (!expected) return undefined;
4308
+ const featurePrefix = `${featuresPath}/${match[1]}/`;
4309
+ const actualRelative = file.slice(featurePrefix.length);
4310
+ const expectedRelative = canonicalPath.slice(featurePrefix.length);
4311
+ const actual = actualRelative.includes("/")
4312
+ ? `${actualRelative.split("/")[0]}/`
4313
+ : actualRelative;
4314
+ const expectedDirectory = path.posix.dirname(expectedRelative);
4315
+ const expected =
4316
+ expectedDirectory === "." ? expectedRelative : `${expectedDirectory}/`;
3951
4317
 
3952
- return { actual: `${folder}/`, expected };
4318
+ return { actual, expected };
3953
4319
  }
3954
4320
 
3955
4321
  function isNotificationDefinitionFile(
@@ -4328,10 +4694,7 @@ async function workflowRegistrationDrift(
4328
4694
  // Events without listeners have nothing to deliver on drain, so only
4329
4695
  // record-only events stay out of the registry without a warning.
4330
4696
  const listened = await listenedEventNames(targetDir, files, config);
4331
- const value = outboxRegistryValueInfo(outboxSource, "events");
4332
- const registered = value
4333
- ? identifiersFromArrayExpression(value.text)
4334
- : new Set<string>();
4697
+ const registered = outboxRegistryIdentifiers(outboxSource, "events");
4335
4698
  drift.outbox.events = unregisteredWorkflowRegistries(
4336
4699
  eventRegistries,
4337
4700
  registered,
@@ -4340,10 +4703,7 @@ async function workflowRegistrationDrift(
4340
4703
  }
4341
4704
 
4342
4705
  if (jobRegistries.length > 0) {
4343
- const value = outboxRegistryValueInfo(outboxSource, "jobs");
4344
- const registered = value
4345
- ? identifiersFromArrayExpression(value.text)
4346
- : new Set<string>();
4706
+ const registered = outboxRegistryIdentifiers(outboxSource, "jobs");
4347
4707
  drift.outbox.jobs = unregisteredWorkflowRegistries(
4348
4708
  jobRegistries,
4349
4709
  registered,
@@ -5043,6 +5403,56 @@ async function appHasOutboxDrainEntrypoint(
5043
5403
  return false;
5044
5404
  }
5045
5405
 
5406
+ async function inspectOutboxJobDispatcherDrift(
5407
+ targetDir: string,
5408
+ files: string[],
5409
+ config: ResolvedBeignetConfig,
5410
+ ): Promise<InspectDiagnostic[]> {
5411
+ if (!hasOutboxRegistry(files, config)) return [];
5412
+
5413
+ const source = await readFile(
5414
+ path.join(targetDir, config.paths.outbox),
5415
+ "utf8",
5416
+ );
5417
+ const jobsState = outboxRegistryEntryState(source, "jobs");
5418
+ if (jobsState === "absent" || jobsState === "empty") return [];
5419
+
5420
+ const analysis = await analyzePortWiringFiles({ targetDir, files, config });
5421
+ const declaresJobs = analysis.declared.has("jobs");
5422
+ const wiresJobs = analysis.bound.has("jobs") || analysis.deferred.has("jobs");
5423
+ if (declaresJobs && wiresJobs) return [];
5424
+
5425
+ const file =
5426
+ jobsState === "indeterminate"
5427
+ ? config.paths.outbox
5428
+ : !declaresJobs
5429
+ ? config.paths.ports
5430
+ : config.paths.portWiring;
5431
+ const reason = !analysis.portsFileExists
5432
+ ? `${config.paths.ports} does not exist at the configured path`
5433
+ : !declaresJobs
5434
+ ? analysis.declarationIndeterminate
5435
+ ? `${config.paths.ports} does not expose a statically verifiable jobs declaration`
5436
+ : `${config.paths.ports} does not declare a jobs port`
5437
+ : !analysis.portWiringFileExists
5438
+ ? `${config.paths.portWiring} does not exist at the configured path`
5439
+ : analysis.indeterminate
5440
+ ? `${config.paths.portWiring} does not expose statically verifiable jobs wiring`
5441
+ : `${config.paths.portWiring} does not bind or defer the jobs port`;
5442
+
5443
+ return [
5444
+ {
5445
+ severity: "warning",
5446
+ code: "BEIGNET_OUTBOX_JOB_DISPATCHER_MISSING",
5447
+ file,
5448
+ message:
5449
+ jobsState === "indeterminate"
5450
+ ? `The jobs entries in the outbox registry at ${config.paths.outbox} could not be statically resolved, and ${reason}. Keep the jobs property explicit so doctor can verify it, or add jobs: JobDispatcherPort to AppPorts and bind it directly or list "jobs" as deferred with an inline or provider-backed dispatcher. If the app uses custom paths, configure paths.ports and paths.portWiring in beignet.config.ts.`
5451
+ : `The outbox registry in ${config.paths.outbox} contains jobs, but ${reason}. Add jobs: JobDispatcherPort to AppPorts and bind it directly or list "jobs" as deferred with an inline or provider-backed dispatcher. If the app uses custom paths, configure paths.ports and paths.portWiring in beignet.config.ts.`,
5452
+ },
5453
+ ];
5454
+ }
5455
+
5046
5456
  async function inspectPortProviderDrift(
5047
5457
  targetDir: string,
5048
5458
  files: string[],
@@ -5175,7 +5585,7 @@ async function inspectDatabaseLifecycleDrift(
5175
5585
  }
5176
5586
 
5177
5587
  if (hasDrizzleArtifacts) {
5178
- for (const script of ["db:generate", "db:migrate"] as const) {
5588
+ for (const script of ["db:generate", "db:migrate", "db:status"] as const) {
5179
5589
  if (packageScripts[script]) continue;
5180
5590
  diagnostics.push({
5181
5591
  severity: "warning",
@@ -5257,6 +5667,12 @@ async function inspectDatabaseLifecycleDrift(
5257
5667
  file: `${infrastructurePath}/db/reset.ts`,
5258
5668
  code: "BEIGNET_DB_RESET_ENTRYPOINT_MISSING",
5259
5669
  },
5670
+ {
5671
+ script: "db:status",
5672
+ command: "status",
5673
+ file: `${infrastructurePath}/db/migration-status.ts`,
5674
+ code: "BEIGNET_DB_STATUS_ENTRYPOINT_MISSING",
5675
+ },
5260
5676
  ] as const;
5261
5677
 
5262
5678
  for (const entrypoint of standardEntrypoints) {
@@ -6284,6 +6700,32 @@ function configuredProviderRequiredEnv(
6284
6700
  return envVars.filter((envVar) => !ignored.has(envVar));
6285
6701
  }
6286
6702
 
6703
+ function configuredProviderRequiredEnvAlternatives(
6704
+ alternatives: readonly (readonly string[])[],
6705
+ config: ResolvedBeignetConfig,
6706
+ ): string[][] {
6707
+ return alternatives.map((alternative) =>
6708
+ configuredProviderRequiredEnv(alternative, config),
6709
+ );
6710
+ }
6711
+
6712
+ async function providerRequiredEnvAlternativeExists(
6713
+ targetDir: string,
6714
+ configFiles: readonly string[],
6715
+ alternatives: readonly (readonly string[])[],
6716
+ sourceCache: Map<string, string>,
6717
+ ): Promise<boolean> {
6718
+ for (const alternative of alternatives) {
6719
+ const exists = await Promise.all(
6720
+ alternative.map((envVar) =>
6721
+ providerRequiredEnvExists(targetDir, configFiles, envVar, sourceCache),
6722
+ ),
6723
+ );
6724
+ if (exists.every(Boolean)) return true;
6725
+ }
6726
+ return false;
6727
+ }
6728
+
6287
6729
  function configuredProviderRequiredTable(
6288
6730
  tableName: string,
6289
6731
  config: ResolvedBeignetConfig,
@@ -8309,8 +8751,9 @@ async function contractsFromRoutesSource(
8309
8751
  source,
8310
8752
  routeIdentifier,
8311
8753
  );
8754
+ const { contracts } = await readContracts(targetDir, files, config);
8312
8755
  const registeredContracts = contractsForRegisteredRouteGroups(
8313
- await readContracts(targetDir, files, config),
8756
+ contracts,
8314
8757
  routeGroups,
8315
8758
  registeredGroups,
8316
8759
  );