@open-mercato/cli 0.6.7-develop.6795.1.8a3f27921c → 0.6.7-develop.6814.1.0627c7e9f1

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 (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/build.mjs +1 -1
  3. package/dist/agentic/guides/module-facts.json +90266 -20749
  4. package/dist/agentic/guides/modules/ai_assistant.md +170 -60
  5. package/dist/agentic/guides/modules/api_docs.md +26 -1
  6. package/dist/agentic/guides/modules/api_keys.md +57 -17
  7. package/dist/agentic/guides/modules/attachments.md +72 -19
  8. package/dist/agentic/guides/modules/audit_logs.md +95 -29
  9. package/dist/agentic/guides/modules/auth.md +218 -83
  10. package/dist/agentic/guides/modules/business_rules.md +108 -34
  11. package/dist/agentic/guides/modules/catalog.md +386 -157
  12. package/dist/agentic/guides/modules/channel_gmail.md +55 -6
  13. package/dist/agentic/guides/modules/channel_imap.md +55 -6
  14. package/dist/agentic/guides/modules/checkout.md +254 -129
  15. package/dist/agentic/guides/modules/communication_channels.md +280 -109
  16. package/dist/agentic/guides/modules/configs.md +68 -11
  17. package/dist/agentic/guides/modules/content.md +26 -1
  18. package/dist/agentic/guides/modules/currencies.md +109 -44
  19. package/dist/agentic/guides/modules/customer_accounts.md +350 -156
  20. package/dist/agentic/guides/modules/customers.md +642 -294
  21. package/dist/agentic/guides/modules/dashboards.md +122 -28
  22. package/dist/agentic/guides/modules/data_sync.md +117 -37
  23. package/dist/agentic/guides/modules/design_system.md +36 -2
  24. package/dist/agentic/guides/modules/dictionaries.md +78 -20
  25. package/dist/agentic/guides/modules/directory.md +110 -45
  26. package/dist/agentic/guides/modules/entities.md +95 -38
  27. package/dist/agentic/guides/modules/events.md +31 -1
  28. package/dist/agentic/guides/modules/feature_toggles.md +79 -12
  29. package/dist/agentic/guides/modules/gateway_stripe.md +61 -6
  30. package/dist/agentic/guides/modules/generators.md +17 -1
  31. package/dist/agentic/guides/modules/inbox_ops.md +171 -60
  32. package/dist/agentic/guides/modules/integrations.md +116 -30
  33. package/dist/agentic/guides/modules/messages.md +187 -66
  34. package/dist/agentic/guides/modules/notifications.md +64 -9
  35. package/dist/agentic/guides/modules/onboarding.md +47 -8
  36. package/dist/agentic/guides/modules/payment_gateways.md +133 -48
  37. package/dist/agentic/guides/modules/perspectives.md +50 -12
  38. package/dist/agentic/guides/modules/planner.md +102 -32
  39. package/dist/agentic/guides/modules/portal.md +58 -15
  40. package/dist/agentic/guides/modules/progress.md +78 -30
  41. package/dist/agentic/guides/modules/query_index.md +115 -53
  42. package/dist/agentic/guides/modules/record_locks.md +178 -59
  43. package/dist/agentic/guides/modules/resources.md +155 -78
  44. package/dist/agentic/guides/modules/sales.md +620 -257
  45. package/dist/agentic/guides/modules/scheduler.md +95 -19
  46. package/dist/agentic/guides/modules/search.md +124 -20
  47. package/dist/agentic/guides/modules/security.md +251 -105
  48. package/dist/agentic/guides/modules/shipping_carriers.md +106 -37
  49. package/dist/agentic/guides/modules/sso.md +121 -49
  50. package/dist/agentic/guides/modules/staff.md +314 -126
  51. package/dist/agentic/guides/modules/storage_s3.md +55 -6
  52. package/dist/agentic/guides/modules/sync_akeneo.md +54 -4
  53. package/dist/agentic/guides/modules/sync_excel.md +49 -11
  54. package/dist/agentic/guides/modules/system_status_overlays.md +33 -5
  55. package/dist/agentic/guides/modules/translations.md +76 -21
  56. package/dist/agentic/guides/modules/webhooks.md +166 -82
  57. package/dist/agentic/guides/modules/wms.md +383 -223
  58. package/dist/agentic/guides/modules/workflows.md +248 -112
  59. package/dist/agentic/guides/upstream/AGENTS.md +1 -1
  60. package/dist/agentic/guides/upstream/manifest.json +2 -2
  61. package/dist/agentic/shared/AGENTS.md.template +6 -5
  62. package/dist/agentic/shared/ai/harness/cases.json +3 -0
  63. package/dist/agentic/shared/ai/lessons/_template.md +16 -0
  64. package/dist/agentic/shared/ai/lessons.md +26 -3
  65. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +7 -6
  66. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +4 -2
  67. package/dist/agentic/shared/scripts/check-lessons.mjs +178 -0
  68. package/dist/lib/agentic-setup.js +2 -2
  69. package/dist/lib/agentic-setup.js.map +2 -2
  70. package/dist/lib/generators/module-extension-facts.js +652 -24
  71. package/dist/lib/generators/module-extension-facts.js.map +3 -3
  72. package/dist/lib/generators/module-fact-sources.js +70 -0
  73. package/dist/lib/generators/module-fact-sources.js.map +7 -0
  74. package/dist/lib/generators/module-facts.js +1001 -26
  75. package/dist/lib/generators/module-facts.js.map +3 -3
  76. package/dist/lib/generators/module-override-targets.js +688 -0
  77. package/dist/lib/generators/module-override-targets.js.map +7 -0
  78. package/dist/lib/testing/integration.js +5 -0
  79. package/dist/lib/testing/integration.js.map +2 -2
  80. package/package.json +5 -5
  81. package/src/lib/__tests__/agentic-setup.ownership.test.ts +6 -0
  82. package/src/lib/agentic-setup.ts +6 -2
  83. package/src/lib/generators/__tests__/activation-adapter-coverage.test.ts +92 -0
  84. package/src/lib/generators/__tests__/module-extension-activations.test.ts +762 -0
  85. package/src/lib/generators/__tests__/module-extension-facts.test.ts +4 -2
  86. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +37 -3
  87. package/src/lib/generators/__tests__/module-facts.owned-contracts.test.ts +561 -0
  88. package/src/lib/generators/__tests__/module-override-targets.roundtrip.test.ts +149 -0
  89. package/src/lib/generators/__tests__/module-override-targets.test.ts +304 -0
  90. package/src/lib/generators/module-extension-facts.ts +923 -27
  91. package/src/lib/generators/module-fact-sources.ts +108 -0
  92. package/src/lib/generators/module-facts.ts +1360 -25
  93. package/src/lib/generators/module-override-targets.ts +863 -0
  94. package/src/lib/testing/__tests__/integration.test.ts +4 -0
  95. package/src/lib/testing/integration.ts +5 -0
@@ -9,12 +9,20 @@ import {
9
9
  import type {
10
10
  ExtensionHostCapability,
11
11
  ExtensionHostFamily,
12
+ ModuleContributionResolution,
13
+ ModuleExtensionActivation,
14
+ ModuleExtensionActivationKind,
12
15
  ModuleExtensionContributionFact,
13
16
  ModuleExtensionContributionBase,
17
+ ModuleExtensionContributionKind,
14
18
  ModuleExtensionHostFact,
19
+ ModuleExtensionResolution,
15
20
  ModuleExtensionSurfaceFacts,
16
21
  ModuleExtensionTargetFact,
22
+ ModuleExtensionTargetRef,
17
23
  ModuleExtensionUnresolvedFact,
24
+ ModuleFactSourceRef,
25
+ ModuleIncomingExtensionRef,
18
26
  } from '@open-mercato/shared/modules/widgets/extension-points'
19
27
  import { extractCommandIdsFromSource } from './module-registry'
20
28
  import { scanModuleDir, SCAN_CONFIGS } from './scanner'
@@ -71,6 +79,8 @@ type StaticContext = {
71
79
 
72
80
  type ApiExtensionHostIds = {
73
81
  entityIds: string[]
82
+ /** Declaring `api/**` file per entity host id, so the host links to a real source. */
83
+ entitySourceFiles: Record<string, string>
74
84
  commandIds: string[]
75
85
  routeIds: string[]
76
86
  }
@@ -651,6 +661,8 @@ function factRefHost(options: {
651
661
  family: ExtensionHostFamily
652
662
  capabilities: ExtensionHostCapability[]
653
663
  factSection: string
664
+ /** Key inside `factSection`; defaults to the host id when the two coincide. */
665
+ factKey?: string
654
666
  phases?: string[]
655
667
  activation?: ModuleExtensionHostFact['activation']
656
668
  scopeContract?: string
@@ -667,7 +679,7 @@ function factRefHost(options: {
667
679
  ...(options.scopeContract ? { scopeContract: options.scopeContract } : {}),
668
680
  bound: true,
669
681
  stability: 'stable',
670
- source: { kind: 'fact-ref', factSection: options.factSection, factKey: options.id },
682
+ source: { kind: 'fact-ref', factSection: options.factSection, factKey: options.factKey ?? options.id },
671
683
  }
672
684
  }
673
685
 
@@ -704,6 +716,7 @@ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtension
704
716
  const cached = activeExtractionCache?.apiExtensionHostIds.get(cacheKey)
705
717
  if (cached) return cached
706
718
  const entityIds = new Set<string>()
719
+ const entitySourceFiles: Record<string, string> = {}
707
720
  const commandIds = new Set<string>()
708
721
  const routeIds = new Set<string>()
709
722
  for (const filePath of sourceFilesBelow(path.join(moduleRoot, 'api'))) {
@@ -722,7 +735,10 @@ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtension
722
735
  if (name === 'enrichers') {
723
736
  const config = staticValue(node.initializer, context)
724
737
  const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
725
- if (entityId) entityIds.add(entityId)
738
+ if (entityId) {
739
+ entityIds.add(entityId)
740
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
741
+ }
726
742
  }
727
743
  }
728
744
  if (
@@ -732,7 +748,10 @@ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtension
732
748
  ) {
733
749
  const input = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
734
750
  const entityId = isStaticObject(input) ? stringValue(input.resourceKind) : undefined
735
- if (entityId) entityIds.add(entityId)
751
+ if (entityId) {
752
+ entityIds.add(entityId)
753
+ if (!entitySourceFiles[entityId]) entitySourceFiles[entityId] = filePath
754
+ }
736
755
  }
737
756
  node.forEachChild(visit)
738
757
  }
@@ -740,6 +759,7 @@ function apiExtensionHostIds(moduleId: string, moduleRoot: string): ApiExtension
740
759
  }
741
760
  const result = {
742
761
  entityIds: [...entityIds].sort((left, right) => left.localeCompare(right)),
762
+ entitySourceFiles,
743
763
  commandIds: [...commandIds].sort((left, right) => left.localeCompare(right)),
744
764
  routeIds: [...routeIds].sort((left, right) => left.localeCompare(right)),
745
765
  }
@@ -765,6 +785,142 @@ export function extractKnownCommandIds(moduleId: string, moduleRoot: string): st
765
785
  return moduleCommandIds(moduleRoot, apiHosts.commandIds)
766
786
  }
767
787
 
788
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
789
+ export type ApiInterceptorPhase = 'before' | 'after'
790
+ export type ApiRouteInterceptorBridge = {
791
+ method: (typeof HTTP_METHODS)[number]
792
+ phases: ApiInterceptorPhase[]
793
+ }
794
+
795
+ const API_INTERCEPTOR_BRIDGE_CALLS: Record<string, ApiInterceptorPhase> = {
796
+ runApiInterceptorsBefore: 'before',
797
+ runApiInterceptorsAfter: 'after',
798
+ }
799
+
800
+ function isHttpMethodName(value: string): value is (typeof HTTP_METHODS)[number] {
801
+ return (HTTP_METHODS as readonly string[]).includes(value)
802
+ }
803
+
804
+ /** HTTP method handlers a route file actually exports. */
805
+ function exportedHttpMethods(file: ts.SourceFile): Array<(typeof HTTP_METHODS)[number]> {
806
+ const methods = new Set<(typeof HTTP_METHODS)[number]>()
807
+ const consider = (name: string | undefined): void => {
808
+ if (name && isHttpMethodName(name)) methods.add(name)
809
+ }
810
+ const considerBindingName = (name: ts.BindingName): void => {
811
+ if (ts.isIdentifier(name)) {
812
+ consider(name.text)
813
+ return
814
+ }
815
+ for (const element of name.elements) {
816
+ if (ts.isBindingElement(element)) considerBindingName(element.name)
817
+ }
818
+ }
819
+ const visit = (node: ts.Node): void => {
820
+ if (ts.isVariableStatement(node) && node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
821
+ for (const declaration of node.declarationList.declarations) {
822
+ considerBindingName(declaration.name)
823
+ }
824
+ }
825
+ if (
826
+ ts.isFunctionDeclaration(node)
827
+ && node.name
828
+ && node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
829
+ ) {
830
+ consider(node.name.text)
831
+ }
832
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
833
+ for (const element of node.exportClause.elements) consider(element.name.text)
834
+ }
835
+ node.forEachChild(visit)
836
+ }
837
+ file.forEachChild(visit)
838
+ return [...methods].sort((left, right) => left.localeCompare(right))
839
+ }
840
+
841
+ /**
842
+ * The interceptor pipeline runs only where a route wires it: `makeCrudRoute` runs
843
+ * both phases for every method it handles, and a hand-written route runs whichever
844
+ * of `runApiInterceptorsBefore` / `runApiInterceptorsAfter` it calls. A custom route
845
+ * that calls neither has no bridge, so an interceptor targeting it is never bound.
846
+ */
847
+ function extractApiRouteInterceptorBridges(file: ts.SourceFile, context: StaticContext): ApiRouteInterceptorBridge[] {
848
+ const exportedMethods = exportedHttpMethods(file)
849
+ const phasesByMethod = new Map<(typeof HTTP_METHODS)[number], Set<ApiInterceptorPhase>>()
850
+ const addPhase = (method: (typeof HTTP_METHODS)[number], phase: ApiInterceptorPhase): void => {
851
+ const phases = phasesByMethod.get(method) ?? new Set<ApiInterceptorPhase>()
852
+ phases.add(phase)
853
+ phasesByMethod.set(method, phases)
854
+ }
855
+ const visit = (node: ts.Node): void => {
856
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
857
+ const calleeName = node.expression.text
858
+ if (calleeName === 'makeCrudRoute') {
859
+ for (const method of exportedMethods) {
860
+ addPhase(method, 'before')
861
+ addPhase(method, 'after')
862
+ }
863
+ }
864
+ const phase = API_INTERCEPTOR_BRIDGE_CALLS[calleeName]
865
+ if (phase) {
866
+ const callOptions = node.arguments[0] ? staticValue(node.arguments[0], context) : undefined
867
+ const declaredMethod = isStaticObject(callOptions) ? stringValue(callOptions.method) : undefined
868
+ const methods = declaredMethod && isHttpMethodName(declaredMethod) ? [declaredMethod] : exportedMethods
869
+ for (const method of methods) addPhase(method, phase)
870
+ }
871
+ }
872
+ node.forEachChild(visit)
873
+ }
874
+ file.forEachChild(visit)
875
+ return [...phasesByMethod.entries()]
876
+ .map(([method, phases]) => ({
877
+ method,
878
+ phases: [...phases].sort((left, right) => left.localeCompare(right)),
879
+ }))
880
+ .sort((left, right) => left.method.localeCompare(right.method))
881
+ }
882
+
883
+ /**
884
+ * Concrete runtime-id owners a module contributes as activation targets: its real
885
+ * `api/**` route ids (with the interceptor bridges each route actually wires) and
886
+ * its `commands/**` command ids (dispatched through the command bus). Used
887
+ * post-selection to synthesize `host-reference`/`owner-reference` bindings with a
888
+ * portable source.
889
+ */
890
+ export function extractActivationTargetOwners(options: {
891
+ moduleId: string
892
+ moduleRoot: string
893
+ sourceRoot: string
894
+ }): {
895
+ apiRoutes: Array<{ id: string; source: ModuleFactSourceRef; bridges: ApiRouteInterceptorBridge[] }>
896
+ commands: Array<{ id: string; source: ModuleFactSourceRef }>
897
+ } {
898
+ const apiRoutes: Array<{ id: string; source: ModuleFactSourceRef; bridges: ApiRouteInterceptorBridge[] }> = []
899
+ for (const filePath of sourceFilesBelow(path.join(options.moduleRoot, 'api'))) {
900
+ const routeId = apiRouteId(options.moduleId, options.moduleRoot, filePath)
901
+ if (!routeId) continue
902
+ const file = sourceFile(filePath)
903
+ const bridges = file ? extractApiRouteInterceptorBridges(file, buildStaticContext(file)) : []
904
+ apiRoutes.push({
905
+ id: routeId,
906
+ source: { sourcePath: portablePath(options.moduleRoot, options.sourceRoot, filePath) },
907
+ bridges,
908
+ })
909
+ }
910
+ const commands: Array<{ id: string; source: ModuleFactSourceRef }> = []
911
+ const seenCommands = new Set<string>()
912
+ for (const filePath of sourceFilesBelow(path.join(options.moduleRoot, 'commands'))) {
913
+ if (path.basename(filePath).replace(/\.(?:ts|tsx)$/, '') === 'interceptors') continue
914
+ const source: ModuleFactSourceRef = { sourcePath: portablePath(options.moduleRoot, options.sourceRoot, filePath) }
915
+ for (const id of extractCommandIdsFromSource(filePath)) {
916
+ if (seenCommands.has(id)) continue
917
+ seenCommands.add(id)
918
+ commands.push({ id, source })
919
+ }
920
+ }
921
+ return { apiRoutes, commands }
922
+ }
923
+
768
924
  function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionHostFact[] {
769
925
  const hosts: ModuleExtensionHostFact[] = []
770
926
  for (const entity of options.entities) {
@@ -816,6 +972,7 @@ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): Modul
816
972
  family: 'query-lifecycle',
817
973
  capabilities: ['sync-subscriber'],
818
974
  factSection: 'searchEntities',
975
+ factKey: entityId,
819
976
  phases: phase === 'querying' ? ['block', 'query-transform'] : ['result-transform', 'scope-reapply'],
820
977
  activation: 'caller-opt-in',
821
978
  scopeContract: 'tenant-and-organization-reapplied-after-result',
@@ -824,7 +981,8 @@ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): Modul
824
981
  }
