@beignet/cli 0.0.48 → 0.0.50

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 (111) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +117 -22
  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 +11 -4
  20. package/dist/analysis/workspace.d.ts.map +1 -1
  21. package/dist/analysis/workspace.js +78 -8
  22. package/dist/analysis/workspace.js.map +1 -1
  23. package/dist/app-map-changes.d.ts +103 -0
  24. package/dist/app-map-changes.d.ts.map +1 -0
  25. package/dist/app-map-changes.js +949 -0
  26. package/dist/app-map-changes.js.map +1 -0
  27. package/dist/app-map.d.ts.map +1 -1
  28. package/dist/app-map.js +311 -92
  29. package/dist/app-map.js.map +1 -1
  30. package/dist/check.js +37 -3
  31. package/dist/check.js.map +1 -1
  32. package/dist/explain.js +4 -4
  33. package/dist/explain.js.map +1 -1
  34. package/dist/git-changes.d.ts +30 -0
  35. package/dist/git-changes.d.ts.map +1 -0
  36. package/dist/git-changes.js +367 -0
  37. package/dist/git-changes.js.map +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +26 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/inspect.d.ts.map +1 -1
  42. package/dist/inspect.js +706 -186
  43. package/dist/inspect.js.map +1 -1
  44. package/dist/lib.d.ts +3 -0
  45. package/dist/lib.d.ts.map +1 -1
  46. package/dist/lib.js +1 -0
  47. package/dist/lib.js.map +1 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +309 -487
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make/inbox.d.ts.map +1 -1
  52. package/dist/make/inbox.js +11 -4
  53. package/dist/make/inbox.js.map +1 -1
  54. package/dist/make/payments.js +2 -2
  55. package/dist/make/shared.d.ts.map +1 -1
  56. package/dist/make/shared.js +31 -27
  57. package/dist/make/shared.js.map +1 -1
  58. package/dist/make.d.ts.map +1 -1
  59. package/dist/make.js +144 -8
  60. package/dist/make.js.map +1 -1
  61. package/dist/mcp.d.ts +1 -1
  62. package/dist/mcp.d.ts.map +1 -1
  63. package/dist/mcp.js +43 -21
  64. package/dist/mcp.js.map +1 -1
  65. package/dist/provider-add.d.ts.map +1 -1
  66. package/dist/provider-add.js +142 -7
  67. package/dist/provider-add.js.map +1 -1
  68. package/dist/registry-edits.d.ts +7 -0
  69. package/dist/registry-edits.d.ts.map +1 -1
  70. package/dist/registry-edits.js +237 -53
  71. package/dist/registry-edits.js.map +1 -1
  72. package/dist/templates/agents.d.ts.map +1 -1
  73. package/dist/templates/agents.js +37 -1
  74. package/dist/templates/agents.js.map +1 -1
  75. package/dist/templates/base.d.ts.map +1 -1
  76. package/dist/templates/base.js +13 -2
  77. package/dist/templates/base.js.map +1 -1
  78. package/dist/templates/server.d.ts.map +1 -1
  79. package/dist/templates/server.js +2 -0
  80. package/dist/templates/server.js.map +1 -1
  81. package/dist/templates/shared.d.ts +1 -0
  82. package/dist/templates/shared.d.ts.map +1 -1
  83. package/dist/templates/shared.js +3 -2
  84. package/dist/templates/shared.js.map +1 -1
  85. package/package.json +7 -6
  86. package/skills/app-structure/SKILL.md +71 -7
  87. package/src/analysis/contract-factories.ts +256 -0
  88. package/src/analysis/layers.ts +266 -0
  89. package/src/analysis/port-wiring.ts +506 -0
  90. package/src/analysis/source-index.ts +225 -57
  91. package/src/analysis/workspace.ts +134 -9
  92. package/src/app-map-changes.ts +1462 -0
  93. package/src/app-map.ts +418 -109
  94. package/src/check.ts +51 -3
  95. package/src/explain.ts +4 -4
  96. package/src/git-changes.ts +511 -0
  97. package/src/index.ts +39 -1
  98. package/src/inspect.ts +1005 -238
  99. package/src/lib.ts +21 -0
  100. package/src/lint.ts +393 -643
  101. package/src/make/inbox.ts +11 -4
  102. package/src/make/payments.ts +2 -2
  103. package/src/make/shared.ts +31 -27
  104. package/src/make.ts +225 -8
  105. package/src/mcp.ts +51 -24
  106. package/src/provider-add.ts +186 -7
  107. package/src/registry-edits.ts +313 -63
  108. package/src/templates/agents.ts +37 -1
  109. package/src/templates/base.ts +13 -2
  110. package/src/templates/server.ts +2 -0
  111. package/src/templates/shared.ts +3 -2
package/dist/inspect.js CHANGED
@@ -1,6 +1,11 @@
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 { contractFactoryBindings, isImportedContractFactoryCall, } from "./analysis/contract-factories.js";
6
+ import { canonicalFeatureLayerPath } from "./analysis/layers.js";
7
+ import { analyzePortWiringFiles } from "./analysis/port-wiring.js";
8
+ import { exportedVariableDeclarations } from "./analysis/source-index.js";
4
9
  import { createPainter } from "./ansi.js";