825
982
  const apiHosts = apiExtensionHostIds(options.moduleId, options.moduleRoot)
826
983
  for (const entityId of apiHosts.entityIds) {
827
- hosts.push(factRefHost({
984
+ const declaringFile = apiHosts.entitySourceFiles[entityId]
985
+ const host = factRefHost({
828
986
  key: `api-entity.${entityId}`,
829
987
  id: entityId,
830
988
  moduleId: options.moduleId,
@@ -833,7 +991,17 @@ function extractFactRefHosts(options: ExtractModuleExtensionFactsOptions): Modul
833
991
  factSection: 'apiRoutes',
834
992
  activation: 'host-opt-in',
835
993
  scopeContract: 'tenant-and-organization',
836
- }))
994
+ })
995
+ hosts.push(declaringFile
996
+ ? {
997
+ ...host,
998
+ source: {
999
+ kind: 'declaration',
1000
+ path: portablePath(options.moduleRoot, options.sourceRoot, declaringFile),
1001
+ symbol: entityId,
1002
+ },
1003
+ }
1004
+ : host)
837
1005
  }
838
1006
  return sortHosts([...new Map(hosts.map((host) => [`${host.family}:${host.id}`, host])).values()])
839
1007
  }
@@ -944,7 +1112,12 @@ function extractEnrichers(options: ExtractModuleExtensionFactsOptions): ModuleEx
944
1112
  const targetEntity = stringValue(entry.targetEntity)
945
1113
  if (!targetEntity) return null
946
1114
  const timeoutMs = numberValue(entry.timeout) ?? 2000
947
- const queryEngine = isStaticObject(entry.queryEngine) ? entry.queryEngine : null
1115
+ const queryEngineConfig = isStaticObject(entry.queryEngine) ? entry.queryEngine : null
1116
+ // `enricher-registry` selects query-engine enrichers by `queryEngine.enabled === true`,
1117
+ // so a config object that omits it (or sets it false) is NOT query-enabled.
1118
+ const queryEngine = queryEngineConfig && booleanValue(queryEngineConfig.enabled) === true
1119
+ ? queryEngineConfig
1120
+ : null
948
1121
  const surfaces = [entry.enrichMany === true ? 'list' : null, entry.enrichOne === true ? 'detail' : null]
949
1122
  .filter((value): value is 'list' | 'detail' => value !== null)
950
1123
  const base = contributionBase(id, sourcePath, id)
@@ -1097,14 +1270,13 @@ function extractSubscribers(options: ExtractModuleExtensionFactsOptions): Module
1097
1270
  const directory = path.join(options.moduleRoot, 'subscribers')
1098
1271
  if (!fs.existsSync(directory)) return []
1099
1272
  const facts: ModuleExtensionContributionFact[] = []