5
10
  import { clientDirPath, clientFormsPath, clientIndexPath, defaultBeignetConfig, directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
6
11
  import { renderDatabaseSchema, resolveDatabaseSchemaDialect } from "./db.js";
@@ -8,7 +13,7 @@ import { applyPlannedDoctorFixes, createDoctorFixPlan, planDoctorFixFileChange,
8
13
  import { formatGithubAnnotation, } from "./github-annotations.js";
9
14
  import { createListenersRegistrySource, listenersFilePath, providersFilePath, updateListenersRegistrySource, wireListenersProviderSource, } from "./make/shared.js";
10
15
  import { detectedProviderVariants, diagnosticPackageJsonFile, formatProviderEnvAlternatives, formatProviderMetadataIssues, installedPackageNames, providerDoctorRulesForInstalledPackages, providerOperationalConfigFiles, providerRequiredEnvExists, providerRequiredTableExists, providerVariantRegistrationSeverity, readProviderListEntries, readProviderPackageMetadataSource, registrationTokenHint, serverProviderFiles, } from "./provider-audit.js";
11
- import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, codeCharacterIndexes, identifiersFromArrayExpression, insertAfterImports, matchingDelimiterIndex, outboxRegistryValueInfo, } from "./registry-edits.js";
16
+ import { appendToArrayExpression, appendToNamedArray, appendToOutboxRegistryArray, arrayInitializerInfo, codeCharacterIndexes, identifiersFromArrayExpression, insertAfterImports, matchingDelimiterIndex, outboxRegistryEntryState, outboxRegistryIdentifiers, } from "./registry-edits.js";
12
17
  import { currentGeneratedPackageScripts, externalVersions, isLegacyGeneratedPackageScript, legacyGeneratedPackageScripts, } from "./templates/shared.js";
13
18
  import { testSupportTemplateFiles } from "./templates/testing.js";
14
19
  import { getCliVersion } from "./version.js";
@@ -20,9 +25,10 @@ export async function inspectApp(options = {}) {
20
25
  ? resolveConfig(options.config)
21
26
  : await loadBeignetConfig(targetDir, files);
22
27
  const convention = inspectConvention(files, config);
23
- const contracts = await readContracts(targetDir, files, config);
28
+ const inspectedContracts = await readContracts(targetDir, files, config);
29
+ const { contracts } = inspectedContracts;
24
30
  const matchedRoutes = await inspectRouteSurface(targetDir, files, config, contracts);
25
- const diagnostics = await inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, Boolean(options.strict));
31
+ const diagnostics = await inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, Boolean(options.strict), inspectedContracts.queryTransportDiagnostics);
26
32
  return {
27
33
  schemaVersion: 1,
28
34
  targetDir,
@@ -76,7 +82,7 @@ async function buildDoctorFixPlan(options = {}) {
76
82
  ? resolveConfig(options.config)
77
83
  : await loadBeignetConfig(targetDir, files);
78
84
  const convention = inspectConvention(files, config);
79
- const contracts = await readContracts(targetDir, files, config);
85
+ const { contracts } = await readContracts(targetDir, files, config);
80
86
  const matchedRoutes = await inspectRouteSurface(targetDir, files, config, contracts);
81
87
  const operations = [];
82
88
  const packageFix = await planPackageScripts(targetDir, files, convention);
@@ -318,7 +324,6 @@ export const productionHardeningDiagnosticCodes = new Set([
318
324
  "BEIGNET_BILLING_IDEMPOTENCY_PORT_MISSING",
319
325
  "BEIGNET_CORS_CREDENTIALS_WILDCARD",
320
326
  "BEIGNET_CRON_SECRET_MISSING",
321
- "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
322
327
  "BEIGNET_ENTITLEMENTS_PORT_MISSING",
323
328
  "BEIGNET_PAYMENTS_STRIPE_MEMORY_PROVIDER",
324
329
  "BEIGNET_PAYMENTS_WEBHOOK_ROUTE_MISSING",
@@ -409,12 +414,28 @@ async function listFiles(targetDir) {
409
414
  }
410
415
  async function readContracts(targetDir, files, config) {
411
416
  const contracts = [];
417
+ const queryTransportDiagnostics = [];
412
418
  const contractFiles = contractSourceFiles(files, config);
413
419
  for (const file of contractFiles) {
414
420
  const source = await readFile(path.join(targetDir, file), "utf8");
415
- contracts.push(...parseContracts(source, file));
421
+ for (const parsed of parseContracts(source, file)) {
422
+ contracts.push(parsed.contract);
423
+ if (!parsed.queryTransportMissing)
424
+ continue;
425
+ queryTransportDiagnostics.push({
426
+ severity: "error",
427
+ code: "BEIGNET_QUERY_TRANSPORT_MISSING",
428
+ file,
429
+ contract: parsed.contract.exportName,
430
+ subject: { file, exportName: parsed.contract.exportName },
431
+ 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.`,
432
+ });
433
+ }
416
434
  }
417
- return contracts.sort(compareContracts);
435
+ return {
436
+ contracts: contracts.sort(compareContracts),
437
+ queryTransportDiagnostics,
438
+ };
418
439
  }
419
440
  function inspectConvention(files, config) {
420
441
  const missingNextLayout = missingRequirements(files, [
@@ -464,47 +485,77 @@ function hasRequirement(files, requirement) {
464
485
  }
465
486
  function parseContracts(source, file) {
466
487
  const contracts = [];
467
- const groupPrefixes = parseContractGroupPrefixes(source);
468
- const groupErrorRefs = parseContractGroupErrorRefs(source);
469
- const exportRegex = /(?:^|\n)export const\s+([A-Za-z_$][\w$]*)\s*=([\s\S]*?)(?=\nexport const\s+[A-Za-z_$][\w$]*\s*=|$)/g;
470
- for (const exportMatch of source.matchAll(exportRegex)) {
471
- const block = exportMatch[2];
472
- const methodMatch = /([A-Za-z_$][\w$]*)(?:\s*\.\s*[A-Za-z_$][\w$]*\([^)]*\))*\s*\.\s*(get|post|put|patch|delete|head|options)\(\s*["']([^"']+)["'](?:\s*,\s*["'][^"']+["'])?\s*\)/.exec(block);
473
- if (methodMatch) {
474
- const receiver = methodMatch[1];
475
- const pathPrefix = groupPrefixes.get(receiver) ?? "";
476
- const routePath = joinContractPaths(pathPrefix, methodMatch[3]);
477
- contracts.push({
478
- exportName: exportMatch[1],
479
- file,
480
- metadata: parseContractMetadata(block, groupErrorRefs.get(receiver) ?? []),
481
- method: methodMatch[2].toUpperCase(),
482
- path: routePath,
483
- });
488
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
489
+ const assignments = variableAssignments(sourceFile);
490
+ const assignmentsByName = new Map(assignments.map((assignment) => [assignment.name, assignment.expression]));
491
+ const factoryBindings = contractFactoryBindings(sourceFile);
492
+ for (const { exportName, declaration } of exportedVariableDeclarations(sourceFile)) {
493
+ if (!declaration.initializer)
484
494
  continue;
485
- }
486
- const directContract = parseDirectContract(block);
495
+ const chain = resolveContractCallChain(declaration.initializer, assignmentsByName);
496
+ if (!chain)
497
+ continue;
498
+ const directContract = parseDirectContract(chain, factoryBindings);
487
499
  if (directContract) {
488
500
  contracts.push({
489
- exportName: exportMatch[1],
490
- file,
491
- metadata: parseContractMetadata(block),
492
- method: directContract.method,
493
- path: normalizeContractPath(directContract.path),
501
+ contract: {
502
+ exportName,
503
+ file,
504
+ metadata: parseContractMetadata(applyContractMetadataCalls(emptyContractMetadataState(), chain.calls)),
505
+ method: directContract.method,
506
+ path: normalizeContractPath(directContract.path),
507
+ },
508
+ queryTransportMissing: contractQueryHasTransport(chain) === false,
494
509
  });
510
+ continue;
495
511
  }
512
+ const methodIndex = chain.calls.findIndex((call) => isHttpMethod(call.name.toUpperCase()));
513
+ if (methodIndex === -1)
514
+ continue;
515
+ const methodCall = chain.calls[methodIndex];
516
+ const routePath = staticString(methodCall.arguments[0]);
517
+ if (!routePath)
518
+ continue;
519
+ const hasGroupFactory = contractFactoryCall(chain, "defineContractGroup", factoryBindings);
520
+ if (!hasGroupFactory)
521
+ continue;
522
+ const pathPrefix = applyStaticPrefixes("", chain.calls.slice(0, methodIndex));
523
+ if (pathPrefix === undefined)
524
+ continue;
525
+ contracts.push({
526
+ contract: {
527
+ exportName,
528
+ file,
529
+ metadata: parseContractMetadata(applyContractMetadataCalls(emptyContractMetadataState(), chain.calls)),
530
+ method: methodCall.name.toUpperCase(),
531
+ path: joinContractPaths(pathPrefix, routePath),
532
+ },
533
+ queryTransportMissing: contractQueryHasTransport(chain) === false,
534
+ });
496
535
  }
497
536
  return contracts;
498
537
  }
499
- function parseContractMetadata(block, inheritedCatalogErrors = []) {
500
- const authRequired = /\bauth\s*:\s*["']required["']/.test(block);
501
- const authorizationAbility = /\bauthorization\s*:\s*\{[\s\S]*?\bability\s*:\s*["']([^"']+)["']/.exec(block)?.[1];
502
- const auditRequired = /\baudit\s*:\s*["']required["']/.test(block) ||
503
- /\bauditRequired\s*:\s*true\b/.test(block);
504
- const catalogErrors = dedupeCatalogErrorRefs([
505
- ...inheritedCatalogErrors,
506
- ...parseCatalogErrorRefs(block),
507
- ]);
538
+ function unwrapContractExpression(expression) {
539
+ let current = expression;
540
+ while (ts.isParenthesizedExpression(current) ||
541
+ ts.isAsExpression(current) ||
542
+ ts.isTypeAssertionExpression(current) ||
543
+ ts.isNonNullExpression(current) ||
544
+ ts.isSatisfiesExpression(current)) {
545
+ current = current.expression;
546
+ }
547
+ return current;
548
+ }
549
+ function emptyContractMetadataState() {
550
+ return { catalogErrors: [] };
551
+ }
552
+ function parseContractMetadata(state) {
553
+ const authRequired = state.authRequired === true;
554
+ const authorizationAbility = state.authorizationAbility;
555
+ const auditRequired = state.audit === true ||
556
+ state.auditRequired === true ||
557
+ state.authorizationAuditRequired === true;
558
+ const catalogErrors = state.catalogErrors;
508
559
  if (!authRequired &&
509
560
  !authorizationAbility &&
510
561
  !auditRequired &&
@@ -518,100 +569,271 @@ function parseContractMetadata(block, inheritedCatalogErrors = []) {
518
569
  catalogErrors,
519
570
  };
520
571
  }
521
- function parseDirectContract(block) {
522
- const configMatch = /defineContract\s*\(\s*\{([\s\S]*?)\}\s*\)/.exec(block);
523
- if (!configMatch)
572
+ function applyContractMetadataCalls(initialState, calls) {
573
+ const state = {
574
+ ...initialState,
575
+ catalogErrors: [...initialState.catalogErrors],
576
+ };
577
+ for (const call of calls) {
578
+ if (call.name === "meta") {
579
+ applyContractMetaObject(state, objectLiteral(call.arguments[0]));
580
+ continue;
581
+ }
582
+ if (call.name === "errors") {
583
+ state.catalogErrors = applyCatalogErrorCall(state.catalogErrors, call.arguments[0]);
584
+ }
585
+ }
586
+ return state;
587
+ }
588
+ function applyContractMetaObject(state, metadata) {
589
+ if (!metadata) {
590
+ state.authRequired = undefined;
591
+ state.authorizationAbility = undefined;
592
+ state.authorizationAuditRequired = undefined;
593
+ state.audit = undefined;
594
+ state.auditRequired = undefined;
595
+ return;
596
+ }
597
+ const auth = objectPropertyResult(metadata, "auth");
598
+ if (auth.kind !== "absent") {
599
+ state.authRequired =
600
+ auth.kind === "value" ? staticRequiredValue(auth.value) : undefined;
601
+ }
602
+ const authorization = objectPropertyResult(metadata, "authorization");
603
+ if (authorization.kind !== "absent") {
604
+ const value = authorization.kind === "value"
605
+ ? objectLiteral(authorization.value)
606
+ : undefined;
607
+ if (!value) {
608
+ state.authorizationAbility = undefined;
609
+ state.authorizationAuditRequired = undefined;
610
+ }
611
+ else {
612
+ const ability = objectPropertyResult(value, "ability");
613
+ state.authorizationAbility =
614
+ ability.kind === "value" ? staticString(ability.value) : undefined;
615
+ state.authorizationAuditRequired = objectRequiresAudit(value);
616
+ }
617
+ }
618
+ const audit = objectPropertyResult(metadata, "audit");
619
+ const auditRequired = objectPropertyResult(metadata, "auditRequired");
620
+ if (audit.kind !== "absent") {
621
+ state.audit =
622
+ audit.kind === "value" ? staticRequiredValue(audit.value) : undefined;
623
+ }
624
+ if (auditRequired.kind !== "absent") {
625
+ state.auditRequired =
626
+ auditRequired.kind === "value"
627
+ ? staticBoolean(auditRequired.value)
628
+ : undefined;
629
+ }
630
+ }
631
+ function objectRequiresAudit(metadata) {
632
+ const audit = objectPropertyResult(metadata, "audit");
633
+ const auditRequired = objectPropertyResult(metadata, "auditRequired");
634
+ if (audit.kind === "unknown" || auditRequired.kind === "unknown") {
524
635
  return undefined;
525
- const configSource = configMatch[1];
526
- const methodMatch = /method\s*:\s*["']([^"']+)["']/.exec(configSource);
527
- const pathMatch = /path\s*:\s*["']([^"']+)["']/.exec(configSource);
528
- if (!methodMatch || !pathMatch)
636
+ }
637
+ const auditValue = audit.kind === "value" ? staticRequiredValue(audit.value) : false;
638
+ const auditRequiredValue = auditRequired.kind === "value" ? staticBoolean(auditRequired.value) : false;
639
+ if (auditValue === undefined || auditRequiredValue === undefined) {
529
640
  return undefined;
530
- const method = methodMatch[1].toUpperCase();
641
+ }
642
+ return auditValue || auditRequiredValue;
643
+ }
644
+ function staticRequiredValue(expression) {
645
+ const value = staticString(expression);
646
+ return value === undefined ? undefined : value === "required";
647
+ }
648
+ function staticBoolean(expression) {
649
+ if (expression.kind === ts.SyntaxKind.TrueKeyword)
650
+ return true;
651
+ if (expression.kind === ts.SyntaxKind.FalseKeyword)
652
+ return false;
653
+ return undefined;
654
+ }
655
+ function parseDirectContract(chain, factoryBindings) {
656
+ const factoryCall = contractFactoryCall(chain, "defineContract", factoryBindings);
657
+ if (!factoryCall)
658
+ return undefined;
659
+ const options = objectLiteral(factoryCall.arguments[0]);
660
+ if (!options)
661
+ return undefined;
662
+ const methodValue = staticString(objectProperty(options, "method"));
663
+ const contractPath = staticString(objectProperty(options, "path"));
664
+ if (!methodValue || !contractPath)
665
+ return undefined;
666
+ const method = methodValue.toUpperCase();
531
667
  if (!isHttpMethod(method))
532
668
  return undefined;
533
- return { method, path: pathMatch[1] };
669
+ return { method, path: contractPath };
534
670
  }
535
671
  function isHttpMethod(method) {
536
672
  return ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(method);
537
673
  }
538
- function parseContractGroupPrefixes(source) {
539
- const assignments = [
540
- ...source.matchAll(/(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g),
541
- ].map((match) => ({
542
- name: match[1],
543
- expression: match[2],
544
- }));
545
- const prefixes = new Map();
546
- let changed = true;
547
- while (changed) {
548
- changed = false;
549
- for (const assignment of assignments) {
550
- if (prefixes.has(assignment.name))
551
- continue;
552
- const basePrefix = baseGroupPrefix(assignment.expression, prefixes);
553
- if (basePrefix === undefined)
674
+ function variableAssignments(sourceFile) {
675
+ return sourceFile.statements
676
+ .filter(ts.isVariableStatement)
677
+ .flatMap((statement) => [...statement.declarationList.declarations]
678
+ .filter((declaration) => ts.isIdentifier(declaration.name) &&
679
+ declaration.initializer !== undefined)
680
+ .map((declaration) => ({
681
+ name: declaration.name.text,
682
+ expression: declaration.initializer,
683
+ })));
684
+ }
685
+ function resolveContractCallChain(expression, assignments, resolving = new Set()) {
686
+ const chain = contractCallChain(expression);
687
+ if (!chain || chain.rootCall || !chain.baseIdentifier)
688
+ return chain;
689
+ const baseExpression = assignments.get(chain.baseIdentifier);
690
+ if (!baseExpression || resolving.has(chain.baseIdentifier))
691
+ return chain;
692
+ const nextResolving = new Set(resolving);
693
+ nextResolving.add(chain.baseIdentifier);
694
+ const baseChain = resolveContractCallChain(baseExpression, assignments, nextResolving);
695
+ if (!baseChain)
696
+ return chain;
697
+ return {
698
+ ...baseChain,
699
+ calls: [...baseChain.calls, ...chain.calls],
700
+ };
701
+ }
702
+ function contractCallChain(rawExpression) {
703
+ const calls = [];
704
+ let expression = rawExpression;
705
+ while (true) {
706
+ expression = unwrapContractExpression(expression);
707
+ if (ts.isPropertyAccessExpression(expression)) {
708
+ expression = expression.expression;
709
+ continue;
710
+ }
711
+ if (ts.isCallExpression(expression)) {
712
+ const callee = unwrapContractExpression(expression.expression);
713
+ if (ts.isPropertyAccessExpression(callee)) {
714
+ calls.unshift({
715
+ name: callee.name.text,
716
+ arguments: [...expression.arguments],
717
+ node: expression,
718
+ });
719
+ expression = callee.expression;
554
720
  continue;
555
- const prefix = prefixCalls(assignment.expression).reduce((current, next) => joinContractPaths(current, next), basePrefix);
556
- prefixes.set(assignment.name, prefix);
557
- changed = true;
721
+ }
722
+ if (ts.isIdentifier(callee)) {
723
+ return {
724
+ baseIdentifier: callee.text,
725
+ rootCall: {
726
+ name: callee.text,
727
+ arguments: [...expression.arguments],
728
+ node: expression,
729
+ },
730
+ calls,
731
+ };
732
+ }
733
+ return undefined;
734
+ }
735
+ if (ts.isIdentifier(expression)) {
736
+ return { baseIdentifier: expression.text, calls };
558
737
  }
738
+ return undefined;
559
739
  }
560
- return prefixes;
561
740
  }
562
- function parseContractGroupErrorRefs(source) {
563
- const assignments = [
564
- ...source.matchAll(/(?:^|\n)(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]*?);/g),
565
- ].map((match) => ({
566
- name: match[1],
567
- expression: match[2],
568
- }));
569
- const groupErrors = new Map();
570
- let changed = true;
571
- while (changed) {
572
- changed = false;
573
- for (const assignment of assignments) {
574
- if (groupErrors.has(assignment.name))
575
- continue;
576
- const baseErrors = baseGroupErrors(assignment.expression, groupErrors);
577
- if (baseErrors === undefined)
578
- continue;
579
- groupErrors.set(assignment.name, dedupeCatalogErrorRefs([
580
- ...baseErrors,
581
- ...parseCatalogErrorRefs(assignment.expression),
582
- ]));
583
- changed = true;
584
- }
741
+ function contractFactoryCall(chain, name, factoryBindings) {
742
+ const calls = chain.rootCall ? [chain.rootCall] : chain.calls;
743
+ return calls.find((call) => isImportedContractFactoryCall(call.node, name, factoryBindings));
744
+ }
745
+ function applyStaticPrefixes(initialPrefix, calls) {
746
+ let prefix = initialPrefix;
747
+ for (const call of calls) {
748
+ if (call.name !== "prefix")
749
+ continue;
750
+ const next = staticString(call.arguments[0]);
751
+ if (next === undefined)
752
+ return undefined;
753
+ prefix = joinContractPaths(prefix, next);
585
754
  }
586
- return groupErrors;
755
+ return prefix;
756
+ }
757
+ function contractQueryHasTransport(chain) {
758
+ const queryCall = chain.calls.find((call) => call.name === "query");
759
+ return queryCall ? queryCall.arguments.length >= 2 : undefined;
587
760
  }
588
- function baseGroupErrors(expression, groupErrors) {
589
- if (expression.includes("defineContractGroup("))
761
+ function applyCatalogErrorCall(current, expression) {
762
+ const errors = objectLiteral(expression);
763
+ if (!errors)
590
764
  return [];
591
- const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
592
- if (!baseMatch)
593
- return undefined;
594
- return groupErrors.get(baseMatch[1]);
765
+ let merged = [...current];
766
+ for (const property of errors.properties) {
767
+ if (ts.isSpreadAssignment(property)) {
768
+ merged = [];
769
+ continue;
770
+ }
771
+ const key = staticPropertyName(property.name);
772
+ if (!key) {
773
+ merged = [];
774
+ continue;
775
+ }
776
+ merged = merged.filter((ref) => ref.key !== key);
777
+ if (!ts.isPropertyAssignment(property))
778
+ continue;
779
+ const value = unwrapContractExpression(property.initializer);
780
+ if (!ts.isPropertyAccessExpression(value) ||
781
+ !ts.isIdentifier(value.expression)) {
782
+ continue;
783
+ }
784
+ merged.push({
785
+ key,
786
+ catalog: value.expression.text,
787
+ catalogKey: value.name.text,
788
+ });
789
+ }
790
+ return merged;
595
791
  }
596
- function baseGroupPrefix(expression, prefixes) {
597
- if (expression.includes("defineContractGroup("))
598
- return "";
599
- const baseMatch = /^\s*([A-Za-z_$][\w$]*)\b/.exec(expression);
600
- if (!baseMatch)
792
+ function objectLiteral(expression) {
793
+ if (!expression)
601
794
  return undefined;
602
- return prefixes.get(baseMatch[1]);
603
- }
604
- function prefixCalls(expression) {
605
- return [...expression.matchAll(/\.prefix\(\s*["']([^"']+)["']\s*\)/g)].map((match) => match[1]);
795
+ const unwrapped = unwrapContractExpression(expression);
796
+ return ts.isObjectLiteralExpression(unwrapped) ? unwrapped : undefined;
797
+ }
798
+ function objectProperty(object, name) {
799
+ const result = objectPropertyResult(object, name);
800
+ return result.kind === "value" ? result.value : undefined;
801
+ }
802
+ function objectPropertyResult(object, name) {
803
+ for (const property of [...object.properties].reverse()) {
804
+ if (ts.isSpreadAssignment(property))
805
+ return { kind: "unknown" };
806
+ const propertyName = staticPropertyName(property.name);
807
+ if (propertyName === undefined)
808
+ return { kind: "unknown" };
809
+ if (propertyName !== name)
810
+ continue;
811
+ if (ts.isPropertyAssignment(property)) {
812
+ return {
813
+ kind: "value",
814
+ value: unwrapContractExpression(property.initializer),
815
+ };
816
+ }
817
+ return { kind: "unknown" };
818
+ }
819
+ return { kind: "absent" };
820
+ }
821
+ function staticPropertyName(name) {
822
+ if (ts.isIdentifier(name) ||
823
+ ts.isStringLiteral(name) ||
824
+ ts.isNumericLiteral(name)) {
825
+ return name.text;
826
+ }
827
+ return undefined;
606
828
  }
607
- function parseCatalogErrorRefs(source) {
608
- return dedupeCatalogErrorRefs(objectArgumentBodies(source, /\.errors\s*\(/g).flatMap((body) => [...body.matchAll(errorCatalogRefRegex)].map((match) => ({
609
- key: match[1],
610
- catalog: match[2],
611
- catalogKey: match[3],
612
- }))));
829
+ function staticString(expression) {
830
+ if (!expression)
831
+ return undefined;
832
+ const value = unwrapContractExpression(expression);
833
+ return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
834
+ ? value.text
835
+ : undefined;
613
836
  }
614
- const errorCatalogRefRegex = /(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)/g;
615
837
  function parseErrorCatalogKeys(source) {
616
838
  const keys = new Set();
617
839
  const catalogBodies = objectArgumentBodies(source, /\bdefineErrors\s*\(/g);
@@ -691,18 +913,6 @@ function topLevelObjectKeys(body) {
691
913
  }
692
914
  return keys;
693
915
  }
694
- function dedupeCatalogErrorRefs(refs) {
695
- const seen = new Set();
696
- const deduped = [];
697
- for (const ref of refs) {
698
- const key = `${ref.key}:${ref.catalog}:${ref.catalogKey}`;
699
- if (seen.has(key))
700
- continue;
701
- seen.add(key);
702
- deduped.push(ref);
703
- }
704
- return deduped;
705
- }
706
916
  function joinContractPaths(prefix, routePath) {
707
917
  if (!prefix)
708
918
  return normalizeContractPath(routePath);
@@ -985,8 +1195,8 @@ function contractPathMatchesCatchAll(contractPath, catchAllPrefix) {
985
1195
  return (contractPath === catchAllPrefix ||
986
1196
  contractPath.startsWith(`${catchAllPrefix}/`));
987
1197
  }
988
- async function inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, strict) {
989
- const diagnostics = [];
1198
+ async function inspectDiagnostics(targetDir, files, config, convention, contracts, matchedRoutes, strict, queryTransportDiagnostics) {
1199
+ const diagnostics = [...queryTransportDiagnostics];
990
1200
  const frameworkLayout = config.framework === "next" ? convention.nextLayout : convention.webLayout;
991
1201
  const missingFrameworkLayout = config.framework === "next"
992
1202
  ? convention.missingNextLayout
@@ -1025,7 +1235,7 @@ async function inspectDiagnostics(targetDir, files, config, convention, contract
1025
1235
  diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
1026
1236
  }
1027
1237
  }
1028
- diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectInngestProductionPath(targetDir, files, config)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files, config, convention)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectWorkflowRegistrationDrift(targetDir, files, config, convention)), ...(await inspectRuntimeManifestDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
1238
+ diagnostics.push(...(await inspectPackageScripts(targetDir, files, convention)), ...(await inspectOpenApiDrift(targetDir, files, config, convention, contracts, matchedRoutes)), ...(await inspectFeatureRouteRegistration(targetDir, files, config, convention, contracts)), ...(await inspectRouteErrorCatalogDrift(targetDir, files, config, convention, contracts, strict)), ...(await inspectCanonicalConformance(targetDir, files, config, convention, strict)), ...(await inspectVersionSkew(targetDir, files)), ...(await inspectProviderMetadataDrift(targetDir, files, convention)), ...(await inspectPortProviderDrift(targetDir, files, config, convention)), ...(await inspectProviderRegistrationDrift(targetDir, files, config, convention)), ...(await inspectInngestProductionPath(targetDir, files, config)), ...(await inspectDatabaseLifecycleDrift(targetDir, files, config, convention, strict)), ...(await inspectAuthzAuditDrift(targetDir, files, config, convention, contracts)), ...(await inspectServerlessFootguns(targetDir, files, config, convention)), ...(await inspectOutboxJobDispatcherDrift(targetDir, files, config)), ...(await inspectProductionReadiness(targetDir, files, config, convention)), ...(await inspectFeatureArtifactDrift(targetDir, files, config, convention)), ...(await inspectWorkflowRegistrationDrift(targetDir, files, config, convention)), ...(await inspectRuntimeManifestDrift(targetDir, files, config, convention)), ...(await inspectResourceSlices(targetDir, files, config, convention, strict)));
1029
1239
  return dedupeDiagnostics(diagnostics);
1030
1240
  }
1031
1241
  async function inspectFrameworkAdapter(targetDir, files, config) {
@@ -1485,13 +1695,13 @@ async function inspectProductionReadiness(targetDir, files, config, convention)
1485
1695
  hasSecurityHeadersHook = true;
1486
1696
  }
1487
1697
  if (containsCallExpression(source, "createDevtoolsRoute") &&
1488
- /\benabled\s*:\s*true\b/.test(source) &&
1489
- !/\bauthorize\s*:/.test(source)) {
1698
+ !/\bauthorize\s*(?::|\()/.test(source) &&
1699
+ !/\ballowUnauthenticatedDevelopmentAccess\s*:\s*true\b/.test(source)) {
1490
1700
  diagnostics.push({
1491
1701
  severity: "warning",
1492
- code: "BEIGNET_DEVTOOLS_ROUTE_EXPOSED",
1702
+ code: "BEIGNET_DEVTOOLS_ROUTE_POLICY_MISSING",
1493
1703
  file,
1494
- 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.`,
1704
+ 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.`,
1495
1705
  });
1496
1706
  }
1497
1707
  if (fileWithinDirectory(file, cronDirectory) &&
@@ -2316,7 +2526,7 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
2316
2526
  });
2317
2527
  }
2318
2528
  for (const file of files) {
2319
- const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.test\\.ts$`).exec(file);
2529
+ const match = directFeatureTestMatch(file, featuresPath);
2320
2530
  if (!match)
2321
2531
  continue;
2322
2532
  diagnostics.push({
@@ -2327,20 +2537,25 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
2327
2537
  });
2328
2538
  }
2329
2539
  for (const file of files) {
2330
- if (!file.endsWith(".ts") ||
2331
- file.endsWith(".test.ts") ||
2332
- file.endsWith(".d.ts")) {
2540
+ if (!/\.(?:ts|tsx|mts|cts)$/.test(file) ||
2541
+ /\.d\.(?:ts|mts|cts)$/.test(file)) {
2333
2542
  continue;
2334
2543
  }
2335
- const misplaced = misplacedFeatureArtifactFolder(file, featuresPath);
2544
+ const misplaced = directFeatureTestMatch(file, featuresPath)
2545
+ ? undefined
2546
+ : misplacedFeatureLayer(file, config);
2336
2547
  if (misplaced) {
2337
2548
  diagnostics.push({
2338
2549
  severity: "warning",
2339
2550
  code: "BEIGNET_FEATURE_ARTIFACT_FOLDER",
2340
2551
  file,
2341
- 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.`,
2552
+ 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.`,
2342
2553
  });
2343
2554
  }
2555
+ if (isTestSourceFile(file))
2556
+ continue;
2557
+ if (!file.endsWith(".ts"))
2558
+ continue;
2344
2559
  if (isNotificationDefinitionFile(file, featuresPath) ||
2345
2560
  isInfraOrServerFile(file, config)) {
2346
2561
  continue;
@@ -2357,6 +2572,9 @@ async function inspectFeatureArtifactDrift(targetDir, files, config, convention)
2357
2572
  }
2358
2573
  return diagnostics;
2359
2574
  }
2575
+ function directFeatureTestMatch(file, featuresPath) {
2576
+ return new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/[^/]+\\.(?:test|spec)\\.(?:ts|tsx|mts|cts)$`).exec(file);
2577
+ }
2360
2578
  function isFeatureArtifactFile(file, featuresPath, folder) {
2361
2579
  const match = new RegExp(`^${escapeRegExp(featuresPath)}/[^/]+/${folder}/([^/]+)\\.ts$`).exec(file);
2362
2580
  return Boolean(match && match[1] !== "index");
@@ -2375,28 +2593,23 @@ async function appHasUploadRoute(targetDir, files, config) {
2375
2593
  }
2376
2594
  return false;
2377
2595
  }
2378
- function misplacedFeatureArtifactFolder(file, featuresPath) {
2379
- const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/([^/]+)/`).exec(file);
2596
+ function misplacedFeatureLayer(file, config) {
2597
+ const featuresPath = directoryPath(config.paths.features);
2598
+ const match = new RegExp(`^${escapeRegExp(featuresPath)}/([^/]+)/(.+)$`).exec(file);
2380
2599
  if (!match)
2381
2600
  return undefined;
2382
- const folder = match[2];
2383
- const mappings = {
2384
- command: "tasks/",
2385
- commands: "tasks/",
2386
- event: "domain/events/",
2387
- events: "domain/events/",
2388
- job: "jobs/",
2389
- listener: "listeners/",
2390
- notification: "notifications/",
2391
- schedule: "schedules/",
2392
- scheduled: "schedules/",
2393
- task: "tasks/",
2394
- upload: "uploads/",
2395
- };
2396
- const expected = mappings[folder];
2397
- if (!expected)
2601
+ const canonicalPath = canonicalFeatureLayerPath(file, config);
2602
+ if (!canonicalPath)
2398
2603
  return undefined;
2399
- return { actual: `${folder}/`, expected };
2604
+ const featurePrefix = `${featuresPath}/${match[1]}/`;
2605
+ const actualRelative = file.slice(featurePrefix.length);
2606
+ const expectedRelative = canonicalPath.slice(featurePrefix.length);
2607
+ const actual = actualRelative.includes("/")
2608
+ ? `${actualRelative.split("/")[0]}/`
2609
+ : actualRelative;
2610
+ const expectedDirectory = path.posix.dirname(expectedRelative);
2611
+ const expected = expectedDirectory === "." ? expectedRelative : `${expectedDirectory}/`;
2612
+ return { actual, expected };
2400
2613
  }
2401
2614
  function isNotificationDefinitionFile(file, featuresPath) {
2402
2615
  return isFeatureArtifactFile(file, featuresPath, "notifications");
@@ -2478,6 +2691,14 @@ async function inspectWorkflowRegistrationDrift(targetDir, files, config, conven
2478
2691
  }
2479
2692
  }
2480
2693
  const infraDir = directoryPath(path.dirname(config.paths.portWiring));
2694
+ for (const file of drift.listeners.unsafeLifecycleFiles) {
2695
+ diagnostics.push({
2696
+ severity: "warning",
2697
+ code: "BEIGNET_LISTENER_LIFECYCLE_UNSAFE",
2698
+ file,
2699
+ message: `${file} calls registerListeners(...) without the complete provider lifecycle, so server startup can resolve before listeners are ready or shutdown can leak subscriptions. Register listeners in start(), return or await registration.ready, and return or await registration.unsubscribe() in stop().`,
2700
+ });
2701
+ }
2481
2702
  const listenerTarget = drift.listeners.wiringFile
2482
2703
  ? `${drift.listeners.wiringFile}, which already calls registerListeners(...)`
2483
2704
  : drift.listeners.eventBusFile
@@ -2591,7 +2812,7 @@ async function workflowRegistrationDrift(targetDir, files, config) {
2591
2812
  events: [],
2592
2813
  jobs: [],
2593
2814
  },
2594
- listeners: { unregistered: [] },
2815
+ listeners: { unregistered: [], unsafeLifecycleFiles: [] },
2595
2816
  };
2596
2817
  if (registries.length === 0)
2597
2818
  return drift;
@@ -2616,28 +2837,23 @@ async function workflowRegistrationDrift(targetDir, files, config) {
2616
2837
  // Events without listeners have nothing to deliver on drain, so only
2617
2838
  // record-only events stay out of the registry without a warning.
2618
2839
  const listened = await listenedEventNames(targetDir, files, config);
2619
- const value = outboxRegistryValueInfo(outboxSource, "events");
2620
- const registered = value
2621
- ? identifiersFromArrayExpression(value.text)
2622
- : new Set();
2840
+ const registered = outboxRegistryIdentifiers(outboxSource, "events");
2623
2841
  drift.outbox.events = unregisteredWorkflowRegistries(eventRegistries, registered, (member) => listened.has(member));
2624
2842
  }
2625
2843
  if (jobRegistries.length > 0) {
2626
- const value = outboxRegistryValueInfo(outboxSource, "jobs");
2627
- const registered = value
2628
- ? identifiersFromArrayExpression(value.text)
2629
- : new Set();
2844
+ const registered = outboxRegistryIdentifiers(outboxSource, "jobs");
2630
2845
  drift.outbox.jobs = unregisteredWorkflowRegistries(jobRegistries, registered);
2631
2846
  }
2632
2847
  }
2633
2848
  }
2634
2849
  const listenerRegistries = byKind("listeners");
2635
2850
  if (listenerRegistries.length > 0) {
2636
- const wiring = await listenerWiringReferences(targetDir, files, config);
2851
+ const wiring = await listenerWiringReferences(targetDir, files, config, listenerRegistries);
2637
2852
  drift.listeners = {
2638
2853
  unregistered: unregisteredWorkflowRegistries(listenerRegistries, wiring.identifiers),
2639
2854
  wiringFile: wiring.wiringFile,
2640
2855
  eventBusFile: wiring.eventBusFile,
2856
+ unsafeLifecycleFiles: wiring.unsafeLifecycleFiles,
2641
2857
  };
2642
2858
  }
2643
2859
  return drift;
@@ -2876,8 +3092,9 @@ async function listenedEventNames(targetDir, files, config) {
2876
3092
  }
2877
3093
  return listened;
2878
3094
  }
2879
- async function listenerWiringReferences(targetDir, files, config) {
3095
+ async function listenerWiringReferences(targetDir, files, config, listenerRegistries) {
2880
3096
  const identifiers = new Set();
3097
+ const unsafeLifecycleFiles = new Set();
2881
3098
  let wiringFile;
2882
3099
  let eventBusFile;
2883
3100
  let centralListenerRegistryReferenced = false;
@@ -2889,29 +3106,43 @@ async function listenerWiringReferences(targetDir, files, config) {
2889
3106
  continue;
2890
3107
  const source = await readFile(path.join(targetDir, file), "utf8");
2891
3108
  const namedImports = parseNamedImportSources(source);
2892
- let foundCall = false;
2893
- for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
2894
- const openParen = (match.index ?? 0) + match[0].length - 1;
2895
- const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
2896
- const argsText = closeParen === -1
2897
- ? source.slice(openParen)
2898
- : source.slice(openParen + 1, closeParen);
2899
- foundCall = true;
3109
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
3110
+ const registerListenersBindings = importedRegisterListenersBindings(source, namedImports);
3111
+ const calls = registerListenersCalls(sourceFile, registerListenersBindings);
3112
+ let foundRelevantCall = false;
3113
+ for (const call of calls) {
3114
+ const argsText = call.arguments
3115
+ .map((argument) => argument.getText(sourceFile))
3116
+ .join(",");
2900
3117
  const callIdentifiers = identifiersFromArrayExpression(argsText);
2901
- for (const identifier of callIdentifiers) {
2902
- identifiers.add(identifier);
2903
- }
2904
- if (callReferencesCentralListenerRegistry({
3118
+ const referencesCentral = callReferencesCentralListenerRegistry({
2905
3119
  callIdentifiers,
2906
3120
  namedImports,
2907
3121
  importerFile: file,
2908
3122
  listenerRegistryFile,
2909
3123
  files,
2910
- })) {
3124
+ });
3125
+ const referencesFeature = callReferencesFeatureListenerRegistry({
3126
+ callIdentifiers,
3127
+ namedImports,
3128
+ importerFile: file,
3129
+ files,
3130
+ listenerRegistries,
3131
+ });
3132
+ if (!referencesCentral && !referencesFeature)
3133
+ continue;
3134
+ foundRelevantCall = true;
3135
+ for (const identifier of callIdentifiers) {
3136
+ identifiers.add(identifier);
3137
+ }
3138
+ if (referencesCentral) {
2911
3139
  centralListenerRegistryReferenced = true;
2912
3140
  }
3141
+ if (!hasSafeListenerRegistrationLifecycle(call, sourceFile)) {
3142
+ unsafeLifecycleFiles.add(file);
3143
+ }
2913
3144
  }
2914
- if (foundCall) {
3145
+ if (foundRelevantCall) {
2915
3146
  wiringFile ??= file;
2916
3147
  }
2917
3148
  else if (!eventBusFile && /\bcreate\w*EventBus\s*\(/.test(source)) {
@@ -2928,7 +3159,256 @@ async function listenerWiringReferences(targetDir, files, config) {
2928
3159
  }
2929
3160
  }
2930
3161
  }
2931
- return { identifiers, wiringFile, eventBusFile };
3162
+ return {
3163
+ identifiers,
3164
+ wiringFile,
3165
+ eventBusFile,
3166
+ unsafeLifecycleFiles: [...unsafeLifecycleFiles].sort(),
3167
+ };
3168
+ }
3169
+ function registerListenersCalls(sourceFile, bindings) {
3170
+ const calls = [];
3171
+ const visit = (node) => {
3172
+ if (ts.isCallExpression(node) &&
3173
+ ((ts.isIdentifier(node.expression) &&
3174
+ bindings.has(node.expression.text)) ||
3175
+ (ts.isPropertyAccessExpression(node.expression) &&
3176
+ node.expression.name.text === "registerListeners" &&
3177
+ ts.isIdentifier(node.expression.expression) &&
3178
+ bindings.has(`${node.expression.expression.text}.*`)))) {
3179
+ calls.push(node);
3180
+ }
3181
+ ts.forEachChild(node, visit);
3182
+ };
3183
+ visit(sourceFile);
3184
+ return calls;
3185
+ }
3186
+ function importedRegisterListenersBindings(source, namedImports) {
3187
+ const bindings = new Set();
3188
+ for (const [localName, imported] of namedImports) {
3189
+ if (imported.importedName === "registerListeners" &&
3190
+ imported.sourcePath === "@beignet/core/events") {
3191
+ bindings.add(localName);
3192
+ }
3193
+ }
3194
+ const namespaceImport = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']@beignet\/core\/events["']/g;
3195
+ for (const match of source.matchAll(namespaceImport)) {
3196
+ bindings.add(`${match[1]}.*`);
3197
+ }
3198
+ return bindings;
3199
+ }
3200
+ function hasSafeListenerRegistrationLifecycle(call, sourceFile) {
3201
+ const startHook = enclosingListenerLifecycleHook(call, "start");
3202
+ const target = listenerRegistrationTarget(call, sourceFile);
3203
+ if (!startHook || !target)
3204
+ return false;
3205
+ const lifecycleOwner = listenerLifecycleOwner(startHook);
3206
+ if (!lifecycleOwner)
3207
+ return false;
3208
+ let observesReadiness = false;
3209
+ let observesCleanup = false;
3210
+ const visit = (node) => {
3211
+ if (!observesReadiness &&
3212
+ ts.isPropertyAccessExpression(node) &&
3213
+ node.name.text === "ready" &&
3214
+ listenerLifecycleTargetsMatch(listenerLifecycleTarget(node.expression, sourceFile), target) &&
3215
+ enclosingListenerLifecycleHook(node, "start") === startHook &&
3216
+ listenerLifecyclePromiseIsObserved(node, startHook)) {
3217
+ observesReadiness = true;
3218
+ }
3219
+ if (!observesCleanup &&
3220
+ ts.isCallExpression(node) &&
3221
+ ts.isPropertyAccessExpression(node.expression) &&
3222
+ node.expression.name.text === "unsubscribe" &&
3223
+ listenerLifecycleTargetsMatch(listenerLifecycleTarget(node.expression.expression, sourceFile), target)) {
3224
+ const stopHook = enclosingListenerLifecycleHook(node, "stop");
3225
+ if (stopHook &&
3226
+ listenerLifecycleOwner(stopHook) === lifecycleOwner &&
3227
+ listenerLifecyclePromiseIsObserved(node, stopHook)) {
3228
+ observesCleanup = true;
3229
+ }
3230
+ }
3231
+ if (!observesReadiness || !observesCleanup) {
3232
+ ts.forEachChild(node, visit);
3233
+ }
3234
+ };
3235
+ visit(lifecycleOwner);
3236
+ return observesReadiness && observesCleanup;
3237
+ }
3238
+ function listenerRegistrationTarget(call, sourceFile) {
3239
+ let expression = call;
3240
+ while (ts.isParenthesizedExpression(expression.parent) ||
3241
+ ts.isAsExpression(expression.parent) ||
3242
+ ts.isSatisfiesExpression(expression.parent) ||
3243
+ ts.isNonNullExpression(expression.parent)) {
3244
+ expression = expression.parent;
3245
+ }
3246
+ const parent = expression.parent;
3247
+ if (ts.isVariableDeclaration(parent) &&
3248
+ parent.initializer === expression &&
3249
+ ts.isIdentifier(parent.name)) {
3250
+ return { text: parent.name.text, binding: parent };
3251
+ }
3252
+ if (ts.isBinaryExpression(parent) &&
3253
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
3254
+ parent.right === expression) {
3255
+ return listenerLifecycleTarget(parent.left, sourceFile);
3256
+ }
3257
+ return undefined;
3258
+ }
3259
+ function listenerLifecycleTarget(expression, sourceFile) {
3260
+ let current = expression;
3261
+ while (ts.isParenthesizedExpression(current) ||
3262
+ ts.isAsExpression(current) ||
3263
+ ts.isSatisfiesExpression(current) ||
3264
+ ts.isNonNullExpression(current)) {
3265
+ current = current.expression;
3266
+ }
3267
+ if (!ts.isIdentifier(current) && !ts.isPropertyAccessExpression(current)) {
3268
+ return undefined;
3269
+ }
3270
+ return {
3271
+ text: current.getText(sourceFile),
3272
+ binding: listenerLifecycleRootIdentifier(current)
3273
+ ? resolveListenerLifecycleBinding(listenerLifecycleRootIdentifier(current))
3274
+ : undefined,
3275
+ };
3276
+ }
3277
+ function listenerLifecycleRootIdentifier(expression) {
3278
+ let current = expression;
3279
+ while (ts.isPropertyAccessExpression(current)) {
3280
+ current = current.expression;
3281
+ }
3282
+ return ts.isIdentifier(current) ? current : undefined;
3283
+ }
3284
+ function resolveListenerLifecycleBinding(identifier) {
3285
+ let current = identifier;
3286
+ while (current) {
3287
+ if (ts.isBlock(current) || ts.isSourceFile(current)) {
3288
+ for (const statement of current.statements) {
3289
+ if (!ts.isVariableStatement(statement))
3290
+ continue;
3291
+ for (const declaration of statement.declarationList.declarations) {
3292
+ if (ts.isIdentifier(declaration.name) &&
3293
+ declaration.name.text === identifier.text) {
3294
+ return declaration;
3295
+ }
3296
+ }
3297
+ }
3298
+ }
3299
+ if (ts.isFunctionLike(current)) {
3300
+ for (const parameter of current.parameters) {
3301
+ if (ts.isIdentifier(parameter.name) &&
3302
+ parameter.name.text === identifier.text) {
3303
+ return parameter;
3304
+ }
3305
+ }
3306
+ }
3307
+ if (ts.isCatchClause(current) &&
3308
+ current.variableDeclaration &&
3309
+ ts.isIdentifier(current.variableDeclaration.name) &&
3310
+ current.variableDeclaration.name.text === identifier.text) {
3311
+ return current.variableDeclaration;
3312
+ }
3313
+ current = current.parent;
3314
+ }
3315
+ return undefined;
3316
+ }
3317
+ function listenerLifecycleTargetsMatch(left, right) {
3318
+ return (left?.text === right.text &&
3319
+ (left.binding !== undefined || right.binding !== undefined
3320
+ ? left.binding === right.binding
3321
+ : true));
3322
+ }
3323
+ function enclosingListenerLifecycleHook(node, hookName) {
3324
+ let current = node.parent;
3325
+ while (current) {
3326
+ if (ts.isMethodDeclaration(current)) {
3327
+ return staticPropertyName(current.name) === hookName
3328
+ ? current
3329
+ : undefined;
3330
+ }
3331
+ if (ts.isFunctionExpression(current) || ts.isArrowFunction(current)) {
3332
+ const parent = current.parent;
3333
+ return ts.isPropertyAssignment(parent) &&
3334
+ staticPropertyName(parent.name) === hookName
3335
+ ? current
3336
+ : undefined;
3337
+ }
3338
+ if (ts.isFunctionDeclaration(current))
3339
+ return undefined;
3340
+ current = current.parent;
3341
+ }
3342
+ return undefined;
3343
+ }
3344
+ function listenerLifecycleOwner(hook) {
3345
+ if (ts.isMethodDeclaration(hook)) {
3346
+ return ts.isObjectLiteralExpression(hook.parent) ? hook.parent : undefined;
3347
+ }
3348
+ const property = hook.parent;
3349
+ return ts.isPropertyAssignment(property) &&
3350
+ ts.isObjectLiteralExpression(property.parent)
3351
+ ? property.parent
3352
+ : undefined;
3353
+ }
3354
+ function listenerLifecyclePromiseIsObserved(node, hook) {
3355
+ let current = node;
3356
+ while (current && current !== hook) {
3357
+ const parent = current.parent;
3358
+ if ((ts.isAwaitExpression(parent) && parent.expression === current) ||
3359
+ (ts.isReturnStatement(parent) && parent.expression === current)) {
3360
+ return true;
3361
+ }
3362
+ if (ts.isParenthesizedExpression(parent) ||
3363
+ ts.isAsExpression(parent) ||
3364
+ ts.isSatisfiesExpression(parent) ||
3365
+ ts.isNonNullExpression(parent)) {
3366
+ current = parent;
3367
+ continue;
3368
+ }
3369
+ if (ts.isPropertyAccessExpression(parent) &&
3370
+ parent.expression === current) {
3371
+ const method = parent.name.text;
3372
+ const invocation = parent.parent;
3373
+ if (!["then", "catch", "finally"].includes(method) ||
3374
+ !ts.isCallExpression(invocation) ||
3375
+ invocation.expression !== parent ||
3376
+ method === "catch" ||
3377
+ (method === "then" && invocation.arguments.length > 1)) {
3378
+ return false;
3379
+ }
3380
+ current = invocation;
3381
+ continue;
3382
+ }
3383
+ return false;
3384
+ }
3385
+ return false;
3386
+ }
3387
+ function callReferencesFeatureListenerRegistry(args) {
3388
+ for (const identifier of args.callIdentifiers) {
3389
+ for (const registry of args.listenerRegistries) {
3390
+ if (identifier === registry.registryName ||
3391
+ registry.members.includes(identifier)) {
3392
+ return true;
3393
+ }
3394
+ }
3395
+ const imported = args.namedImports.get(identifier);
3396
+ if (!imported)
3397
+ continue;
3398
+ const importedFile = sourceFileFromImport(imported.sourcePath, args.importerFile, args.files);
3399
+ if (!importedFile)
3400
+ continue;
3401
+ for (const registry of args.listenerRegistries) {
3402
+ if ((imported.importedName === registry.registryName &&
3403
+ importedFile === registry.indexFile) ||
3404
+ (registry.members.includes(imported.importedName) &&
3405
+ (importedFile === registry.indexFile ||
3406
+ registry.memberFiles.get(imported.importedName) === importedFile))) {
3407
+ return true;
3408
+ }
3409
+ }
3410
+ }
3411
+ return false;
2932
3412
  }
2933
3413
  function callReferencesCentralListenerRegistry(args) {
2934
3414
  if (args.importerFile === args.listenerRegistryFile &&
@@ -3114,6 +3594,45 @@ async function appHasOutboxDrainEntrypoint(targetDir, files, config) {
3114
3594
  }
3115
3595
  return false;
3116
3596
  }
3597
+ async function inspectOutboxJobDispatcherDrift(targetDir, files, config) {
3598
+ if (!hasOutboxRegistry(files, config))
3599
+ return [];
3600
+ const source = await readFile(path.join(targetDir, config.paths.outbox), "utf8");
3601
+ const jobsState = outboxRegistryEntryState(source, "jobs");
3602
+ if (jobsState === "absent" || jobsState === "empty")
3603
+ return [];
3604
+ const analysis = await analyzePortWiringFiles({ targetDir, files, config });
3605
+ const declaresJobs = analysis.declared.has("jobs");
3606
+ const wiresJobs = analysis.bound.has("jobs") || analysis.deferred.has("jobs");
3607
+ if (declaresJobs && wiresJobs)
3608
+ return [];
3609
+ const file = jobsState === "indeterminate"
3610
+ ? config.paths.outbox
3611
+ : !declaresJobs
3612
+ ? config.paths.ports
3613
+ : config.paths.portWiring;
3614
+ const reason = !analysis.portsFileExists
3615
+ ? `${config.paths.ports} does not exist at the configured path`
3616
+ : !declaresJobs
3617
+ ? analysis.declarationIndeterminate
3618
+ ? `${config.paths.ports} does not expose a statically verifiable jobs declaration`
3619
+ : `${config.paths.ports} does not declare a jobs port`
3620
+ : !analysis.portWiringFileExists
3621
+ ? `${config.paths.portWiring} does not exist at the configured path`
3622
+ : analysis.indeterminate
3623
+ ? `${config.paths.portWiring} does not expose statically verifiable jobs wiring`
3624
+ : `${config.paths.portWiring} does not bind or defer the jobs port`;
3625
+ return [
3626
+ {
3627
+ severity: "warning",
3628
+ code: "BEIGNET_OUTBOX_JOB_DISPATCHER_MISSING",
3629
+ file,
3630
+ message: jobsState === "indeterminate"
3631
+ ? `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.`
3632
+ : `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.`,
3633
+ },
3634
+ ];
3635
+ }
3117
3636
  async function inspectPortProviderDrift(targetDir, files, config, convention) {
3118
3637
  if (!convention.resourceGenerator)
3119
3638
  return [];
@@ -5276,7 +5795,8 @@ async function contractsFromRoutesSource(targetDir, files, config, file, source,
5276
5795
  return undefined;
5277
5796
  const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
5278
5797
  const registeredGroups = await registeredRouteGroupsForSource(targetDir, files, file, source, routeIdentifier);
5279
- const registeredContracts = contractsForRegisteredRouteGroups(await readContracts(targetDir, files, config), routeGroups, registeredGroups);
5798
+ const { contracts } = await readContracts(targetDir, files, config);
5799
+ const registeredContracts = contractsForRegisteredRouteGroups(contracts, routeGroups, registeredGroups);
5280
5800
  return new Set(registeredContracts.map((contract) => contract.exportName));
5281
5801
  }
5282
5802
  function contractsFromRoutesIdentifier(source, exportName) {