1100
- for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
1101
- if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || /\.(?:test|spec)\./.test(entry.name)) continue
1102
- const filePath = path.join(directory, entry.name)
1273
+ for (const filePath of sourceFilesBelow(directory)) {
1103
1274
  const metadata = readRootObject(filePath, 'metadata')
1104
1275
  if (!metadata) continue
1105
1276
  const event = stringValue(metadata.event)
1106
1277
  if (!event) continue
1107
- const subscriberId = stringValue(metadata.id) ?? `${options.moduleId}:${entry.name.replace(/\.tsx?$/, '')}`
1278
+ const relativeStem = path.relative(directory, filePath).replace(/\.tsx?$/, '').split(path.sep).join('/')
1279
+ const subscriberId = stringValue(metadata.id) ?? `${options.moduleId}:${relativeStem}`
1108
1280
  const persistent = booleanValue(metadata.persistent) ?? false
1109
1281
  const sync = booleanValue(metadata.sync) ?? !persistent
1110
1282
  const contribution = contributionBase(subscriberId, portablePath(options.moduleRoot, options.sourceRoot, filePath), 'metadata')
@@ -1152,7 +1324,8 @@ function extractNotificationReactions(options: ExtractModuleExtensionFactsOption
1152
1324
  build(entry, sourcePath, index) {
1153
1325
  const notificationType = stringValue(entry.notificationType)
1154
1326
  if (!notificationType) return null
1155
- const id = stringValue(entry.id) ?? `${options.moduleId}.notification-handler.${index}`
1327
+ const declaredId = stringValue(entry.id)
1328
+ const id = declaredId ?? `${options.moduleId}.notification-handler.${index}`
1156
1329
  const contribution = contributionBase(id, sourcePath, 'notificationHandlers')
1157
1330
  return {
1158
1331
  ...contribution,
@@ -1164,6 +1337,7 @@ function extractNotificationReactions(options: ExtractModuleExtensionFactsOption
1164
1337
  transports: ['notification-effect'],
1165
1338
  hooks: ['useNotificationEffect'],
1166
1339
  audienceScopeContract: 'tenant-organization-user-role-and-customer',
1340
+ ...(declaredId ? { overrideKey: declaredId } : {}),
1167
1341
  },
1168
1342
  }
1169
1343
  },
@@ -1238,21 +1412,37 @@ function extractSpecializedRegistries(options: ExtractModuleExtensionFactsOption
1238
1412
 
1239
1413
  const integrationPath = conventionPath(options.moduleRoot, 'integration.ts')
1240
1414
  if (integrationPath) {
1241
- const integration = readRootObject(integrationPath, 'integration')
1242
1415
  const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, integrationPath)
1243
- const integrationId = integration ? stringValue(integration.id) : undefined
1244
- if (integrationId) add(integrationId, 'integration', sourcePath, 'integrations', 'integration')
1245
- const providerKey = integration ? stringValue(integration.providerKey) : undefined
1246
- const category = integration ? stringValue(integration.category) : undefined
1247
- const categoryRegistry = category === 'payment' ? 'payment'
1248
- : category === 'shipping' ? 'shipping'
1249
- : category === 'currency' ? 'currency'
1250
- : null
1251
- if (providerKey && categoryRegistry) {
1252
- const specialistRoute = categoryRegistry === 'payment' ? 'paymentGateways'
1253
- : categoryRegistry === 'shipping' ? 'shippingCarriers'
1254
- : 'currencies'
1255
- add(providerKey, categoryRegistry, sourcePath, specialistRoute, 'integration.providerKey')
1416
+ const addIntegration = (integration: StaticObject, symbol: string, bundleId?: string): void => {
1417
+ const integrationId = stringValue(integration.id)
1418
+ if (integrationId) {
1419
+ add(integrationId, 'integration', sourcePath, 'integrations', bundleId ? `${symbol}[${bundleId}]` : symbol)
1420
+ }
1421
+ const providerKey = stringValue(integration.providerKey)
1422
+ const category = stringValue(integration.category)
1423
+ const categoryRegistry = category === 'payment' ? 'payment'
1424
+ : category === 'shipping' ? 'shipping'
1425
+ : category === 'currency' ? 'currency'
1426
+ : null
1427
+ if (providerKey && categoryRegistry) {
1428
+ const specialistRoute = categoryRegistry === 'payment' ? 'paymentGateways'
1429
+ : categoryRegistry === 'shipping' ? 'shippingCarriers'
1430
+ : 'currencies'
1431
+ add(providerKey, categoryRegistry, sourcePath, specialistRoute, `${symbol}.providerKey`)
1432
+ }
1433
+ }
1434
+ const singular = readRootObject(integrationPath, 'integration')
1435
+ if (singular) addIntegration(singular, 'integration')
1436
+ for (const integration of readConventionObjectArray(integrationPath, 'integrations')) {
1437
+ addIntegration(integration, 'integrations')
1438
+ }
1439
+ const bundle = readRootObject(integrationPath, 'integrationBundle') ?? readRootObject(integrationPath, 'bundle')
1440
+ if (bundle) {
1441
+ const bundleId = stringValue(bundle.id) ?? 'bundle'
1442
+ const members = Array.isArray(bundle.integrations) ? bundle.integrations : []
1443
+ for (const member of members) {
1444
+ if (isStaticObject(member)) addIntegration(member, 'integrationBundle', bundleId)
1445
+ }
1256
1446
  }
1257
1447
  }
1258
1448
 
@@ -1317,6 +1507,241 @@ function extractSpecializedRegistries(options: ExtractModuleExtensionFactsOption
1317
1507
  return facts
1318
1508
  }
1319
1509
 
1510
+ /**
1511
+ * Closed activation-adapter registry (Spec 2). Each `ModuleExtensionActivationKind`
1512
+ * maps to the contribution kinds it can bind and to the mode by which a `bound`
1513
+ * resolution is proven:
1514
+ *
1515
+ * - `call-site-object` — the host module owns a distinct opt-in call site (a
1516
+ * `makeCrudRoute` option or a mutation-guard bridge). Presence of the option —
1517
+ * NOT mere entity/route existence — produces an emitted `ModuleExtensionActivation`
1518
+ * object. This is what makes "the entity could host X" (capability-only) different
1519
+ * from "this route actually activates X" (bound).
1520
+ * - `host-reference` — the runtime bridge is universal for any matching host
1521
+ * (every api route runs the interceptor pipeline; a bound widget/component host
1522
+ * proves consumption). No object is duplicated; `bound` is derived from the
1523
+ * existing bound host fact and the incoming row carries no `activationId`.
1524
+ * - `owner-reference` — like host-reference but proven by the target module owning
1525
+ * the concrete runtime id (command ids have no host fact of their own).
1526
+ *
1527
+ * The registry is exhaustive by construction: the `Record` key set is the full
1528
+ * `ModuleExtensionActivationKind` union, so adding a kind fails the typecheck until
1529
+ * it is classified. `activation-adapter-coverage.test.ts` re-checks this at runtime.
1530
+ */
1531
+ type ActivationAdapterMode = 'call-site-object' | 'host-reference' | 'owner-reference'
1532
+
1533
+ export const ACTIVATION_ADAPTERS: Record<ModuleExtensionActivationKind, {
1534
+ contributionKinds: ModuleExtensionContributionKind[]
1535
+ mode: ActivationAdapterMode
1536
+ }> = {
1537
+ 'crud-response-enricher': { contributionKinds: ['response-enricher'], mode: 'call-site-object' },
1538
+ 'query-enricher': { contributionKinds: ['response-enricher'], mode: 'call-site-object' },
1539
+ 'mutation-guard': { contributionKinds: ['mutation-guard'], mode: 'call-site-object' },
1540
+ 'api-interceptor-bridge': { contributionKinds: ['api-interceptor'], mode: 'host-reference' },
1541
+ 'command-interceptor-bridge': { contributionKinds: ['command-interceptor'], mode: 'owner-reference' },
1542
+ 'widget-injection-consumer': { contributionKinds: ['widget', 'data-table', 'crud-form'], mode: 'host-reference' },
1543
+ 'component-extension-consumer': { contributionKinds: ['component-override'], mode: 'host-reference' },
1544
+ 'dashboard-host-consumer': { contributionKinds: ['widget'], mode: 'host-reference' },
1545
+ }
1546
+
1547
+ /**
1548
+ * Every UMES contribution kind classified against the activation registry: either
1549
+ * the ordered set of activation kinds that can bind it, or `capability-only` for
1550
+ * contributions that are always active by declaration/registration and never gain
1551
+ * a route/entity activation. Exhaustive by `Record` construction — a new kind fails
1552
+ * the typecheck until classified.
1553
+ */
1554
+ export const CONTRIBUTION_ACTIVATION_CLASSIFICATION: Record<
1555
+ ModuleExtensionContributionKind,
1556
+ ModuleExtensionActivationKind[] | 'capability-only'
1557
+ > = {
1558
+ 'response-enricher': ['crud-response-enricher', 'query-enricher'],
1559
+ 'mutation-guard': ['mutation-guard'],
1560
+ 'api-interceptor': ['api-interceptor-bridge'],
1561
+ 'command-interceptor': ['command-interceptor-bridge'],
1562
+ 'widget': ['widget-injection-consumer', 'dashboard-host-consumer'],
1563
+ 'data-table': ['widget-injection-consumer'],
1564
+ 'crud-form': ['widget-injection-consumer'],
1565
+ 'component-override': ['component-extension-consumer'],
1566
+ 'entity-extension': 'capability-only',
1567
+ 'subscriber': 'capability-only',
1568
+ 'browser-reaction': 'capability-only',
1569
+ 'specialized-registry': 'capability-only',
1570
+ 'module-override': 'capability-only',
1571
+ }
1572
+
1573
+ export const ALL_ACTIVATION_KINDS = Object.keys(ACTIVATION_ADAPTERS) as ModuleExtensionActivationKind[]
1574
+ export const ALL_CONTRIBUTION_KINDS = Object.keys(CONTRIBUTION_ACTIVATION_CLASSIFICATION) as ModuleExtensionContributionKind[]
1575
+
1576
+ /**
1577
+ * Verified mutation-guard bridge shapes. The canonical route helper nests the
1578
+ * resource under `input` (`runRouteMutationGuards({ …, input: { resourceKind } })`),
1579
+ * while the legacy helpers take the resource object directly. Module wrappers around
1580
+ * the canonical helper live under `lib/` as often as under `api/`, so both trees are
1581
+ * scanned.
1582
+ */
1583
+ const MUTATION_GUARD_BRIDGE_ADAPTERS: Record<string, { resourceArgument: 'input-property' | 'any-argument' }> = {
1584
+ runRouteMutationGuards: { resourceArgument: 'input-property' },
1585
+ validateCrudMutationGuard: { resourceArgument: 'any-argument' },
1586
+ runMutationGuards: { resourceArgument: 'any-argument' },
1587
+ }
1588
+
1589
+ const MUTATION_GUARD_SOURCE_DIRECTORIES = ['api', 'lib'] as const
1590
+
1591
+ const MUTATION_GUARD_OPERATIONS = new Set(['create', 'update', 'delete', 'custom'])
1592
+
1593
+ /**
1594
+ * `'custom'` action endpoints are mapped by `toRegistryMutationOperation` onto the
1595
+ * closest registry operation, `'update'`.
1596
+ */
1597
+ function toRegistryGuardOperation(operation: string): string {
1598
+ return operation === 'custom' ? 'update' : operation
1599
+ }
1600
+
1601
+ function readMutationGuardBridge(
1602
+ node: ts.CallExpression,
1603
+ context: StaticContext,
1604
+ ): { entityId: string; operations: string[] } | null {
1605
+ const callee = node.expression
1606
+ const calleeName = ts.isIdentifier(callee)
1607
+ ? callee.text
1608
+ : ts.isPropertyAccessExpression(callee)
1609
+ ? callee.name.text
1610
+ : null
1611
+ const adapter = calleeName ? MUTATION_GUARD_BRIDGE_ADAPTERS[calleeName] : undefined
1612
+ if (!adapter) return null
1613
+
1614
+ const argumentObjects = node.arguments
1615
+ .map((argument) => staticValue(argument, context))
1616
+ .filter(isStaticObject)
1617
+ const resourceObjects = adapter.resourceArgument === 'input-property'
1618
+ ? argumentObjects.flatMap((argumentObject) => (isStaticObject(argumentObject.input) ? [argumentObject.input] : []))
1619
+ : argumentObjects
1620
+ for (const resourceObject of resourceObjects) {
1621
+ const entityId = stringValue(resourceObject.resourceKind) ?? stringValue(resourceObject.entityId)
1622
+ if (!entityId) continue
1623
+ const declaredOperation = stringValue(resourceObject.operation)
1624
+ const operations = declaredOperation && MUTATION_GUARD_OPERATIONS.has(declaredOperation)
1625
+ ? [toRegistryGuardOperation(declaredOperation)]
1626
+ : []
1627
+ return { entityId, operations }
1628
+ }
1629
+ return null
1630
+ }
1631
+
1632
+ function nodeLine(file: ts.SourceFile, node: ts.Node): number {
1633
+ return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1
1634
+ }
1635
+
1636
+ function activationId(host: ModuleExtensionTargetRef, kind: ModuleExtensionActivationKind): string {
1637
+ const method = host.method ? `:${host.method}` : ''
1638
+ return `${host.kind}:${host.id}${method}:${kind}`
1639
+ }
1640
+
1641
+ function sortActivations(activations: ModuleExtensionActivation[]): ModuleExtensionActivation[] {
1642
+ return [...activations].sort((left, right) => left.id.localeCompare(right.id))
1643
+ }
1644
+
1645
+ /**
1646
+ * Extract the `call-site-object` activations (CRUD response/query enrichers and
1647
+ * mutation guards) from a module's real `api/**` opt-in sites. Entity or route
1648
+ * presence alone never lands here — only a literal `enrichers: { entityId }`
1649
+ * option or a mutation-guard bridge call with a static `resourceKind`/`entityId`.
1650
+ */
1651
+ /**
1652
+ * `<engine>.query(entityId, { …, extensions })` — the only call shape that enables the
1653
+ * query-engine enricher and sync query-lifecycle stages.
1654
+ */
1655
+ function isQueryWithExtensionsCall(node: ts.Node, context: StaticContext): node is ts.CallExpression {
1656
+ if (!ts.isCallExpression(node)) return false
1657
+ const callee = node.expression
1658
+ const calleeName = ts.isPropertyAccessExpression(callee)
1659
+ ? callee.name.text
1660
+ : ts.isIdentifier(callee)
1661
+ ? callee.text
1662
+ : null
1663
+ if (calleeName !== 'query') return false
1664
+ const queryOptions = node.arguments[1] ? staticValue(node.arguments[1], context) : undefined
1665
+ return isStaticObject(queryOptions) && queryOptions.extensions !== undefined
1666
+ }
1667
+
1668
+ function extractCallSiteActivations(options: ExtractModuleExtensionFactsOptions): ModuleExtensionActivation[] {
1669
+ const activations: ModuleExtensionActivation[] = []
1670
+ const byId = new Map<string, ModuleExtensionActivation>()
1671
+ const push = (activation: ModuleExtensionActivation): void => {
1672
+ const existing = byId.get(activation.id)
1673
+ if (existing) {
1674
+ if (!existing.operations || !activation.operations) {
1675
+ delete existing.operations
1676
+ } else {
1677
+ existing.operations = [...new Set([...existing.operations, ...activation.operations])]
1678
+ .sort((left, right) => left.localeCompare(right))
1679
+ }
1680
+ return
1681
+ }
1682
+ byId.set(activation.id, activation)
1683
+ activations.push(activation)
1684
+ }
1685
+ const scannedFiles = MUTATION_GUARD_SOURCE_DIRECTORIES
1686
+ .flatMap((directory) => sourceFilesBelow(path.join(options.moduleRoot, directory)))
1687
+ for (const filePath of scannedFiles) {
1688
+ const file = sourceFile(filePath)
1689
+ if (!file) continue
1690
+ const context = buildStaticContext(file)
1691
+ const sourcePath = portablePath(options.moduleRoot, options.sourceRoot, filePath)
1692
+ const visit = (node: ts.Node): void => {
1693
+ // `enrichers: { entityId }` on a CRUD route runs `applyResponseEnrichers` after the
1694
+ // list/detail hook. It does NOT enable the query-engine enricher stage.
1695
+ if (ts.isPropertyAssignment(node) && propertyName(node.name) === 'enrichers') {
1696
+ const config = staticValue(node.initializer, context)
1697
+ const entityId = isStaticObject(config) ? stringValue(config.entityId) : undefined
1698
+ if (entityId) {
1699
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: entityId, moduleId: options.moduleId }
1700
+ push({
1701
+ id: activationId(host, 'crud-response-enricher'),
1702
+ kind: 'crud-response-enricher',
1703
+ host,
1704
+ contributionKinds: ['response-enricher'],
1705
+ source: { sourcePath, line: nodeLine(file, node) },
1706
+ })
1707
+ }
1708
+ }
1709
+ // The query-engine enricher stage runs only for a query that passes `extensions`,
1710
+ // so the bound call site is `queryEngine.query('<entityId>', { …, extensions })`.
1711
+ if (isQueryWithExtensionsCall(node, context)) {
1712
+ const entityId = stringValue(staticValue(node.arguments[0], context))
1713
+ if (entityId) {
1714
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: entityId, moduleId: options.moduleId }
1715
+ push({
1716
+ id: activationId(host, 'query-enricher'),
1717
+ kind: 'query-enricher',
1718
+ host,
1719
+ contributionKinds: ['response-enricher'],
1720
+ source: { sourcePath, line: nodeLine(file, node) },
1721
+ })
1722
+ }
1723
+ }
1724
+ if (ts.isCallExpression(node)) {
1725
+ const bridge = readMutationGuardBridge(node, context)
1726
+ if (bridge) {
1727
+ const host: ModuleExtensionTargetRef = { kind: 'entity', id: bridge.entityId, moduleId: options.moduleId }
1728
+ push({
1729
+ id: activationId(host, 'mutation-guard'),
1730
+ kind: 'mutation-guard',
1731
+ host,
1732
+ contributionKinds: ['mutation-guard'],
1733
+ ...(bridge.operations.length > 0 ? { operations: bridge.operations } : {}),
1734
+ source: { sourcePath, line: nodeLine(file, node) },
1735
+ })
1736
+ }
1737
+ }
1738
+ node.forEachChild(visit)
1739
+ }
1740
+ file.forEachChild(visit)
1741
+ }
1742
+ return sortActivations(activations)
1743
+ }
1744
+
1320
1745
  export function extractModuleExtensionFacts(options: ExtractModuleExtensionFactsOptions): ModuleExtensionSurfaceFacts {
1321
1746
  const declared = extractDeclaredHosts(options)
1322
1747
  const hosts = sortHosts([...declared.hosts, ...extractFactRefHosts(options)])
@@ -1333,7 +1758,12 @@ export function extractModuleExtensionFacts(options: ExtractModuleExtensionFacts
1333
1758
  ...extractComponentOverrides(options),
1334
1759
  ...extractSpecializedRegistries(options),
1335
1760
  ])
1336
- return { hosts, contributions, unresolved: declared.unresolved }
1761
+ return {
1762
+ hosts,
1763
+ contributions,
1764
+ unresolved: declared.unresolved,
1765
+ activations: extractCallSiteActivations(options),
1766
+ }
1337
1767
  }
1338
1768
 
1339
1769
  function patternMatches(pattern: string, targetId: string): boolean {
@@ -1390,7 +1820,7 @@ export function correlateExtensionTarget(
1390
1820
  return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'events', factKey: targetFact.id } }
1391
1821
  }
1392
1822
  if (options.commandIds?.has(targetFact.id)) {
1393
- return factTarget(targetFact.id, 'commands')
1823
+ return factTarget(targetFact.id, 'ownedContracts.command')
1394
1824
  }
1395
1825
  const route = [...options.apiRoutes].find((apiRoute) => apiRoute === targetFact.id || apiRoute.endsWith(`/${targetFact.id}`))
1396
1826
  if (route) return { id: targetFact.id, resolution: 'fact-ref', factRef: { factSection: 'apiRoutes', factKey: route } }
@@ -1428,11 +1858,460 @@ export function correlateModuleExtensionFacts(options: CorrelateExtensionFactsOp
1428
1858
  hosts: sortHosts([...surface.hosts]),
1429
1859
  contributions: sortContributions(contributions),
1430
1860
  unresolved: unresolved.sort((left, right) => left.key.localeCompare(right.key)),
1861
+ activations: sortActivations([...(surface.activations ?? [])]),
1431
1862
  }
1432
1863
  }
1433
1864
  return result
1434
1865
  }
1435
1866
 
1867
+ type IncomingTargetOwner = {
1868
+ moduleId: string
1869
+ source: ModuleFactSourceRef
1870
+ /** Interceptor bridges the owning api route wires (api-route owners only). */
1871
+ bridges?: ApiRouteInterceptorBridge[]
1872
+ }
1873
+
1874
+ export interface CorrelateIncomingExtensionsOptions {
1875
+ surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>
1876
+ /** Concrete runtime-id owner maps derived from the fully-selected module set. */
1877
+ apiRouteOwners?: ReadonlyMap<string, IncomingTargetOwner>
1878
+ commandOwners?: ReadonlyMap<string, IncomingTargetOwner>
1879
+ }
1880
+
1881
+ function normalizeRouteId(routeId: string): string {
1882
+ return routeId.replace(/^\//, '')
1883
+ }
1884
+
1885
+ /**
1886
+ * Owner marker for a contribution bound to a framework host (dashboard, menus,
1887
+ * notifications, …). Framework hosts have no module surface of their own, so the
1888
+ * activation is recorded on the contributor and never produces an incoming row.
1889
+ */
1890
+ const FRAMEWORK_HOST_OWNER = 'framework'
1891
+
1892
+ /**
1893
+ * Framework host families each host-reference adapter may bind, so a dashboard
1894
+ * contribution resolves through `dashboard-host-consumer` rather than being claimed
1895
+ * by the generic widget-injection adapter that runs first.
1896
+ */
1897
+ const FRAMEWORK_HOST_FAMILIES_BY_ACTIVATION: Partial<Record<ModuleExtensionActivationKind, ExtensionHostFamily[]>> = {
1898
+ 'dashboard-host-consumer': ['dashboard'],
1899
+ 'widget-injection-consumer': ['menu', 'generic', 'crud-form', 'data-table', 'notification', 'integration'],
1900
+ 'component-extension-consumer': ['component-handle'],
1901
+ }
1902
+
1903
+ /**
1904
+ * Resolves a target id against the framework host catalog — exact ids first, then
1905
+ * the patterned entries (`dashboard:*`, `crud-form:*`, `integrations.detail:{id}`),
1906
+ * so a concrete `dashboard:<widget>` contribution reaches `framework.dashboard`.
1907
+ */
1908
+ function findFrameworkHost(
1909
+ targetId: string,
1910
+ activationKind: ModuleExtensionActivationKind,
1911
+ ): ModuleExtensionHostFact | null {
1912
+ const families = FRAMEWORK_HOST_FAMILIES_BY_ACTIVATION[activationKind]
1913
+ if (!families) return null
1914
+ const candidates = FRAMEWORK_HOSTS.filter((host) => families.includes(host.family))
1915
+ const exact = candidates.find((host) => host.id === targetId)
1916
+ if (exact) return exact
1917
+ return candidates.find((host) => host.resolution === 'pattern' && patternMatches(host.id, targetId)) ?? null
1918
+ }
1919
+
1920
+ /**
1921
+ * A call site that guards only some operations does not activate a contribution
1922
+ * declaring none of them. Either side leaving operations undeclared means "any".
1923
+ */
1924
+ function operationsIntersect(
1925
+ activationOperations: readonly string[] | undefined,
1926
+ contributionOperations: readonly string[] | undefined,
1927
+ ): boolean {
1928
+ if (!activationOperations || activationOperations.length === 0) return true
1929
+ if (!contributionOperations || contributionOperations.length === 0) return true
1930
+ return contributionOperations.some((operation) => activationOperations.includes(operation))
1931
+ }
1932
+
1933
+ function contributionSourceRef(contribution: ModuleExtensionContributionFact): ModuleFactSourceRef {
1934
+ return {
1935
+ sourcePath: contribution.source.path,
1936
+ ...(contribution.source.symbol ? { exportName: contribution.source.symbol } : {}),
1937
+ }
1938
+ }
1939
+
1940
+ function targetRefKindFor(
1941
+ contribution: ModuleExtensionContributionFact,
1942
+ targetId: string,
1943
+ ): ModuleExtensionTargetRef['kind'] {
1944
+ if (targetId === '*' || targetId.includes('*') || targetId.includes('{')) return 'wildcard'
1945
+ switch (contribution.kind) {
1946
+ case 'response-enricher':
1947
+ case 'mutation-guard':
1948
+ case 'entity-extension':
1949
+ return 'entity'
1950
+ case 'api-interceptor':
1951
+ return 'api-route'
1952
+ case 'command-interceptor':
1953
+ return 'command'
1954
+ case 'component-override':
1955
+ return 'component'
1956
+ case 'subscriber':
1957
+ return 'event'
1958
+ case 'browser-reaction':
1959
+ return contribution.details.transports.includes('notification-effect') ? 'notification' : 'event'
1960
+ case 'widget':
1961
+ case 'data-table':
1962
+ case 'crud-form':
1963
+ return 'widget-spot'
1964
+ case 'specialized-registry':
1965
+ case 'module-override':
1966
+ return 'module'
1967
+ default:
1968
+ return 'module'
1969
+ }
1970
+ }
1971
+
1972
+ /**
1973
+ * Builds the bidirectional incoming/resolution index after every module fact is
1974
+ * extracted and target-correlated. For each contribution target it records one
1975
+ * contributor-owned `ModuleContributionResolution` and, when a concrete target
1976
+ * owner exists, one target-owned `ModuleIncomingExtensionRef` per matching
1977
+ * activation. It never duplicates contribution behavior metadata — incoming rows
1978
+ * point back to the source-owned contribution by `contributorModuleId + contributionId`.
1979
+ */
1980
+ export function correlateIncomingExtensions(
1981
+ options: CorrelateIncomingExtensionsOptions,
1982
+ ): Record<string, ModuleExtensionSurfaceFacts> {
1983
+ const moduleIds = Object.keys(options.surfacesByModule).sort((left, right) => left.localeCompare(right))
1984
+ const apiRouteOwners = options.apiRouteOwners ?? new Map<string, IncomingTargetOwner>()
1985
+ const commandOwners = options.commandOwners ?? new Map<string, IncomingTargetOwner>()
1986
+
1987
+ // Index call-site activations by (kind, normalized host id).
1988
+ const activationIndex = new Map<string, Array<{ moduleId: string; activation: ModuleExtensionActivation }>>()
1989
+ const activationKey = (kind: ModuleExtensionActivationKind, hostKind: ModuleExtensionTargetRef['kind'], id: string): string => {
1990
+ const normId = hostKind === 'api-route' ? normalizeRouteId(id) : id
1991
+ return JSON.stringify([kind, hostKind, normId])
1992
+ }
1993
+ // Index bound hosts by their runtime id for host-reference resolution.
1994
+ const boundHostIndex = new Map<string, { moduleId: string; host: ModuleExtensionHostFact }>()
1995
+
1996
+ for (const moduleId of moduleIds) {
1997
+ const surface = options.surfacesByModule[moduleId]
1998
+ for (const activation of surface.activations ?? []) {
1999
+ const key = activationKey(activation.kind, activation.host.kind, activation.host.id)
2000
+ const bucket = activationIndex.get(key)
2001
+ if (bucket) bucket.push({ moduleId, activation })
2002
+ else activationIndex.set(key, [{ moduleId, activation }])
2003
+ }
2004
+ for (const host of surface.hosts) {
2005
+ if (!host.bound) continue
2006
+ if (!boundHostIndex.has(host.id)) boundHostIndex.set(host.id, { moduleId, host })
2007
+ }
2008
+ }
2009
+
2010
+ const incomingByModule: Record<string, ModuleIncomingExtensionRef[]> = {}
2011
+ const resolutionsByModule: Record<string, ModuleContributionResolution[]> = {}
2012
+ const syntheticActivations: Record<string, ModuleExtensionActivation[]> = {}
2013
+ for (const moduleId of moduleIds) {
2014
+ incomingByModule[moduleId] = []
2015
+ resolutionsByModule[moduleId] = []
2016
+ syntheticActivations[moduleId] = []
2017
+ }
2018
+ const registerSyntheticActivation = (ownerModule: string, activation: ModuleExtensionActivation): void => {
2019
+ if (!syntheticActivations[ownerModule]) syntheticActivations[ownerModule] = []
2020
+ if (!syntheticActivations[ownerModule].some((entry) => entry.id === activation.id)) {
2021
+ syntheticActivations[ownerModule].push(activation)
2022
+ }
2023
+ }
2024
+
2025
+ const findCallSiteActivations = (
2026
+ contributionKind: ModuleExtensionContributionKind,
2027
+ activationKinds: ModuleExtensionActivationKind[],
2028
+ targetRef: ModuleExtensionTargetRef,
2029
+ contributionOperations: readonly string[] | undefined,
2030
+ ): Array<{ moduleId: string; activation: ModuleExtensionActivation }> => {
2031
+ const matches: Array<{ moduleId: string; activation: ModuleExtensionActivation }> = []
2032
+ for (const kind of activationKinds) {
2033
+ if (ACTIVATION_ADAPTERS[kind].mode !== 'call-site-object') continue
2034
+ const bucket = activationIndex.get(activationKey(kind, targetRef.kind, targetRef.id)) ?? []
2035
+ for (const entry of bucket) {
2036
+ if (!entry.activation.contributionKinds.includes(contributionKind)) continue
2037
+ if (!operationsIntersect(entry.activation.operations, contributionOperations)) continue
2038
+ matches.push(entry)
2039
+ }
2040
+ }
2041
+ return matches.sort((left, right) => left.activation.id.localeCompare(right.activation.id))
2042
+ }
2043
+
2044
+ for (const contributorModuleId of moduleIds) {
2045
+ const surface = options.surfacesByModule[contributorModuleId]
2046
+ for (const contribution of surface.contributions) {
2047
+ const classification = CONTRIBUTION_ACTIVATION_CLASSIFICATION[contribution.kind]
2048
+ for (const resolvedTarget of contribution.targets) {
2049
+ const targetKind = targetRefKindFor(contribution, resolvedTarget.id)
2050
+ const baseTarget: ModuleExtensionTargetRef = { kind: targetKind, id: resolvedTarget.id }
2051
+
2052
+ let resolution: ModuleExtensionResolution
2053
+ let ownerModule: string | null = null
2054
+ let boundActivationIds: string[] = []
2055
+
2056
+ if (targetKind === 'wildcard' || resolvedTarget.resolution === 'pattern') {
2057
+ resolution = 'wildcard'
2058
+ } else if (resolvedTarget.resolution === 'optional-external') {
2059
+ resolution = 'optional-target-missing'
2060
+ } else if (resolvedTarget.resolution === 'unresolved') {
2061
+ resolution = 'unresolved'
2062
+ } else {
2063
+ const activationKinds = classification === 'capability-only' ? [] : classification
2064
+ const callSiteMatches = findCallSiteActivations(
2065
+ contribution.kind,
2066
+ activationKinds,
2067
+ baseTarget,
2068
+ contribution.operations,
2069
+ )
2070
+ if (callSiteMatches.length > 0) {
2071
+ resolution = 'bound'
2072
+ ownerModule = callSiteMatches[0].moduleId
2073
+ boundActivationIds = callSiteMatches.map((entry) => entry.activation.id)
2074
+ } else {
2075
+ const referenceBinding = resolveReferenceBinding({
2076
+ contribution,
2077
+ activationKinds,
2078
+ targetRef: baseTarget,
2079
+ boundHostIndex,
2080
+ apiRouteOwners,
2081
+ commandOwners,
2082
+ })
2083
+ if (referenceBinding) {
2084
+ resolution = 'bound'
2085
+ // A framework host owns no module surface: record the activation on the
2086
+ // contributor and leave `ownerModule` unset so no incoming row is emitted.
2087
+ const isFrameworkHost = referenceBinding.ownerModule === FRAMEWORK_HOST_OWNER
2088
+ ownerModule = isFrameworkHost ? null : referenceBinding.ownerModule
2089
+ const activationOwner = isFrameworkHost ? contributorModuleId : referenceBinding.ownerModule
2090
+ for (const activation of referenceBinding.activations) {
2091
+ registerSyntheticActivation(activationOwner, activation)
2092
+ }
2093
+ boundActivationIds = referenceBinding.activations.map((activation) => activation.id)
2094
+ } else {
2095
+ const hostOwner = boundHostIndex.get(resolvedTarget.id)
2096
+ if (hostOwner) {
2097
+ resolution = 'capability-only'
2098
+ ownerModule = hostOwner.moduleId
2099
+ } else {
2100
+ resolution = 'capability-only'
2101
+ ownerModule = null
2102
+ }
2103
+ }
2104
+ }
2105
+ }
2106
+
2107
+ const resolutionTarget: ModuleExtensionTargetRef = ownerModule
2108
+ ? { ...baseTarget, moduleId: ownerModule }
2109
+ : baseTarget
2110
+ resolutionsByModule[contributorModuleId].push({
2111
+ contributionId: contribution.id,
2112
+ target: resolutionTarget,
2113
+ resolution,
2114
+ activationIds: [...boundActivationIds].sort((left, right) => left.localeCompare(right)),
2115
+ })
2116
+
2117
+ // Incoming rows document contributions installed by OTHER modules onto a
2118
+ // target module's surface (cross-module discovery). Same-module targets are
2119
+ // already fully covered by the contributor-owned resolution row above, so
2120
+ // emitting a self-incoming row would only duplicate that fact and bloat the
2121
+ // generated context.
2122
+ if (ownerModule && ownerModule !== contributorModuleId && (resolution === 'bound' || resolution === 'capability-only')) {
2123
+ const source = contributionSourceRef(contribution)
2124
+ const incomingTarget: ModuleExtensionTargetRef = { ...baseTarget, moduleId: ownerModule }
2125
+ if (resolution === 'bound' && boundActivationIds.length > 0) {
2126
+ for (const activationRefId of boundActivationIds) {
2127
+ incomingByModule[ownerModule].push({
2128
+ contributionId: contribution.id,
2129
+ contributionKind: contribution.kind,
2130
+ contributorModuleId,
2131
+ target: incomingTarget,
2132
+ activationId: activationRefId,
2133
+ resolution: 'bound',
2134
+ source,
2135
+ })
2136
+ }
2137
+ } else {
2138
+ incomingByModule[ownerModule].push({
2139
+ contributionId: contribution.id,
2140
+ contributionKind: contribution.kind,
2141
+ contributorModuleId,
2142
+ target: incomingTarget,
2143
+ resolution,
2144
+ source,
2145
+ })
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+ }
2151
+
2152
+ const result: Record<string, ModuleExtensionSurfaceFacts> = {}
2153
+ for (const moduleId of moduleIds) {
2154
+ const surface = options.surfacesByModule[moduleId]
2155
+ result[moduleId] = {
2156
+ ...surface,
2157
+ activations: sortActivations([...(surface.activations ?? []), ...syntheticActivations[moduleId]]),
2158
+ incoming: dedupeIncoming(incomingByModule[moduleId]),
2159
+ contributionResolutions: sortResolutions(resolutionsByModule[moduleId]),
2160
+ }
2161
+ }
2162
+ return result
2163
+ }
2164
+
2165
+ /**
2166
+ * Intersects an api-interceptor contribution's declared methods and phases with the
2167
+ * bridges its target route actually wires. A route with no bridge, a phase the route
2168
+ * never runs, or a method the route does not handle yields no binding, so the
2169
+ * contribution stays `capability-only` instead of being reported as active.
2170
+ */
2171
+ function matchApiInterceptorBridges(
2172
+ contribution: ModuleExtensionContributionFact,
2173
+ owner: IncomingTargetOwner,
2174
+ ): ApiRouteInterceptorBridge[] {
2175
+ if (contribution.kind !== 'api-interceptor') return []
2176
+ const bridges = owner.bridges ?? []
2177
+ const declaredMethods = contribution.details.methods ?? []
2178
+ const declaredPhases = contribution.phases ?? []
2179
+ const matched: ApiRouteInterceptorBridge[] = []
2180
+ for (const bridge of bridges) {
2181
+ if (declaredMethods.length > 0 && !declaredMethods.includes(bridge.method)) continue
2182
+ const phases = declaredPhases.length > 0
2183
+ ? bridge.phases.filter((phase) => declaredPhases.includes(phase))
2184
+ : bridge.phases
2185
+ if (phases.length === 0) continue
2186
+ matched.push({ method: bridge.method, phases })
2187
+ }
2188
+ return matched
2189
+ }
2190
+
2191
+ function resolveReferenceBinding(input: {
2192
+ contribution: ModuleExtensionContributionFact
2193
+ activationKinds: ModuleExtensionActivationKind[]
2194
+ targetRef: ModuleExtensionTargetRef
2195
+ boundHostIndex: ReadonlyMap<string, { moduleId: string; host: ModuleExtensionHostFact }>
2196
+ apiRouteOwners: ReadonlyMap<string, IncomingTargetOwner>
2197
+ commandOwners: ReadonlyMap<string, IncomingTargetOwner>
2198
+ }): { ownerModule: string; activations: ModuleExtensionActivation[] } | null {
2199
+ for (const kind of input.activationKinds) {
2200
+ const adapter = ACTIVATION_ADAPTERS[kind]
2201
+ if (adapter.mode === 'call-site-object') continue
2202
+ if (!adapter.contributionKinds.includes(input.contribution.kind)) continue
2203
+
2204
+ if (adapter.mode === 'host-reference' && kind === 'api-interceptor-bridge') {
2205
+ const routeId = normalizeRouteId(input.targetRef.id)
2206
+ const owner = input.apiRouteOwners.get(routeId)
2207
+ if (!owner) continue
2208
+ const matchedBridges = matchApiInterceptorBridges(input.contribution, owner)
2209
+ if (matchedBridges.length === 0) continue
2210
+ return {
2211
+ ownerModule: owner.moduleId,
2212
+ activations: matchedBridges.map((bridge) => {
2213
+ const host: ModuleExtensionTargetRef = {
2214
+ kind: 'api-route',
2215
+ id: routeId,
2216
+ moduleId: owner.moduleId,
2217
+ method: bridge.method,
2218
+ }
2219
+ return {
2220
+ id: activationId(host, kind),
2221
+ kind,
2222
+ host,
2223
+ contributionKinds: ['api-interceptor'],
2224
+ phases: bridge.phases,
2225
+ source: owner.source,
2226
+ bridge: { factSection: 'apiRoutes', factKey: `/${routeId}` },
2227
+ }
2228
+ }),
2229
+ }
2230
+ }
2231
+
2232
+ if (adapter.mode === 'owner-reference' && kind === 'command-interceptor-bridge') {
2233
+ const owner = input.commandOwners.get(input.targetRef.id)
2234
+ if (!owner) continue
2235
+ const host: ModuleExtensionTargetRef = { kind: 'command', id: input.targetRef.id, moduleId: owner.moduleId }
2236
+ return {
2237
+ ownerModule: owner.moduleId,
2238
+ activations: [{
2239
+ id: activationId(host, kind),
2240
+ kind,
2241
+ host,
2242
+ contributionKinds: ['command-interceptor'],
2243
+ source: owner.source,
2244
+ bridge: { factSection: 'ownedContracts.command', factKey: input.targetRef.id },
2245
+ }],
2246
+ }
2247
+ }
2248
+
2249
+ if (adapter.mode === 'host-reference' && (kind === 'widget-injection-consumer' || kind === 'component-extension-consumer' || kind === 'dashboard-host-consumer')) {
2250
+ const frameworkHostFact = input.boundHostIndex.has(input.targetRef.id)
2251
+ ? null
2252
+ : findFrameworkHost(input.targetRef.id, kind)
2253
+ const hostEntry = input.boundHostIndex.get(input.targetRef.id)
2254
+ ?? (frameworkHostFact ? { moduleId: FRAMEWORK_HOST_OWNER, host: frameworkHostFact } : undefined)
2255
+ if (!hostEntry) continue
2256
+ const hostSource = hostReferenceSource(hostEntry.host)
2257
+ if (!hostSource) continue
2258
+ const host: ModuleExtensionTargetRef = { ...input.targetRef, moduleId: hostEntry.moduleId }
2259
+ return {
2260
+ ownerModule: hostEntry.moduleId,
2261
+ activations: [{
2262
+ id: activationId(host, kind),
2263
+ kind,
2264
+ host,
2265
+ contributionKinds: adapter.contributionKinds,
2266
+ source: hostSource,
2267
+ bridge: { factSection: 'hosts', factKey: hostEntry.host.key },
2268
+ }],
2269
+ }
2270
+ }
2271
+ }
2272
+ return null
2273
+ }
2274
+
2275
+ function hostReferenceSource(host: ModuleExtensionHostFact): ModuleFactSourceRef | null {
2276
+ if (host.source.kind === 'declaration' || host.source.kind === 'framework') {
2277
+ return { sourcePath: host.source.path, exportName: host.source.symbol }
2278
+ }
2279
+ return null
2280
+ }
2281
+
2282
+ function incomingIdentity(entry: ModuleIncomingExtensionRef): string {
2283
+ const target = JSON.stringify([entry.target.kind, entry.target.id])
2284
+ return JSON.stringify([entry.contributorModuleId, entry.contributionId, target, entry.activationId ?? entry.resolution])
2285
+ }
2286
+
2287
+ function dedupeIncoming(entries: ModuleIncomingExtensionRef[]): ModuleIncomingExtensionRef[] {
2288
+ const seen = new Set<string>()
2289
+ const unique: ModuleIncomingExtensionRef[] = []
2290
+ for (const entry of entries) {
2291
+ const identity = incomingIdentity(entry)
2292
+ if (seen.has(identity)) continue
2293
+ seen.add(identity)
2294
+ unique.push(entry)
2295
+ }
2296
+ return unique.sort((left, right) => incomingIdentity(left).localeCompare(incomingIdentity(right)))
2297
+ }
2298
+
2299
+ function resolutionIdentity(entry: ModuleContributionResolution): string {
2300
+ return JSON.stringify([entry.contributionId, entry.target.kind, entry.target.id, entry.resolution])
2301
+ }
2302
+
2303
+ function sortResolutions(entries: ModuleContributionResolution[]): ModuleContributionResolution[] {
2304
+ const seen = new Set<string>()
2305
+ const unique: ModuleContributionResolution[] = []
2306
+ for (const entry of entries) {
2307
+ const identity = resolutionIdentity(entry)
2308
+ if (seen.has(identity)) continue
2309
+ seen.add(identity)
2310
+ unique.push(entry)
2311
+ }
2312
+ return unique.sort((left, right) => resolutionIdentity(left).localeCompare(resolutionIdentity(right)))
2313
+ }
2314
+
1436
2315
  export function assertNoUnresolvedExtensionTargets(
1437
2316
  surfacesByModule: Readonly<Record<string, ModuleExtensionSurfaceFacts>>,
1438
2317
  ): void {
@@ -1446,6 +2325,23 @@ export function assertNoUnresolvedExtensionTargets(
1446
2325
  }
1447
2326
  }
1448
2327
 
2328
+ /**
2329
+ * Supported merge/replace/disable modes for each dotted unified-override host,
2330
+ * derived from {@link FRAMEWORK_OVERRIDE_HOSTS} (the framework catalog authority
2331
+ * — spec 2026-08-02-module-facts-exact-override-targets). Keyed by the dotted
2332
+ * host id (e.g. `routes.api`, `ai.extensions`, `nav.groupOrder`) so the module
2333
+ * override-target adapters share one mode source of truth with the catalog.
2334
+ */
2335
+ export function getFrameworkOverrideModes(): Record<string, 'disable-replace' | 'replace' | 'additive'> {
2336
+ const modes: Record<string, 'disable-replace' | 'replace' | 'additive'> = {}
2337
+ for (const host of FRAMEWORK_OVERRIDE_HOSTS) {
2338
+ const dotted = host.key.replace(/^framework\.module-override\./, '')
2339
+ const mode = host.operations?.[0]
2340
+ if (mode === 'disable-replace' || mode === 'replace' || mode === 'additive') modes[dotted] = mode
2341
+ }
2342
+ return modes
2343
+ }
2344
+
1449
2345
  export function getFrameworkExtensionHosts(): ModuleExtensionHostFact[] {
1450
2346
  return sortHosts(allFrameworkHosts().map((host) => ({
1451
2347
  ...host,